@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/dist/index.es.js CHANGED
@@ -1,51 +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
+ }
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
- * In-memory cache for insight query results.
8
- * Supports TTL-based expiry and inflight request deduplication
9
- * to prevent redundant network requests when multiple widgets
10
- * 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.
11
34
  */
12
- var InsightsCache = class {
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
- constructor(ttl = 6e4) {
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
- get(key) {
20
- const entry = this.cache.get(key);
21
- if (!entry) return null;
22
- if (Date.now() - entry.timestamp > this.ttl) {
23
- this.cache.delete(key);
24
- return null;
25
- }
26
- return entry.data;
27
- }
28
- set(key, data) {
29
- this.cache.set(key, {
30
- data,
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
- getInflight(key) {
36
- 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;
37
67
  }
38
- 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
+ });
39
101
  this.inflight.set(key, promise);
102
+ return promise;
40
103
  }
41
- invalidate(key) {
42
- if (key) {
43
- this.cache.delete(key);
44
- this.inflight.delete(key);
45
- } else {
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
- * Manages a single `InsightsCache` instance shared by all insight widgets
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 cache = useMemo(() => new InsightsCache(cacheTTL), [cacheTTL]);
63
- 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
+ ]);
64
126
  return /* @__PURE__ */ jsx(InsightsContext.Provider, {
65
- value,
127
+ value: engine,
66
128
  children
67
129
  });
68
130
  }
69
131
  /**
70
- * Access the insights cache (for advanced usage).
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/engine/useInsightsData.ts
140
+ //#region src/validateConfig.ts
79
141
  /**
80
- * Hook that fetches and caches data for a single insight definition.
81
- *
82
- * Calls the definition's own `data()` callback and manages:
83
- * - TTL-based caching via InsightsCache
84
- * - Inflight request deduplication (multiple mounts of the same widget)
85
- * - 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.
86
157
  *
87
- * @param definition - The insight to fetch data for
88
- * @param collectionSlug - Optional collection context for cache key scoping
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 useInsightsData(definition, context) {
91
- const cache = useInsightsEngine()?.cache ?? null;
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 [data, setData] = useState(null);
95
- const [loading, setLoading] = useState(true);
96
- const [error, setError] = useState(null);
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 || !cache) return;
169
+ if (!authReady || !engine) return;
100
170
  let cancelled = false;
101
- const cached = cache.get(cacheKey);
102
- if (cached) {
103
- setData(cached);
104
- setLoading(false);
105
- return;
106
- }
107
- const inflight = cache.getInflight(cacheKey);
108
- if (inflight) {
109
- setLoading(true);
110
- inflight.then((result) => {
111
- if (!cancelled) setData(result);
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
- definition.id,
137
- definition.data,
138
- context.path,
139
- context.collectionSlug,
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
- data,
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/components/InsightsScorecardView.tsx
152
- function formatNumber(value, format) {
153
- 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) {
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
- let formatted = new Intl.NumberFormat("en-US", options).format(value);
164
- if (format?.showSign && value > 0) formatted = "+" + formatted;
165
- return formatted;
227
+ return numberFormat(locale, options).format(value);
166
228
  }
167
229
  /**
168
- * Scorecard widget for the Rebase design system.
230
+ * The change from `previous` to `current`, written as the comparison asks.
169
231
  *
170
- * Renders a single KPI metric with optional comparison value and icon.
171
- * Uses Tailwind `dark:` classes — no JS dark mode detection.
172
- * 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.
173
236
  */
174
- function InsightsScorecardView({ config, data, title, compact = false, embedded = false, fixedHeight }) {
175
- const containerRef = useRef(null);
176
- const [containerWidth, setContainerWidth] = useState(null);
177
- React.useLayoutEffect(() => {
178
- if (!containerRef.current) return;
179
- setContainerWidth(containerRef.current.offsetWidth);
180
- const observer = new ResizeObserver((entries) => {
181
- for (const entry of entries) setContainerWidth(entry.contentRect.width);
182
- });
183
- observer.observe(containerRef.current);
184
- return () => observer.disconnect();
185
- }, []);
186
- const mainValue = data[config.value.field];
187
- const formattedValue = typeof mainValue === "number" ? formatNumber(mainValue, config.value.format) : String(mainValue ?? "N/A");
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
- const isSmall = compact || containerWidth !== null && containerWidth < 200;
210
- const iconElement = config.icon ? getIcon(config.icon, "text-text-secondary dark:text-text-secondary-dark", void 0, 14) : null;
211
- if (compact) return /* @__PURE__ */ jsxs("div", {
212
- className: "flex items-baseline gap-1.5 min-w-0",
213
- children: [/* @__PURE__ */ jsx("span", {
214
- className: "text-[10px] uppercase tracking-wider text-surface-400 dark:text-surface-500 truncate",
215
- children: title
216
- }), /* @__PURE__ */ jsxs("div", {
217
- className: "flex items-baseline gap-1.5",
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/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
+ }
259
279
  /**
260
- * Skeleton loader for scorecard insight widgets — displays animated
261
- * shimmer placeholders that exactly match the final rendered layout
262
- * of InsightsScorecardView for a given config, preventing layout shift.
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 InsightWidgetSkeleton({ config, compact = false, embedded = false, fixedHeight }) {
275
- const hasComparison = Boolean(config.comparison);
276
- const hasIcon = Boolean(config.icon);
277
- const hasDateRange = Boolean(config.dateRange);
278
- if (compact) return /* @__PURE__ */ jsxs("div", {
279
- 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),
280
- children: [/* @__PURE__ */ jsx("div", {
281
- className: "bg-surface-200 dark:bg-surface-700 rounded-sm",
282
- style: {
283
- height: 14,
284
- width: 48
285
- }
286
- }), /* @__PURE__ */ jsxs("div", {
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
- return /* @__PURE__ */ jsx(StandardSkeleton, {
304
- hasComparison,
305
- hasIcon,
306
- hasDateRange,
307
- embedded,
308
- 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
+ })]
309
307
  });
308
+ return tooltip ? /* @__PURE__ */ jsx(Tooltip, {
309
+ title: previousLabel,
310
+ children: label
311
+ }) : label;
310
312
  }
311
313
  /**
312
- * Inner component for the standard scorecard skeleton.
314
+ * One insight: its label, its value and its change on the previous period.
313
315
  *
314
- * Mirrors InsightsScorecardView's layout by:
315
- * 1. Using the same ResizeObserver + containerWidth pattern for
316
- * responsive breakpoints (isSmall < 200px, isMedium < 300px).
317
- * 2. Using placeholder heights derived from the exact computed
318
- * Tailwind line-heights that InsightsScorecardView renders.
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 StandardSkeleton({ hasComparison, hasIcon, hasDateRange, embedded, fixedHeight }) {
323
- const containerRef = useRef(null);
324
- const [containerWidth, setContainerWidth] = useState(null);
325
- React.useLayoutEffect(() => {
326
- if (!containerRef.current) return;
327
- setContainerWidth(containerRef.current.offsetWidth);
328
- const observer = new ResizeObserver((entries) => {
329
- for (const entry of entries) setContainerWidth(entry.contentRect.width);
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__ */ jsxs("div", {
345
- className: `flex items-center justify-between ${isSmall ? "mb-1" : "mb-2"}`,
346
- children: [/* @__PURE__ */ jsxs("div", {
347
- className: "flex flex-col min-w-0",
348
- children: [/* @__PURE__ */ jsx("div", {
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("div", {
373
- className: "bg-surface-200 dark:bg-surface-700 rounded",
374
- style: {
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
- hasComparison && /* @__PURE__ */ jsx("div", {
380
- className: isSmall ? "mt-0.5" : "mt-1",
381
- children: /* @__PURE__ */ jsx("div", {
382
- className: "bg-surface-200/60 dark:bg-surface-700/60 rounded",
383
- style: {
384
- height: comparisonHeight,
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
- InsightWidgetSkeleton.displayName = "InsightWidgetSkeleton";
392
+ InsightsScorecardView.displayName = "InsightsScorecardView";
393
393
  //#endregion
394
394
  //#region src/components/InsightWidget.tsx
395
- /**
396
- * Compute a deterministic fixed height for a standard scorecard based
397
- * on which optional elements the config declares. This eliminates
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
- config: definition.scorecard,
452
- data: data.rows[0],
453
- title: definition.title,
454
- compact,
455
- embedded,
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 widgets inline within a home page collection card.
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({ slug, insights }) {
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((def) => /* @__PURE__ */ jsx(InsightWidget, {
473
- definition: def,
474
- collectionSlug: slug,
417
+ children: insights.map((definition) => /* @__PURE__ */ jsx(InsightWidget, {
418
+ definition,
475
419
  compact: true
476
- }, def.id))
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("div", {
491
- className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 pb-6",
492
- style: { minHeight: 92 },
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, path, parentCollectionSlugs, parentEntityIds }) {
471
+ function CollectionInsightsInline({ insights }) {
506
472
  if (!insights || insights.length === 0) return null;
507
- return /* @__PURE__ */ jsx("div", {
508
- className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 pb-4",
509
- children: insights.map((def) => /* @__PURE__ */ jsx(InsightWidget, {
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 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
527
488
  *
528
- * Collection-level insights (`collections.<slug>`) are the single source of truth:
529
- * scorecards render in the collection list view and are automatically extracted
530
- * 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.
531
493
  *
532
- * Each insight owns its own `data()` callback — use the Rebase client SDK,
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
- * 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
+ * },
541
505
  * insights: {
542
- * home: [
543
- * { id: "revenue", title: "Revenue", data: async () => ..., scorecard: { ... } },
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
- * { id: "total", title: "Total Orders", data: async () => ..., scorecard: { ... } },
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: (props) => /* @__PURE__ */ jsx(HomeInsightsSlot, {
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: { cacheTTL }
571
+ props: {
572
+ sources,
573
+ periodDays,
574
+ cacheTTL
575
+ }
603
576
  }]
604
577
  };
605
- }, [insights, cacheTTL]);
578
+ }, [
579
+ insights,
580
+ sources,
581
+ periodDays,
582
+ cacheTTL
583
+ ]);
606
584
  }
607
585
  //#endregion
608
- export { InsightWidget, InsightWidgetSkeleton, InsightsCache, InsightsProvider, InsightsScorecardView, useInsightsData, useInsightsEngine, useInsightsPlugin };
586
+ export { InsightWidget, InsightsEngine, InsightsProvider, InsightsRow, InsightsScorecardView, resolvePeriod, useInsightSource, useInsightsEngine, useInsightsPlugin };
609
587
 
610
588
  //# sourceMappingURL=index.es.js.map