@vue-skuilder/common-ui 0.2.27 → 0.2.29

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 (68) hide show
  1. package/dist/admin/admin.css +2 -0
  2. package/dist/admin/common-ui-admin.es.js +1752 -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 +14 -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/PipelineOverview.vue.d.ts +18 -0
  17. package/dist/admin/components/PipelineOverview.vue.d.ts.map +1 -0
  18. package/dist/admin/components/RunDetail.vue.d.ts +12 -0
  19. package/dist/admin/components/RunDetail.vue.d.ts.map +1 -0
  20. package/dist/admin/composables/useLearnerDataset.d.ts +26 -0
  21. package/dist/admin/composables/useLearnerDataset.d.ts.map +1 -0
  22. package/dist/admin/display.d.ts +20 -0
  23. package/dist/admin/display.d.ts.map +1 -0
  24. package/dist/admin/index.d.ts +23 -0
  25. package/dist/admin/index.d.ts.map +1 -0
  26. package/dist/components/CardBrowser.vue.d.ts +5 -5
  27. package/dist/components/CardHistoryViewer.vue.d.ts +1 -1
  28. package/dist/components/CardSearchResults.vue.d.ts +1 -1
  29. package/dist/components/CourseCardBrowser.vue.d.ts +9 -9
  30. package/dist/components/CourseInformation.vue.d.ts +18 -18
  31. package/dist/components/CourseTagFilterWidget.vue.d.ts +1 -1
  32. package/dist/components/SessionControllerDebug.vue.d.ts +1 -1
  33. package/dist/components/SnackbarService.d.ts +1 -1
  34. package/dist/components/StudySession.types.d.ts +1 -1
  35. package/dist/components/StudySession.vue.d.ts +2 -2
  36. package/dist/components/TagsInput.vue.d.ts +1 -1
  37. package/dist/components/UserTagPreferences.vue.d.ts +1 -1
  38. package/dist/components/auth/UserRegistration.vue.d.ts +74 -74
  39. package/dist/components/auth/UserRegistration.vue.d.ts.map +1 -1
  40. package/dist/components/cardRendering/CardLoader.vue.d.ts +6 -6
  41. package/dist/components/cardRendering/CardViewer.vue.d.ts +2 -2
  42. package/dist/components/cardRendering/MarkdownRenderer.vue.d.ts +6 -6
  43. package/dist/components/cardRendering/MdTokenRenderer.vue.d.ts +6 -6
  44. package/dist/components/studentInputs/BaseUserInput.d.ts +2 -2
  45. package/dist/components/studentInputs/MultipleChoiceOption.vue.d.ts +6 -6
  46. package/dist/components/studentInputs/RadioMultipleChoice.types.d.ts +1 -1
  47. package/dist/components/studentInputs/RadioMultipleChoice.vue.d.ts +9 -9
  48. package/dist/components/studentInputs/TrueFalse.vue.d.ts +9 -9
  49. package/dist/components/studentInputs/UserInputNumber.vue.d.ts +2 -2
  50. package/dist/components/studentInputs/UserInputString.vue.d.ts +3 -3
  51. package/dist/components/studentInputs/fillInInput.vue.d.ts +3 -3
  52. package/dist/composables/CompositionViewable.d.ts +2 -2
  53. package/dist/composables/Displayable.d.ts +1 -1
  54. package/dist/stores/useAuthStore.d.ts +82 -82
  55. package/dist/stores/useAuthStore.d.ts.map +1 -1
  56. package/dist/stores/useConfigStore.d.ts +1 -1
  57. package/package.json +18 -5
  58. package/src/admin/components/CardDossierDetail.vue +99 -0
  59. package/src/admin/components/LearnerCardHistory.vue +196 -0
  60. package/src/admin/components/LearnerDatasetSource.vue +97 -0
  61. package/src/admin/components/LearnerDiagnostics.vue +85 -0
  62. package/src/admin/components/LearnerSessionDetail.vue +439 -0
  63. package/src/admin/components/LearnerSessionList.vue +175 -0
  64. package/src/admin/components/PipelineOverview.vue +390 -0
  65. package/src/admin/components/RunDetail.vue +186 -0
  66. package/src/admin/composables/useLearnerDataset.ts +66 -0
  67. package/src/admin/display.ts +73 -0
  68. package/src/admin/index.ts +28 -0
