wiki-formant 0.34.0 → 0.36.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,19 +724,48 @@ 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 or 365, else 30
727
+ const days = statsDays((await searchParams).days); // 1, 7, 30, 90, 365 or Infinity (?days=all), else 30
728
728
  return <Stats digest={await digest(sql, { days })} days={days} />;
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
- `Stats` (`wiki-formant/react-server`) renders a `digest` as one page: the
733
+ `Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
734
734
  headline figures against the window before, visitors per day, every non-empty
735
- ranked list, and `?days=` links between windows. It ships no JavaScript.
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
737
+ axis from zero, so `lightweight-charts` is an optional peer that a stats page
738
+ needs installed.
739
+
740
+ ## Charts
741
+
742
+ `TimeChart` (`wiki-formant/chart`) is every time-series chart on the four sites:
743
+ token prices, ledger activity, a caper's price, visitors per day. One area
744
+ series in the box's own `color`, its axis in `--chart-axis` if set, with range
745
+ buttons — 24H, 7D, 30D, 90D, 1Y, All — and, on a range long enough, a day, week
746
+ or month step. A step is offered once it makes four points, and the default is
747
+ the finest that makes 200 or fewer, so a year opens weekly and five years
748
+ monthly.
749
+
750
+ ```tsx
751
+ // Daily points: the chart slices and steps them itself; 30D up.
752
+ <TimeChart series={days} label="Transactions" aggregate="mean" fromZero />
753
+
754
+ // A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
755
+ // range), once each.
756
+ <TimeChart series={load} label="Price" format={formatPrice} range="30d" />
757
+ ```
758
+
759
+ `aggregate` is how a week or month is made from its days: `last` for a price,
760
+ `mean` for a count, so the week in progress keeps its level instead of reading
761
+ as a collapse. A single range draws no range buttons and shows every point.
762
+ `bucketChart` and `chartSteps` are exported for a chart drawn elsewhere.
763
+ `base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
764
+ canvas's top-left corner; the site's stylesheet gives them and the box a look.
736
765
 
737
766
  ## Base stylesheet
738
767
 
739
- `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid and bars. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
768
+ `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
740
769
 
