@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/dist/index.es.js CHANGED
@@ -1,66 +1,111 @@
1
- import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
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
- //#region src/engine/InsightsCache.ts
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 insight, in one scope, for one user.
17
+ * The cache key for one source, for one user.
8
18
  *
9
- * The cache lives at the root of the app and outlives a sign-out, and a figure
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 served the previous account's numbers to the
12
- * next one that signed in on the same tab, until the TTL ran out.
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 insightCacheKey(definitionId, context, userId) {
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
- * In-memory cache for insight query results.
23
- * Supports TTL-based expiry and inflight request deduplication
24
- * to prevent redundant network requests when multiple widgets
25
- * share the same query.
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 InsightsCache = class {
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
- constructor(ttl = 6e4) {
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
- get(key) {
35
- const entry = this.cache.get(key);
36
- if (!entry) return null;
37
- if (Date.now() - entry.timestamp > this.ttl) {
38
- this.cache.delete(key);
39
- return null;
40
- }
41
- return entry.data;
42
- }
43
- set(key, data) {
44
- this.cache.set(key, {
45
- data,
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
- getInflight(key) {
51
- return this.inflight.get(key) ?? null;
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
- setInflight(key, promise) {
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
- invalidate(key) {
57
- if (key) {
58
- this.cache.delete(key);
59
- this.inflight.delete(key);
60
- } else {
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
- * Manages a single `InsightsCache` instance shared by all insight widgets
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 cache = useMemo(() => new InsightsCache(cacheTTL), [cacheTTL]);
78
- const value = useMemo(() => ({ cache }), [cache]);
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
- * Access the insights cache (for advanced usage).
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/engine/useInsightsData.ts
140
+ //#region src/validateConfig.ts
94
141
  /**
95
- * Hook that fetches and caches data for a single insight definition.
96
- *
97
- * Calls the definition's own `data()` callback and manages:
98
- * - TTL-based caching via InsightsCache
99
- * - Inflight request deduplication (multiple mounts of the same widget)
100
- * - Loading and error state management
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
- * @param definition - The insight to fetch data for
103
- * @param context - The collection scope; with the signed-in user, it scopes the cache key
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 useInsightsData(definition, context) {
106
- const cache = useInsightsEngine()?.cache ?? null;
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 [data, setData] = useState(null);
110
- const [loading, setLoading] = useState(true);
111
- const [error, setError] = useState(null);
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 || !cache) return;
169
+ if (!authReady || !engine) return;
115
170
  let cancelled = false;
116
- const cached = cache.get(cacheKey);
117
- if (cached) {
118
- setData(cached);
119
- setLoading(false);
120
- return;
121
- }
122
- const inflight = cache.getInflight(cacheKey);
123
- if (inflight) {
124
- setLoading(true);
125
- inflight.then((result) => {
126
- if (!cancelled) setData(result);
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
- definition.id,
152
- definition.data,
153
- context.path,
154
- context.collectionSlug,
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
- data,
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/components/InsightsScorecardView.tsx
167
- function formatNumber(value, format) {
168
- if (value === null || value === void 0) return "N/A";
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
- let formatted = new Intl.NumberFormat("en-US", options).format(value);
179
- if (format?.showSign && value > 0) formatted = "+" + formatted;
180
- return formatted;
227
+ return numberFormat(locale, options).format(value);
181
228
  }
182
229
  /**
183
- * Scorecard widget for the Rebase design system.
230
+ * The change from `previous` to `current`, written as the comparison asks.
184
231
  *
185
- * Renders a single KPI metric with optional comparison value and icon.
186
- * Uses Tailwind `dark:` classes — no JS dark mode detection.
187
- * Icons are resolved via `getIcon` from `@rebasepro/app`.
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 InsightsScorecardView({ config, data, title, compact = false, embedded = false, fixedHeight }) {
190
- const containerRef = useRef(null);
191
- const [containerWidth, setContainerWidth] = useState(null);
192
- React.useLayoutEffect(() => {
193
- if (!containerRef.current) return;
194
- setContainerWidth(containerRef.current.offsetWidth);
195
- const observer = new ResizeObserver((entries) => {
196
- for (const entry of entries) setContainerWidth(entry.contentRect.width);
197
- });
198
- observer.observe(containerRef.current);
199
- return () => observer.disconnect();
200
- }, []);
201
- const mainValue = data[config.value.field];
202
- const formattedValue = typeof mainValue === "number" ? formatNumber(mainValue, config.value.format) : String(mainValue ?? "N/A");
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
- const isSmall = compact || containerWidth !== null && containerWidth < 200;
225
- const iconElement = config.icon ? getIcon(config.icon, "text-text-secondary dark:text-text-secondary-dark", void 0, 14) : null;
226
- if (compact) return /* @__PURE__ */ jsxs("div", {
227
- className: "flex items-baseline gap-1.5 min-w-0",
228
- children: [/* @__PURE__ */ jsx("span", {
229
- className: "text-[10px] uppercase tracking-wider text-surface-400 dark:text-surface-500 truncate",
230
- children: title
231
- }), /* @__PURE__ */ jsxs("div", {
232
- className: "flex items-baseline gap-1.5",
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/InsightWidgetSkeleton.tsx
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
- * Skeleton loader for scorecard insight widgets — displays animated
276
- * shimmer placeholders that exactly match the final rendered layout
277
- * of InsightsScorecardView for a given config, preventing layout shift.
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 InsightWidgetSkeleton({ config, compact = false, embedded = false, fixedHeight }) {
290
- const hasComparison = Boolean(config.comparison);
291
- const hasIcon = Boolean(config.icon);
292
- const hasDateRange = Boolean(config.dateRange);
293
- if (compact) return /* @__PURE__ */ jsxs("div", {
294
- className: cls("animate-pulse", embedded ? "h-full px-2.5 py-2" : "flex flex-col gap-0.5 rounded-md bg-transparent border min-w-0 px-2.5 py-2", !embedded && defaultBorderMixin),
295
- children: [/* @__PURE__ */ jsx("div", {
296
- className: "bg-surface-200 dark:bg-surface-700 rounded-sm",
297
- style: {
298
- height: 14,
299
- width: 48
300
- }
301
- }), /* @__PURE__ */ jsxs("div", {
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
- return /* @__PURE__ */ jsx(StandardSkeleton, {
319
- hasComparison,
320
- hasIcon,
321
- hasDateRange,
322
- embedded,
323
- fixedHeight
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
- * Inner component for the standard scorecard skeleton.
314
+ * One insight: its label, its value and its change on the previous period.
328
315
  *
329
- * Mirrors InsightsScorecardView's layout by:
330
- * 1. Using the same ResizeObserver + containerWidth pattern for
331
- * responsive breakpoints (isSmall < 200px, isMedium < 300px).
332
- * 2. Using placeholder heights derived from the exact computed
333
- * Tailwind line-heights that InsightsScorecardView renders.
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 StandardSkeleton({ hasComparison, hasIcon, hasDateRange, embedded, fixedHeight }) {
338
- const containerRef = useRef(null);
339
- const [containerWidth, setContainerWidth] = useState(null);
340
- React.useLayoutEffect(() => {
341
- if (!containerRef.current) return;
342
- setContainerWidth(containerRef.current.offsetWidth);
343
- const observer = new ResizeObserver((entries) => {
344
- for (const entry of entries) setContainerWidth(entry.contentRect.width);
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__ */ jsxs("div", {
360
- className: `flex items-center justify-between ${isSmall ? "mb-1" : "mb-2"}`,
361
- children: [/* @__PURE__ */ jsxs("div", {
362
- className: "flex flex-col min-w-0",
363
- children: [/* @__PURE__ */ jsx("div", {
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("div", {
388
- className: "bg-surface-200 dark:bg-surface-700 rounded",
389
- style: {
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
- hasComparison && /* @__PURE__ */ jsx("div", {
395
- className: isSmall ? "mt-0.5" : "mt-1",
396
- children: /* @__PURE__ */ jsx("div", {
397
- className: "bg-surface-200/60 dark:bg-surface-700/60 rounded",
398
- style: {
399
- height: comparisonHeight,
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
- InsightWidgetSkeleton.displayName = "InsightWidgetSkeleton";
392
+ InsightsScorecardView.displayName = "InsightsScorecardView";
408
393
  //#endregion
409
394
  //#region src/components/InsightWidget.tsx
410
- /**
411
- * Compute a deterministic fixed height for a standard scorecard based
412
- * on which optional elements the config declares. This eliminates
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
- config: definition.scorecard,
467
- data: data.rows[0],
468
- title: definition.title,
469
- compact,
470
- embedded,
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 widgets inline within a home page collection card.
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({ slug, insights }) {
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((def) => /* @__PURE__ */ jsx(InsightWidget, {
488
- definition: def,
489
- collectionSlug: slug,
417
+ children: insights.map((definition) => /* @__PURE__ */ jsx(InsightWidget, {
418
+ definition,
490
419
  compact: true
491
- }, def.id))
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("div", {
506
- className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 pb-6",
507
- style: { minHeight: 92 },
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, path, parentCollectionSlugs, parentEntityIds }) {
471
+ function CollectionInsightsInline({ insights }) {
521
472
  if (!insights || insights.length === 0) return null;
522
- return /* @__PURE__ */ jsx("div", {
523
- className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 pb-4",
524
- children: insights.map((def) => /* @__PURE__ */ jsx(InsightWidget, {
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 scorecard metrics auto-extracted from collection insights via `home.card.widget` slot
487
+ * - **Home page cards**: Compact readouts of the collection's insights via `home.card.widget` slot
542
488
  *
543
- * Collection-level insights (`collections.<slug>`) are the single source of truth:
544
- * scorecards render in the collection list view and are automatically extracted
545
- * to show as compact widgets on the corresponding home page card.
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
- * Each insight owns its own `data()` callback — use the Rebase client SDK,
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
- * cacheTTL: 120_000,
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
- * { id: "revenue", title: "Revenue", data: async () => ..., scorecard: { ... } },
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
- * { id: "total", title: "Total Orders", data: async () => ..., scorecard: { ... } },
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: (props) => /* @__PURE__ */ jsx(HomeInsightsSlot, {
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: { cacheTTL }
571
+ props: {
572
+ sources,
573
+ periodDays,
574
+ cacheTTL
575
+ }
618
576
  }]
619
577
  };
620
- }, [insights, cacheTTL]);
578
+ }, [
579
+ insights,
580
+ sources,
581
+ periodDays,
582
+ cacheTTL
583
+ ]);
621
584
  }
622
585
  //#endregion
623
- export { InsightWidget, InsightWidgetSkeleton, InsightsCache, InsightsProvider, InsightsScorecardView, useInsightsData, useInsightsEngine, useInsightsPlugin };
586
+ export { InsightWidget, InsightsEngine, InsightsProvider, InsightsRow, InsightsScorecardView, resolvePeriod, useInsightSource, useInsightsEngine, useInsightsPlugin };
624
587
 
625
588
  //# sourceMappingURL=index.es.js.map