wiki-formant 0.36.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
 
@@ -753,8 +758,18 @@ monthly.
753
758
 
754
759
  // A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
755
760
  // range), once each.
756
- <TimeChart series={load} label="Price" format={formatPrice} range="30d" />
757
- ```
761
+ <TimeChart series={load} label="Price" format={formatPrice} range="30d" candles />
762
+ ```
763
+
764
+ A price point may carry `open`, `high` and `low` (its `value` is the close)
765
+ and `volume`. Points that carry the candle get a Line/Candles toggle, opening on
766
+ candles with `candles`; points that carry volume get a volume pane under the
767
+ price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
768
+ the canvas box, else its `color`. A week of candles opens on its first, closes
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.
758
773
 
759
774
  `aggregate` is how a week or month is made from its days: `last` for a price,
760
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.d.ts CHANGED
@@ -1,7 +1,14 @@
1
- /** One value at a time in Unix seconds, UTC. */
1
+ /**
2
+ * One value at a time in Unix seconds, UTC. A price may carry the rest of its
3
+ * candle, `value` being the close, and the volume traded in it.
4
+ */
2
5
  export type ChartPoint = {
3
6
  time: number;
4
7
  value: number;
8
+ open?: number;
9
+ high?: number;
10
+ low?: number;
11
+ volume?: number;
5
12
  };
6
13
  export type ChartRange = '24h' | '7d' | '30d' | '90d' | '1y' | 'all';
7
14
  /** What a loader is asked for: hours for 24H, four hours for 7D, days for every longer range. */
@@ -19,8 +26,9 @@ export declare function chartSteps(span: number): {
19
26
  };
20
27
  /**
21
28
  * 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.
29
+ * step, as a candle does its close: the first open, the highest high, the
30
+ * lowest low. A count takes the mean of its days, so the week in progress does
31
+ * not read as a collapse. Volume is summed.
24
32
  */
25
33
  export declare function bucketChart(points: readonly ChartPoint[], step: ChartStep, aggregate: 'last' | 'mean'): ChartPoint[];
26
34
  /** Compact from a thousand up, four significant figures below. */
@@ -40,13 +48,15 @@ export interface TimeChartProps {
40
48
  fromZero?: boolean;
41
49
  /** The value axis and crosshair label. Read on every draw, so it need not be stable. */
42
50
  format?: (n: number) => string;
51
+ /** Open on candles, where the points carry them. A Line/Candles toggle shows either way. */
52
+ candles?: boolean;
43
53
  /** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
44
54
  height?: number;
45
55
  className?: string;
46
56
  }
47
57
  /**
48
- * A time series with its range and step buttons. The buttons are
58
+ * A time series with its range, step and style buttons. The buttons are
49
59
  * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
50
60
  * and the box their look; the line and fill are the canvas box's `color`.
51
61
  */
52
- export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, height, className, }: TimeChartProps): import("react").JSX.Element;
62
+ export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, candles: initialCandles, height, className, }: TimeChartProps): import("react").JSX.Element;
package/dist/chart.js CHANGED
@@ -3,8 +3,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // chart.tsx — one time series as an area chart: the token, activity and visitor
4
4
  // charts on every site. Ranges run from 24 hours to all time, and a long range
5
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.
6
+ // A price that carries its open, high and low can be drawn as candles, and one
7
+ // that carries volume gets a volume pane under it. `lightweight-charts` is
8
+ // loaded on mount, so the page paints without it. Every colour is the canvas
9
+ // box's own `color`, its axis `--chart-axis`, and a candle `--chart-up` or
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.
8
12
  import { useEffect, useMemo, useRef, useState } from 'react';
9
13
  const DAY = 86_400;
