@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/dist/index.d.cts +45 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +220 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +220 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/study/SessionController.ts +85 -9
- package/src/study/SessionDebugger.ts +10 -0
- package/src/study/SessionOverlay.ts +276 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.2.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '&')
|
|
274
|
+
.replace(/</g, '<')
|
|
275
|
+
.replace(/>/g, '>');
|
|
276
|
+
}
|