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} |
| 13 | html,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. */ |
| 17 | body{font:400 var(--fs)/1.5 var(--fo);color:var(--tx);padding:0 0 6px; |
| 18 | overflow-wrap:anywhere;-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent} |
| 19 | button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer;text-align:left} |
| 20 | input{font:inherit;color:inherit;background:var(--bg);border:1px solid var(--bd); |
| 21 | border-radius:var(--rs);padding:7px 9px;width:100%;min-width:0} |
| 22 | input:focus{outline:2px solid var(--ac);outline-offset:-1px;border-color:var(--ac)} |
| 23 | svg{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; |
| 27 | border-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); |
| 37 | font-size:.82em;line-height:1.5;padding:3px 9px;white-space:nowrap;flex:none; |
| 38 | transition: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); |
| 48 | white-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; |
| 51 | margin: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; |
| 57 | min-height:66px;display:flex;flex-direction:column;justify-content:space-between;gap:5px; |
| 58 | transition: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; |
| 73 | justify-content:flex-start;gap:10px} |
| 74 | .step{grid-column:1/-1;display:flex;align-items:center;gap:8px;background:var(--sf); |
| 75 | border: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); |
| 77 | display: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; |
| 80 | text-align:center} |
| 81 | .go{background:var(--ac);color:var(--on);border-radius:var(--rs);padding:9px 14px;font-weight:650; |
| 82 | font-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); |
| 85 | padding: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); |
| 88 | padding: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; |
| 96 | text-overflow:ellipsis} |
| 97 | .sv{font-size:1.32em;font-weight:650;line-height:1.25;color:var(--tx);letter-spacing:-.01em; |
| 98 | font-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; |
| 107 | border-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; |
| 113 | white-space:nowrap;font-variant-numeric:tabular-nums} |
| 114 | .src{flex:none;width:6px;height:6px;border-radius:50%;background:var(--ac);opacity:.55; |
| 115 | align-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; |
| 118 | color: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; |
| 122 | color: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); |
| 129 | border-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); |
| 132 | padding: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; |
| 135 | font-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); |
| 139 | display: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; |
| 150 | margin: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; |
| 166 | border-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; |
| 180 | color: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`. |
| 194 | var PR = 1, seq = 0, pend = {}, APP = document.getElementById('app'); |
| 195 | |
| 196 | function post(o) { o.dc = 1; o.v = PR; parent.postMessage(o, '*'); } |
| 197 | function 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 | } |
| 202 | function read(p) { return req('asset', { path: p }); } |
| 203 | function 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". |
| 206 | function 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 ──────────── |
| 214 | function esc(s) { |
| 215 | return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<') |
| 216 | .replace(/>/g, '>').replace(/"/g, '"'); |
| 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. |
| 220 | function B(cls, a, v, inner) { |
| 221 | return '<button class="' + cls + '" data-a="' + a + '" data-v="' + esc(v) + '">' + inner + '</button>'; |
| 222 | } |
| 223 | function C(n, a, v, on) { return B('chip' + (on ? ' on' : ''), a, v, esc(n)); } |
| 224 | /// One reading: its name, its number and its unit. |
| 225 | function 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 | } |
| 229 | function p2(n) { return (n < 10 ? '0' : '') + n; } |
| 230 | function num(v) { v = (v === '' || v == null) ? null : +v; return (v != null && isFinite(v)) ? v : null; } |
| 231 | function 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 | } |
| 240 | function 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 | } |
| 245 | function 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. |
| 257 | function ymd(d) { return d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()); } |
| 258 | function 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 | } |
| 263 | function dayOf(d, start) { return ymd(new Date(d.getTime() - (start || 0) * 60000)); } |
| 264 | function tms(at) { var t = Date.parse(at); return isFinite(t) ? t : 0; } |
| 265 | function hhmm(at) { var d = new Date(tms(at)); return p2(d.getHours()) + ':' + p2(d.getMinutes()); } |
| 266 | function dnum(s) { var a = String(s).split('-'); return Math.floor(Date.UTC(+a[0], +a[1] - 1, +a[2]) / 864e5); } |
| 267 | function dstr(n) { |
| 268 | var d = new Date(n * 864e5); |
| 269 | return d.getUTCFullYear() + '-' + p2(d.getUTCMonth() + 1) + '-' + p2(d.getUTCDate()); |
| 270 | } |
| 271 | function dow(n) { return (n + 4) % 7; } |
| 272 | var MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; |
| 273 | var 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. |
| 276 | function 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 | } |
| 284 | function 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 | } |
| 292 | function bend(b, per) { return bshift(b, per, 1); } |
| 293 | function bdays(b, per) { return per === 'day' ? 1 : dnum(bend(b, per)) - dnum(b); } |
| 294 | function 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. |
| 308 | var PREC = { '+': 1, '-': 1, '*': 2, '/': 2 }; |
| 309 | function 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 | } |
| 326 | function 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 | } |
| 344 | function 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. |
| 367 | var IDX = null, LANE = {}, CAT = {}, LOG = {}, GOT = {}; |
| 368 | |
| 369 | /// Last write wins per id, and an import never replaces what a person typed. |
| 370 | function 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 | } |
| 378 | function 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. |
| 389 | function 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. |
| 408 | function 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 | } |
| 428 | function 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. |
| 434 | function laneJson(l) { |
| 435 | return JSON.stringify(l, function (k, v) { return k.charAt(0) === '_' ? undefined : v; }); |
| 436 | } |
| 437 | |
| 438 | // ── Fields ──────────── |
| 439 | function 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 | } |
| 444 | function 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 | } |
| 449 | function citem(l, id) { return cmap(l.id)[id] || null; } |
| 450 | function 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 |
| 457 | function 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 | } |
| 484 | function 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. |
| 490 | function 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. |
| 515 | function spanOf(e) { return ((e.end ? tms(e.end) : Date.now()) - tms(e.at)) / 1000; } |
| 516 | /// It has begun and it has not finished. |
| 517 | function sessOpen(e) { return !!e && !e.end; } |
| 518 | /// The entries that count: everything that is not a plan. |
| 519 | function 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 ──────────── |
| 526 | function 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`. |
| 540 | function 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. |
| 566 | function 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 | } |
| 576 | function 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 | |
| 587 | function 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 | } |
| 593 | function 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 | } |
| 598 | function 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. |
| 605 | function 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. |
| 615 | function 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. |
| 634 | var DARK = false; |
| 635 | var PAL_L = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948']; |
| 636 | var PAL_D = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767']; |
| 637 | function 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. |
| 645 | function 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 | } |
| 665 | function ratio(a, b) { return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); } |
| 666 | function 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 ──────────── |
| 692 | function px(n) { return Math.round(n * 10) / 10; } |
| 693 | function 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. |
| 701 | function 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 | } |
| 739 | function 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. |
| 750 | function 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 | } |
| 776 | function 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. |
| 817 | var S = { lane: '', per: 'day', off: 0, view: 'log', scr: null, sess: '', tile: -1, amt: 0, |
| 818 | pie: null, bar: null }; |
| 819 | var D = {}, TOAST = null, TT = 0, BOOTED = false, STARTED = false, LASTW = 0, LASTH = -1; |
| 820 | |
| 821 | function lane() { return LANE[S.lane] || { id: '', fields: [], tiles: [] }; } |
| 822 | function curB() { var l = lane(); return bshift(bstart(dayOf(new Date(), l.dayStart), S.per), S.per, S.off); } |
| 823 | function 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. |
| 833 | function 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 ──────────── |
| 843 | function 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">✎</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', '‹') |
| 864 | + C(blabel(curB(), S.per), 'now', '', !S.off) |
| 865 | + B('chip q', 'off', '1', '›') |
| 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. |
| 874 | function 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 | } |
| 888 | function 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. |
| 909 | function 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 | } |
| 924 | function 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, '−') |
| 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', '', '✕') + '</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. |
| 937 | function 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 | |
| 943 | function 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 ? '✓' : t.dir === 'max' ? 'over' : 'under') + '</b> ' |
| 959 | + (t.dir === 'max' ? '≤' : '≥') + ' ' + 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 | |
| 967 | function 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">›</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. |
| 1014 | function 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. |
| 1019 | function 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. |
| 1039 | function 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. |
| 1069 | function 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">×</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, '✓') |
| 1086 | + '<span class="et">' + n + '.</span><span class="gap">' + o + '</span>' |
| 1087 | + (ed ? B('frm', 'del', e.id, '✕') |
| 1088 | : B('ev', 'edit', e.id, pf2 ? esc(fmt(fnum(l, e, pf2)) + ' 1RM') : '›')) |
| 1089 | + '</div>'; |
| 1090 | } |
| 1091 | |
| 1092 | /// The fields a chart may read: everything numeric, plus anything counted. |
| 1093 | function 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 | } |
| 1101 | function 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 | } |
| 1112 | function 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 | |
| 1134 | function 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', '', '✕') + '</div>'; |
| 1139 | } |
| 1140 | function 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. |
| 1143 | function 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. |
| 1157 | function 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', '', '‹') |
| 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. |
| 1173 | function 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 ──────────── |
| 1185 | function backBar(txt) { return B('back', 'back', '', '‹ ' + 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. |
| 1201 | function 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. |
| 1215 | function 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. |
| 1229 | function 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. |
| 1265 | var REST = { until: 0, secs: 0 }, RIV = 0, AC = null, ANODES = null; |
| 1266 | |
| 1267 | /// The audio context, opened on the press that wants it. |
| 1268 | function 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. |
| 1276 | function 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. |
| 1283 | function 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 | } |
| 1300 | function restLeft() { return Math.max(0, Math.ceil((REST.until - Date.now()) / 1000)); } |
| 1301 | function 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. |
| 1305 | function 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. |
| 1315 | function 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 | } |
| 1323 | function 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 | } |
| 1328 | function 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', '−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', '', '✕') + '</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. |
| 1341 | function 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 | } |
| 1363 | function 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 | } |
| 1368 | function 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, '−') |
| 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. |
| 1390 | function 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. |
| 1421 | var FTYPES = [['num', 'Number'], ['dur', 'Time'], ['enum', 'Choice'], ['bool', 'Yes/no'], ['text', 'Note']]; |
| 1422 | var 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. |
| 1425 | function 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 | } |
| 1430 | function uniq(base, taken) { |
| 1431 | var k = base || 'x', i = 2; |
| 1432 | while (taken[k]) { k = base + i; i++; } |
| 1433 | return k; |
| 1434 | } |
| 1435 | function 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 | } |
| 1440 | function 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. |
| 1444 | function 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. |
| 1464 | function 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 | } |
| 1477 | function 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, '✕') + '</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 | } |
| 1506 | function 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 | } |
| 1532 | function 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. |
| 1545 | function 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. |
| 1647 | function 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 | } |
| 1656 | function 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. |
| 1686 | function 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. |
| 1706 | function 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 | |
| 1719 | function 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 | } |
| 1729 | function 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 ──────────── |
| 1737 | function 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 | } |
| 1744 | function 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 | } |
| 1793 | function 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 | } |
| 1808 | function cp(o) { var r = {}, k; for (k in o) if (o.hasOwnProperty(k)) r[k] = o[k]; return r; } |
| 1809 | function cp2(e) { |
| 1810 | var r = cp(e); r.f = cp(e.f || {}); return r; |
| 1811 | } |
| 1812 | function 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. |
| 1822 | function 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 | } |
| 1831 | function 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 ──────────── |
| 1842 | document.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. |
| 2013 | document.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 ──────────── |
| 2022 | function 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. |
| 2030 | function 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. |
| 2041 | function 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 | |
| 2062 | window.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 | }); |
| 2088 | if (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 | } |
| 2095 | LASTW = APP.clientWidth; |
| 2096 | post({ 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. |
| 2102 | var 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(';'); |
| 2113 | var 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 | |
| 2117 | function 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> |