Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/capps/lifelog/crystal.html

99.4 KiB, 1 run

created by r2519314175:1081, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1<!doctype html>
2<html><head>
3<meta charset="utf-8">
4<meta name="viewport" content="width=device-width,initial-scale=1">
5<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:">
6<title>Log Life</title>
7<style>
8:root{--bg:transparent;--sf:rgba(128,128,128,.10);--tx:#5c5c5c;--mu:#8d8d8d;--bd:rgba(128,128,128,.32);
9--ac:#4a7fd0;--on:#fff;--fo:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
10--mo:ui-monospace,SFMono-Regular,Consolas,monospace;--fs:14px;--rd:8px;--rs:6px;
11--good:#10893E;--bad:#C4314B;--warn:#B87A00}
12*{box-sizing:border-box}
13html,body{margin:0;padding:0;background:transparent}
14/* No overflow on the body: `overflow-x:hidden` makes it a scroll container, and
15 then the page scrolls inside the frame instead of the frame growing to fit it.
16 Nothing here is wider than its column, and the verifier checks that. */
17body{font:400 var(--fs)/1.5 var(--fo);color:var(--tx);padding:0 0 6px;
18overflow-wrap:anywhere;-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent}
19button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer;text-align:left}
20input{font:inherit;color:inherit;background:var(--bg);border:1px solid var(--bd);
21border-radius:var(--rs);padding:7px 9px;width:100%;min-width:0}
22input:focus{outline:2px solid var(--ac);outline-offset:-1px;border-color:var(--ac)}
23svg{display:block}
24
25/* ── the bar across the top ──────── */
26.hd{display:flex;flex-direction:column;gap:7px;padding:0 0 9px;margin:0 0 12px;
27border-bottom:1px solid var(--bd)}
28.row{display:flex;align-items:center;gap:6px;min-width:0}
29.gap{flex:1;min-width:0}
30.chips{display:flex;gap:5px;min-width:0;overflow-x:auto;scrollbar-width:none}
31.chips::-webkit-scrollbar{display:none}
32/* The view switch never scrolls and never shrinks: it is in a `seg` and not in a
33 `chips`, because a `chips` is a scroller and at 375px the lane names took the
34 width and left "Life" cut off inside it. */
35.seg{display:flex;gap:5px;flex:none}
36.chip{border:1px solid var(--bd);background:var(--sf);border-radius:var(--rs);color:var(--mu);
37font-size:.82em;line-height:1.5;padding:3px 9px;white-space:nowrap;flex:none;
38transition:transform .07s,background .12s,color .12s}
39.chip.on{background:var(--ac);border-color:var(--ac);color:var(--on);font-weight:600}
40.chip:active{transform:scale(.95)}
41.chip.q{padding:3px 7px;font-family:var(--mo)}
42.tag{font-size:.78em;color:var(--mu);white-space:nowrap;font-family:var(--mo)}
43.tag b{color:var(--ac);font-weight:650}
44.cf{margin:7px 0 8px}
45
46/* ── section furniture ──────── */
47.sh{font-size:.79em;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--mu);
48white-space:nowrap}
49.shrow{display:flex;align-items:center;gap:8px;margin:0 0 7px}
50.card{background:var(--sf);border:1px solid var(--bd);border-radius:var(--rd);padding:10px 12px;
51margin:0 0 10px}
52.empty{color:var(--mu);font-size:.9em;padding:10px 0;font-style:italic}
53
54/* ── the tile pad: the thing you actually press ──────── */
55.pad{display:grid;grid-template-columns:repeat(auto-fill,minmax(98px,1fr));gap:8px;margin:0 0 12px}
56.tile{background:var(--sf);border:1px solid var(--bd);border-radius:var(--rd);padding:9px 10px;
57min-height:66px;display:flex;flex-direction:column;justify-content:space-between;gap:5px;
58transition:transform .08s,background .14s,border-color .14s}
59.tile:active{transform:scale(.955);background:var(--ac);border-color:var(--ac);color:var(--on)}
60.tn{font-weight:600;font-size:.94em;line-height:1.25;color:var(--tx)}
61.ts{font-size:.76em;color:var(--mu);font-family:var(--mo);line-height:1.3}
62.tile.acc{border-color:var(--ac)}
63.tile.acc .tn{color:var(--ac)}
64.tile.run{background:var(--ac);border-color:var(--ac)}
65.tile.run .tn,.tile.run .ts{color:var(--on)}
66/* THE INK INVERTS WITH THE GROUND, and this rule sits HERE — after `.tile.acc .tn`
67 — deliberately. Both selectors weigh (0,2,1), so the later one wins, and with
68 them the other way round a pressed accent tile drew accent ink on an accent
69 ground: contrast 1.00, the tile's own name unreadable under the thumb that was
70 pressing it. The verifier measures the ratio on every filled surface. */
71.tile:active .ts,.tile:active .tn{color:var(--on)}
72.tile.wide{grid-column:1/-1;min-height:52px;flex-direction:row;align-items:center;
73justify-content:flex-start;gap:10px}
74.step{grid-column:1/-1;display:flex;align-items:center;gap:8px;background:var(--sf);
75border:1px solid var(--ac);border-radius:var(--rd);padding:8px 10px}
76.stepb{width:38px;height:38px;flex:none;border:1px solid var(--bd);border-radius:var(--rs);
77display:flex;align-items:center;justify-content:center;font-size:1.2em;background:var(--bg)}
78.stepb:active{background:var(--ac);color:var(--on);border-color:var(--ac)}
79.amt{font-family:var(--mo);font-size:1.05em;font-weight:650;color:var(--tx);min-width:62px;
80text-align:center}
81.go{background:var(--ac);color:var(--on);border-radius:var(--rs);padding:9px 14px;font-weight:650;
82font-size:.92em;white-space:nowrap;border:1px solid var(--ac)}
83.go:active{transform:scale(.97)}
84.ghost{background:var(--bg);border:1px solid var(--bd);color:var(--tx);border-radius:var(--rs);
85padding:9px 14px;font-weight:600;font-size:.92em;white-space:nowrap}
86.pickbtn{width:100%}
87.toast{display:flex;align-items:center;gap:8px;border:1px solid var(--ac);border-radius:var(--rd);
88padding:7px 10px;margin:0 0 12px;font-size:.88em;background:var(--sf)}
89.toast.err{border-color:var(--bad)}
90.toast .gap{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
91
92/* ── numbers ──────── */
93.stats{display:grid;grid-template-columns:repeat(auto-fill,minmax(88px,1fr));gap:9px}
94.st{min-width:0}
95.sk{font-size:.75em;color:var(--mu);letter-spacing:.02em;white-space:nowrap;overflow:hidden;
96text-overflow:ellipsis}
97.sv{font-size:1.32em;font-weight:650;line-height:1.25;color:var(--tx);letter-spacing:-.01em;
98font-variant-numeric:tabular-nums}
99.sv u{font-size:.6em;font-weight:500;color:var(--mu);text-decoration:none;margin-left:2px}
100.bar{height:4px;background:var(--bd);border-radius:3px;overflow:hidden;margin:5px 0 2px}
101.bar i{display:block;height:100%;border-radius:3px;background:var(--ac)}
102.tgt{font-size:.7em;color:var(--mu);font-family:var(--mo);display:flex;gap:4px;align-items:center}
103.tgt b{font-weight:700}
104
105/* ── rows ──────── */
106.er{display:flex;align-items:baseline;gap:8px;width:100%;padding:8px 0;
107border-top:1px solid var(--bd)}
108.er:first-child{border-top:0}
109.et{font-family:var(--mo);font-size:.76em;color:var(--mu);flex:none;width:38px}
110.en{flex:1;min-width:0;font-size:.93em;color:var(--tx)}
111.en small{display:block;color:var(--mu);font-size:.82em;font-family:var(--mo)}
112.ev{font-family:var(--mo);font-size:.83em;color:var(--mu);flex:none;text-align:right;
113white-space:nowrap;font-variant-numeric:tabular-nums}
114.src{flex:none;width:6px;height:6px;border-radius:50%;background:var(--ac);opacity:.55;
115align-self:center}
116.chev{flex:none;color:var(--mu);font-size:.9em}
117.dayh{font-size:.75em;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
118color:var(--mu);padding:10px 0 2px;border-top:1px solid var(--bd)}
119.dayh:first-child{border-top:0;padding-top:0}
120.grp{margin:0 0 4px}
121.grph{display:flex;align-items:center;gap:8px;padding:9px 0 3px;font-size:.85em;font-weight:650;
122color:var(--tx);border-top:1px solid var(--bd)}
123.setr{display:flex;align-items:center;gap:8px;width:100%;padding:4px 0;font-size:.88em}
124.setr .ev{font-size:.8em}
125.setr .gap{overflow:hidden}
126
127/* ── a scope, and the rows inside it that are not yet true ──────── */
128.scope{display:flex;align-items:center;gap:9px;background:var(--sf);border:1px solid var(--ac);
129border-radius:var(--rd);padding:8px 11px;margin:0 0 10px}
130.scope .h1{margin:0}
131.rest{display:flex;align-items:center;gap:7px;border:1px solid var(--ac);border-radius:var(--rd);
132padding:6px 9px;margin:0 0 10px;background:var(--sf)}
133.rest.done{border-color:var(--bd)}
134.rt{font-family:var(--mo);font-size:1.15em;font-weight:700;color:var(--ac);min-width:54px;
135font-variant-numeric:tabular-nums}
136.rest.done .rt{color:var(--mu)}
137/* Greyed until the set is true, then green. The tick is the commit. */
138.tick{width:30px;height:30px;flex:none;border:1px solid var(--bd);border-radius:var(--rs);
139display:flex;align-items:center;justify-content:center;color:var(--mu);background:var(--bg)}
140.tick.on{background:var(--good);border-color:var(--good);color:#fff}
141.setin{width:56px;text-align:center;font-family:var(--mo);font-weight:600;padding:4px 5px;flex:none}
142.pickr.on{background:var(--ac);border-color:var(--ac);color:var(--on);border-radius:var(--rs)}
143.pickr.on .gap,.pickr.on .ev{color:var(--on)}
144/* A plan reads quieter than a record, so a day already logged is not confused with
145 a day merely intended. */
146.er.pend .en{color:var(--mu)}
147
148/* ── charts ──────── */
149.leg{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:3px 10px;
150margin:8px 0 0}
151.lg{display:flex;align-items:center;gap:6px;font-size:.8em;min-width:0}
152.sw{width:9px;height:9px;border-radius:2px;flex:none}
153.lgn{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--tx)}
154.lgv{font-family:var(--mo);color:var(--mu);flex:none;font-variant-numeric:tabular-nums}
155.pie{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
156
157/* ── forms and pickers ──────── */
158.fr{margin:0 0 11px}
159.fl{font-size:.78em;color:var(--mu);margin:0 0 4px;letter-spacing:.02em}
160.num{display:flex;align-items:center;gap:7px}
161.num input{text-align:center;font-family:var(--mo);font-weight:600}
162.acts{display:flex;gap:8px;align-items:center;margin:14px 0 4px}
163/* Inset, so a chosen row's filled band has a margin round its words rather than
164 ending flush against them. */
165.pickr{display:flex;align-items:baseline;gap:8px;width:100%;padding:9px 8px;
166border-top:1px solid var(--bd);font-size:.92em}
167.back{display:flex;align-items:center;gap:6px;font-size:.9em;color:var(--mu);margin:0 0 10px}
168.fbox{border:1px solid var(--bd);border-radius:var(--rd);padding:9px 10px;margin:0 0 9px}
169/* Inside a field box the chips WRAP rather than scroll. Everywhere else a chip row
170 is a scroller, but here the last of the five types sat off the end of it on a
171 phone with nothing on screen to say it was there. */
172.fbox .chips{flex-wrap:wrap;overflow-x:visible}
173.frm{flex:none;color:var(--mu);font-size:1.05em;padding:0 3px}
174.hint{font-size:.82em;color:var(--mu);margin:7px 0 0}
175.hint+.fr{margin-top:13px}
176.plus{font-size:1.1em;font-weight:700;line-height:1.2;padding:2px 8px}
177.h1{font-size:1.16em;font-weight:650;color:var(--tx);line-height:1.3;margin:0 0 2px}
178.mem{margin:16px 0 0;border-top:1px solid var(--bd);padding:10px 0 0}
179.mem summary{font-size:.79em;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
180color:var(--mu);cursor:pointer;list-style:none}
181.mem summary::-webkit-details-marker{display:none}
182.mem summary::before{content:"\25B8 ";color:var(--mu)}
183.mem[open] summary::before{content:"\25BE "}
184.mk{font-family:var(--mo);font-size:.76em;color:var(--mu);margin:9px 0 2px}
185.mv{font-size:.87em;white-space:pre-wrap}
186</style>
187</head><body><div id="app"></div>
188<script>
189(function () {
190'use strict';
191
192// ── The channel ────────────
193// `id` matches an answer to a question: the replies carry no `cmd`.
194var PR = 1, seq = 0, pend = {}, APP = document.getElementById('app');
195
196function post(o) { o.dc = 1; o.v = PR; parent.postMessage(o, '*'); }
197function req(cmd, x) {
198 var id = 'q' + (++seq), m = { dc: 1, v: PR, cmd: cmd, id: id }, k;
199 for (k in x) if (x.hasOwnProperty(k)) m[k] = x[k];
200 return new Promise(function (r) { pend[id] = r; parent.postMessage(m, '*'); });
201}
202function read(p) { return req('asset', { path: p }); }
203function save(p, t, mode) { return req('save', { path: p, text: t, mode: mode || 'append' }); }
204/// A JSON file, or null. There is no `list` verb, so a missing shard reads as an
205/// error and an error is simply "nothing there yet".
206function jread(p) {
207 return read(p).then(function (r) {
208 if (!r || r.error || !r.text) return null;
209 try { return JSON.parse(r.text); } catch (e) { return null; }
210 });
211}
212
213// ── Small things ────────────
214function esc(s) {
215 return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
216 .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
217}
218/// A button carrying an action and its argument, which is how every tap gets
219/// back here: one delegated listener, no handler per element.
220function B(cls, a, v, inner) {
221 return '<button class="' + cls + '" data-a="' + a + '" data-v="' + esc(v) + '">' + inner + '</button>';
222}
223function C(n, a, v, on) { return B('chip' + (on ? ' on' : ''), a, v, esc(n)); }
224/// One reading: its name, its number and its unit.
225function ST(k, s, extra) {
226 return '<div class="st"><div class="sk">' + esc(k) + '</div><div class="sv">' + esc(s.v)
227 + (s.u ? '<u>' + esc(s.u) + '</u>' : '') + '</div>' + (extra || '') + '</div>';
228}
229function p2(n) { return (n < 10 ? '0' : '') + n; }
230function num(v) { v = (v === '' || v == null) ? null : +v; return (v != null && isFinite(v)) ? v : null; }
231function fmt(n, dp) {
232 if (n == null || !isFinite(n)) return '—';
233 var a = Math.abs(n);
234 if (dp == null) dp = a >= 100 ? 0 : a >= 10 ? 1 : a >= 1 ? 1 : 2;
235 var s = n.toFixed(dp).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
236 // Global: without it only the first group is separated and a month of volume
237 // reads "1,234567".
238 return s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
239}
240function dur(sec) {
241 if (sec == null || !isFinite(sec)) return '—';
242 var m = Math.round(sec / 60), h = Math.floor(m / 60);
243 return h ? (h + 'h' + (m % 60 ? ' ' + (m % 60) + 'm' : '')) : (m + 'm');
244}
245function ago(ms) {
246 if (ms == null) return '—';
247 var m = Math.round(ms / 60000);
248 if (m < 90) return m + 'm';
249 var h = Math.round(m / 60);
250 return h < 36 ? h + 'h' : Math.round(h / 24) + 'd';
251}
252
253// Dates. Never `toISOString`: a meal at 23:30 belongs to today, and UTC would
254// move it. Both stamps are kept — `at` exact with its offset, `day` the local
255// date the entry belongs to, which is not the same thing once a lane moves its
256// day boundary off midnight.
257function ymd(d) { return d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()); }
258function isoAt(d) {
259 var o = -d.getTimezoneOffset(), s = o < 0 ? '-' : '+', a = Math.abs(o);
260 return ymd(d) + 'T' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds())
261 + s + p2(Math.floor(a / 60)) + ':' + p2(a % 60);
262}
263function dayOf(d, start) { return ymd(new Date(d.getTime() - (start || 0) * 60000)); }
264function tms(at) { var t = Date.parse(at); return isFinite(t) ? t : 0; }
265function hhmm(at) { var d = new Date(tms(at)); return p2(d.getHours()) + ':' + p2(d.getMinutes()); }
266function dnum(s) { var a = String(s).split('-'); return Math.floor(Date.UTC(+a[0], +a[1] - 1, +a[2]) / 864e5); }
267function dstr(n) {
268 var d = new Date(n * 864e5);
269 return d.getUTCFullYear() + '-' + p2(d.getUTCMonth() + 1) + '-' + p2(d.getUTCDate());
270}
271function dow(n) { return (n + 4) % 7; }
272var MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
273var DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
274
275// A bucket is a day, a week or a month, named by the day it starts on.
276function bstart(day, per) {
277 if (per === 'month') return day.slice(0, 7) + '-01';
278 if (per === 'week') {
279 var n = dnum(day), ws = (IDX && IDX.weekStart != null) ? IDX.weekStart : 1;
280 return dstr(n - ((dow(n) - ws + 7) % 7));
281 }
282 return day;
283}
284function bshift(b, per, k) {
285 if (per === 'month') {
286 var a = b.split('-'), y = +a[0], m = +a[1] - 1 + k;
287 y += Math.floor(m / 12); m = ((m % 12) + 12) % 12;
288 return y + '-' + p2(m + 1) + '-01';
289 }
290 return dstr(dnum(b) + k * (per === 'week' ? 7 : 1));
291}
292function bend(b, per) { return bshift(b, per, 1); }
293function bdays(b, per) { return per === 'day' ? 1 : dnum(bend(b, per)) - dnum(b); }
294function blabel(b, per, short) {
295 var d = new Date(b + 'T12:00:00');
296 if (per === 'month') return MON[d.getMonth()] + (short ? '' : ' ' + d.getFullYear());
297 if (per === 'week') return (short ? '' : 'Week of ') + d.getDate() + ' ' + MON[d.getMonth()];
298 if (short) return String(d.getDate());
299 var t = ymd(new Date());
300 if (b === t) return 'Today';
301 if (b === dstr(dnum(t) - 1)) return 'Yesterday';
302 return DAYS[d.getDay()] + ' ' + d.getDate() + ' ' + MON[d.getMonth()];
303}
304
305// ── Expressions ────────────
306// `eval` and `new Function` are blocked by the policy, so `kg*(1+reps/30)` is
307// parsed by hand: shunting-yard to RPN once per field, a stack walk per entry.
308var PREC = { '+': 1, '-': 1, '*': 2, '/': 2 };
309function lex(src) {
310 var s = String(src), t = [], i = 0, j;
311 while (i < s.length) {
312 var c = s.charAt(i);
313 if (c === ' ') { i++; continue; }
314 if (/[0-9.]/.test(c)) {
315 j = i; while (j < s.length && /[0-9.]/.test(s.charAt(j))) j++;
316 t.push({ n: parseFloat(s.slice(i, j)) }); i = j; continue;
317 }
318 if (/[A-Za-z_]/.test(c)) {
319 j = i; while (j < s.length && /[A-Za-z0-9_]/.test(s.charAt(j))) j++;
320 t.push({ v: s.slice(i, j) }); i = j; continue;
321 }
322 t.push({ o: c }); i++;
323 }
324 return t;
325}
326function rpn(src) {
327 var t = lex(src), out = [], ops = [], prev = null, i;
328 for (i = 0; i < t.length; i++) {
329 var k = t[i];
330 if (k.n != null || k.v) { out.push(k); prev = k; continue; }
331 if (k.o === '(') { ops.push(k); prev = k; continue; }
332 if (k.o === ')') {
333 while (ops.length && ops[ops.length - 1].o !== '(') out.push(ops.pop());
334 ops.pop(); prev = k; continue;
335 }
336 // A minus with no value before it negates rather than subtracts.
337 if (k.o === '-' && (!prev || prev.o === '(' || PREC[prev.o])) out.push({ n: 0 });
338 while (ops.length && PREC[ops[ops.length - 1].o] >= PREC[k.o]) out.push(ops.pop());
339 ops.push(k); prev = k;
340 }
341 while (ops.length) out.push(ops.pop());
342 return out;
343}
344function runRpn(prog, get) {
345 var st = [], i, a, b;
346 for (i = 0; i < prog.length; i++) {
347 var k = prog[i];
348 if (k.n != null) { st.push(k.n); continue; }
349 if (k.v) { var x = get(k.v); if (x == null) return null; st.push(x); continue; }
350 b = st.pop(); a = st.pop();
351 if (a == null || b == null) return null;
352 st.push(k.o === '+' ? a + b : k.o === '-' ? a - b : k.o === '*' ? a * b
353 : (b ? a / b : null));
354 }
355 var r = st.pop();
356 return (typeof r === 'number' && isFinite(r)) ? r : null;
357}
358
359// ── The store ────────────
360// index.json lanes present, week start
361// lanes/<lane>.json fields, tiles, templates, targets
362// cat/<lane>.json the catalogue
363// log/<lane>/YYYY-MM.jsonl one shard per month, append-only
364//
365// Monthly shards because `save` in append mode is a read-then-write: a year in
366// one file would rewrite hundreds of kilobytes for every meal.
367var IDX = null, LANE = {}, CAT = {}, LOG = {}, GOT = {};
368
369/// Last write wins per id, and an import never replaces what a person typed.
370function ingest(ln, e) {
371 var m = LOG[ln] || (LOG[ln] = {}), p = m[e.id];
372 if (p) {
373 if (e.src === 'import' && p.src !== 'import') return;
374 if ((e.w || 0) < (p.w || 0)) return;
375 }
376 m[e.id] = e;
377}
378function ingestText(ln, txt) {
379 var ls = String(txt).split('\n'), i;
380 for (i = 0; i < ls.length; i++) {
381 var s = ls[i].trim(); if (!s) continue;
382 try { var e = JSON.parse(s); } catch (x) { continue; }
383 if (e && e.id) ingest(ln, e);
384 }
385}
386/// Every month shard touching [from, to). A shard that is not there yet is
387/// remembered as read, so a lane with no history costs one failed read and no
388/// more.
389function shards(ln, from, to) {
390 var a = from.slice(0, 7), b = to.slice(0, 7), jobs = [], guard = 0;
391 while (a <= b && guard++ < 60) {
392 var key = ln + '/' + a;
393 if (!GOT[key]) {
394 GOT[key] = 1;
395 jobs.push(read('log/' + ln + '/' + a + '.jsonl').then((function (l) {
396 return function (r) { if (r && !r.error && r.text) ingestText(l, r.text); };
397 })(ln)));
398 }
399 var y = +a.slice(0, 4), m = +a.slice(5, 7) + 1;
400 if (m > 12) { m = 1; y++; }
401 a = y + '-' + p2(m);
402 }
403 return jobs.length ? Promise.all(jobs) : null;
404}
405/// Append one or more entries, grouped into the shard each one's day belongs to.
406/// One save per shard, not one per entry: a template that expands to three meals
407/// is one line-triple in one write.
408function put(ln, es) {
409 var by = {}, i, ps = [];
410 for (i = 0; i < es.length; i++) {
411 var e = es[i];
412 e.w = Date.now();
413 ingest(ln, e);
414 var ym = e.day.slice(0, 7);
415 (by[ym] || (by[ym] = [])).push(JSON.stringify(e));
416 }
417 for (var ym2 in by) {
418 GOT[ln + '/' + ym2] = 1;
419 ps.push(save('log/' + ln + '/' + ym2 + '.jsonl', by[ym2].join('\n') + '\n', 'append'));
420 }
421 return Promise.all(ps).then(function (rs) {
422 for (var j = 0; j < rs.length; j++) {
423 if (rs[j] && rs[j].error) { toast('Not saved: ' + rs[j].error, 1); return false; }
424 }
425 return true;
426 });
427}
428function nid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
429/// A lane as it goes to disk. The page hangs working state on its own structures —
430/// `_p`, a formula parsed once per field; `_m`, the catalogue's index — and a plain
431/// `JSON.stringify` writes those into the lane file, where they are noise that grows
432/// every time the lane is saved. A leading underscore is reserved on the wire for
433/// the same reason it is reserved here.
434function laneJson(l) {
435 return JSON.stringify(l, function (k, v) { return k.charAt(0) === '_' ? undefined : v; });
436}
437
438// ── Fields ────────────
439function fdef(l, k) {
440 var f = l.fields || [], i;
441 for (i = 0; i < f.length; i++) if (f[i].k === k) return f[i];
442 return null;
443}
444function cmap(id) {
445 var c = CAT[id]; if (!c || !c.items) return {};
446 if (!c._m) { c._m = {}; for (var i = 0; i < c.items.length; i++) c._m[c.items[i].id] = c.items[i]; }
447 return c._m;
448}
449function citem(l, id) { return cmap(l.id)[id] || null; }
450function cname(l, id) { var it = citem(l, id); return it ? it.n : (id || ''); }
451
452/// A field's value on one entry: stored, scaled from the catalogue, or computed.
453///
454/// {c:'food',k:'kcal',per:100,by:'g'} — kcal per 100 g times the grams eaten
455/// {e:'kg*reps'} — a product
456/// {e:'kg*(1+reps/30)'} — Epley's 1RM
457function fval(l, e, f, depth) {
458 if (!f) return null;
459 var fs = e.f || {};
460 if (f.d && f.d.c) {
461 var it = citem(l, fs[f.d.c]); if (!it) return null;
462 var base = num(fs[f.d.by]), v = num((it.v || {})[f.d.k]);
463 if (base == null || v == null) return null;
464 return v * base / (f.d.per || 100);
465 }
466 if (f.d && f.d.e) {
467 if ((depth || 0) > 4) return null;
468 if (!f._p) f._p = rpn(f.d.e);
469 return runRpn(f._p, function (nm) {
470 var g = fdef(l, nm); return g ? fnum(l, e, g, (depth || 0) + 1) : null;
471 });
472 }
473 var raw = fs[f.k];
474 // A LENGTH FALLS OUT OF TWO STAMPS. An entry that has been closed — `end` beside
475 // its `at` — reads its duration from the pair rather than from a number somebody
476 // typed, so a session's length is measured and not estimated. A duration that IS
477 // stored still wins, which is how a timed nap and a timed session live in one
478 // lane.
479 if ((raw === '' || raw === undefined) && f.t === 'dur' && e.end) {
480 return Math.round(spanOf(e));
481 }
482 return (raw === '' || raw === undefined) ? null : raw;
483}
484function fnum(l, e, f, d) {
485 var v = fval(l, e, f, d);
486 if (typeof v === 'boolean') return v ? 1 : 0;
487 return num(v);
488}
489/// How a field reads on screen, with its unit.
490function fshow(l, e, f) {
491 var v = fval(l, e, f);
492 if (v == null) return '';
493 if (f.t === 'ref') return cname(l, v);
494 if (f.t === 'bool') return v ? 'yes' : 'no';
495 if (f.t === 'dur') return dur(num(v));
496 if (f.t === 'text' || f.t === 'enum') return String(v);
497 return fmt(num(v)) + (f.u ? ' ' + f.u : '');
498}
499
500// ── Open and pending ────────────
501//
502// Two properties an entry can carry, and between them they turn a log into a plan
503// and a record kept in one place.
504//
505// `end` is the second stamp. An entry with an `at` and no `end` is STILL RUNNING —
506// that is the whole definition of an open session, and its length is the distance
507// between the two rather than a number anybody types.
508//
509// `p` is PENDING: something meant, not something done. It is drawn, it can be
510// edited, and it is invisible to every number on the page — a meal planned for
511// tonight is not eaten, a set written down is not lifted. `solid` is the one gate
512// that makes that true, and every path that adds anything up goes through it.
513
514/// Seconds between the two stamps, or since the first if there is no second.
515function spanOf(e) { return ((e.end ? tms(e.end) : Date.now()) - tms(e.at)) / 1000; }
516/// It has begun and it has not finished.
517function sessOpen(e) { return !!e && !e.end; }
518/// The entries that count: everything that is not a plan.
519function solid(es) {
520 var o = [], i;
521 for (i = 0; i < es.length; i++) if (!es[i].p) o.push(es[i]);
522 return o;
523}
524
525// ── Aggregation ────────────
526function reduce(vals, how) {
527 var n = vals.length, i, s = 0;
528 if (how === 'count') return n;
529 if (!n) return null;
530 if (how === 'last') return vals[n - 1];
531 if (how === 'max') { s = vals[0]; for (i = 1; i < n; i++) if (vals[i] > s) s = vals[i]; return s; }
532 if (how === 'min') { s = vals[0]; for (i = 1; i < n; i++) if (vals[i] < s) s = vals[i]; return s; }
533 for (i = 0; i < n; i++) s += vals[i];
534 return how === 'mean' ? s / n : s;
535}
536/// EVERY number on the page comes through here or through `groups`, which is why
537/// the pending gate is on these two and not scattered over the callers: the stats
538/// panel, the targets, the bars, the session totals and the group headings are all
539/// `aggr`, and the pie is `groups`.
540function aggr(l, es, f, how) {
541 how = how || f.agg || 'sum';
542 es = solid(es);
543 var i, v;
544 if (how === 'since') {
545 var last = null;
546 for (i = 0; i < es.length; i++) { if (fval(l, es[i], f) != null) last = es[i]; }
547 return last ? (Date.now() - tms(last.at)) : null;
548 }
549 if (how === 'count') {
550 var n = 0;
551 for (i = 0; i < es.length; i++) if (fval(l, es[i], f) != null) n++;
552 return n;
553 }
554 // The last reading may be a word: the mood you were in is not a number.
555 if (how === 'last' && (f.t === 'enum' || f.t === 'text' || f.t === 'ref')) {
556 var w = null;
557 for (i = 0; i < es.length; i++) { v = fval(l, es[i], f); if (v != null) w = v; }
558 return w;
559 }
560 var vals = [];
561 for (i = 0; i < es.length; i++) { v = fnum(l, es[i], f); if (v != null) vals.push(v); }
562 return reduce(vals, how);
563}
564/// A rollup aggregates the DAYS rather than the entries, which is what lets sleep
565/// sum within a night and average across a week.
566function aggPer(l, es, f, per, how) {
567 if (how || !f.ru || per === 'day') return aggr(l, es, f, how);
568 // A rollup buckets by day BEFORE it aggregates, so the gate goes on here as well:
569 // a day holding nothing but a plan must not become a day with a reading in it.
570 es = solid(es);
571 var by = {}, ds = [], i, k;
572 for (i = 0; i < es.length; i++) (by[es[i].day] || (by[es[i].day] = [])).push(es[i]);
573 for (k in by) { var v = aggr(l, by[k], f); if (v != null) ds.push(v); }
574 return reduce(ds, f.ru);
575}
576function statText(l, f, v, how) {
577 how = how || f.agg;
578 if (v == null) return { v: '—', u: '' };
579 if (how === 'since') return { v: ago(v), u: 'ago' };
580 if (how === 'count') return { v: fmt(v, 0), u: 'x' };
581 if (f.t === 'dur') return { v: dur(v), u: '' };
582 if (f.t === 'enum' || f.t === 'text') return { v: String(v), u: '' };
583 if (f.t === 'ref') return { v: cname(l, v), u: '' };
584 return { v: fmt(v), u: f.u || '' };
585}
586
587function live(ln) {
588 var m = LOG[ln] || {}, o = [], k;
589 for (k in m) if (m.hasOwnProperty(k) && !m[k].del) o.push(m[k]);
590 o.sort(function (a, b) { return tms(a.at) - tms(b.at); });
591 return o;
592}
593function inBucket(ln, b, per) {
594 var a = live(ln), o = [], e = bend(b, per), i;
595 for (i = 0; i < a.length; i++) if (a[i].day >= b && a[i].day < e) o.push(a[i]);
596 return o;
597}
598function kids(ln, id) {
599 var a = live(ln), o = [], i;
600 for (i = 0; i < a.length; i++) if (a[i].of === id) o.push(a[i]);
601 return o;
602}
603/// Consecutive days with something in them. A day not yet logged does not break
604/// a streak; the day before it does.
605function streak(ln, l) {
606 var a = solid(live(ln)), days = {}, i;
607 for (i = 0; i < a.length; i++) days[a[i].day] = 1;
608 var n = dnum(dayOf(new Date(), l.dayStart)), c = 0;
609 if (!days[dstr(n)]) n--;
610 while (days[dstr(n)]) { c++; n--; }
611 return c;
612}
613/// The target in force for a field over the shown period. A daily target still
614/// applies over a week when the field sums — it just multiplies.
615function target(l, k, b, per) {
616 var ts = l.targets || [], i;
617 for (i = 0; i < ts.length; i++) if (ts[i].f === k && ts[i].p === per) {
618 return { v: ts[i].v, dir: ts[i].dir, n: 1, p: per };
619 }
620 if (per !== 'day') {
621 var f = fdef(l, k);
622 for (i = 0; i < ts.length; i++) if (ts[i].f === k && ts[i].p === 'day' && f
623 && (f.agg === 'sum' || f.agg === 'count') && !f.ru) {
624 var d = bdays(b, per);
625 return { v: ts[i].v * d, dir: ts[i].dir, n: d, p: 'day' };
626 }
627 }
628 return null;
629}
630
631// ── Colour ────────────
632// The palette arrives resolved, and everything else is derived from it, so every
633// theme and both skins work without one of them being named here.
634var DARK = false;
635var PAL_L = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'];
636var PAL_D = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'];
637function pal(i) { return (DARK ? PAL_D : PAL_L)[i % 8]; }
638/// The relative luminance of a colour the app resolved — `#rgb`, `#rrggbb` or an
639/// `rgb()` triple, and gamma-corrected so it can be divided into a contrast ratio.
640///
641/// It read the TRIPLE ALONE, and this build's palette is hexadecimal: every colour
642/// therefore scored a flat 0.5. Two things silently stopped working. The dark-mode
643/// chart hues were never chosen, because `DARK` was decided by a number that never
644/// moved. And the ink that reads on a filled accent was never checked at all.
645function luma(s) {
646 var t = String(s || '').trim(), m, r, g, b, f;
647 if (t.charAt(0) === '#') {
648 t = t.slice(1);
649 if (t.length === 3) {
650 t = t.charAt(0) + t.charAt(0) + t.charAt(1) + t.charAt(1) + t.charAt(2) + t.charAt(2);
651 }
652 if (t.length < 6) return 0.5;
653 r = parseInt(t.slice(0, 2), 16); g = parseInt(t.slice(2, 4), 16); b = parseInt(t.slice(4, 6), 16);
654 } else {
655 m = /(-?[\d.]+)[,\s]+(-?[\d.]+)[,\s]+(-?[\d.]+)/.exec(t);
656 if (!m) return 0.5;
657 r = +m[1]; g = +m[2]; b = +m[3];
658 }
659 f = function (v) {
660 v = v / 255;
661 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
662 };
663 return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
664}
665function ratio(a, b) { return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); }
666function theme(t) {
667 if (!t) return;
668 var m = {
669 bg: '--bg', surface: '--sf', text: '--tx', muted: '--mu', border: '--bd',
670 accent: '--ac', font: '--fo', mono: '--mo', size: '--fs', radius: '--rd'
671 }, s = document.documentElement.style, k;
672 for (k in m) if (t[k]) s.setProperty(m[k], t[k]);
673 // WHAT READS ON TOP OF A FILLED ACCENT, and it is NOT simply what the app sent.
674 //
675 // This page used to take `accentText` on trust. That value is a light TINT of the
676 // accent — ink for accent-coloured text on the page's own ground — and it is not
677 // ink for text ON the accent: `#E9A392` on `#D2604F` is 1.83:1. Every selected
678 // chip, every chosen row and every filled button was therefore drawn in a colour
679 // that could not be read off the darker ground it had just been given, which is
680 // exactly the fault reported. So the value is TESTED against the accent it would
681 // sit on, and black or white — whichever carries further — is used when it fails.
682 if (t.accent || t.accentText) {
683 var la = luma(t.accent || '#000000'), on = t.accentText;
684 if (!on || ratio(luma(on), la) < 3) on = ratio(0, la) >= ratio(1, la) ? '#141414' : '#ffffff';
685 s.setProperty('--on', on);
686 }
687 if (t.radius) s.setProperty('--rs', 'calc(' + t.radius + ' * 0.75)');
688 DARK = luma(t.text) > 0.4;
689}
690
691// ── Charts ────────────
692function px(n) { return Math.round(n * 10) / 10; }
693function barPath(x, y, w, h, r) {
694 r = Math.min(r, w / 2, h);
695 return 'M' + px(x) + ' ' + px(y + h) + 'V' + px(y + r)
696 + 'a' + r + ' ' + r + ' 0 0 1 ' + r + ' ' + (-r)
697 + 'h' + px(w - 2 * r) + 'a' + r + ' ' + r + ' 0 0 1 ' + r + ' ' + r
698 + 'V' + px(y + h) + 'Z';
699}
700/// One series over time, so the bars wear the app's accent and need no legend.
701function chartBar(l, key, per, cur, W) {
702 var f = fdef(l, key); if (!f) return '';
703 var N = per === 'day' ? 14 : per === 'week' ? 10 : 12, bs = [], vs = [], i;
704 for (i = N - 1; i >= 0; i--) bs.push(bshift(cur, per, -i));
705 for (i = 0; i < N; i++) vs.push(aggPer(l, inBucket(l.id, bs[i], per), f, per) || 0);
706 var max = 0;
707 for (i = 0; i < N; i++) if (vs[i] > max) max = vs[i];
708 var tg = target(l, key, cur, per);
709 if (tg && tg.v > max) max = tg.v;
710 if (!max) max = 1;
711 var H = 128, top = 16, bot = H - 17, cw = W / N, bw = Math.max(4, cw - Math.max(2, cw * 0.28));
712 var o = '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '" role="img">';
713 o += '<line x1="0" y1="' + bot + '" x2="' + W + '" y2="' + bot
714 + '" stroke="var(--bd)" stroke-width="1"/>';
715 if (tg) {
716 var ty = bot - (tg.v / max) * (bot - top);
717 o += '<line x1="0" y1="' + px(ty) + '" x2="' + W + '" y2="' + px(ty)
718 + '" stroke="var(--mu)" stroke-width="1" stroke-dasharray="3 3" opacity=".7"/>';
719 }
720 for (i = 0; i < N; i++) {
721 var h = (vs[i] / max) * (bot - top), x = i * cw + (cw - bw) / 2, sel = bs[i] === cur;
722 if (h > 0.5) {
723 o += '<path d="' + barPath(x, bot - h, bw, h, 4) + '" fill="var(--ac)" opacity="'
724 + (sel ? 1 : 0.42) + '"/>';
725 }
726 o += '<text x="' + px(x + bw / 2) + '" y="' + (H - 4) + '" text-anchor="middle"'
727 + ' font-size="9" fill="var(--mu)"' + (sel ? ' font-weight="700"' : '') + '>'
728 + esc(blabel(bs[i], per, 1)) + '</text>';
729 }
730 // One value label, on the bucket being read. A number on every bar is noise.
731 var sh = statText(l, f, vs[N - 1], null);
732 o += '<text x="' + W + '" y="10" text-anchor="end" font-size="10" fill="var(--mu)">'
733 + esc('max ' + statText(l, f, max, null).v) + '</text>';
734 o += '<text x="0" y="10" font-size="10" fill="var(--tx)" font-weight="700">'
735 + esc(sh.v + (sh.u ? ' ' + sh.u : '')) + '</text>';
736 o += '</svg>';
737 return o;
738}
739function arc(cx, cy, r, ri, a0, a1) {
740 var lg = (a1 - a0) > Math.PI ? 1 : 0;
741 return 'M' + px(cx + r * Math.cos(a0)) + ' ' + px(cy + r * Math.sin(a0))
742 + 'A' + r + ' ' + r + ' 0 ' + lg + ' 1 ' + px(cx + r * Math.cos(a1)) + ' ' + px(cy + r * Math.sin(a1))
743 + 'L' + px(cx + ri * Math.cos(a1)) + ' ' + px(cy + ri * Math.sin(a1))
744 + 'A' + ri + ' ' + ri + ' 0 ' + lg + ' 0 ' + px(cx + ri * Math.cos(a0)) + ' ' + px(cy + ri * Math.sin(a0))
745 + 'Z';
746}
747/// Group by any `ref` or `enum` field — or by an attribute of the catalogue item
748/// a `ref` points at, which is what turns one mechanism into both the food pie
749/// and per-muscle-group volume.
750function groups(l, es, key, gs) {
751 var f = fdef(l, key), gf = fdef(l, gs.k), by = {}, i;
752 if (!f || !gf) return [];
753 es = solid(es);
754 for (i = 0; i < es.length; i++) {
755 var v = fnum(l, es[i], f); if (v == null || v <= 0) continue;
756 var raw = (es[i].f || {})[gs.k];
757 if (raw == null || raw === '') continue;
758 var name = gf.t === 'ref' ? cname(l, raw) : String(raw);
759 if (gs.a) {
760 var it = citem(l, raw); if (!it) continue;
761 name = it[gs.a] || '—';
762 }
763 by[name] = (by[name] || 0) + v;
764 }
765 var out = [];
766 for (var k in by) out.push({ n: k, v: by[k] });
767 out.sort(function (a, b) { return b.v - a.v; });
768 // A ninth series is never a ninth hue: the tail folds into a neutral Other.
769 if (out.length > 8) {
770 var rest = out.slice(7), s = 0;
771 for (i = 0; i < rest.length; i++) s += rest[i].v;
772 out = out.slice(0, 7); out.push({ n: 'Other', v: s, o: 1 });
773 }
774 return out;
775}
776function chartPie(l, key, gs, es) {
777 var f = fdef(l, key), gg = groups(l, es, key, gs), i, tot = 0;
778 for (i = 0; i < gg.length; i++) tot += gg[i].v;
779 if (!tot) return '<div class="empty">Nothing to split up yet.</div>';
780 var R = 62, ri = 38, S = R * 2 + 4, cx = S / 2, cy = S / 2, a = -Math.PI / 2;
781 var o = '<div class="pie"><svg width="' + S + '" height="' + S + '" viewBox="0 0 ' + S + ' ' + S + '">';
782 var col = function (j) { return gg[j].o ? 'var(--mu)' : pal(j); };
783 if (gg.length === 1) {
784 o += '<circle cx="' + cx + '" cy="' + cy + '" r="' + ((R + ri) / 2) + '" fill="none" stroke="'
785 + col(0) + '" stroke-width="' + (R - ri) + '"/>';
786 } else {
787 for (i = 0; i < gg.length; i++) {
788 var a1 = a + (gg[i].v / tot) * Math.PI * 2;
789 o += '<path d="' + arc(cx, cy, R, ri, a, a1) + '" fill="' + col(i)
790 + '" stroke="var(--sf)" stroke-width="2"/>';
791 a = a1;
792 }
793 }
794 var sh = statText(l, f, tot, 'sum');
795 o += '<text x="' + cx + '" y="' + (cy + 1) + '" text-anchor="middle" font-size="15"'
796 + ' font-weight="700" fill="var(--tx)">' + esc(sh.v) + '</text>';
797 o += '<text x="' + cx + '" y="' + (cy + 15) + '" text-anchor="middle" font-size="9.5"'
798 + ' fill="var(--mu)">' + esc(sh.u || (f.u || '')) + '</text>';
799 o += '</svg><div class="leg gap">';
800 for (i = 0; i < gg.length; i++) {
801 o += '<div class="lg"><span class="sw" style="background:' + col(i) + '"></span>'
802 + '<span class="lgn">' + esc(gg[i].n) + '</span>'
803 + '<span class="lgv">' + esc(fmt(gg[i].v)) + ' · '
804 + Math.round(gg[i].v / tot * 100) + '%</span></div>';
805 }
806 return o + '</div></div>';
807}
808
809// ── State ────────────
810// `view` is the axis: LOG is where you put things in, LIFE is where you read
811// them back. It is one tap either way and it survives a change of lane, because
812// a mode a person gets stuck in is worse than a page that mixes the two.
813// `sess` is the SCOPE: the id of the session the log is narrowed to, or nothing.
814// It is not a screen. The live workout is the Log view with a scope, a grouping and
815// rows that can be committed in place, and a second rendering path for it would be
816// two engines where the page can afford one.
817var S = { lane: '', per: 'day', off: 0, view: 'log', scr: null, sess: '', tile: -1, amt: 0,
818 pie: null, bar: null };
819var D = {}, TOAST = null, TT = 0, BOOTED = false, STARTED = false, LASTW = 0, LASTH = -1;
820
821function lane() { return LANE[S.lane] || { id: '', fields: [], tiles: [] }; }
822function curB() { var l = lane(); return bshift(bstart(dayOf(new Date(), l.dayStart), S.per), S.per, S.off); }
823function toast(msg, err) {
824 TOAST = { m: msg, e: !!err, u: null };
825 clearTimeout(TT); TT = setTimeout(function () { TOAST = null; redraw(); }, 6000);
826}
827/// A REDRAW NOBODY ASKED FOR — a toast clearing itself six seconds after it
828/// appeared, the frame changing width, a theme arriving. What is half-typed lives
829/// in the DOM until something reads it back into state, so a redraw that skips
830/// that step types over it. Found by a verifier: a lane being described lost the
831/// field being added the moment the last toast expired, and the entry form had
832/// carried the same fault since it was written.
833function redraw() {
834 try {
835 if (S.scr && S.scr.t === 'form' && S.scr.e) readInputs();
836 else if (S.scr && S.scr.t === 'lane') readLane();
837 else if (!S.scr) readSets();
838 } catch (x) { }
839 draw();
840}
841
842// ── Drawing ────────────
843function head() {
844 var i, o = '<div class="hd"><div class="row"><div class="chips">';
845 var ls = (IDX && IDX.lanes) || [];
846 for (i = 0; i < ls.length; i++) {
847 var d = LANE[ls[i]];
848 o += C(d ? d.n : ls[i], 'lane', ls[i], ls[i] === S.lane);
849 }
850 // OUTSIDE the scroller, so the way to make a fourth lane does not scroll off
851 // the end of the three that arrived.
852 o += '</div><button class="chip q plus" data-a="lanenew" data-v="" title="New lane"'
853 + ' aria-label="New lane">+</button>'
854 + '<button class="chip q" data-a="laneedit" data-v="" title="Change this lane"'
855 + ' aria-label="Change this lane">&#9998;</button>'
856 + '<div class="gap"></div><div class="seg">'
857 + C('Log', 'view', 'log', S.view === 'log') + C('Life', 'view', 'life', S.view === 'life')
858 + '</div></div>';
859 // While the log is scoped to one session the bucket is not what is being read, so
860 // the date row goes and the scope bar under it says what is.
861 if (S.sess && S.view === 'log') return o + '</div>';
862 o += '<div class="row">'
863 + B('chip q', 'off', '-1', '&#8249;')
864 + C(blabel(curB(), S.per), 'now', '', !S.off)
865 + B('chip q', 'off', '1', '&#8250;')
866 + '<div class="gap"></div><div class="chips">';
867 var ps = [['day', 'D'], ['week', 'W'], ['month', 'M']];
868 for (i = 0; i < 3; i++) o += B('chip q' + (S.per === ps[i][0] ? ' on' : ''), 'per', ps[i][0], ps[i][1]);
869 return o + '</div></div></div>';
870}
871
872/// The tile pad. This is the heart of the thing: what you press, first on the
873/// screen and therefore under the thumb.
874function pad() {
875 var l = lane(), ts = l.tiles || [], o = '<div class="pad">', i, sc = !!S.sess;
876 for (i = 0; i < ts.length; i++) {
877 var t = ts[i], w = t.when || 'open';
878 // WHERE a tile belongs. `sess` is a tile for the middle of a session, `open`
879 // (the default) one for the pad you start from, `any` one for both. Without
880 // this the way to add an exercise would sit on the pad beside the way to start
881 // a workout, which is a button that cannot work half the time.
882 if (w !== 'any' && (w === 'sess') !== sc) continue;
883 if (S.tile === i && t.m === 'scale') { o += stepper(l, t, i); continue; }
884 o += tile(l, t, i);
885 }
886 return o + '</div>';
887}
888function tile(l, t, i) {
889 // A tile can ask for the whole row. A pad of two is two thin columns and a lot of
890 // wrapped words otherwise.
891 var sub = t.s || '', cls = 'tile' + (t.wide ? ' wide' : ''), run = null;
892 if (t.m === 'timer') {
893 run = running(l, t);
894 if (run) { cls += ' run'; sub = 'started ' + hhmm(run.at) + ' — tap to stop'; }
895 else sub = sub || 'tap to start';
896 } else if (t.m === 'scale' || t.m === 'preset') {
897 sub = sub || presetLine(l, t);
898 } else if (t.m === 'expand') {
899 sub = sub || (t.pick ? 'choose several' : (t.e || []).length + ' entries');
900 if (t.pick) cls += ' acc';
901 } else if (t.m === 'form') {
902 cls += ' acc';
903 sub = sub || 'fill in';
904 }
905 return B(cls, 'tile', i, '<span class="tn">' + esc(t.n) + '</span><span class="ts">'
906 + esc(sub) + '</span>');
907}
908/// What a preset tile will actually record, in the lane's own numbers.
909function presetLine(l, t) {
910 var bits = [], i, sh = l.show || [];
911 var e = { f: t.f || {} };
912 if (t.by && (t.f || {})[t.by] != null) {
913 var bf = fdef(l, t.by);
914 bits.push(fmt(num(t.f[t.by])) + (bf && bf.u ? ' ' + bf.u : ''));
915 }
916 for (i = 0; i < sh.length && bits.length < 2; i++) {
917 var f = fdef(l, String(sh[i]).split(':')[0]);
918 if (!f || f.d == null) continue;
919 var v = fnum(l, e, f);
920 if (v != null) bits.push(fmt(v) + (f.u ? ' ' + f.u : ''));
921 }
922 return bits.join(' · ');
923}
924function stepper(l, t, i) {
925 var f = fdef(l, t.by), st = t.step || 10;
926 var e = { f: {} }, k;
927 for (k in (t.f || {})) e.f[k] = t.f[k];
928 e.f[t.by] = S.amt;
929 return '<div class="step">' + B('stepb', 'amt', -st, '&#8722;')
930 + '<span class="amt">' + fmt(S.amt, 0) + (f && f.u ? '<span class="ts"> ' + esc(f.u) + '</span>' : '') + '</span>'
931 + B('stepb', 'amt', st, '+')
932 + '<span class="gap ts">' + esc(t.n + ' · ' + presetLine(l, { f: e.f, by: t.by })) + '</span>'
933 + B('go', 'logscale', i, 'Log') + B('ghost', 'close', '', '&#10005;') + '</div>';
934}
935/// A timer that is running is an entry already on disk with no duration on it
936/// yet, so a reload finds it still going.
937function running(l, t) {
938 var a = live(l.id), i;
939 for (i = a.length - 1; i >= 0; i--) if (a[i].run && a[i].tile === t.n) return a[i];
940 return null;
941}
942
943function stats() {
944 var l = lane(), sh = l.show || [], b = curB(), es = inBucket(l.id, b, S.per), o = '', i;
945 if (!sh.length) return '';
946 o += '<div class="card"><div class="stats">';
947 for (i = 0; i < sh.length; i++) {
948 var parts = String(sh[i]).split(':'), f = fdef(l, parts[0]), how = parts[1] || null;
949 if (!f) continue;
950 var v = aggPer(l, es, f, S.per, how), t = how ? null : target(l, f.k, b, S.per), bar = '';
951 if (t) {
952 // Never colour alone: the state is written out beside the bar.
953 var pc = t.v ? Math.max(0, Math.min(1, (v || 0) / t.v)) : 0;
954 var good = t.dir === 'max' ? (v || 0) <= t.v : (v || 0) >= t.v;
955 var col = good ? 'var(--good)' : 'var(--warn)';
956 bar = '<div class="bar"><i style="width:' + Math.round(pc * 100) + '%;background:' + col
957 + '"></i></div><div class="tgt"><b style="color:' + col + '">'
958 + (good ? '&#10003;' : t.dir === 'max' ? 'over' : 'under') + '</b> '
959 + (t.dir === 'max' ? '&#8804;' : '&#8805;') + ' ' + esc(statText(l, f, t.v).v)
960 + (t.n > 1 ? ' (' + t.n + 'd)' : '') + '</div>';
961 }
962 o += ST(how === 'since' ? f.n + ' logged' : f.n, statText(l, f, v, how), bar);
963 }
964 return o + '</div></div>';
965}
966
967function entryRow(l, e) {
968 var sh = l.show || [], bits = [], i, name = '', sub = [];
969 // A session is named by the lane's TITLE field and an ordinary entry by its
970 // primary, because a session carries none of the thing the lane is a log of: a
971 // workout has no lift on it, its sets do.
972 var pf = fdef(l, (e.sess && l.title) || l.primary || '');
973 if (pf) name = fshow(l, e, pf);
974 if (!name) {
975 // No headline field on this entry, so it is named after what it carries:
976 // a weigh-in reads "Weight · Waist" rather than "Body entry".
977 var ns = [], fl = l.fields || [];
978 for (i = 0; i < fl.length && ns.length < 2; i++) {
979 if (fl[i].d || fl[i].t === 'text') continue;
980 if (fval(l, e, fl[i]) != null) ns.push(fl[i].n);
981 }
982 name = ns.join(' · ') || e.note || 'Entry';
983 }
984 for (i = 0; i < sh.length && bits.length < 3; i++) {
985 var f = fdef(l, String(sh[i]).split(':')[0]);
986 if (!f || String(sh[i]).indexOf(':') > 0) continue;
987 var v = fshow(l, e, f);
988 if (v) bits.push(v);
989 }
990 if (l.second) {
991 var s2 = fdef(l, l.second), sv = s2 ? fshow(l, e, s2) : '';
992 if (sv) sub.push(sv);
993 }
994 if (e.sess) {
995 // Done, then still to do: a session part way through says both, and a plan is
996 // never counted among what happened.
997 var ks = kids(l.id, e.id), done = solid(ks).length;
998 if (done) sub.push(done + ' logged');
999 if (ks.length > done) sub.push((ks.length - done) + ' to do');
1000 if (sessOpen(e)) sub.push('running');
1001 }
1002 if (e.p) sub.push('planned');
1003 if (e.note) sub.push(e.note);
1004 return B('er' + (e.p ? ' pend' : ''), e.sess ? 'sess' : 'edit', e.id,
1005 '<span class="et">' + hhmm(e.at) + '</span><span class="en">' + esc(name)
1006 + (sub.length ? '<small>' + esc(sub.join(' · ')) + '</small>' : '') + '</span>'
1007 + '<span class="ev">' + esc(bits.join(' ')) + '</span>'
1008 // The provenance dot: a tile, a timer or an import, never a hand-typed entry.
1009 + (e.src && e.src !== 'form' ? '<span class="src" title="' + esc(e.src) + '"></span>' : '')
1010 + '<span class="chev">&#8250;</span>');
1011}
1012/// WHAT THE LOG IS SHOWING: a whole bucket, or one session's own entries. This one
1013/// function is the scope, and every check on the scope points at it.
1014function scoped(l, sc) { return sc ? kids(l.id, sc.id) : inBucket(l.id, curB(), S.per); }
1015/// The entries, in the shape the scope asks for. Unscoped that is a flat list under
1016/// day headings; scoped it is grouped by the lane's `primary`, because the question
1017/// in the middle of a session is how one exercise is going, not what happened at
1018/// 18:04.
1019function logList(sc) {
1020 var l = lane(), b = curB(), es = scoped(l, sc), rows = [], i;
1021 if (sc) return grpList(l, es);
1022 for (i = es.length - 1; i >= 0; i--) if (!es[i].of) rows.push(es[i]);
1023 if (!rows.length) return '<div class="empty">Nothing logged for ' + esc(blabel(b, S.per).toLowerCase())
1024 + '. Press a tile above.</div>';
1025 var o = '', day = '';
1026 for (i = 0; i < rows.length; i++) {
1027 if (S.per !== 'day' && rows[i].day !== day) {
1028 day = rows[i].day;
1029 o += '<div class="dayh">' + esc(blabel(day, 'day')) + '</div>';
1030 }
1031 o += entryRow(l, rows[i]);
1032 }
1033 return o;
1034}
1035/// The grouping: one block per value of the lane's `primary`, in the order each
1036/// first appeared, headed by the lane's own summing field. The same three levels a
1037/// session was always read at — the session, each lift, each set — except that they
1038/// are now a property of the list rather than a screen of their own.
1039function grpList(l, ks) {
1040 var by = {}, order = [], i, j, o = '';
1041 if (!ks.length) {
1042 return '<div class="empty">Nothing here yet. Press a tile above to add some.</div>';
1043 }
1044 for (i = 0; i < ks.length; i++) {
1045 var g = (ks[i].f || {})[l.primary] || '?';
1046 if (!by[g]) { by[g] = []; order.push(g); }
1047 by[g].push(ks[i]);
1048 }
1049 // Which number heads a group and which tails a row are the lane's to name; a lane
1050 // that names neither gets no number rather than a row of dashes.
1051 var vf = fdef(l, l.setSum || ''), pf2 = fdef(l, l.setPeak || '');
1052 for (i = 0; i < order.length; i++) {
1053 var set = by[order[i]], gv = vf ? aggr(l, set, vf) : null;
1054 // A group with nothing done in it yet gets no number rather than a dash: the
1055 // exercise has not gone badly, it has not started.
1056 o += '<div class="grp"><div class="grph"><span class="gap">' + esc(cname(l, order[i]))
1057 + '</span><span class="ev">'
1058 + (gv == null ? '' : esc(fmt(gv) + (vf.u ? ' ' + vf.u : ''))) + '</span>'
1059 + B('chip q plus', 'addset', order[i], '+') + '</div>';
1060 for (j = 0; j < set.length; j++) o += setRow(l, set[j], j + 1, pf2);
1061 o += '</div>';
1062 }
1063 return o;
1064}
1065/// One row of the grouping. A PENDING one is editable where it sits and carries the
1066/// tick that makes it true; a committed one is the same row with the tick filled in
1067/// and its numbers read back rather than typed. One shape, two states — a second
1068/// row for the live case is how a page ends up with two of everything.
1069function setRow(l, e, n, pf2) {
1070 var fs = l.setFields || [], i, o = '', ed = !!e.p;
1071 for (i = 0; i < fs.length; i++) {
1072 var f = fdef(l, fs[i]);
1073 if (!f || f.t === 'ref') continue;
1074 if (ed && (f.t === 'num' || f.t === 'dur')) {
1075 o += (o ? '<span class="frm">&#215;</span>' : '')
1076 + '<input class="setin" data-set="' + esc(e.id + '|' + f.k) + '" inputmode="decimal"'
1077 + ' aria-label="' + esc(f.n) + '" value="'
1078 + esc((e.f || {})[f.k] == null ? '' : e.f[f.k]) + '">';
1079 } else {
1080 var v = fshow(l, e, f);
1081 if (v) o += (o ? ' × ' : '') + esc(v);
1082 }
1083 }
1084 return '<div class="setr' + (ed ? ' pend' : '') + '">'
1085 + B('tick' + (ed ? '' : ' on'), ed ? 'tick' : 'edit', e.id, '&#10003;')
1086 + '<span class="et">' + n + '.</span><span class="gap">' + o + '</span>'
1087 + (ed ? B('frm', 'del', e.id, '&#10005;')
1088 : B('ev', 'edit', e.id, pf2 ? esc(fmt(fnum(l, e, pf2)) + ' 1RM') : '&#8250;'))
1089 + '</div>';
1090}
1091
1092/// The fields a chart may read: everything numeric, plus anything counted.
1093function numFields(l) {
1094 var o = [], f = l.fields || [], i;
1095 // A yes/no counts, which is the whole chart a habit lane wants.
1096 for (i = 0; i < f.length; i++) {
1097 if (f[i].t === 'num' || f[i].t === 'dur' || f[i].t === 'bool') o.push(f[i]);
1098 }
1099 return o;
1100}
1101function groupOpts(l) {
1102 var o = [], f = l.fields || [], i, at = (CAT[l.id] || {}).attrs || {}, k;
1103 for (i = 0; i < f.length; i++) {
1104 if (f[i].t === 'enum') o.push({ k: f[i].k, n: f[i].n });
1105 if (f[i].t === 'ref') {
1106 o.push({ k: f[i].k, n: f[i].n });
1107 for (k in at) o.push({ k: f[i].k, a: k, n: at[k] });
1108 }
1109 }
1110 return o;
1111}
1112function charts() {
1113 var l = lane(), nf = numFields(l), go = groupOpts(l), i, o = '';
1114 if (!nf.length) return '<div class="empty">This lane has no numbers to chart.</div>';
1115 // The lane's own headline reading, not whichever number happens to be first.
1116 var first = fdef(l, String((l.show || [])[0] || '').split(':')[0]) || nf[0];
1117 var bar = S.bar && fdef(l, S.bar) ? S.bar : first.k;
1118 var W = Math.max(220, (LASTW || 340) - 26);
1119 o += '<div class="card"><div class="sh">Over time</div><div class="chips cf">';
1120 for (i = 0; i < nf.length; i++) o += C(nf[i].n, 'bar', nf[i].k, nf[i].k === bar);
1121 o += '</div>' + chartBar(l, bar, S.per, curB(), W) + '</div>';
1122 if (!go.length) return o;
1123 var pf = S.pie && fdef(l, S.pie.f) ? S.pie : { f: first.k, g: go[0] };
1124 o += '<div class="card"><div class="sh">Where it went</div><div class="chips cf">';
1125 for (i = 0; i < go.length; i++) {
1126 o += C(go[i].n, 'pieg', i, go[i].k === pf.g.k && (go[i].a || '') === (pf.g.a || ''));
1127 }
1128 o += '</div><div class="chips cf">';
1129 for (i = 0; i < nf.length; i++) o += C(nf[i].n, 'pief', nf[i].k, nf[i].k === pf.f);
1130 o += '</div>' + chartPie(l, pf.f, pf.g, inBucket(l.id, curB(), S.per)) + '</div>';
1131 return o;
1132}
1133
1134function toastBar() {
1135 if (!TOAST) return '';
1136 return '<div class="toast' + (TOAST.e ? ' err' : '') + '"><span class="gap">'
1137 + esc(TOAST.m) + '</span>' + (TOAST.u ? C('Undo', 'undo', '', 0) : '')
1138 + B('chip q', 'dismiss', '', '&#10005;') + '</div>';
1139}
1140function home() { return S.view === 'life' ? lifeView() : logView(); }
1141/// LOG: the tiles under the thumb and what they put down, and nothing else. A
1142/// number to admire is a reason to hesitate over a button.
1143function logView() {
1144 var l = lane(), sc = S.sess ? (LOG[l.id] || {})[S.sess] : null;
1145 if (S.sess && !sc) S.sess = '';
1146 var es = scoped(l, sc), n = 0, i;
1147 for (i = 0; i < es.length; i++) if (sc || !es[i].of) n++;
1148 return (sc ? scopeBar(l, sc) : '') + pad() + restBar(l) + toastBar()
1149 + '<div class="shrow"><span class="sh gap">Entries</span>'
1150 + (n ? '<span class="tag"><b>' + n + '</b></span>' : '')
1151 + '</div><div class="card">' + logList(sc) + '</div>';
1152}
1153/// THE SCOPE, and the way out of it. A session is the only thing that narrows the
1154/// log today, and while one is open this is the screen: its name, its running
1155/// totals, and the button that ends it. The title is a button because the metadata
1156/// is editable later — it opens the session's own entry in a form.
1157function scopeBar(l, e) {
1158 var sh = l.sessShow || l.show || [], ks = kids(l.id, e.id), bits = [], i;
1159 for (i = 0; i < sh.length; i++) {
1160 var f = fdef(l, String(sh[i]).split(':')[0]); if (!f) continue;
1161 var s2 = statText(l, f, f.t === 'dur' ? spanOf(e) : aggr(l, ks, f), null);
1162 if (s2.v !== '—') bits.push(s2.v + (s2.u ? ' ' + s2.u : ''));
1163 }
1164 return '<div class="scope">' + B('chip q', 'unscope', '', '&#8249;')
1165 + B('gap', 'edit', e.id, '<div class="h1">'
1166 + esc(fshow(l, e, fdef(l, l.title || '')) || l.n) + '</div><div class="ts">'
1167 + esc(hhmm(e.at) + (bits.length ? ' · ' + bits.join(' · ') : '')) + '</div>')
1168 + B(sessOpen(e) ? 'go' : 'ghost', 'finish', e.id, sessOpen(e) ? 'Finish' : 'Reopen')
1169 + '</div>';
1170}
1171/// LIFE: the same entries read back — the totals against their targets, the run
1172/// of days, the shape over time and where it all went.
1173function lifeView() {
1174 var l = lane(), st = streak(S.lane, l);
1175 var o = toastBar() + '<div class="shrow"><span class="sh gap">How it is going</span>'
1176 + (st > 1 ? '<span class="tag"><b>' + st + '</b> day streak</span>' : '') + '</div>';
1177 if (!solid(live(l.id)).length) {
1178 return o + '<div class="empty">Nothing logged in ' + esc(l.n || 'this lane')
1179 + ' yet. Press Log, then a tile.</div>';
1180 }
1181 return o + stats() + charts();
1182}
1183
1184// ── Screens ────────────
1185function backBar(txt) { return B('back', 'back', '', '&#8249; ' + esc(txt)); }
1186
1187// ── Prefill ────────────
1188//
1189// The second set of an exercise should open on the first set's numbers, and the
1190// first on last week's. That is not a gym idea: the amount of a food is the amount
1191// you usually have of it, and a dose is the dose you took last time. So it is a
1192// FIELD property — `prefill:'last'` — and the lane says which of its fields wants
1193// it.
1194
1195/// The entry to copy from: the most recent NON-PENDING one sharing this entry's
1196/// `primary` value. A plan is not a record, so a row that has been written down and
1197/// not yet ticked cannot be the thing the next row is filled from — otherwise one
1198/// mistyped number propagates down the whole exercise. Within an open scope the row
1199/// above wins, which is what makes the second set follow the first rather than last
1200/// week's.
1201function lastFor(l, ref, of) {
1202 var a = live(l.id), any = null, i;
1203 if (!ref) return null;
1204 for (i = a.length - 1; i >= 0; i--) {
1205 var e = a[i];
1206 if (e.p || (e.f || {})[l.primary] !== ref) continue;
1207 if (of && e.of === of) return e;
1208 if (!any) any = e;
1209 }
1210 return any;
1211}
1212/// Fill the blanks of a bag from that entry, for every field the lane marks. What is
1213/// already in the bag is never overwritten: a value the person chose beats a value
1214/// they had last time.
1215function prefill(l, bag, fs, of) {
1216 var src = lastFor(l, bag[l.primary], of), i;
1217 if (!src) return bag;
1218 for (i = 0; i < (fs || []).length; i++) {
1219 var f = fdef(l, fs[i]);
1220 if (!f || f.prefill !== 'last' || bag[f.k] != null) continue;
1221 var v = (src.f || {})[f.k];
1222 if (v != null && v !== '') bag[f.k] = v;
1223 }
1224 return bag;
1225}
1226/// The session the lane is in the middle of, if it is in one. This is what makes a
1227/// workout the main screen: the page opens on the session rather than on the pad,
1228/// through a reload and through a change of lane and back.
1229function openSess(l) {
1230 var a = l.sess ? live(l.id) : [], i;
1231 for (i = a.length - 1; i >= 0; i--) {
1232 var e = a[i];
1233 if (!e.sess || !sessOpen(e)) continue;
1234 // One left open overnight is not one still running, and scoping the page into
1235 // yesterday would be worse than showing the pad.
1236 return e.day === dayOf(new Date(), l.dayStart) ? e : null;
1237 }
1238 return null;
1239}
1240
1241// ── The rest clock ────────────
1242//
1243// A LANE property — `rest:{secs:120}` — and not a tile, because nothing is recorded
1244// by it. After any commit in the lane the clock starts again, and when it runs out
1245// it makes a noise. A lane that wants an entry out of a stretch of time still uses a
1246// `timer` tile; this is the other thing, and the difference between them is exactly
1247// that one writes and one does not.
1248//
1249// TWO CONSTRAINTS SHAPE THE IMPLEMENTATION, and both are the policy the page runs
1250// under rather than a preference.
1251//
1252// THE DING CANNOT BE A FILE. `media-src` falls back to `default-src 'none'`, so
1253// there is no `<audio>` — and `data:` is granted to images and fonts alone, so not
1254// even an inlined one. The tone is synthesised. The context is opened on the TICK,
1255// which is a real press, so that it is unlocked two minutes later when it is wanted;
1256// one opened at the moment of ringing could not play at all.
1257//
1258// THE COUNTDOWN IS COMPUTED FROM A STAMP AND NEVER COUNTED DOWN. A backgrounded tab
1259// throttles `setInterval` to about once a minute, and there are no workers here
1260// either (`worker-src` falls back to `script-src`, which lists `'unsafe-inline'` and
1261// therefore matches no URL). So the number is `until - now` at every paint, and the
1262// tone is SCHEDULED on the audio clock rather than fired by the interval — which is
1263// what carries it through the throttling. A tab the phone has suspended outright
1264// will still not ring, and the bar says so rather than promising.
1265var REST = { until: 0, secs: 0 }, RIV = 0, AC = null, ANODES = null;
1266
1267/// The audio context, opened on the press that wants it.
1268function restCtx() {
1269 var K = window.AudioContext || window.webkitAudioContext;
1270 if (!AC && K) AC = new K();
1271 if (AC && AC.state === 'suspended' && AC.resume) AC.resume();
1272 return AC;
1273}
1274/// Anything already scheduled, cancelled. Adjusting the clock must not leave the
1275/// previous ding queued behind the new one.
1276function restQuiet() {
1277 var i;
1278 for (i = 0; ANODES && i < ANODES.length; i++) { try { ANODES[i].stop(0); } catch (x) { } }
1279 ANODES = null;
1280}
1281/// Two short tones, `secs` from now on the audio clock. The envelope is there
1282/// because a bare square start is a click rather than a ding.
1283function restTone(secs) {
1284 var c = restCtx(), i, t0, o, g;
1285 restQuiet();
1286 if (!c) return;
1287 ANODES = [];
1288 for (i = 0; i < 2; i++) {
1289 t0 = c.currentTime + secs + i * 0.3;
1290 o = c.createOscillator(); g = c.createGain();
1291 o.type = 'sine'; o.frequency.value = i ? 1320 : 880;
1292 g.gain.setValueAtTime(0.0001, t0);
1293 g.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
1294 g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.24);
1295 o.connect(g); g.connect(c.destination);
1296 o.start(t0); o.stop(t0 + 0.28);
1297 ANODES.push(o);
1298 }
1299}
1300function restLeft() { return Math.max(0, Math.ceil((REST.until - Date.now()) / 1000)); }
1301function restClock() { var n = restLeft(); return p2(Math.floor(n / 60)) + ':' + p2(n % 60); }
1302/// Start it, or start it AGAIN — which is the whole behaviour. A set finished in any
1303/// exercise at all puts the clock back to the top, because the rest is between one
1304/// effort and the next and not between one exercise and the next.
1305function restBump(l) {
1306 if (!l.rest || !l.rest.secs) return;
1307 if (!REST.secs) REST.secs = l.rest.secs;
1308 REST.until = Date.now() + REST.secs * 1000;
1309 restTone(REST.secs);
1310 restRun();
1311}
1312/// The interval exists to move ONE number. A full redraw once a second would take
1313/// the caret out of whatever set was being typed into, which is the one thing a
1314/// person does while they are resting.
1315function restPaint() {
1316 var el = document.getElementById('rest'), n = restLeft();
1317 if (el) {
1318 el.textContent = restClock();
1319 if (!n) el.parentNode.className = 'rest done';
1320 }
1321 if (!n) restRun();
1322}
1323function restRun() {
1324 var want = REST.until > Date.now();
1325 if (want && !RIV) RIV = setInterval(restPaint, 1000);
1326 if (!want && RIV) { clearInterval(RIV); RIV = 0; }
1327}
1328function restBar(l) {
1329 if (!l.rest || !REST.until) return '';
1330 var n = restLeft();
1331 return '<div class="rest' + (n ? '' : ' done') + '"><span class="rt" id="rest">'
1332 + restClock() + '</span>'
1333 + B('chip q', 'rest', '-30', '&#8722;30') + B('chip q', 'rest', '30', '+30')
1334 + '<span class="gap ts">' + (n ? 'rest' : 'rest over')
1335 + ' — a tab you have left may not ring</span>'
1336 + B('chip q', 'restoff', '', '&#10005;') + '</div>';
1337}
1338
1339/// One entry, in a form. The same screen edits and creates: an edit appends a new
1340/// version of the same id, which is what makes yesterday's lunch fixable.
1341function formScreen() {
1342 var l = lane(), sc = S.scr, e = sc.e, o = backBar(sc.id ? 'Edit' : 'New entry');
1343 var fs = sc.fields || (l.tiles[sc.tile] || {}).fs || defFields(l), i;
1344 o += '<div class="card"><div class="h1">' + esc(sc.title || l.n) + '</div>';
1345 for (i = 0; i < fs.length; i++) {
1346 var f = fdef(l, fs[i]); if (!f) continue;
1347 o += '<div class="fr"><div class="fl">' + esc(f.n + (f.u ? ' (' + f.u + ')' : '')) + '</div>'
1348 + fieldInput(l, f, (e.f || {})[f.k]) + '</div>';
1349 }
1350 o += '<div class="fr"><div class="fl">Note</div>'
1351 + '<input data-in="_note" value="' + esc(e.note || '') + '" placeholder="optional"></div>';
1352 // A PENDING entry opened in the form commits from here: the same act as the tick
1353 // beside its row, so a plan can be made true from either end.
1354 o += '<div class="acts">'
1355 + B('go', 'commit', '', e.p ? 'Log it' : sc.id ? 'Save' : 'Log it')
1356 + B('ghost', 'back', '', 'Cancel') + '<div class="gap"></div>'
1357 + (sc.id ? C('Delete', 'del', sc.id, 0) : '') + '</div>';
1358 if (sc.id && e.src) {
1359 o += '<div class="tgt">recorded by <b>' + esc(e.src) + '</b> at ' + esc(e.at) + '</div>';
1360 }
1361 return o + '</div>';
1362}
1363function defFields(l) {
1364 var o = [], f = l.fields || [], i;
1365 for (i = 0; i < f.length; i++) if (!f[i].d) o.push(f[i].k);
1366 return o;
1367}
1368function fieldInput(l, f, v) {
1369 if (f.t === 'ref') {
1370 return B('ghost pickbtn', 'pick', f.k, esc(v ? cname(l, v) : 'Choose…'));
1371 }
1372 if (f.t === 'enum') {
1373 var o = '<div class="chips">', i, os = f.o || [];
1374 for (i = 0; i < os.length; i++) o += C(os[i], 'enum', f.k + '|' + os[i], os[i] === v);
1375 return o + '</div>';
1376 }
1377 if (f.t === 'bool') return C(v ? 'Yes' : 'No', 'bool', f.k, v);
1378 if (f.t === 'num' || f.t === 'dur') {
1379 // A duration is typed in minutes and kept in seconds.
1380 var d = f.t === 'dur', st = d ? 900 : (f.step || 1);
1381 return '<div class="num">' + B('stepb', 'nudge', f.k + '|-' + st, '&#8722;')
1382 + '<input data-in="' + esc(f.k) + '"' + (d ? ' data-dur="1" placeholder="minutes"' : '')
1383 + ' inputmode="decimal" value="' + esc(v == null ? '' : d ? fmt(num(v) / 60, 0) : v) + '">'
1384 + B('stepb', 'nudge', f.k + '|' + st, '+') + '</div>';
1385 }
1386 return '<input data-in="' + esc(f.k) + '" value="' + esc(v == null ? '' : v) + '">';
1387}
1388/// The catalogue. In `multi` it takes several at once and adds them together, which
1389/// is `expand` with the choices still to make rather than already made.
1390function pickScreen() {
1391 var l = lane(), sc = S.scr, items = ((CAT[l.id] || {}).items) || [], i, k, n = 0;
1392 var on = sc.on || (sc.on = {});
1393 var o = backBar('Back') + '<div class="card"><input id="q" placeholder="Search…" '
1394 + 'autocomplete="off"><div style="margin-top:6px">';
1395 for (i = 0; i < items.length; i++) {
1396 var it = items[i], grp = it.grp || '';
1397 o += '<button class="pickr' + (on[it.id] ? ' on' : '') + '" data-a="'
1398 + (sc.multi ? 'ptoggle' : 'picked') + '" data-v="' + esc(it.id) + '" data-n="'
1399 + esc((it.n + ' ' + grp).toLowerCase()) + '"><span class="gap">' + esc(it.n)
1400 + '</span><span class="ev">' + esc(grp) + '</span></button>';
1401 }
1402 o += '</div>';
1403 if (sc.multi) {
1404 for (k in on) if (on[k]) n++;
1405 o += '<div class="acts">' + B(n ? 'go' : 'ghost', 'padd', '', n ? 'Add ' + n : 'Add')
1406 + B('ghost', 'back', '', 'Cancel') + '</div>';
1407 }
1408 return o + '</div></div>';
1409}
1410
1411// ── Lanes made from the page ────────────
1412//
1413// A lane is this capp's primitive — a subject with fields — and three arriving
1414// furnished with no way to make a fourth is a capp its owner cannot finish. The
1415// daimon still writes lane STRUCTURE, and that is unchanged: what it writes are
1416// the shapes this screen has no vocabulary for — a catalogue and the `ref` field
1417// that reads it, a field computed from a formula, a session lane. This screen
1418// writes the ordinary shape and costs no model turn to do it, and an edit CARRIES
1419// EVERYTHING IT DOES NOT UNDERSTAND THROUGH UNTOUCHED rather than flattening a
1420// lane a daimon wrote.
1421var FTYPES = [['num', 'Number'], ['dur', 'Time'], ['enum', 'Choice'], ['bool', 'Yes/no'], ['text', 'Note']];
1422var FAGGS = [['sum', 'Total'], ['mean', 'Average'], ['last', 'Latest'], ['max', 'Highest']];
1423
1424/// A name as a key: what the store and the expression lexer will both accept.
1425function slug(s) {
1426 var t = String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, '_')
1427 .replace(/^_+|_+$/g, '').slice(0, 24);
1428 return /^[0-9]/.test(t) ? 'f' + t : t;
1429}
1430function uniq(base, taken) {
1431 var k = base || 'x', i = 2;
1432 while (taken[k]) { k = base + i; i++; }
1433 return k;
1434}
1435function optList(s) {
1436 var a = String(s == null ? '' : s).split(','), o = [], i;
1437 for (i = 0; i < a.length; i++) { var t = a[i].trim(); if (t) o.push(t); }
1438 return o;
1439}
1440function newRow() { return { n: '', t: 'num', u: '', agg: 'sum', o: '', tv: '', td: 'min' }; }
1441/// The rows this screen can show for a lane that already exists. A computed field
1442/// and a `ref` are not among them, and they are not lost either — `laneFrom` keeps
1443/// them exactly as they were.
1444function laneRows(l) {
1445 var o = [], f = l.fields || [], ts = l.targets || [], i, j;
1446 for (i = 0; i < f.length; i++) {
1447 var d = f[i];
1448 if (d.d || d.t === 'ref') continue;
1449 var r = { k: d.k, n: d.n, t: d.t, u: d.u || '', agg: d.agg || 'sum',
1450 o: (d.o || []).join(', '), tv: '', td: 'min' };
1451 for (j = 0; j < ts.length; j++) {
1452 if (ts[j].f === d.k && ts[j].p === 'day') {
1453 r.tv = String(d.t === 'dur' ? ts[j].v / 60 : ts[j].v);
1454 r.td = ts[j].dir === 'max' ? 'max' : 'min';
1455 }
1456 }
1457 o.push(r);
1458 }
1459 return o;
1460}
1461/// Why this lane cannot be made yet, in the words of the thing that is missing.
1462/// A lane with no fields draws nothing, and a choice with nothing to choose is a
1463/// field that can never be filled in.
1464function laneBad(sc) {
1465 if (!slug(sc.n)) return 'Give the lane a name — "Reading", "Money", "Meditation".';
1466 var n = 0, i;
1467 for (i = 0; i < sc.fs.length; i++) {
1468 var f = sc.fs[i], nm = String(f.n || '').trim();
1469 if (!nm) continue;
1470 n++;
1471 if (f.t === 'enum' && !optList(f.o).length) {
1472 return 'Give "' + nm + '" some choices to pick from, or make it a note.';
1473 }
1474 }
1475 return n ? '' : 'Add at least one field. A lane with none has nothing to draw.';
1476}
1477function fieldBox(f, i) {
1478 var j, o = '<div class="fbox"><div class="row"><input data-lin="' + i + '.n" value="'
1479 + esc(f.n || '') + '" placeholder="' + (i ? 'Another field' : 'Pages, Amount, Mood…') + '">'
1480 + B('frm', 'fdel', i, '&#10005;') + '</div><div class="chips">';
1481 for (j = 0; j < FTYPES.length; j++) {
1482 o += C(FTYPES[j][1], 'ftype', i + '|' + FTYPES[j][0], f.t === FTYPES[j][0]);
1483 }
1484 o += '</div>';
1485 if (f.t === 'enum') {
1486 o += '<div class="fr cf"><div class="fl">Choices, separated by commas</div>'
1487 + '<input data-lin="' + i + '.o" value="' + esc(f.o || '') + '" placeholder="Low, Fine, Good"></div>';
1488 }
1489 if (f.t === 'num' || f.t === 'dur') {
1490 if (f.t === 'num') {
1491 o += '<div class="fr cf"><div class="fl">Unit</div><input data-lin="' + i + '.u" value="'
1492 + esc(f.u || '') + '" placeholder="kg, km, pages, hours"></div>';
1493 }
1494 o += '<div class="fl">Over a day it reads as</div><div class="chips cf">';
1495 for (j = 0; j < FAGGS.length; j++) {
1496 o += C(FAGGS[j][1], 'fagg', i + '|' + FAGGS[j][0], (f.agg || 'sum') === FAGGS[j][0]);
1497 }
1498 o += '</div><div class="fl">Daily target, if you want one'
1499 + (f.t === 'dur' ? ' (minutes)' : '') + '</div><div class="row">'
1500 + C(f.td === 'max' ? 'at most' : 'at least', 'ftdir', i, 0)
1501 + '<input data-lin="' + i + '.tv" inputmode="decimal" value="' + esc(f.tv || '')
1502 + '" placeholder="optional"></div>';
1503 }
1504 return o + '</div>';
1505}
1506function laneScreen() {
1507 var sc = S.scr, old = sc.edit ? (LANE[sc.edit] || {}) : null, i, kept = 0;
1508 var o = backBar('Back') + toastBar() + '<div class="card"><div class="h1">'
1509 + (sc.edit ? esc('Change ' + (old.n || sc.edit)) : 'A new lane') + '</div>'
1510 + '<div class="hint">A lane is one subject you keep: what goes on every entry, '
1511 + 'and how each number adds up over a day.</div>'
1512 + '<div class="fr cf"><div class="fl">Name</div><input data-lin="n" value="'
1513 + esc(sc.n || '') + '" placeholder="Reading, Money, Meditation…"></div>';
1514 for (i = 0; i < sc.fs.length; i++) o += fieldBox(sc.fs[i], i);
1515 o += B('ghost', 'fadd', '', '+ Add a field');
1516 var why = laneBad(sc);
1517 o += '<div class="acts">' + B(why ? 'ghost' : 'go', 'lanesave', '',
1518 sc.edit ? 'Save lane' : 'Create lane') + B('ghost', 'back', '', 'Cancel')
1519 + '<div class="gap"></div>'
1520 + (sc.edit ? C(sc.armed ? 'Tap again to remove' : 'Remove', 'lanedel', '', sc.armed) : '')
1521 + '</div>';
1522 if (why) o += '<div class="hint">' + esc(why) + '</div>';
1523 for (i = 0; i < ((old && old.fields) || []).length; i++) {
1524 if (old.fields[i].d || old.fields[i].t === 'ref') kept++;
1525 }
1526 if (kept) {
1527 o += '<div class="hint">' + kept + (kept > 1 ? ' fields' : ' field')
1528 + ' here came from your daimon — a catalogue or a formula — and stay as they are.</div>';
1529 }
1530 return o + '</div>';
1531}
1532function readLane() {
1533 var sc = S.scr, ins = APP.querySelectorAll('[data-lin]'), i;
1534 if (!sc || sc.t !== 'lane') return;
1535 for (i = 0; i < ins.length; i++) {
1536 var k = ins[i].getAttribute('data-lin'), v = ins[i].value;
1537 if (k === 'n') { sc.n = v; continue; }
1538 var p = k.split('.'), f = sc.fs[+p[0]];
1539 if (f) f[p[1]] = v;
1540 }
1541}
1542/// The lane the screen describes. On an edit this PATCHES rather than replaces: a
1543/// field that is already there keeps its key, or every entry logged against it
1544/// stops reading; and anything the screen cannot show is carried through.
1545function laneFrom(sc) {
1546 var old = sc.edit ? (LANE[sc.edit] || {}) : null, l = {}, k, i, j;
1547 for (k in old) if (old.hasOwnProperty(k)) l[k] = old[k];
1548 // Against the INDEX as well as what is loaded: a lane whose file failed to read
1549 // is still a lane, and reusing its id would write over its log.
1550 var ids = {};
1551 for (k in LANE) if (LANE.hasOwnProperty(k)) ids[k] = 1;
1552 for (i = 0; i < ((IDX && IDX.lanes) || []).length; i++) ids[IDX.lanes[i]] = 1;
1553 l.id = sc.edit || uniq(slug(sc.n) || 'lane', ids);
1554 l.n = String(sc.n || '').trim();
1555 if (l.dayStart == null) l.dayStart = 0;
1556 var keep = [], taken = {}, fresh = {}, fields = [], oldF = (old && old.fields) || [];
1557 for (i = 0; i < oldF.length; i++) {
1558 if (oldF[i].d || oldF[i].t === 'ref') { keep.push(oldF[i]); taken[oldF[i].k] = 1; }
1559 }
1560 for (i = 0; i < sc.fs.length; i++) {
1561 var r = sc.fs[i], nm = String(r.n || '').trim();
1562 if (!nm) continue;
1563 if (!r.k) { r.k = uniq(slug(nm) || 'f', taken); fresh[r.k] = r.t; }
1564 taken[r.k] = 1;
1565 // This screen owns a field's name, type, unit, choices and aggregation.
1566 // EVERYTHING ELSE ON IT IS CARRIED — the step a daimon chose, the rollup
1567 // that makes sleep sum within a night and average across a week — because
1568 // a screen that cannot show a property must not be the thing that deletes
1569 // it.
1570 var d = {}, oldFd = null;
1571 for (j = 0; j < oldF.length; j++) if (oldF[j].k === r.k) oldFd = oldF[j];
1572 for (k in oldFd) if (oldFd.hasOwnProperty(k)) d[k] = oldFd[k];
1573 d.k = r.k; d.t = r.t; d.n = nm;
1574 d.agg = (r.t === 'enum' || r.t === 'text') ? 'last'
1575 : r.t === 'bool' ? 'count' : (r.agg || 'sum');
1576 if (r.t === 'num' && String(r.u || '').trim()) d.u = String(r.u).trim(); else delete d.u;
1577 if (r.t === 'enum') d.o = optList(r.o); else delete d.o;
1578 // A reading you take rather than accumulate averages over a week; summing
1579 // fourteen weigh-ins would be a number of nothing.
1580 if (!oldFd && (r.t === 'num' || r.t === 'dur') && d.agg === 'last') d.ru = 'mean';
1581 fields.push(d);
1582 }
1583 l.fields = fields.concat(keep);
1584 // Targets: the screen owns the daily one on a field it shows, and nothing else.
1585 var tg = [], ots = (old && old.targets) || [], rowKey = {};
1586 for (i = 0; i < fields.length; i++) rowKey[fields[i].k] = 1;
1587 for (i = 0; i < ots.length; i++) if (!(rowKey[ots[i].f] && ots[i].p === 'day')) tg.push(ots[i]);
1588 for (i = 0; i < sc.fs.length; i++) {
1589 var r2 = sc.fs[i], tv = num(r2.tv);
1590 if (!r2.k || tv == null || (r2.t !== 'num' && r2.t !== 'dur')) continue;
1591 tg.push({ f: r2.k, p: 'day', dir: r2.td === 'max' ? 'max' : 'min',
1592 v: r2.t === 'dur' ? tv * 60 : tv });
1593 }
1594 l.targets = tg;
1595 // What the numbers panel reads: whatever it read before that is still here,
1596 // then the fields made just now. ONLY the new ones — a field the lane
1597 // deliberately left out of its readings stays out of them.
1598 var show = [], have = {}, os = (old && old.show) || [];
1599 for (i = 0; i < os.length; i++) {
1600 var sk = String(os[i]).split(':')[0];
1601 if (taken[sk]) { show.push(os[i]); have[sk] = 1; }
1602 }
1603 for (i = 0; i < fields.length && show.length < 6; i++) {
1604 if (have[fields[i].k] || fields[i].t === 'text' || !fresh[fields[i].k]) continue;
1605 show.push(fields[i].k); have[fields[i].k] = 1;
1606 }
1607 l.show = show;
1608 // A pointer at a field that has gone draws a dash for ever, so it goes too.
1609 var ptr = ['second', 'title', 'setSum', 'setPeak'];
1610 for (i = 0; i < ptr.length; i++) if (l[ptr[i]] && !taken[l[ptr[i]]]) delete l[ptr[i]];
1611 var arrs = ['sessShow', 'setFields'];
1612 for (i = 0; i < arrs.length; i++) {
1613 if (!l[arrs[i]]) continue;
1614 var a2 = [], src = l[arrs[i]];
1615 for (j = 0; j < src.length; j++) if (taken[String(src[j]).split(':')[0]]) a2.push(src[j]);
1616 l[arrs[i]] = a2;
1617 }
1618 if (!l.primary || !taken[l.primary]) {
1619 l.primary = '';
1620 for (i = 0; i < fields.length; i++) {
1621 if (fields[i].t === 'enum' || fields[i].t === 'text') { l.primary = fields[i].k; break; }
1622 }
1623 }
1624 // THE TILES, which is what makes the difference between a lane and an empty
1625 // pad. The lane's own tiles stay; a new yes/no gets a tap and a new duration
1626 // gets a timer; and one form tile — this screen's own, marked `gen` — always
1627 // covers every field, so a field added today can be filled in today.
1628 var tiles = [], ot = (old && old.tiles) || [], genI = -1;
1629 for (i = 0; i < ot.length; i++) {
1630 if (tileOk(ot[i], taken)) { if (ot[i].gen && ot[i].m === 'form') genI = tiles.length; tiles.push(ot[i]); }
1631 }
1632 for (k in fresh) {
1633 if (!fresh.hasOwnProperty(k)) continue;
1634 var bag = {}, fn = '';
1635 for (i = 0; i < fields.length; i++) if (fields[i].k === k) fn = fields[i].n;
1636 if (fresh[k] === 'bool') { bag[k] = true; tiles.push({ m: 'tap', n: fn, f: bag, gen: 1 }); }
1637 if (fresh[k] === 'dur') tiles.push({ m: 'timer', n: fn, by: k, f: {}, gen: 1 });
1638 }
1639 var fs = [];
1640 for (i = 0; i < fields.length; i++) fs.push(fields[i].k);
1641 if (genI >= 0) tiles[genI] = { m: 'form', n: tiles[genI].n, fs: fs, gen: 1 };
1642 else tiles.push({ m: 'form', n: 'New entry', fs: fs, gen: 1 });
1643 l.tiles = tiles;
1644 return l;
1645}
1646/// A tile whose field has gone would draw nothing and log nothing.
1647function tileOk(t, taken) {
1648 var bags = [t.f].concat(t.e || []), i, k;
1649 if (t.by && !taken[t.by]) return false;
1650 for (i = 0; i < (t.fs || []).length; i++) if (!taken[t.fs[i]]) return false;
1651 for (i = 0; i < bags.length; i++) {
1652 for (k in bags[i]) if (bags[i].hasOwnProperty(k) && !taken[k]) return false;
1653 }
1654 return true;
1655}
1656function laneCommit() {
1657 readLane();
1658 var sc = S.scr, why = laneBad(sc), i;
1659 // Never a broken lane, and never in silence: the reason is the thing missing.
1660 if (why) { toast(why, 1); draw(); return; }
1661 var l = laneFrom(sc), isNew = !sc.edit, prev = IDX.lanes.slice();
1662 LANE[l.id] = l;
1663 var ps = [save('lanes/' + l.id + '.json', laneJson(l), 'replace')];
1664 if (IDX.lanes.indexOf(l.id) < 0) {
1665 IDX.lanes.push(l.id);
1666 ps.push(save('index.json', JSON.stringify(IDX), 'replace'));
1667 }
1668 S.scr = null; S.lane = l.id; S.tile = -1; S.view = 'log'; S.off = 0; S.bar = null; S.pie = null;
1669 Promise.all(ps).then(function (rs) {
1670 for (var j = 0; j < rs.length; j++) {
1671 if (rs[j] && rs[j].error) {
1672 // Nothing half-made: a lane that did not reach the disk leaves the
1673 // index it never got into.
1674 IDX.lanes = prev;
1675 if (isNew) { delete LANE[l.id]; S.lane = IDX.lanes[0]; }
1676 toast('Lane not saved: ' + rs[j].error, 1); draw(); return;
1677 }
1678 }
1679 toast(isNew ? l.n + ' — press a tile to log something' : 'Lane saved');
1680 loadRange().then(draw);
1681 });
1682 draw();
1683}
1684/// Taking a lane off the page is not deleting a log: the entries stay in the
1685/// Diamond, and the lane comes back if it is made again under the same name.
1686function laneRemove() {
1687 var sc = S.scr, id = sc.edit, ls = [], i;
1688 if (!sc.armed) { sc.armed = 1; draw(); return; }
1689 if ((IDX.lanes || []).length < 2) {
1690 toast('This is the only lane there is. Make another one first.', 1); draw(); return;
1691 }
1692 for (i = 0; i < IDX.lanes.length; i++) if (IDX.lanes[i] !== id) ls.push(IDX.lanes[i]);
1693 var prev = IDX.lanes;
1694 IDX.lanes = ls;
1695 S.scr = null; S.lane = ls[0]; S.tile = -1; S.off = 0; S.bar = null; S.pie = null;
1696 save('index.json', JSON.stringify(IDX), 'replace').then(function (r) {
1697 if (r && r.error) { IDX.lanes = prev; toast('Not removed: ' + r.error, 1); draw(); return; }
1698 toast('Removed. Its entries are still in this Diamond.');
1699 loadRange().then(draw);
1700 });
1701 draw();
1702}
1703
1704/// The Diamond's own memory, drawn whole. A capp that skipped a key it did not
1705/// recognise would trip the coverage check, and rightly.
1706function memory() {
1707 var o = '', ks = [], k;
1708 for (k in D) if (D.hasOwnProperty(k) && k.charAt(0) !== '_') ks.push(k);
1709 if (!ks.length) return '';
1710 o += '<details class="mem"><summary>Diamond memory</summary>';
1711 for (var i = 0; i < ks.length; i++) {
1712 var v = D[ks[i]];
1713 o += '<div class="mk">' + esc(ks[i]) + '</div><div class="mv">'
1714 + esc(typeof v === 'string' ? v : JSON.stringify(v, null, 1)) + '</div>';
1715 }
1716 return o + '</details>';
1717}
1718
1719function draw() {
1720 if (!BOOTED) { APP.innerHTML = '<div class="empty">Opening the log…</div>'; return; }
1721 var body;
1722 if (S.scr && S.scr.t === 'form') body = formScreen();
1723 else if (S.scr && S.scr.t === 'pick') body = pickScreen();
1724 else if (S.scr && S.scr.t === 'lane') body = laneScreen();
1725 else body = home();
1726 APP.innerHTML = head() + body + memory();
1727 measure();
1728}
1729function measure() {
1730 var h = Math.ceil(Math.max(document.body.scrollHeight,
1731 APP.getBoundingClientRect().height + 10)) + 4;
1732 if (Math.abs(h - LASTH) < 3) return;
1733 LASTH = h; post({ cmd: 'height', px: h });
1734}
1735
1736// ── Doing things ────────────
1737function mkEntry(l, f, src, of, day) {
1738 var now = new Date();
1739 return {
1740 id: nid(), at: isoAt(now), day: day || dayOf(now, l.dayStart),
1741 of: of || undefined, src: src, f: f || {}
1742 };
1743}
1744function logTile(l, t, i) {
1745 if (t.m === 'scale') { S.tile = i; S.amt = num((t.f || {})[t.by]) || 0; draw(); return; }
1746 if (t.m === 'form') {
1747 S.scr = { t: 'form', e: { f: cp(t.f) }, fields: t.fs || null, title: t.n, tile: i };
1748 draw(); return;
1749 }
1750 if (t.m === 'expand') {
1751 // `pick` is the same tile with the choices still to make: several entries in one
1752 // act, either from a list the tile carries or from a list the person chooses.
1753 if (t.pick) {
1754 var se = (LOG[l.id] || {})[S.sess];
1755 S.scr = { t: 'pick', multi: 1, on: {}, fs: t.fs || l.setFields, p: t.p,
1756 of: S.sess || undefined, day: se ? se.day : null, back: null };
1757 draw(); return;
1758 }
1759 var es = [], j;
1760 for (j = 0; j < (t.e || []).length; j++) {
1761 var pe = mkEntry(l, cp(t.e[j]), 'expand');
1762 if (t.p) pe.p = 1;
1763 es.push(pe);
1764 }
1765 commitNew(l, es, t.n + ' — ' + es.length + ' entries');
1766 return;
1767 }
1768 if (t.m === 'timer') {
1769 var r = running(l, t);
1770 if (r) {
1771 var e2 = cp2(r);
1772 e2.f[t.by] = Math.round((Date.now() - tms(r.at)) / 1000);
1773 delete e2.run;
1774 put(l.id, [e2]).then(function () { toast(t.n + ' — ' + dur(e2.f[t.by])); draw(); });
1775 } else {
1776 var e3 = mkEntry(l, cp(t.f), 'timer');
1777 e3.run = 1; e3.tile = t.n;
1778 put(l.id, [e3]).then(function () { toast(t.n + ' started'); draw(); });
1779 }
1780 return;
1781 }
1782 // preset and tap: one entry, straight down.
1783 var e = mkEntry(l, cp(t.f), t.m === 'tap' ? 'tap' : 'preset');
1784 if (t.sess) {
1785 // It opens with a start and no end, and the log scopes to it: from here on this
1786 // IS the screen, until it is finished or stepped out of.
1787 e.sess = 1;
1788 put(l.id, [e]).then(function () { S.sess = e.id; S.scr = null; draw(); });
1789 return;
1790 }
1791 commitNew(l, [e], t.n + ' logged');
1792}
1793function commitNew(l, es, msg) {
1794 // ANY commit in a lane that rests starts the clock — a tile that logs a set is the
1795 // same act as a tick, and only a PLAN is not. The context is opened here, still
1796 // inside the press, because one opened when the clock runs out cannot play.
1797 var real = false, i;
1798 for (i = 0; i < es.length; i++) if (!es[i].p) real = true;
1799 if (real && l.rest) restCtx();
1800 put(l.id, es).then(function (ok) {
1801 if (ok) { TOAST = { m: msg, e: 0, u: es }; clearTimeout(TT);
1802 TT = setTimeout(function () { TOAST = null; redraw(); }, 6000); }
1803 if (real) restBump(l);
1804 draw();
1805 });
1806 draw();
1807}
1808function cp(o) { var r = {}, k; for (k in o) if (o.hasOwnProperty(k)) r[k] = o[k]; return r; }
1809function cp2(e) {
1810 var r = cp(e); r.f = cp(e.f || {}); return r;
1811}
1812function undo(es) {
1813 var l = lane(), out = [], i;
1814 for (i = 0; i < es.length; i++) out.push({ id: es[i].id, at: es[i].at, day: es[i].day, del: 1 });
1815 put(l.id, out).then(function () { TOAST = null; draw(); });
1816}
1817/// What has been typed into the rows of the live list but not yet committed. The
1818/// pending entry is already on disk, so this only moves the typing into the copy in
1819/// memory; the tick is what writes it. A redraw that skipped this step would type
1820/// over the row being filled in, which is the fault the entry form and the lane
1821/// builder each carried once.
1822function readSets() {
1823 var l = lane(), ins = APP.querySelectorAll('[data-set]'), m = LOG[l.id] || {}, i;
1824 for (i = 0; i < ins.length; i++) {
1825 var p = ins[i].getAttribute('data-set').split('|'), e = m[p[0]];
1826 if (!e) continue;
1827 e.f = e.f || {};
1828 if (ins[i].value === '') delete e.f[p[1]]; else e.f[p[1]] = ins[i].value;
1829 }
1830}
1831function readInputs() {
1832 var l = lane(), sc = S.scr, ins = APP.querySelectorAll('[data-in]'), i;
1833 for (i = 0; i < ins.length; i++) {
1834 var k = ins[i].getAttribute('data-in'), v = ins[i].value;
1835 if (k === '_note') { sc.e.note = v.trim() || undefined; continue; }
1836 if (v === '') { delete sc.e.f[k]; continue; }
1837 sc.e.f[k] = ins[i].getAttribute('data-dur') ? Math.round(parseFloat(v) * 60) : v;
1838 }
1839}
1840
1841// ── Events ────────────
1842document.addEventListener('click', function (ev) {
1843 var t = ev.target;
1844 while (t && t !== document.body && !t.getAttribute('data-a')) t = t.parentNode;
1845 if (!t || !t.getAttribute) return;
1846 var a = t.getAttribute('data-a'), v = t.getAttribute('data-v') || '';
1847 if (!a) return;
1848 var l = lane(), sc = S.scr;
1849 // Anything at all that redraws the home view would otherwise wipe a half-typed
1850 // set, so the rows are read back BEFORE the action rather than inside the two or
1851 // three actions that happen to remember.
1852 if (!sc) { try { readSets(); } catch (x) { } }
1853 if (a === 'lane') {
1854 S.lane = v; S.tile = -1; S.scr = null; S.sess = ''; S.off = 0; S.bar = null; S.pie = null;
1855 boot(); return;
1856 }
1857 if (a === 'per') { S.per = v; S.off = 0; S.tile = -1; loadRange().then(draw); }
1858 else if (a === 'off') { S.off = Math.min(0, S.off + (+v)); S.tile = -1; loadRange().then(draw); }
1859 else if (a === 'now') { S.off = 0; loadRange().then(draw); }
1860 else if (a === 'view') { S.view = v; S.tile = -1; S.scr = null; loadRange().then(draw); }
1861 else if (a === 'lanenew') { S.scr = { t: 'lane', edit: '', n: '', fs: [newRow()] }; draw(); }
1862 else if (a === 'laneedit') {
1863 var el = LANE[S.lane];
1864 if (el) { S.scr = { t: 'lane', edit: el.id, n: el.n || '', fs: laneRows(el) }; draw(); }
1865 }
1866 else if (a === 'fadd') { readLane(); sc.fs.push(newRow()); draw(); }
1867 else if (a === 'fdel') { readLane(); sc.fs.splice(+v, 1); draw(); }
1868 else if (a === 'ftype') { readLane(); var q4 = v.split('|'); sc.fs[+q4[0]].t = q4[1]; draw(); }
1869 else if (a === 'fagg') { readLane(); var q5 = v.split('|'); sc.fs[+q5[0]].agg = q5[1]; draw(); }
1870 else if (a === 'ftdir') {
1871 readLane();
1872 var r4 = sc.fs[+v]; r4.td = r4.td === 'max' ? 'min' : 'max'; draw();
1873 }
1874 else if (a === 'lanesave') { laneCommit(); }
1875 else if (a === 'lanedel') { laneRemove(); }
1876 else if (a === 'tile') { S.tile = -1; logTile(l, (l.tiles || [])[+v], +v); }
1877 else if (a === 'amt') { S.amt = Math.max(0, S.amt + (+v)); draw(); }
1878 else if (a === 'close') { S.tile = -1; draw(); }
1879 else if (a === 'logscale') {
1880 var tl = (l.tiles || [])[+v], f = cp(tl.f); f[tl.by] = S.amt;
1881 S.tile = -1;
1882 commitNew(l, [mkEntry(l, f, 'scale')], tl.n + ' · ' + fmt(S.amt, 0));
1883 }
1884 else if (a === 'undo') { if (TOAST && TOAST.u) undo(TOAST.u); }
1885 else if (a === 'dismiss') { TOAST = null; draw(); }
1886 else if (a === 'bar') { S.bar = v; draw(); }
1887 else if (a === 'pief') { S.pie = { f: v, g: (S.pie || {}).g || groupOpts(l)[0] }; draw(); }
1888 else if (a === 'pieg') { S.pie = { f: (S.pie || {}).f || numFields(l)[0].k, g: groupOpts(l)[+v] }; draw(); }
1889 else if (a === 'sess') { S.sess = v; S.scr = null; draw(); }
1890 else if (a === 'unscope') { S.sess = ''; draw(); }
1891 else if (a === 'rest') {
1892 // The adjustment moves BOTH: the length this lane rests for from now on, and
1893 // what is left of the rest already running.
1894 REST.secs = Math.max(15, (REST.secs || 120) + (+v));
1895 REST.until = Math.max(Date.now(), REST.until + (+v) * 1000);
1896 restTone(Math.max(0, (REST.until - Date.now()) / 1000));
1897 restRun();
1898 if (l.rest) { l.rest.secs = REST.secs; save('lanes/' + l.id + '.json', laneJson(l), 'replace'); }
1899 draw();
1900 }
1901 else if (a === 'restoff') { REST.until = 0; restQuiet(); restRun(); draw(); }
1902 else if (a === 'edit') {
1903 var e = (LOG[l.id] || {})[v];
1904 if (e) { S.scr = { t: 'form', e: cp2(e), id: v, back: sc, title: 'Entry' }; draw(); }
1905 }
1906 else if (a === 'back') { S.scr = (sc && sc.back) || null; draw(); }
1907 else if (a === 'enum') {
1908 var p = v.split('|');
1909 readInputs(); sc.e.f[p[0]] = sc.e.f[p[0]] === p[1] ? '' : p[1]; draw();
1910 }
1911 else if (a === 'bool') { readInputs(); sc.e.f[v] = !sc.e.f[v]; draw(); }
1912 else if (a === 'nudge') {
1913 var q = v.split('|'), inp = APP.querySelector('[data-in="' + q[0] + '"]');
1914 var step = +q[1], cur = parseFloat(inp.value || '0') || 0;
1915 if (inp.getAttribute('data-dur')) inp.value = Math.max(0, cur + step / 60);
1916 else inp.value = Math.max(0, Math.round((cur + step) * 100) / 100);
1917 }
1918 else if (a === 'pick') { readInputs(); S.scr = { t: 'pick', field: v, back: sc }; draw(); }
1919 else if (a === 'picked') {
1920 var b = sc.back;
1921 b.e.f[sc.field] = v;
1922 // Choosing what the entry is about is what makes the rest of it predictable, so
1923 // the blanks fill from the last time you had this thing.
1924 if (sc.field === l.primary) prefill(l, b.e.f, b.fields || defFields(l), b.of);
1925 S.scr = b; draw();
1926 }
1927 // A choice taken WITHOUT a redraw: the search box holds a filter and a caret, and
1928 // both are gone the moment the screen is rebuilt under them.
1929 else if (a === 'ptoggle') {
1930 var on = sc.on || (sc.on = {}), nb = APP.querySelector('[data-a="padd"]'), n5 = 0, k5;
1931 on[v] = !on[v];
1932 t.className = 'pickr' + (on[v] ? ' on' : '');
1933 for (k5 in on) if (on[k5]) n5++;
1934 if (nb) { nb.className = n5 ? 'go' : 'ghost'; nb.textContent = n5 ? 'Add ' + n5 : 'Add'; }
1935 }
1936 // One entry per choice, prefilled, under whatever the picker was opened from.
1937 else if (a === 'padd') {
1938 var es3 = [], k6, on6 = sc.on || {};
1939 for (k6 in on6) {
1940 if (!on6[k6]) continue;
1941 var bag = {};
1942 bag[l.primary] = k6;
1943 prefill(l, bag, sc.fs, sc.of);
1944 var ne3 = mkEntry(l, bag, 'pick', sc.of, sc.day);
1945 if (sc.p) ne3.p = 1;
1946 es3.push(ne3);
1947 }
1948 S.scr = sc.back || null;
1949 if (es3.length) commitNew(l, es3, es3.length + ' added');
1950 else draw();
1951 }
1952 // One more of the same thing, filled from the row above it.
1953 else if (a === 'addset') {
1954 var se4 = (LOG[l.id] || {})[S.sess], bag4 = {};
1955 bag4[l.primary] = v;
1956 prefill(l, bag4, l.setFields, S.sess);
1957 var ne4 = mkEntry(l, bag4, 'pick', S.sess || undefined, se4 ? se4.day : null);
1958 ne4.p = 1;
1959 commitNew(l, [ne4], cname(l, v) + ' — one more');
1960 }
1961 // THE TICK. It commits the row, and it is also where the audio context is opened,
1962 // because this press is the user gesture that unlocks it for the ding two minutes
1963 // from now.
1964 else if (a === 'tick') {
1965 var pe5 = (LOG[l.id] || {})[v];
1966 if (pe5) {
1967 restCtx();
1968 var c5 = cp2(pe5);
1969 delete c5.p;
1970 c5.at = isoAt(new Date());
1971 c5.src = 'tick';
1972 put(l.id, [c5]).then(function () { restBump(l); draw(); });
1973 draw();
1974 }
1975 }
1976 else if (a === 'finish') {
1977 var se5 = (LOG[l.id] || {})[v];
1978 if (se5) {
1979 var n2 = cp2(se5);
1980 if (n2.end) delete n2.end; else n2.end = isoAt(new Date());
1981 put(l.id, [n2]).then(function () { if (n2.end) S.sess = ''; draw(); });
1982 }
1983 }
1984 else if (a === 'del') {
1985 var de = (LOG[l.id] || {})[v];
1986 if (de) {
1987 put(l.id, [{ id: v, at: de.at, day: de.day, del: 1 }]).then(function () {
1988 S.scr = (sc && sc.back) || null; toast('Deleted'); draw();
1989 });
1990 }
1991 }
1992 else if (a === 'commit') {
1993 readInputs();
1994 var ne = sc.e;
1995 if (!ne.id) {
1996 var made = mkEntry(l, ne.f, 'form', sc.of, sc.day);
1997 made.note = ne.note;
1998 ne = made;
1999 } else { ne.w = Date.now(); }
2000 // Saving a plan is what makes it true, and it rests the same as a tick does. So
2001 // does a set typed straight in. Correcting an old entry does not: nothing has
2002 // just been done.
2003 var was = !!ne.p, fresh = was || !sc.id;
2004 if (was) { delete ne.p; ne.at = isoAt(new Date()); }
2005 if (fresh && l.rest) restCtx();
2006 S.scr = (sc && sc.back) || null;
2007 put(l.id, [ne]).then(function () { if (fresh) restBump(l); toast('Saved'); draw(); });
2008 draw();
2009 }
2010}, false);
2011
2012// Filtering the catalogue must not re-render, or the search box loses the caret.
2013document.addEventListener('input', function (ev) {
2014 if (!ev.target || ev.target.id !== 'q') return;
2015 var q = ev.target.value.toLowerCase(), rows = APP.querySelectorAll('.pickr'), i;
2016 for (i = 0; i < rows.length; i++) {
2017 rows[i].style.display = rows[i].getAttribute('data-n').indexOf(q) >= 0 ? '' : 'none';
2018 }
2019});
2020
2021// ── Boot ────────────
2022function loadRange() {
2023 var l = lane(), b = curB(), N = S.per === 'day' ? 14 : S.per === 'week' ? 10 : 12;
2024 var from = bshift(b, S.per, -(N - 1)), to = bend(b, S.per);
2025 var p = shards(l.id, from, to);
2026 return p ? p : Promise.resolve();
2027}
2028/// A workout survives a reload and a walk through the other lanes: if the lane is in
2029/// the middle of a session, the log opens scoped to it rather than on the pad.
2030function boot() {
2031 return loadRange().then(function () {
2032 BOOTED = true;
2033 var o = openSess(lane());
2034 if (o) S.sess = o.id;
2035 draw();
2036 });
2037}
2038/// Everything the page needs, in the order it needs it. `rendered` has already
2039/// gone out by the time any of this runs — a capp that waits on a read before
2040/// reporting is a capp that falls back on a slower device.
2041function start() {
2042 return jread('index.json').then(function (ix) {
2043 if (ix && ix.lanes && ix.lanes.length) { IDX = ix; return null; }
2044 return seed();
2045 }).then(function () {
2046 var ls = IDX.lanes, jobs = [], i;
2047 for (i = 0; i < ls.length; i++) {
2048 jobs.push(jread('lanes/' + ls[i] + '.json').then((function (id) {
2049 return function (d) { if (d) LANE[id] = d; };
2050 })(ls[i])));
2051 jobs.push(jread('cat/' + ls[i] + '.json').then((function (id) {
2052 return function (d) { if (d) CAT[id] = d; };
2053 })(ls[i])));
2054 }
2055 return Promise.all(jobs);
2056 }).then(function () {
2057 S.lane = IDX.lanes[0];
2058 return boot();
2059 });
2060}
2061
2062window.addEventListener('message', function (ev) {
2063 var m = ev.data;
2064 if (!m || m.dc !== 1 || m.v !== PR) return;
2065 if (m.id && pend[m.id]) { pend[m.id](m); delete pend[m.id]; return; }
2066 if (m.cmd !== 'data') return;
2067 D = m.data || {};
2068 theme(D._theme);
2069 // Every top-level key with content, named, on every data message. A second
2070 // `rendered` naming fewer keys tears the frame down just as a first would.
2071 var ks = [], k;
2072 for (k in D) if (D.hasOwnProperty(k) && k.charAt(0) !== '_') ks.push(k);
2073 post({ cmd: 'rendered', keys: ks });
2074 // ONCE. `data` arrives again on every theme, ink and skin change, and a
2075 // second `start()` while the first is still reading would seed the store a
2076 // second time.
2077 if (!STARTED) {
2078 STARTED = true;
2079 start().catch(function (e) {
2080 APP.innerHTML = '<div class="empty">' + esc('Could not open the log: ' + e) + '</div>';
2081 });
2082 return;
2083 }
2084 // A theme change mid-sentence must not take the half-typed entry — or the
2085 // half-built lane — with it.
2086 redraw();
2087});
2088if (window.ResizeObserver) {
2089 new ResizeObserver(function () {
2090 var w = APP.clientWidth;
2091 if (Math.abs(w - LASTW) > 6) { LASTW = w; if (BOOTED) redraw(); }
2092 measure();
2093 }).observe(document.body);
2094}
2095LASTW = APP.clientWidth;
2096post({ cmd: 'ready' });
2097
2098// ── The furniture it arrives with ────────────
2099// Two working lanes and a third for readings, with real numbers in them, which
2100// is the difference between an empty app and one somebody keeps. The food
2101// catalogue is per 100 g.
2102var FOODS = ('oats|Rolled oats|Grain|379|13.2|67.7|6.5|10.1;milk|Whole milk|Dairy|61|3.2|4.8|3.3|0;'
2103 + 'yog|Greek yoghurt|Dairy|59|10|3.6|0.4|0;ched|Cheddar|Dairy|403|25|1.3|33|0;'
2104 + 'egg|Eggs|Protein|143|12.6|0.7|9.5|0;chick|Chicken breast|Protein|165|31|0|3.6|0;'
2105 + 'salm|Salmon|Protein|208|20.4|0|13.4|0;lent|Lentils, cooked|Protein|116|9|20|0.4|7.9;'
2106 + 'rice|White rice, cooked|Grain|130|2.7|28.2|0.3|0.4;pasta|Pasta, cooked|Grain|131|5|25|1.1|1.8;'
2107 + 'bread|Wholemeal bread|Grain|247|13|41|3.4|7;pot|Potato, boiled|Veg|77|2|17.5|0.1|2.2;'
2108 + 'broc|Broccoli|Veg|34|2.8|6.6|0.4|2.6;spin|Spinach|Veg|23|2.9|3.6|0.4|2.2;'
2109 + 'ban|Banana|Fruit|89|1.1|22.8|0.3|2.6;app|Apple|Fruit|52|0.3|13.8|0.2|2.4;'
2110 + 'alm|Almonds|Fat|579|21.2|21.6|49.9|12.5;pnut|Peanut butter|Fat|588|25|20|50|6;'
2111 + 'oil|Olive oil|Fat|884|0|0|100|0;choc|Dark chocolate|Snack|546|4.9|61|31|7;'
2112 + 'coff|Black coffee|Drink|1|0.1|0|0|0;beer|Beer, 4.5%|Drink|43|0.5|3.6|0|0').split(';');
2113var LIFTS = ('sq|Back squat|Legs;dl|Deadlift|Back;bp|Bench press|Chest;ohp|Overhead press|Shoulders;'
2114 + 'row|Barbell row|Back;pull|Pull-up|Back;rdl|Romanian deadlift|Legs;lp|Leg press|Legs;'
2115 + 'lat|Lat pulldown|Back;dip|Dip|Chest;curl|Dumbbell curl|Arms;push|Triceps pushdown|Arms').split(';');
2116
2117function seed() {
2118 var i, a, foods = [], lifts = [];
2119 for (i = 0; i < FOODS.length; i++) {
2120 a = FOODS[i].split('|');
2121 foods.push({ id: a[0], n: a[1], grp: a[2],
2122 v: { kcal: +a[3], p: +a[4], c: +a[5], ft: +a[6], fib: +a[7] } });
2123 }
2124 for (i = 0; i < LIFTS.length; i++) {
2125 a = LIFTS[i].split('|');
2126 lifts.push({ id: a[0], n: a[1], grp: a[2] });
2127 }
2128 // A derived field that scales a catalogue number by how much was eaten.
2129 function sc(k, n, u) {
2130 return { k: k, t: 'num', n: n, u: u, agg: 'sum', d: { c: 'food', k: k, per: 100, by: 'g' } };
2131 }
2132 var diet = {
2133 id: 'diet', n: 'Diet', dayStart: 0, primary: 'food', second: 'meal',
2134 fields: [
2135 { k: 'food', t: 'ref', n: 'Food', agg: 'count' },
2136 { k: 'meal', t: 'enum', n: 'Meal', o: ['Breakfast', 'Lunch', 'Dinner', 'Snack'], agg: 'count' },
2137 // The amount you usually have of a thing is the amount you had last time.
2138 { k: 'g', t: 'num', n: 'Amount', u: 'g', agg: 'sum', step: 10, prefill: 'last' },
2139 sc('kcal', 'Energy', 'kcal'), sc('p', 'Protein', 'g'), sc('c', 'Carbs', 'g'),
2140 sc('ft', 'Fat', 'g'), sc('fib', 'Fibre', 'g')
2141 ],
2142 show: ['kcal', 'p', 'c', 'ft', 'fib'],
2143 targets: [{ f: 'kcal', p: 'day', dir: 'max', v: 2400 }, { f: 'p', p: 'day', dir: 'min', v: 120 },
2144 { f: 'fib', p: 'day', dir: 'min', v: 30 }],
2145 tiles: [
2146 // A scale tile carries the usual amount, so the tile already reads
2147 // "80 g · 303 kcal" and one tap opens it at that rather than at zero.
2148 { m: 'scale', n: 'Oats', f: { food: 'oats', g: 80, meal: 'Breakfast' }, by: 'g', step: 10 },
2149 { m: 'scale', n: 'Milk', f: { food: 'milk', g: 250, meal: 'Breakfast' }, by: 'g', step: 50 },
2150 { m: 'scale', n: 'Banana', f: { food: 'ban', g: 120, meal: 'Snack' }, by: 'g', step: 20 },
2151 { m: 'scale', n: 'Chicken', f: { food: 'chick', g: 200, meal: 'Dinner' }, by: 'g', step: 25 },
2152 { m: 'scale', n: 'Rice', f: { food: 'rice', g: 250, meal: 'Dinner' }, by: 'g', step: 50 },
2153 { m: 'scale', n: 'Eggs', f: { food: 'egg', g: 100, meal: 'Breakfast' }, by: 'g', step: 50 },
2154 { m: 'scale', n: 'Yoghurt', f: { food: 'yog', g: 170, meal: 'Snack' }, by: 'g', step: 25 },
2155 { m: 'preset', n: 'Coffee', f: { food: 'coff', g: 250, meal: 'Snack' }, by: 'g' },
2156 { m: 'expand', n: 'Porridge breakfast', e: [
2157 { food: 'oats', g: 80, meal: 'Breakfast' }, { food: 'milk', g: 250, meal: 'Breakfast' },
2158 { food: 'ban', g: 120, meal: 'Breakfast' }] },
2159 { m: 'expand', n: 'Chicken & rice', e: [
2160 { food: 'chick', g: 200, meal: 'Dinner' }, { food: 'rice', g: 250, meal: 'Dinner' },
2161 { food: 'broc', g: 150, meal: 'Dinner' }] },
2162 { m: 'form', n: 'Something else', fs: ['food', 'g', 'meal'] }
2163 ]
2164 };
2165 // The gym lane is the ontology's hardest case and none of it is gym-only: a
2166 // session with two stamps, a picker that takes several at once, rows that are
2167 // written down before they are true, fields that fill from the last time, and a
2168 // rest between one effort and the next.
2169 var gym = {
2170 id: 'gym', n: 'Gym', dayStart: 0, sess: true, primary: 'lift', title: 'name',
2171 setFields: ['lift', 'kg', 'reps'], setSum: 'vol', setPeak: 'e1rm',
2172 rest: { secs: 120 },
2173 fields: [
2174 { k: 'name', t: 'text', n: 'Session', agg: 'last' },
2175 { k: 'lift', t: 'ref', n: 'Lift', agg: 'count' },
2176 { k: 'kg', t: 'num', n: 'Weight', u: 'kg', agg: 'max', ru: 'max', step: 2.5, prefill: 'last' },
2177 { k: 'reps', t: 'num', n: 'Reps', agg: 'sum', step: 1, prefill: 'last' },
2178 { k: 'vol', t: 'num', n: 'Volume', u: 'kg', agg: 'sum', d: { e: 'kg*reps' } },
2179 { k: 'e1rm', t: 'num', n: 'Est. 1RM', u: 'kg', agg: 'max', ru: 'max', d: { e: 'kg*(1+reps/30)' } },
2180 { k: 'dur', t: 'dur', n: 'Time', agg: 'sum' }
2181 ],
2182 show: ['vol', 'reps', 'e1rm', 'dur'],
2183 sessShow: ['vol', 'reps', 'e1rm', 'dur'],
2184 targets: [{ f: 'vol', p: 'week', dir: 'min', v: 24000 }],
2185 tiles: [
2186 { m: 'tap', n: 'Start a workout', sess: 1, f: { name: 'Morning' },
2187 s: 'and it becomes the screen' },
2188 { m: 'timer', n: 'Cardio', by: 'dur', f: {} },
2189 { m: 'expand', n: 'Add exercises', pick: 1, p: 1, when: 'sess', wide: 1,
2190 s: 'choose several at once' },
2191 { m: 'form', n: 'One set', fs: ['lift', 'kg', 'reps'], when: 'any' }
2192 ]
2193 };
2194 var body = {
2195 // The day starts at 04:00, so sleep recorded at 02:00 belongs to the night
2196 // before rather than to the morning after.
2197 id: 'body', n: 'Body', dayStart: 240, primary: 'mood',
2198 fields: [
2199 { k: 'kg', t: 'num', n: 'Weight', u: 'kg', agg: 'last', ru: 'mean', step: 0.1 },
2200 { k: 'waist', t: 'num', n: 'Waist', u: 'cm', agg: 'last', ru: 'mean', step: 0.5 },
2201 { k: 'hr', t: 'num', n: 'Resting HR', u: 'bpm', agg: 'mean', step: 1 },
2202 { k: 'sleep', t: 'dur', n: 'Slept', agg: 'sum', ru: 'mean' },
2203 { k: 'mood', t: 'enum', n: 'Mood', o: ['Low', 'Flat', 'Fine', 'Good', 'Great'], agg: 'last' },
2204 { k: 'fast', t: 'bool', n: 'Fasted', agg: 'count' }
2205 ],
2206 show: ['kg', 'sleep', 'hr', 'mood', 'kg:since'],
2207 targets: [{ f: 'sleep', p: 'day', dir: 'min', v: 27000 }],
2208 tiles: [
2209 { m: 'form', n: 'Weigh in', fs: ['kg', 'waist'] },
2210 { m: 'form', n: 'Slept', fs: ['sleep', 'mood'] },
2211 { m: 'form', n: 'Resting HR', fs: ['hr', 'fast'] },
2212 { m: 'timer', n: 'Nap', by: 'sleep', f: {} },
2213 { m: 'tap', n: 'Feeling good', f: { mood: 'Good' } }
2214 ]
2215 };
2216 IDX = { v: 1, lanes: ['diet', 'gym', 'body'], weekStart: 1, seeded: isoAt(new Date()) };
2217 return Promise.all([
2218 save('index.json', JSON.stringify(IDX), 'replace'),
2219 save('lanes/diet.json', JSON.stringify(diet), 'replace'),
2220 save('lanes/gym.json', JSON.stringify(gym), 'replace'),
2221 save('lanes/body.json', JSON.stringify(body), 'replace'),
2222 save('cat/diet.json', JSON.stringify({ attrs: { grp: 'Food group' }, items: foods }), 'replace'),
2223 save('cat/gym.json', JSON.stringify({ attrs: { grp: 'Muscle group' }, items: lifts }), 'replace')
2224 ]);
2225}
2226})();
2227</script></body></html>