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 +33 -4
- package/dist/analytics.d.ts +1 -1
- package/dist/analytics.js +2 -1
- package/dist/base.css +23 -17
- package/dist/chart.d.ts +52 -0
- package/dist/chart.js +194 -0
- package/dist/react-server.d.ts +0 -20
- package/dist/react-server.js +0 -55
- package/dist/stats.d.ts +20 -0
- package/dist/stats.js +60 -0
- package/package.json +18 -3
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
|
|
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/
|
|
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
|
|
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`,
|
|
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);
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/dist/chart.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/react-server.d.ts
CHANGED
|
@@ -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;
|
package/dist/react-server.js
CHANGED
|
@@ -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
|
-
}
|
package/dist/stats.d.ts
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
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
|
}
|