eklavya 1.24.2 → 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.
@@ -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 — learning dashboard</title>
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
- nav.side__nav { display:flex; flex-direction:column; }
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
- .side { position:static; height:auto; flex-direction:column; border-right:0;
322
- border-bottom:1px solid var(--line-2); padding:var(--space-4) 0; gap:var(--space-4); }
323
- nav.side__nav { flex-direction:row; overflow-x:auto; padding:0 var(--space-4); gap:var(--space-1); }
324
- nav.side__nav a { border-left:0; border-bottom:2px solid transparent; padding:var(--space-2) var(--space-3);
325
- white-space:nowrap; }
326
- nav.side__nav a[aria-current="page"] { border-left:0; border-bottom-color:var(--spot); }
327
- /* The two control groups sit side by side rather than stacking: a rail
328
- unrolled into a column of full-width controls buries the page. */
329
- .side__controls { flex-direction:row; gap:var(--space-5); align-items:flex-end;
330
- padding:0 var(--space-4); margin-top:0; }
331
- .side__group { padding:0; }
332
- .side__group:first-child { flex:0 1 260px; }
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
- <aside class="side">
351
- <a class="side__brand" href="#/overview" aria-label="Eklavya dashboard, overview">
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>learning dashboard</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="*">All projects</option></select>
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 learning history…</p>
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 FILTER = '*'; // project scope: '*' all, '~' unattributed, else a repo path
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 inScope = (repo) => FILTER === '*' || (repo ?? '') === (FILTER === '~' ? '' : FILTER);
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 (FILTER === '*') { SCOPE = null; return; }
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 = () => (FILTER === '*' ? 'all projects' : base(FILTER === '~' ? null : FILTER));
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
- /** Monotonic token: a response that lands after the reader has moved on is dropped. */
838
- let SEQ = 0;
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 seq = ++SEQ;
972
+ const gen = GEN;
847
973
  AFTER.push(() => {
848
- fetch(url).then((r) => r.json()).then((d) => {
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 || seq !== SEQ) return;
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.querySelectorAll('tr.row').forEach((r) => { r.tabIndex = 0; });
984
+ focusable(el);
985
+ for (const fn of AFTER.splice(0)) fn();
853
986
  }).catch((e) => {
854
987
  const el = $(id);
855
- if (el && seq === SEQ) el.innerHTML = `<p class="empty">Could not load: ${esc(e.message)}</p>`;
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 (FILTER !== '*' && FILTER !== '~') params.set('project', FILTER);
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 viewOverview() {
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
- const sess = sessions();
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}`, answered && pct(passed, answered) >= 70 ? 'good' : '', '#/sessions'],
931
- ['Mastered', mastered.length, `of ${seen.length} asked`, mastered.length ? 'good' : '', '#/concepts/mastered'],
932
- ['Due now', due.length, due.length ? 'ready for review' : 'nothing waiting', due.length ? 'warn' : '', '#/review'],
933
- ['Streak', st.current ? plural(st.current, 'day') : '—', st.best ? `best ${plural(st.best, 'day')}` : 'no answers yet'],
934
- ['Active days', st.days, 'with at least one answer'],
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="#/review">review queue</a> to see what is waiting.</p></div>`
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="#/concept/${encodeURIComponent(c.slug)}">${esc(c.name)}</a>
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="#/session/${encodeURIComponent(s.id)}">
1011
- <td><span class="title">${esc(base(s.repo))}</span>
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="#/sessions">All ${sess.length} sessions →</a>` }),
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="#/concept/${encodeURIComponent(c.slug)}">
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="#/concepts">Back to concepts</a>.</p>`;
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="#/concept/${encodeURIComponent(s)}">${esc(byName.get(s)?.name ?? s)}</a>`).join('') + `</div>`
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(base(a.repo))}</span>` : ''}
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="#/concepts">← Concepts</a>
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(base(l.repo))} · ${esc(l.origin ?? 'work')} ·
1164
- <a class="link" data-go="#/session/${encodeURIComponent(l.session_id)}">that session</a></div>
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="#/concept/${encodeURIComponent(x.slug)}">
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="#/concept/${encodeURIComponent(x.slug)}">
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
- const all = sessions();
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="#/session/${encodeURIComponent(s.id)}">
1259
- <td style="min-width:260px"><span class="title">${esc(base(s.repo))}</span>
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.mem ? s.mem.events : '—'}</td>
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">—</span>'
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 captured or logged something. Open one for the
1275
- whole thread: the evidence it captured, what it remembered, the concepts that came out of it, and every
1276
- question you were asked.</p>
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, 'with captured or logged work'],
1415
+ ['Sessions', all.length, 'that logged or asked'],
1280
1416
  ['With questions', withQuestions, `${pct(withQuestions, all.length)}% of sessions`],
