wiki-formant 0.39.0 → 0.41.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
@@ -724,21 +724,32 @@ export const POST = gate.route;
724
724
  // src/app/stats/page.tsx
725
725
  if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
726
726
  return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
727
- const days = statsDays((await searchParams).days); // 1, 7, 30, 90, 365 or Infinity (?days=all), else 30
728
- return <Stats digest={await digest(sql, { days })} days={days} />;
727
+ const q = statsQuery(await searchParams); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
728
+ return <Stats digest={await digest(sql, q)} {...q} />;
729
729
  ```
730
730
 
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
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
735
+ ranked list, and `?days=` links between windows, all time among them, pinned in
736
+ the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
736
737
  figure is a button that charts it: visitors, page views, bounce rate, visit
737
738
  length, visitors with agents and clicks to X. `digest`'s `by_day` carries every
738
739
  one of those per UTC day, a visit on the day it began, so the days add up to the
739
740
  totals. A day with no visits has no bounce rate or visit length and is left out
740
741
  of those charts rather than drawn as zero. Everything but the figures and the
741
- chart is server-rendered. The chart is `TimeChart` with a count
742
+ chart is server-rendered.
743
+
744
+ Every row in a list is a link that narrows the page to it, and the narrowings
745
+ stack: `?page=`, `?entry=`, `?source=`, `?country=` and `?device=`, each shown as
746
+ a chip that drops it. A narrowing keeps the visits that pass – that saw the
747
+ page, began on it, came from the source, or were made from the country or
748
+ device – and recounts every figure, list and day from those visits alone, with
749
+ events only from their visitors. Agent tool calls and missed searches are
750
+ events no visit reaches, so their rows are not links. `ViewDay` keeps totals,
751
+ not visits, so a narrowed page starts at `counted_from`, the first day the site
752
+ counted itself, says so, and drops the comparison with the window before. The chart is `TimeChart` with a count
742
753
  axis from zero, so `lightweight-charts` is an optional peer that a stats page
743
754
  needs installed.
744
755
 
@@ -65,6 +65,9 @@ export interface Gap {
65
65
  }
66
66
  /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
67
67
  export declare const SEARCH_GAPS: Gap;
68
+ /** What a stats page can be narrowed to: one value each, every one applied. */
69
+ export declare const STATS_FILTERS: readonly ["page", "entry", "source", "country", "device"];
70
+ export type StatsFilter = Partial<Record<(typeof STATS_FILTERS)[number], string>>;
68
71
  export interface DigestOptions {
69
72
  site?: string;
70
73
  /** How far back, in whole days from now; `Infinity` for all time. */
@@ -79,6 +82,12 @@ export interface DigestOptions {
79
82
  * The default is the conformance suites (`<repo>-mcp-test`) and the studio's own tools.
80
83
  */
81
84
  ownAgents?: string[];
85
+ /**
86
+ * Narrows every figure to the visits that pass, and the events to their
87
+ * visitors. `ViewDay` keeps no visits, so a narrowed digest leaves it out
88
+ * and starts at `counted_from`, and has no `previous_visitors`.
89
+ */
90
+ filter?: StatsFilter;
82
91
  }
83
92
  type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
84
93
  /** One UTC day of the figures a digest totals; a visit falls on the day it began. */
@@ -104,8 +113,10 @@ export interface Digest {
104
113
  visit_duration: number | null;
105
114
  /** Visitors plus every agent that sent an event, such as an MCP call. */
106
115
  visitors_incl_agents: number;
107
- /** Visitors in the same number of days before. */
108
- previous_visitors: number;
116
+ /** Visitors in the same number of days before; null when filtered. */
117
+ previous_visitors: number | null;
118
+ /** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
119
+ counted_from: string | null;
109
120
  top_sources: Ranked<'source', 'visitors'>;
110
121
  top_pages: Ranked<'page', 'visitors'>;
111
122
  entry_pages: Ranked<'page', 'visitors'>;
@@ -130,7 +141,7 @@ export interface Digest {
130
141
  * page, visit, source, country, device and per-day figures; events have no
131
142
  * history, so an archived day's visitors are its visitors with agents too.
132
143
  */
133
- export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
144
+ export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter }: DigestOptions): Promise<Digest>;
134
145
  /**
135
146
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
136
147
  *
package/dist/analytics.js CHANGED
@@ -188,6 +188,19 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
188
188
  }
189
189
  /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
190
190
  export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
191
+ /** What a stats page can be narrowed to: one value each, every one applied. */
192
+ export const STATS_FILTERS = ['page', 'entry', 'source', 'country', 'device'];
193
+ /**
194
+ * Each filter as a test on one visit. A page, country or device is any of the
195
+ * visit's views; an entry page or a source is its first. `(none)` is no source.
196
+ */
197
+ const VISIT_TEST = {
198
+ page: v => `bool_or(path = ${v})`,
199
+ entry: v => `(array_agg(path ORDER BY at, id))[1] = ${v}`,
200
+ source: v => `coalesce((array_agg(source ORDER BY at, id))[1], '(none)') = ${v}`,
201
+ country: v => `bool_or(country = ${v})`,
202
+ device: v => `bool_or(device = ${v})`,
203
+ };
191
204
  /**
192
205
  * Everything a report needs about one site's last `days` days, in one query.
193
206
  * Visitor figures count distinct visitors, so a person who comes back on
@@ -195,7 +208,7 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
195
208
  * page, visit, source, country, device and per-day figures; events have no
196
209
  * history, so an archived day's visitors are its visitors with agents too.
197
210
  */
198
- export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
211
+ export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {} }) {
199
212
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
200
213
  // All time is a century: further back than any row, and still an int to Postgres.
201
214
  const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
@@ -205,6 +218,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
205
218
  const merged = (label, column, live) => `SELECT k AS ${label}, sum(n) AS visitors FROM (${live} UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a."${column}")) t(k, n) GROUP BY 1`;
206
219
  /** Clicks on links to the site's own X account, among `e`. */
207
220
  const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
221
+ const tests = STATS_FILTERS.flatMap(k => (filter[k] ? [VISIT_TEST[k]($(filter[k]))] : []));
208
222
  const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
209
223
  const gapSelects = gaps.map(g => {
210
224
  const prop = $(g.prop);
@@ -212,21 +226,24 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
212
226
  WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
213
227
  GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
214
228
  });
215
- const [row] = (await sql(`WITH v AS (
229
+ const [row] = (await sql(`WITH viewed AS (
216
230
  SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View"
217
231
  WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
218
232
  ), marked AS (
219
- 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 v
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
220
234
  ), numbered AS (
221
235
  SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
222
236
  ), visits AS (
223
- SELECT visitor, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
237
+ SELECT visitor, visit, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
224
238
  (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
225
- FROM numbered GROUP BY visitor, visit
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'}
226
242
  ), e AS (
227
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)' : ''}
228
245
  ), a AS (
229
- SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date
246
+ SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date${tests.length ? ' AND false' : ''}
230
247
  ), sessions AS (
231
248
  SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
232
249
  SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
@@ -244,11 +261,14 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
244
261
  'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
245
262
  'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
246
263
  'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
247
- 'previous_visitors', (SELECT count(DISTINCT visitor) FROM "View"
264
+ 'previous_visitors', ${tests.length
265
+ ? 'NULL'
266
+ : `(SELECT count(DISTINCT visitor) FROM "View"
248
267
  WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
249
268
  + (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
250
269
  AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
251
- AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date),
270
+ AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date)`},
271
+ 'counted_from', (SELECT ${day('min("createdAt")')} FROM "View" WHERE "siteId" = $1),
252
272
  'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
253
273
  'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
254
274
  'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
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;
@@ -242,4 +241,12 @@ pre:hover > .code-copy-btn,
242
241
  pointer-events: none;
243
242
  }
244
243
  .stats-list li > span:first-child { min-width: 0; overflow-wrap: anywhere; }
244
+ /* A row narrows the page to itself; the whole row is the link. */
245
+ .stats-list a { color: inherit; text-decoration: none; }
246
+ .stats-list a::after { content: ''; position: absolute; inset: 0; }
247
+ .stats-list a:is(:hover, :focus-visible) { text-decoration: underline; text-underline-offset: 0.2em; }
248
+ .stats-filters { display: grid; gap: 0.5rem; }
249
+ .stats-filters ul { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }
250
+ .stats-filters a { display: inline-block; padding: 0.25em 0.75em; border: 1px solid; border-radius: 9999px; text-decoration: none; }
251
+ .stats-filters p { margin: 0; opacity: 0.7; }
245
252
  .stats-count { font-variant-numeric: tabular-nums; }
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,12 @@
1
+ import { type ReactNode } from 'react';
1
2
  import type { Digest } from './analytics.js';
2
3
  /**
3
4
  * The figures as `.stats-figures button[aria-pressed]` and the pressed one per
4
5
  * day from `first` (a UTC day number) to today. A count's empty day is a zero;
5
6
  * a rate's is left out, since no visits is not a bounce rate of nothing.
6
7
  */
7
- export declare function StatsFigures({ digest, first }: {
8
+ export declare function StatsFigures({ digest, first, windows }: {
8
9
  digest: Digest;
9
10
  first: number;
11
+ windows: ReactNode;
10
12
  }): import("react").JSX.Element;
@@ -41,7 +41,7 @@ const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seco
41
41
  * day from `first` (a UTC day number) to today. A count's empty day is a zero;
42
42
  * a rate's is left out, since no visits is not a bounce rate of nothing.
43
43
  */
44
- export function StatsFigures({ digest, first }) {
44
+ export function StatsFigures({ digest, first, windows }) {
45
45
  const figures = useMemo(() => figuresOf(digest), [digest]);
46
46
  const [chosen, choose] = useState(0);
47
47
  const figure = figures[chosen] ?? figures[0];
@@ -54,5 +54,5 @@ export function StatsFigures({ digest, first }) {
54
54
  return value == null ? null : { time, value };
55
55
  }).filter(p => p !== null);
56
56
  }, [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" })] }));
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" })] }));
58
58
  }
package/dist/stats.d.ts CHANGED
@@ -1,20 +1,32 @@
1
- import type { Digest } from './analytics.js';
1
+ import { type Digest, type StatsFilter } from './analytics.js';
2
2
  export interface StatsProps {
3
3
  /** One window's figures, from `digest` in `wiki-formant/analytics`. */
4
4
  digest: Digest;
5
5
  /** The window the digest covers, in days; `Infinity` for all time. */
6
6
  days: number;
7
+ /** What the digest was narrowed to, as `digest` was given it. */
8
+ filter?: StatsFilter;
7
9
  /** The windows offered as `?days=` links on the same page. */
8
10
  ranges?: readonly number[];
9
11
  }
10
12
  /** The windows the stats page offers, in days; `Infinity` is all time. */
11
13
  export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365, number];
12
- /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
14
+ /** A `?days=` value as one of `ranges`, else `fallback`. */
13
15
  export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
16
+ /**
17
+ * A stats page's query as the window and the filter, both ready for `digest`
18
+ * and `Stats`: `const q = statsQuery(await searchParams)`. A repeated or empty
19
+ * parameter is ignored.
20
+ */
21
+ export declare function statsQuery(params: Record<string, string | string[] | undefined>, ranges?: readonly number[]): {
22
+ days: number;
23
+ filter: Partial<Record<"page" | "entry" | "source" | "country" | "device", string>>;
24
+ };
14
25
  /**
15
26
  * A site's own analytics on one page: the headline figures against the window
16
27
  * before, whichever of them is chosen per day, and every ranked list the
17
- * digest carries. Lists with nothing in them are left out. The chart and the
18
- * bars are `currentColor` until the importing stylesheet gives them a colour.
28
+ * digest carries, each row a link that narrows the page to it. Lists with
29
+ * nothing in them are left out. The chart and the bars are `currentColor`
30
+ * until the importing stylesheet gives them a colour.
19
31
  */
20
- export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
32
+ export declare function Stats({ digest: d, days, filter, ranges }: StatsProps): import("react").JSX.Element;
package/dist/stats.js CHANGED
@@ -1,11 +1,36 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { STATS_FILTERS } from './analytics.js';
2
3
  import { StatsFigures } from './stats-figures.js';
3
4
  /** The windows the stats page offers, in days; `Infinity` is all time. */
4
5
  export const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
5
6
  /** A window as its `?days=` value. */
6
7
  const daysParam = (n) => (Number.isFinite(n) ? String(n) : 'all');
7
- /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
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');
10
+ /** A `?days=` value as one of `ranges`, else `fallback`. */
8
11
  export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => daysParam(n) === param) ?? fallback;
12
+ /**
13
+ * A stats page's query as the window and the filter, both ready for `digest`
14
+ * and `Stats`: `const q = statsQuery(await searchParams)`. A repeated or empty
15
+ * parameter is ignored.
16
+ */
17
+ export function statsQuery(params, ranges = STATS_RANGES) {
18
+ const filter = {};
19
+ for (const k of STATS_FILTERS) {
20
+ const v = params[k];
21
+ if (typeof v === 'string' && v)
22
+ filter[k] = v.slice(0, 500);
23
+ }
24
+ return { days: statsDays(params.days, ranges), filter };
25
+ }
26
+ /** The page at a window and a filter, as a query string. */
27
+ const href = (days, filter) => {
28
+ const q = new URLSearchParams({ days: daysParam(days) });
29
+ for (const k of STATS_FILTERS)
30
+ if (filter[k])
31
+ q.set(k, filter[k]);
32
+ return `?${q}`;
33
+ };
9
34
  const count = (n) => n.toLocaleString('en');
10
35
  const regions = new Intl.DisplayNames(['en'], { type: 'region' });
11
36
  const country = (code) => {
@@ -16,23 +41,36 @@ const country = (code) => {
16
41
  return code;
17
42
  }
18
43
  };
19
- function StatsList({ title, rows, label = v => v }) {
44
+ const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
45
+ const DAY_MS = 86_400_000;
46
+ function StatsList({ title, rows, label = v => v, to, }) {
20
47
  if (!rows.length)
21
48
  return null;
22
49
  const max = Math.max(...rows.map(([, n]) => n));
23
- return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
50
+ return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: to ? _jsx("a", { href: to(v), children: label(v) }) : label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
24
51
  }
25
52
  /**
26
53
  * A site's own analytics on one page: the headline figures against the window
27
54
  * before, whichever of them is chosen per day, and every ranked list the
28
- * digest carries. Lists with nothing in them are left out. The chart and the
29
- * bars are `currentColor` until the importing stylesheet gives them a colour.
55
+ * digest carries, each row a link that narrows the page to it. Lists with
56
+ * nothing in them are left out. The chart and the bars are `currentColor`
57
+ * until the importing stylesheet gives them a colour.
30
58
  */
31
- export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
59
+ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
60
+ const today = Math.floor(Date.now() / DAY_MS);
61
+ const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
62
+ const narrowed = STATS_FILTERS.filter(k => filter[k]);
32
63
  // A rolling window of n days touches n + 1 UTC dates, the first of them in
33
- // part. All time starts at the first day anything was counted.
34
- const today = Math.floor(Date.now() / 86_400_000);
35
- const first = Number.isFinite(days) ? today - days : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / 86_400_000));
64
+ // part. All time starts at the first day anything was counted, and a
65
+ // narrowed window at the first day the site counted itself.
66
+ const start = Number.isFinite(days) ? today - days : -Infinity;
67
+ const first = narrowed.length
68
+ ? Math.max(start, counted)
69
+ : Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
36
70
  const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
37
- return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${daysParam(n)}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : Number.isFinite(n) ? `${n} days` : 'All time' }, n))) }), _jsx(StatsFigures, { digest: d, first: first }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors') }), _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
+ const to = (k) => (v) => href(days, { ...filter, [k]: v });
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') })] })] }));
38
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.39.0",
3
+ "version": "0.41.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",