10
14
  const RANGE = {
@@ -41,23 +45,35 @@ function bucketStart(time, step) {
41
45
  }
42
46
  /**
43
47
  * 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.
48
+ * step, as a candle does its close: the first open, the highest high, the
49
+ * lowest low. A count takes the mean of its days, so the week in progress does
50
+ * not read as a collapse. Volume is summed.
46
51
  */
47
52
  export function bucketChart(points, step, aggregate) {
48
53
  const out = [];
49
- for (const { time, value } of points) {
50
- const start = bucketStart(time, step);
54
+ let n = 0;
55
+ for (const p of points) {
56
+ const start = bucketStart(p.time, step);
51
57
  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;
58
+ if (last?.time !== start) {
59
+ out.push({ ...p, time: start });
60
+ n = 1;
61
+ continue;
62
+ }
63
+ if (aggregate === 'last')
64
+ last.value = p.value;
56
65
  else
57
- last.value += (value - last.value) / ++last.n;
66
+ last.value += (p.value - last.value) / ++n;
67
+ if (last.high !== undefined && p.high !== undefined)
68
+ last.high = Math.max(last.high, p.high);
69
+ if (last.low !== undefined && p.low !== undefined)
70
+ last.low = Math.min(last.low, p.low);
71
+ if (p.volume !== undefined)
72
+ last.volume = (last.volume ?? 0) + p.volume;
58
73
  }
59
- return out.map(({ time, value }) => ({ time, value }));
74
+ return out;
60
75
  }
76
+ const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
61
77
  const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });
62
78
  const precise = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 });
63
79
  /** Compact from a thousand up, four significant figures below. */
@@ -81,11 +97,11 @@ const fromZeroScale = (base) => {
81
97
  return info;
82
98
  };
83
99
  /**
84
- * A time series with its range and step buttons. The buttons are
100
+ * A time series with its range, step and style buttons. The buttons are
85
101
  * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
86
102
  * and the box their look; the line and fill are the canvas box's `color`.
87
103
  */
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, }) {
104
+ export function TimeChart({ series, label, ranges = typeof series === 'function' ? CHART_RANGES : DAILY_RANGES, range: initial = ranges.includes('30d') ? '30d' : ranges[0], aggregate = 'last', fromZero = false, format = formatChartValue, candles: initialCandles = false, height, className, }) {
89
105
  const box = useRef(null);
90
106
  const chart = useRef(null);
91
107
  const formatRef = useRef(format);
@@ -93,6 +109,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
93
109
  const [ready, setReady] = useState(false);
94
110
  const [range, setRange] = useState(initial);
95
111
  const [step, setStep] = useState(null);
112
+ const [candles, setCandles] = useState(initialCandles);
96
113
  const [loaded, setLoaded] = useState(null);
97
114
  const resolution = RANGE[range].resolution;
98
115
  useEffect(() => {
@@ -100,13 +117,17 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
100
117
  if (!el)
101
118
  return;
102
119
  let disposed = false;
103
- import('lightweight-charts').then(({ createChart, AreaSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
120
+ import('lightweight-charts').then(({ createChart, AreaSeries, CandlestickSeries, HistogramSeries, ColorType, CrosshairMode, LineStyle, LineType }) => {
104
121
  if (disposed)
105
122
  return;
106
123
  const style = getComputedStyle(el);
107
124
  const axis = style.getPropertyValue('--chart-axis').trim();
108
125
  const ink = rgba(style.color);
126
+ const up = rgba(style.getPropertyValue('--chart-up').trim() || style.color);
127
+ const down = rgba(style.getPropertyValue('--chart-down').trim() || style.color, style.getPropertyValue('--chart-down').trim() ? 1 : 0.45);
128
+ const priceFormat = { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 };
109
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);
110
131
  const api = createChart(el, {
111
132
  autoSize: true,
112
133
  layout: {
@@ -114,8 +135,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
114
135
  textColor: axis ? rgba(axis) : rgba(style.color, 0.7),
115
136
  fontFamily: style.fontFamily,
116
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 },
117
140
  },
118
- 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 } },
119
142
  crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
120
143
  rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } },
121
144
  timeScale: { borderVisible: false, secondsVisible: false },
@@ -127,16 +150,33 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
127
150
  topColor: rgba(style.color, 0.4),
128
151
  bottomColor: rgba(style.color, 0.02),
129
152
  lineWidth: 2,
130
- lineType: LineType.Curved,
153
+ lineType: aggregate === 'mean' ? LineType.Curved : LineType.Simple,
131
154
  crosshairMarkerBackgroundColor: ink,
132
155
  crosshairMarkerBorderColor: ink,
133
- priceFormat: { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 },
156
+ priceFormat,
134
157
  ...(fromZero && { autoscaleInfoProvider: fromZeroScale }),
135
158
  });
159
+ const candle = api.addSeries(CandlestickSeries, {
160
+ visible: false,
161
+ upColor: up,
162
+ downColor: down,
163
+ borderUpColor: up,
164
+ borderDownColor: down,
165
+ wickUpColor: up,
166
+ wickDownColor: down,
167
+ priceFormat,
168
+ });
136
169
  // The chart cannot be scrolled or zoomed, so every size it takes shows every point,
137
170
  // the first included: data drawn before autoSize has measured the box fits nothing.
138
171
  api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
139
- chart.current = { api, area };
172
+ // Volume takes a pane of its own under the price, a fifth of the height.
173
+ const addVolume = () => {
174
+ const volume = api.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceLineVisible: false, lastValueVisible: false }, 1);
175
+ api.panes()[0]?.setStretchFactor(4);
176
+ api.panes()[1]?.setStretchFactor(1);
177
+ return volume;
178
+ };
179
+ chart.current = { api, area, candle, volume: null, addVolume, up, down };
140
180
  setReady(true);
141
181
  });
