@vue-skuilder/db 0.2.3 → 0.2.4

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.3",
7
+ "version": "0.2.4",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.2.3",
51
+ "@vue-skuilder/common": "0.2.4",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -62,5 +62,5 @@
62
62
  "vite": "^8.0.0",
63
63
  "vitest": "^4.1.0"
64
64
  },
65
- "stableVersion": "0.2.3"
65
+ "stableVersion": "0.2.4"
66
66
  }
@@ -21,6 +21,7 @@ import { mergeHints } from '@db/core/navigators/Pipeline';
21
21
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
22
22
  import { captureMixerRun } from './MixerDebugger';
23
23
  import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
24
+ import { registerActiveController, type SessionDebugSnapshot, type SessionQueueDebug } from './SessionOverlay';
24
25
 
25
26
  // ReplanHints is defined in generators/types to avoid circular dependencies.
26
27
  // Re-exported here for backward compatibility.
@@ -236,6 +237,14 @@ export class SessionController<TView = unknown> extends Loggable {
236
237
  */
237
238
  private _replanPromise: Promise<void> | null = null;
238
239
 
240
+ /**
241
+ * Reason for the replan currently executing in `_runReplan`, surfaced by the
242
+ * debug overlay's spinner. The caller's `opts.label` when present, else
243
+ * `'(auto)'`. Only meaningful while `_replanPromise` is non-null; cleared
244
+ * when the in-flight chain settles.
245
+ */
246
+ private _activeReplanLabel: string | null = null;
247
+
239
248
  /**
240
249
  * Number of well-indicated new cards remaining before the queue
241
250
  * degrades to poorly-indicated content. Decremented on each newQ
@@ -372,6 +381,11 @@ export class SessionController<TView = unknown> extends Loggable {
372
381
  endTime: ${this.endTime}
373
382
  defaultBatchLimit: ${this._defaultBatchLimit}
374
383
  initialReviewCap: ${this._initialReviewCap}`);
384
+
385
+ // Expose this (now the most-recently-constructed) controller to the debug
386
+ // overlay (window.skuilder.session.dbgOverlay()). A new session overwrites
387
+ // the prior handle; no-op overhead when the overlay is never opened.
388
+ registerActiveController(this);
375
389
  }
376
390
 
377
391
  private tick() {
@@ -500,17 +514,33 @@ export class SessionController<TView = unknown> extends Loggable {
500
514
  .catch(() => undefined)
501
515
  .then(() => this._runReplan(opts));
502
516
 
503
- this._replanPromise = queued.finally(() => {
504
- if (this._replanPromise === queued) this._replanPromise = null;
517
+ // Compare against the promise we actually store. `.finally()` returns a
518
+ // NEW promise, so guarding on `=== queued` (the pre-finally promise) never
519
+ // matches and would leak _replanPromise. `tracked` is read only inside the
520
+ // async callback (after init), so the self-reference is safe.
521
+ const tracked: Promise<void> = queued.finally(() => {
522
+ if (this._replanPromise === tracked) {
523
+ this._replanPromise = null;
524
+ this._activeReplanLabel = null;
525
+ }
505
526
  });
527
+ this._replanPromise = tracked;
506
528
 
507
529
  return queued;
508
530
  }
509
531
 
510
532
  const run = this._runReplan(opts);
511
- this._replanPromise = run.finally(() => {
512
- if (this._replanPromise === run) this._replanPromise = null;
533
+ // Compare against the wrapped promise we store, not `run` — `.finally()`
534
+ // returns a new promise, so `=== run` never matches and _replanPromise
535
+ // would never clear (perpetual "replan in progress"). Safe self-reference:
536
+ // `tracked` is read only in the async callback, after initialization.
537
+ const tracked: Promise<void> = run.finally(() => {
538
+ if (this._replanPromise === tracked) {
539
+ this._replanPromise = null;
540
+ this._activeReplanLabel = null;
541
+ }
513
542
  });
543
+ this._replanPromise = tracked;
514
544
 
515
545
  await run;
516
546
  }
@@ -521,7 +551,12 @@ export class SessionController<TView = unknown> extends Loggable {
521
551
  * triggers in nextCard) return false and may coalesce.
522
552
  */
523
553
  private _replanHasIntent(opts: ReplanOptions): boolean {
524
- if (opts.label) return true;
554
+ // NOTE: `label` is intentionally NOT an intent signal. It is observability-
555
+ // only metadata (debug overlay spinner, log tags, Pipeline strategy names),
556
+ // so labelling a replan must never change scheduling. Intent is strictly
557
+ // "does this replan carry scheduling-relevant options". This lets the
558
+ // unlabeled-but-named auto-replans (auto:depletion / auto:quality) keep
559
+ // coalescing while still showing a reason in the overlay.
525
560
  if (opts.limit !== undefined) return true;
526
561
  if (opts.minFollowUpCards !== undefined) return true;
527
562
  if (opts.mode && opts.mode !== 'replace') return true;
@@ -543,6 +578,11 @@ export class SessionController<TView = unknown> extends Loggable {
543
578
  * newQ.peek(0) is the imminent draw we need to exclude.
544
579
  */
545
580
  private async _runReplan(opts: ReplanOptions): Promise<void> {
581
+ // Surface the executing replan's reason to the debug overlay spinner.
582
+ // `label` is observability-only (see _replanHasIntent); '(auto)' covers any
583
+ // unlabeled path.
584
+ this._activeReplanLabel = opts.label ?? '(auto)';
585
+
546
586
  // Exclude all cards already presented this session. The pipeline may
547
587
  // not yet see their encounter records (async writes), so without this
548
588
  // they can re-enter newQ via replaceAll and cause duplicates.
@@ -638,6 +678,35 @@ export class SessionController<TView = unknown> extends Loggable {
638
678
  return this._sessionHints;
639
679
  }
640
680
 
681
+ /**
682
+ * Live state snapshot for the debug overlay (window.skuilder.session
683
+ * .dbgOverlay()). Reads directly from the private queues and hints, so it
684
+ * always reflects the current moment — unlike the passive SessionDebugger
685
+ * snapshots, which only capture what was explicitly pushed to them.
686
+ */
687
+ public getDebugSnapshot(): SessionDebugSnapshot {
688
+ const describe = <T extends { cardID: string }>(q: ItemQueue<T>): SessionQueueDebug => {
689
+ const cards: string[] = [];
690
+ for (let i = 0; i < q.length; i++) {
691
+ cards.push(q.peek(i).cardID);
692
+ }
693
+ return { length: q.length, dequeueCount: q.dequeueCount, cards };
694
+ };
695
+ return {
696
+ secondsRemaining: this.secondsRemaining,
697
+ hasCardGuarantee: this.hasCardGuarantee,
698
+ minCardsGuarantee: this._minCardsGuarantee,
699
+ wellIndicatedRemaining: this._wellIndicatedRemaining,
700
+ currentCard: this._currentCard?.item.cardID ?? null,
701
+ sessionHints: this._sessionHints,
702
+ replanActive: this._replanPromise !== null,
703
+ replanLabel: this._activeReplanLabel,
704
+ reviewQ: describe(this.reviewQ),
705
+ newQ: describe(this.newQ),
706
+ failedQ: describe(this.failedQ),
707
+ };
708
+ }
709
+
641
710
  /**
642
711
  * Merge `hints` into the durable session hints via the pipeline's
643
712
  * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
@@ -734,9 +803,14 @@ export class SessionController<TView = unknown> extends Loggable {
734
803
  */
735
804
  private async _replanUncoalesced(opts: ReplanOptions): Promise<void> {
736
805
  const run = this._runReplan(opts);
737
- this._replanPromise = run.finally(() => {
738
- if (this._replanPromise === run) this._replanPromise = null;
806
+ // See requestReplan: guard against the wrapped promise we store, not `run`.
807
+ const tracked: Promise<void> = run.finally(() => {
808
+ if (this._replanPromise === tracked) {
809
+ this._replanPromise = null;
810
+ this._activeReplanLabel = null;
811
+ }
739
812
  });
813
+ this._replanPromise = tracked;
740
814
  await run;
741
815
  }
742
816
 
@@ -1272,7 +1346,8 @@ export class SessionController<TView = unknown> extends Loggable {
1272
1346
  `(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
1273
1347
  `Triggering background replan.`
1274
1348
  );
1275
- void this.requestReplan();
1349
+ // label is observability-only (overlay/logs); does not affect coalescing.
1350
+ void this.requestReplan({ label: 'auto:depletion' });
1276
1351
  }
1277
1352
 
1278
1353
  // Opportunistic quality: few well-indicated cards remain.
@@ -1288,7 +1363,8 @@ export class SessionController<TView = unknown> extends Loggable {
1288
1363
  `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
1289
1364
  `(newQ: ${this.newQ.length}). Triggering background replan.`
1290
1365
  );
1291
- void this.requestReplan();
1366
+ // label is observability-only (overlay/logs); does not affect coalescing.
1367
+ void this.requestReplan({ label: 'auto:quality' });
1292
1368
  }
1293
1369
 
1294
1370
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
@@ -1,5 +1,6 @@
1
1
  import { logger } from '../util/logger';
2
2
  import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
3
+ import { toggleSessionOverlay } from './SessionOverlay';
3
4
 
4
5
  // ============================================================================
5
6
  // SESSION DEBUGGER
@@ -344,6 +345,14 @@ export const sessionDebugAPI = {
344
345
  showCurrentQueue();
345
346
  },
346
347
 
348
+ /**
349
+ * Toggle the pinned, live-updating DOM overlay for the active controller
350
+ * (queues, session hints, timer). No-ops in non-browser hosts.
351
+ */
352
+ dbgOverlay(): void {
353
+ toggleSessionOverlay();
354
+ },
355
+
347
356
  /**
348
357
  * Show presentation history for current or past session.
349
358
  */
@@ -413,6 +422,7 @@ export const sessionDebugAPI = {
413
422
  🎯 Session Debug API
414
423
 
415
424
  Commands:
425
+ .dbgOverlay() Toggle the pinned live overlay (queues, hints, timer)
416
426
  .showQueue() Show current queue state (active session only)
417
427
  .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
418
428
  .showInterleaving(index?) Analyze course interleaving pattern
@@ -0,0 +1,276 @@
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
+ /** Live snapshot of the controller, read fresh on each overlay tick. */
29
+ export interface SessionDebugSnapshot {
30
+ secondsRemaining: number;
31
+ hasCardGuarantee: boolean;
32
+ minCardsGuarantee: number;
33
+ wellIndicatedRemaining: number;
34
+ /** cardID of the card currently in front of the learner, if any. */
35
+ currentCard: string | null;
36
+ /** Session-durable hints re-merged into every pipeline run this session. */
37
+ sessionHints: ReplanHints | null;
38
+ /** True while a replan is executing (in-flight). */
39
+ replanActive: boolean;
40
+ /** Reason for the in-flight replan (caller label, or '(auto)'); may be stale when idle. */
41
+ replanLabel: string | null;
42
+ reviewQ: SessionQueueDebug;
43
+ newQ: SessionQueueDebug;
44
+ failedQ: SessionQueueDebug;
45
+ }
46
+
47
+ /** The narrow surface the overlay needs from a SessionController. */
48
+ export interface SessionDebugTarget {
49
+ getDebugSnapshot(): SessionDebugSnapshot;
50
+ }
51
+
52
+ // ----------------------------------------------------------------------------
53
+ // Active-controller registry
54
+ // ----------------------------------------------------------------------------
55
+ //
56
+ // The controller registers itself on construction; a new session overwrites the
57
+ // prior handle. Kept here (a leaf module) so SessionController can import the
58
+ // registrar without pulling in the overlay's DOM code or risking an import
59
+ // cycle with SessionDebugger.
60
+
61
+ let activeController: SessionDebugTarget | null = null;
62
+
63
+ /** Called by SessionController's constructor. Pass `null` to deregister. */
64
+ export function registerActiveController(controller: SessionDebugTarget | null): void {
65
+ activeController = controller;
66
+ }
67
+
68
+ export function getActiveController(): SessionDebugTarget | null {
69
+ return activeController;
70
+ }
71
+
72
+ // ----------------------------------------------------------------------------
73
+ // Overlay rendering (vanilla DOM)
74
+ // ----------------------------------------------------------------------------
75
+
76
+ const OVERLAY_ID = 'skuilder-session-overlay';
77
+ const POLL_MS = 300;
78
+ /** Queues with at most this many cards are listed outright; larger ones collapse to a count. */
79
+ const INLINE_THRESHOLD = 5;
80
+
81
+ /** Braille spinner frames, advanced once per render tick (≈POLL_MS cadence). */
82
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
83
+ let spinnerFrame = 0;
84
+
85
+ let overlayEl: HTMLElement | null = null;
86
+ let pollHandle: ReturnType<typeof setInterval> | null = null;
87
+
88
+ /** Expansion state for collapsible (large) queues, preserved across re-renders. */
89
+ const expanded: Record<string, boolean> = { reviewQ: false, newQ: false, failedQ: false };
90
+
91
+ /**
92
+ * Toggle the pinned overlay on/off. No-ops (with a console hint) when there is
93
+ * no DOM, so it is safe to call from any host environment.
94
+ */
95
+ export function toggleSessionOverlay(): void {
96
+ if (typeof document === 'undefined') {
97
+ logger.info('[Session Overlay] No DOM available (non-browser host); overlay unavailable.');
98
+ return;
99
+ }
100
+ if (overlayEl) {
101
+ teardown();
102
+ logger.info('[Session Overlay] Hidden.');
103
+ } else {
104
+ mount();
105
+ logger.info('[Session Overlay] Shown. Toggle off with window.skuilder.session.dbgOverlay().');
106
+ }
107
+ }
108
+
109
+ function mount(): void {
110
+ overlayEl = document.createElement('div');
111
+ overlayEl.id = OVERLAY_ID;
112
+ Object.assign(overlayEl.style, {
113
+ position: 'fixed',
114
+ top: '8px',
115
+ left: '8px',
116
+ zIndex: '2147483647',
117
+ maxWidth: '320px',
118
+ maxHeight: '90vh',
119
+ overflowY: 'auto',
120
+ padding: '8px 10px',
121
+ background: 'rgba(17, 24, 39, 0.92)',
122
+ color: '#e5e7eb',
123
+ font: '11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace',
124
+ borderRadius: '6px',
125
+ boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
126
+ pointerEvents: 'auto',
127
+ userSelect: 'none',
128
+ });
129
+ document.body.appendChild(overlayEl);
130
+ render();
131
+ pollHandle = setInterval(render, POLL_MS);
132
+ }
133
+
134
+ function teardown(): void {
135
+ if (pollHandle !== null) {
136
+ clearInterval(pollHandle);
137
+ pollHandle = null;
138
+ }
139
+ if (overlayEl?.parentNode) {
140
+ overlayEl.parentNode.removeChild(overlayEl);
141
+ }
142
+ overlayEl = null;
143
+ }
144
+
145
+ function render(): void {
146
+ if (!overlayEl) return;
147
+
148
+ spinnerFrame++;
149
+
150
+ const ctrl = getActiveController();
151
+ if (!ctrl) {
152
+ overlayEl.innerHTML = headerHtml() + `<div style="opacity:.65">No active session.</div>`;
153
+ return;
154
+ }
155
+
156
+ const s = ctrl.getDebugSnapshot();
157
+ overlayEl.innerHTML =
158
+ headerHtml() +
159
+ replanHtml(s) +
160
+ metaHtml(s) +
161
+ hintsHtml(s.sessionHints) +
162
+ queueHtml('reviewQ', 'reviewQ', s.reviewQ) +
163
+ queueHtml('newQ', 'newQ', s.newQ) +
164
+ queueHtml('failedQ', 'failedQ', s.failedQ);
165
+
166
+ // Re-attach toggle handlers for collapsible queue headers each render.
167
+ overlayEl.querySelectorAll<HTMLElement>('[data-q]').forEach((el) => {
168
+ el.onclick = () => {
169
+ const key = el.dataset.q;
170
+ if (!key) return;
171
+ expanded[key] = !expanded[key];
172
+ render();
173
+ };
174
+ });
175
+ }
176
+
177
+ function headerHtml(): string {
178
+ return `<div style="font-weight:600;color:#93c5fd;margin-bottom:4px">⚙ SessionController</div>`;
179
+ }
180
+
181
+ function replanHtml(s: SessionDebugSnapshot): string {
182
+ if (!s.replanActive) {
183
+ return `<div style="margin-bottom:6px;opacity:.45">○ idle</div>`;
184
+ }
185
+ const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
186
+ const reason = esc(s.replanLabel ?? '(auto)');
187
+ return (
188
+ `<div style="margin-bottom:6px;color:#fde047">` +
189
+ `${frame} replanning <span style="opacity:.85">[${reason}]</span></div>`
190
+ );
191
+ }
192
+
193
+ function metaHtml(s: SessionDebugSnapshot): string {
194
+ const mmss = formatTime(s.secondsRemaining);
195
+ const guarantee = s.hasCardGuarantee
196
+ ? ` · <span style="color:#fbbf24">guarantee ${s.minCardsGuarantee}</span>`
197
+ : '';
198
+ const rows = [
199
+ `time ${mmss}${guarantee}`,
200
+ `well-indicated left: ${s.wellIndicatedRemaining}`,
201
+ `current: ${s.currentCard ? esc(s.currentCard) : '<span style="opacity:.6">—</span>'}`,
202
+ ];
203
+ return `<div style="margin-bottom:6px">${rows.map((r) => `<div>${r}</div>`).join('')}</div>`;
204
+ }
205
+
206
+ function hintsHtml(h: ReplanHints | null): string {
207
+ const parts: string[] = [];
208
+ if (h) {
209
+ if (h.boostTags && Object.keys(h.boostTags).length) {
210
+ parts.push(
211
+ `boost: ` +
212
+ Object.entries(h.boostTags)
213
+ .map(([k, v]) => `${esc(k)}<span style="opacity:.6">×${v}</span>`)
214
+ .join(', ')
215
+ );
216
+ }
217
+ if (h.boostCards && Object.keys(h.boostCards).length) {
218
+ parts.push(
219
+ `boostCards: ` +
220
+ Object.entries(h.boostCards)
221
+ .map(([k, v]) => `${esc(k)}<span style="opacity:.6">×${v}</span>`)
222
+ .join(', ')
223
+ );
224
+ }
225
+ if (h.requireCards?.length) parts.push(`require: ${h.requireCards.map(esc).join(', ')}`);
226
+ if (h.requireTags?.length) parts.push(`requireTags: ${h.requireTags.map(esc).join(', ')}`);
227
+ if (h.excludeTags?.length) parts.push(`exclude: ${h.excludeTags.map(esc).join(', ')}`);
228
+ if (h.excludeCards?.length) parts.push(`excludeCards: ${h.excludeCards.map(esc).join(', ')}`);
229
+ }
230
+ const body = parts.length
231
+ ? parts.map((p) => `<div style="margin-left:6px">${p}</div>`).join('')
232
+ : `<div style="margin-left:6px;opacity:.6">none</div>`;
233
+ return (
234
+ `<div style="margin-bottom:6px">` +
235
+ `<div style="color:#86efac">sessionHints</div>${body}</div>`
236
+ );
237
+ }
238
+
239
+ function queueHtml(key: string, label: string, q: SessionQueueDebug): string {
240
+ const collapsible = q.length > INLINE_THRESHOLD;
241
+ const isOpen = !collapsible || expanded[key];
242
+ const caret = collapsible ? (expanded[key] ? '▾ ' : '▸ ') : '';
243
+ const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : '';
244
+ const titleStyle = collapsible
245
+ ? 'cursor:pointer;color:#f9a8d4'
246
+ : 'color:#f9a8d4';
247
+ const titleAttr = collapsible ? ` data-q="${key}"` : '';
248
+ const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
249
+
250
+ let body = '';
251
+ if (isOpen && q.cards.length) {
252
+ body =
253
+ `<ol style="margin:2px 0 6px 0;padding-left:20px">` +
254
+ q.cards.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join('') +
255
+ `</ol>`;
256
+ } else if (!q.cards.length) {
257
+ body = `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
258
+ } else {
259
+ body = `<div style="margin:1px 0 6px 6px;opacity:.55">(${q.length} cards — click to expand)</div>`;
260
+ }
261
+ return title + body;
262
+ }
263
+
264
+ function formatTime(totalSeconds: number): string {
265
+ const s = Math.max(0, Math.round(totalSeconds));
266
+ const m = Math.floor(s / 60);
267
+ const r = s % 60;
268
+ return `${m}:${r.toString().padStart(2, '0')}`;
269
+ }
270
+
271
+ function esc(value: string): string {
272
+ return value
273
+ .replace(/&/g, '&amp;')
274
+ .replace(/</g, '&lt;')
275
+ .replace(/>/g, '&gt;');
276
+ }