wiki-formant 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/dist/analytics.d.ts +18 -2
- package/dist/analytics.js +16 -11
- package/dist/base.css +15 -2
- package/dist/chart.js +14 -5
- package/dist/stats-figures.d.ts +10 -0
- package/dist/stats-figures.js +58 -0
- package/dist/stats.d.ts +3 -3
- package/dist/stats.js +5 -27
- package/package.json +1 -1
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,
|
|
735
|
-
ranked list, and `?days=` links between windows, all time among them.
|
|
736
|
-
|
|
734
|
+
headline figures against the window before, one of them per day, every non-empty
|
|
735
|
+
ranked list, and `?days=` links between windows, all time among them. Each
|
|
736
|
+
figure is a button that charts it: visitors, page views, bounce rate, visit
|
|
737
|
+
length, visitors with agents and clicks to X. `digest`'s `by_day` carries every
|
|
738
|
+
one of those per UTC day, a visit on the day it began, so the days add up to the
|
|
739
|
+
totals. A day with no visits has no bounce rate or visit length and is left out
|
|
740
|
+
of those charts rather than drawn as zero. Everything but the figures and the
|
|
741
|
+
chart is server-rendered. The chart is `TimeChart` with a count
|
|
737
742
|
axis from zero, so `lightweight-charts` is an optional peer that a stats page
|
|
738
743
|
needs installed.
|
|
739
744
|
|
|
@@ -761,7 +766,10 @@ and `volume`. Points that carry the candle get a Line/Candles toggle, opening on
|
|
|
761
766
|
candles with `candles`; points that carry volume get a volume pane under the
|
|
762
767
|
price, a fifth of the height. Candles take `--chart-up` and `--chart-down` from
|
|
763
768
|
the canvas box, else its `color`. A week of candles opens on its first, closes
|
|
764
|
-
on its last, spans the highest high and lowest low, and sums its volume.
|
|
769
|
+
on its last, spans the highest high and lowest low, and sums its volume. A point
|
|
770
|
+
with a volume of zero is a stretch nothing traded in: its candle and bar leave
|
|
771
|
+
the slot empty, except the last, which is the price now. A price's line is
|
|
772
|
+
straight between closes and a count's is curved.
|
|
765
773
|
|
|
766
774
|
`aggregate` is how a week or month is made from its days: `last` for a price,
|
|
767
775
|
`mean` for a count, so the week in progress keeps its level instead of reading
|
package/dist/analytics.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
208
|
+
.stats-figures button {
|
|
209
|
+
display: grid;
|
|
210
|
+
gap: 0.25em;
|
|
211
|
+
padding: 0;
|
|
212
|
+
font: inherit;
|
|
213
|
+
color: inherit;
|
|
214
|
+
text-align: start;
|
|
215
|
+
background: none;
|
|
216
|
+
border: 0;
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
opacity: 0.6;
|
|
219
|
+
}
|
|
220
|
+
.stats-figures button[aria-pressed='true'] { opacity: 1; }
|
|
221
|
+
.stats-figures strong { font-variant-numeric: tabular-nums; }
|
|
209
222
|
.stats-lists {
|
|
210
223
|
display: grid;
|
|
211
224
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
package/dist/chart.js
CHANGED
|
@@ -7,7 +7,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
// that carries volume gets a volume pane under it. `lightweight-charts` is
|
|
8
8
|
// loaded on mount, so the page paints without it. Every colour is the canvas
|
|
9
9
|
// box's own `color`, its axis `--chart-axis`, and a candle `--chart-up` or
|
|
10
|
-
// `--chart-down`, each if set.
|
|
10
|
+
// `--chart-down`, each if set. A count's line is curved; a price's is straight,
|
|
11
|
+
// since a curve between two closes draws prices nobody paid.
|
|
11
12
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
12
13
|
const DAY = 86_400;
|
|
13
14
|
const RANGE = {
|
|
@@ -126,6 +127,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
126
127
|
const down = rgba(style.getPropertyValue('--chart-down').trim() || style.color, style.getPropertyValue('--chart-down').trim() ? 1 : 0.45);
|
|
127
128
|
const priceFormat = { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 };
|
|
128
129
|
const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
|
|
130
|
+
const grid = rgba(axis || style.color, 0.1);
|
|
129
131
|
const api = createChart(el, {
|
|
130
132
|
autoSize: true,
|
|
131
133
|
layout: {
|
|
@@ -133,8 +135,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
133
135
|
textColor: axis ? rgba(axis) : rgba(style.color, 0.7),
|
|
134
136
|
fontFamily: style.fontFamily,
|
|
135
137
|
fontSize: parseFloat(style.fontSize) || 11,
|
|
138
|
+
// The volume pane's border is a grid line, not the library's light-theme rule.
|
|
139
|
+
panes: { separatorColor: grid, separatorHoverColor: 'transparent', enableResize: false },
|
|
136
140
|
},
|
|
137
|
-
grid: { vertLines: { color:
|
|
141
|
+
grid: { vertLines: { color: grid }, horzLines: { color: grid } },
|
|
138
142
|
crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
|
|
139
143
|
rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } },
|
|
140
144
|
timeScale: { borderVisible: false, secondsVisible: false },
|
|
@@ -146,7 +150,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
146
150
|
topColor: rgba(style.color, 0.4),
|
|
147
151
|
bottomColor: rgba(style.color, 0.02),
|
|
148
152
|
lineWidth: 2,
|
|
149
|
-
lineType: LineType.Curved,
|
|
153
|
+
lineType: aggregate === 'mean' ? LineType.Curved : LineType.Simple,
|
|
150
154
|
crosshairMarkerBackgroundColor: ink,
|
|
151
155
|
crosshairMarkerBorderColor: ink,
|
|
152
156
|
priceFormat,
|
|
@@ -181,7 +185,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
181
185
|
chart.current = null;
|
|
182
186
|
setReady(false);
|
|
183
187
|
};
|
|
184
|
-
}, [fromZero]);
|
|
188
|
+
}, [fromZero, aggregate]);
|
|
185
189
|
// One request per resolution for the life of the loader: every range from 30D up reads the same days.
|
|
186
190
|
const cache = useRef(new Map());
|
|
187
191
|
useEffect(() => {
|
|
@@ -232,11 +236,14 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
232
236
|
const shown = view?.shown ?? [];
|
|
233
237
|
const asCandles = candles && !!view?.ohlc;
|
|
234
238
|
const time = (p) => p.time;
|
|
239
|
+
// A point with no volume is a stretch nothing traded in: its candle and its bar leave the slot
|
|
240
|
+
// empty rather than draw a flat dash. The last one always draws, as the price now.
|
|
241
|
+
const idle = (p, i) => p.volume === 0 && i < shown.length - 1;
|
|
235
242
|
c.api.applyOptions({ timeScale: { timeVisible: source.resolution !== 'day' } });
|
|
236
243
|
c.area.applyOptions({ visible: !asCandles });
|
|
237
244
|
c.candle.applyOptions({ visible: asCandles });
|
|
238
245
|
c.area.setData(asCandles ? [] : shown.map(p => ({ time: time(p), value: p.value })));
|
|
239
|
-
c.candle.setData(asCandles ? shown.map(p => ({ time: time(p), open: p.open, high: p.high, low: p.low, close: p.value })) : []);
|
|
246
|
+
c.candle.setData(asCandles ? shown.map((p, i) => (idle(p, i) ? { time: time(p) } : { time: time(p), open: p.open, high: p.high, low: p.low, close: p.value })) : []);
|
|
240
247
|
if (view?.volume && !c.volume)
|
|
241
248
|
c.volume = c.addVolume();
|
|
242
249
|
else if (!view?.volume && c.volume) {
|
|
@@ -246,6 +253,8 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
246
253
|
c.api.removePane(1);
|
|
247
254
|
}
|
|
248
255
|
c.volume?.setData(shown.map((p, i) => {
|
|
256
|
+
if (!p.volume)
|
|
257
|
+
return { time: time(p) };
|
|
249
258
|
const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
|
|
250
259
|
return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
|
|
251
260
|
}));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Digest } from './analytics.js';
|
|
2
|
+
/**
|
|
3
|
+
* The figures as `.stats-figures button[aria-pressed]` and the pressed one per
|
|
4
|
+
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
5
|
+
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function StatsFigures({ digest, first }: {
|
|
8
|
+
digest: Digest;
|
|
9
|
+
first: number;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// stats-figures.tsx — the stats page's headline figures and the chart under
|
|
4
|
+
// them. Each figure is a button that charts that figure per day, so the chart
|
|
5
|
+
// is not only visitors. Its own module because it holds state; `Stats` stays a
|
|
6
|
+
// server component and hands it the digest, which is plain JSON.
|
|
7
|
+
import { useMemo, useState } from 'react';
|
|
8
|
+
import { TimeChart } from './chart.js';
|
|
9
|
+
const count = (n) => n.toLocaleString('en');
|
|
10
|
+
const duration = (s) => (s < 60 ? `${Math.round(s)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
|
|
11
|
+
const percent = (n) => `${Math.round(n)}%`;
|
|
12
|
+
/** Unsigned at zero, so no change never reads as growth. */
|
|
13
|
+
const change = (now, before) => {
|
|
14
|
+
if (!before)
|
|
15
|
+
return null;
|
|
16
|
+
const pct = Math.round((100 * (now - before)) / before);
|
|
17
|
+
return pct > 0 ? `+${pct}%` : `${pct}%`;
|
|
18
|
+
};
|
|
19
|
+
const figuresOf = (d) => [
|
|
20
|
+
{ label: 'Visitors', value: count(d.visitors), delta: change(d.visitors, d.previous_visitors), day: r => r.visitors },
|
|
21
|
+
{ label: 'Page views', value: count(d.pageviews), day: r => r.pageviews },
|
|
22
|
+
{
|
|
23
|
+
label: 'Bounce rate',
|
|
24
|
+
value: d.bounce_rate == null ? '–' : percent(d.bounce_rate),
|
|
25
|
+
day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
|
|
26
|
+
format: percent,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
label: 'Visit length',
|
|
30
|
+
value: d.visit_duration == null ? '–' : duration(d.visit_duration),
|
|
31
|
+
day: r => (r.visits ? r.seconds / r.visits : null),
|
|
32
|
+
format: duration,
|
|
33
|
+
},
|
|
34
|
+
{ label: 'With agents', value: count(d.visitors_incl_agents), day: r => r.visitors_incl_agents },
|
|
35
|
+
...(d.follow_clicks ? [{ label: 'Clicks to X', value: count(d.follow_clicks.events), day: (r) => r.follow_clicks }] : []),
|
|
36
|
+
];
|
|
37
|
+
const DAY = 86_400;
|
|
38
|
+
const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, visitors_incl_agents: 0, follow_clicks: 0 };
|
|
39
|
+
/**
|
|
40
|
+
* The figures as `.stats-figures button[aria-pressed]` and the pressed one per
|
|
41
|
+
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
42
|
+
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function StatsFigures({ digest, first }) {
|
|
45
|
+
const figures = useMemo(() => figuresOf(digest), [digest]);
|
|
46
|
+
const [chosen, choose] = useState(0);
|
|
47
|
+
const figure = figures[chosen] ?? figures[0];
|
|
48
|
+
const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
|
|
49
|
+
const series = useMemo(() => {
|
|
50
|
+
const today = Math.floor(Date.now() / 1000 / DAY);
|
|
51
|
+
return Array.from({ length: today - first + 1 }, (_, i) => {
|
|
52
|
+
const time = (first + i) * DAY;
|
|
53
|
+
const value = figure.day(byDay.get(new Date(time * 1000).toISOString().slice(0, 10)) ?? EMPTY);
|
|
54
|
+
return value == null ? null : { time, value };
|
|
55
|
+
}).filter(p => p !== null);
|
|
56
|
+
}, [byDay, first, figure]);
|
|
57
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && _jsx("small", { children: ` ${f.delta}` })] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: `${figure.label} per day`, ranges: ['all'], aggregate: "mean", fromZero: true, format: figure.format, className: "stats-chart" })] }));
|
|
58
|
+
}
|
package/dist/stats.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365, number];
|
|
|
13
13
|
export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
|
|
14
14
|
/**
|
|
15
15
|
* A site's own analytics on one page: the headline figures against the window
|
|
16
|
-
* before,
|
|
17
|
-
* with nothing in them are left out. The chart and the
|
|
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 {
|
|
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,
|
|
36
|
-
* with nothing in them are left out. The chart and the
|
|
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(
|
|
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.
|
|
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",
|