wiki-formant 0.37.0 → 0.40.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,16 +724,31 @@ 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
- headline figures against the window before, visitors per day, every non-empty
735
- ranked list, and `?days=` links between windows, all time among them. Everything
736
- but the daily chart is server-rendered. The chart is `TimeChart` with a count
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
736
+ figure is a button that charts it: visitors, page views, bounce rate, visit
737
+ length, visitors with agents and clicks to X. `digest`'s `by_day` carries every
738
+ one of those per UTC day, a visit on the day it began, so the days add up to the
739
+ totals. A day with no visits has no bounce rate or visit length and is left out
740
+ of those charts rather than drawn as zero. Everything but the figures and the
741
+ chart is server-rendered.
742
+
743
+ Every row in a list is a link that narrows the page to it, and the narrowings
744
+ stack: `?page=`, `?entry=`, `?source=`, `?country=` and `?device=`, each shown as
745
+ a chip that drops it. A narrowing keeps the visits that pass – that saw the
746
+ page, began on it, came from the source, or were made from the country or
747
+ device – and recounts every figure, list and day from those visits alone, with
748
+ events only from their visitors. Agent tool calls and missed searches are
749
+ events no visit reaches, so their rows are not links. `ViewDay` keeps totals,
750
+ not visits, so a narrowed page starts at `counted_from`, the first day the site
751
+ counted itself, says so, and drops the comparison with the window before. The chart is `TimeChart` with a count
737
752
  axis from zero, so `lightweight-charts` is an optional peer that a stats page
738
753
  needs installed.
739
754
 
@@ -761,7 +776,10 @@ and `volume`. Points that carry the candle get a Line/Candles toggle, opening on
761
776
  candles with `candles`; points that carry volume get a volume pane under the
762
777
  price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
763
778
  the canvas box, else its `color`. A week of candles opens on its first, closes
764
- on its last, spans the highest high and lowest low, and sums its volume.
779
+ on its last, spans the highest high and lowest low, and sums its volume. A point
780
+ with a volume of zero is a stretch nothing traded in: its candle and bar leave
781
+ the slot empty, except the last, which is the price now. A price's line is
782
+ straight between closes and a count's is curved.
765
783
 
766
784
  `aggregate` is how a week or month is made from its days: `last` for a price,
767
785
  `mean` for a count, so the week in progress keeps its level instead of reading
@@ -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,8 +82,28 @@ 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>>;
93
+ /** One UTC day of the figures a digest totals; a visit falls on the day it began. */
94
+ export interface DigestDay {
95
+ date: string;
96
+ visitors: number;
97
+ pageviews: number;
98
+ visits: number;
99
+ /** Visits that saw one page. */
100
+ bounces: number;
101
+ /** Seconds from each visit's first view to its last, summed. */
102
+ seconds: number;
103
+ visitors_incl_agents: number;
104
+ /** Clicks on links to the site's X handle; zero without a handle. */
105
+ follow_clicks: number;
106
+ }
84
107
  export interface Digest {
85
108
  visitors: number;
86
109
  pageviews: number;
@@ -90,8 +113,10 @@ export interface Digest {
90
113
  visit_duration: number | null;
91
114
  /** Visitors plus every agent that sent an event, such as an MCP call. */
92
115
  visitors_incl_agents: number;
93
- /** Visitors in the same number of days before. */
94
- 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;
95
120
  top_sources: Ranked<'source', 'visitors'>;
96
121
  top_pages: Ranked<'page', 'visitors'>;
97
122
  entry_pages: Ranked<'page', 'visitors'>;
@@ -100,7 +125,8 @@ export interface Digest {
100
125
  sibling_referrals: Ranked<'source', 'visitors'>;
101
126
  countries: Ranked<'country', 'visitors'>;
102
127
  devices: Ranked<'device', 'visitors'>;
103
- by_day: Ranked<'date', 'visitors'>;
128
+ /** Every day in the window with anything in it, oldest first. */
129
+ by_day: DigestDay[];
104
130
  follow_clicks: {
105
131
  events: number;
106
132
  visitors: number;
@@ -112,9 +138,10 @@ export interface Digest {
112
138
  * Everything a report needs about one site's last `days` days, in one query.
113
139
  * Visitor figures count distinct visitors, so a person who comes back on
114
140
  * another day counts once for each day. Days in `ViewDay` add to the visitor,
115
- * page, source, country, device and per-day figures; events have no history.
141
+ * page, visit, source, country, device and per-day figures; events have no
142
+ * history, so an archived day's visitors are its visitors with agents too.
116
143
  */
117
- 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>;
118
145
  /**
119
146
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
120
147
  *
package/dist/analytics.js CHANGED
@@ -188,13 +188,27 @@ 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
194
207
  * another day counts once for each day. Days in `ViewDay` add to the visitor,
195
- * page, source, country, device and per-day figures; events have no history.
208
+ * page, visit, source, country, device and per-day figures; events have no
209
+ * history, so an archived day's visitors are its visitors with agents too.
196
210
  */
197
- 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 = {} }) {
198
212
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
199
213
  // All time is a century: further back than any row, and still an int to Postgres.
200
214
  const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
@@ -202,27 +216,34 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
202
216
  const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
203
217
  /** A ranked list from the views, plus the same breakdown from `ViewDay`. */
204
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`;
219
+ /** Clicks on links to the site's own X account, among `e`. */
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]))] : []));
222
+ const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
205
223
  const gapSelects = gaps.map(g => {
206
224
  const prop = $(g.prop);
207
225
  return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
208
226
  WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
209
227
  GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
210
228
  });
211
- const [row] = (await sql(`WITH v AS (
229
+ const [row] = (await sql(`WITH viewed AS (
212
230
  SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View"
213
231
  WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
214
232
  ), marked AS (
215
- 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
216
234
  ), numbered AS (
217
235
  SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
218
236
  ), visits AS (
219
- SELECT visitor, 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,
220
238
  (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
221
- 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'}
222
242
  ), e AS (
223
- SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
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)' : ''}
224
245
  ), a AS (
225
- 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' : ''}
226
247
  ), sessions AS (
227
248
  SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
228
249
  SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
@@ -240,11 +261,14 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
240
261
  'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
241
262
  'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
242
263
  'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
243
- 'previous_visitors', (SELECT count(DISTINCT visitor) FROM "View"
264
+ 'previous_visitors', ${tests.length
265
+ ? 'NULL'
266
+ : `(SELECT count(DISTINCT visitor) FROM "View"
244
267
  WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
245
268
  + (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
246
269
  AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
247
- 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),
248
272
  'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
249
273
  'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
250
274
  'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
@@ -252,14 +276,15 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
252
276
  'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
253
277
  'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
254
278
  'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
255
- 'by_day', ${list(`SELECT date, sum(n) AS visitors FROM (
256
- SELECT to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD'), count(DISTINCT visitor) FROM v GROUP BY 1
257
- UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors FROM a
258
- ) t(date, n) GROUP BY 1`, undefined, '1')},
259
- 'follow_clicks', ${handle
260
- ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e
261
- WHERE name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[]))`
262
- : 'NULL'},
279
+ 'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
280
+ sum(seconds) AS seconds, sum(agents) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
281
+ SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0 FROM v GROUP BY 1
282
+ UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0 FROM visits GROUP BY 1
283
+ UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM e) u GROUP BY 1
284
+ ${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
285
+ UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, visitors, 0 FROM a
286
+ ) t(date, visitors, pageviews, visits, bounces, seconds, agents, follows) GROUP BY 1`, undefined, '1')},
287
+ 'follow_clicks', ${follows ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e WHERE ${follows})` : 'NULL'},
263
288
  'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
264
289
  WHERE name = 'MCP Call' AND props->>'method' = 'tools/call'
265
290
  AND NOT coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])
package/dist/base.css CHANGED
@@ -199,13 +199,26 @@ pre:hover > .code-copy-btn,
199
199
  .stats { display: grid; gap: 2rem; }
200
200
  .stats-ranges { display: flex; flex-wrap: wrap; gap: 1rem; }
201
201
  .stats-ranges a[aria-current] { text-decoration: underline; text-underline-offset: 0.3em; }
202
+ /* Each figure is a button that charts it; pressed is the one drawn. */
202
203
  .stats-figures {
203
204
  display: grid;
204
205
  grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
205
206
  gap: 1rem;
206
- margin: 0;
207
207
  }
208
- .stats-figures dd { margin: 0; font-variant-numeric: tabular-nums; }
208
+ .stats-figures button {
209
+ display: grid;
210
+ gap: 0.25em;
211
+ padding: 0;
212
+ font: inherit;
213
+ color: inherit;
214
+ text-align: start;
215
+ background: none;
216
+ border: 0;
217
+ cursor: pointer;
218
+ opacity: 0.6;
219
+ }
220
+ .stats-figures button[aria-pressed='true'] { opacity: 1; }
221
+ .stats-figures strong { font-variant-numeric: tabular-nums; }
209
222
  .stats-lists {
210
223
  display: grid;
211
224
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
@@ -229,4 +242,12 @@ pre:hover > .code-copy-btn,
229
242
  pointer-events: none;
230
243
  }
231
244
  .stats-list li > span:first-child { min-width: 0; overflow-wrap: anywhere; }
245
+ /* A row narrows the page to itself; the whole row is the link. */
246
+ .stats-list a { color: inherit; text-decoration: none; }
247
+ .stats-list a::after { content: ''; position: absolute; inset: 0; }
248
+ .stats-list a:is(:hover, :focus-visible) { text-decoration: underline; text-underline-offset: 0.2em; }
249
+ .stats-filters { display: grid; gap: 0.5rem; }
250
+ .stats-filters ul { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }
251
+ .stats-filters a { display: inline-block; padding: 0.25em 0.75em; border: 1px solid; border-radius: 9999px; text-decoration: none; }
252
+ .stats-filters p { margin: 0; opacity: 0.7; }
232
253
  .stats-count { font-variant-numeric: tabular-nums; }
package/dist/chart.js CHANGED
@@ -7,7 +7,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  // that carries volume gets a volume pane under it. `lightweight-charts` is
8
8
  // loaded on mount, so the page paints without it. Every colour is the canvas
9
9
  // box's own `color`, its axis `--chart-axis`, and a candle `--chart-up` or
10
- // `--chart-down`, each if set.
10
+ // `--chart-down`, each if set. A count's line is curved; a price's is straight,
11
+ // since a curve between two closes draws prices nobody paid.
11
12
  import { useEffect, useMemo, useRef, useState } from 'react';
12
13
  const DAY = 86_400;
13
14
  const RANGE = {
@@ -126,6 +127,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
126
127
  const down = rgba(style.getPropertyValue('--chart-down').trim() || style.color, style.getPropertyValue('--chart-down').trim() ? 1 : 0.45);
127
128
  const priceFormat = { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 };
128
129
  const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
130
+ const grid = rgba(axis || style.color, 0.1);
129
131
  const api = createChart(el, {
130
132
  autoSize: true,
131
133
  layout: {
@@ -133,8 +135,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
133
135
  textColor: axis ? rgba(axis) : rgba(style.color, 0.7),
134
136
  fontFamily: style.fontFamily,
135
137
  fontSize: parseFloat(style.fontSize) || 11,
138
+ // The volume pane's border is a grid line, not the library's light-theme rule.
139
+ panes: { separatorColor: grid, separatorHoverColor: 'transparent', enableResize: false },
136
140
  },
137
- grid: { vertLines: { color: rgba(axis || style.color, 0.1) }, horzLines: { color: rgba(axis || style.color, 0.1) } },
141
+ grid: { vertLines: { color: grid }, horzLines: { color: grid } },
138
142
  crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
139
143
  rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } },
140
144
  timeScale: { borderVisible: false, secondsVisible: false },
@@ -146,7 +150,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
146
150
  topColor: rgba(style.color, 0.4),
147
151
  bottomColor: rgba(style.color, 0.02),
148
152
  lineWidth: 2,
149
- lineType: LineType.Curved,
153
+ lineType: aggregate === 'mean' ? LineType.Curved : LineType.Simple,
150
154
  crosshairMarkerBackgroundColor: ink,
151
155
  crosshairMarkerBorderColor: ink,
152
156
  priceFormat,
@@ -181,7 +185,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
181
185
  chart.current = null;
182
186
  setReady(false);
183
187
  };
184
- }, [fromZero]);
188
+ }, [fromZero, aggregate]);
185
189
  // One request per resolution for the life of the loader: every range from 30D up reads the same days.
186
190
  const cache = useRef(new Map());
187
191
  useEffect(() => {
@@ -232,11 +236,14 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
232
236
  const shown = view?.shown ?? [];
233
237
  const asCandles = candles && !!view?.ohlc;
234
238
  const time = (p) => p.time;
239
+ // A point with no volume is a stretch nothing traded in: its candle and its bar leave the slot
240
+ // empty rather than draw a flat dash. The last one always draws, as the price now.
241
+ const idle = (p, i) => p.volume === 0 && i < shown.length - 1;
235
242
  c.api.applyOptions({ timeScale: { timeVisible: source.resolution !== 'day' } });
236
243
  c.area.applyOptions({ visible: !asCandles });
237
244
  c.candle.applyOptions({ visible: asCandles });
238
245
  c.area.setData(asCandles ? [] : shown.map(p => ({ time: time(p), value: p.value })));
239
- c.candle.setData(asCandles ? shown.map(p => ({ time: time(p), open: p.open, high: p.high, low: p.low, close: p.value })) : []);
246
+ c.candle.setData(asCandles ? shown.map((p, i) => (idle(p, i) ? { time: time(p) } : { time: time(p), open: p.open, high: p.high, low: p.low, close: p.value })) : []);
240
247
  if (view?.volume && !c.volume)
241
248
  c.volume = c.addVolume();
242
249
  else if (!view?.volume && c.volume) {
@@ -246,6 +253,8 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
246
253
  c.api.removePane(1);
247
254
  }
248
255
  c.volume?.setData(shown.map((p, i) => {
256
+ if (!p.volume)
257
+ return { time: time(p) };
249
258
  const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
250
259
  return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
251
260
  }));
@@ -0,0 +1,10 @@
1
+ import type { Digest } from './analytics.js';
2
+ /**
3
+ * The figures as `.stats-figures button[aria-pressed]` and the pressed one per
4
+ * day from `first` (a UTC day number) to today. A count's empty day is a zero;
5
+ * a rate's is left out, since no visits is not a bounce rate of nothing.
6
+ */
7
+ export declare function StatsFigures({ digest, first }: {
8
+ digest: Digest;
9
+ first: number;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // stats-figures.tsx — the stats page's headline figures and the chart under
4
+ // them. Each figure is a button that charts that figure per day, so the chart
5
+ // is not only visitors. Its own module because it holds state; `Stats` stays a
6
+ // server component and hands it the digest, which is plain JSON.
7
+ import { useMemo, useState } from 'react';
8
+ import { TimeChart } from './chart.js';
9
+ const count = (n) => n.toLocaleString('en');
10
+ const duration = (s) => (s < 60 ? `${Math.round(s)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
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)
15
+ 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 },
22
+ {
23
+ label: 'Bounce rate',
24
+ value: d.bounce_rate == null ? '–' : percent(d.bounce_rate),
25
+ day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
26
+ format: percent,
27
+ },
28
+ {
29
+ label: 'Visit length',
30
+ value: d.visit_duration == null ? '–' : duration(d.visit_duration),
31
+ day: r => (r.visits ? r.seconds / r.visits : null),
32
+ format: duration,
33
+ },
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 }] : []),
36
+ ];
37
+ const DAY = 86_400;
38
+ const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, visitors_incl_agents: 0, follow_clicks: 0 };
39
+ /**
40
+ * The figures as `.stats-figures button[aria-pressed]` and the pressed one per
41
+ * day from `first` (a UTC day number) to today. A count's empty day is a zero;
42
+ * a rate's is left out, since no visits is not a bounce rate of nothing.
43
+ */
44
+ export function StatsFigures({ digest, first }) {
45
+ const figures = useMemo(() => figuresOf(digest), [digest]);
46
+ const [chosen, choose] = useState(0);
47
+ const figure = figures[chosen] ?? figures[0];
48
+ const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
49
+ const series = useMemo(() => {
50
+ const today = Math.floor(Date.now() / 1000 / DAY);
51
+ return Array.from({ length: today - first + 1 }, (_, i) => {
52
+ const time = (first + i) * DAY;
53
+ const value = figure.day(byDay.get(new Date(time * 1000).toISOString().slice(0, 10)) ?? EMPTY);
54
+ return value == null ? null : { time, value };
55
+ }).filter(p => p !== null);
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" })] }));
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
- * before, visitors per day, and every ranked list the digest carries. Lists
17
- * with nothing in them are left out. The chart and the bars are
18
- * `currentColor` until the importing stylesheet gives them a colour.
27
+ * before, whichever of them is chosen per day, and every ranked list the
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,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { TimeChart } from './chart.js';
2
+ import { STATS_FILTERS } from './analytics.js';
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 `?days=` value as one of `ranges`, else `fallback`. */
8
9
  export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => daysParam(n) === param) ?? fallback;
10
+ /**
11
+ * A stats page's query as the window and the filter, both ready for `digest`
12
+ * and `Stats`: `const q = statsQuery(await searchParams)`. A repeated or empty
13
+ * parameter is ignored.
14
+ */
15
+ export function statsQuery(params, ranges = STATS_RANGES) {
16
+ const filter = {};
17
+ for (const k of STATS_FILTERS) {
18
+ const v = params[k];
19
+ if (typeof v === 'string' && v)
20
+ filter[k] = v.slice(0, 500);
21
+ }
22
+ return { days: statsDays(params.days, ranges), filter };
23
+ }
24
+ /** The page at a window and a filter, as a query string. */
25
+ const href = (days, filter) => {
26
+ const q = new URLSearchParams({ days: daysParam(days) });
27
+ for (const k of STATS_FILTERS)
28
+ if (filter[k])
29
+ q.set(k, filter[k]);
30
+ return `?${q}`;
31
+ };
9
32
  const count = (n) => n.toLocaleString('en');
10
33
  const regions = new Intl.DisplayNames(['en'], { type: 'region' });
11
34
  const country = (code) => {
@@ -16,45 +39,33 @@ const country = (code) => {
16
39
  return code;
17
40
  }
18
41
  };
19
- const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
20
- /** Unsigned at zero, so no change never reads as growth. */
21
- const change = (now, before) => {
22
- if (!before)
23
- return null;
24
- const pct = Math.round((100 * (now - before)) / before);
25
- return pct > 0 ? `+${pct}%` : `${pct}%`;
26
- };
27
- function StatsList({ title, rows, label = v => v }) {
42
+ const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
43
+ const DAY_MS = 86_400_000;
44
+ function StatsList({ title, rows, label = v => v, to, }) {
28
45
  if (!rows.length)
29
46
  return null;
30
47
  const max = Math.max(...rows.map(([, n]) => n));
31
- 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))) })] }));
48
+ 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))) })] }));
32
49
  }
