@vue-skuilder/common-ui 0.2.34 → 0.2.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/dist/_virtual/_@oxc-project_runtime@0.115.0/helpers/defineProperty.mjs +13 -0
  2. package/dist/_virtual/_@oxc-project_runtime@0.115.0/helpers/toPrimitive.mjs +15 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.115.0/helpers/toPropertyKey.mjs +10 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.115.0/helpers/typeof.mjs +12 -0
  5. package/dist/_virtual/_plugin-vue_export-helper.mjs +9 -0
  6. package/dist/_virtual/_rolldown/runtime.mjs +7 -0
  7. package/dist/admin/admin.css +1 -1
  8. package/dist/admin/common-ui-admin.es.js +1074 -473
  9. package/dist/admin/common-ui-admin.es.js.map +1 -1
  10. package/dist/admin/components/EChart.vue.d.ts +14 -0
  11. package/dist/admin/components/EChart.vue.d.ts.map +1 -0
  12. package/dist/admin/components/LearnerDiagnostics.vue.d.ts +3 -3
  13. package/dist/admin/components/LearnerDiagnostics.vue.d.ts.map +1 -1
  14. package/dist/admin/components/LearnerTagElo.vue.d.ts +14 -0
  15. package/dist/admin/components/LearnerTagElo.vue.d.ts.map +1 -0
  16. package/dist/admin/index.d.ts +1 -0
  17. package/dist/admin/index.d.ts.map +1 -1
  18. package/dist/assets/index.css +2 -1
  19. package/dist/components/CardBrowser.mjs +63 -0
  20. package/dist/components/CardBrowser.mjs.map +1 -0
  21. package/dist/components/CardBrowser.vue_vue_type_script_lang.mjs +50 -0
  22. package/dist/components/CardBrowser.vue_vue_type_script_lang.mjs.map +1 -0
  23. package/dist/components/CardHistoryViewer.mjs +70 -0
  24. package/dist/components/CardHistoryViewer.mjs.map +1 -0
  25. package/dist/components/CardHistoryViewer.vue_vue_type_script_lang.mjs +108 -0
  26. package/dist/components/CardHistoryViewer.vue_vue_type_script_lang.mjs.map +1 -0
  27. package/dist/components/CardSearch.mjs +26 -0
  28. package/dist/components/CardSearch.mjs.map +1 -0
  29. package/dist/components/CardSearch.vue_vue_type_script_lang.mjs +17 -0
  30. package/dist/components/CardSearch.vue_vue_type_script_lang.mjs.map +1 -0
  31. package/dist/components/CardSearchResults.mjs +32 -0
  32. package/dist/components/CardSearchResults.mjs.map +1 -0
  33. package/dist/components/CardSearchResults.vue_vue_type_script_lang.mjs +71 -0
  34. package/dist/components/CardSearchResults.vue_vue_type_script_lang.mjs.map +1 -0
  35. package/dist/components/CourseCardBrowser.mjs +175 -0
  36. package/dist/components/CourseCardBrowser.mjs.map +1 -0
  37. package/dist/components/CourseCardBrowser.vue.d.ts +3 -3
  38. package/dist/components/CourseCardBrowser.vue_vue_type_script_lang.mjs +173 -0
  39. package/dist/components/CourseCardBrowser.vue_vue_type_script_lang.mjs.map +1 -0
  40. package/dist/components/CourseInformation.mjs +132 -0
  41. package/dist/components/CourseInformation.mjs.map +1 -0
  42. package/dist/components/CourseInformation.vue.d.ts +3 -3
  43. package/dist/components/CourseInformation.vue_vue_type_script_lang.mjs +56 -0
  44. package/dist/components/CourseInformation.vue_vue_type_script_lang.mjs.map +1 -0
  45. package/dist/components/CourseTagFilterWidget.mjs +114 -0
  46. package/dist/components/CourseTagFilterWidget.mjs.map +1 -0
  47. package/dist/components/CourseTagFilterWidget.vue_vue_type_script_lang.mjs +65 -0
  48. package/dist/components/CourseTagFilterWidget.vue_vue_type_script_lang.mjs.map +1 -0
  49. package/dist/components/HeatMap.mjs +40 -0
  50. package/dist/components/HeatMap.mjs.map +1 -0
  51. package/dist/components/HeatMap.vue_vue_type_script_lang.mjs +211 -0
  52. package/dist/components/HeatMap.vue_vue_type_script_lang.mjs.map +1 -0
  53. package/dist/components/PaginatingToolbar.mjs +94 -0
  54. package/dist/components/PaginatingToolbar.mjs.map +1 -0
  55. package/dist/components/PaginatingToolbar.vue.d.ts +3 -3
  56. package/dist/components/PaginatingToolbar.vue_vue_type_script_lang.mjs +36 -0
  57. package/dist/components/PaginatingToolbar.vue_vue_type_script_lang.mjs.map +1 -0
  58. package/dist/components/SessionControllerDebug.mjs +130 -0
  59. package/dist/components/SessionControllerDebug.mjs.map +1 -0
  60. package/dist/components/SessionControllerDebug.vue_vue_type_script_lang.mjs +45 -0
  61. package/dist/components/SessionControllerDebug.vue_vue_type_script_lang.mjs.map +1 -0
  62. package/dist/components/SkMouseTrap.mjs +66 -0
  63. package/dist/components/SkMouseTrap.mjs.map +1 -0
  64. package/dist/components/SkMouseTrap.vue_vue_type_script_lang.mjs +31 -0
  65. package/dist/components/SkMouseTrap.vue_vue_type_script_lang.mjs.map +1 -0
  66. package/dist/components/SkMouseTrapToolTip.mjs +28 -0
  67. package/dist/components/SkMouseTrapToolTip.mjs.map +1 -0
  68. package/dist/components/SkMouseTrapToolTip.vue.d.ts +1 -1
  69. package/dist/components/SkMouseTrapToolTip.vue_vue_type_script_lang.mjs +91 -0
  70. package/dist/components/SkMouseTrapToolTip.vue_vue_type_script_lang.mjs.map +1 -0
  71. package/dist/components/SnackbarService.mjs +24 -0
  72. package/dist/components/SnackbarService.mjs.map +1 -0
  73. package/dist/components/SnackbarService.vue_vue_type_script_lang.mjs +34 -0
  74. package/dist/components/SnackbarService.vue_vue_type_script_lang.mjs.map +1 -0
  75. package/dist/components/SnackbarService2.mjs +40 -0
  76. package/dist/components/SnackbarService2.mjs.map +1 -0
  77. package/dist/components/StudySession.mjs +123 -0
  78. package/dist/components/StudySession.mjs.map +1 -0
  79. package/dist/components/StudySession.vue_vue_type_script_lang.mjs +280 -0
  80. package/dist/components/StudySession.vue_vue_type_script_lang.mjs.map +1 -0
  81. package/dist/components/StudySessionTimer.mjs +50 -0
  82. package/dist/components/StudySessionTimer.mjs.map +1 -0
  83. package/dist/components/StudySessionTimer.vue_vue_type_script_lang.mjs +43 -0
  84. package/dist/components/StudySessionTimer.vue_vue_type_script_lang.mjs.map +1 -0
  85. package/dist/components/TagsInput.mjs +45 -0
  86. package/dist/components/TagsInput.mjs.map +1 -0
  87. package/dist/components/TagsInput.vue_vue_type_script_lang.mjs +117 -0
  88. package/dist/components/TagsInput.vue_vue_type_script_lang.mjs.map +1 -0
  89. package/dist/components/UserTagPreferences.mjs +205 -0
  90. package/dist/components/UserTagPreferences.mjs.map +1 -0
  91. package/dist/components/UserTagPreferences.vue_vue_type_script_lang.mjs +139 -0
  92. package/dist/components/UserTagPreferences.vue_vue_type_script_lang.mjs.map +1 -0
  93. package/dist/components/auth/RequestPasswordReset.mjs +96 -0
  94. package/dist/components/auth/RequestPasswordReset.mjs.map +1 -0
  95. package/dist/components/auth/RequestPasswordReset.vue_vue_type_script_lang.mjs +47 -0
  96. package/dist/components/auth/RequestPasswordReset.vue_vue_type_script_lang.mjs.map +1 -0
  97. package/dist/components/auth/ResetPassword.mjs +129 -0
  98. package/dist/components/auth/ResetPassword.mjs.map +1 -0
  99. package/dist/components/auth/ResetPassword.vue_vue_type_script_lang.mjs +69 -0
  100. package/dist/components/auth/ResetPassword.vue_vue_type_script_lang.mjs.map +1 -0
  101. package/dist/components/auth/UserChip.mjs +9 -0
  102. package/dist/components/auth/UserChip.mjs.map +1 -0
  103. package/dist/components/auth/UserChip.vue_vue_type_script_setup_true_lang.mjs +213 -0
  104. package/dist/components/auth/UserChip.vue_vue_type_script_setup_true_lang.mjs.map +1 -0
  105. package/dist/components/auth/UserLogin.mjs +9 -0
  106. package/dist/components/auth/UserLogin.mjs.map +1 -0
  107. package/dist/components/auth/UserLogin.vue_vue_type_script_setup_true_lang.mjs +155 -0
  108. package/dist/components/auth/UserLogin.vue_vue_type_script_setup_true_lang.mjs.map +1 -0
  109. package/dist/components/auth/UserLoginAndRegistrationContainer.mjs +9 -0
  110. package/dist/components/auth/UserLoginAndRegistrationContainer.mjs.map +1 -0
  111. package/dist/components/auth/UserLoginAndRegistrationContainer.vue_vue_type_script_setup_true_lang.mjs +126 -0
  112. package/dist/components/auth/UserLoginAndRegistrationContainer.vue_vue_type_script_setup_true_lang.mjs.map +1 -0
  113. package/dist/components/auth/UserRegistration.mjs +147 -0
  114. package/dist/components/auth/UserRegistration.mjs.map +1 -0
  115. package/dist/components/auth/UserRegistration.vue_vue_type_script_lang.mjs +130 -0
  116. package/dist/components/auth/UserRegistration.vue_vue_type_script_lang.mjs.map +1 -0
  117. package/dist/components/auth/VerifyEmail.mjs +118 -0
  118. package/dist/components/auth/VerifyEmail.mjs.map +1 -0
  119. package/dist/components/auth/VerifyEmail.vue_vue_type_script_lang.mjs +59 -0
  120. package/dist/components/auth/VerifyEmail.vue_vue_type_script_lang.mjs.map +1 -0
  121. package/dist/components/cardRendering/AudioAutoPlayer.mjs +9 -0
  122. package/dist/components/cardRendering/AudioAutoPlayer.mjs.map +1 -0
  123. package/dist/components/cardRendering/AudioAutoPlayer.vue_vue_type_script_setup_true_lang.mjs +69 -0
  124. package/dist/components/cardRendering/AudioAutoPlayer.vue_vue_type_script_setup_true_lang.mjs.map +1 -0
  125. package/dist/components/cardRendering/CardLoader.mjs +31 -0
  126. package/dist/components/cardRendering/CardLoader.mjs.map +1 -0
  127. package/dist/components/cardRendering/CardLoader.vue_vue_type_script_lang.mjs +75 -0
  128. package/dist/components/cardRendering/CardLoader.vue_vue_type_script_lang.mjs.map +1 -0
  129. package/dist/components/cardRendering/CardViewer.mjs +39 -0
  130. package/dist/components/cardRendering/CardViewer.mjs.map +1 -0
  131. package/dist/components/cardRendering/CardViewer.vue_vue_type_script_lang.mjs +66 -0
  132. package/dist/components/cardRendering/CardViewer.vue_vue_type_script_lang.mjs.map +1 -0
  133. package/dist/components/cardRendering/CodeBlockRenderer.mjs +8 -0
  134. package/dist/components/cardRendering/CodeBlockRenderer.mjs.map +1 -0
  135. package/dist/components/cardRendering/CodeBlockRenderer.vue_vue_type_script_setup_true_lang.mjs +44 -0
  136. package/dist/components/cardRendering/CodeBlockRenderer.vue_vue_type_script_setup_true_lang.mjs.map +1 -0
  137. package/dist/components/cardRendering/MarkdownRenderer.mjs +21 -0
  138. package/dist/components/cardRendering/MarkdownRenderer.mjs.map +1 -0
  139. package/dist/components/cardRendering/MarkdownRenderer.vue_vue_type_script_lang.mjs +24 -0
  140. package/dist/components/cardRendering/MarkdownRenderer.vue_vue_type_script_lang.mjs.map +1 -0
  141. package/dist/components/cardRendering/MarkdownRendererHelpers.mjs +79 -0
  142. package/dist/components/cardRendering/MarkdownRendererHelpers.mjs.map +1 -0
  143. package/dist/components/cardRendering/MdTokenRenderer.mjs +9 -0
  144. package/dist/components/cardRendering/MdTokenRenderer.mjs.map +1 -0
  145. package/dist/components/cardRendering/MdTokenRenderer.vue_vue_type_script_setup_true_lang.mjs +176 -0
  146. package/dist/components/cardRendering/MdTokenRenderer.vue_vue_type_script_setup_true_lang.mjs.map +1 -0
  147. package/dist/components/studentInputs/BaseUserInput.mjs +54 -0
  148. package/dist/components/studentInputs/BaseUserInput.mjs.map +1 -0
  149. package/dist/components/studentInputs/MultipleChoiceOption.mjs +26 -0
  150. package/dist/components/studentInputs/MultipleChoiceOption.mjs.map +1 -0
  151. package/dist/components/studentInputs/MultipleChoiceOption.vue_vue_type_script_lang.mjs +76 -0
  152. package/dist/components/studentInputs/MultipleChoiceOption.vue_vue_type_script_lang.mjs.map +1 -0
  153. package/dist/components/studentInputs/RadioMultipleChoice.mjs +33 -0
  154. package/dist/components/studentInputs/RadioMultipleChoice.mjs.map +1 -0
  155. package/dist/components/studentInputs/RadioMultipleChoice.vue_vue_type_script_lang.mjs +105 -0
  156. package/dist/components/studentInputs/RadioMultipleChoice.vue_vue_type_script_lang.mjs.map +1 -0
  157. package/dist/components/studentInputs/TrueFalse.mjs +19 -0
  158. package/dist/components/studentInputs/TrueFalse.mjs.map +1 -0
  159. package/dist/components/studentInputs/TrueFalse.vue_vue_type_script_lang.mjs +21 -0
  160. package/dist/components/studentInputs/TrueFalse.vue_vue_type_script_lang.mjs.map +1 -0
  161. package/dist/components/studentInputs/UserInputNumber.mjs +33 -0
  162. package/dist/components/studentInputs/UserInputNumber.mjs.map +1 -0
  163. package/dist/components/studentInputs/UserInputNumber.vue_vue_type_script_lang.mjs +25 -0
  164. package/dist/components/studentInputs/UserInputNumber.vue_vue_type_script_lang.mjs.map +1 -0
  165. package/dist/components/studentInputs/UserInputString.mjs +22 -0
  166. package/dist/components/studentInputs/UserInputString.mjs.map +1 -0
  167. package/dist/components/studentInputs/UserInputString.vue_vue_type_script_lang.mjs +23 -0
  168. package/dist/components/studentInputs/UserInputString.vue_vue_type_script_lang.mjs.map +1 -0
  169. package/dist/components/studentInputs/fillInInput.mjs +25 -0
  170. package/dist/components/studentInputs/fillInInput.mjs.map +1 -0
  171. package/dist/components/studentInputs/fillInInput.vue_vue_type_script_lang.mjs +26 -0
  172. package/dist/components/studentInputs/fillInInput.vue_vue_type_script_lang.mjs.map +1 -0
  173. package/dist/composables/CompositionViewable.mjs +65 -0
  174. package/dist/composables/CompositionViewable.mjs.map +1 -0
  175. package/dist/composables/Displayable.mjs +36 -0
  176. package/dist/composables/Displayable.mjs.map +1 -0
  177. package/dist/composables/useAuthUI.mjs +38 -0
  178. package/dist/composables/useAuthUI.mjs.map +1 -0
  179. package/dist/composables/useEntitlements.mjs +58 -0
  180. package/dist/composables/useEntitlements.mjs.map +1 -0
  181. package/dist/index.mjs +49 -0
  182. package/dist/plugins/pinia.mjs +12 -0
  183. package/dist/plugins/pinia.mjs.map +1 -0
  184. package/dist/services/authAPI.mjs +159 -0
  185. package/dist/services/authAPI.mjs.map +1 -0
  186. package/dist/stores/useAuthStore.mjs +68 -0
  187. package/dist/stores/useAuthStore.mjs.map +1 -0
  188. package/dist/stores/useCardPreviewModeStore.mjs +20 -0
  189. package/dist/stores/useCardPreviewModeStore.mjs.map +1 -0
  190. package/dist/stores/useConfigStore.mjs +60 -0
  191. package/dist/stores/useConfigStore.mjs.map +1 -0
  192. package/dist/utils/SkldrMouseTrap.mjs +48 -0
  193. package/dist/utils/SkldrMouseTrap.mjs.map +1 -0
  194. package/dist/utils/passwordValidation.mjs +12 -0
  195. package/dist/utils/passwordValidation.mjs.map +1 -0
  196. package/package.json +8 -8
  197. package/src/admin/components/EChart.vue +59 -0
  198. package/src/admin/components/LearnerDiagnostics.vue +8 -4
  199. package/src/admin/components/LearnerTagElo.vue +563 -0
  200. package/src/admin/index.ts +1 -0
  201. package/dist/MarkdownRenderer-U_iPiA2x.mjs +0 -4966
  202. package/dist/MarkdownRenderer-U_iPiA2x.mjs.map +0 -1
  203. package/dist/assets/MarkdownRenderer.css +0 -1
  204. package/dist/common-ui.es.js +0 -5090
  205. package/dist/common-ui.es.js.map +0 -1
  206. package/dist/common-ui.umd.js +0 -77
  207. package/dist/common-ui.umd.js.map +0 -1
