@rebasepro/plugin-insights 0.22.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 +403 -425
- 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 -18
- package/dist/engine/useInsightsData.d.ts +0 -17
package/dist/index.es.js
CHANGED
|
@@ -1,51 +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
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The cache key for one source, for one user.
|
|
18
|
+
*
|
|
19
|
+
* The engine lives at the root of the app and outlives a sign-out, and a figure
|
|
20
|
+
* is computed under the permissions and row-level security of whoever asked
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
function sourceCacheKey(sourceId, userId) {
|
|
25
|
+
return JSON.stringify([userId, sourceId]);
|
|
26
|
+
}
|
|
6
27
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
11
34
|
*/
|
|
12
|
-
var
|
|
35
|
+
var InsightsEngine = class {
|
|
36
|
+
sources;
|
|
37
|
+
periodDays;
|
|
13
38
|
ttl;
|
|
39
|
+
now;
|
|
14
40
|
cache = /* @__PURE__ */ new Map();
|
|
15
41
|
inflight = /* @__PURE__ */ new Map();
|
|
16
|
-
|
|
42
|
+
anchor = null;
|
|
43
|
+
constructor(sources, periodDays = 30, ttl = 6e4, now = Date.now) {
|
|
44
|
+
this.sources = sources;
|
|
45
|
+
this.periodDays = periodDays;
|
|
17
46
|
this.ttl = ttl;
|
|
47
|
+
this.now = now;
|
|
18
48
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
timestamp: Date.now()
|
|
32
|
-
});
|
|
33
|
-
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;
|
|
34
61
|
}
|
|
35
|
-
|
|
36
|
-
|
|
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;
|
|
37
67
|
}
|
|
38
|
-
|
|
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
|
+
});
|
|
39
101
|
this.inflight.set(key, promise);
|
|
102
|
+
return promise;
|
|
40
103
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this.cache.clear();
|
|
47
|
-
this.inflight.clear();
|
|
48
|
-
}
|
|
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;
|
|
49
109
|
}
|
|
50
110
|
};
|
|
51
111
|
//#endregion
|
|
@@ -55,19 +115,21 @@ var InsightsContext = createContext(null);
|
|
|
55
115
|
* Root-level provider for the insights data engine.
|
|
56
116
|
* Injected automatically by the plugin via `providers: [{ scope: "root" }]`.
|
|
57
117
|
*
|
|
58
|
-
*
|
|
59
|
-
* for TTL-based caching and inflight request deduplication.
|
|
118
|
+
* Holds the one `InsightsEngine` every insight widget reads its source from.
|
|
60
119
|
*/
|
|
61
|
-
function InsightsProvider({ cacheTTL, children }) {
|
|
62
|
-
const
|
|
63
|
-
|
|
120
|
+
function InsightsProvider({ sources, periodDays, cacheTTL, children }) {
|
|
121
|
+
const engine = useMemo(() => new InsightsEngine(sources, periodDays, cacheTTL), [
|
|
122
|
+
sources,
|
|
123
|
+
periodDays,
|
|
124
|
+
cacheTTL
|
|
125
|
+
]);
|
|
64
126
|
return /* @__PURE__ */ jsx(InsightsContext.Provider, {
|
|
65
|
-
value,
|
|
127
|
+
value: engine,
|
|
66
128
|
children
|
|
67
129
|
});
|
|
68
130
|
}
|
|
69
131
|
/**
|
|
70
|
-
*
|
|
132
|
+
* The insights engine (for advanced usage).
|
|
71
133
|
* Returns null when called outside of an `InsightsProvider`
|
|
72
134
|
* (e.g. during auth-loading phase before plugin providers mount).
|
|
73
135
|
*/
|
|
@@ -75,82 +137,84 @@ function useInsightsEngine() {
|
|
|
75
137
|
return useContext(InsightsContext);
|
|
76
138
|
}
|
|
77
139
|
//#endregion
|
|
78
|
-
//#region src/
|
|
140
|
+
//#region src/validateConfig.ts
|
|
79
141
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
86
157
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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.
|
|
89
160
|
*/
|
|
90
|
-
function
|
|
91
|
-
const
|
|
161
|
+
function useInsightSource(sourceId) {
|
|
162
|
+
const engine = useInsightsEngine();
|
|
92
163
|
const { initialLoading, authLoading, user, loginSkipped } = useAuthController();
|
|
93
164
|
const authReady = !initialLoading && !authLoading && (Boolean(user) || loginSkipped);
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const [
|
|
97
|
-
const cacheKey = `${definition.id}:${context.path ?? context.collectionSlug ?? "global"}`;
|
|
165
|
+
const userId = user?.uid ?? null;
|
|
166
|
+
const key = sourceCacheKey(sourceId, userId);
|
|
167
|
+
const [settled, setSettled] = useState(null);
|
|
98
168
|
useEffect(() => {
|
|
99
|
-
if (!authReady || !
|
|
169
|
+
if (!authReady || !engine) return;
|
|
100
170
|
let cancelled = false;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}).catch((err) => {
|
|
113
|
-
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
|
|
114
|
-
}).finally(() => {
|
|
115
|
-
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))
|
|
116
182
|
});
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
setLoading(true);
|
|
120
|
-
setError(null);
|
|
121
|
-
const promise = definition.data(context);
|
|
122
|
-
cache.setInflight(cacheKey, promise);
|
|
123
|
-
promise.then((result) => {
|
|
124
|
-
cache.set(cacheKey, result);
|
|
125
|
-
if (!cancelled) setData(result);
|
|
126
|
-
}).catch((err) => {
|
|
127
|
-
cache.invalidate(cacheKey);
|
|
128
|
-
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
|
|
129
|
-
}).finally(() => {
|
|
130
|
-
if (!cancelled) setLoading(false);
|
|
131
183
|
});
|
|
132
184
|
return () => {
|
|
133
185
|
cancelled = true;
|
|
134
186
|
};
|
|
135
187
|
}, [
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
cacheKey,
|
|
141
|
-
cache,
|
|
188
|
+
engine,
|
|
189
|
+
sourceId,
|
|
190
|
+
userId,
|
|
191
|
+
key,
|
|
142
192
|
authReady
|
|
143
193
|
]);
|
|
194
|
+
const current = settled?.key === key ? settled : null;
|
|
195
|
+
const cached = !current && authReady && engine ? engine.peek(sourceId, userId) : null;
|
|
144
196
|
return {
|
|
145
|
-
|
|
146
|
-
loading,
|
|
147
|
-
error
|
|
197
|
+
result: current?.result ?? cached,
|
|
198
|
+
loading: !current && !cached,
|
|
199
|
+
error: current?.error ?? null
|
|
148
200
|
};
|
|
149
201
|
}
|
|
150
202
|
//#endregion
|
|
151
|
-
//#region src/
|
|
152
|
-
|
|
153
|
-
|
|
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) {
|
|
154
218
|
const options = {
|
|
155
219
|
style: format?.style ?? "decimal",
|
|
156
220
|
notation: format?.notation ?? "standard"
|
|
@@ -160,337 +224,239 @@ function formatNumber(value, format) {
|
|
|
160
224
|
options.minimumFractionDigits = format.decimals;
|
|
161
225
|
}
|
|
162
226
|
if (format?.style === "currency") options.currency = format.currency ?? "USD";
|
|
163
|
-
|
|
164
|
-
if (format?.showSign && value > 0) formatted = "+" + formatted;
|
|
165
|
-
return formatted;
|
|
227
|
+
return numberFormat(locale, options).format(value);
|
|
166
228
|
}
|
|
167
229
|
/**
|
|
168
|
-
*
|
|
230
|
+
* The change from `previous` to `current`, written as the comparison asks.
|
|
169
231
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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.
|
|
173
236
|
*/
|
|
174
|
-
function
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
let comparisonElement = null;
|
|
189
|
-
if (config.comparison) {
|
|
190
|
-
const comparisonValue = data[config.comparison.field];
|
|
191
|
-
if (typeof comparisonValue === "number") {
|
|
192
|
-
const formattedComparison = formatNumber(comparisonValue, config.comparison.format);
|
|
193
|
-
const isPositive = comparisonValue > 0;
|
|
194
|
-
const isNegative = comparisonValue < 0;
|
|
195
|
-
let colorClass = "text-surface-500 dark:text-surface-400";
|
|
196
|
-
if (config.comparison.intent === "increase_is_good") {
|
|
197
|
-
if (isPositive) colorClass = "text-emerald-500";
|
|
198
|
-
if (isNegative) colorClass = "text-red-500";
|
|
199
|
-
} else if (config.comparison.intent === "decrease_is_good") {
|
|
200
|
-
if (isPositive) colorClass = "text-red-500";
|
|
201
|
-
if (isNegative) colorClass = "text-emerald-500";
|
|
202
|
-
}
|
|
203
|
-
comparisonElement = /* @__PURE__ */ jsx("span", {
|
|
204
|
-
className: `font-mono tabular-nums font-medium ${compact ? "text-[10px]" : "text-xs"} ${colorClass}`,
|
|
205
|
-
children: formattedComparison
|
|
206
|
-
});
|
|
207
|
-
}
|
|
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
|
+
};
|
|
208
251
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
children: [/* @__PURE__ */ jsx("span", {
|
|
219
|
-
className: "text-sm font-semibold tabular-nums text-surface-800 dark:text-surface-100",
|
|
220
|
-
children: formattedValue
|
|
221
|
-
}), comparisonElement]
|
|
222
|
-
})]
|
|
223
|
-
});
|
|
224
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
225
|
-
ref: containerRef,
|
|
226
|
-
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"),
|
|
227
|
-
style: embedded ? void 0 : fixedHeight ? { height: fixedHeight } : { minHeight: isSmall ? 68 : 92 },
|
|
228
|
-
children: [
|
|
229
|
-
/* @__PURE__ */ jsxs("div", {
|
|
230
|
-
className: `flex flex-col min-w-0 ${isSmall ? "mb-1" : "mb-2.5"}`,
|
|
231
|
-
children: [/* @__PURE__ */ jsxs("div", {
|
|
232
|
-
className: "flex items-center gap-1.5 min-w-0",
|
|
233
|
-
children: [iconElement && /* @__PURE__ */ jsx("span", {
|
|
234
|
-
className: "shrink-0 flex items-center text-text-secondary dark:text-text-secondary-dark [&>svg]:size-3.5",
|
|
235
|
-
children: iconElement
|
|
236
|
-
}), /* @__PURE__ */ jsx("span", {
|
|
237
|
-
className: "typography-micro truncate text-surface-400 dark:text-surface-400",
|
|
238
|
-
children: title
|
|
239
|
-
})]
|
|
240
|
-
}), config.dateRange && !isSmall && /* @__PURE__ */ jsx("span", {
|
|
241
|
-
className: "font-mono tabular-nums text-[10px] text-surface-400 dark:text-surface-500 truncate mt-1",
|
|
242
|
-
children: config.dateRange
|
|
243
|
-
})]
|
|
244
|
-
}),
|
|
245
|
-
/* @__PURE__ */ jsx("div", {
|
|
246
|
-
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"}`,
|
|
247
|
-
children: formattedValue
|
|
248
|
-
}),
|
|
249
|
-
comparisonElement && /* @__PURE__ */ jsx("div", {
|
|
250
|
-
className: isSmall ? "mt-0.5" : "mt-1",
|
|
251
|
-
children: comparisonElement
|
|
252
|
-
})
|
|
253
|
-
]
|
|
254
|
-
});
|
|
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";
|
|
255
261
|
}
|
|
256
|
-
InsightsScorecardView.displayName = "InsightsScorecardView";
|
|
257
262
|
//#endregion
|
|
258
|
-
//#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
|
+
}
|
|
259
279
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* The skeleton receives the scorecard config so it can conditionally
|
|
265
|
-
* render placeholder lines for comparison, dateRange, and icon —
|
|
266
|
-
* only when the loaded view will also render them.
|
|
267
|
-
*
|
|
268
|
-
* The standard skeleton mirrors InsightsScorecardView's responsive
|
|
269
|
-
* container-width breakpoints (ResizeObserver → isSmall / isMedium)
|
|
270
|
-
* and uses placeholder heights that exactly match the **computed**
|
|
271
|
-
* Tailwind line-heights (accounting for `leading-*` overrides).
|
|
272
|
-
* 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.
|
|
273
283
|
*/
|
|
274
|
-
function
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
className: "flex items-baseline gap-1.5",
|
|
288
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
289
|
-
className: "bg-surface-200 dark:bg-surface-700 rounded-sm",
|
|
290
|
-
style: {
|
|
291
|
-
height: 20,
|
|
292
|
-
width: 40
|
|
293
|
-
}
|
|
294
|
-
}), hasComparison && /* @__PURE__ */ jsx("div", {
|
|
295
|
-
className: "bg-surface-200/60 dark:bg-surface-700/60 rounded-sm",
|
|
296
|
-
style: {
|
|
297
|
-
height: 14,
|
|
298
|
-
width: 28
|
|
299
|
-
}
|
|
300
|
-
})]
|
|
301
|
-
})]
|
|
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)
|
|
302
297
|
});
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
})]
|
|
309
307
|
});
|
|
308
|
+
return tooltip ? /* @__PURE__ */ jsx(Tooltip, {
|
|
309
|
+
title: previousLabel,
|
|
310
|
+
children: label
|
|
311
|
+
}) : label;
|
|
310
312
|
}
|
|
311
313
|
/**
|
|
312
|
-
*
|
|
314
|
+
* One insight: its label, its value and its change on the previous period.
|
|
313
315
|
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
* 3. Matching all container classes, margins, paddings, and flex
|
|
320
|
-
* 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.
|
|
321
321
|
*/
|
|
322
|
-
function
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
});
|
|
331
|
-
observer.observe(containerRef.current);
|
|
332
|
-
return () => observer.disconnect();
|
|
333
|
-
}, []);
|
|
334
|
-
const isSmall = containerWidth !== null && containerWidth < 200;
|
|
335
|
-
const titleHeight = isSmall ? 15 : 16.5;
|
|
336
|
-
const valueHeight = isSmall ? 22.5 : containerWidth !== null && containerWidth < 300 ? 25 : 30;
|
|
337
|
-
const comparisonHeight = 16;
|
|
338
|
-
const iconSize = isSmall ? 14 : 18;
|
|
339
|
-
return /* @__PURE__ */ jsxs("div", {
|
|
340
|
-
ref: containerRef,
|
|
341
|
-
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")),
|
|
342
|
-
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,
|
|
343
330
|
children: [
|
|
344
|
-
/* @__PURE__ */
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
className: "bg-surface-200 dark:bg-surface-700 rounded",
|
|
350
|
-
style: {
|
|
351
|
-
height: titleHeight,
|
|
352
|
-
width: "60%"
|
|
353
|
-
}
|
|
354
|
-
}), hasDateRange && !isSmall && /* @__PURE__ */ jsx("div", {
|
|
355
|
-
className: "bg-surface-200/60 dark:bg-surface-700/60 rounded mt-0.5",
|
|
356
|
-
style: {
|
|
357
|
-
height: 14,
|
|
358
|
-
width: "40%"
|
|
359
|
-
}
|
|
360
|
-
})]
|
|
361
|
-
}), hasIcon && /* @__PURE__ */ jsx("span", {
|
|
362
|
-
className: "ml-2 shrink-0",
|
|
363
|
-
children: /* @__PURE__ */ jsx("div", {
|
|
364
|
-
className: "bg-surface-200 dark:bg-surface-700 rounded",
|
|
365
|
-
style: {
|
|
366
|
-
height: iconSize,
|
|
367
|
-
width: iconSize
|
|
368
|
-
}
|
|
369
|
-
})
|
|
370
|
-
})]
|
|
331
|
+
/* @__PURE__ */ jsx(Typography, {
|
|
332
|
+
variant: "micro",
|
|
333
|
+
color: "secondary",
|
|
334
|
+
className: "truncate",
|
|
335
|
+
children: definition.title
|
|
371
336
|
}),
|
|
372
|
-
/* @__PURE__ */ jsx("
|
|
373
|
-
className: "
|
|
374
|
-
|
|
375
|
-
height: valueHeight,
|
|
376
|
-
width: "40%"
|
|
377
|
-
}
|
|
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
|
|
378
340
|
}),
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
width: "25%"
|
|
386
|
-
}
|
|
387
|
-
})
|
|
341
|
+
!loading && row && result && /* @__PURE__ */ jsx(InsightChange, {
|
|
342
|
+
definition,
|
|
343
|
+
row,
|
|
344
|
+
period: result.period,
|
|
345
|
+
locale,
|
|
346
|
+
tooltip: false
|
|
388
347
|
})
|
|
389
348
|
]
|
|
390
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
|
+
});
|
|
391
391
|
}
|
|
392
|
-
|
|
392
|
+
InsightsScorecardView.displayName = "InsightsScorecardView";
|
|
393
393
|
//#endregion
|
|
394
394
|
//#region src/components/InsightWidget.tsx
|
|
395
|
-
/**
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
* layout shift between skeleton and loaded states.
|
|
399
|
-
*
|
|
400
|
-
* Breakdown (non-compact, non-small):
|
|
401
|
-
* py-4 padding: 16 + 16 = 32
|
|
402
|
-
* title row: 16.5 (text-xs leading-snug)
|
|
403
|
-
* mb-2 margin: 8
|
|
404
|
-
* value: 30 (text-2xl leading-tight)
|
|
405
|
-
* ---
|
|
406
|
-
* base: 86.5
|
|
407
|
-
* + dateRange: +16 (14px text + 2px mt-0.5)
|
|
408
|
-
* + comparison: +20 (16px text + 4px mt-1)
|
|
409
|
-
*/
|
|
410
|
-
function computeFixedHeight(config) {
|
|
411
|
-
let h = 86.5;
|
|
412
|
-
if (config.dateRange) h += 16;
|
|
413
|
-
if (config.comparison) h += 20;
|
|
414
|
-
return Math.ceil(h);
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* Single insight widget orchestrator.
|
|
418
|
-
*
|
|
419
|
-
* Wraps skeleton and loaded states in a fixed-height container
|
|
420
|
-
* (computed from the scorecard config) to prevent layout shift.
|
|
421
|
-
*
|
|
422
|
-
* All theme-awareness is handled via Tailwind `dark:` classes.
|
|
423
|
-
*/
|
|
424
|
-
function InsightWidget({ definition, collectionSlug, path, parentCollectionSlugs, parentEntityIds, compact = false, embedded = false }) {
|
|
425
|
-
const { data, loading, error } = useInsightsData(definition, {
|
|
426
|
-
path,
|
|
427
|
-
collectionSlug,
|
|
428
|
-
parentCollectionSlugs
|
|
429
|
-
});
|
|
430
|
-
const fixedHeight = !compact && !embedded ? computeFixedHeight(definition.scorecard) : void 0;
|
|
431
|
-
if (loading) return /* @__PURE__ */ jsx(InsightWidgetSkeleton, {
|
|
432
|
-
config: definition.scorecard,
|
|
433
|
-
compact,
|
|
434
|
-
embedded,
|
|
435
|
-
fixedHeight
|
|
436
|
-
});
|
|
437
|
-
if (error) return /* @__PURE__ */ jsxs("div", {
|
|
438
|
-
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"}`}`,
|
|
439
|
-
style: fixedHeight ? { height: fixedHeight } : void 0,
|
|
440
|
-
children: [/* @__PURE__ */ jsx("div", {
|
|
441
|
-
className: "font-semibold mb-1",
|
|
442
|
-
children: definition.title
|
|
443
|
-
}), /* @__PURE__ */ jsx("div", { children: error.message })]
|
|
444
|
-
});
|
|
445
|
-
if (!data || data.rows.length === 0) return /* @__PURE__ */ jsxs("div", {
|
|
446
|
-
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"}`}`,
|
|
447
|
-
style: fixedHeight ? { height: fixedHeight } : void 0,
|
|
448
|
-
children: [definition.title, " — No data"]
|
|
449
|
-
});
|
|
395
|
+
/** One insight, reading its source through the shared engine. */
|
|
396
|
+
function InsightWidget({ definition, compact = false }) {
|
|
397
|
+
const { result, loading, error } = useInsightSource(definition.source);
|
|
450
398
|
return /* @__PURE__ */ jsx(InsightsScorecardView, {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
fixedHeight
|
|
399
|
+
definition,
|
|
400
|
+
result,
|
|
401
|
+
loading,
|
|
402
|
+
error,
|
|
403
|
+
compact
|
|
457
404
|
});
|
|
458
405
|
}
|
|
459
406
|
InsightWidget.displayName = "InsightWidget";
|
|
460
407
|
//#endregion
|
|
461
408
|
//#region src/components/HomeCardInsightSlot.tsx
|
|
462
409
|
/**
|
|
463
|
-
* Renders compact insight
|
|
410
|
+
* Renders compact insight readouts inside a home page collection card.
|
|
464
411
|
* Injected via the `home.card.widget` slot.
|
|
465
|
-
*
|
|
466
|
-
* Uses a horizontal flex layout so multiple cards sit side by side.
|
|
467
412
|
*/
|
|
468
|
-
function HomeCardInsightSlot({
|
|
413
|
+
function HomeCardInsightSlot({ insights }) {
|
|
469
414
|
if (!insights || insights.length === 0) return null;
|
|
470
415
|
return /* @__PURE__ */ jsx("div", {
|
|
471
416
|
className: "flex flex-wrap items-baseline gap-x-4 gap-y-1 mt-1.5",
|
|
472
|
-
children: insights.map((
|
|
473
|
-
definition
|
|
474
|
-
collectionSlug: slug,
|
|
417
|
+
children: insights.map((definition) => /* @__PURE__ */ jsx(InsightWidget, {
|
|
418
|
+
definition,
|
|
475
419
|
compact: true
|
|
476
|
-
},
|
|
420
|
+
}, definition.id))
|
|
477
421
|
});
|
|
478
422
|
}
|
|
479
423
|
HomeCardInsightSlot.displayName = "HomeCardInsightSlot";
|
|
480
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
|
|
481
450
|
//#region src/components/HomeInsightsSlot.tsx
|
|
482
451
|
/**
|
|
483
452
|
* Scorecard insights panel rendered at the top of the home page.
|
|
484
453
|
* Injected via the `home.children.start` slot.
|
|
485
|
-
*
|
|
486
|
-
* Renders scorecards in a responsive grid (up to 4 columns).
|
|
487
454
|
*/
|
|
488
455
|
function HomeInsightsSlot({ insights }) {
|
|
489
456
|
if (!insights || insights.length === 0) return null;
|
|
490
|
-
return /* @__PURE__ */ jsx(
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
children: insights.map((def) => /* @__PURE__ */ jsx(InsightWidget, { definition: def }, def.id))
|
|
457
|
+
return /* @__PURE__ */ jsx(InsightsRow, {
|
|
458
|
+
insights,
|
|
459
|
+
className: "mt-6 pb-2"
|
|
494
460
|
});
|
|
495
461
|
}
|
|
496
462
|
HomeInsightsSlot.displayName = "HomeInsightsSlot";
|
|
@@ -502,16 +468,11 @@ HomeInsightsSlot.displayName = "HomeInsightsSlot";
|
|
|
502
468
|
*
|
|
503
469
|
* Injected via the `collection.widgets` slot.
|
|
504
470
|
*/
|
|
505
|
-
function CollectionInsightsInline({ insights
|
|
471
|
+
function CollectionInsightsInline({ insights }) {
|
|
506
472
|
if (!insights || insights.length === 0) return null;
|
|
507
|
-
return /* @__PURE__ */ jsx(
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
definition: def,
|
|
511
|
-
path,
|
|
512
|
-
parentCollectionSlugs,
|
|
513
|
-
parentEntityIds
|
|
514
|
-
}, def.id))
|
|
473
|
+
return /* @__PURE__ */ jsx(InsightsRow, {
|
|
474
|
+
insights,
|
|
475
|
+
className: "pb-4"
|
|
515
476
|
});
|
|
516
477
|
}
|
|
517
478
|
CollectionInsightsInline.displayName = "CollectionInsightsInline";
|
|
@@ -523,46 +484,60 @@ CollectionInsightsInline.displayName = "CollectionInsightsInline";
|
|
|
523
484
|
* This plugin injects scorecard widgets into key UI locations:
|
|
524
485
|
* - **Home page header**: KPI overview via `home.children.start` slot
|
|
525
486
|
* - **Collection list view**: Scorecards inline (below title, above list) via `collection.widgets` slot
|
|
526
|
-
* - **Home page cards**: Compact
|
|
487
|
+
* - **Home page cards**: Compact readouts of the collection's insights via `home.card.widget` slot
|
|
527
488
|
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
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.
|
|
531
493
|
*
|
|
532
|
-
*
|
|
533
|
-
* 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.
|
|
534
495
|
*
|
|
535
496
|
* @example
|
|
536
497
|
* ```typescript
|
|
537
498
|
* import { useInsightsPlugin } from "@rebasepro/plugin-insights";
|
|
538
499
|
*
|
|
539
|
-
* const insightsPlugin = useInsightsPlugin({
|
|
540
|
-
*
|
|
500
|
+
* const insightsPlugin = useInsightsPlugin(useMemo(() => ({
|
|
501
|
+
* period: { days: 30 },
|
|
502
|
+
* sources: {
|
|
503
|
+
* orders: ({ period }) => fetchOrderStats(period.from, period.to, period.previousFrom)
|
|
504
|
+
* },
|
|
541
505
|
* insights: {
|
|
542
|
-
* home: [
|
|
543
|
-
*
|
|
544
|
-
*
|
|
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
|
+
* }],
|
|
545
513
|
* collections: {
|
|
546
|
-
* orders: [
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* }
|
|
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
|
+
* }), []));
|
|
552
524
|
* ```
|
|
553
525
|
*/
|
|
554
526
|
function useInsightsPlugin(config) {
|
|
555
|
-
const { insights, cacheTTL } = config;
|
|
527
|
+
const { insights, sources, period, cacheTTL } = config;
|
|
528
|
+
const periodDays = period?.days ?? 30;
|
|
556
529
|
return React.useMemo(() => {
|
|
530
|
+
assertValidConfig({
|
|
531
|
+
insights,
|
|
532
|
+
sources,
|
|
533
|
+
period: { days: periodDays }
|
|
534
|
+
});
|
|
557
535
|
const slots = [];
|
|
558
536
|
if (insights.home && insights.home.length > 0) {
|
|
559
537
|
const homeInsights = insights.home;
|
|
560
538
|
slots.push({
|
|
561
539
|
slot: "home.children.start",
|
|
562
|
-
Component: (
|
|
563
|
-
...props,
|
|
564
|
-
insights: homeInsights
|
|
565
|
-
}),
|
|
540
|
+
Component: () => /* @__PURE__ */ jsx(HomeInsightsSlot, { insights: homeInsights }),
|
|
566
541
|
order: 10
|
|
567
542
|
});
|
|
568
543
|
}
|
|
@@ -574,10 +549,7 @@ function useInsightsPlugin(config) {
|
|
|
574
549
|
Component: (props) => {
|
|
575
550
|
if ((props.path?.split("/").filter(Boolean).pop() ?? "") !== slug) return null;
|
|
576
551
|
if (props.parentEntityIds && props.parentEntityIds.length > 0) return null;
|
|
577
|
-
return /* @__PURE__ */ jsx(CollectionInsightsInline, {
|
|
578
|
-
...props,
|
|
579
|
-
insights: collectionInsights
|
|
580
|
-
});
|
|
552
|
+
return /* @__PURE__ */ jsx(CollectionInsightsInline, { insights: collectionInsights });
|
|
581
553
|
},
|
|
582
554
|
order: 10
|
|
583
555
|
});
|
|
@@ -585,10 +557,7 @@ function useInsightsPlugin(config) {
|
|
|
585
557
|
slot: "home.card.widget",
|
|
586
558
|
Component: (props) => {
|
|
587
559
|
if (props.slug !== slug) return null;
|
|
588
|
-
return /* @__PURE__ */ jsx(HomeCardInsightSlot, {
|
|
589
|
-
...props,
|
|
590
|
-
insights: collectionInsights
|
|
591
|
-
});
|
|
560
|
+
return /* @__PURE__ */ jsx(HomeCardInsightSlot, { insights: collectionInsights });
|
|
592
561
|
},
|
|
593
562
|
order: 10
|
|
594
563
|
});
|
|
@@ -599,12 +568,21 @@ function useInsightsPlugin(config) {
|
|
|
599
568
|
providers: [{
|
|
600
569
|
scope: "root",
|
|
601
570
|
Component: InsightsProvider,
|
|
602
|
-
props: {
|
|
571
|
+
props: {
|
|
572
|
+
sources,
|
|
573
|
+
periodDays,
|
|
574
|
+
cacheTTL
|
|
575
|
+
}
|
|
603
576
|
}]
|
|
604
577
|
};
|
|
605
|
-
}, [
|
|
578
|
+
}, [
|
|
579
|
+
insights,
|
|
580
|
+
sources,
|
|
581
|
+
periodDays,
|
|
582
|
+
cacheTTL
|
|
583
|
+
]);
|
|
606
584
|
}
|
|
607
585
|
//#endregion
|
|
608
|
-
export { InsightWidget,
|
|
586
|
+
export { InsightWidget, InsightsEngine, InsightsProvider, InsightsRow, InsightsScorecardView, resolvePeriod, useInsightSource, useInsightsEngine, useInsightsPlugin };
|
|
609
587
|
|
|
610
588
|
//# sourceMappingURL=index.es.js.map
|