wiki-formant 0.100.0 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -565,7 +565,7 @@ const off = onTweetResize((h, source) => sizeTweetEmbed(el, h, source));
565
565
 
566
566
  ## Search
567
567
 
568
- `wiki-formant/search`: what a literal tier and a full-text tier must agree on: `proseSql`, `HEADLINE_OPTIONS`, `FTS_RANK_NORMALIZATION`, and `searchTsvDdl(table)`, the statements that drop and re-add the generated `search_tsv` column and its index in one transaction.
568
+ `wiki-formant/search`: what a literal tier and a full-text tier must agree on: `proseSql`, `HEADLINE_OPTIONS`, `FTS_RANK_NORMALIZATION`, and `searchTsvDdl(table)`, the statements that drop and re-add the generated `search_tsv` column and its index in one transaction. `fullTextQuery` is a query as the full-text tier reads it, a hyphen or underscore inside a word a space; `correctQuery(query, vocabulary)` corrects a query's misspelt words against a site's own, which the store's `search` retries with when its first page is empty, returning the query that found the rows as `corrected`.
569
569
 
570
570
  ```ts
571
571
  const { column, index } = searchTsvDdl('"WikiPage"');
@@ -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, bounce rate, visit length, sources, pages, countries, devices, by day, agents by name, MCP calls and searches that found nothing. 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
@@ -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,55 +84,61 @@ 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
- /** Percent of visits that are bounces (`DigestDay.bounces`); null with no visits. */
114
+ /** Runs of one visitor's views with no gap over 30 minutes. */
115
+ visits: number;
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
+ */
114
122
  bounce_rate: number | null;
115
- /** Mean seconds from a visit's first view to its last; null with no visits. */
116
- visit_duration: number | null;
117
123
  /**
118
124
  * Agents, counted as visitors are: an AI crawler, an MCP client, or anything
119
125
  * else reading the machine surface (`trackAiBot`), one per address and user
120
126
  * agent a day, the site's own left out. They run no page script, so they are
121
- * never among `visitors`.
127
+ * never among `visitors`, and the ranked lists count people alone.
122
128
  */
123
129
  agents: number;
124
- /** Visitors and `agents` as one count, which a stats page shows unless asked to keep them apart. */
125
- visitors_incl_agents: number;
126
130
  /**
127
131
  * The headline figures over the same number of days before, narrowed alike.
128
132
  * A figure is null where that window reaches back past `counted_from` and
129
133
  * the archive cannot stand in for it: event figures always, all of them
130
134
  * when filtered.
131
135
  */
132
- previous: Record<'visitors' | 'pageviews' | '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>;
133
137
  /** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
134
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;
141
+ /** `(bot)` stands for each referrer `BOT_VISITORS` takes for a crawler's. */
135
142
  top_sources: Ranked<'source', 'visitors'>;
136
143
  top_pages: Ranked<'page', 'visitors'>;
137
144
  entry_pages: Ranked<'page', 'visitors'>;
@@ -164,11 +171,11 @@ export interface Digest {
164
171
  * Visitor figures count distinct visitors, so a person who comes back on
165
172
  * another day counts once for each day. Days in `ViewDay` add to the visitor,
166
173
  * page, visit, source, country, device and per-day figures; events have no
167
- * history, so an archived day's visitors are its visitors with agents too.
174
+ * history.
168
175
  * The window before is the same chain of views, visits and events a second
169
176
  * time, `_p` on every name, so its figures are the headline's to the letter.
170
177
  */
171
- 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>;
172
179
  /**
173
180
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
174
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). */
@@ -158,30 +173,40 @@ export const STATS_FILTERS = ['page', 'entry', 'source', 'country', 'device'];
158
173
  * `Stats` calls it on the server, where a client module's export is only a reference. */
159
174
  export const count = (n) => n.toLocaleString('en');
160
175
  /**
161
- * Each filter as a test on one visit. A page, country or device is any of the
162
- * visit's views; an entry page or a source is its first. `(none)` is no source.
176
+ * A referrer that sent this many visitors in the window, not one of whom read a
177
+ * second page, is a crawler naming a site it never came from: aocr.org sent
178
+ * radix.wiki 319 one-page visitors in a month while Google's bounced four in five.
179
+ */
180
+ const BOT_VISITORS = 20;
181
+ const BOT_SOURCE = '(bot)';
182
+ /** A visit's source: its first view's, or `(bot)` where that source is among `bots`. */
183
+ const visitSource = (s) => {
184
+ const first = '(array_agg(source ORDER BY at, id))[1]';
185
+ return `CASE WHEN ${first} IN (SELECT source FROM bots${s}) THEN '${BOT_SOURCE}' ELSE ${first} END`;
186
+ };
187
+ /**
188
+ * Each filter as a test on one visit in the window suffixed `s`. A page,
189
+ * country or device is any of the visit's views; an entry page or a source is
190
+ * its first. `(none)` is no source.
163
191
  */
164
192
  const VISIT_TEST = {
165
193
  page: v => `bool_or(path = ${v})`,
166
194
  entry: v => `(array_agg(path ORDER BY at, id))[1] = ${v}`,
167
- source: v => `coalesce((array_agg(source ORDER BY at, id))[1], '(none)') = ${v}`,
195
+ source: (v, s) => `coalesce(${visitSource(s)}, '(none)') = ${v}`,
168
196
  country: v => `bool_or(country = ${v})`,
169
197
  device: v => `bool_or(device = ${v})`,
170
198
  };
171
- /** An agent's source and device in the lists, which no request of its carries. */
172
- const AGENT_SOURCE = '(agent)';
173
- const AGENT_DEVICE = 'agent';
174
199
  /**
175
200
  * Each filter as a test on one agent: its reads in the window are its visit,
176
- * the first its entry page. Its source and device are the two above, and its
177
- * 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.
178
203
  */
179
204
  const AGENT_TEST = {
180
205
  page: v => `bool_or(path = ${v})`,
181
206
  entry: v => `(array_agg(path ORDER BY at))[1] = ${v}`,
182
- source: v => `${v} = '${AGENT_SOURCE}'`,
207
+ source: () => 'false',
183
208
  country: v => `bool_or(country = ${v})`,
184
- device: v => `${v} = '${AGENT_DEVICE}'`,
209
+ device: () => 'false',
185
210
  };
186
211
  /**
187
212
  * An agent's name from its event: the roster's label, else what its user agent
@@ -193,11 +218,11 @@ const AGENT_NAME = `coalesce(props->>'bot', substring(props->>'ua' from 'compati
193
218
  * Visitor figures count distinct visitors, so a person who comes back on
194
219
  * another day counts once for each day. Days in `ViewDay` add to the visitor,
195
220
  * page, visit, source, country, device and per-day figures; events have no
196
- * history, so an archived day's visitors are its visitors with agents too.
221
+ * history.
197
222
  * The window before is the same chain of views, visits and events a second
198
223
  * time, `_p` on every name, so its figures are the headline's to the letter.
199
224
  */
200
- 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 = {} }) {
201
226
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
202
227
  // All time is a century: further back than any row, and still an int to Postgres.
203
228
  const values = [site, Number.isFinite(days) ? Math.max(1, Math.round(days)) : 36_500];
@@ -207,17 +232,29 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
207
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`;
208
233
  /** Clicks on links to the site's own X account, among `e`. */
209
234
  const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
210
- const tests = STATS_FILTERS.flatMap(k => (filter[k] ? [VISIT_TEST[k]($(filter[k]))] : []));
211
- const agentTests = STATS_FILTERS.flatMap(k => (filter[k] ? [AGENT_TEST[k]($(filter[k]))] : []));
212
- /** Agents among the rows of a ranked list, unless they are counted apart. */
213
- 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));
214
239
  const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
215
240
  /** The site's own agents, as a test on an event's user agent. */
216
241
  const ours = `coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])`;
242
+ /**
243
+ * A typeahead logs each settled keystroke, so one reader typing a question
244
+ * filled acuiq's list with thirteen of its prefixes. A query is left out when
245
+ * the same visitor's next one, inside five minutes, extends it: only where
246
+ * the reader stopped is a gap. So is a single character.
247
+ */
217
248
  const gapSelects = gaps.map(g => {
218
249
  const prop = $(g.prop);
219
- return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
220
- WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
250
+ return `(SELECT query, count(*) AS events FROM (
251
+ SELECT props, visitor, props->>${prop} AS query,
252
+ lead(props->>${prop}) OVER typed AS next, lead(at) OVER typed - at AS after
253
+ FROM e WHERE name = ${$(g.event)} AND props->>${prop} IS NOT NULL
254
+ WINDOW typed AS (PARTITION BY visitor ORDER BY at)
255
+ ) s
256
+ WHERE props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND char_length(query) > 1
257
+ AND NOT coalesce(visitor IS NOT NULL AND after <= interval '5 minutes' AND char_length(next) > char_length(query) AND starts_with(next, query), false)
221
258
  GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
222
259
  });
