@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.
- package/dist/admin/admin.css +2 -0
- package/dist/admin/common-ui-admin.es.js +1394 -0
- package/dist/admin/common-ui-admin.es.js.map +1 -0
- package/dist/admin/components/CardDossierDetail.vue.d.ts +9 -0
- package/dist/admin/components/CardDossierDetail.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerCardHistory.vue.d.ts +13 -0
- package/dist/admin/components/LearnerCardHistory.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerDatasetSource.vue.d.ts +24 -0
- package/dist/admin/components/LearnerDatasetSource.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerDiagnostics.vue.d.ts +13 -0
- package/dist/admin/components/LearnerDiagnostics.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerSessionDetail.vue.d.ts +14 -0
- package/dist/admin/components/LearnerSessionDetail.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerSessionList.vue.d.ts +13 -0
- package/dist/admin/components/LearnerSessionList.vue.d.ts.map +1 -0
- package/dist/admin/components/RunDetail.vue.d.ts +12 -0
- package/dist/admin/components/RunDetail.vue.d.ts.map +1 -0
- package/dist/admin/composables/useLearnerDataset.d.ts +26 -0
- package/dist/admin/composables/useLearnerDataset.d.ts.map +1 -0
- package/dist/admin/display.d.ts +20 -0
- package/dist/admin/display.d.ts.map +1 -0
- package/dist/admin/index.d.ts +22 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/components/CardBrowser.vue.d.ts +5 -5
- package/dist/components/CardHistoryViewer.vue.d.ts +1 -1
- package/dist/components/CardSearchResults.vue.d.ts +1 -1
- package/dist/components/CourseCardBrowser.vue.d.ts +9 -9
- package/dist/components/CourseInformation.vue.d.ts +18 -18
- package/dist/components/CourseTagFilterWidget.vue.d.ts +1 -1
- package/dist/components/SessionControllerDebug.vue.d.ts +1 -1
- package/dist/components/SnackbarService.d.ts +1 -1
- package/dist/components/StudySession.types.d.ts +1 -1
- package/dist/components/StudySession.vue.d.ts +2 -2
- package/dist/components/TagsInput.vue.d.ts +1 -1
- package/dist/components/UserTagPreferences.vue.d.ts +1 -1
- package/dist/components/auth/UserRegistration.vue.d.ts +74 -74
- package/dist/components/auth/UserRegistration.vue.d.ts.map +1 -1
- package/dist/components/cardRendering/CardLoader.vue.d.ts +6 -6
- package/dist/components/cardRendering/CardViewer.vue.d.ts +2 -2
- package/dist/components/cardRendering/MarkdownRenderer.vue.d.ts +6 -6
- package/dist/components/cardRendering/MdTokenRenderer.vue.d.ts +6 -6
- package/dist/components/studentInputs/BaseUserInput.d.ts +2 -2
- package/dist/components/studentInputs/MultipleChoiceOption.vue.d.ts +6 -6
- package/dist/components/studentInputs/RadioMultipleChoice.types.d.ts +1 -1
- package/dist/components/studentInputs/RadioMultipleChoice.vue.d.ts +9 -9
- package/dist/components/studentInputs/TrueFalse.vue.d.ts +9 -9
- package/dist/components/studentInputs/UserInputNumber.vue.d.ts +2 -2
- package/dist/components/studentInputs/UserInputString.vue.d.ts +3 -3
- package/dist/components/studentInputs/fillInInput.vue.d.ts +3 -3
- package/dist/composables/CompositionViewable.d.ts +2 -2
- package/dist/composables/Displayable.d.ts +1 -1
- package/dist/stores/useAuthStore.d.ts +82 -82
- package/dist/stores/useAuthStore.d.ts.map +1 -1
- package/dist/stores/useConfigStore.d.ts +1 -1
- package/package.json +18 -5
- package/src/admin/components/CardDossierDetail.vue +99 -0
- package/src/admin/components/LearnerCardHistory.vue +196 -0
- package/src/admin/components/LearnerDatasetSource.vue +97 -0
- package/src/admin/components/LearnerDiagnostics.vue +68 -0
- package/src/admin/components/LearnerSessionDetail.vue +439 -0
- package/src/admin/components/LearnerSessionList.vue +175 -0
- package/src/admin/components/RunDetail.vue +166 -0
- package/src/admin/composables/useLearnerDataset.ts +66 -0
- package/src/admin/display.ts +73 -0
- package/src/admin/index.ts +27 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"common-ui-admin.es.js","names":[],"sources":["../../src/admin/composables/useLearnerDataset.ts","../../src/admin/components/LearnerDatasetSource.vue","../../src/admin/components/LearnerDatasetSource.vue","../../src/admin/display.ts","../../src/admin/components/CardDossierDetail.vue","../../src/admin/components/CardDossierDetail.vue","../../src/admin/components/LearnerCardHistory.vue","../../src/admin/components/LearnerCardHistory.vue","../../src/admin/components/RunDetail.vue","../../src/admin/components/RunDetail.vue","../../src/admin/components/LearnerSessionDetail.vue","../../src/admin/components/LearnerSessionDetail.vue","../../src/admin/components/LearnerSessionList.vue","../../src/admin/components/LearnerSessionList.vue","../../src/admin/components/LearnerDiagnostics.vue","../../src/admin/components/LearnerDiagnostics.vue"],"sourcesContent":["import { ref, shallowRef } from 'vue';\nimport {\n dumpFileName,\n fetchLearnerDump,\n fromDump,\n parseLearnerDump,\n type LearnerDataset,\n type LearnerDump,\n} from '@vue-skuilder/db/diagnostics';\n\n/** Where to read a learner from live. Auth is the signed-in admin's couch session cookie. */\nexport interface LearnerCouchSource {\n /** Couch base URL, e.g. `https://example.com/couch`. */\n couchUrl: string;\n username: string;\n courseId: string;\n /** Course cards to include. Default `touched`. */\n cards?: 'touched' | 'all' | 'none';\n}\n\n/**\n * Load one learner's dataset for admin views, live from couch or from a dump\n * file (`skuilder dump-user`, or a file saved from here). Keeps the raw dump\n * so a view can hand exactly what it showed back out as a file.\n */\nexport function useLearnerDataset() {\n const dump = shallowRef<LearnerDump | null>(null);\n const dataset = shallowRef<LearnerDataset | null>(null);\n const loading = ref(false);\n const error = ref<string | null>(null);\n\n async function load(read: () => Promise<LearnerDump>, courseId?: string): Promise<void> {\n loading.value = true;\n error.value = null;\n try {\n const d = await read();\n dataset.value = fromDump(d, courseId);\n dump.value = d;\n } catch (e) {\n error.value = e instanceof Error ? e.message : String(e);\n } finally {\n loading.value = false;\n }\n }\n\n function loadFromCouch(source: LearnerCouchSource): Promise<void> {\n return load(() => fetchLearnerDump({ ...source, auth: { kind: 'cookie' } }), source.courseId);\n }\n\n function loadFromFile(file: File, courseId?: string): Promise<void> {\n return load(async () => parseLearnerDump(await file.text()), courseId);\n }\n\n return { dump, dataset, loading, error, loadFromCouch, loadFromFile };\n}\n\n/** Save a dump under the same name `skuilder dump-user` would give it. */\nexport function downloadLearnerDump(dump: LearnerDump): void {\n const blob = new Blob([JSON.stringify(dump, null, 2)], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = dumpFileName(dump);\n a.click();\n URL.revokeObjectURL(url);\n}\n","<template>\n <v-card variant=\"outlined\">\n <v-card-text>\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <v-text-field\n v-model=\"username\"\n label=\"Learner\"\n density=\"compact\"\n hide-details\n style=\"max-width: 16rem\"\n :disabled=\"!couchUrl || !courseId\"\n @keydown.enter=\"loadLive\"\n />\n <v-btn color=\"primary\" :loading=\"loading\" :disabled=\"!couchUrl || !courseId || !username\" @click=\"loadLive\">\n Load\n </v-btn>\n <span class=\"text-medium-emphasis mx-2\">or</span>\n <v-btn variant=\"tonal\" prepend-icon=\"mdi-file-upload-outline\" @click=\"fileInput?.click()\">\n Open dump file\n </v-btn>\n <input ref=\"fileInput\" type=\"file\" accept=\".json,application/json\" hidden @change=\"onFile\" />\n </div>\n\n <v-alert v-if=\"error\" type=\"error\" variant=\"tonal\" density=\"compact\" class=\"mt-3\">\n {{ error }}\n </v-alert>\n\n <div v-if=\"dataset && dump\" class=\"d-flex flex-wrap align-center ga-2 mt-3\">\n <span>\n <strong>{{ dataset.username }}</strong>\n · {{ dataset.sessions.length }} sessions\n <template v-if=\"sessionRange\">({{ sessionRange }})</template>\n · {{ dataset.cardHistories.length }} cards seen · as of {{ dataset.asOf.slice(0, 16) }}Z\n <template v-if=\"!dataset.cards\">· no course slice</template>\n </span>\n <v-spacer />\n <v-btn variant=\"text\" size=\"small\" prepend-icon=\"mdi-download\" @click=\"downloadLearnerDump(dump)\">\n Save dump\n </v-btn>\n </div>\n </v-card-text>\n </v-card>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, onMounted, ref, watch } from 'vue';\nimport type { LearnerDataset, LearnerDump } from '@vue-skuilder/db/diagnostics';\nimport { downloadLearnerDump, useLearnerDataset } from '../composables/useLearnerDataset';\n\n/**\n * Pick a learner's dataset: live from couch by username (needs `couchUrl` and\n * `courseId`), or from a dump file. Emits `loaded` with the indexed dataset\n * and the raw dump.\n */\nconst props = defineProps<{\n /** Couch base URL. Without it, only files can be opened. */\n couchUrl?: string;\n courseId?: string;\n /** Prefills the learner field. */\n initialUsername?: string;\n /** Load `initialUsername` on mount, when live loading is possible. */\n autoload?: boolean;\n}>();\n\nconst emit = defineEmits<{\n loaded: [dataset: LearnerDataset, dump: LearnerDump];\n}>();\n\nconst { dump, dataset, loading, error, loadFromCouch, loadFromFile } = useLearnerDataset();\nconst username = ref(props.initialUsername ?? '');\nconst fileInput = ref<HTMLInputElement | null>(null);\n\nconst sessionRange = computed(() => {\n const s = dataset.value?.sessions ?? [];\n return s.length ? `${s[0].startTime.slice(0, 10)} .. ${s[s.length - 1].startTime.slice(0, 10)}` : '';\n});\n\nfunction loadLive(): void {\n if (!props.couchUrl || !props.courseId || !username.value) return;\n void loadFromCouch({ couchUrl: props.couchUrl, courseId: props.courseId, username: username.value });\n}\n\nfunction onFile(event: Event): void {\n const input = event.target as HTMLInputElement;\n const file = input.files?.[0];\n if (file) void loadFromFile(file, props.courseId);\n input.value = '';\n}\n\nwatch(dataset, (ds) => {\n if (ds && dump.value) emit('loaded', ds, dump.value);\n});\n\nonMounted(() => {\n if (props.autoload) loadLive();\n});\n</script>\n","<template>\n <v-card variant=\"outlined\">\n <v-card-text>\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <v-text-field\n v-model=\"username\"\n label=\"Learner\"\n density=\"compact\"\n hide-details\n style=\"max-width: 16rem\"\n :disabled=\"!couchUrl || !courseId\"\n @keydown.enter=\"loadLive\"\n />\n <v-btn color=\"primary\" :loading=\"loading\" :disabled=\"!couchUrl || !courseId || !username\" @click=\"loadLive\">\n Load\n </v-btn>\n <span class=\"text-medium-emphasis mx-2\">or</span>\n <v-btn variant=\"tonal\" prepend-icon=\"mdi-file-upload-outline\" @click=\"fileInput?.click()\">\n Open dump file\n </v-btn>\n <input ref=\"fileInput\" type=\"file\" accept=\".json,application/json\" hidden @change=\"onFile\" />\n </div>\n\n <v-alert v-if=\"error\" type=\"error\" variant=\"tonal\" density=\"compact\" class=\"mt-3\">\n {{ error }}\n </v-alert>\n\n <div v-if=\"dataset && dump\" class=\"d-flex flex-wrap align-center ga-2 mt-3\">\n <span>\n <strong>{{ dataset.username }}</strong>\n · {{ dataset.sessions.length }} sessions\n <template v-if=\"sessionRange\">({{ sessionRange }})</template>\n · {{ dataset.cardHistories.length }} cards seen · as of {{ dataset.asOf.slice(0, 16) }}Z\n <template v-if=\"!dataset.cards\">· no course slice</template>\n </span>\n <v-spacer />\n <v-btn variant=\"text\" size=\"small\" prepend-icon=\"mdi-download\" @click=\"downloadLearnerDump(dump)\">\n Save dump\n </v-btn>\n </div>\n </v-card-text>\n </v-card>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, onMounted, ref, watch } from 'vue';\nimport type { LearnerDataset, LearnerDump } from '@vue-skuilder/db/diagnostics';\nimport { downloadLearnerDump, useLearnerDataset } from '../composables/useLearnerDataset';\n\n/**\n * Pick a learner's dataset: live from couch by username (needs `couchUrl` and\n * `courseId`), or from a dump file. Emits `loaded` with the indexed dataset\n * and the raw dump.\n */\nconst props = defineProps<{\n /** Couch base URL. Without it, only files can be opened. */\n couchUrl?: string;\n courseId?: string;\n /** Prefills the learner field. */\n initialUsername?: string;\n /** Load `initialUsername` on mount, when live loading is possible. */\n autoload?: boolean;\n}>();\n\nconst emit = defineEmits<{\n loaded: [dataset: LearnerDataset, dump: LearnerDump];\n}>();\n\nconst { dump, dataset, loading, error, loadFromCouch, loadFromFile } = useLearnerDataset();\nconst username = ref(props.initialUsername ?? '');\nconst fileInput = ref<HTMLInputElement | null>(null);\n\nconst sessionRange = computed(() => {\n const s = dataset.value?.sessions ?? [];\n return s.length ? `${s[0].startTime.slice(0, 10)} .. ${s[s.length - 1].startTime.slice(0, 10)}` : '';\n});\n\nfunction loadLive(): void {\n if (!props.couchUrl || !props.courseId || !username.value) return;\n void loadFromCouch({ couchUrl: props.couchUrl, courseId: props.courseId, username: username.value });\n}\n\nfunction onFile(event: Event): void {\n const input = event.target as HTMLInputElement;\n const file = input.files?.[0];\n if (file) void loadFromFile(file, props.courseId);\n input.value = '';\n}\n\nwatch(dataset, (ds) => {\n if (ds && dump.value) emit('loaded', ds, dump.value);\n});\n\nonMounted(() => {\n if (props.autoload) loadLive();\n});\n</script>\n","// Display-only helpers shared by the admin views: colours, icons, and\n// locale-formatted times. Anything a text export also needs lives in\n// @vue-skuilder/db/diagnostics instead.\nimport { ref, type Ref } from 'vue';\nimport type { SessionFlag } from '@vue-skuilder/db/diagnostics';\n\nexport const FLAG_UI: Record<SessionFlag, { icon: string; color: string }> = {\n churned: { icon: 'mdi-autorenew', color: 'warning' },\n 'out-of-content': { icon: 'mdi-cup-off-outline', color: 'error' },\n walked: { icon: 'mdi-exit-run', color: 'error' },\n stalled: { icon: 'mdi-arrow-collapse-horizontal', color: 'grey' },\n grinding: { icon: 'mdi-alert-octagon-outline', color: 'warning' },\n};\n\nexport function statusColor(status: string): string {\n if (status === 'closed') return 'success';\n if (status === 'abandoned') return 'warning';\n return 'error'; // 'open': the tab went away before close\n}\n\nexport function accuracyClass(a: number | null): string {\n if (a === null) return 'text-disabled';\n if (a >= 0.8) return 'text-success';\n if (a < 0.5) return 'text-error';\n return '';\n}\n\nexport function deltaClass(d: number | null): string {\n if (d === null || d === 0) return 'text-disabled';\n return d > 0 ? 'text-success' : 'text-error';\n}\n\nexport function deltaColor(d: number): string {\n if (d > 0) return 'success';\n if (d < 0) return 'error';\n return 'grey';\n}\n\nexport function fmtTime(iso: string | null | undefined): string {\n if (!iso) return '—';\n const d = new Date(iso);\n return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();\n}\n\n/** Time of day only: a session timeline reads as a within-sitting sequence. */\nexport function fmtClock(iso: string): string {\n const d = new Date(iso);\n return Number.isNaN(d.getTime()) ? iso : d.toLocaleTimeString(undefined, { hour12: false });\n}\n\nexport function shortId(id: string, max = 28): string {\n return id.length > max ? `…${id.slice(-(max - 2))}` : id;\n}\n\n/** Copy text, and remember which control asked, briefly, for a tick. */\nexport function useCopy(): {\n copied: Ref<string | null>;\n copy: (text: string, key: string) => Promise<void>;\n} {\n const copied = ref<string | null>(null);\n let timer: ReturnType<typeof setTimeout> | null = null;\n async function copy(text: string, key: string): Promise<void> {\n try {\n await navigator.clipboard.writeText(text);\n copied.value = key;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => (copied.value = null), 1400);\n } catch (e) {\n console.warn('[admin] clipboard write failed', e);\n }\n }\n return { copied, copy };\n}\n","<template>\n <div>\n <div v-if=\"dossier.card && Object.keys(dossier.card.data).length\" class=\"text-caption mb-2\">\n <span v-for=\"(v, k) in dossier.card.data\" :key=\"k\" class=\"mr-3\">\n <span class=\"text-medium-emphasis\">{{ k }}:</span> {{ fmtAnswer(v, 60) }}\n </span>\n </div>\n <div class=\"d-flex flex-wrap mb-2 ga-1\">\n <v-chip\n v-for=\"t in dossier.card?.tags ?? []\"\n :key=\"t\"\n size=\"x-small\"\n variant=\"outlined\"\n :color=\"isSkillTag(interpreters, t) ? 'primary' : undefined\"\n >\n {{ t }}\n </v-chip>\n <span v-if=\"!dossier.card\" class=\"text-caption text-disabled\">no course data in this dataset</span>\n </div>\n <div class=\"text-caption text-medium-emphasis mb-2\">\n lapses {{ dossier.lapses }} · streak {{ dossier.streak }} · best interval\n {{ fmtInterval(dossier.bestIntervalSeconds) }} ·\n <template v-if=\"dossier.pendingReview\">\n next review {{ fmtTime(dossier.pendingReview.reviewTime) }}\n <span v-if=\"dossier.pendingReview.overdue\" class=\"text-error\">(overdue)</span>\n </template>\n <template v-else>no review scheduled</template>\n </div>\n\n <v-table density=\"compact\" class=\"sk-record-table\">\n <thead>\n <tr>\n <th>#</th>\n <th>When</th>\n <th>Result</th>\n <th>Perf</th>\n <th>Prior att.</th>\n <th>Time</th>\n <th>Answer</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"(r, i) in dossier.records\" :key=\"i\" :class=\"r.isCorrect === false ? 'sk-fail-row' : ''\">\n <td>{{ i + 1 }}</td>\n <td class=\"text-no-wrap\">{{ fmtTime(r.timeStamp) }}</td>\n <td>\n <v-icon v-if=\"r.isCorrect === true\" size=\"small\" color=\"success\">mdi-check</v-icon>\n <v-icon v-else-if=\"r.isCorrect === false\" size=\"small\" color=\"error\">mdi-close</v-icon>\n <span v-else class=\"text-disabled\">—</span>\n </td>\n <td>{{ perf(r) }}</td>\n <td>{{ r.priorAttemps ?? '—' }}</td>\n <td>{{ ((r.timeSpent ?? 0) / 1000).toFixed(1) }}s</td>\n <td class=\"text-truncate\" style=\"max-width: 200px\">{{ fmtAnswer(r.userAnswer) }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport {\n fmtAnswer,\n isSkillTag,\n recordPerformance,\n type CardDossier,\n type DatasetRecord,\n type DiagnosticsInterpreters,\n} from '@vue-skuilder/db/diagnostics';\nimport { fmtTime } from '../display';\n\n/** One card's record for one learner: content, tags, scheduling state, and every response. */\ndefineProps<{\n dossier: CardDossier;\n interpreters?: DiagnosticsInterpreters;\n}>();\n\nfunction perf(r: DatasetRecord): string {\n const p = recordPerformance(r);\n return p === null ? '—' : p.toFixed(2);\n}\n\nfunction fmtInterval(s: number): string {\n if (!s) return 'none';\n if (s < 3600) return `${Math.round(s / 60)}m`;\n if (s < 86400) return `${Math.round(s / 3600)}h`;\n return `${Math.round(s / 86400)}d`;\n}\n</script>\n\n<style scoped>\n.sk-record-table :deep(td),\n.sk-record-table :deep(th) {\n font-size: 0.78rem;\n}\n.sk-fail-row {\n background-color: rgba(var(--v-theme-error), 0.06);\n}\n</style>\n","<template>\n <div>\n <div v-if=\"dossier.card && Object.keys(dossier.card.data).length\" class=\"text-caption mb-2\">\n <span v-for=\"(v, k) in dossier.card.data\" :key=\"k\" class=\"mr-3\">\n <span class=\"text-medium-emphasis\">{{ k }}:</span> {{ fmtAnswer(v, 60) }}\n </span>\n </div>\n <div class=\"d-flex flex-wrap mb-2 ga-1\">\n <v-chip\n v-for=\"t in dossier.card?.tags ?? []\"\n :key=\"t\"\n size=\"x-small\"\n variant=\"outlined\"\n :color=\"isSkillTag(interpreters, t) ? 'primary' : undefined\"\n >\n {{ t }}\n </v-chip>\n <span v-if=\"!dossier.card\" class=\"text-caption text-disabled\">no course data in this dataset</span>\n </div>\n <div class=\"text-caption text-medium-emphasis mb-2\">\n lapses {{ dossier.lapses }} · streak {{ dossier.streak }} · best interval\n {{ fmtInterval(dossier.bestIntervalSeconds) }} ·\n <template v-if=\"dossier.pendingReview\">\n next review {{ fmtTime(dossier.pendingReview.reviewTime) }}\n <span v-if=\"dossier.pendingReview.overdue\" class=\"text-error\">(overdue)</span>\n </template>\n <template v-else>no review scheduled</template>\n </div>\n\n <v-table density=\"compact\" class=\"sk-record-table\">\n <thead>\n <tr>\n <th>#</th>\n <th>When</th>\n <th>Result</th>\n <th>Perf</th>\n <th>Prior att.</th>\n <th>Time</th>\n <th>Answer</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"(r, i) in dossier.records\" :key=\"i\" :class=\"r.isCorrect === false ? 'sk-fail-row' : ''\">\n <td>{{ i + 1 }}</td>\n <td class=\"text-no-wrap\">{{ fmtTime(r.timeStamp) }}</td>\n <td>\n <v-icon v-if=\"r.isCorrect === true\" size=\"small\" color=\"success\">mdi-check</v-icon>\n <v-icon v-else-if=\"r.isCorrect === false\" size=\"small\" color=\"error\">mdi-close</v-icon>\n <span v-else class=\"text-disabled\">—</span>\n </td>\n <td>{{ perf(r) }}</td>\n <td>{{ r.priorAttemps ?? '—' }}</td>\n <td>{{ ((r.timeSpent ?? 0) / 1000).toFixed(1) }}s</td>\n <td class=\"text-truncate\" style=\"max-width: 200px\">{{ fmtAnswer(r.userAnswer) }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport {\n fmtAnswer,\n isSkillTag,\n recordPerformance,\n type CardDossier,\n type DatasetRecord,\n type DiagnosticsInterpreters,\n} from '@vue-skuilder/db/diagnostics';\nimport { fmtTime } from '../display';\n\n/** One card's record for one learner: content, tags, scheduling state, and every response. */\ndefineProps<{\n dossier: CardDossier;\n interpreters?: DiagnosticsInterpreters;\n}>();\n\nfunction perf(r: DatasetRecord): string {\n const p = recordPerformance(r);\n return p === null ? '—' : p.toFixed(2);\n}\n\nfunction fmtInterval(s: number): string {\n if (!s) return 'none';\n if (s < 3600) return `${Math.round(s / 60)}m`;\n if (s < 86400) return `${Math.round(s / 3600)}h`;\n return `${Math.round(s / 86400)}d`;\n}\n</script>\n\n<style scoped>\n.sk-record-table :deep(td),\n.sk-record-table :deep(th) {\n font-size: 0.78rem;\n}\n.sk-fail-row {\n background-color: rgba(var(--v-theme-error), 0.06);\n}\n</style>\n","<template>\n <div>\n <p class=\"text-caption text-medium-emphasis mb-4\">\n Every card the learner has met, most painful first (time sunk × failures), with its pending review and current\n card ELO. <v-icon size=\"x-small\" color=\"error\">mdi-sync-alert</v-icon> is a failed card holding an overdue review;\n <v-icon size=\"x-small\" color=\"warning\">mdi-snowflake</v-icon> is a repeatedly failed card whose ELO has fewer\n updates than failures.\n </p>\n\n <v-row dense class=\"mb-2\">\n <v-col v-for=\"tile in tiles\" :key=\"tile.label\" cols=\"6\" sm=\"4\" md=\"2\">\n <v-card variant=\"tonal\" :color=\"tile.color\" class=\"pa-2 text-center\">\n <div class=\"text-h6\">{{ tile.value }}</div>\n <div class=\"text-caption\">{{ tile.label }}</div>\n </v-card>\n </v-col>\n </v-row>\n\n <div class=\"d-flex align-center flex-wrap mb-3 ga-3\">\n <v-btn-toggle v-model=\"filter\" density=\"compact\" color=\"primary\" variant=\"outlined\">\n <v-btn value=\"all\" size=\"small\">All ({{ cards.length }})</v-btn>\n <v-btn value=\"stuck\" size=\"small\">\n <v-icon start size=\"small\" color=\"error\">mdi-sync-alert</v-icon>\n Stuck ({{ counts.stuck }})\n </v-btn>\n <v-btn value=\"failed\" size=\"small\">Ever failed ({{ counts.failed }})</v-btn>\n <v-btn value=\"frozen\" size=\"small\">\n <v-icon start size=\"small\" color=\"warning\">mdi-snowflake</v-icon>\n Frozen ELO ({{ counts.frozen }})\n </v-btn>\n </v-btn-toggle>\n <v-text-field\n v-model=\"search\"\n density=\"compact\"\n variant=\"outlined\"\n hide-details\n clearable\n placeholder=\"filter by card / tag / type / content\"\n prepend-inner-icon=\"mdi-magnify\"\n style=\"max-width: 320px\"\n />\n </div>\n\n <v-alert v-if=\"cards.length === 0\" type=\"info\" variant=\"tonal\">No card history for this learner.</v-alert>\n\n <v-expansion-panels v-else v-model=\"open\" multiple variant=\"accordion\">\n <v-expansion-panel\n v-for=\"c in visible\"\n :key=\"c.cardId\"\n :value=\"c.cardId\"\n :data-card=\"c.cardId\"\n :class=\"c.cardId === flash ? 'sk-card-flash' : ''\"\n >\n <v-expansion-panel-title>\n <div class=\"d-flex align-center flex-wrap ga-2\" style=\"width: 100%\">\n <v-icon v-if=\"c.stuck\" color=\"error\" size=\"small\" title=\"failed + overdue review\">mdi-sync-alert</v-icon>\n <v-icon v-if=\"c.frozenElo\" color=\"warning\" size=\"small\" title=\"repeatedly failed, ELO barely updated\">\n mdi-snowflake\n </v-icon>\n <span class=\"text-body-2 font-weight-medium\">{{ c.card?.questionType ?? 'card' }}</span>\n <code class=\"text-caption text-medium-emphasis\">{{ shortId(c.cardId, 24) }}</code>\n <span v-if=\"headline(c)\" class=\"text-caption\">{{ headline(c) }}</span>\n <v-spacer />\n <v-chip v-if=\"c.fails > 0\" size=\"x-small\" color=\"error\" variant=\"tonal\">\n {{ c.fails }} fail{{ c.fails === 1 ? '' : 's' }}\n </v-chip>\n <v-chip size=\"x-small\" variant=\"tonal\">{{ c.attempts }} view{{ c.attempts === 1 ? '' : 's' }}</v-chip>\n <v-chip size=\"x-small\" variant=\"tonal\" color=\"grey\">{{ fmtDuration(c.totalTimeMs / 1000) }}</v-chip>\n <v-chip\n v-if=\"c.pendingReview\"\n size=\"x-small\"\n :color=\"c.pendingReview.overdue ? 'error' : 'success'\"\n variant=\"flat\"\n >\n review {{ c.pendingReview.overdue ? 'overdue' : 'scheduled' }}\n </v-chip>\n <v-chip size=\"x-small\" variant=\"tonal\" :color=\"c.frozenElo ? 'warning' : undefined\">\n ELO {{ c.cardElo !== null ? Math.round(c.cardElo) : '?' }}\n <span v-if=\"c.cardEloCount !== null\" class=\"ml-1 text-disabled\">/{{ c.cardEloCount }}</span>\n </v-chip>\n </div>\n </v-expansion-panel-title>\n\n <v-expansion-panel-text>\n <card-dossier-detail :dossier=\"c\" :interpreters=\"interpreters\" />\n </v-expansion-panel-text>\n </v-expansion-panel>\n </v-expansion-panels>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue';\nimport {\n cardDossiers,\n fmtDuration,\n type CardDossier,\n type DiagnosticsInterpreters,\n type LearnerDataset,\n} from '@vue-skuilder/db/diagnostics';\nimport { shortId } from '../display';\nimport CardDossierDetail from './CardDossierDetail.vue';\n\n/**\n * Every card the learner has history with. `focus` opens, scrolls to, and\n * briefly highlights one card (e.g. followed from a session timeline).\n */\nconst props = defineProps<{\n dataset: LearnerDataset;\n interpreters?: DiagnosticsInterpreters;\n focus?: string;\n}>();\n\nconst cards = computed(() => cardDossiers(props.dataset));\nconst filter = ref<'all' | 'stuck' | 'failed' | 'frozen' | undefined>('all');\nconst search = ref<string | null>('');\nconst open = ref<string[]>([]);\nconst flash = ref<string | null>(null);\n\nconst counts = computed(() => ({\n stuck: cards.value.filter((c) => c.stuck).length,\n failed: cards.value.filter((c) => c.fails > 0).length,\n frozen: cards.value.filter((c) => c.frozenElo).length,\n}));\n\nconst tiles = computed(() => [\n { label: 'cards seen', value: cards.value.length, color: 'primary' },\n { label: 'total attempts', value: cards.value.reduce((n, c) => n + c.attempts, 0), color: 'primary' },\n { label: 'total fails', value: cards.value.reduce((n, c) => n + c.fails, 0), color: 'error' },\n {\n label: 'time on cards',\n value: fmtDuration(cards.value.reduce((n, c) => n + c.totalTimeMs, 0) / 1000),\n color: 'grey',\n },\n { label: 'stuck reviews', value: counts.value.stuck, color: 'error' },\n { label: 'frozen ELO', value: counts.value.frozen, color: 'warning' },\n]);\n\nconst visible = computed(() => {\n let list = cards.value;\n if (filter.value === 'stuck') list = list.filter((c) => c.stuck);\n else if (filter.value === 'failed') list = list.filter((c) => c.fails > 0);\n else if (filter.value === 'frozen') list = list.filter((c) => c.frozenElo);\n const q = search.value?.trim().toLowerCase();\n if (q) {\n list = list.filter(\n (c) =>\n c.cardId.toLowerCase().includes(q) ||\n (c.card?.questionType ?? '').toLowerCase().includes(q) ||\n (c.card?.tags ?? []).some((t) => t.toLowerCase().includes(q)) ||\n Object.values(c.card?.data ?? {}).some((v) => typeof v === 'string' && v.toLowerCase().includes(q))\n );\n }\n return list;\n});\n\n/** The first short string field of the card's content (usually the word or prompt). */\nfunction headline(c: CardDossier): string {\n const v = Object.values(c.card?.data ?? {}).find((x) => typeof x === 'string' && x.length <= 40);\n return typeof v === 'string' ? v : '';\n}\n\nasync function applyFocus(id: string | undefined): Promise<void> {\n if (!id || !cards.value.some((c) => c.cardId === id)) return;\n filter.value = 'all';\n search.value = id;\n open.value = [id];\n await nextTick();\n document.querySelector(`[data-card=\"${CSS.escape(id)}\"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });\n flash.value = id;\n setTimeout(() => {\n if (flash.value === id) flash.value = null;\n }, 2200);\n}\n\nwatch(\n () => props.focus,\n (id) => void applyFocus(id),\n { immediate: true }\n);\n</script>\n\n<style scoped>\n.sk-card-flash {\n animation: sk-card-flash 2.2s ease-out;\n}\n@keyframes sk-card-flash {\n 0%,\n 30% {\n background-color: rgba(var(--v-theme-primary), 0.18);\n }\n 100% {\n background-color: transparent;\n }\n}\n</style>\n","<template>\n <div>\n <p class=\"text-caption text-medium-emphasis mb-4\">\n Every card the learner has met, most painful first (time sunk × failures), with its pending review and current\n card ELO. <v-icon size=\"x-small\" color=\"error\">mdi-sync-alert</v-icon> is a failed card holding an overdue review;\n <v-icon size=\"x-small\" color=\"warning\">mdi-snowflake</v-icon> is a repeatedly failed card whose ELO has fewer\n updates than failures.\n </p>\n\n <v-row dense class=\"mb-2\">\n <v-col v-for=\"tile in tiles\" :key=\"tile.label\" cols=\"6\" sm=\"4\" md=\"2\">\n <v-card variant=\"tonal\" :color=\"tile.color\" class=\"pa-2 text-center\">\n <div class=\"text-h6\">{{ tile.value }}</div>\n <div class=\"text-caption\">{{ tile.label }}</div>\n </v-card>\n </v-col>\n </v-row>\n\n <div class=\"d-flex align-center flex-wrap mb-3 ga-3\">\n <v-btn-toggle v-model=\"filter\" density=\"compact\" color=\"primary\" variant=\"outlined\">\n <v-btn value=\"all\" size=\"small\">All ({{ cards.length }})</v-btn>\n <v-btn value=\"stuck\" size=\"small\">\n <v-icon start size=\"small\" color=\"error\">mdi-sync-alert</v-icon>\n Stuck ({{ counts.stuck }})\n </v-btn>\n <v-btn value=\"failed\" size=\"small\">Ever failed ({{ counts.failed }})</v-btn>\n <v-btn value=\"frozen\" size=\"small\">\n <v-icon start size=\"small\" color=\"warning\">mdi-snowflake</v-icon>\n Frozen ELO ({{ counts.frozen }})\n </v-btn>\n </v-btn-toggle>\n <v-text-field\n v-model=\"search\"\n density=\"compact\"\n variant=\"outlined\"\n hide-details\n clearable\n placeholder=\"filter by card / tag / type / content\"\n prepend-inner-icon=\"mdi-magnify\"\n style=\"max-width: 320px\"\n />\n </div>\n\n <v-alert v-if=\"cards.length === 0\" type=\"info\" variant=\"tonal\">No card history for this learner.</v-alert>\n\n <v-expansion-panels v-else v-model=\"open\" multiple variant=\"accordion\">\n <v-expansion-panel\n v-for=\"c in visible\"\n :key=\"c.cardId\"\n :value=\"c.cardId\"\n :data-card=\"c.cardId\"\n :class=\"c.cardId === flash ? 'sk-card-flash' : ''\"\n >\n <v-expansion-panel-title>\n <div class=\"d-flex align-center flex-wrap ga-2\" style=\"width: 100%\">\n <v-icon v-if=\"c.stuck\" color=\"error\" size=\"small\" title=\"failed + overdue review\">mdi-sync-alert</v-icon>\n <v-icon v-if=\"c.frozenElo\" color=\"warning\" size=\"small\" title=\"repeatedly failed, ELO barely updated\">\n mdi-snowflake\n </v-icon>\n <span class=\"text-body-2 font-weight-medium\">{{ c.card?.questionType ?? 'card' }}</span>\n <code class=\"text-caption text-medium-emphasis\">{{ shortId(c.cardId, 24) }}</code>\n <span v-if=\"headline(c)\" class=\"text-caption\">{{ headline(c) }}</span>\n <v-spacer />\n <v-chip v-if=\"c.fails > 0\" size=\"x-small\" color=\"error\" variant=\"tonal\">\n {{ c.fails }} fail{{ c.fails === 1 ? '' : 's' }}\n </v-chip>\n <v-chip size=\"x-small\" variant=\"tonal\">{{ c.attempts }} view{{ c.attempts === 1 ? '' : 's' }}</v-chip>\n <v-chip size=\"x-small\" variant=\"tonal\" color=\"grey\">{{ fmtDuration(c.totalTimeMs / 1000) }}</v-chip>\n <v-chip\n v-if=\"c.pendingReview\"\n size=\"x-small\"\n :color=\"c.pendingReview.overdue ? 'error' : 'success'\"\n variant=\"flat\"\n >\n review {{ c.pendingReview.overdue ? 'overdue' : 'scheduled' }}\n </v-chip>\n <v-chip size=\"x-small\" variant=\"tonal\" :color=\"c.frozenElo ? 'warning' : undefined\">\n ELO {{ c.cardElo !== null ? Math.round(c.cardElo) : '?' }}\n <span v-if=\"c.cardEloCount !== null\" class=\"ml-1 text-disabled\">/{{ c.cardEloCount }}</span>\n </v-chip>\n </div>\n </v-expansion-panel-title>\n\n <v-expansion-panel-text>\n <card-dossier-detail :dossier=\"c\" :interpreters=\"interpreters\" />\n </v-expansion-panel-text>\n </v-expansion-panel>\n </v-expansion-panels>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue';\nimport {\n cardDossiers,\n fmtDuration,\n type CardDossier,\n type DiagnosticsInterpreters,\n type LearnerDataset,\n} from '@vue-skuilder/db/diagnostics';\nimport { shortId } from '../display';\nimport CardDossierDetail from './CardDossierDetail.vue';\n\n/**\n * Every card the learner has history with. `focus` opens, scrolls to, and\n * briefly highlights one card (e.g. followed from a session timeline).\n */\nconst props = defineProps<{\n dataset: LearnerDataset;\n interpreters?: DiagnosticsInterpreters;\n focus?: string;\n}>();\n\nconst cards = computed(() => cardDossiers(props.dataset));\nconst filter = ref<'all' | 'stuck' | 'failed' | 'frozen' | undefined>('all');\nconst search = ref<string | null>('');\nconst open = ref<string[]>([]);\nconst flash = ref<string | null>(null);\n\nconst counts = computed(() => ({\n stuck: cards.value.filter((c) => c.stuck).length,\n failed: cards.value.filter((c) => c.fails > 0).length,\n frozen: cards.value.filter((c) => c.frozenElo).length,\n}));\n\nconst tiles = computed(() => [\n { label: 'cards seen', value: cards.value.length, color: 'primary' },\n { label: 'total attempts', value: cards.value.reduce((n, c) => n + c.attempts, 0), color: 'primary' },\n { label: 'total fails', value: cards.value.reduce((n, c) => n + c.fails, 0), color: 'error' },\n {\n label: 'time on cards',\n value: fmtDuration(cards.value.reduce((n, c) => n + c.totalTimeMs, 0) / 1000),\n color: 'grey',\n },\n { label: 'stuck reviews', value: counts.value.stuck, color: 'error' },\n { label: 'frozen ELO', value: counts.value.frozen, color: 'warning' },\n]);\n\nconst visible = computed(() => {\n let list = cards.value;\n if (filter.value === 'stuck') list = list.filter((c) => c.stuck);\n else if (filter.value === 'failed') list = list.filter((c) => c.fails > 0);\n else if (filter.value === 'frozen') list = list.filter((c) => c.frozenElo);\n const q = search.value?.trim().toLowerCase();\n if (q) {\n list = list.filter(\n (c) =>\n c.cardId.toLowerCase().includes(q) ||\n (c.card?.questionType ?? '').toLowerCase().includes(q) ||\n (c.card?.tags ?? []).some((t) => t.toLowerCase().includes(q)) ||\n Object.values(c.card?.data ?? {}).some((v) => typeof v === 'string' && v.toLowerCase().includes(q))\n );\n }\n return list;\n});\n\n/** The first short string field of the card's content (usually the word or prompt). */\nfunction headline(c: CardDossier): string {\n const v = Object.values(c.card?.data ?? {}).find((x) => typeof x === 'string' && x.length <= 40);\n return typeof v === 'string' ? v : '';\n}\n\nasync function applyFocus(id: string | undefined): Promise<void> {\n if (!id || !cards.value.some((c) => c.cardId === id)) return;\n filter.value = 'all';\n search.value = id;\n open.value = [id];\n await nextTick();\n document.querySelector(`[data-card=\"${CSS.escape(id)}\"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });\n flash.value = id;\n setTimeout(() => {\n if (flash.value === id) flash.value = null;\n }, 2200);\n}\n\nwatch(\n () => props.focus,\n (id) => void applyFocus(id),\n { immediate: true }\n);\n</script>\n\n<style scoped>\n.sk-card-flash {\n animation: sk-card-flash 2.2s ease-out;\n}\n@keyframes sk-card-flash {\n 0%,\n 30% {\n background-color: rgba(var(--v-theme-primary), 0.18);\n }\n 100% {\n background-color: transparent;\n }\n}\n</style>\n","<template>\n <div class=\"sk-run-detail\">\n <div v-if=\"run.generators?.length\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">generators</div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th></th>\n <th class=\"text-right\">cards</th>\n <th class=\"text-right\">new</th>\n <th class=\"text-right\">review</th>\n <th class=\"text-right\">top</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"g in run.generators\" :key=\"g.name\">\n <td>{{ g.name }}</td>\n <td class=\"text-right\">{{ g.cardCount }}</td>\n <td class=\"text-right\">{{ g.newCount }}</td>\n <td class=\"text-right\">{{ g.reviewCount }}</td>\n <td class=\"text-right\">{{ g.topScore.toFixed(2) }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n\n <div v-if=\"run.filters?.length\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">filters</div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th></th>\n <th class=\"text-right\">boosted</th>\n <th class=\"text-right\">penalized</th>\n <th class=\"text-right\">passed</th>\n <th class=\"text-right\">removed</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"f in run.filters\" :key=\"f.name\">\n <td>{{ f.name }}</td>\n <td class=\"text-right\">{{ f.boosted }}</td>\n <td class=\"text-right\">{{ f.penalized }}</td>\n <td class=\"text-right\">{{ f.passed }}</td>\n <td class=\"text-right\" :class=\"f.removed > 0 ? 'text-error' : ''\">{{ f.removed }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n\n <div v-if=\"run.cards?.length\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">selection</div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th></th>\n <th>card</th>\n <th>origin</th>\n <th>generator</th>\n <th class=\"text-right\">score</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"c in run.cards\" :key=\"c.cardId\" :class=\"c.selected ? '' : 'text-medium-emphasis'\">\n <td>\n <v-icon v-if=\"c.selected\" size=\"x-small\" color=\"success\">mdi-check</v-icon>\n <span v-else title=\"runner-up\">·</span>\n </td>\n <td>\n <a class=\"sk-card-link\" @click=\"emit('open-card', c.cardId)\">{{ c.cardId }}</a>\n </td>\n <td>{{ c.origin }}</td>\n <td>{{ c.generator ?? '—' }}</td>\n <td class=\"text-right\">{{ fmtScore(c.score) }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n\n <div v-for=\"t in trails\" :key=\"t.label\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">\n {{ t.label }}:\n <a class=\"sk-card-link\" @click=\"emit('open-card', t.card.cardId)\">{{ t.card.cardId }}</a>\n <span class=\"text-medium-emphasis\">\n ({{ t.card.origin }}, {{ t.card.generator ?? '?' }}) final {{ fmtScore(t.card.score) }}\n </span>\n </div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <tbody>\n <tr v-for=\"(p, i) in t.card.trail\" :key=\"i\">\n <td class=\"text-no-wrap\">{{ p.action }}</td>\n <td class=\"text-no-wrap\">{{ p.strategyName }}</td>\n <td class=\"text-right text-no-wrap\">{{ fmtScore(p.score) }}</td>\n <td class=\"text-caption\">{{ p.reason }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n\n <div v-if=\"run.hints\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">hints</div>\n <pre class=\"sk-hint-block\">{{ JSON.stringify(run.hints, null, 2) }}</pre>\n </div>\n\n <div v-if=\"run.discardedTail\" class=\"text-caption text-medium-emphasis\">\n discarded tail: {{ run.discardedTail.count }} cards\n <template v-if=\"run.discardedTail.scoreRange\">\n · score [{{ run.discardedTail.scoreRange[0].toFixed(2) }}, {{ run.discardedTail.scoreRange[1].toFixed(2) }}]\n </template>\n <template v-if=\"run.discardedTail.eloRange\">\n · ELO [{{ Math.round(run.discardedTail.eloRange[0]) }}, {{ Math.round(run.discardedTail.eloRange[1]) }}]\n </template>\n <div v-if=\"run.discardedTail.note\">{{ run.discardedTail.note }}</div>\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport type { StudySessionRunCardTrail, StudySessionRunSummary } from '@vue-skuilder/db/diagnostics';\n\n/** The persisted detail of one pipeline run. */\nconst props = defineProps<{ run: StudySessionRunSummary }>();\nconst emit = defineEmits<{ 'open-card': [cardId: string] }>();\n\nconst trails = computed(() => {\n const u = props.run.unselectedNew;\n const out: Array<{ label: string; card: StudySessionRunCardTrail }> = [];\n if (u?.nextInLine) out.push({ label: 'next new card in line', card: u.nextInLine });\n if (u?.topGenerated) out.push({ label: 'top-generated new card (sunk)', card: u.topGenerated });\n return out;\n});\n\nfunction fmtScore(n: number): string {\n return Math.abs(n) >= 0.01 || n === 0 ? n.toFixed(2) : n.toExponential(1);\n}\n</script>\n\n<style scoped>\n.sk-run-detail {\n border-left: 2px solid rgba(var(--v-theme-primary), 0.25);\n padding-left: 10px;\n margin-left: 2px;\n}\n.sk-mini-table :deep(td),\n.sk-mini-table :deep(th) {\n font-size: 0.72rem;\n}\n.sk-hint-block {\n font-size: 0.72rem;\n background: rgba(var(--v-theme-on-surface), 0.04);\n border-radius: 4px;\n padding: 6px 8px;\n margin: 0;\n max-height: 140px;\n overflow: auto;\n white-space: pre-wrap;\n}\n.sk-card-link {\n cursor: pointer;\n border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);\n}\n.sk-card-link:hover {\n color: rgb(var(--v-theme-primary));\n}\n</style>\n","<template>\n <div class=\"sk-run-detail\">\n <div v-if=\"run.generators?.length\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">generators</div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th></th>\n <th class=\"text-right\">cards</th>\n <th class=\"text-right\">new</th>\n <th class=\"text-right\">review</th>\n <th class=\"text-right\">top</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"g in run.generators\" :key=\"g.name\">\n <td>{{ g.name }}</td>\n <td class=\"text-right\">{{ g.cardCount }}</td>\n <td class=\"text-right\">{{ g.newCount }}</td>\n <td class=\"text-right\">{{ g.reviewCount }}</td>\n <td class=\"text-right\">{{ g.topScore.toFixed(2) }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n\n <div v-if=\"run.filters?.length\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">filters</div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th></th>\n <th class=\"text-right\">boosted</th>\n <th class=\"text-right\">penalized</th>\n <th class=\"text-right\">passed</th>\n <th class=\"text-right\">removed</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"f in run.filters\" :key=\"f.name\">\n <td>{{ f.name }}</td>\n <td class=\"text-right\">{{ f.boosted }}</td>\n <td class=\"text-right\">{{ f.penalized }}</td>\n <td class=\"text-right\">{{ f.passed }}</td>\n <td class=\"text-right\" :class=\"f.removed > 0 ? 'text-error' : ''\">{{ f.removed }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n\n <div v-if=\"run.cards?.length\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">selection</div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th></th>\n <th>card</th>\n <th>origin</th>\n <th>generator</th>\n <th class=\"text-right\">score</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"c in run.cards\" :key=\"c.cardId\" :class=\"c.selected ? '' : 'text-medium-emphasis'\">\n <td>\n <v-icon v-if=\"c.selected\" size=\"x-small\" color=\"success\">mdi-check</v-icon>\n <span v-else title=\"runner-up\">·</span>\n </td>\n <td>\n <a class=\"sk-card-link\" @click=\"emit('open-card', c.cardId)\">{{ c.cardId }}</a>\n </td>\n <td>{{ c.origin }}</td>\n <td>{{ c.generator ?? '—' }}</td>\n <td class=\"text-right\">{{ fmtScore(c.score) }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n\n <div v-for=\"t in trails\" :key=\"t.label\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">\n {{ t.label }}:\n <a class=\"sk-card-link\" @click=\"emit('open-card', t.card.cardId)\">{{ t.card.cardId }}</a>\n <span class=\"text-medium-emphasis\">\n ({{ t.card.origin }}, {{ t.card.generator ?? '?' }}) final {{ fmtScore(t.card.score) }}\n </span>\n </div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <tbody>\n <tr v-for=\"(p, i) in t.card.trail\" :key=\"i\">\n <td class=\"text-no-wrap\">{{ p.action }}</td>\n <td class=\"text-no-wrap\">{{ p.strategyName }}</td>\n <td class=\"text-right text-no-wrap\">{{ fmtScore(p.score) }}</td>\n <td class=\"text-caption\">{{ p.reason }}</td>\n </tr>\n </tbody>\n </v-table>\n </div>\n\n <div v-if=\"run.hints\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">hints</div>\n <pre class=\"sk-hint-block\">{{ JSON.stringify(run.hints, null, 2) }}</pre>\n </div>\n\n <div v-if=\"run.discardedTail\" class=\"text-caption text-medium-emphasis\">\n discarded tail: {{ run.discardedTail.count }} cards\n <template v-if=\"run.discardedTail.scoreRange\">\n · score [{{ run.discardedTail.scoreRange[0].toFixed(2) }}, {{ run.discardedTail.scoreRange[1].toFixed(2) }}]\n </template>\n <template v-if=\"run.discardedTail.eloRange\">\n · ELO [{{ Math.round(run.discardedTail.eloRange[0]) }}, {{ Math.round(run.discardedTail.eloRange[1]) }}]\n </template>\n <div v-if=\"run.discardedTail.note\">{{ run.discardedTail.note }}</div>\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport type { StudySessionRunCardTrail, StudySessionRunSummary } from '@vue-skuilder/db/diagnostics';\n\n/** The persisted detail of one pipeline run. */\nconst props = defineProps<{ run: StudySessionRunSummary }>();\nconst emit = defineEmits<{ 'open-card': [cardId: string] }>();\n\nconst trails = computed(() => {\n const u = props.run.unselectedNew;\n const out: Array<{ label: string; card: StudySessionRunCardTrail }> = [];\n if (u?.nextInLine) out.push({ label: 'next new card in line', card: u.nextInLine });\n if (u?.topGenerated) out.push({ label: 'top-generated new card (sunk)', card: u.topGenerated });\n return out;\n});\n\nfunction fmtScore(n: number): string {\n return Math.abs(n) >= 0.01 || n === 0 ? n.toFixed(2) : n.toExponential(1);\n}\n</script>\n\n<style scoped>\n.sk-run-detail {\n border-left: 2px solid rgba(var(--v-theme-primary), 0.25);\n padding-left: 10px;\n margin-left: 2px;\n}\n.sk-mini-table :deep(td),\n.sk-mini-table :deep(th) {\n font-size: 0.72rem;\n}\n.sk-hint-block {\n font-size: 0.72rem;\n background: rgba(var(--v-theme-on-surface), 0.04);\n border-radius: 4px;\n padding: 6px 8px;\n margin: 0;\n max-height: 140px;\n overflow: auto;\n white-space: pre-wrap;\n}\n.sk-card-link {\n cursor: pointer;\n border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);\n}\n.sk-card-link:hover {\n color: rgb(var(--v-theme-primary));\n}\n</style>\n","<template>\n <div>\n <v-alert v-if=\"!detail\" type=\"info\" variant=\"tonal\">No session with id {{ sessionId }}.</v-alert>\n\n <template v-else>\n <div class=\"d-flex align-center flex-wrap mb-4 ga-2\">\n <v-chip size=\"small\" variant=\"flat\" :color=\"statusColor(doc.status)\">{{ doc.status }}</v-chip>\n <v-chip size=\"small\" variant=\"tonal\">{{ fmtTime(doc.startTime) }}</v-chip>\n <v-chip size=\"small\" variant=\"tonal\" color=\"grey\">{{ clockText }}</v-chip>\n <v-chip\n v-for=\"f in detail.summary.flags\"\n :key=\"f\"\n size=\"small\"\n variant=\"tonal\"\n :color=\"FLAG_UI[f].color\"\n :title=\"SESSION_FLAGS[f].help\"\n >\n <v-icon start size=\"small\">{{ FLAG_UI[f].icon }}</v-icon>\n {{ SESSION_FLAGS[f].label }}\n </v-chip>\n <v-spacer />\n <code class=\"text-caption text-medium-emphasis\">{{ sessionId }}</code>\n <v-btn\n variant=\"text\"\n size=\"small\"\n :prepend-icon=\"copied === 'session' ? 'mdi-check' : 'mdi-clipboard-text-outline'\"\n :color=\"copied === 'session' ? 'success' : undefined\"\n @click=\"copy(asText(), 'session')\"\n >\n {{ copied === 'session' ? 'Copied' : 'Copy as text' }}\n </v-btn>\n </div>\n\n <v-alert v-if=\"detail.responsesMissing\" type=\"warning\" variant=\"tonal\" density=\"compact\" class=\"mb-4\">\n The session records {{ doc.tally?.responses }} responses but only {{ responseCount }} card records carry its id.\n The timeline is incomplete.\n </v-alert>\n\n <v-row class=\"mb-2\">\n <v-col cols=\"12\" md=\"6\">\n <v-card variant=\"outlined\" class=\"pa-3 h-100\">\n <div class=\"text-subtitle-2 mb-1\">Learner state</div>\n <div class=\"text-caption text-medium-emphasis mb-3\">\n What the navigator was choosing against, and what moved.\n </div>\n <v-table v-if=\"detail.summary.state.length\" density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th></th>\n <th class=\"text-right\">Start</th>\n <th class=\"text-right\">End</th>\n <th class=\"text-right\">Δ</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"row in detail.summary.state\" :key=\"row.label\">\n <td>{{ row.label }}</td>\n <td class=\"text-right\">{{ fmtNum(row.from) }}</td>\n <td class=\"text-right\">{{ fmtNum(row.to) }}</td>\n <td class=\"text-right\" :class=\"deltaClass(row.delta)\">{{ fmtDelta(row.delta) }}</td>\n </tr>\n </tbody>\n </v-table>\n <div v-else class=\"text-caption text-disabled\">No state snapshot recorded.</div>\n </v-card>\n </v-col>\n\n <v-col cols=\"12\" md=\"6\">\n <v-card variant=\"outlined\" class=\"pa-3 h-100\">\n <div class=\"text-subtitle-2 mb-1\">Session hints</div>\n <div class=\"text-caption text-medium-emphasis mb-3\">\n What outcome observers told the navigator. They act only through these.\n </div>\n <span class=\"text-caption font-weight-medium\">at open</span>\n <pre class=\"sk-hint-block\">{{ fmtHints(doc.config?.initHints) }}</pre>\n <span class=\"text-caption font-weight-medium\">at close</span>\n <pre class=\"sk-hint-block\">{{ fmtHints(doc.finalHints) }}</pre>\n </v-card>\n </v-col>\n </v-row>\n\n <div class=\"text-caption text-medium-emphasis mb-4\">\n batch limit {{ doc.config?.defaultBatchLimit ?? '?' }} · {{ doc.config?.sourceCount ?? '?' }} source(s) ·\n opening queues supply {{ doc.initialQueues?.supplyQ ?? '?' }} / failed {{ doc.initialQueues?.failedQ ?? '?' }}\n <template v-if=\"doc.tally\"> · ended with {{ doc.tally.failedQRemaining }} still in the failed queue</template>\n </div>\n\n <v-card v-if=\"tags.length\" variant=\"outlined\" class=\"pa-3 mb-4\">\n <div class=\"text-subtitle-2 mb-1\">Served tags</div>\n <div class=\"text-caption text-medium-emphasis mb-2\">\n What this sitting put in front of the learner, by content tag. A response with several tags counts toward\n each.\n </div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th>Tag</th>\n <th class=\"text-right\">Resp.</th>\n <th class=\"text-right\">Cards</th>\n <th class=\"text-right\">Accuracy</th>\n <th class=\"text-right\">Time</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"t in tags\" :key=\"t.tag\">\n <td>\n <code :class=\"isSkillTag(interpreters, t.tag) ? 'text-primary' : ''\">{{ t.tag }}</code>\n </td>\n <td class=\"text-right\">{{ t.responses }}</td>\n <td class=\"text-right\">{{ t.cards }}</td>\n <td class=\"text-right\" :class=\"accuracyClass(t.accuracy)\">\n <template v-if=\"t.accuracy === null\">—</template>\n <template v-else>\n {{ Math.round(t.accuracy * 100) }}%\n <span class=\"text-disabled\">({{ t.correct }}/{{ t.correct + t.incorrect }})</span>\n </template>\n </td>\n <td class=\"text-right\">{{ (t.totalTimeMs / 1000).toFixed(1) }}s</td>\n </tr>\n </tbody>\n </v-table>\n </v-card>\n\n <v-card v-if=\"ledger\" variant=\"outlined\" class=\"pa-3 mb-4\">\n <div class=\"text-subtitle-2 mb-1\">ELO ledger</div>\n <div class=\"text-caption text-medium-emphasis mb-3\">\n The per-response learner global-ELO exchanges that net to the session's ELO move. Wins above the learner's\n level pay little; losses below it cost a lot, so a high hit rate can still net negative.\n </div>\n <div class=\"d-flex flex-wrap ga-5\">\n <div>\n <div class=\"text-caption text-medium-emphasis\">net</div>\n <div class=\"text-h6\" :class=\"deltaClass(ledger.net)\">{{ fmtDelta(ledger.net) }}</div>\n </div>\n <div>\n <div class=\"text-caption text-medium-emphasis\">gained · {{ ledger.wins }} win(s)</div>\n <div class=\"text-h6 text-success\">{{ fmtDelta(ledger.gained) }}</div>\n </div>\n <div>\n <div class=\"text-caption text-medium-emphasis\">lost · {{ ledger.losses }} loss(es)</div>\n <div class=\"text-h6 text-error\">{{ fmtDelta(ledger.lost) }}</div>\n </div>\n <div>\n <div class=\"text-caption text-medium-emphasis\">exchanges</div>\n <div class=\"text-h6\">{{ ledger.count }}</div>\n <div class=\"text-caption text-disabled\">of {{ responseCount }} response(s)</div>\n </div>\n </div>\n </v-card>\n\n <div class=\"d-flex align-center flex-wrap mb-2 ga-3\">\n <h2 class=\"text-h6\">Timeline</h2>\n <v-btn-toggle v-model=\"show\" density=\"compact\" color=\"primary\" variant=\"outlined\">\n <v-btn value=\"all\" size=\"small\">All ({{ detail.timeline.length }})</v-btn>\n <v-btn value=\"response\" size=\"small\">Responses ({{ responseCount }})</v-btn>\n <v-btn value=\"run\" size=\"small\">Runs ({{ detail.timeline.length - responseCount }})</v-btn>\n </v-btn-toggle>\n </div>\n\n <v-alert v-if=\"detail.timeline.length === 0\" type=\"info\" variant=\"tonal\"\n >Nothing recorded for this session.</v-alert\n >\n\n <div v-else class=\"sk-timeline\">\n <div v-for=\"(e, i) in visibleTimeline\" :key=\"i\" class=\"sk-tl-row\" :class=\"rowClass(e)\">\n <div class=\"sk-tl-time text-caption text-disabled\">{{ fmtClock(e.at) }}</div>\n <div class=\"sk-tl-gutter\">\n <v-icon size=\"small\" :color=\"entryColor(e)\">{{ entryIcon(e) }}</v-icon>\n </div>\n\n <div v-if=\"e.kind === 'run'\" class=\"sk-tl-body\">\n <div class=\"d-flex align-center flex-wrap ga-1\">\n <span class=\"font-weight-medium\">{{ e.run.label }}</span>\n <v-chip v-if=\"e.run.mode\" size=\"x-small\" variant=\"tonal\">{{ e.run.mode }}</v-chip>\n <span class=\"text-caption text-medium-emphasis ml-2\">\n generated {{ e.run.generatedCount }} → selected {{ e.run.finalCount }} ({{ e.run.newSelected }} new,\n {{ e.run.reviewsSelected }} review)\n </span>\n <span v-if=\"e.run.generatedCount > 0 && e.run.finalCount === 0\" class=\"text-caption text-error ml-2\">\n nothing survived\n </span>\n <v-chip v-if=\"typeof e.run.userElo === 'number'\" size=\"x-small\" variant=\"tonal\" color=\"grey\" class=\"ml-2\">\n ELO {{ Math.round(e.run.userElo) }}\n </v-chip>\n <v-spacer />\n <v-btn\n :icon=\"expanded.has(e.run.runId) ? 'mdi-chevron-up' : 'mdi-chevron-down'\"\n size=\"x-small\"\n variant=\"text\"\n @click=\"toggle(e.run.runId)\"\n />\n <v-btn\n :icon=\"copied === e.run.runId ? 'mdi-check' : 'mdi-content-copy'\"\n size=\"x-small\"\n variant=\"text\"\n :color=\"copied === e.run.runId ? 'success' : undefined\"\n title=\"Copy run as text\"\n @click=\"copy(runText(e.run), e.run.runId)\"\n />\n </div>\n <run-detail\n v-if=\"expanded.has(e.run.runId)\"\n :run=\"e.run\"\n class=\"mt-1\"\n @open-card=\"(id: string) => emit('open-card', id)\"\n />\n </div>\n\n <div v-else class=\"sk-tl-body\">\n <a class=\"sk-card-link\" :title=\"`Open ${e.cardId} in card history`\" @click=\"emit('open-card', e.cardId)\">\n {{ e.questionType ?? 'card' }} <code class=\"text-caption\">{{ shortId(e.cardId) }}</code>\n </a>\n <v-chip\n v-for=\"t in skillTags(e.tags)\"\n :key=\"t\"\n size=\"x-small\"\n variant=\"outlined\"\n color=\"primary\"\n class=\"ml-1\"\n >\n {{ shortTag(interpreters, t) }}\n </v-chip>\n <span class=\"text-caption text-medium-emphasis ml-2\">\n {{ (e.timeSpentMs / 1000).toFixed(1) }}s\n <template v-if=\"e.priorAttempts\"> · attempt {{ e.priorAttempts + 1 }}</template>\n </span>\n <span v-if=\"e.userAnswer != null\" class=\"text-caption text-disabled ml-2\"\n >“{{ fmtAnswer(e.userAnswer) }}”</span\n >\n <template v-if=\"e.elo\">\n <v-chip\n size=\"x-small\"\n variant=\"tonal\"\n :color=\"deltaColor(e.elo.global.delta)\"\n class=\"ml-2\"\n :title=\"`learner global ELO ${e.elo.global.before} → ${e.elo.global.after} · userScore ${e.elo.userScore.toFixed(2)}`\"\n >\n ELO {{ fmtDelta(e.elo.global.delta) }}\n </v-chip>\n <v-chip\n v-for=\"t in scoredTagDeltas(e.elo)\"\n :key=\"t.tag\"\n size=\"x-small\"\n variant=\"text\"\n :color=\"deltaColor(t.delta)\"\n class=\"sk-elo-tag\"\n :title=\"`${t.tag}: ${t.before} → ${t.after} (score ${t.score?.toFixed(2)})`\"\n >\n {{ shortTag(interpreters, t.tag) }} {{ fmtDelta(t.delta) }}\n </v-chip>\n </template>\n </div>\n </div>\n </div>\n </template>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport {\n eloLedger,\n fmtAnswer,\n fmtDelta,\n fmtDuration,\n isSkillTag,\n runText,\n scoredTagDeltas,\n servedTags,\n SESSION_FLAGS,\n sessionDetail,\n sessionText,\n shortTag,\n type DiagnosticsInterpreters,\n type LearnerDataset,\n type TimelineEntry,\n} from '@vue-skuilder/db/diagnostics';\nimport {\n accuracyClass,\n deltaClass,\n deltaColor,\n FLAG_UI,\n fmtClock,\n fmtTime,\n shortId,\n statusColor,\n useCopy,\n} from '../display';\nimport RunDetail from './RunDetail.vue';\n\n/** One session: headline, state, hints, served tags, ELO ledger, and the interleaved timeline. */\nconst props = defineProps<{\n dataset: LearnerDataset;\n sessionId: string;\n interpreters?: DiagnosticsInterpreters;\n}>();\n\nconst emit = defineEmits<{ 'open-card': [cardId: string] }>();\n\nconst detail = computed(() => sessionDetail(props.dataset, props.sessionId, props.interpreters));\nconst doc = computed(() => detail.value!.summary.doc);\nconst tags = computed(() => servedTags(detail.value?.timeline ?? [], props.interpreters));\nconst ledger = computed(() => eloLedger(detail.value?.timeline ?? []));\nconst responseCount = computed(() => detail.value?.timeline.filter((e) => e.kind === 'response').length ?? 0);\n\nconst show = ref<'all' | 'response' | 'run' | undefined>('all');\nconst visibleTimeline = computed(() => {\n const t = detail.value?.timeline ?? [];\n return !show.value || show.value === 'all' ? t : t.filter((e) => e.kind === show.value);\n});\n\nconst expanded = ref(new Set<string>());\nfunction toggle(runId: string): void {\n const next = new Set(expanded.value);\n if (next.has(runId)) next.delete(runId);\n else next.add(runId);\n expanded.value = next;\n}\n\nconst { copied, copy } = useCopy();\n\nfunction asText(): string {\n return detail.value\n ? sessionText(\n props.dataset.username,\n detail.value,\n { servedTags: tags.value, eloLedger: ledger.value },\n props.interpreters\n )\n : '';\n}\n\nconst clockText = computed(() => {\n const s = detail.value?.summary;\n if (!s) return '';\n const planned = fmtDuration(s.doc.plannedSeconds);\n return s.durationSeconds === null ? `— of ${planned}` : `${fmtDuration(s.durationSeconds)} of ${planned}`;\n});\n\n/** Skill tags inline, at most three. Without a course interpreter, none are singled out. */\nfunction skillTags(t: string[]): string[] {\n return t.filter((tag) => isSkillTag(props.interpreters, tag)).slice(0, 3);\n}\n\nfunction fmtNum(n: number | null): string {\n if (n === null) return '—';\n return Number.isInteger(n) ? String(n) : n.toFixed(1);\n}\n\nfunction fmtHints(h: unknown): string {\n return h == null ? 'none' : JSON.stringify(h, null, 2);\n}\n\n// Narrow the timeline union in script: vue-tsc handles discriminated unions\n// inside template bindings least reliably.\nfunction entryIcon(e: TimelineEntry): string {\n if (e.kind === 'run') return 'mdi-cog-refresh-outline';\n if (e.isCorrect === true) return 'mdi-check-circle-outline';\n if (e.isCorrect === false) return 'mdi-close-circle-outline';\n return 'mdi-circle-small';\n}\n\nfunction entryColor(e: TimelineEntry): string {\n if (e.kind === 'run') return 'primary';\n if (e.isCorrect === true) return 'success';\n if (e.isCorrect === false) return 'error';\n return 'grey';\n}\n\nfunction rowClass(e: TimelineEntry): string {\n if (e.kind === 'run') return 'sk-tl-run';\n return e.isCorrect === false ? 'sk-tl-fail' : '';\n}\n</script>\n\n<style scoped>\n.sk-mini-table :deep(td),\n.sk-mini-table :deep(th) {\n font-size: 0.78rem;\n}\n.sk-hint-block {\n font-size: 0.72rem;\n background: rgba(var(--v-theme-on-surface), 0.04);\n border-radius: 4px;\n padding: 6px 8px;\n margin: 0 0 8px;\n max-height: 140px;\n overflow: auto;\n white-space: pre-wrap;\n}\n.sk-timeline {\n border-left: 2px solid rgba(var(--v-theme-on-surface), 0.12);\n margin-left: 62px;\n}\n.sk-tl-row {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 3px 8px 3px 0;\n position: relative;\n}\n.sk-tl-time {\n position: absolute;\n left: -62px;\n width: 56px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n.sk-tl-gutter {\n margin-left: -11px;\n background: rgb(var(--v-theme-surface));\n line-height: 1;\n}\n.sk-tl-body {\n flex: 1;\n min-width: 0;\n font-size: 0.82rem;\n}\n/* Runs are the navigator speaking: tint them so responses read as the\n foreground stream and replans as punctuation between them. */\n.sk-tl-run {\n background-color: rgba(var(--v-theme-primary), 0.05);\n}\n.sk-tl-fail {\n background-color: rgba(var(--v-theme-error), 0.06);\n}\n.sk-card-link {\n cursor: pointer;\n color: inherit;\n border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);\n}\n.sk-card-link:hover {\n color: rgb(var(--v-theme-primary));\n}\n.sk-elo-tag {\n font-size: 0.68rem !important;\n padding-inline: 4px !important;\n}\n</style>\n","<template>\n <div>\n <v-alert v-if=\"!detail\" type=\"info\" variant=\"tonal\">No session with id {{ sessionId }}.</v-alert>\n\n <template v-else>\n <div class=\"d-flex align-center flex-wrap mb-4 ga-2\">\n <v-chip size=\"small\" variant=\"flat\" :color=\"statusColor(doc.status)\">{{ doc.status }}</v-chip>\n <v-chip size=\"small\" variant=\"tonal\">{{ fmtTime(doc.startTime) }}</v-chip>\n <v-chip size=\"small\" variant=\"tonal\" color=\"grey\">{{ clockText }}</v-chip>\n <v-chip\n v-for=\"f in detail.summary.flags\"\n :key=\"f\"\n size=\"small\"\n variant=\"tonal\"\n :color=\"FLAG_UI[f].color\"\n :title=\"SESSION_FLAGS[f].help\"\n >\n <v-icon start size=\"small\">{{ FLAG_UI[f].icon }}</v-icon>\n {{ SESSION_FLAGS[f].label }}\n </v-chip>\n <v-spacer />\n <code class=\"text-caption text-medium-emphasis\">{{ sessionId }}</code>\n <v-btn\n variant=\"text\"\n size=\"small\"\n :prepend-icon=\"copied === 'session' ? 'mdi-check' : 'mdi-clipboard-text-outline'\"\n :color=\"copied === 'session' ? 'success' : undefined\"\n @click=\"copy(asText(), 'session')\"\n >\n {{ copied === 'session' ? 'Copied' : 'Copy as text' }}\n </v-btn>\n </div>\n\n <v-alert v-if=\"detail.responsesMissing\" type=\"warning\" variant=\"tonal\" density=\"compact\" class=\"mb-4\">\n The session records {{ doc.tally?.responses }} responses but only {{ responseCount }} card records carry its id.\n The timeline is incomplete.\n </v-alert>\n\n <v-row class=\"mb-2\">\n <v-col cols=\"12\" md=\"6\">\n <v-card variant=\"outlined\" class=\"pa-3 h-100\">\n <div class=\"text-subtitle-2 mb-1\">Learner state</div>\n <div class=\"text-caption text-medium-emphasis mb-3\">\n What the navigator was choosing against, and what moved.\n </div>\n <v-table v-if=\"detail.summary.state.length\" density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th></th>\n <th class=\"text-right\">Start</th>\n <th class=\"text-right\">End</th>\n <th class=\"text-right\">Δ</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"row in detail.summary.state\" :key=\"row.label\">\n <td>{{ row.label }}</td>\n <td class=\"text-right\">{{ fmtNum(row.from) }}</td>\n <td class=\"text-right\">{{ fmtNum(row.to) }}</td>\n <td class=\"text-right\" :class=\"deltaClass(row.delta)\">{{ fmtDelta(row.delta) }}</td>\n </tr>\n </tbody>\n </v-table>\n <div v-else class=\"text-caption text-disabled\">No state snapshot recorded.</div>\n </v-card>\n </v-col>\n\n <v-col cols=\"12\" md=\"6\">\n <v-card variant=\"outlined\" class=\"pa-3 h-100\">\n <div class=\"text-subtitle-2 mb-1\">Session hints</div>\n <div class=\"text-caption text-medium-emphasis mb-3\">\n What outcome observers told the navigator. They act only through these.\n </div>\n <span class=\"text-caption font-weight-medium\">at open</span>\n <pre class=\"sk-hint-block\">{{ fmtHints(doc.config?.initHints) }}</pre>\n <span class=\"text-caption font-weight-medium\">at close</span>\n <pre class=\"sk-hint-block\">{{ fmtHints(doc.finalHints) }}</pre>\n </v-card>\n </v-col>\n </v-row>\n\n <div class=\"text-caption text-medium-emphasis mb-4\">\n batch limit {{ doc.config?.defaultBatchLimit ?? '?' }} · {{ doc.config?.sourceCount ?? '?' }} source(s) ·\n opening queues supply {{ doc.initialQueues?.supplyQ ?? '?' }} / failed {{ doc.initialQueues?.failedQ ?? '?' }}\n <template v-if=\"doc.tally\"> · ended with {{ doc.tally.failedQRemaining }} still in the failed queue</template>\n </div>\n\n <v-card v-if=\"tags.length\" variant=\"outlined\" class=\"pa-3 mb-4\">\n <div class=\"text-subtitle-2 mb-1\">Served tags</div>\n <div class=\"text-caption text-medium-emphasis mb-2\">\n What this sitting put in front of the learner, by content tag. A response with several tags counts toward\n each.\n </div>\n <v-table density=\"compact\" class=\"sk-mini-table\">\n <thead>\n <tr>\n <th>Tag</th>\n <th class=\"text-right\">Resp.</th>\n <th class=\"text-right\">Cards</th>\n <th class=\"text-right\">Accuracy</th>\n <th class=\"text-right\">Time</th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"t in tags\" :key=\"t.tag\">\n <td>\n <code :class=\"isSkillTag(interpreters, t.tag) ? 'text-primary' : ''\">{{ t.tag }}</code>\n </td>\n <td class=\"text-right\">{{ t.responses }}</td>\n <td class=\"text-right\">{{ t.cards }}</td>\n <td class=\"text-right\" :class=\"accuracyClass(t.accuracy)\">\n <template v-if=\"t.accuracy === null\">—</template>\n <template v-else>\n {{ Math.round(t.accuracy * 100) }}%\n <span class=\"text-disabled\">({{ t.correct }}/{{ t.correct + t.incorrect }})</span>\n </template>\n </td>\n <td class=\"text-right\">{{ (t.totalTimeMs / 1000).toFixed(1) }}s</td>\n </tr>\n </tbody>\n </v-table>\n </v-card>\n\n <v-card v-if=\"ledger\" variant=\"outlined\" class=\"pa-3 mb-4\">\n <div class=\"text-subtitle-2 mb-1\">ELO ledger</div>\n <div class=\"text-caption text-medium-emphasis mb-3\">\n The per-response learner global-ELO exchanges that net to the session's ELO move. Wins above the learner's\n level pay little; losses below it cost a lot, so a high hit rate can still net negative.\n </div>\n <div class=\"d-flex flex-wrap ga-5\">\n <div>\n <div class=\"text-caption text-medium-emphasis\">net</div>\n <div class=\"text-h6\" :class=\"deltaClass(ledger.net)\">{{ fmtDelta(ledger.net) }}</div>\n </div>\n <div>\n <div class=\"text-caption text-medium-emphasis\">gained · {{ ledger.wins }} win(s)</div>\n <div class=\"text-h6 text-success\">{{ fmtDelta(ledger.gained) }}</div>\n </div>\n <div>\n <div class=\"text-caption text-medium-emphasis\">lost · {{ ledger.losses }} loss(es)</div>\n <div class=\"text-h6 text-error\">{{ fmtDelta(ledger.lost) }}</div>\n </div>\n <div>\n <div class=\"text-caption text-medium-emphasis\">exchanges</div>\n <div class=\"text-h6\">{{ ledger.count }}</div>\n <div class=\"text-caption text-disabled\">of {{ responseCount }} response(s)</div>\n </div>\n </div>\n </v-card>\n\n <div class=\"d-flex align-center flex-wrap mb-2 ga-3\">\n <h2 class=\"text-h6\">Timeline</h2>\n <v-btn-toggle v-model=\"show\" density=\"compact\" color=\"primary\" variant=\"outlined\">\n <v-btn value=\"all\" size=\"small\">All ({{ detail.timeline.length }})</v-btn>\n <v-btn value=\"response\" size=\"small\">Responses ({{ responseCount }})</v-btn>\n <v-btn value=\"run\" size=\"small\">Runs ({{ detail.timeline.length - responseCount }})</v-btn>\n </v-btn-toggle>\n </div>\n\n <v-alert v-if=\"detail.timeline.length === 0\" type=\"info\" variant=\"tonal\"\n >Nothing recorded for this session.</v-alert\n >\n\n <div v-else class=\"sk-timeline\">\n <div v-for=\"(e, i) in visibleTimeline\" :key=\"i\" class=\"sk-tl-row\" :class=\"rowClass(e)\">\n <div class=\"sk-tl-time text-caption text-disabled\">{{ fmtClock(e.at) }}</div>\n <div class=\"sk-tl-gutter\">\n <v-icon size=\"small\" :color=\"entryColor(e)\">{{ entryIcon(e) }}</v-icon>\n </div>\n\n <div v-if=\"e.kind === 'run'\" class=\"sk-tl-body\">\n <div class=\"d-flex align-center flex-wrap ga-1\">\n <span class=\"font-weight-medium\">{{ e.run.label }}</span>\n <v-chip v-if=\"e.run.mode\" size=\"x-small\" variant=\"tonal\">{{ e.run.mode }}</v-chip>\n <span class=\"text-caption text-medium-emphasis ml-2\">\n generated {{ e.run.generatedCount }} → selected {{ e.run.finalCount }} ({{ e.run.newSelected }} new,\n {{ e.run.reviewsSelected }} review)\n </span>\n <span v-if=\"e.run.generatedCount > 0 && e.run.finalCount === 0\" class=\"text-caption text-error ml-2\">\n nothing survived\n </span>\n <v-chip v-if=\"typeof e.run.userElo === 'number'\" size=\"x-small\" variant=\"tonal\" color=\"grey\" class=\"ml-2\">\n ELO {{ Math.round(e.run.userElo) }}\n </v-chip>\n <v-spacer />\n <v-btn\n :icon=\"expanded.has(e.run.runId) ? 'mdi-chevron-up' : 'mdi-chevron-down'\"\n size=\"x-small\"\n variant=\"text\"\n @click=\"toggle(e.run.runId)\"\n />\n <v-btn\n :icon=\"copied === e.run.runId ? 'mdi-check' : 'mdi-content-copy'\"\n size=\"x-small\"\n variant=\"text\"\n :color=\"copied === e.run.runId ? 'success' : undefined\"\n title=\"Copy run as text\"\n @click=\"copy(runText(e.run), e.run.runId)\"\n />\n </div>\n <run-detail\n v-if=\"expanded.has(e.run.runId)\"\n :run=\"e.run\"\n class=\"mt-1\"\n @open-card=\"(id: string) => emit('open-card', id)\"\n />\n </div>\n\n <div v-else class=\"sk-tl-body\">\n <a class=\"sk-card-link\" :title=\"`Open ${e.cardId} in card history`\" @click=\"emit('open-card', e.cardId)\">\n {{ e.questionType ?? 'card' }} <code class=\"text-caption\">{{ shortId(e.cardId) }}</code>\n </a>\n <v-chip\n v-for=\"t in skillTags(e.tags)\"\n :key=\"t\"\n size=\"x-small\"\n variant=\"outlined\"\n color=\"primary\"\n class=\"ml-1\"\n >\n {{ shortTag(interpreters, t) }}\n </v-chip>\n <span class=\"text-caption text-medium-emphasis ml-2\">\n {{ (e.timeSpentMs / 1000).toFixed(1) }}s\n <template v-if=\"e.priorAttempts\"> · attempt {{ e.priorAttempts + 1 }}</template>\n </span>\n <span v-if=\"e.userAnswer != null\" class=\"text-caption text-disabled ml-2\"\n >“{{ fmtAnswer(e.userAnswer) }}”</span\n >\n <template v-if=\"e.elo\">\n <v-chip\n size=\"x-small\"\n variant=\"tonal\"\n :color=\"deltaColor(e.elo.global.delta)\"\n class=\"ml-2\"\n :title=\"`learner global ELO ${e.elo.global.before} → ${e.elo.global.after} · userScore ${e.elo.userScore.toFixed(2)}`\"\n >\n ELO {{ fmtDelta(e.elo.global.delta) }}\n </v-chip>\n <v-chip\n v-for=\"t in scoredTagDeltas(e.elo)\"\n :key=\"t.tag\"\n size=\"x-small\"\n variant=\"text\"\n :color=\"deltaColor(t.delta)\"\n class=\"sk-elo-tag\"\n :title=\"`${t.tag}: ${t.before} → ${t.after} (score ${t.score?.toFixed(2)})`\"\n >\n {{ shortTag(interpreters, t.tag) }} {{ fmtDelta(t.delta) }}\n </v-chip>\n </template>\n </div>\n </div>\n </div>\n </template>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport {\n eloLedger,\n fmtAnswer,\n fmtDelta,\n fmtDuration,\n isSkillTag,\n runText,\n scoredTagDeltas,\n servedTags,\n SESSION_FLAGS,\n sessionDetail,\n sessionText,\n shortTag,\n type DiagnosticsInterpreters,\n type LearnerDataset,\n type TimelineEntry,\n} from '@vue-skuilder/db/diagnostics';\nimport {\n accuracyClass,\n deltaClass,\n deltaColor,\n FLAG_UI,\n fmtClock,\n fmtTime,\n shortId,\n statusColor,\n useCopy,\n} from '../display';\nimport RunDetail from './RunDetail.vue';\n\n/** One session: headline, state, hints, served tags, ELO ledger, and the interleaved timeline. */\nconst props = defineProps<{\n dataset: LearnerDataset;\n sessionId: string;\n interpreters?: DiagnosticsInterpreters;\n}>();\n\nconst emit = defineEmits<{ 'open-card': [cardId: string] }>();\n\nconst detail = computed(() => sessionDetail(props.dataset, props.sessionId, props.interpreters));\nconst doc = computed(() => detail.value!.summary.doc);\nconst tags = computed(() => servedTags(detail.value?.timeline ?? [], props.interpreters));\nconst ledger = computed(() => eloLedger(detail.value?.timeline ?? []));\nconst responseCount = computed(() => detail.value?.timeline.filter((e) => e.kind === 'response').length ?? 0);\n\nconst show = ref<'all' | 'response' | 'run' | undefined>('all');\nconst visibleTimeline = computed(() => {\n const t = detail.value?.timeline ?? [];\n return !show.value || show.value === 'all' ? t : t.filter((e) => e.kind === show.value);\n});\n\nconst expanded = ref(new Set<string>());\nfunction toggle(runId: string): void {\n const next = new Set(expanded.value);\n if (next.has(runId)) next.delete(runId);\n else next.add(runId);\n expanded.value = next;\n}\n\nconst { copied, copy } = useCopy();\n\nfunction asText(): string {\n return detail.value\n ? sessionText(\n props.dataset.username,\n detail.value,\n { servedTags: tags.value, eloLedger: ledger.value },\n props.interpreters\n )\n : '';\n}\n\nconst clockText = computed(() => {\n const s = detail.value?.summary;\n if (!s) return '';\n const planned = fmtDuration(s.doc.plannedSeconds);\n return s.durationSeconds === null ? `— of ${planned}` : `${fmtDuration(s.durationSeconds)} of ${planned}`;\n});\n\n/** Skill tags inline, at most three. Without a course interpreter, none are singled out. */\nfunction skillTags(t: string[]): string[] {\n return t.filter((tag) => isSkillTag(props.interpreters, tag)).slice(0, 3);\n}\n\nfunction fmtNum(n: number | null): string {\n if (n === null) return '—';\n return Number.isInteger(n) ? String(n) : n.toFixed(1);\n}\n\nfunction fmtHints(h: unknown): string {\n return h == null ? 'none' : JSON.stringify(h, null, 2);\n}\n\n// Narrow the timeline union in script: vue-tsc handles discriminated unions\n// inside template bindings least reliably.\nfunction entryIcon(e: TimelineEntry): string {\n if (e.kind === 'run') return 'mdi-cog-refresh-outline';\n if (e.isCorrect === true) return 'mdi-check-circle-outline';\n if (e.isCorrect === false) return 'mdi-close-circle-outline';\n return 'mdi-circle-small';\n}\n\nfunction entryColor(e: TimelineEntry): string {\n if (e.kind === 'run') return 'primary';\n if (e.isCorrect === true) return 'success';\n if (e.isCorrect === false) return 'error';\n return 'grey';\n}\n\nfunction rowClass(e: TimelineEntry): string {\n if (e.kind === 'run') return 'sk-tl-run';\n return e.isCorrect === false ? 'sk-tl-fail' : '';\n}\n</script>\n\n<style scoped>\n.sk-mini-table :deep(td),\n.sk-mini-table :deep(th) {\n font-size: 0.78rem;\n}\n.sk-hint-block {\n font-size: 0.72rem;\n background: rgba(var(--v-theme-on-surface), 0.04);\n border-radius: 4px;\n padding: 6px 8px;\n margin: 0 0 8px;\n max-height: 140px;\n overflow: auto;\n white-space: pre-wrap;\n}\n.sk-timeline {\n border-left: 2px solid rgba(var(--v-theme-on-surface), 0.12);\n margin-left: 62px;\n}\n.sk-tl-row {\n display: flex;\n align-items: baseline;\n gap: 8px;\n padding: 3px 8px 3px 0;\n position: relative;\n}\n.sk-tl-time {\n position: absolute;\n left: -62px;\n width: 56px;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n.sk-tl-gutter {\n margin-left: -11px;\n background: rgb(var(--v-theme-surface));\n line-height: 1;\n}\n.sk-tl-body {\n flex: 1;\n min-width: 0;\n font-size: 0.82rem;\n}\n/* Runs are the navigator speaking: tint them so responses read as the\n foreground stream and replans as punctuation between them. */\n.sk-tl-run {\n background-color: rgba(var(--v-theme-primary), 0.05);\n}\n.sk-tl-fail {\n background-color: rgba(var(--v-theme-error), 0.06);\n}\n.sk-card-link {\n cursor: pointer;\n color: inherit;\n border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);\n}\n.sk-card-link:hover {\n color: rgb(var(--v-theme-primary));\n}\n.sk-elo-tag {\n font-size: 0.68rem !important;\n padding-inline: 4px !important;\n}\n</style>\n","<template>\n <div>\n <p class=\"text-caption text-medium-emphasis mb-4\">\n One row per sitting. Flags name a specific way the session failed to do its job; open one to read its interleaved\n timeline of responses and pipeline runs. <strong>Δ</strong> is movement across the session: ELO, then any progress\n metrics the course declares.\n </p>\n\n <v-row dense class=\"mb-2\">\n <v-col v-for=\"tile in tiles\" :key=\"tile.label\" cols=\"6\" sm=\"4\" md=\"2\">\n <v-card variant=\"tonal\" :color=\"tile.color\" class=\"pa-2 text-center\">\n <div class=\"text-h6\">{{ tile.value }}</div>\n <div class=\"text-caption\">{{ tile.label }}</div>\n </v-card>\n </v-col>\n </v-row>\n\n <div class=\"d-flex align-center flex-wrap mb-3 ga-3\">\n <v-btn-toggle v-model=\"filter\" density=\"compact\" color=\"primary\" variant=\"outlined\">\n <v-btn value=\"all\" size=\"small\">All ({{ sessions.length }})</v-btn>\n <v-btn v-for=\"f in activeFlags\" :key=\"f.flag\" :value=\"f.flag\" size=\"small\" :title=\"SESSION_FLAGS[f.flag].help\">\n <v-icon start size=\"small\" :color=\"FLAG_UI[f.flag].color\">{{ FLAG_UI[f.flag].icon }}</v-icon>\n {{ SESSION_FLAGS[f.flag].label }} ({{ f.count }})\n </v-btn>\n </v-btn-toggle>\n </div>\n\n <v-alert v-if=\"sessions.length === 0\" type=\"info\" variant=\"tonal\">\n No study sessions recorded. Session records began with a framework release; there is no backfill, so earlier\n activity shows only in card history.\n </v-alert>\n\n <v-table v-else density=\"compact\" class=\"sk-admin-table\">\n <thead>\n <tr>\n <th>Started</th>\n <th>Status</th>\n <th class=\"text-right\">Cards</th>\n <th class=\"text-right\">Acc.</th>\n <th class=\"text-right\">Runs</th>\n <th>Clock</th>\n <th>Δ</th>\n <th>Flags</th>\n <th></th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"s in visible\" :key=\"s.doc.sessionId\" class=\"sk-admin-row\" @click=\"emit('open', s.doc.sessionId)\">\n <td class=\"text-no-wrap\">{{ fmtTime(s.doc.startTime) }}</td>\n <td>\n <v-chip size=\"x-small\" variant=\"flat\" :color=\"statusColor(s.doc.status)\">{{ s.doc.status }}</v-chip>\n </td>\n <td class=\"text-right\">{{ s.doc.tally?.cardsPresented ?? '—' }}</td>\n <td class=\"text-right\">\n <span v-if=\"s.accuracy !== null\" :class=\"accuracyClass(s.accuracy)\">\n {{ Math.round(s.accuracy * 100) }}%\n </span>\n <span v-else class=\"text-disabled\">—</span>\n </td>\n <td class=\"text-right\">{{ s.doc.runs?.length ?? 0 }}</td>\n <td class=\"text-no-wrap text-caption\">{{ clockText(s) }}</td>\n <td class=\"text-no-wrap text-caption\">\n <template v-for=\"(d, i) in deltas(s)\" :key=\"d.label\">\n <span v-if=\"i > 0\"> · </span>\n <span :class=\"deltaClass(d.delta)\">{{ fmtDelta(d.delta) }} {{ d.label }}</span>\n </template>\n <span v-if=\"deltas(s).length === 0\" class=\"text-disabled\">—</span>\n </td>\n <td>\n <v-icon\n v-for=\"f in s.flags\"\n :key=\"f\"\n size=\"small\"\n class=\"mr-1\"\n :color=\"FLAG_UI[f].color\"\n :title=\"`${SESSION_FLAGS[f].label}: ${SESSION_FLAGS[f].help}`\"\n >\n {{ FLAG_UI[f].icon }}\n </v-icon>\n </td>\n <td class=\"text-right\"><v-icon size=\"small\" class=\"text-disabled\">mdi-chevron-right</v-icon></td>\n </tr>\n </tbody>\n </v-table>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport {\n fmtDelta,\n fmtDuration,\n SESSION_FLAGS,\n summarizeSessions,\n type DiagnosticsInterpreters,\n type LearnerDataset,\n type SessionFlag,\n type SessionSummary,\n} from '@vue-skuilder/db/diagnostics';\nimport { accuracyClass, deltaClass, FLAG_UI, fmtTime, statusColor } from '../display';\n\n/** A learner's sessions, newest first, as triage. Emits `open` with a session id. */\nconst props = defineProps<{\n dataset: LearnerDataset;\n interpreters?: DiagnosticsInterpreters;\n}>();\n\nconst emit = defineEmits<{ open: [sessionId: string] }>();\n\nconst sessions = computed(() => summarizeSessions(props.dataset, props.interpreters));\nconst filter = ref<'all' | SessionFlag | undefined>('all');\n\n/** Only offer filters for flags that occur. */\nconst activeFlags = computed(() => {\n const counts = new Map<SessionFlag, number>();\n for (const s of sessions.value) for (const f of s.flags) counts.set(f, (counts.get(f) ?? 0) + 1);\n return [...counts.entries()].map(([flag, count]) => ({ flag, count })).sort((a, b) => b.count - a.count);\n});\n\n// `!filter.value` covers v-btn-toggle's deselect, which clears the model.\nconst visible = computed(() =>\n !filter.value || filter.value === 'all'\n ? sessions.value\n : sessions.value.filter((s) => s.flags.includes(filter.value as SessionFlag))\n);\n\nconst tiles = computed(() => {\n const list = sessions.value;\n const closed = list.filter((s) => s.doc.status === 'closed').length;\n const responses = list.reduce((n, s) => n + (s.doc.tally?.responses ?? 0), 0);\n const correct = list.reduce((n, s) => n + (s.doc.tally?.correct ?? 0), 0);\n const seconds = list.reduce((n, s) => n + (s.durationSeconds ?? 0), 0);\n return [\n { label: 'sessions', value: list.length, color: 'primary' },\n { label: 'completed', value: closed, color: 'success' },\n { label: 'not completed', value: list.length - closed, color: 'error' },\n { label: 'responses', value: responses, color: 'primary' },\n {\n label: 'overall acc.',\n value: responses > 0 ? `${Math.round((correct / responses) * 100)}%` : '—',\n color: 'primary',\n },\n { label: 'time studied', value: fmtDuration(seconds), color: 'grey' },\n ];\n});\n\nfunction deltas(s: SessionSummary): Array<{ label: string; delta: number | null }> {\n const out: Array<{ label: string; delta: number | null }> = [];\n if (s.eloNet !== null) out.push({ label: 'elo', delta: Math.round(s.eloNet) });\n for (const label of props.interpreters?.progressMetrics ?? []) {\n const row = s.state.find((r) => r.label === label);\n if (row) out.push({ label, delta: row.delta });\n }\n return out;\n}\n\n/** \"1m40s of 2m\": how much of the planned dose was spent. */\nfunction clockText(s: SessionSummary): string {\n const planned = fmtDuration(s.doc.plannedSeconds);\n return s.durationSeconds === null ? `— of ${planned}` : `${fmtDuration(s.durationSeconds)} of ${planned}`;\n}\n</script>\n\n<style scoped>\n.sk-admin-table :deep(td),\n.sk-admin-table :deep(th) {\n font-size: 0.78rem;\n}\n.sk-admin-row {\n cursor: pointer;\n}\n.sk-admin-row:hover {\n background-color: rgba(var(--v-theme-primary), 0.05);\n}\n</style>\n","<template>\n <div>\n <p class=\"text-caption text-medium-emphasis mb-4\">\n One row per sitting. Flags name a specific way the session failed to do its job; open one to read its interleaved\n timeline of responses and pipeline runs. <strong>Δ</strong> is movement across the session: ELO, then any progress\n metrics the course declares.\n </p>\n\n <v-row dense class=\"mb-2\">\n <v-col v-for=\"tile in tiles\" :key=\"tile.label\" cols=\"6\" sm=\"4\" md=\"2\">\n <v-card variant=\"tonal\" :color=\"tile.color\" class=\"pa-2 text-center\">\n <div class=\"text-h6\">{{ tile.value }}</div>\n <div class=\"text-caption\">{{ tile.label }}</div>\n </v-card>\n </v-col>\n </v-row>\n\n <div class=\"d-flex align-center flex-wrap mb-3 ga-3\">\n <v-btn-toggle v-model=\"filter\" density=\"compact\" color=\"primary\" variant=\"outlined\">\n <v-btn value=\"all\" size=\"small\">All ({{ sessions.length }})</v-btn>\n <v-btn v-for=\"f in activeFlags\" :key=\"f.flag\" :value=\"f.flag\" size=\"small\" :title=\"SESSION_FLAGS[f.flag].help\">\n <v-icon start size=\"small\" :color=\"FLAG_UI[f.flag].color\">{{ FLAG_UI[f.flag].icon }}</v-icon>\n {{ SESSION_FLAGS[f.flag].label }} ({{ f.count }})\n </v-btn>\n </v-btn-toggle>\n </div>\n\n <v-alert v-if=\"sessions.length === 0\" type=\"info\" variant=\"tonal\">\n No study sessions recorded. Session records began with a framework release; there is no backfill, so earlier\n activity shows only in card history.\n </v-alert>\n\n <v-table v-else density=\"compact\" class=\"sk-admin-table\">\n <thead>\n <tr>\n <th>Started</th>\n <th>Status</th>\n <th class=\"text-right\">Cards</th>\n <th class=\"text-right\">Acc.</th>\n <th class=\"text-right\">Runs</th>\n <th>Clock</th>\n <th>Δ</th>\n <th>Flags</th>\n <th></th>\n </tr>\n </thead>\n <tbody>\n <tr v-for=\"s in visible\" :key=\"s.doc.sessionId\" class=\"sk-admin-row\" @click=\"emit('open', s.doc.sessionId)\">\n <td class=\"text-no-wrap\">{{ fmtTime(s.doc.startTime) }}</td>\n <td>\n <v-chip size=\"x-small\" variant=\"flat\" :color=\"statusColor(s.doc.status)\">{{ s.doc.status }}</v-chip>\n </td>\n <td class=\"text-right\">{{ s.doc.tally?.cardsPresented ?? '—' }}</td>\n <td class=\"text-right\">\n <span v-if=\"s.accuracy !== null\" :class=\"accuracyClass(s.accuracy)\">\n {{ Math.round(s.accuracy * 100) }}%\n </span>\n <span v-else class=\"text-disabled\">—</span>\n </td>\n <td class=\"text-right\">{{ s.doc.runs?.length ?? 0 }}</td>\n <td class=\"text-no-wrap text-caption\">{{ clockText(s) }}</td>\n <td class=\"text-no-wrap text-caption\">\n <template v-for=\"(d, i) in deltas(s)\" :key=\"d.label\">\n <span v-if=\"i > 0\"> · </span>\n <span :class=\"deltaClass(d.delta)\">{{ fmtDelta(d.delta) }} {{ d.label }}</span>\n </template>\n <span v-if=\"deltas(s).length === 0\" class=\"text-disabled\">—</span>\n </td>\n <td>\n <v-icon\n v-for=\"f in s.flags\"\n :key=\"f\"\n size=\"small\"\n class=\"mr-1\"\n :color=\"FLAG_UI[f].color\"\n :title=\"`${SESSION_FLAGS[f].label}: ${SESSION_FLAGS[f].help}`\"\n >\n {{ FLAG_UI[f].icon }}\n </v-icon>\n </td>\n <td class=\"text-right\"><v-icon size=\"small\" class=\"text-disabled\">mdi-chevron-right</v-icon></td>\n </tr>\n </tbody>\n </v-table>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport {\n fmtDelta,\n fmtDuration,\n SESSION_FLAGS,\n summarizeSessions,\n type DiagnosticsInterpreters,\n type LearnerDataset,\n type SessionFlag,\n type SessionSummary,\n} from '@vue-skuilder/db/diagnostics';\nimport { accuracyClass, deltaClass, FLAG_UI, fmtTime, statusColor } from '../display';\n\n/** A learner's sessions, newest first, as triage. Emits `open` with a session id. */\nconst props = defineProps<{\n dataset: LearnerDataset;\n interpreters?: DiagnosticsInterpreters;\n}>();\n\nconst emit = defineEmits<{ open: [sessionId: string] }>();\n\nconst sessions = computed(() => summarizeSessions(props.dataset, props.interpreters));\nconst filter = ref<'all' | SessionFlag | undefined>('all');\n\n/** Only offer filters for flags that occur. */\nconst activeFlags = computed(() => {\n const counts = new Map<SessionFlag, number>();\n for (const s of sessions.value) for (const f of s.flags) counts.set(f, (counts.get(f) ?? 0) + 1);\n return [...counts.entries()].map(([flag, count]) => ({ flag, count })).sort((a, b) => b.count - a.count);\n});\n\n// `!filter.value` covers v-btn-toggle's deselect, which clears the model.\nconst visible = computed(() =>\n !filter.value || filter.value === 'all'\n ? sessions.value\n : sessions.value.filter((s) => s.flags.includes(filter.value as SessionFlag))\n);\n\nconst tiles = computed(() => {\n const list = sessions.value;\n const closed = list.filter((s) => s.doc.status === 'closed').length;\n const responses = list.reduce((n, s) => n + (s.doc.tally?.responses ?? 0), 0);\n const correct = list.reduce((n, s) => n + (s.doc.tally?.correct ?? 0), 0);\n const seconds = list.reduce((n, s) => n + (s.durationSeconds ?? 0), 0);\n return [\n { label: 'sessions', value: list.length, color: 'primary' },\n { label: 'completed', value: closed, color: 'success' },\n { label: 'not completed', value: list.length - closed, color: 'error' },\n { label: 'responses', value: responses, color: 'primary' },\n {\n label: 'overall acc.',\n value: responses > 0 ? `${Math.round((correct / responses) * 100)}%` : '—',\n color: 'primary',\n },\n { label: 'time studied', value: fmtDuration(seconds), color: 'grey' },\n ];\n});\n\nfunction deltas(s: SessionSummary): Array<{ label: string; delta: number | null }> {\n const out: Array<{ label: string; delta: number | null }> = [];\n if (s.eloNet !== null) out.push({ label: 'elo', delta: Math.round(s.eloNet) });\n for (const label of props.interpreters?.progressMetrics ?? []) {\n const row = s.state.find((r) => r.label === label);\n if (row) out.push({ label, delta: row.delta });\n }\n return out;\n}\n\n/** \"1m40s of 2m\": how much of the planned dose was spent. */\nfunction clockText(s: SessionSummary): string {\n const planned = fmtDuration(s.doc.plannedSeconds);\n return s.durationSeconds === null ? `— of ${planned}` : `${fmtDuration(s.durationSeconds)} of ${planned}`;\n}\n</script>\n\n<style scoped>\n.sk-admin-table :deep(td),\n.sk-admin-table :deep(th) {\n font-size: 0.78rem;\n}\n.sk-admin-row {\n cursor: pointer;\n}\n.sk-admin-row:hover {\n background-color: rgba(var(--v-theme-primary), 0.05);\n}\n</style>\n","<template>\n <div>\n <v-tabs v-model=\"tab\" density=\"compact\" class=\"mb-4\">\n <v-tab value=\"sessions\">Sessions ({{ dataset.sessions.length }})</v-tab>\n <v-tab value=\"cards\">Cards ({{ dataset.cardHistories.length }})</v-tab>\n </v-tabs>\n\n <template v-if=\"tab === 'sessions'\">\n <template v-if=\"sessionId\">\n <v-btn variant=\"text\" size=\"small\" prepend-icon=\"mdi-arrow-left\" class=\"mb-2\" @click=\"sessionId = null\">\n All sessions\n </v-btn>\n <learner-session-detail\n :dataset=\"dataset\"\n :session-id=\"sessionId\"\n :interpreters=\"interpreters\"\n @open-card=\"openCard\"\n />\n </template>\n <learner-session-list\n v-else\n :dataset=\"dataset\"\n :interpreters=\"interpreters\"\n @open=\"(id: string) => (sessionId = id)\"\n />\n </template>\n\n <learner-card-history v-else :dataset=\"dataset\" :interpreters=\"interpreters\" :focus=\"focusCard\" />\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, watch } from 'vue';\nimport type { DiagnosticsInterpreters, LearnerDataset } from '@vue-skuilder/db/diagnostics';\nimport LearnerCardHistory from './LearnerCardHistory.vue';\nimport LearnerSessionDetail from './LearnerSessionDetail.vue';\nimport LearnerSessionList from './LearnerSessionList.vue';\n\n/**\n * One learner: sessions (list, then a session's detail) and card history,\n * with session timelines linking into card history. Navigation is internal;\n * hosts that want URLs can compose the three views themselves.\n */\nconst props = defineProps<{\n dataset: LearnerDataset;\n interpreters?: DiagnosticsInterpreters;\n}>();\n\nconst tab = ref<'sessions' | 'cards'>('sessions');\nconst sessionId = ref<string | null>(null);\nconst focusCard = ref<string | undefined>(undefined);\n\nfunction openCard(cardId: string): void {\n // Re-set through undefined so following the same card twice still refocuses.\n focusCard.value = undefined;\n tab.value = 'cards';\n setTimeout(() => (focusCard.value = cardId));\n}\n\nwatch(\n () => props.dataset,\n () => {\n tab.value = 'sessions';\n sessionId.value = null;\n focusCard.value = undefined;\n }\n);\n</script>\n","<template>\n <div>\n <v-tabs v-model=\"tab\" density=\"compact\" class=\"mb-4\">\n <v-tab value=\"sessions\">Sessions ({{ dataset.sessions.length }})</v-tab>\n <v-tab value=\"cards\">Cards ({{ dataset.cardHistories.length }})</v-tab>\n </v-tabs>\n\n <template v-if=\"tab === 'sessions'\">\n <template v-if=\"sessionId\">\n <v-btn variant=\"text\" size=\"small\" prepend-icon=\"mdi-arrow-left\" class=\"mb-2\" @click=\"sessionId = null\">\n All sessions\n </v-btn>\n <learner-session-detail\n :dataset=\"dataset\"\n :session-id=\"sessionId\"\n :interpreters=\"interpreters\"\n @open-card=\"openCard\"\n />\n </template>\n <learner-session-list\n v-else\n :dataset=\"dataset\"\n :interpreters=\"interpreters\"\n @open=\"(id: string) => (sessionId = id)\"\n />\n </template>\n\n <learner-card-history v-else :dataset=\"dataset\" :interpreters=\"interpreters\" :focus=\"focusCard\" />\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, watch } from 'vue';\nimport type { DiagnosticsInterpreters, LearnerDataset } from '@vue-skuilder/db/diagnostics';\nimport LearnerCardHistory from './LearnerCardHistory.vue';\nimport LearnerSessionDetail from './LearnerSessionDetail.vue';\nimport LearnerSessionList from './LearnerSessionList.vue';\n\n/**\n * One learner: sessions (list, then a session's detail) and card history,\n * with session timelines linking into card history. Navigation is internal;\n * hosts that want URLs can compose the three views themselves.\n */\nconst props = defineProps<{\n dataset: LearnerDataset;\n interpreters?: DiagnosticsInterpreters;\n}>();\n\nconst tab = ref<'sessions' | 'cards'>('sessions');\nconst sessionId = ref<string | null>(null);\nconst focusCard = ref<string | undefined>(undefined);\n\nfunction openCard(cardId: string): void {\n // Re-set through undefined so following the same card twice still refocuses.\n focusCard.value = undefined;\n tab.value = 'cards';\n setTimeout(() => (focusCard.value = cardId));\n}\n\nwatch(\n () => props.dataset,\n () => {\n tab.value = 'sessions';\n sessionId.value = null;\n focusCard.value = undefined;\n }\n);\n</script>\n"],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { CardDossier, DiagnosticsInterpreters } from '@vue-skuilder/db/diagnostics';
|
|
2
|
+
/** One card's record for one learner: content, tags, scheduling state, and every response. */
|
|
3
|
+
type __VLS_Props = {
|
|
4
|
+
dossier: CardDossier;
|
|
5
|
+
interpreters?: DiagnosticsInterpreters;
|
|
6
|
+
};
|
|
7
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
8
|
+
export default _default;
|
|
9
|
+
//# sourceMappingURL=CardDossierDetail.vue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CardDossierDetail.vue.d.ts","sourceRoot":"","sources":["../../../src/admin/components/CardDossierDetail.vue"],"names":[],"mappings":"AAqGA,OAAO,EAIL,KAAK,WAAW,EAEhB,KAAK,uBAAuB,EAC7B,MAAM,8BAA8B,CAAC;AAGtC,8FAA8F;AAC9F,KAAK,WAAW,GAAG;IACjB,OAAO,EAAE,WAAW,CAAC;IACrB,YAAY,CAAC,EAAE,uBAAuB,CAAC;CACxC,CAAC;;AA0NF,wBAOG"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DiagnosticsInterpreters, LearnerDataset } from '@vue-skuilder/db/diagnostics';
|
|
2
|
+
/**
|
|
3
|
+
* Every card the learner has history with. `focus` opens, scrolls to, and
|
|
4
|
+
* briefly highlights one card (e.g. followed from a session timeline).
|
|
5
|
+
*/
|
|
6
|
+
type __VLS_Props = {
|
|
7
|
+
dataset: LearnerDataset;
|
|
8
|
+
interpreters?: DiagnosticsInterpreters;
|
|
9
|
+
focus?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
12
|
+
export default _default;
|
|
13
|
+
//# sourceMappingURL=LearnerCardHistory.vue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LearnerCardHistory.vue.d.ts","sourceRoot":"","sources":["../../../src/admin/components/LearnerCardHistory.vue"],"names":[],"mappings":"AAuMA,OAAO,EAIL,KAAK,uBAAuB,EAC5B,KAAK,cAAc,EACpB,MAAM,8BAA8B,CAAC;AAItC;;;GAGG;AACH,KAAK,WAAW,GAAG;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,YAAY,CAAC,EAAE,uBAAuB,CAAC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;;AAojBF,wBAOG"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { LearnerDataset, LearnerDump } from '@vue-skuilder/db/diagnostics';
|
|
2
|
+
/**
|
|
3
|
+
* Pick a learner's dataset: live from couch by username (needs `couchUrl` and
|
|
4
|
+
* `courseId`), or from a dump file. Emits `loaded` with the indexed dataset
|
|
5
|
+
* and the raw dump.
|
|
6
|
+
*/
|
|
7
|
+
type __VLS_Props = {
|
|
8
|
+
/** Couch base URL. Without it, only files can be opened. */
|
|
9
|
+
couchUrl?: string;
|
|
10
|
+
courseId?: string;
|
|
11
|
+
/** Prefills the learner field. */
|
|
12
|
+
initialUsername?: string;
|
|
13
|
+
/** Load `initialUsername` on mount, when live loading is possible. */
|
|
14
|
+
autoload?: boolean;
|
|
15
|
+
};
|
|
16
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
17
|
+
loaded: (dataset: LearnerDataset, dump: LearnerDump) => any;
|
|
18
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
19
|
+
onLoaded?: ((dataset: LearnerDataset, dump: LearnerDump) => any) | undefined;
|
|
20
|
+
}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {
|
|
21
|
+
fileInput: HTMLInputElement;
|
|
22
|
+
}, any>;
|
|
23
|
+
export default _default;
|
|
24
|
+
//# sourceMappingURL=LearnerDatasetSource.vue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LearnerDatasetSource.vue.d.ts","sourceRoot":"","sources":["../../../src/admin/components/LearnerDatasetSource.vue"],"names":[],"mappings":"AAoGA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAGhF;;;;GAIG;AACH,KAAK,WAAW,GAAG;IACjB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kCAAkC;IAClC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;;;;;;;;AAoQF,wBASG"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DiagnosticsInterpreters, LearnerDataset } from '@vue-skuilder/db/diagnostics';
|
|
2
|
+
/**
|
|
3
|
+
* One learner: sessions (list, then a session's detail) and card history,
|
|
4
|
+
* with session timelines linking into card history. Navigation is internal;
|
|
5
|
+
* hosts that want URLs can compose the three views themselves.
|
|
6
|
+
*/
|
|
7
|
+
type __VLS_Props = {
|
|
8
|
+
dataset: LearnerDataset;
|
|
9
|
+
interpreters?: DiagnosticsInterpreters;
|
|
10
|
+
};
|
|
11
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
12
|
+
export default _default;
|
|
13
|
+
//# sourceMappingURL=LearnerDiagnostics.vue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LearnerDiagnostics.vue.d.ts","sourceRoot":"","sources":["../../../src/admin/components/LearnerDiagnostics.vue"],"names":[],"mappings":"AAuEA,OAAO,KAAK,EAAE,uBAAuB,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAK5F;;;;GAIG;AACH,KAAK,WAAW,GAAG;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,YAAY,CAAC,EAAE,uBAAuB,CAAC;CACxC,CAAC;;AAkMF,wBAOG"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { DiagnosticsInterpreters, LearnerDataset } from '@vue-skuilder/db/diagnostics';
|
|
2
|
+
/** One session: headline, state, hints, served tags, ELO ledger, and the interleaved timeline. */
|
|
3
|
+
type __VLS_Props = {
|
|
4
|
+
dataset: LearnerDataset;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
interpreters?: DiagnosticsInterpreters;
|
|
7
|
+
};
|
|
8
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
9
|
+
"open-card": (cardId: string) => any;
|
|
10
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
11
|
+
"onOpen-card"?: ((cardId: string) => any) | undefined;
|
|
12
|
+
}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
13
|
+
export default _default;
|
|
14
|
+
//# sourceMappingURL=LearnerSessionDetail.vue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LearnerSessionDetail.vue.d.ts","sourceRoot":"","sources":["../../../src/admin/components/LearnerSessionDetail.vue"],"names":[],"mappings":"AA0bA,OAAO,EAaL,KAAK,uBAAuB,EAC5B,KAAK,cAAc,EAEpB,MAAM,8BAA8B,CAAC;AActC,kGAAkG;AAClG,KAAK,WAAW,GAAG;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,uBAAuB,CAAC;CACxC,CAAC;;;;;;AA6+BF,wBAQG"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DiagnosticsInterpreters, LearnerDataset } from '@vue-skuilder/db/diagnostics';
|
|
2
|
+
/** A learner's sessions, newest first, as triage. Emits `open` with a session id. */
|
|
3
|
+
type __VLS_Props = {
|
|
4
|
+
dataset: LearnerDataset;
|
|
5
|
+
interpreters?: DiagnosticsInterpreters;
|
|
6
|
+
};
|
|
7
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
8
|
+
open: (sessionId: string) => any;
|
|
9
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
10
|
+
onOpen?: ((sessionId: string) => any) | undefined;
|
|
11
|
+
}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
12
|
+
export default _default;
|
|
13
|
+
//# sourceMappingURL=LearnerSessionList.vue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LearnerSessionList.vue.d.ts","sourceRoot":"","sources":["../../../src/admin/components/LearnerSessionList.vue"],"names":[],"mappings":"AAkLA,OAAO,EAKL,KAAK,uBAAuB,EAC5B,KAAK,cAAc,EAGpB,MAAM,8BAA8B,CAAC;AAGtC,qFAAqF;AACrF,KAAK,WAAW,GAAG;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,YAAY,CAAC,EAAE,uBAAuB,CAAC;CACxC,CAAC;;;;;;AAsaF,wBAQG"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StudySessionRunSummary } from '@vue-skuilder/db/diagnostics';
|
|
2
|
+
/** The persisted detail of one pipeline run. */
|
|
3
|
+
type __VLS_Props = {
|
|
4
|
+
run: StudySessionRunSummary;
|
|
5
|
+
};
|
|
6
|
+
declare const _default: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
7
|
+
"open-card": (cardId: string) => any;
|
|
8
|
+
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
9
|
+
"onOpen-card"?: ((cardId: string) => any) | undefined;
|
|
10
|
+
}>, {}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {}, HTMLDivElement>;
|
|
11
|
+
export default _default;
|
|
12
|
+
//# sourceMappingURL=RunDetail.vue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunDetail.vue.d.ts","sourceRoot":"","sources":["../../../src/admin/components/RunDetail.vue"],"names":[],"mappings":"AAyKA,OAAO,KAAK,EAA4B,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAErG,gDAAgD;AAChD,KAAK,WAAW,GAAG;IAAE,GAAG,EAAE,sBAAsB,CAAA;CAAE,CAAC;;;;;;AAoXnD,wBAQG"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { LearnerDataset, LearnerDump } from '@vue-skuilder/db/diagnostics';
|
|
2
|
+
/** Where to read a learner from live. Auth is the signed-in admin's couch session cookie. */
|
|
3
|
+
export interface LearnerCouchSource {
|
|
4
|
+
/** Couch base URL, e.g. `https://example.com/couch`. */
|
|
5
|
+
couchUrl: string;
|
|
6
|
+
username: string;
|
|
7
|
+
courseId: string;
|
|
8
|
+
/** Course cards to include. Default `touched`. */
|
|
9
|
+
cards?: 'touched' | 'all' | 'none';
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Load one learner's dataset for admin views, live from couch or from a dump
|
|
13
|
+
* file (`skuilder dump-user`, or a file saved from here). Keeps the raw dump
|
|
14
|
+
* so a view can hand exactly what it showed back out as a file.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useLearnerDataset(): {
|
|
17
|
+
dump: import('vue').ShallowRef<LearnerDump | null, LearnerDump | null>;
|
|
18
|
+
dataset: import('vue').ShallowRef<LearnerDataset | null, LearnerDataset | null>;
|
|
19
|
+
loading: import('vue').Ref<boolean, boolean>;
|
|
20
|
+
error: import('vue').Ref<string | null, string | null>;
|
|
21
|
+
loadFromCouch: (source: LearnerCouchSource) => Promise<void>;
|
|
22
|
+
loadFromFile: (file: File, courseId?: string) => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
/** Save a dump under the same name `skuilder dump-user` would give it. */
|
|
25
|
+
export declare function downloadLearnerDump(dump: LearnerDump): void;
|
|
26
|
+
//# sourceMappingURL=useLearnerDataset.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLearnerDataset.d.ts","sourceRoot":"","sources":["../../../src/admin/composables/useLearnerDataset.ts"],"names":[],"mappings":"AACA,OAAO,EAKL,KAAK,cAAc,EACnB,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC,6FAA6F;AAC7F,MAAM,WAAW,kBAAkB;IACjC,wDAAwD;IACxD,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,KAAK,CAAC,EAAE,SAAS,GAAG,KAAK,GAAG,MAAM,CAAC;CACpC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB;;;;;4BAoBA,kBAAkB,KAAG,OAAO,CAAC,IAAI,CAAC;yBAIrC,IAAI,aAAa,MAAM,KAAG,OAAO,CAAC,IAAI,CAAC;EAKpE;AAED,0EAA0E;AAC1E,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAQ3D"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Ref } from 'vue';
|
|
2
|
+
import { SessionFlag } from '@vue-skuilder/db/diagnostics';
|
|
3
|
+
export declare const FLAG_UI: Record<SessionFlag, {
|
|
4
|
+
icon: string;
|
|
5
|
+
color: string;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function statusColor(status: string): string;
|
|
8
|
+
export declare function accuracyClass(a: number | null): string;
|
|
9
|
+
export declare function deltaClass(d: number | null): string;
|
|
10
|
+
export declare function deltaColor(d: number): string;
|
|
11
|
+
export declare function fmtTime(iso: string | null | undefined): string;
|
|
12
|
+
/** Time of day only: a session timeline reads as a within-sitting sequence. */
|
|
13
|
+
export declare function fmtClock(iso: string): string;
|
|
14
|
+
export declare function shortId(id: string, max?: number): string;
|
|
15
|
+
/** Copy text, and remember which control asked, briefly, for a tick. */
|
|
16
|
+
export declare function useCopy(): {
|
|
17
|
+
copied: Ref<string | null>;
|
|
18
|
+
copy: (text: string, key: string) => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
//# sourceMappingURL=display.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"display.d.ts","sourceRoot":"","sources":["../../src/admin/display.ts"],"names":[],"mappings":"AAGA,OAAO,EAAO,KAAK,GAAG,EAAE,MAAM,KAAK,CAAC;AACpC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAEhE,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAMxE,CAAC;AAEF,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAIlD;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAKtD;AAED,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAGnD;AAED,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAI5C;AAED,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAI9D;AAED,+EAA+E;AAC/E,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG5C;AAED,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,MAAM,CAEpD;AAED,wEAAwE;AACxE,wBAAgB,OAAO,IAAI;IACzB,MAAM,EAAE,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC3B,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD,CAcA"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@vue-skuilder/common-ui/admin`: admin and diagnostics components.
|
|
3
|
+
*
|
|
4
|
+
* A separate entry (built by vite.config.admin.js) so learner-facing bundles
|
|
5
|
+
* don't carry it. Views here render a `LearnerDataset` from
|
|
6
|
+
* `@vue-skuilder/db/diagnostics`, whether it came from couch or a dump file,
|
|
7
|
+
* and take the course's `DiagnosticsInterpreters` for domain meaning.
|
|
8
|
+
*
|
|
9
|
+
* Built CSS: import '@vue-skuilder/common-ui/admin/style' where these render.
|
|
10
|
+
*
|
|
11
|
+
* Import shared common-ui pieces from '@vue-skuilder/common-ui', not
|
|
12
|
+
* '@cui/...', so the admin bundle doesn't duplicate them.
|
|
13
|
+
*/
|
|
14
|
+
export { useLearnerDataset, downloadLearnerDump, type LearnerCouchSource, } from './composables/useLearnerDataset';
|
|
15
|
+
export { default as LearnerDatasetSource } from './components/LearnerDatasetSource.vue';
|
|
16
|
+
export { default as LearnerDiagnostics } from './components/LearnerDiagnostics.vue';
|
|
17
|
+
export { default as LearnerSessionList } from './components/LearnerSessionList.vue';
|
|
18
|
+
export { default as LearnerSessionDetail } from './components/LearnerSessionDetail.vue';
|
|
19
|
+
export { default as LearnerCardHistory } from './components/LearnerCardHistory.vue';
|
|
20
|
+
export { default as CardDossierDetail } from './components/CardDossierDetail.vue';
|
|
21
|
+
export { default as RunDetail } from './components/RunDetail.vue';
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/admin/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,KAAK,kBAAkB,GACxB,MAAM,iCAAiC,CAAC;AAEzC,OAAO,EAAE,OAAO,IAAI,oBAAoB,EAAE,MAAM,uCAAuC,CAAC;AACxF,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AACpF,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AACpF,OAAO,EAAE,OAAO,IAAI,oBAAoB,EAAE,MAAM,uCAAuC,CAAC;AACxF,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AACpF,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AAClF,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,4BAA4B,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ViewData } from '
|
|
1
|
+
import { ViewData } from '@vue-skuilder/common';
|
|
2
2
|
import { PropType } from 'vue';
|
|
3
3
|
import { ViewComponent } from '../composables/Displayable';
|
|
4
4
|
declare const _default: import('vue').DefineComponent<import('vue').ExtractPropTypes<{
|
|
@@ -67,7 +67,7 @@ declare const _default: import('vue').DefineComponent<import('vue').ExtractPropT
|
|
|
67
67
|
required: true;
|
|
68
68
|
};
|
|
69
69
|
user_elo: {
|
|
70
|
-
type: () => import('
|
|
70
|
+
type: () => import('@vue-skuilder/common').CourseElo;
|
|
71
71
|
default: () => {
|
|
72
72
|
global: {
|
|
73
73
|
score: number;
|
|
@@ -86,7 +86,7 @@ declare const _default: import('vue').DefineComponent<import('vue').ExtractPropT
|
|
|
86
86
|
default: boolean;
|
|
87
87
|
};
|
|
88
88
|
}>, {}, {}, {}, {
|
|
89
|
-
processResponse(r: import('
|
|
89
|
+
processResponse(r: import('@vue-skuilder/db').CardRecord): void;
|
|
90
90
|
}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, ("emitResponse" | "requestReplan" | "readyToAdvance")[], "emitResponse" | "requestReplan" | "readyToAdvance", import('vue').PublicProps, Readonly<import('vue').ExtractPropTypes<{
|
|
91
91
|
sessionOrder: {
|
|
92
92
|
type: NumberConstructor;
|
|
@@ -112,7 +112,7 @@ declare const _default: import('vue').DefineComponent<import('vue').ExtractPropT
|
|
|
112
112
|
required: true;
|
|
113
113
|
};
|
|
114
114
|
user_elo: {
|
|
115
|
-
type: () => import('
|
|
115
|
+
type: () => import('@vue-skuilder/common').CourseElo;
|
|
116
116
|
default: () => {
|
|
117
117
|
global: {
|
|
118
118
|
score: number;
|
|
@@ -138,7 +138,7 @@ declare const _default: import('vue').DefineComponent<import('vue').ExtractPropT
|
|
|
138
138
|
sessionOrder: number;
|
|
139
139
|
card_id: string;
|
|
140
140
|
course_id: string;
|
|
141
|
-
user_elo: import('
|
|
141
|
+
user_elo: import('@vue-skuilder/common').CourseElo;
|
|
142
142
|
card_elo: number;
|
|
143
143
|
frameless: boolean;
|
|
144
144
|
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PropType } from 'vue';
|
|
2
|
-
import { UserDBReader, CardHistory, CardRecord } from '
|
|
2
|
+
import { UserDBReader, CardHistory, CardRecord } from '@vue-skuilder/db';
|
|
3
3
|
interface FormattedRecord extends CardRecord {
|
|
4
4
|
formattedTimeStamp: string;
|
|
5
5
|
intervalFromPrevious?: number;
|