wiki-formant 0.92.0 → 0.94.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
@@ -957,7 +957,10 @@ An agent is counted as a visitor is, one address and user agent a day, from its
957
957
  `AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
958
958
  among the visitors, and `visitors_incl_agents` counts the two at once.
959
959
  `ownAgents` leaves the site's own out of both. A visit is a run of one
960
- visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
960
+ visitor's views with no gap over 30 minutes, and a bounce is a visit of one view
961
+ with no event of the reader's inside it: a search that keeps the path, a click
962
+ out or a share makes the visit count. A browser under automation
963
+ (`navigator.webdriver`) sends no beacon at all. A multi-tenant app passes a `site`
961
964
  key to every call and a `site` resolver to `collect`.
962
965
 
963
966
  Instrumenting the agent lane and not the human one is the easy mistake: it
@@ -1081,7 +1084,8 @@ which no total makes sense of. A stepped range starts on a whole week or month,
1081
1084
  so its first point is not a few days of one; the week in progress is its total
1082
1085
  so far. `fromZero` starts a count's axis at zero and stops it there, with
1083
1086
  no negative ticks under it. A single range draws no range buttons and shows every point.
1084
- `chartSteps` is exported for a chart drawn elsewhere.
1087
+ No step is finer than the points' own spacing, so weekly readings offer W and M
1088
+ and draw on the days they were taken. `chartSteps` is exported for a chart drawn elsewhere.
1085
1089
 
1086
1090
  `format` writes the axis and the crosshair label. An axis tick also gets the
1087
1091
  spacing between ticks as a second argument, so a formatter can write every tick
@@ -1090,13 +1094,15 @@ decimal places that takes, and `formatChartValue`, the default, uses it.
1090
1094
 
1091
1095
  `markers` are events, `{ time, label }`, each drawn as an arrow on the last
1092
1096
  point at or before it, so one mid-week lands on its week and two on one point
1093
- share it. `step` opens on a step where the
1094
- range can make it, and `onView` hears the range and step on mount and on every
1095
- change, for a page that keeps them in its URL:
1097
+ share it. No words go on the plot: the label is a tooltip (`.chart-tip`) while
1098
+ the crosshair is on the point, one line an event, so it can be the event's full
1099
+ name. `step` opens on a step where the range can make it, and `onView` hears the
1100
+ range, and the step the reader picked (null while the chart picks its own), once
1101
+ the points are in and on every change, for a page that keeps them in its URL:
1096
1102
 
1097
1103
  ```tsx
1098
1104
  <TimeChart series={days} label="Transactions" aggregate="sum" fromZero
1099
- markers={[{ time: haltAt, label: 'Halt' }]} step="month" onView={v => keep(v)} />
1105
+ markers={[{ time: haltAt, label: 'Network halt' }]} step="month" onView={v => keep(v)} />
1100
1106
  ```
1101
1107
  `base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
1102
1108
  canvas's top-left corner, rules a line between groups and dims a disabled step;
