@protobase/ui 0.28.1 → 0.28.3

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.
@@ -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 average with the field's decimals, prefix and suffix. */
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
- hour: {
11
- hour: "2-digit",
12
- minute: "2-digit",
13
- hour12: false
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
- hour: {
31
- day: "numeric",
32
- month: "short",
33
- hour: "2-digit",
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 !== "average" && /* @__PURE__ */ jsx("div", {
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(formats[bucket]);
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 };
@@ -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
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.28.1";
2
+ var version = "0.28.3";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };