wiki-formant 0.40.0 → 0.42.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,8 +731,11 @@ 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
735
- ranked list, and `?days=` links between windows, all time among them. Each
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
737
+ ranked list, and `?days=` links between windows, all time among them, pinned in
738
+ the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
736
739
  figure is a button that charts it: visitors, page views, bounce rate, visit
737
740
  length, visitors with agents and clicks to X. `digest`'s `by_day` carries every
738
741
  one of those per UTC day, a visit on the day it began, so the days add up to the
@@ -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
@@ -190,15 +190,14 @@ pre:hover > .code-copy-btn,
190
190
  gap: 0.5rem;
191
191
  }
192
192
  .chart-controls > div { display: flex; gap: 2px; }
193
- .chart-controls button { font: inherit; color: inherit; background: none; cursor: pointer; opacity: 0.6; }
194
- .chart-controls button[aria-pressed='true'] { opacity: 1; }
193
+ .chart-controls :is(button, a) { font: inherit; color: inherit; text-decoration: none; background: none; cursor: pointer; opacity: 0.6; }
194
+ /* 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; }
195
196
 
196
197
  /* ---- Stats ----------------------------------------------------------------
197
198
  `--share` is a row's value over the largest in its list, 0 to 1. Bars take
198
199
  `currentColor` so they show before a stylesheet colours them. */
199
200
  .stats { display: grid; gap: 2rem; }
200
- .stats-ranges { display: flex; flex-wrap: wrap; gap: 1rem; }
201
- .stats-ranges a[aria-current] { text-decoration: underline; text-underline-offset: 0.3em; }
202
201
  /* Each figure is a button that charts it; pressed is the one drawn. */
203
202
  .stats-figures {
204
203
  display: grid;
@@ -219,6 +218,7 @@ pre:hover > .code-copy-btn,
219
218
  }
220
219
  .stats-figures button[aria-pressed='true'] { opacity: 1; }
221
220
  .stats-figures strong { font-variant-numeric: tabular-nums; }
221
+ .stats-delta { margin-inline-start: 0.4em; }
222
222
  .stats-lists {
223
223
  display: grid;
224
224
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
package/dist/chart.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ReactNode } from 'react';
1
2
  /**
2
3
  * One value at a time in Unix seconds, UTC. A price may carry the rest of its
3
4
  * candle, `value` being the close, and the volume traded in it.
@@ -52,6 +53,11 @@ export interface TimeChartProps {
52
53
  candles?: boolean;
53
54
  /** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
54
55
  height?: number;
56
+ /**
57
+ * Groups pinned before the chart's own, styled as its buttons: a stats page's
58
+ * window links, which load another digest rather than slice this one.
59
+ */
60
+ controls?: ReactNode;
55
61
  className?: string;
56
62
  }
57
63
  /**
@@ -59,4 +65,4 @@ export interface TimeChartProps {
59
65
  * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
60
66
  * and the box their look; the line and fill are the canvas box's `color`.
61
67
  */
62
- export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, candles: initialCandles, height, className, }: TimeChartProps): import("react").JSX.Element;
68
+ export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, candles: initialCandles, height, controls, className, }: TimeChartProps): import("react").JSX.Element;
package/dist/chart.js CHANGED
@@ -101,7 +101,7 @@ const fromZeroScale = (base) => {
101
101
  * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
102
102
  * and the box their look; the line and fill are the canvas box's `color`.
103
103
  */
104
- export function TimeChart({ series, label, ranges = typeof series === 'function' ? CHART_RANGES : DAILY_RANGES, range: initial = ranges.includes('30d') ? '30d' : ranges[0], aggregate = 'last', fromZero = false, format = formatChartValue, candles: initialCandles = false, height, className, }) {
104
+ export function TimeChart({ series, label, ranges = typeof series === 'function' ? CHART_RANGES : DAILY_RANGES, range: initial = ranges.includes('30d') ? '30d' : ranges[0], aggregate = 'last', fromZero = false, format = formatChartValue, candles: initialCandles = false, height, controls, className, }) {
105
105
  const box = useRef(null);
106
106
  const chart = useRef(null);
107
107
  const formatRef = useRef(format);
@@ -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 }), (ranges.length > 1 || (view && (view.steps.length > 1 || view.ohlc))) && (_jsxs("div", { className: "chart-controls", children: [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 && (_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" })] }))] }))] }));
265
265
  }
@@ -1,10 +1,13 @@
1
+ import { type ReactNode } from 'react';
1
2
  import type { Digest } from './analytics.js';
2
3
  /**
3
- * 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
4
6
  * day from `first` (a UTC day number) to today. A count's empty day is a zero;
5
7
  * a rate's is left out, since no visits is not a bounce rate of nothing.
6
8
  */
7
- export declare function StatsFigures({ digest, first }: {
9
+ export declare function StatsFigures({ digest, first, windows }: {
8
10
  digest: Digest;
9
11
  first: number;
12
+ windows: ReactNode;
10
13
  }): import("react").JSX.Element;
@@ -9,39 +9,62 @@ 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
  */
44
- export function StatsFigures({ digest, first }) {
67
+ export function StatsFigures({ digest, first, windows }) {
45
68
  const figures = useMemo(() => figuresOf(digest), [digest]);
46
69
  const [chosen, choose] = useState(0);
47
70
  const figure = figures[chosen] ?? figures[0];
@@ -54,5 +77,5 @@ export function StatsFigures({ digest, first }) {
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, 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/dist/stats.js CHANGED
@@ -5,6 +5,8 @@ import { StatsFigures } from './stats-figures.js';
5
5
  export const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
6
6
  /** A window as its `?days=` value. */
7
7
  const daysParam = (n) => (Number.isFinite(n) ? String(n) : 'all');
8
+ /** A window as `TimeChart` names its ranges, since its buttons are where the windows sit. */
9
+ const windowLabel = (n) => (n === 1 ? '24H' : n === 365 ? '1Y' : Number.isFinite(n) ? `${n}D` : 'All');
8
10
  /** A `?days=` value as one of `ranges`, else `fallback`. */
9
11
  export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => daysParam(n) === param) ?? fallback;
10
12
  /**
@@ -67,5 +69,8 @@ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
67
69
  : Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
68
70
  const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
69
71
  const to = (k) => (v) => href(days, { ...filter, [k]: v });
70
- return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: href(n, filter), "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : Number.isFinite(n) ? `${n} days` : 'All time' }, n))) }), narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: href(days, { ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => href(days, { ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
72
+ // Each window is another digest, so a link rather than a button, pinned
73
+ // with the chart's own controls.
74
+ const windows = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: href(n, filter), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }));
75
+ return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: href(days, { ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, windows: windows }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => href(days, { ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
71
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.40.0",
3
+ "version": "0.42.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",