wiki-formant 0.41.0 → 0.43.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.
package/README.md CHANGED
@@ -731,7 +731,9 @@ return <Stats digest={await digest(sql, q)} {...q} />;
731
731
  `PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
732
732
  own subpath, because it imports the optional peer `@simplewebauthn/browser`.
733
733
  `Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
734
- headline figures against the window before, one of them per day, every non-empty
734
+ headline figures, each with its change from the window before
735
+ (`.stats-delta[data-better]`, a share or for the bounce rate points, for the
736
+ site's stylesheet to colour), one of them per day, every non-empty
735
737
  ranked list, and `?days=` links between windows, all time among them, pinned in
736
738
  the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
737
739
  figure is a button that charts it: visitors, page views, bounce rate, visit
@@ -773,8 +775,8 @@ monthly.
773
775
  ```
774
776
 
775
777
  A price point may carry `open`, `high` and `low` (its `value` is the close)
776
- and `volume`. Points that carry the candle get a Line/Candles toggle, opening on
777
- candles with `candles`; points that carry volume get a volume pane under the
778
+ and `volume`. Points that carry the candle get a candles switch, a candlestick icon
779
+ pressed while they show, opening on candles with `candles`; points that carry volume get a volume pane under the
778
780
  price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
779
781
  the canvas box, else its `color`. A week of candles opens on its first, closes
780
782
  on its last, spans the highest high and lowest low, and sums its volume. A point
@@ -113,8 +113,13 @@ export interface Digest {
113
113
  visit_duration: number | null;
114
114
  /** Visitors plus every agent that sent an event, such as an MCP call. */
115
115
  visitors_incl_agents: number;
116
- /** Visitors in the same number of days before; null when filtered. */
117
- previous_visitors: number | null;
116
+ /**
117
+ * The headline figures over the same number of days before, narrowed alike.
118
+ * A figure is null where that window reaches back past `counted_from` and
119
+ * the archive cannot stand in for it: event figures always, all of them
120
+ * when filtered.
121
+ */
122
+ previous: Record<'visitors' | 'pageviews' | 'bounce_rate' | 'visit_duration' | 'visitors_incl_agents' | 'follow_clicks', number | null>;
118
123
  /** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
119
124
  counted_from: string | null;
120
125
  top_sources: Ranked<'source', 'visitors'>;
@@ -140,6 +145,8 @@ export interface Digest {
140
145
  * another day counts once for each day. Days in `ViewDay` add to the visitor,
141
146
  * page, visit, source, country, device and per-day figures; events have no
142
147
  * history, so an archived day's visitors are its visitors with agents too.
148
+ * The window before is the same chain of views, visits and events a second
149
+ * time, `_p` on every name, so its figures are the headline's to the letter.
143
150
  */
144
151
  export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter }: DigestOptions): Promise<Digest>;
145
152
  /**
package/dist/analytics.js CHANGED
@@ -207,6 +207,8 @@ const VISIT_TEST = {
207
207
  * another day counts once for each day. Days in `ViewDay` add to the visitor,
208
208
  * page, visit, source, country, device and per-day figures; events have no
209
209
  * history, so an archived day's visitors are its visitors with agents too.
210
+ * The window before is the same chain of views, visits and events a second
211
+ * time, `_p` on every name, so its figures are the headline's to the letter.
210
212
  */
211
213
  export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {} }) {
212
214
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
@@ -226,48 +228,53 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
226
228
  WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
227
229
  GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
228
230
  });
229
- const [row] = (await sql(`WITH viewed AS (
230
- SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View"
231
- WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
232
- ), marked AS (
233
- SELECT *, CASE WHEN at - lag(at) OVER (PARTITION BY visitor ORDER BY at, id) <= interval '30 minutes' THEN 0 ELSE 1 END AS starts FROM viewed
234
- ), numbered AS (
235
- SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
236
- ), visits AS (
231
+ /**
232
+ * One window's views, visits, events and archived days as CTEs whose names
233
+ * end in `s`: this window with `back` 0, the one before it with 1.
234
+ */
235
+ const chain = (s, back) => {
236
+ const ago = (k) => `now() - make_interval(days => ${k} * $2::int)`;
237
+ const dayAgo = (k) => `(now() AT TIME ZONE 'UTC' - make_interval(days => ${k} * $2::int))::date`;
238
+ const at = `"createdAt" >= ${ago(back + 1)}${back ? ` AND "createdAt" < ${ago(back)}` : ''}`;
239
+ return `viewed${s} AS (
240
+ SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View" WHERE "siteId" = $1 AND ${at}
241
+ ), marked${s} AS (
242
+ SELECT *, CASE WHEN at - lag(at) OVER (PARTITION BY visitor ORDER BY at, id) <= interval '30 minutes' THEN 0 ELSE 1 END AS starts FROM viewed${s}
243
+ ), numbered${s} AS (
244
+ SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked${s}
245
+ ), visits${s} AS (
237
246
  SELECT visitor, visit, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
238
247
  (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
239
- FROM numbered GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
240
- ), v AS (
241
- ${tests.length ? 'SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.at FROM numbered n JOIN visits USING (visitor, visit)' : 'SELECT * FROM viewed'}
242
- ), e AS (
243
- SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
244
- ${tests.length ? 'AND visitor IN (SELECT visitor FROM v)' : ''}
245
- ), a AS (
246
- SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date${tests.length ? ' AND false' : ''}
247
- ), sessions AS (
248
+ FROM numbered${s} GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
249
+ ), v${s} AS (
250
+ ${tests.length ? `SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.at FROM numbered${s} n JOIN visits${s} USING (visitor, visit)` : `SELECT * FROM viewed${s}`}
251
+ ), e${s} AS (
252
+ SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
253
+ ${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
254
+ ), a${s} AS (
255
+ SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
256
+ ), sessions${s} AS (
248
257
  SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
249
- SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
250
- UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a
251
- ) s(bounces, visits, seconds)
252
- ), src AS (
258
+ SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits${s}
259
+ UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
260
+ ) t(bounces, visits, seconds)
261
+ ), archived${s} AS (
262
+ SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a${s}
263
+ )`;
264
+ };
265
+ /** One window's headline figures, as `json_build_object` arguments. */
266
+ const headline = (s) => `'visitors', (SELECT count(DISTINCT visitor) FROM v${s}) + (SELECT visitors FROM archived${s}),
267
+ 'pageviews', (SELECT count(*) FROM v${s}) + (SELECT pageviews FROM archived${s}),
268
+ 'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions${s}),
269
+ 'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions${s}),
270
+ 'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM e${s}) u) + (SELECT visitors FROM archived${s})`;
271
+ const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
253
272
  SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
254
273
  UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
255
- ), archived AS (
256
- SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a
257
274
  )
258
275
  SELECT json_build_object(
259
- 'visitors', (SELECT count(DISTINCT visitor) FROM v) + (SELECT visitors FROM archived),
260
- 'pageviews', (SELECT count(*) FROM v) + (SELECT pageviews FROM archived),
261
- 'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
262
- 'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
263
- 'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
264
- 'previous_visitors', ${tests.length
265
- ? 'NULL'
266
- : `(SELECT count(DISTINCT visitor) FROM "View"
267
- WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
268
- + (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
269
- AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
270
- AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date)`},
276
+ ${headline('')},
277
+ 'previous', json_build_object(${headline('_p')}, 'follow_clicks', ${follows ? `(SELECT count(*) FROM e_p WHERE ${follows})` : 'NULL'}),
271
278
  'counted_from', (SELECT ${day('min("createdAt")')} FROM "View" WHERE "siteId" = $1),
272
279
  'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
273
280
  'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
@@ -291,7 +298,19 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
291
298
  GROUP BY 1`, 10)},
292
299
  'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
293
300
  ) AS digest`, ...values));
