hearald 0.0.1

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.
@@ -0,0 +1,321 @@
1
+ import { html, Component } from "htm/preact";
2
+
3
+ import {
4
+ getJson,
5
+ deepGet,
6
+ numFormatter,
7
+ getData,
8
+ fetchChartData,
9
+ fetchData,
10
+ } from "./util.js";
11
+ import Tile from "./Tile";
12
+
13
+ const DATE = new Date().toISOString().split("T")[0];
14
+
15
+ const Logo = () => html`
16
+ <svg
17
+ width="256"
18
+ height="256"
19
+ viewBox="0 0 256 256"
20
+ xmlns="http://www.w3.org/2000/svg"
21
+ role="img"
22
+ aria-labelledby="title desc"
23
+ >
24
+ <title id="title">Hearald</title>
25
+ <desc id="desc">
26
+ Circular seal with a centered Gothic-style H monogram.
27
+ </desc>
28
+
29
+ <style>
30
+ .stroke {
31
+ stroke: white;
32
+ stroke-width: 8;
33
+ stroke-linecap: round;
34
+ stroke-linejoin: round;
35
+ fill: none;
36
+ }
37
+ .fill {
38
+ fill: #127d44;
39
+ }
40
+ </style>
41
+
42
+ <!-- Outer seal circle -->
43
+ <circle class="stroke" cx="128" cy="128" r="112" />
44
+
45
+ <!-- Inner decorative circle -->
46
+ <circle class="stroke" cx="128" cy="128" r="96" stroke-dasharray="14 10" />
47
+
48
+ <!-- Gothic-style H (stylized manually) -->
49
+ <g transform="translate(78,72)">
50
+ <!-- Left vertical stroke with spiked top/bottom -->
51
+ <path class="fill" d="M8 0 L20 0 L20 112 L8 112 L0 100 L0 12 Z" />
52
+ <!-- Right vertical stroke with spiked top/bottom -->
53
+ <path class="fill" d="M80 0 L92 0 L100 12 L100 100 L92 112 L80 112 Z" />
54
+ <!-- Crossbar with gothic curve -->
55
+ <path class="fill" d="M20 48 H80 V64 H20 Z" />
56
+ <!-- Decorative inward serifs -->
57
+ <path class="fill" d="M20 0 q10 8 0 16 Z" />
58
+ <path class="fill" d="M80 0 q-10 8 0 16 Z" />
59
+ <path class="fill" d="M20 96 q10 8 0 16 Z" />
60
+ <path class="fill" d="M80 96 q-10 8 0 16 Z" />
61
+ </g>
62
+ </svg>
63
+ `;
64
+
65
+ const getDataFn = ({ n, d, q }) =>
66
+ getData({
67
+ n,
68
+ d,
69
+ q,
70
+ endpointBuilder: (n, d, q) => {
71
+ return `${window.location.pathname.replace(/\/$/, "")}/data/${n}/${d}${
72
+ q ? "?" + q : ""
73
+ }`;
74
+ },
75
+ fetchStats: getJson,
76
+ });
77
+
78
+ let UI = [];
79
+
80
+ async function fetchUI() {
81
+ try {
82
+ const res = await fetch(window.location.href + "/layout");
83
+ UI = await res.json();
84
+ } catch (e) {
85
+ UI = [];
86
+ }
87
+ }
88
+
89
+ class App extends Component {
90
+ constructor(props) {
91
+ super(props);
92
+ this.state = {
93
+ numDays: 30,
94
+ date: DATE,
95
+ chartField: "users",
96
+ chartContext: "all",
97
+ uiSectionsLoaded: false,
98
+ };
99
+ }
100
+
101
+ async componentDidMount() {
102
+ await fetchUI();
103
+ this.setState({ uiSectionsLoaded: true });
104
+ fetchData({
105
+ numDays: this.state.numDays,
106
+ date: this.state.date,
107
+ getDataFn,
108
+ onLoadComplete: (stats) => this.setState({ stats }),
109
+ });
110
+ fetchChartData({
111
+ numDays: this.state.numDays,
112
+ date: this.state.date,
113
+ chartContext: this.state.chartContext,
114
+ 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
+ },
122
+ });
123
+ }
124
+
125
+ async fetchChartData({ numDays, date, chartContext, chartField }) {
126
+ await fetchChartData({
127
+ numDays: numDays || this.state.numDays,
128
+ date: date || this.state.date,
129
+ chartContext: chartContext || this.state.chartContext,
130
+ chartField: chartField || this.state.chartField,
131
+ getDataFn,
132
+ onLoadProgress: (i, n) =>
133
+ this.setState({ chartData: { loading: { max: n, value: i } } }),
134
+ onLoadComplete: (data, max) =>
135
+ this.setState({ chartData: { loading: false, data, max } }),
136
+ });
137
+ }
138
+
139
+ async fetchData({ numDays, date }) {
140
+ await fetchData({
141
+ numDays: numDays || this.state.numDays,
142
+ date: date || this.state.date,
143
+ getDataFn,
144
+ onLoadComplete: (stats) => this.setState({ stats }),
145
+ });
146
+ }
147
+
148
+ changeNumDays(e) {
149
+ const numDays = e.target.value;
150
+ this.setState({ numDays });
151
+ this.fetchData({ numDays });
152
+ this.fetchChartData({ numDays });
153
+ }
154
+
155
+ changeDate(e) {
156
+ const date = e.target.value;
157
+ this.setState({ date });
158
+ this.fetchData({ date });
159
+ this.fetchChartData({ date });
160
+ }
161
+
162
+ changeChartField(e) {
163
+ const chartField = e.target.value;
164
+ this.setState({ chartField });
165
+ this.fetchChartData({ chartField });
166
+ }
167
+
168
+ changeChartContext(e) {
169
+ const chartContext = e.target.value;
170
+ this.setState({ chartContext });
171
+ this.fetchChartData({ chartContext });
172
+ }
173
+
174
+ render(
175
+ _props,
176
+ {
177
+ chartField,
178
+ chartContext,
179
+ numDays,
180
+ date,
181
+ stats = null,
182
+ chartData = null,
183
+ uiSectionsLoaded,
184
+ }
185
+ ) {
186
+ return html`
187
+ <div id="analytics">
188
+ <div class="analytics__header">
189
+ <div class="header__logo">
190
+ <${Logo} />
191
+ </div>
192
+ <span>Hearald</span>
193
+ <input
194
+ type="text"
195
+ class="analytics__header__input"
196
+ onchange=${(e) => this.changeNumDays(e)}
197
+ value=${numDays}
198
+ />
199
+ <input
200
+ type="text"
201
+ class="analytics__header__input"
202
+ onchange=${(e) => this.changeDate(e)}
203
+ value=${date}
204
+ />
205
+ </div>
206
+ ${!uiSectionsLoaded || !stats || !stats.metrics
207
+ ? html` <div><section class="analytics">Loading...</section></div> `
208
+ : html`
209
+ <div>
210
+ <section class="analytics analytics--chart">
211
+ <form
212
+ class="analytics__menu"
213
+ onsubmit=${(e) => e.preventDefault()}
214
+ >
215
+ <label>
216
+ Context
217
+ <select
218
+ name="context"
219
+ onchange=${(e) => this.changeChartContext(e)}
220
+ >
221
+ ${UI.chartContexts.map(
222
+ ({ id, menuName }) =>
223
+ html`
224
+ <option
225
+ value="${id}"
226
+ selected=${chartContext === id}
227
+ >
228
+ ${menuName}
229
+ </option>
230
+ `
231
+ )}
232
+ </select>
233
+ </label>
234
+ <label>
235
+ Field
236
+ <select
237
+ name="field"
238
+ onchange=${(e) => this.changeChartField(e)}
239
+ >
240
+ ${stats?.metrics?.all
241
+ ? Object.keys(stats.metrics.all).map(
242
+ (f) =>
243
+ html`
244
+ <option
245
+ value="${f}"
246
+ selected=${chartField === f}
247
+ >
248
+ ${f}
249
+ </option>
250
+ `
251
+ )
252
+ : ""}
253
+ </select>
254
+ </label>
255
+ </form>
256
+ <div class="bar-chart">
257
+ ${!chartData &&
258
+ html` <span class="bar-chart__empty">no data</span> `}
259
+ ${chartData &&
260
+ chartData.loading &&
261
+ html`
262
+ <progress
263
+ class="bar-chart__empty"
264
+ max=${chartData.loading.max}
265
+ value=${chartData.loading.value}
266
+ ></progress>
267
+ `}
268
+ ${chartData &&
269
+ !chartData.loading &&
270
+ html`
271
+ <div class="bar-chart__scale">${chartData.max}</div>
272
+ ${chartData.data.map(
273
+ (v) =>
274
+ html`
275
+ <div
276
+ class="bar-chart__bar"
277
+ style="--bar-height:${v.value / chartData.max}"
278
+ >
279
+ <div class="bar-chart__bar__label">
280
+ ${v.date}
281
+ <br />
282
+ <b>${numFormatter(v.value)}</b>
283
+ </div>
284
+ </div>
285
+ `
286
+ )}
287
+ `}
288
+ </div>
289
+ </section>
290
+ ${UI.sections.map((statsSection) => {
291
+ return html`
292
+ <section class="analytics analytics--${statsSection.id}">
293
+ ${statsSection.menuName !== "" &&
294
+ html`
295
+ <h2 class="analytics__title">
296
+ ${statsSection.menuName}
297
+ </h2>
298
+ `}
299
+ ${statsSection.stats.map((f) => {
300
+ return html`
301
+ <${Tile}
302
+ title="${f.label}"
303
+ type="${f.type}"
304
+ value=${deepGet(stats.metrics, f.key)}
305
+ span=${f.span || null}
306
+ ${f.type === "percent" ? "percent" : ""}
307
+ ${f.type === "histogram" ? "histogram" : ""}
308
+ />
309
+ `;
310
+ })}
311
+ </section>
312
+ `;
313
+ })}
314
+ </div>
315
+ `}
316
+ </div>
317
+ `;
318
+ }
319
+ }
320
+
321
+ export default App;
@@ -0,0 +1,112 @@
1
+ import { html, Component } from "htm/preact";
2
+
3
+ const prettyNumericFormatter = (val) => {
4
+ const digits = Math.log10(val);
5
+ let truncDigits = 0;
6
+ let decimalPlaces = 0;
7
+ let suffix = "";
8
+ if (digits > 8) {
9
+ truncDigits = 9;
10
+ suffix = "b";
11
+ decimalPlaces = 2;
12
+ } else if (digits > 5) {
13
+ truncDigits = 6;
14
+ suffix = "m";
15
+ decimalPlaces = 2;
16
+ } else if (digits > 3) {
17
+ truncDigits = 3;
18
+ suffix = "k";
19
+ decimalPlaces = 2;
20
+ }
21
+
22
+ const numeric = Math.floor(val / 10 ** truncDigits);
23
+ if (decimalPlaces) {
24
+ const fractional =
25
+ (val % 10 ** decimalPlaces) % 10 ** (truncDigits - decimalPlaces);
26
+ return `${numeric}.${fractional}${suffix}`;
27
+ } else {
28
+ return `${numeric}${suffix}`;
29
+ }
30
+ };
31
+
32
+ const percentFormatter = (val) =>
33
+ `${(Math.round(val * 100) / 100).toFixed(2)}%`;
34
+
35
+ const plainFormatter = (val) => val;
36
+
37
+ const getFormattedValue = (value, percent = false, plain = false) => {
38
+ let formattedValue;
39
+ let formatter = prettyNumericFormatter;
40
+ if (percent) {
41
+ if (plain) {
42
+ console.error("Multiple formatters specified, using percent formatter");
43
+ }
44
+
45
+ formatter = percentFormatter;
46
+ } else if (plain) {
47
+ formatter = plainFormatter;
48
+ }
49
+
50
+ if (isNaN(value)) {
51
+ formattedValue = "(no data)";
52
+ } else {
53
+ formattedValue = formatter(value);
54
+ }
55
+
56
+ return formattedValue;
57
+ };
58
+
59
+ class Tile extends Component {
60
+ render({
61
+ title,
62
+ value,
63
+ type,
64
+ percent = false,
65
+ plain = false,
66
+ endRow = false,
67
+ span = null,
68
+ }) {
69
+ return html`
70
+ <div
71
+ class="analytics__tile ${span ? `analytics__tile--span-${span}` : ""}"
72
+ >
73
+ <div class="analytics__tile__header">${title}</div>
74
+ ${type === "histogram"
75
+ ? html`
76
+ <!-- div class="analytics__tile__value" -->
77
+ <ul class="analytics__tile__histogram">
78
+ ${value && value.length
79
+ ? value.map(
80
+ ([label, count]) => html`
81
+ <li class="analytics__tile__histogram__row">
82
+ <div class="analytics__tile__histogram__value">
83
+ ${count}
84
+ </div>
85
+ <div
86
+ class="analytics__tile__histogram__label"
87
+ style="--bar-width:${count / (value[0]?.[1] || 1)}"
88
+ >
89
+ ${label}
90
+ </div>
91
+ </li>
92
+ `
93
+ )
94
+ : html`
95
+ <li class="analytics__tile__histogram__row">No data</li>
96
+ `}
97
+ </ul>
98
+ <!-- /div -->
99
+ `
100
+ : html`
101
+ <div class="analytics__tile__value" data-value=${value}>
102
+ ${getFormattedValue(value, percent, plain)}
103
+ </div>
104
+ `}
105
+ </div>
106
+ ${endRow &&
107
+ html` <div class="analytics__tile analytics__tile--end-row" /> `}
108
+ `;
109
+ }
110
+ }
111
+
112
+ export default Tile;
@@ -0,0 +1,9 @@
1
+ import "../css/analytics.css";
2
+
3
+ console.log("index.js loaded");
4
+
5
+ import { render, html } from "htm/preact";
6
+
7
+ import App from "./App";
8
+
9
+ render(html` <${App} /> `, document.getElementById("analytics-root"));
@@ -0,0 +1,111 @@
1
+ import { iso } from "../../shared/utils.js";
2
+
3
+ /* START methods for fetching data from the server */
4
+
5
+ export const dataCache = {};
6
+
7
+ // Fetches data from the server with caching
8
+ export async function getData({ n, d, q, endpointBuilder, fetchStats }) {
9
+ const key = [n, d, q].join("_");
10
+ if (dataCache[key]) {
11
+ return dataCache[key];
12
+ }
13
+
14
+ const endpoint = endpointBuilder
15
+ ? endpointBuilder(n, d, q)
16
+ : `/data/${n}/${d}${q ? "?" + q : ""}`;
17
+ console.log("endpoint", endpoint);
18
+
19
+ const data = await fetchStats(endpoint);
20
+ dataCache[key] = data;
21
+ return data;
22
+ }
23
+
24
+ // Fetches and builds chart data for a given field/context over a time window
25
+ export async function fetchChartData({
26
+ numDays,
27
+ date,
28
+ chartContext,
29
+ chartField,
30
+ getDataFn,
31
+
32
+ onLoadProgress, // (i, n) => void
33
+ onLoadComplete, // (data, max) => void
34
+ }) {
35
+ const n = numDays;
36
+ const d = date;
37
+ const field = chartField;
38
+ const context = chartContext;
39
+
40
+ const [yyyy, mm, dd] = d.split("-");
41
+ const startDate = new Date(yyyy, mm - 1, dd);
42
+
43
+ let data = [];
44
+ let max = 1;
45
+
46
+ for (let i = 0; i < n; i++) {
47
+ let day = new Date(startDate);
48
+ day.setDate(startDate.getDate() - i);
49
+ onLoadProgress(i, n);
50
+
51
+ const json = await getDataFn({ n: 1, d: iso(day) });
52
+ const v = json.metrics[context][field] || 0;
53
+
54
+ data.unshift({ date: iso(day), value: v });
55
+ max = Math.max(max, v);
56
+ }
57
+
58
+ onLoadComplete(data, max);
59
+ }
60
+
61
+ // Fetches and sets the main stats data for a given date/range
62
+ export async function fetchData({
63
+ numDays,
64
+ date,
65
+ getDataFn,
66
+ onLoadComplete, // (stats) => void
67
+ }) {
68
+ const n = numDays;
69
+ const d = date;
70
+ const stats = await getDataFn({ n, d });
71
+ onLoadComplete(stats);
72
+ }
73
+
74
+ /* END methods for fetching data from the server */
75
+
76
+ /* START helper methods */
77
+
78
+ export const getJson = async (url) => {
79
+ const response = await fetch(url, {
80
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
81
+ });
82
+ return response.json();
83
+ };
84
+
85
+ export const postJson = async (url, body) => {
86
+ const response = await fetch(url, {
87
+ method: "post",
88
+ body: JSON.stringify(body),
89
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
90
+ });
91
+ return response.json();
92
+ };
93
+
94
+ export const deepGet = (obj, path) => {
95
+ const parts = path.split(".");
96
+ let current = obj;
97
+ for (let i = 0; i < parts.length; i += 1) {
98
+ if (current == null) return undefined;
99
+ current = current[parts[i]];
100
+ }
101
+ return current;
102
+ };
103
+
104
+ export function numFormatter(n) {
105
+ if (typeof Intl !== "undefined" && Intl.NumberFormat) {
106
+ return new Intl.NumberFormat().format(n);
107
+ }
108
+ return n;
109
+ }
110
+
111
+ /* END helper methods */
@@ -0,0 +1,59 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import resolve from "@rollup/plugin-node-resolve";
4
+ import postcss from "rollup-plugin-postcss";
5
+
6
+ // Custom plugin to inline JS and CSS into HTML
7
+ function inlineAssetsPlugin({ htmlFile, outputFile }) {
8
+ return {
9
+ name: "inline-assets",
10
+ async writeBundle(options, bundle) {
11
+ const htmlPath = path.resolve(htmlFile);
12
+ let html = fs.readFileSync(htmlPath, "utf8");
13
+
14
+ // Find JS and CSS output from bundle
15
+ const jsBundle = Object.values(bundle).find(
16
+ (file) => file.type === "chunk" && file.isEntry
17
+ );
18
+ const cssBundle = Object.values(bundle).find(
19
+ (file) => file.type === "asset" && file.fileName.endsWith(".css")
20
+ );
21
+
22
+ const js = jsBundle ? jsBundle.code : "";
23
+ const css = cssBundle ? cssBundle.source : "";
24
+
25
+ // Replace <script src=...> and <link rel="stylesheet" ...> with inline tags
26
+ html = html.replace(
27
+ /<script\s+src="[^"]*"[^>]*><\/script>/,
28
+ `<script>${js}</script>`
29
+ );
30
+ // Replace any <link rel="stylesheet" ...> (with any attributes)
31
+ html = html.replace(
32
+ /<link\s+rel=["']stylesheet["'][^>]*>/gi,
33
+ `<style>${css}</style>`
34
+ );
35
+
36
+ // Write the inlined HTML
37
+ fs.writeFileSync(path.resolve(options.dir, outputFile), html);
38
+ },
39
+ };
40
+ }
41
+
42
+ export default {
43
+ input: "public/js/index.js", // Use JS entry point
44
+ output: {
45
+ dir: "dist",
46
+ format: "iife",
47
+ },
48
+ plugins: [
49
+ resolve(),
50
+ postcss({
51
+ inject: false,
52
+ extract: true,
53
+ }),
54
+ inlineAssetsPlugin({
55
+ htmlFile: "public/analytics.html",
56
+ outputFile: "analytics.html",
57
+ }),
58
+ ],
59
+ };