@@ -89,7 +89,7 @@ export interface DigestDay {
89
89
  visitors: number;
90
90
  pageviews: number;
91
91
  visits: number;
92
- /** Visits that saw one page. */
92
+ /** Visits that saw one page and did nothing else: a search, a click out or any reader event makes it a visit. */
93
93
  bounces: number;
94
94
  /** Seconds from each visit's first view to its last, summed. */
95
95
  seconds: number;
@@ -101,7 +101,7 @@ export interface DigestDay {
101
101
  export interface Digest {
102
102
  visitors: number;
103
103
  pageviews: number;
104
- /** Percent of visits that saw one page; null with no visits. */
104
+ /** Percent of visits that are bounces (`DigestDay.bounces`); null with no visits. */
105
105
  bounce_rate: number | null;
106
106
  /** Mean seconds from a visit's first view to its last; null with no visits. */
107
107
  visit_duration: number | null;
package/dist/analytics.js CHANGED
@@ -223,6 +223,10 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
223
223
  ), e${s} AS (
224
224
  SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
225
225
  ${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
226
+ ), bounced${s} AS (
227
+ SELECT * FROM visits${s} b WHERE views = 1 AND NOT EXISTS (
228
+ SELECT 1 FROM e${s} x WHERE x.visitor = b.visitor AND x.name NOT IN ('AI Bot Visit', 'MCP Call')
229
+ AND x.at >= b.at AND x.at < b.at + interval '30 minutes')
226
230
  ), ag${s} AS (
227
231
  SELECT visitor, at, ${AGENT_NAME} AS agent FROM e${s}
228
232
  WHERE name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
@@ -230,7 +234,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
230
234
  SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
231
235
  ), sessions${s} AS (
232
236
  SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
233
- SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits${s}
237
+ SELECT (SELECT count(*) FROM bounced${s}), count(*), coalesce(sum(seconds), 0) FROM visits${s}
234
238
  UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
235
239
  ) t(bounces, visits, seconds)
236
240
  ), archived${s} AS (
@@ -262,7 +266,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
262
266
  'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
263
267
  sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
264
268
  SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
265
- UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
269
+ UNION ALL SELECT ${day('at')}, 0, 0, count(*), 0, coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
270
+ UNION ALL SELECT ${day('at')}, 0, 0, 0, count(*), 0, 0, 0, 0 FROM bounced GROUP BY 1
266
271
  UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
267
272
  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
268
273
  ${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
package/dist/base.css CHANGED
@@ -614,6 +614,20 @@ pre:hover > .code-copy-btn,
614
614
  cut the axis to half its labels. */
615
615
  .chart-canvas table { table-layout: auto; }
616
616
  .chart-status { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; }
617
+ /* An event's name, over the mark the crosshair is on, in the colours of the axis labels. */
618
+ .chart-tip {
619
+ position: absolute;
620
+ z-index: 2;
621
+ max-width: 50%;
622
+ padding: 0.25rem 0.5rem;
623
+ font-size: 0.6875rem;
624
+ line-height: 1.4;
625
+ white-space: pre-line;
626
+ color: var(--ui-on-accent);
627
+ background: var(--ui-accent);
628
+ border-radius: calc(var(--ui-radius) / 2);
629
+ pointer-events: none;
630
+ }
617
631
  .chart-controls {
618
632
  position: absolute;
619
633
  top: 0.5rem;
package/dist/chart.d.ts CHANGED
@@ -18,12 +18,12 @@ export type ChartResolution = 'hour' | '4h' | 'day';
18
18
  export type ChartStep = 'day' | 'week' | 'month';
19
19
  /** Points by resolution, from `from` (Unix seconds, 0 for all time), oldest first. */
20
20
  export type ChartLoader = (resolution: ChartResolution, from: number) => Promise<readonly ChartPoint[]>;
21
- /** Something that happened at a time, in Unix seconds, drawn on the point that holds it. */
21
+ /** Something that happened at a time, in Unix seconds, marked on the point that holds it; `label` is its tooltip. */
22
22
  export type ChartMarker = {
23
23
  time: number;
24
24
  label: string;
25
25
  };
26
- /** The range and step a reader has picked, for a page that keeps them in its URL. */
26
+ /** The range and step a reader has picked, for a page that keeps them in its URL; `step` is null while the chart picks its own. */
27
27
  export type ChartView = {
28
28
  range: ChartRange;
29
29
  step: ChartStep | null;
@@ -32,8 +32,12 @@ export type ChartView = {
32
32
  export type ChartAggregate = 'last' | 'sum' | 'mean';
33
33
  /** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
34
34
  export declare const CHART_RANGES: readonly ChartRange[];
35
- /** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
36
- export declare function chartSteps(span: number): {
35
+ /**
36
+ * A step is offered once it makes four points, and the default is the finest that
37
+ * makes 200 or fewer. None is finer than the points' own `spacing`: weekly readings
38
+ * have no day.
39
+ */
40
+ export declare function chartSteps(span: number, spacing?: number): {
37
41
  steps: ChartStep[];
38
42
  fallback: ChartStep;
39
43
  };
@@ -71,7 +75,7 @@ export interface TimeChartProps {
71
75
  * window links, which load another digest rather than slice this one.
72
76
  */
73
77
  controls?: ReactNode;
74
- /** Events drawn on the points that hold them. */
78
+ /** Events marked on the points that hold them, each named in a tooltip. */
75
79
  markers?: readonly ChartMarker[];
76
80
  /** The step shown first, where the range can make it. */
77
81
  step?: ChartStep;
package/dist/chart.js CHANGED
@@ -8,8 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // loaded on mount, so the page paints without it. Every colour is the canvas
9
9
  // box's own `color`, its axis `--chart-axis`, and a candle `--chart-up` or
10
10
  // `--chart-down`, each if set. A count's line is curved; a price's is straight,
11
- // since a curve between two closes draws prices nobody paid. Events are markers
12
- // on the point they fall in, so a spike carries the reason for it.
11
+ // since a curve between two closes draws prices nobody paid. Events are marks
12
+ // on the point they fall in, named in a tooltip over it, so a spike carries the
13
+ // reason for it without words across the plot.
13
14
  import { useEffect, useMemo, useRef, useState } from 'react';
14
15
  const DAY = 86_400;
15
16
  const RANGE = {
@@ -29,11 +30,22 @@ const STEPS = ['day', 'week', 'month'];
29
30
  /** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
30
31
  export const CHART_RANGES = ['24h', '7d', '30d', '90d', '1y', 'all'];
31
32
  const DAILY_RANGES = ['30d', '90d', '1y', 'all'];
32
- /** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
33
- export function chartSteps(span) {
34
- const steps = STEPS.filter(s => s === 'day' || span / STEP[s].span >= 4);
33
+ /**
34
+ * A step is offered once it makes four points, and the default is the finest that
35
+ * makes 200 or fewer. None is finer than the points' own `spacing`: weekly readings
36
+ * have no day.
37
+ */
38
+ export function chartSteps(span, spacing = DAY) {
39
+ const fine = STEPS.filter(s => STEP[s].span >= spacing * 0.9);
40
+ const finest = fine[0] ?? 'month';
41
+ const steps = STEPS.filter(s => s === finest || (fine.includes(s) && span / STEP[s].span >= 4));
35
42
  return { steps, fallback: steps.find(s => span / STEP[s].span <= 200) ?? steps.at(-1) };
36
43
  }
44
+ /** The points' own spacing: the median gap, so a missing day or a skipped week does not change it. */
45
+ function spacing(points) {
46
+ const gaps = points.slice(1).map((p, i) => p.time - points[i].time).sort((a, b) => a - b);
47
+ return gaps[Math.floor(gaps.length / 2)] ?? DAY;
48
+ }
37
49
  /** The UTC day, Monday or first of the month a time falls in. */
38
50
  function bucketStart(time, step) {
39
51
  const day = Math.floor(time / DAY);
@@ -81,7 +93,7 @@ export function bucketChart(points, step, aggregate) {
81
93
  }
82
94
  /**
83
95
  * Markers on the points drawn: each on the last point at or before it, so one
84
- * mid-week sits on its week. Markers on one point share it, labels joined; one
96
+ * mid-week sits on its week. Markers on one point share it, a label a line; one
85
97
  * before the first point or after the last point's step is left off.
86
98
  *
87
99
  * @internal
@@ -102,7 +114,7 @@ export function placeMarkers(markers, shown, step) {
102
114
  const time = shown[i].time;
103
115
  placed.set(time, [...(placed.get(time) ?? []), m.label]);
104
116
  }
105
- return [...placed].map(([time, labels]) => ({ time, label: labels.join(' · ') }));
117
+ return [...placed].map(([time, labels]) => ({ time, label: labels.join('\n') }));
106
118
  }
107
119
  const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
108
120
  /** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
@@ -163,6 +175,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
163
175
  const [step, setStep] = useState(initialStep ?? null);
164
176
  const [candles, setCandles] = useState(initialCandles);
165
177
  const [loaded, setLoaded] = useState(null);
178
+ // The marker under the crosshair, at the cursor, in the box's pixels.
179
+ const [tip, setTip] = useState(null);
180
+ const placed = useRef(new Map());
166
181
  const resolution = RANGE[range].resolution;
167
182
  useEffect(() => {
168
183
  const el = box.current;
@@ -227,6 +242,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
227
242
  wickDownColor: down,
228
243
  priceFormat,
229
244
  });
245
+ api.subscribeCrosshairMove(({ time, point }) => {
246
+ const label = time === undefined || !point ? undefined : placed.current.get(time);
247
+ setTip(label ? { x: point.x, y: point.y, label } : null);
248
+ });
230
249
  // The chart cannot be scrolled or zoomed, so every size it takes shows every point,
231
250
  // the first included: data drawn before autoSize has measured the box fits nothing.
232
251
  api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
@@ -298,9 +317,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
298
317
  });
299
318
  if (resolution !== 'day' || !windowed.length)
300
319
  return drawn(windowed, [], null);
301
- const { steps, fallback } = chartSteps(end - windowed[0].time);
320
+ const { steps, fallback } = chartSteps(end - windowed[0].time, spacing(points));
302
321
  const chosen = step && steps.includes(step) ? step : fallback;
303
- if (chosen === 'day')
322
+ // The points' own step draws them as they are: a Sunday's reading stays on its Sunday.
323
+ if (chosen === steps[0])
304
324
  return drawn(windowed, steps, chosen);
305
325
  // A stepped range starts on a whole week or month, so its first total is not a few days of one.
306
326
  const from = bucketStart(windowed[0].time, chosen);
@@ -336,24 +356,29 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
336
356
  const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
337
357
  return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
338
358
  }));
339
- const drawn = placeMarkers(markers ?? [], shown, view?.step ?? null).map(m => ({
340
- time: time(m),
341
- position: 'aboveBar',
342
- shape: 'arrowDown',
343
- color: c.ink,
344
- text: m.label,
345
- }));
359
+ const marks = placeMarkers(markers ?? [], shown, view?.step ?? null);
360
+ placed.current = new Map(marks.map(m => [m.time, m.label]));
361
+ const drawn = marks.map(m => ({ time: time(m), position: 'aboveBar', shape: 'arrowDown', color: c.ink }));
346
362
  c.areaMarkers.setMarkers(asCandles ? [] : drawn);
347
363
  c.candleMarkers.setMarkers(asCandles ? drawn : []);
348
364
  c.api.timeScale().fitContent();
349
365
  }, [ready, source, view, candles, markers]);
350
- const shownStep = view?.step ?? null;
366
+ // Reported once there are points to step, so a step read from a URL is not cleared while they load.
367
+ const shown = !!view;
368
+ const picked = step && step === view?.step ? step : null;
351
369
  useEffect(() => {
352
- onViewRef.current?.({ range, step: shownStep });
353
- }, [range, shownStep]);
370
+ if (shown)
371
+ onViewRef.current?.({ range, step: picked });
372
+ }, [shown, range, picked]);
354
373
  // The steps always show where a range is drawn by day, so the buttons do not jump about; one
355
374
  // the range cannot make (any, under 24H and 7D) is disabled.
356
375
  const daily = ranges.some(r => RANGE[r].resolution === 'day');
357
376
  const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
358
- return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
377
+ return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), tip && box.current && (
378
+ // Over the cursor, or under it near the top; growing away from the nearer side.
379
+ _jsx("span", { className: "chart-tip", role: "tooltip", style: {
380
+ top: box.current.offsetTop + tip.y,
381
+ ...(tip.x < box.current.clientWidth / 2 ? { left: tip.x } : { right: box.current.clientWidth - tip.x }),
382
+ translate: tip.y < 48 ? '0 1rem' : '0 calc(-100% - 0.75rem)',
383
+ }, children: tip.label })), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
359
384
  }
package/dist/dom.js CHANGED
@@ -330,7 +330,10 @@ export function scrollTables(html) {
330
330
  // ---- analytics beacon -------------------------------------------------------
331
331
  let beaconEndpoint = '/api/view';
332
332
  let beaconStarted = false;
333
- const sendBeacon = (body) => navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
333
+ // A browser under automation (Puppeteer, Playwright, Selenium) sets
334
+ // `navigator.webdriver`: a crawler that runs scripts, whose user agent the bot
335
+ // pattern cannot tell from a reader's, so it sends nothing at all.
336
+ const sendBeacon = (body) => navigator.webdriver || navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
334
337
  /**
335
338
  * Report each page view to the site's own `endpoint`, whose route hands it to
336
339
  * `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.92.0",
3
+ "version": "0.94.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",