741
770
  ```css
742
771
  @import "wiki-formant/base.css" layer(components);
@@ -67,7 +67,7 @@ export interface Gap {
67
67
  export declare const SEARCH_GAPS: Gap;
68
68
  export interface DigestOptions {
69
69
  site?: string;
70
- /** How far back, in whole days from now. */
70
+ /** How far back, in whole days from now; `Infinity` for all time. */
71
71
  days: number;
72
72
  /** The site's own X handle: `follow_clicks` counts clicks on links to it. */
73
73
  handle?: string;
package/dist/analytics.js CHANGED
@@ -196,7 +196,8 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
196
196
  */
197
197
  export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
198
198
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
199
- const values = [site, Math.max(1, Math.round(days))];
199
+ // All time is a century: further back than any row, and still an int to Postgres.
200
+ const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
200
201
  const $ = (v) => `$${values.push(v)}`;
201
202
  const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
202
203
  /** A ranked list from the views, plus the same breakdown from `ViewDay`. */
package/dist/base.css CHANGED
@@ -170,6 +170,29 @@ pre:hover > .code-copy-btn,
170
170
  }
171
171
  .revision-diff { margin: 0.25em 0 0; }
172
172
 
173
+ /* ---- Chart ----------------------------------------------------------------
174
+ `TimeChart`: the canvas, a loading or empty state over it, and the range and
175
+ step buttons pinned to its top-left corner. A site gives the buttons and the
176
+ box their look; pressed is all this marks. */
177
+ .chart { position: relative; margin: 0; }
178
+ /* Its own stacking context: the library's canvases stack themselves, and the buttons go over them. */
179
+ .chart-canvas { position: relative; z-index: 0; height: var(--chart-height, 15rem); }
180
+ /* The chart lays itself out in a <table>; a site's table styles are not for it. */
181
+ .chart-canvas :is(table, tr, td) { background: none; border: 0; border-radius: 0; }
182
+ .chart-status { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; }
183
+ .chart-controls {
184
+ position: absolute;
185
+ top: 0.5rem;
186
+ left: 0.5rem;
187
+ z-index: 1;
188
+ display: flex;
189
+ flex-wrap: wrap;
190
+ gap: 0.5rem;
191
+ }
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; }
195
+
173
196
  /* ---- Stats ----------------------------------------------------------------
174
197
  `--share` is a row's value over the largest in its list, 0 to 1. Bars take
175
198
  `currentColor` so they show before a stylesheet colours them. */
@@ -183,23 +206,6 @@ pre:hover > .code-copy-btn,
183
206
  margin: 0;
184
207
  }
185
208
  .stats-figures dd { margin: 0; font-variant-numeric: tabular-nums; }
186
- .stats-chart {
187
- display: flex;
188
- align-items: flex-end;
189
- /* A year's bars on a phone would be all gap at 2px each. */
190
- gap: min(2px, 100% / var(--bars, 1) / 4);
191
- height: 8rem;
192
- margin: 0;
193
- padding: 0;
194
- list-style: none;
195
- }
196
- .stats-chart li {
197
- flex: 1;
198
- height: calc(var(--share) * 100%);
199
- min-height: 1px;
200
- background: currentColor;
201
- opacity: 0.4;
202
- }
203
209
  .stats-lists {
204
210
  display: grid;
205
211
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
@@ -0,0 +1,52 @@
1
+ /** One value at a time in Unix seconds, UTC. */
2
+ export type ChartPoint = {
3
+ time: number;
4
+ value: number;
5
+ };
6
+ export type ChartRange = '24h' | '7d' | '30d' | '90d' | '1y' | 'all';
7
+ /** What a loader is asked for: hours for 24H, four hours for 7D, days for every longer range. */
8
+ export type ChartResolution = 'hour' | '4h' | 'day';
9
+ /** How a range of days is drawn: each day, or a point per week or month. */
10
+ export type ChartStep = 'day' | 'week' | 'month';
11
+ /** Points by resolution, from `from` (Unix seconds, 0 for all time), oldest first. */
12
+ export type ChartLoader = (resolution: ChartResolution, from: number) => Promise<readonly ChartPoint[]>;
13
+ /** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
14
+ export declare const CHART_RANGES: readonly ChartRange[];
15
+ /** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
16
+ export declare function chartSteps(span: number): {
17
+ steps: ChartStep[];
18
+ fallback: ChartStep;
19
+ };
20
+ /**
21
+ * Points, oldest first, one per step. A price keeps the last value in each
22
+ * step; a count takes the mean of its days, so the week in progress does not
23
+ * read as a collapse.
24
+ */
25
+ export declare function bucketChart(points: readonly ChartPoint[], step: ChartStep, aggregate: 'last' | 'mean'): ChartPoint[];
26
+ /** Compact from a thousand up, four significant figures below. */
27
+ export declare const formatChartValue: (n: number) => string;
28
+ export interface TimeChartProps {
29
+ /** Daily points, oldest first, or a loader for every resolution the ranges need. */
30
+ series: readonly ChartPoint[] | ChartLoader;
31
+ /** Read by assistive technology, as the chart's name. */
32
+ label: string;
33
+ /** The ranges offered. A single one draws no buttons and shows every point. */
34
+ ranges?: readonly ChartRange[];
35
+ /** The range shown first. */
36
+ range?: ChartRange;
37
+ /** A week or month of prices keeps its last; of counts, the mean of its days. */
38
+ aggregate?: 'last' | 'mean';
39
+ /** The value axis starts at zero, so a quiet week does not read as a collapse. */
40
+ fromZero?: boolean;
41
+ /** The value axis and crosshair label. Read on every draw, so it need not be stable. */
42
+ format?: (n: number) => string;
43
+ /** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
44
+ height?: number;
45
+ className?: string;
46
+ }
47
+ /**
48
+ * A time series with its range and step buttons. The buttons are
49
+ * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
50
+ * and the box their look; the line and fill are the canvas box's `color`.
51
+ */
52
+ export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, height, className, }: TimeChartProps): import("react").JSX.Element;
package/dist/chart.js ADDED
@@ -0,0 +1,194 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // chart.tsx — one time series as an area chart: the token, activity and visitor
4
+ // charts on every site. Ranges run from 24 hours to all time, and a long range
5
+ // steps by day, week or month, so three years do not draw as a thousand points.
6
+ // `lightweight-charts` is loaded on mount, so the page paints without it. Every
7
+ // colour is the canvas box's own `color`, and its axis `--chart-axis` if set.
8
+ import { useEffect, useMemo, useRef, useState } from 'react';
9
+ const DAY = 86_400;
10
+ const RANGE = {
11
+ '24h': { label: '24H', span: DAY, resolution: 'hour' },
12
+ '7d': { label: '7D', span: 7 * DAY, resolution: '4h' },
13
+ '30d': { label: '30D', span: 30 * DAY, resolution: 'day' },
14
+ '90d': { label: '90D', span: 90 * DAY, resolution: 'day' },
15
+ '1y': { label: '1Y', span: 365 * DAY, resolution: 'day' },
16
+ all: { label: 'All', span: Infinity, resolution: 'day' },
17
+ };
18
+ const STEP = {
19
+ day: { label: 'D', span: DAY },
20
+ week: { label: 'W', span: 7 * DAY },
21
+ month: { label: 'M', span: 30.44 * DAY },
22
+ };
23
+ /** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
24
+ export const CHART_RANGES = ['24h', '7d', '30d', '90d', '1y', 'all'];
25
+ const DAILY_RANGES = ['30d', '90d', '1y', 'all'];
26
+ /** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
27
+ export function chartSteps(span) {
28
+ const steps = ['day', 'week', 'month'].filter(s => s === 'day' || span / STEP[s].span >= 4);
29
+ return { steps, fallback: steps.find(s => span / STEP[s].span <= 200) ?? steps.at(-1) };
30
+ }
31
+ /** The UTC day, Monday or first of the month a time falls in. */
32
+ function bucketStart(time, step) {
33
+ const day = Math.floor(time / DAY);
34
+ if (step === 'day')
35
+ return day * DAY;
36
+ // 1 January 1970 was a Thursday, three days after a Monday.
37
+ if (step === 'week')
38
+ return (day - ((day + 3) % 7)) * DAY;
39
+ const d = new Date(time * 1000);
40
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;
41
+ }
42
+ /**
43
+ * Points, oldest first, one per step. A price keeps the last value in each
44
+ * step; a count takes the mean of its days, so the week in progress does not
45
+ * read as a collapse.
46
+ */
47
+ export function bucketChart(points, step, aggregate) {
48
+ const out = [];
49
+ for (const { time, value } of points) {
50
+ const start = bucketStart(time, step);
51
+ const last = out.at(-1);
52
+ if (last?.time !== start)
53
+ out.push({ time: start, value, n: 1 });
54
+ else if (aggregate === 'last')
55
+ last.value = value;
56
+ else
57
+ last.value += (value - last.value) / ++last.n;
58
+ }
59
+ return out.map(({ time, value }) => ({ time, value }));
60
+ }
61
+ const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });
62
+ const precise = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 });
63
+ /** Compact from a thousand up, four significant figures below. */
64
+ export const formatChartValue = (n) => (Math.abs(n) >= 1e3 ? compact.format(n) : precise.format(n));
65
+ /**
66
+ * A CSS colour at an opacity, as the rgba() the chart's parser reads. Drawn to
67
+ * a pixel and read back, because a site's colour may be oklch() or a named one.
68
+ */
69
+ function rgba(colour, alpha = 1) {
70
+ const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
71
+ if (!ctx)
72
+ return colour;
73
+ ctx.fillStyle = colour;
74
+ ctx.fillRect(0, 0, 1, 1);
75
+ return `rgba(${Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(', ')}, ${alpha})`;
76
+ }
77
+ const fromZeroScale = (base) => {
78
+ const info = base();
79
+ if (info?.priceRange)
80
+ info.priceRange.minValue = 0;
81
+ return info;
82
+ };
83
+ /**
84
+ * A time series with its range and step buttons. The buttons are
85
+ * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
86
+ * and the box their look; the line and fill are the canvas box's `color`.
87
+ */
88
+ 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, height, className, }) {
89
+ const box = useRef(null);
90
+ const chart = useRef(null);
91
+ const formatRef = useRef(format);
92
+ formatRef.current = format;
93
+ const [ready, setReady] = useState(false);
94
+ const [range, setRange] = useState(initial);
95
+ const [step, setStep] = useState(null);
96
+ const [loaded, setLoaded] = useState(null);
97
+ const resolution = RANGE[range].resolution;
98
+ useEffect(() => {
99
+ const el = box.current;
100
+ if (!el)
101
+ return;
102
+ let disposed = false;
103
+ import('lightweight-charts').then(({ createChart, AreaSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
104
+ if (disposed)
105
+ return;
106
+ const style = getComputedStyle(el);
107
+ const axis = style.getPropertyValue('--chart-axis').trim();
108
+ const ink = rgba(style.color);
109
+ const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
110
+ const api = createChart(el, {
111
+ autoSize: true,
112
+ layout: {
113
+ background: { type: ColorType.Solid, color: 'transparent' },
114
+ textColor: axis ? rgba(axis) : rgba(style.color, 0.7),
115
+ fontFamily: style.fontFamily,
116
+ fontSize: parseFloat(style.fontSize) || 11,
117
+ },
118
+ grid: { vertLines: { color: rgba(axis || style.color, 0.1) }, horzLines: { color: rgba(axis || style.color, 0.1) } },
119
+ crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
120
+ rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } },
121
+ timeScale: { borderVisible: false, secondsVisible: false },
122
+ handleScroll: false,
123
+ handleScale: false,
124
+ });
125
+ const area = api.addSeries(AreaSeries, {
126
+ lineColor: ink,
127
+ topColor: rgba(style.color, 0.4),
128
+ bottomColor: rgba(style.color, 0.02),
129
+ lineWidth: 2,
130
+ lineType: LineType.Curved,
131
+ crosshairMarkerBackgroundColor: ink,
132
+ crosshairMarkerBorderColor: ink,
133
+ priceFormat: { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 },
134
+ ...(fromZero && { autoscaleInfoProvider: fromZeroScale }),
135
+ });
136
+ // The chart cannot be scrolled or zoomed, so every size it takes shows every point,
137
+ // the first included: data drawn before autoSize has measured the box fits nothing.
138
+ api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
139
+ chart.current = { api, area };
140
+ setReady(true);
141
+ });
142
+ return () => {
143
+ disposed = true;
144
+ chart.current?.api.remove();
145
+ chart.current = null;
146
+ setReady(false);
147
+ };
148
+ }, [fromZero]);
149
+ // One request per resolution for the life of the loader: every range from 30D up reads the same days.
150
+ const cache = useRef(new Map());
151
+ useEffect(() => {
152
+ cache.current.clear();
153
+ }, [series]);
154
+ useEffect(() => {
155
+ if (typeof series !== 'function')
156
+ return;
157
+ let current = true;
158
+ let request = cache.current.get(resolution);
159
+ if (!request) {
160
+ const now = Math.floor(Date.now() / 1000);
161
+ request = series(resolution, resolution === 'day' ? 0 : now - RANGE[range].span);
162
+ cache.current.set(resolution, request);
163
+ request.catch(() => cache.current.delete(resolution));
164
+ }
165
+ request.then(points => current && setLoaded({ resolution, points }), (e) => current && setLoaded({ resolution, error: e instanceof Error ? e.message : 'The chart could not load' }));
166
+ return () => {
167
+ current = false;
168
+ };
169
+ }, [series, resolution, range]);
170
+ const source = useMemo(() => (typeof series === 'function' ? (loaded?.resolution === resolution ? loaded : null) : { resolution: 'day', points: series }), [series, loaded, resolution]);
171
+ const points = source && 'points' in source ? source.points : null;
172
+ const view = useMemo(() => {
173
+ if (!points?.length)
174
+ return null;
175
+ const end = points.at(-1).time;
176
+ const span = RANGE[range].span;
177
+ const windowed = span === Infinity || ranges.length < 2 ? points : points.filter(p => p.time > Date.now() / 1000 - span);
178
+ if (resolution !== 'day' || !windowed.length)
179
+ return { shown: windowed, steps: [], step: null };
180
+ const { steps, fallback } = chartSteps(end - windowed[0].time);
181
+ const chosen = step && steps.includes(step) ? step : fallback;
182
+ return { shown: chosen === 'day' ? windowed : bucketChart(windowed, chosen, aggregate), steps, step: chosen };
183
+ }, [points, range, ranges.length, resolution, step, aggregate]);
184
+ // While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
185
+ useEffect(() => {
186
+ if (!ready || !chart.current || !source)
187
+ return;
188
+ chart.current.api.applyOptions({ timeScale: { timeVisible: source.resolution !== 'day' } });
189
+ chart.current.area.setData((view?.shown ?? []).map(p => ({ time: p.time, value: p.value })));
190
+ chart.current.api.timeScale().fitContent();
191
+ }, [ready, source, view]);
192
+ const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
193
+ 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)) && (_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))) }))] }))] }));
194
+ }
@@ -1,7 +1,6 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
2
  import type { Control, FacetControlGroup, MetadataRow } from './taxonomy.js';
3
3
  import type { HistoryChange } from './history.js';
4
- import type { Digest } from './analytics.js';
5
4
  /**
6
5
  * The shape a router's link component has to satisfy here.
7
6
  *
@@ -268,22 +267,3 @@ export interface RevisionChangesProps {
268
267
  * are the importing stylesheet's.
269
268
  */
270
269
  export declare function RevisionChanges({ changes, label }: RevisionChangesProps): import("react").JSX.Element | null;
271
- export interface StatsProps {
272
- /** One window's figures, from `digest` in `wiki-formant/analytics`. */
273
- digest: Digest;
274
- /** The window the digest covers, in days. */
275
- days: number;
276
- /** The windows offered as `?days=` links on the same page. */
277
- ranges?: readonly number[];
278
- }
279
- /** The windows the stats page offers, in days. */
280
- export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365];
281
- /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
282
- export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
283
- /**
284
- * A site's own analytics on one page: the headline figures against the window
285
- * before, visitors per day, and every ranked list the digest carries. Lists
286
- * with nothing in them are left out. Bars are `currentColor` until the
287
- * importing stylesheet gives them a colour.
288
- */
289
- export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
@@ -225,58 +225,3 @@ export function RevisionChanges({ changes, label = type => type }) {
225
225
  return null;
226
226
  return (_jsx("ul", { className: "revision-changes", children: changes.map((c, i) => (_jsxs("li", { className: "revision-change", "data-action": c.action, children: [_jsx("span", { className: "revision-change-action", children: ACTION_LABEL[c.action] }), ` – ${label(c.type)} at ${blockLocation(c.path)}`, c.leafDiff && (_jsx("p", { className: "revision-diff", children: c.leafDiff.map(([op, text], j) => op === -1 ? _jsx("del", { children: text }, j) : op === 1 ? _jsx("ins", { children: text }, j) : _jsx("span", { children: text }, j)) }))] }, i))) }));
227
227
  }
228
- /** The windows the stats page offers, in days. */
229
- export const STATS_RANGES = [1, 7, 30, 90, 365];
230
- /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
231
- export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => String(n) === param) ?? fallback;
232
- const count = (n) => n.toLocaleString('en');
233
- const regions = new Intl.DisplayNames(['en'], { type: 'region' });
234
- const country = (code) => {
235
- try {
236
- return regions.of(code) ?? code;
237
- }
238
- catch {
239
- return code;
240
- }
241
- };
242
- const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
243
- /** Unsigned at zero, so no change never reads as growth. */
244
- const change = (now, before) => {
245
- if (!before)
246
- return null;
247
- const pct = Math.round((100 * (now - before)) / before);
248
- return pct > 0 ? `+${pct}%` : `${pct}%`;
249
- };
250
- function StatsList({ title, rows, label = v => v }) {
251
- if (!rows.length)
252
- return null;
253
- const max = Math.max(...rows.map(([, n]) => n));
254
- 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))) })] }));
255
- }
256
- /**
257
- * A site's own analytics on one page: the headline figures against the window
258
- * before, visitors per day, and every ranked list the digest carries. Lists
259
- * with nothing in them are left out. Bars are `currentColor` until the
260
- * importing stylesheet gives them a colour.
261
- */
262
- export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
263
- const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
264
- // A rolling window of n days touches n + 1 UTC dates, the first of them in part.
265
- const now = Date.now();
266
- const series = Array.from({ length: days + 1 }, (_, i) => {
267
- const date = new Date(now - (days - i) * 86_400_000).toISOString().slice(0, 10);
268
- return [date, byDay.get(date) ?? 0];
269
- });
270
- const peak = Math.max(1, ...series.map(([, n]) => n));
271
- const figures = [
272
- ['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
273
- ['Page views', count(d.pageviews), null],
274
- ['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
275
- ['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
276
- ['With agents', count(d.visitors_incl_agents), null],
277
- ];
278
- if (d.follow_clicks)
279
- figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
280
- const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
281
- return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, 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("ol", { className: "stats-chart", "aria-label": "Visitors per day", style: { '--bars': series.length }, children: series.map(([date, n]) => (_jsx("li", { title: `${date}: ${count(n)}`, style: { '--share': n / peak } }, date))) }), _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') })] })] }));
282
- }
@@ -0,0 +1,20 @@
1
+ import type { Digest } from './analytics.js';
2
+ export interface StatsProps {
3
+ /** One window's figures, from `digest` in `wiki-formant/analytics`. */
4
+ digest: Digest;
5
+ /** The window the digest covers, in days; `Infinity` for all time. */
6
+ days: number;
7
+ /** The windows offered as `?days=` links on the same page. */
8
+ ranges?: readonly number[];
9
+ }
10
+ /** The windows the stats page offers, in days; `Infinity` is all time. */
11
+ 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. */
13
+ export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
14
+ /**
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.
19
+ */
20
+ export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
package/dist/stats.js ADDED
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { TimeChart } from './chart.js';
3
+ /** The windows the stats page offers, in days; `Infinity` is all time. */
4
+ export const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
5
+ /** A window as its `?days=` value. */
6
+ 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
+ export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => daysParam(n) === param) ?? fallback;
9
+ const count = (n) => n.toLocaleString('en');
10
+ const regions = new Intl.DisplayNames(['en'], { type: 'region' });
11
+ const country = (code) => {
12
+ try {
13
+ return regions.of(code) ?? code;
14
+ }
15
+ catch {
16
+ return code;
17
+ }
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
+ function StatsList({ title, rows, label = v => v }) {
28
+ if (!rows.length)
29
+ return null;
30
+ 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))) })] }));
32
+ }
33
+ /**
34
+ * 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.
38
+ */
39
+ export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
40
+ const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
41
+ // 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]);
58
+ 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') })] })] }));
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.34.0",
3
+ "version": "0.36.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",
@@ -172,6 +172,16 @@
172
172
  "import": "./dist/react-server.js",
173
173
  "default": "./dist/react-server.js"
174
174
  },
175
+ "./stats": {
176
+ "types": "./dist/stats.d.ts",
177
+ "import": "./dist/stats.js",
178
+ "default": "./dist/stats.js"
179
+ },
180
+ "./chart": {
181
+ "types": "./dist/chart.d.ts",
182
+ "import": "./dist/chart.js",
183
+ "default": "./dist/chart.js"
184
+ },
175
185
  "./dom": {
176
186
  "types": "./dist/dom.d.ts",
177
187
  "import": "./dist/dom.js",
@@ -266,11 +276,14 @@
266
276
  "@types/sanitize-html": "^2.16.1",
267
277
  "fast-diff": "^1.3.0",
268
278
  "jsdom": "^30.1.0",
279
+ "lightweight-charts": "^5.2.1",
269
280
  "react": "^19.2.4",
270
281
  "sanitize-html": "^2.17.7",
271
282
  "typescript": "^5.9.0"
272
283
  },
273
284
  "peerDependencies": {
285
+ "@simplewebauthn/browser": ">=13",
286
+ "@simplewebauthn/server": ">=13",
274
287
  "@tiptap/core": ">=2.10",
275
288
  "@tiptap/extension-code-block": ">=2.10",
276
289
  "@tiptap/extension-image": ">=2.10",
@@ -288,8 +301,7 @@
288
301
  "pg": ">=8",
289
302
  "react": ">=18",
290
303
  "sanitize-html": ">=2",
291
- "@simplewebauthn/server": ">=13",
292
- "@simplewebauthn/browser": ">=13"
304
+ "lightweight-charts": ">=5"
293
305
  },
294
306
  "peerDependenciesMeta": {
295
307
  "react": {
@@ -348,6 +360,9 @@
348
360
  },
349
361
  "@simplewebauthn/browser": {
350
362
  "optional": true
363
+ },
364
+ "lightweight-charts": {
365
+ "optional": true
351
366
  }
352
367
  }
353
368
  }