@protobase/ui 0.28.0 → 0.28.2
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/dist/app/charts/chart-groups.js +1 -1
- package/dist/app/charts/period-chart.js +32 -15
- package/dist/app/record-chart/chart-points.js +35 -0
- package/dist/app/record-chart/messages.js +28 -0
- package/dist/app/record-chart/record-chart-section.js +114 -0
- package/dist/app/record-page.js +5 -0
- package/dist/data/use-aggregates.js +17 -2
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-CT9B90lM.js +69 -0
- package/dist/shell/assets/{index-BF_9NTir.js → index-Dl_cjkSq.js} +1 -1
- package/dist/shell/assets/{records-map-7UYIVM03.js → records-map-B0KTc5ES.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-CsNp8ORv.js → shared-protobase-ui-B3__v-fO.js} +1 -1
- package/dist/shell/index.html +3 -3
- package/dist/shell/protobase.shell.json +1 -1
- package/dist/shell/protobase.sw.js +2 -2
- package/package.json +4 -4
- package/dist/shell/assets/app-DY0ReSvA.js +0 -69
|
@@ -39,7 +39,7 @@ const periodPoints = (groups, measure) => groups.map((group) => ({
|
|
|
39
39
|
value: measured(group, measure),
|
|
40
40
|
count: group.count
|
|
41
41
|
}));
|
|
42
|
-
/** A measured number as text: a count whole, a sum or
|
|
42
|
+
/** A measured number as text: a count whole, a sum, average or maximum with the field's decimals, prefix and suffix. */
|
|
43
43
|
const measureText = (value, measure) => {
|
|
44
44
|
if (value === null) return "—";
|
|
45
45
|
if (measure.kind === "count") return value.toLocaleString(formatTag());
|
|
@@ -6,12 +6,21 @@ import { LegendItem } from "./legend-item.js";
|
|
|
6
6
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
7
|
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
8
8
|
//#region src/app/charts/period-chart.tsx
|
|
9
|
+
const clock = {
|
|
10
|
+
hour: "2-digit",
|
|
11
|
+
minute: "2-digit",
|
|
12
|
+
hour12: false
|
|
13
|
+
};
|
|
14
|
+
const dayAndClock = {
|
|
15
|
+
day: "numeric",
|
|
16
|
+
month: "short",
|
|
17
|
+
...clock
|
|
18
|
+
};
|
|
9
19
|
const formats = {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
},
|
|
20
|
+
minute: clock,
|
|
21
|
+
"5_minutes": clock,
|
|
22
|
+
"15_minutes": clock,
|
|
23
|
+
hour: clock,
|
|
15
24
|
day: {
|
|
16
25
|
day: "numeric",
|
|
17
26
|
month: "short"
|
|
@@ -27,13 +36,10 @@ const formats = {
|
|
|
27
36
|
year: { year: "numeric" }
|
|
28
37
|
};
|
|
29
38
|
const tooltipFormats = {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
minute: "2-digit",
|
|
35
|
-
hour12: false
|
|
36
|
-
},
|
|
39
|
+
minute: dayAndClock,
|
|
40
|
+
"5_minutes": dayAndClock,
|
|
41
|
+
"15_minutes": dayAndClock,
|
|
42
|
+
hour: dayAndClock,
|
|
37
43
|
day: {
|
|
38
44
|
day: "numeric",
|
|
39
45
|
month: "short",
|
|
@@ -61,6 +67,17 @@ const axisTick = {
|
|
|
61
67
|
fontSize: 11,
|
|
62
68
|
fill: "var(--muted-foreground)"
|
|
63
69
|
};
|
|
70
|
+
const dayMs = 864e5;
|
|
71
|
+
const withinDay = /* @__PURE__ */ new Set([
|
|
72
|
+
"minute",
|
|
73
|
+
"5_minutes",
|
|
74
|
+
"15_minutes",
|
|
75
|
+
"hour"
|
|
76
|
+
]);
|
|
77
|
+
const tickFormat = (bucket, points) => {
|
|
78
|
+
const span = points.length > 1 ? points.at(-1).at - points[0].at : 0;
|
|
79
|
+
return withinDay.has(bucket) && span >= dayMs ? dayAndClock : formats[bucket];
|
|
80
|
+
};
|
|
64
81
|
const PointTooltip = ({ active, payload, measure, bucket, series }) => {
|
|
65
82
|
const point = payload?.[0]?.payload;
|
|
66
83
|
if (!active || !point) return null;
|
|
@@ -85,11 +102,11 @@ const PointTooltip = ({ active, payload, measure, bucket, series }) => {
|
|
|
85
102
|
}),
|
|
86
103
|
/* @__PURE__ */ jsx("span", {
|
|
87
104
|
className: "font-semibold tabular-nums",
|
|
88
|
-
children: measureText(point[part.key] ?? null, measure)
|
|
105
|
+
children: measureText(point[part.key] ?? null, part.measure ?? measure)
|
|
89
106
|
})
|
|
90
107
|
]
|
|
91
108
|
}, part.key)),
|
|
92
|
-
series && measure.kind
|
|
109
|
+
series && (measure.kind === "count" || measure.kind === "sum") && /* @__PURE__ */ jsx("div", {
|
|
93
110
|
className: "mt-0.5 border-t pt-0.5 font-semibold tabular-nums",
|
|
94
111
|
children: measureText(point.value, measure)
|
|
95
112
|
}),
|
|
@@ -103,7 +120,7 @@ const PointTooltip = ({ active, payload, measure, bucket, series }) => {
|
|
|
103
120
|
/** A date field's buckets over time, as bars or a line; a bucket without a value to add up is a gap. */
|
|
104
121
|
const PeriodChart = ({ points, measure, bucket, type, series, resource, view }) => {
|
|
105
122
|
const wide = useMediaQuery("(min-width: 640px)");
|
|
106
|
-
const tick = formatterOf(
|
|
123
|
+
const tick = formatterOf(tickFormat(bucket, points));
|
|
107
124
|
const grid = /* @__PURE__ */ jsx(CartesianGrid, {
|
|
108
125
|
vertical: false,
|
|
109
126
|
stroke: "var(--border)"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/app/record-chart/chart-points.ts
|
|
2
|
+
/** How long a bucket is over each range: a minute over the hour, 5 minutes over the day, an hour over the week. */
|
|
3
|
+
const rangeBuckets = {
|
|
4
|
+
"1h": "minute",
|
|
5
|
+
"24h": "5_minutes",
|
|
6
|
+
"7d": "hour"
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The buckets of several series as one line of points, each series' number under `keys[i]`. The series come from
|
|
10
|
+
* requests sent one by one, so their ranges may end a bucket apart: a bucket one of them lacks holds `null` for it, as
|
|
11
|
+
* one without a sample does, which the chart draws as a gap rather than as 0.
|
|
12
|
+
*/
|
|
13
|
+
const chartPoints = (series, keys, measure) => {
|
|
14
|
+
const points = /* @__PURE__ */ new Map();
|
|
15
|
+
series.forEach((groups, index) => {
|
|
16
|
+
for (const group of groups) {
|
|
17
|
+
const at = Date.parse(`${String(group.value)}Z`);
|
|
18
|
+
const point = points.get(at) ?? {
|
|
19
|
+
at,
|
|
20
|
+
value: null,
|
|
21
|
+
count: 0
|
|
22
|
+
};
|
|
23
|
+
point[keys[index]] = group[measure] ?? null;
|
|
24
|
+
point.count = Math.max(point.count, group.count);
|
|
25
|
+
points.set(at, point);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const empty = Object.fromEntries(keys.map((key) => [key, null]));
|
|
29
|
+
return [...points.values()].sort((a, b) => a.at - b.at).map((point) => ({
|
|
30
|
+
...empty,
|
|
31
|
+
...point
|
|
32
|
+
}));
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { chartPoints, rangeBuckets };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineMessages } from "../../locale/messages.js";
|
|
2
|
+
//#region src/app/record-chart/messages.ts
|
|
3
|
+
const text = defineMessages({
|
|
4
|
+
en: {
|
|
5
|
+
range: "Range",
|
|
6
|
+
hour: "Hour",
|
|
7
|
+
day: "Day",
|
|
8
|
+
week: "Week",
|
|
9
|
+
measure: "Per bucket",
|
|
10
|
+
average: "Average",
|
|
11
|
+
maximum: "Maximum",
|
|
12
|
+
noSamples: "No samples in this range.",
|
|
13
|
+
couldNotLoad: "Could not load the chart."
|
|
14
|
+
},
|
|
15
|
+
nl: {
|
|
16
|
+
range: "Periode",
|
|
17
|
+
hour: "Uur",
|
|
18
|
+
day: "Dag",
|
|
19
|
+
week: "Week",
|
|
20
|
+
measure: "Per interval",
|
|
21
|
+
average: "Gemiddelde",
|
|
22
|
+
maximum: "Maximum",
|
|
23
|
+
noSamples: "Geen metingen in deze periode.",
|
|
24
|
+
couldNotLoad: "Kon de grafiek niet laden."
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
//#endregion
|
|
28
|
+
export { text };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Skeleton } from "../../primitives/skeleton.js";
|
|
2
|
+
import { Segmented } from "../../primitives/segmented.js";
|
|
3
|
+
import { viewerZone } from "../../format/zone.js";
|
|
4
|
+
import { humanize } from "../../live/naming.js";
|
|
5
|
+
import { Section } from "../../record-view/section.js";
|
|
6
|
+
import { useAdminMeta } from "../meta-gate.js";
|
|
7
|
+
import { useAggregates } from "../../data/use-aggregates.js";
|
|
8
|
+
import { PeriodChart } from "../charts/period-chart.js";
|
|
9
|
+
import { chartPoints, rangeBuckets } from "./chart-points.js";
|
|
10
|
+
import { text } from "./messages.js";
|
|
11
|
+
import { useState } from "react";
|
|
12
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
+
import { createWhere, printFilter } from "@protobase/schema";
|
|
14
|
+
//#region src/app/record-chart/record-chart-section.tsx
|
|
15
|
+
const where$1 = createWhere();
|
|
16
|
+
const ranges = [
|
|
17
|
+
"1h",
|
|
18
|
+
"24h",
|
|
19
|
+
"7d"
|
|
20
|
+
];
|
|
21
|
+
const rangeLabels = {
|
|
22
|
+
"1h": "hour",
|
|
23
|
+
"24h": "day",
|
|
24
|
+
"7d": "week"
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Line graphs of the samples of `item.resource` that point at the record (`l.chart`), each series' average or maximum
|
|
28
|
+
* per bucket over the last hour, day or week, which the person switches between. Each series is one `:aggregate`
|
|
29
|
+
* read with their access; a bucket without a sample is a gap in its line.
|
|
30
|
+
*/
|
|
31
|
+
const RecordChartSection = ({ item, recordKey }) => {
|
|
32
|
+
const { resources, views } = useAdminMeta();
|
|
33
|
+
const [range, setRange] = useState(item.range ?? "1h");
|
|
34
|
+
const [measure, setMeasure] = useState("average");
|
|
35
|
+
const model = resources[item.resource];
|
|
36
|
+
const view = views[item.resource];
|
|
37
|
+
const granularity = rangeBuckets[range];
|
|
38
|
+
const filter = printFilter(where$1.eq(item.field, recordKey));
|
|
39
|
+
const requests = item.series.map((name) => ({
|
|
40
|
+
groupBy: item.at,
|
|
41
|
+
filter,
|
|
42
|
+
...measure === "average" ? { average: name } : { maximum: name },
|
|
43
|
+
range,
|
|
44
|
+
granularity,
|
|
45
|
+
timeZone: viewerZone()
|
|
46
|
+
}));
|
|
47
|
+
const results = useAggregates(item.resource, requests, Boolean(model));
|
|
48
|
+
if (!model) return null;
|
|
49
|
+
const measures = item.series.map((name) => ({
|
|
50
|
+
kind: measure,
|
|
51
|
+
field: model.fields[name],
|
|
52
|
+
...view?.fields[name] && { hints: view.fields[name] }
|
|
53
|
+
}));
|
|
54
|
+
const series = item.series.map((name, index) => ({
|
|
55
|
+
key: `s${index}`,
|
|
56
|
+
label: view?.fields[name]?.label ?? humanize(name),
|
|
57
|
+
color: `var(--chart-${index + 1})`,
|
|
58
|
+
measure: measures[index]
|
|
59
|
+
}));
|
|
60
|
+
const points = results.every((result) => result.data) ? chartPoints(results.map((result) => result.data.groups), series.map((part) => part.key), measure) : [];
|
|
61
|
+
const actions = /* @__PURE__ */ jsxs("div", {
|
|
62
|
+
className: "flex flex-wrap gap-2",
|
|
63
|
+
children: [/* @__PURE__ */ jsx(Segmented, {
|
|
64
|
+
label: text("measure"),
|
|
65
|
+
value: measure,
|
|
66
|
+
onChange: setMeasure,
|
|
67
|
+
options: [{
|
|
68
|
+
value: "average",
|
|
69
|
+
label: text("average")
|
|
70
|
+
}, {
|
|
71
|
+
value: "maximum",
|
|
72
|
+
label: text("maximum")
|
|
73
|
+
}]
|
|
74
|
+
}), /* @__PURE__ */ jsx(Segmented, {
|
|
75
|
+
label: text("range"),
|
|
76
|
+
value: range,
|
|
77
|
+
onChange: setRange,
|
|
78
|
+
options: ranges.map((value) => ({
|
|
79
|
+
value,
|
|
80
|
+
label: text(rangeLabels[value])
|
|
81
|
+
}))
|
|
82
|
+
})]
|
|
83
|
+
});
|
|
84
|
+
const body = () => {
|
|
85
|
+
if (results.some((result) => result.error)) return /* @__PURE__ */ jsx("p", {
|
|
86
|
+
className: "text-danger-text",
|
|
87
|
+
children: text("couldNotLoad")
|
|
88
|
+
});
|
|
89
|
+
if (results.some((result) => result.isPending)) return /* @__PURE__ */ jsx(Skeleton, { className: "h-40 w-full" });
|
|
90
|
+
if (points.every((point) => point.count === 0)) return /* @__PURE__ */ jsx("p", {
|
|
91
|
+
className: "text-muted-foreground",
|
|
92
|
+
children: text("noSamples")
|
|
93
|
+
});
|
|
94
|
+
return /* @__PURE__ */ jsx(PeriodChart, {
|
|
95
|
+
points,
|
|
96
|
+
measure: measures[0],
|
|
97
|
+
bucket: granularity,
|
|
98
|
+
type: "line",
|
|
99
|
+
series,
|
|
100
|
+
...series.length > 1 && {
|
|
101
|
+
resource: item.resource,
|
|
102
|
+
view
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
return /* @__PURE__ */ jsx(Section, {
|
|
107
|
+
title: item.title,
|
|
108
|
+
help: item.help,
|
|
109
|
+
actions,
|
|
110
|
+
children: body()
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
//#endregion
|
|
114
|
+
export { RecordChartSection };
|
package/dist/app/record-page.js
CHANGED
|
@@ -48,6 +48,7 @@ import { useRestore } from "./delete/use-restore.js";
|
|
|
48
48
|
import { auditField } from "../live/audit-fields.js";
|
|
49
49
|
import { CustomSection } from "./custom-section.js";
|
|
50
50
|
import { MapSection } from "./map/map-section.js";
|
|
51
|
+
import { RecordChartSection } from "./record-chart/record-chart-section.js";
|
|
51
52
|
import { RecordActions } from "./pages/record-actions.js";
|
|
52
53
|
import { groupOpen, toggledOpen } from "./group-open.js";
|
|
53
54
|
import { attentionTab, shownTab, splitTabs } from "./record-tabs.js";
|
|
@@ -380,6 +381,10 @@ const LoadedRecord = ({ model, recordKey, stored, preview }) => {
|
|
|
380
381
|
owner: model.name
|
|
381
382
|
}, `related:${item.title}`)];
|
|
382
383
|
}
|
|
384
|
+
if (item.kind === "chart") return [/* @__PURE__ */ jsx(RecordChartSection, {
|
|
385
|
+
item,
|
|
386
|
+
recordKey
|
|
387
|
+
}, `chart:${index}`)];
|
|
383
388
|
if (preview && (item.kind === "custom" || item.kind === "map" || item.kind === "gallery")) return [];
|
|
384
389
|
if (item.kind === "custom") return item.sidebar ? [] : [/* @__PURE__ */ jsx(CustomSection, {
|
|
385
390
|
item,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { keys } from "./query-keys.js";
|
|
2
2
|
import { useClient } from "./api-provider.js";
|
|
3
3
|
import { filterText } from "@protobase/client";
|
|
4
|
-
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
5
5
|
//#region src/data/use-aggregates.ts
|
|
6
6
|
const useFacets = (resource, field, filter, enabled = true) => {
|
|
7
7
|
const client = useClient();
|
|
@@ -52,5 +52,20 @@ const useAggregate = (resource, params, enabled = true) => {
|
|
|
52
52
|
queryFn: () => client.aggregate(resource, wire)
|
|
53
53
|
});
|
|
54
54
|
};
|
|
55
|
+
/** Several `:aggregate` reads of one resource at once, in the order of `requests`, cached as `useAggregate` caches each. */
|
|
56
|
+
const useAggregates = (resource, requests, enabled = true) => {
|
|
57
|
+
const client = useClient();
|
|
58
|
+
return useQueries({ queries: requests.map((params) => {
|
|
59
|
+
const wire = {
|
|
60
|
+
...params,
|
|
61
|
+
filter: filterText(params.filter) ?? ""
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
queryKey: keys.aggregate(resource, JSON.stringify(wire)),
|
|
65
|
+
enabled,
|
|
66
|
+
queryFn: () => client.aggregate(resource, wire)
|
|
67
|
+
};
|
|
68
|
+
}) });
|
|
69
|
+
};
|
|
55
70
|
//#endregion
|
|
56
|
-
export { useAggregate, useFacets, useHistogram, useSeries };
|
|
71
|
+
export { useAggregate, useAggregates, useFacets, useHistogram, useSeries };
|
package/dist/package.js
CHANGED