wiki-formant 0.39.0 → 0.41.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 +15 -4
- package/dist/analytics.d.ts +14 -3
- package/dist/analytics.js +28 -8
- package/dist/base.css +11 -4
- package/dist/chart.d.ts +7 -1
- package/dist/chart.js +2 -2
- package/dist/stats-figures.d.ts +3 -1
- package/dist/stats-figures.js +2 -2
- package/dist/stats.d.ts +17 -5
- package/dist/stats.js +48 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -724,21 +724,32 @@ 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
|
|
728
|
-
return <Stats digest={await digest(sql,
|
|
727
|
+
const q = statsQuery(await searchParams); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
|
|
728
|
+
return <Stats digest={await digest(sql, q)} {...q} />;
|
|
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
733
|
`Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
|
|
734
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
|
|
735
|
+
ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
736
|
+
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
736
737
|
figure is a button that charts it: visitors, page views, bounce rate, visit
|
|
737
738
|
length, visitors with agents and clicks to X. `digest`'s `by_day` carries every
|
|
738
739
|
one of those per UTC day, a visit on the day it began, so the days add up to the
|
|
739
740
|
totals. A day with no visits has no bounce rate or visit length and is left out
|
|
740
741
|
of those charts rather than drawn as zero. Everything but the figures and the
|
|
741
|
-
chart is server-rendered.
|
|
742
|
+
chart is server-rendered.
|
|
743
|
+
|
|
744
|
+
Every row in a list is a link that narrows the page to it, and the narrowings
|
|
745
|
+
stack: `?page=`, `?entry=`, `?source=`, `?country=` and `?device=`, each shown as
|
|
746
|
+
a chip that drops it. A narrowing keeps the visits that pass – that saw the
|
|
747
|
+
page, began on it, came from the source, or were made from the country or
|
|
748
|
+
device – and recounts every figure, list and day from those visits alone, with
|
|
749
|
+
events only from their visitors. Agent tool calls and missed searches are
|
|
750
|
+
events no visit reaches, so their rows are not links. `ViewDay` keeps totals,
|
|
751
|
+
not visits, so a narrowed page starts at `counted_from`, the first day the site
|
|
752
|
+
counted itself, says so, and drops the comparison with the window before. The chart is `TimeChart` with a count
|
|
742
753
|
axis from zero, so `lightweight-charts` is an optional peer that a stats page
|
|
743
754
|
needs installed.
|
|
744
755
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -65,6 +65,9 @@ export interface Gap {
|
|
|
65
65
|
}
|
|
66
66
|
/** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
|
|
67
67
|
export declare const SEARCH_GAPS: Gap;
|
|
68
|
+
/** What a stats page can be narrowed to: one value each, every one applied. */
|
|
69
|
+
export declare const STATS_FILTERS: readonly ["page", "entry", "source", "country", "device"];
|
|
70
|
+
export type StatsFilter = Partial<Record<(typeof STATS_FILTERS)[number], string>>;
|
|
68
71
|
export interface DigestOptions {
|
|
69
72
|
site?: string;
|
|
70
73
|
/** How far back, in whole days from now; `Infinity` for all time. */
|
|
@@ -79,6 +82,12 @@ export interface DigestOptions {
|
|
|
79
82
|
* The default is the conformance suites (`<repo>-mcp-test`) and the studio's own tools.
|
|
80
83
|
*/
|
|
81
84
|
ownAgents?: string[];
|
|
85
|
+
/**
|
|
86
|
+
* Narrows every figure to the visits that pass, and the events to their
|
|
87
|
+
* visitors. `ViewDay` keeps no visits, so a narrowed digest leaves it out
|
|
88
|
+
* and starts at `counted_from`, and has no `previous_visitors`.
|
|
89
|
+
*/
|
|
90
|
+
filter?: StatsFilter;
|
|
82
91
|
}
|
|
83
92
|
type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
|
|
84
93
|
/** One UTC day of the figures a digest totals; a visit falls on the day it began. */
|
|
@@ -104,8 +113,10 @@ export interface Digest {
|
|
|
104
113
|
visit_duration: number | null;
|
|
105
114
|
/** Visitors plus every agent that sent an event, such as an MCP call. */
|
|
106
115
|
visitors_incl_agents: number;
|
|
107
|
-
/** Visitors in the same number of days before. */
|
|
108
|
-
previous_visitors: number;
|
|
116
|
+
/** Visitors in the same number of days before; null when filtered. */
|
|
117
|
+
previous_visitors: number | null;
|
|
118
|
+
/** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
|
|
119
|
+
counted_from: string | null;
|
|
109
120
|
top_sources: Ranked<'source', 'visitors'>;
|
|
110
121
|
top_pages: Ranked<'page', 'visitors'>;
|
|
111
122
|
entry_pages: Ranked<'page', 'visitors'>;
|
|
@@ -130,7 +141,7 @@ export interface Digest {
|
|
|
130
141
|
* page, visit, source, country, device and per-day figures; events have no
|
|
131
142
|
* history, so an archived day's visitors are its visitors with agents too.
|
|
132
143
|
*/
|
|
133
|
-
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
|
|
144
|
+
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter }: DigestOptions): Promise<Digest>;
|
|
134
145
|
/**
|
|
135
146
|
* Props for an "MCP Call" event, read out of the JSON-RPC envelope.
|
|
136
147
|
*
|
package/dist/analytics.js
CHANGED
|
@@ -188,6 +188,19 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
|
|
|
188
188
|
}
|
|
189
189
|
/** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
|
|
190
190
|
export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
|
|
191
|
+
/** What a stats page can be narrowed to: one value each, every one applied. */
|
|
192
|
+
export const STATS_FILTERS = ['page', 'entry', 'source', 'country', 'device'];
|
|
193
|
+
/**
|
|
194
|
+
* Each filter as a test on one visit. A page, country or device is any of the
|
|
195
|
+
* visit's views; an entry page or a source is its first. `(none)` is no source.
|
|
196
|
+
*/
|
|
197
|
+
const VISIT_TEST = {
|
|
198
|
+
page: v => `bool_or(path = ${v})`,
|
|
199
|
+
entry: v => `(array_agg(path ORDER BY at, id))[1] = ${v}`,
|
|
200
|
+
source: v => `coalesce((array_agg(source ORDER BY at, id))[1], '(none)') = ${v}`,
|
|
201
|
+
country: v => `bool_or(country = ${v})`,
|
|
202
|
+
device: v => `bool_or(device = ${v})`,
|
|
203
|
+
};
|
|
191
204
|
/**
|
|
192
205
|
* Everything a report needs about one site's last `days` days, in one query.
|
|
193
206
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
@@ -195,7 +208,7 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
|
|
|
195
208
|
* page, visit, source, country, device and per-day figures; events have no
|
|
196
209
|
* history, so an archived day's visitors are its visitors with agents too.
|
|
197
210
|
*/
|
|
198
|
-
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
|
|
211
|
+
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {} }) {
|
|
199
212
|
// The window is the database's clock, which stamped the rows; the app's can run behind it.
|
|
200
213
|
// All time is a century: further back than any row, and still an int to Postgres.
|
|
201
214
|
const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
|
|
@@ -205,6 +218,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
205
218
|
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
219
|
/** Clicks on links to the site's own X account, among `e`. */
|
|
207
220
|
const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
|
|
221
|
+
const tests = STATS_FILTERS.flatMap(k => (filter[k] ? [VISIT_TEST[k]($(filter[k]))] : []));
|
|
208
222
|
const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
|
|
209
223
|
const gapSelects = gaps.map(g => {
|
|
210
224
|
const prop = $(g.prop);
|
|
@@ -212,21 +226,24 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
212
226
|
WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
|
|
213
227
|
GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
|
|
214
228
|
});
|
|
215
|
-
const [row] = (await sql(`WITH
|
|
229
|
+
const [row] = (await sql(`WITH viewed AS (
|
|
216
230
|
SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View"
|
|
217
231
|
WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
|
|
218
232
|
), marked AS (
|
|
219
|
-
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
|
|
233
|
+
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
|
|
220
234
|
), numbered AS (
|
|
221
235
|
SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
|
|
222
236
|
), visits AS (
|
|
223
|
-
SELECT visitor, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
|
|
237
|
+
SELECT visitor, visit, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
|
|
224
238
|
(array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
|
|
225
|
-
FROM numbered GROUP BY visitor, visit
|
|
239
|
+
FROM numbered GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
|
|
240
|
+
), v AS (
|
|
241
|
+
${tests.length ? 'SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.at FROM numbered n JOIN visits USING (visitor, visit)' : 'SELECT * FROM viewed'}
|
|
226
242
|
), e AS (
|
|
227
243
|
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
|
|
244
|
+
${tests.length ? 'AND visitor IN (SELECT visitor FROM v)' : ''}
|
|
228
245
|
), a AS (
|
|
229
|
-
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date
|
|
246
|
+
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date${tests.length ? ' AND false' : ''}
|
|
230
247
|
), sessions AS (
|
|
231
248
|
SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
|
|
232
249
|
SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
|
|
@@ -244,11 +261,14 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
244
261
|
'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
|
|
245
262
|
'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
|
|
246
263
|
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
|
|
247
|
-
'previous_visitors',
|
|
264
|
+
'previous_visitors', ${tests.length
|
|
265
|
+
? 'NULL'
|
|
266
|
+
: `(SELECT count(DISTINCT visitor) FROM "View"
|
|
248
267
|
WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
|
|
249
268
|
+ (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
|
|
250
269
|
AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
|
|
251
|
-
AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date),
|
|
270
|
+
AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date)`},
|
|
271
|
+
'counted_from', (SELECT ${day('min("createdAt")')} FROM "View" WHERE "siteId" = $1),
|
|
252
272
|
'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
|
|
253
273
|
'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
|
|
254
274
|
'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
|
package/dist/base.css
CHANGED
|
@@ -190,15 +190,14 @@ pre:hover > .code-copy-btn,
|
|
|
190
190
|
gap: 0.5rem;
|
|
191
191
|
}
|
|
192
192
|
.chart-controls > div { display: flex; gap: 2px; }
|
|
193
|
-
.chart-controls button { font: inherit; color: inherit; background: none; cursor: pointer; opacity: 0.6; }
|
|
194
|
-
|
|
193
|
+
.chart-controls :is(button, a) { font: inherit; color: inherit; text-decoration: none; background: none; cursor: pointer; opacity: 0.6; }
|
|
194
|
+
/* A button is pressed; a link (a stats page's window) is the page shown. */
|
|
195
|
+
.chart-controls :is([aria-pressed='true'], [aria-current]) { opacity: 1; }
|
|
195
196
|
|
|
196
197
|
/* ---- Stats ----------------------------------------------------------------
|
|
197
198
|
`--share` is a row's value over the largest in its list, 0 to 1. Bars take
|
|
198
199
|
`currentColor` so they show before a stylesheet colours them. */
|
|
199
200
|
.stats { display: grid; gap: 2rem; }
|
|
200
|
-
.stats-ranges { display: flex; flex-wrap: wrap; gap: 1rem; }
|
|
201
|
-
.stats-ranges a[aria-current] { text-decoration: underline; text-underline-offset: 0.3em; }
|
|
202
201
|
/* Each figure is a button that charts it; pressed is the one drawn. */
|
|
203
202
|
.stats-figures {
|
|
204
203
|
display: grid;
|
|
@@ -242,4 +241,12 @@ pre:hover > .code-copy-btn,
|
|
|
242
241
|
pointer-events: none;
|
|
243
242
|
}
|
|
244
243
|
.stats-list li > span:first-child { min-width: 0; overflow-wrap: anywhere; }
|
|
244
|
+
/* A row narrows the page to itself; the whole row is the link. */
|
|
245
|
+
.stats-list a { color: inherit; text-decoration: none; }
|
|
246
|
+
.stats-list a::after { content: ''; position: absolute; inset: 0; }
|
|
247
|
+
.stats-list a:is(:hover, :focus-visible) { text-decoration: underline; text-underline-offset: 0.2em; }
|
|
248
|
+
.stats-filters { display: grid; gap: 0.5rem; }
|
|
249
|
+
.stats-filters ul { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }
|
|
250
|
+
.stats-filters a { display: inline-block; padding: 0.25em 0.75em; border: 1px solid; border-radius: 9999px; text-decoration: none; }
|
|
251
|
+
.stats-filters p { margin: 0; opacity: 0.7; }
|
|
245
252
|
.stats-count { font-variant-numeric: tabular-nums; }
|
package/dist/chart.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
1
2
|
/**
|
|
2
3
|
* One value at a time in Unix seconds, UTC. A price may carry the rest of its
|
|
3
4
|
* candle, `value` being the close, and the volume traded in it.
|
|
@@ -52,6 +53,11 @@ export interface TimeChartProps {
|
|
|
52
53
|
candles?: boolean;
|
|
53
54
|
/** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
|
|
54
55
|
height?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Groups pinned before the chart's own, styled as its buttons: a stats page's
|
|
58
|
+
* window links, which load another digest rather than slice this one.
|
|
59
|
+
*/
|
|
60
|
+
controls?: ReactNode;
|
|
55
61
|
className?: string;
|
|
56
62
|
}
|
|
57
63
|
/**
|
|
@@ -59,4 +65,4 @@ export interface TimeChartProps {
|
|
|
59
65
|
* `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
|
|
60
66
|
* and the box their look; the line and fill are the canvas box's `color`.
|
|
61
67
|
*/
|
|
62
|
-
export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, candles: initialCandles, height, className, }: TimeChartProps): import("react").JSX.Element;
|
|
68
|
+
export declare function TimeChart({ series, label, ranges, range: initial, aggregate, fromZero, format, candles: initialCandles, height, controls, className, }: TimeChartProps): import("react").JSX.Element;
|
package/dist/chart.js
CHANGED
|
@@ -101,7 +101,7 @@ const fromZeroScale = (base) => {
|
|
|
101
101
|
* `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
|
|
102
102
|
* and the box their look; the line and fill are the canvas box's `color`.
|
|
103
103
|
*/
|
|
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, }) {
|
|
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, controls, className, }) {
|
|
105
105
|
const box = useRef(null);
|
|
106
106
|
const chart = useRef(null);
|
|
107
107
|
const formatRef = useRef(format);
|
|
@@ -261,5 +261,5 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
|
|
|
261
261
|
c.api.timeScale().fitContent();
|
|
262
262
|
}, [ready, source, view, candles]);
|
|
263
263
|
const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
|
|
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" })] }))] }))] }));
|
|
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 }), (controls || ranges.length > 1 || (view && (view.steps.length > 1 || view.ohlc))) && (_jsxs("div", { className: "chart-controls", children: [controls, 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" })] }))] }))] }));
|
|
265
265
|
}
|
package/dist/stats-figures.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
1
2
|
import type { Digest } from './analytics.js';
|
|
2
3
|
/**
|
|
3
4
|
* The figures as `.stats-figures button[aria-pressed]` and the pressed one per
|
|
4
5
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
5
6
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
6
7
|
*/
|
|
7
|
-
export declare function StatsFigures({ digest, first }: {
|
|
8
|
+
export declare function StatsFigures({ digest, first, windows }: {
|
|
8
9
|
digest: Digest;
|
|
9
10
|
first: number;
|
|
11
|
+
windows: ReactNode;
|
|
10
12
|
}): import("react").JSX.Element;
|
package/dist/stats-figures.js
CHANGED
|
@@ -41,7 +41,7 @@ const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seco
|
|
|
41
41
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
42
42
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
43
43
|
*/
|
|
44
|
-
export function StatsFigures({ digest, first }) {
|
|
44
|
+
export function StatsFigures({ digest, first, windows }) {
|
|
45
45
|
const figures = useMemo(() => figuresOf(digest), [digest]);
|
|
46
46
|
const [chosen, choose] = useState(0);
|
|
47
47
|
const figure = figures[chosen] ?? figures[0];
|
|
@@ -54,5 +54,5 @@ export function StatsFigures({ digest, first }) {
|
|
|
54
54
|
return value == null ? null : { time, value };
|
|
55
55
|
}).filter(p => p !== null);
|
|
56
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" })] }));
|
|
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, controls: windows, className: "stats-chart" })] }));
|
|
58
58
|
}
|
package/dist/stats.d.ts
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Digest, type StatsFilter } from './analytics.js';
|
|
2
2
|
export interface StatsProps {
|
|
3
3
|
/** One window's figures, from `digest` in `wiki-formant/analytics`. */
|
|
4
4
|
digest: Digest;
|
|
5
5
|
/** The window the digest covers, in days; `Infinity` for all time. */
|
|
6
6
|
days: number;
|
|
7
|
+
/** What the digest was narrowed to, as `digest` was given it. */
|
|
8
|
+
filter?: StatsFilter;
|
|
7
9
|
/** The windows offered as `?days=` links on the same page. */
|
|
8
10
|
ranges?: readonly number[];
|
|
9
11
|
}
|
|
10
12
|
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
11
13
|
export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365, number];
|
|
12
|
-
/** A `?days=` value as one of `ranges`, else `fallback
|
|
14
|
+
/** A `?days=` value as one of `ranges`, else `fallback`. */
|
|
13
15
|
export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
|
|
16
|
+
/**
|
|
17
|
+
* A stats page's query as the window and the filter, both ready for `digest`
|
|
18
|
+
* and `Stats`: `const q = statsQuery(await searchParams)`. A repeated or empty
|
|
19
|
+
* parameter is ignored.
|
|
20
|
+
*/
|
|
21
|
+
export declare function statsQuery(params: Record<string, string | string[] | undefined>, ranges?: readonly number[]): {
|
|
22
|
+
days: number;
|
|
23
|
+
filter: Partial<Record<"page" | "entry" | "source" | "country" | "device", string>>;
|
|
24
|
+
};
|
|
14
25
|
/**
|
|
15
26
|
* A site's own analytics on one page: the headline figures against the window
|
|
16
27
|
* before, whichever of them is chosen per day, and every ranked list the
|
|
17
|
-
* digest carries
|
|
18
|
-
*
|
|
28
|
+
* digest carries, each row a link that narrows the page to it. Lists with
|
|
29
|
+
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
30
|
+
* until the importing stylesheet gives them a colour.
|
|
19
31
|
*/
|
|
20
|
-
export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
|
|
32
|
+
export declare function Stats({ digest: d, days, filter, ranges }: StatsProps): import("react").JSX.Element;
|
package/dist/stats.js
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { STATS_FILTERS } from './analytics.js';
|
|
2
3
|
import { StatsFigures } from './stats-figures.js';
|
|
3
4
|
/** The windows the stats page offers, in days; `Infinity` is all time. */
|
|
4
5
|
export const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
|
|
5
6
|
/** A window as its `?days=` value. */
|
|
6
7
|
const daysParam = (n) => (Number.isFinite(n) ? String(n) : 'all');
|
|
7
|
-
/** A
|
|
8
|
+
/** A window as `TimeChart` names its ranges, since its buttons are where the windows sit. */
|
|
9
|
+
const windowLabel = (n) => (n === 1 ? '24H' : n === 365 ? '1Y' : Number.isFinite(n) ? `${n}D` : 'All');
|
|
10
|
+
/** A `?days=` value as one of `ranges`, else `fallback`. */
|
|
8
11
|
export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => daysParam(n) === param) ?? fallback;
|
|
12
|
+
/**
|
|
13
|
+
* A stats page's query as the window and the filter, both ready for `digest`
|
|
14
|
+
* and `Stats`: `const q = statsQuery(await searchParams)`. A repeated or empty
|
|
15
|
+
* parameter is ignored.
|
|
16
|
+
*/
|
|
17
|
+
export function statsQuery(params, ranges = STATS_RANGES) {
|
|
18
|
+
const filter = {};
|
|
19
|
+
for (const k of STATS_FILTERS) {
|
|
20
|
+
const v = params[k];
|
|
21
|
+
if (typeof v === 'string' && v)
|
|
22
|
+
filter[k] = v.slice(0, 500);
|
|
23
|
+
}
|
|
24
|
+
return { days: statsDays(params.days, ranges), filter };
|
|
25
|
+
}
|
|
26
|
+
/** The page at a window and a filter, as a query string. */
|
|
27
|
+
const href = (days, filter) => {
|
|
28
|
+
const q = new URLSearchParams({ days: daysParam(days) });
|
|
29
|
+
for (const k of STATS_FILTERS)
|
|
30
|
+
if (filter[k])
|
|
31
|
+
q.set(k, filter[k]);
|
|
32
|
+
return `?${q}`;
|
|
33
|
+
};
|
|
9
34
|
const count = (n) => n.toLocaleString('en');
|
|
10
35
|
const regions = new Intl.DisplayNames(['en'], { type: 'region' });
|
|
11
36
|
const country = (code) => {
|
|
@@ -16,23 +41,36 @@ const country = (code) => {
|
|
|
16
41
|
return code;
|
|
17
42
|
}
|
|
18
43
|
};
|
|
19
|
-
|
|
44
|
+
const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
|
|
45
|
+
const DAY_MS = 86_400_000;
|
|
46
|
+
function StatsList({ title, rows, label = v => v, to, }) {
|
|
20
47
|
if (!rows.length)
|
|
21
48
|
return null;
|
|
22
49
|
const max = Math.max(...rows.map(([, n]) => n));
|
|
23
|
-
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))) })] }));
|
|
50
|
+
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: to ? _jsx("a", { href: to(v), children: label(v) }) : label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
|
|
24
51
|
}
|
|
25
52
|
/**
|
|
26
53
|
* A site's own analytics on one page: the headline figures against the window
|
|
27
54
|
* before, whichever of them is chosen per day, and every ranked list the
|
|
28
|
-
* digest carries
|
|
29
|
-
*
|
|
55
|
+
* digest carries, each row a link that narrows the page to it. Lists with
|
|
56
|
+
* nothing in them are left out. The chart and the bars are `currentColor`
|
|
57
|
+
* until the importing stylesheet gives them a colour.
|
|
30
58
|
*/
|
|
31
|
-
export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
|
|
59
|
+
export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
|
|
60
|
+
const today = Math.floor(Date.now() / DAY_MS);
|
|
61
|
+
const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
|
|
62
|
+
const narrowed = STATS_FILTERS.filter(k => filter[k]);
|
|
32
63
|
// A rolling window of n days touches n + 1 UTC dates, the first of them in
|
|
33
|
-
// part. All time starts at the first day anything was counted
|
|
34
|
-
|
|
35
|
-
const
|
|
64
|
+
// part. All time starts at the first day anything was counted, and a
|
|
65
|
+
// narrowed window at the first day the site counted itself.
|
|
66
|
+
const start = Number.isFinite(days) ? today - days : -Infinity;
|
|
67
|
+
const first = narrowed.length
|
|
68
|
+
? Math.max(start, counted)
|
|
69
|
+
: Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
|
|
36
70
|
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
37
|
-
|
|
71
|
+
const to = (k) => (v) => href(days, { ...filter, [k]: v });
|
|
72
|
+
// Each window is another digest, so a link rather than a button, pinned
|
|
73
|
+
// with the chart's own controls.
|
|
74
|
+
const windows = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: href(n, filter), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }));
|
|
75
|
+
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: href(days, { ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, windows: windows }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => href(days, { ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _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') })] })] }));
|
|
38
76
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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",
|