zz-meridian 0.31.0 → 0.31.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/migrations.js +14 -0
- package/package.json +1 -1
- package/payload/CHANGELOG.md +17 -0
- package/payload/package.json +1 -1
- package/payload/scripts/check.ts +1 -1
- package/payload/skills/zz-meridian/references/live.md +29 -0
- package/payload/skills/zz-meridian/references/update.md +1 -0
- package/payload/src/components/charts/column-chart/index.tsx +67 -28
- package/payload/src/components/charts/trend-chart/index.tsx +5 -2
- package/payload/src/components/charts/use-settled.ts +19 -0
- package/payload/src/data/live-stream.ts +29 -0
- package/payload/src/lib/live.ts +11 -0
- package/payload/tests/live-client.test.tsx +19 -0
package/dist/migrations.js
CHANGED
|
@@ -556,7 +556,21 @@ export const RELEASE_MIGRATIONS = [
|
|
|
556
556
|
instructions: 'The release\'s `app/layout.tsx` shows `app.description` as the document\'s description — the line a search engine and a link preview put under the name — where it used to write a sentence of the template\'s own. `src/app.config.ts` is yours and an update does not touch it, so the key is missing there until you add it. Add `description: \'<one sentence about your product>\',` to the `app` object beside `name`. A project that keeps its own `app/layout.tsx` needs the key all the same, because the layout a project brings over from the release reads it.',
|
|
557
557
|
checks: ['gate'],
|
|
558
558
|
},
|
|
559
|
+
{
|
|
560
|
+
id: 'live-stream-shutdown',
|
|
561
|
+
since: '0.31.1',
|
|
562
|
+
applies: liveStreamKeptOnStop,
|
|
563
|
+
summary: 'The live stream ends every open stream when the server is told to stop, so tabs reconnect to the server that replaces it.',
|
|
564
|
+
instructions: '`src/data/live-stream.ts` is yours, so an update does not touch it, and the copy you have never ends a stream. A server told to stop (`next start` on SIGTERM or SIGINT, as a deploy or a restart does) closes its port and waits for its open connections: the old process stays up holding every tab\'s stream while the new one serves the port, so no tab hears a change hint again and every change waits for its next safety poll. Bring the block that begins `Every stream still open` over from `references/live.md` (the `open` set, `endLiveStreams` and `endOnShutdown`), call `open.delete(close)` first in the stream\'s `close`, and `endOnShutdown(close)` after the stream listens for its abort. A project that serves its stream some other way ends its streams on the signal the same way.',
|
|
565
|
+
checks: ['gate'],
|
|
566
|
+
},
|
|
559
567
|
];
|
|
568
|
+
/** 0.31.1: a live stream starter that does not end its streams when the server is told to stop. */
|
|
569
|
+
function liveStreamKeptOnStop(root) {
|
|
570
|
+
const rel = 'src/data/live-stream.ts';
|
|
571
|
+
const body = text(root, rel);
|
|
572
|
+
return body !== null && /export function liveStream\b/.test(body) && !/endLiveStreamsOnSignal/.test(body) ? [rel] : null;
|
|
573
|
+
}
|
|
560
574
|
/** The `app` object of a project's configuration, as `src/app.config.ts` writes it. */
|
|
561
575
|
const APP_OBJECT = /^export const app = \{[\s\S]*?^\} as const;$/m;
|
|
562
576
|
/** 0.30.0: the release's layout reads `app.description`, and a project's own configuration has no such key. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.2",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
|
|
4
4
|
|
|
5
|
+
## [0.31.2] · 2026-10-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Home and Revenue no longer drop a frame when the period changes to 7D on a slow phone** (the known issue of 0.31.0). At 7D the year is 52 windows, drawn in two column charts and a trend, and the tap committed about a thousand elements in one frame: on the weekly runner's phone its longest gap was 83 ms against the 50 ms floor. Measured at the emulated phone's rate times 1.6, which reproduces the runner, the gap is now 50 ms in six of six runs (style and layout 31 to 36 ms, from 63 to 72).
|
|
10
|
+
- **A dense column chart draws a finished column as one element.** Past 30 columns, a plain chart's finished column is the `[data-column]` element alone, its bar the element's `::before` with the same share of the scale, rounded top and 2px floor, and the row of columns grows as one instead of a column at a time. A running column, a column with no data, a stacked or a signed chart is drawn as before. A finished plain column of any chart is its own fill, one element fewer.
|
|
11
|
+
- **A chart's data table and axis labels follow its marks a frame later when the period changes.** ColumnChart's and TrendChart's `sr-only` table, and ColumnChart's `aria-hidden` axis labels, render one frame after the set of columns or dates changes; the first render has them at once, and the label row keeps its height for that frame.
|
|
12
|
+
|
|
13
|
+
## [0.31.1] · 2026-10-27
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **A server that is stopped lets go of its live streams.** `next start` told to stop (SIGTERM or SIGINT, as a deploy or a restart does) closes its port and then waits for its open connections, and a live stream never ended of itself. So the old process stayed up holding every tab's stream while the new one served the port: no tab heard a change hint again, and every change waited for the tab's next safety poll (the weekly live check: `a server restart: B did not show a write made after the restart within 5000 ms`). `liveStream` now ends every open stream when the process is told to stop; each tab reconnects to the new server within about a second and hears hints again (a write after a restart reached the other tab in 540 ms, where it took 2.9 s). Where nothing else listens for the signal, the process still stops as it did.
|
|
18
|
+
- **What a team does.** `src/data/live-stream.ts` is yours, so an update does not replace it: run `update`, which reports `migration:live-stream-shutdown` for a copy that never ends its streams, and bring the block that begins `Every stream still open` over from `references/live.md`.
|
|
19
|
+
- **Doing nothing.** Nothing fails; a deploy or restart keeps the old server up while a tab is open, and changes reach other tabs only at their next poll until they reload.
|
|
20
|
+
- **A live tab tries its stream again as soon as the server answers after it did not.** A tab that had given its stream up for polling tried it again only on every fifth poll. A poll that answers after one that failed (the server is back) now opens the stream at once, so hints resume; a stream a proxy blocks, whose polls never fail, is still tried on every fifth poll.
|
|
21
|
+
|
|
5
22
|
## [0.31.0] · 2026-10-26
|
|
6
23
|
|
|
7
24
|
### New
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.2",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "ZZ Meridian: a dashboard design system and starter. Six layers, from DTCG tokens through four layers of React components to product workspaces, and a Design Atlas, in a light and a dark theme.",
|
package/payload/scripts/check.ts
CHANGED
|
@@ -81,7 +81,7 @@ function walk(dir: string, ext: RegExp, out: string[] = []) {
|
|
|
81
81
|
const css = read('src/styles/tokens.css') + read('src/styles/base.css') + read('src/styles/motion.css');
|
|
82
82
|
const defined = new Set([...css.matchAll(/--([a-z0-9-]+)\s*:/g)].map((m) => m[1]));
|
|
83
83
|
/** Local custom properties a component sets for itself, and the host's bridged variables. */
|
|
84
|
-
const LOCAL = /^(i|w|m-len|cols(-(xl|lg|md|sm))?|readout-(cursor|width)|safe-(top|right|bottom|left)|font-face-(sans|mono)|color-[a-z-]+|border-radius-[a-z]+|tw-.*|radix-.*)$/;
|
|
84
|
+
const LOCAL = /^(i|w|m-len|bar-(h|at)|cols(-(xl|lg|md|sm))?|readout-(cursor|width)|safe-(top|right|bottom|left)|font-face-(sans|mono)|color-[a-z-]+|border-radius-[a-z]+|tw-.*|radix-.*)$/;
|
|
85
85
|
|
|
86
86
|
// ── Cards ────────────────────────────────────────────────────────────────────────────────────────────
|
|
87
87
|
const CARD_SECTIONS = ['## Surfaces', '## Agents', '## Accessibility'];
|
|
@@ -115,6 +115,33 @@ const MAX_QUEUED = 100;
|
|
|
115
115
|
|
|
116
116
|
let noted = false;
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Every stream still open. A server told to stop (`next start` on SIGTERM or SIGINT) closes its port and then waits for
|
|
120
|
+
* its open connections, and a stream never ends of itself: the old process kept every tab's stream while the new one
|
|
121
|
+
* served the port, so no tab heard a hint again until its next poll. Ending them lets the old process go and each tab
|
|
122
|
+
* connect to the new one.
|
|
123
|
+
*/
|
|
124
|
+
const open = new Set<() => void>();
|
|
125
|
+
let watching = false;
|
|
126
|
+
|
|
127
|
+
/** Ends every open stream; each tab reconnects (the stream's `retry`), to whichever server then holds the port. */
|
|
128
|
+
function endLiveStreams() {
|
|
129
|
+
for (const close of [...open]) close();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function endOnShutdown(close: () => void) {
|
|
133
|
+
open.add(close);
|
|
134
|
+
if (watching || typeof process === 'undefined' || typeof process.once !== 'function') return;
|
|
135
|
+
watching = true;
|
|
136
|
+
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
|
137
|
+
process.once(sig, function endLiveStreamsOnSignal() {
|
|
138
|
+
endLiveStreams();
|
|
139
|
+
// A listener takes the place of the signal's default: where nothing else listens, the process stops as it would have.
|
|
140
|
+
if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
type LiveStreamOptions = {
|
|
119
146
|
/** The requested names, already authorized once; the first frame greets with what moved. */
|
|
120
147
|
names: string[];
|
|
@@ -141,6 +168,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
|
|
|
141
168
|
function close() {
|
|
142
169
|
if (closed) return;
|
|
143
170
|
closed = true;
|
|
171
|
+
open.delete(close);
|
|
144
172
|
clearInterval(timer);
|
|
145
173
|
for (const off of unsubscribe) off();
|
|
146
174
|
unsubscribe.length = 0;
|
|
@@ -195,6 +223,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
|
|
|
195
223
|
controller = c;
|
|
196
224
|
if (signal.aborted) return close();
|
|
197
225
|
signal.addEventListener('abort', close);
|
|
226
|
+
endOnShutdown(close);
|
|
198
227
|
// Subscribe first, then compare: a write after the page's read either moves a revision the greeting names or
|
|
199
228
|
// arrives as a hint.
|
|
200
229
|
collections.forEach((collection, i) => {
|
|
@@ -191,6 +191,7 @@ Moving a project to 0.9.0 can report two more `migration:<id>` items, to 0.10.0
|
|
|
191
191
|
| `no-data-mark` (0.19.0) | a test of the team's importing the Column chart and expecting a `.bg-fill-track` in a period with no data, such as `optional:tests/column-chart-no-data.test.tsx` as the template wrote it | a `null` period draws a dashed hairline on the baseline since 0.19.0, not a track the height of the scale, which was the tallest mark on the chart: bring `optional:tests/column-chart-no-data.test.tsx` over from the release as it has it, and find `[data-no-data-mark]` where a test of your own found `.bg-fill-track` or `[data-track]`; checked by the gate |
|
|
192
192
|
| `table-grid` (0.26.0) | a file of the team's importing the Table or the DataTable that expects a `grow` column's `@min-[672px]:min-w-64`, such as `optional:tests/table-cut-cell.test.tsx` as the template wrote it, or passes a `colSpan` to a `TableCell` | a table is a grid since 0.26.0, its spare shared equally between the gaps, and a `grow` column's floor is `min-w-40` at every width: bring `optional:tests/table-cut-cell.test.tsx` over from the release as it has it, expect `min-w-40` alone where a test of your own expected the wider floor, and draw a row that spans the table, such as an empty state, outside it or as a `DataTable` `empty`, since a cell takes one track; checked by the gate |
|
|
193
193
|
| `four-layers` (0.28.0) | a file of the team's importing a module that has left `optional:src/views/` or `optional:src/data/` for `src/logic/` — the migration names the file and, in its instruction, every path the module left — or importing `afterStart` from `src/components/patterns/incident-card` or `summarise` from `src/components/patterns/status-list`, which no longer export them | thirty-eight modules are in `src/logic/` since 0.28.0, thirty-six of them from `optional:src/views/`, and each import is rewritten to the path the module has: `afterStart` is `src/logic/after-start.ts` and `summarise` is `src/logic/summarise.ts`, and those two must move because the cards are Meridian's and the update replaces them, while every path under `optional:src/views/` and `optional:src/data/` is the team's and stays as it is; a created project brings the release's `src/logic/` over with the pages that import it; the standard the layers come from is `data-layers.md`; checked by the gate and a build |
|
|
194
|
+
| `live-stream-shutdown` (0.31.1) | `optional:src/data/live-stream.ts` that never ends its streams when the server is told to stop | a stopped `next start` closes its port and waits for its open connections, so the old process kept every tab's stream while the new one served the port and no tab heard a hint again until its next poll: bring the block that begins `Every stream still open` over from `live.md`, delete the stream from the open set first in its `close`, and register it after it listens for its abort; checked by the gate |
|
|
194
195
|
|
|
195
196
|
A created project that never changed the template's pages brings the release's version of each affected file over, with
|
|
196
197
|
the files that arrived with them: the view contexts `optional:src/logic/home-context.ts` and its siblings, the view
|
|
@@ -5,6 +5,7 @@ import { cn } from '@/lib/cn';
|
|
|
5
5
|
import { useMeridian } from '@/components/charts/meridian';
|
|
6
6
|
import { Readout } from '@/components/charts/readout';
|
|
7
7
|
import { niceTicks, SERIES_VAR, stackPart, ticksBetween } from '@/components/charts/scale';
|
|
8
|
+
import { useSettled } from '@/components/charts/use-settled';
|
|
8
9
|
import { useSize } from '@/components/charts/use-size';
|
|
9
10
|
import { columnFold } from '@/logic/derivations';
|
|
10
11
|
|
|
@@ -58,6 +59,12 @@ export type ColumnChartProps = Shared & (
|
|
|
58
59
|
* narrowest column that keeps its value at rest.
|
|
59
60
|
*/
|
|
60
61
|
const GAP = 2, THIN = 1, REST = 36;
|
|
62
|
+
/**
|
|
63
|
+
* Past this many columns a plain chart draws each finished column as one element, its bar the element's own `::before`,
|
|
64
|
+
* and grows them as one row: a year of 52 windows at 7D, three such charts on Home, committed about a thousand
|
|
65
|
+
* elements in the frame of the period tap and dropped it on a mid-range phone.
|
|
66
|
+
*/
|
|
67
|
+
const DENSE = 30;
|
|
61
68
|
/** About the width of one character of a value at rest (`text-2xs`), in px: enough to tell whether a label fits its column. */
|
|
62
69
|
const CH = 6.5;
|
|
63
70
|
/**
|
|
@@ -153,6 +160,8 @@ export function ColumnChart({
|
|
|
153
160
|
const set = (i: number | null) => { (timed ? meridian.setIndex : setOwn)(i); setLocal(i !== null); };
|
|
154
161
|
const fill = height === 'fill';
|
|
155
162
|
const soFar = running ? n - 1 : -1;
|
|
163
|
+
// The axis labels and the table follow the columns a frame later when the set of them changes (`useSettled`).
|
|
164
|
+
const settled = useSettled(bars.map((b) => b.key).join('\n'));
|
|
156
165
|
|
|
157
166
|
// A period with no data has no height, so the scale is the periods that have one. Plain, a value under zero is a
|
|
158
167
|
// fall: the scale goes under zero for it and the column hangs from the zero line (#78). Stacked, parts make a whole.
|
|
@@ -175,6 +184,7 @@ export function ColumnChart({
|
|
|
175
184
|
// A column wide enough for a figure keeps its value above it at rest; a narrower one shows it when pointed at.
|
|
176
185
|
const column = n ? (width - left - gap * (n - 1)) / n : 0;
|
|
177
186
|
const rest = column >= REST;
|
|
187
|
+
const dense = n > DENSE && !signed;
|
|
178
188
|
// Whatever is centred on a column and may be wider than it starts with the first column and ends with the last, so
|
|
179
189
|
// nothing runs past the plot; inside those, a narrower one is still centred.
|
|
180
190
|
/** A value as it reads; a period with no data reads the product's word, and "so far" with it while it is running. */
|
|
@@ -231,7 +241,7 @@ export function ColumnChart({
|
|
|
231
241
|
<span className={cn('h-px flex-1', t === 0 ? 'bg-chart-axis' : 'bg-chart-grid')} />
|
|
232
242
|
</div>
|
|
233
243
|
))}
|
|
234
|
-
<div className=
|
|
244
|
+
<div className={cn('absolute inset-y-0 right-0 flex', dense && 'grow-y')} style={{ left, columnGap: gapOf(n) }}>
|
|
235
245
|
{bars.map((b, i) => {
|
|
236
246
|
const pointed = active === i;
|
|
237
247
|
const hot = pointed || (active === null && b.key === highlight);
|
|
@@ -245,6 +255,49 @@ export function ColumnChart({
|
|
|
245
255
|
// never "No data…" cut mid-word; the tooltip, the readout, the announcement and the table say it whole (#71).
|
|
246
256
|
const full = totalOf(b, i);
|
|
247
257
|
const text = !tip && b.none && unfinished && full.length * CH > column ? 'So far' : full;
|
|
258
|
+
const plainFill = !b.none && !drawn && !unfinished;
|
|
259
|
+
const valueLabel = rest || pointed ? (
|
|
260
|
+
// One element at rest and pointed at, so its entrance never replays. At rest the value is held to its
|
|
261
|
+
// column; the tooltip, and a total shown only on pointing, may be wider than theirs.
|
|
262
|
+
<span aria-hidden className={cn('pointer-events-none absolute', fall ? 'top-full mt-1' : 'bottom-full mb-1', rest && 'fade-in', tip || !rest ? cn('z-10', placed(i)) : 'inset-x-0')}>
|
|
263
|
+
<span
|
|
264
|
+
className={cn(
|
|
265
|
+
't-num block text-2xs leading-4',
|
|
266
|
+
tip
|
|
267
|
+
? 'rounded-sm bg-surface-inverse px-1.5 py-1 font-medium whitespace-nowrap text-ink-inverse shadow-overlay'
|
|
268
|
+
: rest
|
|
269
|
+
? cn('truncate text-center', pointed ? 'font-medium text-ink' : 'text-ink-3')
|
|
270
|
+
: 'font-medium whitespace-nowrap text-ink',
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
<span data-value>{text}</span>
|
|
274
|
+
{tip && unfinished && !b.none ? ' · so far' : null}
|
|
275
|
+
</span>
|
|
276
|
+
</span>
|
|
277
|
+
) : null;
|
|
278
|
+
const share = `${(Math.abs(b.total) / (top - low)) * 100}%`;
|
|
279
|
+
if (dense && plainFill) {
|
|
280
|
+
// One element: the bar is its ::before, its share of the scale and never under 2px; the value, when it shows,
|
|
281
|
+
// stands on a box of the bar's height.
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
key={b.key}
|
|
285
|
+
data-column={b.key}
|
|
286
|
+
data-active={pointed ? '' : undefined}
|
|
287
|
+
title={[rest && text.length * 7 > column ? text : null, b.key === highlight ? highlightLabel : null].filter(Boolean).join(' · ') || undefined}
|
|
288
|
+
className={cn(
|
|
289
|
+
'group/col relative h-full min-w-0 flex-1 before:absolute before:inset-x-0 before:bottom-(--bar-at) before:h-(--bar-h) before:rounded-t-[4px] before:transition-colors before:duration-(--dur-hover)',
|
|
290
|
+
b.total !== 0 && 'before:min-h-[2px]',
|
|
291
|
+
hot ? 'before:bg-accent' : 'before:bg-chart-neutral hover:before:bg-chart-neutral-strong',
|
|
292
|
+
)}
|
|
293
|
+
style={{ ['--bar-h' as string]: share, ['--bar-at' as string]: `${at100(0)}%` }}
|
|
294
|
+
onPointerEnter={() => set(i)}
|
|
295
|
+
onPointerDown={() => set(i)}
|
|
296
|
+
>
|
|
297
|
+
{valueLabel ? <div className={cn('absolute inset-x-0', b.total !== 0 && 'min-h-[2px]')} style={{ bottom: `${at100(0)}%`, height: share }}>{valueLabel}</div> : null}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
248
301
|
return (
|
|
249
302
|
<div
|
|
250
303
|
key={b.key}
|
|
@@ -269,14 +322,17 @@ export function ColumnChart({
|
|
|
269
322
|
className="absolute inset-x-0"
|
|
270
323
|
style={{
|
|
271
324
|
...(fall ? { top: `${100 - at100(0)}%` } : { bottom: `${at100(0)}%` }),
|
|
272
|
-
height: b.none ? '0%' :
|
|
325
|
+
height: b.none ? '0%' : share,
|
|
273
326
|
minHeight: (drawn ? Math.max(0, shown.length * 4 - 1) : b.total !== 0 ? 2 : 0) + (unfinished ? 2 : 0),
|
|
274
327
|
}}
|
|
275
328
|
>
|
|
276
329
|
<div
|
|
277
330
|
className={cn(
|
|
278
|
-
|
|
331
|
+
// A dense chart grows its row as one; a column of its own grows only in a chart that is not.
|
|
332
|
+
!dense && 'grow-y', 'relative h-full',
|
|
279
333
|
fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]',
|
|
334
|
+
// A finished plain column is its own fill: one element fewer a column, in a chart of 52 of them.
|
|
335
|
+
plainFill && cn('w-full transition-colors duration-(--dur-hover)', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/col:bg-chart-neutral-strong'),
|
|
280
336
|
drawn && 'transition-[box-shadow] duration-(--dur-hover)',
|
|
281
337
|
drawn && hot && 'z-1 ring-2 ring-ink',
|
|
282
338
|
// A period still running: a dashed outline, open at the baseline, around a fill at half strength. The
|
|
@@ -304,29 +360,11 @@ export function ColumnChart({
|
|
|
304
360
|
/>
|
|
305
361
|
))}
|
|
306
362
|
</div>
|
|
307
|
-
) : (
|
|
308
|
-
<div className={cn('h-full w-full transition-colors duration-(--dur-hover)', fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/col:bg-chart-neutral-strong',
|
|
363
|
+
) : plainFill ? null : (
|
|
364
|
+
<div className={cn('h-full w-full transition-colors duration-(--dur-hover)', fall ? 'rounded-b-[4px]' : 'rounded-t-[4px]', hot ? 'bg-accent' : 'bg-chart-neutral group-hover/col:bg-chart-neutral-strong', 'opacity-50')} />
|
|
309
365
|
)}
|
|
310
366
|
</div>
|
|
311
|
-
{
|
|
312
|
-
// One element at rest and pointed at, so its entrance never replays. At rest the value is held to its
|
|
313
|
-
// column; the tooltip, and a total shown only on pointing, may be wider than theirs.
|
|
314
|
-
<span aria-hidden className={cn('pointer-events-none absolute', fall ? 'top-full mt-1' : 'bottom-full mb-1', rest && 'fade-in', tip || !rest ? cn('z-10', placed(i)) : 'inset-x-0')}>
|
|
315
|
-
<span
|
|
316
|
-
className={cn(
|
|
317
|
-
't-num block text-2xs leading-4',
|
|
318
|
-
tip
|
|
319
|
-
? 'rounded-sm bg-surface-inverse px-1.5 py-1 font-medium whitespace-nowrap text-ink-inverse shadow-overlay'
|
|
320
|
-
: rest
|
|
321
|
-
? cn('truncate text-center', pointed ? 'font-medium text-ink' : 'text-ink-3')
|
|
322
|
-
: 'font-medium whitespace-nowrap text-ink',
|
|
323
|
-
)}
|
|
324
|
-
>
|
|
325
|
-
<span data-value>{text}</span>
|
|
326
|
-
{tip && unfinished && !b.none ? ' · so far' : null}
|
|
327
|
-
</span>
|
|
328
|
-
</span>
|
|
329
|
-
) : null}
|
|
367
|
+
{valueLabel}
|
|
330
368
|
</div>
|
|
331
369
|
</div>
|
|
332
370
|
);
|
|
@@ -351,8 +389,9 @@ export function ColumnChart({
|
|
|
351
389
|
</Readout>
|
|
352
390
|
) : null}
|
|
353
391
|
</div>
|
|
354
|
-
|
|
355
|
-
|
|
392
|
+
{/* The row keeps its height for the frame its labels wait, so nothing under it moves. */}
|
|
393
|
+
<div aria-hidden className="mt-2 flex min-h-4 text-2xs text-ink-3" style={{ paddingLeft: left, columnGap: gapOf(n) }}>
|
|
394
|
+
{settled && bars.map((b, i) => (
|
|
356
395
|
<span key={b.key} className="relative h-4 min-w-0 flex-1">
|
|
357
396
|
{active === i ? (
|
|
358
397
|
<span className={cn('absolute z-10', placed(i))}><span className="rounded-xs bg-surface px-1 whitespace-nowrap text-ink">{b.label}</span></span>
|
|
@@ -365,7 +404,7 @@ export function ColumnChart({
|
|
|
365
404
|
))}
|
|
366
405
|
</div>
|
|
367
406
|
<p className="sr-only" aria-live="polite">{reading}</p>
|
|
368
|
-
<table className="sr-only">
|
|
407
|
+
{settled ? <table className="sr-only">
|
|
369
408
|
<caption>{label}{highlight && highlightLabel ? `. Accented: ${highlightLabel}` : ''}</caption>
|
|
370
409
|
{drawn ? <thead><tr><th scope="col">Period</th>{drawn.map((p) => <th key={p.key} scope="col">{p.label}</th>)}<th scope="col">Total</th></tr></thead> : null}
|
|
371
410
|
<tbody>
|
|
@@ -377,7 +416,7 @@ export function ColumnChart({
|
|
|
377
416
|
</tr>
|
|
378
417
|
))}
|
|
379
418
|
</tbody>
|
|
380
|
-
</table>
|
|
419
|
+
</table> : null}
|
|
381
420
|
</figure>
|
|
382
421
|
);
|
|
383
422
|
}
|
|
@@ -7,6 +7,7 @@ import { formatDate } from '@/lib/format-date';
|
|
|
7
7
|
import { useMeridian } from '@/components/charts/meridian';
|
|
8
8
|
import { Readout } from '@/components/charts/readout';
|
|
9
9
|
import { monotonePath, ticksBetween, SERIES_VAR, stackPart } from '@/components/charts/scale';
|
|
10
|
+
import { useSettled } from '@/components/charts/use-settled';
|
|
10
11
|
import { useSize } from '@/components/charts/use-size';
|
|
11
12
|
import { trendTotals } from '@/logic/derivations';
|
|
12
13
|
|
|
@@ -205,6 +206,8 @@ export function TrendChart({
|
|
|
205
206
|
const n = dates.length;
|
|
206
207
|
// The point still running, if there is one: everything solid stops at the point before it.
|
|
207
208
|
const soFar = running ? n - 1 : -1;
|
|
209
|
+
// The table follows the line a frame later when the dates change (`useSettled`).
|
|
210
|
+
const settled = useSettled(dates.join('\n'));
|
|
208
211
|
const x = (i: number) => pad.l + (n <= 1 ? W / 2 : (i / (n - 1)) * W);
|
|
209
212
|
const y = (v: number) => pad.t + H - ((v - low) / (top - low)) * H;
|
|
210
213
|
|
|
@@ -474,11 +477,11 @@ export function TrendChart({
|
|
|
474
477
|
) : null}
|
|
475
478
|
</Readout>
|
|
476
479
|
) : null}
|
|
477
|
-
<table className="sr-only">
|
|
480
|
+
{settled ? <table className="sr-only">
|
|
478
481
|
<caption>{label}{band ? `. ${band.label}` : ''}{marks ? `. Marked: ${marks.label}` : ''}{baseline ? `. ${baseline.label}` : ''}{running ? `. ${SO_FAR}` : ''}</caption>
|
|
479
482
|
<thead><tr><th>Date</th>{series.map((s) => <th key={s.key}>{s.label}</th>)}{totals ? <th>Total</th> : null}</tr></thead>
|
|
480
483
|
<tbody>{dates.map((d, i) => <tr key={d}><td>{d}{i === soFar ? ', so far' : null}</td>{series.map((s) => <td key={s.key}>{fmt(s.values[i])}</td>)}{totals ? <td>{fmt(totals[i])}</td> : null}</tr>)}</tbody>
|
|
481
|
-
</table>
|
|
484
|
+
</table> : null}
|
|
482
485
|
</div>
|
|
483
486
|
);
|
|
484
487
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether `key` was already the chart's at the last frame: false for the one frame after it changes, true on the first
|
|
7
|
+
* render and ever after. A chart draws its marks at once and its data table, which nobody sees, a frame later, so a
|
|
8
|
+
* change of period commits the marks without the table's rows: Home's year at 7D, three charts of 52 windows, dropped a
|
|
9
|
+
* frame on a mid-range phone when every row came in the same frame as the columns.
|
|
10
|
+
*/
|
|
11
|
+
export function useSettled(key: string): boolean {
|
|
12
|
+
const [settled, setSettled] = useState(key);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (settled === key) return;
|
|
15
|
+
const id = requestAnimationFrame(() => setSettled(key));
|
|
16
|
+
return () => cancelAnimationFrame(id);
|
|
17
|
+
}, [key, settled]);
|
|
18
|
+
return settled === key;
|
|
19
|
+
}
|
|
@@ -10,6 +10,33 @@ const MAX_QUEUED = 100;
|
|
|
10
10
|
|
|
11
11
|
let noted = false;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Every stream still open. A server told to stop (`next start` on SIGTERM or SIGINT) closes its port and then waits for
|
|
15
|
+
* its open connections, and a stream never ends of itself: the old process kept every tab's stream while the new one
|
|
16
|
+
* served the port, so no tab heard a hint again until its next poll. Ending them lets the old process go and each tab
|
|
17
|
+
* connect to the new one.
|
|
18
|
+
*/
|
|
19
|
+
const open = new Set<() => void>();
|
|
20
|
+
let watching = false;
|
|
21
|
+
|
|
22
|
+
/** Ends every open stream; each tab reconnects (the stream's `retry`), to whichever server then holds the port. */
|
|
23
|
+
function endLiveStreams() {
|
|
24
|
+
for (const close of [...open]) close();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function endOnShutdown(close: () => void) {
|
|
28
|
+
open.add(close);
|
|
29
|
+
if (watching || typeof process === 'undefined' || typeof process.once !== 'function') return;
|
|
30
|
+
watching = true;
|
|
31
|
+
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
|
32
|
+
process.once(sig, function endLiveStreamsOnSignal() {
|
|
33
|
+
endLiveStreams();
|
|
34
|
+
// A listener takes the place of the signal's default: where nothing else listens, the process stops as it would have.
|
|
35
|
+
if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
13
40
|
type LiveStreamOptions = {
|
|
14
41
|
/** The requested names, already authorized once; the first frame greets with what moved. */
|
|
15
42
|
names: string[];
|
|
@@ -36,6 +63,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
|
|
|
36
63
|
function close() {
|
|
37
64
|
if (closed) return;
|
|
38
65
|
closed = true;
|
|
66
|
+
open.delete(close);
|
|
39
67
|
clearInterval(timer);
|
|
40
68
|
for (const off of unsubscribe) off();
|
|
41
69
|
unsubscribe.length = 0;
|
|
@@ -90,6 +118,7 @@ export function liveStream({ names, collections, signal, heartbeatMs = 15000, au
|
|
|
90
118
|
controller = c;
|
|
91
119
|
if (signal.aborted) return close();
|
|
92
120
|
signal.addEventListener('abort', close);
|
|
121
|
+
endOnShutdown(close);
|
|
93
122
|
// Subscribe first, then compare: a write after the page's read either moves a revision the greeting names or
|
|
94
123
|
// arrives as a hint.
|
|
95
124
|
collections.forEach((collection, i) => {
|
package/payload/src/lib/live.ts
CHANGED
|
@@ -56,6 +56,8 @@ export function createLiveClient({ refresh, endpoint = '/api/live', pollMs = 300
|
|
|
56
56
|
let disposed = false;
|
|
57
57
|
let paused = false;
|
|
58
58
|
let polling = false;
|
|
59
|
+
/** The last refresh failed for a reason other than access: the server did not answer. */
|
|
60
|
+
let unreachable = false;
|
|
59
61
|
let state: LiveState | null = null;
|
|
60
62
|
/** Bumped whenever results in flight must stop counting: the page hid, or the client ended. */
|
|
61
63
|
let epoch = 0;
|
|
@@ -122,9 +124,18 @@ export function createLiveClient({ refresh, endpoint = '/api/live', pollMs = 300
|
|
|
122
124
|
if (full) synced = true;
|
|
123
125
|
if (synced && opened) emit('live');
|
|
124
126
|
else if (polling) emit('polling');
|
|
127
|
+
// A server that answers again after it did not (a restart, a deploy) is tried for its stream at once, not on the
|
|
128
|
+
// fifth poll: until then its hints are lost and every change waits for a poll. A refresh that never failed does
|
|
129
|
+
// not count, so a stream a proxy blocks is still tried only on every fifth poll.
|
|
130
|
+
if (unreachable && polling) {
|
|
131
|
+
attempts = MAX_ATTEMPTS - 1;
|
|
132
|
+
connect();
|
|
133
|
+
}
|
|
134
|
+
unreachable = false;
|
|
125
135
|
}
|
|
126
136
|
} catch (e) {
|
|
127
137
|
if (mine === epoch && !disposed) {
|
|
138
|
+
if (!isAuthError(e)) unreachable = true;
|
|
128
139
|
if (isAuthError(e)) {
|
|
129
140
|
paused = true;
|
|
130
141
|
closeSource();
|
|
@@ -158,6 +158,25 @@ describe('the live client', () => {
|
|
|
158
158
|
expect(FakeSource.all.length).toBe(opened + 1);
|
|
159
159
|
c.dispose();
|
|
160
160
|
});
|
|
161
|
+
it('tries the stream at once when a poll answers after one that did not, as a restarted server does', async () => {
|
|
162
|
+
let down = false;
|
|
163
|
+
const refresh = vi.fn(async () => { if (down) throw new Error('network'); });
|
|
164
|
+
const { c, states } = client(refresh, 1000);
|
|
165
|
+
c.setNames(['members']);
|
|
166
|
+
for (let i = 0; i < 3; i++) { live().at(-1)!.fail(); await flush(1100); }
|
|
167
|
+
expect(states.at(-1)).toBe('polling');
|
|
168
|
+
down = true;
|
|
169
|
+
await flush(1000);
|
|
170
|
+
const opened = FakeSource.all.length;
|
|
171
|
+
down = false;
|
|
172
|
+
await flush(1000);
|
|
173
|
+
// The poll that answered opened a stream; without the rule the next try would wait for the fifth poll.
|
|
174
|
+
expect(FakeSource.all.length).toBe(opened + 1);
|
|
175
|
+
live().at(-1)!.open();
|
|
176
|
+
await flush(600);
|
|
177
|
+
expect(states.at(-1)).toBe('live');
|
|
178
|
+
c.dispose();
|
|
179
|
+
});
|
|
161
180
|
it('pauses on an authorization failure and goes stale on any other', async () => {
|
|
162
181
|
const denied = client(async () => { throw Object.assign(new Error('no'), { status: 403 }); });
|
|
163
182
|
denied.c.setNames(['members']);
|