portal-mojo 0.2.11 → 0.2.13

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.
@@ -8,9 +8,10 @@
8
8
  // through — and the account-only query keys.
9
9
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
10
10
  import {
11
- mojoSave, sanitizeMe, useAuthSnapshot, withFreshAuth,
11
+ mojoCall, sanitizeMe, useAuthSnapshot, withFreshAuth,
12
12
  type Me, type SaveVars,
13
13
  } from '../client/runtime';
14
+ import { withAccountActor } from './actor';
14
15
  import { getNotificationPreferences, getRecoveryCodeStatus, type NotificationPreferencesResponse, type RecoveryCodeStatus } from './api';
15
16
 
16
17
  // ── /api/user/me ──────────────────────────────────────────────────────
@@ -21,18 +22,24 @@ import { getNotificationPreferences, getRecoveryCodeStatus, type NotificationPre
21
22
  * 403s email/username/is_superuser/permissions even unchanged), sanitizes
22
23
  * the answer with sanitizeMe, writes `['me', uid]` and refreshes every
23
24
  * `/api/user` cache (UserModel.useSave never touches `me`, and vice versa).
24
- * The `id` in SaveVars is ignored: the endpoint is always the caller.
25
+ * The endpoint is always the caller. A supplied id must match that caller;
26
+ * actor checks fence the final send, fresh-auth retry, and cache publication.
25
27
  */
26
28
  export const MeSaveModel = {
27
29
  useSave: () => {
28
30
  const qc = useQueryClient();
29
31
  const { uid } = useAuthSnapshot();
30
32
  return useMutation<Me, Error, SaveVars>({
31
- mutationFn: async ({ changes }: SaveVars) => sanitizeMe(await withFreshAuth(() => mojoSave<Me>('/api/user/me', null, changes))),
32
- onSuccess: (row) => {
33
- qc.setQueryData(['me', uid], row);
33
+ mutationFn: ({ id, changes }: SaveVars) => withAccountActor(async (actor) => {
34
+ const body = await withFreshAuth(() => mojoCall('/api/user/me', {
35
+ method: 'POST', body: changes, beforeSend: actor.check,
36
+ }));
37
+ actor.check();
38
+ const row = sanitizeMe(body.data as Me);
39
+ qc.setQueryData(['me', actor.uid], row);
34
40
  void qc.invalidateQueries({ queryKey: ['/api/user'] });
35
- },
41
+ return row;
42
+ }, id ?? uid),
36
43
  });
37
44
  },
38
45
  };
@@ -3,13 +3,14 @@
3
3
  // (stacked:'auto' → true for bar), grouped opt-out, legend, gridlines,
4
4
  // nice Y ticks, thinned X labels, and Chart.js-style "index mode"
5
5
  // crosshair tooltips (snap to nearest column, ghost dots, multi-row card).
6
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
6
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
7
7
 
8
8
  export type ChartType = 'line' | 'bar' | 'area';
9
9
 
10
10
  export interface Dataset {
11
11
  label: string;
12
- data: number[];
12
+ /** Null, undefined and nonfinite numbers are missing samples, never zero. */
13
+ data: (number | null | undefined)[];
13
14
  color?: string;
14
15
  }
15
16
 
@@ -30,6 +31,18 @@ export interface SeriesChartProps {
30
31
  smoothing?: number; // 0 = straight segments
31
32
  showDots?: boolean;
32
33
  emptyText?: string;
34
+ /** Numeric positions (e.g. epoch seconds), aligned with labels and data. */
35
+ xValues?: number[];
36
+ /** Visible numeric X window, in the same units as xValues. */
37
+ xDomain?: [number, number];
38
+ /** Split line/area runs when consecutive numeric X values exceed this gap. */
39
+ maxGap?: number;
40
+ /** Exact Y bounds; omitted bounds are computed from visible data. */
41
+ minValue?: number;
42
+ maxValue?: number;
43
+ ariaLabel?: string;
44
+ /** Shared pointer/touch/keyboard inspection; null means inspection ended. */
45
+ onInspect?: (index: number | null) => void;
33
46
  }
34
47
 
