syndes 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,25 @@
1
1
  /**
2
2
  * The shell: unlock, top bar, page head, rail, and the router.
3
3
  *
4
- * The range lives here rather than in a view, because it is shared — moving
5
- * from Overview to Habits must not silently reset the window you are reading.
4
+ * The window lives here rather than in a view, because it is shared — moving
5
+ * from Overview to Habits must not silently reset what you are reading.
6
+ *
7
+ * It is two pieces of state, not one. `scope` is how wide a window you want and
8
+ * is remembered between sessions; `day` is which day you are on and is NOT, so
9
+ * opening the dashboard tomorrow shows tomorrow rather than silently serving a
10
+ * stale Tuesday that a stored range would have pinned you to.
11
+ *
12
+ * The default is a single day, because that is the question people actually
13
+ * arrive with: what happened today. A seven-day average hides the day you came
14
+ * to look at, and a shared pool averaged over a week says nothing about whether
15
+ * the two of you are in each other's way right now.
6
16
  *
7
17
  * Views are imported lazily, so the first paint does not wait on the Ledger
8
18
  * view's table code and a view that fails to load takes only itself down.
9
19
  */
10
20
 
11
21
  import { api, ApiError, onBusy } from './api.js';
12
- import { h, replace, mount, icon, dropdown } from './ui.js';
22
+ import { h, replace, mount, icon, dropdown, daypicker, todayISO, dayLabel } from './ui.js';
13
23
 
