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 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: visitors, agents, views, visits, bounce rate, visit length, 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 with no event of the reader's. `ownAgents` leaves the site's own out. A multi-tenant app passes `site`.
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), `?agents=apart` and the narrowings `?page=`, `?entry=`, `?source=`, `?country=`, `?device=`, which stack and recount every figure from the visits that pass.
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
@@ -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 ?? '') && (typeof b === 'string' || (b.status ?? 'open') === 'open') && !kept.has(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
  }
@@ -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 saw one page and did nothing else: a search, a click out or any reader event makes it a visit. */
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
- /** Percent of visits that are bounces (`DigestDay.bounces`); null with no visits. */
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' | 'pageviews' | 'visits' | 'bounce_rate' | 'visit_duration' | 'agents' | 'visitors_incl_agents' | 'follow_clicks', number | null>;
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, so an archived day's visitors are its visitors with agents too.
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, apart }: DigestOptions): Promise<Digest>;
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
- : recordView(sql, { headers, path: raw, referrer: body.referrer, utm: body.utm, site: key }));
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. Its source and device are the two above, and its
190
- * country the one its requests came from, where they were counted with one.
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: v => `${v} = '${AGENT_SOURCE}'`,
207
+ source: () => 'false',
196
208
  country: v => `bool_or(country = ${v})`,
197
- device: v => `${v} = '${AGENT_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, so an archived day's visitors are its visitors with agents too.
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 = {}, apart = true }) {
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
- const tests = STATS_FILTERS.flatMap(k => {
224
- if (!filter[k])
225
- return [];
226
- const v = $(filter[k]);
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(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
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 sessions${s}),
305
- 'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions${s}),
306
- 'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions${s}),
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)${agentsToo(`SELECT '${AGENT_SOURCE}', count(DISTINCT visitor) FROM ag HAVING count(*) > 0`)}
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', `SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1${agentsToo('SELECT path, count(DISTINCT visitor) FROM ag WHERE path IS NOT NULL GROUP BY 1')}`), 6)},
319
- 'entry_pages', ${list(merged('page', 'entryPages', `SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1${agentsToo('SELECT entry, count(*) FROM (SELECT (array_agg(path ORDER BY at))[1] AS entry FROM ag GROUP BY visitor) f WHERE entry IS NOT NULL GROUP BY 1')}`), 6)},
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', `SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1${agentsToo('SELECT country, count(DISTINCT visitor) FROM ag WHERE country IS NOT NULL GROUP BY 1')}`), 10)},
323
- 'devices', ${list(merged('device', 'devices', `SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1${agentsToo(`SELECT '${AGENT_DEVICE}', count(DISTINCT visitor) FROM ag HAVING count(*) > 0`)}`))},
324
- 'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
325
- sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
326
- SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
327
- UNION ALL SELECT ${day('at')}, 0, 0, count(*), 0, coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
328
- UNION ALL SELECT ${day('at')}, 0, 0, 0, count(*), 0, 0, 0, 0 FROM bounced GROUP BY 1
329
- UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
330
- UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM ag) u GROUP BY 1
331
- ${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
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.visitors_incl_agents = p.follow_clicks = null;
357
+ p.agents = p.follow_clicks = p.engaged = p.bounce_rate = null;
354
358
  }
355
359
  return d;
356
360
  }
@@ -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 [activeTab, setActiveTab] = useState(0);
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
- setTimeout(() => {
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
- const url = await uploadImage(file);
486
- if (url)
487
- editor.chain().focus().setImage({ src: url }).run();
488
- setIsUploading(false);
489
- // Cleared so choosing the same file twice in a row still fires a change.
490
- if (fileInputRef.current)
491
- fileInputRef.current.value = '';
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 };
@@ -11,7 +11,7 @@ interface SourceEntry {
11
11
  export type BacklogEntry = string | {
12
12
  date?: string;
13
13
  item: string;
14
- status?: string;
14
+ status?: string | null;
15
15
  };
16
16
  type FeedbackEntry = string | {
17
17
  date?: string;
@@ -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
- .map((b, i) => ({ status: 'open', ...(typeof b === 'string' ? { date: '', item: b } : b), i }))
40
- .filter(b => b.status === 'open' && b.item && (PINNED.test(b.item) || !JOURNAL.test(b.item)))
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]);
@@ -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, apart, controls }: {
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 {
@@ -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 seconds and percentages, unless an axis tick's step needs more to tell it from the next.
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
- /** Visitors are one figure counting agents too, or `apart` two: people, and agents. */
32
- const figuresOf = ({ previous: p, ...d }, apart) => [
33
- ...(apart
34
- ? [
35
- { label: 'People', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: (r) => r.visitors },
36
- { label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: (r) => r.agents },
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, pageviews: 0, visits: 0, bounces: 0, seconds: 0, agents: 0, visitors_incl_agents: 0, follow_clicks: 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, apart, controls }) {
91
- const figures = useMemo(() => figuresOf(digest, apart), [digest, apart]);
92
- // By name, since a window or the agents switch can arrive without a reload and the figures shift.
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 value = figure.day(byDay.get(new Date(time * 1000).toISOString().slice(0, 10)) ?? EMPTY);
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, visitors counting agents unless `apart`, whichever of them is chosen per day, and every ranked list the
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 digest is made with the same `apart`, so
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, apart, ranges, link: Link }: StatsProps): import("react").JSX.Element;
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
- // `apart` is `digest`'s option too, so its lists count the visitors the chart draws.
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, a filter and a count of agents, as a query string. */
29
- const href = (days, filter, apart) => {
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, visitors counting agents unless `apart`, whichever of them is chosen per day, and every ranked list the
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 digest is made with the same `apart`, so
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 = {}, apart = false, ranges = STATS_RANGES, link: Link = Anchor }) {
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, a = apart) => href(n, f, a);
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
- // with the chart's own controls, and so is the switch that counts agents apart.
80
- const controls = (_jsxs(_Fragment, { children: [_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))) }), _jsx("div", { children: _jsx(Link, { href: at(filter, days, !apart), role: "switch", "aria-checked": !apart, "aria-label": "Agents counted among visitors", title: "Agents counted among visitors", prefetch: false, children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M6 1.5v2M1 6.5v2M11 6.5v2", stroke: "currentColor" }), _jsx("rect", { x: "2.5", y: "3.5", width: "7", height: "7", rx: "1.5", fill: "none", stroke: "currentColor" }), _jsx("circle", { cx: "4.75", cy: "7", r: "0.9", fill: "currentColor" }), _jsx("circle", { cx: "7.25", cy: "7", r: "0.9", fill: "currentColor" })] }) }) })] }));
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.104.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",
@@ -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])