wiki-formant 0.33.0 → 0.34.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
@@ -641,8 +641,10 @@ address and browser under a salt that is replaced every UTC day, and the old
641
641
  salt is deleted, so a visitor is told apart within a day and never followed
642
642
  into the next. Rows are kept 400 days. The package owns the SQL and the app
643
643
  owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
644
- placeholders, and the three tables it needs are stated as Prisma models at the
645
- top of `src/analytics.ts`.
644
+ placeholders, and the four tables it needs are stated as Prisma models at the
645
+ top of `src/analytics.ts`. The fourth, `ViewDay`, holds days a site counted
646
+ elsewhere before it counted its own; `digest` adds them to any window that
647
+ reaches back that far, and a site with no such history leaves it empty.
646
648
 
647
649
  ```ts
648
650
  // src/lib/track.ts
@@ -722,7 +724,7 @@ export const POST = gate.route;
722
724
  // src/app/stats/page.tsx
723
725
  if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
724
726
  return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
725
- const days = statsDays((await searchParams).days); // 1, 7, 30 or 90, else 30
727
+ const days = statsDays((await searchParams).days); // 1, 7, 30, 90 or 365, else 30
726
728
  return <Stats digest={await digest(sql, { days })} days={days} />;
727
729
  ```
728
730
 
@@ -111,7 +111,8 @@ export interface Digest {
111
111
  /**
112
112
  * Everything a report needs about one site's last `days` days, in one query.
113
113
  * Visitor figures count distinct visitors, so a person who comes back on
114
- * another day counts once for each day.
114
+ * another day counts once for each day. Days in `ViewDay` add to the visitor,
115
+ * page, source, country, device and per-day figures; events have no history.
115
116
  */
116
117
  export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
117
118
  /**
package/dist/analytics.js CHANGED
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // The package owns the SQL; the app owns the connection. `Sql` is one function
16
16
  // that runs a parameterised statement, which Prisma, `pg` and postgres.js can
17
- // all supply in a line. The app declares the three tables in its own schema,
17
+ // all supply in a line. The app declares the four tables in its own schema,
18
18
  // so `prisma db push` keeps them:
19
19
  //
20
20
  // model View {
@@ -45,6 +45,28 @@
45
45
  // value String
46
46
  // }
47
47
  //
48
+ // model ViewDay {
49
+ // siteId String @default("")
50
+ // day DateTime @db.Date
51
+ // visitors Int
52
+ // pageviews Int
53
+ // visits Int
54
+ // bounces Int
55
+ // seconds Int
56
+ // pages Json @default("{}")
57
+ // entryPages Json @default("{}")
58
+ // sources Json @default("{}")
59
+ // countries Json @default("{}")
60
+ // devices Json @default("{}")
61
+ // @@id([siteId, day])
62
+ // }
63
+ //
64
+ // `ViewDay` holds days a site counted somewhere else before it counted its
65
+ // own, one row a day with each breakdown as `{ value: visitors }`. Nothing
66
+ // writes or deletes it: the app loads it once, and `digest` adds the days in a
67
+ // window to the views. Summing days agrees with the views, whose visitor is
68
+ // one browser on one day. A site with no history leaves it empty.
69
+ //
48
70
  // `siteId` is "" in an app that serves one site. A multi-tenant app passes its
49
71
  // own site key to every call.
50
72
  //
@@ -169,13 +191,16 @@ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results:
169
191
  /**
170
192
  * Everything a report needs about one site's last `days` days, in one query.
171
193
  * Visitor figures count distinct visitors, so a person who comes back on
172
- * another day counts once for each day.
194
+ * another day counts once for each day. Days in `ViewDay` add to the visitor,
195
+ * page, source, country, device and per-day figures; events have no history.
173
196
  */
174
197
  export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
175
198
  // The window is the database's clock, which stamped the rows; the app's can run behind it.
176
199
  const values = [site, Math.max(1, Math.round(days))];
177
200
  const $ = (v) => `$${values.push(v)}`;
178
201
  const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
202
+ /** A ranked list from the views, plus the same breakdown from `ViewDay`. */
203
+ const merged = (label, column, live) => `SELECT k AS ${label}, sum(n) AS visitors FROM (${live} UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a."${column}")) t(k, n) GROUP BY 1`;
179
204
  const gapSelects = gaps.map(g => {
180
205
  const prop = $(g.prop);
181
206
  return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
@@ -195,23 +220,41 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
195
220
  FROM numbered GROUP BY visitor, visit
196
221
  ), e AS (
197
222
  SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
223
+ ), a AS (
224
+ SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date
225
+ ), sessions AS (
226
+ SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
227
+ SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits
228
+ UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a
229
+ ) s(bounces, visits, seconds)
230
+ ), src AS (
231
+ SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
232
+ UNION ALL SELECT key, value::int FROM a, jsonb_each_text(a.sources)
233
+ ), archived AS (
234
+ SELECT coalesce(sum(visitors), 0) AS visitors, coalesce(sum(pageviews), 0) AS pageviews FROM a
198
235
  )
199
236
  SELECT json_build_object(
200
- 'visitors', (SELECT count(DISTINCT visitor) FROM v),
201
- 'pageviews', (SELECT count(*) FROM v),
202
- 'bounce_rate', (SELECT round(100.0 * count(*) FILTER (WHERE views = 1) / nullif(count(*), 0)) FROM visits),
203
- 'visit_duration', (SELECT round(avg(seconds)) FROM visits),
204
- 'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) a),
237
+ 'visitors', (SELECT count(DISTINCT visitor) FROM v) + (SELECT visitors FROM archived),
238
+ 'pageviews', (SELECT count(*) FROM v) + (SELECT pageviews FROM archived),
239
+ 'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions),
240
+ 'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions),
241
+ 'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) u) + (SELECT visitors FROM archived),
205
242
  'previous_visitors', (SELECT count(DISTINCT visitor) FROM "View"
206
- WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int)),
207
- 'top_sources', ${list(`SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1`, 6)},
208
- 'top_pages', ${list('SELECT path AS page, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1', 6)},
209
- 'entry_pages', ${list('SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1', 6)},
243
+ WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int))
244
+ + (SELECT coalesce(sum(visitors), 0) FROM "ViewDay" WHERE "siteId" = $1
245
+ AND day >= (now() AT TIME ZONE 'UTC' - make_interval(days => 2 * $2::int))::date
246
+ AND day < (now() AT TIME ZONE 'UTC' - make_interval(days => $2::int))::date),
247
+ 'top_sources', ${list('SELECT source, sum(visitors) AS visitors FROM src GROUP BY 1', 6)},
248
+ 'top_pages', ${list(merged('page', 'pages', 'SELECT path, count(DISTINCT visitor) FROM v GROUP BY 1'), 6)},
249
+ 'entry_pages', ${list(merged('page', 'entryPages', 'SELECT entry, count(DISTINCT visitor) FROM visits GROUP BY 1'), 6)},
210
250
  'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
