wiki-formant 0.56.0 → 0.58.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
@@ -847,7 +847,8 @@ own subpath, because it imports `@simplewebauthn/browser`.
847
847
  `Stats` (`wiki-formant/stats`) renders a `digest` as one page: the
848
848
  headline figures, each with its change from the window before
849
849
  (`.stats-delta[data-better]`, a share or for the bounce rate points, for the
850
- site's stylesheet to colour), one of them per day, every non-empty
850
+ site's stylesheet to colour), one of them by day, week or month (a count's
851
+ total, a rate's mean), every non-empty
851
852
  ranked list, and `?days=` links between windows, all time among them, pinned in
852
853
  the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
853
854
  figure is a button that charts it: visitors, agents, page views, bounce rate,
@@ -897,7 +898,7 @@ that makes 200 or fewer, so a year opens weekly and five years monthly.
897
898
 
898
899
  ```tsx
899
900
  // Daily points: the chart slices and steps them itself; 30D up.
900
- <TimeChart series={days} label="Transactions" aggregate="mean" fromZero />
901
+ <TimeChart series={days} label="Transactions" aggregate="sum" fromZero />
901
902
 
902
903
  // A loader: asked for 'hour' (24H), '4h' (7D) or 'day' from 0 (every longer
903
904
  // range), once each.
@@ -915,10 +916,18 @@ the slot empty, except the last, which is the price now. A price's line is
915
916
  straight between closes and a count's is curved.
916
917
 
917
918
  `aggregate` is how a week or month is made from its days: `last` for a price,
918
- `mean` for a count, so the week in progress keeps its level instead of reading
919
- as a collapse. A single range draws no range buttons and shows every point.
919
+ `sum` for a count, so a week reads as the week's total, and `mean` for a rate,
920
+ which no total makes sense of. A stepped range starts on a whole week or month,
921
+ so its first point is not a few days of one; the week in progress is its total
922
+ so far. `fromZero` starts a count's axis at zero and stops it there, with
923
+ no negative ticks under it. A single range draws no range buttons and shows every point.
920
924
  `chartSteps` is exported for a chart drawn elsewhere.
921
925
 
926
+ `format` writes the axis and the crosshair label. An axis tick also gets the
927
+ spacing between ticks as a second argument, so a formatter can write every tick
928
+ to the same precision and two never read alike: `stepDecimals(step)` is the
929
+ decimal places that takes, and `formatChartValue`, the default, uses it.
930
+
922
931
  `markers` are events, `{ time, label }`, each drawn as an arrow on the last
923
932
  point at or before it, so one mid-week lands on its week and two on one point
924
933
  share it. `step` opens on a step where the
@@ -926,7 +935,7 @@ range can make it, and `onView` hears the range and step on mount and on every
926
935
  change, for a page that keeps them in its URL:
927
936
 
928
937
  ```tsx
929
- <TimeChart series={days} label="Transactions" aggregate="mean" fromZero
938
+ <TimeChart series={days} label="Transactions" aggregate="sum" fromZero
930
939
  markers={[{ time: haltAt, label: 'Halt' }]} step="month" onView={v => keep(v)} />
931
940
  ```
932
941
  `base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
@@ -1021,10 +1030,11 @@ An app that keeps one schema file and writes the models into it by hand, because
1021
1030
  npx prisma-models analytics --check --in prisma/schema.prisma --tenant # miow
1022
1031
  ```
1023
1032
 
1024
- `fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why.
1033
+ `fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why. A PascalCase Prisma table goes in quoted, as SQL names it.
1025
1034
 
1026
1035
  ```sh
1027
1036
  npx fts-ddl pages
1037
+ npx fts-ddl '"WikiPage"'
1028
1038
  ```
1029
1039
 
1030
1040
  `mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
package/bin/fts-ddl.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  //
9
9
  // npx fts-ddl pages
10
10
  // npx fts-ddl wiki_pages
11
+ // npx fts-ddl '"WikiPage"'
11
12
  //
12
13
  // The column is rebuilt every run rather than skipped when it exists: a skip
13
14
  // is blind to a changed EXPRESSION, and Postgres normalises the stored one, so
@@ -32,7 +33,7 @@ import { searchTsvDdl } from '../dist/search.js';
32
33
  import { connect } from './_cli.mjs';
33
34
 
34
35
  const table = process.argv[2];
35
- if (!table || !/^[a-z_][a-z0-9_]*$/.test(table)) {
36
+ if (!table || !/^([a-z_][a-z0-9_]*|"[A-Za-z_][A-Za-z0-9_]*")$/.test(table)) {
36
37
  console.error('usage: npx fts-ddl <table>');
37
38
  process.exit(1);
38
39
  }
@@ -43,6 +44,7 @@ const client = await connect({ direct: true, ssl: { rejectUnauthorized: false },
43
44
  process.exit(1);
44
45
  });
45
46
  const q = s => client.query(s).then(r => r.rows);
47
+ const bare = table.replaceAll('"', '');
46
48
  try {
47
49
  const ddl = searchTsvDdl(table);
48
50
  await q('BEGIN');
@@ -52,9 +54,9 @@ try {
52
54
  await q(ddl.index);
53
55
  console.log('GIN index present');
54
56
  console.table(await q(`select column_name, data_type, is_generated from information_schema.columns
55
- where table_name='${table}' and column_name='search_tsv'`));
57
+ where table_name='${bare}' and column_name='search_tsv'`));
56
58
  console.table(await q(`select count(*)::int rows, count(*) filter (where search_tsv is null)::int null_tsv from ${table}`));
57
- console.table(await q(`select pg_size_pretty(pg_relation_size('${table}_search_tsv_idx')) index_size`));
59
+ console.table(await q(`select pg_size_pretty(pg_relation_size('"${bare}_search_tsv_idx"')) index_size`));
58
60
  } catch (e) {
59
61
  await q('ROLLBACK').catch(() => {});
60
62
  console.error(e.message);
package/dist/chart.d.ts CHANGED
@@ -28,6 +28,8 @@ export type ChartView = {
28
28
  range: ChartRange;
29
29
  step: ChartStep | null;
30
30
  };
31
+ /** How a week or month is made from its days: a price's last, a count's total, a rate's mean. */
32
+ export type ChartAggregate = 'last' | 'sum' | 'mean';
31
33
  /** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
32
34
  export declare const CHART_RANGES: readonly ChartRange[];
33
35
  /** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
@@ -35,8 +37,13 @@ export declare function chartSteps(span: number): {
35
37
  steps: ChartStep[];
36
38
  fallback: ChartStep;
37
39
  };
38
- /** Compact from a thousand up, four significant figures below. */
39
- export declare const formatChartValue: (n: number) => string;
40
+ /** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
41
+ export declare function stepDecimals(step: number): number;
42
+ /**
43
+ * Compact from a thousand up, four significant figures below. An axis tick is
44
+ * written to its step in the unit it reads in, so 4.845B is not a second 4.85B.
45
+ */
46
+ export declare function formatChartValue(n: number, step?: number): string;
40
47
  export interface TimeChartProps {
41
48
  /** Daily points, oldest first, or a loader for every resolution the ranges need. */
42
49
  series: readonly ChartPoint[] | ChartLoader;
@@ -46,12 +53,15 @@ export interface TimeChartProps {
46
53
  ranges?: readonly ChartRange[];
47
54
  /** The range shown first. */
48
55
  range?: ChartRange;
49
- /** A week or month of prices keeps its last; of counts, the mean of its days. */
50
- aggregate?: 'last' | 'mean';
56
+ /** A week or month of prices keeps its last; of counts, the total of its days; of rates, their mean. */
57
+ aggregate?: ChartAggregate;
51
58
  /** The value axis starts at zero, so a quiet week does not read as a collapse. */
52
59
  fromZero?: boolean;
53
- /** The value axis and crosshair label. Read on every draw, so it need not be stable. */
54
- format?: (n: number) => string;
60
+ /**
61
+ * The value axis and crosshair label. Read on every draw, so it need not be stable.
62
+ * An axis tick passes the spacing between ticks, so neighbours never read alike.
63
+ */
64
+ format?: (n: number, step?: number) => string;
55
65
  /** Open on candles, where the points carry them. A candles toggle shows either way. */
56
66
  candles?: boolean;
57
67
  /** In pixels. Otherwise the stylesheet's `--chart-height`, else 15rem. */
package/dist/chart.js CHANGED
@@ -48,8 +48,8 @@ function bucketStart(time, step) {
48
48
  /**
49
49
  * Points, oldest first, one per step. A price keeps the last value in each
50
50
  * step, as a candle does its close: the first open, the highest high, the
51
- * lowest low. A count takes the mean of its days, so the week in progress does
52
- * not read as a collapse. Volume is summed.
51
+ * lowest low. A count is the total of its days; a rate, their mean. Volume is
52
+ * summed.
53
53
  *
54
54
  * @internal
55
55
  */
@@ -66,6 +66,8 @@ export function bucketChart(points, step, aggregate) {
66
66
  }
67
67
  if (aggregate === 'last')
68
68
  last.value = p.value;
69
+ else if (aggregate === 'sum')
70
+ last.value += p.value;
69
71
  else
70
72
  last.value += (p.value - last.value) / ++n;
71
73
  if (last.high !== undefined && p.high !== undefined)
@@ -103,10 +105,25 @@ export function placeMarkers(markers, shown, step) {
103
105
  return [...placed].map(([time, labels]) => ({ time, label: labels.join(' · ') }));
104
106
  }
105
107
  const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
108
+ /** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
109
+ export function stepDecimals(step) {
110
+ const exponent = Math.floor(Math.log10(step));
111
+ // The axis steps by 1, 2, 2.5 or 5 times a power of ten; rounding drops float noise.
112
+ const mantissa = String(Math.round((step / 10 ** exponent) * 1e6) / 1e6);
113
+ return Math.min(20, Math.max(0, (mantissa.split('.')[1]?.length ?? 0) - exponent));
114
+ }
106
115
  const compact = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 });
107
116
  const precise = new Intl.NumberFormat('en', { maximumSignificantDigits: 4 });
108
- /** Compact from a thousand up, four significant figures below. */
109
- export const formatChartValue = (n) => (Math.abs(n) >= 1e3 ? compact.format(n) : precise.format(n));
117
+ /**
118
+ * Compact from a thousand up, four significant figures below. An axis tick is
119
+ * written to its step in the unit it reads in, so 4.845B is not a second 4.85B.
120
+ */
121
+ export function formatChartValue(n, step) {
122
+ if (step === undefined)
123
+ return Math.abs(n) >= 1e3 ? compact.format(n) : precise.format(n);
124
+ const unit = Math.abs(n) >= 1e3 ? 1e3 ** Math.min(4, Math.floor(Math.log10(Math.abs(n)) / 3)) : 1;
125
+ return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: stepDecimals(step / unit) }).format(n);
126
+ }
110
127
  /**
111
128
  * A CSS colour at an opacity, as the rgba() the chart's parser reads. Drawn to
112
129
  * a pixel and read back, because a site's colour may be oklch() or a named one.
@@ -119,12 +136,16 @@ function rgba(colour, alpha = 1) {
119
136
  ctx.fillRect(0, 0, 1, 1);
120
137
  return `rgba(${Array.from(ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)).join(', ')}, ${alpha})`;
121
138
  }
139
+ // A count's axis starts at zero and stops there: the scale's bottom margin is off and
140
+ // a few pixels below zero hold its label, where a margin would draw negative ticks.
122
141
  const fromZeroScale = (base) => {
123
142
  const info = base();
124
143
  if (info?.priceRange)
125
144
  info.priceRange.minValue = 0;
126
- return info;
145
+ return info && { ...info, margins: { above: info.margins?.above ?? 0, below: 8 } };
127
146
  };
147
+ const ownScale = (base) => base();
148
+ const MARGIN_TOP = 0.15;
128
149
  /**
129
150
  * A time series with its range, step and style buttons. The buttons are
130
151
  * `.chart-controls button[aria-pressed]`, and a site's stylesheet gives them
@@ -156,7 +177,17 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
156
177
  const ink = rgba(style.color);
157
178
  const up = rgba(style.getPropertyValue('--chart-up').trim() || style.color);
158
179
  const down = rgba(style.getPropertyValue('--chart-down').trim() || style.color, style.getPropertyValue('--chart-down').trim() ? 1 : 0.45);
159
- const priceFormat = { type: 'custom', formatter: (n) => formatRef.current(n), minMove: 1e-12 };
180
+ const priceFormat = {
181
+ type: 'custom',
182
+ formatter: (n) => formatRef.current(n),
183
+ // Ticks are evenly spaced, so the gap between any two is the step. Each is snapped to
184
+ // it, since the library's zero can arrive as -1e-18.
185
+ tickmarksFormatter: (ticks) => {
186
+ const step = ticks.length > 1 ? Math.abs(ticks[0] - ticks[1]) : 0;
187
+ return ticks.map(n => (step ? formatRef.current(Math.round(n / step) * step, step) : formatRef.current(n)));
188
+ },
189
+ minMove: 1e-12,
190
+ };
160
191
  const guide = { color: rgba(style.color, 0.4), width: 1, style: LineStyle.Dotted, labelBackgroundColor: ink };
161
192
  const grid = rgba(axis || style.color, 0.1);
162
193
  const api = createChart(el, {
@@ -171,8 +202,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
171
202
  },
172
203
  grid: { vertLines: { color: grid }, horzLines: { color: grid } },
173
204
  crosshair: { mode: CrosshairMode.Magnet, vertLine: guide, horzLine: guide },
174
- rightPriceScale: { borderVisible: false, scaleMargins: { top: 0.15, bottom: 0.1 } },
175
- timeScale: { borderVisible: false, secondsVisible: false },
205
+ rightPriceScale: { borderVisible: false },
206
+ // The value axis settles its width after the points are fitted; the range holds through it.
207
+ timeScale: { borderVisible: false, secondsVisible: false, lockVisibleTimeRangeOnResize: true },
176
208
  handleScroll: false,
177
209
  handleScale: false,
178
210
  });
@@ -181,11 +213,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
181
213
  topColor: rgba(style.color, 0.4),
182
214
  bottomColor: rgba(style.color, 0.02),
183
215
  lineWidth: 2,
184
- lineType: aggregate === 'mean' ? LineType.Curved : LineType.Simple,
185
216
  crosshairMarkerBackgroundColor: ink,
186
217
  crosshairMarkerBorderColor: ink,
187
218
  priceFormat,
188
- ...(fromZero && { autoscaleInfoProvider: fromZeroScale }),
189
219
  });
190
220
  const candle = api.addSeries(CandlestickSeries, {
191
221
  visible: false,
@@ -209,6 +239,7 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
209
239
  };
210
240
  chart.current = {
211
241
  api, area, candle, volume: null, addVolume, up, down, ink,
242
+ curved: LineType.Curved, straight: LineType.Simple,
212
243
  areaMarkers: createSeriesMarkers(area, []),
213
244
  candleMarkers: createSeriesMarkers(candle, []),
214
245
  };
@@ -220,7 +251,15 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
220
251
  chart.current = null;
221
252
  setReady(false);
222
253
  };
223
- }, [fromZero, aggregate]);
254
+ }, []);
255
+ // Applied to the chart in place: rebuilding it would leave a new chart with nothing drawn on it.
256
+ useEffect(() => {
257
+ const c = chart.current;
258
+ if (!ready || !c)
259
+ return;
260
+ c.api.priceScale('right').applyOptions({ scaleMargins: { top: MARGIN_TOP, bottom: fromZero ? 0 : 0.1 } });
261
+ c.area.applyOptions({ lineType: aggregate === 'last' ? c.straight : c.curved, autoscaleInfoProvider: fromZero ? fromZeroScale : ownScale });
262
+ }, [ready, fromZero, aggregate]);
224
263
  // One request per resolution for the life of the loader: every range from 30D up reads the same days.
225
264
  const cache = useRef(new Map());
226
265
  useEffect(() => {
@@ -261,7 +300,11 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
261
300
  return drawn(windowed, [], null);
262
301
  const { steps, fallback } = chartSteps(end - windowed[0].time);
263
302
  const chosen = step && steps.includes(step) ? step : fallback;
264
- return drawn(chosen === 'day' ? windowed : bucketChart(windowed, chosen, aggregate), steps, chosen);
303
+ if (chosen === 'day')
304
+ return drawn(windowed, steps, chosen);
305
+ // A stepped range starts on a whole week or month, so its first total is not a few days of one.
306
+ const from = bucketStart(windowed[0].time, chosen);
307
+ return drawn(bucketChart(points.filter(p => p.time >= from), chosen, aggregate), steps, chosen);
265
308
  }, [points, range, ranges.length, resolution, step, aggregate]);
266
309
  // While a resolution loads, the last one stays under the skeleton; an empty or failed one clears it.
267
310
  useEffect(() => {
package/dist/search.d.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  * Every `text` value at any block depth, as prose.
3
3
  *
4
4
  * `$.**.text` rather than the raw JSON, so block ids and `type` discriminators
5
- * cannot score as prose. Then three classes of non-prose are collapsed to
6
- * spaces:
5
+ * cannot score as prose. In strict mode: lax, Postgres' default, unwraps each
6
+ * array `.**` meets and visits its elements twice, so every value came out
7
+ * twice — measured on all 624 miow pages in October 2026, doubling snippets and
8
+ * every lexeme in `search_tsv`. `silent` keeps strict from raising on items
9
+ * with no `text`. Then three classes of non-prose are collapsed to spaces:
7
10
  *
8
11
  * - markup (`<[^>]*>`) and the `&nbsp;` entity
9
12
  * - the JSON array literal's own syntax — `", "` between adjacent values,
@@ -32,6 +35,9 @@ export declare function proseSql(content?: string): string;
32
35
  * rebuilds. The column is derived from `content`, so dropping it loses nothing.
33
36
  * Run it BEFORE declaring the column in a Prisma schema: `prisma db push` reads
34
37
  * a generated column it does not know about as drift and drops it.
38
+ *
39
+ * @param table the table as SQL names it: `pages`, or `"WikiPage"` for a
40
+ * PascalCase Prisma model, whose index is `"WikiPage_search_tsv_idx"`.
35
41
  */
36
42
  export declare function searchTsvDdl(table: string, { content, title }?: {
37
43
  content?: string;
package/dist/search.js CHANGED
@@ -25,8 +25,11 @@
25
25
  * Every `text` value at any block depth, as prose.
26
26
  *
27
27
  * `$.**.text` rather than the raw JSON, so block ids and `type` discriminators
28
- * cannot score as prose. Then three classes of non-prose are collapsed to
29
- * spaces:
28
+ * cannot score as prose. In strict mode: lax, Postgres' default, unwraps each
29
+ * array `.**` meets and visits its elements twice, so every value came out
30
+ * twice — measured on all 624 miow pages in October 2026, doubling snippets and
31
+ * every lexeme in `search_tsv`. `silent` keeps strict from raising on items
32
+ * with no `text`. Then three classes of non-prose are collapsed to spaces:
30
33
  *
31
34
  * - markup (`<[^>]*>`) and the `&nbsp;` entity
32
35
  * - the JSON array literal's own syntax — `", "` between adjacent values,
@@ -43,7 +46,7 @@
43
46
  * when the query aliases its table.
44
47
  */
45
48
  export function proseSql(content = 'content') {
46
- return `regexp_replace(translate(jsonb_path_query_array(${content},'$.**.text')::text, chr(160),' '),'${PROSE_PATTERN}',' ','g')`;
49
+ return `regexp_replace(translate(jsonb_path_query_array(${content},'strict $.**.text','{}',true)::text, chr(160),' '),'${PROSE_PATTERN}',' ','g')`;
47
50
  }
48
51
  /**
49
52
  * The `regexp_replace` pattern inside {@link proseSql}, as it must appear in
@@ -76,6 +79,9 @@ export function searchTsvSql(content = 'content', title = 'title') {
76
79
  * rebuilds. The column is derived from `content`, so dropping it loses nothing.
77
80
  * Run it BEFORE declaring the column in a Prisma schema: `prisma db push` reads
78
81
  * a generated column it does not know about as drift and drops it.
82
+ *
83
+ * @param table the table as SQL names it: `pages`, or `"WikiPage"` for a
84
+ * PascalCase Prisma model, whose index is `"WikiPage_search_tsv_idx"`.
79
85
  */
80
86
  export function searchTsvDdl(table, { content = 'content', title = 'title' } = {}) {
81
87
  return {
@@ -83,7 +89,7 @@ export function searchTsvDdl(table, { content = 'content', title = 'title' } = {
83
89
  `ALTER TABLE ${table} DROP COLUMN IF EXISTS search_tsv`,
84
90
  `ALTER TABLE ${table} ADD COLUMN search_tsv tsvector GENERATED ALWAYS AS (${searchTsvSql(content, title)}) STORED`,
85
91
  ],
86
- index: `CREATE INDEX IF NOT EXISTS ${table}_search_tsv_idx ON ${table} USING GIN (search_tsv)`,
92
+ index: `CREATE INDEX IF NOT EXISTS "${table.replaceAll('"', '')}_search_tsv_idx" ON ${table} USING GIN (search_tsv)`,
87
93
  };
88
94
  }
89
95
  /**
@@ -1,14 +1,16 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // stats-figures.tsx — the stats page's headline figures and the chart under
4
- // them. Each figure is a button that charts that figure per day, so the chart
4
+ // them. Each figure is a button that charts that figure by day, week or month, so the chart
5
5
  // is not only visitors. Its own module because it holds state; `Stats` stays a
6
6
  // server component and hands it the digest, which is plain JSON.
7
7
  import { useMemo, useState } from 'react';
8
- import { TimeChart } from './chart.js';
8
+ import { TimeChart, stepDecimals } from './chart.js';
9
9
  const count = (n) => n.toLocaleString('en');
10
- const duration = (s) => (s < 60 ? `${Math.round(s)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
11
- const percent = (n) => `${Math.round(n)}%`;
10
+ // Whole seconds and percentages, unless an axis tick's step needs more to tell it from the next.
11
+ const places = (step) => (step ? stepDecimals(step) : 0);
12
+ const duration = (s, step) => (s < 60 ? `${s.toFixed(places(step))}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`);
13
+ const percent = (n, step) => `${n.toFixed(places(step))}%`;
12
14
  /**
13
15
  * The change from the window before: a share of the figure before, or for a
14
16
  * rate its difference in points, since a bounce rate up 10% of itself reads
@@ -32,6 +34,7 @@ const figuresOf = ({ previous: p, ...d }) => [
32
34
  delta: change(d.bounce_rate, p.bounce_rate, { points: true, lowerIsBetter: true }),
33
35
  day: r => (r.visits ? (100 * r.bounces) / r.visits : null),
34
36
  format: percent,
37
+ aggregate: 'mean',
35
38
  },
36
39
  {
37
40
  label: 'Visit length',
@@ -39,6 +42,7 @@ const figuresOf = ({ previous: p, ...d }) => [
39
42
  delta: change(d.visit_duration, p.visit_duration),
40
43
  day: r => (r.visits ? r.seconds / r.visits : null),
41
44
  format: duration,
45
+ aggregate: 'mean',
42
46
  },
43
47
  ...(d.follow_clicks
44
48
  ? [
@@ -72,5 +76,5 @@ export function StatsFigures({ digest, first, windows }) {
72
76
  return value == null ? null : { time, value };
73
77
  }).filter(p => p !== null);
74
78
  }, [byDay, first, figure]);
75
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: `${figure.label} per day`, ranges: ['all'], aggregate: "mean", fromZero: true, format: figure.format, controls: windows, className: "stats-chart" })] }));
79
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "stats-figures", role: "group", "aria-label": "Chart", children: figures.map((f, i) => (_jsxs("button", { type: "button", "aria-pressed": i === chosen, onClick: () => choose(i), children: [_jsx("span", { children: f.label }), _jsxs("strong", { children: [f.value, f.delta && (_jsx("small", { className: "stats-delta", "data-better": f.delta.better, children: f.delta.text }))] })] }, f.label))) }), _jsx(TimeChart, { series: series, label: figure.label, ranges: ['all'], aggregate: figure.aggregate ?? 'sum', fromZero: true, format: figure.format, controls: windows, className: "stats-chart" })] }));
76
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.56.0",
3
+ "version": "0.58.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",