@vue-skuilder/common-ui 0.2.27 → 0.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/admin.css +2 -0
- package/dist/admin/common-ui-admin.es.js +1394 -0
- package/dist/admin/common-ui-admin.es.js.map +1 -0
- package/dist/admin/components/CardDossierDetail.vue.d.ts +9 -0
- package/dist/admin/components/CardDossierDetail.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerCardHistory.vue.d.ts +13 -0
- package/dist/admin/components/LearnerCardHistory.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerDatasetSource.vue.d.ts +24 -0
- package/dist/admin/components/LearnerDatasetSource.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerDiagnostics.vue.d.ts +13 -0
- package/dist/admin/components/LearnerDiagnostics.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerSessionDetail.vue.d.ts +14 -0
- package/dist/admin/components/LearnerSessionDetail.vue.d.ts.map +1 -0
- package/dist/admin/components/LearnerSessionList.vue.d.ts +13 -0
- package/dist/admin/components/LearnerSessionList.vue.d.ts.map +1 -0
- package/dist/admin/components/RunDetail.vue.d.ts +12 -0
- package/dist/admin/components/RunDetail.vue.d.ts.map +1 -0
- package/dist/admin/composables/useLearnerDataset.d.ts +26 -0
- package/dist/admin/composables/useLearnerDataset.d.ts.map +1 -0
- package/dist/admin/display.d.ts +20 -0
- package/dist/admin/display.d.ts.map +1 -0
- package/dist/admin/index.d.ts +22 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/components/CardBrowser.vue.d.ts +5 -5
- package/dist/components/CardHistoryViewer.vue.d.ts +1 -1
- package/dist/components/CardSearchResults.vue.d.ts +1 -1
- package/dist/components/CourseCardBrowser.vue.d.ts +9 -9
- package/dist/components/CourseInformation.vue.d.ts +18 -18
- package/dist/components/CourseTagFilterWidget.vue.d.ts +1 -1
- package/dist/components/SessionControllerDebug.vue.d.ts +1 -1
- package/dist/components/SnackbarService.d.ts +1 -1
- package/dist/components/StudySession.types.d.ts +1 -1
- package/dist/components/StudySession.vue.d.ts +2 -2
- package/dist/components/TagsInput.vue.d.ts +1 -1
- package/dist/components/UserTagPreferences.vue.d.ts +1 -1
- package/dist/components/auth/UserRegistration.vue.d.ts +74 -74
- package/dist/components/auth/UserRegistration.vue.d.ts.map +1 -1
- package/dist/components/cardRendering/CardLoader.vue.d.ts +6 -6
- package/dist/components/cardRendering/CardViewer.vue.d.ts +2 -2
- package/dist/components/cardRendering/MarkdownRenderer.vue.d.ts +6 -6
- package/dist/components/cardRendering/MdTokenRenderer.vue.d.ts +6 -6
- package/dist/components/studentInputs/BaseUserInput.d.ts +2 -2
- package/dist/components/studentInputs/MultipleChoiceOption.vue.d.ts +6 -6
- package/dist/components/studentInputs/RadioMultipleChoice.types.d.ts +1 -1
- package/dist/components/studentInputs/RadioMultipleChoice.vue.d.ts +9 -9
- package/dist/components/studentInputs/TrueFalse.vue.d.ts +9 -9
- package/dist/components/studentInputs/UserInputNumber.vue.d.ts +2 -2
- package/dist/components/studentInputs/UserInputString.vue.d.ts +3 -3
- package/dist/components/studentInputs/fillInInput.vue.d.ts +3 -3
- package/dist/composables/CompositionViewable.d.ts +2 -2
- package/dist/composables/Displayable.d.ts +1 -1
- package/dist/stores/useAuthStore.d.ts +82 -82
- package/dist/stores/useAuthStore.d.ts.map +1 -1
- package/dist/stores/useConfigStore.d.ts +1 -1
- package/package.json +18 -5
- package/src/admin/components/CardDossierDetail.vue +99 -0
- package/src/admin/components/LearnerCardHistory.vue +196 -0
- package/src/admin/components/LearnerDatasetSource.vue +97 -0
- package/src/admin/components/LearnerDiagnostics.vue +68 -0
- package/src/admin/components/LearnerSessionDetail.vue +439 -0
- package/src/admin/components/LearnerSessionList.vue +175 -0
- package/src/admin/components/RunDetail.vue +166 -0
- package/src/admin/composables/useLearnerDataset.ts +66 -0
- package/src/admin/display.ts +73 -0
- package/src/admin/index.ts +27 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<v-alert v-if="!detail" type="info" variant="tonal">No session with id {{ sessionId }}.</v-alert>
|
|
4
|
+
|
|
5
|
+
<template v-else>
|
|
6
|
+
<div class="d-flex align-center flex-wrap mb-4 ga-2">
|
|
7
|
+
<v-chip size="small" variant="flat" :color="statusColor(doc.status)">{{ doc.status }}</v-chip>
|
|
8
|
+
<v-chip size="small" variant="tonal">{{ fmtTime(doc.startTime) }}</v-chip>
|
|
9
|
+
<v-chip size="small" variant="tonal" color="grey">{{ clockText }}</v-chip>
|
|
10
|
+
<v-chip
|
|
11
|
+
v-for="f in detail.summary.flags"
|
|
12
|
+
:key="f"
|
|
13
|
+
size="small"
|
|
14
|
+
variant="tonal"
|
|
15
|
+
:color="FLAG_UI[f].color"
|
|
16
|
+
:title="SESSION_FLAGS[f].help"
|
|
17
|
+
>
|
|
18
|
+
<v-icon start size="small">{{ FLAG_UI[f].icon }}</v-icon>
|
|
19
|
+
{{ SESSION_FLAGS[f].label }}
|
|
20
|
+
</v-chip>
|
|
21
|
+
<v-spacer />
|
|
22
|
+
<code class="text-caption text-medium-emphasis">{{ sessionId }}</code>
|
|
23
|
+
<v-btn
|
|
24
|
+
variant="text"
|
|
25
|
+
size="small"
|
|
26
|
+
:prepend-icon="copied === 'session' ? 'mdi-check' : 'mdi-clipboard-text-outline'"
|
|
27
|
+
:color="copied === 'session' ? 'success' : undefined"
|
|
28
|
+
@click="copy(asText(), 'session')"
|
|
29
|
+
>
|
|
30
|
+
{{ copied === 'session' ? 'Copied' : 'Copy as text' }}
|
|
31
|
+
</v-btn>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<v-alert v-if="detail.responsesMissing" type="warning" variant="tonal" density="compact" class="mb-4">
|
|
35
|
+
The session records {{ doc.tally?.responses }} responses but only {{ responseCount }} card records carry its id.
|
|
36
|
+
The timeline is incomplete.
|
|
37
|
+
</v-alert>
|
|
38
|
+
|
|
39
|
+
<v-row class="mb-2">
|
|
40
|
+
<v-col cols="12" md="6">
|
|
41
|
+
<v-card variant="outlined" class="pa-3 h-100">
|
|
42
|
+
<div class="text-subtitle-2 mb-1">Learner state</div>
|
|
43
|
+
<div class="text-caption text-medium-emphasis mb-3">
|
|
44
|
+
What the navigator was choosing against, and what moved.
|
|
45
|
+
</div>
|
|
46
|
+
<v-table v-if="detail.summary.state.length" density="compact" class="sk-mini-table">
|
|
47
|
+
<thead>
|
|
48
|
+
<tr>
|
|
49
|
+
<th></th>
|
|
50
|
+
<th class="text-right">Start</th>
|
|
51
|
+
<th class="text-right">End</th>
|
|
52
|
+
<th class="text-right">Δ</th>
|
|
53
|
+
</tr>
|
|
54
|
+
</thead>
|
|
55
|
+
<tbody>
|
|
56
|
+
<tr v-for="row in detail.summary.state" :key="row.label">
|
|
57
|
+
<td>{{ row.label }}</td>
|
|
58
|
+
<td class="text-right">{{ fmtNum(row.from) }}</td>
|
|
59
|
+
<td class="text-right">{{ fmtNum(row.to) }}</td>
|
|
60
|
+
<td class="text-right" :class="deltaClass(row.delta)">{{ fmtDelta(row.delta) }}</td>
|
|
61
|
+
</tr>
|
|
62
|
+
</tbody>
|
|
63
|
+
</v-table>
|
|
64
|
+
<div v-else class="text-caption text-disabled">No state snapshot recorded.</div>
|
|
65
|
+
</v-card>
|
|
66
|
+
</v-col>
|
|
67
|
+
|
|
68
|
+
<v-col cols="12" md="6">
|
|
69
|
+
<v-card variant="outlined" class="pa-3 h-100">
|
|
70
|
+
<div class="text-subtitle-2 mb-1">Session hints</div>
|
|
71
|
+
<div class="text-caption text-medium-emphasis mb-3">
|
|
72
|
+
What outcome observers told the navigator. They act only through these.
|
|
73
|
+
</div>
|
|
74
|
+
<span class="text-caption font-weight-medium">at open</span>
|
|
75
|
+
<pre class="sk-hint-block">{{ fmtHints(doc.config?.initHints) }}</pre>
|
|
76
|
+
<span class="text-caption font-weight-medium">at close</span>
|
|
77
|
+
<pre class="sk-hint-block">{{ fmtHints(doc.finalHints) }}</pre>
|
|
78
|
+
</v-card>
|
|
79
|
+
</v-col>
|
|
80
|
+
</v-row>
|
|
81
|
+
|
|
82
|
+
<div class="text-caption text-medium-emphasis mb-4">
|
|
83
|
+
batch limit {{ doc.config?.defaultBatchLimit ?? '?' }} · {{ doc.config?.sourceCount ?? '?' }} source(s) ·
|
|
84
|
+
opening queues supply {{ doc.initialQueues?.supplyQ ?? '?' }} / failed {{ doc.initialQueues?.failedQ ?? '?' }}
|
|
85
|
+
<template v-if="doc.tally"> · ended with {{ doc.tally.failedQRemaining }} still in the failed queue</template>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<v-card v-if="tags.length" variant="outlined" class="pa-3 mb-4">
|
|
89
|
+
<div class="text-subtitle-2 mb-1">Served tags</div>
|
|
90
|
+
<div class="text-caption text-medium-emphasis mb-2">
|
|
91
|
+
What this sitting put in front of the learner, by content tag. A response with several tags counts toward
|
|
92
|
+
each.
|
|
93
|
+
</div>
|
|
94
|
+
<v-table density="compact" class="sk-mini-table">
|
|
95
|
+
<thead>
|
|
96
|
+
<tr>
|
|
97
|
+
<th>Tag</th>
|
|
98
|
+
<th class="text-right">Resp.</th>
|
|
99
|
+
<th class="text-right">Cards</th>
|
|
100
|
+
<th class="text-right">Accuracy</th>
|
|
101
|
+
<th class="text-right">Time</th>
|
|
102
|
+
</tr>
|
|
103
|
+
</thead>
|
|
104
|
+
<tbody>
|
|
105
|
+
<tr v-for="t in tags" :key="t.tag">
|
|
106
|
+
<td>
|
|
107
|
+
<code :class="isSkillTag(interpreters, t.tag) ? 'text-primary' : ''">{{ t.tag }}</code>
|
|
108
|
+
</td>
|
|
109
|
+
<td class="text-right">{{ t.responses }}</td>
|
|
110
|
+
<td class="text-right">{{ t.cards }}</td>
|
|
111
|
+
<td class="text-right" :class="accuracyClass(t.accuracy)">
|
|
112
|
+
<template v-if="t.accuracy === null">—</template>
|
|
113
|
+
<template v-else>
|
|
114
|
+
{{ Math.round(t.accuracy * 100) }}%
|
|
115
|
+
<span class="text-disabled">({{ t.correct }}/{{ t.correct + t.incorrect }})</span>
|
|
116
|
+
</template>
|
|
117
|
+
</td>
|
|
118
|
+
<td class="text-right">{{ (t.totalTimeMs / 1000).toFixed(1) }}s</td>
|
|
119
|
+
</tr>
|
|
120
|
+
</tbody>
|
|
121
|
+
</v-table>
|
|
122
|
+
</v-card>
|
|
123
|
+
|
|
124
|
+
<v-card v-if="ledger" variant="outlined" class="pa-3 mb-4">
|
|
125
|
+
<div class="text-subtitle-2 mb-1">ELO ledger</div>
|
|
126
|
+
<div class="text-caption text-medium-emphasis mb-3">
|
|
127
|
+
The per-response learner global-ELO exchanges that net to the session's ELO move. Wins above the learner's
|
|
128
|
+
level pay little; losses below it cost a lot, so a high hit rate can still net negative.
|
|
129
|
+
</div>
|
|
130
|
+
<div class="d-flex flex-wrap ga-5">
|
|
131
|
+
<div>
|
|
132
|
+
<div class="text-caption text-medium-emphasis">net</div>
|
|
133
|
+
<div class="text-h6" :class="deltaClass(ledger.net)">{{ fmtDelta(ledger.net) }}</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div>
|
|
136
|
+
<div class="text-caption text-medium-emphasis">gained · {{ ledger.wins }} win(s)</div>
|
|
137
|
+
<div class="text-h6 text-success">{{ fmtDelta(ledger.gained) }}</div>
|
|
138
|
+
</div>
|
|
139
|
+
<div>
|
|
140
|
+
<div class="text-caption text-medium-emphasis">lost · {{ ledger.losses }} loss(es)</div>
|
|
141
|
+
<div class="text-h6 text-error">{{ fmtDelta(ledger.lost) }}</div>
|
|
142
|
+
</div>
|
|
143
|
+
<div>
|
|
144
|
+
<div class="text-caption text-medium-emphasis">exchanges</div>
|
|
145
|
+
<div class="text-h6">{{ ledger.count }}</div>
|
|
146
|
+
<div class="text-caption text-disabled">of {{ responseCount }} response(s)</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</v-card>
|
|
150
|
+
|
|
151
|
+
<div class="d-flex align-center flex-wrap mb-2 ga-3">
|
|
152
|
+
<h2 class="text-h6">Timeline</h2>
|
|
153
|
+
<v-btn-toggle v-model="show" density="compact" color="primary" variant="outlined">
|
|
154
|
+
<v-btn value="all" size="small">All ({{ detail.timeline.length }})</v-btn>
|
|
155
|
+
<v-btn value="response" size="small">Responses ({{ responseCount }})</v-btn>
|
|
156
|
+
<v-btn value="run" size="small">Runs ({{ detail.timeline.length - responseCount }})</v-btn>
|
|
157
|
+
</v-btn-toggle>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<v-alert v-if="detail.timeline.length === 0" type="info" variant="tonal"
|
|
161
|
+
>Nothing recorded for this session.</v-alert
|
|
162
|
+
>
|
|
163
|
+
|
|
164
|
+
<div v-else class="sk-timeline">
|
|
165
|
+
<div v-for="(e, i) in visibleTimeline" :key="i" class="sk-tl-row" :class="rowClass(e)">
|
|
166
|
+
<div class="sk-tl-time text-caption text-disabled">{{ fmtClock(e.at) }}</div>
|
|
167
|
+
<div class="sk-tl-gutter">
|
|
168
|
+
<v-icon size="small" :color="entryColor(e)">{{ entryIcon(e) }}</v-icon>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div v-if="e.kind === 'run'" class="sk-tl-body">
|
|
172
|
+
<div class="d-flex align-center flex-wrap ga-1">
|
|
173
|
+
<span class="font-weight-medium">{{ e.run.label }}</span>
|
|
174
|
+
<v-chip v-if="e.run.mode" size="x-small" variant="tonal">{{ e.run.mode }}</v-chip>
|
|
175
|
+
<span class="text-caption text-medium-emphasis ml-2">
|
|
176
|
+
generated {{ e.run.generatedCount }} → selected {{ e.run.finalCount }} ({{ e.run.newSelected }} new,
|
|
177
|
+
{{ e.run.reviewsSelected }} review)
|
|
178
|
+
</span>
|
|
179
|
+
<span v-if="e.run.generatedCount > 0 && e.run.finalCount === 0" class="text-caption text-error ml-2">
|
|
180
|
+
nothing survived
|
|
181
|
+
</span>
|
|
182
|
+
<v-chip v-if="typeof e.run.userElo === 'number'" size="x-small" variant="tonal" color="grey" class="ml-2">
|
|
183
|
+
ELO {{ Math.round(e.run.userElo) }}
|
|
184
|
+
</v-chip>
|
|
185
|
+
<v-spacer />
|
|
186
|
+
<v-btn
|
|
187
|
+
:icon="expanded.has(e.run.runId) ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
|
188
|
+
size="x-small"
|
|
189
|
+
variant="text"
|
|
190
|
+
@click="toggle(e.run.runId)"
|
|
191
|
+
/>
|
|
192
|
+
<v-btn
|
|
193
|
+
:icon="copied === e.run.runId ? 'mdi-check' : 'mdi-content-copy'"
|
|
194
|
+
size="x-small"
|
|
195
|
+
variant="text"
|
|
196
|
+
:color="copied === e.run.runId ? 'success' : undefined"
|
|
197
|
+
title="Copy run as text"
|
|
198
|
+
@click="copy(runText(e.run), e.run.runId)"
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
<run-detail
|
|
202
|
+
v-if="expanded.has(e.run.runId)"
|
|
203
|
+
:run="e.run"
|
|
204
|
+
class="mt-1"
|
|
205
|
+
@open-card="(id: string) => emit('open-card', id)"
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div v-else class="sk-tl-body">
|
|
210
|
+
<a class="sk-card-link" :title="`Open ${e.cardId} in card history`" @click="emit('open-card', e.cardId)">
|
|
211
|
+
{{ e.questionType ?? 'card' }} <code class="text-caption">{{ shortId(e.cardId) }}</code>
|
|
212
|
+
</a>
|
|
213
|
+
<v-chip
|
|
214
|
+
v-for="t in skillTags(e.tags)"
|
|
215
|
+
:key="t"
|
|
216
|
+
size="x-small"
|
|
217
|
+
variant="outlined"
|
|
218
|
+
color="primary"
|
|
219
|
+
class="ml-1"
|
|
220
|
+
>
|
|
221
|
+
{{ shortTag(interpreters, t) }}
|
|
222
|
+
</v-chip>
|
|
223
|
+
<span class="text-caption text-medium-emphasis ml-2">
|
|
224
|
+
{{ (e.timeSpentMs / 1000).toFixed(1) }}s
|
|
225
|
+
<template v-if="e.priorAttempts"> · attempt {{ e.priorAttempts + 1 }}</template>
|
|
226
|
+
</span>
|
|
227
|
+
<span v-if="e.userAnswer != null" class="text-caption text-disabled ml-2"
|
|
228
|
+
>“{{ fmtAnswer(e.userAnswer) }}”</span
|
|
229
|
+
>
|
|
230
|
+
<template v-if="e.elo">
|
|
231
|
+
<v-chip
|
|
232
|
+
size="x-small"
|
|
233
|
+
variant="tonal"
|
|
234
|
+
:color="deltaColor(e.elo.global.delta)"
|
|
235
|
+
class="ml-2"
|
|
236
|
+
:title="`learner global ELO ${e.elo.global.before} → ${e.elo.global.after} · userScore ${e.elo.userScore.toFixed(2)}`"
|
|
237
|
+
>
|
|
238
|
+
ELO {{ fmtDelta(e.elo.global.delta) }}
|
|
239
|
+
</v-chip>
|
|
240
|
+
<v-chip
|
|
241
|
+
v-for="t in scoredTagDeltas(e.elo)"
|
|
242
|
+
:key="t.tag"
|
|
243
|
+
size="x-small"
|
|
244
|
+
variant="text"
|
|
245
|
+
:color="deltaColor(t.delta)"
|
|
246
|
+
class="sk-elo-tag"
|
|
247
|
+
:title="`${t.tag}: ${t.before} → ${t.after} (score ${t.score?.toFixed(2)})`"
|
|
248
|
+
>
|
|
249
|
+
{{ shortTag(interpreters, t.tag) }} {{ fmtDelta(t.delta) }}
|
|
250
|
+
</v-chip>
|
|
251
|
+
</template>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</template>
|
|
256
|
+
</div>
|
|
257
|
+
</template>
|
|
258
|
+
|
|
259
|
+
<script setup lang="ts">
|
|
260
|
+
import { computed, ref } from 'vue';
|
|
261
|
+
import {
|
|
262
|
+
eloLedger,
|
|
263
|
+
fmtAnswer,
|
|
264
|
+
fmtDelta,
|
|
265
|
+
fmtDuration,
|
|
266
|
+
isSkillTag,
|
|
267
|
+
runText,
|
|
268
|
+
scoredTagDeltas,
|
|
269
|
+
servedTags,
|
|
270
|
+
SESSION_FLAGS,
|
|
271
|
+
sessionDetail,
|
|
272
|
+
sessionText,
|
|
273
|
+
shortTag,
|
|
274
|
+
type DiagnosticsInterpreters,
|
|
275
|
+
type LearnerDataset,
|
|
276
|
+
type TimelineEntry,
|
|
277
|
+
} from '@vue-skuilder/db/diagnostics';
|
|
278
|
+
import {
|
|
279
|
+
accuracyClass,
|
|
280
|
+
deltaClass,
|
|
281
|
+
deltaColor,
|
|
282
|
+
FLAG_UI,
|
|
283
|
+
fmtClock,
|
|
284
|
+
fmtTime,
|
|
285
|
+
shortId,
|
|
286
|
+
statusColor,
|
|
287
|
+
useCopy,
|
|
288
|
+
} from '../display';
|
|
289
|
+
import RunDetail from './RunDetail.vue';
|
|
290
|
+
|
|
291
|
+
/** One session: headline, state, hints, served tags, ELO ledger, and the interleaved timeline. */
|
|
292
|
+
const props = defineProps<{
|
|
293
|
+
dataset: LearnerDataset;
|
|
294
|
+
sessionId: string;
|
|
295
|
+
interpreters?: DiagnosticsInterpreters;
|
|
296
|
+
}>();
|
|
297
|
+
|
|
298
|
+
const emit = defineEmits<{ 'open-card': [cardId: string] }>();
|
|
299
|
+
|
|
300
|
+
const detail = computed(() => sessionDetail(props.dataset, props.sessionId, props.interpreters));
|
|
301
|
+
const doc = computed(() => detail.value!.summary.doc);
|
|
302
|
+
const tags = computed(() => servedTags(detail.value?.timeline ?? [], props.interpreters));
|
|
303
|
+
const ledger = computed(() => eloLedger(detail.value?.timeline ?? []));
|
|
304
|
+
const responseCount = computed(() => detail.value?.timeline.filter((e) => e.kind === 'response').length ?? 0);
|
|
305
|
+
|
|
306
|
+
const show = ref<'all' | 'response' | 'run' | undefined>('all');
|
|
307
|
+
const visibleTimeline = computed(() => {
|
|
308
|
+
const t = detail.value?.timeline ?? [];
|
|
309
|
+
return !show.value || show.value === 'all' ? t : t.filter((e) => e.kind === show.value);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const expanded = ref(new Set<string>());
|
|
313
|
+
function toggle(runId: string): void {
|
|
314
|
+
const next = new Set(expanded.value);
|
|
315
|
+
if (next.has(runId)) next.delete(runId);
|
|
316
|
+
else next.add(runId);
|
|
317
|
+
expanded.value = next;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const { copied, copy } = useCopy();
|
|
321
|
+
|
|
322
|
+
function asText(): string {
|
|
323
|
+
return detail.value
|
|
324
|
+
? sessionText(
|
|
325
|
+
props.dataset.username,
|
|
326
|
+
detail.value,
|
|
327
|
+
{ servedTags: tags.value, eloLedger: ledger.value },
|
|
328
|
+
props.interpreters
|
|
329
|
+
)
|
|
330
|
+
: '';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const clockText = computed(() => {
|
|
334
|
+
const s = detail.value?.summary;
|
|
335
|
+
if (!s) return '';
|
|
336
|
+
const planned = fmtDuration(s.doc.plannedSeconds);
|
|
337
|
+
return s.durationSeconds === null ? `— of ${planned}` : `${fmtDuration(s.durationSeconds)} of ${planned}`;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/** Skill tags inline, at most three. Without a course interpreter, none are singled out. */
|
|
341
|
+
function skillTags(t: string[]): string[] {
|
|
342
|
+
return t.filter((tag) => isSkillTag(props.interpreters, tag)).slice(0, 3);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function fmtNum(n: number | null): string {
|
|
346
|
+
if (n === null) return '—';
|
|
347
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function fmtHints(h: unknown): string {
|
|
351
|
+
return h == null ? 'none' : JSON.stringify(h, null, 2);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Narrow the timeline union in script: vue-tsc handles discriminated unions
|
|
355
|
+
// inside template bindings least reliably.
|
|
356
|
+
function entryIcon(e: TimelineEntry): string {
|
|
357
|
+
if (e.kind === 'run') return 'mdi-cog-refresh-outline';
|
|
358
|
+
if (e.isCorrect === true) return 'mdi-check-circle-outline';
|
|
359
|
+
if (e.isCorrect === false) return 'mdi-close-circle-outline';
|
|
360
|
+
return 'mdi-circle-small';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function entryColor(e: TimelineEntry): string {
|
|
364
|
+
if (e.kind === 'run') return 'primary';
|
|
365
|
+
if (e.isCorrect === true) return 'success';
|
|
366
|
+
if (e.isCorrect === false) return 'error';
|
|
367
|
+
return 'grey';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function rowClass(e: TimelineEntry): string {
|
|
371
|
+
if (e.kind === 'run') return 'sk-tl-run';
|
|
372
|
+
return e.isCorrect === false ? 'sk-tl-fail' : '';
|
|
373
|
+
}
|
|
374
|
+
</script>
|
|
375
|
+
|
|
376
|
+
<style scoped>
|
|
377
|
+
.sk-mini-table :deep(td),
|
|
378
|
+
.sk-mini-table :deep(th) {
|
|
379
|
+
font-size: 0.78rem;
|
|
380
|
+
}
|
|
381
|
+
.sk-hint-block {
|
|
382
|
+
font-size: 0.72rem;
|
|
383
|
+
background: rgba(var(--v-theme-on-surface), 0.04);
|
|
384
|
+
border-radius: 4px;
|
|
385
|
+
padding: 6px 8px;
|
|
386
|
+
margin: 0 0 8px;
|
|
387
|
+
max-height: 140px;
|
|
388
|
+
overflow: auto;
|
|
389
|
+
white-space: pre-wrap;
|
|
390
|
+
}
|
|
391
|
+
.sk-timeline {
|
|
392
|
+
border-left: 2px solid rgba(var(--v-theme-on-surface), 0.12);
|
|
393
|
+
margin-left: 62px;
|
|
394
|
+
}
|
|
395
|
+
.sk-tl-row {
|
|
396
|
+
display: flex;
|
|
397
|
+
align-items: baseline;
|
|
398
|
+
gap: 8px;
|
|
399
|
+
padding: 3px 8px 3px 0;
|
|
400
|
+
position: relative;
|
|
401
|
+
}
|
|
402
|
+
.sk-tl-time {
|
|
403
|
+
position: absolute;
|
|
404
|
+
left: -62px;
|
|
405
|
+
width: 56px;
|
|
406
|
+
text-align: right;
|
|
407
|
+
font-variant-numeric: tabular-nums;
|
|
408
|
+
}
|
|
409
|
+
.sk-tl-gutter {
|
|
410
|
+
margin-left: -11px;
|
|
411
|
+
background: rgb(var(--v-theme-surface));
|
|
412
|
+
line-height: 1;
|
|
413
|
+
}
|
|
414
|
+
.sk-tl-body {
|
|
415
|
+
flex: 1;
|
|
416
|
+
min-width: 0;
|
|
417
|
+
font-size: 0.82rem;
|
|
418
|
+
}
|
|
419
|
+
/* Runs are the navigator speaking: tint them so responses read as the
|
|
420
|
+
foreground stream and replans as punctuation between them. */
|
|
421
|
+
.sk-tl-run {
|
|
422
|
+
background-color: rgba(var(--v-theme-primary), 0.05);
|
|
423
|
+
}
|
|
424
|
+
.sk-tl-fail {
|
|
425
|
+
background-color: rgba(var(--v-theme-error), 0.06);
|
|
426
|
+
}
|
|
427
|
+
.sk-card-link {
|
|
428
|
+
cursor: pointer;
|
|
429
|
+
color: inherit;
|
|
430
|
+
border-bottom: 1px dotted rgba(var(--v-theme-on-surface), 0.3);
|
|
431
|
+
}
|
|
432
|
+
.sk-card-link:hover {
|
|
433
|
+
color: rgb(var(--v-theme-primary));
|
|
434
|
+
}
|
|
435
|
+
.sk-elo-tag {
|
|
436
|
+
font-size: 0.68rem !important;
|
|
437
|
+
padding-inline: 4px !important;
|
|
438
|
+
}
|
|
439
|
+
</style>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<p class="text-caption text-medium-emphasis mb-4">
|
|
4
|
+
One row per sitting. Flags name a specific way the session failed to do its job; open one to read its interleaved
|
|
5
|
+
timeline of responses and pipeline runs. <strong>Δ</strong> is movement across the session: ELO, then any progress
|
|
6
|
+
metrics the course declares.
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<v-row dense class="mb-2">
|
|
10
|
+
<v-col v-for="tile in tiles" :key="tile.label" cols="6" sm="4" md="2">
|
|
11
|
+
<v-card variant="tonal" :color="tile.color" class="pa-2 text-center">
|
|
12
|
+
<div class="text-h6">{{ tile.value }}</div>
|
|
13
|
+
<div class="text-caption">{{ tile.label }}</div>
|
|
14
|
+
</v-card>
|
|
15
|
+
</v-col>
|
|
16
|
+
</v-row>
|
|
17
|
+
|
|
18
|
+
<div class="d-flex align-center flex-wrap mb-3 ga-3">
|
|
19
|
+
<v-btn-toggle v-model="filter" density="compact" color="primary" variant="outlined">
|
|
20
|
+
<v-btn value="all" size="small">All ({{ sessions.length }})</v-btn>
|
|
21
|
+
<v-btn v-for="f in activeFlags" :key="f.flag" :value="f.flag" size="small" :title="SESSION_FLAGS[f.flag].help">
|
|
22
|
+
<v-icon start size="small" :color="FLAG_UI[f.flag].color">{{ FLAG_UI[f.flag].icon }}</v-icon>
|
|
23
|
+
{{ SESSION_FLAGS[f.flag].label }} ({{ f.count }})
|
|
24
|
+
</v-btn>
|
|
25
|
+
</v-btn-toggle>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<v-alert v-if="sessions.length === 0" type="info" variant="tonal">
|
|
29
|
+
No study sessions recorded. Session records began with a framework release; there is no backfill, so earlier
|
|
30
|
+
activity shows only in card history.
|
|
31
|
+
</v-alert>
|
|
32
|
+
|
|
33
|
+
<v-table v-else density="compact" class="sk-admin-table">
|
|
34
|
+
<thead>
|
|
35
|
+
<tr>
|
|
36
|
+
<th>Started</th>
|
|
37
|
+
<th>Status</th>
|
|
38
|
+
<th class="text-right">Cards</th>
|
|
39
|
+
<th class="text-right">Acc.</th>
|
|
40
|
+
<th class="text-right">Runs</th>
|
|
41
|
+
<th>Clock</th>
|
|
42
|
+
<th>Δ</th>
|
|
43
|
+
<th>Flags</th>
|
|
44
|
+
<th></th>
|
|
45
|
+
</tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
<tr v-for="s in visible" :key="s.doc.sessionId" class="sk-admin-row" @click="emit('open', s.doc.sessionId)">
|
|
49
|
+
<td class="text-no-wrap">{{ fmtTime(s.doc.startTime) }}</td>
|
|
50
|
+
<td>
|
|
51
|
+
<v-chip size="x-small" variant="flat" :color="statusColor(s.doc.status)">{{ s.doc.status }}</v-chip>
|
|
52
|
+
</td>
|
|
53
|
+
<td class="text-right">{{ s.doc.tally?.cardsPresented ?? '—' }}</td>
|
|
54
|
+
<td class="text-right">
|
|
55
|
+
<span v-if="s.accuracy !== null" :class="accuracyClass(s.accuracy)">
|
|
56
|
+
{{ Math.round(s.accuracy * 100) }}%
|
|
57
|
+
</span>
|
|
58
|
+
<span v-else class="text-disabled">—</span>
|
|
59
|
+
</td>
|
|
60
|
+
<td class="text-right">{{ s.doc.runs?.length ?? 0 }}</td>
|
|
61
|
+
<td class="text-no-wrap text-caption">{{ clockText(s) }}</td>
|
|
62
|
+
<td class="text-no-wrap text-caption">
|
|
63
|
+
<template v-for="(d, i) in deltas(s)" :key="d.label">
|
|
64
|
+
<span v-if="i > 0"> · </span>
|
|
65
|
+
<span :class="deltaClass(d.delta)">{{ fmtDelta(d.delta) }} {{ d.label }}</span>
|
|
66
|
+
</template>
|
|
67
|
+
<span v-if="deltas(s).length === 0" class="text-disabled">—</span>
|
|
68
|
+
</td>
|
|
69
|
+
<td>
|
|
70
|
+
<v-icon
|
|
71
|
+
v-for="f in s.flags"
|
|
72
|
+
:key="f"
|
|
73
|
+
size="small"
|
|
74
|
+
class="mr-1"
|
|
75
|
+
:color="FLAG_UI[f].color"
|
|
76
|
+
:title="`${SESSION_FLAGS[f].label}: ${SESSION_FLAGS[f].help}`"
|
|
77
|
+
>
|
|
78
|
+
{{ FLAG_UI[f].icon }}
|
|
79
|
+
</v-icon>
|
|
80
|
+
</td>
|
|
81
|
+
<td class="text-right"><v-icon size="small" class="text-disabled">mdi-chevron-right</v-icon></td>
|
|
82
|
+
</tr>
|
|
83
|
+
</tbody>
|
|
84
|
+
</v-table>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<script setup lang="ts">
|
|
89
|
+
import { computed, ref } from 'vue';
|
|
90
|
+
import {
|
|
91
|
+
fmtDelta,
|
|
92
|
+
fmtDuration,
|
|
93
|
+
SESSION_FLAGS,
|
|
94
|
+
summarizeSessions,
|
|
95
|
+
type DiagnosticsInterpreters,
|
|
96
|
+
type LearnerDataset,
|
|
97
|
+
type SessionFlag,
|
|
98
|
+
type SessionSummary,
|
|
99
|
+
} from '@vue-skuilder/db/diagnostics';
|
|
100
|
+
import { accuracyClass, deltaClass, FLAG_UI, fmtTime, statusColor } from '../display';
|
|
101
|
+
|
|
102
|
+
/** A learner's sessions, newest first, as triage. Emits `open` with a session id. */
|
|
103
|
+
const props = defineProps<{
|
|
104
|
+
dataset: LearnerDataset;
|
|
105
|
+
interpreters?: DiagnosticsInterpreters;
|
|
106
|
+
}>();
|
|
107
|
+
|
|
108
|
+
const emit = defineEmits<{ open: [sessionId: string] }>();
|
|
109
|
+
|
|
110
|
+
const sessions = computed(() => summarizeSessions(props.dataset, props.interpreters));
|
|
111
|
+
const filter = ref<'all' | SessionFlag | undefined>('all');
|
|
112
|
+
|
|
113
|
+
/** Only offer filters for flags that occur. */
|
|
114
|
+
const activeFlags = computed(() => {
|
|
115
|
+
const counts = new Map<SessionFlag, number>();
|
|
116
|
+
for (const s of sessions.value) for (const f of s.flags) counts.set(f, (counts.get(f) ?? 0) + 1);
|
|
117
|
+
return [...counts.entries()].map(([flag, count]) => ({ flag, count })).sort((a, b) => b.count - a.count);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// `!filter.value` covers v-btn-toggle's deselect, which clears the model.
|
|
121
|
+
const visible = computed(() =>
|
|
122
|
+
!filter.value || filter.value === 'all'
|
|
123
|
+
? sessions.value
|
|
124
|
+
: sessions.value.filter((s) => s.flags.includes(filter.value as SessionFlag))
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const tiles = computed(() => {
|
|
128
|
+
const list = sessions.value;
|
|
129
|
+
const closed = list.filter((s) => s.doc.status === 'closed').length;
|
|
130
|
+
const responses = list.reduce((n, s) => n + (s.doc.tally?.responses ?? 0), 0);
|
|
131
|
+
const correct = list.reduce((n, s) => n + (s.doc.tally?.correct ?? 0), 0);
|
|
132
|
+
const seconds = list.reduce((n, s) => n + (s.durationSeconds ?? 0), 0);
|
|
133
|
+
return [
|
|
134
|
+
{ label: 'sessions', value: list.length, color: 'primary' },
|
|
135
|
+
{ label: 'completed', value: closed, color: 'success' },
|
|
136
|
+
{ label: 'not completed', value: list.length - closed, color: 'error' },
|
|
137
|
+
{ label: 'responses', value: responses, color: 'primary' },
|
|
138
|
+
{
|
|
139
|
+
label: 'overall acc.',
|
|
140
|
+
value: responses > 0 ? `${Math.round((correct / responses) * 100)}%` : '—',
|
|
141
|
+
color: 'primary',
|
|
142
|
+
},
|
|
143
|
+
{ label: 'time studied', value: fmtDuration(seconds), color: 'grey' },
|
|
144
|
+
];
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
function deltas(s: SessionSummary): Array<{ label: string; delta: number | null }> {
|
|
148
|
+
const out: Array<{ label: string; delta: number | null }> = [];
|
|
149
|
+
if (s.eloNet !== null) out.push({ label: 'elo', delta: Math.round(s.eloNet) });
|
|
150
|
+
for (const label of props.interpreters?.progressMetrics ?? []) {
|
|
151
|
+
const row = s.state.find((r) => r.label === label);
|
|
152
|
+
if (row) out.push({ label, delta: row.delta });
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** "1m40s of 2m": how much of the planned dose was spent. */
|
|
158
|
+
function clockText(s: SessionSummary): string {
|
|
159
|
+
const planned = fmtDuration(s.doc.plannedSeconds);
|
|
160
|
+
return s.durationSeconds === null ? `— of ${planned}` : `${fmtDuration(s.durationSeconds)} of ${planned}`;
|
|
161
|
+
}
|
|
162
|
+
</script>
|
|
163
|
+
|
|
164
|
+
<style scoped>
|
|
165
|
+
.sk-admin-table :deep(td),
|
|
166
|
+
.sk-admin-table :deep(th) {
|
|
167
|
+
font-size: 0.78rem;
|
|
168
|
+
}
|
|
169
|
+
.sk-admin-row {
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
}
|
|
172
|
+
.sk-admin-row:hover {
|
|
173
|
+
background-color: rgba(var(--v-theme-primary), 0.05);
|
|
174
|
+
}
|
|
175
|
+
</style>
|