eklavya 1.24.1 → 1.25.0
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/assets/dashboard.html +1011 -254
- package/dist/cli.js +152 -48
- package/dist/cli.js.map +1 -1
- package/dist/dashboard.js +253 -1
- package/dist/dashboard.js.map +1 -1
- package/dist/hooks/memory-lib.js +3 -31
- package/dist/hooks/memory-lib.js.map +1 -1
- package/dist/memory/provider.js +102 -21
- package/dist/memory/provider.js.map +1 -1
- package/dist/memory/reservation.js +279 -24
- package/dist/memory/reservation.js.map +1 -1
- package/dist/memory/store.js +86 -0
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/worker.js +161 -7
- package/dist/memory/worker.js.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/tools/memory_read_tools.js +1 -1
- package/dist/tools/memory_read_tools.js.map +1 -1
- package/dist/user-skill/eklavya/SKILL.md +23 -12
- package/package.json +2 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>Eklavya —
|
|
6
|
+
<title>Eklavya — dashboard</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230E6E66'/%3E%3Cg fill='none' stroke='%23fff' stroke-width='1.8' stroke-linecap='round'%3E%3Cpath d='M7 5a11 11 0 0 1 0 14'/%3E%3Cpath d='M6 12h12'/%3E%3Cpath d='M14 9l3 3-3 3'/%3E%3C/g%3E%3C/svg%3E">
|
|
8
8
|
<script>
|
|
9
9
|
/* Ground selection, shared with the site: same localStorage key, same default.
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
window.eklavyaGround.apply(window.eklavyaGround.read());
|
|
32
32
|
})();
|
|
33
33
|
</script>
|
|
34
|
+
<!-- Tokens only, and served with their remote font import stripped: nothing on
|
|
35
|
+
this page is fetched from anywhere but this loopback server. The font
|
|
36
|
+
stacks fall through to the system faces they already name. -->
|
|
34
37
|
<link rel="stylesheet" href="/tokens.css">
|
|
35
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
36
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
37
|
-
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@600;800;900&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
38
38
|
<style>
|
|
39
39
|
/* ============================================================
|
|
40
40
|
Layout: a fixed rail and one scrolling column.
|
|
@@ -75,7 +75,44 @@
|
|
|
75
75
|
.side__label { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps);
|
|
76
76
|
text-transform:uppercase; color:var(--faint); margin:0 0 var(--space-2); }
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/* ---------- the workflow control: open this workflow, or switch ---------- */
|
|
79
|
+
/* Two targets in one row, the way the reference shell does it: the name opens
|
|
80
|
+
the active workflow's Dashboard, the caret opens the picker. They are two
|
|
81
|
+
buttons rather than one, because "go home" and "go somewhere else" are two
|
|
82
|
+
different intentions and one click should never guess between them. */
|
|
83
|
+
.wf { display:flex; margin:0 var(--space-5); border:1px solid var(--line-2); background:var(--bg); }
|
|
84
|
+
.wf__main { flex:1; min-width:0; display:flex; align-items:center; gap:var(--space-3);
|
|
85
|
+
padding:var(--space-2) var(--space-3); text-decoration:none; color:var(--ink);
|
|
86
|
+
font-weight:var(--weight-semibold); font-size:14px; }
|
|
87
|
+
.wf__main:hover { background:var(--mass); }
|
|
88
|
+
.wf__main svg, .picker svg { width:17px; height:17px; flex:none; color:var(--spot); }
|
|
89
|
+
.wf__caret { flex:none; width:36px; background:none; border:0; border-left:1px solid var(--line-2);
|
|
90
|
+
color:var(--dim); cursor:pointer; display:grid; place-items:center; }
|
|
91
|
+
.wf__caret:hover, .wf__caret[aria-expanded="true"] { background:var(--mass); color:var(--ink); }
|
|
92
|
+
.wf__caret svg { width:14px; height:14px; transition:transform var(--duration-fast) var(--ease); }
|
|
93
|
+
.wf__caret[aria-expanded="true"] svg { transform:rotate(180deg); }
|
|
94
|
+
|
|
95
|
+
/* The picker lives at the end of <body>, outside the rail: a menu inside a
|
|
96
|
+
scrolling sidebar is clipped by it. Its position is computed and clamped to
|
|
97
|
+
the viewport, so it never opens off-screen on a phone or a short window. */
|
|
98
|
+
.picker { position:fixed; z-index:40; min-width:220px; max-width:calc(100vw - 16px);
|
|
99
|
+
background:var(--panel); border:1px solid var(--line-2); padding:var(--space-1) 0;
|
|
100
|
+
box-shadow:0 12px 32px rgba(0,0,0,.28); overflow-y:auto; }
|
|
101
|
+
.picker[hidden] { display:none; }
|
|
102
|
+
.picker__label { font-family:var(--font-mono); font-size:10px; letter-spacing:var(--tracking-caps);
|
|
103
|
+
text-transform:uppercase; color:var(--faint); padding:var(--space-2) var(--space-4) var(--space-1); margin:0; }
|
|
104
|
+
.picker button { display:flex; align-items:center; gap:var(--space-3); width:100%; text-align:left;
|
|
105
|
+
background:none; border:0; color:var(--dim); font:inherit; font-size:14px;
|
|
106
|
+
padding:var(--space-2) var(--space-4); cursor:pointer; }
|
|
107
|
+
.picker button:hover, .picker button:focus-visible { background:var(--mass); color:var(--ink); box-shadow:none; outline:none; }
|
|
108
|
+
.picker button:focus-visible { box-shadow:inset 2px 0 0 var(--spot); }
|
|
109
|
+
.picker button[aria-checked="true"] { color:var(--ink); }
|
|
110
|
+
.picker button span { flex:1; }
|
|
111
|
+
.picker .check { width:15px; height:15px; color:var(--spot); visibility:hidden; }
|
|
112
|
+
.picker button[aria-checked="true"] .check { visibility:visible; }
|
|
113
|
+
|
|
114
|
+
/* ---------- the sidebar navigation ---------- */
|
|
115
|
+
nav.side__nav { display:flex; flex-direction:column; gap:var(--space-4); }
|
|
79
116
|
nav.side__nav a { display:flex; align-items:center; gap:var(--space-3); text-decoration:none;
|
|
80
117
|
padding:var(--space-2) var(--space-5); color:var(--dim); font-size:14px;
|
|
81
118
|
border-left:2px solid transparent;
|
|
@@ -87,6 +124,22 @@
|
|
|
87
124
|
nav.side__nav a i { margin-left:auto; font-style:normal; font-family:var(--font-mono);
|
|
88
125
|
font-size:11px; color:var(--faint); }
|
|
89
126
|
nav.side__nav a i.hot { color:var(--warning); }
|
|
127
|
+
.nav__toggle { display:flex; align-items:center; gap:var(--space-2); width:100%; background:none; border:0;
|
|
128
|
+
font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps);
|
|
129
|
+
text-transform:uppercase; color:var(--faint); padding:var(--space-1) var(--space-5);
|
|
130
|
+
cursor:pointer; text-align:left; }
|
|
131
|
+
.nav__toggle:hover { color:var(--ink); }
|
|
132
|
+
.nav__toggle svg { width:12px; height:12px; transition:transform var(--duration-fast) var(--ease); }
|
|
133
|
+
.nav__toggle[aria-expanded="false"] svg { transform:rotate(-90deg); }
|
|
134
|
+
.nav__items[hidden] { display:none; }
|
|
135
|
+
|
|
136
|
+
/* ---------- phone chrome: a top bar and a drawer ---------- */
|
|
137
|
+
.topbar { display:none; }
|
|
138
|
+
.scrim { display:none; }
|
|
139
|
+
.side__close { display:none; }
|
|
140
|
+
.crumb { display:flex; gap:var(--space-2); flex-wrap:wrap; }
|
|
141
|
+
.crumb span + span::before { content:'·'; margin-right:var(--space-2); color:var(--faint); }
|
|
142
|
+
.crumb .dim { color:var(--faint); }
|
|
90
143
|
|
|
91
144
|
.foot { margin-top:var(--space-12); padding-top:var(--space-5); border-top:1px solid var(--line);
|
|
92
145
|
color:var(--faint); font-size:var(--text-xs); display:flex; gap:var(--space-4);
|
|
@@ -128,7 +181,7 @@
|
|
|
128
181
|
.card--flush { padding:0; }
|
|
129
182
|
.head { margin-bottom:var(--space-4); }
|
|
130
183
|
.head h2 { font-size:var(--text-md); font-weight:var(--weight-bold); letter-spacing:-.01em; margin:0; }
|
|
131
|
-
.head p { color:var(--faint); font-size:13.5px; margin:2px 0 0; max-width:78ch; }
|
|
184
|
+
.head p { color:var(--faint); font-size:13.5px; margin:2px 0 0; max-width:78ch; overflow-wrap:anywhere; }
|
|
132
185
|
.head--row { display:flex; align-items:flex-end; gap:var(--space-4); flex-wrap:wrap; }
|
|
133
186
|
.head--row > :first-child { flex:1 1 320px; }
|
|
134
187
|
|
|
@@ -316,24 +369,43 @@
|
|
|
316
369
|
@media (max-width:1080px) {
|
|
317
370
|
.g3 { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
|
318
371
|
}
|
|
372
|
+
/* At 900 and below the rail becomes a drawer: the same navigation, the same
|
|
373
|
+
workflow control and the same controls, behind a menu button. It is a modal
|
|
374
|
+
while open (focus is held inside it, the page behind is inert) and it is
|
|
375
|
+
`inert` while closed, so a hidden drawer is never in the Tab order. */
|
|
319
376
|
@media (max-width:900px) {
|
|
320
|
-
.app { grid-template-columns:1fr; }
|
|
321
|
-
.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.
|
|
330
|
-
|
|
331
|
-
.
|
|
332
|
-
|
|
377
|
+
.app { grid-template-columns:minmax(0,1fr); }
|
|
378
|
+
.topbar { display:flex; align-items:center; gap:var(--space-3); position:sticky; top:0; z-index:20;
|
|
379
|
+
background:var(--panel); border-bottom:1px solid var(--line-2); padding:var(--space-2) var(--space-4); }
|
|
380
|
+
.topbar__menu { background:none; border:1px solid var(--line-2); color:var(--ink); width:38px; height:38px;
|
|
381
|
+
display:grid; place-items:center; cursor:pointer; flex:none; }
|
|
382
|
+
.topbar__menu svg { width:18px; height:18px; }
|
|
383
|
+
.topbar__brand { display:flex; align-items:center; gap:var(--space-2); text-decoration:none; color:inherit;
|
|
384
|
+
font-family:var(--font-disp); font-weight:var(--weight-extra); letter-spacing:var(--tracking-tight); }
|
|
385
|
+
.topbar__brand .mark { width:26px; height:26px; }
|
|
386
|
+
.topbar__wf { margin-left:auto; font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps);
|
|
387
|
+
text-transform:uppercase; color:var(--spot); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
388
|
+
.side { position:fixed; top:0; bottom:0; left:0; z-index:30; width:min(300px, 86vw); height:100vh; height:100dvh;
|
|
389
|
+
border-right:1px solid var(--line-2); visibility:hidden; left:-100%;
|
|
390
|
+
transition:left var(--duration-base) var(--ease), visibility 0s linear var(--duration-base); }
|
|
391
|
+
.side.is-open { visibility:visible; left:0; transition:left var(--duration-base) var(--ease); }
|
|
392
|
+
.side__close { display:grid; place-items:center; position:absolute; top:var(--space-4); right:var(--space-4);
|
|
393
|
+
background:none; border:1px solid var(--line-2); color:var(--ink); width:34px; height:34px; cursor:pointer; }
|
|
394
|
+
.side__close svg { width:16px; height:16px; }
|
|
395
|
+
.side__brand { padding-right:64px; }
|
|
396
|
+
.scrim { position:fixed; inset:0; z-index:25; background:rgba(0,0,0,.45); }
|
|
397
|
+
.scrim.is-open { display:block; }
|
|
398
|
+
body.drawer-open { overflow:hidden; }
|
|
333
399
|
main { padding:var(--space-6) var(--space-5) var(--space-16); }
|
|
334
400
|
.g2, .g3 { grid-template-columns:minmax(0,1fr); }
|
|
335
401
|
}
|
|
336
402
|
@media (max-width:560px) {
|
|
403
|
+
/* The check rows carry a label, a bar and a sentence; on a phone the
|
|
404
|
+
sentence goes under the bar rather than off the edge of the screen. */
|
|
405
|
+
.checks div { flex-wrap:wrap; }
|
|
406
|
+
.checks .bar { flex:1 1 120px; }
|
|
407
|
+
.checks .slug { white-space:normal !important; flex-basis:100%; }
|
|
408
|
+
main { padding:var(--space-5) var(--space-4) var(--space-16); }
|
|
337
409
|
.stats { grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--space-3); }
|
|
338
410
|
.tile strong { font-size:var(--text-xl); }
|
|
339
411
|
.card { padding:var(--space-5) var(--space-4); }
|
|
@@ -347,8 +419,28 @@
|
|
|
347
419
|
</head>
|
|
348
420
|
<body>
|
|
349
421
|
<div class="app">
|
|
350
|
-
|
|
351
|
-
|
|
422
|
+
<!-- Phones only: the rail is a drawer behind this bar. -->
|
|
423
|
+
<header class="topbar" id="topbar">
|
|
424
|
+
<button type="button" class="topbar__menu" id="menu" aria-controls="side" aria-expanded="false" aria-label="Open navigation">
|
|
425
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
|
426
|
+
</button>
|
|
427
|
+
<a class="topbar__brand" id="brand-top" href="#/learning/dashboard" aria-label="Eklavya, Learning dashboard">
|
|
428
|
+
<div class="mark" aria-hidden="true">
|
|
429
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
430
|
+
stroke-linecap="round" stroke-linejoin="round">
|
|
431
|
+
<path d="M5 3a13 13 0 0 1 0 18"/><path d="M5 3l14 9L5 21"/><path d="M3 12h18"/><path d="M15 8l4 4-4 4"/>
|
|
432
|
+
</svg>
|
|
433
|
+
</div><span>Eklavya</span>
|
|
434
|
+
</a>
|
|
435
|
+
<span class="topbar__wf" id="topbar-wf">Learning</span>
|
|
436
|
+
</header>
|
|
437
|
+
<div class="scrim" id="scrim"></div>
|
|
438
|
+
|
|
439
|
+
<aside class="side" id="side" aria-label="Dashboard navigation">
|
|
440
|
+
<button type="button" class="side__close" id="side-close" aria-label="Close navigation">
|
|
441
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
|
442
|
+
</button>
|
|
443
|
+
<a class="side__brand" id="brand" href="#/learning/dashboard" aria-label="Eklavya, Learning dashboard">
|
|
352
444
|
<div class="mark" aria-hidden="true">
|
|
353
445
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
|
|
354
446
|
stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -357,16 +449,22 @@
|
|
|
357
449
|
</div>
|
|
358
450
|
<div>
|
|
359
451
|
<b>Eklavya</b>
|
|
360
|
-
<span>
|
|
452
|
+
<span>dashboard</span>
|
|
361
453
|
</div>
|
|
362
454
|
</a>
|
|
363
455
|
|
|
456
|
+
<div class="wf" id="wf">
|
|
457
|
+
<a class="wf__main" id="wf-main" href="#/learning/dashboard"></a>
|
|
458
|
+
<button type="button" class="wf__caret" id="wf-caret" aria-label="Switch workflow"
|
|
459
|
+
aria-haspopup="menu" aria-expanded="false" aria-controls="wf-menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
364
462
|
<nav class="side__nav" id="nav" aria-label="Sections"></nav>
|
|
365
463
|
|
|
366
464
|
<div class="side__controls">
|
|
367
465
|
<div class="side__group">
|
|
368
466
|
<p class="side__label" id="proj-label">Project</p>
|
|
369
|
-
<select id="proj" aria-labelledby="proj-label"><option value="
|
|
467
|
+
<select id="proj" aria-labelledby="proj-label"><option value="">All projects</option></select>
|
|
370
468
|
</div>
|
|
371
469
|
|
|
372
470
|
<div class="side__group">
|
|
@@ -380,12 +478,13 @@
|
|
|
380
478
|
|
|
381
479
|
</aside>
|
|
382
480
|
|
|
383
|
-
<main>
|
|
481
|
+
<main id="main">
|
|
384
482
|
<button type="button" class="stale inner" id="stale" hidden aria-live="polite">
|
|
385
483
|
New activity since this page loaded — refresh
|
|
386
484
|
</button>
|
|
485
|
+
<p class="page__eyebrow crumb inner" id="crumb" aria-label="Workflow and scope"></p>
|
|
387
486
|
<div class="inner stack" id="view">
|
|
388
|
-
<p class="empty">Loading your
|
|
487
|
+
<p class="empty">Loading your history…</p>
|
|
389
488
|
</div>
|
|
390
489
|
<footer class="foot inner">
|
|
391
490
|
<span>Read from <code id="dbline">~/.eklavya/knowledge.db</code> — nothing on this page leaves your machine.<span id="foot-note"></span></span>
|
|
@@ -394,6 +493,8 @@
|
|
|
394
493
|
</main>
|
|
395
494
|
</div>
|
|
396
495
|
|
|
496
|
+
<div class="picker" id="wf-menu" role="menu" aria-label="Workflows" hidden></div>
|
|
497
|
+
|
|
397
498
|
<script>
|
|
398
499
|
/* ============================================================
|
|
399
500
|
One payload, many views.
|
|
@@ -446,15 +547,19 @@ const TIER = { 1:'recall', 2:'mechanism', 3:'judgement', 4:'failure modes', 5:'d
|
|
|
446
547
|
const tierLabel = (t) => `T${t}${TIER[t] ? ' ' + TIER[t] : ''}`;
|
|
447
548
|
|
|
448
549
|
let S = null; // the payload
|
|
449
|
-
let
|
|
550
|
+
let INV = null; // /api/projects: the one project inventory both workflows share
|
|
551
|
+
let PROJECT = null; // project scope: null for all, else an inventory id ('~' unattributed, '*' no repository)
|
|
450
552
|
let SCOPE = null; // Set of slugs touched in scope, or null for all
|
|
451
553
|
const AFTER = []; // render hooks that need a laid-out DOM (charts)
|
|
554
|
+
/** The screen being shown, as the URL says it: `{ wf, page, param, query }`. */
|
|
555
|
+
let CUR = { wf:'learning', page:'dashboard', param:'', query:new URLSearchParams() };
|
|
452
556
|
|
|
453
557
|
/* ---------- per-view control state, kept across re-renders ---------- */
|
|
454
558
|
const T = {
|
|
455
559
|
concepts: { page:1, q:'', state:'all', domain:'all', sort:'weakest' },
|
|
456
560
|
review: { page:1, tab:'due' },
|
|
457
561
|
sessions: { page:1 },
|
|
562
|
+
msessions:{ page:1 },
|
|
458
563
|
attempts: { page:1 },
|
|
459
564
|
domain: { page:1 },
|
|
460
565
|
concept: { page:1 },
|
|
@@ -466,20 +571,36 @@ const PER = 20;
|
|
|
466
571
|
|
|
467
572
|
/* ============================================================
|
|
468
573
|
Scope
|
|
574
|
+
|
|
575
|
+
Every row names its project in whatever spelling it was written with: a
|
|
576
|
+
folded checkout on an attempt, the unfolded worktree on a gate, nothing at
|
|
577
|
+
all on a pre-migration row. `/api/projects` resolves every spelling to one
|
|
578
|
+
id (`aliases`), plus the sessions whose gate named no project but whose own
|
|
579
|
+
answers or evidence did (`sessions`). `pid()` is the only place a row is
|
|
580
|
+
given a project, so the selector, the filters and every count agree.
|
|
469
581
|
============================================================ */
|
|
470
|
-
const
|
|
582
|
+
const pid = (raw, sid) => {
|
|
583
|
+
const v = raw == null ? '' : String(raw).trim();
|
|
584
|
+
if (!v) return (sid && INV.sessions[sid]) || '~';
|
|
585
|
+
return INV.aliases[v] ?? v;
|
|
586
|
+
};
|
|
587
|
+
const inScope = (raw, sid) => PROJECT === null || pid(raw, sid) === PROJECT;
|
|
588
|
+
const projectById = (id) => INV.projects.find((p) => p.id === id) ?? null;
|
|
589
|
+
const pname = (raw, sid) => { const id = pid(raw, sid); return projectById(id)?.name ?? base(id); };
|
|
471
590
|
|
|
472
591
|
function recomputeScope() {
|
|
473
|
-
if (
|
|
592
|
+
if (PROJECT === null) { SCOPE = null; return; }
|
|
474
593
|
SCOPE = new Set();
|
|
475
|
-
for (const a of S.attempts) if (inScope(a.repo)) SCOPE.add(a.slug);
|
|
476
|
-
for (const l of S.logged) if (inScope(l.repo)) SCOPE.add(l.slug);
|
|
594
|
+
for (const a of S.attempts) if (inScope(a.repo, a.session_id)) SCOPE.add(a.slug);
|
|
595
|
+
for (const l of S.logged) if (inScope(l.repo, l.session_id)) SCOPE.add(l.slug);
|
|
477
596
|
}
|
|
478
597
|
const conceptsInScope = () => (SCOPE ? S.concepts.filter((c) => SCOPE.has(c.slug)) : S.concepts);
|
|
479
598
|
const seenConcepts = () => conceptsInScope().filter((c) => c.seen);
|
|
480
|
-
const attemptsInScope = () => S.attempts.filter((a) => inScope(a.repo));
|
|
481
|
-
const loggedInScope = () => S.logged.filter((l) => inScope(l.repo));
|
|
482
|
-
const scopeName = () => (
|
|
599
|
+
const attemptsInScope = () => S.attempts.filter((a) => inScope(a.repo, a.session_id));
|
|
600
|
+
const loggedInScope = () => S.logged.filter((l) => inScope(l.repo, l.session_id));
|
|
601
|
+
const scopeName = () => (PROJECT === null ? 'all projects' : projectById(PROJECT)?.name ?? PROJECT);
|
|
602
|
+
/** Inventory rows in scope — one, or all of them. */
|
|
603
|
+
const projectsInScope = () => INV.projects.filter((p) => PROJECT === null || p.id === PROJECT);
|
|
483
604
|
|
|
484
605
|
/** One row per calendar day in scope: { day, passed, missed, skipped, total }. */
|
|
485
606
|
function dailyInScope() {
|
|
@@ -552,7 +673,7 @@ function sessions() {
|
|
|
552
673
|
// leaving it out would make the memory half invisible in the one place the
|
|
553
674
|
// two halves are supposed to line up.
|
|
554
675
|
for (const mem of S.memory_sessions ?? []) {
|
|
555
|
-
if (!inScope(mem.project)) continue;
|
|
676
|
+
if (!inScope(mem.project, mem.session_id)) continue;
|
|
556
677
|
const s = get(mem.session_id);
|
|
557
678
|
s.mem = mem; s.repo = s.repo ?? mem.project;
|
|
558
679
|
touch(s, mem.first); touch(s, mem.last);
|
|
@@ -834,8 +955,13 @@ const chart = (id, fn) => { AFTER.push(() => { const el = $(id); if (el) fn(el);
|
|
|
834
955
|
That makes exactly two views asynchronous, and both go through `fill()`.
|
|
835
956
|
============================================================ */
|
|
836
957
|
|
|
837
|
-
/**
|
|
838
|
-
|
|
958
|
+
/**
|
|
959
|
+
* Which render a response belongs to. `render()` bumps it, so a response that
|
|
960
|
+
* lands after the reader has moved on — to another page, another project or the
|
|
961
|
+
* other workflow — is dropped instead of painting over the screen they are on.
|
|
962
|
+
* Per render rather than per request: a view with two fills keeps both.
|
|
963
|
+
*/
|
|
964
|
+
let GEN = 0;
|
|
839
965
|
|
|
840
966
|
/**
|
|
841
967
|
* Renders a placeholder now and the real markup when the fetch lands. Queued
|
|
@@ -843,20 +969,39 @@ let SEQ = 0;
|
|
|
843
969
|
* not exist until `render()` has written the view's `innerHTML`.
|
|
844
970
|
*/
|
|
845
971
|
function fill(id, url, draw) {
|
|
846
|
-
const
|
|
972
|
+
const gen = GEN;
|
|
847
973
|
AFTER.push(() => {
|
|
848
|
-
fetch(url).then((r) =>
|
|
974
|
+
fetch(url).then((r) => {
|
|
975
|
+
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
976
|
+
return r.json();
|
|
977
|
+
}).then((d) => {
|
|
849
978
|
const el = $(id);
|
|
850
|
-
if (!el ||
|
|
979
|
+
if (!el || gen !== GEN) return;
|
|
980
|
+
// A draw can queue its own charts and fills (a session page drawn from a
|
|
981
|
+
// lookup does); the render that queued this one has already run its hooks.
|
|
982
|
+
AFTER.length = 0;
|
|
851
983
|
el.innerHTML = draw(d);
|
|
852
|
-
el
|
|
984
|
+
focusable(el);
|
|
985
|
+
for (const fn of AFTER.splice(0)) fn();
|
|
853
986
|
}).catch((e) => {
|
|
854
987
|
const el = $(id);
|
|
855
|
-
if (el &&
|
|
988
|
+
if (el && gen === GEN) el.innerHTML = `<p class="empty">Could not load: ${esc(e.message)}</p>`;
|
|
856
989
|
});
|
|
857
990
|
});
|
|
858
991
|
}
|
|
859
992
|
|
|
993
|
+
/**
|
|
994
|
+
* Anything with a `data-go` is a link, so it has to be reachable by Tab: rows,
|
|
995
|
+
* tiles and the `<a class="link">` shorthand, which has no `href` and so is
|
|
996
|
+
* not focusable on its own.
|
|
997
|
+
*/
|
|
998
|
+
function focusable(root) {
|
|
999
|
+
root.querySelectorAll('tr.row, [data-go]').forEach((el) => {
|
|
1000
|
+
if (el.tabIndex < 0) el.tabIndex = 0;
|
|
1001
|
+
if (el.tagName === 'A' && !el.hasAttribute('href')) el.setAttribute('role', 'link');
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
860
1005
|
/** Everything stored is untrusted text: a title is whatever a tool printed. */
|
|
861
1006
|
const tagPills = (tags) => (tags ?? []).map((t) => `<span class="pill">${esc(t)}</span>`).join(' ');
|
|
862
1007
|
const jsonList = (raw) => { try { const v = JSON.parse(raw || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } };
|
|
@@ -870,7 +1015,7 @@ function entryMark(e) {
|
|
|
870
1015
|
|
|
871
1016
|
/** Project scope as a query parameter; `~` (unattributed) predates memory entirely. */
|
|
872
1017
|
function memScope(params) {
|
|
873
|
-
if (
|
|
1018
|
+
if (PROJECT !== null) params.set('project', PROJECT);
|
|
874
1019
|
return params;
|
|
875
1020
|
}
|
|
876
1021
|
|
|
@@ -899,7 +1044,7 @@ function savingsText(s, receipts) {
|
|
|
899
1044
|
Views
|
|
900
1045
|
============================================================ */
|
|
901
1046
|
|
|
902
|
-
function
|
|
1047
|
+
function viewLearningDashboard() {
|
|
903
1048
|
const daily = dailyInScope();
|
|
904
1049
|
const st = streaks(daily);
|
|
905
1050
|
const seen = seenConcepts();
|
|
@@ -909,67 +1054,54 @@ function viewOverview() {
|
|
|
909
1054
|
const passed = atts.filter((a) => a.grade >= 3).length;
|
|
910
1055
|
const due = seen.filter((c) => c.due);
|
|
911
1056
|
const mastered = seen.filter((c) => c.mastered);
|
|
912
|
-
|
|
1057
|
+
// Learning's sessions; a memory-only one belongs to the other workflow.
|
|
1058
|
+
const sess = sessions().filter((x) => x.logged.length || x.asked.length);
|
|
913
1059
|
const last30 = fillDays(daily, 30);
|
|
914
1060
|
|
|
915
1061
|
const weakest = seen.filter((c) => !c.mastered).sort((a, b) => a.score - b.score || b.attempts - a.attempts).slice(0, 5);
|
|
916
1062
|
const recentSessions = sess.slice(0, 5);
|
|
917
1063
|
|
|
1064
|
+
// Logged is what the work touched; assessed is what a question was actually
|
|
1065
|
+
// asked about. They are different claims and the page keeps them apart.
|
|
1066
|
+
const logged = new Set(loggedInScope().filter((l) => (l.origin ?? 'work') === 'work').map((l) => l.slug));
|
|
1067
|
+
const asked = new Set(atts.map((a) => a.slug));
|
|
1068
|
+
const loggedAsked = [...logged].filter((x) => asked.has(x)).length;
|
|
1069
|
+
const inv = projectsInScope();
|
|
1070
|
+
const unassessed = inv.filter((p) => !p.learning.answers && p.learning.logged_concepts);
|
|
1071
|
+
|
|
918
1072
|
chart('c-activity', (el) => chartActivity(el, last30));
|
|
919
1073
|
chart('c-heat', (el) => chartHeatmap(el, daily));
|
|
920
1074
|
|
|
921
1075
|
return [
|
|
922
1076
|
`<div>
|
|
923
|
-
<p class="page__eyebrow">${esc(scopeName())}</p>
|
|
924
1077
|
<h1 class="page__title">Am I actually getting better?</h1>
|
|
925
1078
|
<p class="page__sub">${plural(seen.length, 'concept')} asked out of ${S.totals.catalogue} in the graph ·
|
|
926
1079
|
${plural(answered, 'answer')} · ${plural(sess.length, 'session')} · first answer ${esc(fmtDate(S.totals.first_answer))}.</p>
|
|
927
1080
|
</div>`,
|
|
928
1081
|
|
|
929
1082
|
tiles([
|
|
930
|
-
['Accuracy', answered ? pct(passed, answered) + '%' : '—', `${passed} right of ${answered}
|
|
931
|
-
['
|
|
932
|
-
['
|
|
933
|
-
['
|
|
934
|
-
['
|
|
1083
|
+
['Accuracy', answered ? pct(passed, answered) + '%' : '—', answered ? `${passed} right of ${answered}` : 'No assessments yet', answered && pct(passed, answered) >= 70 ? 'good' : '', LH('sessions')],
|
|
1084
|
+
['Recorded', logged.size, `concepts your work logged · ${loggedAsked} assessed`, '', LH('sessions')],
|
|
1085
|
+
['Mastered', mastered.length, `of ${seen.length} asked`, mastered.length ? 'good' : '', LH('concepts', 'mastered')],
|
|
1086
|
+
['Due now', due.length, due.length ? 'ready for review' : 'nothing waiting', due.length ? 'warn' : '', LH('review')],
|
|
1087
|
+
['Streak', st.current ? plural(st.current, 'day') : '—', st.best ? `best ${plural(st.best, 'day')} · ${plural(st.days, 'active day')}` : 'no answers yet'],
|
|
935
1088
|
]),
|
|
936
1089
|
|
|
1090
|
+
PROJECT !== null && !answered
|
|
1091
|
+
? `<div class="banner"><p><strong>No assessments yet</strong> in ${esc(scopeName())}.
|
|
1092
|
+
${logged.size ? `${plural(logged.size, 'concept')} ${logged.size === 1 ? 'was' : 'were'} recorded from its work and will come up in a quiz.` : 'Nothing has been logged here either.'}
|
|
1093
|
+
Captured memory is never counted as an answer.</p></div>`
|
|
1094
|
+
: PROJECT === null && unassessed.length
|
|
1095
|
+
? `<div class="banner"><p><strong>${plural(unassessed.length, 'project')}</strong> recorded concepts but has no assessments yet:
|
|
1096
|
+
${unassessed.slice(0, 4).map((p) => `<a class="link" data-go="${esc(LH('projects', null, { project: p.id }))}">${esc(p.name)}</a>`).join(', ')}.</p></div>`
|
|
1097
|
+
: '',
|
|
1098
|
+
|
|
937
1099
|
due.length
|
|
938
1100
|
? `<div class="banner"><p><strong>${plural(due.length, 'concept')}</strong> ${due.length === 1 ? 'is' : 'are'} due.
|
|
939
1101
|
Run <code>/eklavya:quiz</code> in Claude Code, or open the
|
|
940
|
-
<a class="link" data-go="
|
|
1102
|
+
<a class="link" data-go="${esc(LH('review'))}">review queue</a> to see what is waiting.</p></div>`
|
|
941
1103
|
: '',
|
|
942
1104
|
|
|
943
|
-
// The memory half, in two summaries. Both are all-project: the timeline is
|
|
944
|
-
// paged on the server, so narrowing these to a scope the page cannot
|
|
945
|
-
// recount would be a number nobody could check.
|
|
946
|
-
`<div class="g2">
|
|
947
|
-
${card('Context reuse', 'B is what the same material would have cost read from source; D is what was delivered instead.',
|
|
948
|
-
`<div class="kv">
|
|
949
|
-
<div><b>B — from source</b><span>${S.reuse.base}</span></div>
|
|
950
|
-
<div><b>D — delivered</b><span>${S.reuse.delivered}</span></div>
|
|
951
|
-
<div><b>Net</b><span>${S.reuse.base - S.reuse.delivered}</span></div>
|
|
952
|
-
<div><b>Verdict</b><span>${S.reuse.savings.kind === 'saving' ? S.reuse.savings.percent + '%'
|
|
953
|
-
: S.reuse.savings.kind === 'overhead' ? '+' + S.reuse.savings.tokens + ' tokens' : '—'}</span></div>
|
|
954
|
-
</div>
|
|
955
|
-
<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">${esc(savingsText(S.reuse.savings, S.reuse.receipts))}
|
|
956
|
-
Estimated with <code class="slug">${esc(S.reuse.estimator)}</code> on both sides of the subtraction —
|
|
957
|
-
a reduction in context volume, not a bill.
|
|
958
|
-
<a class="link" data-go="#/reuse">Every receipt →</a></p>`)}
|
|
959
|
-
${card('Memory', 'Captured is not assessed. Each of these is a different thing that happened to one piece of evidence.',
|
|
960
|
-
`<div class="kv">
|
|
961
|
-
<div><b>Observations</b><span>${S.memory.entries}</span></div>
|
|
962
|
-
<div><b>Evidence captured</b><span>${S.memory.captured}</span></div>
|
|
963
|
-
<div><b>Pending</b><span>${S.memory.pending}</span></div>
|
|
964
|
-
<div><b>Queue</b><span>${S.health.queue.pending}</span></div>
|
|
965
|
-
</div>
|
|
966
|
-
<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
|
|
967
|
-
${S.memory.processed} distilled · ${S.memory.indexed} indexed · ${S.memory.reused} reused ·
|
|
968
|
-
${S.memory.exposed} delivered · ${S.memory.assessed} assessed.
|
|
969
|
-
<a class="link" data-go="#/memory">The timeline →</a>
|
|
970
|
-
<a class="link" data-go="#/health">Health →</a></p>`)}
|
|
971
|
-
</div>`,
|
|
972
|
-
|
|
973
1105
|
card('Answers over the last 30 days', 'Right, missed and skipped, per day. Empty days are shown as empty.',
|
|
974
1106
|
`<div class="legend">
|
|
975
1107
|
<span><b style="background:var(--spot)"></b>Right</span>
|
|
@@ -996,7 +1128,7 @@ function viewOverview() {
|
|
|
996
1128
|
? `<div class="list">` + weakest.map((c) => `
|
|
997
1129
|
<div style="display:flex;gap:var(--space-4);align-items:center">
|
|
998
1130
|
<div style="flex:1;min-width:0">
|
|
999
|
-
<a class="link" data-go="
|
|
1131
|
+
<a class="link" data-go="${esc(LH('concept', c.slug))}">${esc(c.name)}</a>
|
|
1000
1132
|
<div class="slug">${esc(c.domain)} · ${esc(tierLabel(c.tier))} · ${plural(c.attempts, 'attempt')}</div>
|
|
1001
1133
|
</div>
|
|
1002
1134
|
<div style="width:96px">${meter(c.score * 100, c.score < 0.5 ? 'warn' : '')}
|
|
@@ -1007,14 +1139,14 @@ function viewOverview() {
|
|
|
1007
1139
|
|
|
1008
1140
|
card('Recent sessions', 'Each one is a piece of work your agent did, and what it taught.',
|
|
1009
1141
|
table([{t:'Session'},{t:'Concepts',n:1},{t:'Asked',n:1},{t:'Right',n:1},{t:'When',n:1}],
|
|
1010
|
-
recentSessions.map((s) => `<tr class="row" data-go="
|
|
1011
|
-
<td><span class="title">${esc(
|
|
1142
|
+
recentSessions.map((s) => `<tr class="row" data-go="${esc(sessHref(s.id))}">
|
|
1143
|
+
<td><span class="title">${esc(pname(s.repo, s.id))}</span>
|
|
1012
1144
|
<div class="slug">${esc(sessionLabel(s))}</div></td>
|
|
1013
1145
|
<td class="num">${s.logged.length}</td>
|
|
1014
1146
|
<td class="num">${s.asked.length}</td>
|
|
1015
1147
|
<td class="num">${s.passed}</td>
|
|
1016
1148
|
<td class="num slug">${esc(ago(s.last))}</td></tr>`)),
|
|
1017
|
-
{ aside: `<a class="link" data-go="
|
|
1149
|
+
{ aside: `<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">All ${sess.length} sessions →</a>` }),
|
|
1018
1150
|
].join('');
|
|
1019
1151
|
}
|
|
1020
1152
|
|
|
@@ -1024,6 +1156,7 @@ const CONCEPT_STATES = ['all', 'due', 'learning', 'mastered', 'unseen'];
|
|
|
1024
1156
|
function viewConcepts(param) {
|
|
1025
1157
|
const t = T.concepts;
|
|
1026
1158
|
t.state = CONCEPT_STATES.includes(param) ? param : 'all';
|
|
1159
|
+
t.domain = CUR.query.get('domain') || 'all';
|
|
1027
1160
|
const all = conceptsInScope();
|
|
1028
1161
|
const domains = [...new Set(all.map((c) => c.domain))].sort();
|
|
1029
1162
|
const q = t.q.trim().toLowerCase();
|
|
@@ -1050,7 +1183,7 @@ function viewConcepts(param) {
|
|
|
1050
1183
|
|
|
1051
1184
|
const p = page('concepts', list.length);
|
|
1052
1185
|
const rows = slice(list, p).map((c) => `
|
|
1053
|
-
<tr class="row" data-go="
|
|
1186
|
+
<tr class="row" data-go="${esc(LH('concept', c.slug))}">
|
|
1054
1187
|
<td style="min-width:210px"><span class="title">${esc(c.name)}</span>
|
|
1055
1188
|
<div class="slug">${esc(c.slug)}</div></td>
|
|
1056
1189
|
<td><span class="pill">${esc(c.domain)}</span></td>
|
|
@@ -1101,7 +1234,7 @@ function viewConcepts(param) {
|
|
|
1101
1234
|
|
|
1102
1235
|
function viewConcept(slug) {
|
|
1103
1236
|
const c = S.concepts.find((x) => x.slug === slug);
|
|
1104
|
-
if (!c) return `<p class="empty">No concept called <code>${esc(slug)}</code>. <a class="link" data-go="
|
|
1237
|
+
if (!c) return `<p class="empty">No concept called <code>${esc(slug)}</code>. <a class="link" data-go="${esc(LH('concepts'))}">Back to concepts</a>.</p>`;
|
|
1105
1238
|
|
|
1106
1239
|
const atts = S.attempts.filter((a) => a.slug === slug);
|
|
1107
1240
|
// A logged row with no context line is a bookkeeping row, not a memory: it
|
|
@@ -1109,7 +1242,7 @@ function viewConcept(slug) {
|
|
|
1109
1242
|
const ctx = S.logged.filter((l) => l.slug === slug && l.context && String(l.context).trim());
|
|
1110
1243
|
const byName = new Map(S.concepts.map((x) => [x.slug, x]));
|
|
1111
1244
|
const links = (slugs) => slugs.length
|
|
1112
|
-
? `<div class="tags">` + slugs.map((s) => `<a class="pill link" data-go="
|
|
1245
|
+
? `<div class="tags">` + slugs.map((s) => `<a class="pill link" data-go="${esc(LH('concept', s))}">${esc(byName.get(s)?.name ?? s)}</a>`).join('') + `</div>`
|
|
1113
1246
|
: '<p class="slug" style="margin:0">None recorded.</p>';
|
|
1114
1247
|
|
|
1115
1248
|
const p = page('concept', atts.length, 5);
|
|
@@ -1120,7 +1253,7 @@ function viewConcept(slug) {
|
|
|
1120
1253
|
<div class="qa__meta">
|
|
1121
1254
|
<span>${esc(fmtDate(a.ts))}</span><span>${esc(tierLabel(a.tier))}</span>
|
|
1122
1255
|
${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
|
|
1123
|
-
${a.repo ? `<span>${esc(
|
|
1256
|
+
${a.repo ? `<span>${esc(pname(a.repo, a.session_id))}</span>` : ''}
|
|
1124
1257
|
<span class="sp">${gradeBadge(a)}</span>
|
|
1125
1258
|
</div>
|
|
1126
1259
|
<p class="qa__q">${esc(a.question)}</p>
|
|
@@ -1136,7 +1269,7 @@ function viewConcept(slug) {
|
|
|
1136
1269
|
|
|
1137
1270
|
return [
|
|
1138
1271
|
`<div>
|
|
1139
|
-
<a class="page__back" data-go="
|
|
1272
|
+
<a class="page__back" data-go="${esc(LH('concepts'))}">← Concepts</a>
|
|
1140
1273
|
<h1 class="page__title">${esc(c.name)}</h1>
|
|
1141
1274
|
<p class="page__sub"><code class="slug">${esc(c.slug)}</code> · ${esc(c.domain)} · ${esc(tierLabel(c.tier))} ·
|
|
1142
1275
|
${esc(c.source === 'seed' ? 'from the seed graph' : 'minted from your work')}
|
|
@@ -1160,8 +1293,8 @@ function viewConcept(slug) {
|
|
|
1160
1293
|
? `<div class="list">` + ctx.slice(0, 12).map((l) => `
|
|
1161
1294
|
<div>
|
|
1162
1295
|
<div class="ctx">${esc(l.context ?? '')}</div>
|
|
1163
|
-
<div class="slug">${esc(ago(l.ts))} · ${esc(
|
|
1164
|
-
<a class="link" data-go="
|
|
1296
|
+
<div class="slug">${esc(ago(l.ts))} · ${esc(pname(l.repo, l.session_id))} · ${esc(l.origin ?? 'work')} ·
|
|
1297
|
+
<a class="link" data-go="${esc(sessHref(l.session_id))}">that session</a></div>
|
|
1165
1298
|
</div>`).join('') + `</div>`
|
|
1166
1299
|
: '<p class="empty">No context recorded — this one came from the seed graph.</p>'),
|
|
1167
1300
|
|
|
@@ -1208,12 +1341,12 @@ function viewReview(param) {
|
|
|
1208
1341
|
const list = t.tab === 'due' ? due : t.tab === 'upcoming' ? upcoming : skipped;
|
|
1209
1342
|
const p = page('review', list.length);
|
|
1210
1343
|
const rows = slice(list, p).map((x) => t.tab === 'skipped'
|
|
1211
|
-
? `<tr class="row" data-go="
|
|
1344
|
+
? `<tr class="row" data-go="${esc(LH('concept', x.slug))}">
|
|
1212
1345
|
<td><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.slug)}</div></td>
|
|
1213
1346
|
<td><span class="pill">${x.outcome === 'declined' ? 'declined' : 'blanked'}</span></td>
|
|
1214
1347
|
<td class="ctx" style="min-width:280px">${esc(x.question)}</td>
|
|
1215
1348
|
<td class="num slug">${esc(ago(x.ts))}</td></tr>`
|
|
1216
|
-
: `<tr class="row" data-go="
|
|
1349
|
+
: `<tr class="row" data-go="${esc(LH('concept', x.slug))}">
|
|
1217
1350
|
<td><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.slug)} · ${esc(x.domain)}</div></td>
|
|
1218
1351
|
<td style="min-width:104px">${meter(x.score * 100, x.score < 0.5 ? 'warn' : '')}
|
|
1219
1352
|
<div class="slug">${Math.round(x.score * 100)}%</div></td>
|
|
@@ -1251,18 +1384,21 @@ function viewReview(param) {
|
|
|
1251
1384
|
}
|
|
1252
1385
|
|
|
1253
1386
|
function viewSessions() {
|
|
1254
|
-
|
|
1387
|
+
// Learning's sessions: the ones that logged a concept or asked a question.
|
|
1388
|
+
// A session that only captured memory is the Memory workflow's, and is
|
|
1389
|
+
// counted here so it is never silently missing.
|
|
1390
|
+
const every = sessions();
|
|
1391
|
+
const all = every.filter((s) => s.logged.length || s.asked.length);
|
|
1392
|
+
const memOnly = every.length - all.length;
|
|
1255
1393
|
const p = page('sessions', all.length);
|
|
1256
1394
|
const rows = slice(all, p).map((s) => {
|
|
1257
1395
|
const acc = s.asked.length ? pct(s.passed, s.asked.length) : null;
|
|
1258
|
-
return `<tr class="row" data-go="
|
|
1259
|
-
<td style="min-width:260px"><span class="title">${esc(
|
|
1396
|
+
return `<tr class="row" data-go="${esc(sessHref(s.id))}">
|
|
1397
|
+
<td style="min-width:260px"><span class="title">${esc(pname(s.repo, s.id))}</span>
|
|
1260
1398
|
<div class="slug">${esc(sessionLabel(s).slice(0, 120))}</div></td>
|
|
1261
|
-
<td class="num">${s.
|
|
1262
|
-
<td class="num">${s.mem ? s.mem.entries : '—'}</td>
|
|
1263
|
-
<td class="num">${s.logged.length}</td>
|
|
1399
|
+
<td class="num">${s.logged.filter((l) => (l.origin ?? 'work') === 'work').length}</td>
|
|
1264
1400
|
<td class="num">${s.asked.length}</td>
|
|
1265
|
-
<td style="min-width:96px">${acc === null ? '<span class="slug"
|
|
1401
|
+
<td style="min-width:96px">${acc === null ? '<span class="slug">not assessed</span>'
|
|
1266
1402
|
: meter(acc, acc < 70 ? 'warn' : '') + `<div class="slug">${acc}%</div>`}</td>
|
|
1267
1403
|
<td class="num slug">${esc(stamp(s.last))}</td></tr>`;
|
|
1268
1404
|
});
|
|
@@ -1271,27 +1407,49 @@ function viewSessions() {
|
|
|
1271
1407
|
return [
|
|
1272
1408
|
`<div>
|
|
1273
1409
|
<h1 class="page__title">Sessions</h1>
|
|
1274
|
-
<p class="page__sub">One row per Claude Code session that
|
|
1275
|
-
whole thread: the
|
|
1276
|
-
|
|
1410
|
+
<p class="page__sub">One row per Claude Code session that logged a concept or asked a question. Open one
|
|
1411
|
+
for the whole thread: the concepts the work touched, every question you were asked, and — joined on the
|
|
1412
|
+
same session id — what the session captured and remembered.</p>
|
|
1277
1413
|
</div>`,
|
|
1278
1414
|
tiles([
|
|
1279
|
-
['Sessions', all.length, '
|
|
1415
|
+
['Sessions', all.length, 'that logged or asked'],
|
|
1280
1416
|
['With questions', withQuestions, `${pct(withQuestions, all.length)}% of sessions`],
|
|
1281
|
-
['
|
|
1282
|
-
['Concepts logged', all.reduce((a, s) => a + s.logged.length, 0), 'context lines recorded'],
|
|
1417
|
+
['Concepts logged', all.reduce((a, s) => a + s.logged.filter((l) => (l.origin ?? 'work') === 'work').length, 0), 'context lines recorded'],
|
|
1283
1418
|
['Answers', all.reduce((a, s) => a + s.asked.length, 0), 'across every session'],
|
|
1284
1419
|
]),
|
|
1285
1420
|
card(null, null,
|
|
1286
|
-
table([{t:'Session'},{t:'
|
|
1287
|
-
+ pager('sessions', all.length, p)
|
|
1421
|
+
table([{t:'Session'},{t:'Logged',n:1},{t:'Asked',n:1},{t:'Accuracy'},{t:'When',n:1}], rows)
|
|
1422
|
+
+ pager('sessions', all.length, p)
|
|
1423
|
+
+ (memOnly ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(memOnly, 'session')} captured memory and logged no concept —
|
|
1424
|
+
<a class="link" data-go="${esc(MH('sessions'))}">see them in Memory → Sessions</a>.</p>` : '')),
|
|
1288
1425
|
].join('');
|
|
1289
1426
|
}
|
|
1290
1427
|
|
|
1428
|
+
const noSession = (id) => `<p class="empty">No session <code>${esc(id)}</code> in this scope.
|
|
1429
|
+
<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">Back to sessions</a>.</p>`;
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* One session, from either workflow. Shared rendering: the learning half and
|
|
1433
|
+
* the memory half of a session are joined on the session id the host handed
|
|
1434
|
+
* both, which is the one association between them that is recorded rather
|
|
1435
|
+
* than inferred. The back link and the sidebar stay in the workflow the reader
|
|
1436
|
+
* came from.
|
|
1437
|
+
*/
|
|
1291
1438
|
function viewSession(id) {
|
|
1292
1439
|
const s = sessions().find((x) => x.id === id);
|
|
1293
|
-
if (
|
|
1294
|
-
|
|
1440
|
+
if (s) return sessionBody(s);
|
|
1441
|
+
// Not in the payload: a memory-only session past `memory_sessions`' cap, or
|
|
1442
|
+
// one outside the current scope. The paged resource answers for it exactly.
|
|
1443
|
+
fill('sess-lookup', '/api/memory/sessions?' + memScope(new URLSearchParams({ session: id })), (d) => {
|
|
1444
|
+
const row = d.rows[0];
|
|
1445
|
+
if (!row) return noSession(id);
|
|
1446
|
+
return sessionBody({ id, repo: row.project, logged: [], asked: [], mem: row, first: row.first, last: row.last, passed: 0 });
|
|
1447
|
+
});
|
|
1448
|
+
return `<div class="stack" id="sess-lookup"><p class="empty">Looking up this session…</p></div>`;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function sessionBody(s) {
|
|
1452
|
+
const id = s.id;
|
|
1295
1453
|
|
|
1296
1454
|
const byName = new Map(S.concepts.map((c) => [c.slug, c]));
|
|
1297
1455
|
const work = s.logged.filter((l) => (l.origin ?? 'work') === 'work');
|
|
@@ -1299,7 +1457,7 @@ function viewSession(id) {
|
|
|
1299
1457
|
|
|
1300
1458
|
const conceptRow = (l) => {
|
|
1301
1459
|
const c = byName.get(l.slug);
|
|
1302
|
-
return `<tr class="row" data-go="
|
|
1460
|
+
return `<tr class="row" data-go="${esc(LH('concept', l.slug))}">
|
|
1303
1461
|
<td style="min-width:190px"><span class="title">${esc(c?.name ?? l.slug)}</span>
|
|
1304
1462
|
<div class="slug">${esc(l.slug)} · ${esc(l.domain)}</div></td>
|
|
1305
1463
|
<td class="ctx" style="min-width:280px">${esc(l.context ?? '')}</td>
|
|
@@ -1311,7 +1469,7 @@ function viewSession(id) {
|
|
|
1311
1469
|
try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
|
|
1312
1470
|
return `<article class="qa">
|
|
1313
1471
|
<div class="qa__meta">
|
|
1314
|
-
<a class="link" data-go="
|
|
1472
|
+
<a class="link" data-go="${esc(LH('concept', a.slug))}">${esc(a.name)}</a>
|
|
1315
1473
|
<span>${esc(tierLabel(a.tier))}</span>${a.level ? `<span>${esc(a.level)}</span>` : ''}
|
|
1316
1474
|
<span class="sp">${gradeBadge(a)}</span>
|
|
1317
1475
|
</div>
|
|
@@ -1328,7 +1486,7 @@ function viewSession(id) {
|
|
|
1328
1486
|
if (s.mem) {
|
|
1329
1487
|
fill('sess-mem', '/api/memory?per=100&session=' + encodeURIComponent(id), (d) => {
|
|
1330
1488
|
const entries = d.rows.map((e) => `
|
|
1331
|
-
<tr class="row${e.deleted_at || e.superseded_by ? ' gone' : ''}" data-go="
|
|
1489
|
+
<tr class="row${e.deleted_at || e.superseded_by ? ' gone' : ''}" data-go="${esc(MH('entry', e.id))}">
|
|
1332
1490
|
<td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
|
|
1333
1491
|
<td style="min-width:260px"><span class="title">${esc(e.title)}</span>
|
|
1334
1492
|
<div class="ctx">${esc(e.snippet ?? '')}</div></td>
|
|
@@ -1336,7 +1494,7 @@ function viewSession(id) {
|
|
|
1336
1494
|
<td class="num">${e.event_count}</td>
|
|
1337
1495
|
<td>${entryMark(e)}</td></tr>`);
|
|
1338
1496
|
const cands = (d.candidates ?? []).map((c) => {
|
|
1339
|
-
const go = c.concept_slug ? ` class="row" data-go="
|
|
1497
|
+
const go = c.concept_slug ? ` class="row" data-go="${esc(LH('concept', c.concept_slug))}"` : '';
|
|
1340
1498
|
return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
|
|
1341
1499
|
<td><span class="pill">${esc(c.domain ?? '—')}</span></td>
|
|
1342
1500
|
<td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
|
|
@@ -1351,8 +1509,8 @@ function viewSession(id) {
|
|
|
1351
1509
|
|
|
1352
1510
|
return [
|
|
1353
1511
|
`<div>
|
|
1354
|
-
<a class="page__back" data-go="
|
|
1355
|
-
<h1 class="page__title">${esc(
|
|
1512
|
+
<a class="page__back" data-go="${esc(href(CUR.wf, 'sessions'))}">← Sessions</a>
|
|
1513
|
+
<h1 class="page__title">${esc(pname(s.repo, s.id))}</h1>
|
|
1356
1514
|
<p class="page__sub">${esc(stamp(s.first))} → ${esc(stamp(s.last))} · <code class="slug">${esc(s.id)}</code></p>
|
|
1357
1515
|
</div>`,
|
|
1358
1516
|
tiles([
|
|
@@ -1376,71 +1534,98 @@ function viewSession(id) {
|
|
|
1376
1534
|
].join('');
|
|
1377
1535
|
}
|
|
1378
1536
|
|
|
1537
|
+
/** Where a project was last seen, said plainly. A gone checkout is history, not an error. */
|
|
1538
|
+
const whereLine = (p) => p.kind === 'global' ? 'work done outside any repository'
|
|
1539
|
+
: p.kind === 'unattributed' ? 'recorded before projects were tracked, or by a session that named none'
|
|
1540
|
+
: p.path + (p.available === false ? ' — checkout no longer on disk' : '');
|
|
1541
|
+
|
|
1379
1542
|
function viewProjects() {
|
|
1380
|
-
const
|
|
1543
|
+
const inv = projectsInScope();
|
|
1544
|
+
const list = inv.filter((p) => p.learning.answers || p.learning.logged_concepts);
|
|
1545
|
+
const other = inv.filter((p) => !p.learning.answers && !p.learning.logged_concepts);
|
|
1381
1546
|
const daily = dailyInScope();
|
|
1382
1547
|
|
|
1383
1548
|
const cards = list.map((p, i) => {
|
|
1384
|
-
const
|
|
1385
|
-
const
|
|
1549
|
+
const L = p.learning;
|
|
1550
|
+
const detail = `<div class="kv" style="margin-bottom:var(--space-5)">
|
|
1551
|
+
<div><b>Answers</b><span>${L.answers}</span></div>
|
|
1552
|
+
<div><b>Accuracy</b><span>${L.answers ? pct(L.passed, L.answers) + '%' : '—'}</span></div>
|
|
1553
|
+
<div><b>Assessed</b><span>${plural(L.assessed_concepts, 'concept')}</span></div>
|
|
1554
|
+
<div><b>Recorded</b><span>${plural(L.logged_concepts, 'concept')}</span></div>
|
|
1555
|
+
<div><b>Sessions</b><span>${L.sessions}</span></div>
|
|
1556
|
+
<div><b>Last active</b><span>${esc(ago(L.last))}</span></div>
|
|
1557
|
+
</div>`;
|
|
1558
|
+
if (!L.answers) {
|
|
1559
|
+
return card(p.name, whereLine(p), detail
|
|
1560
|
+
+ `<p class="slug" style="margin:0"><strong>No assessments yet.</strong> The concepts this project's work recorded
|
|
1561
|
+
come up in a quiz like any others; nothing counts toward a level until one is answered.
|
|
1562
|
+
<a class="link" data-go="${esc(LH('sessions', null, { project: p.id }))}">Its sessions →</a></p>`);
|
|
1563
|
+
}
|
|
1564
|
+
// The level runway comes from the learning payload, keyed by the same fold.
|
|
1565
|
+
const st = S.projects.filter((x) => pid(x.repo) === p.id).sort((x, y) => y.answers - x.answers)[0];
|
|
1386
1566
|
const bar = (label, value, target, ok) => `
|
|
1387
1567
|
<div><span class="${ok ? 'mark-ok' : 'mark-no'}">${ok ? '✓' : '○'}</span>
|
|
1388
1568
|
<span style="min-width:150px">${esc(label)}</span>
|
|
1389
1569
|
<span class="bar">${meter(pct(value, target), ok ? '' : 'mute')}</span>
|
|
1390
1570
|
<span class="slug" style="white-space:nowrap">${value} / ${target}</span></div>`;
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
const idx = ['easy','medium','hard'].indexOf(l), cur = ['easy','medium','hard'].indexOf(p.level);
|
|
1571
|
+
const ladder = st ? ['easy', 'medium', 'hard'].map((l, idx) => {
|
|
1572
|
+
const cur = ['easy', 'medium', 'hard'].indexOf(st.level);
|
|
1394
1573
|
return `<div class="${idx === cur ? 'on' : idx < cur ? 'done' : ''}">${l}</div>`;
|
|
1395
|
-
}).join('');
|
|
1574
|
+
}).join('') : '';
|
|
1396
1575
|
|
|
1397
1576
|
chart('c-proj-' + i, (el) => {
|
|
1398
1577
|
const m = new Map();
|
|
1399
1578
|
for (const d of S.daily) {
|
|
1400
|
-
if ((d.repo
|
|
1401
|
-
m.
|
|
1579
|
+
if (pid(d.repo) !== p.id) continue;
|
|
1580
|
+
const cur = m.get(d.day) ?? { day:d.day, passed:0, missed:0, skipped:0, total:0 };
|
|
1581
|
+
cur.passed += d.passed; cur.missed += d.missed; cur.skipped += d.skipped;
|
|
1582
|
+
cur.total = cur.passed + cur.missed + cur.skipped;
|
|
1583
|
+
m.set(d.day, cur);
|
|
1402
1584
|
}
|
|
1403
1585
|
chartActivity(el, fillDays(m, 30));
|
|
1404
1586
|
});
|
|
1405
1587
|
|
|
1406
|
-
return card(
|
|
1407
|
-
`<div class="
|
|
1408
|
-
|
|
1409
|
-
<div><b>Accuracy</b><span>${acc}%</span></div>
|
|
1410
|
-
<div><b>Concepts</b><span>${p.concepts}</span></div>
|
|
1411
|
-
<div><b>Level</b><span>${esc(p.level)}${p.pinned ? ' (pinned)' : ''}</span></div>
|
|
1412
|
-
<div><b>Active since</b><span>${esc(fmtDate(p.first_active))}</span></div>
|
|
1413
|
-
<div><b>Last answer</b><span>${esc(ago(p.last_active))}</span></div>
|
|
1414
|
-
</div>
|
|
1415
|
-
<div class="ladder">${ladder}</div>
|
|
1416
|
-
${p.pinned
|
|
1588
|
+
return card(p.name, whereLine(p), detail
|
|
1589
|
+
+ (st ? `<div class="ladder">${ladder}</div>
|
|
1590
|
+
${st.pinned
|
|
1417
1591
|
? `<p class="slug" style="margin:var(--space-4) 0 0">Difficulty is pinned in config, so this project does not promote.</p>`
|
|
1418
|
-
:
|
|
1419
|
-
? `<p class="slug" style="margin:var(--space-4) 0 var(--space-2)">To reach <strong>${esc(
|
|
1592
|
+
: st.next_level
|
|
1593
|
+
? `<p class="slug" style="margin:var(--space-4) 0 var(--space-2)">To reach <strong>${esc(st.next_level)}</strong>, counting answers since ${esc(st.promoted_at ? fmtDate(st.promoted_at) : 'you started')}:</p>
|
|
1420
1594
|
<div class="checks">
|
|
1421
|
-
${bar('Passing answers',
|
|
1422
|
-
${bar('Accuracy', Math.round(
|
|
1423
|
-
${bar('Distinct concepts',
|
|
1595
|
+
${bar('Passing answers', st.level_counts.passed, st.level_needed.answers, !st.level_unmet.includes('answers'))}
|
|
1596
|
+
${bar('Accuracy', Math.round(st.level_accuracy * 100), Math.round(st.level_needed.accuracy * 100), !st.level_unmet.includes('accuracy'))}
|
|
1597
|
+
${bar('Distinct concepts', st.level_counts.concepts, st.level_needed.concepts, !st.level_unmet.includes('concepts'))}
|
|
1424
1598
|
</div>`
|
|
1425
|
-
: `<p class="slug" style="margin:var(--space-4) 0 0">Top of the ladder — questions can reach tier 5 here.</p>`}
|
|
1426
|
-
|
|
1599
|
+
: `<p class="slug" style="margin:var(--space-4) 0 0">Top of the ladder — questions can reach tier 5 here.</p>`}` : '')
|
|
1600
|
+
+ `<div class="chart" style="margin-top:var(--space-5)"><svg id="c-proj-${i}" role="img"
|
|
1427
1601
|
aria-label="Thirty days of answers in this project"></svg></div>`);
|
|
1428
1602
|
});
|
|
1429
1603
|
|
|
1604
|
+
const assessed = list.filter((p) => p.learning.answers);
|
|
1430
1605
|
return [
|
|
1431
1606
|
`<div>
|
|
1432
1607
|
<h1 class="page__title">Projects</h1>
|
|
1433
1608
|
<p class="page__sub">Difficulty is earned per codebase, not per person: the same developer is a different
|
|
1434
1609
|
learner on their fourth Node service and their first Rust one. Everyone starts at easy (tiers 1–2)
|
|
1435
|
-
and climbs on evidence
|
|
1610
|
+
and climbs on evidence. A project appears here as soon as its work logs a concept — before a single
|
|
1611
|
+
question is asked.</p>
|
|
1436
1612
|
</div>`,
|
|
1437
1613
|
tiles([
|
|
1438
|
-
['Projects', list.length, 'with
|
|
1439
|
-
['
|
|
1440
|
-
['
|
|
1614
|
+
['Projects', list.length, 'with logged or assessed work'],
|
|
1615
|
+
['Assessed', assessed.length, `${list.length - assessed.length} with no assessments yet`],
|
|
1616
|
+
['Answers', list.reduce((a, p) => a + p.learning.answers, 0), 'across all of them'],
|
|
1617
|
+
['Best accuracy', assessed.length ? Math.max(...assessed.map((p) => pct(p.learning.passed, p.learning.answers))) + '%' : '—', 'of any project'],
|
|
1441
1618
|
['Active days', streaks(daily).days, 'with at least one answer'],
|
|
1442
1619
|
]),
|
|
1443
|
-
cards.join('') || '<p class="empty">No
|
|
1620
|
+
cards.join('') || '<p class="empty">No project has logged or answered anything yet.</p>',
|
|
1621
|
+
other.length
|
|
1622
|
+
? card('Memory only', 'Recorded by capture, with no learning activity yet. Captured is not assessed.',
|
|
1623
|
+
table([{t:'Project'},{t:'Evidence',n:1},{t:'Observations',n:1},{t:'Last active',n:1}], other.map((p) => `
|
|
1624
|
+
<tr class="row" data-go="${esc(MH('projects', null, { project: p.id }))}">
|
|
1625
|
+
<td><span class="title">${esc(p.name)}</span><div class="slug">${esc(whereLine(p))}</div></td>
|
|
1626
|
+
<td class="num">${p.memory.events}</td><td class="num">${p.memory.entries}</td>
|
|
1627
|
+
<td class="num slug">${esc(ago(p.last_active))}</td></tr>`)))
|
|
1628
|
+
: '',
|
|
1444
1629
|
].join('');
|
|
1445
1630
|
}
|
|
1446
1631
|
|
|
@@ -1456,7 +1641,7 @@ function viewDomains() {
|
|
|
1456
1641
|
const list = [...byDomain.values()].sort((a, b) => b.touched - a.touched || b.catalogue - a.catalogue);
|
|
1457
1642
|
|
|
1458
1643
|
const rows = list.map((d) => `
|
|
1459
|
-
<tr class="row" data-go="
|
|
1644
|
+
<tr class="row" data-go="${esc(LH('domain', d.domain))}">
|
|
1460
1645
|
<td><span class="title">${esc(d.domain)}</span>
|
|
1461
1646
|
<div class="slug">${plural(d.catalogue, 'concept')} in the graph</div></td>
|
|
1462
1647
|
<td style="min-width:130px">${d.touched
|
|
@@ -1488,13 +1673,13 @@ function viewDomains() {
|
|
|
1488
1673
|
function viewDomain(name) {
|
|
1489
1674
|
const all = conceptsInScope().filter((c) => c.domain === name);
|
|
1490
1675
|
if (!all.length) return `<p class="empty">No concepts in <code>${esc(name)}</code> for this scope.
|
|
1491
|
-
<a class="link" data-go="
|
|
1676
|
+
<a class="link" data-go="${esc(LH('domains'))}">Back to domains</a>.</p>`;
|
|
1492
1677
|
|
|
1493
1678
|
const seen = all.filter((c) => c.seen);
|
|
1494
1679
|
const list = [...all].sort((a, b) => (a.seen === b.seen ? a.score - b.score : a.seen ? -1 : 1));
|
|
1495
1680
|
const p = page('domain', list.length);
|
|
1496
1681
|
const rows = slice(list, p).map((c) => `
|
|
1497
|
-
<tr class="row" data-go="
|
|
1682
|
+
<tr class="row" data-go="${esc(LH('concept', c.slug))}">
|
|
1498
1683
|
<td style="min-width:220px"><span class="title">${esc(c.name)}</span><div class="slug">${esc(c.slug)}</div></td>
|
|
1499
1684
|
<td class="slug" style="white-space:nowrap">${esc(tierLabel(c.tier))}</td>
|
|
1500
1685
|
<td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '')
|
|
@@ -1506,7 +1691,7 @@ function viewDomain(name) {
|
|
|
1506
1691
|
|
|
1507
1692
|
return [
|
|
1508
1693
|
`<div>
|
|
1509
|
-
<a class="page__back" data-go="
|
|
1694
|
+
<a class="page__back" data-go="${esc(LH('domains'))}">← Domains</a>
|
|
1510
1695
|
<h1 class="page__title">${esc(name)}</h1>
|
|
1511
1696
|
<p class="page__sub">${plural(all.length, 'concept')} in the graph · ${seen.length} asked ·
|
|
1512
1697
|
${seen.filter((c) => c.mastered).length} mastered.</p>
|
|
@@ -1544,13 +1729,13 @@ function drawMemory(d) {
|
|
|
1544
1729
|
const rows = d.rows.map((e) => {
|
|
1545
1730
|
const gone = e.deleted_at || e.superseded_by;
|
|
1546
1731
|
const more = e.narrative_length > String(e.snippet ?? '').length;
|
|
1547
|
-
return `<tr class="row${gone ? ' gone' : ''}" data-go="
|
|
1732
|
+
return `<tr class="row${gone ? ' gone' : ''}" data-go="${esc(MH('entry', e.id))}">
|
|
1548
1733
|
<td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
|
|
1549
1734
|
<td style="min-width:280px"><span class="title">${esc(e.title)}</span>
|
|
1550
1735
|
<div class="ctx">${esc(e.snippet ?? '')}${more ? '…' : ''}</div></td>
|
|
1551
1736
|
<td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
|
|
1552
1737
|
<td style="min-width:120px"><div class="tags">${tagPills(e.tags)}</div></td>
|
|
1553
|
-
<td class="slug">${esc(
|
|
1738
|
+
<td class="slug">${esc(pname(e.project))}</td>
|
|
1554
1739
|
<td class="num">${e.event_count}</td>
|
|
1555
1740
|
<td>${entryMark(e)}</td>
|
|
1556
1741
|
</tr>`;
|
|
@@ -1559,31 +1744,57 @@ function drawMemory(d) {
|
|
|
1559
1744
|
+ pager('memory', d.total, d.page, d.per);
|
|
1560
1745
|
}
|
|
1561
1746
|
|
|
1747
|
+
/**
|
|
1748
|
+
* The memory counts, scoped when they can be. The inventory counts evidence,
|
|
1749
|
+
* pending evidence, observations and sessions per project, so those follow the
|
|
1750
|
+
* selector; indexing, reuse, delivery and assessment are only aggregated for
|
|
1751
|
+
* the whole database, and say so rather than pretend to be this project's.
|
|
1752
|
+
*/
|
|
1753
|
+
function memTiles() {
|
|
1754
|
+
const M = S.memory;
|
|
1755
|
+
const scoped = PROJECT !== null;
|
|
1756
|
+
const inv = projectsInScope();
|
|
1757
|
+
const sum = (f) => inv.reduce((a, p) => a + f(p), 0);
|
|
1758
|
+
const events = scoped ? sum((p) => p.memory.events) : M.captured;
|
|
1759
|
+
const pending = scoped ? sum((p) => p.memory.pending) : M.pending;
|
|
1760
|
+
const entries = scoped ? sum((p) => p.memory.entries) : M.entries;
|
|
1761
|
+
const everywhere = scoped ? 'all projects · ' : '';
|
|
1762
|
+
return tiles([
|
|
1763
|
+
['Observations', entries, scoped ? `live here · ${M.indexed} indexed across all projects`
|
|
1764
|
+
: `${M.entries_total} written · ${M.superseded} superseded · ${M.deleted} deleted · ${M.indexed} indexed`, '', MH('timeline')],
|
|
1765
|
+
['Evidence', events, `${events - pending} distilled · ${pending} still pending`, pending ? 'warn' : '', MH('health')],
|
|
1766
|
+
['Sessions', scoped ? sum((p) => p.memory.sessions) : INV.projects.reduce((a, p) => a + p.memory.sessions, 0), 'that captured or remembered', '', MH('sessions')],
|
|
1767
|
+
['Reused', M.reused, `${everywhere}${M.exposed} actually delivered`, '', MH('reuse')],
|
|
1768
|
+
['Assessed', M.assessed, everywhere + 'evidence concepts you were asked about', '', LH('concepts')],
|
|
1769
|
+
]);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1562
1772
|
function viewMemory(param) {
|
|
1563
1773
|
const t = T.memory;
|
|
1564
1774
|
const M = S.memory;
|
|
1565
1775
|
const types = M.types ?? [];
|
|
1566
|
-
|
|
1776
|
+
// The URL holds the filters, so a filtered timeline is a link that reopens
|
|
1777
|
+
// filtered. A type with no live entries still filters — to nothing, visibly.
|
|
1778
|
+
const type = param || 'all';
|
|
1779
|
+
t.tag = CUR.query.get('tag') || 'all';
|
|
1780
|
+
t.since = CUR.query.get('from') || '';
|
|
1781
|
+
t.until = CUR.query.get('to') || '';
|
|
1567
1782
|
fill('mem-rows', '/api/memory?' + memoryQuery(type), drawMemory);
|
|
1568
1783
|
|
|
1569
1784
|
const chip = (k, label, n) =>
|
|
1570
1785
|
`<button class="chip" data-mtype="${esc(k)}" aria-pressed="${type === k}">${esc(label)}${n === undefined ? '' : ' ' + n}</button>`;
|
|
1786
|
+
const typeChips = types.map((x) => chip(x.type, x.type, x.n)).join('')
|
|
1787
|
+
+ (type !== 'all' && !types.some((x) => x.type === type) ? chip(type, type, 0) : '');
|
|
1571
1788
|
|
|
1572
1789
|
return [
|
|
1573
1790
|
`<div>
|
|
1574
|
-
<h1 class="page__title">
|
|
1791
|
+
<h1 class="page__title">Timeline</h1>
|
|
1575
1792
|
<p class="page__sub">What your sessions actually did, distilled into observations and kept with the raw
|
|
1576
1793
|
evidence behind each one. Corrections supersede rather than overwrite and deletions are marked, so what
|
|
1577
1794
|
you see is the audit trail and not a tidied version of it.</p>
|
|
1578
1795
|
</div>`,
|
|
1579
1796
|
|
|
1580
|
-
|
|
1581
|
-
['Observations', M.entries, `${M.entries_total} written, ${M.superseded} superseded, ${M.deleted} deleted`],
|
|
1582
|
-
['Evidence', M.captured, `${M.processed} distilled · ${M.pending} still pending`, M.pending ? 'warn' : '', '#/health'],
|
|
1583
|
-
['Indexed', M.indexed, 'reachable by semantic search'],
|
|
1584
|
-
['Reused', M.reused, `${M.exposed} actually delivered`, '', '#/reuse'],
|
|
1585
|
-
['Assessed', M.assessed, 'evidence concepts you were asked about', '', '#/concepts'],
|
|
1586
|
-
]),
|
|
1797
|
+
memTiles(),
|
|
1587
1798
|
|
|
1588
1799
|
card(null, null,
|
|
1589
1800
|
`<div class="filters">
|
|
@@ -1596,18 +1807,147 @@ function viewMemory(param) {
|
|
|
1596
1807
|
<input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">
|
|
1597
1808
|
</div>
|
|
1598
1809
|
<div class="chips" id="mtypes">
|
|
1599
|
-
${chip('all', 'All')}${
|
|
1810
|
+
${chip('all', 'All')}${typeChips}
|
|
1600
1811
|
</div>
|
|
1601
1812
|
<div style="margin-top:var(--space-4)" id="mem-rows"><p class="empty">Loading the timeline…</p></div>`),
|
|
1602
1813
|
].join('');
|
|
1603
1814
|
}
|
|
1604
1815
|
|
|
1816
|
+
function viewMemoryDashboard() {
|
|
1817
|
+
const R = S.reuse, H = S.health, Q = H.queue;
|
|
1818
|
+
const scope = () => memScope(new URLSearchParams());
|
|
1819
|
+
const q6 = scope(); q6.set('per', '6');
|
|
1820
|
+
fill('mem-recent', '/api/memory?' + q6, (d) => d.rows.length
|
|
1821
|
+
? table([{t:'When'},{t:'Observation'},{t:'Type'},{t:'Project'}], d.rows.map((e) => `
|
|
1822
|
+
<tr class="row${e.deleted_at || e.superseded_by ? ' gone' : ''}" data-go="${esc(MH('entry', e.id))}">
|
|
1823
|
+
<td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
|
|
1824
|
+
<td style="min-width:240px"><span class="title">${esc(e.title)}</span></td>
|
|
1825
|
+
<td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
|
|
1826
|
+
<td class="slug">${esc(pname(e.project))}</td></tr>`))
|
|
1827
|
+
: '<p class="empty">Nothing remembered in this scope yet.</p>');
|
|
1828
|
+
const q5 = scope(); q5.set('per', '5');
|
|
1829
|
+
fill('mem-sessions', '/api/memory/sessions?' + q5, (d) => d.rows.length
|
|
1830
|
+
? table([{t:'Session'},{t:'Captured',n:1},{t:'Remembered',n:1},{t:'When',n:1}], d.rows.map((m) => `
|
|
1831
|
+
<tr class="row" data-go="${esc(MH('session', m.session_id))}">
|
|
1832
|
+
<td><span class="title">${esc(pname(m.project, m.session_id))}</span><div class="slug">${esc(m.session_id)}</div></td>
|
|
1833
|
+
<td class="num">${m.events}</td><td class="num">${m.entries}</td>
|
|
1834
|
+
<td class="num slug">${esc(ago(m.last))}</td></tr>`))
|
|
1835
|
+
: '<p class="empty">No session has captured anything in this scope.</p>');
|
|
1836
|
+
|
|
1837
|
+
return [
|
|
1838
|
+
`<div>
|
|
1839
|
+
<h1 class="page__title">What does memory hold?</h1>
|
|
1840
|
+
<p class="page__sub">Captured, processed, stored, retrieved, delivered, assessed — six different things that
|
|
1841
|
+
can happen to one piece of evidence, counted separately. None of them is an answer: only a question you
|
|
1842
|
+
answered moves mastery.</p>
|
|
1843
|
+
</div>`,
|
|
1844
|
+
memTiles(),
|
|
1845
|
+
`<div class="g2">
|
|
1846
|
+
${card('Context reuse', 'All projects. B is what the same material would have cost read from source; D is what was delivered instead.',
|
|
1847
|
+
`<div class="kv">
|
|
1848
|
+
<div><b>B — from source</b><span>${R.base}</span></div>
|
|
1849
|
+
<div><b>D — delivered</b><span>${R.delivered}</span></div>
|
|
1850
|
+
<div><b>Net</b><span>${R.base - R.delivered}</span></div>
|
|
1851
|
+
<div><b>Verdict</b><span>${R.savings.kind === 'saving' ? R.savings.percent + '%'
|
|
1852
|
+
: R.savings.kind === 'overhead' ? '+' + R.savings.tokens + ' tokens' : '—'}</span></div>
|
|
1853
|
+
</div>
|
|
1854
|
+
<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">${esc(savingsText(R.savings, R.receipts))}
|
|
1855
|
+
Estimated with <code class="slug">${esc(R.estimator)}</code> on both sides of the subtraction —
|
|
1856
|
+
a reduction in context volume, not a bill.
|
|
1857
|
+
<a class="link" data-go="${esc(MH('reuse'))}">Every receipt →</a></p>`)}
|
|
1858
|
+
${card('Health', 'All projects. Where the pipeline stops, when it stops.',
|
|
1859
|
+
`<div class="kv">
|
|
1860
|
+
<div><b>Last capture</b><span>${esc(H.capture.newest_event ? ago(H.capture.newest_event) : 'never')}</span></div>
|
|
1861
|
+
<div><b>Capture</b><span>${H.capture.enabled ? esc(H.capture.mode) : 'off'}</span></div>
|
|
1862
|
+
<div><b>Queued</b><span>${Q.pending}</span></div>
|
|
1863
|
+
<div><b>Failed</b><span>${Q.failed}</span></div>
|
|
1864
|
+
</div>
|
|
1865
|
+
<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
|
|
1866
|
+
${S.memory.processed} distilled · ${S.memory.indexed} indexed · ${S.memory.reused} reused ·
|
|
1867
|
+
${S.memory.exposed} delivered · ${S.memory.assessed} assessed.
|
|
1868
|
+
<a class="link" data-go="${esc(MH('health'))}">Health →</a></p>`)}
|
|
1869
|
+
</div>`,
|
|
1870
|
+
card('Recent observations', 'The newest things memory kept, in this scope.',
|
|
1871
|
+
`<div id="mem-recent"><p class="empty">Loading…</p></div>`,
|
|
1872
|
+
{ aside: `<a class="link" data-go="${esc(MH('timeline'))}">The timeline →</a>` }),
|
|
1873
|
+
card('Recent sessions', 'Sessions that captured evidence or wrote an observation.',
|
|
1874
|
+
`<div id="mem-sessions"><p class="empty">Loading…</p></div>`,
|
|
1875
|
+
{ aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` }),
|
|
1876
|
+
].join('');
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function viewMemorySessions() {
|
|
1880
|
+
const t = T.msessions;
|
|
1881
|
+
const q = memScope(new URLSearchParams());
|
|
1882
|
+
q.set('page', t.page); q.set('per', PER);
|
|
1883
|
+
// The learning half of the same session, joined on its id, where there is one.
|
|
1884
|
+
const learned = new Map(sessions().map((x) => [x.id, x]));
|
|
1885
|
+
fill('msess-rows', '/api/memory/sessions?' + q, (d) => {
|
|
1886
|
+
t.page = d.page;
|
|
1887
|
+
return table([{t:'Session'},{t:'Captured',n:1},{t:'Pending',n:1},{t:'Remembered',n:1},{t:'Proposed',n:1},{t:'Asked',n:1},{t:'When',n:1}],
|
|
1888
|
+
d.rows.map((m) => {
|
|
1889
|
+
const l = learned.get(m.session_id);
|
|
1890
|
+
return `<tr class="row" data-go="${esc(MH('session', m.session_id))}">
|
|
1891
|
+
<td style="min-width:240px"><span class="title">${esc(pname(m.project, m.session_id))}</span>
|
|
1892
|
+
<div class="slug">${esc(m.session_id)}</div></td>
|
|
1893
|
+
<td class="num">${m.events}</td>
|
|
1894
|
+
<td class="num">${m.pending || '—'}</td>
|
|
1895
|
+
<td class="num">${m.entries}</td>
|
|
1896
|
+
<td class="num">${m.candidates || '—'}</td>
|
|
1897
|
+
<td class="num">${l && l.asked.length ? l.asked.length : '—'}</td>
|
|
1898
|
+
<td class="num slug">${esc(stamp(m.last))}</td></tr>`;
|
|
1899
|
+
})) + pager('msessions', d.total, d.page, d.per)
|
|
1900
|
+
+ `<p class="slug" style="margin:var(--space-4) 0 0">${plural(d.total, 'session')} in scope.</p>`;
|
|
1901
|
+
});
|
|
1902
|
+
return [
|
|
1903
|
+
`<div>
|
|
1904
|
+
<h1 class="page__title">Sessions</h1>
|
|
1905
|
+
<p class="page__sub">One row per session that captured evidence or wrote an observation. Pending is evidence
|
|
1906
|
+
no observation job has processed yet. Asked is the same session's questions, joined on its session id —
|
|
1907
|
+
a session can be remembered without ever being assessed.</p>
|
|
1908
|
+
</div>`,
|
|
1909
|
+
card(null, null, `<div id="msess-rows"><p class="empty">Loading sessions…</p></div>`),
|
|
1910
|
+
].join('');
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function viewMemoryProjects() {
|
|
1914
|
+
const inv = projectsInScope();
|
|
1915
|
+
const list = inv.filter((p) => p.memory.events || p.memory.entries || p.memory.receipts);
|
|
1916
|
+
const none = inv.length - list.length;
|
|
1917
|
+
const rows = list.map((p) => `
|
|
1918
|
+
<tr class="row" data-go="${esc(MH('timeline', null, { project: p.id }))}">
|
|
1919
|
+
<td style="min-width:220px"><span class="title">${esc(p.name)}</span><div class="slug">${esc(whereLine(p))}</div></td>
|
|
1920
|
+
<td class="num">${p.memory.events}</td>
|
|
1921
|
+
<td class="num">${p.memory.pending || '—'}</td>
|
|
1922
|
+
<td class="num">${p.memory.entries}</td>
|
|
1923
|
+
<td class="num">${p.memory.sessions}</td>
|
|
1924
|
+
<td class="num">${p.memory.receipts || '—'}</td>
|
|
1925
|
+
<td class="num slug">${esc(ago(p.memory.last))}</td></tr>`);
|
|
1926
|
+
return [
|
|
1927
|
+
`<div>
|
|
1928
|
+
<h1 class="page__title">Projects</h1>
|
|
1929
|
+
<p class="page__sub">Every project memory has recorded anything for — including evidence that has been
|
|
1930
|
+
captured and not yet processed into an observation. Open one to read its timeline.</p>
|
|
1931
|
+
</div>`,
|
|
1932
|
+
tiles([
|
|
1933
|
+
['Projects', list.length, 'with captured or stored memory'],
|
|
1934
|
+
['Evidence', list.reduce((a, p) => a + p.memory.events, 0), 'events captured'],
|
|
1935
|
+
['Pending', list.reduce((a, p) => a + p.memory.pending, 0), 'waiting to be processed'],
|
|
1936
|
+
['Observations', list.reduce((a, p) => a + p.memory.entries, 0), 'live entries'],
|
|
1937
|
+
]),
|
|
1938
|
+
card(null, null,
|
|
1939
|
+
table([{t:'Project'},{t:'Captured',n:1},{t:'Pending',n:1},{t:'Observations',n:1},{t:'Sessions',n:1},{t:'Receipts',n:1},{t:'Last capture',n:1}], rows)
|
|
1940
|
+
+ (none ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(none, 'project')} in scope ${none === 1 ? 'has' : 'have'} learning activity and no memory —
|
|
1941
|
+
<a class="link" data-go="${esc(LH('projects'))}">see Learning → Projects</a>.</p>` : '')),
|
|
1942
|
+
].join('');
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1605
1945
|
function drawEntry(d) {
|
|
1606
1946
|
const e = d.entry;
|
|
1607
1947
|
const gone = e.deleted_at || e.superseded_by;
|
|
1608
1948
|
const facts = jsonList(e.facts);
|
|
1609
1949
|
const files = jsonList(e.files);
|
|
1610
|
-
const link = (x) => `<a class="link" data-go="
|
|
1950
|
+
const link = (x) => `<a class="link" data-go="${esc(MH('entry', x.id))}">${esc(x.title)}</a> <span class="slug">${esc(fmtDate(x.occurred_at))}</span>`;
|
|
1611
1951
|
|
|
1612
1952
|
const evidence = d.events.map((ev) => `
|
|
1613
1953
|
<details class="qa">
|
|
@@ -1625,10 +1965,10 @@ function drawEntry(d) {
|
|
|
1625
1965
|
`<div>
|
|
1626
1966
|
<p class="page__eyebrow">${esc(e.type ?? e.kind)}</p>
|
|
1627
1967
|
<h1 class="page__title${gone ? ' gone-title' : ''}">${esc(e.title)}</h1>
|
|
1628
|
-
<p class="page__sub">${esc(stamp(e.occurred_at))} · ${esc(
|
|
1968
|
+
<p class="page__sub">${esc(stamp(e.occurred_at))} · ${esc(pname(e.project))} ·
|
|
1629
1969
|
written by <code class="slug">${esc(e.generator)}</code>
|
|
1630
1970
|
${e.import_source ? ` · imported from ${esc(e.import_source)}` : ''}
|
|
1631
|
-
${e.session_id ? ` · <a class="link" data-go="
|
|
1971
|
+
${e.session_id ? ` · <a class="link" data-go="${esc(sessHref(e.session_id))}">that session</a>` : ''}</p>
|
|
1632
1972
|
</div>`,
|
|
1633
1973
|
|
|
1634
1974
|
e.deleted_at
|
|
@@ -1642,7 +1982,7 @@ function drawEntry(d) {
|
|
|
1642
1982
|
['Confidence', e.confidence == null ? '—' : Math.round(e.confidence * 100) + '%', 'the summariser’s own, not a score'],
|
|
1643
1983
|
['Evidence', d.events.length, 'raw events behind this claim'],
|
|
1644
1984
|
['Candidates', d.candidates.length, 'concepts it proposed'],
|
|
1645
|
-
['Reused', d.receipts.length, d.receipts.length ? 'times it was selected for context' : 'never selected yet', '', '
|
|
1985
|
+
['Reused', d.receipts.length, d.receipts.length ? 'times it was selected for context' : 'never selected yet', '', MH('reuse')],
|
|
1646
1986
|
]),
|
|
1647
1987
|
|
|
1648
1988
|
card('What happened', 'The observation, as written.',
|
|
@@ -1654,7 +1994,7 @@ function drawEntry(d) {
|
|
|
1654
1994
|
d.candidates.length
|
|
1655
1995
|
? card('Concepts it proposed', 'A candidate is a proposal, not a concept — and never a passing answer. Only a question you answered moves mastery.',
|
|
1656
1996
|
table([{t:'Slug'},{t:'Domain'},{t:'Confidence',n:1},{t:'Status'}], d.candidates.map((c) => {
|
|
1657
|
-
const go = c.concept_slug ? ` class="row" data-go="
|
|
1997
|
+
const go = c.concept_slug ? ` class="row" data-go="${esc(LH('concept', c.concept_slug))}"` : '';
|
|
1658
1998
|
return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
|
|
1659
1999
|
<td><span class="pill">${esc(c.domain ?? '—')}</span></td>
|
|
1660
2000
|
<td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
|
|
@@ -1684,7 +2024,7 @@ function drawEntry(d) {
|
|
|
1684
2024
|
function viewEntry(id) {
|
|
1685
2025
|
fill('entry-body', '/api/memory/entry?id=' + encodeURIComponent(id), (d) =>
|
|
1686
2026
|
d && d.entry ? drawEntry(d) : `<p class="empty">No observation <code>${esc(id)}</code>.</p>`);
|
|
1687
|
-
return `<div><a class="page__back" data-go="
|
|
2027
|
+
return `<div><a class="page__back" data-go="${esc(MH('timeline'))}">← Timeline</a></div>
|
|
1688
2028
|
<div class="stack" id="entry-body"><p class="empty">Loading the observation…</p></div>`;
|
|
1689
2029
|
}
|
|
1690
2030
|
|
|
@@ -1692,7 +2032,7 @@ function viewEntry(id) {
|
|
|
1692
2032
|
|
|
1693
2033
|
function viewReuse() {
|
|
1694
2034
|
const R = S.reuse;
|
|
1695
|
-
const rows = R.rows.filter((r) =>
|
|
2035
|
+
const rows = R.rows.filter((r) => inScope(r.project, r.session_id));
|
|
1696
2036
|
const p = page('reuse', rows.length);
|
|
1697
2037
|
const net = R.base - R.delivered;
|
|
1698
2038
|
const verdict = R.savings.kind === 'saving' ? R.savings.percent + '%'
|
|
@@ -1707,7 +2047,7 @@ function viewReuse() {
|
|
|
1707
2047
|
: '—';
|
|
1708
2048
|
return `<tr>
|
|
1709
2049
|
<td class="slug" style="white-space:nowrap">${esc(stamp(r.created_at))}</td>
|
|
1710
|
-
<td><span class="title">${esc(r.scope)}</span><div class="slug">${esc(
|
|
2050
|
+
<td><span class="title">${esc(r.scope)}</span><div class="slug">${esc(pname(r.project))}</div></td>
|
|
1711
2051
|
<td class="num">${r.base_tokens}</td>
|
|
1712
2052
|
<td class="num">${r.delivered_tokens}</td>
|
|
1713
2053
|
<td class="num">${saved}</td>
|
|
@@ -1820,72 +2160,468 @@ function viewHealth() {
|
|
|
1820
2160
|
}
|
|
1821
2161
|
|
|
1822
2162
|
/* ============================================================
|
|
1823
|
-
|
|
2163
|
+
Workflows and routes
|
|
2164
|
+
|
|
2165
|
+
Two workflows, one shell. The registry below is the only description of
|
|
2166
|
+
either: the sidebar, the picker, the route resolver and the page renderer all
|
|
2167
|
+
read it, so the selector, the highlighted item and the screen cannot disagree
|
|
2168
|
+
about where the reader is.
|
|
2169
|
+
|
|
2170
|
+
The URL is the authority. `#/<workflow>/<page>/<param>?project=…` names the
|
|
2171
|
+
workflow, the page, the highlighted item and the project scope, so a refresh,
|
|
2172
|
+
a pasted link or Back/Forward rebuilds the same screen from nothing.
|
|
1824
2173
|
============================================================ */
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2174
|
+
const ICONS = {
|
|
2175
|
+
learning: '<path d="M4 19V6a2 2 0 0 1 2-2h13v13H6a2 2 0 0 0-2 2z"/><path d="M6 21h13v-4"/><path d="M9 8h6"/>',
|
|
2176
|
+
memory: '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6"/><path d="M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
|
|
2177
|
+
dashboard: '<path d="M3 12l9-9 9 9"/><path d="M5 10v10h14V10"/>',
|
|
2178
|
+
concepts: '<circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/>',
|
|
2179
|
+
review: '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/>',
|
|
2180
|
+
sessions: '<rect x="3" y="4" width="18" height="16"/><path d="M3 9h18M8 4v16"/>',
|
|
2181
|
+
projects: '<path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 12l9 4 9-4M3 17l9 4 9-4"/>',
|
|
2182
|
+
domains: '<circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 9v6M9 6h6a3 3 0 0 1 3 3v6"/>',
|
|
2183
|
+
timeline: '<path d="M4 5h16v14H4z"/><path d="M8 9h8M8 13h5"/>',
|
|
2184
|
+
reuse: '<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>',
|
|
2185
|
+
health: '<path d="M3 12h4l2-6 3 12 2-6h7"/>',
|
|
2186
|
+
};
|
|
2187
|
+
const svg = (paths, cls = '') =>
|
|
2188
|
+
`<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
|
2189
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
2190
|
+
|
|
2191
|
+
/**
|
|
2192
|
+
* `pages[key]` is `{ label, view }` for a page with a sidebar entry and
|
|
2193
|
+
* `{ parent, view }` for a detail page: a detail keeps its parent highlighted.
|
|
2194
|
+
* `query` lists the query keys that page reads, so a workflow switch or a link
|
|
2195
|
+
* to another page carries the project and drops filters that mean nothing there.
|
|
2196
|
+
*/
|
|
2197
|
+
const WORKFLOWS = {
|
|
2198
|
+
learning: {
|
|
2199
|
+
name: 'Learning',
|
|
2200
|
+
groups: [
|
|
2201
|
+
{ id: 'learn', label: 'Learn', items: ['concepts', 'review'] },
|
|
2202
|
+
{ id: 'history', label: 'History', items: ['sessions', 'projects', 'domains'] },
|
|
2203
|
+
],
|
|
2204
|
+
pages: {
|
|
2205
|
+
dashboard: { label: 'Dashboard', view: () => viewLearningDashboard() },
|
|
2206
|
+
concepts: { label: 'Concepts', view: (p) => viewConcepts(p), query: ['domain'] },
|
|
2207
|
+
concept: { parent: 'concepts', view: (p) => viewConcept(p) },
|
|
2208
|
+
review: { label: 'Review', view: (p) => viewReview(p) },
|
|
2209
|
+
sessions: { label: 'Sessions', view: () => viewSessions() },
|
|
2210
|
+
session: { parent: 'sessions', view: (p) => viewSession(p) },
|
|
2211
|
+
projects: { label: 'Projects', view: () => viewProjects() },
|
|
2212
|
+
domains: { label: 'Domains', view: () => viewDomains() },
|
|
2213
|
+
domain: { parent: 'domains', view: (p) => viewDomain(p) },
|
|
2214
|
+
},
|
|
2215
|
+
},
|
|
2216
|
+
memory: {
|
|
2217
|
+
name: 'Memory',
|
|
2218
|
+
groups: [
|
|
2219
|
+
{ id: 'memory', label: 'Memory', items: ['timeline', 'sessions', 'projects'] },
|
|
2220
|
+
{ id: 'insights', label: 'Insights', items: ['reuse', 'health'] },
|
|
2221
|
+
],
|
|
2222
|
+
pages: {
|
|
2223
|
+
dashboard: { label: 'Dashboard', view: () => viewMemoryDashboard() },
|
|
2224
|
+
timeline: { label: 'Timeline', view: (p) => viewMemory(p), query: ['tag', 'from', 'to'] },
|
|
2225
|
+
entry: { parent: 'timeline', view: (p) => viewEntry(p) },
|
|
2226
|
+
sessions: { label: 'Sessions', view: () => viewMemorySessions() },
|
|
2227
|
+
session: { parent: 'sessions', view: (p) => viewSession(p) },
|
|
2228
|
+
projects: { label: 'Projects', view: () => viewMemoryProjects() },
|
|
2229
|
+
reuse: { label: 'Reuse', view: () => viewReuse() },
|
|
2230
|
+
health: { label: 'Health', view: () => viewHealth() },
|
|
2231
|
+
},
|
|
2232
|
+
},
|
|
1851
2233
|
};
|
|
1852
2234
|
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
2235
|
+
/**
|
|
2236
|
+
* A canonical link. The project scope rides along unless `q` overrides it —
|
|
2237
|
+
* it is shared by both workflows — and nothing else does unless asked for.
|
|
2238
|
+
*/
|
|
2239
|
+
function href(wf, page, param, q = {}) {
|
|
2240
|
+
const qs = new URLSearchParams();
|
|
2241
|
+
const project = 'project' in q ? q.project : PROJECT;
|
|
2242
|
+
if (project !== null && project !== undefined && project !== '') qs.set('project', project);
|
|
2243
|
+
for (const [k, v] of Object.entries(q)) if (k !== 'project' && v !== null && v !== undefined && v !== '') qs.set(k, v);
|
|
2244
|
+
const tail = param !== undefined && param !== null && param !== '' ? '/' + encodeURIComponent(param) : '';
|
|
2245
|
+
const s = qs.toString();
|
|
2246
|
+
return `#/${wf}/${page}${tail}${s ? '?' + s : ''}`;
|
|
1857
2247
|
}
|
|
2248
|
+
const LH = (page, param, q) => href('learning', page, param, q);
|
|
2249
|
+
const MH = (page, param, q) => href('memory', page, param, q);
|
|
2250
|
+
/** A session opened from Memory stays in Memory, and from Learning in Learning. */
|
|
2251
|
+
const sessHref = (id) => href(CUR.wf, 'session', id);
|
|
1858
2252
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
2253
|
+
/**
|
|
2254
|
+
* Bookmarks and older documentation link to the single-workflow routes. Each
|
|
2255
|
+
* one maps to its canonical home; the rest of the path and the query are kept.
|
|
2256
|
+
* `memory` is special-cased in `resolve()`: `#/memory/<type>` was the typed
|
|
2257
|
+
* timeline, and canonical Memory pages have to win over it.
|
|
2258
|
+
*/
|
|
2259
|
+
const LEGACY = {
|
|
2260
|
+
overview: ['learning', 'dashboard'],
|
|
2261
|
+
concepts: ['learning', 'concepts'],
|
|
2262
|
+
concept: ['learning', 'concept'],
|
|
2263
|
+
review: ['learning', 'review'],
|
|
2264
|
+
sessions: ['learning', 'sessions'],
|
|
2265
|
+
session: ['learning', 'session'],
|
|
2266
|
+
projects: ['learning', 'projects'],
|
|
2267
|
+
domains: ['learning', 'domains'],
|
|
2268
|
+
domain: ['learning', 'domain'],
|
|
2269
|
+
entry: ['memory', 'entry'],
|
|
2270
|
+
reuse: ['memory', 'reuse'],
|
|
2271
|
+
health: ['memory', 'health'],
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
/** Decodes a path parameter, or reports that it cannot be decoded. */
|
|
2275
|
+
function decodeParam(raw) {
|
|
2276
|
+
try { return { param: decodeURIComponent(raw), bad: false }; }
|
|
2277
|
+
catch (e) { return { param: raw, bad: true }; }
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
/**
|
|
2281
|
+
* Reads a hash into a screen, and says whether it has to be rewritten first.
|
|
2282
|
+
*
|
|
2283
|
+
* Returns `{ wf, page, param, query, redirect, missing, bad }`: `redirect` is
|
|
2284
|
+
* the canonical hash when the one given is a legacy or partial form, `missing`
|
|
2285
|
+
* that no page answers to it, `bad` that its parameter is malformed encoding.
|
|
2286
|
+
* Pure: no DOM, no state beyond its arguments.
|
|
2287
|
+
*/
|
|
2288
|
+
function resolve(hash, savedProject) {
|
|
2289
|
+
let raw = String(hash || '').replace(/^#/, '');
|
|
2290
|
+
let qs = '';
|
|
2291
|
+
const qi = raw.indexOf('?');
|
|
2292
|
+
if (qi >= 0) { qs = raw.slice(qi + 1); raw = raw.slice(0, qi); }
|
|
2293
|
+
const parts = raw.replace(/^\/+/, '').split('/');
|
|
2294
|
+
while (parts.length > 1 && parts[parts.length - 1] === '') parts.pop();
|
|
2295
|
+
const query = new URLSearchParams(qs);
|
|
2296
|
+
const [head = '', ...rest] = parts;
|
|
2297
|
+
|
|
2298
|
+
const canonical = (wf, page, restParts) => {
|
|
2299
|
+
// The old dashboard remembered the project across visits; a legacy link
|
|
2300
|
+
// that arrives with no project in it keeps that behaviour once, and the
|
|
2301
|
+
// canonical URL carries the scope from then on.
|
|
2302
|
+
if (!query.has('project') && savedProject) query.set('project', savedProject);
|
|
2303
|
+
const s = query.toString();
|
|
2304
|
+
const tail = restParts.length ? '/' + restParts.join('/') : '';
|
|
2305
|
+
return { redirect: `#/${wf}/${page}${tail}${s ? '?' + s : ''}` };
|
|
1871
2306
|
};
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
2307
|
+
|
|
2308
|
+
if (head === '' ) return canonical('learning', 'dashboard', []);
|
|
2309
|
+
// Own keys only: `#/constructor` would otherwise find Object's and crash the render.
|
|
2310
|
+
const own = (o, k) => Object.hasOwn(o, k);
|
|
2311
|
+
if (own(WORKFLOWS, head) && head === 'memory' && (rest.length === 0 || rest[0] === '')) {
|
|
2312
|
+
return canonical('memory', 'timeline', []);
|
|
2313
|
+
}
|
|
2314
|
+
if (own(WORKFLOWS, head)) {
|
|
2315
|
+
const W = WORKFLOWS[head];
|
|
2316
|
+
const [page = '', ...more] = rest;
|
|
2317
|
+
if (page === '') return { redirect: href(head, 'dashboard', null, { project: query.get('project') }) };
|
|
2318
|
+
if (own(W.pages, page)) {
|
|
2319
|
+
const { param, bad } = decodeParam(more.join('/'));
|
|
2320
|
+
return { wf: head, page, param, query, bad };
|
|
2321
|
+
}
|
|
2322
|
+
// `#/memory/<type>`: the typed timeline, from before there were two workflows.
|
|
2323
|
+
if (head === 'memory') return canonical('memory', 'timeline', rest);
|
|
2324
|
+
return { wf: head, page, param: '', query, missing: true };
|
|
2325
|
+
}
|
|
2326
|
+
if (own(LEGACY, head)) return canonical(LEGACY[head][0], LEGACY[head][1], rest);
|
|
2327
|
+
return { wf: 'learning', page: head, param: '', query, missing: true };
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
/* ---------- the sidebar ---------- */
|
|
2331
|
+
|
|
2332
|
+
/** Collapsed group ids, per workflow. Expanded is the default and is not stored. */
|
|
2333
|
+
const collapseKey = (wf) => `eklavya-dash-nav-collapsed:${wf}`;
|
|
2334
|
+
function readCollapsed(wf) {
|
|
2335
|
+
try {
|
|
2336
|
+
const v = JSON.parse(localStorage.getItem(collapseKey(wf)) || '[]');
|
|
2337
|
+
const known = WORKFLOWS[wf].groups.map((g) => g.id);
|
|
2338
|
+
return new Set(Array.isArray(v) ? v.filter((id) => known.includes(id)) : []);
|
|
2339
|
+
} catch (e) { return new Set(); }
|
|
2340
|
+
}
|
|
2341
|
+
function writeCollapsed(wf, set) {
|
|
2342
|
+
try { localStorage.setItem(collapseKey(wf), JSON.stringify([...set])); } catch (e) {}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function navCounts(wf) {
|
|
2346
|
+
const inv = projectsInScope();
|
|
2347
|
+
const sum = (f) => inv.reduce((a, p) => a + f(p), 0);
|
|
2348
|
+
if (wf === 'learning') {
|
|
2349
|
+
const seen = seenConcepts();
|
|
2350
|
+
return {
|
|
2351
|
+
concepts: conceptsInScope().length,
|
|
2352
|
+
review: seen.filter((c) => c.due).length,
|
|
2353
|
+
sessions: sessions().filter((s) => s.logged.length || s.asked.length).length,
|
|
2354
|
+
projects: inv.filter((p) => p.learning.answers || p.learning.logged_concepts).length,
|
|
2355
|
+
domains: new Set(conceptsInScope().map((c) => c.domain)).size,
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
return {
|
|
2359
|
+
timeline: sum((p) => p.memory.entries),
|
|
2360
|
+
sessions: sum((p) => p.memory.sessions),
|
|
2361
|
+
projects: inv.filter((p) => p.memory.events || p.memory.entries).length,
|
|
2362
|
+
reuse: sum((p) => p.memory.receipts),
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function renderNav(wf, active) {
|
|
2367
|
+
const W = WORKFLOWS[wf];
|
|
2368
|
+
const counts = navCounts(wf);
|
|
2369
|
+
const collapsed = readCollapsed(wf);
|
|
2370
|
+
// A direct link into a collapsed group opens it: the reader has to be able to
|
|
2371
|
+
// see where they are. A collapse they make afterwards still takes effect.
|
|
2372
|
+
const home = W.groups.find((g) => g.items.includes(active));
|
|
2373
|
+
if (home && collapsed.has(home.id)) { collapsed.delete(home.id); writeCollapsed(wf, collapsed); }
|
|
2374
|
+
|
|
2375
|
+
const item = (key) => {
|
|
2376
|
+
const pg = W.pages[key];
|
|
2377
|
+
const n = counts[key];
|
|
2378
|
+
return `<a href="${esc(href(wf, key))}"${key === active ? ' aria-current="page"' : ''} data-nav="${key}">
|
|
2379
|
+
${svg(ICONS[key] ?? ICONS.dashboard)}${esc(pg.label)}${n !== undefined ? `<i class="${key === 'review' && n ? 'hot' : ''}">${n}</i>` : ''}
|
|
2380
|
+
</a>`;
|
|
2381
|
+
};
|
|
2382
|
+
$('nav').setAttribute('aria-label', `${W.name} sections`);
|
|
2383
|
+
$('nav').innerHTML = `<div>${item('dashboard')}</div>` + W.groups.map((g) => {
|
|
2384
|
+
const open = !collapsed.has(g.id);
|
|
2385
|
+
const id = `grp-${wf}-${g.id}`;
|
|
2386
|
+
return `<div class="nav__group">
|
|
2387
|
+
<button type="button" class="nav__toggle" id="tog-${wf}-${g.id}" data-group="${g.id}" aria-expanded="${open}" aria-controls="${id}">
|
|
2388
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>${esc(g.label)}
|
|
2389
|
+
</button>
|
|
2390
|
+
<div class="nav__items" id="${id}" role="group" aria-label="${esc(g.label)}"${open ? '' : ' hidden'}>${g.items.map(item).join('')}</div>
|
|
2391
|
+
</div>`;
|
|
2392
|
+
}).join('');
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
$('nav').addEventListener('click', (e) => {
|
|
2396
|
+
const tog = e.target.closest('[data-group]');
|
|
2397
|
+
if (tog) {
|
|
2398
|
+
const set = readCollapsed(CUR.wf);
|
|
2399
|
+
const open = tog.getAttribute('aria-expanded') === 'true';
|
|
2400
|
+
if (open) set.add(tog.dataset.group); else set.delete(tog.dataset.group);
|
|
2401
|
+
writeCollapsed(CUR.wf, set);
|
|
2402
|
+
tog.setAttribute('aria-expanded', String(!open));
|
|
2403
|
+
$(tog.getAttribute('aria-controls')).hidden = open;
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
// Following a link closes the drawer on a phone; the hashchange renders.
|
|
2407
|
+
if (e.target.closest('a[href]')) closeDrawer();
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
function renderShell() {
|
|
2411
|
+
const W = WORKFLOWS[CUR.wf];
|
|
2412
|
+
const home = href(CUR.wf, 'dashboard');
|
|
2413
|
+
for (const id of ['brand', 'brand-top']) {
|
|
2414
|
+
$(id).setAttribute('href', home);
|
|
2415
|
+
$(id).setAttribute('aria-label', `Eklavya, ${W.name} dashboard`);
|
|
2416
|
+
}
|
|
2417
|
+
$('wf-main').setAttribute('href', home);
|
|
2418
|
+
$('wf-main').innerHTML = `${svg(ICONS[CUR.wf])}<span>${esc(W.name)}</span>`;
|
|
2419
|
+
$('wf-main').setAttribute('aria-label', `${W.name} dashboard`);
|
|
2420
|
+
$('topbar-wf').textContent = W.name;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/* ---------- the workflow picker ---------- */
|
|
2424
|
+
const menu = $('wf-menu');
|
|
2425
|
+
const caret = $('wf-caret');
|
|
2426
|
+
|
|
2427
|
+
function pickerItems() { return [...menu.querySelectorAll('[role="menuitemradio"]')]; }
|
|
2428
|
+
|
|
2429
|
+
function placePicker() {
|
|
2430
|
+
const anchor = $('wf').getBoundingClientRect();
|
|
2431
|
+
const pad = 8;
|
|
2432
|
+
const vw = document.documentElement.clientWidth;
|
|
2433
|
+
const vh = window.innerHeight;
|
|
2434
|
+
menu.style.maxHeight = `${vh - pad * 2}px`;
|
|
2435
|
+
menu.style.minWidth = `${Math.min(Math.max(220, anchor.width), vw - pad * 2)}px`;
|
|
2436
|
+
const w = menu.offsetWidth, h = menu.offsetHeight;
|
|
2437
|
+
const left = Math.max(pad, Math.min(anchor.left, vw - w - pad));
|
|
2438
|
+
let top = anchor.bottom + 4;
|
|
2439
|
+
// Below if it fits, above if that fits, otherwise as high as the window allows.
|
|
2440
|
+
if (top + h > vh - pad) top = anchor.top - h - 4 >= pad ? anchor.top - h - 4 : Math.max(pad, vh - h - pad);
|
|
2441
|
+
menu.style.left = `${left}px`;
|
|
2442
|
+
menu.style.top = `${top}px`;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function openPicker() {
|
|
2446
|
+
menu.innerHTML = `<p class="picker__label" aria-hidden="true">Workflow</p>` + Object.entries(WORKFLOWS).map(([key, W]) =>
|
|
2447
|
+
`<button type="button" role="menuitemradio" aria-checked="${key === CUR.wf}" data-wf="${key}" tabindex="-1">
|
|
2448
|
+
${svg(ICONS[key])}<span>${esc(W.name)}</span>
|
|
2449
|
+
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10"/></svg>
|
|
2450
|
+
</button>`).join('');
|
|
2451
|
+
menu.hidden = false;
|
|
2452
|
+
caret.setAttribute('aria-expanded', 'true');
|
|
2453
|
+
placePicker();
|
|
2454
|
+
(pickerItems().find((b) => b.dataset.wf === CUR.wf) ?? pickerItems()[0]).focus();
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
function closePicker(restore = true) {
|
|
2458
|
+
if (menu.hidden) return;
|
|
2459
|
+
const hadFocus = menu.contains(document.activeElement);
|
|
2460
|
+
menu.hidden = true;
|
|
2461
|
+
caret.setAttribute('aria-expanded', 'false');
|
|
2462
|
+
if (restore || hadFocus) caret.focus();
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
caret.addEventListener('click', () => (menu.hidden ? openPicker() : closePicker()));
|
|
2466
|
+
caret.addEventListener('keydown', (e) => {
|
|
2467
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); openPicker(); }
|
|
2468
|
+
});
|
|
2469
|
+
menu.addEventListener('click', (e) => {
|
|
2470
|
+
const b = e.target.closest('[data-wf]');
|
|
2471
|
+
if (!b) return;
|
|
2472
|
+
switchWorkflow(b.dataset.wf);
|
|
2473
|
+
});
|
|
2474
|
+
menu.addEventListener('keydown', (e) => {
|
|
2475
|
+
const items = pickerItems();
|
|
2476
|
+
const i = items.indexOf(document.activeElement);
|
|
2477
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus(); }
|
|
2478
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
|
|
2479
|
+
else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
|
|
2480
|
+
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
|
|
2481
|
+
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePicker(); }
|
|
2482
|
+
// A menu is one Tab stop: Tab leaves it, back to the control that opened it.
|
|
2483
|
+
else if (e.key === 'Tab') { e.preventDefault(); closePicker(); }
|
|
2484
|
+
});
|
|
2485
|
+
document.addEventListener('pointerdown', (e) => {
|
|
2486
|
+
if (!menu.hidden && !menu.contains(e.target) && !caret.contains(e.target)) closePicker(false);
|
|
2487
|
+
});
|
|
2488
|
+
addEventListener('resize', () => { if (!menu.hidden) placePicker(); });
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* Opens the other workflow's Dashboard. The project and the ground carry over;
|
|
2492
|
+
* screen filters do not, because a tag filter on the timeline means nothing on
|
|
2493
|
+
* the Learning Dashboard. A real navigation, so Back returns to where they were.
|
|
2494
|
+
*/
|
|
2495
|
+
function switchWorkflow(wf) {
|
|
2496
|
+
closePicker(false);
|
|
2497
|
+
const target = href(wf, 'dashboard');
|
|
2498
|
+
closeDrawer(false);
|
|
2499
|
+
if (location.hash === target) render(); else location.hash = target;
|
|
2500
|
+
(narrow.matches ? $('menu') : caret).focus();
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
/* ---------- the drawer (900px and below) ---------- */
|
|
2504
|
+
const side = $('side');
|
|
2505
|
+
const narrow = matchMedia('(max-width: 900px)');
|
|
2506
|
+
let drawerOpen = false;
|
|
2507
|
+
|
|
2508
|
+
const focusables = (root) => [...root.querySelectorAll(
|
|
2509
|
+
'a[href], button:not([disabled]), select, input, [tabindex]:not([tabindex="-1"])',
|
|
2510
|
+
)].filter((el) => !el.closest('[hidden]') && el.getClientRects().length);
|
|
2511
|
+
|
|
2512
|
+
/**
|
|
2513
|
+
* The drawer is a modal only while it is open on a narrow screen. On a wide
|
|
2514
|
+
* one the rail is the page's navigation and must stay available to assistive
|
|
2515
|
+
* technology whatever the drawer last did, so every modal attribute comes off.
|
|
2516
|
+
*/
|
|
2517
|
+
function syncDrawer() {
|
|
2518
|
+
if (!narrow.matches) {
|
|
2519
|
+
drawerOpen = false;
|
|
2520
|
+
side.classList.remove('is-open');
|
|
2521
|
+
side.inert = false;
|
|
2522
|
+
side.removeAttribute('role');
|
|
2523
|
+
side.removeAttribute('aria-modal');
|
|
2524
|
+
$('main').inert = false;
|
|
2525
|
+
$('topbar').inert = false;
|
|
2526
|
+
$('scrim').classList.remove('is-open');
|
|
2527
|
+
document.body.classList.remove('drawer-open');
|
|
2528
|
+
$('menu').setAttribute('aria-expanded', 'false');
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
side.classList.toggle('is-open', drawerOpen);
|
|
2532
|
+
side.inert = !drawerOpen;
|
|
2533
|
+
if (drawerOpen) { side.setAttribute('role', 'dialog'); side.setAttribute('aria-modal', 'true'); }
|
|
2534
|
+
else { side.removeAttribute('role'); side.removeAttribute('aria-modal'); }
|
|
2535
|
+
$('main').inert = drawerOpen;
|
|
2536
|
+
$('topbar').inert = drawerOpen;
|
|
2537
|
+
$('scrim').classList.toggle('is-open', drawerOpen);
|
|
2538
|
+
document.body.classList.toggle('drawer-open', drawerOpen);
|
|
2539
|
+
$('menu').setAttribute('aria-expanded', String(drawerOpen));
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
function openDrawer() {
|
|
2543
|
+
if (!narrow.matches) return;
|
|
2544
|
+
drawerOpen = true;
|
|
2545
|
+
syncDrawer();
|
|
2546
|
+
$('side-close').focus();
|
|
2547
|
+
}
|
|
2548
|
+
function closeDrawer(restore = true) {
|
|
2549
|
+
if (!drawerOpen) return;
|
|
2550
|
+
closePicker(false);
|
|
2551
|
+
drawerOpen = false;
|
|
2552
|
+
syncDrawer();
|
|
2553
|
+
if (restore) $('menu').focus();
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
$('menu').addEventListener('click', openDrawer);
|
|
2557
|
+
$('side-close').addEventListener('click', () => closeDrawer());
|
|
2558
|
+
$('scrim').addEventListener('click', () => closeDrawer());
|
|
2559
|
+
narrow.addEventListener('change', syncDrawer);
|
|
2560
|
+
document.addEventListener('keydown', (e) => {
|
|
2561
|
+
if (!drawerOpen) return;
|
|
2562
|
+
if (e.key === 'Escape' && menu.hidden) { e.preventDefault(); closeDrawer(); return; }
|
|
2563
|
+
// Hold focus inside the open drawer.
|
|
2564
|
+
if (e.key === 'Tab' && menu.hidden) {
|
|
2565
|
+
const f = focusables(side);
|
|
2566
|
+
if (!f.length) return;
|
|
2567
|
+
const first = f[0], last = f[f.length - 1];
|
|
2568
|
+
if (e.shiftKey && (document.activeElement === first || !side.contains(document.activeElement))) { e.preventDefault(); last.focus(); }
|
|
2569
|
+
else if (!e.shiftKey && (document.activeElement === last || !side.contains(document.activeElement))) { e.preventDefault(); first.focus(); }
|
|
2570
|
+
}
|
|
2571
|
+
});
|
|
2572
|
+
syncDrawer();
|
|
2573
|
+
|
|
2574
|
+
/* ---------- rendering ---------- */
|
|
2575
|
+
|
|
2576
|
+
function viewNotFound(what, bad) {
|
|
2577
|
+
return `<div>
|
|
2578
|
+
<h1 class="page__title">${bad ? 'This link is malformed' : 'No page here'}</h1>
|
|
2579
|
+
<p class="page__sub">${bad ? 'Part of' : 'Nothing answers to'} <code class="slug">${esc(what)}</code>${bad ? ' is not valid URL encoding, so it cannot be opened.' : '.'}
|
|
2580
|
+
Open the <a class="link" data-go="${esc(LH('dashboard'))}">Learning dashboard</a> or the
|
|
2581
|
+
<a class="link" data-go="${esc(MH('dashboard'))}">Memory dashboard</a>.</p>
|
|
2582
|
+
</div>`;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/**
|
|
2586
|
+
* Brings the screen in line with the URL: canonicalises a legacy or partial
|
|
2587
|
+
* link in place (no extra history entry), reads the project from the query,
|
|
2588
|
+
* then renders. Every navigation — a click, a pasted link, Back — lands here.
|
|
2589
|
+
*/
|
|
2590
|
+
function syncFromUrl() {
|
|
2591
|
+
let saved = null;
|
|
2592
|
+
try { saved = localStorage.getItem('eklavya-dash-project'); } catch (e) {}
|
|
2593
|
+
if (saved && !INV.projects.some((p) => p.id === saved)) saved = null;
|
|
2594
|
+
const r = resolve(location.hash, saved);
|
|
2595
|
+
if (r.redirect) {
|
|
2596
|
+
history.replaceState(history.state, '', location.pathname + location.search + r.redirect);
|
|
2597
|
+
return syncFromUrl();
|
|
2598
|
+
}
|
|
2599
|
+
const project = r.query.get('project');
|
|
2600
|
+
const known = project !== null && INV.projects.some((p) => p.id === project);
|
|
2601
|
+
const next = known ? project : null;
|
|
2602
|
+
if (next !== PROJECT) { PROJECT = next; for (const k of Object.keys(T)) T[k].page = 1; recomputeScope(); }
|
|
2603
|
+
CUR = { wf: r.wf, page: r.page, param: r.param, query: r.query, missing: r.missing, bad: r.bad };
|
|
2604
|
+
$('proj').value = PROJECT ?? '';
|
|
2605
|
+
render();
|
|
1878
2606
|
}
|
|
1879
2607
|
|
|
1880
2608
|
function render(keepFocus) {
|
|
1881
|
-
const
|
|
1882
|
-
const
|
|
1883
|
-
|
|
2609
|
+
const W = WORKFLOWS[CUR.wf];
|
|
2610
|
+
const pg = CUR.missing ? null : W.pages[CUR.page];
|
|
2611
|
+
const active = pg ? (pg.parent ?? CUR.page) : null;
|
|
2612
|
+
GEN += 1;
|
|
2613
|
+
renderShell();
|
|
2614
|
+
renderNav(CUR.wf, active);
|
|
2615
|
+
const label = pg ? (W.pages[pg.parent ?? CUR.page].label) : 'Not found';
|
|
2616
|
+
$('crumb').innerHTML = `<span>${esc(W.name)}</span><span class="dim">${esc(label)}</span><span class="dim">${esc(scopeName())}</span>`;
|
|
2617
|
+
document.title = `${label} · ${W.name} — Eklavya`;
|
|
1884
2618
|
|
|
1885
2619
|
AFTER.length = 0;
|
|
1886
|
-
$('view').innerHTML =
|
|
2620
|
+
$('view').innerHTML = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
|
|
2621
|
+
: CUR.bad ? viewNotFound(CUR.param, true)
|
|
2622
|
+
: pg.view(CUR.param);
|
|
1887
2623
|
// A clickable row has to be reachable by Tab as well as by mouse.
|
|
1888
|
-
$('view')
|
|
2624
|
+
focusable($('view'));
|
|
1889
2625
|
for (const fn of AFTER) fn();
|
|
1890
2626
|
|
|
1891
2627
|
if (keepFocus) {
|
|
@@ -1894,6 +2630,9 @@ function render(keepFocus) {
|
|
|
1894
2630
|
} else {
|
|
1895
2631
|
window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
|
|
1896
2632
|
}
|
|
2633
|
+
// Which URL the screen was last drawn for. The tests wait on it rather than on
|
|
2634
|
+
// a timer, and it costs nothing to leave in.
|
|
2635
|
+
document.documentElement.dataset.rendered = location.hash;
|
|
1897
2636
|
}
|
|
1898
2637
|
|
|
1899
2638
|
/* ---------- events, all delegated ---------- */
|
|
@@ -1902,6 +2641,18 @@ function goTo(hash) {
|
|
|
1902
2641
|
if (location.hash === hash) render();
|
|
1903
2642
|
else location.hash = hash;
|
|
1904
2643
|
}
|
|
2644
|
+
/**
|
|
2645
|
+
* Rewrites the current URL's query without a history entry: a select or a
|
|
2646
|
+
* date box is adjusting this screen, not going somewhere new.
|
|
2647
|
+
*/
|
|
2648
|
+
function setQuery(changes) {
|
|
2649
|
+
const q = new URLSearchParams(CUR.query);
|
|
2650
|
+
for (const [k, v] of Object.entries(changes)) { if (v === null || v === '' || v === 'all') q.delete(k); else q.set(k, v); }
|
|
2651
|
+
const s = q.toString();
|
|
2652
|
+
const path = location.hash.replace(/\?.*$/, '');
|
|
2653
|
+
history.replaceState(history.state, '', location.pathname + location.search + path + (s ? '?' + s : ''));
|
|
2654
|
+
syncFromUrl();
|
|
2655
|
+
}
|
|
1905
2656
|
|
|
1906
2657
|
function onActivate(e) {
|
|
1907
2658
|
const pg = e.target.closest('[data-page]');
|
|
@@ -1911,14 +2662,17 @@ function onActivate(e) {
|
|
|
1911
2662
|
const chipState = e.target.closest('[data-state]');
|
|
1912
2663
|
if (chipState) {
|
|
1913
2664
|
T.concepts.page = 1;
|
|
1914
|
-
|
|
2665
|
+
const s = chipState.dataset.state;
|
|
2666
|
+
return goTo(LH('concepts', s === 'all' ? null : s, { domain: CUR.query.get('domain') }));
|
|
1915
2667
|
}
|
|
1916
2668
|
const tab = e.target.closest('[data-tab]');
|
|
1917
|
-
if (tab) { T.review.page = 1; return goTo(tab.dataset.tab === 'due' ?
|
|
2669
|
+
if (tab) { T.review.page = 1; return goTo(LH('review', tab.dataset.tab === 'due' ? null : tab.dataset.tab)); }
|
|
1918
2670
|
const mtype = e.target.closest('[data-mtype]');
|
|
1919
2671
|
if (mtype) {
|
|
1920
2672
|
T.memory.page = 1;
|
|
1921
|
-
|
|
2673
|
+
const q = CUR.query;
|
|
2674
|
+
return goTo(MH('timeline', mtype.dataset.mtype === 'all' ? null : mtype.dataset.mtype,
|
|
2675
|
+
{ tag: q.get('tag'), from: q.get('from'), to: q.get('to') }));
|
|
1922
2676
|
}
|
|
1923
2677
|
const go = e.target.closest('[data-go]');
|
|
1924
2678
|
if (go) return goTo(go.dataset.go);
|
|
@@ -1929,7 +2683,7 @@ $('view').addEventListener('click', onActivate);
|
|
|
1929
2683
|
// are divs and table rows, so Enter and Space have to be wired by hand.
|
|
1930
2684
|
$('view').addEventListener('keydown', (e) => {
|
|
1931
2685
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
1932
|
-
if (!e.target.closest('[data-go],[data-state],[data-tab],[data-page]')) return;
|
|
2686
|
+
if (!e.target.closest('[data-go],[data-state],[data-tab],[data-page],[data-mtype]')) return;
|
|
1933
2687
|
e.preventDefault();
|
|
1934
2688
|
onActivate(e);
|
|
1935
2689
|
});
|
|
@@ -1948,23 +2702,23 @@ $('view').addEventListener('input', (e) => {
|
|
|
1948
2702
|
});
|
|
1949
2703
|
|
|
1950
2704
|
$('view').addEventListener('change', (e) => {
|
|
1951
|
-
if (e.target.id === 'dom') { T.concepts.
|
|
2705
|
+
if (e.target.id === 'dom') { T.concepts.page = 1; setQuery({ domain: e.target.value }); }
|
|
1952
2706
|
if (e.target.id === 'sort') { T.concepts.sort = e.target.value; T.concepts.page = 1; render(); }
|
|
1953
|
-
if (e.target.id === 'mtag') { T.memory.
|
|
1954
|
-
if (e.target.id === 'mfrom') { T.memory.
|
|
1955
|
-
if (e.target.id === 'mto') { T.memory.
|
|
2707
|
+
if (e.target.id === 'mtag') { T.memory.page = 1; setQuery({ tag: e.target.value }); }
|
|
2708
|
+
if (e.target.id === 'mfrom') { T.memory.page = 1; setQuery({ from: e.target.value }); }
|
|
2709
|
+
if (e.target.id === 'mto') { T.memory.page = 1; setQuery({ to: e.target.value }); }
|
|
1956
2710
|
});
|
|
1957
2711
|
|
|
1958
|
-
addEventListener('hashchange', () =>
|
|
2712
|
+
addEventListener('hashchange', () => { if (INV) { closePicker(false); syncFromUrl(); } });
|
|
1959
2713
|
|
|
1960
2714
|
let resizeTimer = 0;
|
|
1961
2715
|
addEventListener('resize', () => {
|
|
1962
2716
|
clearTimeout(resizeTimer);
|
|
1963
|
-
resizeTimer = setTimeout(() => render({ id:'q', pos:null }), 120);
|
|
2717
|
+
resizeTimer = setTimeout(() => { if (S) render({ id:'q', pos:null }); }, 120);
|
|
1964
2718
|
});
|
|
1965
2719
|
|
|
1966
2720
|
document.querySelectorAll('[data-ground]').forEach((b) => {
|
|
1967
|
-
b.addEventListener('click', () => { window.eklavyaGround.set(b.dataset.ground); render(); });
|
|
2721
|
+
b.addEventListener('click', () => { window.eklavyaGround.set(b.dataset.ground); if (S) render(); });
|
|
1968
2722
|
});
|
|
1969
2723
|
|
|
1970
2724
|
/* ---------- staying honest about how old this page is ---------- */
|
|
@@ -2012,8 +2766,14 @@ $('stale').addEventListener('click', () => location.reload());
|
|
|
2012
2766
|
// shown "ink" as the active ground.
|
|
2013
2767
|
window.eklavyaGround.apply(window.eklavyaGround.read());
|
|
2014
2768
|
|
|
2015
|
-
|
|
2769
|
+
const json = (url) => fetch(url).then((r) => {
|
|
2770
|
+
if (!r.ok) throw new Error(`${url} answered ${r.status}`);
|
|
2771
|
+
return r.json();
|
|
2772
|
+
});
|
|
2773
|
+
|
|
2774
|
+
Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
|
|
2016
2775
|
S = s;
|
|
2776
|
+
INV = inv;
|
|
2017
2777
|
$('dbline').textContent = s.db_path;
|
|
2018
2778
|
// Totals are aggregated in SQL over the whole table, so a cap can only
|
|
2019
2779
|
// shorten a history list — but say so rather than let it look complete.
|
|
@@ -2034,28 +2794,25 @@ fetch('/api/state').then((r) => r.json()).then((s) => {
|
|
|
2034
2794
|
].filter(Boolean);
|
|
2035
2795
|
$('dials').innerHTML = `<span class="mono">${dials.map(esc).join(' · ')}</span>`;
|
|
2036
2796
|
|
|
2797
|
+
// One selector for both workflows, built from the one inventory: a project
|
|
2798
|
+
// that logged concepts but was never asked a question, or that only ever
|
|
2799
|
+
// captured memory, is a project and is listed.
|
|
2037
2800
|
const sel = $('proj');
|
|
2038
|
-
for (const p of
|
|
2801
|
+
for (const p of inv.projects) {
|
|
2039
2802
|
const o = document.createElement('option');
|
|
2040
|
-
o.value = p.
|
|
2041
|
-
o.textContent =
|
|
2803
|
+
o.value = p.id;
|
|
2804
|
+
o.textContent = p.name + (p.available === false ? ' (checkout gone)' : '');
|
|
2042
2805
|
sel.appendChild(o);
|
|
2043
2806
|
}
|
|
2044
|
-
try {
|
|
2045
|
-
const saved = localStorage.getItem('eklavya-dash-project');
|
|
2046
|
-
if (saved && [...sel.options].some((o) => o.value === saved)) FILTER = saved;
|
|
2047
|
-
} catch (e) {}
|
|
2048
|
-
sel.value = FILTER;
|
|
2049
2807
|
sel.addEventListener('change', () => {
|
|
2050
|
-
|
|
2051
|
-
try { localStorage.setItem('eklavya-dash-project',
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2808
|
+
const v = sel.value || null;
|
|
2809
|
+
try { if (v) localStorage.setItem('eklavya-dash-project', v); else localStorage.removeItem('eklavya-dash-project'); } catch (e) {}
|
|
2810
|
+
// A real navigation: the scope is part of the URL, so Back undoes it.
|
|
2811
|
+
goTo(href(CUR.wf, CUR.page, CUR.param || null, { project: v, ...Object.fromEntries(
|
|
2812
|
+
(WORKFLOWS[CUR.wf].pages[CUR.page]?.query ?? []).map((k) => [k, CUR.query.get(k)])) }));
|
|
2055
2813
|
});
|
|
2056
2814
|
|
|
2057
|
-
|
|
2058
|
-
render();
|
|
2815
|
+
syncFromUrl();
|
|
2059
2816
|
}).catch((e) => {
|
|
2060
2817
|
$('view').innerHTML = `<p class="empty">Could not read the database: ${esc(e.message)}</p>`;
|
|
2061
2818
|
});
|