35
48
  export const CHART_PALETTE = [
@@ -38,6 +51,7 @@ export const CHART_PALETTE = [
38
51
  ];
39
52
 
40
53
  const PAD = { top: 10, right: 14, bottom: 26, left: 48 };
54
+ const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
41
55
 
42
56
  function niceNum(range: number, round: boolean): number {
43
57
  const exp = Math.floor(Math.log10(range || 1));
@@ -53,6 +67,9 @@ function niceScale(min: number, max: number, ticks: number) {
53
67
  if (max === min) max = min + 1;
54
68
  const range = niceNum(max - min, false);
55
69
  const step = niceNum(range / Math.max(1, ticks - 1), true);
70
+ // Extreme but finite feeds must not turn tick generation into an endless
71
+ // loop or emit nonfinite SVG coordinates.
72
+ if (!Number.isFinite(step) || step <= 0) return { min, max, step: max / 2 - min / 2 || 1 };
56
73
  return {
57
74
  min: Math.floor(min / step) * step,
58
75
  max: Math.ceil(max / step) * step,
@@ -90,7 +107,7 @@ export function SeriesChart({
90
107
  datasets: datasetsProp,
91
108
  chartType = 'line',
92
109
  stacked = 'auto',
93
- height = 260,
110
+ height: heightProp = 260,
94
111
  showLegend = true,
95
112
  showGrid = true,
96
113
  gridLines = 5,
@@ -100,7 +117,15 @@ export function SeriesChart({
100
117
  smoothing = 0.3,
101
118
  showDots = true,
102
119
  emptyText = 'No data for this range',
120
+ xValues,
121
+ xDomain,
122
+ maxGap,
123
+ minValue,
124
+ maxValue,
125
+ ariaLabel = 'Series chart',
126
+ onInspect,
103
127
  }: SeriesChartProps) {
128
+ const height = isFiniteNumber(heightProp) && heightProp > 0 ? heightProp : 260;
104
129
  // Malformed feed data must degrade to the empty state, not take down the
105
130
  // route (a live-backend shape drift did exactly that once) — warn + empty,
106
131
  // per the unknown-value rule.
@@ -115,6 +140,9 @@ export function SeriesChart({
115
140
  const [width, setWidth] = useState(600);
116
141
  const [hover, setHover] = useState<number | null>(null);
117
142
  const [visible, setVisible] = useState<Set<string>>(() => new Set());
143
+ const id = useId();
144
+ const tipRef = useRef<HTMLDivElement>(null);
145
+ const [tipWidth, setTipWidth] = useState(180);
118
146
 
119
147
  // Measure the container — real pixel geometry keeps text unscaled
120
148
  // (a viewBox + preserveAspectRatio="none" would smear the labels).
@@ -123,7 +151,7 @@ export function SeriesChart({
123
151
  if (!node) return;
124
152
  const ro = new ResizeObserver(([entry]) => {
125
153
  const w = entry?.contentRect.width ?? 0;
126
- if (w > 0) setWidth(w);
154
+ if (isFiniteNumber(w) && w > 0) setWidth(w);
127
155
  });
128
156
  ro.observe(node);
129
157
  return () => ro.disconnect();
@@ -139,6 +167,38 @@ export function SeriesChart({
139
167
 
140
168
  const plotW = Math.max(40, width - PAD.left - PAD.right);
141
169
  const plotH = Math.max(40, height - PAD.top - PAD.bottom);
170
+ const n = labels.length;
171
+ const colW = n > 0 ? plotW / n : plotW;
172
+ const xPositions = useMemo(() => {
173
+ if (!xValues) return labels.map((_, i) => PAD.left + colW * (i + 0.5));
174
+ const values = xValues.slice(0, n).filter(isFiniteNumber);
175
+ let min = values.reduce((a, b) => Math.min(a, b), Infinity);
176
+ let max = values.reduce((a, b) => Math.max(a, b), -Infinity);
177
+ if (xDomain && isFiniteNumber(xDomain[0]) && isFiniteNumber(xDomain[1]) && xDomain[1] > xDomain[0]) {
178
+ [min, max] = xDomain;
179
+ }
180
+ return labels.map((_, i) => {
181
+ const x = xValues[i];
182
+ if (!isFiniteNumber(x) || x < min || x > max) return null;
183
+ // Half-scaled differences also work for finite domains spanning
184
+ // more than Number.MAX_VALUE, without overflowing the subtraction.
185
+ const fraction = max === min ? 0.5 : (x / 2 - min / 2) / (max / 2 - min / 2);
186
+ return Number.isFinite(fraction) ? PAD.left + fraction * plotW : null;
187
+ });
188
+ }, [labels, xValues, xDomain, n, colW, plotW]);
189
+ const inspectable = xPositions.flatMap((x, i) => x == null ? [] : [i]);
190
+ const active = hover != null && xPositions[hover] != null ? hover : null;
191
+ const lastInspection = useRef<number | null | undefined>(undefined);
192
+ // Compare content, not array identity: callers often construct props during
193
+ // render, including renders caused by onInspect itself.
194
+ const inspectionKey = JSON.stringify([seriesKey, labels, xValues, xDomain, datasets.map((ds) => ds.data)]);
195
+ useEffect(() => { setHover(null); }, [inspectionKey]);
196
+ useEffect(() => {
197
+ if (onInspect && lastInspection.current !== active) {
198
+ lastInspection.current = active;
199
+ onInspect(active);
200
+ }
201
+ }, [active, onInspect]);
142
202
 
143
203
  const scale = useMemo(() => {
144
204
  let max = 0;
@@ -148,7 +208,8 @@ export function SeriesChart({
148
208
  let pos = 0;
149
209
  let neg = 0;
150
210
  for (const ds of shown) {
151
- const v = ds.data[i] ?? 0;
211
+ const v = ds.data[i];
212
+ if (xPositions[i] == null || !isFiniteNumber(v)) continue;
152
213
  if (v >= 0) pos += v; else neg += v;
153
214
  }
154
215
  max = Math.max(max, pos);
@@ -156,28 +217,47 @@ export function SeriesChart({
156
217
  }
157
218
  } else {
158
219
  for (const ds of shown) {
159
- for (const v of ds.data) {
160
- max = Math.max(max, v ?? 0);
161
- min = Math.min(min, v ?? 0);
220
+ for (let i = 0; i < n; i++) {
221
+ const v = ds.data[i];
222
+ if (xPositions[i] == null || !isFiniteNumber(v)) continue;
223
+ max = Math.max(max, v);
224
+ min = Math.min(min, v);
162
225
  }
163
226
  }
164
227
  }
165
- return niceScale(min, max, gridLines);
166
- }, [labels.length, shown, isStacked, gridLines]);
228
+ if (!Number.isFinite(min)) min = -Number.MAX_VALUE;
229
+ if (!Number.isFinite(max)) max = Number.MAX_VALUE;
230
+ const count = isFiniteNumber(gridLines) ? Math.max(2, Math.min(100, Math.floor(gridLines))) : 5;
231
+ const nice = niceScale(min, max, count);
232
+ let lo = isFiniteNumber(minValue) ? minValue : nice.min;
233
+ let hi = isFiniteNumber(maxValue) ? maxValue : nice.max;
234
+ if (hi <= lo) {
235
+ if (isFiniteNumber(minValue) && !isFiniteNumber(maxValue)) hi = lo + Math.max(1, Math.abs(lo) * 0.1);
236
+ else if (isFiniteNumber(maxValue) && !isFiniteNumber(minValue)) lo = hi - Math.max(1, Math.abs(hi) * 0.1);
237
+ else { lo = nice.min; hi = nice.max; }
238
+ }
239
+ const boundedStep = niceNum((hi / 2 - lo / 2) / (count - 1), true) * 2;
240
+ return { min: lo, max: hi, step: minValue != null || maxValue != null ? boundedStep : nice.step };
241
+ }, [n, shown, isStacked, gridLines, xPositions, minValue, maxValue]);
167
242
 
168
243
  const yOf = useCallback(
169
- (v: number) => PAD.top + plotH - ((v - scale.min) / (scale.max - scale.min || 1)) * plotH,
170
- [plotH, scale],
244
+ (v: number) => {
245
+ const y = PAD.top + plotH - ((v / 2 - scale.min / 2) / (scale.max / 2 - scale.min / 2 || 1)) * plotH;
246
+ return Number.isFinite(y) ? y : v > scale.max ? -plotH : plotH + height;
247
+ },
248
+ [plotH, scale, height],
171
249
  );
172
250
 
173
- const n = labels.length;
174
- const colW = n > 0 ? plotW / n : plotW;
175
- // Line/area sample at column centers; bars fill the column.
176
- const xCenter = useCallback((i: number) => PAD.left + colW * (i + 0.5), [colW]);
251
+ const xCenter = (i: number) => xPositions[i]!;
177
252
 
178
253
  const ticks = useMemo(() => {
179
254
  const out: number[] = [];
180
- for (let v = scale.min; v <= scale.max + scale.step / 2; v += scale.step) out.push(v);
255
+ for (let i = 0; i < 200; i++) {
256
+ const v = scale.min + i * scale.step;
257
+ if (!isFiniteNumber(v) || v > scale.max) break;
258
+ if (out.at(-1) !== v) out.push(v);
259
+ }
260
+ if (out.at(-1) !== scale.max) out.push(scale.max);
181
261
  return out;
182
262
  }, [scale]);
183
263
 
@@ -186,14 +266,37 @@ export function SeriesChart({
186
266
 
187
267
  const colorOf = (ds: Dataset, i: number) => ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length]!;
188
268
 
189
- const onMove = (e: React.MouseEvent<SVGRectElement>) => {
269
+ const onMove = (e: React.PointerEvent<SVGRectElement>) => {
190
270
  const rect = e.currentTarget.getBoundingClientRect();
191
- const x = e.clientX - rect.left;
192
- const idx = Math.min(n - 1, Math.max(0, Math.floor(x / (colW || 1))));
193
- setHover(idx);
271
+ const x = PAD.left + (e.clientX - rect.left) * (plotW / (rect.width || plotW));
272
+ let nearest: number | null = null;
273
+ for (const i of inspectable) {
274
+ if (nearest == null || Math.abs(xCenter(i) - x) < Math.abs(xCenter(nearest) - x)) nearest = i;
275
+ }
276
+ setHover(nearest);
277
+ };
278
+
279
+ const onKeyDown = (e: React.KeyboardEvent<SVGSVGElement>) => {
280
+ const current = active == null ? -1 : inspectable.indexOf(active);
281
+ let next: number | null;
282
+ if (e.key === 'Home') next = inspectable[0] ?? null;
283
+ else if (e.key === 'End') next = inspectable.at(-1) ?? null;
284
+ else if (e.key === 'ArrowRight') next = inspectable[Math.min(inspectable.length - 1, current + 1)] ?? null;
285
+ else if (e.key === 'ArrowLeft') next = inspectable[Math.max(0, current - 1)] ?? null;
286
+ else if (e.key === 'Escape') next = null;
287
+ else return;
288
+ e.preventDefault();
289
+ setHover(next);
194
290
  };
195
291
 
196
- const hasData = n > 0 && datasets.length > 0;
292
+ const formatSample = (v: unknown) => isFiniteNumber(v) ? fmt(v) : 'Unavailable';
293
+ const readout = active == null ? '' : `${labels[active]}. ${shown.map((ds) => `${ds.label}: ${formatSample(ds.data[active])}`).join('; ')}`;
294
+ useLayoutEffect(() => {
295
+ const measured = tipRef.current?.getBoundingClientRect().width;
296
+ if (measured && Number.isFinite(measured)) setTipWidth(measured);
297
+ }, [readout, width]);
298
+
299
+ const hasData = n > 0 && datasets.length > 0 && inspectable.length > 0;
197
300
 
198
301
  return (
199
302
  <div className="chart-wrap" ref={wrapRef}>
@@ -203,8 +306,10 @@ export function SeriesChart({
203
306
  const off = visible.has(ds.label);
204
307
  return (
205
308
  <button
309
+ type="button"
206
310
  key={ds.label}
207
311
  className={`legend-item${off ? ' legend-off' : ''}`}
312
+ aria-pressed={!off}
208
313
  onClick={() => setVisible((prev) => {
209
314
  const next = new Set(prev);
210
315
  if (next.has(ds.label)) next.delete(ds.label); else next.add(ds.label);
@@ -224,7 +329,14 @@ export function SeriesChart({
224
329
  <div className="chart-empty" style={{ height }}>{emptyText}</div>
225
330
  ) : (
226
331
  <div className="chart-svg-area" style={{ height }}>
227
- <svg width={width} height={height} className="series-svg" role="img">
332
+ <svg width={width} height={height} className="series-svg" role="img"
333
+ tabIndex={0} aria-label={ariaLabel} aria-describedby={`${id}-help ${id}-value`}
334
+ onKeyDown={onKeyDown} onBlur={() => setHover(null)}>
335
+ <defs>
336
+ <clipPath id={`${id}-plot`}>
337
+ <rect x={PAD.left - 4} y={PAD.top} width={plotW + 8} height={plotH} />
338
+ </clipPath>
339
+ </defs>
228
340
  {/* gridlines + Y ticks */}
229
341
  {showGrid && ticks.map((t) => (
230
342
  <line
@@ -242,30 +354,38 @@ export function SeriesChart({
242
354
 
243
355
  {/* bars */}
244
356
  {chartType === 'bar' && labels.map((_, i) => {
245
- const inner = colW * 0.72;
357
+ if (xPositions[i] == null) return null;
358
+ const previous = xPositions[i - 1];
359
+ const next = xPositions[i + 1];
360
+ const slot = xValues ? Math.min(colW,
361
+ previous == null ? colW : Math.abs(xCenter(i) - previous),
362
+ next == null ? colW : Math.abs(next - xCenter(i))) : colW;
363
+ const inner = slot * 0.72;
246
364
  const groupN = Math.max(1, shown.length);
247
365
  let posAcc = 0;
248
366
  let negAcc = 0;
249
367
  return (
250
- <g key={`bar${i}`}>
368
+ <g key={`bar${i}`} clipPath={`url(#${id}-plot)`}>
251
369
  {shown.map((ds, di) => {
252
- const v = ds.data[i] ?? 0;
370
+ const v = ds.data[i];
371
+ if (!isFiniteNumber(v)) return null;
253
372
  const color = colorOf(ds, datasets.indexOf(ds));
254
373
  let x: number;
255
374
  let w: number;
256
375
  let y: number;
257
376
  let h: number;
258
377
  if (isStacked) {
259
- x = PAD.left + colW * i + (colW - inner) / 2;
378
+ x = xCenter(i) - inner / 2;
260
379
  w = inner;
261
380
  const base = v >= 0 ? posAcc : negAcc;
262
381
  const top = base + v;
382
+ if (!Number.isFinite(top)) return null;
263
383
  y = yOf(Math.max(base, top));
264
384
  h = Math.abs(yOf(top) - yOf(base));
265
385
  if (v >= 0) posAcc = top; else negAcc = top;
266
386
  } else {
267
387
  w = inner / groupN;
268
- x = PAD.left + colW * i + (colW - inner) / 2 + w * di;
388
+ x = xCenter(i) - inner / 2 + w * di;
269
389
  const zero = yOf(Math.max(0, scale.min));
270
390
  y = Math.min(zero, yOf(v));
271
391
  h = Math.abs(yOf(v) - zero);
@@ -276,7 +396,7 @@ export function SeriesChart({
276
396
  x={x} y={y}
277
397
  width={Math.max(1, w - 1)} height={Math.max(0, h)}
278
398
  fill={color}
279
- className={`bar${hover === i ? ' bar-hot' : ''}`}
399
+ className={`bar${active === i ? ' bar-hot' : ''}`}
280
400
  />
281
401
  );
282
402
  })}
@@ -287,41 +407,62 @@ export function SeriesChart({
287
407
  {/* area fills + lines */}
288
408
  {chartType !== 'bar' && shown.map((ds) => {
289
409
  const color = colorOf(ds, datasets.indexOf(ds));
290
- const pts = ds.data.slice(0, n).map((v, i) => [xCenter(i), yOf(v ?? 0)] as [number, number]);
291
- if (!pts.length) return null;
292
- const path = smoothPath(pts, smoothing);
410
+ const runs: { point: [number, number]; index: number }[][] = [];
411
+ let run: (typeof runs)[number] = [];
412
+ for (let i = 0; i < n; i++) {
413
+ const value = ds.data[i];
414
+ const previous = run.at(-1);
415
+ const gap = xValues && previous ? xValues[i]! - xValues[previous.index]! : 0;
416
+ if (xPositions[i] == null || !isFiniteNumber(value)) {
417
+ run = [];
418
+ continue;
419
+ }
420
+ if (previous && (gap < 0 || (isFiniteNumber(maxGap) && maxGap >= 0 && gap > maxGap))) run = [];
421
+ if (!run.length) runs.push(run);
422
+ run.push({ point: [xCenter(i), yOf(value)], index: i });
423
+ }
293
424
  const zeroY = yOf(Math.max(0, scale.min));
294
425
  return (
295
- <g key={ds.label}>
296
- {chartType === 'area' && (
297
- <path
298
- d={`${path}L${pts[pts.length - 1]![0]},${zeroY}L${pts[0]![0]},${zeroY}Z`}
299
- fill={color}
300
- opacity={0.16}
301
- />
302
- )}
303
- <path d={path} fill="none" stroke={color} strokeWidth={2} className="series-line" />
304
- {showDots && n <= 40 && pts.map(([x, y], i) => (
305
- <circle key={i} cx={x} cy={y} r={hover === i ? 4 : 2.5} fill={color} className="series-dot" />
306
- ))}
426
+ <g key={ds.label} clipPath={`url(#${id}-plot)`}>
427
+ {runs.map((segment) => {
428
+ const pts = segment.map((p) => p.point);
429
+ const path = smoothPath(pts, isFiniteNumber(smoothing) ? smoothing : 0);
430
+ return (
431
+ <g key={segment[0]!.index}>
432
+ {chartType === 'area' && pts.length > 1 && (
433
+ <path
434
+ d={`${path}L${pts[pts.length - 1]![0]},${zeroY}L${pts[0]![0]},${zeroY}Z`}
435
+ fill={color}
436
+ opacity={0.16}
437
+ />
438
+ )}
439
+ {pts.length > 1 && <path d={path} fill="none" stroke={color} strokeWidth={2} className="series-line" />}
440
+ {(pts.length === 1 || (showDots && n <= 40)) && segment.map(({ point: [x, y], index }) => (
441
+ <circle key={index} cx={x} cy={y} r={active === index ? 4 : 2.5} fill={color} className="series-dot" />
442
+ ))}
443
+ </g>
444
+ );
445
+ })}
307
446
  </g>
308
447
  );
309
448
  })}
310
449
 
311
450
  {/* crosshair */}
312
- {hover != null && (
451
+ {active != null && (
313
452
  <line
314
- x1={xCenter(hover)} x2={xCenter(hover)}
453
+ x1={xCenter(active)} x2={xCenter(active)}
315
454
  y1={PAD.top} y2={PAD.top + plotH}
316
455
  className="crosshair"
317
456
  />
318
457
  )}
319
- {hover != null && chartType !== 'bar' && shown.map((ds) => {
458
+ {active != null && chartType !== 'bar' && shown.map((ds) => {
459
+ const value = ds.data[active];
460
+ if (!isFiniteNumber(value) || value < scale.min || value > scale.max) return null;
320
461
  const color = colorOf(ds, datasets.indexOf(ds));
321
462
  return (
322
463
  <circle
323
464
  key={`h${ds.label}`}
324
- cx={xCenter(hover)} cy={yOf(ds.data[hover] ?? 0)}
465
+ cx={xCenter(active)} cy={yOf(value)} className="series-inspect-dot"
325
466
  r={4} fill={color} stroke="var(--surface)" strokeWidth={1.5}
326
467
  />
327
468
  );
@@ -329,7 +470,7 @@ export function SeriesChart({
329
470
 
330
471
  {/* X labels */}
331
472
  {showXLabels && labels.map((label, i) => (
332
- i % labelStride === 0 ? (
473
+ i % labelStride === 0 && xPositions[i] != null ? (
333
474
  <text key={`xl${i}`} x={xCenter(i)} y={height - 8} className="axis-label" textAnchor="middle">
334
475
  {label}
335
476
  </text>
@@ -340,37 +481,46 @@ export function SeriesChart({
340
481
  <rect
341
482
  x={PAD.left} y={PAD.top} width={plotW} height={plotH}
342
483
  fill="transparent"
343
- onMouseMove={onMove}
344
- onMouseLeave={() => setHover(null)}
484
+ style={{ touchAction: 'pan-y' }}
485
+ onPointerMove={onMove}
486
+ onPointerDown={onMove}
487
+ onPointerLeave={(e) => { if (e.pointerType !== 'touch') setHover(null); }}
488
+ onPointerCancel={() => setHover(null)}
345
489
  />
346
490
  </svg>
347
491
 
348
- {hover != null && (
492
+ {active != null && (
349
493
  <div
350
- className="chart-tip"
494
+ className="chart-tip" ref={tipRef} aria-hidden="true"
351
495
  style={{
352
- left: Math.min(Math.max(xCenter(hover), 90), width - 90),
496
+ left: Math.max(8, Math.min(xCenter(active) - tipWidth / 2, width - tipWidth - 8)),
353
497
  top: PAD.top,
498
+ transform: 'none', maxWidth: 'calc(100% - 16px)', minWidth: 0,
499
+ width: 'max-content', boxSizing: 'border-box', overflowWrap: 'anywhere',
354
500
  }}
355
501
  >
356
- <div className="chart-tip-head">{labels[hover]}</div>
502
+ <div className="chart-tip-head">{labels[active]}</div>
357
503
  {shown.map((ds) => (
358
504
  <div key={ds.label} className="chart-tip-row">
359
505
  <span className="legend-swatch" style={{ background: colorOf(ds, datasets.indexOf(ds)) }} />
360
506
  <span className="chart-tip-label">{ds.label}</span>
361
- <span className="chart-tip-val">{fmt(ds.data[hover] ?? 0)}</span>
507
+ <span className="chart-tip-val">{formatSample(ds.data[active])}</span>
362
508
  </div>
363
509
  ))}
364
510
  {isStacked && shown.length > 1 && (
365
511
  <div className="chart-tip-row chart-tip-total">
366
512
  <span className="chart-tip-label">Total</span>
367
513
  <span className="chart-tip-val">
368
- {fmt(shown.reduce((sum, ds) => sum + (ds.data[hover!] ?? 0), 0))}
514
+ {shown.every((ds) => isFiniteNumber(ds.data[active]))
515
+ ? formatSample(shown.reduce((sum, ds) => sum + (ds.data[active] as number), 0))
516
+ : 'Unavailable'}
369
517
  </span>
370
518
  </div>
371
519
  )}
372
520
  </div>
373
521
  )}
522
+ <span id={`${id}-help`} className="chart-accessible">Use Left and Right arrows to inspect samples, Home or End for the first or last, and Escape to clear.</span>
523
+ <span id={`${id}-value`} className="chart-accessible" role="status" aria-live="polite" aria-atomic="true">{readout}</span>
374
524
  </div>
375
525
  )}
376
526
  </div>
@@ -330,12 +330,13 @@ export const GRANT_SIGN_IN_AGAIN = 'Sign in again to continue';
330
330
  * access + refresh pair. Serialised behind any in-flight refresh so a
331
331
  * refresh that started on the old session can never land on top of it.
332
332
  */
333
- async function adoptGrant(data: TokenGrant | null | undefined, remember = true, event: 'login' | 'rotated' = 'login'): Promise<AuthUser> {
333
+ async function adoptGrant(data: TokenGrant | null | undefined, remember = true, event: 'login' | 'rotated' = 'login', check?: () => void): Promise<AuthUser> {
334
334
  if (!data || typeof data.access_token !== 'string' || !data.access_token
335
335
  || typeof data.refresh_token !== 'string' || !data.refresh_token) {
336
336
  throw new Error(GRANT_SIGN_IN_AGAIN);
337
337
  }
338
338
  if (refreshPromise) await refreshPromise;
339
+ check?.();
339
340
  setTokens(data.access_token, data.refresh_token, remember);
340
341
  startAutoRefresh();
341
342
  const user = data.user ?? {};
@@ -686,9 +687,11 @@ export function passkeyErrorMessage(error: unknown): string {
686
687
  * adopted here into the storage this session already uses. Emits 'rotated'
687
688
  * (never 'login': no explicit sign-in happened).
688
689
  */
689
- export async function confirmEmailChange(code: string): Promise<AuthUser> {
690
- const body = await mojoCall('/api/auth/email/change/confirm', { method: 'POST', body: { code } });
691
- return adoptGrant(body.data as TokenGrant, sessionIsPersistent(), 'rotated');
690
+ export async function confirmEmailChange(code: string, check?: () => void): Promise<AuthUser> {
691
+ const body = await mojoCall('/api/auth/email/change/confirm', { method: 'POST', body: { code }, beforeSend: check });
692
+ // Account dialogs fence both dispatch and adoption of the returned login.
693
+ check?.();
694
+ return adoptGrant(body.data as TokenGrant, sessionIsPersistent(), 'rotated', check);
692
695
  }
693
696
 
694
697
  /**
@@ -7,7 +7,7 @@
7
7
  // · multi-value filters collapse to `field__in=a,b` (single value → `field`)
8
8
  // · a daterange is the TRIPLE `dr_field` / `dr_start` / `dr_end`, so only
9
9
  // one range can be active at a time — by construction, not by convention.
10
- import { useCallback, useMemo, useRef } from 'react';
10
+ import { useCallback, useMemo, useRef, useState } from 'react';
11
11
  import { useSearchParams } from 'react-router-dom';
12
12
  import type { Params } from './types';
13
13
 
@@ -27,6 +27,8 @@ export interface TableParams {
27
27
  size: number;
28
28
  }
29
29
 
30
+ export type TableParamsMode = 'url' | 'local';
31
+
30
32
  export interface TableParamsApi extends TableParams {
31
33
  wire: Params;
32
34
  activeFilters: ActiveFilter[];
@@ -139,8 +141,19 @@ export function useTableParams(
139
141
  // resurface as a removable pill. Pass a render-stable Set — it is a
140
142
  // memo dependency.
141
143
  excludeKeys?: ReadonlySet<string>,
144
+ // Select once per mount. Local tables use this same store/transforms,
145
+ // with an empty per-instance source instead of the route's query string.
146
+ mode: TableParamsMode = 'url',
142
147
  ): TableParamsApi {
143
- const [sp, setSp] = useSearchParams();
148
+ const warnedMode = useRef(false);
149
+ const resolvedMode = mode === 'local' ? 'local' : 'url';
150
+ if (mode !== 'url' && mode !== 'local' && !warnedMode.current) {
151
+ warnedMode.current = true;
152
+ console.warn(`[portal-mojo] useTableParams: unknown mode "${String(mode)}" — falling back to 'url'`);
153
+ }
154
+ const [urlParams, setUrlParams] = useSearchParams();
155
+ const [localParams, setLocalParams] = useState(() => new URLSearchParams());
156
+ const sp = resolvedMode === 'local' ? localParams : urlParams;
144
157
  // Defaults seed only the untouched route. Once this table writes params,
145
158
  // an intentionally empty filter set must stay empty ("Clear all" must
146
159
  // not immediately resurrect a route default).
@@ -175,7 +188,9 @@ export function useTableParams(
175
188
  if (next.search) out.set('search', next.search);
176
189
  if (next.sort) out.set('sort', next.sort);
177
190
  if (next.page > 1) out.set('page', String(next.page));
178
- if (next.size !== DEFAULT_SIZE) out.set('size', String(next.size));
191
+ // Omit only the effective default. Omitting the global default (10)
192
+ // would resurrect a caller's different default on the next render.
193
+ if (next.size !== (defaults.size ?? DEFAULT_SIZE)) out.set('size', String(next.size));
179
194
  for (const [key, value] of Object.entries(nextFilters)) {
180
195
  if (value !== '') out.set(key, value);
181
196
  }
@@ -185,8 +200,9 @@ export function useTableParams(
185
200
  const carried = sp.get(key);
186
201
  if (carried != null) out.set(key, carried);
187
202
  }
188
- setSp(out, { replace: true });
189
- }, [setSp, sp]);
203
+ if (resolvedMode === 'local') setLocalParams(out);
204
+ else setUrlParams(out, { replace: true });
205
+ }, [resolvedMode, setUrlParams, sp, defaults.size]);
190
206
 
191
207
  const wire = useMemo<Params>(() => ({
192
208
  start: (state.page - 1) * state.size,
@@ -459,6 +459,8 @@ button { font: inherit; color: inherit; }
459
459
  .legend-swatch { width: 9px; height: 9px; border-radius: 3px; flex: none; }
460
460
  .chart-svg-area { position: relative; }
461
461
  .series-svg { display: block; overflow: visible; }
462
+ .series-svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 3px; }
463
+ .chart-accessible { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
462
464
  .grid-line { stroke: var(--line); stroke-width: 1; }
463
465
  .grid-zero { stroke: var(--mute); stroke-opacity: 0.5; }
464
466
  .axis-label { fill: var(--mute); font-size: 10.5px; font-family: ui-monospace, "SF Mono", Menlo, monospace; }