@vue-skuilder/common-ui 0.2.27 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/admin/admin.css +2 -0
  2. package/dist/admin/common-ui-admin.es.js +1394 -0
  3. package/dist/admin/common-ui-admin.es.js.map +1 -0
  4. package/dist/admin/components/CardDossierDetail.vue.d.ts +9 -0
  5. package/dist/admin/components/CardDossierDetail.vue.d.ts.map +1 -0
  6. package/dist/admin/components/LearnerCardHistory.vue.d.ts +13 -0
  7. package/dist/admin/components/LearnerCardHistory.vue.d.ts.map +1 -0
  8. package/dist/admin/components/LearnerDatasetSource.vue.d.ts +24 -0
  9. package/dist/admin/components/LearnerDatasetSource.vue.d.ts.map +1 -0
  10. package/dist/admin/components/LearnerDiagnostics.vue.d.ts +13 -0
  11. package/dist/admin/components/LearnerDiagnostics.vue.d.ts.map +1 -0
  12. package/dist/admin/components/LearnerSessionDetail.vue.d.ts +14 -0
  13. package/dist/admin/components/LearnerSessionDetail.vue.d.ts.map +1 -0
  14. package/dist/admin/components/LearnerSessionList.vue.d.ts +13 -0
  15. package/dist/admin/components/LearnerSessionList.vue.d.ts.map +1 -0
  16. package/dist/admin/components/RunDetail.vue.d.ts +12 -0
  17. package/dist/admin/components/RunDetail.vue.d.ts.map +1 -0
  18. package/dist/admin/composables/useLearnerDataset.d.ts +26 -0
  19. package/dist/admin/composables/useLearnerDataset.d.ts.map +1 -0
  20. package/dist/admin/display.d.ts +20 -0
  21. package/dist/admin/display.d.ts.map +1 -0
  22. package/dist/admin/index.d.ts +22 -0
  23. package/dist/admin/index.d.ts.map +1 -0
  24. package/dist/components/CardBrowser.vue.d.ts +5 -5
  25. package/dist/components/CardHistoryViewer.vue.d.ts +1 -1
  26. package/dist/components/CardSearchResults.vue.d.ts +1 -1
  27. package/dist/components/CourseCardBrowser.vue.d.ts +9 -9
  28. package/dist/components/CourseInformation.vue.d.ts +18 -18
  29. package/dist/components/CourseTagFilterWidget.vue.d.ts +1 -1
  30. package/dist/components/SessionControllerDebug.vue.d.ts +1 -1
  31. package/dist/components/SnackbarService.d.ts +1 -1
  32. package/dist/components/StudySession.types.d.ts +1 -1
  33. package/dist/components/StudySession.vue.d.ts +2 -2
  34. package/dist/components/TagsInput.vue.d.ts +1 -1
  35. package/dist/components/UserTagPreferences.vue.d.ts +1 -1
  36. package/dist/components/auth/UserRegistration.vue.d.ts +74 -74
  37. package/dist/components/auth/UserRegistration.vue.d.ts.map +1 -1
  38. package/dist/components/cardRendering/CardLoader.vue.d.ts +6 -6
  39. package/dist/components/cardRendering/CardViewer.vue.d.ts +2 -2
  40. package/dist/components/cardRendering/MarkdownRenderer.vue.d.ts +6 -6
  41. package/dist/components/cardRendering/MdTokenRenderer.vue.d.ts +6 -6
  42. package/dist/components/studentInputs/BaseUserInput.d.ts +2 -2
  43. package/dist/components/studentInputs/MultipleChoiceOption.vue.d.ts +6 -6
  44. package/dist/components/studentInputs/RadioMultipleChoice.types.d.ts +1 -1
  45. package/dist/components/studentInputs/RadioMultipleChoice.vue.d.ts +9 -9
  46. package/dist/components/studentInputs/TrueFalse.vue.d.ts +9 -9
  47. package/dist/components/studentInputs/UserInputNumber.vue.d.ts +2 -2
  48. package/dist/components/studentInputs/UserInputString.vue.d.ts +3 -3
  49. package/dist/components/studentInputs/fillInInput.vue.d.ts +3 -3
  50. package/dist/composables/CompositionViewable.d.ts +2 -2
  51. package/dist/composables/Displayable.d.ts +1 -1
  52. package/dist/stores/useAuthStore.d.ts +82 -82
  53. package/dist/stores/useAuthStore.d.ts.map +1 -1
  54. package/dist/stores/useConfigStore.d.ts +1 -1
  55. package/package.json +18 -5
  56. package/src/admin/components/CardDossierDetail.vue +99 -0
  57. package/src/admin/components/LearnerCardHistory.vue +196 -0
  58. package/src/admin/components/LearnerDatasetSource.vue +97 -0
  59. package/src/admin/components/LearnerDiagnostics.vue +68 -0
  60. package/src/admin/components/LearnerSessionDetail.vue +439 -0
  61. package/src/admin/components/LearnerSessionList.vue +175 -0
  62. package/src/admin/components/RunDetail.vue +166 -0
  63. package/src/admin/composables/useLearnerDataset.ts +66 -0
  64. package/src/admin/display.ts +73 -0
  65. package/src/admin/index.ts +27 -0
