Skip to content

Commit 200692d

Browse files
authored
Add course search and admin visibility toolng (#848)
and auth subinterfaces. Motivation: seeking to create readonly interfaces for admininstrator / supervisor / datavis on live data.
2 parents e4013ec + eb0ab63 commit 200692d

File tree

19 files changed

+1140
-89
lines changed

19 files changed

+1140
-89
lines changed
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<template>
2+
<div class="card-history-viewer">
3+
<h3 v-if="userId">Card History for User: {{ userId }}</h3>
4+
<div v-if="loading">Loading...</div>
5+
<div v-else-if="error" class="error-message">{{ error }}</div>
6+
<div v-else-if="cardHistory">
7+
<v-card class="mb-4">
8+
<v-card-title>Summary</v-card-title>
9+
<v-list-item>
10+
<v-list-item-content>
11+
<v-list-item-title
12+
>Best Interval: {{ cardHistory.bestInterval }} seconds ({{ bestIntervalHumanized }})</v-list-item-title
13+
>
14+
<v-list-item-subtitle>The to-date largest interval between successful card reviews.</v-list-item-subtitle>
15+
</v-list-item-content>
16+
</v-list-item>
17+
<v-list-item>
18+
<v-list-item-content>
19+
<v-list-item-title>Lapses: {{ cardHistory.lapses }}</v-list-item-title>
20+
<v-list-item-subtitle>The number of times that a card has been failed in review.</v-list-item-subtitle>
21+
</v-list-item-content>
22+
</v-list-item>
23+
<v-list-item>
24+
<v-list-item-content>
25+
<v-list-item-title>Streak: {{ cardHistory.streak }}</v-list-item-title>
26+
<v-list-item-subtitle>The number of consecutive successful impressions on this card.</v-list-item-subtitle>
27+
</v-list-item-content>
28+
</v-list-item>
29+
</v-card>
30+
<v-data-table :headers="headers" :items="history" class="elevation-1"></v-data-table>
31+
</div>
32+
</div>
33+
</template>
34+
35+
<script lang="ts">
36+
import { defineComponent, PropType } from 'vue';
37+
import { UserDBInterface, UserDBReader, CardHistory, CardRecord, getCardHistoryID } from '@vue-skuilder/db';
38+
import moment, { Moment } from 'moment';
39+
40+
interface FormattedRecord extends CardRecord {
41+
formattedTimeStamp: string;
42+
intervalFromPrevious?: number;
43+
userFriendlyInterval?: string;
44+
timeSpentSeconds: number;
45+
}
46+
47+
export default defineComponent({
48+
name: 'CardHistoryViewer',
49+
props: {
50+
cardId: {
51+
type: String,
52+
required: true,
53+
},
54+
courseId: {
55+
type: String,
56+
required: true,
57+
},
58+
userId: {
59+
type: String,
60+
required: true,
61+
},
62+
userDB: {
63+
type: Object as PropType<UserDBReader>,
64+
required: true,
65+
},
66+
},
67+
data() {
68+
return {
69+
history: [] as FormattedRecord[],
70+
cardHistory: null as CardHistory<CardRecord> | null,
71+
bestIntervalHumanized: '',
72+
loading: false,
73+
error: null as string | null,
74+
headers: [
75+
{ title: 'Timestamp', key: 'formattedTimeStamp' },
76+
{ title: 'Interval', key: 'userFriendlyInterval' },
77+
{ title: 'Time Spent (s)', key: 'timeSpentSeconds' },
78+
{ title: 'Correct?', key: 'isCorrect' },
79+
{ title: 'Performance', key: 'performance' },
80+
{ title: 'Prior Attempts', key: 'priorAttemps' },
81+
{ title: 'User Answer', key: 'userAnswer' },
82+
],
83+
};
84+
},
85+
watch: {
86+
cardId: {
87+
immediate: true,
88+
handler(newCardId) {
89+
if (newCardId && this.userDB) {
90+
this.fetchHistory();
91+
}
92+
},
93+
},
94+
userDB: {
95+
handler(newUserDB) {
96+
if (newUserDB && this.cardId) {
97+
this.fetchHistory();
98+
}
99+
},
100+
},
101+
},
102+
methods: {
103+
async fetchHistory() {
104+
this.loading = true;
105+
this.error = null;
106+
try {
107+
const cardHistoryID = getCardHistoryID(this.courseId, this.cardId);
108+
const historyDoc: CardHistory<CardRecord> = await this.userDB.get(cardHistoryID);
109+
this.cardHistory = historyDoc;
110+
this.bestIntervalHumanized = moment.duration(historyDoc.bestInterval, 'seconds').humanize();
111+
112+
// Sort records by timestamp and format them
113+
const sortedRecords = [...historyDoc.records].sort(
114+
(a, b) => moment(a.timeStamp).valueOf() - moment(b.timeStamp).valueOf()
115+
);
116+
117+
this.history = sortedRecords.map((record, index) => {
118+
const currentTime = moment(record.timeStamp);
119+
const formatted: FormattedRecord = {
120+
...record,
121+
formattedTimeStamp: currentTime.format('YYYY-MM-DD HH:mm:ss'),
122+
timeSpentSeconds: Math.round(record.timeSpent / 1000),
123+
isCorrect: (record as any).isCorrect,
124+
performance: (record as any).performance,
125+
priorAttemps: (record as any).priorAttemps,
126+
userAnswer: (record as any).userAnswer,
127+
};
128+
129+
// Calculate interval from previous record
130+
if (index > 0) {
131+
const previousTime = moment(sortedRecords[index - 1].timeStamp);
132+
const intervalSeconds = currentTime.diff(previousTime, 'seconds', true);
133+
formatted.intervalFromPrevious = Math.round(intervalSeconds * 100) / 100;
134+
formatted.userFriendlyInterval = `${formatted.intervalFromPrevious} sec (${moment.duration(intervalSeconds, 'seconds').humanize()})`;
135+
}
136+
137+
return formatted;
138+
});
139+
} catch (e) {
140+
this.error = 'Error fetching card history.';
141+
console.error(e);
142+
} finally {
143+
this.loading = false;
144+
}
145+
},
146+
},
147+
});
148+
</script>
149+
150+
<style scoped>
151+
.error-message {
152+
color: #f44336;
153+
padding: 16px;
154+
background-color: #ffebee;
155+
border-radius: 4px;
156+
margin: 8px 0;
157+
}
158+
159+
.negative-interval {
160+
color: #f44336;
161+
font-weight: bold;
162+
background-color: #ffebee;
163+
padding: 2px 4px;
164+
border-radius: 3px;
165+
}
166+
167+
.invalid-timestamp {
168+
color: #f44336;
169+
font-weight: bold;
170+
}
171+
172+
.card-history-viewer {
173+
margin: 16px 0;
174+
}
175+
</style>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<template>
2+
<div class="card-search">
3+
<v-text-field
4+
v-model="query"
5+
label="Search for cards..."
6+
append-icon="mdi-magnify"
7+
@click:append="search"
8+
@keydown.enter="search"
9+
></v-text-field>
10+
</div>
11+
</template>
12+
13+
<script lang="ts">
14+
import { defineComponent } from 'vue';
15+
16+
export default defineComponent({
17+
name: 'CardSearch',
18+
emits: {
19+
search: (query: string) => typeof query === 'string'
20+
},
21+
data() {
22+
return {
23+
query: '',
24+
};
25+
},
26+
methods: {
27+
search() {
28+
this.$emit('search', this.query);
29+
},
30+
},
31+
});
32+
</script>
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<template>
2+
<div class="card-search-results">
3+
<div v-if="loading">Loading...</div>
4+
<div v-else-if="error">{{ error }}</div>
5+
<div v-else>
6+
<v-list>
7+
<v-list-item
8+
v-for="card in cards"
9+
:key="card._id"
10+
@click="selectCard(card)"
11+
:class="{'selected-card': card._id === selectedCardId}"
12+
class="cursor-pointer"
13+
>
14+
<v-list-item-title>{{ card._id }}</v-list-item-title>
15+
<v-list-item-subtitle>Course: {{ card.courseId }}</v-list-item-subtitle>
16+
</v-list-item>
17+
</v-list>
18+
</div>
19+
</div>
20+
</template>
21+
22+
<script lang="ts">
23+
import { defineComponent, PropType } from 'vue';
24+
import { DataLayerProvider, CardData } from '@vue-skuilder/db';
25+
26+
interface CardWithCourse extends CardData {
27+
courseId: string;
28+
}
29+
30+
export default defineComponent({
31+
name: 'CardSearchResults',
32+
emits: {
33+
'card-selected': (payload: { cardId: string; courseId: string }) =>
34+
typeof payload.cardId === 'string' && typeof payload.courseId === 'string'
35+
},
36+
props: {
37+
query: {
38+
type: String,
39+
required: true,
40+
},
41+
dataLayer: {
42+
type: Object as PropType<DataLayerProvider>,
43+
required: true,
44+
},
45+
courseFilter: {
46+
type: String as PropType<string | null>,
47+
default: null,
48+
},
49+
},
50+
data() {
51+
return {
52+
cards: [] as CardWithCourse[],
53+
loading: false,
54+
error: null as string | null,
55+
selectedCardId: null as string | null,
56+
};
57+
},
58+
watch: {
59+
query: {
60+
immediate: true,
61+
handler(newQuery) {
62+
if (newQuery) {
63+
this.fetchResults(newQuery);
64+
}
65+
},
66+
},
67+
},
68+
methods: {
69+
async fetchResults(query: string) {
70+
this.loading = true;
71+
this.error = null;
72+
try {
73+
let courseIds: string[] = [];
74+
75+
// Get course IDs efficiently
76+
if (this.courseFilter) {
77+
// Single course search - no need to fetch all courses
78+
courseIds = [this.courseFilter];
79+
console.log(`Filtering search to course: ${this.courseFilter}`);
80+
} else {
81+
// Get all course IDs without expensive config lookups
82+
const { CourseLookup } = await import('@vue-skuilder/db');
83+
const lookupCourses = await CourseLookup.allCourseWare();
84+
courseIds = lookupCourses.map(c => c._id).filter(Boolean);
85+
console.log(`Searching across all ${courseIds.length} courses`);
86+
}
87+
88+
const allCards: CardWithCourse[] = [];
89+
90+
for (const courseId of courseIds) {
91+
const courseDB = this.dataLayer.getCourseDB(courseId);
92+
const cards = await courseDB.searchCards(query);
93+
94+
for (const card of cards) {
95+
allCards.push({
96+
...card,
97+
courseId: courseId,
98+
});
99+
}
100+
}
101+
this.cards = allCards;
102+
console.log(`Search completed: found ${allCards.length} cards across ${courseIds.length} courses`);
103+
} catch (e) {
104+
this.error = 'Error fetching search results.';
105+
console.error('Search error:', e);
106+
} finally {
107+
this.loading = false;
108+
}
109+
},
110+
111+
selectCard(card: CardWithCourse) {
112+
this.selectedCardId = card._id;
113+
this.$emit('card-selected', { cardId: card._id, courseId: card.courseId });
114+
},
115+
},
116+
});
117+
</script>
118+
119+
<style scoped>
120+
.selected-card {
121+
background-color: #e0f2f7; /* Light blue background */
122+
border-left: 4px solid #2196f3; /* Blue left border */
123+
}
124+
125+
.cursor-pointer {
126+
cursor: pointer;
127+
}
128+
</style>

packages/common-ui/src/components/auth/UserLogin.vue

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ import { User } from '@vue-skuilder/db';
5353
import { getCurrentUser, useAuthStore } from '../../stores/useAuthStore';
5454
import { useConfigStore } from '../../stores/useConfigStore';
5555
56+
// Define props
57+
interface Props {
58+
redirectTo?: string;
59+
}
60+
61+
const props = withDefaults(defineProps<Props>(), {
62+
redirectTo: '/study'
63+
});
64+
65+
// Define emits for toggle and cleanup
66+
const emit = defineEmits<{
67+
toggle: [];
68+
loginSuccess: [redirectPath: string];
69+
}>();
70+
5671
const router = useRouter();
5772
const route = useRoute();
5873
const authStore = useAuthStore();
@@ -108,8 +123,12 @@ const login = async () => {
108123
// set login state
109124
log('Setting authentication state');
110125
authStore.loginAndRegistration.loggedIn = true;
111-
log('Authentication state set, redirecting to study page');
112-
router.push('/study');
126+
log(`Authentication state set, redirecting to: ${props.redirectTo}`);
127+
128+
// Emit success event for cleanup
129+
emit('loginSuccess', props.redirectTo);
130+
131+
router.push(props.redirectTo);
113132
log('Login and redirect complete');
114133
} catch (e) {
115134
// entry #186
@@ -126,7 +145,6 @@ const login = async () => {
126145
awaitingResponse.value = false;
127146
};
128147
129-
const emit = defineEmits(['toggle']);
130148
131149
const toggle = () => {
132150
log('Toggling registration / login forms.');

packages/common-ui/src/components/cardRendering/CardLoader.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ export default defineComponent({
5656
this.loadCard();
5757
},
5858
59+
watch: {
60+
qualified_id: {
61+
immediate: true,
62+
handler() {
63+
this.loadCard();
64+
},
65+
},
66+
},
67+
5968
methods: {
6069
processResponse(r: CardRecord) {
6170
log(`

0 commit comments

Comments
 (0)