223
260
  /**
@@ -229,17 +266,20 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
229
266
  const dayAgo = (k) => `(now() AT TIME ZONE 'UTC' - make_interval(days => ${k} * $2::int))::date`;
230
267
  const at = `"createdAt" >= ${ago(back + 1)}${back ? ` AND "createdAt" < ${ago(back)}` : ''}`;
231
268
  return `viewed${s} AS (
232
- 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}
233
270
  ), marked${s} AS (
234
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}
235
272
  ), numbered${s} AS (
236
273
  SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked${s}
274
+ ), bots${s} AS (
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
276
+ WHERE source IS NOT NULL GROUP BY 1 HAVING count(DISTINCT visitor) >= ${BOT_VISITORS} AND max(views) = 1
237
277
  ), visits${s} AS (
238
- SELECT visitor, visit, min(at) AS at, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
239
- (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
240
- FROM numbered${s} GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.join(' AND ')}` : ''}
278
+ SELECT visitor, visit, min(at) AS at, count(*) = 1 AND NOT bool_or(engaged) AS bounced,
279
+ (array_agg(path ORDER BY at, id))[1] AS entry, ${visitSource(s)} AS source
280
+ FROM numbered${s} GROUP BY visitor, visit${tests.length ? ` HAVING ${tests.map(t => t(s)).join(' AND ')}` : ''}
241
281
  ), v${s} AS (
242
- ${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}`}
243
283
  ), read${s} AS (
244
284
  SELECT visitor, "createdAt" AS at, path, props->>'country' AS country, ${AGENT_NAME} AS agent FROM "Event"
245
285
  WHERE "siteId" = $1 AND ${at} AND name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
@@ -248,55 +288,53 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
248
288
  ), e${s} AS (
249
289
  SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
250
290
  ${tests.length ? `AND (visitor IN (SELECT visitor FROM v${s}) OR visitor IN (SELECT visitor FROM ag${s}))` : ''}
251
- ), acted${s} AS MATERIALIZED (
252
- SELECT visitor, at FROM e${s} WHERE name NOT IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL
253
- ), bounced${s} AS (
254
- -- Against the few reader events alone: probing every agent event once per visit took seconds.
255
- SELECT * FROM visits${s} b WHERE views = 1 AND NOT EXISTS (
256
- SELECT 1 FROM acted${s} x WHERE x.visitor = b.visitor AND x.at >= b.at AND x.at < b.at + interval '30 minutes')
257
291
  ), a${s} AS (
258
292
  SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
259
- ), sessions${s} AS (
260
- SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
261
- SELECT (SELECT count(*) FROM bounced${s}), count(*), coalesce(sum(seconds), 0) FROM visits${s}
262
- UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
263
- ) t(bounces, visits, seconds)
264
293
  ), archived${s} AS (
265
- 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}
266
295
  )`;
267
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
+ };
268
307
  /** One window's headline figures, as `json_build_object` arguments. */