142
182
  return () => {
@@ -145,7 +185,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
145
185
  chart.current = null;
146
186
  setReady(false);
147
187
  };
148
- }, [fromZero]);
188
+ }, [fromZero, aggregate]);
149
189
  // One request per resolution for the life of the loader: every range from 30D up reads the same days.
150
190
  const cache = useRef(new Map());
151
191
  useEffect(() => {
@@ -175,20 +215,51 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
175
215
  const end = points.at(-1).time;
176
216
  const span = RANGE[range].span;
177
217
  const windowed = span === Infinity || ranges.length < 2 ? points : points.filter(p => p.time > Date.now() / 1000 - span);
218
+ const drawn = (shown, steps, step) => ({
219
+ shown,
220
+ steps,
221
+ step,
222
+ ohlc: aggregate === 'last' && shown.length > 0 && shown.every(isCandle),
223
+ volume: shown.some(p => p.volume !== undefined),
224
+ });
178
225
  if (resolution !== 'day' || !windowed.length)
179
- return { shown: windowed, steps: [], step: null };
226
+ return drawn(windowed, [], null);
180
227
  const { steps, fallback } = chartSteps(end - windowed[0].time);
181
228
  const chosen = step && steps.includes(step) ? step : fallback;
182
- return { shown: chosen === 'day' ? windowed : bucketChart(windowed, chosen, aggregate), steps, step: chosen };
229
+ return drawn(chosen === 'day' ? windowed : bucketChart(windowed, chosen, aggregate), steps, chosen);
183
230
  }, [points, range, ranges.length, resolution, step, aggregate]);
184
231
  // While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
185
232
  useEffect(() => {
186
- if (!ready || !chart.current || !source)
233
+ const c = chart.current;
234
+ if (!ready || !c || !source)
187
235
  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]);
236
+ const shown = view?.shown ?? [];
237
+ const asCandles = candles && !!view?.ohlc;
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;
242
+ c.api.applyOptions({ timeScale: { timeVisible: source.resolution !== 'day' } });
243
+ c.area.applyOptions({ visible: !asCandles });
244
+ c.candle.applyOptions({ visible: asCandles });
245
+ c.area.setData(asCandles ? [] : shown.map(p => ({ time: time(p), value: 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 })) : []);
247
+ if (view?.volume && !c.volume)
248
+ c.volume = c.addVolume();
249
+ else if (!view?.volume && c.volume) {
250
+ c.api.removeSeries(c.volume);
251
+ c.volume = null;
252
+ if (c.api.panes().length > 1)
253
+ c.api.removePane(1);
254
+ }
255
+ c.volume?.setData(shown.map((p, i) => {
256
+ if (!p.volume)
257
+ return { time: time(p) };
258
+ const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
259
+ return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
260
+ }));
261
+ c.api.timeScale().fitContent();
262
+ }, [ready, source, view, candles]);
192
263
  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))) }))] }))] }));
264
+ return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (ranges.length > 1 || (view && (view.steps.length > 1 || view.ohlc))) && (_jsxs("div", { className: "chart-controls", children: [ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), view && view.steps.length > 1 && (_jsx("div", { role: "group", "aria-label": "Step", children: view.steps.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view.step, onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsxs("div", { role: "group", "aria-label": "Style", children: [_jsx("button", { type: "button", "aria-pressed": !candles, onClick: () => setCandles(false), children: "Line" }), _jsx("button", { type: "button", "aria-pressed": candles, onClick: () => setCandles(true), children: "Candles" })] }))] }))] }));
194
265
  }
@@ -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.36.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",