33
50
  /**
34
51
  * A site's own analytics on one page: the headline figures against the window
35
- * before, visitors per day, and every ranked list the digest carries. Lists
36
- * with nothing in them are left out. The chart and the bars are
37
- * `currentColor` until the importing stylesheet gives them a colour.
52
+ * before, whichever of them is chosen per day, and every ranked list the
53
+ * digest carries, each row a link that narrows the page to it. Lists with
54
+ * nothing in them are left out. The chart and the bars are `currentColor`
55
+ * until the importing stylesheet gives them a colour.
38
56
  */
39
- export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
40
- const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
57
+ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
58
+ const today = Math.floor(Date.now() / DAY_MS);
59
+ const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
60
+ const narrowed = STATS_FILTERS.filter(k => filter[k]);
41
61
  // A rolling window of n days touches n + 1 UTC dates, the first of them in
42
- // part. All time starts at the first day anything was counted.
43
- const today = Math.floor(Date.now() / 86_400_000);
44
- const first = Number.isFinite(days) ? today - days : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / 86_400_000));
45
- const series = Array.from({ length: today - first + 1 }, (_, i) => {
46
- const time = (first + i) * 86_400;
47
- return { time, value: byDay.get(new Date(time * 1000).toISOString().slice(0, 10)) ?? 0 };
48
- });
49
- const figures = [
50
- ['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
51
- ['Page views', count(d.pageviews), null],
52
- ['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
53
- ['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
54
- ['With agents', count(d.visitors_incl_agents), null],
55
- ];
56
- if (d.follow_clicks)
57
- figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
62
+ // part. All time starts at the first day anything was counted, and a
63
+ // narrowed window at the first day the site counted itself.
64
+ const start = Number.isFinite(days) ? today - days : -Infinity;
65
+ const first = narrowed.length
66
+ ? Math.max(start, counted)
67
+ : Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
58
68
  const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
59
- 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("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx(TimeChart, { series: series, label: "Visitors per day", ranges: ['all'], aggregate: "mean", fromZero: true, className: "stats-chart" }), _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') })] })] }));
69
+ 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') })] })] }));
60
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.37.0",
3
+ "version": "0.40.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",