wiki-formant 0.37.0 → 0.39.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,9 +731,14 @@ return <Stats digest={await digest(sql, { days })} days={days} />;
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. The chart is `TimeChart` with a count
737
742
  axis from zero, so `lightweight-charts` is an optional peer that a stats page
738
743
  needs installed.
739
744
 
@@ -761,7 +766,10 @@ and `volume`. Points that carry the candle get a Line/Candles toggle, opening on
761
766
  candles with `candles`; points that carry volume get a volume pane under the
762
767
  price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
763
768
  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.
769
+ on its last, spans the highest high and lowest low, and sums its volume. A point
770
+ with a volume of zero is a stretch nothing traded in: its candle and bar leave
771
+ the slot empty, except the last, which is the price now. A price's line is
772
+ straight between closes and a count's is curved.
765
773
 
766
774
  `aggregate` is how a week or month is made from its days: `last` for a price,
767
775
  `mean` for a count, so the week in progress keeps its level instead of reading
@@ -81,6 +81,20 @@ export interface DigestOptions {
81
81
  ownAgents?: string[];
82
82
  }
83
83
  type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
84
+ /** One UTC day of the figures a digest totals; a visit falls on the day it began. */
85
+ export interface DigestDay {
86
+ date: string;
87
+ visitors: number;
88
+ pageviews: number;
89
+ visits: number;
90
+ /** Visits that saw one page. */
91
+ bounces: number;
92
+ /** Seconds from each visit's first view to its last, summed. */
93
+ seconds: number;
94
+ visitors_incl_agents: number;
95
+ /** Clicks on links to the site's X handle; zero without a handle. */
96
+ follow_clicks: number;
97
+ }
84
98
  export interface Digest {
85
99
  visitors: number;
86
100
  pageviews: number;
@@ -100,7 +114,8 @@ export interface Digest {
100
114
  sibling_referrals: Ranked<'source', 'visitors'>;
101
115
  countries: Ranked<'country', 'visitors'>;
102
116
  devices: Ranked<'device', 'visitors'>;
103
- by_day: Ranked<'date', 'visitors'>;
117
+ /** Every day in the window with anything in it, oldest first. */
118
+ by_day: DigestDay[];
104
119
  follow_clicks: {
105
120
  events: number;
106
121
  visitors: number;
@@ -112,7 +127,8 @@ export interface Digest {
112
127
  * Everything a report needs about one site's last `days` days, in one query.
113
128
  * Visitor figures count distinct visitors, so a person who comes back on
114
129
  * 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.
130
+ * page, visit, source, country, device and per-day figures; events have no
131
+ * history, so an archived day's visitors are its visitors with agents too.
116
132
  */
117
133
  export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
118
134
  /**
package/dist/analytics.js CHANGED
@@ -192,7 +192,8 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
192
192
  * Everything a report needs about one site's last `days` days, in one query.
193
193
  * Visitor figures count distinct visitors, so a person who comes back on
194
194
  * 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.
195
+ * page, visit, source, country, device and per-day figures; events have no
196
+ * history, so an archived day's visitors are its visitors with agents too.
196
197
  */
197
198
  export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
198
199
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
@@ -202,6 +203,9 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
202
203
  const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
203
204
  /** A ranked list from the views, plus the same breakdown from `ViewDay`. */
204
205
  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
+ /** Clicks on links to the site's own X account, among `e`. */
207
+ const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
208
+ const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
205
209
  const gapSelects = gaps.map(g => {
206
210
  const prop = $(g.prop);
207
211
  return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
@@ -216,11 +220,11 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
216
220
  ), numbered AS (
217
221
  SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
218
222
  ), visits AS (
219
- SELECT visitor, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
223
+ SELECT visitor, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
220
224
  (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
221
225
  FROM numbered GROUP BY visitor, visit
222
226
  ), e AS (
223
- SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
227
+ SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
224
228
  ), a AS (
225
229
  SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date
226
230
  ), sessions AS (
@@ -252,14 +256,15 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
252
256
  'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
253
257
  'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
254
258
  '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'},
259
+ 'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
260
+ sum(seconds) AS seconds, sum(agents) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
261
+ SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0 FROM v GROUP BY 1
262
+ UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0 FROM visits GROUP BY 1
263
+ 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
264
+ ${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
265
+ UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, visitors, 0 FROM a
266
+ ) t(date, visitors, pageviews, visits, bounces, seconds, agents, follows) GROUP BY 1`, undefined, '1')},
267
+ 'follow_clicks', ${follows ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e WHERE ${follows})` : 'NULL'},
263
268
  'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
264
269
  WHERE name = 'MCP Call' AND props->>'method' = 'tools/call'
265
270
  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));
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
@@ -13,8 +13,8 @@ export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365, number];
13
13
  export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
14
14
  /**
15
15
  * 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.
16
+ * 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.
19
19
  */
20
20
  export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
package/dist/stats.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { TimeChart } from './chart.js';
2
+ import { StatsFigures } from './stats-figures.js';
3
3
  /** The windows the stats page offers, in days; `Infinity` is all time. */
4
4
  export const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
5
5
  /** A window as its `?days=` value. */
@@ -16,14 +16,6 @@ const country = (code) => {
16
16
  return code;
17
17
  }
18
18
  };
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
19
  function StatsList({ title, rows, label = v => v }) {
28
20
  if (!rows.length)
29
21
  return null;
@@ -32,29 +24,15 @@ function StatsList({ title, rows, label = v => v }) {
32
24
  }
33
25
  /**
34
26
  * 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.
27
+ * 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.
38
30
  */
39
31
  export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
40
- const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
41
32
  // A rolling window of n days touches n + 1 UTC dates, the first of them in
42
33
  // part. All time starts at the first day anything was counted.
43
34
  const today = Math.floor(Date.now() / 86_400_000);
44
35
  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]);
58
36
  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') })] })] }));
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') })] })] }));
60
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.37.0",
3
+ "version": "0.39.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",