@@ -0,0 +1,390 @@
1
+ <template>
2
+ <div class="sk-pipeline">
3
+ <div class="d-flex flex-wrap align-center ga-2 mb-3">
4
+ <v-chip size="small" variant="tonal" :color="plan.kind === 'default' ? 'warning' : 'primary'">
5
+ {{
6
+ plan.kind === 'default' ? 'default pipeline: the course has no strategy docs' : 'assembled from strategy docs'
7
+ }}
8
+ </v-chip>
9
+ <span class="text-caption">
10
+ {{ plan.generators.length }} generator(s) · {{ plan.filters.length }} filter(s)
11
+ </span>
12
+ <span v-if="stats" class="text-caption text-medium-emphasis">
13
+ · run stats{{ statsLabel ? ` for ${statsLabel}` : '' }}: {{ stats.runs }} runs over
14
+ {{ stats.sessions }} sessions, {{ fmtTime(stats.firstRunAt) }} – {{ fmtTime(stats.lastRunAt) }}
15
+ </span>
16
+ </div>
17
+
18
+ <v-alert
19
+ v-for="s in plan.skipped"
20
+ :key="s.strategy._id"
21
+ type="warning"
22
+ variant="tonal"
23
+ density="compact"
24
+ class="mb-2"
25
+ >
26
+ <strong>{{ s.strategy.name }}</strong> (<code>{{ s.strategy.implementingClass }}</code
27
+ >) is {{ s.reason }} in this app, so it would not run here. A course's own navigators only plan correctly in the
28
+ app that registers them.
29
+ </v-alert>
30
+ <v-alert v-for="w in plan.warnings" :key="w" type="warning" variant="tonal" density="compact" class="mb-2">
31
+ {{ w }}
32
+ </v-alert>
33
+ <v-alert v-for="n in notes" :key="n" type="info" variant="tonal" density="compact" class="mb-2">
34
+ {{ n }}
35
+ </v-alert>
36
+
37
+ <div v-for="(stage, i) in PIPELINE_STAGES" :key="stage.key">
38
+ <div class="sk-stage">
39
+ <div class="d-flex align-center mb-1">
40
+ <span class="sk-stage-num">{{ i + 1 }}</span>
41
+ <span class="font-weight-medium">{{ stage.title }}</span>
42
+ </div>
43
+ <div class="text-caption text-medium-emphasis mb-2">{{ stage.summary }}</div>
44
+
45
+ <template v-if="stage.key === 'generate'">
46
+ <div v-for="g in plan.generators" :key="g.name" class="sk-nav">
47
+ <div class="d-flex flex-wrap align-center ga-2">
48
+ <span class="font-weight-medium">{{ g.name }}</span>
49
+ <code class="text-caption">{{ g.implementingClass }}</code>
50
+ <v-chip size="x-small" variant="outlined" :color="ORIGIN[g.origin].color">
51
+ {{ ORIGIN[g.origin].label }}
52
+ </v-chip>
53
+ </div>
54
+ <div v-if="g.strategy?.description" class="text-caption mt-1">{{ g.strategy.description }}</div>
55
+
56
+ <div v-if="stats" class="text-caption mt-1">
57
+ <template v-if="genStat(g.name)">
58
+ {{ fmtNum(genStat(g.name)!.meanCards) }} candidates/run ({{ fmtNum(genStat(g.name)!.meanNew) }} new,
59
+ {{ fmtNum(genStat(g.name)!.meanReview) }} review) · top score
60
+ {{ genStat(g.name)!.topScoreLast.toFixed(2) }} last, {{ genStat(g.name)!.topScoreMax.toFixed(2) }} max
61
+ <template v-if="stats.runsWithSelection > 0">
62
+ · <strong>{{ genStat(g.name)!.selected }}</strong> selected
63
+ <template v-if="genStat(g.name)!.firstAttempts > 0">
64
+ · first attempts {{ genStat(g.name)!.firstAttemptsCorrect }}/{{ genStat(g.name)!.firstAttempts }}
65
+ correct
66
+ <span :class="accuracyClass(accuracyOf(genStat(g.name)!))">
67
+ ({{ Math.round(100 * accuracyOf(genStat(g.name)!)!) }}%)
68
+ </span>
69
+ </template>
70
+ </template>
71
+ </template>
72
+ <span v-else class="text-medium-emphasis">absent from these runs</span>
73
+ </div>
74
+
75
+ <div v-if="prettyConfig(g.strategy?.serializedData)" class="mt-1">
76
+ <a class="sk-config-link text-caption" @click="toggleConfig(`g:${g.name}`)">
77
+ {{ openConfigs.has(`g:${g.name}`) ? 'Hide' : 'Show' }} config
78
+ </a>
79
+ <pre v-if="openConfigs.has(`g:${g.name}`)" class="sk-config">{{
80
+ prettyConfig(g.strategy?.serializedData)
81
+ }}</pre>
82
+ </div>
83
+ </div>
84
+ </template>
85
+
86
+ <template v-else-if="stage.key === 'filter'">
87
+ <div v-if="plan.filters.length === 0" class="text-caption text-medium-emphasis">No filters.</div>
88
+ <div v-for="f in plan.filters" :key="f.name" class="sk-nav">
89
+ <div class="d-flex flex-wrap align-center ga-2">
90
+ <span class="font-weight-medium">{{ f.name }}</span>
91
+ <code class="text-caption">{{ f.implementingClass }}</code>
92
+ <v-chip size="x-small" variant="outlined" :color="ORIGIN[f.origin].color">
93
+ {{ ORIGIN[f.origin].label }}
94
+ </v-chip>
95
+ <v-chip v-if="f.strategy?.learnable" size="x-small" variant="outlined">learnable weight</v-chip>
96
+ <v-chip
97
+ size="x-small"
98
+ variant="tonal"
99
+ :color="filterKind(f).kind === 'gate' ? 'deep-orange' : 'blue-grey'"
100
+ :title="filterKind(f).help"
101
+ >
102
+ {{ filterKind(f).label }}
103
+ </v-chip>
104
+ </div>
105
+ <div v-if="f.strategy?.description" class="text-caption mt-1">{{ f.strategy.description }}</div>
106
+ <div v-else-if="!f.strategy" class="text-caption mt-1 text-medium-emphasis">
107
+ Built in code; no strategy document.
108
+ </div>
109
+
110
+ <div v-if="stats" class="mt-1">
111
+ <template v-if="filterStat(f.name)">
112
+ <div class="sk-impact-bar">
113
+ <div
114
+ v-for="part in impactParts(filterStat(f.name)!)"
115
+ :key="part.key"
116
+ :class="`bg-${part.color}`"
117
+ :style="{ width: `${part.share * 100}%` }"
118
+ :title="`${part.key}: ${part.count}`"
119
+ />
120
+ </div>
121
+ <div class="text-caption">
122
+ over {{ filterStat(f.name)!.runs }} runs:
123
+ <span v-for="part in impactParts(filterStat(f.name)!)" :key="part.key" class="mr-2">
124
+ <span :class="`text-${part.color}`">●</span> {{ part.key }} {{ fmtPct(part.share) }}
125
+ </span>
126
+ </div>
127
+ </template>
128
+ <span v-else class="text-caption text-medium-emphasis">absent from these runs</span>
129
+ </div>
130
+
131
+ <div v-if="prettyConfig(f.strategy?.serializedData)" class="mt-1">
132
+ <a class="sk-config-link text-caption" @click="toggleConfig(`f:${f.name}`)">
133
+ {{ openConfigs.has(`f:${f.name}`) ? 'Hide' : 'Show' }} config
134
+ </a>
135
+ <pre v-if="openConfigs.has(`f:${f.name}`)" class="sk-config">{{
136
+ prettyConfig(f.strategy?.serializedData)
137
+ }}</pre>
138
+ </div>
139
+ </div>
140
+ </template>
141
+
142
+ <template v-else-if="stage.key === 'regulate'">
143
+ <div class="sk-nav">
144
+ <div class="d-flex flex-wrap align-center ga-2">
145
+ <span class="font-weight-medium">Review mass</span>
146
+ <v-chip size="x-small" variant="outlined">on review cards</v-chip>
147
+ </div>
148
+ <div class="text-caption mt-1">
149
+ Each due review weighs its SRS urgency (0–1) times the filters' net effect on it. Above
150
+ {{ plan.regulators.reviewMass.healthyMass }} card-equivalents, every review is multiplied by ×{{
151
+ plan.regulators.reviewMass.rate
152
+ }}
153
+ per {{ plan.regulators.reviewMass.healthyMass }} in excess (uncapped). Reviews without an urgency weigh
154
+ their score against {{ plan.regulators.reviewMass.referenceScore }}.
155
+ </div>
156
+ <div v-if="regStat('review-mass')" class="text-caption mt-1">
157
+ {{ regStatText(regStat('review-mass')!) }}
158
+ </div>
159
+ </div>
160
+ <div class="sk-nav">
161
+ <div class="d-flex flex-wrap align-center ga-2">
162
+ <span class="font-weight-medium">Intake</span>
163
+ <v-chip size="x-small" variant="outlined">on new cards no gate penalized</v-chip>
164
+ </div>
165
+ <div class="text-caption mt-1">
166
+ ×{{ plan.regulators.intake.rate }} per {{ plan.regulators.intake.scaleHours }} hours since a new card was
167
+ last presented (uncapped). The clock stops while no eligible new card exists.
168
+ </div>
169
+ <div v-if="regStat('intake')" class="text-caption mt-1">{{ regStatText(regStat('intake')!) }}</div>
170
+ </div>
171
+ <div v-if="stats && stats.regulators.length === 0" class="text-caption text-medium-emphasis">
172
+ None of these runs recorded regulator readings (recorded from 0.2.29).
173
+ </div>
174
+ </template>
175
+
176
+ <template v-else-if="stage.key === 'select' && stats && stats.runsWithSelection > 0">
177
+ <div class="text-caption">
178
+ {{ stats.selectedUnattributed }} selected card(s) had no generator: forced in by a hint's
179
+ <code>requireCards</code>.
180
+ </div>
181
+ </template>
182
+ </div>
183
+ <div v-if="i < PIPELINE_STAGES.length - 1" class="sk-stage-arrow">
184
+ <v-icon size="small">mdi-arrow-down</v-icon>
185
+ </div>
186
+ </div>
187
+
188
+ <div v-if="stats && stats.runsWithSelection === 0" class="text-caption text-medium-emphasis mt-3">
189
+ None of these runs recorded which cards they selected (recorded from 0.2.28), so selection and first-attempt
190
+ counts are not shown.
191
+ </div>
192
+ </div>
193
+ </template>
194
+
195
+ <script setup lang="ts">
196
+ import { computed, ref } from 'vue';
197
+ import {
198
+ PIPELINE_STAGES,
199
+ planPipeline,
200
+ type ContentNavigationStrategyData,
201
+ type PlannedNavigator,
202
+ type PlannedNavigatorOrigin,
203
+ } from '@vue-skuilder/db';
204
+ import {
205
+ regulatorText,
206
+ type FilterKind,
207
+ type FilterRunStat,
208
+ type GeneratorRunStat,
209
+ type PipelineRunStats,
210
+ type RegulatorName,
211
+ type RegulatorRunStat,
212
+ } from '@vue-skuilder/db/diagnostics';
213
+ import { accuracyClass, fmtTime } from '../display';
214
+
215
+ /**
216
+ * A course's navigation pipeline as it assembles in this app: each stage in
217
+ * run order, which generators and filters run in it, and where each came from.
218
+ * With `stats` (from `pipelineRunStats`), each piece also shows what it did
219
+ * across a learner's persisted runs. Stats join to the plan by name.
220
+ */
221
+ const props = defineProps<{
222
+ strategies: ContentNavigationStrategyData[];
223
+ courseId: string;
224
+ stats?: PipelineRunStats;
225
+ /** Whose runs the stats cover, e.g. a username. */
226
+ statsLabel?: string;
227
+ }>();
228
+
229
+ const ORIGIN: Record<PlannedNavigatorOrigin, { label: string; color: string }> = {
230
+ 'strategy-doc': { label: 'strategy doc', color: 'primary' },
231
+ 'assembler-default': { label: 'added by the assembler', color: 'secondary' },
232
+ 'default-pipeline': { label: 'default pipeline', color: 'warning' },
233
+ };
234
+
235
+ const regStat = (name: RegulatorName): RegulatorRunStat | undefined =>
236
+ props.stats?.regulators.find((r) => r.name === name);
237
+
238
+ function regStatText(r: RegulatorRunStat): string {
239
+ return (
240
+ `In these runs: active (×>1) in ${r.runsActive}/${r.runs}, mean ×${r.meanMultiplier.toFixed(2)}, ` +
241
+ `max ×${r.maxMultiplier.toFixed(2)}. Last: ${regulatorText(r.last)}.`
242
+ );
243
+ }
244
+
245
+ const KIND_HELP: Record<FilterKind, string> = {
246
+ gate: 'Gate: its penalties mean not ready yet; regulators will not lift what it penalized.',
247
+ signal: 'Signal: its penalties express preference.',
248
+ };
249
+
250
+ /**
251
+ * A filter's kind: as its class declares it (the plan), else as recorded by
252
+ * runs. Undeclared filters run as gates.
253
+ */
254
+ function filterKind(f: PlannedNavigator): { kind: FilterKind; label: string; help: string } {
255
+ const kind = f.kind ?? filterStat(f.name)?.kind;
256
+ if (kind) return { kind, label: kind, help: KIND_HELP[kind] };
257
+ return {
258
+ kind: 'gate',
259
+ label: 'gate (undeclared)',
260
+ help: `${KIND_HELP.gate} Its class declares no kind, so it counts as a gate.`,
261
+ };
262
+ }
263
+
264
+ const plan = computed(() => planPipeline(props.strategies, props.courseId));
265
+
266
+ const genStats = computed(() => new Map((props.stats?.generators ?? []).map((g) => [g.name, g])));
267
+ const filterStats = computed(() => new Map((props.stats?.filters ?? []).map((f) => [f.name, f])));
268
+ const genStat = (name: string): GeneratorRunStat | undefined => genStats.value.get(name);
269
+ const filterStat = (name: string): FilterRunStat | undefined => filterStats.value.get(name);
270
+
271
+ /** The plan's notes, plus what the run stats show about pieces that did nothing. */
272
+ const notes = computed(() => {
273
+ const out = [...plan.value.notes];
274
+ const stats = props.stats;
275
+ if (!stats) return out;
276
+ for (const f of plan.value.filters) {
277
+ const s = filterStat(f.name);
278
+ if (s && s.boosted + s.penalized + s.removed === 0) {
279
+ out.push(`${f.name} changed no candidate's score in ${s.runs} runs.`);
280
+ }
281
+ }
282
+ if (stats.runsWithSelection > 0) {
283
+ for (const g of plan.value.generators) {
284
+ const s = genStat(g.name);
285
+ if (s && s.runs > 0 && s.selected === 0) {
286
+ out.push(`${g.name} proposed candidates in ${s.runs} runs, and none was selected.`);
287
+ }
288
+ }
289
+ }
290
+ return out;
291
+ });
292
+
293
+ function accuracyOf(g: GeneratorRunStat): number | null {
294
+ return g.firstAttempts > 0 ? g.firstAttemptsCorrect / g.firstAttempts : null;
295
+ }
296
+
297
+ function impactParts(f: FilterRunStat) {
298
+ const total = f.boosted + f.penalized + f.passed + f.removed || 1;
299
+ return [
300
+ { key: 'boosted', count: f.boosted, color: 'success' },
301
+ { key: 'penalized', count: f.penalized, color: 'warning' },
302
+ { key: 'passed', count: f.passed, color: 'grey' },
303
+ { key: 'removed', count: f.removed, color: 'error' },
304
+ ].map((p) => ({ ...p, share: p.count / total }));
305
+ }
306
+
307
+ function fmtNum(n: number): string {
308
+ return n >= 10 ? Math.round(n).toString() : n.toFixed(1);
309
+ }
310
+
311
+ function fmtPct(share: number): string {
312
+ if (share === 0) return '0%';
313
+ return share < 0.01 ? '<1%' : `${Math.round(share * 100)}%`;
314
+ }
315
+
316
+ const openConfigs = ref(new Set<string>());
317
+
318
+ function toggleConfig(key: string): void {
319
+ const next = new Set(openConfigs.value);
320
+ if (next.has(key)) next.delete(key);
321
+ else next.add(key);
322
+ openConfigs.value = next;
323
+ }
324
+
325
+ /** A strategy's serializedData, pretty-printed when it's JSON; '' when there's nothing to show. */
326
+ function prettyConfig(serialized: string | undefined): string {
327
+ if (!serialized) return '';
328
+ try {
329
+ const parsed: unknown = JSON.parse(serialized);
330
+ const text = JSON.stringify(parsed, null, 2);
331
+ return text === '{}' ? '' : text;
332
+ } catch {
333
+ return serialized;
334
+ }
335
+ }
336
+ </script>
337
+
338
+ <style scoped>
339
+ .sk-stage {
340
+ border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
341
+ border-radius: 6px;
342
+ padding: 10px 12px;
343
+ }
344
+ .sk-stage-num {
345
+ display: inline-flex;
346
+ align-items: center;
347
+ justify-content: center;
348
+ width: 20px;
349
+ height: 20px;
350
+ border-radius: 50%;
351
+ margin-right: 8px;
352
+ font-size: 0.72rem;
353
+ background: rgba(var(--v-theme-primary), 0.15);
354
+ color: rgb(var(--v-theme-primary));
355
+ }
356
+ .sk-stage-arrow {
357
+ display: flex;
358
+ justify-content: center;
359
+ padding: 2px 0;
360
+ opacity: 0.5;
361
+ }
362
+ .sk-nav {
363
+ border-left: 2px solid rgba(var(--v-theme-primary), 0.25);
364
+ padding: 4px 0 4px 10px;
365
+ margin: 6px 0 6px 2px;
366
+ }
367
+ .sk-impact-bar {
368
+ display: flex;
369
+ height: 6px;
370
+ border-radius: 3px;
371
+ overflow: hidden;
372
+ background: rgba(var(--v-theme-on-surface), 0.06);
373
+ max-width: 420px;
374
+ margin: 4px 0 2px;
375
+ }
376
+ .sk-config-link {
377
+ cursor: pointer;
378
+ border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);
379
+ }
380
+ .sk-config {
381
+ font-size: 0.72rem;
382
+ background: rgba(var(--v-theme-on-surface), 0.04);
383
+ border-radius: 4px;
384
+ padding: 6px 8px;
385
+ margin: 4px 0 0;
386
+ max-height: 260px;
387
+ overflow: auto;
388
+ white-space: pre-wrap;
389
+ }
390
+ </style>
@@ -0,0 +1,186 @@
1
+ <template>
2
+ <div class="sk-run-detail">
3
+ <div v-if="run.generators?.length" class="mb-2">
4
+ <div class="text-caption font-weight-medium">generators</div>
5
+ <v-table density="compact" class="sk-mini-table">
6
+ <thead>
7
+ <tr>
8
+ <th></th>
9
+ <th class="text-right">cards</th>
10
+ <th class="text-right">new</th>
11
+ <th class="text-right">review</th>
12
+ <th class="text-right">top</th>
13
+ </tr>
14
+ </thead>
15
+ <tbody>
16
+ <tr v-for="g in run.generators" :key="g.name">
17
+ <td>{{ g.name }}</td>
18
+ <td class="text-right">{{ g.cardCount }}</td>
19
+ <td class="text-right">{{ g.newCount }}</td>
20
+ <td class="text-right">{{ g.reviewCount }}</td>
21
+ <td class="text-right">{{ g.topScore.toFixed(2) }}</td>
22
+ </tr>
23
+ </tbody>
24
+ </v-table>
25
+ </div>
26
+
27
+ <div v-if="run.filters?.length" class="mb-2">
28
+ <div class="text-caption font-weight-medium">filters</div>
29
+ <v-table density="compact" class="sk-mini-table">
30
+ <thead>
31
+ <tr>
32
+ <th></th>
33
+ <th>kind</th>
34
+ <th class="text-right">boosted</th>
35
+ <th class="text-right">penalized</th>
36
+ <th class="text-right">passed</th>
37
+ <th class="text-right">removed</th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ <tr v-for="f in run.filters" :key="f.name">
42
+ <td>{{ f.name }}</td>
43
+ <td>{{ f.kind ?? '—' }}</td>
44
+ <td class="text-right">{{ f.boosted }}</td>
45
+ <td class="text-right">{{ f.penalized }}</td>
46
+ <td class="text-right">{{ f.passed }}</td>
47
+ <td class="text-right" :class="f.removed > 0 ? 'text-error' : ''">{{ f.removed }}</td>
48
+ </tr>
49
+ </tbody>
50
+ </v-table>
51
+ </div>
52
+
53
+ <div v-if="run.regulators?.length" class="mb-2">
54
+ <div class="text-caption font-weight-medium">regulators</div>
55
+ <div v-for="r in run.regulators" :key="r.name" class="sk-mini-line">
56
+ <span :class="r.multiplier > 1 ? 'text-warning' : 'text-medium-emphasis'">
57
+ ×{{ r.multiplier.toFixed(2) }}
58
+ </span>
59
+ {{ regulatorText(r) }}
60
+ </div>
61
+ </div>
62
+
63
+ <div v-if="run.cards?.length" class="mb-2">
64
+ <div class="text-caption font-weight-medium">selection</div>
65
+ <v-table density="compact" class="sk-mini-table">
66
+ <thead>
67
+ <tr>
68
+ <th></th>
69
+ <th>card</th>
70
+ <th>origin</th>
71
+ <th>generator</th>
72
+ <th class="text-right">score</th>
73
+ </tr>
74
+ </thead>
75
+ <tbody>
76
+ <tr v-for="c in run.cards" :key="c.cardId" :class="c.selected ? '' : 'text-medium-emphasis'">
77
+ <td>
78
+ <v-icon v-if="c.selected" size="x-small" color="success">mdi-check</v-icon>
79
+ <span v-else title="runner-up">·</span>
80
+ </td>
81
+ <td>
82
+ <a class="sk-card-link" @click="emit('open-card', c.cardId)">{{ c.cardId }}</a>
83
+ </td>
84
+ <td>{{ c.origin }}</td>
85
+ <td>{{ c.generator ?? '—' }}</td>
86
+ <td class="text-right">{{ fmtScore(c.score) }}</td>
87
+ </tr>
88
+ </tbody>
89
+ </v-table>
90
+ </div>
91
+
92
+ <div v-for="t in trails" :key="t.label" class="mb-2">
93
+ <div class="text-caption font-weight-medium">
94
+ {{ t.label }}:
95
+ <a class="sk-card-link" @click="emit('open-card', t.card.cardId)">{{ t.card.cardId }}</a>
96
+ <span class="text-medium-emphasis">
97
+ ({{ t.card.origin }}, {{ t.card.generator ?? '?' }}) final {{ fmtScore(t.card.score) }}
98
+ </span>
99
+ </div>
100
+ <v-table density="compact" class="sk-mini-table">
101
+ <tbody>
102
+ <tr v-for="(p, i) in t.card.trail" :key="i">
103
+ <td class="text-no-wrap">{{ p.action }}</td>
104
+ <td class="text-no-wrap">{{ p.strategyName }}</td>
105
+ <td class="text-right text-no-wrap">{{ fmtScore(p.score) }}</td>
106
+ <td class="text-caption">{{ p.reason }}</td>
107
+ </tr>
108
+ </tbody>
109
+ </v-table>
110
+ </div>
111
+
112
+ <div v-if="run.hints" class="mb-2">
113
+ <div class="text-caption font-weight-medium">hints</div>
114
+ <pre class="sk-hint-block">{{ JSON.stringify(run.hints, null, 2) }}</pre>
115
+ </div>
116
+
117
+ <div v-if="run.discardedTail" class="text-caption text-medium-emphasis">
118
+ discarded tail: {{ run.discardedTail.count }} cards
119
+ <template v-if="run.discardedTail.scoreRange">
120
+ · score [{{ run.discardedTail.scoreRange[0].toFixed(2) }}, {{ run.discardedTail.scoreRange[1].toFixed(2) }}]
121
+ </template>
122
+ <template v-if="run.discardedTail.eloRange">
123
+ · ELO [{{ Math.round(run.discardedTail.eloRange[0]) }}, {{ Math.round(run.discardedTail.eloRange[1]) }}]
124
+ </template>
125
+ <div v-if="run.discardedTail.note">{{ run.discardedTail.note }}</div>
126
+ </div>
127
+ </div>
128
+ </template>
129
+
130
+ <script setup lang="ts">
131
+ import { computed } from 'vue';
132
+ import {
133
+ regulatorText,
134
+ type StudySessionRunCardTrail,
135
+ type StudySessionRunSummary,
136
+ } from '@vue-skuilder/db/diagnostics';
137
+
138
+ /** The persisted detail of one pipeline run. */
139
+ const props = defineProps<{ run: StudySessionRunSummary }>();
140
+ const emit = defineEmits<{ 'open-card': [cardId: string] }>();
141
+
142
+ const trails = computed(() => {
143
+ const u = props.run.unselectedNew;
144
+ const out: Array<{ label: string; card: StudySessionRunCardTrail }> = [];
145
+ if (u?.nextInLine) out.push({ label: 'next new card in line', card: u.nextInLine });
146
+ if (u?.topGenerated) out.push({ label: 'top-generated new card (sunk)', card: u.topGenerated });
147
+ return out;
148
+ });
149
+
150
+ function fmtScore(n: number): string {
151
+ return Math.abs(n) >= 0.01 || n === 0 ? n.toFixed(2) : n.toExponential(1);
152
+ }
153
+ </script>
154
+
155
+ <style scoped>
156
+ .sk-run-detail {
157
+ border-left: 2px solid rgba(var(--v-theme-primary), 0.25);
158
+ padding-left: 10px;
159
+ margin-left: 2px;
160
+ }
161
+ .sk-mini-table :deep(td),
162
+ .sk-mini-table :deep(th) {
163
+ font-size: 0.72rem;
164
+ }
165
+ .sk-mini-line {
166
+ font-size: 0.72rem;
167
+ padding-left: 8px;
168
+ }
169
+ .sk-hint-block {
170
+ font-size: 0.72rem;
171
+ background: rgba(var(--v-theme-on-surface), 0.04);
172
+ border-radius: 4px;
173
+ padding: 6px 8px;
174
+ margin: 0;
175
+ max-height: 140px;
176
+ overflow: auto;
177
+ white-space: pre-wrap;
178
+ }
179
+ .sk-card-link {
180
+ cursor: pointer;
181
+ border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);
182
+ }
183
+ .sk-card-link:hover {
184
+ color: rgb(var(--v-theme-primary));
185
+ }
186
+ </style>
@@ -0,0 +1,66 @@
1
+ import { ref, shallowRef } from 'vue';
2
+ import {
3
+ dumpFileName,
4
+ fetchLearnerDump,
5
+ fromDump,
6
+ parseLearnerDump,
7
+ type LearnerDataset,
8
+ type LearnerDump,
9
+ } from '@vue-skuilder/db/diagnostics';
10
+
11
+ /** Where to read a learner from live. Auth is the signed-in admin's couch session cookie. */
12
+ export interface LearnerCouchSource {
13
+ /** Couch base URL, e.g. `https://example.com/couch`. */
14
+ couchUrl: string;
15
+ username: string;
16
+ courseId: string;
17
+ /** Course cards to include. Default `touched`. */
18
+ cards?: 'touched' | 'all' | 'none';
19
+ }
20
+
21
+ /**
22
+ * Load one learner's dataset for admin views, live from couch or from a dump
23
+ * file (`skuilder dump-user`, or a file saved from here). Keeps the raw dump
24
+ * so a view can hand exactly what it showed back out as a file.
25
+ */
26
+ export function useLearnerDataset() {
27
+ const dump = shallowRef<LearnerDump | null>(null);
28
+ const dataset = shallowRef<LearnerDataset | null>(null);
29
+ const loading = ref(false);
30
+ const error = ref<string | null>(null);
31
+
32
+ async function load(read: () => Promise<LearnerDump>, courseId?: string): Promise<void> {
33
+ loading.value = true;
34
+ error.value = null;
35
+ try {
36
+ const d = await read();
37
+ dataset.value = fromDump(d, courseId);
38
+ dump.value = d;
39
+ } catch (e) {
40
+ error.value = e instanceof Error ? e.message : String(e);
41
+ } finally {
42
+ loading.value = false;
43
+ }
44
+ }
45
+
46
+ function loadFromCouch(source: LearnerCouchSource): Promise<void> {
47
+ return load(() => fetchLearnerDump({ ...source, auth: { kind: 'cookie' } }), source.courseId);
48
+ }
49
+
50
+ function loadFromFile(file: File, courseId?: string): Promise<void> {
51
+ return load(async () => parseLearnerDump(await file.text()), courseId);
52
+ }
53
+
54
+ return { dump, dataset, loading, error, loadFromCouch, loadFromFile };
55
+ }
56
+
57
+ /** Save a dump under the same name `skuilder dump-user` would give it. */
58
+ export function downloadLearnerDump(dump: LearnerDump): void {
59
+ const blob = new Blob([JSON.stringify(dump, null, 2)], { type: 'application/json' });
60
+ const url = URL.createObjectURL(blob);
61
+ const a = document.createElement('a');
62
+ a.href = url;
63
+ a.download = dumpFileName(dump);
64
+ a.click();
65
+ URL.revokeObjectURL(url);
66
+ }