@@ -0,0 +1,196 @@
1
+ <template>
2
+ <div>
3
+ <p class="text-caption text-medium-emphasis mb-4">
4
+ Every card the learner has met, most painful first (time sunk × failures), with its pending review and current
5
+ card ELO. <v-icon size="x-small" color="error">mdi-sync-alert</v-icon> is a failed card holding an overdue review;
6
+ <v-icon size="x-small" color="warning">mdi-snowflake</v-icon> is a repeatedly failed card whose ELO has fewer
7
+ updates than failures.
8
+ </p>
9
+
10
+ <v-row dense class="mb-2">
11
+ <v-col v-for="tile in tiles" :key="tile.label" cols="6" sm="4" md="2">
12
+ <v-card variant="tonal" :color="tile.color" class="pa-2 text-center">
13
+ <div class="text-h6">{{ tile.value }}</div>
14
+ <div class="text-caption">{{ tile.label }}</div>
15
+ </v-card>
16
+ </v-col>
17
+ </v-row>
18
+
19
+ <div class="d-flex align-center flex-wrap mb-3 ga-3">
20
+ <v-btn-toggle v-model="filter" density="compact" color="primary" variant="outlined">
21
+ <v-btn value="all" size="small">All ({{ cards.length }})</v-btn>
22
+ <v-btn value="stuck" size="small">
23
+ <v-icon start size="small" color="error">mdi-sync-alert</v-icon>
24
+ Stuck ({{ counts.stuck }})
25
+ </v-btn>
26
+ <v-btn value="failed" size="small">Ever failed ({{ counts.failed }})</v-btn>
27
+ <v-btn value="frozen" size="small">
28
+ <v-icon start size="small" color="warning">mdi-snowflake</v-icon>
29
+ Frozen ELO ({{ counts.frozen }})
30
+ </v-btn>
31
+ </v-btn-toggle>
32
+ <v-text-field
33
+ v-model="search"
34
+ density="compact"
35
+ variant="outlined"
36
+ hide-details
37
+ clearable
38
+ placeholder="filter by card / tag / type / content"
39
+ prepend-inner-icon="mdi-magnify"
40
+ style="max-width: 320px"
41
+ />
42
+ </div>
43
+
44
+ <v-alert v-if="cards.length === 0" type="info" variant="tonal">No card history for this learner.</v-alert>
45
+
46
+ <v-expansion-panels v-else v-model="open" multiple variant="accordion">
47
+ <v-expansion-panel
48
+ v-for="c in visible"
49
+ :key="c.cardId"
50
+ :value="c.cardId"
51
+ :data-card="c.cardId"
52
+ :class="c.cardId === flash ? 'sk-card-flash' : ''"
53
+ >
54
+ <v-expansion-panel-title>
55
+ <div class="d-flex align-center flex-wrap ga-2" style="width: 100%">
56
+ <v-icon v-if="c.stuck" color="error" size="small" title="failed + overdue review">mdi-sync-alert</v-icon>
57
+ <v-icon v-if="c.frozenElo" color="warning" size="small" title="repeatedly failed, ELO barely updated">
58
+ mdi-snowflake
59
+ </v-icon>
60
+ <span class="text-body-2 font-weight-medium">{{ c.card?.questionType ?? 'card' }}</span>
61
+ <code class="text-caption text-medium-emphasis">{{ shortId(c.cardId, 24) }}</code>
62
+ <span v-if="headline(c)" class="text-caption">{{ headline(c) }}</span>
63
+ <v-spacer />
64
+ <v-chip v-if="c.fails > 0" size="x-small" color="error" variant="tonal">
65
+ {{ c.fails }} fail{{ c.fails === 1 ? '' : 's' }}
66
+ </v-chip>
67
+ <v-chip size="x-small" variant="tonal">{{ c.attempts }} view{{ c.attempts === 1 ? '' : 's' }}</v-chip>
68
+ <v-chip size="x-small" variant="tonal" color="grey">{{ fmtDuration(c.totalTimeMs / 1000) }}</v-chip>
69
+ <v-chip
70
+ v-if="c.pendingReview"
71
+ size="x-small"
72
+ :color="c.pendingReview.overdue ? 'error' : 'success'"
73
+ variant="flat"
74
+ >
75
+ review {{ c.pendingReview.overdue ? 'overdue' : 'scheduled' }}
76
+ </v-chip>
77
+ <v-chip size="x-small" variant="tonal" :color="c.frozenElo ? 'warning' : undefined">
78
+ ELO {{ c.cardElo !== null ? Math.round(c.cardElo) : '?' }}
79
+ <span v-if="c.cardEloCount !== null" class="ml-1 text-disabled">/{{ c.cardEloCount }}</span>
80
+ </v-chip>
81
+ </div>
82
+ </v-expansion-panel-title>
83
+
84
+ <v-expansion-panel-text>
85
+ <card-dossier-detail :dossier="c" :interpreters="interpreters" />
86
+ </v-expansion-panel-text>
87
+ </v-expansion-panel>
88
+ </v-expansion-panels>
89
+ </div>
90
+ </template>
91
+
92
+ <script setup lang="ts">
93
+ import { computed, nextTick, ref, watch } from 'vue';
94
+ import {
95
+ cardDossiers,
96
+ fmtDuration,
97
+ type CardDossier,
98
+ type DiagnosticsInterpreters,
99
+ type LearnerDataset,
100
+ } from '@vue-skuilder/db/diagnostics';
101
+ import { shortId } from '../display';
102
+ import CardDossierDetail from './CardDossierDetail.vue';
103
+
104
+ /**
105
+ * Every card the learner has history with. `focus` opens, scrolls to, and
106
+ * briefly highlights one card (e.g. followed from a session timeline).
107
+ */
108
+ const props = defineProps<{
109
+ dataset: LearnerDataset;
110
+ interpreters?: DiagnosticsInterpreters;
111
+ focus?: string;
112
+ }>();
113
+
114
+ const cards = computed(() => cardDossiers(props.dataset));
115
+ const filter = ref<'all' | 'stuck' | 'failed' | 'frozen' | undefined>('all');
116
+ const search = ref<string | null>('');
117
+ const open = ref<string[]>([]);
118
+ const flash = ref<string | null>(null);
119
+
120
+ const counts = computed(() => ({
121
+ stuck: cards.value.filter((c) => c.stuck).length,
122
+ failed: cards.value.filter((c) => c.fails > 0).length,
123
+ frozen: cards.value.filter((c) => c.frozenElo).length,
124
+ }));
125
+
126
+ const tiles = computed(() => [
127
+ { label: 'cards seen', value: cards.value.length, color: 'primary' },
128
+ { label: 'total attempts', value: cards.value.reduce((n, c) => n + c.attempts, 0), color: 'primary' },
129
+ { label: 'total fails', value: cards.value.reduce((n, c) => n + c.fails, 0), color: 'error' },
130
+ {
131
+ label: 'time on cards',
132
+ value: fmtDuration(cards.value.reduce((n, c) => n + c.totalTimeMs, 0) / 1000),
133
+ color: 'grey',
134
+ },
135
+ { label: 'stuck reviews', value: counts.value.stuck, color: 'error' },
136
+ { label: 'frozen ELO', value: counts.value.frozen, color: 'warning' },
137
+ ]);
138
+
139
+ const visible = computed(() => {
140
+ let list = cards.value;
141
+ if (filter.value === 'stuck') list = list.filter((c) => c.stuck);
142
+ else if (filter.value === 'failed') list = list.filter((c) => c.fails > 0);
143
+ else if (filter.value === 'frozen') list = list.filter((c) => c.frozenElo);
144
+ const q = search.value?.trim().toLowerCase();
145
+ if (q) {
146
+ list = list.filter(
147
+ (c) =>
148
+ c.cardId.toLowerCase().includes(q) ||
149
+ (c.card?.questionType ?? '').toLowerCase().includes(q) ||
150
+ (c.card?.tags ?? []).some((t) => t.toLowerCase().includes(q)) ||
151
+ Object.values(c.card?.data ?? {}).some((v) => typeof v === 'string' && v.toLowerCase().includes(q))
152
+ );
153
+ }
154
+ return list;
155
+ });
156
+
157
+ /** The first short string field of the card's content (usually the word or prompt). */
158
+ function headline(c: CardDossier): string {
159
+ const v = Object.values(c.card?.data ?? {}).find((x) => typeof x === 'string' && x.length <= 40);
160
+ return typeof v === 'string' ? v : '';
161
+ }
162
+
163
+ async function applyFocus(id: string | undefined): Promise<void> {
164
+ if (!id || !cards.value.some((c) => c.cardId === id)) return;
165
+ filter.value = 'all';
166
+ search.value = id;
167
+ open.value = [id];
168
+ await nextTick();
169
+ document.querySelector(`[data-card="${CSS.escape(id)}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
170
+ flash.value = id;
171
+ setTimeout(() => {
172
+ if (flash.value === id) flash.value = null;
173
+ }, 2200);
174
+ }
175
+
176
+ watch(
177
+ () => props.focus,
178
+ (id) => void applyFocus(id),
179
+ { immediate: true }
180
+ );
181
+ </script>
182
+
183
+ <style scoped>
184
+ .sk-card-flash {
185
+ animation: sk-card-flash 2.2s ease-out;
186
+ }
187
+ @keyframes sk-card-flash {
188
+ 0%,
189
+ 30% {
190
+ background-color: rgba(var(--v-theme-primary), 0.18);
191
+ }
192
+ 100% {
193
+ background-color: transparent;
194
+ }
195
+ }
196
+ </style>
@@ -0,0 +1,97 @@
1
+ <template>
2
+ <v-card variant="outlined">
3
+ <v-card-text>
4
+ <div class="d-flex flex-wrap align-center ga-2">
5
+ <v-text-field
6
+ v-model="username"
7
+ label="Learner"
8
+ density="compact"
9
+ hide-details
10
+ style="max-width: 16rem"
11
+ :disabled="!couchUrl || !courseId"
12
+ @keydown.enter="loadLive"
13
+ />
14
+ <v-btn color="primary" :loading="loading" :disabled="!couchUrl || !courseId || !username" @click="loadLive">
15
+ Load
16
+ </v-btn>
17
+ <span class="text-medium-emphasis mx-2">or</span>
18
+ <v-btn variant="tonal" prepend-icon="mdi-file-upload-outline" @click="fileInput?.click()">
19
+ Open dump file
20
+ </v-btn>
21
+ <input ref="fileInput" type="file" accept=".json,application/json" hidden @change="onFile" />
22
+ </div>
23
+
24
+ <v-alert v-if="error" type="error" variant="tonal" density="compact" class="mt-3">
25
+ {{ error }}
26
+ </v-alert>
27
+
28
+ <div v-if="dataset && dump" class="d-flex flex-wrap align-center ga-2 mt-3">
29
+ <span>
30
+ <strong>{{ dataset.username }}</strong>
31
+ · {{ dataset.sessions.length }} sessions
32
+ <template v-if="sessionRange">({{ sessionRange }})</template>
33
+ · {{ dataset.cardHistories.length }} cards seen · as of {{ dataset.asOf.slice(0, 16) }}Z
34
+ <template v-if="!dataset.cards">· no course slice</template>
35
+ </span>
36
+ <v-spacer />
37
+ <v-btn variant="text" size="small" prepend-icon="mdi-download" @click="downloadLearnerDump(dump)">
38
+ Save dump
39
+ </v-btn>
40
+ </div>
41
+ </v-card-text>
42
+ </v-card>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { computed, onMounted, ref, watch } from 'vue';
47
+ import type { LearnerDataset, LearnerDump } from '@vue-skuilder/db/diagnostics';
48
+ import { downloadLearnerDump, useLearnerDataset } from '../composables/useLearnerDataset';
49
+
50
+ /**
51
+ * Pick a learner's dataset: live from couch by username (needs `couchUrl` and
52
+ * `courseId`), or from a dump file. Emits `loaded` with the indexed dataset
53
+ * and the raw dump.
54
+ */
55
+ const props = defineProps<{
56
+ /** Couch base URL. Without it, only files can be opened. */
57
+ couchUrl?: string;
58
+ courseId?: string;
59
+ /** Prefills the learner field. */
60
+ initialUsername?: string;
61
+ /** Load `initialUsername` on mount, when live loading is possible. */
62
+ autoload?: boolean;
63
+ }>();
64
+
65
+ const emit = defineEmits<{
66
+ loaded: [dataset: LearnerDataset, dump: LearnerDump];
67
+ }>();
68
+
69
+ const { dump, dataset, loading, error, loadFromCouch, loadFromFile } = useLearnerDataset();
70
+ const username = ref(props.initialUsername ?? '');
71
+ const fileInput = ref<HTMLInputElement | null>(null);
72
+
73
+ const sessionRange = computed(() => {
74
+ const s = dataset.value?.sessions ?? [];
75
+ return s.length ? `${s[0].startTime.slice(0, 10)} .. ${s[s.length - 1].startTime.slice(0, 10)}` : '';
76
+ });
77
+
78
+ function loadLive(): void {
79
+ if (!props.couchUrl || !props.courseId || !username.value) return;
80
+ void loadFromCouch({ couchUrl: props.couchUrl, courseId: props.courseId, username: username.value });
81
+ }
82
+
83
+ function onFile(event: Event): void {
84
+ const input = event.target as HTMLInputElement;
85
+ const file = input.files?.[0];
86
+ if (file) void loadFromFile(file, props.courseId);
87
+ input.value = '';
88
+ }
89
+
90
+ watch(dataset, (ds) => {
91
+ if (ds && dump.value) emit('loaded', ds, dump.value);
92
+ });
93
+
94
+ onMounted(() => {
95
+ if (props.autoload) loadLive();
96
+ });
97
+ </script>
@@ -0,0 +1,68 @@
1
+ <template>
2
+ <div>
3
+ <v-tabs v-model="tab" density="compact" class="mb-4">
4
+ <v-tab value="sessions">Sessions ({{ dataset.sessions.length }})</v-tab>
5
+ <v-tab value="cards">Cards ({{ dataset.cardHistories.length }})</v-tab>
6
+ </v-tabs>
7
+
8
+ <template v-if="tab === 'sessions'">
9
+ <template v-if="sessionId">
10
+ <v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" class="mb-2" @click="sessionId = null">
11
+ All sessions
12
+ </v-btn>
13
+ <learner-session-detail
14
+ :dataset="dataset"
15
+ :session-id="sessionId"
16
+ :interpreters="interpreters"
17
+ @open-card="openCard"
18
+ />
19
+ </template>
20
+ <learner-session-list
21
+ v-else
22
+ :dataset="dataset"
23
+ :interpreters="interpreters"
24
+ @open="(id: string) => (sessionId = id)"
25
+ />
26
+ </template>
27
+
28
+ <learner-card-history v-else :dataset="dataset" :interpreters="interpreters" :focus="focusCard" />
29
+ </div>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ import { ref, watch } from 'vue';
34
+ import type { DiagnosticsInterpreters, LearnerDataset } from '@vue-skuilder/db/diagnostics';
35
+ import LearnerCardHistory from './LearnerCardHistory.vue';
36
+ import LearnerSessionDetail from './LearnerSessionDetail.vue';
37
+ import LearnerSessionList from './LearnerSessionList.vue';
38
+
39
+ /**
40
+ * One learner: sessions (list, then a session's detail) and card history,
41
+ * with session timelines linking into card history. Navigation is internal;
42
+ * hosts that want URLs can compose the three views themselves.
43
+ */
44
+ const props = defineProps<{
45
+ dataset: LearnerDataset;
46
+ interpreters?: DiagnosticsInterpreters;
47
+ }>();
48
+
49
+ const tab = ref<'sessions' | 'cards'>('sessions');
50
+ const sessionId = ref<string | null>(null);
51
+ const focusCard = ref<string | undefined>(undefined);
52
+
53
+ function openCard(cardId: string): void {
54
+ // Re-set through undefined so following the same card twice still refocuses.
55
+ focusCard.value = undefined;
56
+ tab.value = 'cards';
57
+ setTimeout(() => (focusCard.value = cardId));
58
+ }
59
+
60
+ watch(
61
+ () => props.dataset,
62
+ () => {
63
+ tab.value = 'sessions';
64
+ sessionId.value = null;
65
+ focusCard.value = undefined;
66
+ }
67
+ );
68
+ </script>