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 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.
@@ -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: 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
- fetchData({
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: (stats) => this.setState({ stats }),
766
+ onLoadComplete: (s) => (stats = s),
721
767
  });
722
- fetchChartData({
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
- getDataFn,
728
- onLoadProgress: (i, n) => {
729
- this.setState({ chartData: { loading: { max: n, value: i } } });
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
- ${statsSection.menuName !== "" &&
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: 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
- fetchData({
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: (stats) => this.setState({ stats }),
368
+ onLoadComplete: (s) => (stats = s),
323
369
  });
324
- fetchChartData({
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
- getDataFn,
330
- onLoadProgress: (i, n) => {
331
- this.setState({ chartData: { loading: { max: n, value: i } } });
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
- ${statsSection.menuName !== "" &&
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hearald",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A single server drop-in file system based metrics and analytics library.",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -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: 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
- fetchData({
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: (stats) => this.setState({ stats }),
110
+ onLoadComplete: (s) => (stats = s),
109
111
  });
110
- fetchChartData({
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
- getDataFn,
116
- onLoadProgress: (i, n) => {
117
- this.setState({ chartData: { loading: { max: n, value: i } } });
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
- ${statsSection.menuName !== "" &&
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}"
@@ -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) => {
@@ -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));