@@ -0,0 +1,563 @@
1
+ <template>
2
+ <div>
3
+ <v-alert v-if="!view" type="info" variant="tonal">
4
+ This learner's registration carries no ELO for this course.
5
+ </v-alert>
6
+
7
+ <template v-else>
8
+ <p class="text-caption text-medium-emphasis mb-4">
9
+ The learner's ELO on each tag (from their course registration) beside their global ELO. A tag's rating starts at
10
+ the learner's global ELO when first graded and moves only on responses that grade it: read it with its response
11
+ count. Bookkeeping tags (<code>intro</code>, <code>expose</code>) are counted, never rated, and are hidden by
12
+ default.
13
+ </p>
14
+
15
+ <v-row dense class="mb-2">
16
+ <v-col v-for="tile in tiles" :key="tile.label" cols="6" sm="3">
17
+ <v-card variant="tonal" color="primary" class="pa-2 text-center">
18
+ <div class="text-h6">{{ tile.value }}</div>
19
+ <div class="text-caption">{{ tile.label }}</div>
20
+ </v-card>
21
+ </v-col>
22
+ </v-row>
23
+
24
+ <div class="d-flex align-center flex-wrap mb-1 ga-3">
25
+ <v-chip-group v-model="selectedBatches" multiple column selected-class="text-primary">
26
+ <v-chip
27
+ v-for="b in batchOptions"
28
+ :key="b.key"
29
+ :value="b.key"
30
+ size="small"
31
+ filter
32
+ :variant="b.course ? 'outlined' : 'tonal'"
33
+ :title="b.course ? 'course batch' : 'namespace × role'"
34
+ >
35
+ {{ b.batch.label }} ({{ b.matches }})
36
+ </v-chip>
37
+ </v-chip-group>
38
+ </div>
39
+ <div class="d-flex align-center flex-wrap mb-4 ga-3">
40
+ <v-btn-toggle v-model="sortBy" mandatory density="compact" color="primary" variant="outlined">
41
+ <v-btn value="score" size="small">ELO</v-btn>
42
+ <v-btn value="count" size="small">Responses</v-btn>
43
+ <v-btn value="lastSeen" size="small">Last seen</v-btn>
44
+ <v-btn value="tag" size="small">Name</v-btn>
45
+ </v-btn-toggle>
46
+ <v-text-field
47
+ v-model.number="minCount"
48
+ type="number"
49
+ min="0"
50
+ density="compact"
51
+ variant="outlined"
52
+ hide-details
53
+ label="min responses"
54
+ style="max-width: 140px"
55
+ />
56
+ <v-text-field
57
+ v-model="search"
58
+ density="compact"
59
+ variant="outlined"
60
+ hide-details
61
+ clearable
62
+ placeholder="filter by tag"
63
+ prepend-inner-icon="mdi-magnify"
64
+ style="max-width: 280px"
65
+ />
66
+ <v-spacer />
67
+ <span class="text-caption text-medium-emphasis">
68
+ {{ picked.size }}/{{ MAX_PICKED }} plotted
69
+ <v-btn v-if="picked.size" variant="text" size="x-small" @click="picked = new Map()">clear</v-btn>
70
+ </span>
71
+ </div>
72
+
73
+ <v-alert v-if="visible.length === 0" type="info" variant="tonal">No tags match these filters.</v-alert>
74
+
75
+ <v-row v-else>
76
+ <v-col cols="12" md="5">
77
+ <div class="text-subtitle-2">ELO by tag</div>
78
+ <div class="text-caption text-medium-emphasis mb-1">
79
+ Dot size: responses (log scale). Click a dot or a name to plot its trajectory.
80
+ </div>
81
+ <e-chart v-if="plotted.length" :option="dotOption" :height="dotHeight" @click="onDotClick" />
82
+ <div v-else class="text-caption text-disabled pa-4">No rated tags in this selection.</div>
83
+ </v-col>
84
+ <v-col cols="12" md="7">
85
+ <div class="sk-tag-elo-sticky">
86
+ <div class="text-subtitle-2">Trajectories</div>
87
+ <div class="text-caption text-medium-emphasis mb-1">
88
+ <template v-if="view.seriesFrom">
89
+ From {{ fmtTime(view.seriesFrom) }}, when sessions began recording ELO events. Faint lines are the other
90
+ tags in view; click one to plot it. A jump between recorded events is a session that didn't save its
91
+ events.
92
+ </template>
93
+ <template v-else>No session in this dataset recorded ELO events.</template>
94
+ </div>
95
+ <e-chart v-if="view.seriesFrom" :option="lineOption" :height="380" @click="onLineClick" />
96
+ </div>
97
+ </v-col>
98
+ </v-row>
99
+
100
+ <v-table v-if="visible.length" density="compact" class="mt-4 sk-tag-elo-table">
101
+ <thead>
102
+ <tr>
103
+ <th>Tag</th>
104
+ <th>Batch</th>
105
+ <th class="text-right">ELO</th>
106
+ <th class="text-right">vs global</th>
107
+ <th class="text-right">Responses</th>
108
+ <th>Recent first attempts</th>
109
+ <th>First seen</th>
110
+ <th>Last seen</th>
111
+ <th class="text-right">ELO events</th>
112
+ </tr>
113
+ </thead>
114
+ <tbody>
115
+ <tr
116
+ v-for="r in visible"
117
+ :key="r.tag"
118
+ :class="{ 'sk-pickable': r.score !== null }"
119
+ @click="r.score !== null && toggle(r.tag)"
120
+ >
121
+ <td>
122
+ <span
123
+ class="sk-swatch"
124
+ :style="{ background: picked.has(r.tag) ? slotColor(picked.get(r.tag)!) : 'transparent' }"
125
+ />
126
+ <code class="text-caption">{{ r.tag }}</code>
127
+ </td>
128
+ <td class="text-caption text-medium-emphasis">{{ r.group }}</td>
129
+ <td class="text-right">
130
+ <template v-if="r.score !== null">{{ r.score }}</template>
131
+ <span v-else class="text-disabled" :title="staleTitle(r)">
132
+ —<template v-if="r.staleScore !== undefined"> ({{ r.staleScore }})</template>
133
+ </span>
134
+ </td>
135
+ <td class="text-right" :class="deltaClass(r.vsGlobal)">{{ signed(r.vsGlobal) }}</td>
136
+ <td class="text-right">{{ r.count }}</td>
137
+ <td>
138
+ <span v-if="r.recent.length" class="sk-ticks">
139
+ <span
140
+ v-for="(p, i) in r.recent"
141
+ :key="i"
142
+ class="sk-tick"
143
+ :class="p.ok ? 'bg-success' : 'bg-error'"
144
+ :title="`${fmtTime(p.at)} · ${p.ok ? 'correct' : 'missed'} · ${p.card}`"
145
+ />
146
+ <span class="text-caption ml-1">{{ r.recentOk }}/{{ r.recent.length }}</span>
147
+ </span>
148
+ <span v-else class="text-caption text-disabled">—</span>
149
+ </td>
150
+ <td class="text-caption">{{ fmtDate(r.firstSeen) }}</td>
151
+ <td class="text-caption">{{ fmtDate(r.lastSeen) }}</td>
152
+ <td class="text-right">{{ r.events || '' }}</td>
153
+ </tr>
154
+ </tbody>
155
+ </v-table>
156
+ </template>
157
+ </div>
158
+ </template>
159
+
160
+ <script setup lang="ts">
161
+ import { computed, ref, watch } from 'vue';
162
+ import { useTheme } from 'vuetify';
163
+ import type { ECElementEvent, EChartsCoreOption } from 'echarts/core';
164
+ import {
165
+ matchesTagBatch,
166
+ shortTag,
167
+ tagEloView,
168
+ type DiagnosticsInterpreters,
169
+ type EloPoint,
170
+ type LearnerDataset,
171
+ type TagBatch,
172
+ type TagEloRow,
173
+ } from '@vue-skuilder/db/diagnostics';
174
+ import { deltaClass, fmtTime } from '../display';
175
+ import EChart from './EChart.vue';
176
+
177
+ /**
178
+ * A learner's per-tag ELO: which skills they're rated high or low on, how
179
+ * much evidence each rating rests on, and how ratings moved. Filtered by tag
180
+ * batch: the namespace × role groups found on the tags, plus any the course
181
+ * names (`interpreters.tagBatches`).
182
+ */
183
+ const props = defineProps<{
184
+ dataset: LearnerDataset;
185
+ interpreters?: DiagnosticsInterpreters;
186
+ }>();
187
+
188
+ const view = computed(() => tagEloView(props.dataset));
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Filters
192
+ // ---------------------------------------------------------------------------
193
+
194
+ const batchOptions = computed(() => {
195
+ const rows = view.value?.rows ?? [];
196
+ const options = [
197
+ ...(view.value?.groups ?? []).map((batch) => ({ key: `group:${batch.id}`, batch, course: false })),
198
+ ...(props.interpreters?.tagBatches ?? []).map((batch) => ({ key: `course:${batch.id}`, batch, course: true })),
199
+ ];
200
+ return options.map((o) => ({ ...o, matches: rows.filter((r) => matchesTagBatch(r.tag, o.batch, r.role)).length }));
201
+ });
202
+
203
+ const isBookkeeping = (b: TagBatch): boolean => b.roles?.some((r) => r === 'intro' || r === 'expose') ?? false;
204
+
205
+ const selectedBatches = ref<string[]>([]);
206
+ const sortBy = ref<'score' | 'count' | 'lastSeen' | 'tag'>('score');
207
+ const minCount = ref<number>(0);
208
+ const search = ref<string | null>('');
209
+
210
+ const byScore = (a: TagEloRow, b: TagEloRow): number =>
211
+ a.score === null || b.score === null ? Number(a.score === null) - Number(b.score === null) : b.score - a.score;
212
+ const byLastSeen = (a: TagEloRow, b: TagEloRow): number => (b.lastSeen ?? '').localeCompare(a.lastSeen ?? '');
213
+ const SORTS: Record<typeof sortBy.value, (a: TagEloRow, b: TagEloRow) => number> = {
214
+ score: byScore,
215
+ count: (a, b) => b.count - a.count,
216
+ lastSeen: byLastSeen,
217
+ tag: (a, b) => a.tag.localeCompare(b.tag),
218
+ };
219
+
220
+ const visible = computed<TagEloRow[]>(() => {
221
+ const chosen = batchOptions.value.filter((o) => selectedBatches.value.includes(o.key)).map((o) => o.batch);
222
+ const q = search.value?.trim().toLowerCase();
223
+ const min = Number(minCount.value) || 0;
224
+ return (view.value?.rows ?? [])
225
+ .filter(
226
+ (r) =>
227
+ chosen.some((b) => matchesTagBatch(r.tag, b, r.role)) &&
228
+ r.count >= min &&
229
+ (!q || r.tag.toLowerCase().includes(q))
230
+ )
231
+ .sort((a, b) => SORTS[sortBy.value](a, b) || a.tag.localeCompare(b.tag));
232
+ });
233
+
234
+ /** Rows with a rating: the ones the charts can place. */
235
+ const plotted = computed(() => visible.value.filter((r) => r.score !== null));
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Picked tags: plotted as trajectories, each keeping its colour while picked
239
+ // ---------------------------------------------------------------------------
240
+
241
+ const MAX_PICKED = 8;
242
+ const picked = ref<Map<string, number>>(new Map());
243
+
244
+ function toggle(tag: string): void {
245
+ const next = new Map(picked.value);
246
+ if (next.has(tag)) next.delete(tag);
247
+ else {
248
+ if (next.size >= MAX_PICKED) return;
249
+ const used = new Set(next.values());
250
+ let slot = 0;
251
+ while (used.has(slot)) slot++;
252
+ next.set(tag, slot);
253
+ }
254
+ picked.value = next;
255
+ }
256
+
257
+ watch(
258
+ () => props.dataset,
259
+ () => {
260
+ picked.value = new Map();
261
+ selectedBatches.value = batchOptions.value.filter((o) => !o.course && !isBookkeeping(o.batch)).map((o) => o.key);
262
+ },
263
+ { immediate: true }
264
+ );
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Labels
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /** Short labels where they're unique among the tags in view, full tags where they aren't. */
271
+ const labels = computed(() => {
272
+ const short = new Map<string, string>();
273
+ const uses = new Map<string, number>();
274
+ for (const r of visible.value) {
275
+ const s = shortTag(props.interpreters, r.tag);
276
+ short.set(r.tag, s);
277
+ uses.set(s, (uses.get(s) ?? 0) + 1);
278
+ }
279
+ return new Map([...short].map(([tag, s]) => [tag, (uses.get(s) ?? 0) > 1 ? tag : s]));
280
+ });
281
+ const labelOf = (tag: string): string => labels.value.get(tag) ?? shortTag(props.interpreters, tag);
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Chart chrome: the admin palette's ink and categorical slots, per theme
285
+ // ---------------------------------------------------------------------------
286
+
287
+ const theme = useTheme();
288
+ const dark = computed(() => theme.current.value.dark);
289
+
290
+ const SLOTS = {
291
+ light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#6250d6', '#e34948'],
292
+ dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'],
293
+ };
294
+ const INK = {
295
+ light: { primary: '#0b0b0b', secondary: '#52514e', muted: '#898781', grid: '#e1e0d9', axis: '#c3c2b7' },
296
+ dark: { primary: '#f0efec', secondary: '#c3c2b7', muted: '#898781', grid: '#2c2c2a', axis: '#383835' },
297
+ };
298
+ const ink = computed(() => INK[dark.value ? 'dark' : 'light']);
299
+ const slotColor = (slot: number): string => SLOTS[dark.value ? 'dark' : 'light'][slot];
300
+ const contextLine = computed(() => (dark.value ? 'rgba(195,194,183,0.18)' : 'rgba(82,81,78,0.16)'));
301
+ const surface = computed(() => theme.current.value.colors.surface);
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // ELO by tag (dot plot)
305
+ // ---------------------------------------------------------------------------
306
+
307
+ const ROW_PX = 20;
308
+ const dotHeight = computed(() => Math.max(160, plotted.value.length * ROW_PX + 72));
309
+ const dotSize = (count: number): number => Math.min(20, 8 + 2.2 * Math.log1p(count));
310
+
311
+ function escapeHtml(s: string): string {
312
+ return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
313
+ }
314
+
315
+ function rowTooltip(r: TagEloRow): string {
316
+ const recent = r.recent.length ? `recent ${r.recentOk}/${r.recent.length} correct` : 'no recent buffer';
317
+ return [
318
+ `<b>${escapeHtml(r.tag)}</b>`,
319
+ `ELO ${r.score} (${signed(r.vsGlobal)} vs global) · ${r.count} responses`,
320
+ `${recent} · last seen ${fmtDate(r.lastSeen)}`,
321
+ ].join('<br/>');
322
+ }
323
+
324
+ const dotOption = computed<EChartsCoreOption>(() => {
325
+ const rows = plotted.value;
326
+ const c = ink.value;
327
+ const global = view.value?.global.score ?? 0;
328
+ const scores = [global, ...rows.map((r) => r.score as number)];
329
+ const lo = Math.floor((Math.min(...scores) - 20) / 50) * 50;
330
+ const hi = Math.ceil((Math.max(...scores) + 20) / 50) * 50;
331
+ return {
332
+ animation: false,
333
+ grid: { left: 8, right: 16, top: 24, bottom: 8, containLabel: true },
334
+ tooltip: {
335
+ trigger: 'axis',
336
+ // The whole row band answers hover, not only the dot.
337
+ axisPointer: { type: 'shadow', axis: 'y' },
338
+ formatter: (params: unknown) => {
339
+ const i = (params as Array<{ dataIndex: number }>)[0]?.dataIndex;
340
+ return i === undefined ? '' : rowTooltip(rows[i]);
341
+ },
342
+ },
343
+ xAxis: {
344
+ type: 'value',
345
+ position: 'top',
346
+ min: lo,
347
+ max: hi,
348
+ axisLabel: { color: c.muted },
349
+ axisLine: { show: false },
350
+ splitLine: { lineStyle: { color: c.grid } },
351
+ },
352
+ yAxis: {
353
+ type: 'category',
354
+ inverse: true,
355
+ triggerEvent: true,
356
+ data: rows.map((r) => labelOf(r.tag)),
357
+ axisLabel: { color: c.secondary, fontSize: 11, interval: 0 },
358
+ axisTick: { show: false },
359
+ axisLine: { lineStyle: { color: c.axis } },
360
+ },
361
+ series: [
362
+ {
363
+ type: 'scatter',
364
+ data: rows.map((r, i) => {
365
+ const slot = picked.value.get(r.tag);
366
+ return {
367
+ name: r.tag,
368
+ value: [r.score, i],
369
+ symbolSize: dotSize(r.count),
370
+ itemStyle: {
371
+ color: slot === undefined ? c.muted : slotColor(slot),
372
+ borderColor: surface.value,
373
+ borderWidth: 2,
374
+ },
375
+ };
376
+ }),
377
+ markLine: {
378
+ silent: true,
379
+ symbol: 'none',
380
+ lineStyle: { color: c.primary, type: 'solid', width: 1 },
381
+ label: { formatter: `global ${global}`, color: c.secondary, position: 'end' },
382
+ data: [{ xAxis: global }],
383
+ },
384
+ },
385
+ ],
386
+ };
387
+ });
388
+
389
+ function onDotClick(e: ECElementEvent): void {
390
+ if (e.componentType === 'series' && typeof e.name === 'string') toggle(e.name);
391
+ else if (e.componentType === 'yAxis') {
392
+ const label = (e as unknown as { value?: string }).value;
393
+ const row = plotted.value.find((r) => labelOf(r.tag) === label);
394
+ if (row) toggle(row.tag);
395
+ }
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Trajectories
400
+ // ---------------------------------------------------------------------------
401
+
402
+ const toData = (points: EloPoint[]): Array<[string, number]> => points.map((p) => [p.at, p.score]);
403
+
404
+ /** A step series' value at time t: its last point at or before t. */
405
+ function valueAt(points: EloPoint[], t: number): number | null {
406
+ let v: number | null = null;
407
+ for (const p of points) {
408
+ if (Date.parse(p.at) > t) break;
409
+ v = p.score;
410
+ }
411
+ return v;
412
+ }
413
+
414
+ const lineOption = computed<EChartsCoreOption>(() => {
415
+ const v = view.value;
416
+ const c = ink.value;
417
+ if (!v) return {};
418
+ const chosen = [...picked.value].filter(([tag]) => v.series.tags[tag]);
419
+ const context = plotted.value.filter((r) => !picked.value.has(r.tag) && v.series.tags[r.tag]);
420
+ const labelled = chosen.length <= 4;
421
+ const shown: Array<{ name: string; color: string; points: EloPoint[] }> = [
422
+ { name: 'global', color: c.primary, points: v.series.global },
423
+ ...chosen.map(([tag, slot]) => ({ name: labelOf(tag), color: slotColor(slot), points: v.series.tags[tag] })),
424
+ ];
425
+ return {
426
+ animation: false,
427
+ grid: { left: 8, right: labelled ? 96 : 16, top: 36, bottom: 48, containLabel: true },
428
+ legend: {
429
+ top: 0,
430
+ data: shown.map((s) => s.name),
431
+ textStyle: { color: c.secondary },
432
+ selectedMode: false,
433
+ },
434
+ tooltip: {
435
+ trigger: 'axis',
436
+ formatter: (params: unknown) => {
437
+ const t = Number((params as Array<{ axisValue: number }>)[0]?.axisValue);
438
+ if (!Number.isFinite(t)) return '';
439
+ const lines = shown
440
+ .map((s) => ({ s, value: valueAt(s.points, t) }))
441
+ .filter((x) => x.value !== null)
442
+ .map(
443
+ ({ s, value }) =>
444
+ `<span style="display:inline-block;width:8px;height:8px;border-radius:4px;margin-right:6px;background:${s.color}"></span>${escapeHtml(s.name)} ${value}`
445
+ );
446
+ return [fmtTime(new Date(t).toISOString()), ...lines].join('<br/>');
447
+ },
448
+ },
449
+ xAxis: {
450
+ type: 'time',
451
+ axisLabel: { color: c.muted },
452
+ axisLine: { lineStyle: { color: c.axis } },
453
+ splitLine: { show: false },
454
+ },
455
+ yAxis: {
456
+ type: 'value',
457
+ scale: true,
458
+ axisLabel: { color: c.muted },
459
+ splitLine: { lineStyle: { color: c.grid } },
460
+ },
461
+ dataZoom: [{ type: 'inside' }, { type: 'slider', height: 16, bottom: 8 }],
462
+ series: [
463
+ ...context.map((r) => ({
464
+ type: 'line',
465
+ name: r.tag,
466
+ data: toData(v.series.tags[r.tag]),
467
+ step: 'end',
468
+ showSymbol: false,
469
+ triggerLineEvent: true,
470
+ lineStyle: { width: 1, color: contextLine.value },
471
+ itemStyle: { color: contextLine.value },
472
+ emphasis: { lineStyle: { width: 2, color: c.muted } },
473
+ z: 1,
474
+ })),
475
+ ...shown.map((s, i) => ({
476
+ type: 'line',
477
+ name: s.name,
478
+ data: toData(s.points),
479
+ step: 'end',
480
+ showSymbol: false,
481
+ triggerLineEvent: true,
482
+ lineStyle: { width: 2, color: s.color },
483
+ itemStyle: { color: s.color },
484
+ endLabel: { show: i === 0 || labelled, formatter: s.name, color: c.secondary },
485
+ z: i === 0 ? 3 : 4,
486
+ })),
487
+ ],
488
+ };
489
+ });
490
+
491
+ function onLineClick(e: ECElementEvent): void {
492
+ if (e.componentType !== 'series' || typeof e.seriesName !== 'string') return;
493
+ // Context lines are named by tag; picked lines by label.
494
+ const tag =
495
+ view.value?.series.tags[e.seriesName] !== undefined
496
+ ? e.seriesName
497
+ : [...picked.value.keys()].find((t) => labelOf(t) === e.seriesName);
498
+ if (tag) toggle(tag);
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Display
503
+ // ---------------------------------------------------------------------------
504
+
505
+ function signed(d: number | null): string {
506
+ if (d === null) return '—';
507
+ return d > 0 ? `+${d}` : String(d);
508
+ }
509
+
510
+ function fmtDate(iso: string | null): string {
511
+ if (!iso) return '—';
512
+ const d = new Date(iso);
513
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString();
514
+ }
515
+
516
+ function staleTitle(r: TagEloRow): string {
517
+ return r.staleScore !== undefined
518
+ ? 'counted, not rated: the bracketed score predates count-only roles and is ignored'
519
+ : 'counted, not rated';
520
+ }
521
+
522
+ const tiles = computed(() => {
523
+ const v = view.value;
524
+ const rated = (v?.rows ?? []).filter((r) => r.score !== null);
525
+ const gaps = plotted.value.map((r) => r.vsGlobal as number).sort((a, b) => a - b);
526
+ const median = gaps.length ? gaps[Math.floor(gaps.length / 2)] : null;
527
+ return [
528
+ { label: `global ELO (${v?.global.count ?? 0} responses)`, value: v?.global.score ?? '—' },
529
+ { label: 'rated tags in view', value: `${plotted.value.length} / ${rated.length}` },
530
+ { label: 'median vs global, in view', value: signed(median) },
531
+ { label: 'trajectories from', value: fmtDate(v?.seriesFrom ?? null) },
532
+ ];
533
+ });
534
+ </script>
535
+
536
+ <style scoped>
537
+ .sk-tag-elo-sticky {
538
+ position: sticky;
539
+ top: 72px;
540
+ }
541
+ .sk-tag-elo-table tbody tr.sk-pickable {
542
+ cursor: pointer;
543
+ }
544
+ .sk-swatch {
545
+ display: inline-block;
546
+ width: 8px;
547
+ height: 8px;
548
+ border-radius: 4px;
549
+ margin-right: 6px;
550
+ vertical-align: middle;
551
+ }
552
+ .sk-ticks {
553
+ display: inline-flex;
554
+ align-items: center;
555
+ gap: 2px;
556
+ }
557
+ .sk-tick {
558
+ display: inline-block;
559
+ width: 5px;
560
+ height: 12px;
561
+ border-radius: 1px;
562
+ }
563
+ </style>
@@ -26,3 +26,4 @@ export { default as LearnerCardHistory } from './components/LearnerCardHistory.v
26
26
  export { default as CardDossierDetail } from './components/CardDossierDetail.vue';
27
27
  export { default as RunDetail } from './components/RunDetail.vue';
28
28
  export { default as PipelineOverview } from './components/PipelineOverview.vue';
29
+ export { default as LearnerTagElo } from './components/LearnerTagElo.vue';