portal-mojo 0.2.13 → 0.2.14
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/docs/admin-modals.md +6 -0
- package/docs/charts.md +103 -5
- package/package.json +1 -1
- package/src/charts/MetricsChart.tsx +29 -331
- package/src/charts/MetricsChartView.tsx +280 -0
- package/src/charts/SeriesChart.tsx +99 -18
- package/src/charts/chart-dialogs.tsx +13 -7
- package/src/charts/index.ts +2 -0
- package/src/charts/metrics-window.ts +57 -0
- package/src/charts/stats.ts +8 -6
- package/src/styles/components/charts-c2.css +15 -0
- package/src/ui/modal.tsx +6 -0
package/src/charts/stats.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export interface SeriesLike {
|
|
8
8
|
label: string;
|
|
9
|
-
data: number[];
|
|
9
|
+
data: (number | null | undefined)[];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export interface SeriesStat {
|
|
@@ -31,15 +31,17 @@ export function toNumber(v: unknown): number {
|
|
|
31
31
|
/**
|
|
32
32
|
* Per-series summary stats. Empty datasets produce no entry (the caller
|
|
33
33
|
* renders its "No data" state); an empty series inside a non-empty set
|
|
34
|
-
* yields
|
|
34
|
+
* yields unavailable (NaN) values so the table stays aligned with the legend.
|
|
35
|
+
* Aggregates use finite observations only; Latest refers to the final sample.
|
|
35
36
|
*/
|
|
36
37
|
export function computeSeriesStats(datasets: readonly SeriesLike[] | null | undefined): SeriesStat[] {
|
|
37
38
|
// (Array.isArray would widen the readonly array to any[] — a TS quirk.)
|
|
38
39
|
const list: readonly SeriesLike[] = datasets ?? [];
|
|
39
40
|
return list.map((d) => {
|
|
40
|
-
const
|
|
41
|
+
const data = Array.isArray(d.data) ? d.data : [];
|
|
42
|
+
const values = data.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
|
|
41
43
|
if (values.length === 0) {
|
|
42
|
-
return { label: d.label ?? '', count: 0, sum:
|
|
44
|
+
return { label: d.label ?? '', count: 0, sum: NaN, avg: NaN, median: NaN, min: NaN, max: NaN, latest: NaN };
|
|
43
45
|
}
|
|
44
46
|
const sorted = [...values].sort((a, b) => a - b);
|
|
45
47
|
const sum = values.reduce((a, b) => a + b, 0);
|
|
@@ -53,14 +55,14 @@ export function computeSeriesStats(datasets: readonly SeriesLike[] | null | unde
|
|
|
53
55
|
median,
|
|
54
56
|
min: sorted[0]!,
|
|
55
57
|
max: sorted[sorted.length - 1]!,
|
|
56
|
-
latest:
|
|
58
|
+
latest: Number.isFinite(data.at(-1)) ? data.at(-1)! : NaN,
|
|
57
59
|
};
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/** '–' for nothing; integers plain; decimals to 2 places (source parity). */
|
|
62
64
|
export function defaultStatFormat(n: number | null | undefined): string {
|
|
63
|
-
if (n == null || Number.
|
|
65
|
+
if (n == null || !Number.isFinite(n)) return '–';
|
|
64
66
|
if (Number.isInteger(n)) return n.toLocaleString();
|
|
65
67
|
return Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
66
68
|
}
|
|
@@ -247,3 +247,18 @@ button.cprog { cursor: pointer; font: inherit; }
|
|
|
247
247
|
|
|
248
248
|
/* Settings dialog fields */
|
|
249
249
|
.mmw-settings { display: flex; flex-direction: column; gap: 12px; }
|
|
250
|
+
|
|
251
|
+
/* Shared MetricsChartView also lives in narrow cards inside wide pages. */
|
|
252
|
+
.chart-panel { container-type: inline-size; min-width: 0; }
|
|
253
|
+
.chart-panel .chart-error { flex-wrap: wrap; overflow-wrap: anywhere; }
|
|
254
|
+
.chart-range-dialog { container-type: inline-size; }
|
|
255
|
+
@container (max-width: 560px) {
|
|
256
|
+
.chart-panel .gran-toggle { display: none; }
|
|
257
|
+
.chart-panel .gran-select { display: inline-block; }
|
|
258
|
+
}
|
|
259
|
+
@container (max-width: 500px) {
|
|
260
|
+
.chart-range-dialog .mojo-daterange-with-presets { grid-template-columns: minmax(0, 1fr); }
|
|
261
|
+
.chart-range-dialog .mojo-calendar-presets { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--line); }
|
|
262
|
+
.chart-range-dialog .mojo-calendar-presets-eyebrow { width: 100%; }
|
|
263
|
+
.chart-range-dialog .mojo-calendar-presets-divider { display: none; }
|
|
264
|
+
}
|
package/src/ui/modal.tsx
CHANGED
|
@@ -113,9 +113,12 @@ export interface ModalOptions {
|
|
|
113
113
|
flush?: boolean;
|
|
114
114
|
/** Guards Escape/backdrop dismissal. Programmatic close still succeeds. */
|
|
115
115
|
canDismiss?: () => boolean;
|
|
116
|
+
/** Abort removes this entry immediately, even without a mounted ModalHost. */
|
|
117
|
+
signal?: AbortSignal;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOptions = {}): Promise<T | null> {
|
|
121
|
+
if (opts.signal?.aborted) return Promise.resolve(null);
|
|
119
122
|
wireAuth();
|
|
120
123
|
return new Promise<T | null>((resolve) => {
|
|
121
124
|
const id = nextId++;
|
|
@@ -123,10 +126,13 @@ function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOp
|
|
|
123
126
|
const close = (value: unknown) => {
|
|
124
127
|
if (settled) return;
|
|
125
128
|
settled = true;
|
|
129
|
+
opts.signal?.removeEventListener('abort', abort);
|
|
126
130
|
stack = stack.filter((m) => m.id !== id);
|
|
127
131
|
emit();
|
|
128
132
|
resolve(value as T | null);
|
|
129
133
|
};
|
|
134
|
+
const abort = () => close(null);
|
|
135
|
+
opts.signal?.addEventListener('abort', abort, { once: true });
|
|
130
136
|
stack = [...stack, {
|
|
131
137
|
id,
|
|
132
138
|
variant: 'modal',
|