211
- 'sibling_referrals', ${list(`SELECT source, count(DISTINCT visitor) AS visitors FROM visits WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
212
- 'countries', ${list('SELECT country, count(DISTINCT visitor) AS visitors FROM v WHERE country IS NOT NULL GROUP BY 1', 10)},
213
- 'devices', ${list('SELECT device, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1')},
214
- 'by_day', ${list(`SELECT to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1`, undefined, '1')},
251
+ 'sibling_referrals', ${list(`SELECT source, sum(visitors) AS visitors FROM src WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
252
+ 'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
253
+ 'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
254
+ 'by_day', ${list(`SELECT date, sum(n) AS visitors FROM (
255
+ SELECT to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD'), count(DISTINCT visitor) FROM v GROUP BY 1
256
+ UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors FROM a
257
+ ) t(date, n) GROUP BY 1`, undefined, '1')},
215
258
  'follow_clicks', ${handle
216
259
  ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e
217
260
  WHERE name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[]))`
package/dist/base.css CHANGED
@@ -186,7 +186,8 @@ pre:hover > .code-copy-btn,
186
186
  .stats-chart {
187
187
  display: flex;
188
188
  align-items: flex-end;
189
- gap: 2px;
189
+ /* A year's bars on a phone would be all gap at 2px each. */
190
+ gap: min(2px, 100% / var(--bars, 1) / 4);
190
191
  height: 8rem;
191
192
  margin: 0;
192
193
  padding: 0;
@@ -277,7 +277,7 @@ export interface StatsProps {
277
277
  ranges?: readonly number[];
278
278
  }
279
279
  /** The windows the stats page offers, in days. */
280
- export declare const STATS_RANGES: readonly [1, 7, 30, 90];
280
+ export declare const STATS_RANGES: readonly [1, 7, 30, 90, 365];
281
281
  /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
282
282
  export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
283
283
  /**
@@ -226,7 +226,7 @@ export function RevisionChanges({ changes, label = type => type }) {
226
226
  return (_jsx("ul", { className: "revision-changes", children: changes.map((c, i) => (_jsxs("li", { className: "revision-change", "data-action": c.action, children: [_jsx("span", { className: "revision-change-action", children: ACTION_LABEL[c.action] }), ` – ${label(c.type)} at ${blockLocation(c.path)}`, c.leafDiff && (_jsx("p", { className: "revision-diff", children: c.leafDiff.map(([op, text], j) => op === -1 ? _jsx("del", { children: text }, j) : op === 1 ? _jsx("ins", { children: text }, j) : _jsx("span", { children: text }, j)) }))] }, i))) }));
227
227
  }
228
228
  /** The windows the stats page offers, in days. */
229
- export const STATS_RANGES = [1, 7, 30, 90];
229
+ export const STATS_RANGES = [1, 7, 30, 90, 365];
230
230
  /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
231
231
  export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => String(n) === param) ?? fallback;
232
232
  const count = (n) => n.toLocaleString('en');
@@ -278,5 +278,5 @@ export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
278
278
  if (d.follow_clicks)
279
279
  figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
280
280
  const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
281
- return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx("ol", { className: "stats-chart", "aria-label": "Visitors per day", children: series.map(([date, n]) => (_jsx("li", { title: `${date}: ${count(n)}`, style: { '--share': n / peak } }, date))) }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
281
+ return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx("ol", { className: "stats-chart", "aria-label": "Visitors per day", style: { '--bars': series.length }, children: series.map(([date, n]) => (_jsx("li", { title: `${date}: ${count(n)}`, style: { '--share': n / peak } }, date))) }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
282
282
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.33.0",
3
+ "version": "0.34.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",