wiki-formant 0.41.0 → 0.42.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 +3 -1
- package/dist/analytics.d.ts +9 -2
- package/dist/analytics.js +55 -36
- package/dist/base.css +1 -0
- package/dist/stats-figures.d.ts +2 -1
- package/dist/stats-figures.js +36 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -731,7 +731,9 @@ return <Stats digest={await digest(sql, q)} {...q} />;
|
|
|
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
|
|
734
|
+
headline figures, each with its change from the window before
|
|
735
|
+
(`.stats-delta[data-better]`, a share or for the bounce rate points, for the
|
|
736
|
+
site's stylesheet to colour), one of them per day, every non-empty
|
|
735
737
|
ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
736
738
|
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
737
739
|
figure is a button that charts it: visitors, page views, bounce rate, visit
|
package/dist/analytics.d.ts
CHANGED
|
@@ -113,8 +113,13 @@ export interface Digest {
|
|
|
113
113
|
visit_duration: number | null;
|
|
114
114
|
/** Visitors plus every agent that sent an event, such as an MCP call. */
|
|
115
115
|
visitors_incl_agents: number;
|
|
116
|
-
/**
|
|
117
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The headline figures over the same number of days before, narrowed alike.
|
|
118
|
+
* A figure is null where that window reaches back past `counted_from` and
|
|
119
|
+
* the archive cannot stand in for it: event figures always, all of them
|
|
120
|
+
* when filtered.
|
|
121
|
+
*/
|
|
122
|
+
previous: Record<'visitors' | 'pageviews' | 'bounce_rate' | 'visit_duration' | 'visitors_incl_agents' | 'follow_clicks', number | null>;
|
|
118
123
|
/** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
|
|
119
124
|
counted_from: string | null;
|
|
120
125
|
top_sources: Ranked<'source', 'visitors'>;
|
|
@@ -140,6 +145,8 @@ export interface Digest {
|
|
|
140
145
|
* another day counts once for each day. Days in `ViewDay` add to the visitor,
|
|
141
146
|
* page, visit, source, country, device and per-day figures; events have no
|
|
142
147
|
* history, so an archived day's visitors are its visitors with agents too.
|
|
148
|
+
* The window before is the same chain of views, visits and events a second
|
|
149
|
+
* time, `_p` on every name, so its figures are the headline's to the letter.
|
|
143
150
|
*/
|
|
144
151
|
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter }: DigestOptions): Promise<Digest>;
|
|
145
152
|
/**
|
package/dist/analytics.js
CHANGED
|
@@ -207,6 +207,8 @@ const VISIT_TEST = {
|
|
|
207
207
|
* another day counts once for each day. Days in `ViewDay` add to the visitor,
|
|
208
208
|
* page, visit, source, country, device and per-day figures; events have no
|
|
209
209
|
* history, so an archived day's visitors are its visitors with agents too.
|
|
210
|
+
* The window before is the same chain of views, visits and events a second
|
|
211
|
+
* time, `_p` on every name, so its figures are the headline's to the letter.
|
|
210
212
|
*/
|
|
211
213
|
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {} }) {
|
|
212
214
|
// The window is the database's clock, which stamped the rows; the app's can run behind it.
|
|
@@ -226,48 +228,53 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
226
228
|
WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
|
|
227
229
|
GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
|
|
228
230
|
});
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
231
|
+
/**
|
|
232
|
+
* One window's views, visits, events and archived days as CTEs whose names
|
|
233
|
+
* end in `s`: this window with `back` 0, the one before it with 1.
|
|
234
|
+
*/
|
|
235
|
+
const chain = (s, back) => {
|
|
236
|
+
const ago = (k) => `now() - make_interval(days => ${k} * $2::int)`;
|
|
237
|
+
const dayAgo = (k) => `(now() AT TIME ZONE 'UTC' - make_interval(days => ${k} * $2::int))::date`;
|
|
238
|
+
const at = `"createdAt" >= ${ago(back + 1)}${back ? ` AND "createdAt" < ${ago(back)}` : ''}`;
|
|
239
|
+
return `viewed${s} AS (
|
|
240
|
+
SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View" WHERE "siteId" = $1 AND ${at}
|
|
241
|
+
), marked${s} AS (
|
|
242
|
+
SELECT *, CASE WHEN at - lag(at) OVER (PARTITION BY visitor ORDER BY at, id) <= interval '30 minutes' THEN 0 ELSE 1 END AS starts FROM viewed${s}
|
|
243
|
+
), numbered${s} AS (
|
|
244
|
+
SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked${s}
|
|
245
|
+
), visits${s} AS (
|
|
237
246
|
SELECT visitor, visit, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
|
|
238
247
|
(array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
|
|
239
|
-
FROM numbered GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
|
|
240
|
-
), v AS (
|
|
241
|
-
${tests.length ?
|
|
242
|
-
), e AS (
|
|
243
|
-
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND
|
|
244
|
-
${tests.length ?
|
|
245
|
-
), a AS (
|
|
246
|
-
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (
|
|
247
|
-
), sessions AS (
|
|
248
|
+
FROM numbered${s} GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
|
|
249
|
+
), v${s} AS (
|
|
250
|
+
${tests.length ? `SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.at FROM numbered${s} n JOIN visits${s} USING (visitor, visit)` : `SELECT * FROM viewed${s}`}
|
|
251
|
+
), e${s} AS (
|
|
252
|
+
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
|
|
253
|
+
${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
|
|
254
|
+
), a${s} AS (
|
|
255
|
+
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
|
|
256
|
+
), sessions${s} AS (
|
|
248
257
|
SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
|
|
249
|
-
SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
|
|
250
|
-
UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a
|
|
251
|
-
)
|
|
252
|
-
),
|
|
258
|
+
SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits${s}
|
|
259
|
+
UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
|
|
260
|
+
) t(bounces, visits, seconds)
|
|
261
|
+
), archived${s} AS (
|
|
262
|
+
SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a${s}
|
|
263
|
+
)`;
|
|
264
|
+
};
|
|
265
|
+
/** One window's headline figures, as `json_build_object` arguments. */
|
|
266
|
+
const headline = (s) => `'visitors', (SELECT count(DISTINCT visitor) FROM v${s}) + (SELECT visitors FROM archived${s}),
|
|
267
|
+
'pageviews', (SELECT count(*) FROM v${s}) + (SELECT pageviews FROM archived${s}),
|
|
268
|
+
'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions${s}),
|
|
269
|
+
'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions${s}),
|
|
270
|
+
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM e${s}) u) + (SELECT visitors FROM archived${s})`;
|
|
271
|
+
const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
|
|
253
272
|
SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
|
|
254
273
|
UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
|
|
255
|
-
), archived AS (
|
|
256
|
-
SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a
|
|
257
274
|
)
|
|
258
275
|
SELECT json_build_object(
|
|
259
|
-
'
|
|
260
|
-
'
|
|
261
|
-
'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
|
|
262
|
-
'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
|
|
263
|
-
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
|
|
264
|
-
'previous_visitors', ${tests.length
|
|
265
|
-
? 'NULL'
|
|
266
|
-
: `(SELECT count(DISTINCT visitor) FROM "View"
|
|
267
|
-
WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
|
|
268
|
-
+ (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
|
|
269
|
-
AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
|
|
270
|
-
AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date)`},
|
|
276
|
+
${headline('')},
|
|
277
|
+
'previous', json_build_object(${headline('_p')}, 'follow_clicks', ${follows ? `(SELECT count(*) FROM e_p WHERE ${follows})` : 'NULL'}),
|
|
271
278
|
'counted_from', (SELECT ${day('min("createdAt")')} FROM "View" WHERE "siteId" = $1),
|
|
272
279
|
'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
|
|
273
280
|
'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
|
|
@@ -291,7 +298,19 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
291
298
|
GROUP BY 1`, 10)},
|
|
292
299
|
'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
|
|
293
300
|
) AS digest`, ...values));
|
|
294
|
-
|
|
301
|
+
const d = row.digest;
|
|
302
|
+
// Before the site counted itself only ViewDay speaks, and it holds no
|
|
303
|
+
// events and no visits a filter could pick out.
|
|
304
|
+
const before = new Date(Date.now() - 2 * values[1] * 86_400_000).toISOString().slice(0, 10);
|
|
305
|
+
if (d.counted_from && before < d.counted_from) {
|
|
306
|
+
const p = d.previous;
|
|
307
|
+
if (tests.length)
|
|
308
|
+
for (const k of Object.keys(p))
|
|
309
|
+
p[k] = null;
|
|
310
|
+
else
|
|
311
|
+
p.visitors_incl_agents = p.follow_clicks = null;
|
|
312
|
+
}
|
|
313
|
+
return d;
|
|
295
314
|
}
|
|
296
315
|
/**
|
|
297
316
|
* Props for an "MCP Call" event, read out of the JSON-RPC envelope.
|
package/dist/base.css
CHANGED
|
@@ -218,6 +218,7 @@ pre:hover > .code-copy-btn,
|
|
|
218
218
|
}
|
|
219
219
|
.stats-figures button[aria-pressed='true'] { opacity: 1; }
|
|
220
220
|
.stats-figures strong { font-variant-numeric: tabular-nums; }
|
|
221
|
+
.stats-delta { margin-inline-start: 0.4em; }
|
|
221
222
|
.stats-lists {
|
|
222
223
|
display: grid;
|
|
223
224
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
package/dist/stats-figures.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { Digest } from './analytics.js';
|
|
3
3
|
/**
|
|
4
|
-
* The figures as `.stats-figures button[aria-pressed]
|
|
4
|
+
* The figures as `.stats-figures button[aria-pressed]`, each with its change
|
|
5
|
+
* as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
|
|
5
6
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
6
7
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
7
8
|
*/
|
package/dist/stats-figures.js
CHANGED
|
@@ -9,35 +9,58 @@ import { TimeChart } from './chart.js';
|
|
|
9
9
|
const count = (n) => n.toLocaleString('en');
|
|
10
10
|
const duration = (s) => (s < 60 ? `${Math.round(s)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
|
|
11
11
|
const percent = (n) => `${Math.round(n)}%`;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The change from the window before: a share of the figure before, or for a
|
|
14
|
+
* rate its difference in points, since a bounce rate up 10% of itself reads
|
|
15
|
+
* as ten points. Unsigned and unmarked at zero, so no change never reads as
|
|
16
|
+
* growth. A lower bounce rate is the better one; every other figure, higher.
|
|
17
|
+
*/
|
|
18
|
+
function change(now, before, { points = false, lowerIsBetter = false } = {}) {
|
|
19
|
+
if (now == null || before == null || (!points && !before))
|
|
15
20
|
return null;
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
{ label: '
|
|
21
|
+
const n = Math.round(points ? now - before : (100 * (now - before)) / before);
|
|
22
|
+
const text = `${n > 0 ? '+' : ''}${n}${points ? ' pp' : '%'}`;
|
|
23
|
+
return n ? { text, better: n > 0 !== lowerIsBetter } : { text };
|
|
24
|
+
}
|
|
25
|
+
const figuresOf = ({ previous: p, ...d }) => [
|
|
26
|
+
{ label: 'Visitors', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: r => r.visitors },
|
|
27
|
+
{ label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
|
|
22
28
|
{
|
|
23
29
|
label: 'Bounce rate',
|
|
24
30
|
value: d.bounce_rate == null ? '–' : percent(d.bounce_rate),
|
|
31
|
+
delta: change(d.bounce_rate, p.bounce_rate, { points: true, lowerIsBetter: true }),
|
|
25
32
|
day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
|
|
26
33
|
format: percent,
|
|
27
34
|
},
|
|
28
35
|
{
|
|
29
36
|
label: 'Visit length',
|
|
30
37
|
value: d.visit_duration == null ? '–' : duration(d.visit_duration),
|
|
38
|
+
delta: change(d.visit_duration, p.visit_duration),
|
|
31
39
|
day: r => (r.visits ? r.seconds / r.visits : null),
|
|
32
40
|
format: duration,
|
|
33
41
|
},
|
|
34
|
-
{
|
|
35
|
-
|
|
42
|
+
{
|
|
43
|
+
label: 'With agents',
|
|
44
|
+
value: count(d.visitors_incl_agents),
|
|
45
|
+
delta: change(d.visitors_incl_agents, p.visitors_incl_agents),
|
|
46
|
+
day: r => r.visitors_incl_agents,
|
|
47
|
+
},
|
|
48
|
+
...(d.follow_clicks
|
|
49
|
+
? [
|
|
50
|
+
{
|
|
51
|
+
label: 'Clicks to X',
|
|
52
|
+
value: count(d.follow_clicks.events),
|
|
53
|
+
delta: change(d.follow_clicks.events, p.follow_clicks),
|
|
54
|
+
day: (r) => r.follow_clicks,
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
: []),
|
|
36
58
|
];
|
|
37
59
|
const DAY = 86_400;
|
|
38
60
|
const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, visitors_incl_agents: 0, follow_clicks: 0 };
|
|
39
61
|
/**
|
|
40
|
-
* The figures as `.stats-figures button[aria-pressed]
|
|
62
|
+
* The figures as `.stats-figures button[aria-pressed]`, each with its change
|
|
63
|
+
* as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
|
|
41
64
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
42
65
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
43
66
|
*/
|
|
@@ -54,5 +77,5 @@ export function StatsFigures({ digest, first, windows }) {
|
|
|
54
77
|
return value == null ? null : { time, value };
|
|
55
78
|
}).filter(p => p !== null);
|
|
56
79
|
}, [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", {
|
|
80
|
+
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", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: `${figure.label} per day`, ranges: ['all'], aggregate: "mean", fromZero: true, format: figure.format, controls: windows, className: "stats-chart" })] }));
|
|
58
81
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.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",
|