269
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)`)},
270
310
  'pageviews', (SELECT count(*) FROM v${s}) + (SELECT pageviews FROM archived${s}),
271
- 'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions${s}),
272
- 'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions${s}),
273
- 'agents', (SELECT count(DISTINCT visitor) FROM ag${s}),
274
- '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})`;
275
314
  const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
276
315
  SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
277
- 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)
278
317
  )
279
318
  SELECT json_build_object(
280
319
  ${headline('')},
281
320
  'previous', json_build_object(${headline('_p')}, 'follow_clicks', ${follows ? `(SELECT count(*) FROM e_p WHERE ${follows})` : 'NULL'}),
282
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),
283
323
  'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
284
- '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)},
285
- '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)},
286
326
  'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
287
327
  'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
288
- '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)},
289
- '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`)}`))},
290
- 'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
291
- sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
292
- SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
293
- UNION ALL SELECT ${day('at')}, 0, 0, count(*), 0, coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
294
- UNION ALL SELECT ${day('at')}, 0, 0, 0, count(*), 0, 0, 0, 0 FROM bounced GROUP BY 1
295
- UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
296
- 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
297
- ${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
298
- UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, 0, visitors, 0 FROM a
299
- ) 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')},
300
338
  'follow_clicks', ${follows ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e WHERE ${follows})` : 'NULL'},
301
339
  'top_agents', ${list('SELECT agent, count(DISTINCT visitor) AS visitors FROM ag GROUP BY 1', 10)},
302
340
  'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
@@ -316,7 +354,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
316
354
  for (const k of Object.keys(p))
317
355
  p[k] = null;
318
356
  else
319
- p.agents = p.visitors_incl_agents = p.follow_clicks = null;
357
+ p.agents = p.follow_clicks = p.engaged = p.bounce_rate = null;
320
358
  }
321
359
  return d;
322
360
  }
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
@@ -330,6 +330,8 @@ export function scrollTables(html) {
330
330
  // ---- analytics beacon -------------------------------------------------------
331
331
  let beaconEndpoint = '/api/view';
332
332
  let beaconStarted = false;
333
+ /** How long a view's page must be visible, in all, before its reader counts as engaged. */
334
+ const ENGAGED_MS = 15_000;
333
335
  // A browser under automation (Puppeteer, Playwright, Selenium) sets
334
336
  // `navigator.webdriver`: a crawler that runs scripts, whose user agent the bot
335
337
  // pattern cannot tell from a reader's, so it sends nothing at all.
@@ -345,6 +347,11 @@ const sendBeacon = (body) => navigator.webdriver || navigator.sendBeacon?.(beaco
345
347
  * A click on a link to another host is sent as an "Outbound Link: Click" event
346
348
  * carrying its `url`. Starts once per page load however often it is called, so
347
349
  * React's doubled effects in development send one view, not two.
350
+ *
351
+ * Each view is then marked engaged, `{ path, engaged: true }`, once its reader
352
+ * has had the page in a visible tab for `ENGAGED_MS`, or touches, clicks, types
353
+ * or wheels on it first. A crawler that runs scripts renders a page and leaves
354
+ * within seconds and gives it no input, so engaged views are the people's.
348
355
  */
349
356
  export function startBeacon(endpoint = '/api/view') {
350
357
  if (beaconStarted || typeof window === 'undefined')
@@ -353,6 +360,30 @@ export function startBeacon(endpoint = '/api/view') {
353
360
  beaconEndpoint = endpoint;
354
361
  let last = '';
355
362
  let first = true;
363
+ /** The path whose view is not yet engaged, the visible time it has had, and since when it has been visible. */
364
+ let pending = null;
365
+ let seen = 0;
366
+ let since = 0;
367
+ let timer;
368
+ const engage = () => {
369
+ clearTimeout(timer);
370
+ if (pending !== null)
371
+ sendBeacon({ path: pending, engaged: true });
372
+ pending = null;
373
+ };
374
+ const watch = () => {
375
+ clearTimeout(timer);
376
+ if (pending === null)
377
+ return;
378
+ if (document.visibilityState === 'visible') {
379
+ since = Date.now();
380
+ timer = setTimeout(engage, ENGAGED_MS - seen);
381
+ }
382
+ else if (since) {
383
+ seen += Date.now() - since;
384
+ since = 0;
385
+ }
386
+ };
356
387
  const view = () => {
357
388
  if (location.pathname === last)
358
389
  return;
@@ -364,7 +395,15 @@ export function startBeacon(endpoint = '/api/view') {
364
395
  utm: new URLSearchParams(location.search).get('utm_source') ?? undefined,
365
396
  });
366
397
  first = false;
398
+ pending = last;
399
+ seen = since = 0;
400
+ watch();
367
401
  };
402
+ document.addEventListener('visibilitychange', watch);
403
+ // Not `scroll`, which a script fires as readily as a reader. Capture, so a tap on a link engages the page it leaves.
404
+ for (const type of ['pointerdown', 'keydown', 'wheel', 'touchstart']) {
405
+ addEventListener(type, e => e.isTrusted && engage(), { capture: true, passive: true });
406
+ }
368
407
  for (const method of ['pushState', 'replaceState']) {
369
408
  const write = history[method];
370
409
  history[method] = function (...args) {
package/dist/search.d.ts CHANGED
@@ -72,3 +72,22 @@ export declare const FTS_RANK_NORMALIZATION = 32;
72
72
  * rather than as a pattern matching everything.
73
73
  */
74
74
  export declare function escapeLikeTerm(query: string): string;
75
+ /**
76
+ * A query as the full-text tier reads it: a hyphen or underscore inside a word
77
+ * is a space. `websearch_to_tsquery` keeps a hyphenated word as a phrase that
78
+ * needs the compound itself, so the slug `radix-pipeline` found nothing where
79
+ * `radix pipeline` found sixteen pages. A leading `-` is websearch's NOT, and
80
+ * stays.
81
+ */
82
+ export declare function fullTextQuery(query: string): string;
83
+ /**
84
+ * A query with each misspelled word replaced by the nearest word in `vocabulary`
85
+ * (word → how often it occurs), or `null` when no word changes. Called only for
86
+ * a query that found nothing, so a word the wiki already uses is never touched.
87
+ *
88
+ * Tight on purpose: a word of four to seven letters may be one edit out, a
89
+ * longer one two, and a shorter one none. Looser, a name the wiki lacks turns
90
+ * into a word it has – `bondix` into `bonds` – and the reader gets an answer to
91
+ * a question they did not ask. Ties go to the commoner word.
92
+ */
93
+ export declare function correctQuery(query: string, vocabulary: ReadonlyMap<string, number>): string | null;
package/dist/search.js CHANGED
@@ -120,3 +120,62 @@ export const FTS_RANK_NORMALIZATION = 32;
120
120
  export function escapeLikeTerm(query) {
121
121
  return (query ?? '').trim().replace(/[\\%_]/g, char => `\\${char}`);
122
122
  }
123
+ /**
124
+ * A query as the full-text tier reads it: a hyphen or underscore inside a word
125
+ * is a space. `websearch_to_tsquery` keeps a hyphenated word as a phrase that
126
+ * needs the compound itself, so the slug `radix-pipeline` found nothing where
127
+ * `radix pipeline` found sixteen pages. A leading `-` is websearch's NOT, and
128
+ * stays.
129
+ */
130
+ export function fullTextQuery(query) {
131
+ return (query ?? '').trim().replace(/([\p{L}\p{N}])[-_]+(?=[\p{L}\p{N}])/gu, '$1 ');
132
+ }
133
+ /** Edits between two words, a swapped adjacent pair counting as one. */
134
+ function editDistance(a, b) {
135
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
136
+ for (let j = 1; j <= b.length; j++)
137
+ d[0][j] = j;
138
+ for (let i = 1; i <= a.length; i++)
139
+ for (let j = 1; j <= b.length; j++) {
140
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
141
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
142
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
143
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
144
+ }
145
+ return d[a.length][b.length];
146
+ }
147
+ /**
148
+ * A query with each misspelled word replaced by the nearest word in `vocabulary`
149
+ * (word → how often it occurs), or `null` when no word changes. Called only for
150
+ * a query that found nothing, so a word the wiki already uses is never touched.
151
+ *
152
+ * Tight on purpose: a word of four to seven letters may be one edit out, a
153
+ * longer one two, and a shorter one none. Looser, a name the wiki lacks turns
154
+ * into a word it has – `bondix` into `bonds` – and the reader gets an answer to
155
+ * a question they did not ask. Ties go to the commoner word.
156
+ */
157
+ export function correctQuery(query, vocabulary) {
158
+ let changed = false;
159
+ const fixed = query.trim().toLowerCase().split(/\s+/).map(word => {
160
+ const budget = word.length >= 8 ? 2 : word.length >= 4 ? 1 : 0;
161
+ if (!budget || vocabulary.has(word) || !/^[\p{L}\p{N}]+$/u.test(word))
162
+ return word;
163
+ let best = null;
164
+ let bestDistance = Infinity;
165
+ let bestCount = 0;
166
+ for (const [candidate, count] of vocabulary) {
167
+ if (Math.abs(candidate.length - word.length) > budget)
168
+ continue;
169
+ const distance = editDistance(word, candidate);
170
+ if (distance > budget)
171
+ continue;
172
+ if (distance < bestDistance || (distance === bestDistance && count > bestCount))
173
+ [best, bestDistance, bestCount] = [candidate, distance, count];
174
+ }
175
+ if (!best)
176
+ return word;
177
+ changed = true;
178
+ return best;
179
+ });
180
+ return changed ? fixed.join(' ') : null;
181
+ }
@@ -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,10 +9,11 @@ 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))}%`;
15
+ // Views per visit sit between one and a few, so a tenth tells them apart.
16
+ const perVisit = (n, step) => n.toFixed(step ? stepDecimals(step) : 1);
16
17
  /**
17
18
  * The change from the window before: a share of the figure before, or for a
18
19
  * rate its difference in points, since a bounce rate up 10% of itself reads
@@ -26,22 +27,24 @@ function change(now, before, { points = false, lowerIsBetter = false } = {}) {
26
27
  const text = `${n > 0 ? '+' : ''}${n}${points ? ' pp' : '%'}`;
27
28
  return n ? { text, better: n > 0 !== lowerIsBetter } : { text };
28
29
  }
29
- /** Visitors are one figure counting agents too, or `apart` two: people, and agents. */
30
- const figuresOf = ({ previous: p, ...d }, apart) => [
31
- ...(apart
32
- ? [
33
- { label: 'People', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: (r) => r.visitors },
34
- { label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: (r) => r.agents },
35
- ]
36
- : [
37
- {
38
- label: 'Visitors',
39
- value: count(d.visitors_incl_agents),
40
- delta: change(d.visitors_incl_agents, p.visitors_incl_agents),
41
- day: (r) => r.visitors_incl_agents,
42
- },
43
- ]),
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 },
44
39
  { label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
40
+ {
41
+ label: 'Views per visit',
42
+ value: d.visits ? perVisit(d.pageviews / d.visits) : '–',
43
+ delta: change(d.visits ? d.pageviews / d.visits : null, p.pageviews != null && p.visits ? p.pageviews / p.visits : null),
44
+ day: r => (r.visits ? r.pageviews / r.visits : null),
45
+ format: perVisit,
46
+ aggregate: 'mean',
47
+ },
45
48
  {
46
49
  label: 'Bounce rate',
47
50
  value: d.bounce_rate == null ? '–' : percent(d.bounce_rate),
@@ -49,14 +52,7 @@ const figuresOf = ({ previous: p, ...d }, apart) => [
49
52
  day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
50
53
  format: percent,
51
54
  aggregate: 'mean',
52
- },
53
- {
54
- label: 'Visit length',
55
- value: d.visit_duration == null ? '–' : duration(d.visit_duration),
56
- delta: change(d.visit_duration, p.visit_duration),
57
- day: r => (r.visits ? r.seconds / r.visits : null),
58
- format: duration,
59
- aggregate: 'mean',
55
+ from: d.engaged_from ?? '9999',
60
56
  },
61
57
  ...(d.follow_clicks
62
58
  ? [
@@ -70,16 +66,16 @@ const figuresOf = ({ previous: p, ...d }, apart) => [
70
66
  : []),
71
67
  ];
72
68
  const DAY = 86_400;
73
- 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 };
74
70
  /**
75
71
  * The figures as `.stats-figures button[aria-pressed]`, each with its change
76
72
  * as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
77
73
  * day from `first` (a UTC day number) to today. A count's empty day is a zero;
78
74
  * a rate's is left out, since no visits is not a bounce rate of nothing.
79
75
  */
80
- export function StatsFigures({ digest, first, apart, controls }) {
81
- const figures = useMemo(() => figuresOf(digest, apart), [digest, apart]);
82
- // 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).
83
79
  const [chosen, choose] = useState(figures[0].label);
84
80
  const figure = figures.find(f => f.label === chosen) ?? figures[0];
85
81
  const byDay = useMemo(() => new Map(digest.by_day.map(r => [r.date, r])), [digest.by_day]);
@@ -87,7 +83,8 @@ export function StatsFigures({ digest, first, apart, controls }) {
87
83
  const today = Math.floor(Date.now() / 1000 / DAY);
88
84
  return Array.from({ length: today - first + 1 }, (_, i) => {
89
85
  const time = (first + i) * DAY;
90
- 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);
91
88
  return value == null ? null : { time, value };
92
89
  }).filter(p => p !== null);
93
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,17 +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
- * nothing in them are left out. The digest is made with the same `apart`, so
34
- * the lists count the visitors the chart draws. The chart and the bars are `currentColor`
30
+ * nothing in them are left out, but for the searches that found nothing,
31
+ * which says it found none, so a quiet window never reads as one not counted. The chart and the bars are `currentColor`
35
32
  * until the importing stylesheet gives them a colour.
36
33
  */
37
- 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;
38
35
  export interface StatsGateProps {
39
36
  gate: PasskeyGate;
40
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]);
@@ -46,21 +43,21 @@ const country = (code) => {
46
43
  };
47
44
  const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
48
45
  const DAY_MS = 86_400_000;
49
- function StatsList({ title, rows, label = v => v, to, link: Link, }) {
46
+ function StatsList({ title, rows, label = v => v, to, empty, link: Link, }) {
50
47
  if (!rows.length)
51
- return null;
48
+ return empty ? (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("p", { children: empty })] })) : null;
52
49
  const max = Math.max(...rows.map(([, n]) => n));
53
50
  return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: to ? (_jsx(Link, { href: to(v), prefetch: false, children: label(v) })) : (label(v)) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
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
- * nothing in them are left out. The digest is made with the same `apart`, so
60
- * the lists count the visitors the chart draws. The chart and the bars are `currentColor`
56
+ * nothing in them are left out, but for the searches that found nothing,
57
+ * which says it found none, so a quiet window never reads as one not counted. The chart and the bars are `currentColor`
61
58
  * until the importing stylesheet gives them a colour.
62
59
  */
63
- 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 }) {
64
61
  const today = Math.floor(Date.now() / DAY_MS);
65
62
  const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
66
63
  const narrowed = STATS_FILTERS.filter(k => filter[k]);
@@ -72,12 +69,11 @@ export function Stats({ digest: d, days, filter = {}, apart = false, ranges = ST
72
69
  ? Math.max(start, counted)
73
70
  : Number.isFinite(start) ? start : Math.min(today, ...d.by_day.map(r => Date.parse(r.date) / DAY_MS));
74
71
  const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
75
- const at = (f, n = days, a = apart) => href(n, f, a);
72
+ const at = (f, n = days) => href(n, f);
76
73
  const to = (k) => (v) => at({ ...filter, [k]: v });
77
- // Each window is another digest, so a link rather than a button, pinned
78
- // with the chart's own controls, and so is the switch that counts agents apart.
79
- 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" })] }) }) })] }));
80
- 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') })] })] }));
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." })] })] }));
81
77
  }
82
78
  /**
83
79
  * A stats page's door: `children` and a sign-out button for a signed-in
package/dist/store.d.ts CHANGED
@@ -47,6 +47,13 @@ export type WikiRevisionEntry = Omit<WikiRevisionRow, 'content'> & {
47
47
  changes: HistoryChange[];
48
48
  };
49
49
  export type ListSort = 'title' | 'recent' | 'date';
50
+ /** A search's scope and page. `content` adds each row's blocks. */
51
+ export interface SearchOptions {
52
+ tagPath?: string | null;
53
+ skip?: number;
54
+ take?: number;
55
+ content?: boolean;
56
+ }
50
57
  export interface WikiStoreOptions<B extends DiffBlock> {
51
58
  sql: Sql;
52
59
  /** The site every read and write is scoped to. "" in an app that serves one wiki. */
@@ -169,17 +176,14 @@ export declare function wikiStore<B extends DiffBlock = DiffBlock>(o: WikiStoreO
169
176
  pages: number;
170
177
  updated: Date | null;
171
178
  }>;
172
- search: (query: string, { tagPath, skip, take, content: withContent }?: {
173
- tagPath?: string | null;
174
- skip?: number;
175
- take?: number;
176
- content?: boolean;
177
- }) => Promise<{
179
+ search: (query: string, options?: SearchOptions) => Promise<Awaited<ReturnType<(query: string, { tagPath, skip, take, content: withContent }?: SearchOptions) => Promise<{
178
180
  items: (WikiPageListing & {
179
181
  score: number;
180
182
  content?: B[];
181
183
  })[];
182
184
  total: number;
185
+ }>>> & {
186
+ corrected?: string;
183
187
  }>;
184
188
  history: (pageId: string, { limit, offset }?: {
185
189
  limit?: number;
package/dist/store.js CHANGED
@@ -18,7 +18,7 @@
18
18
  import { computeRevisionDiff } from './revisions.js';
19
19
  import { bump } from './versioning.js';
20
20
  import { withChanges } from './history.js';
21
- import { proseSql, escapeLikeTerm, HEADLINE_OPTIONS, FTS_RANK_NORMALIZATION } from './search.js';
21
+ import { proseSql, escapeLikeTerm, fullTextQuery, correctQuery, HEADLINE_OPTIONS, FTS_RANK_NORMALIZATION } from './search.js';
22
22
  import { openingText, matchSnippet } from './text.js';
23
23
  import { describe } from './metadata.js';
24
24
  import { decodeEntities } from './markdown.js';
@@ -132,7 +132,7 @@ export function wikiStore(o) {
132
132
  * page is never a result. A tier-3 row's snippet is Postgres' own headline,
133
133
  * because a stem match has no literal substring to window on.
134
134
  */
135
- const search = async (query, { tagPath = null, skip = 0, take = 25, content: withContent = false } = {}) => {
135
+ const ranked = async (query, { tagPath = null, skip = 0, take = 25, content: withContent = false } = {}) => {
136
136
  const term = escapeLikeTerm(query);
137
137
  if (!term)
138
138
  return { items: [], total: 0 };
@@ -163,7 +163,7 @@ export function wikiStore(o) {
163
163
  SELECT ${COLUMNS}, tier, total,
164
164
  CASE WHEN tier = 3 THEN ts_headline('english', ${proseSql()}, tsq, '${HEADLINE_OPTIONS}') END AS headline
165
165
  FROM (SELECT count(*)::int AS total FROM matched) t LEFT JOIN paged ON true
166
- ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC`, query.trim(), `${term}%`, `%${term}%`, hidden, site, tagPath, take, skip);
166
+ ORDER BY tier, CASE WHEN tier = 0 THEN title END, CASE WHEN tier = 3 THEN fts END DESC, "updatedAt" DESC`, fullTextQuery(query), `${term}%`, `%${term}%`, hidden, site, tagPath, take, skip);
167
167
  const items = found.filter(hit => hit.id !== null).map(({ tier, total: _, headline, content, ...row }) => ({
168
168
  ...row,
169
169
  ...(withContent ? { content: content } : {}),
@@ -173,6 +173,26 @@ export function wikiStore(o) {
173
173
  }));
174
174
  return { items, total: found[0]?.total ?? 0 };
175
175
  };
176
+ /** Every word of the searchable titles, with how many titles use it: what a misspelling is corrected against. */
177
+ const titleWords = async (tagPath) => new Map((await rows(`SELECT word, count(*)::int AS n FROM ${PAGE} p, regexp_split_to_table(lower(p.title), '[^[:alnum:]]+') word
178
+ WHERE p."siteId" = $1 AND word <> '' AND ($2::text IS NULL OR p."tagPath" = $2) AND ($2::text IS NOT NULL OR NOT ${hiddenSql('$3')})
179
+ GROUP BY 1`, site, tagPath, hidden)).map(r => [r.word, r.n]));
180
+ /**
181
+ * {@link ranked}, and when its first page is empty, the same search with each
182
+ * misspelled word corrected against the titles' own words (`correctQuery`).
183
+ * A result found that way carries `corrected`, the query that found it, so a
184
+ * page can say what it searched for instead.
185
+ */
186
+ const search = async (query, options = {}) => {
187
+ const found = await ranked(query, options);
188
+ if (found.total || options.skip)
189
+ return found;
190
+ const corrected = correctQuery(query, await titleWords(options.tagPath ?? null));
191
+ if (!corrected)
192
+ return found;
193
+ const again = await ranked(corrected, options);
194
+ return again.total ? { ...again, corrected } : found;
195
+ };
176
196
  /** A revision query's guard: `$1`, its page, is this site's. Another site's page id finds nothing. */
177
197
  const OWN_PAGE = `"pageId" IN (SELECT id FROM ${PAGE} WHERE id = $1 AND "siteId" = $2)`;
178
198
  /**
@@ -75,5 +75,6 @@ export declare function searchWiki<B extends DiffBlock>(o: Pick<WikiToolsOptions
75
75
  }): Promise<{
76
76
  items: WikiRow[];
77
77
  total: number;
78
+ corrected?: string;
78
79
  }>;
79
80
  export declare function wikiReadTools<B extends DiffBlock>(o: WikiToolsOptions<B>): WikiTool[];
@@ -54,8 +54,8 @@ const rowFor = (o) => (p) => {
54
54
  export async function searchWiki(o, query, { tagPath, page = 1, size = 20 } = {}) {
55
55
  if (offFor(o)(tagPath))
56
56
  return { items: [], total: 0 };
57
- const { items, total } = await o.store.search(query, { tagPath, skip: (page - 1) * size, take: size, content: true });
58
- return { items: items.map(rowFor(o)), total };
57
+ const { items, total, corrected } = await o.store.search(query, { tagPath, skip: (page - 1) * size, take: size, content: true });
58
+ return { items: items.map(rowFor(o)), total, ...(corrected ? { corrected } : {}) };
59
59
  }
60
60
  export function wikiReadTools(o) {
61
61
  const { store, tree, noun } = o;
@@ -116,7 +116,7 @@ export function wikiReadTools(o) {
116
116
  skill: { id: 'search', tags: ['search', 'wiki'] },
117
117
  description: `Keyword search across ${noun}: the first call when you know what you are looking for but not where it lives. ` +
118
118
  'Matches titles and body prose, ranked in four tiers: titles starting with the term, titles containing it, literal body hits, then English full-text hits by relevance, which stem words and drop stopwords. ' +
119
- 'Not semantic: one distinctive word beats a sentence. ' +
119
+ 'Not semantic: one distinctive word beats a sentence. A query that finds nothing is retried with its misspelled words corrected, and `corrected` then names what was searched. ' +
120
120
  'Each row carries a `path`, its size in `chars` and a snippet windowed on the match. Feed the paths to get_pages in one call rather than looping get_page. ' +
121
121
  'Narrow with tagPath when a term is common. Defaults: page 1, pageSize 20 (max 50); keep paging while `hasMore` is true.',
122
122
  inputSchema: {
@@ -137,9 +137,10 @@ export function wikiReadTools(o) {
137
137
  const size = r.num('pageSize', 20, 1, 50);
138
138
  if (!query)
139
139
  throw new McpToolError('`query` is empty. Pass a keyword to search for, or call list_pages to enumerate a branch instead.');
140
- const { items, total } = await searchWiki(o, query, { tagPath, page, size });
140
+ const { items, total, corrected } = await searchWiki(o, query, { tagPath, page, size });
141
141
  return withAdjustments(r, {
142
142
  query,
143
+ ...(corrected ? { corrected } : {}),
143
144
  ...listEnvelope(items, total, page, size, o.noMatch?.(query, tagPath)
144
145
  ?? `Nothing matches "${query}"${tagPath ? ` under "${tagPath}"` : ''}. Search is keyword, not semantic: try one distinctive word${tagPath ? ', or drop the tagPath filter' : ''}, or call get_categories to see what ${noun} covers.`),
145
146
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.100.0",
3
+ "version": "0.105.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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])