eklavya 1.9.3 → 1.10.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 progress</title>
6
+ <title>Eklavya — learning 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.
@@ -36,269 +36,587 @@
36
36
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
37
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
+ /* ============================================================
40
+ Layout: a fixed rail and one scrolling column.
41
+
42
+ Every view is a stack of cards on the same rhythm — 24px between
43
+ cards, 16px from a card's heading block to its content. Nothing
44
+ sets its own vertical margins; `.stack` owns the gaps, so a new
45
+ card can never invent a different one.
46
+ ============================================================ */
39
47
  *, *::before, *::after { box-sizing: border-box; }
48
+ html { scroll-behavior: smooth; }
40
49
  body { margin:0; font-family:var(--font-body); background:var(--bg); color:var(--ink);
41
- font-size:14.5px; line-height:1.65;
42
- background-image:
43
- linear-gradient(var(--grid) 1px, transparent 1px),
44
- linear-gradient(90deg, var(--grid) 1px, transparent 1px);
45
- background-size:72px 72px; }
50
+ font-size:14.5px; line-height:1.65; }
46
51
  ::selection { background:var(--spot); color:var(--spot-ink); }
47
- .wrap { max-width:1140px; margin:0 auto; padding:0 var(--space-8); }
48
- code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
52
+ code, .mono { font-family:var(--font-mono); }
53
+ a { color:inherit; }
54
+
55
+ .app { display:grid; grid-template-columns:248px minmax(0,1fr); min-height:100vh; }
49
56
 
50
- /* ---------- header ---------- */
51
- header { border-bottom:1px solid var(--line-2); padding:var(--space-8) 0 var(--space-10); }
52
- .bar { display:flex; align-items:center; gap:var(--space-4); flex-wrap:wrap; }
53
- .mark { width:34px; height:34px; background:var(--spot); color:var(--spot-ink);
57
+ /* ---------- the rail ---------- */
58
+ .side { position:sticky; top:0; align-self:start; height:100vh; overflow-y:auto;
59
+ background:var(--panel); border-right:1px solid var(--line-2);
60
+ display:flex; flex-direction:column; gap:var(--space-6); padding:var(--space-6) 0; }
61
+ /* The wordmark is the way home, the way it is on every other site the reader
62
+ has ever used. */
63
+ .side__brand { display:flex; align-items:center; gap:var(--space-3); padding:0 var(--space-5);
64
+ text-decoration:none; color:inherit; }
65
+ .side__brand:hover b { color:var(--spot); }
66
+ .mark { width:30px; height:30px; background:var(--spot); color:var(--spot-ink);
54
67
  display:grid; place-items:center; flex:none; }
55
- h1 { font-family:var(--font-disp); font-size:26px; font-weight:var(--weight-extra);
56
- letter-spacing:var(--tracking-tight); margin:0; }
57
- .sub { color:var(--dim); font-size:var(--text-sm); margin:2px 0 0; }
58
- .spacer { flex:1 1 auto; }
59
- select { font:inherit; font-size:var(--text-sm); background:var(--panel); color:var(--ink);
60
- border:1px solid var(--line-2); border-radius:0;
61
- padding:var(--space-2) var(--space-3); }
62
- select:focus-visible { outline:none; box-shadow:var(--ring); }
68
+ .side__brand b { font-family:var(--font-disp); font-size:17px; font-weight:var(--weight-extra);
69
+ letter-spacing:var(--tracking-tight); display:block; line-height:1.2; }
70
+ .side__brand span { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps);
71
+ text-transform:uppercase; color:var(--faint); }
72
+
73
+ .side__controls { display:flex; flex-direction:column; gap:var(--space-6); margin-top:auto; }
74
+ .side__group { padding:0 var(--space-5); }
75
+ .side__label { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps);
76
+ text-transform:uppercase; color:var(--faint); margin:0 0 var(--space-2); }
77
+
78
+ nav.side__nav { display:flex; flex-direction:column; }
79
+ nav.side__nav a { display:flex; align-items:center; gap:var(--space-3); text-decoration:none;
80
+ padding:var(--space-2) var(--space-5); color:var(--dim); font-size:14px;
81
+ border-left:2px solid transparent;
82
+ transition:color var(--duration-fast) var(--ease), background var(--duration-fast) var(--ease); }
83
+ nav.side__nav a:hover { color:var(--ink); background:var(--mass); }
84
+ nav.side__nav a[aria-current="page"] { color:var(--ink); border-left-color:var(--spot); background:var(--mass); }
85
+ nav.side__nav a svg { width:17px; height:17px; flex:none; color:var(--faint); }
86
+ nav.side__nav a[aria-current="page"] svg { color:var(--spot); }
87
+ nav.side__nav a i { margin-left:auto; font-style:normal; font-family:var(--font-mono);
88
+ font-size:11px; color:var(--faint); }
89
+ nav.side__nav a i.hot { color:var(--warning); }
90
+
91
+ .foot { margin-top:var(--space-12); padding-top:var(--space-5); border-top:1px solid var(--line);
92
+ color:var(--faint); font-size:var(--text-xs); display:flex; gap:var(--space-4);
93
+ justify-content:space-between; flex-wrap:wrap; }
94
+ .foot code { font-family:var(--font-mono); word-break:break-all; color:var(--dim); }
95
+ #dials { font-family:var(--font-mono); }
96
+
97
+ select, input[type="search"], input[type="text"] {
98
+ font:inherit; font-size:13.5px; background:var(--bg); color:var(--ink);
99
+ border:1px solid var(--line-2); border-radius:0; padding:7px var(--space-3); width:100%;
100
+ }
101
+ select:focus-visible, input:focus-visible, button:focus-visible, a:focus-visible,
102
+ [tabindex]:focus-visible { outline:none; box-shadow:var(--ring); }
103
+
104
+ .ground { display:flex; border:1px solid var(--line-2); width:max-content; }
105
+ .ground button { font-family:var(--font-mono); font-size:10.5px; letter-spacing:.12em;
106
+ text-transform:uppercase; background:none; border:0; color:var(--faint);
107
+ padding:5px 10px; cursor:pointer; line-height:1.4;
108
+ transition:background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); }
109
+ .ground button:hover { color:var(--ink); }
110
+ .ground button[aria-pressed="true"] { background:var(--ink); color:var(--bg); }
111
+
112
+ /* ---------- the column ---------- */
113
+ main { padding:var(--space-8) var(--space-10) var(--space-20); min-width:0; }
114
+ .inner { max-width:1080px; }
115
+ .stack > * + * { margin-top:var(--space-6); }
116
+
117
+ .page__eyebrow { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps);
118
+ text-transform:uppercase; color:var(--spot); margin:0 0 var(--space-2); }
119
+ .page__title { font-family:var(--font-disp); font-size:var(--text-2xl); font-weight:var(--weight-extra);
120
+ letter-spacing:var(--tracking-tight); line-height:1.1; margin:0; }
121
+ .page__sub { color:var(--dim); margin:var(--space-2) 0 0; max-width:70ch; }
122
+ .page__back { display:inline-block; font-family:var(--font-mono); font-size:11.5px;
123
+ letter-spacing:.08em; text-transform:uppercase; color:var(--faint);
124
+ text-decoration:none; margin:0 0 var(--space-3); cursor:pointer; }
125
+ .page__back:hover { color:var(--spot); }
126
+
127
+ .card { background:var(--panel); border:1px solid var(--line); padding:var(--space-6); }
128
+ .card--flush { padding:0; }
129
+ .head { margin-bottom:var(--space-4); }
130
+ .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; }
132
+ .head--row { display:flex; align-items:flex-end; gap:var(--space-4); flex-wrap:wrap; }
133
+ .head--row > :first-child { flex:1 1 320px; }
134
+
135
+ .g2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--space-6); }
136
+ .g3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:var(--space-6); }
137
+ .g2 > *, .g3 > * { margin-top:0 !important; }
63
138
 
64
139
  /* ---------- stat tiles ---------- */
65
- .tiles { display:grid; grid-template-columns:repeat(4,1fr); gap:var(--space-4); margin-top:var(--space-6); }
66
- .tile { background:var(--panel); border:1px solid var(--line-2); border-radius:0;
67
- padding:var(--space-5); }
68
- .tile .k { font-size:var(--text-xs); font-weight:var(--weight-bold); letter-spacing:.08em;
69
- text-transform:uppercase; color:var(--dim); }
70
- .tile .v { font-size:var(--text-3xl); font-weight:var(--weight-extra,800); letter-spacing:-.025em;
71
- line-height:1.1; margin-top:var(--space-2); }
72
- .tile .n { font-size:var(--text-sm); color:var(--dim); margin-top:var(--space-1); }
73
- .good { color:var(--spot); }
74
-
75
- /* ---------- cards ---------- */
76
- main { padding:var(--space-10) 0 var(--space-20); }
77
- section { background:var(--panel); border:1px solid var(--line); border-radius:0;
78
- padding:var(--space-6); margin-bottom:var(--space-6); }
79
- h2 { font-size:var(--text-md); font-weight:var(--weight-bold); letter-spacing:-.01em; margin:0; }
80
- .hint { color:var(--faint); font-size:var(--text-sm); margin:var(--space-1) 0 var(--space-5); }
81
- .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:var(--space-6); }
82
- .grid2 > section { margin:0; }
140
+ .stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:var(--space-4); }
141
+ .tile { background:var(--panel); border:1px solid var(--line-2); padding:var(--space-4) var(--space-5); }
142
+ .tile b { display:block; font-size:10.5px; font-family:var(--font-mono); font-weight:var(--weight-medium);
143
+ letter-spacing:var(--tracking-caps); text-transform:uppercase; color:var(--faint); }
144
+ .tile strong { display:block; font-family:var(--font-disp); font-size:var(--text-2xl);
145
+ font-weight:var(--weight-extra); letter-spacing:-.03em; line-height:1.15;
146
+ margin-top:var(--space-2); }
147
+ .tile span { display:block; font-size:12.5px; color:var(--dim); margin-top:2px; }
148
+ .tile.is-good strong { color:var(--spot); }
149
+ .tile.is-warn strong { color:var(--warning); }
83
150
 
84
151
  /* ---------- tables ---------- */