14
24
  const ROUTES = [
15
25
  { id: 'overview', label: 'Overview', title: 'Overview', load: () => import('./views/overview.js') },
@@ -22,20 +32,38 @@ const ROUTES = [
22
32
  { id: 'settings', label: 'Settings', title: 'Settings', rail: true, noRange: true, load: () => import('./views/settings.js') },
23
33
  ];
24
34
 
25
- const RANGES = [
26
- { id: 'today', label: 'Today' },
35
+ /**
36
+ * How wide a window. `day` is not a span — it defers to `state.day`, which is
37
+ * what the stepper beside this control moves.
38
+ */
39
+ const SCOPES = [
40
+ { id: 'day', label: 'One day', hint: 'the day picker chooses which' },
27
41
  { id: '7d', label: '7 days' },
28
42
  { id: '30d', label: '30 days' },
29
43
  { id: '90d', label: '90 days' },
30
44
  { id: 'all', label: 'All time' },
31
45
  ];
32
46
 
47
+ const SCOPE_KEY = 'syndes.scope';
48
+
33
49
  const state = {
34
- range: localStorage.getItem('syndes.range') ?? '7d',
50
+ scope: SCOPES.some((scope) => scope.id === storedScope()) ? storedScope() : 'day',
51
+ day: todayISO(),
35
52
  route: 'overview',
36
53
  status: null,
54
+ /** The day index behind the picker, fetched once and refreshed on reload. */
55
+ days: [],
37
56
  };
38
57
 
58
+ function storedScope() {
59
+ return localStorage.getItem(SCOPE_KEY);
60
+ }
61
+
62
+ /** The one spec every endpoint is asked with. A single day is its literal date. */
63
+ function rangeSpec() {
64
+ return state.scope === 'day' ? state.day : state.scope;
65
+ }
66
+
39
67
  const root = document.getElementById('root');
40
68
  let outlet;
41
69
  let titleNode;
@@ -145,16 +173,16 @@ function renderApp() {
145
173
 
146
174
  h('div.pagehead', {}, [
147
175
  h('div', {}, [titleNode, subNode]),
148
- // The range filter is hidden where it would be a lie: nothing on Settings
149
- // is scoped to a time window.
150
- h('div.filters', { id: 'filters' }, [rangeFilter()]),
176
+ // Hidden where it would be a lie: nothing on Settings is scoped to a
177
+ // time window.
178
+ h('div.filters', { id: 'filters' }, filters()),
151
179
  ]),
152
180
 
153
181
  h('div.body', {}, [
154
182
  h('aside.rail', {}, [
155
- railButton('refresh', 'Reload this view', () => render()),
183
+ railButton('refresh', 'Reload this view', () => { loadDays(); render(); }),
156
184
  railButton('verify', 'Check the chain', () => go('ledger')),
157
- railButton('download', 'Export everything as CSV', () => { window.location.href = api.exportUrl('csv', state.range); }),
185
+ railButton('download', 'Export everything as CSV', () => { window.location.href = api.exportUrl('csv', rangeSpec()); }),
158
186
  railButton('settings', 'Settings', () => go('settings'), 'settings'),
159
187
  ]),
160
188
  outlet,
@@ -165,6 +193,23 @@ function renderApp() {
165
193
  window.addEventListener('hashchange', () => go(routeFromHash(), { push: false }));
166
194
  go(routeFromHash(), { push: false });
167
195
  refreshStatus();
196
+ loadDays();
197
+ }
198
+
199
+ /**
200
+ * What the page head says the numbers cover.
201
+ *
202
+ * A single day gets its full name. "2026-09-10" is a string a machine chose;
203
+ * "Thursday, 10 September" is the thing a person came to look at, and the
204
+ * distinction matters most on the view that is now the default.
205
+ */
206
+ function windowLabel() {
207
+ if (state.scope !== 'day') return SCOPES.find((scope) => scope.id === state.scope)?.label ?? state.scope;
208
+ const named = dayLabel(state.day, { long: true });
209
+ const full = new Date(`${state.day}T12:00`).toLocaleDateString(undefined, {
210
+ weekday: 'long', day: 'numeric', month: 'long',
211
+ });
212
+ return named === full ? full : `${named} \u00b7 ${full}`;
168
213
  }
169
214
 
170
215
  function railButton(name, label, onClick, routeId = null) {
@@ -174,21 +219,67 @@ function railButton(name, label, onClick, routeId = null) {
174
219
  }, [icon(name)]);
175
220
  }
176
221
 
177
- function rangeFilter() {
178
- return dropdown({
179
- label: 'Range',
180
- value: state.range,
181
- options: RANGES.map((range) => ({ id: range.id, label: range.label })),
182
- onChange: setRange,
183
- });
222
+ /**
223
+ * Scope, and — when the scope is one day — which day.
224
+ *
225
+ * The stepper only exists in day scope. Leaving a day control visible beside
226
+ * "30 days" would invite somebody to set a day that the view then ignores, which
227
+ * is worse than not offering it.
228
+ */
229
+ function filters() {
230
+ return [
231
+ dropdown({
232
+ label: 'Window',
233
+ value: state.scope,
234
+ options: SCOPES.map((scope) => ({ id: scope.id, label: scope.label, hint: scope.hint })),
235
+ onChange: setScope,
236
+ }),
237
+ state.scope === 'day'
238
+ ? daypicker({ day: state.day, days: state.days, onChange: setDay })
239
+ : null,
240
+ ].filter(Boolean);
241
+ }
242
+
243
+ function repaintFilters() {
244
+ const node = document.getElementById('filters');
245
+ if (node) replace(node, filters());
184
246
  }
185
247
 
186
- function setRange(id) {
187
- state.range = id;
188
- localStorage.setItem('syndes.range', id);
248
+ function setScope(id) {
249
+ state.scope = id;
250
+ localStorage.setItem(SCOPE_KEY, id);
251
+ // Landing on day scope lands on today, never on whichever day was last open
252
+ // before you went to look at a month.
253
+ if (id === 'day') state.day = todayISO();
254
+ repaintFilters();
189
255
  render();
190
256
  }
191
257
 
258
+ function setDay(day) {
259
+ state.scope = 'day';
260
+ state.day = day;
261
+ localStorage.setItem(SCOPE_KEY, 'day');
262
+ repaintFilters();
263
+ render();
264
+ }
265
+
266
+ /**
267
+ * The day index behind the picker.
268
+ *
269
+ * Failure is silent and leaves the list empty: the picker still steps with its
270
+ * arrows, so a dashboard whose rollup cache is mid-rebuild loses the hints and
271
+ * nothing else.
272
+ */
273
+ async function loadDays() {
274
+ try {
275
+ const { days } = await api.days();
276
+ state.days = days ?? [];
277
+ repaintFilters();
278
+ } catch {
279
+ state.days = [];
280
+ }
281
+ }
282
+
192
283
  function routeFromHash() {
193
284
  const id = location.hash.replace(/^#\/?/, '').split('/')[0];
194
285
  return ROUTES.some((route) => route.id === id) ? id : 'overview';
@@ -209,16 +300,26 @@ function go(id, { push = true } = {}) {
209
300
  async function render() {
210
301
  const route = ROUTES.find((candidate) => candidate.id === state.route);
211
302
  titleNode.textContent = route.title;
212
- subNode.textContent = route.noRange ? 'Stored on this machine only' : (RANGES.find((range) => range.id === state.range)?.label ?? state.range);
213
- const filters = document.getElementById('filters');
214
- if (filters) filters.hidden = Boolean(route.noRange);
303
+ subNode.textContent = route.noRange ? 'Stored on this machine only' : windowLabel();
304
+ const filterBar = document.getElementById('filters');
305
+ if (filterBar) filterBar.hidden = Boolean(route.noRange);
215
306
 
307
+ const spec = rangeSpec();
216
308
  mount(outlet, skeleton());
217
309
  try {
218
310
  const module = await route.load();
219
- const node = await module.render({ range: state.range, status: state.status, go });
220
- // A range switch mid-load must not paint the answer to the previous question.
221
- if (state.route === route.id) mount(outlet, node);
311
+ const node = await module.render({
312
+ range: spec,
313
+ scope: state.scope,
314
+ day: state.day,
315
+ days: state.days,
316
+ status: state.status,
317
+ go,
318
+ setDay,
319
+ setScope,
320
+ });
321
+ // A window switch mid-load must not paint the answer to the previous question.
322
+ if (state.route === route.id && spec === rangeSpec()) mount(outlet, node);
222
323
  } catch (error) {
223
324
  if (error instanceof ApiError && error.status === 401) return renderUnlock();
224
325
  mount(outlet, notice(`Could not load ${route.label}`, error.message));
@@ -169,27 +169,40 @@ export function dualLine(seriesA, seriesB, { height = 96, label = 'trend' } = {}
169
169
  }
170
170
 
171
171
  /**
172
- * A row of day capsules. Compact activity: one capsule per day, height by
173
- * volume, so a gap in the week is visible as an empty slot rather than absent.
172
+ * A row of capsules over an ordered axis: one per day across a week, or one per
173
+ * hour across a single day. Height is volume, so a gap is visible as an empty
174
+ * slot rather than as an absence.
175
+ *
176
+ * `labelEvery` exists because twenty-four hour labels do not fit where seven day
177
+ * labels do. Labelling every third hour keeps the axis readable instead of
178
+ * letting the text overlap into a smear, and the full value is always on the
179
+ * hover title so nothing is only available by squinting.
174
180
  */
175
- export function dayStrip(days, { height = 74, label = 'activity by day' } = {}) {
181
+ export function dayStrip(slots, { height = 74, label = 'activity', labelEvery = null, format = String } = {}) {
176
182
  const width = 640;
177
- const max = Math.max(...days.map((day) => day.value), 1);
178
- const slot = width / Math.max(days.length, 1);
179
- const capWidth = Math.min(30, slot * 0.55);
183
+ const max = Math.max(...slots.map((slot) => slot.value), 1);
184
+ const slotWidth = width / Math.max(slots.length, 1);
185
+ const capWidth = Math.min(30, slotWidth * 0.55);
180
186
  const floor = height - 20;
187
+ const every = labelEvery ?? (slots.length <= 8 ? 1 : null);
181
188
 
182
189
  const nodes = [];
183
- days.forEach((day, index) => {
184
- const cx = index * slot + slot / 2;
185
- const h = day.value === 0 ? capWidth * 0.5 : Math.max(capWidth, (day.value / max) * (floor - 8));
186
- nodes.push(el('rect', {
187
- class: day.value === 0 ? '' : TONE[day.tone] ?? TONE.lime,
188
- fill: day.value === 0 ? 'var(--track)' : null,
189
- x: cx - capWidth / 2, y: floor - h, width: capWidth, height: h, rx: capWidth / 2,
190
- }));
191
- if (index === 0 || index === days.length - 1 || days.length <= 8) {
192
- nodes.push(el('text', { x: cx, y: height - 4, 'text-anchor': 'middle' }, [txt(day.label)]));
190
+ slots.forEach((slot, index) => {
191
+ const cx = index * slotWidth + slotWidth / 2;
192
+ const barHeight = slot.value === 0 ? capWidth * 0.5 : Math.max(capWidth, (slot.value / max) * (floor - 8));
193
+ const bar = el('rect', {
194
+ class: slot.value === 0 ? '' : TONE[slot.tone] ?? TONE.lime,
195
+ fill: slot.value === 0 ? 'var(--track)' : null,
196
+ x: cx - capWidth / 2, y: floor - barHeight, width: capWidth, height: barHeight, rx: capWidth / 2,
197
+ });
198
+ bar.appendChild(el('title', {}, [txt(slot.title ?? `${slot.label}: ${format(slot.value)}`)]));
199
+ nodes.push(bar);
200
+
201
+ const labelled = every
202
+ ? index % every === 0
203
+ : index === 0 || index === slots.length - 1;
204
+ if (labelled) {
205
+ nodes.push(el('text', { x: cx, y: height - 4, 'text-anchor': 'middle' }, [txt(slot.label)]));
193
206
  }
194
207
  });
195
208
  return frame(width, height, label, nodes);
@@ -352,6 +365,90 @@ export function dotMatrix(grid, { label = 'when each of you worked', dayLabel =
352
365
  return frame(width, height, label, nodes);
353
366
  }
354
367
 
368
+ /**
369
+ * The one-day contention chart: a lane per person across the twenty-four hours.
370
+ *
371
+ * The days × bands matrix is the right picture for a week and the wrong one for a
372
+ * day — one day at three-hour bands is eight dots in a single column, which
373
+ * throws away the only axis a day has. So a single selected day is drawn
374
+ * transposed: hours run across, people run down, and the dot on each person's
375
+ * lane is how much they did in that hour.
376
+ *
377
+ * Colour keeps the meaning it has everywhere else. A dot is orange when somebody
378
+ * ELSE was also working that hour, which is the question this chart exists to
379
+ * answer: not "was the pool busy" but "was I sharing the window, and with whom".
380
+ * A top summary lane carries the pool, so the overlap reads at a glance before
381
+ * any single name does.
382
+ *
383
+ * @param {object} grid from analytics/team.mjs — needs `cells`, `people`, `peak`
384
+ */
385
+ export function hourMatrix(grid, { label = 'who worked when today' } = {}) {
386
+ const hours = 24;
387
+ const cell = 24;
388
+ const left = 92;
389
+ const top = 8;
390
+ const rowGap = 6;
391
+ const rows = [{ deviceId: null, name: 'Everyone', initials: null, isMe: false }, ...grid.people];
392
+ const width = left + hours * cell + 10;
393
+ const height = top + rows.length * (cell + rowGap) + 18;
394
+ const maxRadius = cell * 0.44;
395
+ const peak = Math.max(grid.peak, 1);
396
+
397
+ const byBand = new Map(grid.cells.map((item) => [item.band, item]));
398
+ const nodes = [];
399
+
400
+ rows.forEach((person, index) => {
401
+ const cy = top + index * (cell + rowGap) + cell / 2;
402
+
403
+ nodes.push(el('text', {
404
+ x: left - 12, y: cy, 'text-anchor': 'end', 'dominant-baseline': 'middle',
405
+ style: person.isMe ? 'fill:var(--ink)' : null,
406
+ }, [txt(clipName(person.isMe ? `${person.name} (you)` : person.name))]));
407
+
408
+ for (let hour = 0; hour < hours; hour += 1) {
409
+ const item = byBand.get(hour);
410
+ const cx = left + hour * cell + cell / 2;
411
+
412
+ // The pool lane is the hour's total; a person's lane is only their own.
413
+ const value = person.deviceId === null
414
+ ? item?.total ?? 0
415
+ : item?.byDevice?.[person.deviceId] ?? 0;
416
+
417
+ if (!value) {
418
+ nodes.push(el('circle', { cx, cy, r: 2, fill: 'var(--track)' }));
419
+ continue;
420
+ }
421
+
422
+ // Shared means: somebody other than this lane's person also worked here.
423
+ const shared = person.deviceId === null
424
+ ? (item?.people ?? 0) > 1
425
+ : Object.keys(item?.byDevice ?? {}).some((id) => id !== person.deviceId);
426
+
427
+ // Square root, because the eye compares a dot's AREA. Scaling the radius
428
+ // linearly makes a busy hour look four times worse than it is.
429
+ const radius = Math.max(3, Math.sqrt(value / peak) * maxRadius);
430
+ const dot = el('circle', { class: shared ? TONE.orange : TONE.lime, cx, cy, r: radius });
431
+ dot.appendChild(el('title', {}, [txt(
432
+ `${person.deviceId === null ? 'Everyone' : person.name} \u00b7 ${String(hour).padStart(2, '0')}:00 \u00b7 ` +
433
+ `${value} events${shared ? ' \u00b7 shared with someone else' : ' \u00b7 had the window alone'}`,
434
+ )]));
435
+ nodes.push(dot);
436
+ }
437
+ });
438
+
439
+ for (let hour = 0; hour < hours; hour += 3) {
440
+ nodes.push(el('text', {
441
+ x: left + hour * cell + cell / 2, y: height - 5, 'text-anchor': 'middle',
442
+ }, [txt(String(hour).padStart(2, '0'))]));
443
+ }
444
+
445
+ return frame(width, height, label, nodes);
446
+ }
447
+
448
+ function clipName(name) {
449
+ return name.length > 14 ? `${name.slice(0, 13)}\u2026` : name;
450
+ }
451
+
355
452
  /**
356
453
  * One row per day, split into a capsule per person — the reference's timeline,
357
454
  * answering "who took the day".
@@ -45,6 +45,8 @@ const ICONS = {
45
45
  'M15 8a2 2 0 1 0 4 0 2 2 0 0 0-4 0', 'M8 16a2 2 0 1 0 4 0 2 2 0 0 0-4 0',
46
46
  ],
47
47
  chevron: ['M6.5 9.5 12 15l5.5-5.5'],
48
+ left: ['M14.5 5.5 8 12l6.5 6.5'],
49
+ right: ['M9.5 5.5 16 12l-6.5 6.5'],
48
50
  tick: ['M5 12.5 9.5 17 19 7.5'],
49
51
  };
50
52
 
@@ -188,6 +190,77 @@ export function dropdown({ value, options, onChange, label = null, onCard = fals
188
190
  return trigger;
189
191
  }
190
192
 
193
+ /**
194
+ * The day picker: step back a day, pick one from a list, step forward.
195
+ *
196
+ * Two ways in, because they answer different questions. The arrows are for
197
+ * "what about the day before this one", which is the move somebody makes five
198
+ * times in a row and must not cost five menu openings. The list is for "take me
199
+ * to last Tuesday", and it carries each day's volume so a quiet day is visible
200
+ * before it is opened rather than after.
201
+ *
202
+ * Forward is disabled on today rather than hidden. A control that disappears
203
+ * makes the row reflow under the pointer, and the disabled state is what says
204
+ * "this is the edge" instead of leaving someone to wonder where the button went.
205
+ *
206
+ * @param {string} day the selected day, YYYY-MM-DD
207
+ * @param {object[]} days [{ day, records, activeMs, tokens }], newest first
208
+ * @param {Function} onChange called with the newly selected day
209
+ */
210
+ export function daypicker({ day, days = [], onChange }) {
211
+ const today = todayISO();
212
+ const known = new Map(days.map((entry) => [entry.day, entry]));
213
+
214
+ // The listed days, plus the selected one if it is outside what the server
215
+ // returned — a day you are looking at must always appear in the list that
216
+ // claims to be showing which day you are looking at.
217
+ const listed = known.has(day) ? [...days] : [{ day, records: null }, ...days];
218
+
219
+ const step = (delta) => {
220
+ const next = shiftISO(day, delta);
221
+ if (next > today) return;
222
+ onChange(next);
223
+ };
224
+
225
+ const arrow = (name, label, delta, disabled) => h('button.stepper__btn', {
226
+ type: 'button',
227
+ title: label,
228
+ 'aria-label': label,
229
+ disabled,
230
+ onclick: () => step(delta),
231
+ }, [icon(name, { size: 16 })]);
232
+
233
+ return h('div.stepper', {}, [
234
+ arrow('left', 'The day before', -1, false),
235
+ dropdown({
236
+ label: 'Day',
237
+ value: day,
238
+ onChange,
239
+ options: listed.slice(0, 120).map((entry) => ({
240
+ id: entry.day,
241
+ label: dayLabel(entry.day),
242
+ hint: dayHint(entry),
243
+ })),
244
+ }),
245
+ arrow('right', 'The day after', 1, day >= today),
246
+ ]);
247
+ }
248
+
249
+ /**
250
+ * What a day looks like before you open it.
251
+ *
252
+ * `records: null` means the rollup cache has not folded that day yet, which is
253
+ * not the same as a day with nothing in it — so it says so rather than claiming
254
+ * a zero it has not measured.
255
+ */
256
+ function dayHint(entry) {
257
+ if (entry.records === null || entry.records === undefined) return 'not counted yet';
258
+ if (!entry.records) return 'nothing recorded';
259
+ const parts = [`${compact(entry.records)} records`];
260
+ if (entry.activeMs) parts.push(duration(entry.activeMs));
261
+ return parts.join(' \u00b7 ');
262
+ }
263
+
191
264
  export function replace(node, ...children) {
192
265
  node.replaceChildren(...children.flat().filter(Boolean));
193
266
  return node;
@@ -212,11 +285,6 @@ export function compact(value) {
212
285
  return String(Math.round(value));
213
286
  }
214
287
 
215
- export function usd(value) {
216
- if (value === null || value === undefined) return DASH;
217
- return value >= 1000 ? `$${Math.round(value).toLocaleString()}` : `$${value.toFixed(2)}`;
218
- }
219
-
220
288
  export function duration(ms) {
221
289
  if (!ms || ms < 0) return '0m';
222
290
  const minutes = Math.round(ms / 60_000);
@@ -226,6 +294,24 @@ export function duration(ms) {
226
294
  return rest ? `${hours}h ${rest}m` : `${hours}h`;
227
295
  }
228
296
 
297
+ /**
298
+ * A per-hour rate. `compact` does the magnitude; this only adds the unit, so a
299
+ * burn rate and a token count never disagree about what "1.2M" means.
300
+ *
301
+ * Null is an em dash, not 0 — a rate we could not measure must not read as a
302
+ * rate of nothing. See analytics/metrics/window.mjs.
303
+ */
304
+ export function perHour(value) {
305
+ if (value === null || value === undefined || Number.isNaN(value)) return DASH;
306
+ return `${compact(value)}/h`;
307
+ }
308
+
309
+ /** A ratio printed as a multiplier: 63.6 → "64×". */
310
+ export function times(value, digits = 0) {
311
+ if (value === null || value === undefined || Number.isNaN(value)) return DASH;
312
+ return `${value.toFixed(digits)}×`;
313
+ }
314
+
229
315
  export function percent(fraction, digits = 0) {
230
316
  if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return DASH;
231
317
  return `${(fraction * 100).toFixed(digits)}%`;
@@ -243,6 +329,36 @@ export const when = (ts) => (ts ? new Date(ts).toLocaleString(undefined, { month
243
329
  export const clock = (ts) => new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
244
330
  export const dayName = (iso) => new Date(`${iso}T12:00`).toLocaleDateString(undefined, { weekday: 'short' });
245
331
 
332
+ /** Today's local date as YYYY-MM-DD — the same shape analytics/ranges.mjs speaks. */
333
+ export function todayISO(now = new Date()) {
334
+ const month = String(now.getMonth() + 1).padStart(2, '0');
335
+ const day = String(now.getDate()).padStart(2, '0');
336
+ return `${now.getFullYear()}-${month}-${day}`;
337
+ }
338
+
339
+ export function shiftISO(iso, delta) {
340
+ const [year, month, date] = iso.split('-').map(Number);
341
+ // Noon, so a DST boundary cannot push the result onto the wrong calendar day.
342
+ return todayISO(new Date(year, month - 1, date + delta, 12));
343
+ }
344
+
345
+ /**
346
+ * A day, named the way a person would say it.
347
+ *
348
+ * "Today" and "Yesterday" beat a date for the two days somebody is nearly always
349
+ * looking at, and a date beats a weekday for everything older — "Tuesday" is
350
+ * ambiguous the moment more than one Tuesday is on offer.
351
+ */
352
+ export function dayLabel(iso, { long = false } = {}) {
353
+ const today = todayISO();
354
+ if (iso === today) return 'Today';
355
+ if (iso === shiftISO(today, -1)) return 'Yesterday';
356
+ const date = new Date(`${iso}T12:00`);
357
+ return date.toLocaleDateString(undefined, long
358
+ ? { weekday: 'long', day: 'numeric', month: 'long' }
359
+ : { weekday: 'short', day: 'numeric', month: 'short' });
360
+ }
361
+
246
362
  // ── Components ─────────────────────────────────────────────────────────────
247
363
 
248
364
  export function card(title, body, { note = null, actions = null, className = '' } = {}) {