wiki-formant 0.33.0 → 0.35.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 +11 -6
- package/dist/analytics.d.ts +2 -1
- package/dist/analytics.js +58 -15
- package/dist/base.css +3 -16
- package/dist/react-server.d.ts +0 -20
- package/dist/react-server.js +0 -55
- package/dist/stats-chart.d.ts +4 -0
- package/dist/stats-chart.js +71 -0
- package/dist/stats.d.ts +20 -0
- package/dist/stats.js +56 -0
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -641,8 +641,10 @@ address and browser under a salt that is replaced every UTC day, and the old
|
|
|
641
641
|
salt is deleted, so a visitor is told apart within a day and never followed
|
|
642
642
|
into the next. Rows are kept 400 days. The package owns the SQL and the app
|
|
643
643
|
owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
|
|
644
|
-
placeholders, and the
|
|
645
|
-
top of `src/analytics.ts`.
|
|
644
|
+
placeholders, and the four tables it needs are stated as Prisma models at the
|
|
645
|
+
top of `src/analytics.ts`. The fourth, `ViewDay`, holds days a site counted
|
|
646
|
+
elsewhere before it counted its own; `digest` adds them to any window that
|
|
647
|
+
reaches back that far, and a site with no such history leaves it empty.
|
|
646
648
|
|
|
647
649
|
```ts
|
|
648
650
|
// src/lib/track.ts
|
|
@@ -722,19 +724,22 @@ export const POST = gate.route;
|
|
|
722
724
|
// src/app/stats/page.tsx
|
|
723
725
|
if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
|
|
724
726
|
return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
|
|
725
|
-
const days = statsDays((await searchParams).days); // 1, 7, 30 or
|
|
727
|
+
const days = statsDays((await searchParams).days); // 1, 7, 30, 90 or 365, else 30
|
|
726
728
|
return <Stats digest={await digest(sql, { days })} days={days} />;
|
|
727
729
|
```
|
|
728
730
|
|
|
729
731
|
`PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
|
|
730
732
|
own subpath, because it imports the optional peer `@simplewebauthn/browser`.
|
|
731
|
-
`Stats` (`wiki-formant/
|
|
733
|
+
`Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
|
|
732
734
|
headline figures against the window before, visitors per day, every non-empty
|
|
733
|
-
ranked list, and `?days=` links between windows.
|
|
735
|
+
ranked list, and `?days=` links between windows. Everything but the daily chart
|
|
736
|
+
is server-rendered. The chart is a `lightweight-charts` area series with a date
|
|
737
|
+
axis and a count axis from zero, drawn in the box's own `color`, so
|
|
738
|
+
`lightweight-charts` is an optional peer that a stats page needs installed.
|
|
734
739
|
|
|
735
740
|
## Base stylesheet
|
|
736
741
|
|
|
737
|
-
`wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid and
|
|
742
|
+
`wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid, bars and chart height. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
|
|
738
743
|
|
|
739
744
|
```css
|
|
740
745
|
@import "wiki-formant/base.css" layer(components);
|
package/dist/analytics.d.ts
CHANGED
|
@@ -111,7 +111,8 @@ export interface Digest {
|
|
|
111
111
|
/**
|
|
112
112
|
* Everything a report needs about one site's last `days` days, in one query.
|
|
113
113
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
114
|
-
* another day counts once for each day.
|
|
114
|
+
* 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.
|
|
115
116
|
*/
|
|
116
117
|
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
|
|
117
118
|
/**
|
package/dist/analytics.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
// The package owns the SQL; the app owns the connection. `Sql` is one function
|
|
16
16
|
// that runs a parameterised statement, which Prisma, `pg` and postgres.js can
|
|
17
|
-
// all supply in a line. The app declares the
|
|
17
|
+
// all supply in a line. The app declares the four tables in its own schema,
|
|
18
18
|
// so `prisma db push` keeps them:
|
|
19
19
|
//
|
|
20
20
|
// model View {
|
|
@@ -45,6 +45,28 @@
|
|
|
45
45
|
// value String
|
|
46
46
|
// }
|
|
47
47
|
//
|
|
48
|
+
// model ViewDay {
|
|
49
|
+
// siteId String @default("")
|
|
50
|
+
// day DateTime @db.Date
|
|
51
|
+
// visitors Int
|
|
52
|
+
// pageviews Int
|
|
53
|
+
// visits Int
|
|
54
|
+
// bounces Int
|
|
55
|
+
// seconds Int
|
|
56
|
+
// pages Json @default("{}")
|
|
57
|
+
// entryPages Json @default("{}")
|
|
58
|
+
// sources Json @default("{}")
|
|
59
|
+
// countries Json @default("{}")
|
|
60
|
+
// devices Json @default("{}")
|
|
61
|
+
// @@id([siteId, day])
|
|
62
|
+
// }
|
|
63
|
+
//
|
|
64
|
+
// `ViewDay` holds days a site counted somewhere else before it counted its
|
|
65
|
+
// own, one row a day with each breakdown as `{ value: visitors }`. Nothing
|
|
66
|
+
// writes or deletes it: the app loads it once, and `digest` adds the days in a
|
|
67
|
+
// window to the views. Summing days agrees with the views, whose visitor is
|
|
68
|
+
// one browser on one day. A site with no history leaves it empty.
|
|
69
|
+
//
|
|
48
70
|
// `siteId` is "" in an app that serves one site. A multi-tenant app passes its
|
|
49
71
|
// own site key to every call.
|
|
50
72
|
//
|
|
@@ -169,13 +191,16 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
|
|
|
169
191
|
/**
|
|
170
192
|
* Everything a report needs about one site's last `days` days, in one query.
|
|
171
193
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
172
|
-
* another day counts once for each day.
|
|
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.
|
|
173
196
|
*/
|
|
174
197
|
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
|
|
175
198
|
// The window is the database's clock, which stamped the rows; the app's can run behind it.
|
|
176
199
|
const values = [site, Math.max(1, Math.round(days))];
|
|
177
200
|
const $ = (v) => `$${values.push(v)}`;
|
|
178
201
|
const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
|
|
202
|
+
/** A ranked list from the views, plus the same breakdown from `ViewDay`. */
|
|
203
|
+
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`;
|
|
179
204
|
const gapSelects = gaps.map(g => {
|
|
180
205
|
const prop = $(g.prop);
|
|
181
206
|
return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
|
|
@@ -195,23 +220,41 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
195
220
|
FROM numbered GROUP BY visitor, visit
|
|
196
221
|
), e AS (
|
|
197
222
|
SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
|
|
223
|
+
), a AS (
|
|
224
|
+
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date
|
|
225
|
+
), sessions AS (
|
|
226
|
+
SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
|
|
227
|
+
SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
|
|
228
|
+
UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a
|
|
229
|
+
) s(bounces, visits, seconds)
|
|
230
|
+
), src AS (
|
|
231
|
+
SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
|
|
232
|
+
UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
|
|
233
|
+
), archived AS (
|
|
234
|
+
SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a
|
|
198
235
|
)
|
|
199
236
|
SELECT json_build_object(
|
|
200
|
-
'visitors', (SELECT count(DISTINCT visitor) FROM v),
|
|
201
|
-
'pageviews', (SELECT count(*) FROM v),
|
|
202
|
-
'bounce_rate', (SELECT round(100.0 *
|
|
203
|
-
'visit_duration', (SELECT round(
|
|
204
|
-
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e)
|
|
237
|
+
'visitors', (SELECT count(DISTINCT visitor) FROM v) + (SELECT visitors FROM archived),
|
|
238
|
+
'pageviews', (SELECT count(*) FROM v) + (SELECT pageviews FROM archived),
|
|
239
|
+
'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
|
|
240
|
+
'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
|
|
241
|
+
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
|
|
205
242
|
'previous_visitors', (SELECT count(DISTINCT visitor) FROM "View"
|
|
206
|
-
WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
243
|
+
WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
|
|
244
|
+
+ (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
|
|
245
|
+
AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
|
|
246
|
+
AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date),
|
|
247
|
+
'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
|
|
248
|
+
'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
|
|
249
|
+
'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
|
|
210
250
|
'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
|
|
211
|
-
'sibling_referrals', ${list(`SELECT source,
|
|
212
|
-
'countries', ${list('SELECT country, count(DISTINCT visitor)
|
|
213
|
-
'devices', ${list('SELECT device, count(DISTINCT visitor)
|
|
214
|
-
'by_day', ${list(`SELECT
|
|
251
|
+
'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
|
|
252
|
+
'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
|
|
253
|
+
'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
|
|
254
|
+
'by_day', ${list(`SELECT date, sum(n) AS visitors FROM (
|
|
255
|
+
SELECT to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD'), count(DISTINCT visitor) FROM v GROUP BY 1
|
|
256
|
+
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors FROM a
|
|
257
|
+
) t(date, n) GROUP BY 1`, undefined, '1')},
|
|
215
258
|
'follow_clicks', ${handle
|
|
216
259
|
? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e
|
|
217
260
|
WHERE name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[]))`
|
package/dist/base.css
CHANGED
|
@@ -183,22 +183,9 @@ pre:hover > .code-copy-btn,
|
|
|
183
183
|
margin: 0;
|
|
184
184
|
}
|
|
185
185
|
.stats-figures dd { margin: 0; font-variant-numeric: tabular-nums; }
|
|
186
|
-
.stats-chart {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
gap: 2px;
|
|
190
|
-
height: 8rem;
|
|
191
|
-
margin: 0;
|
|
192
|
-
padding: 0;
|
|
193
|
-
list-style: none;
|
|
194
|
-
}
|
|
195
|
-
.stats-chart li {
|
|
196
|
-
flex: 1;
|
|
197
|
-
height: calc(var(--share) * 100%);
|
|
198
|
-
min-height: 1px;
|
|
199
|
-
background: currentColor;
|
|
200
|
-
opacity: 0.4;
|
|
201
|
-
}
|
|
186
|
+
.stats-chart { height: 15rem; }
|
|
187
|
+
/* The chart lays itself out in a <table>; a site's table styles are not for it. */
|
|
188
|
+
.stats-chart :is(table, tr, td) { background: none; border: 0; border-radius: 0; }
|
|
202
189
|
.stats-lists {
|
|
203
190
|
display: grid;
|
|
204
191
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
package/dist/react-server.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from 'react';
|
|
2
2
|
import type { Control, FacetControlGroup, MetadataRow } from './taxonomy.js';
|
|
3
3
|
import type { HistoryChange } from './history.js';
|
|
4
|
-
import type { Digest } from './analytics.js';
|
|
5
4
|
/**
|
|
6
5
|
* The shape a router's link component has to satisfy here.
|
|
7
6
|
*
|
|
@@ -268,22 +267,3 @@ export interface RevisionChangesProps {
|
|
|
268
267
|
* are the importing stylesheet's.
|
|
269
268
|
*/
|
|
270
269
|
export declare function RevisionChanges({ changes, label }: RevisionChangesProps): import("react").JSX.Element | null;
|
|
271
|
-
export interface StatsProps {
|
|
272
|
-
/** One window's figures, from `digest` in `wiki-formant/analytics`. */
|
|
273
|
-
digest: Digest;
|
|
274
|
-
/** The window the digest covers, in days. */
|
|
275
|
-
days: number;
|
|
276
|
-
/** The windows offered as `?days=` links on the same page. */
|
|
277
|
-
ranges?: readonly number[];
|
|
278
|
-
}
|
|
279
|
-
/** The windows the stats page offers, in days. */
|
|
280
|
-
export declare const STATS_RANGES: readonly [1, 7, 30, 90];
|
|
281
|
-
/** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
|
|
282
|
-
export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
|
|
283
|
-
/**
|
|
284
|
-
* A site's own analytics on one page: the headline figures against the window
|
|
285
|
-
* before, visitors per day, and every ranked list the digest carries. Lists
|
|
286
|
-
* with nothing in them are left out. Bars are `currentColor` until the
|
|
287
|
-
* importing stylesheet gives them a colour.
|
|
288
|
-
*/
|
|
289
|
-
export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
|
package/dist/react-server.js
CHANGED
|
@@ -225,58 +225,3 @@ export function RevisionChanges({ changes, label = type => type }) {
|
|
|
225
225
|
return null;
|
|
226
226
|
return (_jsx("ul", { className: "revision-changes", children: changes.map((c, i) => (_jsxs("li", { className: "revision-change", "data-action": c.action, children: [_jsx("span", { className: "revision-change-action", children: ACTION_LABEL[c.action] }), ` – ${label(c.type)} at ${blockLocation(c.path)}`, c.leafDiff && (_jsx("p", { className: "revision-diff", children: c.leafDiff.map(([op, text], j) => op === -1 ? _jsx("del", { children: text }, j) : op === 1 ? _jsx("ins", { children: text }, j) : _jsx("span", { children: text }, j)) }))] }, i))) }));
|
|
227
227
|
}
|
|
228
|
-
/** The windows the stats page offers, in days. */
|
|
229
|
-
export const STATS_RANGES = [1, 7, 30, 90];
|
|
230
|
-
/** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
|
|
231
|
-
export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => String(n) === param) ?? fallback;
|
|
232
|
-
const count = (n) => n.toLocaleString('en');
|
|
233
|
-
const regions = new Intl.DisplayNames(['en'], { type: 'region' });
|
|
234
|
-
const country = (code) => {
|
|
235
|
-
try {
|
|
236
|
-
return regions.of(code) ?? code;
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
return code;
|
|
240
|
-
}
|
|
241
|
-
};
|
|
242
|
-
const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
|
|
243
|
-
/** Unsigned at zero, so no change never reads as growth. */
|
|
244
|
-
const change = (now, before) => {
|
|
245
|
-
if (!before)
|
|
246
|
-
return null;
|
|
247
|
-
const pct = Math.round((100 * (now - before)) / before);
|
|
248
|
-
return pct > 0 ? `+${pct}%` : `${pct}%`;
|
|
249
|
-
};
|
|
250
|
-
function StatsList({ title, rows, label = v => v }) {
|
|
251
|
-
if (!rows.length)
|
|
252
|
-
return null;
|
|
253
|
-
const max = Math.max(...rows.map(([, n]) => n));
|
|
254
|
-
return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* A site's own analytics on one page: the headline figures against the window
|
|
258
|
-
* before, visitors per day, and every ranked list the digest carries. Lists
|
|
259
|
-
* with nothing in them are left out. Bars are `currentColor` until the
|
|
260
|
-
* importing stylesheet gives them a colour.
|
|
261
|
-
*/
|
|
262
|
-
export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
|
|
263
|
-
const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
|
|
264
|
-
// A rolling window of n days touches n + 1 UTC dates, the first of them in part.
|
|
265
|
-
const now = Date.now();
|
|
266
|
-
const series = Array.from({ length: days + 1 }, (_, i) => {
|
|
267
|
-
const date = new Date(now - (days - i) * 86_400_000).toISOString().slice(0, 10);
|
|
268
|
-
return [date, byDay.get(date) ?? 0];
|
|
269
|
-
});
|
|
270
|
-
const peak = Math.max(1, ...series.map(([, n]) => n));
|
|
271
|
-
const figures = [
|
|
272
|
-
['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
|
|
273
|
-
['Page views', count(d.pageviews), null],
|
|
274
|
-
['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
|
|
275
|
-
['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
|
|
276
|
-
['With agents', count(d.visitors_incl_agents), null],
|
|
277
|
-
];
|
|
278
|
-
if (d.follow_clicks)
|
|
279
|
-
figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
|
|
280
|
-
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
281
|
-
return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx("ol", { className: "stats-chart", "aria-label": "Visitors per day", children: series.map(([date, n]) => (_jsx("li", { title: `${date}: ${count(n)}`, style: { '--share': n / peak } }, date))) }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
282
|
-
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// stats-chart.tsx — visitors per day for `Stats`, with a date axis and a count
|
|
4
|
+
// axis. `lightweight-charts` is loaded on mount, so the rest of the page paints
|
|
5
|
+
// without it. Every colour is the box's own `color`, like the bars beside it.
|
|
6
|
+
import { useEffect, useRef } from 'react';
|
|
7
|
+
/**
|
|
8
|
+
* A CSS colour at an opacity, as the rgba() the chart's parser reads. Drawn to
|
|
9
|
+
* a pixel and read back, because a site's colour may be oklch() or a named one.
|
|
10
|
+
*/
|
|
11
|
+
function rgba(colour, alpha = 1) {
|
|
12
|
+
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
13
|
+
if (!ctx)
|
|
14
|
+
return colour;
|
|
15
|
+
ctx.fillStyle = colour;
|
|
16
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
17
|
+
return `rgba(${Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(', ')}, ${alpha})`;
|
|
18
|
+
}
|
|
19
|
+
/** Counts start at zero, so a quiet week does not read as a collapse. */
|
|
20
|
+
const fromZero = (base) => {
|
|
21
|
+
const info = base();
|
|
22
|
+
if (info?.priceRange)
|
|
23
|
+
info.priceRange.minValue = 0;
|
|
24
|
+
return info;
|
|
25
|
+
};
|
|
26
|
+
/** One `[YYYY-MM-DD, visitors]` per UTC day, oldest first. */
|
|
27
|
+
export function StatsChart({ series }) {
|
|
28
|
+
const ref = useRef(null);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const box = ref.current;
|
|
31
|
+
if (!box)
|
|
32
|
+
return;
|
|
33
|
+
let chart;
|
|
34
|
+
let disposed = false;
|
|
35
|
+
import('lightweight-charts').then(({ createChart, AreaSeries, ColorType, CrosshairMode, LineStyle }) => {
|
|
36
|
+
if (disposed)
|
|
37
|
+
return;
|
|
38
|
+
const { color, fontFamily } = getComputedStyle(box);
|
|
39
|
+
const ink = rgba(color);
|
|
40
|
+
const guide = { color: rgba(color, 0.4), width: 1, style: LineStyle.Dotted };
|
|
41
|
+
chart = createChart(box, {
|
|
42
|
+
autoSize: true,
|
|
43
|
+
layout: { background: { type: ColorType.Solid, color: 'transparent' }, textColor: rgba(color, 0.7), fontFamily },
|
|
44
|
+
grid: { vertLines: { visible: false }, horzLines: { color: rgba(color, 0.1) } },
|
|
45
|
+
crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
|
|
46
|
+
rightPriceScale: { borderVisible: false },
|
|
47
|
+
timeScale: { borderVisible: false, fixLeftEdge: true, fixRightEdge: true },
|
|
48
|
+
handleScroll: false,
|
|
49
|
+
handleScale: false,
|
|
50
|
+
});
|
|
51
|
+
chart
|
|
52
|
+
.addSeries(AreaSeries, {
|
|
53
|
+
lineColor: ink,
|
|
54
|
+
topColor: rgba(color, 0.4),
|
|
55
|
+
bottomColor: rgba(color, 0.02),
|
|
56
|
+
lineWidth: 2,
|
|
57
|
+
crosshairMarkerBackgroundColor: ink,
|
|
58
|
+
priceLineVisible: false,
|
|
59
|
+
priceFormat: { type: 'price', precision: 0, minMove: 1 },
|
|
60
|
+
autoscaleInfoProvider: fromZero,
|
|
61
|
+
})
|
|
62
|
+
.setData(series.map(([time, value]) => ({ time, value })));
|
|
63
|
+
chart.timeScale().fitContent();
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
disposed = true;
|
|
67
|
+
chart?.remove();
|
|
68
|
+
};
|
|
69
|
+
}, [series]);
|
|
70
|
+
return _jsx("div", { ref: ref, className: "stats-chart", role: "img", "aria-label": "Visitors per day" });
|
|
71
|
+
}
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Digest } from './analytics.js';
|
|
2
|
+
export interface StatsProps {
|
|
3
|
+
/** One window's figures, from `digest` in `wiki-formant/analytics`. */
|
|
4
|
+
digest: Digest;
|
|
5
|
+
/** The window the digest covers, in days. */
|
|
6
|
+
days: number;
|
|
7
|
+
/** The windows offered as `?days=` links on the same page. */
|
|
8
|
+
ranges?: readonly number[];
|
|
9
|
+
}
|
|
10
|
+
/** The windows the stats page offers, in days. */
|
|
11
|
+
export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365];
|
|
12
|
+
/** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
|
|
13
|
+
export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
|
|
14
|
+
/**
|
|
15
|
+
* A site's own analytics on one page: the headline figures against the window
|
|
16
|
+
* before, visitors per day, and every ranked list the digest carries. Lists
|
|
17
|
+
* with nothing in them are left out. The chart and the bars are
|
|
18
|
+
* `currentColor` until the importing stylesheet gives them a colour.
|
|
19
|
+
*/
|
|
20
|
+
export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { StatsChart } from './stats-chart.js';
|
|
3
|
+
/** The windows the stats page offers, in days. */
|
|
4
|
+
export const STATS_RANGES = [1, 7, 30, 90, 365];
|
|
5
|
+
/** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
|
|
6
|
+
export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => String(n) === param) ?? fallback;
|
|
7
|
+
const count = (n) => n.toLocaleString('en');
|
|
8
|
+
const regions = new Intl.DisplayNames(['en'], { type: 'region' });
|
|
9
|
+
const country = (code) => {
|
|
10
|
+
try {
|
|
11
|
+
return regions.of(code) ?? code;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return code;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
|
|
18
|
+
/** Unsigned at zero, so no change never reads as growth. */
|
|
19
|
+
const change = (now, before) => {
|
|
20
|
+
if (!before)
|
|
21
|
+
return null;
|
|
22
|
+
const pct = Math.round((100 * (now - before)) / before);
|
|
23
|
+
return pct > 0 ? `+${pct}%` : `${pct}%`;
|
|
24
|
+
};
|
|
25
|
+
function StatsList({ title, rows, label = v => v }) {
|
|
26
|
+
if (!rows.length)
|
|
27
|
+
return null;
|
|
28
|
+
const max = Math.max(...rows.map(([, n]) => n));
|
|
29
|
+
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))) })] }));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A site's own analytics on one page: the headline figures against the window
|
|
33
|
+
* before, visitors per day, and every ranked list the digest carries. Lists
|
|
34
|
+
* with nothing in them are left out. The chart and the bars are
|
|
35
|
+
* `currentColor` until the importing stylesheet gives them a colour.
|
|
36
|
+
*/
|
|
37
|
+
export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
|
|
38
|
+
const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
|
|
39
|
+
// A rolling window of n days touches n + 1 UTC dates, the first of them in part.
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const series = Array.from({ length: days + 1 }, (_, i) => {
|
|
42
|
+
const date = new Date(now - (days - i) * 86_400_000).toISOString().slice(0, 10);
|
|
43
|
+
return [date, byDay.get(date) ?? 0];
|
|
44
|
+
});
|
|
45
|
+
const figures = [
|
|
46
|
+
['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
|
|
47
|
+
['Page views', count(d.pageviews), null],
|
|
48
|
+
['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
|
|
49
|
+
['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
|
|
50
|
+
['With agents', count(d.visitors_incl_agents), null],
|
|
51
|
+
];
|
|
52
|
+
if (d.follow_clicks)
|
|
53
|
+
figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
|
|
54
|
+
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
55
|
+
return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx(StatsChart, { series: series }), _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') })] })] }));
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -172,6 +172,11 @@
|
|
|
172
172
|
"import": "./dist/react-server.js",
|
|
173
173
|
"default": "./dist/react-server.js"
|
|
174
174
|
},
|
|
175
|
+
"./stats": {
|
|
176
|
+
"types": "./dist/stats.d.ts",
|
|
177
|
+
"import": "./dist/stats.js",
|
|
178
|
+
"default": "./dist/stats.js"
|
|
179
|
+
},
|
|
175
180
|
"./dom": {
|
|
176
181
|
"types": "./dist/dom.d.ts",
|
|
177
182
|
"import": "./dist/dom.js",
|
|
@@ -266,11 +271,14 @@
|
|
|
266
271
|
"@types/sanitize-html": "^2.16.1",
|
|
267
272
|
"fast-diff": "^1.3.0",
|
|
268
273
|
"jsdom": "^30.1.0",
|
|
274
|
+
"lightweight-charts": "^5.2.1",
|
|
269
275
|
"react": "^19.2.4",
|
|
270
276
|
"sanitize-html": "^2.17.7",
|
|
271
277
|
"typescript": "^5.9.0"
|
|
272
278
|
},
|
|
273
279
|
"peerDependencies": {
|
|
280
|
+
"@simplewebauthn/browser": ">=13",
|
|
281
|
+
"@simplewebauthn/server": ">=13",
|
|
274
282
|
"@tiptap/core": ">=2.10",
|
|
275
283
|
"@tiptap/extension-code-block": ">=2.10",
|
|
276
284
|
"@tiptap/extension-image": ">=2.10",
|
|
@@ -288,8 +296,7 @@
|
|
|
288
296
|
"pg": ">=8",
|
|
289
297
|
"react": ">=18",
|
|
290
298
|
"sanitize-html": ">=2",
|
|
291
|
-
"
|
|
292
|
-
"@simplewebauthn/browser": ">=13"
|
|
299
|
+
"lightweight-charts": ">=5"
|
|
293
300
|
},
|
|
294
301
|
"peerDependenciesMeta": {
|
|
295
302
|
"react": {
|
|
@@ -348,6 +355,9 @@
|
|
|
348
355
|
},
|
|
349
356
|
"@simplewebauthn/browser": {
|
|
350
357
|
"optional": true
|
|
358
|
+
},
|
|
359
|
+
"lightweight-charts": {
|
|
360
|
+
"optional": true
|
|
351
361
|
}
|
|
352
362
|
}
|
|
353
363
|
}
|