294
- return row.digest;
301
+ const d = row.digest;
302
+ // Before the site counted itself only ViewDay speaks, and it holds no
303
+ // events and no visits a filter could pick out.
304
+ const before = new Date(Date.now() - 2 * values[1] * 86_400_000).toISOString().slice(0, 10);
305
+ if (d.counted_from && before < d.counted_from) {
306
+ const p = d.previous;
307
+ if (tests.length)
308
+ for (const k of Object.keys(p))
309
+ p[k] = null;
310
+ else
311
+ p.visitors_incl_agents = p.follow_clicks = null;
312
+ }
313
+ return d;
295
314
  }
296
315
  /**
297
316
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
package/dist/base.css CHANGED
@@ -191,8 +191,10 @@ pre:hover > .code-copy-btn,
191
191
  }
192
192
  .chart-controls > div { display: flex; gap: 2px; }
193
193
  .chart-controls :is(button, a) { font: inherit; color: inherit; text-decoration: none; background: none; cursor: pointer; opacity: 0.6; }
194
+ /* An icon sits on the text's line, so an icon button is as tall as a labelled one. */
195
+ .chart-icon { display: inline-block; width: 1em; height: 1em; vertical-align: -0.125em; }
194
196
  /* A button is pressed; a link (a stats page's window) is the page shown. */
