@vue-skuilder/common-ui 0.2.28 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +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) }}&nbsp;{{ 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) }}&nbsp;{{ 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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"}
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/PipelineOverview.vue","../../src/admin/components/PipelineOverview.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>kind</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>{{ f.kind ?? '—' }}</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.regulators?.length\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">regulators</div>\n <div v-for=\"r in run.regulators\" :key=\"r.name\" class=\"sk-mini-line\">\n <span :class=\"r.multiplier > 1 ? 'text-warning' : 'text-medium-emphasis'\">\n ×{{ r.multiplier.toFixed(2) }}\n </span>\n {{ regulatorText(r) }}\n </div>\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 {\n regulatorText,\n type StudySessionRunCardTrail,\n type StudySessionRunSummary,\n} 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-mini-line {\n font-size: 0.72rem;\n padding-left: 8px;\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>kind</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>{{ f.kind ?? '—' }}</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.regulators?.length\" class=\"mb-2\">\n <div class=\"text-caption font-weight-medium\">regulators</div>\n <div v-for=\"r in run.regulators\" :key=\"r.name\" class=\"sk-mini-line\">\n <span :class=\"r.multiplier > 1 ? 'text-warning' : 'text-medium-emphasis'\">\n ×{{ r.multiplier.toFixed(2) }}\n </span>\n {{ regulatorText(r) }}\n </div>\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 {\n regulatorText,\n type StudySessionRunCardTrail,\n type StudySessionRunSummary,\n} 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-mini-line {\n font-size: 0.72rem;\n padding-left: 8px;\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) }}&nbsp;{{ 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) }}&nbsp;{{ 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 class=\"sk-pipeline\">\n <div class=\"d-flex flex-wrap align-center ga-2 mb-3\">\n <v-chip size=\"small\" variant=\"tonal\" :color=\"plan.kind === 'default' ? 'warning' : 'primary'\">\n {{\n plan.kind === 'default' ? 'default pipeline: the course has no strategy docs' : 'assembled from strategy docs'\n }}\n </v-chip>\n <span class=\"text-caption\">\n {{ plan.generators.length }} generator(s) · {{ plan.filters.length }} filter(s)\n </span>\n <span v-if=\"stats\" class=\"text-caption text-medium-emphasis\">\n · run stats{{ statsLabel ? ` for ${statsLabel}` : '' }}: {{ stats.runs }} runs over\n {{ stats.sessions }} sessions, {{ fmtTime(stats.firstRunAt) }} – {{ fmtTime(stats.lastRunAt) }}\n </span>\n </div>\n\n <v-alert\n v-for=\"s in plan.skipped\"\n :key=\"s.strategy._id\"\n type=\"warning\"\n variant=\"tonal\"\n density=\"compact\"\n class=\"mb-2\"\n >\n <strong>{{ s.strategy.name }}</strong> (<code>{{ s.strategy.implementingClass }}</code\n >) is {{ s.reason }} in this app, so it would not run here. A course's own navigators only plan correctly in the\n app that registers them.\n </v-alert>\n <v-alert v-for=\"w in plan.warnings\" :key=\"w\" type=\"warning\" variant=\"tonal\" density=\"compact\" class=\"mb-2\">\n {{ w }}\n </v-alert>\n <v-alert v-for=\"n in notes\" :key=\"n\" type=\"info\" variant=\"tonal\" density=\"compact\" class=\"mb-2\">\n {{ n }}\n </v-alert>\n\n <div v-for=\"(stage, i) in PIPELINE_STAGES\" :key=\"stage.key\">\n <div class=\"sk-stage\">\n <div class=\"d-flex align-center mb-1\">\n <span class=\"sk-stage-num\">{{ i + 1 }}</span>\n <span class=\"font-weight-medium\">{{ stage.title }}</span>\n </div>\n <div class=\"text-caption text-medium-emphasis mb-2\">{{ stage.summary }}</div>\n\n <template v-if=\"stage.key === 'generate'\">\n <div v-for=\"g in plan.generators\" :key=\"g.name\" class=\"sk-nav\">\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <span class=\"font-weight-medium\">{{ g.name }}</span>\n <code class=\"text-caption\">{{ g.implementingClass }}</code>\n <v-chip size=\"x-small\" variant=\"outlined\" :color=\"ORIGIN[g.origin].color\">\n {{ ORIGIN[g.origin].label }}\n </v-chip>\n </div>\n <div v-if=\"g.strategy?.description\" class=\"text-caption mt-1\">{{ g.strategy.description }}</div>\n\n <div v-if=\"stats\" class=\"text-caption mt-1\">\n <template v-if=\"genStat(g.name)\">\n {{ fmtNum(genStat(g.name)!.meanCards) }} candidates/run ({{ fmtNum(genStat(g.name)!.meanNew) }} new,\n {{ fmtNum(genStat(g.name)!.meanReview) }} review) · top score\n {{ genStat(g.name)!.topScoreLast.toFixed(2) }} last, {{ genStat(g.name)!.topScoreMax.toFixed(2) }} max\n <template v-if=\"stats.runsWithSelection > 0\">\n · <strong>{{ genStat(g.name)!.selected }}</strong> selected\n <template v-if=\"genStat(g.name)!.firstAttempts > 0\">\n · first attempts {{ genStat(g.name)!.firstAttemptsCorrect }}/{{ genStat(g.name)!.firstAttempts }}\n correct\n <span :class=\"accuracyClass(accuracyOf(genStat(g.name)!))\">\n ({{ Math.round(100 * accuracyOf(genStat(g.name)!)!) }}%)\n </span>\n </template>\n </template>\n </template>\n <span v-else class=\"text-medium-emphasis\">absent from these runs</span>\n </div>\n\n <div v-if=\"prettyConfig(g.strategy?.serializedData)\" class=\"mt-1\">\n <a class=\"sk-config-link text-caption\" @click=\"toggleConfig(`g:${g.name}`)\">\n {{ openConfigs.has(`g:${g.name}`) ? 'Hide' : 'Show' }} config\n </a>\n <pre v-if=\"openConfigs.has(`g:${g.name}`)\" class=\"sk-config\">{{\n prettyConfig(g.strategy?.serializedData)\n }}</pre>\n </div>\n </div>\n </template>\n\n <template v-else-if=\"stage.key === 'filter'\">\n <div v-if=\"plan.filters.length === 0\" class=\"text-caption text-medium-emphasis\">No filters.</div>\n <div v-for=\"f in plan.filters\" :key=\"f.name\" class=\"sk-nav\">\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <span class=\"font-weight-medium\">{{ f.name }}</span>\n <code class=\"text-caption\">{{ f.implementingClass }}</code>\n <v-chip size=\"x-small\" variant=\"outlined\" :color=\"ORIGIN[f.origin].color\">\n {{ ORIGIN[f.origin].label }}\n </v-chip>\n <v-chip v-if=\"f.strategy?.learnable\" size=\"x-small\" variant=\"outlined\">learnable weight</v-chip>\n <v-chip\n size=\"x-small\"\n variant=\"tonal\"\n :color=\"filterKind(f).kind === 'gate' ? 'deep-orange' : 'blue-grey'\"\n :title=\"filterKind(f).help\"\n >\n {{ filterKind(f).label }}\n </v-chip>\n </div>\n <div v-if=\"f.strategy?.description\" class=\"text-caption mt-1\">{{ f.strategy.description }}</div>\n <div v-else-if=\"!f.strategy\" class=\"text-caption mt-1 text-medium-emphasis\">\n Built in code; no strategy document.\n </div>\n\n <div v-if=\"stats\" class=\"mt-1\">\n <template v-if=\"filterStat(f.name)\">\n <div class=\"sk-impact-bar\">\n <div\n v-for=\"part in impactParts(filterStat(f.name)!)\"\n :key=\"part.key\"\n :class=\"`bg-${part.color}`\"\n :style=\"{ width: `${part.share * 100}%` }\"\n :title=\"`${part.key}: ${part.count}`\"\n />\n </div>\n <div class=\"text-caption\">\n over {{ filterStat(f.name)!.runs }} runs:\n <span v-for=\"part in impactParts(filterStat(f.name)!)\" :key=\"part.key\" class=\"mr-2\">\n <span :class=\"`text-${part.color}`\">●</span> {{ part.key }} {{ fmtPct(part.share) }}\n </span>\n </div>\n </template>\n <span v-else class=\"text-caption text-medium-emphasis\">absent from these runs</span>\n </div>\n\n <div v-if=\"prettyConfig(f.strategy?.serializedData)\" class=\"mt-1\">\n <a class=\"sk-config-link text-caption\" @click=\"toggleConfig(`f:${f.name}`)\">\n {{ openConfigs.has(`f:${f.name}`) ? 'Hide' : 'Show' }} config\n </a>\n <pre v-if=\"openConfigs.has(`f:${f.name}`)\" class=\"sk-config\">{{\n prettyConfig(f.strategy?.serializedData)\n }}</pre>\n </div>\n </div>\n </template>\n\n <template v-else-if=\"stage.key === 'regulate'\">\n <div class=\"sk-nav\">\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <span class=\"font-weight-medium\">Review mass</span>\n <v-chip size=\"x-small\" variant=\"outlined\">on review cards</v-chip>\n </div>\n <div class=\"text-caption mt-1\">\n Each due review weighs its SRS urgency (0–1) times the filters' net effect on it. Above\n {{ plan.regulators.reviewMass.healthyMass }} card-equivalents, every review is multiplied by ×{{\n plan.regulators.reviewMass.rate\n }}\n per {{ plan.regulators.reviewMass.healthyMass }} in excess (uncapped). Reviews without an urgency weigh\n their score against {{ plan.regulators.reviewMass.referenceScore }}.\n </div>\n <div v-if=\"regStat('review-mass')\" class=\"text-caption mt-1\">\n {{ regStatText(regStat('review-mass')!) }}\n </div>\n </div>\n <div class=\"sk-nav\">\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <span class=\"font-weight-medium\">Intake</span>\n <v-chip size=\"x-small\" variant=\"outlined\">on new cards no gate penalized</v-chip>\n </div>\n <div class=\"text-caption mt-1\">\n ×{{ plan.regulators.intake.rate }} per {{ plan.regulators.intake.scaleHours }} hours since a new card was\n last presented (uncapped). The clock stops while no eligible new card exists.\n </div>\n <div v-if=\"regStat('intake')\" class=\"text-caption mt-1\">{{ regStatText(regStat('intake')!) }}</div>\n </div>\n <div v-if=\"stats && stats.regulators.length === 0\" class=\"text-caption text-medium-emphasis\">\n None of these runs recorded regulator readings (recorded from 0.2.29).\n </div>\n </template>\n\n <template v-else-if=\"stage.key === 'select' && stats && stats.runsWithSelection > 0\">\n <div class=\"text-caption\">\n {{ stats.selectedUnattributed }} selected card(s) had no generator: forced in by a hint's\n <code>requireCards</code>.\n </div>\n </template>\n </div>\n <div v-if=\"i < PIPELINE_STAGES.length - 1\" class=\"sk-stage-arrow\">\n <v-icon size=\"small\">mdi-arrow-down</v-icon>\n </div>\n </div>\n\n <div v-if=\"stats && stats.runsWithSelection === 0\" class=\"text-caption text-medium-emphasis mt-3\">\n None of these runs recorded which cards they selected (recorded from 0.2.28), so selection and first-attempt\n counts are not shown.\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport {\n PIPELINE_STAGES,\n planPipeline,\n type ContentNavigationStrategyData,\n type PlannedNavigator,\n type PlannedNavigatorOrigin,\n} from '@vue-skuilder/db';\nimport {\n regulatorText,\n type FilterKind,\n type FilterRunStat,\n type GeneratorRunStat,\n type PipelineRunStats,\n type RegulatorName,\n type RegulatorRunStat,\n} from '@vue-skuilder/db/diagnostics';\nimport { accuracyClass, fmtTime } from '../display';\n\n/**\n * A course's navigation pipeline as it assembles in this app: each stage in\n * run order, which generators and filters run in it, and where each came from.\n * With `stats` (from `pipelineRunStats`), each piece also shows what it did\n * across a learner's persisted runs. Stats join to the plan by name.\n */\nconst props = defineProps<{\n strategies: ContentNavigationStrategyData[];\n courseId: string;\n stats?: PipelineRunStats;\n /** Whose runs the stats cover, e.g. a username. */\n statsLabel?: string;\n}>();\n\nconst ORIGIN: Record<PlannedNavigatorOrigin, { label: string; color: string }> = {\n 'strategy-doc': { label: 'strategy doc', color: 'primary' },\n 'assembler-default': { label: 'added by the assembler', color: 'secondary' },\n 'default-pipeline': { label: 'default pipeline', color: 'warning' },\n};\n\nconst regStat = (name: RegulatorName): RegulatorRunStat | undefined =>\n props.stats?.regulators.find((r) => r.name === name);\n\nfunction regStatText(r: RegulatorRunStat): string {\n return (\n `In these runs: active (×>1) in ${r.runsActive}/${r.runs}, mean ×${r.meanMultiplier.toFixed(2)}, ` +\n `max ×${r.maxMultiplier.toFixed(2)}. Last: ${regulatorText(r.last)}.`\n );\n}\n\nconst KIND_HELP: Record<FilterKind, string> = {\n gate: 'Gate: its penalties mean not ready yet; regulators will not lift what it penalized.',\n signal: 'Signal: its penalties express preference.',\n};\n\n/**\n * A filter's kind: as its class declares it (the plan), else as recorded by\n * runs. Undeclared filters run as gates.\n */\nfunction filterKind(f: PlannedNavigator): { kind: FilterKind; label: string; help: string } {\n const kind = f.kind ?? filterStat(f.name)?.kind;\n if (kind) return { kind, label: kind, help: KIND_HELP[kind] };\n return {\n kind: 'gate',\n label: 'gate (undeclared)',\n help: `${KIND_HELP.gate} Its class declares no kind, so it counts as a gate.`,\n };\n}\n\nconst plan = computed(() => planPipeline(props.strategies, props.courseId));\n\nconst genStats = computed(() => new Map((props.stats?.generators ?? []).map((g) => [g.name, g])));\nconst filterStats = computed(() => new Map((props.stats?.filters ?? []).map((f) => [f.name, f])));\nconst genStat = (name: string): GeneratorRunStat | undefined => genStats.value.get(name);\nconst filterStat = (name: string): FilterRunStat | undefined => filterStats.value.get(name);\n\n/** The plan's notes, plus what the run stats show about pieces that did nothing. */\nconst notes = computed(() => {\n const out = [...plan.value.notes];\n const stats = props.stats;\n if (!stats) return out;\n for (const f of plan.value.filters) {\n const s = filterStat(f.name);\n if (s && s.boosted + s.penalized + s.removed === 0) {\n out.push(`${f.name} changed no candidate's score in ${s.runs} runs.`);\n }\n }\n if (stats.runsWithSelection > 0) {\n for (const g of plan.value.generators) {\n const s = genStat(g.name);\n if (s && s.runs > 0 && s.selected === 0) {\n out.push(`${g.name} proposed candidates in ${s.runs} runs, and none was selected.`);\n }\n }\n }\n return out;\n});\n\nfunction accuracyOf(g: GeneratorRunStat): number | null {\n return g.firstAttempts > 0 ? g.firstAttemptsCorrect / g.firstAttempts : null;\n}\n\nfunction impactParts(f: FilterRunStat) {\n const total = f.boosted + f.penalized + f.passed + f.removed || 1;\n return [\n { key: 'boosted', count: f.boosted, color: 'success' },\n { key: 'penalized', count: f.penalized, color: 'warning' },\n { key: 'passed', count: f.passed, color: 'grey' },\n { key: 'removed', count: f.removed, color: 'error' },\n ].map((p) => ({ ...p, share: p.count / total }));\n}\n\nfunction fmtNum(n: number): string {\n return n >= 10 ? Math.round(n).toString() : n.toFixed(1);\n}\n\nfunction fmtPct(share: number): string {\n if (share === 0) return '0%';\n return share < 0.01 ? '<1%' : `${Math.round(share * 100)}%`;\n}\n\nconst openConfigs = ref(new Set<string>());\n\nfunction toggleConfig(key: string): void {\n const next = new Set(openConfigs.value);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n openConfigs.value = next;\n}\n\n/** A strategy's serializedData, pretty-printed when it's JSON; '' when there's nothing to show. */\nfunction prettyConfig(serialized: string | undefined): string {\n if (!serialized) return '';\n try {\n const parsed: unknown = JSON.parse(serialized);\n const text = JSON.stringify(parsed, null, 2);\n return text === '{}' ? '' : text;\n } catch {\n return serialized;\n }\n}\n</script>\n\n<style scoped>\n.sk-stage {\n border: 1px solid rgba(var(--v-theme-on-surface), 0.12);\n border-radius: 6px;\n padding: 10px 12px;\n}\n.sk-stage-num {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n border-radius: 50%;\n margin-right: 8px;\n font-size: 0.72rem;\n background: rgba(var(--v-theme-primary), 0.15);\n color: rgb(var(--v-theme-primary));\n}\n.sk-stage-arrow {\n display: flex;\n justify-content: center;\n padding: 2px 0;\n opacity: 0.5;\n}\n.sk-nav {\n border-left: 2px solid rgba(var(--v-theme-primary), 0.25);\n padding: 4px 0 4px 10px;\n margin: 6px 0 6px 2px;\n}\n.sk-impact-bar {\n display: flex;\n height: 6px;\n border-radius: 3px;\n overflow: hidden;\n background: rgba(var(--v-theme-on-surface), 0.06);\n max-width: 420px;\n margin: 4px 0 2px;\n}\n.sk-config-link {\n cursor: pointer;\n border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);\n}\n.sk-config {\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: 4px 0 0;\n max-height: 260px;\n overflow: auto;\n white-space: pre-wrap;\n}\n</style>\n","<template>\n <div class=\"sk-pipeline\">\n <div class=\"d-flex flex-wrap align-center ga-2 mb-3\">\n <v-chip size=\"small\" variant=\"tonal\" :color=\"plan.kind === 'default' ? 'warning' : 'primary'\">\n {{\n plan.kind === 'default' ? 'default pipeline: the course has no strategy docs' : 'assembled from strategy docs'\n }}\n </v-chip>\n <span class=\"text-caption\">\n {{ plan.generators.length }} generator(s) · {{ plan.filters.length }} filter(s)\n </span>\n <span v-if=\"stats\" class=\"text-caption text-medium-emphasis\">\n · run stats{{ statsLabel ? ` for ${statsLabel}` : '' }}: {{ stats.runs }} runs over\n {{ stats.sessions }} sessions, {{ fmtTime(stats.firstRunAt) }} – {{ fmtTime(stats.lastRunAt) }}\n </span>\n </div>\n\n <v-alert\n v-for=\"s in plan.skipped\"\n :key=\"s.strategy._id\"\n type=\"warning\"\n variant=\"tonal\"\n density=\"compact\"\n class=\"mb-2\"\n >\n <strong>{{ s.strategy.name }}</strong> (<code>{{ s.strategy.implementingClass }}</code\n >) is {{ s.reason }} in this app, so it would not run here. A course's own navigators only plan correctly in the\n app that registers them.\n </v-alert>\n <v-alert v-for=\"w in plan.warnings\" :key=\"w\" type=\"warning\" variant=\"tonal\" density=\"compact\" class=\"mb-2\">\n {{ w }}\n </v-alert>\n <v-alert v-for=\"n in notes\" :key=\"n\" type=\"info\" variant=\"tonal\" density=\"compact\" class=\"mb-2\">\n {{ n }}\n </v-alert>\n\n <div v-for=\"(stage, i) in PIPELINE_STAGES\" :key=\"stage.key\">\n <div class=\"sk-stage\">\n <div class=\"d-flex align-center mb-1\">\n <span class=\"sk-stage-num\">{{ i + 1 }}</span>\n <span class=\"font-weight-medium\">{{ stage.title }}</span>\n </div>\n <div class=\"text-caption text-medium-emphasis mb-2\">{{ stage.summary }}</div>\n\n <template v-if=\"stage.key === 'generate'\">\n <div v-for=\"g in plan.generators\" :key=\"g.name\" class=\"sk-nav\">\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <span class=\"font-weight-medium\">{{ g.name }}</span>\n <code class=\"text-caption\">{{ g.implementingClass }}</code>\n <v-chip size=\"x-small\" variant=\"outlined\" :color=\"ORIGIN[g.origin].color\">\n {{ ORIGIN[g.origin].label }}\n </v-chip>\n </div>\n <div v-if=\"g.strategy?.description\" class=\"text-caption mt-1\">{{ g.strategy.description }}</div>\n\n <div v-if=\"stats\" class=\"text-caption mt-1\">\n <template v-if=\"genStat(g.name)\">\n {{ fmtNum(genStat(g.name)!.meanCards) }} candidates/run ({{ fmtNum(genStat(g.name)!.meanNew) }} new,\n {{ fmtNum(genStat(g.name)!.meanReview) }} review) · top score\n {{ genStat(g.name)!.topScoreLast.toFixed(2) }} last, {{ genStat(g.name)!.topScoreMax.toFixed(2) }} max\n <template v-if=\"stats.runsWithSelection > 0\">\n · <strong>{{ genStat(g.name)!.selected }}</strong> selected\n <template v-if=\"genStat(g.name)!.firstAttempts > 0\">\n · first attempts {{ genStat(g.name)!.firstAttemptsCorrect }}/{{ genStat(g.name)!.firstAttempts }}\n correct\n <span :class=\"accuracyClass(accuracyOf(genStat(g.name)!))\">\n ({{ Math.round(100 * accuracyOf(genStat(g.name)!)!) }}%)\n </span>\n </template>\n </template>\n </template>\n <span v-else class=\"text-medium-emphasis\">absent from these runs</span>\n </div>\n\n <div v-if=\"prettyConfig(g.strategy?.serializedData)\" class=\"mt-1\">\n <a class=\"sk-config-link text-caption\" @click=\"toggleConfig(`g:${g.name}`)\">\n {{ openConfigs.has(`g:${g.name}`) ? 'Hide' : 'Show' }} config\n </a>\n <pre v-if=\"openConfigs.has(`g:${g.name}`)\" class=\"sk-config\">{{\n prettyConfig(g.strategy?.serializedData)\n }}</pre>\n </div>\n </div>\n </template>\n\n <template v-else-if=\"stage.key === 'filter'\">\n <div v-if=\"plan.filters.length === 0\" class=\"text-caption text-medium-emphasis\">No filters.</div>\n <div v-for=\"f in plan.filters\" :key=\"f.name\" class=\"sk-nav\">\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <span class=\"font-weight-medium\">{{ f.name }}</span>\n <code class=\"text-caption\">{{ f.implementingClass }}</code>\n <v-chip size=\"x-small\" variant=\"outlined\" :color=\"ORIGIN[f.origin].color\">\n {{ ORIGIN[f.origin].label }}\n </v-chip>\n <v-chip v-if=\"f.strategy?.learnable\" size=\"x-small\" variant=\"outlined\">learnable weight</v-chip>\n <v-chip\n size=\"x-small\"\n variant=\"tonal\"\n :color=\"filterKind(f).kind === 'gate' ? 'deep-orange' : 'blue-grey'\"\n :title=\"filterKind(f).help\"\n >\n {{ filterKind(f).label }}\n </v-chip>\n </div>\n <div v-if=\"f.strategy?.description\" class=\"text-caption mt-1\">{{ f.strategy.description }}</div>\n <div v-else-if=\"!f.strategy\" class=\"text-caption mt-1 text-medium-emphasis\">\n Built in code; no strategy document.\n </div>\n\n <div v-if=\"stats\" class=\"mt-1\">\n <template v-if=\"filterStat(f.name)\">\n <div class=\"sk-impact-bar\">\n <div\n v-for=\"part in impactParts(filterStat(f.name)!)\"\n :key=\"part.key\"\n :class=\"`bg-${part.color}`\"\n :style=\"{ width: `${part.share * 100}%` }\"\n :title=\"`${part.key}: ${part.count}`\"\n />\n </div>\n <div class=\"text-caption\">\n over {{ filterStat(f.name)!.runs }} runs:\n <span v-for=\"part in impactParts(filterStat(f.name)!)\" :key=\"part.key\" class=\"mr-2\">\n <span :class=\"`text-${part.color}`\">●</span> {{ part.key }} {{ fmtPct(part.share) }}\n </span>\n </div>\n </template>\n <span v-else class=\"text-caption text-medium-emphasis\">absent from these runs</span>\n </div>\n\n <div v-if=\"prettyConfig(f.strategy?.serializedData)\" class=\"mt-1\">\n <a class=\"sk-config-link text-caption\" @click=\"toggleConfig(`f:${f.name}`)\">\n {{ openConfigs.has(`f:${f.name}`) ? 'Hide' : 'Show' }} config\n </a>\n <pre v-if=\"openConfigs.has(`f:${f.name}`)\" class=\"sk-config\">{{\n prettyConfig(f.strategy?.serializedData)\n }}</pre>\n </div>\n </div>\n </template>\n\n <template v-else-if=\"stage.key === 'regulate'\">\n <div class=\"sk-nav\">\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <span class=\"font-weight-medium\">Review mass</span>\n <v-chip size=\"x-small\" variant=\"outlined\">on review cards</v-chip>\n </div>\n <div class=\"text-caption mt-1\">\n Each due review weighs its SRS urgency (0–1) times the filters' net effect on it. Above\n {{ plan.regulators.reviewMass.healthyMass }} card-equivalents, every review is multiplied by ×{{\n plan.regulators.reviewMass.rate\n }}\n per {{ plan.regulators.reviewMass.healthyMass }} in excess (uncapped). Reviews without an urgency weigh\n their score against {{ plan.regulators.reviewMass.referenceScore }}.\n </div>\n <div v-if=\"regStat('review-mass')\" class=\"text-caption mt-1\">\n {{ regStatText(regStat('review-mass')!) }}\n </div>\n </div>\n <div class=\"sk-nav\">\n <div class=\"d-flex flex-wrap align-center ga-2\">\n <span class=\"font-weight-medium\">Intake</span>\n <v-chip size=\"x-small\" variant=\"outlined\">on new cards no gate penalized</v-chip>\n </div>\n <div class=\"text-caption mt-1\">\n ×{{ plan.regulators.intake.rate }} per {{ plan.regulators.intake.scaleHours }} hours since a new card was\n last presented (uncapped). The clock stops while no eligible new card exists.\n </div>\n <div v-if=\"regStat('intake')\" class=\"text-caption mt-1\">{{ regStatText(regStat('intake')!) }}</div>\n </div>\n <div v-if=\"stats && stats.regulators.length === 0\" class=\"text-caption text-medium-emphasis\">\n None of these runs recorded regulator readings (recorded from 0.2.29).\n </div>\n </template>\n\n <template v-else-if=\"stage.key === 'select' && stats && stats.runsWithSelection > 0\">\n <div class=\"text-caption\">\n {{ stats.selectedUnattributed }} selected card(s) had no generator: forced in by a hint's\n <code>requireCards</code>.\n </div>\n </template>\n </div>\n <div v-if=\"i < PIPELINE_STAGES.length - 1\" class=\"sk-stage-arrow\">\n <v-icon size=\"small\">mdi-arrow-down</v-icon>\n </div>\n </div>\n\n <div v-if=\"stats && stats.runsWithSelection === 0\" class=\"text-caption text-medium-emphasis mt-3\">\n None of these runs recorded which cards they selected (recorded from 0.2.28), so selection and first-attempt\n counts are not shown.\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref } from 'vue';\nimport {\n PIPELINE_STAGES,\n planPipeline,\n type ContentNavigationStrategyData,\n type PlannedNavigator,\n type PlannedNavigatorOrigin,\n} from '@vue-skuilder/db';\nimport {\n regulatorText,\n type FilterKind,\n type FilterRunStat,\n type GeneratorRunStat,\n type PipelineRunStats,\n type RegulatorName,\n type RegulatorRunStat,\n} from '@vue-skuilder/db/diagnostics';\nimport { accuracyClass, fmtTime } from '../display';\n\n/**\n * A course's navigation pipeline as it assembles in this app: each stage in\n * run order, which generators and filters run in it, and where each came from.\n * With `stats` (from `pipelineRunStats`), each piece also shows what it did\n * across a learner's persisted runs. Stats join to the plan by name.\n */\nconst props = defineProps<{\n strategies: ContentNavigationStrategyData[];\n courseId: string;\n stats?: PipelineRunStats;\n /** Whose runs the stats cover, e.g. a username. */\n statsLabel?: string;\n}>();\n\nconst ORIGIN: Record<PlannedNavigatorOrigin, { label: string; color: string }> = {\n 'strategy-doc': { label: 'strategy doc', color: 'primary' },\n 'assembler-default': { label: 'added by the assembler', color: 'secondary' },\n 'default-pipeline': { label: 'default pipeline', color: 'warning' },\n};\n\nconst regStat = (name: RegulatorName): RegulatorRunStat | undefined =>\n props.stats?.regulators.find((r) => r.name === name);\n\nfunction regStatText(r: RegulatorRunStat): string {\n return (\n `In these runs: active (×>1) in ${r.runsActive}/${r.runs}, mean ×${r.meanMultiplier.toFixed(2)}, ` +\n `max ×${r.maxMultiplier.toFixed(2)}. Last: ${regulatorText(r.last)}.`\n );\n}\n\nconst KIND_HELP: Record<FilterKind, string> = {\n gate: 'Gate: its penalties mean not ready yet; regulators will not lift what it penalized.',\n signal: 'Signal: its penalties express preference.',\n};\n\n/**\n * A filter's kind: as its class declares it (the plan), else as recorded by\n * runs. Undeclared filters run as gates.\n */\nfunction filterKind(f: PlannedNavigator): { kind: FilterKind; label: string; help: string } {\n const kind = f.kind ?? filterStat(f.name)?.kind;\n if (kind) return { kind, label: kind, help: KIND_HELP[kind] };\n return {\n kind: 'gate',\n label: 'gate (undeclared)',\n help: `${KIND_HELP.gate} Its class declares no kind, so it counts as a gate.`,\n };\n}\n\nconst plan = computed(() => planPipeline(props.strategies, props.courseId));\n\nconst genStats = computed(() => new Map((props.stats?.generators ?? []).map((g) => [g.name, g])));\nconst filterStats = computed(() => new Map((props.stats?.filters ?? []).map((f) => [f.name, f])));\nconst genStat = (name: string): GeneratorRunStat | undefined => genStats.value.get(name);\nconst filterStat = (name: string): FilterRunStat | undefined => filterStats.value.get(name);\n\n/** The plan's notes, plus what the run stats show about pieces that did nothing. */\nconst notes = computed(() => {\n const out = [...plan.value.notes];\n const stats = props.stats;\n if (!stats) return out;\n for (const f of plan.value.filters) {\n const s = filterStat(f.name);\n if (s && s.boosted + s.penalized + s.removed === 0) {\n out.push(`${f.name} changed no candidate's score in ${s.runs} runs.`);\n }\n }\n if (stats.runsWithSelection > 0) {\n for (const g of plan.value.generators) {\n const s = genStat(g.name);\n if (s && s.runs > 0 && s.selected === 0) {\n out.push(`${g.name} proposed candidates in ${s.runs} runs, and none was selected.`);\n }\n }\n }\n return out;\n});\n\nfunction accuracyOf(g: GeneratorRunStat): number | null {\n return g.firstAttempts > 0 ? g.firstAttemptsCorrect / g.firstAttempts : null;\n}\n\nfunction impactParts(f: FilterRunStat) {\n const total = f.boosted + f.penalized + f.passed + f.removed || 1;\n return [\n { key: 'boosted', count: f.boosted, color: 'success' },\n { key: 'penalized', count: f.penalized, color: 'warning' },\n { key: 'passed', count: f.passed, color: 'grey' },\n { key: 'removed', count: f.removed, color: 'error' },\n ].map((p) => ({ ...p, share: p.count / total }));\n}\n\nfunction fmtNum(n: number): string {\n return n >= 10 ? Math.round(n).toString() : n.toFixed(1);\n}\n\nfunction fmtPct(share: number): string {\n if (share === 0) return '0%';\n return share < 0.01 ? '<1%' : `${Math.round(share * 100)}%`;\n}\n\nconst openConfigs = ref(new Set<string>());\n\nfunction toggleConfig(key: string): void {\n const next = new Set(openConfigs.value);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n openConfigs.value = next;\n}\n\n/** A strategy's serializedData, pretty-printed when it's JSON; '' when there's nothing to show. */\nfunction prettyConfig(serialized: string | undefined): string {\n if (!serialized) return '';\n try {\n const parsed: unknown = JSON.parse(serialized);\n const text = JSON.stringify(parsed, null, 2);\n return text === '{}' ? '' : text;\n } catch {\n return serialized;\n }\n}\n</script>\n\n<style scoped>\n.sk-stage {\n border: 1px solid rgba(var(--v-theme-on-surface), 0.12);\n border-radius: 6px;\n padding: 10px 12px;\n}\n.sk-stage-num {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n border-radius: 50%;\n margin-right: 8px;\n font-size: 0.72rem;\n background: rgba(var(--v-theme-primary), 0.15);\n color: rgb(var(--v-theme-primary));\n}\n.sk-stage-arrow {\n display: flex;\n justify-content: center;\n padding: 2px 0;\n opacity: 0.5;\n}\n.sk-nav {\n border-left: 2px solid rgba(var(--v-theme-primary), 0.25);\n padding: 4px 0 4px 10px;\n margin: 6px 0 6px 2px;\n}\n.sk-impact-bar {\n display: flex;\n height: 6px;\n border-radius: 3px;\n overflow: hidden;\n background: rgba(var(--v-theme-on-surface), 0.06);\n max-width: 420px;\n margin: 4px 0 2px;\n}\n.sk-config-link {\n cursor: pointer;\n border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);\n}\n.sk-config {\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: 4px 0 0;\n max-height: 260px;\n overflow: auto;\n white-space: pre-wrap;\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-tab v-if=\"dataset.strategies\" value=\"pipeline\">Pipeline</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\n v-else-if=\"tab === 'cards'\"\n :dataset=\"dataset\"\n :interpreters=\"interpreters\"\n :focus=\"focusCard\"\n />\n\n <pipeline-overview\n v-else-if=\"tab === 'pipeline' && dataset.strategies\"\n :strategies=\"dataset.strategies\"\n :course-id=\"dataset.courseId\"\n :stats=\"pipelineStats\"\n :stats-label=\"dataset.username\"\n />\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue';\nimport { pipelineRunStats, type DiagnosticsInterpreters, type LearnerDataset } from '@vue-skuilder/db/diagnostics';\nimport LearnerCardHistory from './LearnerCardHistory.vue';\nimport LearnerSessionDetail from './LearnerSessionDetail.vue';\nimport LearnerSessionList from './LearnerSessionList.vue';\nimport PipelineOverview from './PipelineOverview.vue';\n\n/**\n * One learner: sessions (list, then a session's detail), card history, and,\n * when the dataset carries the course's strategy docs, the pipeline with this\n * learner's run stats. Session timelines link 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' | 'pipeline'>('sessions');\nconst sessionId = ref<string | null>(null);\nconst focusCard = ref<string | undefined>(undefined);\nconst pipelineStats = computed(() => pipelineRunStats(props.dataset));\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-tab v-if=\"dataset.strategies\" value=\"pipeline\">Pipeline</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\n v-else-if=\"tab === 'cards'\"\n :dataset=\"dataset\"\n :interpreters=\"interpreters\"\n :focus=\"focusCard\"\n />\n\n <pipeline-overview\n v-else-if=\"tab === 'pipeline' && dataset.strategies\"\n :strategies=\"dataset.strategies\"\n :course-id=\"dataset.courseId\"\n :stats=\"pipelineStats\"\n :stats-label=\"dataset.username\"\n />\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { computed, ref, watch } from 'vue';\nimport { pipelineRunStats, type DiagnosticsInterpreters, type LearnerDataset } from '@vue-skuilder/db/diagnostics';\nimport LearnerCardHistory from './LearnerCardHistory.vue';\nimport LearnerSessionDetail from './LearnerSessionDetail.vue';\nimport LearnerSessionList from './LearnerSessionList.vue';\nimport PipelineOverview from './PipelineOverview.vue';\n\n/**\n * One learner: sessions (list, then a session's detail), card history, and,\n * when the dataset carries the course's strategy docs, the pipeline with this\n * learner's run stats. Session timelines link 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' | 'pipeline'>('sessions');\nconst sessionId = ref<string | null>(null);\nconst focusCard = ref<string | undefined>(undefined);\nconst pipelineStats = computed(() => pipelineRunStats(props.dataset));\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":";;;;;;;;;;AAyBA,SAAgB,oBAAoB;CAClC,IAAM,IAAO,EAA+B,KAAK,EAC3C,IAAU,EAAkC,KAAK,EACjD,IAAU,EAAI,GAAM,EACpB,IAAQ,EAAmB,KAAK;CAEtC,eAAe,KAAK,GAAkC,GAAkC;AAEtF,EADA,EAAQ,QAAQ,IAChB,EAAM,QAAQ;AACd,MAAI;GACF,IAAM,IAAI,MAAM,GAAM;AAEtB,GADA,EAAQ,QAAQ,EAAS,GAAG,EAAS,EACrC,EAAK,QAAQ;WACN,GAAG;AACV,KAAM,QAAQ,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE;YAChD;AACR,KAAQ,QAAQ;;;CAIpB,SAAS,cAAc,GAA2C;AAChE,SAAO,WAAW,EAAiB;GAAE,GAAG;GAAQ,MAAM,EAAE,MAAM,UAAU;GAAE,CAAC,EAAE,EAAO,SAAS;;CAG/F,SAAS,aAAa,GAAY,GAAkC;AAClE,SAAO,KAAK,YAAY,EAAiB,MAAM,EAAK,MAAM,CAAC,EAAE,EAAS;;AAGxE,QAAO;EAAE;EAAM;EAAS;EAAS;EAAO;EAAe;EAAc;;AAIvE,SAAgB,oBAAoB,GAAyB;CAC3D,IAAM,IAAO,IAAI,KAAK,CAAC,KAAK,UAAU,GAAM,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,oBAAoB,CAAC,EAC9E,IAAM,IAAI,gBAAgB,EAAK,EAC/B,IAAI,SAAS,cAAc,IAAI;AAIrC,CAHA,EAAE,OAAO,GACT,EAAE,WAAW,EAAa,EAAK,EAC/B,EAAE,OAAO,EACT,IAAI,gBAAgB,EAAI;;;;;;;;;;;;;;;;;EEV1B,IAAM,IAAQ,GAUR,IAAO,GAIP,EAAE,SAAM,YAAS,YAAS,UAAO,kBAAe,oBAAiB,mBAAmB,EACpF,IAAW,EAAI,EAAM,mBAAmB,GAAG,EAC3C,IAAY,EAA6B,KAAK,EAE9C,IAAe,QAAe;GAClC,IAAM,IAAI,EAAQ,OAAO,YAAY,EAAE;AACvC,UAAO,EAAE,SAAS,GAAG,EAAE,GAAG,UAAU,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,SAAS,GAAG,UAAU,MAAM,GAAG,GAAG,KAAK;IAClG;EAEF,SAAS,WAAiB;AACpB,IAAC,EAAM,YAAY,CAAC,EAAM,YAAY,CAAC,EAAS,SAC/C,EAAc;IAAE,UAAU,EAAM;IAAU,UAAU,EAAM;IAAU,UAAU,EAAS;IAAO,CAAC;;EAGtG,SAAS,OAAO,GAAoB;GAClC,IAAM,IAAQ,EAAM,QACd,IAAO,EAAM,QAAQ;AAE3B,GADI,KAAW,EAAa,GAAM,EAAM,SAAS,EACjD,EAAM,QAAQ;;SAGhB,EAAM,IAAU,MAAO;AACrB,GAAI,KAAM,EAAK,SAAO,EAAK,UAAU,GAAI,EAAK,MAAM;IACpD,EAEF,QAAgB;AACd,GAAI,EAAM,YAAU,UAAU;IAC9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ICzFW,IAAgE;CAC3E,SAAS;EAAE,MAAM;EAAiB,OAAO;EAAW;CACpD,kBAAkB;EAAE,MAAM;EAAuB,OAAO;EAAS;CACjE,QAAQ;EAAE,MAAM;EAAgB,OAAO;EAAS;CAChD,SAAS;EAAE,MAAM;EAAiC,OAAO;EAAQ;CACjE,UAAU;EAAE,MAAM;EAA6B,OAAO;EAAW;CAClE;AAED,SAAgB,YAAY,GAAwB;AAGlD,QAFI,MAAW,WAAiB,YAC5B,MAAW,cAAoB,YAC5B;;AAGT,SAAgB,cAAc,GAA0B;AAItD,QAHI,MAAM,OAAa,kBACnB,KAAK,KAAY,iBACjB,IAAI,KAAY,eACb;;AAGT,SAAgB,WAAW,GAA0B;AAEnD,QADI,MAAM,QAAQ,MAAM,IAAU,kBAC3B,IAAI,IAAI,iBAAiB;;AAGlC,SAAgB,WAAW,GAAmB;AAG5C,QAFI,IAAI,IAAU,YACd,IAAI,IAAU,UACX;;AAGT,SAAgB,QAAQ,GAAwC;AAC9D,KAAI,CAAC,EAAK,QAAO;CACjB,IAAM,IAAI,IAAI,KAAK,EAAI;AACvB,QAAO,OAAO,MAAM,EAAE,SAAS,CAAC,GAAG,IAAM,EAAE,gBAAgB;;AAI7D,SAAgB,SAAS,GAAqB;CAC5C,IAAM,IAAI,IAAI,KAAK,EAAI;AACvB,QAAO,OAAO,MAAM,EAAE,SAAS,CAAC,GAAG,IAAM,EAAE,mBAAmB,KAAA,GAAW,EAAE,QAAQ,IAAO,CAAC;;AAG7F,SAAgB,QAAQ,GAAY,IAAM,IAAY;AACpD,QAAO,EAAG,SAAS,IAAM,IAAI,EAAG,MAAM,EAAE,IAAM,GAAG,KAAK;;AAIxD,SAAgB,UAGd;CACA,IAAM,IAAS,EAAmB,KAAK,EACnC,IAA8C;CAClD,eAAe,KAAK,GAAc,GAA4B;AAC5D,MAAI;AAIF,GAHA,MAAM,UAAU,UAAU,UAAU,EAAK,EACzC,EAAO,QAAQ,GACX,KAAO,aAAa,EAAM,EAC9B,IAAQ,iBAAkB,EAAO,QAAQ,MAAO,KAAK;WAC9C,GAAG;AACV,WAAQ,KAAK,kCAAkC,EAAE;;;AAGrD,QAAO;EAAE;EAAQ;EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;EEMzB,SAAS,KAAK,GAA0B;GACtC,IAAM,IAAI,EAAkB,EAAE;AAC9B,UAAO,MAAM,OAAO,MAAM,EAAE,QAAQ,EAAE;;EAGxC,SAAS,YAAY,GAAmB;AAItC,UAHK,IACD,IAAI,OAAa,GAAG,KAAK,MAAM,IAAI,GAAG,CAAC,KACvC,IAAI,QAAc,GAAG,KAAK,MAAM,IAAI,KAAK,CAAC,KACvC,GAAG,KAAK,MAAM,IAAI,MAAM,CAAC,KAHjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EEwBjB,IAAM,IAAQ,GAMR,IAAQ,QAAe,EAAa,EAAM,QAAQ,CAAC,EACnD,IAAS,EAAuD,MAAM,EACtE,IAAS,EAAmB,GAAG,EAC/B,IAAO,EAAc,EAAE,CAAC,EACxB,IAAQ,EAAmB,KAAK,EAEhC,IAAS,SAAgB;GAC7B,OAAO,EAAM,MAAM,QAAQ,MAAM,EAAE,MAAM,CAAC;GAC1C,QAAQ,EAAM,MAAM,QAAQ,MAAM,EAAE,QAAQ,EAAE,CAAC;GAC/C,QAAQ,EAAM,MAAM,QAAQ,MAAM,EAAE,UAAU,CAAC;GAChD,EAAE,EAEG,IAAQ,QAAe;GAC3B;IAAE,OAAO;IAAc,OAAO,EAAM,MAAM;IAAQ,OAAO;IAAW;GACpE;IAAE,OAAO;IAAkB,OAAO,EAAM,MAAM,QAAQ,GAAG,MAAM,IAAI,EAAE,UAAU,EAAE;IAAE,OAAO;IAAW;GACrG;IAAE,OAAO;IAAe,OAAO,EAAM,MAAM,QAAQ,GAAG,MAAM,IAAI,EAAE,OAAO,EAAE;IAAE,OAAO;IAAS;GAC7F;IACE,OAAO;IACP,OAAO,EAAY,EAAM,MAAM,QAAQ,GAAG,MAAM,IAAI,EAAE,aAAa,EAAE,GAAG,IAAK;IAC7E,OAAO;IACR;GACD;IAAE,OAAO;IAAiB,OAAO,EAAO,MAAM;IAAO,OAAO;IAAS;GACrE;IAAE,OAAO;IAAc,OAAO,EAAO,MAAM;IAAQ,OAAO;IAAW;GACtE,CAAC,EAEI,IAAU,QAAe;GAC7B,IAAI,IAAO,EAAM;AACjB,GAAI,EAAO,UAAU,UAAS,IAAO,EAAK,QAAQ,MAAM,EAAE,MAAM,GACvD,EAAO,UAAU,WAAU,IAAO,EAAK,QAAQ,MAAM,EAAE,QAAQ,EAAE,GACjE,EAAO,UAAU,aAAU,IAAO,EAAK,QAAQ,MAAM,EAAE,UAAU;GAC1E,IAAM,IAAI,EAAO,OAAO,MAAM,CAAC,aAAa;AAU5C,UATI,MACF,IAAO,EAAK,QACT,MACC,EAAE,OAAO,aAAa,CAAC,SAAS,EAAE,KACjC,EAAE,MAAM,gBAAgB,IAAI,aAAa,CAAC,SAAS,EAAE,KACrD,EAAE,MAAM,QAAQ,EAAE,EAAE,MAAM,MAAM,EAAE,aAAa,CAAC,SAAS,EAAE,CAAC,IAC7D,OAAO,OAAO,EAAE,MAAM,QAAQ,EAAE,CAAC,CAAC,MAAM,MAAM,OAAO,KAAM,YAAY,EAAE,aAAa,CAAC,SAAS,EAAE,CAAA,CACrG,GAEI;IACP;EAGF,SAAS,SAAS,GAAwB;GACxC,IAAM,IAAI,OAAO,OAAO,EAAE,MAAM,QAAQ,EAAE,CAAC,CAAC,MAAM,MAAM,OAAO,KAAM,YAAY,EAAE,UAAU,GAAG;AAChG,UAAO,OAAO,KAAM,WAAW,IAAI;;EAGrC,eAAe,WAAW,GAAuC;AAC3D,IAAC,KAAM,CAAC,EAAM,MAAM,MAAM,MAAM,EAAE,WAAW,EAAG,KACpD,EAAO,QAAQ,OACf,EAAO,QAAQ,GACf,EAAK,QAAQ,CAAC,EAAG,EACjB,MAAM,GAAU,EAChB,SAAS,cAAc,eAAe,IAAI,OAAO,EAAG,CAAC,IAAI,EAAE,eAAe;IAAE,UAAU;IAAU,OAAO;IAAU,CAAC,EAClH,EAAM,QAAQ,GACd,iBAAiB;AACf,IAAI,EAAM,UAAU,MAAI,EAAM,QAAQ;MACrC,KAAK;;SAGV,QACQ,EAAM,QACX,MAAO,KAAK,WAAW,EAAG,EAC3B,EAAE,WAAW,IAAK,CACnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EEzCD,IAAM,IAAQ,GACR,IAAO,GAEP,IAAS,QAAe;GAC5B,IAAM,IAAI,EAAM,IAAI,eACd,IAAgE,EAAE;AAGxE,UAFI,GAAG,cAAY,EAAI,KAAK;IAAE,OAAO;IAAyB,MAAM,EAAE;IAAY,CAAC,EAC/E,GAAG,gBAAc,EAAI,KAAK;IAAE,OAAO;IAAiC,MAAM,EAAE;IAAc,CAAC,EACxF;IACP;EAEF,SAAS,SAAS,GAAmB;AACnC,UAAO,KAAK,IAAI,EAAE,IAAI,OAAQ,MAAM,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,cAAc,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EE6I3E,IAAM,IAAQ,GAMR,IAAO,GAEP,IAAS,QAAe,EAAc,EAAM,SAAS,EAAM,WAAW,EAAM,aAAa,CAAC,EAC1F,IAAM,QAAe,EAAO,MAAO,QAAQ,IAAI,EAC/C,IAAO,QAAe,EAAW,EAAO,OAAO,YAAY,EAAE,EAAE,EAAM,aAAa,CAAC,EACnF,IAAS,QAAe,EAAU,EAAO,OAAO,YAAY,EAAE,CAAC,CAAC,EAChE,IAAgB,QAAe,EAAO,OAAO,SAAS,QAAQ,MAAM,EAAE,SAAS,WAAW,CAAC,UAAU,EAAE,EAEvG,IAAO,EAA4C,MAAM,EACzD,IAAkB,QAAe;GACrC,IAAM,IAAI,EAAO,OAAO,YAAY,EAAE;AACtC,UAAO,CAAC,EAAK,SAAS,EAAK,UAAU,QAAQ,IAAI,EAAE,QAAQ,MAAM,EAAE,SAAS,EAAK,MAAM;IACvF,EAEI,IAAW,kBAAI,IAAI,KAAa,CAAC;EACvC,SAAS,OAAO,GAAqB;GACnC,IAAM,IAAO,IAAI,IAAI,EAAS,MAAM;AAGpC,GAFI,EAAK,IAAI,EAAM,GAAE,EAAK,OAAO,EAAM,GAClC,EAAK,IAAI,EAAM,EACpB,EAAS,QAAQ;;EAGnB,IAAM,EAAE,WAAQ,YAAS,SAAS;EAElC,SAAS,SAAiB;AACxB,UAAO,EAAO,QACV,EACE,EAAM,QAAQ,UACd,EAAO,OACP;IAAE,YAAY,EAAK;IAAO,WAAW,EAAO;IAAO,EACnD,EAAM,aACR,GACA;;EAGN,IAAM,IAAY,QAAe;GAC/B,IAAM,IAAI,EAAO,OAAO;AACxB,OAAI,CAAC,EAAG,QAAO;GACf,IAAM,IAAU,EAAY,EAAE,IAAI,eAAe;AACjD,UAAO,EAAE,oBAAoB,OAAO,QAAQ,MAAY,GAAG,EAAY,EAAE,gBAAgB,CAAC,MAAM;IAChG;EAGF,SAAS,UAAU,GAAuB;AACxC,UAAO,EAAE,QAAQ,MAAQ,EAAW,EAAM,cAAc,EAAI,CAAC,CAAC,MAAM,GAAG,EAAE;;EAG3E,SAAS,OAAO,GAA0B;AAExC,UADI,MAAM,OAAa,MAChB,OAAO,UAAU,EAAE,GAAG,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE;;EAGvD,SAAS,SAAS,GAAoB;AACpC,UAAO,KAAK,OAAO,SAAS,KAAK,UAAU,GAAG,MAAM,EAAE;;EAKxD,SAAS,UAAU,GAA0B;AAI3C,UAHI,EAAE,SAAS,QAAc,4BACzB,EAAE,cAAc,KAAa,6BAC7B,EAAE,cAAc,KAAc,6BAC3B;;EAGT,SAAS,WAAW,GAA0B;AAI5C,UAHI,EAAE,SAAS,QAAc,YACzB,EAAE,cAAc,KAAa,YAC7B,EAAE,cAAc,KAAc,UAC3B;;EAGT,SAAS,SAAS,GAA0B;AAE1C,UADI,EAAE,SAAS,QAAc,cACtB,EAAE,cAAc,KAAQ,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EE7QhD,IAAM,IAAQ,GAKR,IAAO,GAEP,IAAW,QAAe,EAAkB,EAAM,SAAS,EAAM,aAAa,CAAC,EAC/E,IAAS,EAAqC,MAAM,EAGpD,IAAc,QAAe;GACjC,IAAM,oBAAS,IAAI,KAA0B;AAC7C,QAAK,IAAM,KAAK,EAAS,MAAO,MAAK,IAAM,KAAK,EAAE,MAAO,GAAO,IAAI,IAAI,EAAO,IAAI,EAAE,IAAI,KAAK,EAAE;AAChG,UAAO,CAAC,GAAG,EAAO,SAAS,CAAC,CAAC,KAAK,CAAC,GAAM,QAAY;IAAE;IAAM;IAAO,EAAE,CAAC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM;IACxG,EAGI,IAAU,QACd,CAAC,EAAO,SAAS,EAAO,UAAU,QAC9B,EAAS,QACT,EAAS,MAAM,QAAQ,MAAM,EAAE,MAAM,SAAS,EAAO,MAAqB,CAAA,CAC/E,EAEK,IAAQ,QAAe;GAC3B,IAAM,IAAO,EAAS,OAChB,IAAS,EAAK,QAAQ,MAAM,EAAE,IAAI,WAAW,SAAS,CAAC,QACvD,IAAY,EAAK,QAAQ,GAAG,MAAM,KAAK,EAAE,IAAI,OAAO,aAAa,IAAI,EAAE,EACvE,IAAU,EAAK,QAAQ,GAAG,MAAM,KAAK,EAAE,IAAI,OAAO,WAAW,IAAI,EAAE,EACnE,IAAU,EAAK,QAAQ,GAAG,MAAM,KAAK,EAAE,mBAAmB,IAAI,EAAE;AACtE,UAAO;IACL;KAAE,OAAO;KAAY,OAAO,EAAK;KAAQ,OAAO;KAAW;IAC3D;KAAE,OAAO;KAAa,OAAO;KAAQ,OAAO;KAAW;IACvD;KAAE,OAAO;KAAiB,OAAO,EAAK,SAAS;KAAQ,OAAO;KAAS;IACvE;KAAE,OAAO;KAAa,OAAO;KAAW,OAAO;KAAW;IAC1D;KACE,OAAO;KACP,OAAO,IAAY,IAAI,GAAG,KAAK,MAAO,IAAU,IAAa,IAAI,CAAC,KAAK;KACvE,OAAO;KACR;IACD;KAAE,OAAO;KAAgB,OAAO,EAAY,EAAQ;KAAE,OAAO;KAAQ;IACtE;IACD;EAEF,SAAS,OAAO,GAAmE;GACjF,IAAM,IAAsD,EAAE;AAC9D,GAAI,EAAE,WAAW,QAAM,EAAI,KAAK;IAAE,OAAO;IAAO,OAAO,KAAK,MAAM,EAAE,OAAO;IAAE,CAAC;AAC9E,QAAK,IAAM,KAAS,EAAM,cAAc,mBAAmB,EAAE,EAAE;IAC7D,IAAM,IAAM,EAAE,MAAM,MAAM,MAAM,EAAE,UAAU,EAAM;AAClD,IAAI,KAAK,EAAI,KAAK;KAAE;KAAO,OAAO,EAAI;KAAO,CAAC;;AAEhD,UAAO;;EAIT,SAAS,UAAU,GAA2B;GAC5C,IAAM,IAAU,EAAY,EAAE,IAAI,eAAe;AACjD,UAAO,EAAE,oBAAoB,OAAO,QAAQ,MAAY,GAAG,EAAY,EAAE,gBAAgB,CAAC,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EE6DlG,IAAM,IAAQ,GAQR,IAA2E;GAC/E,gBAAgB;IAAE,OAAO;IAAgB,OAAO;IAAW;GAC3D,qBAAqB;IAAE,OAAO;IAA0B,OAAO;IAAa;GAC5E,oBAAoB;IAAE,OAAO;IAAoB,OAAO;IAAW;GACpE,EAEK,WAAW,MACf,EAAM,OAAO,WAAW,MAAM,MAAM,EAAE,SAAS,EAAK;EAEtD,SAAS,YAAY,GAA6B;AAChD,UACE,kCAAkC,EAAE,WAAW,GAAG,EAAE,KAAK,UAAU,EAAE,eAAe,QAAQ,EAAE,CAAC,SACvF,EAAE,cAAc,QAAQ,EAAE,CAAC,UAAU,EAAc,EAAE,KAAK,CAAC;;EAIvE,IAAM,IAAwC;GAC5C,MAAM;GACN,QAAQ;GACT;EAMD,SAAS,WAAW,GAAwE;GAC1F,IAAM,IAAO,EAAE,QAAQ,WAAW,EAAE,KAAK,EAAE;AAE3C,UADI,IAAa;IAAE;IAAM,OAAO;IAAM,MAAM,EAAU;IAAO,GACtD;IACL,MAAM;IACN,OAAO;IACP,MAAM,GAAG,EAAU,KAAK;IACzB;;EAGH,IAAM,IAAO,QAAe,GAAa,EAAM,YAAY,EAAM,SAAS,CAAC,EAErE,IAAW,QAAe,IAAI,KAAK,EAAM,OAAO,cAAc,EAAE,EAAE,KAAK,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAC3F,IAAc,QAAe,IAAI,KAAK,EAAM,OAAO,WAAW,EAAE,EAAE,KAAK,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAC3F,WAAW,MAA+C,EAAS,MAAM,IAAI,EAAK,EAClF,cAAc,MAA4C,EAAY,MAAM,IAAI,EAAK,EAGrF,IAAQ,QAAe;GAC3B,IAAM,IAAM,CAAC,GAAG,EAAK,MAAM,MAAM,EAC3B,IAAQ,EAAM;AACpB,OAAI,CAAC,EAAO,QAAO;AACnB,QAAK,IAAM,KAAK,EAAK,MAAM,SAAS;IAClC,IAAM,IAAI,WAAW,EAAE,KAAK;AAC5B,IAAI,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,YAAY,KAC/C,EAAI,KAAK,GAAG,EAAE,KAAK,mCAAmC,EAAE,KAAK,QAAQ;;AAGzE,OAAI,EAAM,oBAAoB,EAC5B,MAAK,IAAM,KAAK,EAAK,MAAM,YAAY;IACrC,IAAM,IAAI,QAAQ,EAAE,KAAK;AACzB,IAAI,KAAK,EAAE,OAAO,KAAK,EAAE,aAAa,KACpC,EAAI,KAAK,GAAG,EAAE,KAAK,0BAA0B,EAAE,KAAK,+BAA+B;;AAIzF,UAAO;IACP;EAEF,SAAS,WAAW,GAAoC;AACtD,UAAO,EAAE,gBAAgB,IAAI,EAAE,uBAAuB,EAAE,gBAAgB;;EAG1E,SAAS,YAAY,GAAkB;GACrC,IAAM,IAAQ,EAAE,UAAU,EAAE,YAAY,EAAE,SAAS,EAAE,WAAW;AAChE,UAAO;IACL;KAAE,KAAK;KAAW,OAAO,EAAE;KAAS,OAAO;KAAW;IACtD;KAAE,KAAK;KAAa,OAAO,EAAE;KAAW,OAAO;KAAW;IAC1D;KAAE,KAAK;KAAU,OAAO,EAAE;KAAQ,OAAO;KAAQ;IACjD;KAAE,KAAK;KAAW,OAAO,EAAE;KAAS,OAAO;KAAS;IACrD,CAAC,KAAK,OAAO;IAAE,GAAG;IAAG,OAAO,EAAE,QAAQ;IAAO,EAAE;;EAGlD,SAAS,OAAO,GAAmB;AACjC,UAAO,KAAK,KAAK,KAAK,MAAM,EAAE,CAAC,UAAU,GAAG,EAAE,QAAQ,EAAE;;EAG1D,SAAS,OAAO,GAAuB;AAErC,UADI,MAAU,IAAU,OACjB,IAAQ,MAAO,QAAQ,GAAG,KAAK,MAAM,IAAQ,IAAI,CAAC;;EAG3D,IAAM,IAAc,kBAAI,IAAI,KAAa,CAAC;EAE1C,SAAS,aAAa,GAAmB;GACvC,IAAM,IAAO,IAAI,IAAI,EAAY,MAAM;AAGvC,GAFI,EAAK,IAAI,EAAI,GAAE,EAAK,OAAO,EAAI,GAC9B,EAAK,IAAI,EAAI,EAClB,EAAY,QAAQ;;EAItB,SAAS,aAAa,GAAwC;AAC5D,OAAI,CAAC,EAAY,QAAO;AACxB,OAAI;IACF,IAAM,IAAkB,KAAK,MAAM,EAAW,EACxC,IAAO,KAAK,UAAU,GAAQ,MAAM,EAAE;AAC5C,WAAO,MAAS,OAAO,KAAK;WACtB;AACN,WAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EEjRX,IAAM,IAAQ,GAKR,IAAM,EAAuC,WAAW,EACxD,IAAY,EAAmB,KAAK,EACpC,IAAY,EAAwB,KAAA,EAAU,EAC9C,IAAgB,QAAe,EAAiB,EAAM,QAAQ,CAAC;EAErE,SAAS,SAAS,GAAsB;AAItC,GAFA,EAAU,QAAQ,KAAA,GAClB,EAAI,QAAQ,SACZ,iBAAkB,EAAU,QAAQ,EAAQ;;SAG9C,QACQ,EAAM,eACN;AAGJ,GAFA,EAAI,QAAQ,YACZ,EAAU,QAAQ,MAClB,EAAU,QAAQ,KAAA;IAErB"}