@rebasepro/plugin-insights 0.23.0 → 0.24.0
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/README.md +76 -49
- package/dist/components/CollectionInsightsInline.d.ts +1 -5
- package/dist/components/HomeCardInsightSlot.d.ts +2 -7
- package/dist/components/HomeInsightsSlot.d.ts +0 -2
- package/dist/components/InsightWidget.d.ts +2 -15
- package/dist/components/InsightsRow.d.ts +13 -0
- package/dist/components/InsightsScorecardView.d.ts +14 -13
- package/dist/engine/InsightsEngine.d.ts +48 -0
- package/dist/engine/InsightsProvider.d.ts +8 -10
- package/dist/engine/useInsightSource.d.ts +12 -0
- package/dist/format.d.ts +21 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.es.js +398 -435
- package/dist/index.es.js.map +1 -1
- package/dist/types/engine.d.ts +81 -53
- package/dist/types/widgets.d.ts +6 -37
- package/dist/useInsightsPlugin.d.ts +28 -17
- package/dist/validateConfig.d.ts +7 -0
- package/package.json +5 -5
- package/dist/components/InsightWidgetSkeleton.d.ts +0 -29
- package/dist/engine/InsightsCache.d.ts +0 -27
- package/dist/engine/useInsightsData.d.ts +0 -17
package/dist/index.es.js
CHANGED
|
@@ -1,66 +1,111 @@
|
|
|
1
|
-
import React, { createContext, useContext, useEffect, useMemo,
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { getIcon, useAuthController } from "@rebasepro/app";
|
|
4
|
-
import { cls, defaultBorderMixin } from "@rebasepro/ui";
|
|
5
|
-
|
|
3
|
+
import { getIcon, useAuthController, useTranslation } from "@rebasepro/app";
|
|
4
|
+
import { Tooltip, Typography, cls, defaultBorderMixin } from "@rebasepro/ui";
|
|
5
|
+
var DAY_MS = 864e5;
|
|
6
|
+
/** The current and previous windows of `days` days, ending at `to`. */
|
|
7
|
+
function resolvePeriod(days, to) {
|
|
8
|
+
const from = /* @__PURE__ */ new Date(to.getTime() - days * DAY_MS);
|
|
9
|
+
return {
|
|
10
|
+
days,
|
|
11
|
+
from,
|
|
12
|
+
to,
|
|
13
|
+
previousFrom: /* @__PURE__ */ new Date(from.getTime() - days * DAY_MS)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
6
16
|
/**
|
|
7
|
-
* The cache key for one
|
|
17
|
+
* The cache key for one source, for one user.
|
|
8
18
|
*
|
|
9
|
-
* The
|
|
19
|
+
* The engine lives at the root of the app and outlives a sign-out, and a figure
|
|
10
20
|
* is computed under the permissions and row-level security of whoever asked
|
|
11
|
-
* for it. A key without the user
|
|
12
|
-
* next one that
|
|
21
|
+
* for it. A key without the user would serve the previous account's numbers to
|
|
22
|
+
* the next one that signs in on the same tab, until the TTL ran out.
|
|
13
23
|
*/
|
|
14
|
-
function
|
|
15
|
-
return JSON.stringify([
|
|
16
|
-
userId,
|
|
17
|
-
definitionId,
|
|
18
|
-
context.path ?? context.collectionSlug ?? "global"
|
|
19
|
-
]);
|
|
24
|
+
function sourceCacheKey(sourceId, userId) {
|
|
25
|
+
return JSON.stringify([userId, sourceId]);
|
|
20
26
|
}
|
|
21
27
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
28
|
+
* Fetches and caches the plugin's sources.
|
|
29
|
+
*
|
|
30
|
+
* Every insight that reads a source shares one fetch of it: concurrent reads
|
|
31
|
+
* join the request in flight, later ones read the cache until the TTL passes.
|
|
32
|
+
* That is what keeps a figure shown on the home page and in a collection view
|
|
33
|
+
* the same number.
|
|
26
34
|
*/
|
|
27
|
-
var
|
|
35
|
+
var InsightsEngine = class {
|
|
36
|
+
sources;
|
|
37
|
+
periodDays;
|
|
28
38
|
ttl;
|
|
39
|
+
now;
|
|
29
40
|
cache = /* @__PURE__ */ new Map();
|
|
30
41
|
inflight = /* @__PURE__ */ new Map();
|
|
31
|
-
|
|
42
|
+
anchor = null;
|
|
43
|
+
constructor(sources, periodDays = 30, ttl = 6e4, now = Date.now) {
|
|
44
|
+
this.sources = sources;
|
|
45
|
+
this.periodDays = periodDays;
|
|
32
46
|
this.ttl = ttl;
|
|
47
|
+
this.now = now;
|
|
33
48
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
timestamp: Date.now()
|
|
47
|
-
});
|
|
48
|
-
this.inflight.delete(key);
|
|
49
|
+
/**
|
|
50
|
+
* The period a fetch starting now is asked for. Its end is fixed by the
|
|
51
|
+
* first fetch and kept until the TTL passes, so sources fetched for the
|
|
52
|
+
* same screen describe the same window.
|
|
53
|
+
*/
|
|
54
|
+
period() {
|
|
55
|
+
const now = this.now();
|
|
56
|
+
if (!this.anchor || now - this.anchor.at > this.ttl) this.anchor = {
|
|
57
|
+
period: resolvePeriod(this.periodDays, new Date(now)),
|
|
58
|
+
at: now
|
|
59
|
+
};
|
|
60
|
+
return this.anchor.period;
|
|
49
61
|
}
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
/** The cached result for this source and user, if it is still fresh. */
|
|
63
|
+
peek(sourceId, userId) {
|
|
64
|
+
const entry = this.cache.get(sourceCacheKey(sourceId, userId));
|
|
65
|
+
if (!entry || this.now() - entry.at > this.ttl) return null;
|
|
66
|
+
return entry.result;
|
|
52
67
|
}
|
|
53
|
-
|
|
68
|
+
/** The source's record for this user: from the cache, the fetch in flight, or a new fetch. */
|
|
69
|
+
load(sourceId, userId) {
|
|
70
|
+
const cached = this.peek(sourceId, userId);
|
|
71
|
+
if (cached) return Promise.resolve(cached);
|
|
72
|
+
const key = sourceCacheKey(sourceId, userId);
|
|
73
|
+
const pending = this.inflight.get(key);
|
|
74
|
+
if (pending) return pending;
|
|
75
|
+
const source = this.sources[sourceId];
|
|
76
|
+
if (!source) return Promise.reject(/* @__PURE__ */ new Error(`No insights source is named "${sourceId}".`));
|
|
77
|
+
const period = this.period();
|
|
78
|
+
let fetched;
|
|
79
|
+
try {
|
|
80
|
+
fetched = Promise.resolve(source({ period }));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
fetched = Promise.reject(error);
|
|
83
|
+
}
|
|
84
|
+
const promise = fetched.then((row) => {
|
|
85
|
+
const result = {
|
|
86
|
+
row,
|
|
87
|
+
period
|
|
88
|
+
};
|
|
89
|
+
if (this.inflight.get(key) === promise) {
|
|
90
|
+
this.inflight.delete(key);
|
|
91
|
+
this.cache.set(key, {
|
|
92
|
+
result,
|
|
93
|
+
at: this.now()
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}, (error) => {
|
|
98
|
+
if (this.inflight.get(key) === promise) this.inflight.delete(key);
|
|
99
|
+
throw error;
|
|
100
|
+
});
|
|
54
101
|
this.inflight.set(key, promise);
|
|
102
|
+
return promise;
|
|
55
103
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
this.cache.clear();
|
|
62
|
-
this.inflight.clear();
|
|
63
|
-
}
|
|
104
|
+
/** Drops every cached result and fetch in flight, and re-anchors the period on the next fetch. */
|
|
105
|
+
invalidate() {
|
|
106
|
+
this.cache.clear();
|
|
107
|
+
this.inflight.clear();
|
|
108
|
+
this.anchor = null;
|
|
64
109
|
}
|
|
65
110
|
};
|
|
66
111
|
//#endregion
|
|
@@ -70,19 +115,21 @@ var InsightsContext = createContext(null);
|
|
|
70
115
|
* Root-level provider for the insights data engine.
|
|
71
116
|
* Injected automatically by the plugin via `providers: [{ scope: "root" }]`.
|
|
72
117
|
*
|
|
73
|
-
*
|
|
74
|
-
* for TTL-based caching and inflight request deduplication.
|
|
118
|
+
* Holds the one `InsightsEngine` every insight widget reads its source from.
|
|
75
119
|
*/
|
|
76
|
-
function InsightsProvider({ cacheTTL, children }) {
|
|
77
|
-
const
|
|
78
|
-
|
|
120
|
+
function InsightsProvider({ sources, periodDays, cacheTTL, children }) {
|
|
121
|
+
const engine = useMemo(() => new InsightsEngine(sources, periodDays, cacheTTL), [
|
|
122
|
+
sources,
|
|
123
|
+
periodDays,
|
|
124
|
+
cacheTTL
|
|
125
|
+
]);
|
|
79
126
|
return /* @__PURE__ */ jsx(InsightsContext.Provider, {
|
|
80
|
-
value,
|
|
127
|
+
value: engine,
|
|
81
128
|
children
|
|
82
129
|
});
|
|
83
130
|
}
|
|
84
131
|
/**
|
|
85
|
-
*
|
|
132
|
+
* The insights engine (for advanced usage).
|
|
86
133
|
* Returns null when called outside of an `InsightsProvider`
|
|
87
134
|
* (e.g. during auth-loading phase before plugin providers mount).
|
|
88
135
|
*/
|
|
@@ -90,82 +137,84 @@ function useInsightsEngine() {
|
|
|
90
137
|
return useContext(InsightsContext);
|
|
91
138
|
}
|
|
92
139
|
//#endregion
|
|
93
|
-
//#region src/
|
|
140
|
+
//#region src/validateConfig.ts
|
|
94
141
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
142
|
+
* Refuses a config whose insights read a source it does not declare, or whose
|
|
143
|
+
* period is not a whole number of days. Both would otherwise surface as an
|
|
144
|
+
* error tile per insight, on whichever screen a user opened first.
|
|
145
|
+
*/
|
|
146
|
+
function assertValidConfig(config) {
|
|
147
|
+
const days = config.period?.days ?? 30;
|
|
148
|
+
if (!Number.isInteger(days) || days < 1) throw new Error(`Insights period.days must be a whole number of days, at least 1; got ${days}.`);
|
|
149
|
+
const declared = Object.keys(config.sources);
|
|
150
|
+
const definitions = [...config.insights.home ?? [], ...Object.values(config.insights.collections ?? {}).flat()];
|
|
151
|
+
for (const definition of definitions) if (!Object.hasOwn(config.sources, definition.source)) throw new Error(`Insight "${definition.id}" reads source "${definition.source}", which is not in \`sources\` (declared: ${declared.length > 0 ? declared.join(", ") : "none"}).`);
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/engine/useInsightSource.ts
|
|
155
|
+
/**
|
|
156
|
+
* Reads one source for the signed-in user.
|
|
101
157
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
158
|
+
* Waits for auth to settle: a source runs under the caller's permissions, so
|
|
159
|
+
* fetching before the user is known would compute the figures for nobody.
|
|
104
160
|
*/
|
|
105
|
-
function
|
|
106
|
-
const
|
|
161
|
+
function useInsightSource(sourceId) {
|
|
162
|
+
const engine = useInsightsEngine();
|
|
107
163
|
const { initialLoading, authLoading, user, loginSkipped } = useAuthController();
|
|
108
164
|
const authReady = !initialLoading && !authLoading && (Boolean(user) || loginSkipped);
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const [
|
|
112
|
-
const cacheKey = insightCacheKey(definition.id, context, user?.uid ?? null);
|
|
165
|
+
const userId = user?.uid ?? null;
|
|
166
|
+
const key = sourceCacheKey(sourceId, userId);
|
|
167
|
+
const [settled, setSettled] = useState(null);
|
|
113
168
|
useEffect(() => {
|
|
114
|
-
if (!authReady || !
|
|
169
|
+
if (!authReady || !engine) return;
|
|
115
170
|
let cancelled = false;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}).catch((err) => {
|
|
128
|
-
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
|
|
129
|
-
}).finally(() => {
|
|
130
|
-
if (!cancelled) setLoading(false);
|
|
171
|
+
engine.load(sourceId, userId).then((result) => {
|
|
172
|
+
if (!cancelled) setSettled({
|
|
173
|
+
key,
|
|
174
|
+
result,
|
|
175
|
+
error: null
|
|
176
|
+
});
|
|
177
|
+
}, (error) => {
|
|
178
|
+
if (!cancelled) setSettled({
|
|
179
|
+
key,
|
|
180
|
+
result: null,
|
|
181
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
131
182
|
});
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
setLoading(true);
|
|
135
|
-
setError(null);
|
|
136
|
-
const promise = definition.data(context);
|
|
137
|
-
cache.setInflight(cacheKey, promise);
|
|
138
|
-
promise.then((result) => {
|
|
139
|
-
cache.set(cacheKey, result);
|
|
140
|
-
if (!cancelled) setData(result);
|
|
141
|
-
}).catch((err) => {
|
|
142
|
-
cache.invalidate(cacheKey);
|
|
143
|
-
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
|
|
144
|
-
}).finally(() => {
|
|
145
|
-
if (!cancelled) setLoading(false);
|
|
146
183
|
});
|
|
147
184
|
return () => {
|
|
148
185
|
cancelled = true;
|
|
149
186
|
};
|
|
150
187
|
}, [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
cacheKey,
|
|
156
|
-
cache,
|
|
188
|
+
engine,
|
|
189
|
+
sourceId,
|
|
190
|
+
userId,
|
|
191
|
+
key,
|
|
157
192
|
authReady
|
|
158
193
|
]);
|
|
194
|
+
const current = settled?.key === key ? settled : null;
|
|
195
|
+
const cached = !current && authReady && engine ? engine.peek(sourceId, userId) : null;
|
|
159
196
|
return {
|
|
160
|
-
|
|
161
|
-
loading,
|
|
162
|
-
error
|
|
197
|
+
result: current?.result ?? cached,
|
|
198
|
+
loading: !current && !cached,
|
|
199
|
+
error: current?.error ?? null
|
|
163
200
|
};
|
|
164
201
|
}
|
|
165
202
|
//#endregion
|
|
166
|
-
//#region src/
|
|
167
|
-
|
|
168
|
-
|
|
203
|
+
//#region src/format.ts
|
|
204
|
+
/**
|
|
205
|
+
* A number formatter in the given language, falling back to the browser's
|
|
206
|
+
* when the language is not a tag `Intl` accepts (i18next's `cimode`, a custom
|
|
207
|
+
* bundle name).
|
|
208
|
+
*/
|
|
209
|
+
function numberFormat(locale, options) {
|
|
210
|
+
try {
|
|
211
|
+
return new Intl.NumberFormat(locale, options);
|
|
212
|
+
} catch {
|
|
213
|
+
return new Intl.NumberFormat(void 0, options);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Writes a value the way its insight asks, in the admin panel's language. */
|
|
217
|
+
function formatValue(value, format, locale) {
|
|
169
218
|
const options = {
|
|
170
219
|
style: format?.style ?? "decimal",
|
|
171
220
|
notation: format?.notation ?? "standard"
|
|
@@ -175,337 +224,239 @@ function formatNumber(value, format) {
|
|
|
175
224
|
options.minimumFractionDigits = format.decimals;
|
|
176
225
|
}
|
|
177
226
|
if (format?.style === "currency") options.currency = format.currency ?? "USD";
|
|
178
|
-
|
|
179
|
-
if (format?.showSign && value > 0) formatted = "+" + formatted;
|
|
180
|
-
return formatted;
|
|
227
|
+
return numberFormat(locale, options).format(value);
|
|
181
228
|
}
|
|
182
229
|
/**
|
|
183
|
-
*
|
|
230
|
+
* The change from `previous` to `current`, written as the comparison asks.
|
|
184
231
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
232
|
+
* A percentage needs a previous figure to be a percentage of, so a change
|
|
233
|
+
* from zero is written as the difference instead. Under 10% keeps one
|
|
234
|
+
* decimal (`0.2%`), above it none (`42%`): the decimal stops carrying
|
|
235
|
+
* information once the change is that large.
|
|
188
236
|
*/
|
|
189
|
-
function
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
let comparisonElement = null;
|
|
204
|
-
if (config.comparison) {
|
|
205
|
-
const comparisonValue = data[config.comparison.field];
|
|
206
|
-
if (typeof comparisonValue === "number") {
|
|
207
|
-
const formattedComparison = formatNumber(comparisonValue, config.comparison.format);
|
|
208
|
-
const isPositive = comparisonValue > 0;
|
|
209
|
-
const isNegative = comparisonValue < 0;
|
|
210
|
-
let colorClass = "text-surface-500 dark:text-surface-400";
|
|
211
|
-
if (config.comparison.intent === "increase_is_good") {
|
|
212
|
-
if (isPositive) colorClass = "text-emerald-500";
|
|
213
|
-
if (isNegative) colorClass = "text-red-500";
|
|
214
|
-
} else if (config.comparison.intent === "decrease_is_good") {
|
|
215
|
-
if (isPositive) colorClass = "text-red-500";
|
|
216
|
-
if (isNegative) colorClass = "text-emerald-500";
|
|
217
|
-
}
|
|
218
|
-
comparisonElement = /* @__PURE__ */ jsx("span", {
|
|
219
|
-
className: `font-mono tabular-nums font-medium ${compact ? "text-[10px]" : "text-xs"} ${colorClass}`,
|
|
220
|
-
children: formattedComparison
|
|
221
|
-
});
|
|
222
|
-
}
|
|
237
|
+
function formatChange(current, previous, comparison, format, locale) {
|
|
238
|
+
const delta = current - previous;
|
|
239
|
+
const direction = delta > 0 ? "up" : delta < 0 ? "down" : "flat";
|
|
240
|
+
if (comparison.show !== "absolute" && previous !== 0) {
|
|
241
|
+
const ratio = Math.abs(delta / previous);
|
|
242
|
+
const decimals = ratio < .1 ? 1 : 0;
|
|
243
|
+
return {
|
|
244
|
+
direction,
|
|
245
|
+
magnitude: numberFormat(locale, {
|
|
246
|
+
style: "percent",
|
|
247
|
+
minimumFractionDigits: decimals,
|
|
248
|
+
maximumFractionDigits: decimals
|
|
249
|
+
}).format(ratio)
|
|
250
|
+
};
|
|
223
251
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
children: [/* @__PURE__ */ jsx("span", {
|
|
234
|
-
className: "text-sm font-semibold tabular-nums text-surface-800 dark:text-surface-100",
|
|
235
|
-
children: formattedValue
|
|
236
|
-
}), comparisonElement]
|
|
237
|
-
})]
|
|
238
|
-
});
|
|
239
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
240
|
-
ref: containerRef,
|
|
241
|
-
className: embedded ? `flex flex-col min-w-0 h-full ${isSmall ? "px-3.5 py-3" : "px-5 py-4"}` : cls("rounded-xl flex flex-col min-w-0 bg-surface-card border", defaultBorderMixin, isSmall ? "px-3.5 py-3" : "px-5 py-4"),
|
|
242
|
-
style: embedded ? void 0 : fixedHeight ? { height: fixedHeight } : { minHeight: isSmall ? 68 : 92 },
|
|
243
|
-
children: [
|
|
244
|
-
/* @__PURE__ */ jsxs("div", {
|
|
245
|
-
className: `flex flex-col min-w-0 ${isSmall ? "mb-1" : "mb-2.5"}`,
|
|
246
|
-
children: [/* @__PURE__ */ jsxs("div", {
|
|
247
|
-
className: "flex items-center gap-1.5 min-w-0",
|
|
248
|
-
children: [iconElement && /* @__PURE__ */ jsx("span", {
|
|
249
|
-
className: "shrink-0 flex items-center text-text-secondary dark:text-text-secondary-dark [&>svg]:size-3.5",
|
|
250
|
-
children: iconElement
|
|
251
|
-
}), /* @__PURE__ */ jsx("span", {
|
|
252
|
-
className: "typography-micro truncate text-surface-400 dark:text-surface-400",
|
|
253
|
-
children: title
|
|
254
|
-
})]
|
|
255
|
-
}), config.dateRange && !isSmall && /* @__PURE__ */ jsx("span", {
|
|
256
|
-
className: "font-mono tabular-nums text-[10px] text-surface-400 dark:text-surface-500 truncate mt-1",
|
|
257
|
-
children: config.dateRange
|
|
258
|
-
})]
|
|
259
|
-
}),
|
|
260
|
-
/* @__PURE__ */ jsx("div", {
|
|
261
|
-
className: `font-headers font-semibold leading-tight tracking-display tabular-nums break-all text-text-primary dark:text-text-primary-dark ${isSmall ? "text-lg" : containerWidth !== null && containerWidth < 300 ? "text-xl" : "text-2xl"}`,
|
|
262
|
-
children: formattedValue
|
|
263
|
-
}),
|
|
264
|
-
comparisonElement && /* @__PURE__ */ jsx("div", {
|
|
265
|
-
className: isSmall ? "mt-0.5" : "mt-1",
|
|
266
|
-
children: comparisonElement
|
|
267
|
-
})
|
|
268
|
-
]
|
|
269
|
-
});
|
|
252
|
+
return {
|
|
253
|
+
direction,
|
|
254
|
+
magnitude: formatValue(Math.abs(delta), format, locale)
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** Whether a change in this direction is good news for this insight. */
|
|
258
|
+
function changeTone(direction, intent) {
|
|
259
|
+
if (direction === "flat") return "neutral";
|
|
260
|
+
return direction === (intent === "increase_is_good" ? "up" : "down") ? "positive" : "negative";
|
|
270
261
|
}
|
|
271
|
-
InsightsScorecardView.displayName = "InsightsScorecardView";
|
|
272
262
|
//#endregion
|
|
273
|
-
//#region src/components/
|
|
263
|
+
//#region src/components/InsightsScorecardView.tsx
|
|
264
|
+
var toneClasses = {
|
|
265
|
+
positive: "text-emerald-700 dark:text-emerald-400",
|
|
266
|
+
negative: "text-red-600 dark:text-red-500",
|
|
267
|
+
neutral: "text-text-secondary dark:text-text-secondary-dark"
|
|
268
|
+
};
|
|
269
|
+
/** A pulsing bar standing in for a figure that has not arrived, sized by its line box. */
|
|
270
|
+
function Placeholder({ className }) {
|
|
271
|
+
return /* @__PURE__ */ jsx("span", { className: cls("inline-block align-middle rounded-sm bg-surface-200 dark:bg-surface-700 animate-pulse", className) });
|
|
272
|
+
}
|
|
273
|
+
function displayValue(row, definition, locale) {
|
|
274
|
+
const value = row?.[definition.value.field];
|
|
275
|
+
if (typeof value === "number") return formatValue(value, definition.value.format, locale);
|
|
276
|
+
if (typeof value === "string" && value !== "") return value;
|
|
277
|
+
return "—";
|
|
278
|
+
}
|
|
274
279
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
* The skeleton receives the scorecard config so it can conditionally
|
|
280
|
-
* render placeholder lines for comparison, dateRange, and icon —
|
|
281
|
-
* only when the loaded view will also render them.
|
|
282
|
-
*
|
|
283
|
-
* The standard skeleton mirrors InsightsScorecardView's responsive
|
|
284
|
-
* container-width breakpoints (ResizeObserver → isSmall / isMedium)
|
|
285
|
-
* and uses placeholder heights that exactly match the **computed**
|
|
286
|
-
* Tailwind line-heights (accounting for `leading-*` overrides).
|
|
287
|
-
* This guarantees a pixel-perfect skeleton → loaded transition.
|
|
280
|
+
* The change against the previous period: an arrow and its size, coloured by
|
|
281
|
+
* whether it is good news. The arrow is what carries the direction, so it
|
|
282
|
+
* reads without the colour.
|
|
288
283
|
*/
|
|
289
|
-
function
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
className: "flex items-baseline gap-1.5",
|
|
303
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
304
|
-
className: "bg-surface-200 dark:bg-surface-700 rounded-sm",
|
|
305
|
-
style: {
|
|
306
|
-
height: 20,
|
|
307
|
-
width: 40
|
|
308
|
-
}
|
|
309
|
-
}), hasComparison && /* @__PURE__ */ jsx("div", {
|
|
310
|
-
className: "bg-surface-200/60 dark:bg-surface-700/60 rounded-sm",
|
|
311
|
-
style: {
|
|
312
|
-
height: 14,
|
|
313
|
-
width: 28
|
|
314
|
-
}
|
|
315
|
-
})]
|
|
316
|
-
})]
|
|
284
|
+
function InsightChange({ definition, row, period, locale, tooltip }) {
|
|
285
|
+
const { t } = useTranslation();
|
|
286
|
+
const comparison = definition.comparison;
|
|
287
|
+
if (!comparison) return null;
|
|
288
|
+
const current = row[definition.value.field];
|
|
289
|
+
const previous = row[comparison.previous];
|
|
290
|
+
if (typeof current !== "number" || typeof previous !== "number") return null;
|
|
291
|
+
const change = formatChange(current, previous, comparison, definition.value.format, locale);
|
|
292
|
+
const arrow = change.direction === "up" ? "↑" : change.direction === "down" ? "↓" : null;
|
|
293
|
+
const spoken = change.direction === "up" ? t("insights_change_up", { change: change.magnitude }) : change.direction === "down" ? t("insights_change_down", { change: change.magnitude }) : t("insights_change_none");
|
|
294
|
+
const previousLabel = t("insights_previous_period_value", {
|
|
295
|
+
count: period.days,
|
|
296
|
+
value: formatValue(previous, definition.value.format, locale)
|
|
317
297
|
});
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
298
|
+
const label = /* @__PURE__ */ jsxs("span", {
|
|
299
|
+
className: cls("typography-mono text-xs font-medium whitespace-nowrap", toneClasses[changeTone(change.direction, comparison.intent)]),
|
|
300
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
301
|
+
"aria-hidden": "true",
|
|
302
|
+
children: arrow ? `${arrow} ${change.magnitude}` : change.magnitude
|
|
303
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
304
|
+
className: "sr-only",
|
|
305
|
+
children: `${spoken}. ${previousLabel}`
|
|
306
|
+
})]
|
|
324
307
|
});
|
|
308
|
+
return tooltip ? /* @__PURE__ */ jsx(Tooltip, {
|
|
309
|
+
title: previousLabel,
|
|
310
|
+
children: label
|
|
311
|
+
}) : label;
|
|
325
312
|
}
|
|
326
313
|
/**
|
|
327
|
-
*
|
|
314
|
+
* One insight: its label, its value and its change on the previous period.
|
|
328
315
|
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
* 3. Matching all container classes, margins, paddings, and flex
|
|
335
|
-
* layout properties identically.
|
|
316
|
+
* The label and icon are known before the figures are, so they render while
|
|
317
|
+
* the source loads and only the figures pulse. Loading, loaded and failed
|
|
318
|
+
* share one shell, so nothing moves when the data arrives.
|
|
319
|
+
*
|
|
320
|
+
* `compact` is the inline readout on a home-page card; the default is a tile.
|
|
336
321
|
*/
|
|
337
|
-
function
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
});
|
|
346
|
-
observer.observe(containerRef.current);
|
|
347
|
-
return () => observer.disconnect();
|
|
348
|
-
}, []);
|
|
349
|
-
const isSmall = containerWidth !== null && containerWidth < 200;
|
|
350
|
-
const titleHeight = isSmall ? 15 : 16.5;
|
|
351
|
-
const valueHeight = isSmall ? 22.5 : containerWidth !== null && containerWidth < 300 ? 25 : 30;
|
|
352
|
-
const comparisonHeight = 16;
|
|
353
|
-
const iconSize = isSmall ? 14 : 18;
|
|
354
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
355
|
-
ref: containerRef,
|
|
356
|
-
className: cls("animate-pulse", embedded ? `flex flex-col min-w-0 h-full ${isSmall ? "px-3.5 py-3" : "px-5 py-4"}` : cls("rounded-lg flex flex-col min-w-0 bg-transparent border", defaultBorderMixin, isSmall ? "px-3.5 py-3" : "px-5 py-4")),
|
|
357
|
-
style: embedded ? void 0 : fixedHeight ? { height: fixedHeight } : { minHeight: isSmall ? 68 : 92 },
|
|
322
|
+
function InsightsScorecardView({ definition, result, loading = false, error = null, compact = false }) {
|
|
323
|
+
const { i18n } = useTranslation();
|
|
324
|
+
const locale = i18n.language;
|
|
325
|
+
const row = result?.row;
|
|
326
|
+
const value = displayValue(row, definition, locale);
|
|
327
|
+
if (compact) return /* @__PURE__ */ jsxs("div", {
|
|
328
|
+
className: "flex items-baseline gap-1.5 min-w-0",
|
|
329
|
+
title: error?.message,
|
|
358
330
|
children: [
|
|
359
|
-
/* @__PURE__ */
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
className: "bg-surface-200 dark:bg-surface-700 rounded",
|
|
365
|
-
style: {
|
|
366
|
-
height: titleHeight,
|
|
367
|
-
width: "60%"
|
|
368
|
-
}
|
|
369
|
-
}), hasDateRange && !isSmall && /* @__PURE__ */ jsx("div", {
|
|
370
|
-
className: "bg-surface-200/60 dark:bg-surface-700/60 rounded mt-0.5",
|
|
371
|
-
style: {
|
|
372
|
-
height: 14,
|
|
373
|
-
width: "40%"
|
|
374
|
-
}
|
|
375
|
-
})]
|
|
376
|
-
}), hasIcon && /* @__PURE__ */ jsx("span", {
|
|
377
|
-
className: "ml-2 shrink-0",
|
|
378
|
-
children: /* @__PURE__ */ jsx("div", {
|
|
379
|
-
className: "bg-surface-200 dark:bg-surface-700 rounded",
|
|
380
|
-
style: {
|
|
381
|
-
height: iconSize,
|
|
382
|
-
width: iconSize
|
|
383
|
-
}
|
|
384
|
-
})
|
|
385
|
-
})]
|
|
331
|
+
/* @__PURE__ */ jsx(Typography, {
|
|
332
|
+
variant: "micro",
|
|
333
|
+
color: "secondary",
|
|
334
|
+
className: "truncate",
|
|
335
|
+
children: definition.title
|
|
386
336
|
}),
|
|
387
|
-
/* @__PURE__ */ jsx("
|
|
388
|
-
className: "
|
|
389
|
-
|
|
390
|
-
height: valueHeight,
|
|
391
|
-
width: "40%"
|
|
392
|
-
}
|
|
337
|
+
loading ? /* @__PURE__ */ jsx(Placeholder, { className: "h-3 w-8" }) : /* @__PURE__ */ jsx("span", {
|
|
338
|
+
className: "text-sm font-semibold tabular-nums text-text-primary dark:text-text-primary-dark",
|
|
339
|
+
children: value
|
|
393
340
|
}),
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
width: "25%"
|
|
401
|
-
}
|
|
402
|
-
})
|
|
341
|
+
!loading && row && result && /* @__PURE__ */ jsx(InsightChange, {
|
|
342
|
+
definition,
|
|
343
|
+
row,
|
|
344
|
+
period: result.period,
|
|
345
|
+
locale,
|
|
346
|
+
tooltip: false
|
|
403
347
|
})
|
|
404
348
|
]
|
|
405
349
|
});
|
|
350
|
+
const icon = definition.icon ? getIcon(definition.icon, "text-text-secondary dark:text-text-secondary-dark", void 0, 14) : null;
|
|
351
|
+
return /* @__PURE__ */ jsx("div", {
|
|
352
|
+
className: cls("@container rounded-xl bg-surface-card border min-w-0", defaultBorderMixin),
|
|
353
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
354
|
+
className: "flex flex-col min-w-0 h-full px-5 py-4 @max-[200px]:px-3.5 @max-[200px]:py-3",
|
|
355
|
+
children: [
|
|
356
|
+
/* @__PURE__ */ jsxs("div", {
|
|
357
|
+
className: "flex items-center gap-1.5 min-w-0 mb-2.5 @max-[200px]:mb-1",
|
|
358
|
+
children: [icon && /* @__PURE__ */ jsx("span", {
|
|
359
|
+
className: "shrink-0 flex items-center text-text-secondary dark:text-text-secondary-dark [&>svg]:size-3.5",
|
|
360
|
+
children: icon
|
|
361
|
+
}), /* @__PURE__ */ jsx(Typography, {
|
|
362
|
+
variant: "micro",
|
|
363
|
+
color: "secondary",
|
|
364
|
+
className: "truncate",
|
|
365
|
+
children: definition.title
|
|
366
|
+
})]
|
|
367
|
+
}),
|
|
368
|
+
/* @__PURE__ */ jsx("div", {
|
|
369
|
+
className: "typography-stat leading-tight break-all text-text-primary dark:text-text-primary-dark @max-[200px]:text-xl",
|
|
370
|
+
children: loading ? /* @__PURE__ */ jsx(Placeholder, { className: "h-[0.8em] w-24" }) : value
|
|
371
|
+
}),
|
|
372
|
+
error ? /* @__PURE__ */ jsx(Typography, {
|
|
373
|
+
variant: "caption",
|
|
374
|
+
color: "error",
|
|
375
|
+
className: "block mt-1 truncate",
|
|
376
|
+
title: error.message,
|
|
377
|
+
children: error.message
|
|
378
|
+
}) : definition.comparison && /* @__PURE__ */ jsx("div", {
|
|
379
|
+
className: "mt-1 leading-4",
|
|
380
|
+
children: loading || !row || !result ? /* @__PURE__ */ jsx(Placeholder, { className: "h-3 w-12" }) : /* @__PURE__ */ jsx(InsightChange, {
|
|
381
|
+
definition,
|
|
382
|
+
row,
|
|
383
|
+
period: result.period,
|
|
384
|
+
locale,
|
|
385
|
+
tooltip: true
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
]
|
|
389
|
+
})
|
|
390
|
+
});
|
|
406
391
|
}
|
|
407
|
-
|
|
392
|
+
InsightsScorecardView.displayName = "InsightsScorecardView";
|
|
408
393
|
//#endregion
|
|
409
394
|
//#region src/components/InsightWidget.tsx
|
|
410
|
-
/**
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
* layout shift between skeleton and loaded states.
|
|
414
|
-
*
|
|
415
|
-
* Breakdown (non-compact, non-small):
|
|
416
|
-
* py-4 padding: 16 + 16 = 32
|
|
417
|
-
* title row: 16.5 (text-xs leading-snug)
|
|
418
|
-
* mb-2 margin: 8
|
|
419
|
-
* value: 30 (text-2xl leading-tight)
|
|
420
|
-
* ---
|
|
421
|
-
* base: 86.5
|
|
422
|
-
* + dateRange: +16 (14px text + 2px mt-0.5)
|
|
423
|
-
* + comparison: +20 (16px text + 4px mt-1)
|
|
424
|
-
*/
|
|
425
|
-
function computeFixedHeight(config) {
|
|
426
|
-
let h = 86.5;
|
|
427
|
-
if (config.dateRange) h += 16;
|
|
428
|
-
if (config.comparison) h += 20;
|
|
429
|
-
return Math.ceil(h);
|
|
430
|
-
}
|
|
431
|
-
/**
|
|
432
|
-
* Single insight widget orchestrator.
|
|
433
|
-
*
|
|
434
|
-
* Wraps skeleton and loaded states in a fixed-height container
|
|
435
|
-
* (computed from the scorecard config) to prevent layout shift.
|
|
436
|
-
*
|
|
437
|
-
* All theme-awareness is handled via Tailwind `dark:` classes.
|
|
438
|
-
*/
|
|
439
|
-
function InsightWidget({ definition, collectionSlug, path, parentCollectionSlugs, parentEntityIds, compact = false, embedded = false }) {
|
|
440
|
-
const { data, loading, error } = useInsightsData(definition, {
|
|
441
|
-
path,
|
|
442
|
-
collectionSlug,
|
|
443
|
-
parentCollectionSlugs
|
|
444
|
-
});
|
|
445
|
-
const fixedHeight = !compact && !embedded ? computeFixedHeight(definition.scorecard) : void 0;
|
|
446
|
-
if (loading) return /* @__PURE__ */ jsx(InsightWidgetSkeleton, {
|
|
447
|
-
config: definition.scorecard,
|
|
448
|
-
compact,
|
|
449
|
-
embedded,
|
|
450
|
-
fixedHeight
|
|
451
|
-
});
|
|
452
|
-
if (error) return /* @__PURE__ */ jsxs("div", {
|
|
453
|
-
className: `text-red-500/70 dark:text-red-400/70 text-[0.8125rem] ${embedded ? "px-5 py-4 h-full" : `rounded-lg bg-red-500/5 dark:bg-red-400/5 border border-red-500/10 dark:border-red-400/10 ${compact ? "px-3.5 py-3" : "px-5 py-4"}`}`,
|
|
454
|
-
style: fixedHeight ? { height: fixedHeight } : void 0,
|
|
455
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
456
|
-
className: "font-semibold mb-1",
|
|
457
|
-
children: definition.title
|
|
458
|
-
}), /* @__PURE__ */ jsx("div", { children: error.message })]
|
|
459
|
-
});
|
|
460
|
-
if (!data || data.rows.length === 0) return /* @__PURE__ */ jsxs("div", {
|
|
461
|
-
className: `text-surface-400 dark:text-surface-500 text-[0.8125rem] ${embedded ? "px-5 py-4 h-full" : `rounded-lg bg-surface-100 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 ${compact ? "px-3.5 py-3" : "px-5 py-4"}`}`,
|
|
462
|
-
style: fixedHeight ? { height: fixedHeight } : void 0,
|
|
463
|
-
children: [definition.title, " — No data"]
|
|
464
|
-
});
|
|
395
|
+
/** One insight, reading its source through the shared engine. */
|
|
396
|
+
function InsightWidget({ definition, compact = false }) {
|
|
397
|
+
const { result, loading, error } = useInsightSource(definition.source);
|
|
465
398
|
return /* @__PURE__ */ jsx(InsightsScorecardView, {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
fixedHeight
|
|
399
|
+
definition,
|
|
400
|
+
result,
|
|
401
|
+
loading,
|
|
402
|
+
error,
|
|
403
|
+
compact
|
|
472
404
|
});
|
|
473
405
|
}
|
|
474
406
|
InsightWidget.displayName = "InsightWidget";
|
|
475
407
|
//#endregion
|
|
476
408
|
//#region src/components/HomeCardInsightSlot.tsx
|
|
477
409
|
/**
|
|
478
|
-
* Renders compact insight
|
|
410
|
+
* Renders compact insight readouts inside a home page collection card.
|
|
479
411
|
* Injected via the `home.card.widget` slot.
|
|
480
|
-
*
|
|
481
|
-
* Uses a horizontal flex layout so multiple cards sit side by side.
|
|
482
412
|
*/
|
|
483
|
-
function HomeCardInsightSlot({
|
|
413
|
+
function HomeCardInsightSlot({ insights }) {
|
|
484
414
|
if (!insights || insights.length === 0) return null;
|
|
485
415
|
return /* @__PURE__ */ jsx("div", {
|
|
486
416
|
className: "flex flex-wrap items-baseline gap-x-4 gap-y-1 mt-1.5",
|
|
487
|
-
children: insights.map((
|
|
488
|
-
definition
|
|
489
|
-
collectionSlug: slug,
|
|
417
|
+
children: insights.map((definition) => /* @__PURE__ */ jsx(InsightWidget, {
|
|
418
|
+
definition,
|
|
490
419
|
compact: true
|
|
491
|
-
},
|
|
420
|
+
}, definition.id))
|
|
492
421
|
});
|
|
493
422
|
}
|
|
494
423
|
HomeCardInsightSlot.displayName = "HomeCardInsightSlot";
|
|
495
424
|
//#endregion
|
|
425
|
+
//#region src/components/InsightsRow.tsx
|
|
426
|
+
/**
|
|
427
|
+
* A row of insight tiles. When any of them compares with the previous period,
|
|
428
|
+
* the row says which period once, above the tiles, rather than on each.
|
|
429
|
+
*/
|
|
430
|
+
function InsightsRow({ insights, className }) {
|
|
431
|
+
const { t } = useTranslation();
|
|
432
|
+
const days = useInsightsEngine()?.periodDays ?? 30;
|
|
433
|
+
const compares = insights.some((definition) => definition.comparison);
|
|
434
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
435
|
+
className: cls("w-full", className),
|
|
436
|
+
children: [compares && /* @__PURE__ */ jsx(Typography, {
|
|
437
|
+
variant: "micro",
|
|
438
|
+
color: "secondary",
|
|
439
|
+
component: "h2",
|
|
440
|
+
className: "block py-1 mb-4",
|
|
441
|
+
children: t("insights_period_last_days", { count: days })
|
|
442
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
443
|
+
className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4",
|
|
444
|
+
children: insights.map((definition) => /* @__PURE__ */ jsx(InsightWidget, { definition }, definition.id))
|
|
445
|
+
})]
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
InsightsRow.displayName = "InsightsRow";
|
|
449
|
+
//#endregion
|
|
496
450
|
//#region src/components/HomeInsightsSlot.tsx
|
|
497
451
|
/**
|
|
498
452
|
* Scorecard insights panel rendered at the top of the home page.
|
|
499
453
|
* Injected via the `home.children.start` slot.
|
|
500
|
-
*
|
|
501
|
-
* Renders scorecards in a responsive grid (up to 4 columns).
|
|
502
454
|
*/
|
|
503
455
|
function HomeInsightsSlot({ insights }) {
|
|
504
456
|
if (!insights || insights.length === 0) return null;
|
|
505
|
-
return /* @__PURE__ */ jsx(
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
children: insights.map((def) => /* @__PURE__ */ jsx(InsightWidget, { definition: def }, def.id))
|
|
457
|
+
return /* @__PURE__ */ jsx(InsightsRow, {
|
|
458
|
+
insights,
|
|
459
|
+
className: "mt-6 pb-2"
|
|
509
460
|
});
|
|
510
461
|
}
|
|
511
462
|
HomeInsightsSlot.displayName = "HomeInsightsSlot";
|
|
@@ -517,16 +468,11 @@ HomeInsightsSlot.displayName = "HomeInsightsSlot";
|
|
|
517
468
|
*
|
|
518
469
|
* Injected via the `collection.widgets` slot.
|
|
519
470
|
*/
|
|
520
|
-
function CollectionInsightsInline({ insights
|
|
471
|
+
function CollectionInsightsInline({ insights }) {
|
|
521
472
|
if (!insights || insights.length === 0) return null;
|
|
522
|
-
return /* @__PURE__ */ jsx(
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
definition: def,
|
|
526
|
-
path,
|
|
527
|
-
parentCollectionSlugs,
|
|
528
|
-
parentEntityIds
|
|
529
|
-
}, def.id))
|
|
473
|
+
return /* @__PURE__ */ jsx(InsightsRow, {
|
|
474
|
+
insights,
|
|
475
|
+
className: "pb-4"
|
|
530
476
|
});
|
|
531
477
|
}
|
|
532
478
|
CollectionInsightsInline.displayName = "CollectionInsightsInline";
|
|
@@ -538,46 +484,60 @@ CollectionInsightsInline.displayName = "CollectionInsightsInline";
|
|
|
538
484
|
* This plugin injects scorecard widgets into key UI locations:
|
|
539
485
|
* - **Home page header**: KPI overview via `home.children.start` slot
|
|
540
486
|
* - **Collection list view**: Scorecards inline (below title, above list) via `collection.widgets` slot
|
|
541
|
-
* - **Home page cards**: Compact
|
|
487
|
+
* - **Home page cards**: Compact readouts of the collection's insights via `home.card.widget` slot
|
|
542
488
|
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
489
|
+
* Figures come from named `sources`, each fetched once per user however many
|
|
490
|
+
* insights read it: a value shown on the home page and in a collection view
|
|
491
|
+
* is the same number. Every source is handed the comparison period, so the
|
|
492
|
+
* query and the label above the tiles describe the same window.
|
|
546
493
|
*
|
|
547
|
-
*
|
|
548
|
-
* call a custom function, or hit any external API. Full flexibility, zero new endpoints.
|
|
494
|
+
* Pass a memoized config: a new `sources` object starts a new cache.
|
|
549
495
|
*
|
|
550
496
|
* @example
|
|
551
497
|
* ```typescript
|
|
552
498
|
* import { useInsightsPlugin } from "@rebasepro/plugin-insights";
|
|
553
499
|
*
|
|
554
|
-
* const insightsPlugin = useInsightsPlugin({
|
|
555
|
-
*
|
|
500
|
+
* const insightsPlugin = useInsightsPlugin(useMemo(() => ({
|
|
501
|
+
* period: { days: 30 },
|
|
502
|
+
* sources: {
|
|
503
|
+
* orders: ({ period }) => fetchOrderStats(period.from, period.to, period.previousFrom)
|
|
504
|
+
* },
|
|
556
505
|
* insights: {
|
|
557
|
-
* home: [
|
|
558
|
-
*
|
|
559
|
-
*
|
|
506
|
+
* home: [{
|
|
507
|
+
* id: "revenue",
|
|
508
|
+
* title: "Revenue",
|
|
509
|
+
* source: "orders",
|
|
510
|
+
* value: { field: "revenue", format: { style: "currency", currency: "USD" } },
|
|
511
|
+
* comparison: { previous: "previousRevenue", intent: "increase_is_good" }
|
|
512
|
+
* }],
|
|
560
513
|
* collections: {
|
|
561
|
-
* orders: [
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
* }
|
|
514
|
+
* orders: [{
|
|
515
|
+
* id: "shipped",
|
|
516
|
+
* title: "Shipped",
|
|
517
|
+
* source: "orders",
|
|
518
|
+
* value: { field: "shipped" },
|
|
519
|
+
* comparison: { previous: "previousShipped", intent: "increase_is_good", show: "absolute" }
|
|
520
|
+
* }]
|
|
521
|
+
* }
|
|
522
|
+
* }
|
|
523
|
+
* }), []));
|
|
567
524
|
* ```
|
|
568
525
|
*/
|
|
569
526
|
function useInsightsPlugin(config) {
|
|
570
|
-
const { insights, cacheTTL } = config;
|
|
527
|
+
const { insights, sources, period, cacheTTL } = config;
|
|
528
|
+
const periodDays = period?.days ?? 30;
|
|
571
529
|
return React.useMemo(() => {
|
|
530
|
+
assertValidConfig({
|
|
531
|
+
insights,
|
|
532
|
+
sources,
|
|
533
|
+
period: { days: periodDays }
|
|
534
|
+
});
|
|
572
535
|
const slots = [];
|
|
573
536
|
if (insights.home && insights.home.length > 0) {
|
|
574
537
|
const homeInsights = insights.home;
|
|
575
538
|
slots.push({
|
|
576
539
|
slot: "home.children.start",
|
|
577
|
-
Component: (
|
|
578
|
-
...props,
|
|
579
|
-
insights: homeInsights
|
|
580
|
-
}),
|
|
540
|
+
Component: () => /* @__PURE__ */ jsx(HomeInsightsSlot, { insights: homeInsights }),
|
|
581
541
|
order: 10
|
|
582
542
|
});
|
|
583
543
|
}
|
|
@@ -589,10 +549,7 @@ function useInsightsPlugin(config) {
|
|
|
589
549
|
Component: (props) => {
|
|
590
550
|
if ((props.path?.split("/").filter(Boolean).pop() ?? "") !== slug) return null;
|
|
591
551
|
if (props.parentEntityIds && props.parentEntityIds.length > 0) return null;
|
|
592
|
-
return /* @__PURE__ */ jsx(CollectionInsightsInline, {
|
|
593
|
-
...props,
|
|
594
|
-
insights: collectionInsights
|
|
595
|
-
});
|
|
552
|
+
return /* @__PURE__ */ jsx(CollectionInsightsInline, { insights: collectionInsights });
|
|
596
553
|
},
|
|
597
554
|
order: 10
|
|
598
555
|
});
|
|
@@ -600,10 +557,7 @@ function useInsightsPlugin(config) {
|
|
|
600
557
|
slot: "home.card.widget",
|
|
601
558
|
Component: (props) => {
|
|
602
559
|
if (props.slug !== slug) return null;
|
|
603
|
-
return /* @__PURE__ */ jsx(HomeCardInsightSlot, {
|
|
604
|
-
...props,
|
|
605
|
-
insights: collectionInsights
|
|
606
|
-
});
|
|
560
|
+
return /* @__PURE__ */ jsx(HomeCardInsightSlot, { insights: collectionInsights });
|
|
607
561
|
},
|
|
608
562
|
order: 10
|
|
609
563
|
});
|
|
@@ -614,12 +568,21 @@ function useInsightsPlugin(config) {
|
|
|
614
568
|
providers: [{
|
|
615
569
|
scope: "root",
|
|
616
570
|
Component: InsightsProvider,
|
|
617
|
-
props: {
|
|
571
|
+
props: {
|
|
572
|
+
sources,
|
|
573
|
+
periodDays,
|
|
574
|
+
cacheTTL
|
|
575
|
+
}
|
|
618
576
|
}]
|
|
619
577
|
};
|
|
620
|
-
}, [
|
|
578
|
+
}, [
|
|
579
|
+
insights,
|
|
580
|
+
sources,
|
|
581
|
+
periodDays,
|
|
582
|
+
cacheTTL
|
|
583
|
+
]);
|
|
621
584
|
}
|
|
622
585
|
//#endregion
|
|
623
|
-
export { InsightWidget,
|
|
586
|
+
export { InsightWidget, InsightsEngine, InsightsProvider, InsightsRow, InsightsScorecardView, resolvePeriod, useInsightSource, useInsightsEngine, useInsightsPlugin };
|
|
624
587
|
|
|
625
588
|
//# sourceMappingURL=index.es.js.map
|