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