eklavya 1.66.1 → 1.67.1
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/README.md +7 -2
- package/dist/artifacts.js +2 -2
- package/dist/assets/dashboard.html +1472 -366
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/dashboard-daemon.js +25 -4
- package/dist/dashboard-daemon.js.map +1 -1
- package/dist/dashboard.js +1152 -223
- package/dist/dashboard.js.map +1 -1
- package/dist/install.js +10 -7
- package/dist/install.js.map +1 -1
- package/dist/memory/import.js +23 -2
- package/dist/memory/import.js.map +1 -1
- package/dist/migrations/030_events_rollup_index.sql +26 -0
- package/dist/migrations/031_session_concept_order.sql +33 -0
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
|
@@ -163,6 +163,18 @@
|
|
|
163
163
|
animation:loader-in var(--duration-base) var(--ease) 150ms both; }
|
|
164
164
|
.loader--page { justify-content:center; flex-direction:column; min-height:40vh; gap:var(--space-5); }
|
|
165
165
|
@keyframes loader-in { from { opacity:0; } to { opacity:1; } }
|
|
166
|
+
/* A fetched region (`slot()`) is in one of three states and none of them moves the page.
|
|
167
|
+
Drawn: its content. Waiting with nothing to show: a box that holds the height the content
|
|
168
|
+
is expected to take, the loader at its top (its own 150ms delay applies). Waiting while it
|
|
169
|
+
still shows an earlier answer (a pager, a filter, a refetch, and every live update): that
|
|
170
|
+
content stays, out of reach of the pointer at once, and dims only if the answer has not come
|
|
171
|
+
within 150ms, the loader's own delay. The page is refreshed whenever something changes, and
|
|
172
|
+
an answer that comes from the server's memo in a few milliseconds would otherwise make every
|
|
173
|
+
refresh flash. The dim is a step, not a fade (this work adds no animation): a transition of no duration that
|
|
174
|
+
starts after the same delay, so an answer that lands first cancels it before anything shows. */
|
|
175
|
+
.slot__wait { display:block; }
|
|
176
|
+
[data-slot][aria-busy="true"][data-kept] { opacity:.55; pointer-events:none;
|
|
177
|
+
transition:opacity 0s linear 150ms; }
|
|
166
178
|
|
|
167
179
|
/* ---------- the sidebar navigation ---------- */
|
|
168
180
|
nav.side__nav { display:flex; flex-direction:column; gap:var(--space-4); }
|
|
@@ -233,7 +245,7 @@
|
|
|
233
245
|
/* ---------- the column ---------- */
|
|
234
246
|
main { padding:var(--space-8) var(--space-10) var(--space-20); min-width:0; }
|
|
235
247
|
.inner { max-width:1080px; }
|
|
236
|
-
/* Centred on a wide window; `main >` outranks the
|
|
248
|
+
/* Centred on a wide window; `main >` outranks the margin `.page__eyebrow` sets. */
|
|
237
249
|
main > .inner { margin-inline:auto; }
|
|
238
250
|
.stack > * + * { margin-top:var(--space-6); }
|
|
239
251
|
|
|
@@ -265,7 +277,9 @@
|
|
|
265
277
|
.art__open { display:flex; flex-direction:column; gap:var(--space-3); color:var(--ink); text-decoration:none; }
|
|
266
278
|
.art__open:focus-visible { outline:2px solid var(--spot); outline-offset:2px; }
|
|
267
279
|
.art__open:hover .title { color:var(--spot); }
|
|
268
|
-
|
|
280
|
+
/* The box is the 16:10 the aspect ratio says, whether or not its image has arrived: without `min-height:0`
|
|
281
|
+
the picture's own height pushes the card taller when it lands (10px at 1280), and the page under it moves. */
|
|
282
|
+
.art__thumb { display:block; aspect-ratio:16/10; min-height:0; background:var(--panel); border:1px solid var(--line-2); padding:var(--space-3); }
|
|
269
283
|
.art__open:hover .art__thumb { border-color:var(--spot); }
|
|
270
284
|
.art__thumb img { display:block; width:100%; height:100%; object-fit:contain; }
|
|
271
285
|
.tag { font-family:var(--font-mono); font-size:11px; letter-spacing:var(--tracking-caps); text-transform:uppercase; }
|
|
@@ -275,9 +289,14 @@
|
|
|
275
289
|
/* The explainer viewer: the agent's page, framed sandboxed, then this page's
|
|
276
290
|
own correction bar. The server adds a script to every framed page that
|
|
277
291
|
reports its height; the frame takes it, so the window is the one scrollbar.
|
|
278
|
-
Until the first report the frame
|
|
279
|
-
|
|
280
|
-
|
|
292
|
+
Until the first report the frame holds the height this tab last reported (else
|
|
293
|
+
80vh) with the loader over its top, so opening a page reflows once, when the
|
|
294
|
+
real height arrives; with no report in 1.5s (script blocked) it falls back to
|
|
295
|
+
80vh and scrolls inside. `--wait-h` is that reserved height: the frame's own
|
|
296
|
+
`height` is set only by a report, which is how the page tells the two apart. */
|
|
297
|
+
.viewer { display:block; width:100%; height:var(--wait-h, 0); border:0; background:var(--bg); }
|
|
298
|
+
.viewer__box { position:relative; }
|
|
299
|
+
.viewer__box > #art-wait { position:absolute; top:0; left:0; right:0; z-index:1; pointer-events:none; }
|
|
281
300
|
/* Artifact tabs: the gallery, then up to TAB_MAX open pages. Pinned to the
|
|
282
301
|
top of the window (under the top bar on a phone) so switching never needs
|
|
283
302
|
a scroll up. One row; it scrolls sideways rather than wrapping. */
|
|
@@ -404,21 +423,13 @@
|
|
|
404
423
|
.ctx { color:var(--dim); }
|
|
405
424
|
.empty { color:var(--faint); padding:var(--space-5) 0; font-size:13.5px; }
|
|
406
425
|
|
|
407
|
-
/* A bar above the view, not a banner over it: the reader is mid-page and the
|
|
408
|
-
notice must not move what they are reading. */
|
|
409
|
-
.stale { display:block; width:100%; text-align:left; margin-bottom:var(--space-6);
|
|
410
|
-
font-family:var(--font-mono); font-size:11.5px; letter-spacing:.04em;
|
|
411
|
-
text-transform:uppercase; background:none; border:1px solid var(--spot);
|
|
412
|
-
color:var(--spot); padding:7px 10px; cursor:pointer;
|
|
413
|
-
transition:background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); }
|
|
414
|
-
.stale:hover { background:var(--spot); color:var(--spot-ink); }
|
|
415
|
-
.stale[hidden] { display:none; }
|
|
416
|
-
|
|
417
426
|
/* ---------- controls ---------- */
|
|
418
427
|
.filters { display:flex; gap:var(--space-3); flex-wrap:wrap; align-items:center;
|
|
419
428
|
margin-bottom:var(--space-4); }
|
|
420
429
|
.filters .grow { flex:1 1 220px; min-width:180px; }
|
|
421
430
|
.filters select { width:auto; }
|
|
431
|
+
/* The controls beside a search box are a region of their own (a redraw replaces them, never the box the reader is typing in); it adds no box. */
|
|
432
|
+
.filters > [data-region] { display:contents; }
|
|
422
433
|
.chips { display:flex; gap:var(--space-2); flex-wrap:wrap; }
|
|
423
434
|
.chip { font-family:var(--font-mono); font-size:11.5px; letter-spacing:.04em; text-transform:uppercase;
|
|
424
435
|
background:none; border:1px solid var(--line-2); color:var(--faint); padding:5px 10px;
|
|
@@ -829,6 +840,8 @@
|
|
|
829
840
|
.sw__scope[aria-current="page"] svg { color:var(--spot); }
|
|
830
841
|
.sw__none { margin:var(--space-1) var(--space-2) var(--space-2); color:var(--faint); font-size:12.5px; }
|
|
831
842
|
.sw__pane { min-width:0; display:flex; flex-direction:column; gap:var(--space-5); }
|
|
843
|
+
/* The pane's three regions add no box: their contents are the pane's flex items, as they were before there were regions. */
|
|
844
|
+
.sw__pane > [data-region] { display:contents; }
|
|
832
845
|
.sw__tabs { display:flex; gap:0; border-bottom:1px solid var(--line-2); overflow-x:auto; scrollbar-width:none; }
|
|
833
846
|
.sw__tabs::-webkit-scrollbar { display:none; }
|
|
834
847
|
.sw__tab { display:flex; align-items:center; gap:6px; padding:10px 9px; margin-bottom:-1px; white-space:nowrap;
|
|
@@ -857,7 +870,8 @@
|
|
|
857
870
|
.sw__scopes::-webkit-scrollbar { display:none; }
|
|
858
871
|
.sw__cap { display:none; }
|
|
859
872
|
.sw__projects { flex-direction:row; max-height:none; overflow:visible; }
|
|
860
|
-
|
|
873
|
+
/* A row that scrolls sideways lets each link keep its width: a shrinking one clipped its label to nothing. */
|
|
874
|
+
.sw__scope { white-space:nowrap; flex:none; }
|
|
861
875
|
.sw__none { margin:0; white-space:nowrap; }
|
|
862
876
|
}
|
|
863
877
|
@media (max-width:560px) {
|
|
@@ -894,6 +908,8 @@
|
|
|
894
908
|
@media (prefers-reduced-motion:reduce) {
|
|
895
909
|
html { scroll-behavior:auto; }
|
|
896
910
|
* { transition:none !important; animation:none !important; }
|
|
911
|
+
/* The dim of a region waiting for its next answer is a step already; the rule above would take its delay too. */
|
|
912
|
+
[data-slot][aria-busy="true"][data-kept] { transition:opacity 0s linear 150ms !important; }
|
|
897
913
|
}
|
|
898
914
|
</style>
|
|
899
915
|
</head>
|
|
@@ -968,9 +984,6 @@
|
|
|
968
984
|
</aside>
|
|
969
985
|
|
|
970
986
|
<main id="main">
|
|
971
|
-
<button type="button" class="stale inner" id="stale" hidden aria-live="polite">
|
|
972
|
-
New activity since this page loaded — refresh
|
|
973
|
-
</button>
|
|
974
987
|
<p class="page__eyebrow crumb inner" id="crumb" aria-label="Workflow and scope"></p>
|
|
975
988
|
<div class="inner stack" id="view" aria-busy="true">
|
|
976
989
|
<!-- The same markup `loader()` writes; the script has not run yet. -->
|
|
@@ -1023,7 +1036,13 @@ const fromKey = (k) => new Date(k + 'T00:00:00Z');
|
|
|
1023
1036
|
const localKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
1024
1037
|
const todayKey = () => localKey(new Date());
|
|
1025
1038
|
|
|
1026
|
-
|
|
1039
|
+
/* One formatter per shape, made once. `toLocaleDateString(undefined, options)` builds a new Intl formatter on
|
|
1040
|
+
every call, and the heatmap's 371 labels alone were about 20 ms of drawing the Learning dashboard; the same
|
|
1041
|
+
options give the same text from `format()`. UTC, because a day key is a calendar date, not an instant. */
|
|
1042
|
+
const FMT_DAY = new Intl.DateTimeFormat(undefined, { month:'short', day:'numeric', timeZone:'UTC' });
|
|
1043
|
+
const FMT_DAY_YEAR = new Intl.DateTimeFormat(undefined, { month:'short', day:'numeric', year:'numeric', timeZone:'UTC' });
|
|
1044
|
+
const FMT_MONTH = new Intl.DateTimeFormat(undefined, { month:'short', timeZone:'UTC' });
|
|
1045
|
+
const fmtDay = (k) => FMT_DAY.format(fromKey(k));
|
|
1027
1046
|
const fmtDate = (ts) => {
|
|
1028
1047
|
const d = parse(ts); if (!d) return '—';
|
|
1029
1048
|
const sameYear = d.getFullYear() === new Date().getFullYear();
|
|
@@ -1061,6 +1080,21 @@ let SCOPE = null; // Set of slugs touched in scope, or null for all
|
|
|
1061
1080
|
const AFTER = []; // render hooks that need a laid-out DOM (charts)
|
|
1062
1081
|
/** The screen being shown, as the URL says it: `{ wf, page, param, query }`. */
|
|
1063
1082
|
let CUR = { wf:'learning', page:'dashboard', param:'', query:new URLSearchParams() };
|
|
1083
|
+
/**
|
|
1084
|
+
* What the view was last drawn from, so the next draw can tell a navigation from
|
|
1085
|
+
* an adjustment and, for an adjustment, redraw only what changed. `null` until the
|
|
1086
|
+
* first draw. See `render()`.
|
|
1087
|
+
*/
|
|
1088
|
+
let DRAWN = null;
|
|
1089
|
+
/**
|
|
1090
|
+
* What the draw in progress is, for the few views and hooks whose work depends on it (both are set by `render()`
|
|
1091
|
+
* and `liveAdopt()` and are only ever true for the length of one synchronous draw). `ADJUSTING`: the screen on show is
|
|
1092
|
+
* the one being drawn again (see `render`), so what belongs to the screen (the viewer's frame, a remembered scroll) is
|
|
1093
|
+
* still there and is left alone. `LIVE_PASS`: the draw is the page's redraw for new data (`liveAdopt`), so it also
|
|
1094
|
+
* keeps what the reader is in the middle of (a control being edited, the heatmap's page) and not only what they had open.
|
|
1095
|
+
*/
|
|
1096
|
+
let ADJUSTING = false;
|
|
1097
|
+
let LIVE_PASS = false;
|
|
1064
1098
|
|
|
1065
1099
|
/* ---------- per-view control state, kept across re-renders ---------- */
|
|
1066
1100
|
const T = {
|
|
@@ -1098,31 +1132,89 @@ const inScope = (raw, sid) => PROJECT === null || pid(raw, sid) === PROJECT;
|
|
|
1098
1132
|
const projectById = (id) => INV.projects.find((p) => p.id === id) ?? null;
|
|
1099
1133
|
const pname = (raw, sid) => { const id = pid(raw, sid); return projectById(id)?.name ?? base(id); };
|
|
1100
1134
|
|
|
1135
|
+
/**
|
|
1136
|
+
* The one place the page's data is swapped: boot, and the arrival of new data (`liveAdopt`, when the server's
|
|
1137
|
+
* stream says the cursor moved). It sets `S` and `INV`, recomputes the scope (which drops every derived list, below),
|
|
1138
|
+
* and drops what was read under the old data (`invalidateData`: the settings read and the response cache). Nothing
|
|
1139
|
+
* else assigns `S` or `INV` after boot, which is what lets the derived lists be kept. What boot also writes from them
|
|
1140
|
+
* outside the view (`paintChrome`) and redrawing the screen are the caller's.
|
|
1141
|
+
*/
|
|
1142
|
+
function adoptState(s, inv) {
|
|
1143
|
+
S = s;
|
|
1144
|
+
INV = inv;
|
|
1145
|
+
recomputeScope();
|
|
1146
|
+
invalidateData();
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Called wherever `S`, `INV` or `PROJECT` has just been assigned: `adoptState`, and a change of project.
|
|
1151
|
+
*
|
|
1152
|
+
* A project's concepts are the inventory's own list for it (`learning.concept_slugs`: every concept an
|
|
1153
|
+
* answer or a logged line of any origin names for the project, read by the server from every row and
|
|
1154
|
+
* attributed as `pid()` does). They are not read from `S.attempts` and `S.logged`, which hold only the
|
|
1155
|
+
* newest rows (`attempts_shown`, `logged_shown`): a concept whose only rows are old would drop out of
|
|
1156
|
+
* its project, and Concepts, Review, Domains and the sidebar's counts would all lose it.
|
|
1157
|
+
*/
|
|
1101
1158
|
function recomputeScope() {
|
|
1102
|
-
|
|
1103
|
-
SCOPE = new Set();
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1159
|
+
dropDerived();
|
|
1160
|
+
SCOPE = PROJECT === null ? null : new Set(projectById(PROJECT)?.learning.concept_slugs ?? []);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Whether the payload holds fewer rows than there are. `attempts` and `logged` are cut at the newest
|
|
1165
|
+
* `ATTEMPT_LIMIT` and `LOGGED_LIMIT` rows (the server says how many it sent and how many there are), and
|
|
1166
|
+
* the footer says so (`paintChrome`). Everything the page derives from a *list* of rows is then a reading of
|
|
1167
|
+
* the newest ones: the sessions, a concept's question history, the context lines, the skipped answers.
|
|
1168
|
+
* Totals, the per-day rows, the catalogue and the inventory are aggregated over every row and are not.
|
|
1169
|
+
*/
|
|
1170
|
+
const cutAnswers = () => S.attempts_shown < S.attempts_total;
|
|
1171
|
+
const cutLines = () => S.logged_shown < S.logged_total;
|
|
1172
|
+
const rowsCut = () => cutAnswers() || cutLines();
|
|
1173
|
+
|
|
1174
|
+
/* ---------- derived lists, kept ----------
|
|
1175
|
+
What the views read of the payload (the concepts in scope, the sessions, the daily buckets, the streak, the
|
|
1176
|
+
heatmap's days, the sidebar's counts) is a function of `S`, `INV`, `PROJECT` and the local day, which the streak
|
|
1177
|
+
and the heatmap are read against, and of nothing else. So each is built once and kept: a navigation, a pager, a
|
|
1178
|
+
keystroke and a resize read it again instead of rebuilding it from every row.
|
|
1179
|
+
|
|
1180
|
+
They are dropped in one place, `dropDerived()`, from `recomputeScope()`, which runs whenever `S`/`INV` are swapped
|
|
1181
|
+
(`adoptState`) and whenever the project changes: the only places any of the three is assigned. The day is
|
|
1182
|
+
checked on every read, so a page left open past midnight reads the new day's streak, not yesterday's.
|
|
1183
|
+
|
|
1184
|
+
The lists are frozen. A caller that sorted or pushed onto one would change what every other view reads, so it
|
|
1185
|
+
throws instead: copy first (`[...list].sort(...)`). The rows inside them are the payload's own and are never
|
|
1186
|
+
written to. The one Map (`dailyInScope`) and the sets in a streak are read-only by the same rule. */
|
|
1187
|
+
let DERIVED = new Map();
|
|
1188
|
+
let DERIVED_DAY = '';
|
|
1189
|
+
function derived(name, build) {
|
|
1190
|
+
const day = todayKey();
|
|
1191
|
+
if (day !== DERIVED_DAY) { DERIVED = new Map(); DERIVED_DAY = day; }
|
|
1192
|
+
if (!DERIVED.has(name)) DERIVED.set(name, build());
|
|
1193
|
+
return DERIVED.get(name);
|
|
1194
|
+
}
|
|
1195
|
+
const dropDerived = () => { DERIVED = new Map(); };
|
|
1196
|
+
|
|
1197
|
+
const conceptsInScope = () => derived('concepts', () => Object.freeze(SCOPE ? S.concepts.filter((c) => SCOPE.has(c.slug)) : [...S.concepts]));
|
|
1198
|
+
const seenConcepts = () => derived('seen', () => Object.freeze(conceptsInScope().filter((c) => c.seen)));
|
|
1199
|
+
const attemptsInScope = () => derived('attempts', () => Object.freeze(S.attempts.filter((a) => inScope(a.repo, a.session_id))));
|
|
1200
|
+
const loggedInScope = () => derived('logged', () => Object.freeze(S.logged.filter((l) => inScope(l.repo, l.session_id))));
|
|
1111
1201
|
const scopeName = () => (PROJECT === null ? 'all projects' : projectById(PROJECT)?.name ?? PROJECT);
|
|
1112
1202
|
/** Inventory rows in scope — one, or all of them. */
|
|
1113
1203
|
const projectsInScope = () => INV.projects.filter((p) => PROJECT === null || p.id === PROJECT);
|
|
1114
1204
|
|
|
1115
|
-
/** One row per calendar day in scope: { day, passed, corrected, missed, skipped, total }. */
|
|
1205
|
+
/** One row per calendar day in scope: { day, passed, corrected, missed, skipped, total }. Read-only. */
|
|
1116
1206
|
function dailyInScope() {
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1207
|
+
return derived('daily', () => {
|
|
1208
|
+
const m = new Map();
|
|
1209
|
+
for (const d of S.daily) {
|
|
1210
|
+
if (!inScope(d.repo)) continue;
|
|
1211
|
+
const cur = m.get(d.day) ?? { day:d.day, passed:0, corrected:0, missed:0, skipped:0, total:0 };
|
|
1212
|
+
cur.passed += d.passed; cur.corrected += d.corrected ?? 0; cur.missed += d.missed; cur.skipped += d.skipped;
|
|
1213
|
+
cur.total = cur.passed + cur.corrected + cur.missed + cur.skipped;
|
|
1214
|
+
m.set(d.day, cur);
|
|
1215
|
+
}
|
|
1216
|
+
return m;
|
|
1217
|
+
});
|
|
1126
1218
|
}
|
|
1127
1219
|
|
|
1128
1220
|
/** Fills every day in a window, including the empty ones — the gaps are data. */
|
|
@@ -1138,8 +1230,15 @@ function fillDays(map, n, endKey) {
|
|
|
1138
1230
|
return out;
|
|
1139
1231
|
}
|
|
1140
1232
|
|
|
1141
|
-
/**
|
|
1233
|
+
/** The 30 days ending today, in scope, empty ones included: the activity chart's days. */
|
|
1234
|
+
const lastThirty = () => derived('last30', () => Object.freeze(fillDays(dailyInScope(), 30)));
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Consecutive days with at least one answer, counting back from today. With no argument it is the streak in scope
|
|
1238
|
+
* (`dailyInScope`), which is kept; given a map of days it is a plain reading of that map.
|
|
1239
|
+
*/
|
|
1142
1240
|
function streaks(map) {
|
|
1241
|
+
if (!map) return derived('streaks', () => streaks(dailyInScope()));
|
|
1143
1242
|
const active = new Set([...map.values()].filter((d) => d.total > 0).map((d) => d.day));
|
|
1144
1243
|
const run = new Set();
|
|
1145
1244
|
const cur = fromKey(todayKey());
|
|
@@ -1153,47 +1252,111 @@ function streaks(map) {
|
|
|
1153
1252
|
best = Math.max(best, len);
|
|
1154
1253
|
prev = k;
|
|
1155
1254
|
}
|
|
1156
|
-
return { current: run.size, best, days: active.size, run, today };
|
|
1255
|
+
return Object.freeze({ current: run.size, best, days: active.size, run, today });
|
|
1157
1256
|
}
|
|
1158
1257
|
|
|
1159
|
-
/**
|
|
1160
|
-
|
|
1258
|
+
/**
|
|
1259
|
+
* Sessions in scope, from the two row sets that mention a session id, newest first. When the payload's lists
|
|
1260
|
+
* are cut (`rowsCut`) these are the sessions of the newest rows: a session older than every row the payload holds
|
|
1261
|
+
* is not here, and the oldest one that is may hold only part of its rows. `sessionTotal` is how many there are,
|
|
1262
|
+
* and a session's own page reads it whole (`viewSession`).
|
|
1263
|
+
*/
|
|
1264
|
+
const sessions = () => derived('sessions', () => Object.freeze(buildSessions()));
|
|
1265
|
+
/** Learning's sessions: the ones that logged a concept or asked a question. A memory-only one belongs to the other workflow. */
|
|
1266
|
+
const learningSessions = () => derived('learning-sessions', () => Object.freeze(sessions().filter((x) => x.logged.length || x.asked.length)));
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* How many learning sessions there are in scope. While the payload holds every row, it is the list's length. Once
|
|
1270
|
+
* it is cut, the list is only the sessions of the newest rows, and the count is the server's, read from every row: the
|
|
1271
|
+
* inventory's distinct sessions with no project selected (a session that touched two checkouts is one session, so the
|
|
1272
|
+
* cards cannot be added up), the project's own card with one. The sidebar, the Sessions page, the dashboard's link and the
|
|
1273
|
+
* Projects page then say one number. Never fewer than the list: a session the rows name and the card attributes to another
|
|
1274
|
+
* project (a repository-less answer is attributed by the page's `pid()`) is still a session in the list.
|
|
1275
|
+
*/
|
|
1276
|
+
const sessionTotal = () => derived('session-total', () => {
|
|
1277
|
+
const listed = learningSessions().length;
|
|
1278
|
+
if (!rowsCut()) return listed;
|
|
1279
|
+
return Math.max(listed, PROJECT === null ? INV.totals.sessions : projectsInScope().reduce((n, p) => n + p.learning.sessions, 0));
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* The counts the Learning pages state about the answers and the concepts in scope, read from the server's aggregates
|
|
1284
|
+
* over every row, never from `attemptsInScope()` and `loggedInScope()`, which are only the newest rows once a list is
|
|
1285
|
+
* cut: the project cards' sums (every answer is in exactly one card, so with no project selected they are the
|
|
1286
|
+
* database's totals, and a corrected miss counts as right as it does on the Accuracy tile), and for the concepts the
|
|
1287
|
+
* work recorded, the inventory's distinct counts, which two projects' cards could not be added up to.
|
|
1288
|
+
*/
|
|
1289
|
+
const scopeTotals = () => derived('scope-totals', () => {
|
|
1290
|
+
const ps = projectsInScope();
|
|
1291
|
+
const sum = (k) => ps.reduce((n, p) => n + p.learning[k], 0);
|
|
1292
|
+
const all = PROJECT === null;
|
|
1293
|
+
return Object.freeze({
|
|
1294
|
+
answers: sum('answers'), passed: sum('passed'), skipped: sum('skipped'),
|
|
1295
|
+
logged: all ? INV.totals.logged_concepts : sum('logged_concepts'),
|
|
1296
|
+
loggedAssessed: all ? INV.totals.logged_assessed_concepts : sum('logged_assessed_concepts'),
|
|
1297
|
+
});
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
/* Two timestamp shapes meet in a session: SQLite's `2026-09-05 21:04:00` from
|
|
1301
|
+
attempts, and the ISO string evidence carries. Compared as text, 'T' sorts
|
|
1302
|
+
after ' ', so the same instant from the two halves would order wrongly —
|
|
1303
|
+
flatten both to one shape before comparing. */
|
|
1304
|
+
const flatTs = (ts) => String(ts ?? '').replace('T', ' ').replace('Z', '');
|
|
1305
|
+
const newSession = (id) => ({ id, repo:null, logged:[], asked:[], mem:null, first:null, last:null });
|
|
1306
|
+
function touchSession(s, ts) {
|
|
1307
|
+
const t = flatTs(ts);
|
|
1308
|
+
if (!t) return;
|
|
1309
|
+
if (!s.first || t < s.first) s.first = t;
|
|
1310
|
+
if (!s.last || t > s.last) s.last = t;
|
|
1311
|
+
}
|
|
1312
|
+
const addLogged = (s, l) => { s.logged.push(l); s.repo = s.repo ?? l.repo; touchSession(s, l.ts); };
|
|
1313
|
+
const addAsked = (s, a) => { s.asked.push(a); s.repo = s.repo ?? a.repo; touchSession(s, a.ts); };
|
|
1314
|
+
const addMemory = (s, mem) => { s.mem = mem; s.repo = s.repo ?? mem.project; touchSession(s, mem.first); touchSession(s, mem.last); };
|
|
1315
|
+
/** A miss corrected from its explainer counts as right, as on the Accuracy tile. */
|
|
1316
|
+
const withPassed = (s) => ({ ...s, passed: s.asked.filter((a) => a.grade >= 3 || a.corrected_at).length });
|
|
1317
|
+
|
|
1318
|
+
function buildSessions() {
|
|
1161
1319
|
const m = new Map();
|
|
1162
1320
|
const get = (id) => {
|
|
1163
|
-
if (!m.has(id)) m.set(id,
|
|
1321
|
+
if (!m.has(id)) m.set(id, newSession(id));
|
|
1164
1322
|
return m.get(id);
|
|
1165
1323
|
};
|
|
1166
|
-
|
|
1167
|
-
attempts, and the ISO string evidence carries. Compared as text, 'T' sorts
|
|
1168
|
-
after ' ', so the same instant from the two halves would order wrongly —
|
|
1169
|
-
flatten both to one shape before comparing. */
|
|
1170
|
-
const flat = (ts) => String(ts ?? '').replace('T', ' ').replace('Z', '');
|
|
1171
|
-
const touch = (s, ts) => {
|
|
1172
|
-
const t = flat(ts);
|
|
1173
|
-
if (!t) return;
|
|
1174
|
-
if (!s.first || t < s.first) s.first = t;
|
|
1175
|
-
if (!s.last || t > s.last) s.last = t;
|
|
1176
|
-
};
|
|
1177
|
-
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); }
|
|
1324
|
+
for (const l of loggedInScope()) addLogged(get(l.session_id), l);
|
|
1178
1325
|
for (const a of attemptsInScope()) {
|
|
1179
1326
|
if (!a.session_id) continue;
|
|
1180
|
-
|
|
1327
|
+
addAsked(get(a.session_id), a);
|
|
1181
1328
|
}
|
|
1182
1329
|
// A session that captured evidence but logged no concept is still a session:
|
|
1183
1330
|
// leaving it out would make the memory half invisible in the one place the
|
|
1184
1331
|
// two halves are supposed to line up.
|
|
1185
1332
|
for (const mem of S.memory_sessions ?? []) {
|
|
1186
1333
|
if (!inScope(mem.project, mem.session_id)) continue;
|
|
1187
|
-
|
|
1188
|
-
s.mem = mem; s.repo = s.repo ?? mem.project;
|
|
1189
|
-
touch(s, mem.first); touch(s, mem.last);
|
|
1334
|
+
addMemory(get(mem.session_id), mem);
|
|
1190
1335
|
}
|
|
1191
1336
|
return [...m.values()]
|
|
1192
|
-
|
|
1193
|
-
.map((s) => ({ ...s, passed: s.asked.filter((a) => a.grade >= 3 || a.corrected_at).length }))
|
|
1337
|
+
.map(withPassed)
|
|
1194
1338
|
.sort((a, b) => String(b.last).localeCompare(String(a.last)));
|
|
1195
1339
|
}
|
|
1196
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* One session from what `/api/memory/sessions?session=` answers (`d`): its logged lines and its answers
|
|
1343
|
+
* whole, however old, in the payload's own row shapes, and its memory half. The same session `buildSessions`
|
|
1344
|
+
* makes, from the same helpers, drawn from rows the payload's cut did not leave out. The lines and answers
|
|
1345
|
+
* are not narrowed by the server to a project (which project a row is in is `pid()`'s to say), so they are narrowed
|
|
1346
|
+
* here as the payload's rows are. `null`: nothing of it is in scope. `cutAsked` says when the session has more
|
|
1347
|
+
* answers than the server sends for one session (`ATTEMPT_LIMIT`).
|
|
1348
|
+
*/
|
|
1349
|
+
function sessionFromLookup(id, d) {
|
|
1350
|
+
const s = newSession(id);
|
|
1351
|
+
const L = d.learning;
|
|
1352
|
+
for (const l of L.logged) if (inScope(l.repo, l.session_id)) addLogged(s, l);
|
|
1353
|
+
for (const a of L.attempts) if (inScope(a.repo, a.session_id)) addAsked(s, a);
|
|
1354
|
+
const mem = d.rows[0] ?? null;
|
|
1355
|
+
if (mem) addMemory(s, mem);
|
|
1356
|
+
if (!s.logged.length && !s.asked.length && !mem) return null;
|
|
1357
|
+
return { ...withPassed(s), cutAsked: L.attempts_total > L.attempts.length ? { shown: L.attempts.length, total: L.attempts_total } : null };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1197
1360
|
/**
|
|
1198
1361
|
* What a session is called in a list. The context line the agent wrote is the
|
|
1199
1362
|
* only human description there is; a session that logged nothing readable is
|
|
@@ -1247,6 +1410,19 @@ function card(title, hint, body, opts = {}) {
|
|
|
1247
1410
|
return `<section class="card">${head}${body}</section>`;
|
|
1248
1411
|
}
|
|
1249
1412
|
|
|
1413
|
+
/**
|
|
1414
|
+
* A part of a screen that an adjustment (a tab, a chip, a select, a pager) redraws
|
|
1415
|
+
* on its own. The element and everything around it keep their place in the page;
|
|
1416
|
+
* only its contents are replaced, wholesale, when what the view returns for it
|
|
1417
|
+
* changed. Name it for what it holds. Regions never nest. `html` is escaped by the
|
|
1418
|
+
* caller. See `drawAdjusted`.
|
|
1419
|
+
*
|
|
1420
|
+
* A list that is fetched is a region too: `slot()` writes `data-region` on its target (`region: true`).
|
|
1421
|
+
* Its contents are the answer when the page holds it, and otherwise the box that reserves its room,
|
|
1422
|
+
* which a redraw never puts over rows that are on screen: they stay, dimmed, until the new ones arrive.
|
|
1423
|
+
*/
|
|
1424
|
+
const region = (id, html, attrs = '') => `<div data-region="${id}"${attrs ? ' ' + attrs : ''}>${html}</div>`;
|
|
1425
|
+
|
|
1250
1426
|
/* ---------- progressive disclosure ---------- */
|
|
1251
1427
|
|
|
1252
1428
|
/**
|
|
@@ -1268,10 +1444,13 @@ const openAttr = (id, dflt) => (isOpen(id, dflt) ? ' open' : '');
|
|
|
1268
1444
|
* the numbers are counted folded under a "How this works" line. `title` and
|
|
1269
1445
|
* every count are HTML the caller escaped.
|
|
1270
1446
|
*/
|
|
1271
|
-
function pageHead(title, counts = [], about = '', back = '') {
|
|
1447
|
+
function pageHead(title, counts = [], about = '', back = '', inRegion = false) {
|
|
1272
1448
|
const id = `about:${CUR.wf}:${CUR.page}`;
|
|
1273
|
-
|
|
1274
|
-
|
|
1449
|
+
// The counts are the part of a head that new data changes, so they are a region of their own: a count that moves
|
|
1450
|
+
// replaces the line, and not the screen around it (an open list, a typed date, a selection). Regions never nest,
|
|
1451
|
+
// so a head that is itself in one (`inRegion`: Settings') is drawn with the rest.
|
|
1452
|
+
return `<div class="page__head">${back}<h1 class="page__title">${title}</h1>
|
|
1453
|
+
${counts.length ? `<p class="counts"${inRegion ? '' : ' data-region="head-counts"'}>${counts.join(' · ')}</p>` : ''}
|
|
1275
1454
|
${about ? `<details class="about" data-fold="${esc(id)}"${openAttr(id, false)}><summary>How this works</summary><p>${about}</p></details>` : ''}</div>`;
|
|
1276
1455
|
}
|
|
1277
1456
|
|
|
@@ -1326,12 +1505,12 @@ function startBlocks(title, cmds) {
|
|
|
1326
1505
|
if (!cmds.length) return '';
|
|
1327
1506
|
const block = (x) => `<div class="next start">
|
|
1328
1507
|
<p><strong>${esc(x.name)}</strong> · ${x.count} due</p>
|
|
1329
|
-
<div class="cmd"><code>${esc(x.command)}</code><button type="button" class="cmd__copy" data-copy>Copy</button></div>
|
|
1508
|
+
<div class="cmd"><code>${esc(x.command)}</code><button type="button" class="cmd__copy" data-copy data-focus="copy:${esc(x.name)}">Copy</button></div>
|
|
1330
1509
|
${x.note ? `<p class="cmd__note">${esc(x.note)}</p>` : ''}</div>`;
|
|
1331
1510
|
const rest = cmds.slice(START_SHOWN);
|
|
1332
1511
|
return `<section class="starts"><div class="head"><h2>${esc(title)}</h2></div>`
|
|
1333
1512
|
+ cmds.slice(0, START_SHOWN).map(block).join('')
|
|
1334
|
-
+ (rest.length ? `<details class="starts__more"><summary>Show ${rest.length} more · ${rest.reduce((n, x) => n + x.count, 0)} due</summary>${rest.map(block).join('')}</details>` : '')
|
|
1513
|
+
+ (rest.length ? `<details class="starts__more" data-key="starts-more"><summary>Show ${rest.length} more · ${rest.reduce((n, x) => n + x.count, 0)} due</summary>${rest.map(block).join('')}</details>` : '')
|
|
1335
1514
|
+ `<p class="cmd__note">Each run asks up to your questions-per-task limit, weakest first. Run it again for the rest.</p>
|
|
1336
1515
|
<span class="sr" id="copy-live" aria-live="polite"></span></section>`;
|
|
1337
1516
|
}
|
|
@@ -1378,7 +1557,7 @@ function lines(items, empty = 'Nothing here yet.') {
|
|
|
1378
1557
|
function qaLine(a, lead, linkConcept = false) {
|
|
1379
1558
|
let opts = [];
|
|
1380
1559
|
try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
|
|
1381
|
-
return `<details class="qa">
|
|
1560
|
+
return `<details class="qa"${a.id != null ? ` data-key="qa:${esc(a.id)}"` : ''}>
|
|
1382
1561
|
<summary class="qa__meta">${lead}<span class="qa__line">${esc(a.question)}</span>${gradeBadge(a)}</summary>
|
|
1383
1562
|
<div class="qa__meta" style="margin-top:var(--space-3)"><span>${esc(tierLabel(a.tier))}</span>
|
|
1384
1563
|
${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
|
|
@@ -1412,14 +1591,16 @@ function pager(key, total, page, per = PER) {
|
|
|
1412
1591
|
push(1); for (let i = page - 2; i <= page + 2; i++) push(i); push(pages);
|
|
1413
1592
|
win.sort((a, b) => a - b);
|
|
1414
1593
|
|
|
1415
|
-
|
|
1594
|
+
// `data-focus` names a button by what it is, not by the page it goes to: after a press the Next
|
|
1595
|
+
// button goes to another page, and focus has to find it again (see `captureUi`).
|
|
1596
|
+
let btns = `<button data-pager="${key}" data-page="${page - 1}" data-focus="${key}:prev"${page === 1 ? ' disabled' : ''} aria-label="Previous page">‹</button>`;
|
|
1416
1597
|
let last = 0;
|
|
1417
1598
|
for (const n of win) {
|
|
1418
1599
|
if (n - last > 1) btns += `<button disabled aria-hidden="true">…</button>`;
|
|
1419
|
-
btns += `<button data-pager="${key}" data-page="${n}"${n === page ? ' aria-current="true"' : ''}>${n}</button>`;
|
|
1600
|
+
btns += `<button data-pager="${key}" data-page="${n}" data-focus="${key}:${n}"${n === page ? ' aria-current="true"' : ''}>${n}</button>`;
|
|
1420
1601
|
last = n;
|
|
1421
1602
|
}
|
|
1422
|
-
btns += `<button data-pager="${key}" data-page="${page + 1}"${page === pages ? ' disabled' : ''} aria-label="Next page">›</button>`;
|
|
1603
|
+
btns += `<button data-pager="${key}" data-page="${page + 1}" data-focus="${key}:next"${page === pages ? ' disabled' : ''} aria-label="Next page">›</button>`;
|
|
1423
1604
|
return `<div class="pager"><span class="pager__count">${from}–${to} of ${total}</span><div class="pager__btns">${btns}</div></div>`;
|
|
1424
1605
|
}
|
|
1425
1606
|
|
|
@@ -1506,10 +1687,45 @@ function chartActivity(el, days) {
|
|
|
1506
1687
|
*/
|
|
1507
1688
|
const HEAT_STEP = [0, 30, 55, 80, 100]; // % of --spot over --mass for steps 1..4
|
|
1508
1689
|
const heatFill = (s) => `color-mix(in srgb, var(--spot) ${HEAT_STEP[s]}%, var(--mass))`;
|
|
1509
|
-
let HEAT_OFF = 0; // weeks paged back; a view position, reset by render()
|
|
1690
|
+
let HEAT_OFF = 0; // weeks paged back; a view position, reset by render() (a redraw for new data keeps it)
|
|
1510
1691
|
const HEAT_ARROWS = 64; // the two arrow buttons beside the grid
|
|
1511
1692
|
|
|
1512
|
-
|
|
1693
|
+
/**
|
|
1694
|
+
* The heatmap's days, bucketed once per (S, PROJECT, day) and kept (`derived`): a day's count, its tooltip (said
|
|
1695
|
+
* with the date, but not whether it is in the current streak, which the drawing adds) and, for a week's first
|
|
1696
|
+
* day, its month, which is set when a column first needs it. A visit, a resize and a ‹ › page all draw some window
|
|
1697
|
+
* of the same year, so each draws from these instead of bucketing and formatting the dates again; only the SVG for
|
|
1698
|
+
* the visible window is built. Cells are made as days are asked for, so the 53 weeks the payload carries, and the
|
|
1699
|
+
* weeks paged back past them, are each made once.
|
|
1700
|
+
*/
|
|
1701
|
+
function heatCell(grid, k) {
|
|
1702
|
+
let c = grid.cells.get(k);
|
|
1703
|
+
if (c) return c;
|
|
1704
|
+
const d = grid.daily.get(k);
|
|
1705
|
+
const total = d ? d.total : 0;
|
|
1706
|
+
const date = fromKey(k);
|
|
1707
|
+
const label = (date.getUTCFullYear() === grid.year ? FMT_DAY : FMT_DAY_YEAR).format(date);
|
|
1708
|
+
const right = d ? d.passed + d.corrected : 0;
|
|
1709
|
+
c = { day: k, total, future: k > grid.today, month: null,
|
|
1710
|
+
tip: esc(`${label} — ${total ? `${plural(total, 'answer')} (${right} right)` : 'nothing'}`) };
|
|
1711
|
+
grid.cells.set(k, c);
|
|
1712
|
+
return c;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** `n` days ending at `endKey`, as heat cells: `fillDays` for the heatmap. */
|
|
1716
|
+
function heatDays(n, endKey) {
|
|
1717
|
+
const grid = derived('heat', () => ({ year: fromKey(todayKey()).getUTCFullYear(), today: todayKey(), daily: dailyInScope(), cells: new Map() }));
|
|
1718
|
+
const out = [];
|
|
1719
|
+
const cur = new Date(fromKey(endKey).getTime() - (n - 1) * DAY_MS);
|
|
1720
|
+
for (let i = 0; i < n; i++) {
|
|
1721
|
+
out.push(heatCell(grid, dayKey(cur)));
|
|
1722
|
+
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
1723
|
+
}
|
|
1724
|
+
return out;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function chartHeatmap(el) {
|
|
1728
|
+
const run = streaks().run;
|
|
1513
1729
|
const CELL = 13, GAP = 3, PAD_T = 18, PAD_L = 26;
|
|
1514
1730
|
// The window is chosen from the width rather than the width from the window:
|
|
1515
1731
|
// a wide card shows a year, a phone shows a quarter, and the cell stays the
|
|
@@ -1524,18 +1740,15 @@ function chartHeatmap(el, map, run = new Set()) {
|
|
|
1524
1740
|
const end = new Date(today);
|
|
1525
1741
|
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()) - HEAT_OFF * 7); // to the end of this week, paged back
|
|
1526
1742
|
const total = weeks * 7;
|
|
1527
|
-
const days =
|
|
1743
|
+
const days = heatDays(total, dayKey(end));
|
|
1528
1744
|
const max = Math.max(...days.map((d) => d.total), 1);
|
|
1529
|
-
const year = today.getUTCFullYear();
|
|
1530
|
-
const label = (k) => fromKey(k).toLocaleDateString(undefined,
|
|
1531
|
-
{ month:'short', day:'numeric', year: fromKey(k).getUTCFullYear() === year ? undefined : 'numeric', timeZone:'UTC' });
|
|
1532
1745
|
|
|
1533
1746
|
let out = '', lastMonth = '', lastCol = -9;
|
|
1534
1747
|
days.forEach((d, i) => {
|
|
1535
1748
|
const col = Math.floor(i / 7), row = i % 7;
|
|
1536
1749
|
const x = PAD_L + col * (CELL + GAP), y = PAD_T + row * (CELL + GAP);
|
|
1537
1750
|
if (row === 0) {
|
|
1538
|
-
const m = fromKey(d.day)
|
|
1751
|
+
const m = d.month ??= FMT_MONTH.format(fromKey(d.day));
|
|
1539
1752
|
// Not in the last two columns: a month label starting there runs off the
|
|
1540
1753
|
// right edge of the card. Nor within three columns of the previous label, which it would overprint.
|
|
1541
1754
|
if (m !== lastMonth) {
|
|
@@ -1544,12 +1757,11 @@ function chartHeatmap(el, map, run = new Set()) {
|
|
|
1544
1757
|
}
|
|
1545
1758
|
}
|
|
1546
1759
|
const step = d.total ? Math.min(4, Math.ceil((4 * d.total) / max)) : 0;
|
|
1547
|
-
const fill = step ? heatFill(step) : d.
|
|
1760
|
+
const fill = step ? heatFill(step) : d.future ? 'transparent' : 'var(--mass)';
|
|
1548
1761
|
// The current run is outlined inside its own cell, so neighbours never touch.
|
|
1549
1762
|
const on = run.has(d.day), inset = on ? 0.75 : 0;
|
|
1550
|
-
const title = `${label(d.day)} — ${d.total ? `${plural(d.total, 'answer')} (${d.passed + d.corrected} right)` : 'nothing'}${on ? ' · current streak' : ''}`;
|
|
1551
1763
|
out += `<rect class="cell${on ? ' is-run' : ''}" data-step="${step}" x="${x + inset}" y="${y + inset}" width="${CELL - 2 * inset}" height="${CELL - 2 * inset}" fill="${fill}">`
|
|
1552
|
-
+ `<title>${
|
|
1764
|
+
+ `<title>${d.tip}${on ? ' · current streak' : ''}</title></rect>`;
|
|
1553
1765
|
});
|
|
1554
1766
|
['Mon', 'Wed', 'Fri'].forEach((label, n) => {
|
|
1555
1767
|
const row = [1, 3, 5][n];
|
|
@@ -1572,8 +1784,8 @@ function chartHeatmap(el, map, run = new Set()) {
|
|
|
1572
1784
|
}
|
|
1573
1785
|
|
|
1574
1786
|
/** The streak, up front: the run, the longest, and the calendar it is counted from. */
|
|
1575
|
-
function streakCard(
|
|
1576
|
-
chart('c-heat',
|
|
1787
|
+
function streakCard(st) {
|
|
1788
|
+
chart('c-heat', chartHeatmap);
|
|
1577
1789
|
const sub = st.current && !st.today ? `Answer one today to make it ${st.current + 1}.`
|
|
1578
1790
|
: !st.current && st.best ? `Your longest was ${plural(st.best, 'day')}.`
|
|
1579
1791
|
: !st.best ? 'Answer one question to start one.' : '';
|
|
@@ -1679,7 +1891,8 @@ const chart = (id, fn) => {
|
|
|
1679
1891
|
Everything above is derived from one payload. The corpus is the exception:
|
|
1680
1892
|
an observation carries a narrative and the tool output behind it, so the
|
|
1681
1893
|
timeline is paged over `/api/memory` instead of shipped whole on every load.
|
|
1682
|
-
|
|
1894
|
+
Those views, and the other pages that read a resource of their own, are
|
|
1895
|
+
asynchronous, and every one of them draws its target through `slot()`.
|
|
1683
1896
|
============================================================ */
|
|
1684
1897
|
|
|
1685
1898
|
/**
|
|
@@ -1690,46 +1903,256 @@ const chart = (id, fn) => {
|
|
|
1690
1903
|
*/
|
|
1691
1904
|
let GEN = 0;
|
|
1692
1905
|
|
|
1693
|
-
/**
|
|
1694
|
-
* Renders a placeholder now and the real markup when the fetch lands. Queued
|
|
1695
|
-
* through `AFTER` like a chart, for the same reason — the target element does
|
|
1696
|
-
* not exist until `render()` has written the view's `innerHTML`.
|
|
1697
|
-
*/
|
|
1698
1906
|
/**
|
|
1699
1907
|
* The one loading state: the companion drawing and releasing its arrow, and what is being waited
|
|
1700
1908
|
* for. `page: true` is the full-height version the first paint uses. Every
|
|
1701
|
-
* placeholder a `
|
|
1909
|
+
* placeholder a `slot()` starts with is one of these, so a wait looks
|
|
1702
1910
|
* the same wherever it happens. `label` is plain text; it is escaped here.
|
|
1703
1911
|
*/
|
|
1704
1912
|
function loader(label, { page = false } = {}) {
|
|
1705
1913
|
return `<div class="loader${page ? ' loader--page' : ''}" role="status" aria-live="polite"><eklavya-mascot state="loading" variant="loading" size="${page ? 96 : 56}"></eklavya-mascot><span>${esc(label)}</span></div>`;
|
|
1706
1914
|
}
|
|
1707
1915
|
|
|
1916
|
+
/* ---------- the response cache ----------
|
|
1917
|
+
What a fetched region asked for, kept by URL, so a page the reader has seen is drawn again
|
|
1918
|
+
with no request and no loader: a revisit, Back and Forward, a pager or a filter returned to.
|
|
1919
|
+
It holds the parsed body, never markup, so a draw runs on it again as it would on a fresh
|
|
1920
|
+
answer (today's "Yesterday" is not the day it was cached).
|
|
1921
|
+
|
|
1922
|
+
The key is the URL, and the URL carries everything the answer depends on: the project scope
|
|
1923
|
+
(`memScope()`, `?project=`), the page, the filters, the id. A different project is a different
|
|
1924
|
+
key, so a project switch leaves the cache alone and switching back is instant. An answer that
|
|
1925
|
+
does not depend on the scope (an entry, a session's rows, a feedback item, a correction) is
|
|
1926
|
+
keyed by the one thing it does depend on.
|
|
1927
|
+
|
|
1928
|
+
An entry is good while the page's `S.cursor` is the one it was read under, and it is dropped
|
|
1929
|
+
by `invalidateData()` (a save, and the arrival of new data: `adoptState`) and, for the one
|
|
1930
|
+
route a write changes, by `respForget()`. A page reload starts with nothing. A read already out when the cache is invalidated is not stored
|
|
1931
|
+
when it lands (`respAt`): it may have been answered before whatever made the cache stale.
|
|
1932
|
+
Bounded at `RESP_MAX`, least recently used out first. An answer that failed is never cached. */
|
|
1933
|
+
const RESP_MAX = 50;
|
|
1934
|
+
const RESP = new Map(); // url -> { cursor, body }, least recently used first
|
|
1935
|
+
|
|
1936
|
+
function respGet(url) {
|
|
1937
|
+
const e = RESP.get(url);
|
|
1938
|
+
if (!e) return null;
|
|
1939
|
+
RESP.delete(url);
|
|
1940
|
+
if (e.cursor !== S.cursor) return null;
|
|
1941
|
+
RESP.set(url, e);
|
|
1942
|
+
return e;
|
|
1943
|
+
}
|
|
1944
|
+
/** What a read is taken under, noted when it is made, not when it lands. */
|
|
1945
|
+
const respAt = () => ({ cursor: S.cursor, gen: DATA_GEN });
|
|
1946
|
+
function respPut(url, body, at) {
|
|
1947
|
+
if (at.cursor !== S.cursor || at.gen !== DATA_GEN) return;
|
|
1948
|
+
RESP.delete(url);
|
|
1949
|
+
RESP.set(url, { cursor: at.cursor, body });
|
|
1950
|
+
if (RESP.size > RESP_MAX) RESP.delete(RESP.keys().next().value);
|
|
1951
|
+
}
|
|
1952
|
+
/** A write the page made changed what these answer: every cached URL that starts with `prefix` goes. */
|
|
1953
|
+
function respForget(prefix) {
|
|
1954
|
+
for (const url of [...RESP.keys()]) if (url.startsWith(prefix)) RESP.delete(url);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/* ---------- slots: a fetched region that never moves the page ----------
|
|
1958
|
+
A view that reads a resource returns `slot(id, url, draw, opts)` where the target element goes.
|
|
1959
|
+
On a cache hit the element already holds `draw(body)`: nothing is inserted, nothing is
|
|
1960
|
+
requested, and the screen is drawn in one go. On a miss it holds a box of the height that
|
|
1961
|
+
region is expected to take, with the loader in it (the mascot still waits its 150ms before it
|
|
1962
|
+
shows), and the fetch is queued. When the same region is already on screen with an earlier
|
|
1963
|
+
answer in it (a pager, a filter, a refetch of the same page), the box is never drawn: the old
|
|
1964
|
+
content stays, dimmed and out of reach (`aria-busy` + `data-kept`), until the answer draws. */
|
|
1965
|
+
|
|
1966
|
+
/** Where the box is looked for in markup. */
|
|
1967
|
+
const WAIT_OPEN = '<div class="slot__wait"';
|
|
1968
|
+
const isWait = (html) => html.startsWith(WAIT_OPEN);
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* What a slot reserves the first time it is drawn, in px, before this page has seen what it holds. A list is
|
|
1972
|
+
* sized from how many rows the page already knows it has (`SLOT_ROWS`: the counts the payload and the inventory carry),
|
|
1973
|
+
* at most the rows one page holds, so a reader with three sessions is not given room for twenty and the footer
|
|
1974
|
+
* does not drop a thousand pixels and come back. The rest are what they take at a year of use (the issue's medium
|
|
1975
|
+
* database, measured at 1280px): an observation, an item of feedback, both folds of a session's memory half, the
|
|
1976
|
+
* settings page, whose fields are fixed. These are estimates for a first visit and nothing more: the footer moves
|
|
1977
|
+
* by however far one misses, and twice if the page being left was taller than the estimate and shorter than the
|
|
1978
|
+
* answer, or the other way round. The height a region had when it last drew (`SLOT_H`) replaces them, and a page
|
|
1979
|
+
* seen again has no box at all.
|
|
1980
|
+
*/
|
|
1981
|
+
const SLOT_MIN = {
|
|
1982
|
+
'entry-body': 650, 'sess-lookup': 360, 'sess-mem': 160, 'fb-item': 520, settings: 1580,
|
|
1983
|
+
};
|
|
1984
|
+
/** A list's room: [how many rows the page knows of, the most one page holds, px a row takes, px around the rows (pager, rule)]. Measured at 1280px. */
|
|
1985
|
+
const SLOT_ROWS = {
|
|
1986
|
+
'mem-rows': [() => memCounts().entries, 25, 48, 143],
|
|
1987
|
+
'msess-rows': [() => memCounts().sessions, PER, 70, 51],
|
|
1988
|
+
'mem-recent': [() => memCounts().entries, 6, 60, 0],
|
|
1989
|
+
'mem-sessions': [() => memCounts().sessions, 5, 60, 0],
|
|
1990
|
+
'fb-rows': [() => S.feedback.acknowledged, PER, 46, 110],
|
|
1991
|
+
};
|
|
1992
|
+
/** What a list with no rows takes: its one line of explanation. */
|
|
1993
|
+
const SLOT_EMPTY = 94;
|
|
1994
|
+
const SLOT_FALLBACK = 160;
|
|
1995
|
+
/** What `#id` reserves while it waits and the page has not drawn it before. */
|
|
1996
|
+
function slotMin(id) {
|
|
1997
|
+
const rows = SLOT_ROWS[id];
|
|
1998
|
+
const n = rows ? rows[0]() : NaN;
|
|
1999
|
+
if (n === 0) return SLOT_EMPTY;
|
|
2000
|
+
if (n > 0) return Math.round(rows[3] + rows[2] * Math.min(n, rows[1]));
|
|
2001
|
+
return SLOT_MIN[id] ?? SLOT_FALLBACK;
|
|
2002
|
+
}
|
|
2003
|
+
const SLOT_H = new Map(); // id -> the height of its content the last time it was drawn
|
|
2004
|
+
|
|
2005
|
+
/** The height of the content of `el`, without the padding it carries. */
|
|
2006
|
+
function contentHeight(el) {
|
|
2007
|
+
const cs = getComputedStyle(el);
|
|
2008
|
+
return Math.round(el.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom));
|
|
2009
|
+
}
|
|
2010
|
+
/** Notes how tall `#id` is now that it holds real content, for the next time it waits. */
|
|
2011
|
+
function remember(id) {
|
|
2012
|
+
const el = $(id);
|
|
2013
|
+
const h = el ? contentHeight(el) : 0;
|
|
2014
|
+
if (h > 0) SLOT_H.set(id, h);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const waitBox = (id, label) => `${WAIT_OPEN} style="min-height:${SLOT_H.get(id) ?? slotMin(id)}px">${loader(label)}</div>`;
|
|
2018
|
+
|
|
2019
|
+
const failure = (e) => `<div class="ek-mascot-message"><eklavya-mascot state="error" size="56"></eklavya-mascot><p class="empty">Could not load: ${esc(e.message)}</p></div>`;
|
|
2020
|
+
|
|
2021
|
+
/** The target element of a slot. Its attributes are the same whatever it holds, so a redraw can tell a change of content from a change of structure. */
|
|
2022
|
+
const slotHost = (id, inner, { cls = '', region = false } = {}) =>
|
|
2023
|
+
`<div id="${id}" data-slot${cls ? ` class="${cls}"` : ''}${region ? ` data-region="${id}"` : ''}>${inner}</div>`;
|
|
2024
|
+
|
|
2025
|
+
/** The state a fetch leaves on its target while it is out; gone once the answer, or the failure, is drawn. */
|
|
2026
|
+
const slotDone = (el) => { el.removeAttribute('aria-busy'); el.removeAttribute('data-kept'); el.removeAttribute('data-failed'); };
|
|
2027
|
+
/** Whether `el` shows something worth keeping under a new request: not the box, and not a failure. */
|
|
2028
|
+
const holdsContent = (el) => !el.hasAttribute('data-failed') && !el.querySelector(':scope > .slot__wait');
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* `opts`: `label` (what the loader says), `cls` (a class for the target), `region` (the target is
|
|
2032
|
+
* a `data-region`, so a redraw replaces its contents alone). `draw` is a pure function of the
|
|
2033
|
+
* response, and must not change it: a cached body is drawn again. It may queue its own charts and slots.
|
|
2034
|
+
*/
|
|
2035
|
+
function slot(id, url, draw, { label, ...host } = {}) {
|
|
2036
|
+
const hit = respGet(url);
|
|
2037
|
+
if (!hit) {
|
|
2038
|
+
fill(id, url, draw);
|
|
2039
|
+
return slotHost(id, waitBox(id, label), host);
|
|
2040
|
+
}
|
|
2041
|
+
AFTER.push(() => remember(id));
|
|
2042
|
+
let html;
|
|
2043
|
+
try {
|
|
2044
|
+
html = draw(hit.body);
|
|
2045
|
+
} catch (e) {
|
|
2046
|
+
html = failure(e);
|
|
2047
|
+
AFTER.push(() => $(id)?.setAttribute('data-failed', ''));
|
|
2048
|
+
}
|
|
2049
|
+
return slotHost(id, html, host);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* What a fetched region was last drawn from: the markup the record of the screen (`DRAWN`) holds for `#id`, which
|
|
2054
|
+
* every draw into it keeps (`recordFill`, `drawAdjusted`, `recordStrip`); null when there is none. A redraw for new
|
|
2055
|
+
* data compares an answer with it: the same markup replaces nothing, and markup made of regions of its own (Settings)
|
|
2056
|
+
* with the same skeleton around them replaces only the regions that changed, so what the new data did not change is
|
|
2057
|
+
* not touched (the reader's text selection, an open row, a tip beacon, a field being edited).
|
|
2058
|
+
*/
|
|
2059
|
+
function drawnSlot(id) {
|
|
2060
|
+
if (!DRAWN) return null;
|
|
2061
|
+
const t = document.createElement('template');
|
|
2062
|
+
t.innerHTML = DRAWN.html;
|
|
2063
|
+
return t.content.getElementById(id)?.innerHTML ?? null;
|
|
2064
|
+
}
|
|
2065
|
+
/** `html` as the browser keeps it, which is the form `drawnSlot` and the record of the screen are in. */
|
|
2066
|
+
function normalized(html) {
|
|
2067
|
+
const t = document.createElement('template');
|
|
2068
|
+
t.innerHTML = html;
|
|
2069
|
+
return t.innerHTML;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* Fetches `url` for the slot `#id` and draws the answer in. Queued through `AFTER` like a chart,
|
|
2074
|
+
* for the same reason: the target does not exist until `render()` has written the view. If the
|
|
2075
|
+
* target already shows an earlier answer, that stays under `aria-busy` until this one draws.
|
|
2076
|
+
*
|
|
2077
|
+
* A fill made for new data (`LIVE_PASS`) is the reader's own screen being brought up to date, so it also leaves
|
|
2078
|
+
* alone the regions that hold a control the reader is using (`swapRegions`), and when the server cannot be reached
|
|
2079
|
+
* it leaves what is on screen rather than putting an error over it.
|
|
2080
|
+
*/
|
|
1708
2081
|
function fill(id, url, draw) {
|
|
1709
2082
|
const gen = GEN;
|
|
2083
|
+
const at = respAt();
|
|
2084
|
+
const live = LIVE_PASS;
|
|
1710
2085
|
AFTER.push(() => {
|
|
1711
|
-
$(id)
|
|
2086
|
+
const was = $(id);
|
|
2087
|
+
const kept = !!was && holdsContent(was);
|
|
2088
|
+
if (was) {
|
|
2089
|
+
// The dim waits its 150ms (CSS) only on an element whose style has been computed before it is set: one the same task just put in
|
|
2090
|
+
// the document would start dimmed.
|
|
2091
|
+
void getComputedStyle(was).opacity;
|
|
2092
|
+
was.setAttribute('aria-busy', 'true');
|
|
2093
|
+
if (kept) was.setAttribute('data-kept', '');
|
|
2094
|
+
}
|
|
1712
2095
|
fetch(url).then((r) => {
|
|
1713
2096
|
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
|
1714
2097
|
return r.json();
|
|
1715
2098
|
}).then((d) => {
|
|
2099
|
+
respPut(url, d, at);
|
|
1716
2100
|
const el = $(id);
|
|
1717
2101
|
if (!el || gen !== GEN) return;
|
|
1718
2102
|
// A draw can queue its own charts and fills (a session page drawn from a
|
|
1719
2103
|
// lookup does); the render that queued this one has already run its hooks.
|
|
1720
2104
|
AFTER.length = 0;
|
|
1721
|
-
|
|
1722
|
-
el
|
|
2105
|
+
const html = draw(d);
|
|
2106
|
+
if (live && kept && holdsContent(el) && redrawKept(el, html, live)) {
|
|
2107
|
+
// What is on screen was brought up to date, or already was: the record of the screen follows.
|
|
2108
|
+
recordFill(id, html);
|
|
2109
|
+
slotDone(el);
|
|
2110
|
+
remember(id);
|
|
2111
|
+
for (const fn of AFTER.splice(0)) fn();
|
|
2112
|
+
TIP.sync();
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
// What the reader had open or focused in the rows being replaced (a timeline entry, a pager
|
|
2116
|
+
// button) is found again in the new ones.
|
|
2117
|
+
const ui = captureUi(el);
|
|
2118
|
+
el.innerHTML = html;
|
|
2119
|
+
// This region is as the answer says. Another that was put off for the reader's control is still put off.
|
|
2120
|
+
HELD_BACK.delete(id);
|
|
2121
|
+
// The view returns this same markup on its own from now on (from the cache), so the record
|
|
2122
|
+
// of the screen is brought up to date with it and a later redraw leaves it alone.
|
|
2123
|
+
recordFill(id, html);
|
|
2124
|
+
slotDone(el);
|
|
1723
2125
|
focusable(el);
|
|
2126
|
+
restoreUi(el, ui);
|
|
2127
|
+
remember(id);
|
|
1724
2128
|
for (const fn of AFTER.splice(0)) fn();
|
|
1725
2129
|
TIP.sync();
|
|
1726
2130
|
}).catch((e) => {
|
|
1727
2131
|
const el = $(id);
|
|
1728
|
-
if (el
|
|
2132
|
+
if (!el || gen !== GEN) return;
|
|
2133
|
+
if (live && kept) { slotDone(el); return; }
|
|
2134
|
+
el.innerHTML = failure(e);
|
|
2135
|
+
slotDone(el);
|
|
2136
|
+
el.setAttribute('data-failed', '');
|
|
1729
2137
|
});
|
|
1730
2138
|
});
|
|
1731
2139
|
}
|
|
1732
2140
|
|
|
2141
|
+
/**
|
|
2142
|
+
* An answer for a region that shows an earlier one, for a redraw for new data: true when what is on screen is now in line
|
|
2143
|
+
* with `html` without replacing the region. That is when it is the same markup, or it is made of regions of its own (the
|
|
2144
|
+
* same skeleton around them, as Settings is) and only some of them changed, which are replaced and the others never
|
|
2145
|
+
* touched. The reader's own change of a list (a pager, a filter) always draws the answer it asked for.
|
|
2146
|
+
*/
|
|
2147
|
+
function redrawKept(el, html, live) {
|
|
2148
|
+
const was = drawnSlot(el.id);
|
|
2149
|
+
if (was === null) return false;
|
|
2150
|
+
if (normalized(html) === was && !HELD_BACK.has(el.id)) return true;
|
|
2151
|
+
const next = scanRegions(html);
|
|
2152
|
+
const prev = scanRegions(was);
|
|
2153
|
+
return next.skeleton !== null && next.skeleton === prev.skeleton && swapRegions(el, prev, next, live);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
1733
2156
|
/**
|
|
1734
2157
|
* Anything with a `data-go` is a link, so it has to be reachable by Tab: rows,
|
|
1735
2158
|
* tiles and the `<a class="link">` shorthand, which has no `href` and so is
|
|
@@ -1737,7 +2160,9 @@ function fill(id, url, draw) {
|
|
|
1737
2160
|
*/
|
|
1738
2161
|
function focusable(root) {
|
|
1739
2162
|
root.querySelectorAll('tr.row, [data-go]').forEach((el) => {
|
|
1740
|
-
|
|
2163
|
+
// `tabIndex` is not the question for the `<a class="link" data-go>` shorthand: Chromium reports 0 for an anchor
|
|
2164
|
+
// with no `href` although it cannot take focus, so it was never given the attribute that makes it Tab-reachable.
|
|
2165
|
+
if (!el.hasAttribute('tabindex') || el.tabIndex < 0) el.tabIndex = 0;
|
|
1741
2166
|
if (el.tagName === 'A' && !el.hasAttribute('href')) el.setAttribute('role', 'link');
|
|
1742
2167
|
});
|
|
1743
2168
|
// Runs after every render and every fill, so it is also where new markup's
|
|
@@ -1790,27 +2215,22 @@ function savingsText(s, receipts) {
|
|
|
1790
2215
|
============================================================ */
|
|
1791
2216
|
|
|
1792
2217
|
function viewLearningDashboard() {
|
|
1793
|
-
const
|
|
1794
|
-
const st = streaks(daily);
|
|
2218
|
+
const st = streaks();
|
|
1795
2219
|
const seen = seenConcepts();
|
|
1796
2220
|
const scoped = conceptsInScope();
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
const passed = atts.filter((a) => a.grade >= 3 || a.corrected_at).length;
|
|
2221
|
+
// The server counted these over every answer, so a cap on the rows the page holds changes none of them. A miss
|
|
2222
|
+
// corrected from its explainer counts as right: fixing it is learning.
|
|
2223
|
+
const { answers: answered, passed, logged: loggedCount, loggedAssessed } = scopeTotals();
|
|
1801
2224
|
const due = seen.filter((c) => c.due);
|
|
1802
2225
|
const mastered = seen.filter((c) => c.mastered);
|
|
1803
2226
|
// Learning's sessions; a memory-only one belongs to the other workflow.
|
|
1804
|
-
const sess =
|
|
1805
|
-
const last30 =
|
|
2227
|
+
const sess = learningSessions();
|
|
2228
|
+
const last30 = lastThirty();
|
|
1806
2229
|
|
|
1807
2230
|
const weakest = seen.filter((c) => !c.mastered).sort((a, b) => a.score - b.score || b.attempts - a.attempts).slice(0, 5);
|
|
1808
2231
|
|
|
1809
2232
|
// Logged is what the work touched; assessed is what a question was actually
|
|
1810
2233
|
// asked about. They are different claims and the page keeps them apart.
|
|
1811
|
-
const logged = new Set(loggedInScope().filter((l) => (l.origin ?? 'work') === 'work').map((l) => l.slug));
|
|
1812
|
-
const asked = new Set(atts.map((a) => a.slug));
|
|
1813
|
-
const loggedAsked = [...logged].filter((x) => asked.has(x)).length;
|
|
1814
2234
|
const inv = projectsInScope();
|
|
1815
2235
|
const unassessed = inv.filter((p) => !p.learning.answers && p.learning.logged_concepts);
|
|
1816
2236
|
|
|
@@ -1822,14 +2242,14 @@ function viewLearningDashboard() {
|
|
|
1822
2242
|
Open the <a class="link" data-go="${esc(LH('review'))}">review queue</a> for the command to run.`)
|
|
1823
2243
|
: PROJECT !== null && !answered
|
|
1824
2244
|
? nextStep(`<strong>No assessments yet in ${esc(scopeName())}.</strong>
|
|
1825
|
-
${
|
|
2245
|
+
${loggedCount ? `${plural(loggedCount, 'concept')} recorded from its work will come up in a quiz.` : 'Nothing has been logged here either.'}`, true)
|
|
1826
2246
|
: '';
|
|
1827
2247
|
|
|
1828
2248
|
return [
|
|
1829
2249
|
pageHead('Am I actually getting better?', [
|
|
1830
2250
|
`${plural(seen.length, 'concept')} asked of ${S.totals.catalogue}`,
|
|
1831
2251
|
plural(answered, 'answer'),
|
|
1832
|
-
`${
|
|
2252
|
+
`${loggedCount} recorded from your work, ${loggedAssessed} assessed`,
|
|
1833
2253
|
`since ${esc(fmtDate(S.totals.first_answer))}`,
|
|
1834
2254
|
...(PROJECT === null && unassessed.length
|
|
1835
2255
|
? [`<a class="link" data-go="${esc(LH('projects'))}">${plural(unassessed.length, 'project')} not assessed yet</a>`] : []),
|
|
@@ -1847,7 +2267,7 @@ function viewLearningDashboard() {
|
|
|
1847
2267
|
|
|
1848
2268
|
next,
|
|
1849
2269
|
|
|
1850
|
-
streakCard(
|
|
2270
|
+
streakCard(st),
|
|
1851
2271
|
|
|
1852
2272
|
card('Answers, last 30 days', null,
|
|
1853
2273
|
`<div class="legend">
|
|
@@ -1875,7 +2295,7 @@ function viewLearningDashboard() {
|
|
|
1875
2295
|
v: `${s.asked.length ? `${s.passed}/${s.asked.length} right` : plural(s.logged.length, 'concept')}<div class="slug">${esc(ago(s.last))}</div>`,
|
|
1876
2296
|
go: sessHref(s.id),
|
|
1877
2297
|
})), 'No sessions in this scope yet.'),
|
|
1878
|
-
{ aside: `<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">All ${
|
|
2298
|
+
{ aside: `<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">All ${sessionTotal()} →</a>` })}
|
|
1879
2299
|
</div>`,
|
|
1880
2300
|
|
|
1881
2301
|
fold('learn:stand', 'Where you stand', `${mastered.length} mastered · ${seen.length - mastered.length} learning`,
|
|
@@ -1944,25 +2364,24 @@ function viewConcepts(param) {
|
|
|
1944
2364
|
pageHead('Concepts', [plural(all.length, 'concept'), `${counts.all - counts.unseen} asked`, `${counts.mastered} mastered`],
|
|
1945
2365
|
`Everything in your graph — what you have been asked, and what is still just catalogue. Open a concept
|
|
1946
2366
|
for every question you were given on it and the code that logged it.`),
|
|
2367
|
+
// The search box is outside every region, so a keystroke replaces the list and leaves the box, the selects and the chips alone.
|
|
1947
2368
|
card(null, null,
|
|
1948
2369
|
`<div class="filters">
|
|
1949
2370
|
<div class="grow"><input type="search" id="q" value="${esc(t.q)}" placeholder="Search concepts" aria-label="Search concepts"></div>
|
|
1950
|
-
|
|
2371
|
+
${region('concepts-selects', `<select id="dom" aria-label="Domain">
|
|
1951
2372
|
<option value="all">All domains</option>
|
|
1952
2373
|
${domains.map((d) => `<option value="${esc(d)}"${t.domain === d ? ' selected' : ''}>${esc(d)}</option>`).join('')}
|
|
1953
2374
|
</select>
|
|
1954
2375
|
<select id="sort" aria-label="Sort">
|
|
1955
2376
|
${[['weakest','Weakest first'],['strongest','Strongest first'],['recent','Recently seen'],['asked','Most asked'],['tier','By tier'],['name','A–Z']]
|
|
1956
2377
|
.map(([v, l]) => `<option value="${v}"${t.sort === v ? ' selected' : ''}>${l}</option>`).join('')}
|
|
1957
|
-
</select
|
|
1958
|
-
</div>
|
|
1959
|
-
<div class="chips" id="chips">
|
|
1960
|
-
${chip('all','All')}${chip('due','Due')}${chip('learning','Learning')}${chip('mastered','Mastered')}${chip('unseen','Not asked')}
|
|
2378
|
+
</select>`)}
|
|
1961
2379
|
</div>
|
|
1962
|
-
|
|
1963
|
-
${
|
|
1964
|
-
|
|
1965
|
-
|
|
2380
|
+
${region('concepts-chips',
|
|
2381
|
+
`${chip('all','All')}${chip('due','Due')}${chip('learning','Learning')}${chip('mastered','Mastered')}${chip('unseen','Not asked')}`,
|
|
2382
|
+
'class="chips" id="chips"')}
|
|
2383
|
+
${region('concepts', `${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}${pager('concepts', list.length, p)}`,
|
|
2384
|
+
'style="margin-top:var(--space-4)"')}`),
|
|
1966
2385
|
].join('');
|
|
1967
2386
|
}
|
|
1968
2387
|
|
|
@@ -2003,8 +2422,8 @@ function viewConcept(slug) {
|
|
|
2003
2422
|
startBlocks('This one is due', startCommand([c])),
|
|
2004
2423
|
|
|
2005
2424
|
card('Every question you were asked', atts.length ? `${plural(atts.length, 'attempt')}, newest first. Open one for the options and the feedback.` : '',
|
|
2006
|
-
atts.length ? history + pager('concept', atts.length, p, PER_Q)
|
|
2007
|
-
: mascotMessage('<p class="empty">Not asked yet. It will come up when your work touches it.</p>')),
|
|
2425
|
+
region('history', atts.length ? history + pager('concept', atts.length, p, PER_Q)
|
|
2426
|
+
: mascotMessage('<p class="empty">Not asked yet. It will come up when your work touches it.</p>'))),
|
|
2008
2427
|
|
|
2009
2428
|
atts.length
|
|
2010
2429
|
? fold('concept:grades', 'How the grades went', plural(atts.length, 'grade'),
|
|
@@ -2038,6 +2457,8 @@ function viewReview(param) {
|
|
|
2038
2457
|
const upcoming = seen.filter((c) => c.owed && !c.due && c.next_review)
|
|
2039
2458
|
.sort((a, b) => String(a.next_review).localeCompare(String(b.next_review)));
|
|
2040
2459
|
const skipped = attemptsInScope().filter((a) => a.outcome === 'declined' || a.outcome === 'dont_know');
|
|
2460
|
+
// How many there are is the server's count over every answer; the list is only the skipped ones among the rows the page holds.
|
|
2461
|
+
const skippedTotal = Math.max(skipped.length, scopeTotals().skipped);
|
|
2041
2462
|
|
|
2042
2463
|
// Fourteen days of scheduled reviews, with everything already overdue in one
|
|
2043
2464
|
// column — a backlog that grows off the left of the chart is a backlog nobody
|
|
@@ -2057,7 +2478,7 @@ function viewReview(param) {
|
|
|
2057
2478
|
const week = buckets.slice(1, 8).reduce((a, b) => a + b.n, 0);
|
|
2058
2479
|
const longest = due.length ? Math.max(...due.map((c) => c.overdue_days ?? 0)) : 0;
|
|
2059
2480
|
|
|
2060
|
-
const tabs = [['due', `Due now ${due.length}`], ['upcoming', `Upcoming ${upcoming.length}`], ['skipped', `Skipped ${
|
|
2481
|
+
const tabs = [['due', `Due now ${due.length}`], ['upcoming', `Upcoming ${upcoming.length}`], ['skipped', `Skipped ${skippedTotal}`]];
|
|
2061
2482
|
const list = t.tab === 'due' ? due : t.tab === 'upcoming' ? upcoming : skipped;
|
|
2062
2483
|
const p = page('review', list.length);
|
|
2063
2484
|
const rows = slice(list, p).map((x) => t.tab === 'skipped'
|
|
@@ -2078,7 +2499,7 @@ function viewReview(param) {
|
|
|
2078
2499
|
return [
|
|
2079
2500
|
pageHead('Review queue', [
|
|
2080
2501
|
due.length ? `<span class="warn">${due.length} due now</span>` : 'nothing due',
|
|
2081
|
-
`${week} in the next 7 days`, `${
|
|
2502
|
+
`${week} in the next 7 days`, `${skippedTotal} skipped`,
|
|
2082
2503
|
...(due.length ? [`longest overdue ${plural(longest, 'day')}`] : []),
|
|
2083
2504
|
], `SM-2 decides when a concept comes back: one day after a miss, then 6, then the interval times its
|
|
2084
2505
|
ease. A score decays about 5% a week while a review is overdue, so this list is also the list of
|
|
@@ -2089,10 +2510,11 @@ function viewReview(param) {
|
|
|
2089
2510
|
card('The next fortnight', null,
|
|
2090
2511
|
`<div class="chart"><svg id="c-forecast" role="img" aria-label="Scheduled reviews for the next fourteen days"></svg></div>`),
|
|
2091
2512
|
|
|
2092
|
-
card(null, null,
|
|
2513
|
+
card(null, null, region('review',
|
|
2093
2514
|
`<div class="chips" id="tabs">` + tabs.map(([k, label]) =>
|
|
2094
2515
|
`<button class="chip" data-tab="${k}" aria-pressed="${t.tab === k}">${esc(label)}</button>`).join('') + `</div>
|
|
2095
|
-
<div style="margin-top:var(--space-4)">${table(cols, rows)}${pager('review', list.length, p)}
|
|
2516
|
+
<div style="margin-top:var(--space-4)">${table(cols, rows)}${pager('review', list.length, p)}
|
|
2517
|
+
${t.tab === 'skipped' && skipped.length < skippedTotal ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(skippedTotal, 'answer')} ${skippedTotal === 1 ? 'was' : 'were'} skipped; the list holds the ${skipped.length} among the newest ${S.attempts_shown} answers.</p>` : ''}</div>`)),
|
|
2096
2518
|
].join('');
|
|
2097
2519
|
}
|
|
2098
2520
|
|
|
@@ -2100,9 +2522,8 @@ function viewSessions() {
|
|
|
2100
2522
|
// Learning's sessions: the ones that logged a concept or asked a question.
|
|
2101
2523
|
// A session that only captured memory is the Memory workflow's, and is
|
|
2102
2524
|
// counted here so it is never silently missing.
|
|
2103
|
-
const
|
|
2104
|
-
const
|
|
2105
|
-
const memOnly = every.length - all.length;
|
|
2525
|
+
const all = learningSessions();
|
|
2526
|
+
const memOnly = sessions().length - all.length;
|
|
2106
2527
|
const p = page('sessions', all.length);
|
|
2107
2528
|
const rows = slice(all, p).map((s) => {
|
|
2108
2529
|
const acc = s.asked.length ? pct(s.passed, s.asked.length) : null;
|
|
@@ -2116,18 +2537,28 @@ function viewSessions() {
|
|
|
2116
2537
|
});
|
|
2117
2538
|
|
|
2118
2539
|
const withQuestions = all.filter((s) => s.asked.length).length;
|
|
2540
|
+
const answers = all.reduce((a, s) => a + s.asked.length, 0);
|
|
2541
|
+
// While the payload's lists are cut the list is the sessions of the newest rows. The count is every session (the
|
|
2542
|
+
// project cards' number), the other two figures are about the listed ones, and the page says which it is saying.
|
|
2543
|
+
const cut = rowsCut();
|
|
2544
|
+
const counts = cut
|
|
2545
|
+
? [plural(sessionTotal(), 'session'), `newest ${all.length} listed`, `${withQuestions} of those with questions`, `${plural(answers, 'answer')} in them`]
|
|
2546
|
+
: [plural(all.length, 'session'), `${withQuestions} with questions`, plural(answers, 'answer')];
|
|
2547
|
+
const built = [
|
|
2548
|
+
...(cutAnswers() ? [`the most recent ${S.attempts_shown} of ${S.attempts_total} answers`] : []),
|
|
2549
|
+
...(cutLines() ? [`the most recent ${S.logged_shown} of ${S.logged_total} logged lines`] : []),
|
|
2550
|
+
].join(' and ');
|
|
2119
2551
|
return [
|
|
2120
|
-
pageHead('Sessions',
|
|
2121
|
-
plural(all.length, 'session'), `${withQuestions} with questions`,
|
|
2122
|
-
plural(all.reduce((a, s) => a + s.asked.length, 0), 'answer'),
|
|
2123
|
-
], `One row per Claude Code session that logged a concept or asked a question. Open one for the whole
|
|
2552
|
+
pageHead('Sessions', counts, `One row per Claude Code session that logged a concept or asked a question. Open one for the whole
|
|
2124
2553
|
thread: the concepts the work touched, every question you were asked, and — joined on the same
|
|
2125
2554
|
session id — what the session captured and remembered.`),
|
|
2126
|
-
card(null, null,
|
|
2555
|
+
card(null, null, region('sessions',
|
|
2127
2556
|
table([{t:'Session'},{t:'Concepts',n:1},{t:'Answers'},{t:'When',n:1}], rows)
|
|
2128
2557
|
+ pager('sessions', all.length, p)
|
|
2558
|
+
+ (cut ? `<p class="slug" style="margin:var(--space-4) 0 0">This list is built from ${built}, so older sessions are counted above
|
|
2559
|
+
but not listed, and the oldest one listed may show only part of its work. Open a session to read it whole.</p>` : '')
|
|
2129
2560
|
+ (memOnly ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(memOnly, 'session')} captured memory and logged no concept —
|
|
2130
|
-
<a class="link" data-go="${esc(MH('sessions'))}">see them in Memory → Sessions</a>.</p>` : '')),
|
|
2561
|
+
<a class="link" data-go="${esc(MH('sessions'))}">see them in Memory → Sessions</a>.</p>` : ''))),
|
|
2131
2562
|
].join('');
|
|
2132
2563
|
}
|
|
2133
2564
|
|
|
@@ -2142,16 +2573,19 @@ const noSession = (id) => mascotMessage(`<p class="empty">No session <code>${esc
|
|
|
2142
2573
|
* came from.
|
|
2143
2574
|
*/
|
|
2144
2575
|
function viewSession(id) {
|
|
2145
|
-
|
|
2576
|
+
// The payload's rows are every row there is only while its lists are not cut (`rowsCut`), and then a session in it is
|
|
2577
|
+
// whole and is drawn with no request. Once they are cut, the rows of a session may be only the newest of its rows, and
|
|
2578
|
+
// nothing in the payload says which ones: a session that began before the cut and ran past it looks like any other
|
|
2579
|
+
// (its older rows are the ones left out). So every session is read from the server, whole.
|
|
2580
|
+
const s = rowsCut() ? null : sessions().find((x) => x.id === id);
|
|
2146
2581
|
if (s) return sessionBody(s);
|
|
2147
|
-
// Not in the payload
|
|
2148
|
-
//
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
return sessionBody(
|
|
2153
|
-
});
|
|
2154
|
-
return `<div class="stack" id="sess-lookup">${loader('Looking up this session…')}</div>`;
|
|
2582
|
+
// Not in the payload (a memory-only session past `memory_sessions`' cap, one outside the current scope, one whose
|
|
2583
|
+
// rows are older than the payload's) or not known to be whole. The paged resource answers for it exactly: its memory
|
|
2584
|
+
// half, and its lines and answers whatever their age (`learning`).
|
|
2585
|
+
return slot('sess-lookup', '/api/memory/sessions?' + memScope(new URLSearchParams({ session: id })), (d) => {
|
|
2586
|
+
const found = sessionFromLookup(id, d);
|
|
2587
|
+
return found ? sessionBody(found) : noSession(id);
|
|
2588
|
+
}, { label: 'Looking up this session…', cls: 'stack' });
|
|
2155
2589
|
}
|
|
2156
2590
|
|
|
2157
2591
|
function sessionBody(s) {
|
|
@@ -2172,28 +2606,26 @@ function sessionBody(s) {
|
|
|
2172
2606
|
|
|
2173
2607
|
// The memory half of this session, folded: what it remembered and what it
|
|
2174
2608
|
// proposed are the audit trail behind the learning, not the headline.
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
});
|
|
2196
|
-
}
|
|
2609
|
+
const memory = !s.mem ? '' : slot('sess-mem', '/api/memory?per=100&session=' + encodeURIComponent(id), (d) => {
|
|
2610
|
+
const entries = d.rows.map((e) => ({
|
|
2611
|
+
t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
|
|
2612
|
+
s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${plural(e.event_count, 'evidence event')}`,
|
|
2613
|
+
v: entryMark(e),
|
|
2614
|
+
go: MH('entry', e.id),
|
|
2615
|
+
}));
|
|
2616
|
+
const cands = (d.candidates ?? []).map((c) => ({
|
|
2617
|
+
t: esc(c.name ?? c.slug),
|
|
2618
|
+
s: `${esc(c.domain ?? '—')} · ${Math.round((c.confidence ?? 0) * 100)}% confidence`,
|
|
2619
|
+
v: `<span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span>`,
|
|
2620
|
+
go: c.concept_slug ? LH('concept', c.concept_slug) : null,
|
|
2621
|
+
}));
|
|
2622
|
+
return fold('sess:mem', 'What the session remembered', plural(entries.length, 'observation'), lines(entries),
|
|
2623
|
+
{ hint: 'Observations distilled from the evidence this session captured.' })
|
|
2624
|
+
+ (cands.length
|
|
2625
|
+
? fold('sess:cands', 'Concepts it proposed', plural(cands.length, 'candidate'), lines(cands),
|
|
2626
|
+
{ hint: 'Derived from evidence, and still only a proposal — nothing here moved mastery.' })
|
|
2627
|
+
: '');
|
|
2628
|
+
}, { label: 'Loading what this session remembered…', cls: 'stack' });
|
|
2197
2629
|
|
|
2198
2630
|
const back = `<a class="page__back" data-go="${esc(href(CUR.wf, 'sessions'))}">← Sessions</a>`;
|
|
2199
2631
|
return [
|
|
@@ -2201,6 +2633,7 @@ function sessionBody(s) {
|
|
|
2201
2633
|
`${esc(stamp(s.first))} → ${esc(stamp(s.last))}`,
|
|
2202
2634
|
`${plural(work.length, 'concept')} logged`,
|
|
2203
2635
|
s.asked.length ? `${s.passed}/${s.asked.length} right (${pct(s.passed, s.asked.length)}%)` : 'no questions',
|
|
2636
|
+
...(s.cutAsked ? [`the newest ${s.cutAsked.shown} of ${s.cutAsked.total} answers`] : []),
|
|
2204
2637
|
...(s.mem ? [`${s.mem.events} captured${s.mem.redacted ? `, ${s.mem.redacted} redacted` : ''}`, `${s.mem.entries} remembered`] : []),
|
|
2205
2638
|
], `Session <code class="slug">${esc(s.id)}</code>. The learning half and the memory half are joined on the
|
|
2206
2639
|
session id the host handed both — the one association between them that is recorded rather than inferred.`, back),
|
|
@@ -2209,14 +2642,18 @@ function sessionBody(s) {
|
|
|
2209
2642
|
? card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.', lines(work.map(conceptLine)))
|
|
2210
2643
|
: '',
|
|
2211
2644
|
|
|
2212
|
-
s.asked.length
|
|
2645
|
+
s.asked.length
|
|
2646
|
+
? card('What you were asked', s.cutAsked
|
|
2647
|
+
? `The newest ${s.cutAsked.shown} of this session's ${s.cutAsked.total} questions, in order. Open one for the options and the feedback.`
|
|
2648
|
+
: `${plural(s.asked.length, 'question')}, in order. Open one for the options and the feedback.`, qa)
|
|
2649
|
+
: '',
|
|
2213
2650
|
|
|
2214
2651
|
review.length
|
|
2215
2652
|
? fold('sess:review', 'Pulled in for review', plural(review.length, 'concept'), lines(review.map(conceptLine)),
|
|
2216
2653
|
{ hint: 'Not from this diff — the tutor widened to prerequisites and neighbours.' })
|
|
2217
2654
|
: '',
|
|
2218
2655
|
|
|
2219
|
-
|
|
2656
|
+
memory,
|
|
2220
2657
|
].join('');
|
|
2221
2658
|
}
|
|
2222
2659
|
|
|
@@ -2229,7 +2666,6 @@ function viewProjects() {
|
|
|
2229
2666
|
const inv = projectsInScope();
|
|
2230
2667
|
const list = inv.filter((p) => p.learning.answers || p.learning.logged_concepts);
|
|
2231
2668
|
const other = inv.filter((p) => !p.learning.answers && !p.learning.logged_concepts);
|
|
2232
|
-
const daily = dailyInScope();
|
|
2233
2669
|
|
|
2234
2670
|
// One fold per project. Its summary line is the standing — level, accuracy,
|
|
2235
2671
|
// answers, last active — and it opens to the runway and thirty days of work.
|
|
@@ -2293,7 +2729,7 @@ function viewProjects() {
|
|
|
2293
2729
|
return [
|
|
2294
2730
|
pageHead('Projects', [
|
|
2295
2731
|
plural(list.length, 'project'), `${assessed.length} assessed`,
|
|
2296
|
-
plural(list.reduce((a, p) => a + p.learning.answers, 0), 'answer'), plural(streaks(
|
|
2732
|
+
plural(list.reduce((a, p) => a + p.learning.answers, 0), 'answer'), plural(streaks().days, 'active day'),
|
|
2297
2733
|
], `Difficulty is earned per codebase, not per person: the same developer is a different learner on their
|
|
2298
2734
|
fourth Node service and their first Rust one. Everyone starts at easy (tiers 1–2) and climbs on
|
|
2299
2735
|
evidence — a promotion needs passing answers <em>and</em> accuracy <em>and</em> distinct concepts. A
|
|
@@ -2368,7 +2804,7 @@ function viewDomain(name) {
|
|
|
2368
2804
|
{ label:'Mastered', n:seen.filter((c) => c.mastered).length, color:'var(--spot)' },
|
|
2369
2805
|
{ label:'Learning', n:seen.filter((c) => !c.mastered).length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
|
|
2370
2806
|
{ label:'Not asked', n:all.length - seen.length, color:'var(--mass)' },
|
|
2371
|
-
]) + `<div style="margin-top:var(--space-5)">${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}${pager('domain', list.length, p)}</div>`),
|
|
2807
|
+
]) + `<div style="margin-top:var(--space-5)" data-region="domain">${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}${pager('domain', list.length, p)}</div>`),
|
|
2372
2808
|
].join('');
|
|
2373
2809
|
}
|
|
2374
2810
|
|
|
@@ -2447,7 +2883,7 @@ function drawMemory(d) {
|
|
|
2447
2883
|
}
|
|
2448
2884
|
const inner = kids.get(e.session_id);
|
|
2449
2885
|
if (e.kind === 'session_summary' && inner) {
|
|
2450
|
-
out.push(`<li><details class="tl-item tl-session">
|
|
2886
|
+
out.push(`<li><details class="tl-item tl-session" data-key="session:${esc(e.session_id)}">
|
|
2451
2887
|
<summary><span class="tl-time">${at ? esc(clock(at)) : '—'}</span>
|
|
2452
2888
|
<span class="tl-title">${esc(e.title.replace(/^Session:\s*/, ''))}</span>
|
|
2453
2889
|
<span class="tl-type">Session · ${plural(inner.length, 'observation')}</span></summary>
|
|
@@ -2507,7 +2943,7 @@ function viewMemory(param) {
|
|
|
2507
2943
|
t.tag = CUR.query.get('tag') || 'all';
|
|
2508
2944
|
t.since = CUR.query.get('from') || '';
|
|
2509
2945
|
t.until = CUR.query.get('to') || '';
|
|
2510
|
-
|
|
2946
|
+
const rows = slot('mem-rows', '/api/memory?' + memoryQuery(type), drawMemory, { label: 'Loading the timeline…', region: true });
|
|
2511
2947
|
|
|
2512
2948
|
// A preset is only a `from` date, so the URL stays the one date format and a
|
|
2513
2949
|
// shared link reads the same; anything else is a custom range.
|
|
@@ -2529,7 +2965,7 @@ function viewMemory(param) {
|
|
|
2529
2965
|
`<div>
|
|
2530
2966
|
<div class="filters">
|
|
2531
2967
|
<div class="grow"><input type="search" id="mq" value="${esc(t.q)}" placeholder="Search memory" aria-label="Search memory"></div>
|
|
2532
|
-
|
|
2968
|
+
${region('timeline-filters', `<select id="mtype" aria-label="Type">${typeOpts}</select>
|
|
2533
2969
|
<select id="mtag" aria-label="Tag">
|
|
2534
2970
|
<option value="all">All tags</option>
|
|
2535
2971
|
${(M.tags ?? []).map((x) => opt(x.tag, `${x.tag} (${x.n})`, t.tag === x.tag)).join('')}
|
|
@@ -2540,9 +2976,9 @@ function viewMemory(param) {
|
|
|
2540
2976
|
${opt('custom', 'Custom range', when === 'custom')}
|
|
2541
2977
|
</select>
|
|
2542
2978
|
${when === 'custom' ? `<input type="date" id="mfrom" value="${esc(t.since)}" aria-label="From date">
|
|
2543
|
-
<input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">` : ''}
|
|
2979
|
+
<input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">` : ''}`)}
|
|
2544
2980
|
</div>
|
|
2545
|
-
|
|
2981
|
+
${rows}
|
|
2546
2982
|
</div>`,
|
|
2547
2983
|
].join('');
|
|
2548
2984
|
}
|
|
@@ -2551,19 +2987,19 @@ function viewMemoryDashboard() {
|
|
|
2551
2987
|
const R = S.reuse, H = S.health, Q = H.queue, M = S.memory;
|
|
2552
2988
|
const scope = () => memScope(new URLSearchParams());
|
|
2553
2989
|
const q6 = scope(); q6.set('per', '6');
|
|
2554
|
-
|
|
2990
|
+
const recent = slot('mem-recent', '/api/memory?' + q6, (d) => lines(d.rows.map((e) => ({
|
|
2555
2991
|
t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
|
|
2556
2992
|
s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${esc(pname(e.project))}`,
|
|
2557
2993
|
v: entryMark(e),
|
|
2558
2994
|
go: MH('entry', e.id),
|
|
2559
|
-
})), 'Nothing remembered in this scope yet.'));
|
|
2995
|
+
})), 'Nothing remembered in this scope yet.'), { label: 'Loading observations…' });
|
|
2560
2996
|
const q5 = scope(); q5.set('per', '5');
|
|
2561
|
-
|
|
2997
|
+
const recentSessions = slot('mem-sessions', '/api/memory/sessions?' + q5, (d) => lines(d.rows.map((m) => ({
|
|
2562
2998
|
t: esc(pname(m.project, m.session_id)),
|
|
2563
2999
|
s: `${m.events} captured · ${m.entries} remembered`,
|
|
2564
3000
|
v: esc(ago(m.last)),
|
|
2565
3001
|
go: MH('session', m.session_id),
|
|
2566
|
-
})), 'No session has captured anything in this scope.'));
|
|
3002
|
+
})), 'No session has captured anything in this scope.'), { label: 'Loading sessions…' });
|
|
2567
3003
|
|
|
2568
3004
|
// The pipeline in two lines: is reuse paying for itself, and is capture alive.
|
|
2569
3005
|
// Each opens its page; the arithmetic lives there.
|
|
@@ -2586,9 +3022,9 @@ function viewMemoryDashboard() {
|
|
|
2586
3022
|
memTiles(),
|
|
2587
3023
|
card('Pipeline', null, pipeline),
|
|
2588
3024
|
`<div class="g2">
|
|
2589
|
-
${card('Recent observations', null,
|
|
3025
|
+
${card('Recent observations', null, recent,
|
|
2590
3026
|
{ aside: `<a class="link" data-go="${esc(MH('timeline'))}">Timeline →</a>` })}
|
|
2591
|
-
${card('Recent sessions', null,
|
|
3027
|
+
${card('Recent sessions', null, recentSessions,
|
|
2592
3028
|
{ aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` })}
|
|
2593
3029
|
</div>`,
|
|
2594
3030
|
].join('');
|
|
@@ -2600,7 +3036,7 @@ function viewMemorySessions() {
|
|
|
2600
3036
|
q.set('page', t.page); q.set('per', PER);
|
|
2601
3037
|
// The learning half of the same session, joined on its id, where there is one.
|
|
2602
3038
|
const learned = new Map(sessions().map((x) => [x.id, x]));
|
|
2603
|
-
|
|
3039
|
+
const rows = slot('msess-rows', '/api/memory/sessions?' + q, (d) => {
|
|
2604
3040
|
t.page = d.page;
|
|
2605
3041
|
return `<p class="counts" style="margin:0 0 var(--space-4)">${plural(d.total, 'session')} in scope</p>`
|
|
2606
3042
|
+ table([{t:'Session'},{t:'Remembered',n:1},{t:'Asked',n:1},{t:'When',n:1}],
|
|
@@ -2614,14 +3050,17 @@ function viewMemorySessions() {
|
|
|
2614
3050
|
<td class="num">${m.entries}</td>
|
|
2615
3051
|
<td class="num">${l && l.asked.length ? l.asked.length : '—'}</td>
|
|
2616
3052
|
<td class="num slug">${esc(ago(m.last))}</td></tr>`;
|
|
2617
|
-
})) + pager('msessions', d.total, d.page, d.per)
|
|
2618
|
-
|
|
3053
|
+
})) + pager('msessions', d.total, d.page, d.per)
|
|
3054
|
+
// Asked is read from the answers the payload holds, so once those are cut a session older than them shows no count.
|
|
3055
|
+
+ (cutAnswers() ? `<p class="slug" style="margin:var(--space-4) 0 0">Asked counts the most recent ${S.attempts_shown} of ${S.attempts_total} answers:
|
|
3056
|
+
a session older than those shows —, and the oldest one that is counted may show only part of its answers.</p>` : '');
|
|
3057
|
+
}, { label: 'Loading sessions…', region: true });
|
|
2619
3058
|
return [
|
|
2620
3059
|
pageHead('Sessions', [],
|
|
2621
3060
|
`One row per session that captured evidence or wrote an observation. Pending is evidence no observation
|
|
2622
3061
|
job has processed yet. Asked is the same session's questions, joined on its session id — a session can
|
|
2623
3062
|
be remembered without ever being assessed.`),
|
|
2624
|
-
card(null, null,
|
|
3063
|
+
card(null, null, rows),
|
|
2625
3064
|
].join('');
|
|
2626
3065
|
}
|
|
2627
3066
|
|
|
@@ -2660,7 +3099,7 @@ function drawEntry(d) {
|
|
|
2660
3099
|
const files = jsonList(e.files);
|
|
2661
3100
|
const link = (x) => `<a class="link" data-go="${esc(MH('entry', x.id))}">${esc(x.title)}</a> <span class="slug">${esc(fmtDate(x.occurred_at))}</span>`;
|
|
2662
3101
|
const evidence = d.events.map((ev) => `
|
|
2663
|
-
<details class="qa">
|
|
3102
|
+
<details class="qa" data-key="ev:${esc(ev.event_uid)}">
|
|
2664
3103
|
<summary class="qa__meta">
|
|
2665
3104
|
<span>${esc(ev.kind)}</span>${ev.tool ? `<span>${esc(ev.tool)}</span>` : ''}
|
|
2666
3105
|
<span>${esc(stamp(ev.occurred_at))}</span><span>${esc(ev.source)}</span>
|
|
@@ -2722,10 +3161,10 @@ function drawEntry(d) {
|
|
|
2722
3161
|
}
|
|
2723
3162
|
|
|
2724
3163
|
function viewEntry(id) {
|
|
2725
|
-
|
|
2726
|
-
d && d.entry ? drawEntry(d) : mascotMessage(`<p class="empty">No observation <code>${esc(id)}</code>.</p>`, 'notFound')
|
|
2727
|
-
|
|
2728
|
-
|
|
3164
|
+
const body = slot('entry-body', '/api/memory/entry?id=' + encodeURIComponent(id), (d) =>
|
|
3165
|
+
d && d.entry ? drawEntry(d) : mascotMessage(`<p class="empty">No observation <code>${esc(id)}</code>.</p>`, 'notFound'),
|
|
3166
|
+
{ label: 'Loading the observation…', cls: 'stack' });
|
|
3167
|
+
return `<div><a class="page__back" data-go="${esc(MH('timeline'))}">← Timeline</a></div>${body}`;
|
|
2729
3168
|
}
|
|
2730
3169
|
|
|
2731
3170
|
/* ---------- Reuse: the savings ledger, receipt by receipt ---------- */
|
|
@@ -2774,11 +3213,11 @@ function viewReuse() {
|
|
|
2774
3213
|
['D — delivered', R.delivered, 'estimated tokens actually injected'],
|
|
2775
3214
|
]),
|
|
2776
3215
|
card('Every receipt', `${plural(rows.length, 'receipt')} in scope. One row per injection.`,
|
|
2777
|
-
table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'Delivery'}], body)
|
|
3216
|
+
region('receipts', table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'Delivery'}], body)
|
|
2778
3217
|
+ pager('reuse', rows.length, p)
|
|
2779
3218
|
+ (R.receipts > R.receipts_shown
|
|
2780
3219
|
? `<p class="slug" style="margin:var(--space-4) 0 0">Showing the most recent ${R.receipts_shown} of ${R.receipts} receipts. The totals above cover all of them.</p>`
|
|
2781
|
-
: '')),
|
|
3220
|
+
: ''))),
|
|
2782
3221
|
].join('');
|
|
2783
3222
|
}
|
|
2784
3223
|
|
|
@@ -2874,7 +3313,7 @@ function viewHealth() {
|
|
|
2874
3313
|
question carries the correction bar there.
|
|
2875
3314
|
============================================================ */
|
|
2876
3315
|
const ARTIFACT_KINDS = ['all', 'explainer', 'artifact', 'to-correct'];
|
|
2877
|
-
const artifactsInScope = () => (S.artifacts ?? []).filter((a) => inScope(a.project));
|
|
3316
|
+
const artifactsInScope = () => derived('artifacts', () => Object.freeze((S.artifacts ?? []).filter((a) => inScope(a.project))));
|
|
2878
3317
|
const artifactUrl = (a) => '/artifacts/' + a.id.split('/').map(encodeURIComponent).join('/');
|
|
2879
3318
|
|
|
2880
3319
|
// The thumbnail's URL names the ground, and the size busts its cache when a page is rewritten.
|
|
@@ -2894,7 +3333,7 @@ function artifactCard(a) {
|
|
|
2894
3333
|
const tag = a.correction === 'open' ? ' · <span class="tag warn">To correct</span>'
|
|
2895
3334
|
: a.correction === 'done' ? ' · <span class="tag ok">Corrected</span>' : '';
|
|
2896
3335
|
return `<article class="art">
|
|
2897
|
-
<a class="art__open" href="${esc(AH('view', a.id))}">
|
|
3336
|
+
<a class="art__open" href="${esc(AH('view', a.id))}" data-focus="art:${esc(a.id)}">
|
|
2898
3337
|
<span class="art__thumb"><img src="${esc(thumbUrl(a, window.eklavyaGround.read()))}" data-thumb="${esc(thumbUrl(a, '@'))}" alt="" loading="lazy" decoding="async"></span>
|
|
2899
3338
|
<span class="title">${esc(a.title)}</span></a>
|
|
2900
3339
|
${a.description ? `<p class="ctx">${esc(a.description)}</p>` : ''}
|
|
@@ -2945,12 +3384,13 @@ function viewArtifacts(param) {
|
|
|
2945
3384
|
['Explainers', counts.explainer, 'one concept each', '', AH('dashboard', 'explainer')],
|
|
2946
3385
|
toCorrectTile(),
|
|
2947
3386
|
]),
|
|
3387
|
+
// The search box is outside every region: a keystroke replaces the gallery and leaves the box and the chips alone.
|
|
2948
3388
|
card(null, null,
|
|
2949
3389
|
`<div class="filters">
|
|
2950
3390
|
<div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search artifacts" aria-label="Search artifacts"></div>
|
|
2951
3391
|
</div>
|
|
2952
|
-
|
|
2953
|
-
|
|
3392
|
+
${region('artifacts-chips', `${chip('all','All')}${chip('explainer','Explainers')}${chip('to-correct','To correct')}${chip('artifact','Other pages')}`, 'class="chips"')}
|
|
3393
|
+
${region('artifacts', body, 'style="margin-top:var(--space-4)"')}`),
|
|
2954
3394
|
].join('');
|
|
2955
3395
|
}
|
|
2956
3396
|
|
|
@@ -2966,42 +3406,74 @@ function viewArtifact(id) {
|
|
|
2966
3406
|
const a = (S.artifacts ?? []).find((x) => x.id === id);
|
|
2967
3407
|
if (!a) return viewNotFound(id, false);
|
|
2968
3408
|
if (a.correction) AFTER.push(() => loadFix(a));
|
|
2969
|
-
// The
|
|
3409
|
+
// The frame that follows the ground and the fallback for a page that never reports its height are made once, for
|
|
3410
|
+
// the frame, not once per draw of the screen around it: the frame is not drawn again with every redraw (below).
|
|
2970
3411
|
AFTER.push(() => {
|
|
2971
3412
|
const f = $('art-frame');
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
const f = $('art-frame'), gen = GEN;
|
|
2976
|
-
setTimeout(() => { if (gen === GEN && !f.style.height) sized(f, '80vh'); }, 1500);
|
|
3413
|
+
if (!f || f.dataset.watched) return;
|
|
3414
|
+
f.dataset.watched = '1';
|
|
3415
|
+
setTimeout(() => { if (f.isConnected && !f.style.height) sized(f, '80vh'); }, 1500);
|
|
2977
3416
|
});
|
|
2978
3417
|
TABS.open(id);
|
|
2979
|
-
|
|
3418
|
+
// A screen that is drawn again keeps its frame, so it keeps where its scroll is being remembered as well.
|
|
3419
|
+
if (!ADJUSTING) TABS.reading = null;
|
|
3420
|
+
// The frame is the box: it holds the height this tab last reported (else 80vh) from the first paint, so
|
|
3421
|
+
// the page reflows once, when the real height arrives, and not once for a loader and again for the frame.
|
|
3422
|
+
// A tab opened before has no loader: the frame fills the box as soon as its page paints, and the box is
|
|
3423
|
+
// already the right size. The question behind the correction bar is the same when the cache has it.
|
|
3424
|
+
//
|
|
3425
|
+
// The box and the bar are held (`data-hold`): once drawn, a redraw of this screen (new data, the ground) never
|
|
3426
|
+
// replaces them, because a frame that leaves the document loads its page again. The tab strip is a region, and
|
|
3427
|
+
// is the part of the screen that changes with the payload (a tab's title, its dot).
|
|
3428
|
+
const height = TABS.height.get(id);
|
|
3429
|
+
const asked = a.correction && respGet(fixUrl(a));
|
|
2980
3430
|
return `${drawTabs(id)}
|
|
2981
|
-
<div class="viewer__bar"><a href="${esc(artifactUrl(a))}" target="_blank" rel="noopener">${ICON_OUT}Open in new browser tab</a></div>
|
|
2982
|
-
<div
|
|
2983
|
-
|
|
2984
|
-
|
|
3431
|
+
<div class="viewer__bar"><a href="${esc(artifactUrl(a))}" target="_blank" rel="noopener" data-focus="art-open">${ICON_OUT}Open in new browser tab</a></div>
|
|
3432
|
+
<div class="viewer__box" data-hold>
|
|
3433
|
+
${height ? '' : `<div id="art-wait">${loader('Opening the page…')}</div>`}
|
|
3434
|
+
<iframe class="viewer" id="art-frame" style="--wait-h:${esc(height ?? '80vh')}" src="${esc(artifactUrl(a))}?embed" title="${esc(a.title)}" sandbox="${FRAME_SANDBOX}"></iframe>
|
|
3435
|
+
</div>
|
|
3436
|
+
${a.correction ? `<div class="fixbar" id="fixbar" role="region" aria-label="Correction" data-hold>${asked ? '' : loader('Loading the question…')}</div>` : ''}`;
|
|
2985
3437
|
}
|
|
2986
3438
|
|
|
3439
|
+
/** The page's own ground follows this one; a page made before the viewer ignores it. Said when the frame loads and when the ground changes. */
|
|
3440
|
+
const frameMode = (f) => f?.contentWindow?.postMessage({ type:'eklavya:mode', mode:window.eklavyaGround.read() }, '*');
|
|
3441
|
+
// A `load` does not bubble, but a listener in the capture phase on an ancestor hears it, so one listener serves every frame there will be.
|
|
3442
|
+
$('view').addEventListener('load', (e) => { if (e.target.id === 'art-frame') frameMode(e.target); }, true);
|
|
3443
|
+
|
|
3444
|
+
const fixUrl = (a) => `/api/attempts/correction?id=${a.attempt}`;
|
|
3445
|
+
|
|
2987
3446
|
/**
|
|
2988
|
-
* Asks the server for the question behind the bar. Not `
|
|
2989
|
-
* shows the server's own words and a Retry, in the bar's one row.
|
|
3447
|
+
* Asks the server for the question behind the bar, or takes it from the cache. Not `slot()`: a refusal
|
|
3448
|
+
* shows the server's own words and a Retry, in the bar's one row, and the bar's markup is the viewer's.
|
|
3449
|
+
*
|
|
3450
|
+
* A bar that is already drawn for this question and says what the payload says (open, or corrected) is left exactly
|
|
3451
|
+
* as it is when the screen is drawn again for new data: it is the reader's, and may be open in front of the dialog.
|
|
3452
|
+
* One that disagrees is read again with the bar still showing and drawn over once the answer is in, with no loader.
|
|
2990
3453
|
*/
|
|
2991
3454
|
function loadFix(a) {
|
|
2992
3455
|
const gen = GEN;
|
|
2993
3456
|
const bar = () => (gen === GEN ? $('fixbar') : null);
|
|
2994
|
-
|
|
2995
|
-
bar().
|
|
2996
|
-
|
|
3457
|
+
const url = fixUrl(a);
|
|
3458
|
+
const drawn = bar().dataset.attempt === String(a.attempt);
|
|
3459
|
+
if (drawn && (a.correction === 'done') === (FIX?.q.corrected_at != null)) return;
|
|
3460
|
+
const kept = respGet(url);
|
|
3461
|
+
if (kept) return paintFix(bar(), a, kept.body);
|
|
3462
|
+
const at = respAt();
|
|
3463
|
+
if (!drawn) {
|
|
3464
|
+
bar().className = 'fixbar';
|
|
3465
|
+
bar().innerHTML = loader('Loading the question…');
|
|
3466
|
+
}
|
|
3467
|
+
fetch(url)
|
|
2997
3468
|
.then((r) => r.json().catch(() => ({})).then((d) => {
|
|
2998
3469
|
if (!r.ok) throw new Error(d.error || `${r.status} ${r.statusText}`);
|
|
3470
|
+
respPut(url, d, at);
|
|
2999
3471
|
const el = bar();
|
|
3000
3472
|
if (el) paintFix(el, a, d);
|
|
3001
3473
|
}))
|
|
3002
3474
|
.catch((e) => {
|
|
3003
3475
|
const el = bar();
|
|
3004
|
-
if (!el) return;
|
|
3476
|
+
if (!el || drawn) return;
|
|
3005
3477
|
el.innerHTML = `<p class="fixbar__err">${esc(e.message)}</p><button type="button" class="set__btn" id="fix-retry">Retry</button>`;
|
|
3006
3478
|
});
|
|
3007
3479
|
}
|
|
@@ -3011,14 +3483,18 @@ function paintFix(el, a, d, fresh) {
|
|
|
3011
3483
|
FIX = { attempt: a.attempt, artifact: a.id, q: d };
|
|
3012
3484
|
const done = d.corrected_at !== null;
|
|
3013
3485
|
armLeave(!done);
|
|
3486
|
+
el.dataset.attempt = String(a.attempt);
|
|
3014
3487
|
el.className = `fixbar ${done ? 'is-done' : 'is-open'}${fresh ? ' is-fresh' : ''}`;
|
|
3015
3488
|
const dot = document.querySelector('#view [aria-selected="true"] .tab__dot');
|
|
3016
3489
|
if (dot) dot.className = `tab__dot is-${done ? 'done' : 'open'}`;
|
|
3490
|
+
// The button the reader is on (a bar drawn again over its own) is found again in the new one.
|
|
3491
|
+
const ui = captureUi(el);
|
|
3017
3492
|
el.innerHTML = done
|
|
3018
3493
|
? `<p class="fixbar__done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>Corrected on try ${d.tries} <span>· ${esc(stamp(d.corrected_at))}</span></p>`
|
|
3019
3494
|
: `<div class="fixbar__text"><p class="fixbar__lead">You missed this one.</p>
|
|
3020
3495
|
<p class="fixbar__note">Read it, then correct your answer. Not now? It stays under To correct.</p></div>
|
|
3021
3496
|
<button type="button" class="btn" id="fix-open">Correct your answer</button>`;
|
|
3497
|
+
restoreUi(el, ui);
|
|
3022
3498
|
}
|
|
3023
3499
|
|
|
3024
3500
|
/** The options in a new order each time: the page above shows the right one at its letter. */
|
|
@@ -3055,6 +3531,8 @@ function pickFix(btn) {
|
|
|
3055
3531
|
all.forEach((b) => { b.disabled = true; });
|
|
3056
3532
|
postJson('/api/attempts/retry', { attempt_id: FIX.attempt, picked: btn.dataset.pick })
|
|
3057
3533
|
.then((r) => {
|
|
3534
|
+
// Tries went up, and a right pick closed the question: what the bar was drawn from is out of date.
|
|
3535
|
+
respForget('/api/attempts/correction');
|
|
3058
3536
|
if (r.corrected) {
|
|
3059
3537
|
btn.classList.add('is-right');
|
|
3060
3538
|
btn.querySelector('.fix__tag').textContent = 'Right answer';
|
|
@@ -3062,9 +3540,8 @@ function pickFix(btn) {
|
|
|
3062
3540
|
FIX.q = { ...FIX.q, tries: r.tries, corrected_at: new Date().toISOString() };
|
|
3063
3541
|
const row = S.artifacts.find((x) => x.id === FIX.artifact);
|
|
3064
3542
|
if (row) row.correction = 'done';
|
|
3065
|
-
// Every other count on the page
|
|
3066
|
-
//
|
|
3067
|
-
$('stale').hidden = false;
|
|
3543
|
+
// Every other count on the page (the tiles, the gallery's tag, the accuracy) follows by itself: the server
|
|
3544
|
+
// noticed this write and told the page, which reads the payload again and redraws in place.
|
|
3068
3545
|
armLeave(false);
|
|
3069
3546
|
$('fix-close').focus();
|
|
3070
3547
|
return;
|
|
@@ -3124,6 +3601,7 @@ function armLeave(on) {
|
|
|
3124
3601
|
*/
|
|
3125
3602
|
function sized(f, h) {
|
|
3126
3603
|
f.style.height = h;
|
|
3604
|
+
TABS.height.set(CUR.param, h);
|
|
3127
3605
|
$('art-wait')?.remove();
|
|
3128
3606
|
if (TABS.reading === CUR.param) return;
|
|
3129
3607
|
const want = TABS.scroll.get(CUR.param) ?? 0;
|
|
@@ -3218,7 +3696,11 @@ const TIP = {
|
|
|
3218
3696
|
return node?.getClientRects().length && !(narrow.matches && side.contains(node)) ? [[t, node]] : [];
|
|
3219
3697
|
});
|
|
3220
3698
|
// Same features, same nodes: leave a bubble the reader opened alone.
|
|
3699
|
+
// Setting the hints closes the bubble that is open. A redraw that replaced some other feature's element is no reason to
|
|
3700
|
+
// close the one the reader is reading, so it is opened again when its tip still applies (`reopen`, below).
|
|
3701
|
+
let reopen = null;
|
|
3221
3702
|
if (eligible.length !== this.shown.length || eligible.some(([t, n], i) => this.shown[i][0] !== t.id || this.shown[i][1] !== n)) {
|
|
3703
|
+
reopen = h.getActive()?.id ?? null;
|
|
3222
3704
|
this.shown = eligible.map(([t, n]) => [t.id, n]);
|
|
3223
3705
|
h.setHints(eligible.map(([t, n]) => ({
|
|
3224
3706
|
id: t.id,
|
|
@@ -3245,6 +3727,11 @@ const TIP = {
|
|
|
3245
3727
|
})));
|
|
3246
3728
|
}
|
|
3247
3729
|
h.show();
|
|
3730
|
+
if (reopen && eligible.some(([t]) => t.id === reopen)) {
|
|
3731
|
+
const was = document.activeElement;
|
|
3732
|
+
h.open(reopen);
|
|
3733
|
+
if (was?.isConnected && was !== document.body) was.focus({ preventScroll: true });
|
|
3734
|
+
}
|
|
3248
3735
|
// Only the screen's first tip opens by itself, and only once; the rest stay beacons.
|
|
3249
3736
|
const first = eligible[0];
|
|
3250
3737
|
if (!first || st.opened.includes(first[0].id)) return;
|
|
@@ -3292,6 +3779,7 @@ const TABS = {
|
|
|
3292
3779
|
ids: null, // open pages, in strip order
|
|
3293
3780
|
recent: [], // the same ids, least recently viewed first
|
|
3294
3781
|
scroll: new Map(), // id → scrollY
|
|
3782
|
+
height: new Map(), // id → the height its page last reported, the box it is opened in next time
|
|
3295
3783
|
reading: null, // the tab whose scroll is being remembered
|
|
3296
3784
|
focus: false, // put focus back on the selected tab after the next draw
|
|
3297
3785
|
active: null, // the page the strip last showed as selected; new tabs open right of it
|
|
@@ -3326,6 +3814,7 @@ const TABS = {
|
|
|
3326
3814
|
this.ids.splice(at, 1);
|
|
3327
3815
|
this.recent = this.recent.filter((x) => x !== id);
|
|
3328
3816
|
this.scroll.delete(id);
|
|
3817
|
+
this.height.delete(id);
|
|
3329
3818
|
this.save();
|
|
3330
3819
|
const next = this.ids[at] ?? this.ids[at - 1];
|
|
3331
3820
|
return next ? AH('view', next) : AH('dashboard');
|
|
@@ -3358,7 +3847,9 @@ function drawTabs(active) {
|
|
|
3358
3847
|
const dot = a?.correction ? `<i class="tab__dot is-${a.correction}" aria-hidden="true"></i>` : '';
|
|
3359
3848
|
return tab(id, a?.title ?? id, dot, true);
|
|
3360
3849
|
});
|
|
3361
|
-
|
|
3850
|
+
// The strip is a region of its own: what the payload changes in it (a title, a dot) is redrawn here, and the
|
|
3851
|
+
// frame under it is not touched.
|
|
3852
|
+
return `<div class="tabs" role="tablist" aria-label="Open artifacts" data-region="art-tabs">${tab(null, 'All artifacts', '', false)}${rows.join('')}</div>`;
|
|
3362
3853
|
}
|
|
3363
3854
|
|
|
3364
3855
|
/** Closes a tab. The visible one hands over to its neighbour; any other just leaves the strip, and the page stays put. */
|
|
@@ -3367,7 +3858,9 @@ function closeTab(id, fromKeys) {
|
|
|
3367
3858
|
if (CUR.page === 'view' && CUR.param === id) { TABS.focus = fromKeys; location.replace(next); return; }
|
|
3368
3859
|
const strip = document.querySelector('#view .tabs');
|
|
3369
3860
|
const active = CUR.page === 'view' ? CUR.param : null;
|
|
3370
|
-
|
|
3861
|
+
const html = active || TABS.ids.length ? drawTabs(active) : '';
|
|
3862
|
+
strip.outerHTML = html;
|
|
3863
|
+
recordStrip(html);
|
|
3371
3864
|
if (fromKeys) document.querySelector('#view [role="tab"][aria-selected="true"]')?.focus();
|
|
3372
3865
|
}
|
|
3373
3866
|
|
|
@@ -3451,8 +3944,50 @@ function viewArtifactProjects() {
|
|
|
3451
3944
|
registry of fields lives on the server (`SETTINGS` in dashboard.ts).
|
|
3452
3945
|
============================================================ */
|
|
3453
3946
|
let SETS = null; // the last /api/settings response
|
|
3947
|
+
let SETS_AT = null; // what it was read under: `{ cursor, project, gen }`; null once something made it stale
|
|
3948
|
+
let DATA_GEN = 0; // counts the times what the page holds was dropped, so a read already out when that happens is not trusted
|
|
3454
3949
|
const SH = (page, param, q) => href('settings', page, param, q);
|
|
3455
3950
|
const settingsUrl = () => '/api/settings' + (PROJECT ? '?project=' + encodeURIComponent(PROJECT) : '');
|
|
3951
|
+
const settingsNow = () => ({ cursor: S.cursor, project: PROJECT, gen: DATA_GEN });
|
|
3952
|
+
|
|
3953
|
+
/** Keeps a response, and what it was read under. `at` is taken when the request is made, not when it lands. */
|
|
3954
|
+
function keepSettings(d, at) {
|
|
3955
|
+
SETS = d;
|
|
3956
|
+
SETS_AT = at;
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
/**
|
|
3960
|
+
* Whether `SETS` can be drawn from as it is. One response holds both scopes (the user file, and the
|
|
3961
|
+
* project's when one was asked for), so it serves any tab of either, for as long as the project and
|
|
3962
|
+
* the cursor it was read under are still the page's, and nothing has dropped it since it was asked for.
|
|
3963
|
+
*/
|
|
3964
|
+
const settingsFresh = () => !!SETS && !!SETS_AT && SETS_AT.cursor === S.cursor && SETS_AT.project === PROJECT && SETS_AT.gen === DATA_GEN;
|
|
3965
|
+
|
|
3966
|
+
/**
|
|
3967
|
+
* `SETS` is no longer drawn from: a project change (it holds one project's answer), and everything
|
|
3968
|
+
* `invalidateData()` does. `SETS` itself stays (its field list still serves a control being changed).
|
|
3969
|
+
* A read that was out when this ran is not trusted either: it may have been answered before whatever
|
|
3970
|
+
* made this necessary.
|
|
3971
|
+
*/
|
|
3972
|
+
function invalidateSettings() {
|
|
3973
|
+
DATA_GEN += 1;
|
|
3974
|
+
SETS_AT = null;
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
/**
|
|
3978
|
+
* Drops what the page holds that was read under data it has since replaced, so the next draw reads
|
|
3979
|
+
* it again: after a save, and when new data arrives (`adoptState`: the stream said the server's cursor
|
|
3980
|
+
* moved on, and the page's `S` is now the payload that goes with it). That is the settings read and every
|
|
3981
|
+
* answer in the response cache.
|
|
3982
|
+
*
|
|
3983
|
+
* A project change is not here. The cache's keys carry the project, so what was read for another
|
|
3984
|
+
* project is simply not asked for, and kept for when the reader switches back; only the one settings
|
|
3985
|
+
* read, which holds a single project's answer, is dropped (`invalidateSettings`).
|
|
3986
|
+
*/
|
|
3987
|
+
function invalidateData() {
|
|
3988
|
+
invalidateSettings();
|
|
3989
|
+
RESP.clear();
|
|
3990
|
+
}
|
|
3456
3991
|
|
|
3457
3992
|
const showValue = (v) =>
|
|
3458
3993
|
v === true ? 'on' : v === false ? 'off' : v === null || v === undefined ? 'none'
|
|
@@ -3565,7 +4100,6 @@ const SW_ICON = {
|
|
|
3565
4100
|
* what this scope's file actually sets.
|
|
3566
4101
|
*/
|
|
3567
4102
|
function drawSettings(d, scope) {
|
|
3568
|
-
SETS = d;
|
|
3569
4103
|
const tabParam = (id) => (id === swTab(d.fields[0].group) ? null : id);
|
|
3570
4104
|
const page = scope === 'project' ? 'project' : 'dashboard';
|
|
3571
4105
|
const file = scope === 'project' ? d.project : d.user;
|
|
@@ -3578,8 +4112,10 @@ function drawSettings(d, scope) {
|
|
|
3578
4112
|
const here = tabs.find((t) => t.id === CUR.param) ?? tabs[0];
|
|
3579
4113
|
|
|
3580
4114
|
const scopeLink = (label, icon, to, current) =>
|
|
3581
|
-
`<a class="sw__scope" href="${esc(to)}"${current ? ' aria-current="page"' : ''}>${svg(icon)}<span>${esc(label)}</span></a>`;
|
|
3582
|
-
|
|
4115
|
+
`<a class="sw__scope" href="${esc(to)}" data-focus="scope:${esc(label)}"${current ? ' aria-current="page"' : ''}>${svg(icon)}<span>${esc(label)}</span></a>`;
|
|
4116
|
+
// The rail is a region of its own: its links carry the tab (picking another scope keeps it), so
|
|
4117
|
+
// they are redrawn when the tab changes, while the element and the pane's neighbours stay.
|
|
4118
|
+
const rail = `<nav class="sw__scopes" aria-label="Settings scope" data-region="sw-rail">
|
|
3583
4119
|
<p class="sw__cap">Everywhere</p>
|
|
3584
4120
|
${scopeLink('User', SW_ICON.user, SH('dashboard', tabParam(here.id)), scope === 'user')}
|
|
3585
4121
|
<p class="sw__cap">Projects</p>
|
|
@@ -3594,17 +4130,19 @@ function drawSettings(d, scope) {
|
|
|
3594
4130
|
<code>eklavya config set <key> <value></code> (with <code>--project</code> for a project), and
|
|
3595
4131
|
<b>Inherit</b> / <b>Reset</b> is <code>eklavya config unset</code>. Nothing is written inside a repository.`;
|
|
3596
4132
|
|
|
4133
|
+
// The pane is three regions and the rail a fourth, which never nest. A tab redraws the three it changes and keeps the
|
|
4134
|
+
// frame around them; new data redraws what changed, and leaves the body alone while the reader is using a control in it
|
|
4135
|
+
// (`swapRegions`), so the tab strip and the counts beside it are still brought up to date.
|
|
4136
|
+
const pane = (head, tabs, body) => `<div class="sw"><div class="sw__pane">${region('sw-head', head)}${region('sw-tabs', tabs)}${region('sw-body', body)}</div>${rail}</div>`;
|
|
4137
|
+
|
|
3597
4138
|
if (!file) {
|
|
3598
|
-
return
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
? `<strong>${esc(scopeName())}</strong> has no git checkout on this machine, so there is no project file to change. Pick another project on the right.`
|
|
3602
|
-
: 'Pick a project on the right to change its settings.'),
|
|
3603
|
-
].join('')}</div>${rail}</div>`;
|
|
4139
|
+
return pane(pageHead('Project settings', [], about) + nextStep(PROJECT
|
|
4140
|
+
? `<strong>${esc(scopeName())}</strong> has no git checkout on this machine, so there is no project file to change. Pick another project on the right.`
|
|
4141
|
+
: 'Pick a project on the right to change its settings.'), '', '');
|
|
3604
4142
|
}
|
|
3605
4143
|
|
|
3606
|
-
const tabBar = `<nav class="sw__tabs" aria-label="Setting categories">${tabs.map((t) =>
|
|
3607
|
-
`<a class="sw__tab" href="${esc(SH(page, tabParam(t.id)))}"${t.id === here.id ? ' aria-current="page"' : ''}>${esc(t.label)}${t.n ? `<i title="${t.n} set in this scope">${t.n}</i>` : ''}</a>`).join('')}</nav>`;
|
|
4144
|
+
const tabBar = `<nav class="sw__tabs" aria-label="Setting categories" data-keep-scroll="sw-tabs">${tabs.map((t) =>
|
|
4145
|
+
`<a class="sw__tab" href="${esc(SH(page, tabParam(t.id)))}" data-focus="tab:${esc(t.id)}"${t.id === here.id ? ' aria-current="page"' : ''}>${esc(t.label)}${t.n ? `<i title="${t.n} set in this scope">${t.n}</i>` : ''}</a>`).join('')}</nav>`;
|
|
3608
4146
|
|
|
3609
4147
|
let body;
|
|
3610
4148
|
if (here.id === 'terminal') {
|
|
@@ -3626,13 +4164,11 @@ function drawSettings(d, scope) {
|
|
|
3626
4164
|
body = `<section class="card sw__list" aria-label="${esc(here.label)} settings">${here.fields.map((f) => settingRow(f, d, scope)).join('')}</section>`;
|
|
3627
4165
|
}
|
|
3628
4166
|
|
|
3629
|
-
return
|
|
4167
|
+
return pane(
|
|
3630
4168
|
pageHead(scope === 'project' ? `${esc(d.project.name)} settings` : 'User settings',
|
|
3631
|
-
[`<span class="mono">${esc(file.path)}</span>`, `${Object.keys(file.set).length} set here`], about)
|
|
3632
|
-
|
|
3633
|
-
tabBar,
|
|
3634
|
-
body,
|
|
3635
|
-
].join('')}</div>${rail}</div>`;
|
|
4169
|
+
[`<span class="mono">${esc(file.path)}</span>`, `${Object.keys(file.set).length} set here`], about, '', true)
|
|
4170
|
+
+ (d.problem ? nextStep(`<strong>A config file is not being read.</strong> ${esc(d.problem)}`) : ''),
|
|
4171
|
+
tabBar, body);
|
|
3636
4172
|
}
|
|
3637
4173
|
|
|
3638
4174
|
/* ============================================================
|
|
@@ -3660,7 +4196,7 @@ const FB = {
|
|
|
3660
4196
|
};
|
|
3661
4197
|
|
|
3662
4198
|
/** A command in a copyable block, as the start-review commands are. */
|
|
3663
|
-
const fbCmd = (text) => `<div class="cmd"><code>${esc(text)}</code><button type="button" class="cmd__copy" data-copy>Copy</button></div>`;
|
|
4199
|
+
const fbCmd = (text) => `<div class="cmd"><code>${esc(text)}</code><button type="button" class="cmd__copy" data-copy data-focus="copy:${esc(text)}">Copy</button></div>`;
|
|
3664
4200
|
|
|
3665
4201
|
/** Why there is no item: the one reason, in plain words. */
|
|
3666
4202
|
function fbEmpty(F) {
|
|
@@ -3685,13 +4221,13 @@ function fbEmpty(F) {
|
|
|
3685
4221
|
|
|
3686
4222
|
function viewFeedbackDashboard() {
|
|
3687
4223
|
const F = S.feedback;
|
|
3688
|
-
|
|
4224
|
+
const item = F.pending ? slot('fb-item', '/api/feedback', (d) => d.item ? drawFbItem(d.item) : fbEmpty(S.feedback), { label: 'Loading your feedback…' }) : '';
|
|
3689
4225
|
if (F.pending && !FB.counted) { FB.counted = true; AFTER.push(fbOpened); }
|
|
3690
4226
|
const counts = [F.pending ? '1 waiting' : 'nothing waiting', plural(F.acknowledged, 'acknowledged item')];
|
|
3691
4227
|
return [
|
|
3692
4228
|
pageHead('Feedback', counts, FB.about),
|
|
3693
4229
|
`<div class="fb__rule" id="fb-rule"><p>${esc(F.pending ? FB.rulePending : FB.ruleEmpty)}</p></div>`,
|
|
3694
|
-
F.pending ?
|
|
4230
|
+
F.pending ? item : fbEmpty(F),
|
|
3695
4231
|
fbTip(),
|
|
3696
4232
|
`<span class="sr" id="copy-live" aria-live="polite"></span>`,
|
|
3697
4233
|
].join('');
|
|
@@ -3722,10 +4258,10 @@ function fbTipDismiss() {
|
|
|
3722
4258
|
}
|
|
3723
4259
|
|
|
3724
4260
|
function viewFeedbackItem(id) {
|
|
3725
|
-
|
|
4261
|
+
const item = slot('fb-item', '/api/feedback?id=' + encodeURIComponent(id), (d) => drawFbItem(d.item), { label: 'Loading your feedback…' });
|
|
3726
4262
|
return [
|
|
3727
4263
|
pageHead('Feedback item', [], FB.about, `<a class="page__back" data-go="${esc(href('feedback', 'history'))}">← History</a>`),
|
|
3728
|
-
|
|
4264
|
+
item,
|
|
3729
4265
|
`<span class="sr" id="copy-live" aria-live="polite"></span>`,
|
|
3730
4266
|
].join('');
|
|
3731
4267
|
}
|
|
@@ -3754,7 +4290,7 @@ function drawFbItem(it) {
|
|
|
3754
4290
|
<div class="fb__grid">
|
|
3755
4291
|
<section><h2 class="fb__h">Your prompt</h2><blockquote class="fb__quote">${esc(it.prompt)}</blockquote></section>
|
|
3756
4292
|
<section><h2 class="fb__h">A better prompt</h2>
|
|
3757
|
-
<div class="cmd cmd--block"><code>${esc(it.better)}</code><button type="button" class="cmd__copy" data-copy>Copy</button></div></section>
|
|
4293
|
+
<div class="cmd cmd--block"><code>${esc(it.better)}</code><button type="button" class="cmd__copy" data-copy data-focus="copy:better">Copy</button></div></section>
|
|
3758
4294
|
</div>
|
|
3759
4295
|
${review}
|
|
3760
4296
|
<section><h2 class="fb__h">Tips</h2><ul class="fb__tips">${it.tips.map((t) => `<li>${esc(t)}</li>`).join('')}</ul></section>
|
|
@@ -3766,10 +4302,10 @@ function viewFeedbackHistory() {
|
|
|
3766
4302
|
const p = new URLSearchParams();
|
|
3767
4303
|
if (PROJECT !== null) p.set('project', PROJECT);
|
|
3768
4304
|
p.set('page', T.fbhistory.page);
|
|
3769
|
-
|
|
4305
|
+
const rows = slot('fb-rows', '/api/feedback/list?' + p, drawFbHistory, { label: 'Loading history…', cls: 'card card--flush', region: true });
|
|
3770
4306
|
return [
|
|
3771
4307
|
pageHead('History', [plural(S.feedback.acknowledged, 'acknowledged item')], FB.about),
|
|
3772
|
-
|
|
4308
|
+
rows,
|
|
3773
4309
|
].join('');
|
|
3774
4310
|
}
|
|
3775
4311
|
|
|
@@ -3802,7 +4338,8 @@ function fbOpened() {
|
|
|
3802
4338
|
|
|
3803
4339
|
/** After a press: redraw from the new state, put focus on the heading and say so. */
|
|
3804
4340
|
function fbDone(text) {
|
|
3805
|
-
|
|
4341
|
+
// What the screen is about has changed (the item is gone), so it is drawn afresh, from the top.
|
|
4342
|
+
render({ nav: true });
|
|
3806
4343
|
const h = document.querySelector('#view h1');
|
|
3807
4344
|
if (h) { h.tabIndex = -1; h.focus({ preventScroll: true }); }
|
|
3808
4345
|
// The region is outside the view, so it exists before the text does.
|
|
@@ -3818,7 +4355,8 @@ function fbAcknowledge(btn) {
|
|
|
3818
4355
|
if (btn.disabled) return;
|
|
3819
4356
|
btn.disabled = true;
|
|
3820
4357
|
postJson('/api/feedback/acknowledge', { id: +btn.dataset.fbAck })
|
|
3821
|
-
|
|
4358
|
+
// The item, its history row and the next one waiting all changed: what was read of them is gone.
|
|
4359
|
+
.then((d) => { S.feedback = d.state; respForget('/api/feedback'); fbDone(FB.announce); })
|
|
3822
4360
|
.catch((e) => { btn.disabled = false; fbFail(e); });
|
|
3823
4361
|
}
|
|
3824
4362
|
|
|
@@ -3834,13 +4372,22 @@ function fbDelete(btn) {
|
|
|
3834
4372
|
if (btn.disabled) return;
|
|
3835
4373
|
btn.disabled = true;
|
|
3836
4374
|
postJson('/api/feedback/delete', { id: +btn.dataset.fbYes })
|
|
3837
|
-
.then((d) => { S.feedback = d.state; fbDone('Deleted.'); })
|
|
4375
|
+
.then((d) => { S.feedback = d.state; respForget('/api/feedback'); fbDone('Deleted.'); })
|
|
3838
4376
|
.catch((e) => { btn.disabled = false; fbFail(e); });
|
|
3839
4377
|
}
|
|
3840
4378
|
|
|
4379
|
+
/**
|
|
4380
|
+
* Drawn from `SETS` when it holds what is asked for, so a tab is instant and makes no request; read
|
|
4381
|
+
* from the server only when it is missing or stale.
|
|
4382
|
+
*/
|
|
3841
4383
|
function viewSettings(scope) {
|
|
3842
|
-
|
|
3843
|
-
|
|
4384
|
+
if (settingsFresh()) {
|
|
4385
|
+
AFTER.push(() => remember('settings'));
|
|
4386
|
+
return slotHost('settings', drawSettings(SETS, scope));
|
|
4387
|
+
}
|
|
4388
|
+
// Not what `SETS` holds, but possibly what the page read for this project earlier: the cache answers that.
|
|
4389
|
+
const at = settingsNow();
|
|
4390
|
+
return slot('settings', settingsUrl(), (d) => { keepSettings(d, at); return drawSettings(d, scope); }, { label: 'Reading your settings…' });
|
|
3844
4391
|
}
|
|
3845
4392
|
|
|
3846
4393
|
/** Reads a control back into the value its field stores. */
|
|
@@ -3867,8 +4414,9 @@ function postJson(path, body) {
|
|
|
3867
4414
|
}
|
|
3868
4415
|
|
|
3869
4416
|
/**
|
|
3870
|
-
* Saves one setting and redraws the settings in place
|
|
3871
|
-
*
|
|
4417
|
+
* Saves one setting and redraws the settings in place, from what the server reads back, and gives
|
|
4418
|
+
* focus back to the control the reader used. It does not go through `render()`: a refused choice
|
|
4419
|
+
* has to be put back to what is on disk, and `render()` leaves markup alone that did not change.
|
|
3872
4420
|
*/
|
|
3873
4421
|
function saveSetting(key, change) {
|
|
3874
4422
|
const scope = CUR.page === 'project' ? 'project' : 'user';
|
|
@@ -3876,14 +4424,29 @@ function saveSetting(key, change) {
|
|
|
3876
4424
|
const say = (text, warn) => { if (msg) { msg.innerHTML = text ? mascotStatus(text, warn ? 'error' : 'loading') : ''; msg.className = warn ? 'warn' : 'here'; } };
|
|
3877
4425
|
fieldError(key, null);
|
|
3878
4426
|
say('saving…');
|
|
3879
|
-
|
|
4427
|
+
// The screen the reader is on: another tab or scope is another screen, but a redraw of this one (new data arrives
|
|
4428
|
+
// while the save is out) is not, and must not stop the save from saying that it saved.
|
|
4429
|
+
const here = location.hash;
|
|
3880
4430
|
postJson('/api/settings', { scope, project: SETS?.project?.id, key, ...change })
|
|
3881
|
-
.then(() =>
|
|
3882
|
-
|
|
4431
|
+
.then(() => {
|
|
4432
|
+
// The file has changed: what was read before is stale from here, and read again now.
|
|
4433
|
+
invalidateData();
|
|
4434
|
+
const at = settingsNow(), url = settingsUrl();
|
|
4435
|
+
return fetch(url).then((r) => r.json()).then((d) => [d, at, url]);
|
|
4436
|
+
})
|
|
4437
|
+
.then(([d, at, url]) => {
|
|
4438
|
+
keepSettings(d, at);
|
|
4439
|
+
// What was just read is the page's answer for this URL too: the next visit to it is free.
|
|
4440
|
+
respPut(url, d, at);
|
|
3883
4441
|
const el = $('settings');
|
|
3884
|
-
if (!el
|
|
4442
|
+
if (!el) return;
|
|
4443
|
+
// The reader moved to another tab while this was saving, and was shown what was read before it:
|
|
4444
|
+
// draw that tab again from what was just read.
|
|
4445
|
+
if (location.hash !== here) { if (CUR.wf === 'settings' && settingsFresh()) render(); return; }
|
|
3885
4446
|
const focusId = document.activeElement?.id;
|
|
3886
|
-
|
|
4447
|
+
const html = drawSettings(d, scope);
|
|
4448
|
+
el.innerHTML = html;
|
|
4449
|
+
recordFill('settings', html);
|
|
3887
4450
|
focusable(el);
|
|
3888
4451
|
const back = (focusId && $(focusId)) || document.querySelector(`[data-set="${CSS.escape(key)}"]`);
|
|
3889
4452
|
back?.focus({ preventScroll: true });
|
|
@@ -3896,8 +4459,10 @@ function saveSetting(key, change) {
|
|
|
3896
4459
|
// are kept, so the reader can fix them where they are.
|
|
3897
4460
|
const el = document.querySelector(`[data-set="${CSS.escape(key)}"]`);
|
|
3898
4461
|
const box = $('settings');
|
|
3899
|
-
if (SETS && box &&
|
|
3900
|
-
|
|
4462
|
+
if (SETS && box && location.hash === here && (el?.type === 'checkbox' || el?.tagName === 'SELECT')) {
|
|
4463
|
+
const html = drawSettings(SETS, scope);
|
|
4464
|
+
box.innerHTML = html;
|
|
4465
|
+
recordFill('settings', html);
|
|
3901
4466
|
focusable(box);
|
|
3902
4467
|
const again = document.querySelector(`[data-set="${CSS.escape(key)}"]`);
|
|
3903
4468
|
(again && ($(again.id + '-combo') || again))?.focus({ preventScroll: true });
|
|
@@ -3936,6 +4501,12 @@ const svg = (paths, cls = '') =>
|
|
|
3936
4501
|
* `{ parent, view }` for a detail page: a detail keeps its parent highlighted.
|
|
3937
4502
|
* `query` lists the query keys that page reads, so a workflow switch or a link
|
|
3938
4503
|
* to another page carries the project and drops filters that mean nothing there.
|
|
4504
|
+
* `inPage` says the page's `param` and `query` are tabs and filters of the one screen
|
|
4505
|
+
* (a Review tab, a concept state, a timeline type, a Settings category), so changing
|
|
4506
|
+
* them adjusts it. On any other page a different `param` is a different subject (another
|
|
4507
|
+
* concept, another entry), which is a navigation. `screen` names the page whose screen this
|
|
4508
|
+
* one is, when two routes draw the same one (user settings are `#/settings/user` and
|
|
4509
|
+
* `#/settings/dashboard`): going between them is no change of screen.
|
|
3939
4510
|
*/
|
|
3940
4511
|
const WORKFLOWS = {
|
|
3941
4512
|
learning: {
|
|
@@ -3946,9 +4517,9 @@ const WORKFLOWS = {
|
|
|
3946
4517
|
],
|
|
3947
4518
|
pages: {
|
|
3948
4519
|
dashboard: { label: 'Dashboard', view: () => viewLearningDashboard() },
|
|
3949
|
-
concepts: { label: 'Concepts', view: (p) => viewConcepts(p), query: ['domain'] },
|
|
4520
|
+
concepts: { label: 'Concepts', view: (p) => viewConcepts(p), query: ['domain'], inPage: true },
|
|
3950
4521
|
concept: { parent: 'concepts', view: (p) => viewConcept(p) },
|
|
3951
|
-
review: { label: 'Review', view: (p) => viewReview(p) },
|
|
4522
|
+
review: { label: 'Review', view: (p) => viewReview(p), inPage: true },
|
|
3952
4523
|
sessions: { label: 'Sessions', view: () => viewSessions() },
|
|
3953
4524
|
session: { parent: 'sessions', view: (p) => viewSession(p) },
|
|
3954
4525
|
projects: { label: 'Projects', view: () => viewProjects() },
|
|
@@ -3964,7 +4535,7 @@ const WORKFLOWS = {
|
|
|
3964
4535
|
],
|
|
3965
4536
|
pages: {
|
|
3966
4537
|
dashboard: { label: 'Dashboard', view: () => viewMemoryDashboard() },
|
|
3967
|
-
timeline: { label: 'Timeline', view: (p) => viewMemory(p), query: ['tag', 'from', 'to'] },
|
|
4538
|
+
timeline: { label: 'Timeline', view: (p) => viewMemory(p), query: ['tag', 'from', 'to'], inPage: true },
|
|
3968
4539
|
entry: { parent: 'timeline', view: (p) => viewEntry(p) },
|
|
3969
4540
|
sessions: { label: 'Sessions', view: () => viewMemorySessions() },
|
|
3970
4541
|
session: { parent: 'sessions', view: (p) => viewSession(p) },
|
|
@@ -3979,7 +4550,7 @@ const WORKFLOWS = {
|
|
|
3979
4550
|
{ id: 'library', label: 'Library', items: ['projects'] },
|
|
3980
4551
|
],
|
|
3981
4552
|
pages: {
|
|
3982
|
-
dashboard: { label: 'Dashboard', view: (p) => viewArtifacts(p) },
|
|
4553
|
+
dashboard: { label: 'Dashboard', view: (p) => viewArtifacts(p), inPage: true },
|
|
3983
4554
|
view: { parent: 'dashboard', view: (p) => viewArtifact(p) },
|
|
3984
4555
|
projects: { label: 'Projects', view: () => viewArtifactProjects() },
|
|
3985
4556
|
},
|
|
@@ -3997,9 +4568,9 @@ const WORKFLOWS = {
|
|
|
3997
4568
|
name: 'Settings',
|
|
3998
4569
|
groups: [],
|
|
3999
4570
|
pages: {
|
|
4000
|
-
dashboard: { label: 'Preferences', view: () => viewSettings('user') },
|
|
4001
|
-
user: { parent: 'dashboard', view: () => viewSettings('user') },
|
|
4002
|
-
project: { parent: 'dashboard', view: () => viewSettings('project') },
|
|
4571
|
+
dashboard: { label: 'Preferences', view: () => viewSettings('user'), inPage: true },
|
|
4572
|
+
user: { parent: 'dashboard', view: () => viewSettings('user'), inPage: true, screen: 'dashboard' },
|
|
4573
|
+
project: { parent: 'dashboard', view: () => viewSettings('project'), inPage: true },
|
|
4003
4574
|
},
|
|
4004
4575
|
},
|
|
4005
4576
|
};
|
|
@@ -4115,15 +4686,15 @@ function writeCollapsed(wf, set) {
|
|
|
4115
4686
|
try { localStorage.setItem(collapseKey(wf), JSON.stringify([...set])); } catch (e) {}
|
|
4116
4687
|
}
|
|
4117
4688
|
|
|
4118
|
-
|
|
4689
|
+
/** The numbers in the sidebar's badges, per workflow: a few lengths of lists kept for the scope (`derived`). */
|
|
4690
|
+
const navCounts = (wf) => derived('nav:' + wf, () => {
|
|
4119
4691
|
const inv = projectsInScope();
|
|
4120
4692
|
const sum = (f) => inv.reduce((a, p) => a + f(p), 0);
|
|
4121
4693
|
if (wf === 'learning') {
|
|
4122
|
-
const seen = seenConcepts();
|
|
4123
4694
|
return {
|
|
4124
4695
|
concepts: conceptsInScope().length,
|
|
4125
|
-
review:
|
|
4126
|
-
sessions:
|
|
4696
|
+
review: seenConcepts().filter((c) => c.due).length,
|
|
4697
|
+
sessions: sessionTotal(),
|
|
4127
4698
|
projects: inv.filter((p) => p.learning.answers || p.learning.logged_concepts).length,
|
|
4128
4699
|
domains: new Set(conceptsInScope().map((c) => c.domain)).size,
|
|
4129
4700
|
};
|
|
@@ -4139,16 +4710,35 @@ function navCounts(wf) {
|
|
|
4139
4710
|
projects: inv.filter((p) => p.memory.events || p.memory.entries).length,
|
|
4140
4711
|
reuse: sum((p) => p.memory.receipts),
|
|
4141
4712
|
};
|
|
4713
|
+
});
|
|
4714
|
+
|
|
4715
|
+
/**
|
|
4716
|
+
* The collapsed group ids for `wf` as a navigation to `active` finds them. A direct link into a collapsed
|
|
4717
|
+
* group opens it: the reader has to be able to see where they are. A collapse they make afterwards still takes effect.
|
|
4718
|
+
*/
|
|
4719
|
+
function collapsedFor(wf, active) {
|
|
4720
|
+
const collapsed = readCollapsed(wf);
|
|
4721
|
+
const home = WORKFLOWS[wf].groups.find((g) => g.items.includes(active));
|
|
4722
|
+
if (home && collapsed.has(home.id)) { collapsed.delete(home.id); writeCollapsed(wf, collapsed); }
|
|
4723
|
+
return collapsed;
|
|
4142
4724
|
}
|
|
4143
4725
|
|
|
4726
|
+
/**
|
|
4727
|
+
* The sidebar for a navigation. It is built when the workflow changes (the first draw, and a switch to the
|
|
4728
|
+
* other workflow: another set of groups, links and icons) and kept otherwise: another page of the same workflow, a
|
|
4729
|
+
* project switch and a pager keep the elements, and `syncNav` brings the links, the current page, the counts and what
|
|
4730
|
+
* is collapsed in line in place. So a focused link stays focused and nothing in the rail is torn down to change one number.
|
|
4731
|
+
*/
|
|
4144
4732
|
function renderNav(wf, active) {
|
|
4733
|
+
const nav = $('nav');
|
|
4734
|
+
if (nav.dataset.workflow === wf) {
|
|
4735
|
+
syncNav(wf, active);
|
|
4736
|
+
syncCollapsed(collapsedFor(wf, active));
|
|
4737
|
+
return;
|
|
4738
|
+
}
|
|
4145
4739
|
const W = WORKFLOWS[wf];
|
|
4146
4740
|
const counts = navCounts(wf);
|
|
4147
|
-
const collapsed =
|
|
4148
|
-
// A direct link into a collapsed group opens it: the reader has to be able to
|
|
4149
|
-
// see where they are. A collapse they make afterwards still takes effect.
|
|
4150
|
-
const home = W.groups.find((g) => g.items.includes(active));
|
|
4151
|
-
if (home && collapsed.has(home.id)) { collapsed.delete(home.id); writeCollapsed(wf, collapsed); }
|
|
4741
|
+
const collapsed = collapsedFor(wf, active);
|
|
4152
4742
|
|
|
4153
4743
|
const item = (key) => {
|
|
4154
4744
|
const pg = W.pages[key];
|
|
@@ -4157,8 +4747,8 @@ function renderNav(wf, active) {
|
|
|
4157
4747
|
${svg(ICONS[key] ?? ICONS.dashboard)}${esc(pg.label)}${n !== undefined ? `<i class="${key === 'review' && n ? 'hot' : ''}">${n}</i>` : ''}
|
|
4158
4748
|
</a>`;
|
|
4159
4749
|
};
|
|
4160
|
-
|
|
4161
|
-
|
|
4750
|
+
nav.setAttribute('aria-label', `${W.name} sections`);
|
|
4751
|
+
nav.innerHTML = `<div>${item('dashboard')}</div>` + W.groups.map((g) => {
|
|
4162
4752
|
const open = !collapsed.has(g.id);
|
|
4163
4753
|
const id = `grp-${wf}-${g.id}`;
|
|
4164
4754
|
return `<div class="nav__group">
|
|
@@ -4168,6 +4758,35 @@ function renderNav(wf, active) {
|
|
|
4168
4758
|
<div class="nav__items" id="${id}" role="group" aria-label="${esc(g.label)}"${open ? '' : ' hidden'}>${g.items.map(item).join('')}</div>
|
|
4169
4759
|
</div>`;
|
|
4170
4760
|
}).join('');
|
|
4761
|
+
nav.dataset.workflow = wf;
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
/**
|
|
4765
|
+
* The sidebar's links, current page and counts, brought in line with the screen in place: the elements stay. The links
|
|
4766
|
+
* are written too, because they carry the project scope (`href`), which a navigation may have changed. An adjustment
|
|
4767
|
+
* (a tab, a chip, a keystroke) calls only this; a navigation also applies what is collapsed (`syncCollapsed`).
|
|
4768
|
+
*/
|
|
4769
|
+
function syncNav(wf, active) {
|
|
4770
|
+
const counts = navCounts(wf);
|
|
4771
|
+
for (const a of $('nav').querySelectorAll('a[data-nav]')) {
|
|
4772
|
+
const key = a.dataset.nav;
|
|
4773
|
+
const to = href(wf, key);
|
|
4774
|
+
if (a.getAttribute('href') !== to) a.setAttribute('href', to);
|
|
4775
|
+
if (key === active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
|
|
4776
|
+
const n = counts[key];
|
|
4777
|
+
const badge = a.querySelector('i');
|
|
4778
|
+
if (badge && n !== undefined) { badge.textContent = n; badge.className = key === 'review' && n ? 'hot' : ''; }
|
|
4779
|
+
}
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4782
|
+
/** Opens and closes the rail's groups to match `collapsed`: a group the link in front of the reader sits in opens, and a collapse made in another tab applies. */
|
|
4783
|
+
function syncCollapsed(collapsed) {
|
|
4784
|
+
for (const tog of $('nav').querySelectorAll('[data-group]')) {
|
|
4785
|
+
const open = !collapsed.has(tog.dataset.group);
|
|
4786
|
+
if ((tog.getAttribute('aria-expanded') === 'true') === open) continue;
|
|
4787
|
+
tog.setAttribute('aria-expanded', String(open));
|
|
4788
|
+
$(tog.getAttribute('aria-controls')).hidden = !open;
|
|
4789
|
+
}
|
|
4171
4790
|
}
|
|
4172
4791
|
|
|
4173
4792
|
$('nav').addEventListener('click', (e) => {
|
|
@@ -4193,7 +4812,11 @@ function renderShell() {
|
|
|
4193
4812
|
$(id).setAttribute('aria-label', `Eklavya, ${W.name} dashboard`);
|
|
4194
4813
|
}
|
|
4195
4814
|
$('wf-main').setAttribute('href', home);
|
|
4196
|
-
|
|
4815
|
+
// The workflow control's content is the workflow's alone: written when it changes, so its elements stay across pages.
|
|
4816
|
+
if ($('wf-main').dataset.workflow !== CUR.wf) {
|
|
4817
|
+
$('wf-main').innerHTML = `${svg(ICONS[CUR.wf])}<span>${esc(W.name)}</span>`;
|
|
4818
|
+
$('wf-main').dataset.workflow = CUR.wf;
|
|
4819
|
+
}
|
|
4197
4820
|
$('wf-main').setAttribute('aria-label', `${W.name} dashboard`);
|
|
4198
4821
|
$('topbar-wf').textContent = W.name;
|
|
4199
4822
|
syncBadge();
|
|
@@ -4291,16 +4914,25 @@ let typed = '', typedAt = 0;
|
|
|
4291
4914
|
|
|
4292
4915
|
function projOptions() { return [...pmenu.querySelectorAll('[role="option"]')]; }
|
|
4293
4916
|
|
|
4294
|
-
/**
|
|
4917
|
+
/**
|
|
4918
|
+
* Built from the inventory, and again only when what it lists changed (a new project, a renamed one, a checkout
|
|
4919
|
+
* that went): `syncProjects()` moves the selection. An open list keeps its focus on the same option.
|
|
4920
|
+
*/
|
|
4921
|
+
let PROJECTS_LISTED = '';
|
|
4295
4922
|
function buildProjects(inv) {
|
|
4923
|
+
const rows = inv.projects.map((p) => [p.id, p.name + (p.available === false ? ' (checkout gone)' : '')]);
|
|
4924
|
+
const listed = JSON.stringify(rows);
|
|
4925
|
+
if (listed === PROJECTS_LISTED) return;
|
|
4926
|
+
PROJECTS_LISTED = listed;
|
|
4296
4927
|
const row = (value, name) =>
|
|
4297
4928
|
`<button type="button" role="option" aria-selected="false" data-value="${esc(value)}" tabindex="-1">
|
|
4298
4929
|
<span>${esc(name)}</span>
|
|
4299
4930
|
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10"/></svg>
|
|
4300
4931
|
</button>`;
|
|
4301
|
-
|
|
4302
|
-
|
|
4932
|
+
const on = pmenu.contains(document.activeElement) ? document.activeElement.dataset.value : null;
|
|
4933
|
+
pmenu.innerHTML = row('', 'All projects') + rows.map(([id, name]) => row(id, name)).join('');
|
|
4303
4934
|
pbtn.disabled = false;
|
|
4935
|
+
if (on !== null) projOptions().find((o) => o.dataset.value === on)?.focus();
|
|
4304
4936
|
}
|
|
4305
4937
|
|
|
4306
4938
|
function syncProjects() {
|
|
@@ -4449,6 +5081,9 @@ function closeSelect(restore = true) {
|
|
|
4449
5081
|
b.setAttribute('aria-expanded', 'false');
|
|
4450
5082
|
TIP.sync();
|
|
4451
5083
|
if ((restore || hadFocus) && b.isConnected) b.focus();
|
|
5084
|
+
// A redraw for new data that waited for this list to close (`unitsInUse`) is drawn now; focus coming back to the button
|
|
5085
|
+
// is no `focusout` of the view's, which is what asks otherwise.
|
|
5086
|
+
setTimeout(catchUp, 0);
|
|
4452
5087
|
}
|
|
4453
5088
|
|
|
4454
5089
|
function chooseOption(i) {
|
|
@@ -4509,7 +5144,7 @@ function switchWorkflow(wf) {
|
|
|
4509
5144
|
// Reached from the unread badge: said so, so the count can tell the three ways in apart.
|
|
4510
5145
|
const target = wf === 'feedback' && fbUnread() ? href(wf, 'dashboard', null, { via: 'badge' }) : href(wf, 'dashboard');
|
|
4511
5146
|
closeDrawer(false);
|
|
4512
|
-
if (location.hash === target) render(); else location.hash = target;
|
|
5147
|
+
if (location.hash === target) render({ nav: true }); else location.hash = target;
|
|
4513
5148
|
(narrow.matches ? $('menu') : caret).focus();
|
|
4514
5149
|
}
|
|
4515
5150
|
|
|
@@ -4615,51 +5250,424 @@ function syncFromUrl() {
|
|
|
4615
5250
|
const project = r.query.get('project');
|
|
4616
5251
|
const known = project !== null && INV.projects.some((p) => p.id === project);
|
|
4617
5252
|
const next = known ? project : null;
|
|
4618
|
-
if (next !== PROJECT) { PROJECT = next; for (const k of Object.keys(T)) T[k].page = 1; recomputeScope(); }
|
|
5253
|
+
if (next !== PROJECT) { PROJECT = next; for (const k of Object.keys(T)) T[k].page = 1; recomputeScope(); invalidateSettings(); }
|
|
4619
5254
|
CUR = { wf: r.wf, page: r.page, param: r.param, query: r.query, missing: r.missing, bad: r.bad };
|
|
4620
5255
|
syncProjects();
|
|
4621
5256
|
render();
|
|
4622
5257
|
}
|
|
4623
5258
|
|
|
4624
|
-
|
|
5259
|
+
/**
|
|
5260
|
+
* Draws the screen the URL names. There are two kinds of draw, and the difference is what
|
|
5261
|
+
* the reader is doing.
|
|
5262
|
+
*
|
|
5263
|
+
* A **navigation** is a different screen: another page, another workflow, another project,
|
|
5264
|
+
* another concept or entry. It brings the shell and the sidebar in line (the sidebar is built only when the
|
|
5265
|
+
* workflow changes, and updated in place otherwise: `renderNav`), replaces the view and scrolls to the top.
|
|
5266
|
+
*
|
|
5267
|
+
* An **adjustment** changes the screen the reader is on: a tab, a chip, a select, a date
|
|
5268
|
+
* range, a pager, a search keystroke, a resize, the ground. The URL still changes where it
|
|
5269
|
+
* says something, and Back still works, but nothing the reader was looking at moves. It keeps
|
|
5270
|
+
* the scroll position, leaves the shell alone, updates the sidebar's current page and counts in
|
|
5271
|
+
* place, and redraws only the regions of the view whose markup changed (`drawAdjusted`),
|
|
5272
|
+
* keeping focus, the caret and open folds. The view's markup is still the one pure function of
|
|
5273
|
+
* (payload, scope, controls) it always was.
|
|
5274
|
+
*
|
|
5275
|
+
* It is an adjustment when the screen on show is the same page (or another route to the same
|
|
5276
|
+
* screen, `screenOf`), in the same project, and either nothing in the URL moved or the page says
|
|
5277
|
+
* its `param` and `query` are filters of the one screen (`inPage`). A project switch is a
|
|
5278
|
+
* navigation: it changes every count in the sidebar and the crumb. `{ nav: true }` forces the
|
|
5279
|
+
* navigation for a caller that replaces what the screen is about (a feedback item that was just
|
|
5280
|
+
* acknowledged).
|
|
5281
|
+
*/
|
|
5282
|
+
function render(opts) {
|
|
4625
5283
|
const W = WORKFLOWS[CUR.wf];
|
|
4626
5284
|
const pg = CUR.missing ? null : W.pages[CUR.page];
|
|
4627
5285
|
const active = pg ? (pg.parent ?? CUR.page) : null;
|
|
5286
|
+
const adjusting = !opts?.nav && isAdjustment(pg);
|
|
4628
5287
|
GEN += 1;
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
5288
|
+
if (adjusting) {
|
|
5289
|
+
syncNav(CUR.wf, active);
|
|
5290
|
+
syncBadge();
|
|
5291
|
+
} else {
|
|
5292
|
+
renderShell();
|
|
5293
|
+
renderNav(CUR.wf, active);
|
|
5294
|
+
const label = pg ? (W.pages[pg.parent ?? CUR.page].label) : 'Not found';
|
|
5295
|
+
$('crumb').innerHTML = `<span>${esc(W.name)}</span><span class="dim">${esc(label)}</span><span class="dim">${esc(scopeName())}</span>`;
|
|
5296
|
+
document.title = `${label} · ${W.name} — Eklavya`;
|
|
5297
|
+
// A bubble never survives a navigation.
|
|
5298
|
+
TIP.hints?.close();
|
|
5299
|
+
}
|
|
4634
5300
|
|
|
4635
5301
|
AFTER.length = 0;
|
|
4636
5302
|
LAZY.clear();
|
|
4637
|
-
|
|
5303
|
+
// The heatmap's page is where the reader paged it to, and new data does not turn it back.
|
|
5304
|
+
if (!LIVE_PASS) HEAT_OFF = 0;
|
|
4638
5305
|
// Leaving the Feedback page ends the opening that fbOpened counted.
|
|
4639
5306
|
if (!(CUR.wf === 'feedback' && CUR.page === 'dashboard')) FB.counted = false;
|
|
4640
|
-
// A
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
5307
|
+
// A screen that has a correction open re-arms this when it draws its bar. The bar of a screen that is
|
|
5308
|
+
// only being drawn again stays as it is (`data-hold`), and so does the prompt that goes with it.
|
|
5309
|
+
if (!adjusting) armLeave(false);
|
|
5310
|
+
ADJUSTING = adjusting;
|
|
5311
|
+
let html;
|
|
5312
|
+
try {
|
|
5313
|
+
html = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
|
|
5314
|
+
: CUR.bad ? viewNotFound(CUR.param, true)
|
|
5315
|
+
: pg.view(CUR.param);
|
|
5316
|
+
} finally {
|
|
5317
|
+
ADJUSTING = false;
|
|
5318
|
+
}
|
|
5319
|
+
if (adjusting) {
|
|
5320
|
+
drawAdjusted(html);
|
|
5321
|
+
} else {
|
|
5322
|
+
$('view').innerHTML = html;
|
|
5323
|
+
HELD_BACK.clear();
|
|
5324
|
+
// A clickable row has to be reachable by Tab as well as by mouse.
|
|
5325
|
+
focusable($('view'));
|
|
5326
|
+
DRAWN = { wf: CUR.wf, page: CUR.page, param: CUR.param, query: CUR.query.toString(), project: PROJECT, fault: !pg || !!CUR.bad, ...recordOf(html) };
|
|
5327
|
+
}
|
|
4649
5328
|
for (const fn of AFTER) fn();
|
|
4650
5329
|
TIP.sync();
|
|
4651
5330
|
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
|
|
4657
|
-
}
|
|
5331
|
+
// A new screen opens at its top, at once. `html` asks for smooth scrolling (for in-page jumps), and a scroll that
|
|
5332
|
+
// does not name its behavior follows it, so a reader coming from the bottom of a long page would see the new
|
|
5333
|
+
// one glide up for half a second, while its content is already being drawn.
|
|
5334
|
+
if (!adjusting) window.scrollTo({ top: 0, behavior: 'instant' });
|
|
4658
5335
|
// Which URL the screen was last drawn for. The tests wait on it rather than on
|
|
4659
5336
|
// a timer, and it costs nothing to leave in.
|
|
4660
5337
|
document.documentElement.dataset.rendered = location.hash;
|
|
4661
5338
|
}
|
|
4662
5339
|
|
|
5340
|
+
/** The page a route's screen belongs to: itself, unless the registry says another route draws the same screen. */
|
|
5341
|
+
const screenOf = (wf, page) => WORKFLOWS[wf].pages[page]?.screen ?? page;
|
|
5342
|
+
|
|
5343
|
+
/** Whether drawing now changes the screen on show (see `render`) rather than showing another one. */
|
|
5344
|
+
function isAdjustment(pg) {
|
|
5345
|
+
const d = DRAWN;
|
|
5346
|
+
if (!d || d.fault || !pg || CUR.bad) return false;
|
|
5347
|
+
if (d.wf !== CUR.wf || screenOf(d.wf, d.page) !== screenOf(CUR.wf, CUR.page) || d.project !== PROJECT) return false;
|
|
5348
|
+
return !!pg.inPage || (d.param === CUR.param && d.query === CUR.query.toString());
|
|
5349
|
+
}
|
|
5350
|
+
|
|
5351
|
+
/* ---------- redrawing a screen without moving the reader ----------
|
|
5352
|
+
A view marks the parts a tab, chip, select or pager changes with
|
|
5353
|
+
`data-region="<id>"` (`region()`, or a fetched list's `slot()`). The markup with every region emptied is its *skeleton*. If
|
|
5354
|
+
the skeleton the view returns now equals the one the screen was drawn from, only the
|
|
5355
|
+
regions whose contents changed are replaced, each wholesale, in the element that is
|
|
5356
|
+
already on the page; nothing outside them is touched, so a head, a chart, a rail, a
|
|
5357
|
+
list being fetched or a text box in the skeleton keeps its identity. Anything else
|
|
5358
|
+
(no regions, a different set, a different skeleton) replaces the view's content, still
|
|
5359
|
+
without moving the scroll; a fetched list that is only the box this time (`slot()`) is then
|
|
5360
|
+
carried over from the old view, node for node, and stays dimmed until its answer draws. The markup the
|
|
5361
|
+
screen was drawn from, drawn again, replaces nothing. A part that must never leave the document (the viewer's
|
|
5362
|
+
frame, the bar of its correction) is marked `data-hold`: the skeleton leaves what it holds out, and a redraw
|
|
5363
|
+
that keeps the skeleton keeps it. A redraw for new data (`liveAdopt`) goes through the same path. */
|
|
5364
|
+
|
|
5365
|
+
/**
|
|
5366
|
+
* The regions of a screen's markup and what is left once they are emptied; no skeleton when it has none, or two
|
|
5367
|
+
* could not be told apart. `regions` is each one's markup as the view drew it, and what a swap draws. `settled`
|
|
5368
|
+
* is the same markup with every fold's open state and every search box's text taken out, and what is compared:
|
|
5369
|
+
* whether a fold is open is the reader's (`FOLD` holds it, and the page already shows it), so opening or closing
|
|
5370
|
+
* one is no change to the screen, and neither is typing in a search box (`T` holds the text, and so does the box).
|
|
5371
|
+
* Without that, the first adjustment after a toggle, or every keystroke, would see a different skeleton, or a
|
|
5372
|
+
* different region, and replace what the reader was looking at.
|
|
5373
|
+
*/
|
|
5374
|
+
function scanRegions(html) {
|
|
5375
|
+
const none = { skeleton: null, regions: new Map(), settled: new Map(), calm: new Map() };
|
|
5376
|
+
if (!html.includes('data-region="')) return none;
|
|
5377
|
+
const t = document.createElement('template');
|
|
5378
|
+
t.innerHTML = html;
|
|
5379
|
+
const found = [...t.content.querySelectorAll('[data-region]')];
|
|
5380
|
+
const regions = new Map();
|
|
5381
|
+
for (const el of found) {
|
|
5382
|
+
if (regions.has(el.dataset.region) || el.parentElement?.closest('[data-region]')) return none;
|
|
5383
|
+
regions.set(el.dataset.region, el.innerHTML);
|
|
5384
|
+
}
|
|
5385
|
+
for (const d of t.content.querySelectorAll('details[data-fold]')) d.removeAttribute('open');
|
|
5386
|
+
// The text in a search box is the reader's too: it is what was typed, and the box on the page already holds it.
|
|
5387
|
+
// Without this, every keystroke would make the screen look changed and the whole view would be replaced under the caret.
|
|
5388
|
+
for (const i of t.content.querySelectorAll('input[type="search"]')) i.removeAttribute('value');
|
|
5389
|
+
const settled = new Map(found.map((el) => [el.dataset.region, el.innerHTML]));
|
|
5390
|
+
// `calm` is `settled` without the value of a date box: what a swap checks to tell a region whose box the reader is
|
|
5391
|
+
// typing a date into (`typingDate`) from one that changed.
|
|
5392
|
+
for (const i of t.content.querySelectorAll('input[type="date"]')) i.removeAttribute('value');
|
|
5393
|
+
const calm = new Map(found.map((el) => [el.dataset.region, el.innerHTML]));
|
|
5394
|
+
for (const el of found) el.replaceChildren();
|
|
5395
|
+
// A held element (`data-hold`) is part of the screen that a redraw never touches (the viewer's frame, whose page
|
|
5396
|
+
// would load again if it left the document): what it holds is whatever it came to hold, so it is not compared.
|
|
5397
|
+
for (const el of t.content.querySelectorAll('[data-hold]')) el.replaceChildren();
|
|
5398
|
+
return { skeleton: t.innerHTML, regions, settled, calm };
|
|
5399
|
+
}
|
|
5400
|
+
const recordOf = (html) => ({ html, ...scanRegions(html) });
|
|
5401
|
+
|
|
5402
|
+
/**
|
|
5403
|
+
* A fill drew `html` into `#id`, and the view returns that same markup itself from now on (the
|
|
5404
|
+
* answer is in the cache, and Settings draws from `SETS`): bring the record in line, so the regions
|
|
5405
|
+
* inside it are known and a redraw that returns it again finds nothing to change.
|
|
5406
|
+
*/
|
|
5407
|
+
function recordFill(id, html) {
|
|
5408
|
+
if (!DRAWN) return;
|
|
5409
|
+
const t = document.createElement('template');
|
|
5410
|
+
t.innerHTML = DRAWN.html;
|
|
5411
|
+
const el = t.content.getElementById(id);
|
|
5412
|
+
if (!el) return;
|
|
5413
|
+
el.innerHTML = html;
|
|
5414
|
+
DRAWN = { ...DRAWN, ...recordOf(t.innerHTML) };
|
|
5415
|
+
}
|
|
5416
|
+
|
|
5417
|
+
/**
|
|
5418
|
+
* The tab strip of the Artifacts pages was replaced on the page by `html` (a tab closed; empty when the
|
|
5419
|
+
* last one went): the record is brought in line the same way, so the next adjustment finds the screen it
|
|
5420
|
+
* is drawing from as it stands, instead of a skeleton that still has the tab.
|
|
5421
|
+
*/
|
|
5422
|
+
function recordStrip(html) {
|
|
5423
|
+
if (!DRAWN) return;
|
|
5424
|
+
const t = document.createElement('template');
|
|
5425
|
+
t.innerHTML = DRAWN.html;
|
|
5426
|
+
const strip = t.content.querySelector('.tabs');
|
|
5427
|
+
if (!strip) return;
|
|
5428
|
+
const now = document.createElement('template');
|
|
5429
|
+
now.innerHTML = html;
|
|
5430
|
+
strip.replaceWith(...now.content.childNodes);
|
|
5431
|
+
DRAWN = { ...DRAWN, ...recordOf(t.innerHTML) };
|
|
5432
|
+
}
|
|
5433
|
+
|
|
5434
|
+
/**
|
|
5435
|
+
* The regions whose redraw was put off because the reader was using a control in them (`swapRegions`): what is on screen
|
|
5436
|
+
* for each is older than the record of the screen, so the next draw that can replaces it, whether or not its markup
|
|
5437
|
+
* differs from the record's. A `focusout` asks for that draw at once (`catchUp`).
|
|
5438
|
+
*/
|
|
5439
|
+
const HELD_BACK = new Set();
|
|
5440
|
+
|
|
5441
|
+
/** The date box whose `change` the page is acting on, for as long as it does: the reader is in it, whatever `document.activeElement` says. */
|
|
5442
|
+
let DATE_BOX = null;
|
|
5443
|
+
|
|
5444
|
+
/**
|
|
5445
|
+
* Whether `host`, a region, holds a date box the reader is typing in, and the markup that would replace it differs from
|
|
5446
|
+
* the one on screen in nothing but that box's value, which is what the box already says. A date box changes (and the page
|
|
5447
|
+
* writes the range into the URL) each time what is typed is a whole date, which is after the first digit of the year, so
|
|
5448
|
+
* the box that says so is the box the rest of the year is still going into: replacing it with a new one would put the
|
|
5449
|
+
* remaining digits in the first segment of an empty box.
|
|
5450
|
+
*/
|
|
5451
|
+
function typingDate(host, id, prev, next) {
|
|
5452
|
+
// The box that just said it changed, else the one with focus: while a box moves from one part of the date to the next
|
|
5453
|
+
// (the month is complete) the document has no focused element for a moment, and that is when it says so.
|
|
5454
|
+
const box = DATE_BOX ?? document.activeElement;
|
|
5455
|
+
if (!(box instanceof HTMLInputElement) || box.type !== 'date' || !box.id || !host.contains(box)) return false;
|
|
5456
|
+
if (prev.calm.get(id) !== next.calm.get(id)) return false;
|
|
5457
|
+
const t = document.createElement('template');
|
|
5458
|
+
t.innerHTML = next.regions.get(id);
|
|
5459
|
+
return (t.content.getElementById(box.id)?.getAttribute('value') ?? null) === box.value;
|
|
5460
|
+
}
|
|
5461
|
+
|
|
5462
|
+
/**
|
|
5463
|
+
* Replaces, in `root`, the regions of `next` whose markup differs from `prev`'s (the record of the screen), each wholesale in
|
|
5464
|
+
* the element that is on the page, and nothing else. False, before anything is touched, when a region to replace is not
|
|
5465
|
+
* there. A region that is only the box and the loader never goes over what it shows (the fetch dims it); over a failure it
|
|
5466
|
+
* does (that is asking again). With `keep` (a redraw for new data), a region that holds a control the reader is using (the one with
|
|
5467
|
+
* focus, an open list, or one holding a value that is typed and not saved) is left as it is: a control that leaves the
|
|
5468
|
+
* document while it has focus is committed by the browser, and the reader has not said they are done. It is put off, not dropped.
|
|
5469
|
+
*/
|
|
5470
|
+
function swapRegions(root, prev, next, keep) {
|
|
5471
|
+
const swaps = [];
|
|
5472
|
+
for (const [id, inner] of next.regions) {
|
|
5473
|
+
const host = root.querySelector(`[data-region="${id}"]`);
|
|
5474
|
+
// Unchanged markup is left alone, whatever the reader has done to what is on screen since (a fold opened, say).
|
|
5475
|
+
if (!HELD_BACK.has(id) && prev.settled.get(id) === next.settled.get(id)) {
|
|
5476
|
+
// Except that a request an earlier draw left out is over for it: its answer is in hand, and is what is on screen.
|
|
5477
|
+
if (host?.hasAttribute('aria-busy') && !isWait(inner)) slotDone(host);
|
|
5478
|
+
continue;
|
|
5479
|
+
}
|
|
5480
|
+
if (!host) return false;
|
|
5481
|
+
if (isWait(inner) && !host.hasAttribute('data-failed')) continue;
|
|
5482
|
+
// The reader's own typing is theirs, in any draw: the range they typed is what the box already says. (A region put off
|
|
5483
|
+
// for new data stays put off, to be drawn when focus leaves.)
|
|
5484
|
+
if (typingDate(host, id, prev, next)) continue;
|
|
5485
|
+
swaps.push([id, host, inner]);
|
|
5486
|
+
}
|
|
5487
|
+
for (const [id, host, inner] of swaps) {
|
|
5488
|
+
if (keep && unitsInUse(host).length) { HELD_BACK.add(id); continue; }
|
|
5489
|
+
HELD_BACK.delete(id);
|
|
5490
|
+
const ui = captureUi(host);
|
|
5491
|
+
host.innerHTML = inner;
|
|
5492
|
+
// Whatever an earlier request left on it (busy, a failure) was about the content it replaced.
|
|
5493
|
+
slotDone(host);
|
|
5494
|
+
focusable(host);
|
|
5495
|
+
restoreUi(host, ui);
|
|
5496
|
+
}
|
|
5497
|
+
return true;
|
|
5498
|
+
}
|
|
5499
|
+
|
|
5500
|
+
/** Whether two views' markup is the same once the browser has read both (a record that was filled in is in that form, a fresh one is not). */
|
|
5501
|
+
const sameMarkup = (a, b) => a === b || normalized(a) === normalized(b);
|
|
5502
|
+
|
|
5503
|
+
/**
|
|
5504
|
+
* The markup with each fetched region that is only its box this time, over one that shows an answer on screen, drawn as
|
|
5505
|
+
* what it shows: the answer stays, and the redraw finds nothing to change there. (`html` as it is when there is none.)
|
|
5506
|
+
*/
|
|
5507
|
+
function withKept(html) {
|
|
5508
|
+
if (!html.includes(WAIT_OPEN)) return html;
|
|
5509
|
+
const t = document.createElement('template');
|
|
5510
|
+
t.innerHTML = html;
|
|
5511
|
+
let any = false;
|
|
5512
|
+
for (const el of t.content.querySelectorAll('[data-slot]')) {
|
|
5513
|
+
if (!el.querySelector(':scope > .slot__wait')) continue;
|
|
5514
|
+
const on = $(el.id);
|
|
5515
|
+
const was = on && holdsContent(on) ? drawnSlot(el.id) : null;
|
|
5516
|
+
if (was === null) continue;
|
|
5517
|
+
el.innerHTML = was;
|
|
5518
|
+
any = true;
|
|
5519
|
+
}
|
|
5520
|
+
return any ? t.innerHTML : html;
|
|
5521
|
+
}
|
|
5522
|
+
|
|
5523
|
+
/**
|
|
5524
|
+
* Draws an adjustment's markup: its changed regions in place, else the view's content; never touching the scroll.
|
|
5525
|
+
* A redraw for new data (`LIVE_PASS`) also draws nothing when the markup is what is on screen, and counts a fetched
|
|
5526
|
+
* region that is only its box as what it shows, so what the new data did not change is not touched.
|
|
5527
|
+
*/
|
|
5528
|
+
function drawAdjusted(html) {
|
|
5529
|
+
const view = $('view');
|
|
5530
|
+
const prev = DRAWN;
|
|
5531
|
+
// The page is the same screen, though it may be reached by another route (`screen`): the record keeps the one on show.
|
|
5532
|
+
const at = { page: CUR.page, param: CUR.param, query: CUR.query.toString() };
|
|
5533
|
+
// Fetched regions that are only their box this time are what they show: the screen is the same where they are, and
|
|
5534
|
+
// it is the regions around them that may have changed. (A view's markup holds the box for every fetched region of
|
|
5535
|
+
// a screen whose answers were dropped with the old data, which is every screen on new data.)
|
|
5536
|
+
const shown = LIVE_PASS ? withKept(html) : html;
|
|
5537
|
+
// The markup the screen was drawn from, again: new data that changed nothing this screen shows replaces nothing in it,
|
|
5538
|
+
// not even where the view has no regions, and so the text the reader selected, the row they opened and the control
|
|
5539
|
+
// they are on stay (a region that failed is the exception: drawing again is asking again).
|
|
5540
|
+
if (LIVE_PASS && HELD_BACK.size === 0 && !view.querySelector('[data-failed]') && sameMarkup(shown, prev.html)) {
|
|
5541
|
+
DRAWN = { ...prev, ...at };
|
|
5542
|
+
return;
|
|
5543
|
+
}
|
|
5544
|
+
const next = scanRegions(shown);
|
|
5545
|
+
if (next.skeleton !== null && next.skeleton === prev.skeleton && swapRegions(view, prev, next, LIVE_PASS)) {
|
|
5546
|
+
DRAWN = { ...prev, ...at, html: shown, ...next };
|
|
5547
|
+
return;
|
|
5548
|
+
}
|
|
5549
|
+
// The view is replaced; a fetched region that is only the box this time still shows what it did, moved
|
|
5550
|
+
// into the new element (the same nodes, so whatever the reader had open or focused in them is still there).
|
|
5551
|
+
const held = new Map();
|
|
5552
|
+
for (const el of view.querySelectorAll('[data-slot]')) if (holdsContent(el)) held.set(el.id, el);
|
|
5553
|
+
// What the old view held that never leaves the document (the viewer's frame, and the bar of its correction) goes
|
|
5554
|
+
// with it: the prompt that guarded the open correction, and the scroll that was waiting for the frame, are over.
|
|
5555
|
+
if (view.querySelector('[data-hold]')) { armLeave(false); TABS.reading = null; }
|
|
5556
|
+
const ui = captureUi(view);
|
|
5557
|
+
view.innerHTML = html;
|
|
5558
|
+
HELD_BACK.clear();
|
|
5559
|
+
for (const el of view.querySelectorAll('[data-slot]')) {
|
|
5560
|
+
const was = held.get(el.id);
|
|
5561
|
+
if (was && el.querySelector(':scope > .slot__wait')) el.replaceChildren(...was.childNodes);
|
|
5562
|
+
}
|
|
5563
|
+
focusable(view);
|
|
5564
|
+
restoreUi(view, ui);
|
|
5565
|
+
// What is on screen is `shown`: the regions that kept their answer carry it in the record too, so the answer that
|
|
5566
|
+
// arrives for them is compared with what they show, and the same one replaces nothing.
|
|
5567
|
+
DRAWN = { ...prev, ...at, html: shown, ...next };
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
/** A control the reader was using has been let go of: what was put off for it is drawn now, unless they are on another one. */
|
|
5571
|
+
function catchUp() {
|
|
5572
|
+
if (!HELD_BACK.size || !DRAWN) return;
|
|
5573
|
+
LIVE_PASS = true;
|
|
5574
|
+
try {
|
|
5575
|
+
render();
|
|
5576
|
+
} finally {
|
|
5577
|
+
LIVE_PASS = false;
|
|
5578
|
+
}
|
|
5579
|
+
}
|
|
5580
|
+
|
|
5581
|
+
/**
|
|
5582
|
+
* What a control is called when it is looked for again in redrawn markup: its id, else the first of these it has (Copy
|
|
5583
|
+
* names the command it copies, and the Feedback buttons the item they act on), else, for a <summary>, the <details> it
|
|
5584
|
+
* opens (`detailSelector`). A control with none is not carried over a redraw, so every control the reader can Tab to has one.
|
|
5585
|
+
*/
|
|
5586
|
+
const FOCUS_KEYS = ['data-focus', 'data-tab', 'data-tab-id', 'data-state', 'data-unset', 'data-go',
|
|
5587
|
+
'data-fb-ack', 'data-fb-del', 'data-fb-yes', 'data-fb-no', 'data-fb-tip-dismiss'];
|
|
5588
|
+
function focusKey(el) {
|
|
5589
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
5590
|
+
for (const a of FOCUS_KEYS) {
|
|
5591
|
+
const v = el.getAttribute(a);
|
|
5592
|
+
if (v !== null) return `[${a}="${CSS.escape(v)}"]`;
|
|
5593
|
+
}
|
|
5594
|
+
const sel = el.tagName === 'SUMMARY' && el.parentElement?.tagName === 'DETAILS' ? detailSelector(el.parentElement) : null;
|
|
5595
|
+
return sel ? `${sel} > summary` : null;
|
|
5596
|
+
}
|
|
5597
|
+
/** The stable key of a <details>: a fold's id, a timeline entry's id, or an explicit `data-key`. */
|
|
5598
|
+
const detailKey = (d) => (d.dataset.fold ? 'fold:' + d.dataset.fold : d.dataset.entry ? 'entry:' + d.dataset.entry : d.dataset.key ? 'key:' + d.dataset.key : null);
|
|
5599
|
+
/** A selector for the <details> that `detailKey` names, to look it (or its summary) up again; null when it has no key. */
|
|
5600
|
+
function detailSelector(d) {
|
|
5601
|
+
for (const a of ['data-fold', 'data-entry', 'data-key']) {
|
|
5602
|
+
const v = d.getAttribute(a);
|
|
5603
|
+
if (v !== null) return `details[${a}="${CSS.escape(v)}"]`;
|
|
5604
|
+
}
|
|
5605
|
+
return null;
|
|
5606
|
+
}
|
|
5607
|
+
|
|
5608
|
+
/**
|
|
5609
|
+
* What the reader is in the middle of inside `root`, taken before it is redrawn: the focused
|
|
5610
|
+
* element with its caret or selection, every open <details> that has a stable key, and the
|
|
5611
|
+
* offset of a strip marked `data-keep-scroll`. `restoreUi` puts them back on the new markup.
|
|
5612
|
+
* The page's scroll is not here because a redraw never moves it, and the lazy charts are not
|
|
5613
|
+
* because the draw's own hooks queue them again (`LAZY`). `liveAdopt` redraws through this pair too.
|
|
5614
|
+
*/
|
|
5615
|
+
function captureUi(root) {
|
|
5616
|
+
const ui = { focus: null, open: new Set(), scrolls: new Map() };
|
|
5617
|
+
// A strip that scrolls sideways (the Settings tabs on a phone) keeps its offset, so the tab just
|
|
5618
|
+
// pressed does not slide out of sight.
|
|
5619
|
+
for (const el of root.querySelectorAll('[data-keep-scroll]')) ui.scrolls.set(el.dataset.keepScroll, el.scrollLeft);
|
|
5620
|
+
const el = document.activeElement;
|
|
5621
|
+
if (el && root.contains(el)) {
|
|
5622
|
+
const key = focusKey(el);
|
|
5623
|
+
if (key) {
|
|
5624
|
+
let sel = null;
|
|
5625
|
+
// Only a text control has a caret; reading it throws on some other inputs.
|
|
5626
|
+
try { if (typeof el.selectionStart === 'number') sel = [el.selectionStart, el.selectionEnd, el.selectionDirection]; } catch (e) { /* none */ }
|
|
5627
|
+
ui.focus = { key, sel };
|
|
5628
|
+
}
|
|
5629
|
+
}
|
|
5630
|
+
for (const d of root.querySelectorAll('details[open]')) { const k = detailKey(d); if (k) ui.open.add(k); }
|
|
5631
|
+
return ui;
|
|
5632
|
+
}
|
|
5633
|
+
|
|
5634
|
+
/**
|
|
5635
|
+
* What in `root` the reader is using, for a redraw that is about new data and not about them. The Settings controls: the
|
|
5636
|
+
* one with focus (the box, or the button that stands in for a select), the one whose list is open, and any that holds
|
|
5637
|
+
* what was typed and not saved (a number field, a text box, a list, or a choice a refusal put back); each is a `.set__ctl`, the control with its own
|
|
5638
|
+
* error line. And, anywhere, the list of a select that is open (its button, which the list would be left without) and a date box with focus
|
|
5639
|
+
* (a date typed into it is not a value yet): a redraw that replaced either would close the list, or start the date again.
|
|
5640
|
+
*/
|
|
5641
|
+
function unitsInUse(root) {
|
|
5642
|
+
const used = [];
|
|
5643
|
+
for (const unit of root.querySelectorAll('.set__ctl')) {
|
|
5644
|
+
const c = unit.querySelector('[data-set]');
|
|
5645
|
+
if (!c) continue;
|
|
5646
|
+
const edited = c.type === 'checkbox' ? c.checked !== c.defaultChecked
|
|
5647
|
+
: c.tagName === 'SELECT' ? [...c.options].some((o) => o.selected !== o.defaultSelected)
|
|
5648
|
+
: c.value !== c.defaultValue;
|
|
5649
|
+
// The list of a select that is open has the focus, not the button it opened from.
|
|
5650
|
+
if (edited || unit.contains(document.activeElement) || (sOpen && unit.contains(sOpen))) used.push(unit);
|
|
5651
|
+
}
|
|
5652
|
+
if (sOpen && root.contains(sOpen) && !used.some((u) => u.contains(sOpen))) used.push(sOpen);
|
|
5653
|
+
const box = document.activeElement;
|
|
5654
|
+
if (box instanceof HTMLInputElement && box.type === 'date' && root.contains(box)) used.push(box);
|
|
5655
|
+
return used;
|
|
5656
|
+
}
|
|
5657
|
+
|
|
5658
|
+
function restoreUi(root, ui) {
|
|
5659
|
+
for (const el of root.querySelectorAll('[data-keep-scroll]')) el.scrollLeft = ui.scrolls.get(el.dataset.keepScroll) ?? 0;
|
|
5660
|
+
for (const d of root.querySelectorAll('details:not([open])')) { const k = detailKey(d); if (k && ui.open.has(k)) d.open = true; }
|
|
5661
|
+
if (!ui.focus) return;
|
|
5662
|
+
let el = null;
|
|
5663
|
+
try { el = root.querySelector(ui.focus.key); } catch (e) { /* a key that is not a selector finds nothing */ }
|
|
5664
|
+
// The Next button of the last page is disabled and cannot hold focus: land on the page you are on.
|
|
5665
|
+
if (el?.disabled) el = el.closest('.pager')?.querySelector('[aria-current]') ?? null;
|
|
5666
|
+
if (!el) return;
|
|
5667
|
+
el.focus({ preventScroll: true });
|
|
5668
|
+
if (ui.focus.sel) { try { el.setSelectionRange(...ui.focus.sel); } catch (e) { /* not a text control any more */ } }
|
|
5669
|
+
}
|
|
5670
|
+
|
|
4663
5671
|
/* ---------- events, all delegated ---------- */
|
|
4664
5672
|
/** Navigates, re-rendering in place when the hash is already what we want. */
|
|
4665
5673
|
function goTo(hash) {
|
|
@@ -4680,6 +5688,8 @@ function setQuery(changes) {
|
|
|
4680
5688
|
}
|
|
4681
5689
|
|
|
4682
5690
|
function onActivate(e) {
|
|
5691
|
+
// A region waiting for its next answer is dimmed and out of the pointer's reach (CSS); the keyboard is held back the same way.
|
|
5692
|
+
if (e.target.closest('[data-slot][aria-busy="true"][data-kept]')) return;
|
|
4683
5693
|
const copy = e.target.closest('[data-copy]');
|
|
4684
5694
|
if (copy) return copyCommand(copy);
|
|
4685
5695
|
if (e.target.closest('[data-fb-tip-dismiss]')) return fbTipDismiss();
|
|
@@ -4699,8 +5709,7 @@ function onActivate(e) {
|
|
|
4699
5709
|
if (arr) {
|
|
4700
5710
|
if (arr.disabled) return;
|
|
4701
5711
|
HEAT_OFF = Math.max(0, HEAT_OFF + (arr.id === 'heat-prev' ? 1 : -1) * +arr.dataset.weeks);
|
|
4702
|
-
|
|
4703
|
-
return chartHeatmap($('c-heat'), dailyInScope(), st.run);
|
|
5712
|
+
return chartHeatmap($('c-heat'));
|
|
4704
5713
|
}
|
|
4705
5714
|
const pg = e.target.closest('[data-page]');
|
|
4706
5715
|
if (pg && !pg.disabled) { T[pg.dataset.pager].page = +pg.dataset.page; render(); return; }
|
|
@@ -4748,20 +5757,24 @@ $('view').addEventListener('input', (e) => {
|
|
|
4748
5757
|
if (e.target.id === 'q') {
|
|
4749
5758
|
T.concepts.q = e.target.value;
|
|
4750
5759
|
T.concepts.page = 1;
|
|
4751
|
-
render(
|
|
5760
|
+
render();
|
|
4752
5761
|
}
|
|
4753
5762
|
if (e.target.id === 'aq') {
|
|
4754
5763
|
T.artifacts.q = e.target.value;
|
|
4755
5764
|
T.artifacts.page = 1;
|
|
4756
|
-
render(
|
|
5765
|
+
render();
|
|
4757
5766
|
}
|
|
4758
5767
|
if (e.target.id === 'mq') {
|
|
4759
5768
|
T.memory.q = e.target.value;
|
|
4760
5769
|
T.memory.page = 1;
|
|
4761
|
-
render(
|
|
5770
|
+
render();
|
|
4762
5771
|
}
|
|
4763
5772
|
});
|
|
4764
5773
|
|
|
5774
|
+
// A control the reader was using has been let go of (a later focus is checked by `catchUp`): what a redraw for new data
|
|
5775
|
+
// put off for it is drawn now.
|
|
5776
|
+
$('view').addEventListener('focusout', () => setTimeout(catchUp, 0));
|
|
5777
|
+
|
|
4765
5778
|
// Settings: a control saves on change (a text box on blur or Enter), and the
|
|
4766
5779
|
// Inherit / Reset button removes the key from that scope's file.
|
|
4767
5780
|
$('view').addEventListener('click', (e) => {
|
|
@@ -4795,8 +5808,11 @@ $('view').addEventListener('change', (e) => {
|
|
|
4795
5808
|
if (v === 'custom') setQuery({ from: CUR.query.get('from') || daysAgo(6), to: daysAgo(0) });
|
|
4796
5809
|
else setQuery({ from: v === 'any' ? null : daysAgo(WHEN[v]), to: null });
|
|
4797
5810
|
}
|
|
4798
|
-
if (e.target.id === 'mfrom'
|
|
4799
|
-
|
|
5811
|
+
if (e.target.id === 'mfrom' || e.target.id === 'mto') {
|
|
5812
|
+
T.memory.page = 1;
|
|
5813
|
+
DATE_BOX = e.target;
|
|
5814
|
+
try { setQuery({ [e.target.id === 'mfrom' ? 'from' : 'to']: e.target.value }); } finally { DATE_BOX = null; }
|
|
5815
|
+
}
|
|
4800
5816
|
});
|
|
4801
5817
|
|
|
4802
5818
|
addEventListener('hashchange', () => { if (INV) { closePicker(false); syncFromUrl(); } });
|
|
@@ -4804,73 +5820,41 @@ addEventListener('hashchange', () => { if (INV) { closePicker(false); syncFromUr
|
|
|
4804
5820
|
let resizeTimer = 0;
|
|
4805
5821
|
addEventListener('resize', () => {
|
|
4806
5822
|
clearTimeout(resizeTimer);
|
|
4807
|
-
// Not the viewer:
|
|
4808
|
-
resizeTimer = setTimeout(() => { if (S && CUR.page !== 'view') render(
|
|
5823
|
+
// Not the viewer: nothing on it depends on the width, and its frame reports its own height.
|
|
5824
|
+
resizeTimer = setTimeout(() => { if (S && CUR.page !== 'view') render(); }, 120);
|
|
4809
5825
|
});
|
|
4810
5826
|
|
|
4811
5827
|
document.querySelectorAll('[data-ground]').forEach((b) => {
|
|
4812
|
-
b.addEventListener('click', () => {
|
|
5828
|
+
b.addEventListener('click', () => {
|
|
5829
|
+
window.eklavyaGround.set(b.dataset.ground);
|
|
5830
|
+
if (S) render();
|
|
5831
|
+
// The frame is not drawn again with the screen, so its page is told the ground itself.
|
|
5832
|
+
frameMode($('art-frame'));
|
|
5833
|
+
});
|
|
4813
5834
|
});
|
|
4814
5835
|
|
|
4815
|
-
/* ---------- staying honest about how old this page is ---------- */
|
|
4816
|
-
// ponytail: a poll, not SSE. Ceiling: a change shows up to POLL_MS late. Each
|
|
4817
|
-
// tick asks `/api/cursor` for the one string, which costs a single-row read and
|
|
4818
|
-
// a stat of the artifact files, never the payload. The upgrade is SSE over the
|
|
4819
|
-
// same cursor (PRD DASH-01), so that swap is this function, not the page.
|
|
4820
|
-
const POLL_MS = 60000;
|
|
4821
|
-
let polling = false;
|
|
4822
|
-
|
|
4823
|
-
/**
|
|
4824
|
-
* Announced, never silent, and that is the whole design.
|
|
4825
|
-
*
|
|
4826
|
-
* A silent re-render moves the table the reader is halfway down, scrolls them
|
|
4827
|
-
* back to the top and drops the caret out of the search box — the page would
|
|
4828
|
-
* be fighting the person reading it. So the poll touches nothing but a button:
|
|
4829
|
-
* the view keeps rendering from the payload it booted with until the reader
|
|
4830
|
-
* asks for the new one. That also means there is no live state to reconcile
|
|
4831
|
-
* with a view derived from the old snapshot; the reload does that.
|
|
4832
|
-
*/
|
|
4833
|
-
function poll() {
|
|
4834
|
-
// Nothing to do if we never loaded, a request is already out, the notice is
|
|
4835
|
-
// already up, or nobody is looking — a background tab must not poll at all.
|
|
4836
|
-
if (!S || polling || !$('stale').hidden || document.visibilityState !== 'visible') return;
|
|
4837
|
-
polling = true;
|
|
4838
|
-
fetch('/api/cursor')
|
|
4839
|
-
.then((r) => r.json())
|
|
4840
|
-
.then((s) => { if (s.cursor !== S.cursor) $('stale').hidden = false; })
|
|
4841
|
-
// A failed poll changes nothing: the page keeps working on the data it has.
|
|
4842
|
-
// One unreachable server must not blank a dashboard that is merely stale.
|
|
4843
|
-
.catch(() => {})
|
|
4844
|
-
.then(() => { polling = false; });
|
|
4845
|
-
}
|
|
4846
|
-
|
|
4847
|
-
setInterval(poll, POLL_MS);
|
|
4848
|
-
// Coming back to a tab left open all afternoon should not mean waiting out the
|
|
4849
|
-
// interval before the page admits it is stale.
|
|
4850
|
-
addEventListener('visibilitychange', poll);
|
|
4851
|
-
$('stale').addEventListener('click', () => location.reload());
|
|
4852
|
-
|
|
4853
|
-
/* ---------- boot ---------- */
|
|
4854
|
-
// The head script runs before the toggle exists, so its aria-pressed state has
|
|
4855
|
-
// to be applied again once the markup is there — otherwise a reader on paper is
|
|
4856
|
-
// shown "ink" as the active ground.
|
|
4857
|
-
window.eklavyaGround.apply(window.eklavyaGround.read());
|
|
4858
|
-
|
|
4859
5836
|
const json = (url) => fetch(url).then((r) => {
|
|
4860
5837
|
if (!r.ok) throw new Error(`${url} answered ${r.status}`);
|
|
4861
5838
|
return r.json();
|
|
4862
5839
|
});
|
|
4863
5840
|
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
5841
|
+
/**
|
|
5842
|
+
* What boot writes from `S` and `INV` outside the view, and a redraw for new data writes again: the footer's database
|
|
5843
|
+
* path and the line that discloses a cap, the dial line, the project picker and the tips switch. `adoptState` does
|
|
5844
|
+
* not: it swaps the data and leaves the screen to the caller.
|
|
5845
|
+
*/
|
|
5846
|
+
function paintChrome(s, inv) {
|
|
4867
5847
|
$('dbline').textContent = s.db_path;
|
|
4868
|
-
// Totals are aggregated in SQL over the whole
|
|
4869
|
-
// shorten a
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
`
|
|
4873
|
-
|
|
5848
|
+
// Totals, the per-day rows, the concept catalogue and the project inventory are aggregated in SQL over the whole
|
|
5849
|
+
// tables, so a cap can only shorten a list of rows — but say which, rather than let it look complete.
|
|
5850
|
+
$('foot-note').textContent = [
|
|
5851
|
+
s.attempts_shown < s.attempts_total
|
|
5852
|
+
? `Question histories show the most recent ${s.attempts_shown} of ${s.attempts_total} answers.` : '',
|
|
5853
|
+
s.logged_shown < s.logged_total
|
|
5854
|
+
? `Logged context lines show the most recent ${s.logged_shown} of ${s.logged_total}.` : '',
|
|
5855
|
+
s.attempts_shown < s.attempts_total || s.logged_shown < s.logged_total
|
|
5856
|
+
? 'The Sessions list shows the most recent sessions; each session opens whole.' : '',
|
|
5857
|
+
].filter(Boolean).map((t) => ` ${t}`).join('');
|
|
4874
5858
|
const c = s.config;
|
|
4875
5859
|
// Same rule as the status bar in `statusline.ts`: `enforced` is a word worth
|
|
4876
5860
|
// a slot, unenforced quizzing is not -- it was the default on three bars in
|
|
@@ -4890,10 +5874,132 @@ Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
|
|
|
4890
5874
|
// captured memory, is a project and is listed.
|
|
4891
5875
|
buildProjects(inv);
|
|
4892
5876
|
TIP.paint();
|
|
5877
|
+
}
|
|
5878
|
+
|
|
5879
|
+
/* ---------- live: the open page is told, and never asks ----------
|
|
5880
|
+
Live, and never moves the reader. While the page is open and its tab is in view it holds one
|
|
5881
|
+
`EventSource` on `/api/events`, which says one thing: the change cursor, once on connect and
|
|
5882
|
+
again whenever anything the page shows changed (a hook, the MCP server, the CLI or another tab
|
|
5883
|
+
wrote). The stream says *that* something changed and the payload says *what*, so the page keeps
|
|
5884
|
+
one source of truth: on a cursor that is not its own it reads `/api/state` and `/api/projects`
|
|
5885
|
+
(the server has already built them, and answers from memory), swaps them in (`adoptState`),
|
|
5886
|
+
writes what boot writes outside the view (`paintChrome`) and draws the screen it is on again as
|
|
5887
|
+
an adjustment (`syncFromUrl` -> `render`): the same route, the scroll where it was, focus and the
|
|
5888
|
+
caret, the folds that are open, a Settings control being edited, the viewer's frame as the same
|
|
5889
|
+
element. Numbers, rows, badges and the sidebar's counts change in place; nothing moves.
|
|
5890
|
+
|
|
5891
|
+
A write the page made itself (a saved setting, a correction, an acknowledge) still redraws from
|
|
5892
|
+
its own answer, and the event that follows draws what is already there, which replaces nothing.
|
|
5893
|
+
|
|
5894
|
+
- Events that arrive while a read or a redraw is out are one more read after it, not one each.
|
|
5895
|
+
- A hidden tab closes the stream and makes no request at all. Coming back opens it again, and its
|
|
5896
|
+
first event is the current cursor, so a tab left open all afternoon catches up in one read.
|
|
5897
|
+
- A server that goes away is the browser's to retry (the stream stays `CONNECTING` and the page
|
|
5898
|
+
keeps what it has, with no banner). A stream the server refuses (the 33rd, a 503) is not:
|
|
5899
|
+
the browser gives up on it for good, so the page opens a new one after a pause that doubles from
|
|
5900
|
+
five seconds to a minute and starts again from five once a stream opens. */
|
|
5901
|
+
const LIVE_RETRY_MIN = 5000;
|
|
5902
|
+
const LIVE_RETRY_MAX = 60000;
|
|
5903
|
+
const LIVE = {
|
|
5904
|
+
es: null, // the open stream; null while the tab is hidden, or the last one was refused
|
|
5905
|
+
timer: 0, // the pending reopen of a stream that was refused
|
|
5906
|
+
delay: LIVE_RETRY_MIN, // how long that reopen waits
|
|
5907
|
+
heard: '', // the newest cursor the stream has said
|
|
5908
|
+
busy: false, // a read of the new data is out
|
|
5909
|
+
more: false, // a cursor that is not the page's arrived while it was
|
|
5910
|
+
};
|
|
5911
|
+
const inView = () => document.visibilityState === 'visible';
|
|
5912
|
+
|
|
5913
|
+
function liveOpen() {
|
|
5914
|
+
clearTimeout(LIVE.timer);
|
|
5915
|
+
if (LIVE.es || !S || !inView()) return;
|
|
5916
|
+
let es;
|
|
5917
|
+
try {
|
|
5918
|
+
es = new EventSource('/api/events');
|
|
5919
|
+
} catch (e) {
|
|
5920
|
+
return liveReopen();
|
|
5921
|
+
}
|
|
5922
|
+
LIVE.es = es;
|
|
5923
|
+
es.addEventListener('open', () => { LIVE.delay = LIVE_RETRY_MIN; });
|
|
5924
|
+
es.addEventListener('cursor', (e) => liveHeard(e.data));
|
|
5925
|
+
es.addEventListener('error', () => {
|
|
5926
|
+
// While the server is away the stream is CONNECTING and the browser asks again by itself. Closed means it will not.
|
|
5927
|
+
if (LIVE.es === es && es.readyState === EventSource.CLOSED) { LIVE.es = null; liveReopen(); }
|
|
5928
|
+
});
|
|
5929
|
+
}
|
|
5930
|
+
|
|
5931
|
+
function liveReopen() {
|
|
5932
|
+
clearTimeout(LIVE.timer);
|
|
5933
|
+
if (!inView()) return;
|
|
5934
|
+
LIVE.timer = setTimeout(liveOpen, LIVE.delay);
|
|
5935
|
+
LIVE.delay = Math.min(LIVE.delay * 2, LIVE_RETRY_MAX);
|
|
5936
|
+
}
|
|
4893
5937
|
|
|
5938
|
+
function liveClose() {
|
|
5939
|
+
clearTimeout(LIVE.timer);
|
|
5940
|
+
LIVE.es?.close();
|
|
5941
|
+
LIVE.es = null;
|
|
5942
|
+
}
|
|
5943
|
+
|
|
5944
|
+
/** The stream said `cursor`. */
|
|
5945
|
+
function liveHeard(cursor) {
|
|
5946
|
+
LIVE.heard = cursor;
|
|
5947
|
+
if (!S || cursor === S.cursor) return;
|
|
5948
|
+
if (LIVE.busy) { LIVE.more = true; return; }
|
|
5949
|
+
liveRefresh();
|
|
5950
|
+
}
|
|
5951
|
+
|
|
5952
|
+
/**
|
|
5953
|
+
* Reads the new payload and draws it. A failure changes nothing: the page keeps what it has, and the next cursor the
|
|
5954
|
+
* stream says (or a reconnect, whose first event is the current one) asks again.
|
|
5955
|
+
*/
|
|
5956
|
+
function liveRefresh() {
|
|
5957
|
+
LIVE.busy = true;
|
|
5958
|
+
LIVE.more = false;
|
|
5959
|
+
Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
|
|
5960
|
+
try {
|
|
5961
|
+
// The same cursor is the page's own write, or a read that raced the stream: there is nothing new to draw.
|
|
5962
|
+
if (s.cursor !== S.cursor) liveAdopt(s, inv);
|
|
5963
|
+
} finally {
|
|
5964
|
+
LIVE.busy = false;
|
|
5965
|
+
}
|
|
5966
|
+
if (LIVE.more && LIVE.heard !== S.cursor && inView()) liveRefresh();
|
|
5967
|
+
}, () => { LIVE.busy = false; });
|
|
5968
|
+
}
|
|
5969
|
+
|
|
5970
|
+
/**
|
|
5971
|
+
* Swaps the new payload in and draws the current screen again in place. Through `syncFromUrl`, not a bare
|
|
5972
|
+
* `render()`: a project that is no longer in the inventory is dropped the way boot drops one. `LIVE_PASS` makes
|
|
5973
|
+
* the redraw keep what the reader is in the middle of, besides what they had open (see `captureUi`).
|
|
5974
|
+
*/
|
|
5975
|
+
function liveAdopt(s, inv) {
|
|
5976
|
+
LIVE_PASS = true;
|
|
5977
|
+
try {
|
|
5978
|
+
adoptState(s, inv);
|
|
5979
|
+
paintChrome(s, inv);
|
|
5980
|
+
syncFromUrl();
|
|
5981
|
+
} finally {
|
|
5982
|
+
LIVE_PASS = false;
|
|
5983
|
+
}
|
|
5984
|
+
}
|
|
5985
|
+
|
|
5986
|
+
document.addEventListener('visibilitychange', () => {
|
|
5987
|
+
if (inView()) { LIVE.delay = LIVE_RETRY_MIN; liveOpen(); } else liveClose();
|
|
5988
|
+
});
|
|
5989
|
+
|
|
5990
|
+
/* ---------- boot ---------- */
|
|
5991
|
+
// The head script runs before the toggle exists, so its aria-pressed state has
|
|
5992
|
+
// to be applied again once the markup is there — otherwise a reader on paper is
|
|
5993
|
+
// shown "ink" as the active ground.
|
|
5994
|
+
window.eklavyaGround.apply(window.eklavyaGround.read());
|
|
5995
|
+
|
|
5996
|
+
Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
|
|
5997
|
+
adoptState(s, inv);
|
|
5998
|
+
paintChrome(s, inv);
|
|
4894
5999
|
syncFromUrl();
|
|
4895
6000
|
$('app').classList.remove('booting');
|
|
4896
6001
|
$('view').removeAttribute('aria-busy');
|
|
6002
|
+
liveOpen();
|
|
4897
6003
|
}).catch((e) => {
|
|
4898
6004
|
$('app').classList.remove('booting');
|
|
4899
6005
|
$('view').removeAttribute('aria-busy');
|