85
- table { width:100%; border-collapse:collapse; font-size:var(--text-sm); }
86
- th { text-align:left; font-size:var(--text-xs); font-weight:var(--weight-bold); letter-spacing:.08em;
87
- text-transform:uppercase; color:var(--faint); padding:0 var(--space-3) var(--space-2) 0;
88
- border-bottom:1px solid var(--line); white-space:nowrap; }
89
- td { padding:var(--space-3) var(--space-3) var(--space-3) 0; border-bottom:1px solid var(--line);
152
+ .scroll { overflow-x:auto; }
153
+ table { width:100%; border-collapse:collapse; font-size:13.5px; }
154
+ th { text-align:left; font-family:var(--font-mono); font-size:10.5px; font-weight:var(--weight-medium);
155
+ letter-spacing:var(--tracking-caps); text-transform:uppercase; color:var(--faint);
156
+ padding:0 var(--space-4) var(--space-3) 0; border-bottom:1px solid var(--line-2); white-space:nowrap; }
157
+ td { padding:var(--space-3) var(--space-4) var(--space-3) 0; border-bottom:1px solid var(--line);
90
158
  vertical-align:top; }
91
- tr:last-child td { border-bottom:none; }
159
+ th:last-child, td:last-child { padding-right:0; }
160
+ tbody tr:last-child td { border-bottom:none; }
92
161
  .num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
93
- .scroll { overflow-x:auto; }
162
+ tr.row { cursor:pointer; }
163
+ tr.row:hover td { background:var(--mass); }
164
+ /* Rows are keyboard-operable: `tabindex` is set on render, Enter and Space
165
+ open them. A row you can only reach with a mouse is a dead end. */
166
+ tr.row:focus-visible { outline:2px solid var(--spot); outline-offset:-2px; }
167
+ .tile[data-go] { cursor:pointer; transition:border-color var(--duration-fast) var(--ease); }
168
+ .tile[data-go]:hover { border-color:var(--spot); }
169
+ .tile[data-go]:focus-visible { outline:none; box-shadow:var(--ring); }
170
+ td .title { font-weight:var(--weight-semibold); color:var(--ink); }
171
+ .slug { font-family:var(--font-mono); font-size:11.5px; color:var(--faint); word-break:break-word; }
172
+ .ctx { color:var(--dim); }
173
+ .empty { color:var(--faint); padding:var(--space-5) 0; font-size:13.5px; }
174
+
175
+ /* ---------- controls ---------- */
176
+ .filters { display:flex; gap:var(--space-3); flex-wrap:wrap; align-items:center;
177
+ margin-bottom:var(--space-4); }
178
+ .filters .grow { flex:1 1 220px; min-width:180px; }
179
+ .filters select { width:auto; }
180
+ .chips { display:flex; gap:var(--space-2); flex-wrap:wrap; }
181
+ .chip { font-family:var(--font-mono); font-size:11.5px; letter-spacing:.04em; text-transform:uppercase;
182
+ background:none; border:1px solid var(--line-2); color:var(--faint); padding:5px 10px;
183
+ cursor:pointer; transition:color var(--duration-fast) var(--ease),
184
+ border-color var(--duration-fast) var(--ease); }
185
+ .chip:hover { color:var(--ink); }
186
+ .chip[aria-pressed="true"] { color:var(--spot); border-color:var(--spot); }
187
+
188
+ .pager { display:flex; align-items:center; gap:var(--space-3); margin-top:var(--space-4);
189
+ padding-top:var(--space-4); border-top:1px solid var(--line); }
190
+ .pager__count { font-size:12.5px; color:var(--faint); font-variant-numeric:tabular-nums; }
191
+ .pager__btns { margin-left:auto; display:flex; gap:var(--space-2); }
192
+ .pager button { font-family:var(--font-mono); font-size:12px; background:none;
193
+ border:1px solid var(--line-2); color:var(--dim); padding:4px 10px; cursor:pointer;
194
+ min-width:32px; }
195
+ .pager button:hover:not(:disabled) { color:var(--ink); border-color:var(--spot); }
196
+ .pager button[aria-current="true"] { color:var(--spot-ink); background:var(--spot); border-color:var(--spot); }
197
+ .pager button:disabled { opacity:.4; cursor:default; }
94
198
 
95
- /* ---------- meters (sequential, one hue) ---------- */
96
- .meter { height:8px; border-radius:var(--radius-pill); background:var(--mass); overflow:hidden;
97
- min-width:80px; }
98
- .meter > i { display:block; height:100%; background:var(--spot); border-radius:var(--radius-pill); }
199
+ /* ---------- meters, pills, badges ---------- */
200
+ .meter { height:6px; background:var(--mass); overflow:hidden; min-width:72px; }
201
+ .meter > i { display:block; height:100%; background:var(--spot); }
99
202
  .meter.warn > i { background:var(--warning); }
203
+ .meter.mute > i { background:var(--faint-2); }
100
204
 
101
- /* ---------- chart ---------- */
102
- .legend { display:flex; gap:var(--space-5); font-size:var(--text-sm); color:var(--dim);
205
+ .pill { display:inline-block; font-family:var(--font-mono); font-size:11px; padding:2px 8px;
206
+ border-radius:var(--radius-pill); background:var(--mass); color:var(--dim); white-space:nowrap; }
207
+ .pill.ok { background:var(--spot-soft); color:var(--spot); }
208
+ .pill.due { color:var(--warning); }
209
+ .pill.new { color:var(--faint); }
210
+
211
+ .grade { font-family:var(--font-mono); font-size:11.5px; padding:2px 8px; white-space:nowrap;
212
+ background:var(--mass); color:var(--dim); }
213
+ .grade.pass { background:var(--spot-soft); color:var(--spot); }
214
+ .grade.miss { color:var(--warning); }
215
+
216
+ .kv { display:grid; grid-template-columns:repeat(auto-fit,minmax(128px,1fr)); gap:var(--space-4) var(--space-5); }
217
+ .kv div b { display:block; font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps);
218
+ text-transform:uppercase; color:var(--faint); font-weight:var(--weight-medium); }
219
+ .kv div span { display:block; font-size:15px; color:var(--ink); margin-top:2px; }
220
+
221
+ /* ---------- segmented bar (a whole, split) ---------- */
222
+ .seg { display:flex; height:10px; background:var(--mass); overflow:hidden; }
223
+ .seg > span { display:block; height:100%; }
224
+ .seg__key { display:flex; gap:var(--space-5); flex-wrap:wrap; margin-top:var(--space-3);
225
+ font-size:12.5px; color:var(--dim); }
226
+ .seg__key b { display:inline-block; width:9px; height:9px; margin-right:var(--space-2); }
227
+
228
+ /* ---------- charts ---------- */
229
+ .legend { display:flex; gap:var(--space-5); font-size:12.5px; color:var(--dim);
103
230
  margin-bottom:var(--space-4); flex-wrap:wrap; }
104
- .legend b { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:var(--space-2); }
231
+ .legend b { display:inline-block; width:9px; height:9px; margin-right:var(--space-2); }
105
232
  .chart { position:relative; }
106
- .chart svg { display:block; width:100%; height:200px; overflow:visible; }
107
- .gridline { stroke:var(--line); stroke-width:1; }
108
- .axis { font-family:var(--font-mono); font-size:10px; fill:var(--faint-2); }
233
+ .chart svg { display:block; width:100%; overflow:visible; }
234
+ .gridline { stroke:var(--line-2); stroke-width:1; }
235
+ .axis { font-family:var(--font-mono); font-size:10px; fill:var(--faint); }
109
236
  .hit { fill:transparent; cursor:pointer; }
110
- .hit:hover + .col, .hit:focus-visible + .col { opacity:.75; }
237
+ .hit:hover + .col, .hit:focus-visible + .col { opacity:.72; }
111
238
  .tip { position:absolute; pointer-events:none; opacity:0; transform:translate(-50%,-100%);
112
- background:var(--panel); color:var(--ink); border-radius:0;
113
- padding:var(--space-3); font-size:var(--text-xs); line-height:1.6; white-space:nowrap; transition:opacity var(--duration-fast) var(--ease); z-index:2; }
239
+ background:var(--bg); border:1px solid var(--line-2); color:var(--ink);
240
+ padding:var(--space-3); font-size:var(--text-xs); line-height:1.6; white-space:nowrap;
241
+ transition:opacity var(--duration-fast) var(--ease); z-index:3; }
114
242
  .tip.on { opacity:1; }
115
243
  .tip .r { display:flex; justify-content:space-between; gap:var(--space-4); }
244
+ .heat { overflow-x:auto; }
245
+ .heat svg { width:auto; max-width:none; }
246
+ .heat rect.cell { stroke:var(--panel); stroke-width:1; }
247
+
248
+ /* ---------- question & answer records ---------- */
249
+ .qa { border:1px solid var(--line); padding:var(--space-4) var(--space-5); }
250
+ .qa + .qa { margin-top:var(--space-3); }
251
+ .qa__meta { display:flex; align-items:center; gap:var(--space-3); flex-wrap:wrap;
252
+ font-family:var(--font-mono); font-size:11px; color:var(--faint); }
253
+ .qa__meta .sp { margin-left:auto; }
254
+ .qa__q { margin:var(--space-3) 0 0; color:var(--ink); }
255
+ .qa__opts { list-style:none; margin:var(--space-3) 0 0; padding:0; display:grid; gap:var(--space-1); }
256
+ .qa__opts li { font-size:13px; color:var(--dim); padding-left:var(--space-5); position:relative; }
257
+ .qa__opts li::before { content:'○'; position:absolute; left:0; color:var(--faint-2); }
258
+ .qa__opts li.picked { color:var(--ink); }
259
+ .qa__opts li.picked::before { content:'●'; color:var(--spot); }
260
+ .qa__f { margin:var(--space-3) 0 0; padding-left:var(--space-4); border-left:2px solid var(--spot);
261
+ color:var(--dim); font-size:13.5px; }
262
+ .qa__a { margin:var(--space-3) 0 0; font-size:13.5px; color:var(--dim); }
263
+ .qa__a b { color:var(--faint); font-weight:var(--weight-semibold); }
264
+
265
+ .ladder { display:flex; gap:var(--space-2); }
266
+ .ladder div { flex:1; border:1px solid var(--line-2); padding:var(--space-2) var(--space-3);
267
+ font-family:var(--font-mono); font-size:11.5px; color:var(--faint); text-align:center;
268
+ text-transform:uppercase; letter-spacing:.08em; }
269
+ .ladder div.on { border-color:var(--spot); color:var(--spot-ink); background:var(--spot); }
270
+ .ladder div.done { border-color:var(--spot); color:var(--spot); }
116
271
 
117
- /* ---------- pills & misc ---------- */
118
- .pill { display:inline-block; font-family:var(--font-mono); font-size:var(--text-xs);
119
- padding:2px 8px; border-radius:var(--radius-pill); background:var(--mass);
120
- color:var(--dim); white-space:nowrap; }
121
- .pill.due { background:var(--mass); color:var(--warning); }
122
- .pill.ok { background:var(--spot-soft); color:var(--spot); }
123
- .ctx { color:var(--ink); }
124
- .slug { font-family:var(--font-mono); font-size:var(--text-xs); color:var(--faint); }
125
- .empty { color:var(--faint); font-size:var(--text-sm); }
126
- footer { color:var(--faint); font-size:var(--text-sm); text-align:center; padding-bottom:var(--space-10); }
272
+ .checks { display:grid; gap:var(--space-3); margin-top:var(--space-4); }
273
+ .checks div { display:flex; align-items:center; gap:var(--space-3); font-size:13.5px; color:var(--dim); }
274
+ .checks .mark-ok { color:var(--spot); }
275
+ .checks .mark-no { color:var(--faint); }
276
+ .checks .bar { flex:1; }
127
277
 
278
+ .list { display:grid; gap:var(--space-3); }
279
+ .link { color:var(--spot); text-decoration:none; cursor:pointer; }
280
+ .link:hover { text-decoration:underline; }
281
+ .tags { display:flex; gap:var(--space-2); flex-wrap:wrap; }
282
+
283
+ .banner { border:1px solid var(--line-2); border-left:2px solid var(--spot); padding:var(--space-4) var(--space-5);
284
+ background:var(--panel); display:flex; gap:var(--space-4); align-items:center; flex-wrap:wrap; }
285
+ .banner p { margin:0; color:var(--dim); font-size:13.5px; }
286
+ .banner code { background:var(--mass); padding:2px 6px; font-size:12.5px; color:var(--ink); }
287
+
288
+ @media (max-width:1080px) {
289
+ .g3 { grid-template-columns:repeat(2,minmax(0,1fr)); }
290
+ }
128
291
  @media (max-width:900px) {
129
- .tiles { grid-template-columns:repeat(2,1fr); }
130
- .grid2 { grid-template-columns:1fr; }
292
+ .app { grid-template-columns:1fr; }
293
+ .side { position:static; height:auto; flex-direction:column; border-right:0;
294
+ border-bottom:1px solid var(--line-2); padding:var(--space-4) 0; gap:var(--space-4); }
295
+ nav.side__nav { flex-direction:row; overflow-x:auto; padding:0 var(--space-4); gap:var(--space-1); }
296
+ nav.side__nav a { border-left:0; border-bottom:2px solid transparent; padding:var(--space-2) var(--space-3);
297
+ white-space:nowrap; }
298
+ nav.side__nav a[aria-current="page"] { border-left:0; border-bottom-color:var(--spot); }
299
+ /* The two control groups sit side by side rather than stacking: a rail
300
+ unrolled into a column of full-width controls buries the page. */
301
+ .side__controls { flex-direction:row; gap:var(--space-5); align-items:flex-end;
302
+ padding:0 var(--space-4); margin-top:0; }
303
+ .side__group { padding:0; }
304
+ .side__group:first-child { flex:0 1 260px; }
305
+ main { padding:var(--space-6) var(--space-5) var(--space-16); }
306
+ .g2, .g3 { grid-template-columns:minmax(0,1fr); }
131
307
  }
132
308
  @media (max-width:560px) {
133
- .wrap { padding:0 var(--space-4); }
134
- .tiles { grid-template-columns:1fr 1fr; gap:var(--space-3); }
135
- .tile .v { font-size:var(--text-2xl); }
309
+ .stats { grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--space-3); }
310
+ .tile strong { font-size:var(--text-xl); }
311
+ .card { padding:var(--space-5) var(--space-4); }
312
+ .page__title { font-size:var(--text-xl); }
136
313
  }
137
- @media (prefers-reduced-motion:reduce) { * { transition:none !important; animation:none !important; } }
138
-
139
- /* ---------- the ground toggle ---------- */
140
- .ground { display:flex; border:1px solid var(--line-2); flex:none; }
141
- .ground button {
142
- font-family:var(--font-mono); font-size:11px; letter-spacing:.12em;
143
- text-transform:uppercase; background:none; border:0; color:var(--faint);
144
- padding:6px 11px; cursor:pointer; line-height:1.4;
145
- transition:background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease);
314
+ @media (prefers-reduced-motion:reduce) {
315
+ html { scroll-behavior:auto; }
316
+ * { transition:none !important; animation:none !important; }
146
317
  }
147
- .ground button:hover { color:var(--ink); }
148
- .ground button[aria-pressed="true"] { background:var(--ink); color:var(--bg); }
149
318
  </style>
150
319
  </head>
151
320
  <body>
152
- <header>
153
- <div class="wrap">
154
- <div class="bar">
321
+ <div class="app">
322
+ <aside class="side">
323
+ <a class="side__brand" href="#/overview" aria-label="Eklavya dashboard, overview">
155
324
  <div class="mark" aria-hidden="true">
156
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
325
+ <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
157
326
  stroke-linecap="round" stroke-linejoin="round">
158
327
  <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"/>
159
328
  </svg>
160
329
  </div>
161
330
  <div>
162
- <h1>Learning progress</h1>
163
- <p class="sub" id="sub">Loading…</p>
331
+ <b>Eklavya</b>
332
+ <span>learning dashboard</span>
164
333
  </div>
165
- <div class="spacer"></div>
334
+ </a>
335
+
336
+ <nav class="side__nav" id="nav" aria-label="Sections"></nav>
337
+
338
+ <div class="side__controls">
339
+ <div class="side__group">
340
+ <p class="side__label" id="proj-label">Project</p>
341
+ <select id="proj" aria-labelledby="proj-label"><option value="*">All projects</option></select>
342
+ </div>
343
+
344
+ <div class="side__group">
345
+ <p class="side__label">Ground</p>
166
346
  <div class="ground" role="group" aria-label="Colour ground">
167
347
  <button type="button" data-ground="ink" aria-pressed="true">ink</button>
168
348
  <button type="button" data-ground="paper" aria-pressed="false">paper</button>
169
349
  </div>
170
- <label class="sub" for="proj">Project</label>
171
- <select id="proj"><option value="*">All projects</option></select>
350
+ </div>
172
351
  </div>
173
- <div class="tiles" id="tiles"></div>
174
- </div>
175
- </header>
176
-
177
- <main class="wrap">
178
- <section>
179
- <h2>Answers over the last 30 days</h2>
180
- <p class="hint" id="chart-hint">Right, missed and skipped, per day.</p>
181
- <div class="legend">
182
- <span><b style="background:var(--spot)"></b>Right</span>
183
- <span><b style="background:var(--warning)"></b>Missed</span>
184
- <span><b style="background:var(--faint)"></b>Skipped</span>
352
+
353
+ </aside>
354
+
355
+ <main>
356
+ <div class="inner stack" id="view">
357
+ <p class="empty">Loading your learning history…</p>
185
358
  </div>