195
- .chart-controls :is([aria-pressed='true'], [aria-current]) { opacity: 1; }
197
+ .chart-controls :is(button, a):is([aria-pressed='true'], [aria-current]) { opacity: 1; }
196
198
 
197
199
  /* ---- Stats ----------------------------------------------------------------
198
200
  `--share` is a row's value over the largest in its list, 0 to 1. Bars take
@@ -218,6 +220,7 @@ pre:hover > .code-copy-btn,
218
220
  }
219
221
  .stats-figures button[aria-pressed='true'] { opacity: 1; }
220
222
  .stats-figures strong { font-variant-numeric: tabular-nums; }
223
+ .stats-delta { margin-inline-start: 0.4em; }
221
224
  .stats-lists {
222
225
  display: grid;
223
226
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
package/dist/chart.d.ts CHANGED
@@ -49,7 +49,7 @@ export interface TimeChartProps {
49
49
  fromZero?: boolean;
50
50
  /** The value axis and crosshair label. Read on every draw, so it need not be stable. */
51
51
  format?: (n: number) => string;
52
- /** Open on candles, where the points carry them. A Line/Candles toggle shows either way. */
52
+ /** Open on candles, where the points carry them. A candles toggle shows either way. */
53
53
  candles?: boolean;
54
54
  /** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
55
55
  height?: number;
package/dist/chart.js CHANGED
@@ -261,5 +261,5 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
261
261
  c.api.timeScale().fitContent();
262
262
  }, [ready, source, view, candles]);
263
263
  const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
264
- return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || (view && (view.steps.length > 1 || view.ohlc))) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), view && view.steps.length > 1 && (_jsx("div", { role: "group", "aria-label": "Step", children: view.steps.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view.step, onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsxs("div", { role: "group", "aria-label": "Style", children: [_jsx("button", { type: "button", "aria-pressed": !candles, onClick: () => setCandles(false), children: "Line" }), _jsx("button", { type: "button", "aria-pressed": candles, onClick: () => setCandles(true), children: "Candles" })] }))] }))] }));
264
+ return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || (view && (view.steps.length > 1 || view.ohlc))) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), view && view.steps.length > 1 && (_jsx("div", { role: "group", "aria-label": "Step", children: view.steps.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view.step, onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
265
265
  }
@@ -1,7 +1,8 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { Digest } from './analytics.js';
3
3
  /**
4
- * The figures as `.stats-figures button[aria-pressed]` and the pressed one per
4
+ * The figures as `.stats-figures button[aria-pressed]`, each with its change
5
+ * as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
5
6
  * day from `first` (a UTC day number) to today. A count's empty day is a zero;
6
7
  * a rate's is left out, since no visits is not a bounce rate of nothing.
7
8
  */
@@ -9,35 +9,58 @@ import { TimeChart } from './chart.js';
9
9
  const count = (n) => n.toLocaleString('en');
