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.
- package/dist/ask.js +14 -9
- package/dist/ask.js.map +1 -1
- package/dist/assets/dashboard.html +1351 -300
- package/dist/assets/tokens.css +14 -6
- package/dist/assets/tutor-skill.md +5 -3
- package/dist/dashboard.js +196 -65
- package/dist/dashboard.js.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/skills/progress/SKILL.md +2 -1
- package/dist/plugin/skills/tutor/SKILL.md +5 -3
- package/dist/user-skill/eklavya/SKILL.md +12 -0
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>Eklavya — learning
|
|
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
|
-
.
|
|
48
|
-
|
|
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
|
-
/* ----------
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
.
|
|
66
|
-
.tile { background:var(--panel); border:1px solid var(--line-2);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
.tile
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
96
|
-
.meter { height:
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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:
|
|
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%;
|
|
107
|
-
.gridline { stroke:var(--line); stroke-width:1; }
|
|
108
|
-
.axis { font-family:var(--font-mono); font-size:10px; fill:var(--faint
|
|
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:.
|
|
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(--
|
|
113
|
-
padding:var(--space-3); font-size:var(--text-xs); line-height:1.6; white-space:nowrap;
|
|
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
|
-
|
|
118
|
-
.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
.
|
|
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
|
-
.
|
|
130
|
-
.
|
|
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
|
-
.
|
|
134
|
-
.
|
|
135
|
-
.
|
|
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) {
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
<
|
|
153
|
-
<
|
|
154
|
-
<
|
|
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="
|
|
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
|
-
<
|
|
163
|
-
<
|
|
331
|
+
<b>Eklavya</b>
|
|
332
|
+
<span>learning dashboard</span>
|
|
164
333
|
</div>
|
|
165
|
-
|
|
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
|
-
|
|
171
|
-
<select id="proj"><option value="*">All projects</option></select>
|
|
350
|
+
</div>
|
|
172
351
|
</div>
|
|
173
|
-
|
|
174
|
-
</
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
<
|
|
178
|
-
|
|
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
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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) => ({'&':'&','<':'<','>':'>','"':'"'}[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
|
|
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 =
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
const
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 =
|
|
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
|
-
|
|
458
|
+
cur.total = cur.passed + cur.missed + cur.skipped;
|
|
459
|
+
m.set(d.day, cur);
|
|
274
460
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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(
|
|
300
|
-
const y = (v) => (H - PAD_B) - (v / max) * (H - PAD_B -
|
|
301
|
-
const
|
|
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
|
-
|
|
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 = '',
|
|
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 -
|
|
319
|
-
//
|
|
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
|
-
|
|
327
|
-
|
|
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}">${
|
|
641
|
+
out += `<text class="axis" x="${x + bw / 2}" text-anchor="middle" y="${H - 6}">${fmtDay(d.day)}</text>`;
|
|
336
642
|
}
|
|
337
643
|
});
|
|
338
|
-
|
|
339
|
-
|
|
644
|
+
el.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
645
|
+
el.style.height = H + 'px';
|
|
646
|
+
el.innerHTML = out;
|
|
340
647
|
|
|
341
|
-
|
|
342
|
-
|
|
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(
|
|
652
|
+
tip.innerHTML = `<div class="mono">${esc(fmtDay(d.day))}</div>`
|
|
346
653
|
+ [['Right', d.passed], ['Missed', d.missed], ['Skipped', d.skipped]]
|
|
347
|
-
|
|
348
|
-
const r = hit.getBoundingClientRect(), p =
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
.
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
|
428
|
-
const scoped =
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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>
|