@heyramzi/ui 2.14.0 → 2.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,18 @@
1
1
  /** The tooltip's name for a drawn point, beside its date. */
2
- export const ROLLING_LABEL = "7-day average";
2
+ export const averageLabel = (days) => `${days}-day average`;
3
+ export const ROLLING_LABEL = averageLabel(7);
3
4
  export const weekOf = (start) => `Week of ${start}`;
4
- export const averageTo = (end) => `${ROLLING_LABEL} to ${end}`;
5
+ export const averageTo = (end, days = 7) => `${averageLabel(days)} to ${end}`;
6
+ /** What a point the collector never measured reads as, where a number would claim a 0. */
7
+ export const NO_DATA = "No data";
5
8
  const DAY = 864e5;
6
- const WINDOW = 7;
9
+ const WEEK = 7;
10
+ /** A year at 7 days still drew one-day spikes as humps; 28 days keeps the growth and drops the spikes. */
11
+ const LONG_WINDOW = 28;
7
12
  // =========================
8
13
  // FUNCTIONS
9
14
  // =========================
10
- /** About 30 to 60 marks across a chart: raw to a week, a 7-day average to a quarter, whole weeks past it. */
15
+ /** About 30 to 60 marks across a chart: raw to a fortnight, a daily average to a quarter, weekly past it. */
11
16
  export function smoothMode(rangeDays) {
12
17
  if (rangeDays <= 14) return "raw";
13
18
  if (rangeDays <= 120) return "rolling";
@@ -16,70 +21,105 @@ export function smoothMode(rangeDays) {
16
21
  function timeOf(value) {
17
22
  return value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
18
23
  }
24
+ /** Null, undefined or not a number: a day nobody measured. An absent row still counts as 0. */
25
+ function measured(value) {
26
+ if (value == null) return null;
27
+ const n = Number(value);
28
+ return Number.isFinite(n) ? n : null;
29
+ }
19
30
  /** The bucket's start in the same form the series uses, so the axis and the formatter see no change. */
20
31
  function sameForm(sample, time) {
21
32
  if (sample instanceof Date) return new Date(time);
22
33
  const iso = new Date(time).toISOString();
23
34
  return String(sample).length === 10 ? iso.slice(0, 10) : iso;
24
35
  }
36
+ const daysBetween = (from, to) => Math.round((to - from) / DAY);
37
+ /**
38
+ * A trailing mean over `window` days, drawn on the days `keep` picks.
39
+ *
40
+ * A missing value is skipped, never averaged in as 0, and a window with fewer measured days than
41
+ * missing ones draws a gap: one partial day after a 12-day outage is not a week. Days before a
42
+ * series' first measured value are not tracked yet, so they neither draw nor count as missing.
43
+ */
44
+ function trailingMean(data, times, yKeys, window, keep) {
45
+ const firstSeen = Object.fromEntries(yKeys.map((k) => [k, times[data.findIndex((d) => measured(d[k]) !== null)] ?? Infinity]));
46
+ const out = [];
47
+ data.forEach((d, i) => {
48
+ if (!keep(i)) return;
49
+ const row = { ...d };
50
+ for (const k of yKeys) {
51
+ if (times[i] < firstSeen[k]) {
52
+ row[k] = null;
53
+ continue;
54
+ }
55
+ let sum = 0;
56
+ let missing = 0;
57
+ for (let j = i; j >= 0 && times[i] - times[j] < window * DAY; j--) {
58
+ const value = measured(data[j][k]);
59
+ if (value === null) missing += times[j] >= firstSeen[k] ? 1 : 0;
60
+ else sum += value;
61
+ }
62
+ // Divided by the tracked days in the window, so a row absent from a sparse series counts as 0.
63
+ const days = Math.min(window, daysBetween(Math.max(times[0], firstSeen[k]), times[i]) + 1);
64
+ const seen = days - missing;
65
+ row[k] = seen > 0 && seen * 2 >= days ? sum / seen : null;
66
+ }
67
+ out.push(row);
68
+ });
69
+ return out;
70
+ }
25
71
  /**
26
- * The line a chart draws: a trailing 7-day mean, or 7-day buckets counted back from the last day.
27
- * Totals stay the caller's raw sums; this only moves the drawn points.
72
+ * The line a chart draws. Up to a quarter, a trailing 7-day mean per day. Past it, a trailing
73
+ * 28-day mean read once a week from the last day, so the axis stays in per-day units at every
74
+ * range; a level reads its last value each week instead. A missing value (null) is a gap in the
75
+ * line, never a 0. Totals stay the caller's raw sums; this only moves the drawn points.
28
76
  */
29
77
  export function smoothSeries(data, xKey, yKeys, { rangeDays, kind = "count" }) {
30
78
  const mode = smoothMode(rangeDays);
31
- if (mode === "raw" || data.length < WINDOW) return {
79
+ if (mode === "raw" || data.length < WEEK) return {
32
80
  points: data,
33
- mode: "raw"
81
+ mode: "raw",
82
+ window: 1
34
83
  };
35
84
  const times = data.map((d) => timeOf(d[xKey]));
36
- if (mode === "rolling") {
37
- // A level has no daily noise to average, and a mean draws slopes between its steps.
38
- if (kind === "level") return {
39
- points: data,
40
- mode: "raw"
41
- };
42
- let from = 0;
43
- const sums = Object.fromEntries(yKeys.map((k) => [k, 0]));
44
- const points = data.map((d, i) => {
45
- for (const k of yKeys) sums[k] += Number(d[k] ?? 0);
46
- while (times[i] - times[from] >= WINDOW * DAY) {
47
- for (const k of yKeys) sums[k] -= Number(data[from][k] ?? 0);
48
- from += 1;
49
- }
50
- // Divided by the days in the window, so a missing day counts as zero, not skipped.
51
- const days = Math.min(WINDOW, Math.round((times[i] - times[0]) / DAY) + 1);
52
- const row = { ...d };
53
- for (const k of yKeys) row[k] = sums[k] / days;
54
- return row;
55
- });
85
+ const last = times[times.length - 1];
86
+ if (kind === "count") {
87
+ const window = mode === "rolling" ? WEEK : LONG_WINDOW;
88
+ const keep = mode === "rolling" ? () => true : (i) => daysBetween(times[i], last) % WEEK === 0;
56
89
  return {
57
- points,
58
- mode
90
+ points: trailingMean(data, times, yKeys, window, keep),
91
+ mode: "rolling",
92
+ window
59
93
  };
60
94
  }
61
- // Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped:
62
- // a 3-day sum would draw a dip that never happened.
63
- const last = times[times.length - 1];
95
+ // A level has no daily noise to average, and a mean draws slopes between its steps.
96
+ if (mode === "rolling") return {
97
+ points: data,
98
+ mode: "raw",
99
+ window: 1
100
+ };
101
+ // Weeks end on the last day, so only the oldest can be partial, and a partial week is dropped.
64
102
  const buckets = new Map();
65
103
  data.forEach((d, i) => {
66
- const index = Math.floor(Math.round((last - times[i]) / DAY) / WINDOW);
104
+ const index = Math.floor(daysBetween(times[i], last) / WEEK);
67
105
  buckets.set(index, [...buckets.get(index) ?? [], d]);
68
106
  });
69
107
  const oldest = Math.max(...buckets.keys());
70
- const oldestStart = last - (oldest * WINDOW + WINDOW - 1) * DAY;
108
+ const oldestStart = last - (oldest * WEEK + WEEK - 1) * DAY;
71
109
  if (times[0] > oldestStart && buckets.size > 1) buckets.delete(oldest);
72
110
  const points = [...buckets.entries()].toSorted(([a], [b]) => b - a).map(([index, rows]) => {
73
111
  const row = { ...rows[rows.length - 1] };
74
- row[xKey] = sameForm(data[0][xKey], last - (index * WINDOW + WINDOW - 1) * DAY);
112
+ row[xKey] = sameForm(data[0][xKey], last - (index * WEEK + WEEK - 1) * DAY);
75
113
  for (const k of yKeys) {
76
- row[k] = kind === "level" ? Number(rows[rows.length - 1][k] ?? 0) : rows.reduce((sum, r) => sum + Number(r[k] ?? 0), 0);
114
+ const readings = rows.map((r) => measured(r[k])).filter((v) => v !== null);
115
+ row[k] = readings.length > 0 ? readings[readings.length - 1] : null;
77
116
  }
78
117
  return row;
79
118
  });
80
119
  return {
81
120
  points,
82
- mode
121
+ mode,
122
+ window: WEEK
83
123
  };
84
124
  }
85
125
  /** Labels at least `spacing` pixels apart, the way a time axis reads at any width. */
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Motion tokens for code that needs a number: a Svelte `flip`, `slide` or `fade`, a timer. The
3
+ * CSS in `tokens/craft.css` owns the values; these mirror it for the server render and for a
4
+ * page that never loaded the tokens.
5
+ */
6
+ export const DURATION_MS = {
7
+ quick: 150,
8
+ fast: 250,
9
+ slow: 300
10
+ };
11
+ /** A duration token in ms, read off a mounted element; both `ms` and `s` parse. */
12
+ export function tokenMs(el, name, fallback) {
13
+ const raw = getComputedStyle(el).getPropertyValue(name).trim();
14
+ const n = Number.parseFloat(raw);
15
+ if (Number.isNaN(n)) return fallback;
16
+ return raw.endsWith("ms") ? n : n * 1e3;
17
+ }
18
+ /** `--duration-<token>` in ms off the root, so a token edit retimes the JS motion too. */
19
+ export function durationMs(token) {
20
+ if (typeof document === "undefined") return DURATION_MS[token];
21
+ return tokenMs(document.documentElement, `--duration-${token}`, DURATION_MS[token]);
22
+ }
@@ -0,0 +1,205 @@
1
+ import { untrack } from "svelte";
2
+ import { SvelteSet } from "svelte/reactivity";
3
+ import { prefersReducedMotion } from "svelte/motion";
4
+ import { cubicOut } from "svelte/easing";
5
+ import { durationMs } from "../lib/motion.js";
6
+ import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
7
+ import { createKanbanRailState } from "./kanban-rail-state.svelte.js";
8
+ import { createCardKeyDown } from "./kanban-keyboard.js";
9
+ /**
10
+ * A board's state: sort, the per-column fold and cap, and the drag wiring. The hidden rail
11
+ * lives in `rail`, the keyboard move in `onCardKeyDown`.
12
+ * Call it during component init, since it registers an effect. `input` is read on every use.
13
+ */
14
+ export function createKanbanBoardState(input) {
15
+ // The drop slot takes the held card's height, so the column doesn't jump when it lands.
16
+ let slotHeight = $state(0);
17
+ let slotMotion = $derived({
18
+ duration: prefersReducedMotion.current ? 0 : durationMs("quick"),
19
+ easing: cubicOut
20
+ });
21
+ let internalExpanded = $state([]);
22
+ let expandedIds = $derived(input().expanded ?? internalExpanded);
23
+ function compare(a, b) {
24
+ const { sortKey, manualTiebreak, boardOrder, sortKeys } = input();
25
+ if (sortKey === "manual") {
26
+ const tie = manualTiebreak?.(a, b) ?? 0;
27
+ if (!boardOrder) return tie;
28
+ const ao = boardOrder(a);
29
+ const bo = boardOrder(b);
30
+ if (ao == null && bo == null) return tie;
31
+ if (ao == null) return 1;
32
+ if (bo == null) return -1;
33
+ return ao < bo ? -1 : ao > bo ? 1 : tie;
34
+ }
35
+ const key = sortKeys.find((entry) => entry.id === sortKey);
36
+ return key ? key.compare(a, b) : 0;
37
+ }
38
+ function sortedByColumn(columnId) {
39
+ const { items, getColumn } = input();
40
+ return items.filter((item) => getColumn(item) === columnId).slice().sort(compare);
41
+ }
42
+ function idAt(list, index) {
43
+ const item = list[index];
44
+ return item === undefined ? null : input().getId(item);
45
+ }
46
+ function capOf(columnId) {
47
+ const { limit } = input();
48
+ return typeof limit === "function" ? limit(columnId) : limit;
49
+ }
50
+ /** The column holds more cards than its cap, so it gets a fold. */
51
+ function isFoldable(columnId, total) {
52
+ const cap = capOf(columnId);
53
+ return cap !== undefined && total > cap;
54
+ }
55
+ // Open from this board's "Show N more", on top of the header caret's `expanded`.
56
+ const laneOpen = new SvelteSet();
57
+ function isOpen(columnId) {
58
+ return expandedIds.includes(columnId) || laneOpen.has(columnId);
59
+ }
60
+ /** The fold is shut, so the tail is out of sight. */
61
+ function isCapped(columnId, total) {
62
+ return isFoldable(columnId, total) && !isOpen(columnId);
63
+ }
64
+ // A card moved into a folded column stays in view, wherever its sort puts it.
65
+ const pinned = new SvelteSet();
66
+ // The caret folding a column shuts this board's own fold of it too, so every lane lands on the cap.
67
+ let previousExpanded = [];
68
+ $effect(() => {
69
+ const now = expandedIds;
70
+ untrack(() => {
71
+ for (const id of previousExpanded) if (!now.includes(id)) shut(id);
72
+ });
73
+ previousExpanded = [...now];
74
+ });
75
+ function moveAndPin(id, move) {
76
+ pinned.add(id);
77
+ return input().onMove(id, move);
78
+ }
79
+ function canDrop(columnId) {
80
+ const { isDroppable, columns } = input();
81
+ return isDroppable?.(columnId) ?? columns.find((c) => c.id === columnId)?.droppable ?? true;
82
+ }
83
+ function setExpanded(next) {
84
+ internalExpanded = next;
85
+ input().onExpandedChange?.(next);
86
+ }
87
+ // Shutting drops the column's pins, so the fold closes to exactly its cap.
88
+ function shut(columnId) {
89
+ laneOpen.delete(columnId);
90
+ for (const item of sortedByColumn(columnId)) pinned.delete(input().getId(item));
91
+ }
92
+ /** "Show N more": opens this board's cell only. */
93
+ function expand(columnId) {
94
+ laneOpen.add(columnId);
95
+ }
96
+ /** "Show less": shuts this board's cell, and lifts the caret's open if it was the caret's. */
97
+ function collapse(columnId) {
98
+ shut(columnId);
99
+ if (expandedIds.includes(columnId)) setExpanded(expandedIds.filter((id) => id !== columnId));
100
+ }
101
+ /** The header caret: the whole column, in every board that shares `expanded`. */
102
+ function toggleColumn(columnId) {
103
+ if (expandedIds.includes(columnId)) collapse(columnId);
104
+ else setExpanded([...expandedIds, columnId]);
105
+ }
106
+ const dnd = createKanbanDnd({
107
+ items: () => input().items,
108
+ getId: (item) => input().getId(item),
109
+ getColumn: (item) => input().getColumn(item),
110
+ getColumnItems: sortedByColumn,
111
+ getSortKey: () => input().sortKey,
112
+ boardOrder: (item) => input().boardOrder?.(item),
113
+ onMove: moveAndPin,
114
+ dropAtTop: (columnId) => input().dropAtTop?.(columnId) ?? isCapped(columnId, sortedByColumn(columnId).length),
115
+ isDroppable: canDrop
116
+ });
117
+ const rail = createKanbanRailState({
118
+ hidden: () => input().hidden,
119
+ onHiddenChange: () => input().onHiddenChange,
120
+ canDrop,
121
+ dnd
122
+ });
123
+ const onCardKeyDown = createCardKeyDown({
124
+ input,
125
+ canDrop,
126
+ isHidden: (columnId) => rail.hiddenSet.has(columnId),
127
+ sortedByColumn,
128
+ isCapped,
129
+ moveAndPin
130
+ });
131
+ let view = $derived(input().columns.map((column) => {
132
+ const all = sortedByColumn(column.id);
133
+ const cap = capOf(column.id);
134
+ const foldable = isFoldable(column.id, all.length);
135
+ const open = foldable && isOpen(column.id);
136
+ if (!foldable || cap === undefined) {
137
+ return {
138
+ column,
139
+ all,
140
+ head: all,
141
+ tail: [],
142
+ foldable,
143
+ open,
144
+ capped: false
145
+ };
146
+ }
147
+ // Head: the first `cap` cards plus any pinned ones, in sort order. The fold holds the rest.
148
+ const head = [];
149
+ const tail = [];
150
+ all.forEach((item, index) => {
151
+ (index < cap || pinned.has(input().getId(item)) ? head : tail).push(item);
152
+ });
153
+ return {
154
+ column,
155
+ all,
156
+ head,
157
+ tail,
158
+ foldable,
159
+ open,
160
+ capped: !open && tail.length > 0
161
+ };
162
+ }));
163
+ let hiddenColumns = $derived(input().columns.filter((column) => rail.hiddenSet.has(column.id)));
164
+ function contextOf(entry, active) {
165
+ const { column, all, head, capped } = entry;
166
+ return {
167
+ count: all.length,
168
+ active,
169
+ shown: capped ? head.length : all.length,
170
+ capped,
171
+ hide: () => rail.hide(column.id),
172
+ expand: () => expand(column.id),
173
+ collapse: () => collapse(column.id)
174
+ };
175
+ }
176
+ return {
177
+ get slotHeight() {
178
+ return slotHeight;
179
+ },
180
+ set slotHeight(value) {
181
+ slotHeight = value;
182
+ },
183
+ get slotMotion() {
184
+ return slotMotion;
185
+ },
186
+ get expandedIds() {
187
+ return expandedIds;
188
+ },
189
+ get view() {
190
+ return view;
191
+ },
192
+ get hiddenColumns() {
193
+ return hiddenColumns;
194
+ },
195
+ dnd,
196
+ rail,
197
+ sortedByColumn,
198
+ idAt,
199
+ expand,
200
+ collapse,
201
+ toggleColumn,
202
+ onCardKeyDown,
203
+ contextOf
204
+ };
205
+ }
@@ -0,0 +1,45 @@
1
+ import { boardOrderAt } from "./board-order.js";
2
+ /** The card's keydown handler: Enter or Space selects, Alt + arrow moves. */
3
+ export function createCardKeyDown(config) {
4
+ const { input, canDrop, isHidden, sortedByColumn, isCapped, moveAndPin } = config;
5
+ /**
6
+ * Alt + ArrowLeft/Right moves the focused card to the next droppable column, so
7
+ * keyboard and screen-reader users get drag's onMove contract.
8
+ */
9
+ return function onCardKeyDown(e, id) {
10
+ const { onSelect, items, getId, getColumn, columns, sortKey, dropAtTop, boardOrder } = input();
11
+ if (onSelect && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
12
+ e.preventDefault();
13
+ onSelect(id);
14
+ return;
15
+ }
16
+ if (!e.altKey || e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
17
+ const item = items.find((entry) => getId(entry) === id);
18
+ if (!item) return;
19
+ const current = getColumn(item);
20
+ const at = columns.findIndex((c) => c.id === current);
21
+ if (at === -1) return;
22
+ const dir = e.key === "ArrowRight" ? 1 : -1;
23
+ for (let i = at + dir; i >= 0 && i < columns.length; i += dir) {
24
+ const target = columns[i];
25
+ if (!canDrop(target.id) || isHidden(target.id) || target.id === current) continue;
26
+ e.preventDefault();
27
+ if (sortKey === "manual") {
28
+ const column = sortedByColumn(target.id).filter((entry) => getId(entry) !== id);
29
+ const atTop = dropAtTop?.(target.id) ?? isCapped(target.id, column.length + 1);
30
+ const index = atTop ? 0 : column.length;
31
+ const before = column[index - 1];
32
+ const after = column[index];
33
+ void moveAndPin(id, {
34
+ columnId: target.id,
35
+ ...boardOrder ? { boardOrder: boardOrderAt(column, index, (entry) => boardOrder(entry)) } : {},
36
+ beforeId: before === undefined ? null : getId(before),
37
+ afterId: after === undefined ? null : getId(after)
38
+ });
39
+ } else {
40
+ void moveAndPin(id, { columnId: target.id });
41
+ }
42
+ return;
43
+ }
44
+ };
45
+ }
@@ -0,0 +1,85 @@
1
+ import { SvelteSet } from "svelte/reactivity";
2
+ /** The rail of hidden columns: which are folded, the fold and unfold timing, and drops onto it. */
3
+ export function createKanbanRailState(config) {
4
+ const { canDrop, dnd } = config;
5
+ let internalHidden = $state([]);
6
+ let hiddenIds = $derived(config.hidden() ?? internalHidden);
7
+ let hiddenSet = $derived(new Set(hiddenIds));
8
+ let railOpen = $state(true);
9
+ let dragOverHidden = $state(null);
10
+ const collapsing = new SvelteSet();
11
+ const expanding = new SvelteSet();
12
+ const leaving = new SvelteSet();
13
+ function setHidden(next) {
14
+ internalHidden = next;
15
+ config.onHiddenChange()?.(next);
16
+ }
17
+ // Fold: the column fades out first, then leaves the row and joins the rail.
18
+ function hide(columnId) {
19
+ if (hiddenSet.has(columnId) || collapsing.has(columnId)) return;
20
+ collapsing.add(columnId);
21
+ setTimeout(() => {
22
+ setHidden([...hiddenIds, columnId]);
23
+ collapsing.delete(columnId);
24
+ }, 300);
25
+ }
26
+ // Unfold: the rail entry leaves, then the column grows back in.
27
+ function show(columnId) {
28
+ leaving.add(columnId);
29
+ setTimeout(() => {
30
+ leaving.delete(columnId);
31
+ expanding.add(columnId);
32
+ setHidden(hiddenIds.filter((id) => id !== columnId));
33
+ setTimeout(() => expanding.delete(columnId), 350);
34
+ }, 200);
35
+ }
36
+ function showAll() {
37
+ for (const id of hiddenIds) leaving.add(id);
38
+ setTimeout(() => {
39
+ leaving.clear();
40
+ setHidden([]);
41
+ }, 200);
42
+ }
43
+ function onHiddenDragOver(e, columnId) {
44
+ if (!canDrop(columnId)) return;
45
+ e.preventDefault();
46
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
47
+ if (dragOverHidden !== columnId) dragOverHidden = columnId;
48
+ dnd.clearDragOver();
49
+ }
50
+ function onHiddenDragLeave(e) {
51
+ if (!e.currentTarget.contains(e.relatedTarget)) dragOverHidden = null;
52
+ }
53
+ // A card dropped on a folded column brings the column back and lands in it.
54
+ function onHiddenDrop(e, columnId) {
55
+ dragOverHidden = null;
56
+ dnd.onDrop(e, columnId);
57
+ show(columnId);
58
+ }
59
+ return {
60
+ get hiddenSet() {
61
+ return hiddenSet;
62
+ },
63
+ get railOpen() {
64
+ return railOpen;
65
+ },
66
+ set railOpen(value) {
67
+ railOpen = value;
68
+ },
69
+ get dragOverHidden() {
70
+ return dragOverHidden;
71
+ },
72
+ set dragOverHidden(value) {
73
+ dragOverHidden = value;
74
+ },
75
+ collapsing,
76
+ expanding,
77
+ leaving,
78
+ hide,
79
+ show,
80
+ showAll,
81
+ onHiddenDragOver,
82
+ onHiddenDragLeave,
83
+ onHiddenDrop
84
+ };
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "2.14.0",
3
+ "version": "2.15.1",
4
4
  "description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
5
5
  "homepage": "https://ui.heyramzi.com",
6
6
  "license": "MIT",
@@ -14,7 +14,8 @@
14
14
  import Reveal from "./reveal.svelte";
15
15
  import ReferenceLine, { type ChartReference } from "./reference-line.svelte";
16
16
  import {
17
- ROLLING_LABEL,
17
+ NO_DATA,
18
+ averageLabel as averageName,
18
19
  averageTo,
19
20
  smoothSeries,
20
21
  weekOf,
@@ -97,10 +98,15 @@
97
98
  return pointLabel(d);
98
99
  }
99
100
 
100
- /** What a drawn point is: a day, the week it opens, or the end of a 7-day average. */
101
+ /** What a drawn point is: a day, the week it opens, or the end of a 7 or 28-day average. */
101
102
  function pointLabel(d: TData): string {
102
103
  const x = formatX(d[xKey]);
103
- return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x) : x;
104
+ return mode === "weekly" ? weekOf(x) : mode === "rolling" ? averageTo(x, smoothed.window) : x;
105
+ }
106
+
107
+ /** A missing point reads "No data", never the 0 Number(null) would claim. */
108
+ function shown(value: unknown): string {
109
+ return value == null ? NO_DATA : formatValue(Number(value));
104
110
  }
105
111
 
106
112
  // =========================
@@ -126,12 +132,12 @@
126
132
  series.map((s) => s.key),
127
133
  smoothing,
128
134
  )
129
- : { points: input, mode: "raw" as const },
135
+ : { points: input, mode: "raw" as const, window: 1 },
130
136
  );
131
137
  const data = $derived(smoothed.points);
132
138
  const mode = $derived(smoothed.mode);
133
- // The series label in the readout, unless the point is an average of the week before it.
134
- const averageLabel = $derived(mode === "rolling" ? ROLLING_LABEL : null);
139
+ // The series label in the readout, unless the point is an average of the days before it.
140
+ const averageLabel = $derived(mode === "rolling" ? averageName(smoothed.window) : null);
135
141
  const dataMax = $derived.by(() => {
136
142
  if (stacked) {
137
143
  return Math.max(0, ...data.map((d) => series.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)));
@@ -182,6 +188,8 @@
182
188
  format={formatValue}
183
189
  size="md"
184
190
  />
191
+ {:else if display && series.every((s) => display[s.key] == null)}
192
+ <Figure value={NO_DATA} eyebrow={pointLabel(display)} size="md" />
185
193
  {:else if figureRows && display}
186
194
  <Figure series={figureRows} eyebrow={pointLabel(display)} format={formatValue} size="md" />
187
195
  {:else}
@@ -246,7 +254,7 @@
246
254
  {#each series as s (s.key)}
247
255
  <Tooltip.Item
248
256
  label={averageLabel ?? s.label}
249
- value={formatValue(Number(d[s.key]))}
257
+ value={shown(d[s.key])}
250
258
  color={s.color}
251
259
  />
252
260
  {/each}
@@ -36,4 +36,5 @@ export {
36
36
  type Smoothing,
37
37
  type SeriesKind,
38
38
  type SmoothMode,
39
+ type Smoothed,
39
40
  } from "./smoothing.js";
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { averageTo, smoothSeries } from "./smoothing.js";
3
+
4
+ type Row = { date: string; value: number | null };
5
+
6
+ /** `values.length` days ending 2026-10-31, oldest first. */
7
+ function days(values: (number | null)[]): Row[] {
8
+ const end = Date.UTC(2026, 9, 31);
9
+ return values.map((value, i) => ({
10
+ date: new Date(end - (values.length - 1 - i) * 86_400_000).toISOString().slice(0, 10),
11
+ value,
12
+ }));
13
+ }
14
+
15
+ const smooth = (rows: Row[], rangeDays: number, kind: "count" | "level" = "count") =>
16
+ smoothSeries(rows, "date", ["value"], { rangeDays, kind });
17
+
18
+ describe("smoothSeries, a missing day", () => {
19
+ it("is left out of the 7-day average, not counted as 0", () => {
20
+ const rows = days([10, 10, 10, 10, null, 10, 10, 10, 10, 10]);
21
+ const values = smooth(rows, 90).points.map((p) => p.value);
22
+ expect(values.every((v) => v === 10)).toBe(true);
23
+ });
24
+
25
+ it("draws a gap where most of the window was never measured", () => {
26
+ // A 12-day outage, then one partial day: the partial day alone is not a week's average.
27
+ const rows = days([100, 100, 100, 100, 100, 100, 100, ...Array(12).fill(null), 5, 100, 100]);
28
+ const values = smooth(rows, 90).points.map((p) => p.value);
29
+ expect(values.slice(12, 19)).toEqual(Array(7).fill(null));
30
+ expect(values.some((v) => v === 0)).toBe(false);
31
+ });
32
+
33
+ it("before a series' first measured day is a gap that drags nothing down", () => {
34
+ const rows = days([null, null, null, null, null, 40, 40, 40, 40, 40]);
35
+ const values = smooth(rows, 90).points.map((p) => p.value);
36
+ expect(values.slice(0, 5)).toEqual([null, null, null, null, null]);
37
+ expect(values.slice(5)).toEqual([40, 40, 40, 40, 40]);
38
+ });
39
+
40
+ it("still counts a row absent from a sparse series as 0", () => {
41
+ const rows = days(Array(20).fill(7)).filter((_, i) => i % 2 === 0);
42
+ const tail = smooth(rows, 90).points.at(-1)?.value;
43
+ // Four sales of 7 in the 7 days to the last row: 28 over 7 days, not over the 4 rows.
44
+ expect(tail).toBe(4);
45
+ });
46
+
47
+ it("is a gap in a level's weekly reading, never a 0", () => {
48
+ const rows = days([...Array(14).fill(500), ...Array(7).fill(null)]);
49
+ const values = smooth(rows, 365, "level").points.map((p) => p.value);
50
+ expect(values).toEqual([500, 500, null]);
51
+ });
52
+ });
53
+
54
+ describe("smoothSeries over a year", () => {
55
+ it("draws a 28-day average per day, one point a week ending on the last day", () => {
56
+ const rows = days(Array(70).fill(50));
57
+ const { points, mode, window } = smooth(rows, 365);
58
+ expect(mode).toBe("rolling");
59
+ expect(window).toBe(28);
60
+ expect(points.at(-1)?.date).toBe("2026-10-31");
61
+ expect(points.map((p) => p.date)).toHaveLength(10);
62
+ expect(points.every((p) => p.value === 50)).toBe(true);
63
+ });
64
+
65
+ it("flattens a one-day spike into a small bump", () => {
66
+ // The 2 Mar 2026 shape: 1,225 visitors on a day that ran about 60.
67
+ const rows = days([...Array(40).fill(60), 1225, ...Array(29).fill(60)]);
68
+ const peak = Math.max(...smooth(rows, 365).points.map((p) => Number(p.value)));
69
+ expect(peak).toBeLessThan(60 + (1225 - 60) / 28 + 1);
70
+ });
71
+
72
+ it("keeps a real climb", () => {
73
+ const climb = Array.from({ length: 112 }, (_, i) => 100 + i * 2);
74
+ const values = smooth(days(climb), 365).points.map((p) => Number(p.value));
75
+ for (let i = 1; i < values.length; i++) expect(values[i]).toBeGreaterThan(values[i - 1]);
76
+ });
77
+
78
+ it("names a point by its window", () => {
79
+ expect(averageTo("Oct 31", 28)).toBe("28-day average to Oct 31");
80
+ expect(averageTo("Oct 31")).toBe("7-day average to Oct 31");
81
+ });
82
+ });