186
- <div class="chart"><div class="tip" id="tip"></div><svg id="chart" role="img"
187
- aria-label="Daily answers, split into right, missed and skipped"></svg></div>
188
- </section>
189
-
190
- <div class="grid2">
191
- <section>
192
- <h2>Projects</h2>
193
- <p class="hint">Where the learning is happening, and how it is going there.</p>
194
- <div class="scroll"><table id="projects"></table></div>
195
- </section>
196
- <section>
197
- <h2>Domains</h2>
198
- <p class="hint">Mastered out of what you have actually touched. The catalogue is a library, not a to-do list.</p>
199
- <div class="scroll"><table id="domains"></table></div>
200
- </section>
201
- </div>
202
-
203
- <section>
204
- <h2>What these sessions taught you</h2>
205
- <p class="hint">The real code each concept came from — the memory the slug hangs on.</p>
206
- <div class="scroll"><table id="recent"></table></div>
207
- </section>
208
-
209
- <section>
210
- <h2>Skipped</h2>
211
- <p class="hint">Declined or blanked. They come back a tier lower — a backlog, not a scolding.</p>
212
- <div class="scroll"><table id="skipped"></table></div>
213
- </section>
214
-
215
- <section>
216
- <h2>Every concept you have been asked</h2>
217
- <p class="hint">Weakest first. Score decays until you see it again.</p>
218
- <div class="scroll"><table id="concepts"></table></div>
219
- </section>
220
- </main>
221
-
222
- <footer class="wrap">
223
- Read from <code id="dbline">~/.eklavya/knowledge.db</code> — nothing on this page leaves your machine.
224
- </footer>
359
+ <footer class="foot inner">
360
+ <span>Read from <code id="dbline">~/.eklavya/knowledge.db</code> nothing on this page leaves your machine.<span id="foot-note"></span></span>
361
+ <span id="dials"></span>
362
+ </footer>
363
+ </main>
364
+ </div>
225
365
 
226
366
  <script>
367
+ /* ============================================================
368
+ One payload, many views.
369
+
370
+ `/api/state` ships flat rows — every attempt, every logged context, one row
371
+ per day — and everything on screen is derived here. That is why a new view is
372
+ a render function and not a new endpoint.
373
+ ============================================================ */
227
374
  const $ = (id) => document.getElementById(id);
228
375
  const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
229
- const base = (p) => (p ? p.split('/').filter(Boolean).pop() : 'unattributed');
230
376
  const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
231
- const day = (s) => new Date(s + 'T00:00:00').toLocaleDateString(undefined, { month:'short', day:'numeric' });
377
+ const base = (p) => (p ? p.split('/').filter(Boolean).pop() : 'unattributed');
378
+ const DAY_MS = 86400000;
379
+
380
+ /* SQLite writes `2026-09-05 21:04:00` in UTC with no zone marker; `new Date()`
381
+ would read that as local time and shift every timestamp by the offset. */
382
+ const parse = (ts) => (ts ? new Date(String(ts).replace(' ', 'T') + (String(ts).endsWith('Z') ? '' : 'Z')) : null);
383
+ const dayKey = (d) => d.toISOString().slice(0, 10);
384
+ const fromKey = (k) => new Date(k + 'T00:00:00Z');
385
+
386
+ const fmtDay = (k) => fromKey(k).toLocaleDateString(undefined, { month:'short', day:'numeric', timeZone:'UTC' });
387
+ const fmtDate = (ts) => {
388
+ const d = parse(ts); if (!d) return '—';
389
+ const sameYear = d.getFullYear() === new Date().getFullYear();
390
+ return d.toLocaleDateString(undefined, { month:'short', day:'numeric', year: sameYear ? undefined : 'numeric' });
391
+ };
232
392
  const ago = (ts) => {
233
- const d = Math.floor((Date.now() - new Date(ts.replace(' ', 'T') + 'Z')) / 86400000);
234
- return d <= 0 ? 'today' : d === 1 ? 'yesterday' : d + ' days ago';
393
+ const d = parse(ts); if (!d) return '';
394
+ const n = Math.floor((Date.now() - d.getTime()) / DAY_MS);
395
+ return n <= 0 ? 'today' : n === 1 ? 'yesterday' : n < 30 ? n + ' days ago'
396
+ : n < 60 ? 'a month ago' : Math.round(n / 30) + ' months ago';
397
+ };
398
+ const inDays = (ts) => {
399
+ const d = parse(ts); if (!d) return null;
400
+ return Math.round((d.getTime() - Date.now()) / DAY_MS);
401
+ };
402
+ const whenDue = (ts) => {
403
+ const n = inDays(ts);
404
+ if (n === null) return 'not scheduled';
405
+ return n < 0 ? Math.abs(n) + ' days overdue' : n === 0 ? 'today' : n === 1 ? 'tomorrow' : 'in ' + n + ' days';
235
406
  };
407
+ const stamp = (ts) => {
408
+ const d = parse(ts); if (!d) return '—';
409
+ return d.toLocaleDateString(undefined, { month:'short', day:'numeric' })
410
+ + ', ' + d.toLocaleTimeString(undefined, { hour:'2-digit', minute:'2-digit' });
411
+ };
412
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
236
413
 
237
- let STATE = null;
238
- let FILTER = '*';
414
+ const TIER = { 1:'recall', 2:'mechanism', 3:'judgement', 4:'failure modes', 5:'design' };
415
+ const tierLabel = (t) => `T${t}${TIER[t] ? ' ' + TIER[t] : ''}`;
239
416
 
240
- const inScope = (repo) => FILTER === '*' || (repo ?? '') === (FILTER === '~' ? '' : FILTER);
417
+ let S = null; // the payload
418
+ let FILTER = '*'; // project scope: '*' all, '~' unattributed, else a repo path
419
+ let SCOPE = null; // Set of slugs touched in scope, or null for all
420
+ const AFTER = []; // render hooks that need a laid-out DOM (charts)
241
421
 
242
- function rows(t, head, body) {
243
- t.innerHTML = body.length
244
- ? '<thead><tr>' + head.map((h) => `<th${h.n ? ' class="num"' : ''}>${esc(h.t)}</th>`).join('') + '</tr></thead>'
245
- + '<tbody>' + body.join('') + '</tbody>'
246
- : '<tbody><tr><td class="empty">Nothing here yet.</td></tr></tbody>';
247
- }
422
+ /* ---------- per-view control state, kept across re-renders ---------- */
423
+ const T = {
424
+ concepts: { page:1, q:'', state:'all', domain:'all', sort:'weakest' },
425
+ review: { page:1, tab:'due' },
426
+ sessions: { page:1 },
427
+ attempts: { page:1 },
428
+ domain: { page:1 },
429
+ concept: { page:1 },
430
+ session: { page:1 },
431
+ };
432
+ const PER = 20;
433
+
434
+ /* ============================================================
435
+ Scope
436
+ ============================================================ */
437
+ const inScope = (repo) => FILTER === '*' || (repo ?? '') === (FILTER === '~' ? '' : FILTER);
248
438
 
249
- function renderTiles() {
250
- const t = STATE.totals;
251
- const scoped = STATE.projects.filter((p) => inScope(p.repo));
252
- const answers = scoped.reduce((a, p) => a + p.answers, 0);
253
- const passed = scoped.reduce((a, p) => a + p.passed, 0);
254
- const skipped = scoped.reduce((a, p) => a + p.skipped, 0);
255
- const due = STATE.concepts.filter((c) => c.due && inScope(c.repo)).length;
256
- const mastered = STATE.concepts.filter((c) => c.mastered && inScope(c.repo)).length;
257
- const tiles = [
258
- ['Accuracy', answers ? pct(passed, answers) + '%' : '—', `${passed} right of ${answers} answered`, passed && pct(passed, answers) >= 70],
259
- ['Mastered', mastered, `of ${STATE.concepts.filter((c) => inScope(c.repo)).length} concepts asked`, mastered > 0],
260
- ['Due now', due, due ? 'run /eklavya:quiz' : 'nothing waiting', false],
261
- ['Skipped', skipped, skipped ? 'waiting a tier lower' : 'none ducked', false],
262
- ];
263
- $('tiles').innerHTML = tiles.map(([k, v, n, good]) =>
264
- `<div class="tile"><div class="k">${k}</div><div class="v${good ? ' good' : ''}">${esc(v)}</div><div class="n">${esc(n)}</div></div>`).join('');
439
+ function recomputeScope() {
440
+ if (FILTER === '*') { SCOPE = null; return; }
441
+ SCOPE = new Set();
442
+ for (const a of S.attempts) if (inScope(a.repo)) SCOPE.add(a.slug);
443
+ for (const l of S.logged) if (inScope(l.repo)) SCOPE.add(l.slug);
265
444
  }
445
+ const conceptsInScope = () => (SCOPE ? S.concepts.filter((c) => SCOPE.has(c.slug)) : S.concepts);
446
+ const seenConcepts = () => conceptsInScope().filter((c) => c.seen);
447
+ const attemptsInScope = () => S.attempts.filter((a) => inScope(a.repo));
448
+ const loggedInScope = () => S.logged.filter((l) => inScope(l.repo));
449
+ const scopeName = () => (FILTER === '*' ? 'all projects' : base(FILTER === '~' ? null : FILTER));
266
450
 
267
- function renderChart() {
268
- const byDay = new Map();
269
- for (const d of STATE.timeline) {
451
+ /** One row per calendar day in scope: { day, passed, missed, skipped, total }. */
452
+ function dailyInScope() {
453
+ const m = new Map();
454
+ for (const d of S.daily) {
270
455
  if (!inScope(d.repo)) continue;
271
- const cur = byDay.get(d.day) ?? { day: d.day, passed: 0, missed: 0, skipped: 0 };
456
+ const cur = m.get(d.day) ?? { day:d.day, passed:0, missed:0, skipped:0, total:0 };
272
457
  cur.passed += d.passed; cur.missed += d.missed; cur.skipped += d.skipped;
273
- byDay.set(d.day, cur);
458
+ cur.total = cur.passed + cur.missed + cur.skipped;
459
+ m.set(d.day, cur);
274
460
  }
275
- const svg = $('chart');
276
- if (!byDay.size) {
277
- svg.innerHTML = '<text x="0" y="24" class="axis">No answers in this window.</text>';
278
- return;
461
+ return m;
462
+ }
463
+
464
+ /** Fills every day in a window, including the empty ones — the gaps are data. */
465
+ function fillDays(map, n, endKey) {
466
+ const out = [];
467
+ const end = endKey ? fromKey(endKey) : new Date();
468
+ end.setUTCHours(0, 0, 0, 0);
469
+ const cur = new Date(end.getTime() - (n - 1) * DAY_MS);
470
+ for (let i = 0; i < n; i++) {
471
+ const k = dayKey(cur);
472
+ out.push(map.get(k) ?? { day:k, passed:0, missed:0, skipped:0, total:0 });
473
+ cur.setUTCDate(cur.getUTCDate() + 1);
279
474
  }
280
- // A real date axis: every day in the window gets a slot, including the empty
281
- // ones. Plotting only the days that have data would space three sessions
282
- // evenly across a month and hide the gaps, which are the honest part.
283
- const days = [];
284
- const cursor = new Date();
285
- cursor.setUTCHours(0, 0, 0, 0);
286
- cursor.setUTCDate(cursor.getUTCDate() - (STATE.timeline_days - 1));
287
- for (let i = 0; i < STATE.timeline_days; i++) {
288
- const key = cursor.toISOString().slice(0, 10);
289
- days.push(byDay.get(key) ?? { day: key, passed: 0, missed: 0, skipped: 0 });
290
- cursor.setUTCDate(cursor.getUTCDate() + 1);
475
+ return out;
476
+ }
477
+
478
+ /** Consecutive days with at least one answer, counting back from today. */
479
+ function streaks(map) {
480
+ const active = new Set([...map.values()].filter((d) => d.total > 0).map((d) => d.day));
481
+ let current = 0;
482
+ const cur = new Date(); cur.setUTCHours(0, 0, 0, 0);
483
+ if (!active.has(dayKey(cur))) cur.setUTCDate(cur.getUTCDate() - 1); // today may not have started yet
484
+ while (active.has(dayKey(cur))) { current += 1; cur.setUTCDate(cur.getUTCDate() - 1); }
485
+
486
+ let best = 0, run = 0, prev = null;
487
+ for (const k of [...active].sort()) {
488
+ run = prev && fromKey(k).getTime() - fromKey(prev).getTime() === DAY_MS ? run + 1 : 1;
489
+ best = Math.max(best, run);
490
+ prev = k;
491
+ }
492
+ return { current, best, days: active.size };
493
+ }
494
+
495
+ /** Sessions, rebuilt from the two row sets that mention a session id. */
496
+ function sessions() {
497
+ const m = new Map();
498
+ const get = (id) => {
499
+ if (!m.has(id)) m.set(id, { id, repo:null, logged:[], asked:[], first:null, last:null });
500
+ return m.get(id);
501
+ };
502
+ const touch = (s, ts) => {
503
+ if (!s.first || ts < s.first) s.first = ts;
504
+ if (!s.last || ts > s.last) s.last = ts;
505
+ };
506
+ for (const l of loggedInScope()) { const s = get(l.session_id); s.logged.push(l); s.repo = s.repo ?? l.repo; touch(s, l.ts); }
507
+ for (const a of attemptsInScope()) {
508
+ if (!a.session_id) continue;
509
+ const s = get(a.session_id); s.asked.push(a); s.repo = s.repo ?? a.repo; touch(s, a.ts);
291
510
  }
292
- // The viewBox tracks the container's real pixel width so the SVG renders 1:1.
293
- // A fixed 1000-wide viewBox scaled to a 520px card would shrink the 10px axis
294
- // text to five, which is the whole reason the chart is redrawn on resize.
295
- const W = Math.max(320, svg.parentElement.clientWidth || 1000);
296
- const H = 200, PAD_B = 22, PAD_L = 24;
297
- const max = Math.max(...days.map((d) => d.passed + d.missed + d.skipped), 1);
511
+ return [...m.values()]
512
+ .map((s) => ({ ...s, passed: s.asked.filter((a) => a.grade >= 3).length }))
513
+ .sort((a, b) => String(b.last).localeCompare(String(a.last)));
514
+ }
515
+
516
+ /**
517
+ * What a session is called in a list. The context line the agent wrote is the
518
+ * only human description there is; a session that logged nothing readable is
519
+ * named by its concepts rather than by its uuid, and never by a question — a
520
+ * question stem in the description slot reads as if the session were about
521
+ * asking it.
522
+ */
523
+ function sessionLabel(s) {
524
+ const ctx = s.logged.find((l) => l.context && String(l.context).trim());
525
+ if (ctx) return ctx.context;
526
+ const names = s.logged.map((l) => l.name);
527
+ if (names.length) return names.slice(0, 4).join(' · ') + (names.length > 4 ? ` +${names.length - 4}` : '');
528
+ return s.asked.length ? plural(s.asked.length, 'question') + ' answered' : s.id;
529
+ }
530
+
531
+ /* ============================================================
532
+ Small view helpers
533
+ ============================================================ */
534
+ const meter = (p, kind) => `<div class="meter${kind ? ' ' + kind : ''}"><i style="width:${Math.max(0, Math.min(100, p))}%"></i></div>`;
535
+ const gradeBadge = (a) => {
536
+ if (a.outcome === 'declined') return '<span class="grade">skipped</span>';
537
+ if (a.outcome === 'dont_know') return '<span class="grade">blanked</span>';
538
+ return `<span class="grade ${a.grade >= 3 ? 'pass' : 'miss'}">${a.grade}/5</span>`;
539
+ };
540
+ const statePill = (c) =>
541
+ !c.seen ? '<span class="pill new">not asked</span>'
542
+ : c.mastered ? '<span class="pill ok">mastered</span>'
543
+ : c.due ? '<span class="pill due">due</span>'
544
+ : '<span class="pill">learning</span>';
545
+
546
+ /**
547
+ * `[label, value, note, kind, go]`. A tile with a `go` is a link: a number the
548
+ * reader wants to act on — 36 due, 28 sessions — should take them to the list it
549
+ * counts, not make them find it in the sidebar.
550
+ */
551
+ function tiles(items) {
552
+ return `<div class="stats">` + items.map(([k, v, n, kind, go]) =>
553
+ `<div class="tile${kind ? ' is-' + kind : ''}"${go ? ` data-go="${esc(go)}" tabindex="0" role="link"` : ''}>`
554
+ + `<b>${esc(k)}</b><strong>${esc(v)}</strong><span>${esc(n)}</span></div>`
555
+ ).join('') + `</div>`;
556
+ }
557
+
558
+ function card(title, hint, body, opts = {}) {
559
+ const head = title
560
+ ? `<div class="head${opts.aside ? ' head--row' : ''}"><div><h2>${esc(title)}</h2>${hint ? `<p>${esc(hint)}</p>` : ''}</div>${opts.aside ?? ''}</div>`
561
+ : '';
562
+ return `<section class="card">${head}${body}</section>`;
563
+ }
564
+
565
+ function table(cols, rows) {
566
+ if (!rows.length) return '<p class="empty">Nothing here yet.</p>';
567
+ return `<div class="scroll"><table><thead><tr>`
568
+ + cols.map((c) => `<th${c.n ? ' class="num"' : ''}${c.w ? ` style="width:${c.w}"` : ''}>${esc(c.t)}</th>`).join('')
569
+ + `</tr></thead><tbody>` + rows.join('') + `</tbody></table></div>`;
570
+ }
571
+
572
+ /**
573
+ * Pagination as a pure function of (total, page): the caller slices, this draws
574
+ * the controls. Windowed to seven buttons so a hundred pages still fit a card.
575
+ */
576
+ function pager(key, total, page, per = PER) {
577
+ const pages = Math.max(1, Math.ceil(total / per));
578
+ if (total <= per) return '';
579
+ const from = (page - 1) * per + 1;
580
+ const to = Math.min(total, page * per);
581
+ const win = [];
582
+ const push = (n) => { if (n >= 1 && n <= pages && !win.includes(n)) win.push(n); };
583
+ push(1); for (let i = page - 2; i <= page + 2; i++) push(i); push(pages);
584
+ win.sort((a, b) => a - b);
585
+
586
+ let btns = `<button data-pager="${key}" data-page="${page - 1}"${page === 1 ? ' disabled' : ''} aria-label="Previous page">‹</button>`;
587
+ let last = 0;
588
+ for (const n of win) {
589
+ if (n - last > 1) btns += `<button disabled aria-hidden="true">…</button>`;
590
+ btns += `<button data-pager="${key}" data-page="${n}"${n === page ? ' aria-current="true"' : ''}>${n}</button>`;
591
+ last = n;
592
+ }
593
+ btns += `<button data-pager="${key}" data-page="${page + 1}"${page === pages ? ' disabled' : ''} aria-label="Next page">›</button>`;
594
+ return `<div class="pager"><span class="pager__count">${from}–${to} of ${total}</span><div class="pager__btns">${btns}</div></div>`;
595
+ }
596
+
597
+ /** Clamps a stored page number to a list that may have shrunk under a filter. */
598
+ function page(key, total, per = PER) {
599
+ const pages = Math.max(1, Math.ceil(total / per));
600
+ if (T[key].page > pages) T[key].page = pages;
601
+ if (T[key].page < 1) T[key].page = 1;
602
+ return T[key].page;
603
+ }
604
+ const slice = (arr, p, per = PER) => arr.slice((p - 1) * per, p * per);
605
+
606
+ /* ============================================================
607
+ Charts — hand-rolled SVG. No library, no network, both grounds.
608
+ ============================================================ */
609
+
610
+ /** Stacked daily bars: right at the base, then missed, then skipped. */
611
+ function chartActivity(el, days) {
612
+ const tip = el.parentElement.querySelector('.tip');
613
+ const W = Math.max(320, el.parentElement.clientWidth || 900);
614
+ const H = 190, PAD_B = 22, PAD_L = 26;
615
+ const max = Math.max(...days.map((d) => d.total), 1);
298
616
  const step = (W - PAD_L) / days.length;
299
- const bw = Math.min(28, Math.max(6, step - 6));
300
- const y = (v) => (H - PAD_B) - (v / max) * (H - PAD_B - 8);
301
- const GAP = 2; // surface gap between stacked segments
617
+ const bw = Math.min(26, Math.max(4, step - 5));
618
+ const y = (v) => (H - PAD_B) - (v / max) * (H - PAD_B - 10);
619
+ const SEGMENTS = [['passed', 'var(--spot)'], ['missed', 'var(--warning)'], ['skipped', 'var(--faint-2)']];
302
620
 
303
621
  let out = '';
304
622
  for (let i = 0; i <= 2; i++) {
@@ -306,46 +624,35 @@ function renderChart() {
306
624
  out += `<line class="gridline" x1="${PAD_L}" x2="${W}" y1="${y(v)}" y2="${y(v)}"/>`
307
625
  + `<text class="axis" x="0" y="${y(v) + 3}">${v}</text>`;
308
626
  }
309
- // Stack order is fixed: right at the base, then missed, then skipped on top.
310
- // Segment colour never depends on rank, only on which measure it is.
311
- const SEGMENTS = [['passed', 'var(--spot)'], ['missed', 'var(--warning)'], ['skipped', 'var(--faint)']];
312
- const labelEvery = Math.max(1, Math.ceil(46 / step));
627
+ const labelEvery = Math.max(1, Math.ceil(48 / step));
313
628
  days.forEach((d, i) => {
314
629
  const x = PAD_L + i * step + (step - bw) / 2;
315
- let acc = 0, seg = '', topColor = 'var(--spot)';
630
+ let acc = 0, seg = '', top = 'var(--spot)';
316
631
  for (const [key, color] of SEGMENTS) {
317
632
  if (!d[key]) continue;
318
- const h = (d[key] / max) * (H - PAD_B - 8);
319
- // A 2px surface gap between stacked fills, taken off the upper segment
320
- // so the bar's total height still reads as the day's total.
321
- const inset = acc ? GAP : 0;
633
+ const h = (d[key] / max) * (H - PAD_B - 10);
634
+ const inset = acc ? 2 : 0; // a 2px surface gap between stacked fills
322
635
  seg += `<rect x="${x}" y="${y(acc) - h + inset}" width="${bw}" height="${Math.max(1, h - inset)}" fill="${color}"/>`;
323
- acc += d[key];
324
- topColor = color;
636
+ acc += d[key]; top = color;
325
637
  }
326
- // 4px rounded cap on the top of the stack only; the base stays square on the
327
- // axis. A day with no answers draws nothing at all — a cap on an empty stack
328
- // is a mark sitting on the baseline, which reads as data that is not there.
329
- const hit = `<rect class="hit" x="${PAD_L + i * step}" y="0" width="${step}" height="${H - PAD_B}" data-i="${i}" tabindex="0"/>`;
330
- const cap = acc ? `<rect x="${x}" y="${y(acc)}" width="${bw}" height="4" rx="2" fill="${topColor}"/>` : '';
331
- out += hit + `<g class="col">${seg}${cap}</g>`;
332
- // Ends always get a label; strided ones stand down near the right edge
333
- // rather than colliding with the last date.
638
+ out += `<rect class="hit" x="${PAD_L + i * step}" y="0" width="${step}" height="${H - PAD_B}" data-i="${i}" tabindex="0"/>`
639
+ + `<g class="col">${seg}${acc ? `<rect x="${x}" y="${y(acc)}" width="${bw}" height="3" fill="${top}"/>` : ''}</g>`;
334
640
  if (i === 0 || i === days.length - 1 || (i % labelEvery === 0 && i < days.length - labelEvery)) {
335
- out += `<text class="axis" x="${x + bw / 2}" text-anchor="middle" y="${H - 6}">${day(d.day)}</text>`;
641
+ out += `<text class="axis" x="${x + bw / 2}" text-anchor="middle" y="${H - 6}">${fmtDay(d.day)}</text>`;
336
642
  }
337
643
  });
338
- svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
339
- svg.innerHTML = out;
644
+ el.setAttribute('viewBox', `0 0 ${W} ${H}`);
645
+ el.style.height = H + 'px';
646
+ el.innerHTML = out;
340
647
 
341
- const tip = $('tip');
342
- svg.querySelectorAll('.hit').forEach((hit) => {
648
+ if (!tip) return;
649
+ el.querySelectorAll('.hit').forEach((hit) => {
343
650
  const show = () => {
344
651
  const d = days[+hit.dataset.i];
345
- tip.innerHTML = `<div class="mono">${esc(day(d.day))}</div>`
652
+ tip.innerHTML = `<div class="mono">${esc(fmtDay(d.day))}</div>`
346
653
  + [['Right', d.passed], ['Missed', d.missed], ['Skipped', d.skipped]]
347
- .map(([k, v]) => `<div class="r"><span>${k}</span><span>${v}</span></div>`).join('');
348
- const r = hit.getBoundingClientRect(), p = svg.parentElement.getBoundingClientRect();
654
+ .map(([k, v]) => `<div class="r"><span>${k}</span><span>${v}</span></div>`).join('');
655
+ const r = hit.getBoundingClientRect(), p = el.parentElement.getBoundingClientRect();
349
656
  tip.style.left = (r.left - p.left + r.width / 2) + 'px';
350
657
  tip.style.top = (r.top - p.top - 8) + 'px';
351
658
  tip.classList.add('on');
@@ -357,86 +664,821 @@ function renderChart() {
357
664
  });
358
665
  }
359
666
 
360
- function renderProjects() {
361
- const body = STATE.projects.filter((p) => inScope(p.repo)).map((p) => {
362
- const acc = pct(p.passed, p.answers);
363
- return `<tr><td><strong>${esc(base(p.repo))}</strong><div class="slug">${esc(p.repo ?? 'recorded before projects were tracked')}</div></td>`
364
- + `<td><span class="pill">${esc(p.level)}</span></td>`
365
- + `<td class="num">${p.answers}</td>`
366
- + `<td style="min-width:120px"><div class="meter${acc < 70 ? ' warn' : ''}"><i style="width:${acc}%"></i></div>
367
- <div class="slug">${acc}% right</div></td>`
368
- + `<td class="num slug">${esc(ago(p.last_active))}</td></tr>`;
667
+ /**
668
+ * Calendar heatmap: one column per week, one row per weekday. Every day in the
669
+ * window gets a cell, empty ones included — a fortnight of silence has to read
670
+ * as a fortnight of silence, not close up into the next session.
671
+ */
672
+ function chartHeatmap(el, map) {
673
+ const CELL = 13, GAP = 3, PAD_T = 18, PAD_L = 26;
674
+ // The window is chosen from the width rather than the width from the window:
675
+ // a wide card shows a year, a phone shows a quarter, and the cell stays the
676
+ // same size on both. Capped at 53 because that is all the payload carries.
677
+ const avail = (el.parentElement.clientWidth || 700) - PAD_L - 2;
678
+ const weeks = Math.max(12, Math.min(53, Math.floor(avail / (CELL + GAP))));
679
+ const end = new Date(); end.setUTCHours(0, 0, 0, 0);
680
+ end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay())); // to the end of this week
681
+ const total = weeks * 7;
682
+ const days = fillDays(map, total, dayKey(end));
683
+ const max = Math.max(...days.map((d) => d.total), 1);
684
+
685
+ let out = '', lastMonth = '';
686
+ days.forEach((d, i) => {
687
+ const col = Math.floor(i / 7), row = i % 7;
688
+ const x = PAD_L + col * (CELL + GAP), y = PAD_T + row * (CELL + GAP);
689
+ const date = fromKey(d.day);
690
+ if (row === 0) {
691
+ const m = date.toLocaleDateString(undefined, { month:'short', timeZone:'UTC' });
692
+ // Not in the last two columns: a month label starting there runs off the
693
+ // right edge of the card.
694
+ if (m !== lastMonth) { if (col < weeks - 2) out += `<text class="axis" x="${x}" y="${PAD_T - 6}">${m}</text>`; lastMonth = m; }
695
+ }
696
+ const future = date.getTime() > Date.now();
697
+ const fill = d.total ? 'var(--spot)' : future ? 'transparent' : 'var(--mass)';
698
+ const op = d.total ? (0.3 + 0.7 * Math.min(1, d.total / max)).toFixed(2) : 1;
699
+ out += `<rect class="cell" x="${x}" y="${y}" width="${CELL}" height="${CELL}" fill="${fill}" fill-opacity="${op}">`
700
+ + `<title>${fmtDate(d.day)} — ${d.total ? plural(d.total, 'answer') + ` (${d.passed} right)` : 'nothing'}</title></rect>`;
701
+ });
702
+ ['Mon', 'Wed', 'Fri'].forEach((label, n) => {
703
+ const row = [1, 3, 5][n];
704
+ out += `<text class="axis" x="0" y="${PAD_T + row * (CELL + GAP) + CELL - 3}">${label}</text>`;
369
705
  });
370
- rows($('projects'), [{t:'Project'},{t:'Level'},{t:'Answers',n:1},{t:'Accuracy'},{t:'Last',n:1}], body);
706
+ const W = PAD_L + weeks * (CELL + GAP), H = PAD_T + 7 * (CELL + GAP);
707
+ el.setAttribute('viewBox', `0 0 ${W} ${H}`);
708
+ el.setAttribute('width', W); el.setAttribute('height', H);
709
+ el.innerHTML = out;
371
710
  }
372
711
 
373
- function renderDomains() {
374
- // Recomputed from the scoped concept list rather than read off the server's
375
- // totals, so picking a project moves this card too. `unseen` stays
376
- // catalogue-wide on purpose: the seed graph does not belong to a repo.
377
- const touchedBy = new Map();
378
- for (const c of STATE.concepts) {
379
- if (!inScope(c.repo)) continue;
380
- const d = touchedBy.get(c.domain) ?? { domain: c.domain, mastered: 0, learning: 0, unseen: 0 };
381
- d[c.mastered ? 'mastered' : 'learning'] += 1;
382
- touchedBy.set(c.domain, d);
712
+ /** Review load for the next fortnight, with everything overdue in one column. */
713
+ function chartForecast(el, buckets) {
714
+ const W = Math.max(300, el.parentElement.clientWidth || 600), H = 150, PAD_B = 26, PAD_L = 24;
715
+ const max = Math.max(...buckets.map((b) => b.n), 1);
716
+ const step = (W - PAD_L) / buckets.length;
717
+ const bw = Math.min(30, Math.max(6, step - 8));
718
+ const y = (v) => (H - PAD_B) - (v / max) * (H - PAD_B - 14);
719
+
720
+ let out = `<line class="gridline" x1="${PAD_L}" x2="${W}" y1="${H - PAD_B}" y2="${H - PAD_B}"/>`;
721
+ buckets.forEach((b, i) => {
722
+ const x = PAD_L + i * step + (step - bw) / 2;
723
+ const fill = b.overdue ? 'var(--warning)' : 'var(--spot)';
724
+ if (b.n) {
725
+ out += `<rect x="${x}" y="${y(b.n)}" width="${bw}" height="${(H - PAD_B) - y(b.n)}" fill="${fill}">`
726
+ + `<title>${esc(b.label)}: ${plural(b.n, 'concept')}</title></rect>`
727
+ + `<text class="axis" x="${x + bw / 2}" text-anchor="middle" y="${y(b.n) - 5}">${b.n}</text>`;
728
+ }
729
+ out += `<text class="axis" x="${x + bw / 2}" text-anchor="middle" y="${H - 8}">${esc(b.short)}</text>`;
730
+ });
731
+ el.setAttribute('viewBox', `0 0 ${W} ${H}`);
732
+ el.style.height = H + 'px';
733
+ el.innerHTML = out;
734
+ }
735
+
736
+ /** One concept's grades in order — the shape of learning it, not a number. */
737
+ function chartGrades(el, attempts) {
738
+ const pts = [...attempts].reverse();
739
+ const W = Math.max(240, el.parentElement.clientWidth || 480), H = 120, PAD_B = 20, PAD_L = 20;
740
+ const step = pts.length > 1 ? (W - PAD_L - 10) / (pts.length - 1) : 0;
741
+ const y = (g) => (H - PAD_B) - (g / 5) * (H - PAD_B - 12);
742
+
743
+ let out = '';
744
+ for (const g of [0, 3, 5]) {
745
+ out += `<line class="gridline" x1="${PAD_L}" x2="${W}" y1="${y(g)}" y2="${y(g)}"/>`
746
+ + `<text class="axis" x="0" y="${y(g) + 3}">${g}</text>`;
383
747
  }
384
- for (const d of STATE.domains) {
385
- const row = touchedBy.get(d.domain);
386
- if (row) row.unseen = d.unseen;
748
+ const xy = pts.map((a, i) => [PAD_L + i * step + (pts.length === 1 ? (W - PAD_L) / 2 : 0), y(a.grade)]);
749
+ if (xy.length > 1) {
750
+ out += `<polyline fill="none" stroke="var(--line-2)" stroke-width="1.5" points="${xy.map((p) => p.join(',')).join(' ')}"/>`;
387
751
  }
388
- const body = [...touchedBy.values()]
389
- .sort((a, b) => b.mastered + b.learning - (a.mastered + a.learning))
390
- .map((d) => {
391
- const touched = d.mastered + d.learning;
392
- return `<tr><td><strong>${esc(d.domain)}</strong></td>`
393
- + `<td style="min-width:120px"><div class="meter"><i style="width:${pct(d.mastered, touched)}%"></i></div>
394
- <div class="slug">${d.mastered} of ${touched} touched</div></td>`
395
- + `<td class="num slug">${d.unseen} not seen</td></tr>`;
752
+ pts.forEach((a, i) => {
753
+ const [x, cy] = xy[i];
754
+ const fill = a.outcome === 'declined' || a.outcome === 'dont_know' ? 'var(--faint-2)'
755
+ : a.grade >= 3 ? 'var(--spot)' : 'var(--warning)';
756
+ out += `<circle cx="${x}" cy="${cy}" r="4.5" fill="${fill}"><title>${esc(fmtDate(a.ts))} — grade ${a.grade}, ${esc(tierLabel(a.tier))}</title></circle>`;
396
757
  });
397
- rows($('domains'), [{t:'Domain'},{t:'Mastered'},{t:'Catalogue',n:1}], body);
758
+ out += `<text class="axis" x="${PAD_L}" y="${H - 4}">${esc(fmtDate(pts[0].ts))}</text>`;
759
+ if (pts.length > 1) out += `<text class="axis" x="${W}" text-anchor="end" y="${H - 4}">${esc(fmtDate(pts.at(-1).ts))}</text>`;
760
+ el.setAttribute('viewBox', `0 0 ${W} ${H}`);
761
+ el.style.height = H + 'px';
762
+ el.innerHTML = out;
398
763
  }
399
764
 
400
- function renderRecent() {
401
- const body = STATE.recent.filter((r) => inScope(r.repo)).slice(0, 20).map((r) =>
402
- `<tr><td class="ctx">${esc(r.context)}<div class="slug">${esc(r.slug)} · ${esc(base(r.repo))}</div></td>`
403
- + `<td class="num slug">${esc(ago(r.ts))}</td></tr>`);
404
- rows($('recent'), [{t:'What you did'},{t:'When',n:1}], body);
765
+ /** A whole, split — mastered / learning / never asked. */
766
+ function segbar(parts) {
767
+ const total = parts.reduce((a, p) => a + p.n, 0) || 1;
768
+ return `<div class="seg">`
769
+ + parts.map((p) => `<span style="width:${(p.n / total) * 100}%;background:${p.color}" title="${esc(p.label)}: ${p.n}"></span>`).join('')
770
+ + `</div><div class="seg__key">`
771
+ + parts.map((p) => `<span><b style="background:${p.color}"></b>${esc(p.label)} ${p.n}</span>`).join('')
772
+ + `</div>`;
405
773
  }
406
774
 
407
- function renderSkipped() {
408
- const body = STATE.skipped.filter((s) => inScope(s.repo)).map((s) =>
409
- `<tr><td><strong>${esc(s.name)}</strong><div class="slug">${esc(s.slug)}</div></td>`
410
- + `<td><span class="pill">${s.outcome === 'declined' ? 'declined' : 'blanked'}</span></td>`
411
- + `<td class="ctx">${esc(s.question ?? '')}</td>`
412
- + `<td class="num slug">${esc(ago(s.ts))}</td></tr>`);
413
- rows($('skipped'), [{t:'Concept'},{t:'How'},{t:'The question'},{t:'When',n:1}], body);
775
+ const chart = (id, fn) => { AFTER.push(() => { const el = $(id); if (el) fn(el); }); };
776
+
777
+ /* ============================================================
778
+ Views
779
+ ============================================================ */
780
+
781
+ function viewOverview() {
782
+ const daily = dailyInScope();
783
+ const st = streaks(daily);
784
+ const seen = seenConcepts();
785
+ const scoped = conceptsInScope();
786
+ const atts = attemptsInScope();
787
+ const answered = atts.length;
788
+ const passed = atts.filter((a) => a.grade >= 3).length;
789
+ const due = seen.filter((c) => c.due);
790
+ const mastered = seen.filter((c) => c.mastered);
791
+ const sess = sessions();
792
+ const last30 = fillDays(daily, 30);
793
+
794
+ const weakest = seen.filter((c) => !c.mastered).sort((a, b) => a.score - b.score || b.attempts - a.attempts).slice(0, 5);
795
+ const recentSessions = sess.slice(0, 5);
796
+
797
+ chart('c-activity', (el) => chartActivity(el, last30));
798
+ chart('c-heat', (el) => chartHeatmap(el, daily));
799
+
800
+ return [
801
+ `<div>
802
+ <p class="page__eyebrow">${esc(scopeName())}</p>
803
+ <h1 class="page__title">Am I actually getting better?</h1>
804
+ <p class="page__sub">${plural(seen.length, 'concept')} asked out of ${S.totals.catalogue} in the graph ·
805
+ ${plural(answered, 'answer')} · ${plural(sess.length, 'session')} · first answer ${esc(fmtDate(S.totals.first_answer))}.</p>
806
+ </div>`,
807
+
808
+ tiles([
809
+ ['Accuracy', answered ? pct(passed, answered) + '%' : '—', `${passed} right of ${answered}`, answered && pct(passed, answered) >= 70 ? 'good' : '', '#/sessions'],
810
+ ['Mastered', mastered.length, `of ${seen.length} asked`, mastered.length ? 'good' : '', '#/concepts/mastered'],
811
+ ['Due now', due.length, due.length ? 'ready for review' : 'nothing waiting', due.length ? 'warn' : '', '#/review'],
812
+ ['Streak', st.current ? plural(st.current, 'day') : '—', st.best ? `best ${plural(st.best, 'day')}` : 'no answers yet'],
813
+ ['Active days', st.days, 'with at least one answer'],
814
+ ]),
815
+
816
+ due.length
817
+ ? `<div class="banner"><p><strong>${plural(due.length, 'concept')}</strong> ${due.length === 1 ? 'is' : 'are'} due.
818
+ Run <code>/eklavya:quiz</code> in Claude Code, or open the
819
+ <a class="link" data-go="#/review">review queue</a> to see what is waiting.</p></div>`
820
+ : '',
821
+
822
+ card('Answers over the last 30 days', 'Right, missed and skipped, per day. Empty days are shown as empty.',
823
+ `<div class="legend">
824
+ <span><b style="background:var(--spot)"></b>Right</span>
825
+ <span><b style="background:var(--warning)"></b>Missed</span>
826
+ <span><b style="background:var(--faint-2)"></b>Skipped</span>
827
+ </div>
828
+ <div class="chart"><div class="tip" id="tip"></div><svg id="c-activity" role="img"
829
+ aria-label="Daily answers, split into right, missed and skipped"></svg></div>`),
830
+
831
+ card('Practice history', 'One cell per day, up to a year back. Darker means more answered that day.',
832
+ `<div class="heat"><svg id="c-heat" role="img" aria-label="Calendar heatmap of daily answers"></svg></div>`),
833
+
834
+ `<div class="g2">
835
+ ${card('Where you stand', 'The catalogue is a library, not a to-do list — untouched concepts are not debt.',
836
+ segbar([
837
+ { label:'Mastered', n:mastered.length, color:'var(--spot)' },
838
+ { label:'Learning', n:seen.length - mastered.length, color:'var(--warning)' },
839
+ { label:'Not asked', n:Math.max(0, scoped.length - seen.length), color:'var(--mass)' },
840
+ ]) + `<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
841
+ A concept is mastered at score 0.7 with two or more passes. Score decays about
842
+ 5% a week once a review is overdue, so mastery is kept, not banked.</p>`)}
843
+ ${card('Weakest five', 'Lowest score first. These are what the next quiz will reach for.',
844
+ weakest.length
845
+ ? `<div class="list">` + weakest.map((c) => `
846
+ <div style="display:flex;gap:var(--space-4);align-items:center">
847
+ <div style="flex:1;min-width:0">
848
+ <a class="link" data-go="#/concept/${encodeURIComponent(c.slug)}">${esc(c.name)}</a>
849
+ <div class="slug">${esc(c.domain)} · ${esc(tierLabel(c.tier))} · ${plural(c.attempts, 'attempt')}</div>
850
+ </div>
851
+ <div style="width:96px">${meter(c.score * 100, c.score < 0.5 ? 'warn' : '')}
852
+ <div class="slug" style="text-align:right">${Math.round(c.score * 100)}%</div></div>
853
+ </div>`).join('') + `</div>`
854
+ : '<p class="empty">Nothing asked in this scope yet.</p>')}
855
+ </div>`,
856
+
857
+ card('Recent sessions', 'Each one is a piece of work your agent did, and what it taught.',
858
+ table([{t:'Session'},{t:'Concepts',n:1},{t:'Asked',n:1},{t:'Right',n:1},{t:'When',n:1}],
859
+ recentSessions.map((s) => `<tr class="row" data-go="#/session/${encodeURIComponent(s.id)}">
860
+ <td><span class="title">${esc(base(s.repo))}</span>
861
+ <div class="slug">${esc(sessionLabel(s))}</div></td>
862
+ <td class="num">${s.logged.length}</td>
863
+ <td class="num">${s.asked.length}</td>
864
+ <td class="num">${s.passed}</td>
865
+ <td class="num slug">${esc(ago(s.last))}</td></tr>`)),
866
+ { aside: `<a class="link" data-go="#/sessions">All ${sess.length} sessions →</a>` }),
867
+ ].join('');
868
+ }
869
+
870
+ /** The state filter lives in the hash — `#/concepts/due` is a link worth having. */
871
+ const CONCEPT_STATES = ['all', 'due', 'learning', 'mastered', 'unseen'];
872
+
873
+ function viewConcepts(param) {
874
+ const t = T.concepts;
875
+ t.state = CONCEPT_STATES.includes(param) ? param : 'all';
876
+ const all = conceptsInScope();
877
+ const domains = [...new Set(all.map((c) => c.domain))].sort();
878
+ const q = t.q.trim().toLowerCase();
879
+
880
+ let list = all.filter((c) => {
881
+ if (t.state === 'mastered' && !c.mastered) return false;
882
+ if (t.state === 'learning' && (!c.seen || c.mastered)) return false;
883
+ if (t.state === 'due' && !c.due) return false;
884
+ if (t.state === 'unseen' && c.seen) return false;
885
+ if (t.domain !== 'all' && c.domain !== t.domain) return false;
886
+ if (q && !(`${c.name} ${c.slug} ${c.domain} ${c.context ?? ''}`.toLowerCase().includes(q))) return false;
887
+ return true;
888
+ });
889
+
890
+ const SORTS = {
891
+ weakest: (a, b) => (a.seen === b.seen ? a.score - b.score || b.attempts - a.attempts : a.seen ? -1 : 1),
892
+ strongest: (a, b) => b.score - a.score,
893
+ recent: (a, b) => String(b.last_seen ?? '').localeCompare(String(a.last_seen ?? '')),
894
+ asked: (a, b) => b.attempts - a.attempts,
895
+ name: (a, b) => a.name.localeCompare(b.name),
896
+ tier: (a, b) => a.tier - b.tier || a.name.localeCompare(b.name),
897
+ };
898
+ list = [...list].sort(SORTS[t.sort] ?? SORTS.weakest);
899
+
900
+ const p = page('concepts', list.length);
901
+ const rows = slice(list, p).map((c) => `
902
+ <tr class="row" data-go="#/concept/${encodeURIComponent(c.slug)}">
903
+ <td style="min-width:210px"><span class="title">${esc(c.name)}</span>
904
+ <div class="slug">${esc(c.slug)}</div></td>
905
+ <td><span class="pill">${esc(c.domain)}</span></td>
906
+ <td class="slug" style="white-space:nowrap">${esc(tierLabel(c.tier))}</td>
907
+ <td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '') +
908
+ `<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
909
+ <td class="num">${c.attempts || '—'}</td>
910
+ <td>${statePill(c)}</td>
911
+ <td class="ctx" style="min-width:220px">${esc(c.context ?? '')}</td>
912
+ </tr>`);
913
+
914
+ const counts = {
915
+ all: all.length,
916
+ due: all.filter((c) => c.due).length,
917
+ learning: all.filter((c) => c.seen && !c.mastered).length,
918
+ mastered: all.filter((c) => c.mastered).length,
919
+ unseen: all.filter((c) => !c.seen).length,
920
+ };
921
+ const chip = (k, label) => `<button class="chip" data-state="${k}" aria-pressed="${t.state === k}">${label} ${counts[k]}</button>`;
922
+
923
+ return [
924
+ `<div>
925
+ <h1 class="page__title">Concepts</h1>
926
+ <p class="page__sub">Everything in your graph — what you have been asked, and what is still just catalogue.
927
+ Open one to see every question you were given on it.</p>
928
+ </div>`,
929
+ card(null, null,
930
+ `<div class="filters">
931
+ <div class="grow"><input type="search" id="q" value="${esc(t.q)}" placeholder="Search name, slug, domain or context…" aria-label="Search concepts"></div>
932
+ <select id="dom" aria-label="Domain">
933
+ <option value="all">All domains</option>
934
+ ${domains.map((d) => `<option value="${esc(d)}"${t.domain === d ? ' selected' : ''}>${esc(d)}</option>`).join('')}
935
+ </select>
936
+ <select id="sort" aria-label="Sort">
937
+ ${[['weakest','Weakest first'],['strongest','Strongest first'],['recent','Recently seen'],['asked','Most asked'],['tier','By tier'],['name','A–Z']]
938
+ .map(([v, l]) => `<option value="${v}"${t.sort === v ? ' selected' : ''}>${l}</option>`).join('')}
939
+ </select>
940
+ </div>
941
+ <div class="chips" id="chips">
942
+ ${chip('all','All')}${chip('due','Due')}${chip('learning','Learning')}${chip('mastered','Mastered')}${chip('unseen','Not asked')}
943
+ </div>
944
+ <div style="margin-top:var(--space-4)">
945
+ ${table([{t:'Concept'},{t:'Domain'},{t:'Tier'},{t:'Score'},{t:'Asked',n:1},{t:'State'},{t:'From your code'}], rows)}
946
+ ${pager('concepts', list.length, p)}
947
+ </div>`),
948
+ ].join('');
949
+ }
950
+
951
+ function viewConcept(slug) {
952
+ const c = S.concepts.find((x) => x.slug === slug);
953
+ if (!c) return `<p class="empty">No concept called <code>${esc(slug)}</code>. <a class="link" data-go="#/concepts">Back to concepts</a>.</p>`;
954
+
955
+ const atts = S.attempts.filter((a) => a.slug === slug);
956
+ // A logged row with no context line is a bookkeeping row, not a memory: it
957
+ // would render as a date attached to nothing.
958
+ const ctx = S.logged.filter((l) => l.slug === slug && l.context && String(l.context).trim());
959
+ const byName = new Map(S.concepts.map((x) => [x.slug, x]));
960
+ const links = (slugs) => slugs.length
961
+ ? `<div class="tags">` + slugs.map((s) => `<a class="pill link" data-go="#/concept/${encodeURIComponent(s)}">${esc(byName.get(s)?.name ?? s)}</a>`).join('') + `</div>`
962
+ : '<p class="slug" style="margin:0">None recorded.</p>';
963
+
964
+ const p = page('concept', atts.length, 5);
965
+ const history = slice(atts, p, 5).map((a) => {
966
+ let opts = [];
967
+ try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
968
+ return `<article class="qa">
969
+ <div class="qa__meta">
970
+ <span>${esc(fmtDate(a.ts))}</span><span>${esc(tierLabel(a.tier))}</span>
971
+ ${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
972
+ ${a.repo ? `<span>${esc(base(a.repo))}</span>` : ''}
973
+ <span class="sp">${gradeBadge(a)}</span>
974
+ </div>
975
+ <p class="qa__q">${esc(a.question)}</p>
976
+ ${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
977
+ `<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
978
+ ${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
979
+ ${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
980
+ </article>`;
981
+ }).join('');
982
+
983
+ if (atts.length) chart('c-grades', (el) => chartGrades(el, atts));
984
+ const decayed = c.stored_score > c.score;
985
+
986
+ return [
987
+ `<div>
988
+ <a class="page__back" data-go="#/concepts">← Concepts</a>
989
+ <h1 class="page__title">${esc(c.name)}</h1>
990
+ <p class="page__sub"><code class="slug">${esc(c.slug)}</code> · ${esc(c.domain)} · ${esc(tierLabel(c.tier))} ·
991
+ ${esc(c.source === 'seed' ? 'from the seed graph' : 'minted from your work')}
992
+ ${c.description ? '<br>' + esc(c.description) : ''}</p>
993
+ </div>`,
994
+
995
+ tiles([
996
+ ['Score', c.seen ? Math.round(c.score * 100) + '%' : '—', decayed ? `decayed from ${Math.round(c.stored_score * 100)}%` : 'recency-weighted last 5 grades', c.mastered ? 'good' : c.score < 0.5 && c.seen ? 'warn' : ''],
997
+ ['State', c.mastered ? 'mastered' : c.seen ? (c.due ? 'due' : 'learning') : 'not asked', c.seen ? `${c.passed} of ${c.attempts} right` : 'no questions yet', c.due ? 'warn' : ''],
998
+ ['Next review', c.seen ? whenDue(c.next_review) : '—', c.interval_d ? `interval ${Math.round(c.interval_d)}d` : 'not scheduled', c.due ? 'warn' : ''],
999
+ ['Reps', c.reps, `ease ${c.ease ? c.ease.toFixed(2) : '—'}`],
1000
+ ]),
1001
+
1002
+ atts.length
1003
+ ? card('How the grades went', 'Every graded answer in order. Grey points are skips and blanks.',
1004
+ `<div class="chart"><svg id="c-grades" role="img" aria-label="Grades over time for this concept"></svg></div>`)
1005
+ : '',
1006
+
1007
+ card('Where this came from', 'The code that logged it — the memory the slug hangs on.',
1008
+ ctx.length
1009
+ ? `<div class="list">` + ctx.slice(0, 12).map((l) => `
1010
+ <div>
1011
+ <div class="ctx">${esc(l.context ?? '')}</div>
1012
+ <div class="slug">${esc(ago(l.ts))} · ${esc(base(l.repo))} · ${esc(l.origin ?? 'work')} ·
1013
+ <a class="link" data-go="#/session/${encodeURIComponent(l.session_id)}">that session</a></div>
1014
+ </div>`).join('') + `</div>`
1015
+ : '<p class="empty">No context recorded — this one came from the seed graph.</p>'),
1016
+
1017
+ card(`Every question you were asked`, atts.length ? `${plural(atts.length, 'attempt')}, newest first.` : '',
1018
+ atts.length ? history + pager('concept', atts.length, p, 5)
1019
+ : '<p class="empty">Not asked yet. It will come up when your work touches it.</p>'),
1020
+
1021
+ `<div class="g3">
1022
+ ${card('Prerequisites', 'Asked first when the graph plans a session.', links(c.prereqs))}
1023
+ ${card('Unlocks', 'These lean on this one.', links(c.unlocks))}
1024
+ ${card('Related', 'Same neighbourhood.', links(c.related))}
1025
+ </div>`,
1026
+ ].join('');
1027
+ }
1028
+
1029
+ const REVIEW_TABS = ['due', 'upcoming', 'skipped'];
1030
+
1031
+ function viewReview(param) {
1032
+ const t = T.review;
1033
+ t.tab = REVIEW_TABS.includes(param) ? param : 'due';
1034
+ const seen = seenConcepts();
1035
+ const due = seen.filter((c) => c.due).sort((a, b) => (b.overdue_days ?? 0) - (a.overdue_days ?? 0));
1036
+ const upcoming = seen.filter((c) => !c.due && c.next_review)
1037
+ .sort((a, b) => String(a.next_review).localeCompare(String(b.next_review)));
1038
+ const skipped = attemptsInScope().filter((a) => a.outcome === 'declined' || a.outcome === 'dont_know');
1039
+
1040
+ // Fourteen days of scheduled reviews, with everything already overdue in one
1041
+ // column — a backlog that grows off the left of the chart is a backlog nobody
1042
+ // sees.
1043
+ const buckets = [{ label:'Overdue', short:'late', n:due.length, overdue:true }];
1044
+ for (let i = 0; i < 14; i++) {
1045
+ const d = new Date(); d.setUTCHours(0, 0, 0, 0); d.setUTCDate(d.getUTCDate() + i);
1046
+ const k = dayKey(d);
1047
+ buckets.push({
1048
+ label: i === 0 ? 'Today' : fmtDay(k),
1049
+ short: i === 0 ? 'today' : String(fromKey(k).getUTCDate()),
1050
+ n: upcoming.filter((c) => String(c.next_review).slice(0, 10) === k).length,
1051
+ overdue: false,
1052
+ });
1053
+ }
1054
+ chart('c-forecast', (el) => chartForecast(el, buckets));
1055
+
1056
+ const tabs = [['due', `Due now ${due.length}`], ['upcoming', `Upcoming ${upcoming.length}`], ['skipped', `Skipped ${skipped.length}`]];
1057
+ const list = t.tab === 'due' ? due : t.tab === 'upcoming' ? upcoming : skipped;
1058
+ const p = page('review', list.length);
1059
+ const rows = slice(list, p).map((x) => t.tab === 'skipped'
1060
+ ? `<tr class="row" data-go="#/concept/${encodeURIComponent(x.slug)}">
1061
+ <td><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.slug)}</div></td>
1062
+ <td><span class="pill">${x.outcome === 'declined' ? 'declined' : 'blanked'}</span></td>
1063
+ <td class="ctx" style="min-width:280px">${esc(x.question)}</td>
1064
+ <td class="num slug">${esc(ago(x.ts))}</td></tr>`
1065
+ : `<tr class="row" data-go="#/concept/${encodeURIComponent(x.slug)}">
1066
+ <td><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.slug)} · ${esc(x.domain)}</div></td>
1067
+ <td style="min-width:104px">${meter(x.score * 100, x.score < 0.5 ? 'warn' : '')}
1068
+ <div class="slug">${Math.round(x.score * 100)}%</div></td>
1069
+ <td class="num">${x.attempts}</td>
1070
+ <td class="slug" style="white-space:nowrap">${esc(tierLabel(x.tier))}</td>
1071
+ <td class="num slug">${esc(whenDue(x.next_review))}</td></tr>`);
1072
+
1073
+ const cols = t.tab === 'skipped'
1074
+ ? [{t:'Concept'},{t:'How'},{t:'The question'},{t:'When',n:1}]
1075
+ : [{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'Tier'},{t:t.tab === 'due' ? 'Overdue' : 'Due',n:1}];
1076
+
1077
+ return [
1078
+ `<div>
1079
+ <h1 class="page__title">Review queue</h1>
1080
+ <p class="page__sub">SM-2 decides when a concept comes back: one day after a miss, then 6, then the
1081
+ interval times its ease. A score decays about 5% a week while a review is overdue, so this list is
1082
+ also the list of things quietly getting weaker.</p>
1083
+ </div>`,
1084
+
1085
+ tiles([
1086
+ ['Due now', due.length, due.length ? 'run /eklavya:quiz' : 'nothing waiting', due.length ? 'warn' : 'good'],
1087
+ ['Next 7 days', buckets.slice(1, 8).reduce((a, b) => a + b.n, 0), 'scheduled reviews'],
1088
+ ['Skipped', skipped.length, 'come back a tier lower'],
1089
+ ['Longest overdue', due.length ? plural(Math.max(...due.map((c) => c.overdue_days ?? 0)), 'day') : '—', 'since it was due'],
1090
+ ]),
1091
+
1092
+ card('The next fortnight', 'How much review is already scheduled, day by day.',
1093
+ `<div class="chart"><svg id="c-forecast" role="img" aria-label="Scheduled reviews for the next fourteen days"></svg></div>`),
1094
+
1095
+ card(null, null,
1096
+ `<div class="chips" id="tabs">` + tabs.map(([k, label]) =>
1097
+ `<button class="chip" data-tab="${k}" aria-pressed="${t.tab === k}">${esc(label)}</button>`).join('') + `</div>
1098
+ <div style="margin-top:var(--space-4)">${table(cols, rows)}${pager('review', list.length, p)}</div>`),
1099
+ ].join('');
1100
+ }
1101
+
1102
+ function viewSessions() {
1103
+ const all = sessions();
1104
+ const p = page('sessions', all.length);
1105
+ const rows = slice(all, p).map((s) => {
1106
+ const acc = s.asked.length ? pct(s.passed, s.asked.length) : null;
1107
+ return `<tr class="row" data-go="#/session/${encodeURIComponent(s.id)}">
1108
+ <td style="min-width:260px"><span class="title">${esc(base(s.repo))}</span>
1109
+ <div class="slug">${esc(sessionLabel(s).slice(0, 120))}</div></td>
1110
+ <td class="num">${s.logged.length}</td>
1111
+ <td class="num">${s.asked.length}</td>
1112
+ <td style="min-width:96px">${acc === null ? '<span class="slug">—</span>'
1113
+ : meter(acc, acc < 70 ? 'warn' : '') + `<div class="slug">${acc}%</div>`}</td>
1114
+ <td class="num slug">${esc(stamp(s.last))}</td></tr>`;
1115
+ });
1116
+
1117
+ const withQuestions = all.filter((s) => s.asked.length).length;
1118
+ return [
1119
+ `<div>
1120
+ <h1 class="page__title">Sessions</h1>
1121
+ <p class="page__sub">One row per Claude Code session that logged something. Open one to see what the
1122
+ agent built, what it said each concept was for, and what you were asked about it.</p>
1123
+ </div>`,
1124
+ tiles([
1125
+ ['Sessions', all.length, 'that logged a concept'],
1126
+ ['With questions', withQuestions, `${pct(withQuestions, all.length)}% of sessions`],
1127
+ ['Concepts logged', all.reduce((a, s) => a + s.logged.length, 0), 'context lines recorded'],
1128
+ ['Answers', all.reduce((a, s) => a + s.asked.length, 0), 'across every session'],
1129
+ ]),
1130
+ card(null, null,
1131
+ table([{t:'Session'},{t:'Logged',n:1},{t:'Asked',n:1},{t:'Accuracy'},{t:'When',n:1}], rows)
1132
+ + pager('sessions', all.length, p)),
1133
+ ].join('');
1134
+ }
1135
+
1136
+ function viewSession(id) {
1137
+ const s = sessions().find((x) => x.id === id);
1138
+ if (!s) return `<p class="empty">No session <code>${esc(id)}</code> in this scope.
1139
+ <a class="link" data-go="#/sessions">Back to sessions</a>.</p>`;
1140
+
1141
+ const byName = new Map(S.concepts.map((c) => [c.slug, c]));
1142
+ const work = s.logged.filter((l) => (l.origin ?? 'work') === 'work');
1143
+ const review = s.logged.filter((l) => l.origin === 'review');
1144
+
1145
+ const conceptRow = (l) => {
1146
+ const c = byName.get(l.slug);
1147
+ return `<tr class="row" data-go="#/concept/${encodeURIComponent(l.slug)}">
1148
+ <td style="min-width:190px"><span class="title">${esc(c?.name ?? l.slug)}</span>
1149
+ <div class="slug">${esc(l.slug)} · ${esc(l.domain)}</div></td>
1150
+ <td class="ctx" style="min-width:280px">${esc(l.context ?? '')}</td>
1151
+ <td>${c ? statePill(c) : ''}</td></tr>`;
1152
+ };
1153
+
1154
+ const qa = s.asked.slice().reverse().map((a) => {
1155
+ let opts = [];
1156
+ try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
1157
+ return `<article class="qa">
1158
+ <div class="qa__meta">
1159
+ <a class="link" data-go="#/concept/${encodeURIComponent(a.slug)}">${esc(a.name)}</a>
1160
+ <span>${esc(tierLabel(a.tier))}</span>${a.level ? `<span>${esc(a.level)}</span>` : ''}
1161
+ <span class="sp">${gradeBadge(a)}</span>
1162
+ </div>
1163
+ <p class="qa__q">${esc(a.question)}</p>
1164
+ ${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
1165
+ `<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
1166
+ ${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
1167
+ ${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
1168
+ </article>`;
1169
+ }).join('');
1170
+
1171
+ return [
1172
+ `<div>
1173
+ <a class="page__back" data-go="#/sessions">← Sessions</a>
1174
+ <h1 class="page__title">${esc(base(s.repo))}</h1>
1175
+ <p class="page__sub">${esc(stamp(s.first))} → ${esc(stamp(s.last))} · <code class="slug">${esc(s.id)}</code></p>
1176
+ </div>`,
1177
+ tiles([
1178
+ ['Logged', s.logged.length, 'concepts the work touched'],
1179
+ ['Asked', s.asked.length, s.asked.length ? `${s.passed} right` : 'no questions in this one'],
1180
+ ['Accuracy', s.asked.length ? pct(s.passed, s.asked.length) + '%' : '—', 'in this session',
1181
+ s.asked.length && pct(s.passed, s.asked.length) >= 70 ? 'good' : ''],
1182
+ ['Review pulled in', review.length, 'from earlier work'],
1183
+ ]),
1184
+ card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.',
1185
+ table([{t:'Concept'},{t:'What you did'},{t:'State now'}], work.map(conceptRow))),
1186
+ review.length
1187
+ ? card('Pulled in for review', 'Not from this diff — the tutor widened to prerequisites and neighbours.',
1188
+ table([{t:'Concept'},{t:'Context'},{t:'State now'}], review.map(conceptRow)))
1189
+ : '',
1190
+ card('What you were asked', s.asked.length ? `${plural(s.asked.length, 'question')}, in order.` : '',
1191
+ s.asked.length ? qa : '<p class="empty">No questions were asked in this session.</p>'),
1192
+ ].join('');
414
1193
  }
415
1194
 
416
- function renderConcepts() {
417
- const body = STATE.concepts.filter((c) => inScope(c.repo)).map((c) =>
418
- `<tr><td><strong>${esc(c.name)}</strong><div class="slug">${esc(c.slug)} · ${esc(c.domain)} · T${c.tier}</div></td>`
419
- + `<td style="min-width:110px"><div class="meter${c.score < 0.5 ? ' warn' : ''}"><i style="width:${Math.round(c.score * 100)}%"></i></div>
420
- <div class="slug">${Math.round(c.score * 100)}%</div></td>`
421
- + `<td class="num">${c.attempts}</td>`
422
- + `<td>${c.mastered ? '<span class="pill ok">mastered</span>' : c.due ? '<span class="pill due">due</span>' : '<span class="pill">learning</span>'}</td>`
423
- + `<td class="ctx">${esc(c.context ?? '')}</td></tr>`);
424
- rows($('concepts'), [{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'},{t:'From your code'}], body);
1195
+ function viewProjects() {
1196
+ const list = S.projects.filter((p) => inScope(p.repo));
1197
+ const daily = dailyInScope();
1198
+
1199
+ const cards = list.map((p, i) => {
1200
+ const acc = pct(p.passed, p.answers);
1201
+ const need = p.level_needed, got = p.level_counts;
1202
+ const bar = (label, value, target, ok) => `
1203
+ <div><span class="${ok ? 'mark-ok' : 'mark-no'}">${ok ? '' : ''}</span>
1204
+ <span style="min-width:150px">${esc(label)}</span>
1205
+ <span class="bar">${meter(pct(value, target), ok ? '' : 'mute')}</span>
1206
+ <span class="slug" style="white-space:nowrap">${value} / ${target}</span></div>`;
1207
+
1208
+ const ladder = ['easy', 'medium', 'hard'].map((l) => {
1209
+ const idx = ['easy','medium','hard'].indexOf(l), cur = ['easy','medium','hard'].indexOf(p.level);
1210
+ return `<div class="${idx === cur ? 'on' : idx < cur ? 'done' : ''}">${l}</div>`;
1211
+ }).join('');
1212
+
1213
+ chart('c-proj-' + i, (el) => {
1214
+ const m = new Map();
1215
+ for (const d of S.daily) {
1216
+ if ((d.repo ?? '') !== (p.repo ?? '')) continue;
1217
+ m.set(d.day, { ...d, total: d.passed + d.missed + d.skipped });
1218
+ }
1219
+ chartActivity(el, fillDays(m, 30));
1220
+ });
1221
+
1222
+ return card(base(p.repo), p.repo ?? 'recorded before projects were tracked',
1223
+ `<div class="kv" style="margin-bottom:var(--space-5)">
1224
+ <div><b>Answers</b><span>${p.answers}</span></div>
1225
+ <div><b>Accuracy</b><span>${acc}%</span></div>
1226
+ <div><b>Concepts</b><span>${p.concepts}</span></div>
1227
+ <div><b>Level</b><span>${esc(p.level)}${p.pinned ? ' (pinned)' : ''}</span></div>
1228
+ <div><b>Active since</b><span>${esc(fmtDate(p.first_active))}</span></div>
1229
+ <div><b>Last answer</b><span>${esc(ago(p.last_active))}</span></div>
1230
+ </div>
1231
+ <div class="ladder">${ladder}</div>
1232
+ ${p.pinned
1233
+ ? `<p class="slug" style="margin:var(--space-4) 0 0">Difficulty is pinned in config, so this project does not promote.</p>`
1234
+ : p.next_level
1235
+ ? `<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>
1236
+ <div class="checks">
1237
+ ${bar('Passing answers', got.passed, need.answers, !p.level_unmet.includes('answers'))}
1238
+ ${bar('Accuracy', Math.round(p.level_accuracy * 100), Math.round(need.accuracy * 100), !p.level_unmet.includes('accuracy'))}
1239
+ ${bar('Distinct concepts', got.concepts, need.concepts, !p.level_unmet.includes('concepts'))}
1240
+ </div>`
1241
+ : `<p class="slug" style="margin:var(--space-4) 0 0">Top of the ladder — questions can reach tier 5 here.</p>`}
1242
+ <div class="chart" style="margin-top:var(--space-5)"><svg id="c-proj-${i}" role="img"
1243
+ aria-label="Thirty days of answers in this project"></svg></div>`);
1244
+ });
1245
+
1246
+ return [
1247
+ `<div>
1248
+ <h1 class="page__title">Projects</h1>
1249
+ <p class="page__sub">Difficulty is earned per codebase, not per person: the same developer is a different
1250
+ learner on their fourth Node service and their first Rust one. Everyone starts at easy (tiers 1–2)
1251
+ and climbs on evidence.</p>
1252
+ </div>`,
1253
+ tiles([
1254
+ ['Projects', list.length, 'with recorded answers'],
1255
+ ['Answers', list.reduce((a, p) => a + p.answers, 0), 'across all of them'],
1256
+ ['Best accuracy', list.length ? Math.max(...list.map((p) => pct(p.passed, p.answers))) + '%' : '—', 'of any project'],
1257
+ ['Active days', streaks(daily).days, 'with at least one answer'],
1258
+ ]),
1259
+ cards.join('') || '<p class="empty">No answers recorded yet.</p>',
1260
+ ].join('');
425
1261
  }
426
1262
 
427
- function renderAll() {
428
- const scoped = STATE.concepts.filter((c) => inScope(c.repo));
429
- const answers = STATE.projects.filter((p) => inScope(p.repo)).reduce((a, p) => a + p.answers, 0);
430
- const where = FILTER === '*' ? 'across all projects' : 'in ' + base(FILTER === '~' ? null : FILTER);
431
- const n = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
432
- $('sub').textContent =
433
- `${n(scoped.length, 'concept')} asked · ${n(answers, 'answer')} · ${scoped.filter((c) => c.mastered).length} mastered ${where}`;
434
- renderTiles(); renderChart(); renderProjects(); renderDomains();
435
- renderRecent(); renderSkipped(); renderConcepts();
1263
+ function viewDomains() {
1264
+ const scoped = conceptsInScope();
1265
+ const byDomain = new Map();
1266
+ for (const c of scoped) {
1267
+ const d = byDomain.get(c.domain) ?? { domain:c.domain, catalogue:0, touched:0, mastered:0, due:0, attempts:0 };
1268
+ d.catalogue += 1;
1269
+ if (c.seen) { d.touched += 1; d.attempts += c.attempts; if (c.mastered) d.mastered += 1; if (c.due) d.due += 1; }
1270
+ byDomain.set(c.domain, d);
1271
+ }
1272
+ const list = [...byDomain.values()].sort((a, b) => b.touched - a.touched || b.catalogue - a.catalogue);
1273
+
1274
+ const rows = list.map((d) => `
1275
+ <tr class="row" data-go="#/domain/${encodeURIComponent(d.domain)}">
1276
+ <td><span class="title">${esc(d.domain)}</span>
1277
+ <div class="slug">${plural(d.catalogue, 'concept')} in the graph</div></td>
1278
+ <td style="min-width:130px">${d.touched
1279
+ ? meter(pct(d.mastered, d.touched)) + `<div class="slug">${d.mastered} mastered of ${d.touched} asked</div>`
1280
+ : '<span class="slug">your work has not reached it</span>'}</td>
1281
+ <td class="num">${d.attempts}</td>
1282
+ <td class="num">${d.due || '—'}</td>
1283
+ <td class="num slug">${d.catalogue - d.touched} not asked</td>
1284
+ </tr>`);
1285
+
1286
+ const tierCount = [1,2,3,4,5].map((t) => scoped.filter((c) => c.seen && c.tier === t).length);
1287
+ // A sequential ramp mixed from the accent role rather than picked off the
1288
+ // accent scale: a fixed --vd-200 is legible on ink and invisible on paper.
1289
+ const COLORS = [100, 78, 58, 40, 24].map((p) => `color-mix(in srgb, var(--spot) ${p}%, var(--mass))`);
1290
+
1291
+ return [
1292
+ `<div>
1293
+ <h1 class="page__title">Domains</h1>
1294
+ <p class="page__sub">What you are learning, grouped. The catalogue is a library, not a to-do list —
1295
+ a domain with untouched concepts is a domain your work has not reached yet.</p>
1296
+ </div>`,
1297
+ card('Coverage', 'Open a domain to see its concepts.',
1298
+ table([{t:'Domain'},{t:'Mastered'},{t:'Answers',n:1},{t:'Due',n:1},{t:'Catalogue',n:1}], rows)),
1299
+ card('Tier spread of what you have been asked', 'Tier is how hard one question is: recall, mechanism, judgement, failure modes, design.',
1300
+ segbar([1,2,3,4,5].map((t, i) => ({ label: tierLabel(t), n: tierCount[i], color: COLORS[i] })))),
1301
+ ].join('');
1302
+ }
1303
+
1304
+ function viewDomain(name) {
1305
+ const all = conceptsInScope().filter((c) => c.domain === name);
1306
+ if (!all.length) return `<p class="empty">No concepts in <code>${esc(name)}</code> for this scope.
1307
+ <a class="link" data-go="#/domains">Back to domains</a>.</p>`;
1308
+
1309
+ const seen = all.filter((c) => c.seen);
1310
+ const list = [...all].sort((a, b) => (a.seen === b.seen ? a.score - b.score : a.seen ? -1 : 1));
1311
+ const p = page('domain', list.length);
1312
+ const rows = slice(list, p).map((c) => `
1313
+ <tr class="row" data-go="#/concept/${encodeURIComponent(c.slug)}">
1314
+ <td style="min-width:220px"><span class="title">${esc(c.name)}</span><div class="slug">${esc(c.slug)}</div></td>
1315
+ <td class="slug" style="white-space:nowrap">${esc(tierLabel(c.tier))}</td>
1316
+ <td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '')
1317
+ + `<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
1318
+ <td class="num">${c.attempts || '—'}</td>
1319
+ <td>${statePill(c)}</td>
1320
+ <td class="ctx" style="min-width:200px">${esc(c.context ?? '')}</td>
1321
+ </tr>`);
1322
+
1323
+ return [
1324
+ `<div>
1325
+ <a class="page__back" data-go="#/domains">← Domains</a>
1326
+ <h1 class="page__title">${esc(name)}</h1>
1327
+ <p class="page__sub">${plural(all.length, 'concept')} in the graph · ${seen.length} asked ·
1328
+ ${seen.filter((c) => c.mastered).length} mastered.</p>
1329
+ </div>`,
1330
+ card(null, null, segbar([
1331
+ { label:'Mastered', n:seen.filter((c) => c.mastered).length, color:'var(--spot)' },
1332
+ { label:'Learning', n:seen.filter((c) => !c.mastered).length, color:'var(--warning)' },
1333
+ { label:'Not asked', n:all.length - seen.length, color:'var(--mass)' },
1334
+ ])),
1335
+ card(null, null, table([{t:'Concept'},{t:'Tier'},{t:'Score'},{t:'Asked',n:1},{t:'State'},{t:'From your code'}], rows)
1336
+ + pager('domain', list.length, p)),
1337
+ ].join('');
436
1338
  }
437
1339
 
1340
+ /* ============================================================
1341
+ Router
1342
+ ============================================================ */
1343
+ const NAV = [
1344
+ ['overview', 'Overview', '<path d="M3 12l9-9 9 9"/><path d="M5 10v10h14V10"/>'],
1345
+ ['concepts', 'Concepts', '<circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/>'],
1346
+ ['review', 'Review', '<path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/>'],
1347
+ ['sessions', 'Sessions', '<rect x="3" y="4" width="18" height="16"/><path d="M3 9h18M8 4v16"/>'],
1348
+ ['projects', 'Projects', '<path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 12l9 4 9-4M3 17l9 4 9-4"/>'],
1349
+ ['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"/>'],
1350
+ ];
1351
+
1352
+ const VIEWS = {
1353
+ overview: viewOverview,
1354
+ concepts: viewConcepts,
1355
+ concept: viewConcept,
1356
+ review: viewReview,
1357
+ sessions: viewSessions,
1358
+ session: viewSession,
1359
+ projects: viewProjects,
1360
+ domains: viewDomains,
1361
+ domain: viewDomain,
1362
+ };
1363
+
1364
+ function route() {
1365
+ const raw = location.hash.replace(/^#\/?/, '');
1366
+ const [name, ...rest] = raw.split('/');
1367
+ return { name: VIEWS[name] ? name : 'overview', param: decodeURIComponent(rest.join('/')) };
1368
+ }
1369
+
1370
+ function renderNav(active) {
1371
+ const seen = seenConcepts();
1372
+ const counts = {
1373
+ concepts: conceptsInScope().length,
1374
+ review: seen.filter((c) => c.due).length,
1375
+ sessions: sessions().length,
1376
+ projects: S.projects.filter((p) => inScope(p.repo)).length,
1377
+ domains: new Set(conceptsInScope().map((c) => c.domain)).size,
1378
+ };
1379
+ $('nav').innerHTML = NAV.map(([key, label, icon]) => `
1380
+ <a href="#/${key}"${key === active ? ' aria-current="page"' : ''}>
1381
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
1382
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg>
1383
+ ${label}${counts[key] !== undefined ? `<i class="${key === 'review' && counts[key] ? 'hot' : ''}">${counts[key]}</i>` : ''}
1384
+ </a>`).join('');
1385
+ }
1386
+
1387
+ function render(keepFocus) {
1388
+ const { name, param } = route();
1389
+ const root = name === 'concept' || name === 'session' || name === 'domain'
1390
+ ? { concept:'concepts', session:'sessions', domain:'domains' }[name] : name;
1391
+ renderNav(root);
1392
+
1393
+ AFTER.length = 0;
1394
+ $('view').innerHTML = VIEWS[name](param);
1395
+ // A clickable row has to be reachable by Tab as well as by mouse.
1396
+ $('view').querySelectorAll('tr.row').forEach((r) => { r.tabIndex = 0; });
1397
+ for (const fn of AFTER) fn();
1398
+
1399
+ if (keepFocus) {
1400
+ const el = $(keepFocus.id);
1401
+ if (el) { el.focus(); if (el.setSelectionRange && keepFocus.pos != null) el.setSelectionRange(keepFocus.pos, keepFocus.pos); }
1402
+ } else {
1403
+ window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
1404
+ }
1405
+ }
1406
+
1407
+ /* ---------- events, all delegated ---------- */
1408
+ /** Navigates, re-rendering in place when the hash is already what we want. */
1409
+ function goTo(hash) {
1410
+ if (location.hash === hash) render();
1411
+ else location.hash = hash;
1412
+ }
1413
+
1414
+ function onActivate(e) {
1415
+ const pg = e.target.closest('[data-page]');
1416
+ if (pg && !pg.disabled) { T[pg.dataset.pager].page = +pg.dataset.page; render(); return; }
1417
+ // The filter and the tab live in the hash, so a filtered list is a link the
1418
+ // reader can bookmark, share and come back to.
1419
+ const chipState = e.target.closest('[data-state]');
1420
+ if (chipState) {
1421
+ T.concepts.page = 1;
1422
+ return goTo(chipState.dataset.state === 'all' ? '#/concepts' : '#/concepts/' + chipState.dataset.state);
1423
+ }
1424
+ const tab = e.target.closest('[data-tab]');
1425
+ if (tab) { T.review.page = 1; return goTo(tab.dataset.tab === 'due' ? '#/review' : '#/review/' + tab.dataset.tab); }
1426
+ const go = e.target.closest('[data-go]');
1427
+ if (go) return goTo(go.dataset.go);
1428
+ }
1429
+
1430
+ $('view').addEventListener('click', onActivate);
1431
+ // Anything clickable is operable from the keyboard: rows, tiles and the pills
1432
+ // are divs and table rows, so Enter and Space have to be wired by hand.
1433
+ $('view').addEventListener('keydown', (e) => {
1434
+ if (e.key !== 'Enter' && e.key !== ' ') return;
1435
+ if (!e.target.closest('[data-go],[data-state],[data-tab],[data-page]')) return;
1436
+ e.preventDefault();
1437
+ onActivate(e);
1438
+ });
1439
+
1440
+ $('view').addEventListener('input', (e) => {
1441
+ if (e.target.id !== 'q') return;
1442
+ T.concepts.q = e.target.value;
1443
+ T.concepts.page = 1;
1444
+ render({ id:'q', pos:e.target.selectionStart });
1445
+ });
1446
+
1447
+ $('view').addEventListener('change', (e) => {
1448
+ if (e.target.id === 'dom') { T.concepts.domain = e.target.value; T.concepts.page = 1; render(); }
1449
+ if (e.target.id === 'sort') { T.concepts.sort = e.target.value; T.concepts.page = 1; render(); }
1450
+ });
1451
+
1452
+ addEventListener('hashchange', () => render());
1453
+
1454
+ let resizeTimer = 0;
1455
+ addEventListener('resize', () => {
1456
+ clearTimeout(resizeTimer);
1457
+ resizeTimer = setTimeout(() => render({ id:'q', pos:null }), 120);
1458
+ });
1459
+
1460
+ document.querySelectorAll('[data-ground]').forEach((b) => {
1461
+ b.addEventListener('click', () => { window.eklavyaGround.set(b.dataset.ground); render(); });
1462
+ });
1463
+
1464
+ /* ---------- boot ---------- */
1465
+ // The head script runs before the toggle exists, so its aria-pressed state has
1466
+ // to be applied again once the markup is there — otherwise a reader on paper is
1467
+ // shown "ink" as the active ground.
1468
+ window.eklavyaGround.apply(window.eklavyaGround.read());
1469
+
438
1470
  fetch('/api/state').then((r) => r.json()).then((s) => {
439
- STATE = s;
1471
+ S = s;
1472
+ $('dbline').textContent = s.db_path;
1473
+ // Totals are aggregated in SQL over the whole table, so a cap can only
1474
+ // shorten a history list — but say so rather than let it look complete.
1475
+ if (s.attempts_shown < s.attempts_total) {
1476
+ $('foot-note').textContent =
1477
+ ` Question histories show the most recent ${s.attempts_shown} of ${s.attempts_total} answers.`;
1478
+ }
1479
+ const c = s.config;
1480
+ $('dials').innerHTML = `<span class="mono">${esc(c.mode)} · ${esc(c.focus)} · ${esc(c.cadence)} · ${esc(c.difficulty)}</span>`;
1481
+
440
1482
  const sel = $('proj');
441
1483
  for (const p of s.projects) {
442
1484
  const o = document.createElement('option');
@@ -444,15 +1486,24 @@ fetch('/api/state').then((r) => r.json()).then((s) => {
444
1486
  o.textContent = base(p.repo);
445
1487
  sel.appendChild(o);
446
1488
  }
447
- sel.addEventListener('change', () => { FILTER = sel.value; renderAll(); });
448
- renderAll();
449
- addEventListener('resize', renderChart);
450
- }).catch((e) => { $('sub').textContent = 'Could not read the database: ' + e.message; });
451
- </script>
452
- <script>
453
- document.querySelectorAll('[data-ground]').forEach(function (b) {
454
- b.addEventListener('click', function () { window.eklavyaGround.set(b.dataset.ground); });
1489
+ try {
1490
+ const saved = localStorage.getItem('eklavya-dash-project');
1491
+ if (saved && [...sel.options].some((o) => o.value === saved)) FILTER = saved;
1492
+ } catch (e) {}
1493
+ sel.value = FILTER;
1494
+ sel.addEventListener('change', () => {
1495
+ FILTER = sel.value;
1496
+ try { localStorage.setItem('eklavya-dash-project', FILTER); } catch (e) {}
1497
+ for (const k of Object.keys(T)) T[k].page = 1;
1498
+ recomputeScope();
1499
+ render();
455
1500
  });
1501
+
1502
+ recomputeScope();
1503
+ render();
1504
+ }).catch((e) => {
1505
+ $('view').innerHTML = `<p class="empty">Could not read the database: ${esc(e.message)}</p>`;
1506
+ });
456
1507
  </script>
457
1508
  </body>
458
1509
  </html>