1281
- ['Evidence', all.reduce((a, s) => a + (s.mem ? s.mem.events : 0), 0), 'events captured'],
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:'Captured',n:1},{t:'Remembered',n:1},{t:'Logged',n:1},{t:'Asked',n:1},{t:'Accuracy'},{t:'When',n:1}], rows)
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 (!s) return `<p class="empty">No session <code>${esc(id)}</code> in this scope.
1294
- <a class="link" data-go="#/sessions">Back to sessions</a>.</p>`;
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="#/concept/${encodeURIComponent(l.slug)}">
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="#/concept/${encodeURIComponent(a.slug)}">${esc(a.name)}</a>
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="#/entry/${e.id}">
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="#/concept/${encodeURIComponent(c.concept_slug)}"` : '';
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="#/sessions">← Sessions</a>
1355
- <h1 class="page__title">${esc(base(s.repo))}</h1>
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 list = S.projects.filter((p) => inScope(p.repo));
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 acc = pct(p.passed, p.answers);
1385
- const need = p.level_needed, got = p.level_counts;
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
- const ladder = ['easy', 'medium', 'hard'].map((l) => {
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 ?? '') !== (p.repo ?? '')) continue;
1401
- m.set(d.day, { ...d, total: d.passed + d.missed + d.skipped });
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(base(p.repo), p.repo ?? 'recorded before projects were tracked',
1407
- `<div class="kv" style="margin-bottom:var(--space-5)">
1408
- <div><b>Answers</b><span>${p.answers}</span></div>
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
- : p.next_level
1419
- ? `<p class="slug" style="margin:var(--space-4) 0 var(--space-2)">To reach <strong>${esc(p.next_level)}</strong>, counting answers since ${esc(p.promoted_at ? fmtDate(p.promoted_at) : 'you started')}:</p>
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', got.passed, need.answers, !p.level_unmet.includes('answers'))}
1422
- ${bar('Accuracy', Math.round(p.level_accuracy * 100), Math.round(need.accuracy * 100), !p.level_unmet.includes('accuracy'))}
1423
- ${bar('Distinct concepts', got.concepts, need.concepts, !p.level_unmet.includes('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
- <div class="chart" style="margin-top:var(--space-5)"><svg id="c-proj-${i}" role="img"
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.</p>
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 recorded answers'],
1439
- ['Answers', list.reduce((a, p) => a + p.answers, 0), 'across all of them'],
1440
- ['Best accuracy', list.length ? Math.max(...list.map((p) => pct(p.passed, p.answers))) + '%' : '—', 'of any project'],
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 answers recorded yet.</p>',
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="#/domain/${encodeURIComponent(d.domain)}">
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="#/domains">Back to domains</a>.</p>`;
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="#/concept/${encodeURIComponent(c.slug)}">
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="#/domains">← Domains</a>
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="#/entry/${e.id}">
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(base(e.project))}</td>
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
- const type = types.some((x) => x.type === param) ? param : 'all';
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">Memory</h1>
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
- tiles([
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')}${types.map((x) => chip(x.type, x.type, x.n)).join('')}
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="#/entry/${x.id}">${esc(x.title)}</a> <span class="slug">${esc(fmtDate(x.occurred_at))}</span>`;
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(base(e.project))} ·
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="#/session/${encodeURIComponent(e.session_id)}">that session</a>` : ''}</p>
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', '', '#/reuse'],
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="#/concept/${encodeURIComponent(c.concept_slug)}"` : '';
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="#/memory">← Memory</a></div>
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) => FILTER === '*' || FILTER === '~' || r.project === FILTER);
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(base(r.project))}</div></td>
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
- Router
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 NAV = [
1826
- ['overview', 'Overview', '<path d="M3 12l9-9 9 9"/><path d="M5 10v10h14V10"/>'],
1827
- ['concepts', 'Concepts', '<circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/>'],
1828
- ['review', 'Review', '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/>'],
1829
- ['sessions', 'Sessions', '<rect x="3" y="4" width="18" height="16"/><path d="M3 9h18M8 4v16"/>'],
1830
- ['projects', 'Projects', '<path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 12l9 4 9-4M3 17l9 4 9-4"/>'],
1831
- ['domains', '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"/>'],
1832
- ['memory', 'Memory', '<path d="M4 5h16v14H4z"/><path d="M8 9h8M8 13h5"/>'],
1833
- ['reuse', '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"/>'],
1834
- ['health', 'Health', '<path d="M3 12h4l2-6 3 12 2-6h7"/>'],
1835
- ];
1836
-
1837
- const VIEWS = {
1838
- overview: viewOverview,
1839
- concepts: viewConcepts,
1840
- concept: viewConcept,
1841
- review: viewReview,
1842
- sessions: viewSessions,
1843
- session: viewSession,
1844
- projects: viewProjects,
1845
- domains: viewDomains,
1846
- domain: viewDomain,
1847
- memory: viewMemory,
1848
- entry: viewEntry,
1849
- reuse: viewReuse,
1850
- health: viewHealth,
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
- function route() {
1854
- const raw = location.hash.replace(/^#\/?/, '');
1855
- const [name, ...rest] = raw.split('/');
1856
- return { name: VIEWS[name] ? name : 'overview', param: decodeURIComponent(rest.join('/')) };
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
- function renderNav(active) {
1860
- const seen = seenConcepts();
1861
- const counts = {
1862
- concepts: conceptsInScope().length,
1863
- review: seen.filter((c) => c.due).length,
1864
- sessions: sessions().length,
1865
- projects: S.projects.filter((p) => inScope(p.repo)).length,
1866
- domains: new Set(conceptsInScope().map((c) => c.domain)).size,
1867
- // Memory counts are all-project: the timeline is paged on the server, so
1868
- // the page cannot honestly narrow a total it was never sent.
1869
- memory: S.memory.entries,
1870
- reuse: S.reuse.receipts,
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
- $('nav').innerHTML = NAV.map(([key, label, icon]) => `
1873
- <a href="#/${key}"${key === active ? ' aria-current="page"' : ''}>
1874
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
1875
- stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg>
1876
- ${label}${counts[key] !== undefined ? `<i class="${key === 'review' && counts[key] ? 'hot' : ''}">${counts[key]}</i>` : ''}
1877
- </a>`).join('');
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 { name, param } = route();
1882
- const root = { concept:'concepts', session:'sessions', domain:'domains', entry:'memory' }[name] ?? name;
1883
- renderNav(root);
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 = VIEWS[name](param);
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').querySelectorAll('tr.row').forEach((r) => { r.tabIndex = 0; });
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
- return goTo(chipState.dataset.state === 'all' ? '#/concepts' : '#/concepts/' + chipState.dataset.state);
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' ? '#/review' : '#/review/' + tab.dataset.tab); }
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
- return goTo(mtype.dataset.mtype === 'all' ? '#/memory' : '#/memory/' + encodeURIComponent(mtype.dataset.mtype));
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.domain = e.target.value; T.concepts.page = 1; render(); }
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.tag = e.target.value; T.memory.page = 1; render(); }
1954
- if (e.target.id === 'mfrom') { T.memory.since = e.target.value; T.memory.page = 1; render(); }
1955
- if (e.target.id === 'mto') { T.memory.until = e.target.value; T.memory.page = 1; render(); }
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', () => render());
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
- fetch('/api/state').then((r) => r.json()).then((s) => {
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 s.projects) {
2801
+ for (const p of inv.projects) {
2039
2802
  const o = document.createElement('option');
2040
- o.value = p.repo ?? '~';
2041
- o.textContent = base(p.repo);
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
- FILTER = sel.value;
2051
- try { localStorage.setItem('eklavya-dash-project', FILTER); } catch (e) {}
2052
- for (const k of Object.keys(T)) T[k].page = 1;
2053
- recomputeScope();
2054
- render();
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
- recomputeScope();
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
  });