hearald 0.1.1 → 0.1.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.
- package/CHANGELOG.md +19 -0
- package/README.md +7 -0
- package/dist/analytics.html +63 -22
- package/dist/index.js +63 -22
- package/package.json +1 -1
- package/src/public/js/App.js +19 -22
- package/src/public/js/util.js +12 -0
- package/src/shared/utils.js +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.3] - 2026-11-08
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fix the dashboard chart's initial loading state when the app does not have a `users` field.
|
|
15
|
+
- Fix the dashboard's timezone to match the server's daily bucketing (Pacific time).
|
|
16
|
+
|
|
17
|
+
## [0.1.2] - 2026-10-26
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- The dashboard crashed (`Cannot read properties of undefined (reading 'map')`) on a `ui` layout written
|
|
22
|
+
the way the README showed it, `{ title, fields }`. Sections now accept `title` or `menuName` and
|
|
23
|
+
`fields` or `stats`, and a section with neither renders empty.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- The README now documents the `ui` layout (sections, tiles, `chartContexts`).
|
|
28
|
+
|
|
10
29
|
## [0.1.1] - 2026-10-09
|
|
11
30
|
|
|
12
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -113,6 +113,13 @@ const statDefinitions = {
|
|
|
113
113
|
};
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
`ui` sets the dashboard layout. Each section has a `title` (or `menuName`; `""` for no heading) and a
|
|
117
|
+
list of tiles in `fields` (or `stats`); a tile is `{ key, label, type }` with `type` one of `number`,
|
|
118
|
+
`percent` or `histogram`, and an optional `span`. `chartContexts` (`[{ id, menuName }]`) fills the chart's
|
|
119
|
+
"Context" menu. Both are optional; a dashboard with no `ui` shows only the chart.
|
|
120
|
+
The chart starts on the `users` field and the `all` context when the app has them, otherwise on the
|
|
121
|
+
first field and the first `chartContexts` entry. The page's date defaults to today in Pacific time.
|
|
122
|
+
|
|
116
123
|
Names with dots become nested objects in the result. `hidden: true` leaves a metric out of the output
|
|
117
124
|
(useful for sets that only feed a computed metric). A computed metric that names a formula that does
|
|
118
125
|
not exist is an error; one whose inputs are missing is simply left out.
|
package/dist/analytics.html
CHANGED
|
@@ -405,6 +405,36 @@ th {
|
|
|
405
405
|
|
|
406
406
|
var m=e.bind(_);
|
|
407
407
|
|
|
408
|
+
// A Date whose UTC fields hold the current Pacific wall-clock time, so `iso()` (which reads UTC
|
|
409
|
+
// fields) names the Pacific day. The instant it represents is not "now"; it is only for day buckets.
|
|
410
|
+
const nowAsPstDate = (now = new Date()) => {
|
|
411
|
+
const parts = Object.fromEntries(
|
|
412
|
+
new Intl.DateTimeFormat("en-US", {
|
|
413
|
+
timeZone: "America/Los_Angeles",
|
|
414
|
+
hourCycle: "h23",
|
|
415
|
+
year: "numeric",
|
|
416
|
+
month: "numeric",
|
|
417
|
+
day: "numeric",
|
|
418
|
+
hour: "numeric",
|
|
419
|
+
minute: "numeric",
|
|
420
|
+
second: "numeric",
|
|
421
|
+
})
|
|
422
|
+
.formatToParts(now)
|
|
423
|
+
.map((p) => [p.type, p.value])
|
|
424
|
+
);
|
|
425
|
+
return new Date(
|
|
426
|
+
Date.UTC(
|
|
427
|
+
Number(parts.year),
|
|
428
|
+
Number(parts.month) - 1,
|
|
429
|
+
Number(parts.day),
|
|
430
|
+
Number(parts.hour),
|
|
431
|
+
Number(parts.minute),
|
|
432
|
+
Number(parts.second),
|
|
433
|
+
now.getMilliseconds()
|
|
434
|
+
)
|
|
435
|
+
);
|
|
436
|
+
};
|
|
437
|
+
|
|
408
438
|
function iso(time) {
|
|
409
439
|
return [
|
|
410
440
|
time.getUTCFullYear(),
|
|
@@ -413,6 +443,10 @@ th {
|
|
|
413
443
|
].join("-");
|
|
414
444
|
}
|
|
415
445
|
|
|
446
|
+
// Today's calendar date in Pacific time as YYYY-MM-DD (daylight saving included), the same day
|
|
447
|
+
// buckets the server uses. The dashboard page defaults its date to this, not the browser's UTC date.
|
|
448
|
+
const pacificToday = (now = new Date()) => iso(nowAsPstDate(now));
|
|
449
|
+
|
|
416
450
|
/* START methods for fetching data from the server */
|
|
417
451
|
|
|
418
452
|
const dataCache = {};
|
|
@@ -485,6 +519,18 @@ th {
|
|
|
485
519
|
|
|
486
520
|
/* END methods for fetching data from the server */
|
|
487
521
|
|
|
522
|
+
// Keeps the preferred chart field/context when they exist, otherwise falls back to the first
|
|
523
|
+
// available one, so the menus (which show the first option when nothing matches) and the chart agree.
|
|
524
|
+
function resolveChartDefaults({ chartField, chartContext, stats, contexts }) {
|
|
525
|
+
const fields = Object.keys(stats?.metrics?.all || {});
|
|
526
|
+
const contextIds = (contexts || []).map((c) => c.id);
|
|
527
|
+
return {
|
|
528
|
+
chartField: fields.length && !fields.includes(chartField) ? fields[0] : chartField,
|
|
529
|
+
chartContext:
|
|
530
|
+
contextIds.length && !contextIds.includes(chartContext) ? contextIds[0] : chartContext,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
488
534
|
/* START helper methods */
|
|
489
535
|
|
|
490
536
|
const getJson = async (url) => {
|
|
@@ -622,8 +668,6 @@ th {
|
|
|
622
668
|
}
|
|
623
669
|
}
|
|
624
670
|
|
|
625
|
-
const DATE = new Date().toISOString().split("T")[0];
|
|
626
|
-
|
|
627
671
|
const Logo = () => m`
|
|
628
672
|
<svg
|
|
629
673
|
width="256"
|
|
@@ -703,7 +747,7 @@ th {
|
|
|
703
747
|
super(props);
|
|
704
748
|
this.state = {
|
|
705
749
|
numDays: 30,
|
|
706
|
-
date:
|
|
750
|
+
date: pacificToday(),
|
|
707
751
|
chartField: "users",
|
|
708
752
|
chartContext: "all",
|
|
709
753
|
uiSectionsLoaded: false,
|
|
@@ -713,25 +757,22 @@ th {
|
|
|
713
757
|
async componentDidMount() {
|
|
714
758
|
await fetchUI();
|
|
715
759
|
this.setState({ uiSectionsLoaded: true });
|
|
716
|
-
|
|
760
|
+
// The chart waits for the stats so its field and context can be checked against them.
|
|
761
|
+
let stats = null;
|
|
762
|
+
await fetchData({
|
|
717
763
|
numDays: this.state.numDays,
|
|
718
764
|
date: this.state.date,
|
|
719
765
|
getDataFn,
|
|
720
|
-
onLoadComplete: (
|
|
766
|
+
onLoadComplete: (s) => (stats = s),
|
|
721
767
|
});
|
|
722
|
-
|
|
723
|
-
numDays: this.state.numDays,
|
|
724
|
-
date: this.state.date,
|
|
725
|
-
chartContext: this.state.chartContext,
|
|
768
|
+
const { chartField, chartContext } = resolveChartDefaults({
|
|
726
769
|
chartField: this.state.chartField,
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
},
|
|
731
|
-
onLoadComplete: (data, max) => {
|
|
732
|
-
this.setState({ chartData: { loading: false, data, max } });
|
|
733
|
-
},
|
|
770
|
+
chartContext: this.state.chartContext,
|
|
771
|
+
stats,
|
|
772
|
+
contexts: UI.chartContexts,
|
|
734
773
|
});
|
|
774
|
+
this.setState({ stats, chartField, chartContext });
|
|
775
|
+
this.fetchChartData({ chartField, chartContext });
|
|
735
776
|
}
|
|
736
777
|
|
|
737
778
|
async fetchChartData({ numDays, date, chartContext, chartField }) {
|
|
@@ -881,7 +922,7 @@ th {
|
|
|
881
922
|
!chartData.loading &&
|
|
882
923
|
m`
|
|
883
924
|
<div class="bar-chart__scale">${chartData.max}</div>
|
|
884
|
-
${chartData.data.map(
|
|
925
|
+
${(chartData.data || []).map(
|
|
885
926
|
(v) =>
|
|
886
927
|
m`
|
|
887
928
|
<div
|
|
@@ -900,15 +941,15 @@ th {
|
|
|
900
941
|
</div>
|
|
901
942
|
</section>
|
|
902
943
|
${(UI.sections || []).map((statsSection) => {
|
|
944
|
+
const sectionTitle =
|
|
945
|
+
statsSection.menuName ?? statsSection.title ?? "";
|
|
903
946
|
return m`
|
|
904
947
|
<section class="analytics analytics--${statsSection.id}">
|
|
905
|
-
${
|
|
948
|
+
${sectionTitle !== "" &&
|
|
906
949
|
m`
|
|
907
|
-
<h2 class="analytics__title">
|
|
908
|
-
${statsSection.menuName}
|
|
909
|
-
</h2>
|
|
950
|
+
<h2 class="analytics__title">${sectionTitle}</h2>
|
|
910
951
|
`}
|
|
911
|
-
${statsSection.stats.map((f) => {
|
|
952
|
+
${(statsSection.stats || statsSection.fields || []).map((f) => {
|
|
912
953
|
return m`
|
|
913
954
|
<${Tile}
|
|
914
955
|
title="${f.label}"
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,36 @@
|
|
|
7
7
|
|
|
8
8
|
var m=e.bind(_);
|
|
9
9
|
|
|
10
|
+
// A Date whose UTC fields hold the current Pacific wall-clock time, so `iso()` (which reads UTC
|
|
11
|
+
// fields) names the Pacific day. The instant it represents is not "now"; it is only for day buckets.
|
|
12
|
+
const nowAsPstDate = (now = new Date()) => {
|
|
13
|
+
const parts = Object.fromEntries(
|
|
14
|
+
new Intl.DateTimeFormat("en-US", {
|
|
15
|
+
timeZone: "America/Los_Angeles",
|
|
16
|
+
hourCycle: "h23",
|
|
17
|
+
year: "numeric",
|
|
18
|
+
month: "numeric",
|
|
19
|
+
day: "numeric",
|
|
20
|
+
hour: "numeric",
|
|
21
|
+
minute: "numeric",
|
|
22
|
+
second: "numeric",
|
|
23
|
+
})
|
|
24
|
+
.formatToParts(now)
|
|
25
|
+
.map((p) => [p.type, p.value])
|
|
26
|
+
);
|
|
27
|
+
return new Date(
|
|
28
|
+
Date.UTC(
|
|
29
|
+
Number(parts.year),
|
|
30
|
+
Number(parts.month) - 1,
|
|
31
|
+
Number(parts.day),
|
|
32
|
+
Number(parts.hour),
|
|
33
|
+
Number(parts.minute),
|
|
34
|
+
Number(parts.second),
|
|
35
|
+
now.getMilliseconds()
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
10
40
|
function iso(time) {
|
|
11
41
|
return [
|
|
12
42
|
time.getUTCFullYear(),
|
|
@@ -15,6 +45,10 @@
|
|
|
15
45
|
].join("-");
|
|
16
46
|
}
|
|
17
47
|
|
|
48
|
+
// Today's calendar date in Pacific time as YYYY-MM-DD (daylight saving included), the same day
|
|
49
|
+
// buckets the server uses. The dashboard page defaults its date to this, not the browser's UTC date.
|
|
50
|
+
const pacificToday = (now = new Date()) => iso(nowAsPstDate(now));
|
|
51
|
+
|
|
18
52
|
/* START methods for fetching data from the server */
|
|
19
53
|
|
|
20
54
|
const dataCache = {};
|
|
@@ -87,6 +121,18 @@
|
|
|
87
121
|
|
|
88
122
|
/* END methods for fetching data from the server */
|
|
89
123
|
|
|
124
|
+
// Keeps the preferred chart field/context when they exist, otherwise falls back to the first
|
|
125
|
+
// available one, so the menus (which show the first option when nothing matches) and the chart agree.
|
|
126
|
+
function resolveChartDefaults({ chartField, chartContext, stats, contexts }) {
|
|
127
|
+
const fields = Object.keys(stats?.metrics?.all || {});
|
|
128
|
+
const contextIds = (contexts || []).map((c) => c.id);
|
|
129
|
+
return {
|
|
130
|
+
chartField: fields.length && !fields.includes(chartField) ? fields[0] : chartField,
|
|
131
|
+
chartContext:
|
|
132
|
+
contextIds.length && !contextIds.includes(chartContext) ? contextIds[0] : chartContext,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
90
136
|
/* START helper methods */
|
|
91
137
|
|
|
92
138
|
const getJson = async (url) => {
|
|
@@ -224,8 +270,6 @@
|
|
|
224
270
|
}
|
|
225
271
|
}
|
|
226
272
|
|
|
227
|
-
const DATE = new Date().toISOString().split("T")[0];
|
|
228
|
-
|
|
229
273
|
const Logo = () => m`
|
|
230
274
|
<svg
|
|
231
275
|
width="256"
|
|
@@ -305,7 +349,7 @@
|
|
|
305
349
|
super(props);
|
|
306
350
|
this.state = {
|
|
307
351
|
numDays: 30,
|
|
308
|
-
date:
|
|
352
|
+
date: pacificToday(),
|
|
309
353
|
chartField: "users",
|
|
310
354
|
chartContext: "all",
|
|
311
355
|
uiSectionsLoaded: false,
|
|
@@ -315,25 +359,22 @@
|
|
|
315
359
|
async componentDidMount() {
|
|
316
360
|
await fetchUI();
|
|
317
361
|
this.setState({ uiSectionsLoaded: true });
|
|
318
|
-
|
|
362
|
+
// The chart waits for the stats so its field and context can be checked against them.
|
|
363
|
+
let stats = null;
|
|
364
|
+
await fetchData({
|
|
319
365
|
numDays: this.state.numDays,
|
|
320
366
|
date: this.state.date,
|
|
321
367
|
getDataFn,
|
|
322
|
-
onLoadComplete: (
|
|
368
|
+
onLoadComplete: (s) => (stats = s),
|
|
323
369
|
});
|
|
324
|
-
|
|
325
|
-
numDays: this.state.numDays,
|
|
326
|
-
date: this.state.date,
|
|
327
|
-
chartContext: this.state.chartContext,
|
|
370
|
+
const { chartField, chartContext } = resolveChartDefaults({
|
|
328
371
|
chartField: this.state.chartField,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
},
|
|
333
|
-
onLoadComplete: (data, max) => {
|
|
334
|
-
this.setState({ chartData: { loading: false, data, max } });
|
|
335
|
-
},
|
|
372
|
+
chartContext: this.state.chartContext,
|
|
373
|
+
stats,
|
|
374
|
+
contexts: UI.chartContexts,
|
|
336
375
|
});
|
|
376
|
+
this.setState({ stats, chartField, chartContext });
|
|
377
|
+
this.fetchChartData({ chartField, chartContext });
|
|
337
378
|
}
|
|
338
379
|
|
|
339
380
|
async fetchChartData({ numDays, date, chartContext, chartField }) {
|
|
@@ -483,7 +524,7 @@
|
|
|
483
524
|
!chartData.loading &&
|
|
484
525
|
m`
|
|
485
526
|
<div class="bar-chart__scale">${chartData.max}</div>
|
|
486
|
-
${chartData.data.map(
|
|
527
|
+
${(chartData.data || []).map(
|
|
487
528
|
(v) =>
|
|
488
529
|
m`
|
|
489
530
|
<div
|
|
@@ -502,15 +543,15 @@
|
|
|
502
543
|
</div>
|
|
503
544
|
</section>
|
|
504
545
|
${(UI.sections || []).map((statsSection) => {
|
|
546
|
+
const sectionTitle =
|
|
547
|
+
statsSection.menuName ?? statsSection.title ?? "";
|
|
505
548
|
return m`
|
|
506
549
|
<section class="analytics analytics--${statsSection.id}">
|
|
507
|
-
${
|
|
550
|
+
${sectionTitle !== "" &&
|
|
508
551
|
m`
|
|
509
|
-
<h2 class="analytics__title">
|
|
510
|
-
${statsSection.menuName}
|
|
511
|
-
</h2>
|
|
552
|
+
<h2 class="analytics__title">${sectionTitle}</h2>
|
|
512
553
|
`}
|
|
513
|
-
${statsSection.stats.map((f) => {
|
|
554
|
+
${(statsSection.stats || statsSection.fields || []).map((f) => {
|
|
514
555
|
return m`
|
|
515
556
|
<${Tile}
|
|
516
557
|
title="${f.label}"
|
package/package.json
CHANGED
package/src/public/js/App.js
CHANGED
|
@@ -7,10 +7,10 @@ import {
|
|
|
7
7
|
getData,
|
|
8
8
|
fetchChartData,
|
|
9
9
|
fetchData,
|
|
10
|
+
resolveChartDefaults,
|
|
10
11
|
} from "./util.js";
|
|
11
12
|
import Tile from "./Tile.js";
|
|
12
|
-
|
|
13
|
-
const DATE = new Date().toISOString().split("T")[0];
|
|
13
|
+
import { pacificToday } from "../../shared/utils.js";
|
|
14
14
|
|
|
15
15
|
const Logo = () => html`
|
|
16
16
|
<svg
|
|
@@ -91,7 +91,7 @@ class App extends Component {
|
|
|
91
91
|
super(props);
|
|
92
92
|
this.state = {
|
|
93
93
|
numDays: 30,
|
|
94
|
-
date:
|
|
94
|
+
date: pacificToday(),
|
|
95
95
|
chartField: "users",
|
|
96
96
|
chartContext: "all",
|
|
97
97
|
uiSectionsLoaded: false,
|
|
@@ -101,25 +101,22 @@ class App extends Component {
|
|
|
101
101
|
async componentDidMount() {
|
|
102
102
|
await fetchUI();
|
|
103
103
|
this.setState({ uiSectionsLoaded: true });
|
|
104
|
-
|
|
104
|
+
// The chart waits for the stats so its field and context can be checked against them.
|
|
105
|
+
let stats = null;
|
|
106
|
+
await fetchData({
|
|
105
107
|
numDays: this.state.numDays,
|
|
106
108
|
date: this.state.date,
|
|
107
109
|
getDataFn,
|
|
108
|
-
onLoadComplete: (
|
|
110
|
+
onLoadComplete: (s) => (stats = s),
|
|
109
111
|
});
|
|
110
|
-
|
|
111
|
-
numDays: this.state.numDays,
|
|
112
|
-
date: this.state.date,
|
|
113
|
-
chartContext: this.state.chartContext,
|
|
112
|
+
const { chartField, chartContext } = resolveChartDefaults({
|
|
114
113
|
chartField: this.state.chartField,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
},
|
|
119
|
-
onLoadComplete: (data, max) => {
|
|
120
|
-
this.setState({ chartData: { loading: false, data, max } });
|
|
121
|
-
},
|
|
114
|
+
chartContext: this.state.chartContext,
|
|
115
|
+
stats,
|
|
116
|
+
contexts: UI.chartContexts,
|
|
122
117
|
});
|
|
118
|
+
this.setState({ stats, chartField, chartContext });
|
|
119
|
+
this.fetchChartData({ chartField, chartContext });
|
|
123
120
|
}
|
|
124
121
|
|
|
125
122
|
async fetchChartData({ numDays, date, chartContext, chartField }) {
|
|
@@ -269,7 +266,7 @@ class App extends Component {
|
|
|
269
266
|
!chartData.loading &&
|
|
270
267
|
html`
|
|
271
268
|
<div class="bar-chart__scale">${chartData.max}</div>
|
|
272
|
-
${chartData.data.map(
|
|
269
|
+
${(chartData.data || []).map(
|
|
273
270
|
(v) =>
|
|
274
271
|
html`
|
|
275
272
|
<div
|
|
@@ -288,15 +285,15 @@ class App extends Component {
|
|
|
288
285
|
</div>
|
|
289
286
|
</section>
|
|
290
287
|
${(UI.sections || []).map((statsSection) => {
|
|
288
|
+
const sectionTitle =
|
|
289
|
+
statsSection.menuName ?? statsSection.title ?? "";
|
|
291
290
|
return html`
|
|
292
291
|
<section class="analytics analytics--${statsSection.id}">
|
|
293
|
-
${
|
|
292
|
+
${sectionTitle !== "" &&
|
|
294
293
|
html`
|
|
295
|
-
<h2 class="analytics__title">
|
|
296
|
-
${statsSection.menuName}
|
|
297
|
-
</h2>
|
|
294
|
+
<h2 class="analytics__title">${sectionTitle}</h2>
|
|
298
295
|
`}
|
|
299
|
-
${statsSection.stats.map((f) => {
|
|
296
|
+
${(statsSection.stats || statsSection.fields || []).map((f) => {
|
|
300
297
|
return html`
|
|
301
298
|
<${Tile}
|
|
302
299
|
title="${f.label}"
|
package/src/public/js/util.js
CHANGED
|
@@ -72,6 +72,18 @@ export async function fetchData({
|
|
|
72
72
|
|
|
73
73
|
/* END methods for fetching data from the server */
|
|
74
74
|
|
|
75
|
+
// Keeps the preferred chart field/context when they exist, otherwise falls back to the first
|
|
76
|
+
// available one, so the menus (which show the first option when nothing matches) and the chart agree.
|
|
77
|
+
export function resolveChartDefaults({ chartField, chartContext, stats, contexts }) {
|
|
78
|
+
const fields = Object.keys(stats?.metrics?.all || {});
|
|
79
|
+
const contextIds = (contexts || []).map((c) => c.id);
|
|
80
|
+
return {
|
|
81
|
+
chartField: fields.length && !fields.includes(chartField) ? fields[0] : chartField,
|
|
82
|
+
chartContext:
|
|
83
|
+
contextIds.length && !contextIds.includes(chartContext) ? contextIds[0] : chartContext,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
/* START helper methods */
|
|
76
88
|
|
|
77
89
|
export const getJson = async (url) => {
|
package/src/shared/utils.js
CHANGED
|
@@ -65,3 +65,7 @@ export function defaultEndDate(now = nowAsPstDate()) {
|
|
|
65
65
|
yesterday.setUTCHours(0, 0, 0, 0);
|
|
66
66
|
return yesterday;
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
// Today's calendar date in Pacific time as YYYY-MM-DD (daylight saving included), the same day
|
|
70
|
+
// buckets the server uses. The dashboard page defaults its date to this, not the browser's UTC date.
|
|
71
|
+
export const pacificToday = (now = new Date()) => iso(nowAsPstDate(now));
|