10
10
  const duration = (s) => (s < 60 ? `${Math.round(s)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
11
11
  const percent = (n) => `${Math.round(n)}%`;
12
- /** Unsigned at zero, so no change never reads as growth. */
13
- const change = (now, before) => {
14
- if (!before)
12
+ /**
13
+ * The change from the window before: a share of the figure before, or for a
14
+ * rate its difference in points, since a bounce rate up 10% of itself reads
15
+ * as ten points. Unsigned and unmarked at zero, so no change never reads as
16
+ * growth. A lower bounce rate is the better one; every other figure, higher.
17
+ */
18
+ function change(now, before, { points = false, lowerIsBetter = false } = {}) {
19
+ if (now == null || before == null || (!points && !before))
15
20
  return null;
16
- const pct = Math.round((100 * (now - before)) / before);
17
- return pct > 0 ? `+${pct}%` : `${pct}%`;
18
- };
19
- const figuresOf = (d) => [
20
- { label: 'Visitors', value: count(d.visitors), delta: change(d.visitors, d.previous_visitors), day: r => r.visitors },
21
- { label: 'Page views', value: count(d.pageviews), day: r => r.pageviews },
21
+ const n = Math.round(points ? now - before : (100 * (now - before)) / before);
22
+ const text = `${n > 0 ? '+' : ''}${n}${points ? ' pp' : '%'}`;
23
+ return n ? { text, better: n > 0 !== lowerIsBetter } : { text };
24
+ }
25
+ const figuresOf = ({ previous: p, ...d }) => [
26
+ { label: 'Visitors', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: r => r.visitors },
27
+ { label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
22
28
  {
23
29
  label: 'Bounce rate',
24
30
  value: d.bounce_rate == null ? '–' : percent(d.bounce_rate),
31
+ delta: change(d.bounce_rate, p.bounce_rate, { points: true, lowerIsBetter: true }),
25
32
  day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
26
33
  format: percent,
27
34
  },
28
35
  {
29
36
  label: 'Visit length',
30
37
  value: d.visit_duration == null ? '–' : duration(d.visit_duration),
38
+ delta: change(d.visit_duration, p.visit_duration),
31
39
  day: r => (r.visits ? r.seconds / r.visits : null),
32
40
  format: duration,
33
41
  },
34
- { label: 'With agents', value: count(d.visitors_incl_agents), day: r => r.visitors_incl_agents },
35
- ...(d.follow_clicks ? [{ label: 'Clicks to X', value: count(d.follow_clicks.events), day: (r) => r.follow_clicks }] : []),
42
+ {
43
+ label: 'With agents',
44
+ value: count(d.visitors_incl_agents),
45
+ delta: change(d.visitors_incl_agents, p.visitors_incl_agents),
46
+ day: r => r.visitors_incl_agents,
47
+ },
48
+ ...(d.follow_clicks
49
+ ? [
50
+ {
51
+ label: 'Clicks to X',
52
+ value: count(d.follow_clicks.events),
53
+ delta: change(d.follow_clicks.events, p.follow_clicks),
54
+ day: (r) => r.follow_clicks,
55
+ },
56
+ ]
57
+ : []),
36
58
  ];
37
59
  const DAY = 86_400;
38
60
  const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, visitors_incl_agents: 0, follow_clicks: 0 };
39
61
  /**
40
- * The figures as `.stats-figures button[aria-pressed]` and the pressed one per
62
+ * The figures as `.stats-figures button[aria-pressed]`, each with its change
63
+ * as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
41
64
  * day from `first` (a UTC day number) to today. A count's empty day is a zero;
42
65
  * a rate's is left out, since no visits is not a bounce rate of nothing.
43
66
  */
@@ -54,5 +77,5 @@ export function StatsFigures({ digest, first, windows }) {
54
77
  return value == null ? null : { time, value };
55
78
  }).filter(p => p !== null);
56
79
  }, [byDay, first, figure]);
57
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && _jsx("small", { children: ` ${f.delta}` })] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: `${figure.label} per day`, ranges: ['all'], aggregate: "mean", fromZero: true, format: figure.format, controls: windows, className: "stats-chart" })] }));
80
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: `${figure.label} per day`, ranges: ['all'], aggregate: "mean", fromZero: true, format: figure.format, controls: windows, className: "stats-chart" })] }));
58
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",