wiki-formant 0.104.0 → 0.105.1
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 -3
- package/bin/maintenance-log.mjs +2 -2
- package/dist/analytics.d.ts +25 -21
- package/dist/analytics.js +59 -55
- package/dist/block-views.js +3 -1
- package/dist/dom.d.ts +5 -0
- package/dist/dom.js +42 -1
- package/dist/editor.js +11 -7
- package/dist/maintenance.d.ts +1 -1
- package/dist/maintenance.js +8 -2
- package/dist/react.js +14 -0
- package/dist/stats-figures.d.ts +1 -2
- package/dist/stats-figures.js +17 -30
- package/dist/stats.d.ts +3 -7
- package/dist/stats.js +10 -15
- package/package.json +1 -1
- package/prisma/analytics.prisma +2 -0
package/README.md
CHANGED
|
@@ -702,7 +702,7 @@ const SCHEMA: BlockSchema<Block> = {
|
|
|
702
702
|
|
|
703
703
|
## Analytics
|
|
704
704
|
|
|
705
|
-
`wiki-formant/analytics`: page views and events in the site's own Postgres. No cookie and no stored address: a visitor is a hash of address and browser under a salt replaced every UTC day. Rows are kept 400 days. The tables ship in `prisma/analytics.prisma`; `ViewDay` holds days a site counted elsewhere first.
|
|
705
|
+
`wiki-formant/analytics`: page views and events in the site's own Postgres. No cookie and no stored address: a visitor is a hash of address and browser under a salt replaced every UTC day. Rows are kept 400 days. A view is marked engaged once its reader has had the page visible fifteen seconds or given it input, so `engaged` counts people where `agents` counts crawlers. The tables ship in `prisma/analytics.prisma`; `ViewDay` holds days a site counted elsewhere first.
|
|
706
706
|
|
|
707
707
|
```ts
|
|
708
708
|
// src/lib/track.ts
|
|
@@ -715,7 +715,7 @@ export const POST = viewRoute();
|
|
|
715
715
|
|
|
716
716
|
- `createTracker` records only on a Vercel production deployment unless `enabled` says otherwise. `trackMcpCall` fits `McpServerConfig.onCall`; `trackSearch` normalises a query and drops an empty one; `recordEvent` is the undeferred write.
|
|
717
717
|
- A proxy reaches the tracker through a dynamic `import()` inside `event.waitUntil`, since it imports the database client.
|
|
718
|
-
- `digest(sql, { days })` is the site as one JSON object:
|
|
718
|
+
- `digest(sql, { days })` is the site as one JSON object: people, engaged people, agents, views, visits, bounce rate, sources, pages, countries, devices, by day, agents by name, MCP calls and searches that found nothing, less a search its visitor went on typing within five minutes and any of one character. A visit is a run of views with no gap over 30 minutes; a bounce is a one-view visit its reader never engaged with. Agents are counted beside people, never among them: the ranked lists are people's. `ownAgents` leaves the site's own out. A multi-tenant app passes `site`.
|
|
719
719
|
- A browser under automation (`navigator.webdriver`) sends no beacon.
|
|
720
720
|
|
|
721
721
|
## Passkeys and the stats page
|
|
@@ -741,7 +741,7 @@ return (
|
|
|
741
741
|
|
|
742
742
|
- There is no sign-up: every passkey is saved from a single-use link `npx passkey-invite` prints. A passkey is bound to its host. Deleting a `Passkey` row signs out every session it opened. Tokens are kept only as hashes (`prisma/passkey.prisma`).
|
|
743
743
|
- `gate.signedIn(cookie)` is a page's check and `gate.allows(request)` a write route's.
|
|
744
|
-
- `statsQuery` reads `?days=` (1, 7, 30, 90, 365 or all)
|
|
744
|
+
- `statsQuery` reads `?days=` (1, 7, 30, 90, 365 or all) and the narrowings `?page=`, `?entry=`, `?source=`, `?country=`, `?device=`, which stack and recount every figure from the visits that pass.
|
|
745
745
|
- Each headline figure shows its change from the window before (`.stats-delta[data-better]`) and charts on press. A narrowed page over `ViewDay` totals starts at `counted_from` and says so.
|
|
746
746
|
|
|
747
747
|
## ROLA sign-in
|
package/bin/maintenance-log.mjs
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// the item back in, so a run's whole-state write cannot drop it. Every mode
|
|
32
32
|
// reads and writes under the row lock. Only `state` in the page's metadata is
|
|
33
33
|
// written; other keys are kept. A revision bumps the page's version a minor.
|
|
34
|
-
import { PINNED, addBacklog, compact, lateBacklog, render, staleWrite } from '../dist/maintenance.js';
|
|
34
|
+
import { PINNED, addBacklog, compact, isOpen, lateBacklog, render, staleWrite } from '../dist/maintenance.js';
|
|
35
35
|
import { bump } from '../dist/versioning.js';
|
|
36
36
|
import { arg, connect, invalidate } from './_cli.mjs';
|
|
37
37
|
|
|
@@ -131,7 +131,7 @@ try {
|
|
|
131
131
|
const kept = new Set(state.backlog.map(b => b.item));
|
|
132
132
|
for (const b of input.backlog ?? []) {
|
|
133
133
|
const item = typeof b === 'string' ? b : b.item;
|
|
134
|
-
if (PINNED.test(item ?? '') && (
|
|
134
|
+
if (PINNED.test(item ?? '') && isOpen(b) && !kept.has(item)) {
|
|
135
135
|
console.log(` flag dropped over the cap: ${item}`);
|
|
136
136
|
}
|
|
137
137
|
}
|
package/dist/analytics.d.ts
CHANGED
|
@@ -25,11 +25,12 @@ export interface EventInput {
|
|
|
25
25
|
}
|
|
26
26
|
/** Record one named event. Never rejects. */
|
|
27
27
|
export declare function recordEvent(sql: Sql, { name, props, url, headers, site }: EventInput): Promise<void>;
|
|
28
|
-
/** What a page sends: a view, or with `name` an event. */
|
|
28
|
+
/** What a page sends: a view, with `engaged` its reader's engagement, or with `name` an event. */
|
|
29
29
|
export interface Beacon {
|
|
30
30
|
path: string;
|
|
31
31
|
referrer?: string;
|
|
32
32
|
utm?: string;
|
|
33
|
+
engaged?: boolean;
|
|
33
34
|
name?: string;
|
|
34
35
|
props?: Record<string, string | number>;
|
|
35
36
|
}
|
|
@@ -83,57 +84,60 @@ export interface DigestOptions {
|
|
|
83
84
|
* and has no `previous_visitors`.
|
|
84
85
|
*/
|
|
85
86
|
filter?: StatsFilter;
|
|
86
|
-
/**
|
|
87
|
-
* People and agents counted apart, as they are by default: the ranked lists
|
|
88
|
-
* count people. False counts agents in the pages, entry pages, sources,
|
|
89
|
-
* countries and devices too, as `visitors_incl_agents` counts them, so a
|
|
90
|
-
* stats page's lists add up to the visitors its chart draws.
|
|
91
|
-
*/
|
|
92
|
-
apart?: boolean;
|
|
93
87
|
}
|
|
94
88
|
type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
|
|
95
89
|
/** One UTC day of the figures a digest totals; a visit falls on the day it began. */
|
|
96
90
|
export interface DigestDay {
|
|
97
91
|
date: string;
|
|
98
92
|
visitors: number;
|
|
93
|
+
/** Visitors who engaged with a page they viewed (`startBeacon`). */
|
|
94
|
+
engaged: number;
|
|
99
95
|
pageviews: number;
|
|
100
96
|
visits: number;
|
|
101
|
-
/** Visits that
|
|
97
|
+
/** Visits that bounced (`Digest.bounce_rate`); an archived day has none. */
|
|
102
98
|
bounces: number;
|
|
103
|
-
/** Seconds from each visit's first view to its last, summed. */
|
|
104
|
-
seconds: number;
|
|
105
99
|
agents: number;
|
|
106
|
-
visitors_incl_agents: number;
|
|
107
100
|
/** Clicks on links to the site's X handle; zero without a handle. */
|
|
108
101
|
follow_clicks: number;
|
|
109
102
|
}
|
|
110
103
|
export interface Digest {
|
|
111
104
|
visitors: number;
|
|
105
|
+
/**
|
|
106
|
+
* Visitors who engaged with a page they viewed: had it in a visible tab for
|
|
107
|
+
* fifteen seconds, or gave it input (`startBeacon`). A crawler that runs
|
|
108
|
+
* scripts renders a page and goes, so these are people. `ViewDay` keeps no
|
|
109
|
+
* engagement, so an archived day adds nothing, and the window before is
|
|
110
|
+
* compared only where engagement was counted from its first day.
|
|
111
|
+
*/
|
|
112
|
+
engaged: number;
|
|
112
113
|
pageviews: number;
|
|
113
114
|
/** Runs of one visitor's views with no gap over 30 minutes. */
|
|
114
115
|
visits: number;
|
|
115
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Percent of visits that bounced: saw one page and never engaged with it,
|
|
118
|
+
* the complement of `engaged`. Over the site's own visits, since `ViewDay`
|
|
119
|
+
* keeps no engagement; null with none. Compared with the window before as
|
|
120
|
+
* `engaged` is.
|
|
121
|
+
*/
|
|
116
122
|
bounce_rate: number | null;
|
|
117
|
-
/** Mean seconds from a visit's first view to its last; null with no visits. */
|
|
118
|
-
visit_duration: number | null;
|
|
119
123
|
/**
|
|
120
124
|
* Agents, counted as visitors are: an AI crawler, an MCP client, or anything
|
|
121
125
|
* else reading the machine surface (`trackAiBot`), one per address and user
|
|
122
126
|
* agent a day, the site's own left out. They run no page script, so they are
|
|
123
|
-
* never among `visitors
|
|
127
|
+
* never among `visitors`, and the ranked lists count people alone.
|
|
124
128
|
*/
|
|
125
129
|
agents: number;
|
|
126
|
-
/** Visitors and `agents` as one count, which a stats page shows unless asked to keep them apart. */
|
|
127
|
-
visitors_incl_agents: number;
|
|
128
130
|
/**
|
|
129
131
|
* The headline figures over the same number of days before, narrowed alike.
|
|
130
132
|
* A figure is null where that window reaches back past `counted_from` and
|
|
131
133
|
* the archive cannot stand in for it: event figures always, all of them
|
|
132
134
|
* when filtered.
|
|
133
135
|
*/
|
|
134
|
-
previous: Record<'visitors' | '
|
|
136
|
+
previous: Record<'visitors' | 'engaged' | 'pageviews' | 'visits' | 'bounce_rate' | 'agents' | 'follow_clicks', number | null>;
|
|
135
137
|
/** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
|
|
136
138
|
counted_from: string | null;
|
|
139
|
+
/** The first UTC day, in this window or the one before, with a view marked engaged, before which `engaged` and `bounce_rate` mean nothing; null with none. */
|
|
140
|
+
engaged_from: string | null;
|
|
137
141
|
/** `(bot)` stands for each referrer `BOT_VISITORS` takes for a crawler's. */
|
|
138
142
|
top_sources: Ranked<'source', 'visitors'>;
|
|
139
143
|
top_pages: Ranked<'page', 'visitors'>;
|
|
@@ -167,11 +171,11 @@ export interface Digest {
|
|
|
167
171
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
168
172
|
* another day counts once for each day. Days in `ViewDay` add to the visitor,
|
|
169
173
|
* page, visit, source, country, device and per-day figures; events have no
|
|
170
|
-
* history
|
|
174
|
+
* history.
|
|
171
175
|
* The window before is the same chain of views, visits and events a second
|
|
172
176
|
* time, `_p` on every name, so its figures are the headline's to the letter.
|
|
173
177
|
*/
|
|
174
|
-
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter
|
|
178
|
+
export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents, filter }: DigestOptions): Promise<Digest>;
|
|
175
179
|
/**
|
|
176
180
|
* Props for an "MCP Call" event, read out of the JSON-RPC envelope.
|
|
177
181
|
*
|
package/dist/analytics.js
CHANGED
|
@@ -103,6 +103,19 @@ export function recordView(sql, { headers, path, referrer, utm, site = '' }) {
|
|
|
103
103
|
return Promise.resolve();
|
|
104
104
|
return quietly(async () => sql('INSERT INTO "View" ("siteId", path, source, country, device, visitor) VALUES ($1, $2, $3, $4, $5, $6)', site, page, sourceOf(referrer, utm, headers.get('host') ?? ''), headers.get('x-vercel-ip-country')?.slice(0, 2) || null, deviceOf(ua), await visitorOf(sql, site, headers)));
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Mark a visitor's latest unengaged view of `path` in the last half hour as
|
|
108
|
+
* engaged: the beacon's second word on a view, sent once its reader engaged
|
|
109
|
+
* (`startBeacon`). Never rejects. @internal
|
|
110
|
+
*/
|
|
111
|
+
export function recordEngaged(sql, { headers, path, site = '' }) {
|
|
112
|
+
const page = normalisePath(path);
|
|
113
|
+
if (page === null)
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
return quietly(async () => sql(`UPDATE "View" SET engaged = true WHERE id = (
|
|
116
|
+
SELECT id FROM "View" WHERE "siteId" = $1 AND visitor = $2 AND path = $3 AND NOT engaged AND "createdAt" > now() - interval '30 minutes'
|
|
117
|
+
ORDER BY id DESC LIMIT 1)`, site, await visitorOf(sql, site, headers), page));
|
|
118
|
+
}
|
|
106
119
|
/** Record one named event. Never rejects. */
|
|
107
120
|
export function recordEvent(sql, { name, props = {}, url, headers, site = '' }) {
|
|
108
121
|
return quietly(async () => {
|
|
@@ -147,7 +160,9 @@ export async function collect(request, { sql, defer, site, rateLimit: limit = BE
|
|
|
147
160
|
// decoded twice, `/100%25-off` was dropped and `/what%3Fnow` cut to `/what`.
|
|
148
161
|
defer(() => typeof body.name === 'string' && body.name
|
|
149
162
|
? recordEvent(sql, { name: body.name, props: beaconProps(body.props), url: raw, headers, site: key })
|
|
150
|
-
:
|
|
163
|
+
: body.engaged === true
|
|
164
|
+
? recordEngaged(sql, { headers, path: raw, site: key })
|
|
165
|
+
: recordView(sql, { headers, path: raw, referrer: body.referrer, utm: body.utm, site: key }));
|
|
151
166
|
return done;
|
|
152
167
|
}
|
|
153
168
|
/** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
|
|
@@ -181,20 +196,17 @@ const VISIT_TEST = {
|
|
|
181
196
|
country: v => `bool_or(country = ${v})`,
|
|
182
197
|
device: v => `bool_or(device = ${v})`,
|
|
183
198
|
};
|
|
184
|
-
/** An agent's source and device in the lists, which no request of its carries. */
|
|
185
|
-
const AGENT_SOURCE = '(agent)';
|
|
186
|
-
const AGENT_DEVICE = 'agent';
|
|
187
199
|
/**
|
|
188
200
|
* Each filter as a test on one agent: its reads in the window are its visit,
|
|
189
|
-
* the first its entry page
|
|
190
|
-
*
|
|
201
|
+
* the first its entry page, and its country the one its requests came from.
|
|
202
|
+
* No request of an agent's carries a source or a device, so neither passes one.
|
|
191
203
|
*/
|
|
192
204
|
const AGENT_TEST = {
|
|
193
205
|
page: v => `bool_or(path = ${v})`,
|
|
194
206
|
entry: v => `(array_agg(path ORDER BY at))[1] = ${v}`,
|
|
195
|
-
source:
|
|
207
|
+
source: () => 'false',
|
|
196
208
|
country: v => `bool_or(country = ${v})`,
|
|
197
|
-
device:
|
|
209
|
+
device: () => 'false',
|
|
198
210
|
};
|
|
199
211
|
/**
|
|
200
212
|
* An agent's name from its event: the roster's label, else what its user agent
|
|
@@ -206,11 +218,11 @@ const AGENT_NAME = `coalesce(props->>'bot', substring(props->>'ua' from 'compati
|
|
|
206
218
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
207
219
|
* another day counts once for each day. Days in `ViewDay` add to the visitor,
|
|
208
220
|
* page, visit, source, country, device and per-day figures; events have no
|
|
209
|
-
* history
|
|
221
|
+
* history.
|
|
210
222
|
* The window before is the same chain of views, visits and events a second
|
|
211
223
|
* time, `_p` on every name, so its figures are the headline's to the letter.
|
|
212
224
|
*/
|
|
213
|
-
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {}
|
|
225
|
+
export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'], filter = {} }) {
|
|
214
226
|
// The window is the database's clock, which stamped the rows; the app's can run behind it.
|
|
215
227
|
// All time is a century: further back than any row, and still an int to Postgres.
|
|
216
228
|
const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
|
|
@@ -220,15 +232,10 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
220
232
|
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`;
|
|
221
233
|
/** Clicks on links to the site's own X account, among `e`. */
|
|
222
234
|
const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return [(s) => VISIT_TEST[k](v, s)];
|
|
228
|
-
});
|
|
229
|
-
const agentTests = STATS_FILTERS.flatMap(k => (filter[k] ? [AGENT_TEST[k]($(filter[k]))] : []));
|
|
230
|
-
/** Agents among the rows of a ranked list, unless they are counted apart. */
|
|
231
|
-
const agentsToo = (select) => (apart ? '' : ` UNION ALL ${select}`);
|
|
235
|
+
/** Each filter given, its value a parameter that the test on visits and the one on agents share. */
|
|
236
|
+
const given = STATS_FILTERS.flatMap(k => (filter[k] ? [[k, $(filter[k])]] : []));
|
|
237
|
+
const tests = given.map(([k, v]) => (s) => VISIT_TEST[k](v, s));
|
|
238
|
+
const agentTests = given.map(([k, v]) => AGENT_TEST[k](v));
|
|
232
239
|
const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
|
|
233
240
|
/** The site's own agents, as a test on an event's user agent. */
|
|
234
241
|
const ours = `coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])`;
|
|
@@ -259,7 +266,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
259
266
|
const dayAgo = (k) => `(now() AT TIME ZONE 'UTC' - make_interval(days => ${k} * $2::int))::date`;
|
|
260
267
|
const at = `"createdAt" >= ${ago(back + 1)}${back ? ` AND "createdAt" < ${ago(back)}` : ''}`;
|
|
261
268
|
return `viewed${s} AS (
|
|
262
|
-
SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View" WHERE "siteId" = $1 AND ${at}
|
|
269
|
+
SELECT id, path, source, country, device, visitor, engaged, "createdAt" AS at FROM "View" WHERE "siteId" = $1 AND ${at}
|
|
263
270
|
), marked${s} AS (
|
|
264
271
|
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}
|
|
265
272
|
), numbered${s} AS (
|
|
@@ -268,11 +275,11 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
268
275
|
SELECT source FROM (SELECT visitor, (array_agg(source ORDER BY at, id))[1] AS source, count(*) AS views FROM numbered${s} GROUP BY visitor, visit) f
|
|
269
276
|
WHERE source IS NOT NULL GROUP BY 1 HAVING count(DISTINCT visitor) >= ${BOT_VISITORS} AND max(views) = 1
|
|
270
277
|
), visits${s} AS (
|
|
271
|
-
SELECT visitor, visit, min(at) AS at, count(*)
|
|
278
|
+
SELECT visitor, visit, min(at) AS at, count(*) = 1 AND NOT bool_or(engaged) AS bounced,
|
|
272
279
|
(array_agg(path ORDER BY at, id))[1] AS entry, ${visitSource(s)} AS source
|
|
273
280
|
FROM numbered${s} GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.map(t => t(s)).join(' AND ')}` : ''}
|
|
274
281
|
), v${s} AS (
|
|
275
|
-
${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}`}
|
|
282
|
+
${tests.length ? `SELECT n.id, n.path, n.source, n.country, n.device, n.visitor, n.engaged, n.at FROM numbered${s} n JOIN visits${s} USING (visitor, visit)` : `SELECT * FROM viewed${s}`}
|
|
276
283
|
), read${s} AS (
|
|
277
284
|
SELECT visitor, "createdAt" AS at, path, props->>'country' AS country, ${AGENT_NAME} AS agent FROM "Event"
|
|
278
285
|
WHERE "siteId" = $1 AND ${at} AND name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
|
|
@@ -281,56 +288,53 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
281
288
|
), e${s} AS (
|
|
282
289
|
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
|
|
283
290
|
${tests.length ? `AND (visitor IN (SELECT visitor FROM v${s}) OR visitor IN (SELECT visitor FROM ag${s}))` : ''}
|
|
284
|
-
), acted${s} AS MATERIALIZED (
|
|
285
|
-
SELECT visitor, at FROM e${s} WHERE name NOT IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL
|
|
286
|
-
), bounced${s} AS (
|
|
287
|
-
-- Against the few reader events alone: probing every agent event once per visit took seconds.
|
|
288
|
-
SELECT * FROM visits${s} b WHERE views = 1 AND NOT EXISTS (
|
|
289
|
-
SELECT 1 FROM acted${s} x WHERE x.visitor = b.visitor AND x.at >= b.at AND x.at < b.at + interval '30 minutes')
|
|
290
291
|
), a${s} AS (
|
|
291
292
|
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
|
|
292
|
-
), sessions${s} AS (
|
|
293
|
-
SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
|
|
294
|
-
SELECT (SELECT count(*) FROM bounced${s}), count(*), coalesce(sum(seconds), 0) FROM visits${s}
|
|
295
|
-
UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
|
|
296
|
-
) t(bounces, visits, seconds)
|
|
297
293
|
), archived${s} AS (
|
|
298
|
-
SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a${s}
|
|
294
|
+
SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews, coalesce(sum(visits), 0) AS visits FROM a${s}
|
|
299
295
|
)`;
|
|
300
296
|
};
|
|
297
|
+
/**
|
|
298
|
+
* A figure engagement decides. The window before has it only where
|
|
299
|
+
* engagement was counted from its first day: its first engaged view is on the day of its first view.
|
|
300
|
+
*/
|
|
301
|
+
const engagement = (s, figure) => {
|
|
302
|
+
if (!s)
|
|
303
|
+
return figure;
|
|
304
|
+
const first = (where) => `(SELECT ${day('min(at)')} FROM viewed${s}${where})`;
|
|
305
|
+
return `CASE WHEN ${first(' WHERE engaged')} = ${first('')} THEN ${figure} END`;
|
|
306
|
+
};
|
|
301
307
|
/** One window's headline figures, as `json_build_object` arguments. */
|
|
302
308
|
const headline = (s) => `'visitors', (SELECT count(DISTINCT visitor) FROM v${s}) + (SELECT visitors FROM archived${s}),
|
|
309
|
+
'engaged', ${engagement(s, `(SELECT count(DISTINCT visitor) FROM v${s} WHERE engaged)`)},
|
|
303
310
|
'pageviews', (SELECT count(*) FROM v${s}) + (SELECT pageviews FROM archived${s}),
|
|
304
|
-
'visits', (SELECT visits FROM
|
|
305
|
-
'bounce_rate', (SELECT round(100.0 *
|
|
306
|
-
'
|
|
307
|
-
'agents', (SELECT count(DISTINCT visitor) FROM ag${s}),
|
|
308
|
-
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM ag${s}) u) + (SELECT visitors FROM archived${s})`;
|
|
311
|
+
'visits', (SELECT count(*) FROM visits${s}) + (SELECT visits FROM archived${s}),
|
|
312
|
+
'bounce_rate', ${engagement(s, `(SELECT round(100.0 * count(*) FILTER (WHERE bounced) / nullif(count(*), 0)) FROM visits${s})`)},
|
|
313
|
+
'agents', (SELECT count(DISTINCT visitor) FROM ag${s})`;
|
|
309
314
|
const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
|
|
310
315
|
SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
|
|
311
|
-
UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
|
|
316
|
+
UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
|
|
312
317
|
)
|
|
313
318
|
SELECT json_build_object(
|
|
314
319
|
${headline('')},
|
|
315
320
|
'previous', json_build_object(${headline('_p')}, 'follow_clicks', ${follows ? `(SELECT count(*) FROM e_p WHERE ${follows})` : 'NULL'}),
|
|
316
321
|
'counted_from', (SELECT ${day('min("createdAt")')} FROM "View" WHERE "siteId" = $1),
|
|
322
|
+
'engaged_from', (SELECT ${day('min(at)')} FROM (SELECT at FROM viewed_p WHERE engaged UNION ALL SELECT at FROM viewed WHERE engaged) t),
|
|
317
323
|
'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
|
|
318
|
-
'top_pages', ${list(merged('page', 'pages',
|
|
319
|
-
'entry_pages', ${list(merged('page', 'entryPages',
|
|
324
|
+
'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
|
|
325
|
+
'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
|
|
320
326
|
'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
|
|
321
327
|
'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
|
|
322
|
-
'countries', ${list(merged('country', 'countries',
|
|
323
|
-
'devices', ${list(merged('device', 'devices',
|
|
324
|
-
'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
|
|
325
|
-
sum(
|
|
326
|
-
SELECT ${day('at')}, count(DISTINCT visitor), count(
|
|
327
|
-
UNION ALL SELECT ${day('at')}, 0, 0, count(*),
|
|
328
|
-
UNION ALL SELECT ${day('at')}, 0, 0, 0,
|
|
329
|
-
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(
|
|
330
|
-
UNION ALL SELECT
|
|
331
|
-
|
|
332
|
-
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, 0, visitors, 0 FROM a
|
|
333
|
-
) t(date, visitors, pageviews, visits, bounces, seconds, agents, everyone, follows) GROUP BY 1`, undefined, '1')},
|
|
328
|
+
'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
|
|
329
|
+
'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
|
|
330
|
+
'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(engaged) AS engaged, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
|
|
331
|
+
sum(agents) AS agents, sum(follows) AS follow_clicks FROM (
|
|
332
|
+
SELECT ${day('at')}, count(DISTINCT visitor), count(DISTINCT visitor) FILTER (WHERE engaged), count(*), 0, 0, 0, 0 FROM v GROUP BY 1
|
|
333
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, count(*), count(*) FILTER (WHERE bounced), 0, 0 FROM visits GROUP BY 1
|
|
334
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM ag GROUP BY 1
|
|
335
|
+
${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
|
|
336
|
+
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, 0, pageviews, visits, 0, 0, 0 FROM a
|
|
337
|
+
) t(date, visitors, engaged, pageviews, visits, bounces, agents, follows) GROUP BY 1`, undefined, '1')},
|
|
334
338
|
'follow_clicks', ${follows ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e WHERE ${follows})` : 'NULL'},
|
|
335
339
|
'top_agents', ${list('SELECT agent, count(DISTINCT visitor) AS visitors FROM ag GROUP BY 1', 10)},
|
|
336
340
|
'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
|
|
@@ -350,7 +354,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
350
354
|
for (const k of Object.keys(p))
|
|
351
355
|
p[k] = null;
|
|
352
356
|
else
|
|
353
|
-
p.agents = p.
|
|
357
|
+
p.agents = p.follow_clicks = p.engaged = p.bounce_rate = null;
|
|
354
358
|
}
|
|
355
359
|
return d;
|
|
356
360
|
}
|
package/dist/block-views.js
CHANGED
|
@@ -32,9 +32,11 @@ const line = (html) => ({ dangerouslySetInnerHTML: { __html: html } });
|
|
|
32
32
|
* whatever the consumer's highlighter attached to it on every tab press.
|
|
33
33
|
*/
|
|
34
34
|
export function CodeTabsView({ tabs, highlighted = false }) {
|
|
35
|
-
const [
|
|
35
|
+
const [picked, setActiveTab] = useState(0);
|
|
36
36
|
if (!tabs.length)
|
|
37
37
|
return null;
|
|
38
|
+
// Clamped, so tabs removed under the selection leave the last one showing.
|
|
39
|
+
const activeTab = Math.min(picked, tabs.length - 1);
|
|
38
40
|
return (_jsxs("div", { className: "code-tabs", children: [_jsx("div", { className: "code-tabs-list", children: tabs.map((tab, i) => (_jsx("button", { type: "button", className: cx('code-tabs-btn', i === activeTab && 'code-tabs-btn-active'), onClick: () => setActiveTab(i), children: tab.label }, i))) }), tabs.map((tab, i) => highlighted ? (_jsx("div", { className: "code-tabs-panel", hidden: i !== activeTab, dangerouslySetInnerHTML: { __html: tab.code } }, i)) : (_jsx("div", { className: "code-tabs-panel", hidden: i !== activeTab, children: _jsx("pre", { children: _jsx("code", { className: tab.language ? `language-${tab.language}` : undefined, children: tab.code }) }) }, i)))] }));
|
|
39
41
|
}
|
|
40
42
|
/**
|
package/dist/dom.d.ts
CHANGED
|
@@ -111,6 +111,11 @@ export declare function scrollTables(html: string): string;
|
|
|
111
111
|
* A click on a link to another host is sent as an "Outbound Link: Click" event
|
|
112
112
|
* carrying its `url`. Starts once per page load however often it is called, so
|
|
113
113
|
* React's doubled effects in development send one view, not two.
|
|
114
|
+
*
|
|
115
|
+
* Each view is then marked engaged, `{ path, engaged: true }`, once its reader
|
|
116
|
+
* has had the page in a visible tab for `ENGAGED_MS`, or touches, clicks, types
|
|
117
|
+
* or wheels on it first. A crawler that runs scripts renders a page and leaves
|
|
118
|
+
* within seconds and gives it no input, so engaged views are the people's.
|
|
114
119
|
*/
|
|
115
120
|
export declare function startBeacon(endpoint?: string): void;
|
|
116
121
|
/** Record a named event from the browser, on the endpoint `startBeacon` was given. */
|
package/dist/dom.js
CHANGED
|
@@ -35,13 +35,15 @@ export function addCopyButtons(root, options = {}) {
|
|
|
35
35
|
btn.className = className;
|
|
36
36
|
btn.setAttribute('aria-label', label);
|
|
37
37
|
btn.innerHTML = COPY_SVG;
|
|
38
|
+
let revert;
|
|
38
39
|
btn.onclick = () => {
|
|
39
40
|
const code = pre.querySelector('code')?.textContent || pre.textContent || '';
|
|
40
41
|
// A rejected clipboard write (permissions, insecure origin) must not
|
|
41
42
|
// leave an unhandled rejection behind — the button simply does nothing.
|
|
42
43
|
navigator.clipboard.writeText(code).then(() => {
|
|
43
44
|
btn.innerHTML = CHECK_SVG;
|
|
44
|
-
|
|
45
|
+
clearTimeout(revert);
|
|
46
|
+
revert = setTimeout(() => {
|
|
45
47
|
btn.innerHTML = COPY_SVG;
|
|
46
48
|
}, revertAfter);
|
|
47
49
|
}, () => { });
|
|
@@ -330,6 +332,8 @@ export function scrollTables(html) {
|
|
|
330
332
|
// ---- analytics beacon -------------------------------------------------------
|
|
331
333
|
let beaconEndpoint = '/api/view';
|
|
332
334
|
let beaconStarted = false;
|
|
335
|
+
/** How long a view's page must be visible, in all, before its reader counts as engaged. */
|
|
336
|
+
const ENGAGED_MS = 15_000;
|
|
333
337
|
// A browser under automation (Puppeteer, Playwright, Selenium) sets
|
|
334
338
|
// `navigator.webdriver`: a crawler that runs scripts, whose user agent the bot
|
|
335
339
|
// pattern cannot tell from a reader's, so it sends nothing at all.
|
|
@@ -345,6 +349,11 @@ const sendBeacon = (body) => navigator.webdriver || navigator.sendBeacon?.(beaco
|
|
|
345
349
|
* A click on a link to another host is sent as an "Outbound Link: Click" event
|
|
346
350
|
* carrying its `url`. Starts once per page load however often it is called, so
|
|
347
351
|
* React's doubled effects in development send one view, not two.
|
|
352
|
+
*
|
|
353
|
+
* Each view is then marked engaged, `{ path, engaged: true }`, once its reader
|
|
354
|
+
* has had the page in a visible tab for `ENGAGED_MS`, or touches, clicks, types
|
|
355
|
+
* or wheels on it first. A crawler that runs scripts renders a page and leaves
|
|
356
|
+
* within seconds and gives it no input, so engaged views are the people's.
|
|
348
357
|
*/
|
|
349
358
|
export function startBeacon(endpoint = '/api/view') {
|
|
350
359
|
if (beaconStarted || typeof window === 'undefined')
|
|
@@ -353,6 +362,30 @@ export function startBeacon(endpoint = '/api/view') {
|
|
|
353
362
|
beaconEndpoint = endpoint;
|
|
354
363
|
let last = '';
|
|
355
364
|
let first = true;
|
|
365
|
+
/** The path whose view is not yet engaged, the visible time it has had, and since when it has been visible. */
|
|
366
|
+
let pending = null;
|
|
367
|
+
let seen = 0;
|
|
368
|
+
let since = 0;
|
|
369
|
+
let timer;
|
|
370
|
+
const engage = () => {
|
|
371
|
+
clearTimeout(timer);
|
|
372
|
+
if (pending !== null)
|
|
373
|
+
sendBeacon({ path: pending, engaged: true });
|
|
374
|
+
pending = null;
|
|
375
|
+
};
|
|
376
|
+
const watch = () => {
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
if (pending === null)
|
|
379
|
+
return;
|
|
380
|
+
if (document.visibilityState === 'visible') {
|
|
381
|
+
since = Date.now();
|
|
382
|
+
timer = setTimeout(engage, ENGAGED_MS - seen);
|
|
383
|
+
}
|
|
384
|
+
else if (since) {
|
|
385
|
+
seen += Date.now() - since;
|
|
386
|
+
since = 0;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
356
389
|
const view = () => {
|
|
357
390
|
if (location.pathname === last)
|
|
358
391
|
return;
|
|
@@ -364,7 +397,15 @@ export function startBeacon(endpoint = '/api/view') {
|
|
|
364
397
|
utm: new URLSearchParams(location.search).get('utm_source') ?? undefined,
|
|
365
398
|
});
|
|
366
399
|
first = false;
|
|
400
|
+
pending = last;
|
|
401
|
+
seen = since = 0;
|
|
402
|
+
watch();
|
|
367
403
|
};
|
|
404
|
+
document.addEventListener('visibilitychange', watch);
|
|
405
|
+
// Not `scroll`, which a script fires as readily as a reader. Capture, so a tap on a link engages the page it leaves.
|
|
406
|
+
for (const type of ['pointerdown', 'keydown', 'wheel', 'touchstart']) {
|
|
407
|
+
addEventListener(type, e => e.isTrusted && engage(), { capture: true, passive: true });
|
|
408
|
+
}
|
|
368
409
|
for (const method of ['pushState', 'replaceState']) {
|
|
369
410
|
const write = history[method];
|
|
370
411
|
history[method] = function (...args) {
|
package/dist/editor.js
CHANGED
|
@@ -482,13 +482,17 @@ export function useWikiEditor({ value, onChange, placeholder = '', nodes = [], p
|
|
|
482
482
|
if (!file || !editor)
|
|
483
483
|
return;
|
|
484
484
|
setIsUploading(true);
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
485
|
+
try {
|
|
486
|
+
const url = await uploadImage(file);
|
|
487
|
+
if (url)
|
|
488
|
+
editor.chain().focus().setImage({ src: url }).run();
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
setIsUploading(false);
|
|
492
|
+
// Cleared so choosing the same file twice in a row still fires a change.
|
|
493
|
+
if (fileInputRef.current)
|
|
494
|
+
fileInputRef.current.value = '';
|
|
495
|
+
}
|
|
492
496
|
}, [editor, uploadImage]);
|
|
493
497
|
const triggerUpload = useCallback(() => fileInputRef.current?.click(), []);
|
|
494
498
|
return { editor, fileInputRef, isUploading, handleFileChange, triggerUpload };
|
package/dist/maintenance.d.ts
CHANGED
package/dist/maintenance.js
CHANGED
|
@@ -31,13 +31,19 @@ const JOURNAL = /^run\s+\d+\b/i;
|
|
|
31
31
|
export const PINNED = /FLAG FOR A HUMAN/i;
|
|
32
32
|
const BANKED = /\bbanked\b|bot[- ]?(wall|block)|\b(403|429|999)\b|cloudflare/i;
|
|
33
33
|
const itemOf = (b) => (typeof b === 'string' ? b : b.item);
|
|
34
|
+
/**
|
|
35
|
+
* @internal The one test of open, which the bin shares: a bare string, or no
|
|
36
|
+
* status, is open; `compact` deletes anything else for good.
|
|
37
|
+
*/
|
|
38
|
+
export const isOpen = (b) => typeof b === 'string' || (b.status ?? 'open') === 'open';
|
|
34
39
|
const recency = (a, b) => String(b[1]?.lastChecked ?? '').localeCompare(String(a[1]?.lastChecked ?? ''));
|
|
35
40
|
/** Drops what is closed or a diary entry, and caps what is append-only. Newest first. */
|
|
36
41
|
export function compact(state) {
|
|
37
42
|
// Undated items fall back to append order: a later index is newer.
|
|
38
43
|
const open = (state.backlog ?? [])
|
|
39
|
-
.
|
|
40
|
-
.
|
|
44
|
+
.filter(isOpen)
|
|
45
|
+
.map((b, i) => ({ ...(typeof b === 'string' ? { date: '', item: b } : b), status: 'open', i }))
|
|
46
|
+
.filter(b => b.item && (PINNED.test(b.item) || !JOURNAL.test(b.item)))
|
|
41
47
|
.sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')) || b.i - a.i);
|
|
42
48
|
const backlog = [...open.filter(b => PINNED.test(b.item)).slice(0, MAX.flags), ...open.filter(b => !PINNED.test(b.item))]
|
|
43
49
|
.slice(0, MAX.backlog)
|
package/dist/react.js
CHANGED
|
@@ -394,6 +394,10 @@ export function useTypeahead({ fetch, onPick, onEscape, delay = 200, minLength =
|
|
|
394
394
|
setHighlight(h => (h - 1 + items.length) % items.length);
|
|
395
395
|
}
|
|
396
396
|
else if (e.key === 'Enter') {
|
|
397
|
+
// The list answers the last settled query. Typed past it, Enter falls
|
|
398
|
+
// through to the form, so it submits what was typed, not a stale pick.
|
|
399
|
+
if (query !== debounced || isSearching)
|
|
400
|
+
return;
|
|
397
401
|
e.preventDefault();
|
|
398
402
|
const target = items[highlight];
|
|
399
403
|
if (target)
|
|
@@ -463,7 +467,10 @@ export function useLinkPreview({ eligible, fetch, cardClassName = 'link-preview-
|
|
|
463
467
|
clearTimeout(hideTimer.current);
|
|
464
468
|
return;
|
|
465
469
|
}
|
|
470
|
+
// A new link retires the last one's card at once: the grace is for
|
|
471
|
+
// crossing into that card, and the cursor has gone somewhere else.
|
|
466
472
|
clearTimers();
|
|
473
|
+
setCard(null);
|
|
467
474
|
activeHref.current = href;
|
|
468
475
|
showTimer.current = setTimeout(async () => {
|
|
469
476
|
let data = cache.current.get(href);
|
|
@@ -754,11 +761,17 @@ export function restoreViaPost(endpoint) {
|
|
|
754
761
|
export function useRevisionRestore(restore, options) {
|
|
755
762
|
const [busyId, setBusyId] = useState(null);
|
|
756
763
|
const [error, setError] = useState(null);
|
|
764
|
+
// One restore at a time: a second, started while the first is in flight,
|
|
765
|
+
// would have its "Restoring…" cleared by the first one finishing.
|
|
766
|
+
const inFlight = useRef(false);
|
|
757
767
|
const { onRestored, confirm = (label) => `Restore ${label}? The current state stays in the history.` } = options;
|
|
758
768
|
const run = useCallback(async (id, label) => {
|
|
769
|
+
if (inFlight.current)
|
|
770
|
+
return;
|
|
759
771
|
const question = confirm(label);
|
|
760
772
|
if (question && !window.confirm(question))
|
|
761
773
|
return;
|
|
774
|
+
inFlight.current = true;
|
|
762
775
|
setBusyId(id);
|
|
763
776
|
setError(null);
|
|
764
777
|
try {
|
|
@@ -769,6 +782,7 @@ export function useRevisionRestore(restore, options) {
|
|
|
769
782
|
onRestored();
|
|
770
783
|
}
|
|
771
784
|
finally {
|
|
785
|
+
inFlight.current = false;
|
|
772
786
|
setBusyId(null);
|
|
773
787
|
}
|
|
774
788
|
}, [restore, onRestored, confirm]);
|
package/dist/stats-figures.d.ts
CHANGED
|
@@ -6,10 +6,9 @@ import { type Digest } from './analytics.js';
|
|
|
6
6
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
7
7
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
8
8
|
*/
|
|
9
|
-
export declare function StatsFigures({ digest, first,
|
|
9
|
+
export declare function StatsFigures({ digest, first, controls }: {
|
|
10
10
|
digest: Digest;
|
|
11
11
|
first: number;
|
|
12
|
-
apart: boolean;
|
|
13
12
|
controls: ReactNode;
|
|
14
13
|
}): import("react").JSX.Element;
|
|
15
14
|
export interface PasskeyButtonProps {
|
package/dist/stats-figures.js
CHANGED
|
@@ -9,9 +9,8 @@ import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
|
|
|
9
9
|
import { useMemo, useState } from 'react';
|
|
10
10
|
import { count } from './analytics.js';
|
|
11
11
|
import { TimeChart, stepDecimals } from './chart.js';
|
|
12
|
-
// Whole
|
|
12
|
+
// Whole percentages, unless an axis tick's step needs more to tell it from the next.
|
|
13
13
|
const places = (step) => (step ? stepDecimals(step) : 0);
|
|
14
|
-
const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(Math.round(s) / 60)}m ${Math.round(s) % 60}s`);
|
|
15
14
|
const percent = (n, step) => `${n.toFixed(places(step))}%`;
|
|
16
15
|
// Views per visit sit between one and a few, so a tenth tells them apart.
|
|
17
16
|
const perVisit = (n, step) => n.toFixed(step ? stepDecimals(step) : 1);
|
|
@@ -28,21 +27,15 @@ function change(now, before, { points = false, lowerIsBetter = false } = {}) {
|
|
|
28
27
|
const text = `${n > 0 ? '+' : ''}${n}${points ? ' pp' : '%'}`;
|
|
29
28
|
return n ? { text, better: n > 0 !== lowerIsBetter } : { text };
|
|
30
29
|
}
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
label: 'Visitors',
|
|
41
|
-
value: count(d.visitors_incl_agents),
|
|
42
|
-
delta: change(d.visitors_incl_agents, p.visitors_incl_agents),
|
|
43
|
-
day: (r) => r.visitors_incl_agents,
|
|
44
|
-
},
|
|
45
|
-
]),
|
|
30
|
+
/**
|
|
31
|
+
* People, the ones among them who engaged, and agents, never one count of both.
|
|
32
|
+
* Engaged and bounce rate are drawn from the day engagement was first counted:
|
|
33
|
+
* before it is nothing, not days of no one engaging.
|
|
34
|
+
*/
|
|
35
|
+
const figuresOf = ({ previous: p, ...d }) => [
|
|
36
|
+
{ label: 'People', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: r => r.visitors },
|
|
37
|
+
{ label: 'Engaged', value: count(d.engaged), delta: change(d.engaged, p.engaged), day: r => r.engaged, from: d.engaged_from ?? '9999' },
|
|
38
|
+
{ label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: r => r.agents },
|
|
46
39
|
{ label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
|
|
47
40
|
{
|
|
48
41
|
label: 'Views per visit',
|
|
@@ -59,14 +52,7 @@ const figuresOf = ({ previous: p, ...d }, apart) => [
|
|
|
59
52
|
day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
|
|
60
53
|
format: percent,
|
|
61
54
|
aggregate: 'mean',
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
label: 'Visit length',
|
|
65
|
-
value: d.visit_duration == null ? '–' : duration(d.visit_duration),
|
|
66
|
-
delta: change(d.visit_duration, p.visit_duration),
|
|
67
|
-
day: r => (r.visits ? r.seconds / r.visits : null),
|
|
68
|
-
format: duration,
|
|
69
|
-
aggregate: 'mean',
|
|
55
|
+
from: d.engaged_from ?? '9999',
|
|
70
56
|
},
|
|
71
57
|
...(d.follow_clicks
|
|
72
58
|
? [
|
|
@@ -80,16 +66,16 @@ const figuresOf = ({ previous: p, ...d }, apart) => [
|
|
|
80
66
|
: []),
|
|
81
67
|
];
|
|
82
68
|
const DAY = 86_400;
|
|
83
|
-
const EMPTY = { date: '', visitors: 0,
|
|
69
|
+
const EMPTY = { date: '', visitors: 0, engaged: 0, pageviews: 0, visits: 0, bounces: 0, agents: 0, follow_clicks: 0 };
|
|
84
70
|
/**
|
|
85
71
|
* The figures as `.stats-figures button[aria-pressed]`, each with its change
|
|
86
72
|
* as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
|
|
87
73
|
* day from `first` (a UTC day number) to today. A count's empty day is a zero;
|
|
88
74
|
* a rate's is left out, since no visits is not a bounce rate of nothing.
|
|
89
75
|
*/
|
|
90
|
-
export function StatsFigures({ digest, first,
|
|
91
|
-
const figures = useMemo(() => figuresOf(digest
|
|
92
|
-
// By name, since a window
|
|
76
|
+
export function StatsFigures({ digest, first, controls }) {
|
|
77
|
+
const figures = useMemo(() => figuresOf(digest), [digest]);
|
|
78
|
+
// By name, since a window can arrive without a reload and the figures shift (Clicks to X comes and goes).
|
|
93
79
|
const [chosen, choose] = useState(figures[0].label);
|
|
94
80
|
const figure = figures.find(f => f.label === chosen) ?? figures[0];
|
|
95
81
|
const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
|
|
@@ -97,7 +83,8 @@ export function StatsFigures({ digest, first, apart, controls }) {
|
|
|
97
83
|
const today = Math.floor(Date.now() / 1000 / DAY);
|
|
98
84
|
return Array.from({ length: today - first + 1 }, (_, i) => {
|
|
99
85
|
const time = (first + i) * DAY;
|
|
100
|
-
const
|
|
86
|
+
const date = new Date(time * 1000).toISOString().slice(0, 10);
|
|
87
|
+
const value = figure.from && date < figure.from ? null : figure.day(byDay.get(date) ?? EMPTY);
|
|
101
88
|
return value == null ? null : { time, value };
|
|
102
89
|
}).filter(p => p !== null);
|
|
103
90
|
}, [byDay, first, figure]);
|
package/dist/stats.d.ts
CHANGED
|
@@ -9,8 +9,6 @@ export interface StatsProps {
|
|
|
9
9
|
days: number;
|
|
10
10
|
/** What the digest was narrowed to, as `digest` was given it. */
|
|
11
11
|
filter?: StatsFilter;
|
|
12
|
-
/** Visitors as people and agents, two figures, rather than one that counts both. */
|
|
13
|
-
apart?: boolean;
|
|
14
12
|
/** The windows offered as `?days=` links on the same page. */
|
|
15
13
|
ranges?: readonly number[];
|
|
16
14
|
/** Every link on the page: `next/link`, say, so a window or a filter does not reload it. Each is given `prefetch={false}`. */
|
|
@@ -24,18 +22,16 @@ export interface StatsProps {
|
|
|
24
22
|
export declare function statsQuery(params: Record<string, string | string[] | undefined>, ranges?: readonly number[]): {
|
|
25
23
|
days: number;
|
|
26
24
|
filter: Partial<Record<"page" | "country" | "entry" | "source" | "device", string>>;
|
|
27
|
-
apart: boolean;
|
|
28
25
|
};
|
|
29
26
|
/**
|
|
30
27
|
* A site's own analytics on one page: the headline figures against the window
|
|
31
|
-
* before,
|
|
28
|
+
* before, people and agents apart, whichever of them is chosen per day, and every ranked list the
|
|
32
29
|
* digest carries, each row a link that narrows the page to it. Lists with
|
|
33
30
|
* nothing in them are left out, but for the searches that found nothing,
|
|
34
|
-
* which says it found none, so a quiet window never reads as one not counted. The
|
|
35
|
-
* the lists count the visitors the chart draws. The chart and the bars are `currentColor`
|
|
31
|
+
* which says it found none, so a quiet window never reads as one not counted. The chart and the bars are `currentColor`
|
|
36
32
|
* until the importing stylesheet gives them a colour.
|
|
37
33
|
*/
|
|
38
|
-
export declare function Stats({ digest: d, days, filter,
|
|
34
|
+
export declare function Stats({ digest: d, days, filter, ranges, link: Link }: StatsProps): import("react").JSX.Element;
|
|
39
35
|
export interface StatsGateProps {
|
|
40
36
|
gate: PasskeyGate;
|
|
41
37
|
/** The gate's cookie from the request: `(await cookies()).get(gate.cookie)?.value`. */
|
package/dist/stats.js
CHANGED
|
@@ -22,14 +22,11 @@ export function statsQuery(params, ranges = STATS_RANGES) {
|
|
|
22
22
|
if (typeof v === 'string' && v)
|
|
23
23
|
filter[k] = v.slice(0, 500);
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
return { days: statsDays(params.days, ranges), filter, apart: params.agents === 'apart' };
|
|
25
|
+
return { days: statsDays(params.days, ranges), filter };
|
|
27
26
|
}
|
|
28
|
-
/** The page at a window
|
|
29
|
-
const href = (days, filter
|
|
27
|
+
/** The page at a window and a filter, as a query string. */
|
|
28
|
+
const href = (days, filter) => {
|
|
30
29
|
const q = new URLSearchParams({ days: daysParam(days) });
|
|
31
|
-
if (apart)
|
|
32
|
-
q.set('agents', 'apart');
|
|
33
30
|
for (const k of STATS_FILTERS)
|
|
34
31
|
if (filter[k])
|
|
35
32
|
q.set(k, filter[k]);
|
|
@@ -54,14 +51,13 @@ function StatsList({ title, rows, label = v => v, to, empty, link: Link, }) {
|
|
|
54
51
|
}
|
|
55
52
|
/**
|
|
56
53
|
* A site's own analytics on one page: the headline figures against the window
|
|
57
|
-
* before,
|
|
54
|
+
* before, people and agents apart, whichever of them is chosen per day, and every ranked list the
|
|
58
55
|
* digest carries, each row a link that narrows the page to it. Lists with
|
|
59
56
|
* nothing in them are left out, but for the searches that found nothing,
|
|
60
|
-
* which says it found none, so a quiet window never reads as one not counted. The
|
|
61
|
-
* the lists count the visitors the chart draws. The chart and the bars are `currentColor`
|
|
57
|
+
* which says it found none, so a quiet window never reads as one not counted. The chart and the bars are `currentColor`
|
|
62
58
|
* until the importing stylesheet gives them a colour.
|
|
63
59
|
*/
|
|
64
|
-
export function Stats({ digest: d, days, filter = {},
|
|
60
|
+
export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES, link: Link = Anchor }) {
|
|
65
61
|
const today = Math.floor(Date.now() / DAY_MS);
|
|
66
62
|
const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
|
|
67
63
|
const narrowed = STATS_FILTERS.filter(k => filter[k]);
|
|
@@ -73,12 +69,11 @@ export function Stats({ digest: d, days, filter = {}, apart = false, ranges = ST
|
|
|
73
69
|
? Math.max(start, counted)
|
|
74
70
|
: Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
|
|
75
71
|
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
76
|
-
const at = (f, n = days
|
|
72
|
+
const at = (f, n = days) => href(n, f);
|
|
77
73
|
const to = (k) => (v) => at({ ...filter, [k]: v });
|
|
78
|
-
// Each window is another digest, so a link rather than a button, pinned
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
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(Link, { href: at({ ...filter, [k]: undefined }), title: "Remove", prefetch: false, 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, apart: apart, controls: controls }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { link: Link, title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { link: Link, title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { link: Link, title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { link: Link, title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { link: Link, title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { link: Link, title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { link: Link, title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { link: Link, title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events'), empty: "None in this window." })] })] }));
|
|
74
|
+
// Each window is another digest, so a link rather than a button, pinned with the chart's own controls.
|
|
75
|
+
const controls = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx(Link, { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, prefetch: false, children: windowLabel(n) }, n))) }));
|
|
76
|
+
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(Link, { href: at({ ...filter, [k]: undefined }), title: "Remove", prefetch: false, 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, controls: controls }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { link: Link, title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { link: Link, title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { link: Link, title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { link: Link, title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { link: Link, title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { link: Link, title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { link: Link, title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { link: Link, title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events'), empty: "None in this window." })] })] }));
|
|
82
77
|
}
|
|
83
78
|
/**
|
|
84
79
|
* A stats page's door: `children` and a sign-out button for a signed-in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.105.1",
|
|
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",
|
package/prisma/analytics.prisma
CHANGED
|
@@ -14,6 +14,8 @@ model View {
|
|
|
14
14
|
country String?
|
|
15
15
|
device String
|
|
16
16
|
visitor String
|
|
17
|
+
// Set once its reader has had the page visible fifteen seconds or given it input.
|
|
18
|
+
engaged Boolean @default(false)
|
|
17
19
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
|
18
20
|
|
|
19
21
|
@@index([siteId, createdAt])
|