@vue-skuilder/db 0.2.3 → 0.2.5
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/core/index.js +5 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +5 -4
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +5 -4
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +5 -4
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +5 -4
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +5 -4
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +103 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +413 -14
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +413 -14
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/navigators/generators/elo.ts +32 -11
- package/src/study/SessionController.ts +187 -10
- package/src/study/SessionDebugger.ts +10 -0
- package/src/study/SessionOverlay.ts +500 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { logger } from '../util/logger';
|
|
2
|
+
import type { ReplanHints } from '@db/core/navigators/generators/types';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// SESSION OVERLAY
|
|
6
|
+
// ============================================================================
|
|
7
|
+
//
|
|
8
|
+
// A pinned, vanilla-DOM debug overlay for the LIVE SessionController. Unlike
|
|
9
|
+
// `SessionDebugger` (a passive tracker of pushed snapshots), this reads the
|
|
10
|
+
// active controller directly each tick — current queues, session hints, timer.
|
|
11
|
+
//
|
|
12
|
+
// Toggled via `window.skuilder.session.dbgOverlay()`.
|
|
13
|
+
//
|
|
14
|
+
// The `db` package is framework-agnostic, so this renders with raw DOM and
|
|
15
|
+
// no-ops gracefully in non-browser hosts (e.g. the tuilder TUI). It is the
|
|
16
|
+
// first DOM-rendering debug util in the package — kept self-contained here.
|
|
17
|
+
//
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
/** Per-queue debug view: total length, cumulative draws, and head-first cardIDs. */
|
|
21
|
+
export interface SessionQueueDebug {
|
|
22
|
+
length: number;
|
|
23
|
+
dequeueCount: number;
|
|
24
|
+
/** cardIDs in queue order, head (next draw) first. */
|
|
25
|
+
cards: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A card the learner has interacted with this session (one entry per card in
|
|
30
|
+
* the session record, regardless of which queue — if any — still holds it).
|
|
31
|
+
*/
|
|
32
|
+
export interface SessionDrawnCardDebug {
|
|
33
|
+
cardID: string;
|
|
34
|
+
/** Queue status at draw time: 'new' | 'review' | 'failed-new' | 'failed-review'. */
|
|
35
|
+
status: string;
|
|
36
|
+
/** Number of CardRecords logged for this card this session (≥1). */
|
|
37
|
+
attempts: number;
|
|
38
|
+
/** Latest record's correctness; null for non-question (info) records. */
|
|
39
|
+
correct: boolean | null;
|
|
40
|
+
/** Total time spent across all of this card's records, in ms. */
|
|
41
|
+
timeSpentMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Live snapshot of the controller, read fresh on each overlay tick. */
|
|
45
|
+
export interface SessionDebugSnapshot {
|
|
46
|
+
secondsRemaining: number;
|
|
47
|
+
hasCardGuarantee: boolean;
|
|
48
|
+
minCardsGuarantee: number;
|
|
49
|
+
wellIndicatedRemaining: number;
|
|
50
|
+
/** cardID of the card currently in front of the learner, if any. */
|
|
51
|
+
currentCard: string | null;
|
|
52
|
+
/** Session-durable hints re-merged into every pipeline run this session. */
|
|
53
|
+
sessionHints: ReplanHints | null;
|
|
54
|
+
/** True while a replan is executing (in-flight). */
|
|
55
|
+
replanActive: boolean;
|
|
56
|
+
/** Reason for the in-flight replan (caller label, or '(auto)'); may be stale when idle. */
|
|
57
|
+
replanLabel: string | null;
|
|
58
|
+
reviewQ: SessionQueueDebug;
|
|
59
|
+
newQ: SessionQueueDebug;
|
|
60
|
+
failedQ: SessionQueueDebug;
|
|
61
|
+
/** Every card the learner has interacted with this session, draw order. */
|
|
62
|
+
drawnCards: SessionDrawnCardDebug[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The narrow surface the overlay needs from a SessionController. */
|
|
66
|
+
export interface SessionDebugTarget {
|
|
67
|
+
getDebugSnapshot(): SessionDebugSnapshot;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ----------------------------------------------------------------------------
|
|
71
|
+
// Active-controller registry
|
|
72
|
+
// ----------------------------------------------------------------------------
|
|
73
|
+
//
|
|
74
|
+
// The controller registers itself on construction; a new session overwrites the
|
|
75
|
+
// prior handle. Kept here (a leaf module) so SessionController can import the
|
|
76
|
+
// registrar without pulling in the overlay's DOM code or risking an import
|
|
77
|
+
// cycle with SessionDebugger.
|
|
78
|
+
|
|
79
|
+
let activeController: SessionDebugTarget | null = null;
|
|
80
|
+
|
|
81
|
+
/** Called by SessionController's constructor. Pass `null` to deregister. */
|
|
82
|
+
export function registerActiveController(controller: SessionDebugTarget | null): void {
|
|
83
|
+
activeController = controller;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getActiveController(): SessionDebugTarget | null {
|
|
87
|
+
return activeController;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ----------------------------------------------------------------------------
|
|
91
|
+
// Overlay rendering (vanilla DOM)
|
|
92
|
+
// ----------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const OVERLAY_ID = 'skuilder-session-overlay';
|
|
95
|
+
const POLL_MS = 300;
|
|
96
|
+
/**
|
|
97
|
+
* Cap on how many cards a queue lists by default. Queues at or below this show
|
|
98
|
+
* in full; larger ones show the first INLINE_THRESHOLD then a clickable
|
|
99
|
+
* "… +N more" affordance that expands to the full list (and back).
|
|
100
|
+
*/
|
|
101
|
+
const INLINE_THRESHOLD = 5;
|
|
102
|
+
|
|
103
|
+
/** Braille spinner frames, advanced once per render tick (≈POLL_MS cadence). */
|
|
104
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
105
|
+
let spinnerFrame = 0;
|
|
106
|
+
|
|
107
|
+
let overlayEl: HTMLElement | null = null;
|
|
108
|
+
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Most recent snapshot rendered, retained so the click-to-copy button can
|
|
112
|
+
* serialise exactly what is on screen at click time (decoupled from the poll).
|
|
113
|
+
*/
|
|
114
|
+
let lastSnapshot: SessionDebugSnapshot | null = null;
|
|
115
|
+
|
|
116
|
+
/** Epoch ms until which the copy button shows its "copied" confirmation. */
|
|
117
|
+
let copyFlashUntil = 0;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* When minified, the overlay collapses to just its header bar (title + copy +
|
|
121
|
+
* restore button), suppressing all body sections. The poll keeps running so
|
|
122
|
+
* `lastSnapshot` — and therefore the copy button — stays current underneath.
|
|
123
|
+
*/
|
|
124
|
+
let minified = false;
|
|
125
|
+
|
|
126
|
+
/** Expansion state for collapsible (large) lists, preserved across re-renders. */
|
|
127
|
+
const expanded: Record<string, boolean> = {
|
|
128
|
+
reviewQ: false,
|
|
129
|
+
newQ: false,
|
|
130
|
+
failedQ: false,
|
|
131
|
+
drawn: false,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Toggle the pinned overlay on/off. No-ops (with a console hint) when there is
|
|
136
|
+
* no DOM, so it is safe to call from any host environment.
|
|
137
|
+
*/
|
|
138
|
+
export function toggleSessionOverlay(): void {
|
|
139
|
+
if (typeof document === 'undefined') {
|
|
140
|
+
logger.info('[Session Overlay] No DOM available (non-browser host); overlay unavailable.');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (overlayEl) {
|
|
144
|
+
teardown();
|
|
145
|
+
logger.info('[Session Overlay] Hidden.');
|
|
146
|
+
} else {
|
|
147
|
+
mount();
|
|
148
|
+
logger.info('[Session Overlay] Shown. Toggle off with window.skuilder.session.dbgOverlay().');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function mount(): void {
|
|
153
|
+
minified = false;
|
|
154
|
+
overlayEl = document.createElement('div');
|
|
155
|
+
overlayEl.id = OVERLAY_ID;
|
|
156
|
+
Object.assign(overlayEl.style, {
|
|
157
|
+
position: 'fixed',
|
|
158
|
+
top: '8px',
|
|
159
|
+
left: '8px',
|
|
160
|
+
zIndex: '2147483647',
|
|
161
|
+
maxWidth: '320px',
|
|
162
|
+
maxHeight: '90vh',
|
|
163
|
+
overflowY: 'auto',
|
|
164
|
+
padding: '8px 10px',
|
|
165
|
+
background: 'rgba(17, 24, 39, 0.92)',
|
|
166
|
+
color: '#e5e7eb',
|
|
167
|
+
font: '11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
168
|
+
borderRadius: '6px',
|
|
169
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
|
170
|
+
pointerEvents: 'auto',
|
|
171
|
+
userSelect: 'none',
|
|
172
|
+
});
|
|
173
|
+
document.body.appendChild(overlayEl);
|
|
174
|
+
render();
|
|
175
|
+
pollHandle = setInterval(render, POLL_MS);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function teardown(): void {
|
|
179
|
+
if (pollHandle !== null) {
|
|
180
|
+
clearInterval(pollHandle);
|
|
181
|
+
pollHandle = null;
|
|
182
|
+
}
|
|
183
|
+
if (overlayEl?.parentNode) {
|
|
184
|
+
overlayEl.parentNode.removeChild(overlayEl);
|
|
185
|
+
}
|
|
186
|
+
overlayEl = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function render(): void {
|
|
190
|
+
if (!overlayEl) return;
|
|
191
|
+
|
|
192
|
+
spinnerFrame++;
|
|
193
|
+
|
|
194
|
+
const ctrl = getActiveController();
|
|
195
|
+
if (!ctrl) {
|
|
196
|
+
lastSnapshot = null;
|
|
197
|
+
overlayEl.innerHTML =
|
|
198
|
+
headerHtml() + (minified ? '' : `<div style="opacity:.65">No active session.</div>`);
|
|
199
|
+
attachHandlers();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const s = ctrl.getDebugSnapshot();
|
|
204
|
+
lastSnapshot = s;
|
|
205
|
+
|
|
206
|
+
if (minified) {
|
|
207
|
+
overlayEl.innerHTML = headerHtml();
|
|
208
|
+
attachHandlers();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
overlayEl.innerHTML =
|
|
213
|
+
headerHtml() +
|
|
214
|
+
replanHtml(s) +
|
|
215
|
+
metaHtml(s) +
|
|
216
|
+
hintsHtml(s.sessionHints) +
|
|
217
|
+
queueHtml('reviewQ', 'reviewQ', s.reviewQ) +
|
|
218
|
+
queueHtml('newQ', 'newQ', s.newQ) +
|
|
219
|
+
queueHtml('failedQ', 'failedQ', s.failedQ) +
|
|
220
|
+
drawnHtml('drawn', s.drawnCards);
|
|
221
|
+
|
|
222
|
+
attachHandlers();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** (Re-)bind click handlers after each innerHTML rewrite. */
|
|
226
|
+
function attachHandlers(): void {
|
|
227
|
+
if (!overlayEl) return;
|
|
228
|
+
|
|
229
|
+
// Toggle handlers for collapsible queue / drawn-list headers and footers.
|
|
230
|
+
overlayEl.querySelectorAll<HTMLElement>('[data-q]').forEach((el) => {
|
|
231
|
+
el.onclick = () => {
|
|
232
|
+
const key = el.dataset.q;
|
|
233
|
+
if (!key) return;
|
|
234
|
+
expanded[key] = !expanded[key];
|
|
235
|
+
render();
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Global click-to-copy: dump the currently-displayed snapshot as plain text.
|
|
240
|
+
const copyBtn = overlayEl.querySelector<HTMLElement>('[data-copy]');
|
|
241
|
+
if (copyBtn) {
|
|
242
|
+
copyBtn.onclick = (ev) => {
|
|
243
|
+
ev.stopPropagation();
|
|
244
|
+
copySnapshot();
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Minify / restore: collapse the overlay to its header bar and back.
|
|
249
|
+
const minBtn = overlayEl.querySelector<HTMLElement>('[data-min]');
|
|
250
|
+
if (minBtn) {
|
|
251
|
+
minBtn.onclick = (ev) => {
|
|
252
|
+
ev.stopPropagation();
|
|
253
|
+
minified = !minified;
|
|
254
|
+
render();
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Serialise the on-screen snapshot to the clipboard, with a transient flash. */
|
|
260
|
+
function copySnapshot(): void {
|
|
261
|
+
const text = snapshotToText(lastSnapshot);
|
|
262
|
+
const flash = () => {
|
|
263
|
+
copyFlashUntil = Date.now() + 1200;
|
|
264
|
+
render();
|
|
265
|
+
};
|
|
266
|
+
const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
|
|
267
|
+
if (clip?.writeText) {
|
|
268
|
+
clip.writeText(text).then(flash, (err) => {
|
|
269
|
+
logger.warn(`[Session Overlay] Clipboard write failed: ${String(err)}`);
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
logger.info(`[Session Overlay] Clipboard unavailable; snapshot follows:\n${text}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function headerHtml(): string {
|
|
277
|
+
const flashing = Date.now() < copyFlashUntil;
|
|
278
|
+
const btnLabel = flashing ? '✓ copied' : '⎘ copy';
|
|
279
|
+
const btnColor = flashing ? '#86efac' : '#93c5fd';
|
|
280
|
+
const copyBtn =
|
|
281
|
+
`<span data-copy style="cursor:pointer;float:right;font-weight:400;` +
|
|
282
|
+
`color:${btnColor};border:1px solid currentColor;border-radius:4px;` +
|
|
283
|
+
`padding:0 4px;line-height:1.3">${btnLabel}</span>`;
|
|
284
|
+
const minBtn =
|
|
285
|
+
`<span data-min title="${minified ? 'Restore' : 'Minify'}" ` +
|
|
286
|
+
`style="cursor:pointer;float:right;font-weight:400;color:#93c5fd;` +
|
|
287
|
+
`border:1px solid currentColor;border-radius:4px;padding:0 5px;` +
|
|
288
|
+
`margin-right:4px;line-height:1.3">${minified ? '▢' : '—'}</span>`;
|
|
289
|
+
return (
|
|
290
|
+
`<div style="font-weight:600;color:#93c5fd;margin-bottom:${minified ? 0 : 4}px">` +
|
|
291
|
+
`${copyBtn}${minBtn}⚙ SessionController</div>`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function replanHtml(s: SessionDebugSnapshot): string {
|
|
296
|
+
if (!s.replanActive) {
|
|
297
|
+
return `<div style="margin-bottom:6px;opacity:.45">○ idle</div>`;
|
|
298
|
+
}
|
|
299
|
+
const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
|
|
300
|
+
const reason = esc(s.replanLabel ?? '(auto)');
|
|
301
|
+
return (
|
|
302
|
+
`<div style="margin-bottom:6px;color:#fde047">` +
|
|
303
|
+
`${frame} replanning <span style="opacity:.85">[${reason}]</span></div>`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function metaHtml(s: SessionDebugSnapshot): string {
|
|
308
|
+
const mmss = formatTime(s.secondsRemaining);
|
|
309
|
+
const guarantee = s.hasCardGuarantee
|
|
310
|
+
? ` · <span style="color:#fbbf24">guarantee ${s.minCardsGuarantee}</span>`
|
|
311
|
+
: '';
|
|
312
|
+
const rows = [
|
|
313
|
+
`time ${mmss}${guarantee}`,
|
|
314
|
+
`well-indicated left: ${s.wellIndicatedRemaining}`,
|
|
315
|
+
`current: ${s.currentCard ? esc(s.currentCard) : '<span style="opacity:.6">—</span>'}`,
|
|
316
|
+
];
|
|
317
|
+
return `<div style="margin-bottom:6px">${rows.map((r) => `<div>${r}</div>`).join('')}</div>`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function hintsHtml(h: ReplanHints | null): string {
|
|
321
|
+
const parts: string[] = [];
|
|
322
|
+
if (h) {
|
|
323
|
+
if (h.boostTags && Object.keys(h.boostTags).length) {
|
|
324
|
+
parts.push(
|
|
325
|
+
`boost: ` +
|
|
326
|
+
Object.entries(h.boostTags)
|
|
327
|
+
.map(([k, v]) => `${esc(k)}<span style="opacity:.6">×${v}</span>`)
|
|
328
|
+
.join(', ')
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (h.boostCards && Object.keys(h.boostCards).length) {
|
|
332
|
+
parts.push(
|
|
333
|
+
`boostCards: ` +
|
|
334
|
+
Object.entries(h.boostCards)
|
|
335
|
+
.map(([k, v]) => `${esc(k)}<span style="opacity:.6">×${v}</span>`)
|
|
336
|
+
.join(', ')
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (h.requireCards?.length) parts.push(`require: ${h.requireCards.map(esc).join(', ')}`);
|
|
340
|
+
if (h.requireTags?.length) parts.push(`requireTags: ${h.requireTags.map(esc).join(', ')}`);
|
|
341
|
+
if (h.excludeTags?.length) parts.push(`exclude: ${h.excludeTags.map(esc).join(', ')}`);
|
|
342
|
+
if (h.excludeCards?.length) parts.push(`excludeCards: ${h.excludeCards.map(esc).join(', ')}`);
|
|
343
|
+
}
|
|
344
|
+
const body = parts.length
|
|
345
|
+
? parts.map((p) => `<div style="margin-left:6px">${p}</div>`).join('')
|
|
346
|
+
: `<div style="margin-left:6px;opacity:.6">none</div>`;
|
|
347
|
+
return (
|
|
348
|
+
`<div style="margin-bottom:6px">` +
|
|
349
|
+
`<div style="color:#86efac">sessionHints</div>${body}</div>`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function queueHtml(key: string, label: string, q: SessionQueueDebug): string {
|
|
354
|
+
const collapsible = q.length > INLINE_THRESHOLD;
|
|
355
|
+
const isOpen = collapsible && expanded[key];
|
|
356
|
+
const caret = collapsible ? (expanded[key] ? '▾ ' : '▸ ') : '';
|
|
357
|
+
const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : '';
|
|
358
|
+
const titleStyle = collapsible ? 'cursor:pointer;color:#f9a8d4' : 'color:#f9a8d4';
|
|
359
|
+
const titleAttr = collapsible ? ` data-q="${key}"` : '';
|
|
360
|
+
const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
|
|
361
|
+
|
|
362
|
+
if (!q.cards.length) {
|
|
363
|
+
return title + `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Always list up to INLINE_THRESHOLD cards; the remainder hides behind an
|
|
367
|
+
// expand toggle so long queues never blow out the overlay but stay inspectable.
|
|
368
|
+
const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
|
|
369
|
+
const hiddenCount = q.length - shown.length;
|
|
370
|
+
const listMarginBottom = collapsible ? 2 : 6;
|
|
371
|
+
|
|
372
|
+
let body =
|
|
373
|
+
`<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` +
|
|
374
|
+
shown.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join('') +
|
|
375
|
+
`</ol>`;
|
|
376
|
+
|
|
377
|
+
if (collapsible) {
|
|
378
|
+
const footer = isOpen ? '▾ show less' : `… +${hiddenCount} more`;
|
|
379
|
+
body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return title + body;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Compact, colour-coded per-card glyph: ✓ correct, ✗ wrong, · info-only. */
|
|
386
|
+
function outcomeGlyph(correct: boolean | null): string {
|
|
387
|
+
if (correct === true) return `<span style="color:#86efac">✓</span>`;
|
|
388
|
+
if (correct === false) return `<span style="color:#fca5a5">✗</span>`;
|
|
389
|
+
return `<span style="opacity:.5">·</span>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Expandable list of every card the learner has interacted with this session.
|
|
394
|
+
* Mirrors `queueHtml`'s collapse behaviour but renders richer per-card detail
|
|
395
|
+
* (status, outcome, attempt count) since this is the audit trail, not a queue.
|
|
396
|
+
*/
|
|
397
|
+
function drawnHtml(key: string, drawn: SessionDrawnCardDebug[]): string {
|
|
398
|
+
const collapsible = drawn.length > INLINE_THRESHOLD;
|
|
399
|
+
const isOpen = collapsible && expanded[key];
|
|
400
|
+
const caret = collapsible ? (expanded[key] ? '▾ ' : '▸ ') : '';
|
|
401
|
+
const titleStyle = collapsible ? 'cursor:pointer;color:#c4b5fd' : 'color:#c4b5fd';
|
|
402
|
+
const titleAttr = collapsible ? ` data-q="${key}"` : '';
|
|
403
|
+
const title = `<div${titleAttr} style="${titleStyle}">${caret}drawn: ${drawn.length}</div>`;
|
|
404
|
+
|
|
405
|
+
if (!drawn.length) {
|
|
406
|
+
return title + `<div style="margin:1px 0 6px 6px;opacity:.5">none yet</div>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const shown = isOpen ? drawn : drawn.slice(0, INLINE_THRESHOLD);
|
|
410
|
+
const hiddenCount = drawn.length - shown.length;
|
|
411
|
+
const listMarginBottom = collapsible ? 2 : 6;
|
|
412
|
+
|
|
413
|
+
const rows = shown
|
|
414
|
+
.map((d) => {
|
|
415
|
+
const retries = d.attempts > 1 ? `<span style="opacity:.5"> ×${d.attempts}</span>` : '';
|
|
416
|
+
const time = `<span style="opacity:.45"> ${Math.round(d.timeSpentMs / 100) / 10}s</span>`;
|
|
417
|
+
return (
|
|
418
|
+
`<li style="white-space:nowrap">${outcomeGlyph(d.correct)} ${esc(d.cardID)}` +
|
|
419
|
+
`<span style="opacity:.5"> [${esc(d.status)}]</span>${retries}${time}</li>`
|
|
420
|
+
);
|
|
421
|
+
})
|
|
422
|
+
.join('');
|
|
423
|
+
|
|
424
|
+
let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">${rows}</ol>`;
|
|
425
|
+
|
|
426
|
+
if (collapsible) {
|
|
427
|
+
const footer = isOpen ? '▾ show less' : `… +${hiddenCount} more`;
|
|
428
|
+
body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return title + body;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Plain-text rendering of a snapshot for the clipboard. Mirrors the on-screen
|
|
436
|
+
* sections (without truncation) so a copied dump is a complete, paste-able
|
|
437
|
+
* picture of session state at the moment of the click.
|
|
438
|
+
*/
|
|
439
|
+
function snapshotToText(s: SessionDebugSnapshot | null): string {
|
|
440
|
+
if (!s) return 'SessionController — no active session.';
|
|
441
|
+
|
|
442
|
+
const lines: string[] = [];
|
|
443
|
+
lines.push('=== SessionController ===');
|
|
444
|
+
lines.push(`time ${formatTime(s.secondsRemaining)}`);
|
|
445
|
+
if (s.hasCardGuarantee) lines.push(`guarantee: ${s.minCardsGuarantee}`);
|
|
446
|
+
lines.push(`well-indicated left: ${s.wellIndicatedRemaining}`);
|
|
447
|
+
lines.push(`current: ${s.currentCard ?? '—'}`);
|
|
448
|
+
lines.push(
|
|
449
|
+
s.replanActive ? `replan: ACTIVE [${s.replanLabel ?? '(auto)'}]` : 'replan: idle'
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
lines.push('');
|
|
453
|
+
lines.push('sessionHints:');
|
|
454
|
+
const h = s.sessionHints;
|
|
455
|
+
const hintParts: string[] = [];
|
|
456
|
+
if (h) {
|
|
457
|
+
if (h.boostTags && Object.keys(h.boostTags).length)
|
|
458
|
+
hintParts.push(` boost: ${Object.entries(h.boostTags).map(([k, v]) => `${k}×${v}`).join(', ')}`);
|
|
459
|
+
if (h.boostCards && Object.keys(h.boostCards).length)
|
|
460
|
+
hintParts.push(` boostCards: ${Object.entries(h.boostCards).map(([k, v]) => `${k}×${v}`).join(', ')}`);
|
|
461
|
+
if (h.requireCards?.length) hintParts.push(` require: ${h.requireCards.join(', ')}`);
|
|
462
|
+
if (h.requireTags?.length) hintParts.push(` requireTags: ${h.requireTags.join(', ')}`);
|
|
463
|
+
if (h.excludeTags?.length) hintParts.push(` exclude: ${h.excludeTags.join(', ')}`);
|
|
464
|
+
if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(', ')}`);
|
|
465
|
+
}
|
|
466
|
+
lines.push(hintParts.length ? hintParts.join('\n') : ' none');
|
|
467
|
+
|
|
468
|
+
const queueText = (label: string, q: SessionQueueDebug) => {
|
|
469
|
+
lines.push('');
|
|
470
|
+
lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
|
|
471
|
+
q.cards.forEach((c, i) => lines.push(` ${i + 1}. ${c}`));
|
|
472
|
+
};
|
|
473
|
+
queueText('reviewQ', s.reviewQ);
|
|
474
|
+
queueText('newQ', s.newQ);
|
|
475
|
+
queueText('failedQ', s.failedQ);
|
|
476
|
+
|
|
477
|
+
lines.push('');
|
|
478
|
+
lines.push(`drawn: ${s.drawnCards.length}`);
|
|
479
|
+
s.drawnCards.forEach((d, i) => {
|
|
480
|
+
const mark = d.correct === true ? '✓' : d.correct === false ? '✗' : '·';
|
|
481
|
+
const time = `${Math.round(d.timeSpentMs / 100) / 10}s`;
|
|
482
|
+
lines.push(` ${i + 1}. ${mark} ${d.cardID} [${d.status}] ×${d.attempts} ${time}`);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
return lines.join('\n');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function formatTime(totalSeconds: number): string {
|
|
489
|
+
const s = Math.max(0, Math.round(totalSeconds));
|
|
490
|
+
const m = Math.floor(s / 60);
|
|
491
|
+
const r = s % 60;
|
|
492
|
+
return `${m}:${r.toString().padStart(2, '0')}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function esc(value: string): string {
|
|
496
|
+
return value
|
|
497
|
+
.replace(/&/g, '&')
|
|
498
|
+
.replace(/</g, '<')
|
|
499
|
+
.replace(/>/g, '>');
|
|
500
|
+
}
|