@rebasepro/plugin-insights 0.17.3 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/index.es.js +1 -2
- package/dist/index.es.js.map +1 -1
- package/package.json +33 -18
- package/src/components/CollectionInsightsInline.tsx +0 -38
- package/src/components/HomeCardInsightSlot.tsx +0 -40
- package/src/components/HomeInsightsSlot.tsx +0 -30
- package/src/components/InsightWidget.tsx +0 -100
- package/src/components/InsightWidgetSkeleton.tsx +0 -218
- package/src/components/InsightsScorecardView.tsx +0 -169
- package/src/engine/InsightsCache.test.ts +0 -53
- package/src/engine/InsightsCache.ts +0 -53
- package/src/engine/InsightsProvider.tsx +0 -38
- package/src/engine/useInsightsData.ts +0 -103
- package/src/index.ts +0 -22
- package/src/types/engine.ts +0 -95
- package/src/types/index.ts +0 -5
- package/src/types/widgets.ts +0 -66
- package/src/useInsightsPlugin.tsx +0 -129
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import React, { useRef, useState } from "react";
|
|
2
|
-
import { cls, defaultBorderMixin } from "@rebasepro/ui";
|
|
3
|
-
import type { ScorecardConfig } from "../types";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Skeleton loader for scorecard insight widgets — displays animated
|
|
7
|
-
* shimmer placeholders that exactly match the final rendered layout
|
|
8
|
-
* of InsightsScorecardView for a given config, preventing layout shift.
|
|
9
|
-
*
|
|
10
|
-
* The skeleton receives the scorecard config so it can conditionally
|
|
11
|
-
* render placeholder lines for comparison, dateRange, and icon —
|
|
12
|
-
* only when the loaded view will also render them.
|
|
13
|
-
*
|
|
14
|
-
* The standard skeleton mirrors InsightsScorecardView's responsive
|
|
15
|
-
* container-width breakpoints (ResizeObserver → isSmall / isMedium)
|
|
16
|
-
* and uses placeholder heights that exactly match the **computed**
|
|
17
|
-
* Tailwind line-heights (accounting for `leading-*` overrides).
|
|
18
|
-
* This guarantees a pixel-perfect skeleton → loaded transition.
|
|
19
|
-
*/
|
|
20
|
-
export function InsightWidgetSkeleton({
|
|
21
|
-
config,
|
|
22
|
-
compact = false,
|
|
23
|
-
embedded = false,
|
|
24
|
-
fixedHeight
|
|
25
|
-
}: {
|
|
26
|
-
/** Scorecard config — used to match optional elements (comparison, dateRange, icon). */
|
|
27
|
-
config: ScorecardConfig;
|
|
28
|
-
compact?: boolean;
|
|
29
|
-
/** When true, skip own border since the parent card provides it. */
|
|
30
|
-
embedded?: boolean;
|
|
31
|
-
/** Explicit height to prevent layout shift between skeleton → loaded. */
|
|
32
|
-
fixedHeight?: number;
|
|
33
|
-
}) {
|
|
34
|
-
const hasComparison = Boolean(config.comparison);
|
|
35
|
-
const hasIcon = Boolean(config.icon);
|
|
36
|
-
const hasDateRange = Boolean(config.dateRange);
|
|
37
|
-
|
|
38
|
-
// ── Compact scorecard skeleton ──────────────────────────────────────
|
|
39
|
-
// Matches InsightsScorecardView compact layout:
|
|
40
|
-
// container: flex flex-col gap-0.5 px-2.5 py-2 rounded-md border
|
|
41
|
-
// title: text-[10px] uppercase → line-height ~14px
|
|
42
|
-
// value row: text-sm font-semibold → line-height 20px
|
|
43
|
-
// + optional comparison text-[10px] inside value row
|
|
44
|
-
if (compact) {
|
|
45
|
-
return (
|
|
46
|
-
<div
|
|
47
|
-
className={cls(
|
|
48
|
-
"animate-pulse",
|
|
49
|
-
embedded
|
|
50
|
-
? "h-full px-2.5 py-2"
|
|
51
|
-
: "flex flex-col gap-0.5 rounded-md bg-transparent border min-w-0 px-2.5 py-2",
|
|
52
|
-
!embedded && defaultBorderMixin
|
|
53
|
-
)}
|
|
54
|
-
>
|
|
55
|
-
{/* Title line */}
|
|
56
|
-
<div className="bg-surface-200 dark:bg-surface-700 rounded-sm"
|
|
57
|
-
style={{ height: 14,
|
|
58
|
-
width: 48 }}
|
|
59
|
-
/>
|
|
60
|
-
{/* Value + optional comparison row */}
|
|
61
|
-
<div className="flex items-baseline gap-1.5">
|
|
62
|
-
<div className="bg-surface-200 dark:bg-surface-700 rounded-sm"
|
|
63
|
-
style={{ height: 20,
|
|
64
|
-
width: 40 }}
|
|
65
|
-
/>
|
|
66
|
-
{hasComparison && (
|
|
67
|
-
<div className="bg-surface-200/60 dark:bg-surface-700/60 rounded-sm"
|
|
68
|
-
style={{ height: 14,
|
|
69
|
-
width: 28 }}
|
|
70
|
-
/>
|
|
71
|
-
)}
|
|
72
|
-
</div>
|
|
73
|
-
</div>
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── Standard scorecard skeleton ─────────────────────────────────────
|
|
78
|
-
return <StandardSkeleton
|
|
79
|
-
hasComparison={hasComparison}
|
|
80
|
-
hasIcon={hasIcon}
|
|
81
|
-
hasDateRange={hasDateRange}
|
|
82
|
-
embedded={embedded}
|
|
83
|
-
fixedHeight={fixedHeight}
|
|
84
|
-
/>;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ── Tailwind line-height reference ──────────────────────────────────────
|
|
88
|
-
// All heights below are the **computed** CSS line-heights, accounting
|
|
89
|
-
// for `leading-*` overrides that InsightsScorecardView applies.
|
|
90
|
-
//
|
|
91
|
-
// Title:
|
|
92
|
-
// text-xs (12px) + leading-snug (1.375) → 12 × 1.375 = 16.5px
|
|
93
|
-
// text-[11px] + leading-snug (1.375) → 11 × 1.375 = 15.125px
|
|
94
|
-
//
|
|
95
|
-
// DateRange:
|
|
96
|
-
// text-[10px] with no explicit LH → normal ≈ 14px (browser)
|
|
97
|
-
//
|
|
98
|
-
// Value:
|
|
99
|
-
// text-2xl (24px) + leading-tight (1.25) → 24 × 1.25 = 30px
|
|
100
|
-
// text-xl (20px) + leading-tight (1.25) → 20 × 1.25 = 25px
|
|
101
|
-
// text-lg (18px) + leading-tight (1.25) → 18 × 1.25 = 22.5px
|
|
102
|
-
//
|
|
103
|
-
// Comparison:
|
|
104
|
-
// text-xs (12px) → built-in LH 1rem = 16px
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Inner component for the standard scorecard skeleton.
|
|
108
|
-
*
|
|
109
|
-
* Mirrors InsightsScorecardView's layout by:
|
|
110
|
-
* 1. Using the same ResizeObserver + containerWidth pattern for
|
|
111
|
-
* responsive breakpoints (isSmall < 200px, isMedium < 300px).
|
|
112
|
-
* 2. Using placeholder heights derived from the exact computed
|
|
113
|
-
* Tailwind line-heights that InsightsScorecardView renders.
|
|
114
|
-
* 3. Matching all container classes, margins, paddings, and flex
|
|
115
|
-
* layout properties identically.
|
|
116
|
-
*/
|
|
117
|
-
function StandardSkeleton({
|
|
118
|
-
hasComparison,
|
|
119
|
-
hasIcon,
|
|
120
|
-
hasDateRange,
|
|
121
|
-
embedded,
|
|
122
|
-
fixedHeight
|
|
123
|
-
}: {
|
|
124
|
-
hasComparison: boolean;
|
|
125
|
-
hasIcon: boolean;
|
|
126
|
-
hasDateRange: boolean;
|
|
127
|
-
embedded: boolean;
|
|
128
|
-
fixedHeight?: number;
|
|
129
|
-
}) {
|
|
130
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
131
|
-
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
|
132
|
-
|
|
133
|
-
React.useLayoutEffect(() => {
|
|
134
|
-
if (!containerRef.current) return;
|
|
135
|
-
setContainerWidth(containerRef.current.offsetWidth);
|
|
136
|
-
const observer = new ResizeObserver((entries) => {
|
|
137
|
-
for (const entry of entries) {
|
|
138
|
-
setContainerWidth(entry.contentRect.width);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
observer.observe(containerRef.current);
|
|
142
|
-
return () => observer.disconnect();
|
|
143
|
-
}, []);
|
|
144
|
-
|
|
145
|
-
// Mirror InsightsScorecardView's responsive breakpoints exactly
|
|
146
|
-
const isSmall = containerWidth !== null && containerWidth < 200;
|
|
147
|
-
|
|
148
|
-
// Computed line-heights for each breakpoint
|
|
149
|
-
// Title: text-xs + leading-snug = 16.5px, text-[11px] + leading-snug = 15.125px
|
|
150
|
-
const titleHeight = isSmall ? 15 : 16.5;
|
|
151
|
-
// Value: leading-tight (×1.25) applied on top of font-size
|
|
152
|
-
const valueHeight = isSmall
|
|
153
|
-
? 22.5 // text-lg: 18 × 1.25
|
|
154
|
-
: (containerWidth !== null && containerWidth < 300)
|
|
155
|
-
? 25 // text-xl: 20 × 1.25
|
|
156
|
-
: 30; // text-2xl: 24 × 1.25
|
|
157
|
-
// Comparison: text-xs = 12px / 16px line-height (no leading override)
|
|
158
|
-
const comparisonHeight = 16;
|
|
159
|
-
// Icon: 14px when small, 18px otherwise
|
|
160
|
-
const iconSize = isSmall ? 14 : 18;
|
|
161
|
-
|
|
162
|
-
const baseClass = embedded
|
|
163
|
-
? `flex flex-col min-w-0 h-full ${isSmall ? "px-3.5 py-3" : "px-5 py-4"}`
|
|
164
|
-
: cls("rounded-lg flex flex-col min-w-0 bg-transparent border", defaultBorderMixin, isSmall ? "px-3.5 py-3" : "px-5 py-4");
|
|
165
|
-
|
|
166
|
-
return (
|
|
167
|
-
<div
|
|
168
|
-
ref={containerRef}
|
|
169
|
-
className={cls("animate-pulse", baseClass)}
|
|
170
|
-
style={embedded ? undefined : fixedHeight ? { height: fixedHeight } : { minHeight: isSmall ? 68 : 92 }}
|
|
171
|
-
>
|
|
172
|
-
{/* Title row — identical flex structure to InsightsScorecardView */}
|
|
173
|
-
<div className={`flex items-center justify-between ${isSmall ? "mb-1" : "mb-2"}`}>
|
|
174
|
-
<div className="flex flex-col min-w-0">
|
|
175
|
-
{/* Title placeholder */}
|
|
176
|
-
<div className="bg-surface-200 dark:bg-surface-700 rounded"
|
|
177
|
-
style={{ height: titleHeight,
|
|
178
|
-
width: "60%" }}
|
|
179
|
-
/>
|
|
180
|
-
{/* DateRange — hidden when isSmall, same as real view (line 134) */}
|
|
181
|
-
{hasDateRange && !isSmall && (
|
|
182
|
-
<div className="bg-surface-200/60 dark:bg-surface-700/60 rounded mt-0.5"
|
|
183
|
-
style={{ height: 14,
|
|
184
|
-
width: "40%" }}
|
|
185
|
-
/>
|
|
186
|
-
)}
|
|
187
|
-
</div>
|
|
188
|
-
{/* Icon placeholder — same wrapper as real view */}
|
|
189
|
-
{hasIcon && (
|
|
190
|
-
<span className="ml-2 shrink-0">
|
|
191
|
-
<div className="bg-surface-200 dark:bg-surface-700 rounded"
|
|
192
|
-
style={{ height: iconSize,
|
|
193
|
-
width: iconSize }}
|
|
194
|
-
/>
|
|
195
|
-
</span>
|
|
196
|
-
)}
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
{/* Main value placeholder */}
|
|
200
|
-
<div className="bg-surface-200 dark:bg-surface-700 rounded"
|
|
201
|
-
style={{ height: valueHeight,
|
|
202
|
-
width: "40%" }}
|
|
203
|
-
/>
|
|
204
|
-
|
|
205
|
-
{/* Comparison placeholder */}
|
|
206
|
-
{hasComparison && (
|
|
207
|
-
<div className={isSmall ? "mt-0.5" : "mt-1"}>
|
|
208
|
-
<div className="bg-surface-200/60 dark:bg-surface-700/60 rounded"
|
|
209
|
-
style={{ height: comparisonHeight,
|
|
210
|
-
width: "25%" }}
|
|
211
|
-
/>
|
|
212
|
-
</div>
|
|
213
|
-
)}
|
|
214
|
-
</div>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
InsightWidgetSkeleton.displayName = "InsightWidgetSkeleton";
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import React, { useRef, useState } from "react";
|
|
2
|
-
import { getIcon } from "@rebasepro/app";
|
|
3
|
-
import { cls, defaultBorderMixin } from "@rebasepro/ui";
|
|
4
|
-
import type { DataRow, ScorecardConfig, ScorecardFormat } from "../types";
|
|
5
|
-
|
|
6
|
-
function formatNumber(value: number, format?: ScorecardFormat): string {
|
|
7
|
-
if (value === null || value === undefined) return "N/A";
|
|
8
|
-
|
|
9
|
-
const options: Intl.NumberFormatOptions = {
|
|
10
|
-
style: format?.style ?? "decimal",
|
|
11
|
-
notation: format?.notation ?? "standard"
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
// Only pin the fraction digits when the config asks for a specific count.
|
|
15
|
-
// Without this, Intl's per-style defaults apply: integers stay integers
|
|
16
|
-
// ("80", not "80.0") while currency keeps its two decimals ("$452.95").
|
|
17
|
-
if (format?.decimals !== undefined) {
|
|
18
|
-
options.maximumFractionDigits = format.decimals;
|
|
19
|
-
options.minimumFractionDigits = format.decimals;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (format?.style === "currency") {
|
|
23
|
-
options.currency = format.currency ?? "USD";
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
let formatted = new Intl.NumberFormat("en-US", options).format(value);
|
|
27
|
-
|
|
28
|
-
if (format?.showSign && value > 0) {
|
|
29
|
-
formatted = "+" + formatted;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return formatted;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Scorecard widget for the Rebase design system.
|
|
37
|
-
*
|
|
38
|
-
* Renders a single KPI metric with optional comparison value and icon.
|
|
39
|
-
* Uses Tailwind `dark:` classes — no JS dark mode detection.
|
|
40
|
-
* Icons are resolved via `getIcon` from `@rebasepro/app`.
|
|
41
|
-
*/
|
|
42
|
-
export function InsightsScorecardView({
|
|
43
|
-
config,
|
|
44
|
-
data,
|
|
45
|
-
title,
|
|
46
|
-
compact = false,
|
|
47
|
-
embedded = false,
|
|
48
|
-
fixedHeight
|
|
49
|
-
}: {
|
|
50
|
-
config: ScorecardConfig;
|
|
51
|
-
data: DataRow;
|
|
52
|
-
title: string;
|
|
53
|
-
compact?: boolean;
|
|
54
|
-
/** When true, skip own border/bg since the parent card provides them. */
|
|
55
|
-
embedded?: boolean;
|
|
56
|
-
/** Explicit height to prevent layout shift between skeleton → loaded. */
|
|
57
|
-
fixedHeight?: number;
|
|
58
|
-
}) {
|
|
59
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
60
|
-
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
|
61
|
-
|
|
62
|
-
React.useLayoutEffect(() => {
|
|
63
|
-
if (!containerRef.current) return;
|
|
64
|
-
// Read initial width synchronously before paint
|
|
65
|
-
setContainerWidth(containerRef.current.offsetWidth);
|
|
66
|
-
const observer = new ResizeObserver((entries) => {
|
|
67
|
-
for (const entry of entries) {
|
|
68
|
-
setContainerWidth(entry.contentRect.width);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
observer.observe(containerRef.current);
|
|
72
|
-
return () => observer.disconnect();
|
|
73
|
-
}, []);
|
|
74
|
-
|
|
75
|
-
const mainValue = data[config.value.field];
|
|
76
|
-
const formattedValue = typeof mainValue === "number"
|
|
77
|
-
? formatNumber(mainValue, config.value.format)
|
|
78
|
-
: String(mainValue ?? "N/A");
|
|
79
|
-
|
|
80
|
-
// Comparison rendering
|
|
81
|
-
let comparisonElement: React.ReactNode = null;
|
|
82
|
-
if (config.comparison) {
|
|
83
|
-
const comparisonValue = data[config.comparison.field];
|
|
84
|
-
if (typeof comparisonValue === "number") {
|
|
85
|
-
const formattedComparison = formatNumber(comparisonValue, config.comparison.format);
|
|
86
|
-
const isPositive = comparisonValue > 0;
|
|
87
|
-
const isNegative = comparisonValue < 0;
|
|
88
|
-
|
|
89
|
-
let colorClass = "text-surface-500 dark:text-surface-400";
|
|
90
|
-
if (config.comparison.intent === "increase_is_good") {
|
|
91
|
-
if (isPositive) colorClass = "text-emerald-500";
|
|
92
|
-
if (isNegative) colorClass = "text-red-500";
|
|
93
|
-
} else if (config.comparison.intent === "decrease_is_good") {
|
|
94
|
-
if (isPositive) colorClass = "text-red-500";
|
|
95
|
-
if (isNegative) colorClass = "text-emerald-500";
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
comparisonElement = (
|
|
99
|
-
<span className={`font-medium ${compact ? "text-[10px]" : "text-xs"} ${colorClass}`}>
|
|
100
|
-
{formattedComparison}
|
|
101
|
-
</span>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const isSmall = compact || (containerWidth !== null && containerWidth < 200);
|
|
107
|
-
|
|
108
|
-
// Resolve icon via getIcon (Lucide-based resolution)
|
|
109
|
-
const iconElement = config.icon
|
|
110
|
-
? getIcon(config.icon, "text-surface-400 dark:text-surface-500", undefined, isSmall ? 14 : 18)
|
|
111
|
-
: null;
|
|
112
|
-
|
|
113
|
-
// ── Compact card-inline layout ──────────────────────────────────────
|
|
114
|
-
if (compact) {
|
|
115
|
-
return (
|
|
116
|
-
<div className={cls("flex flex-col gap-0.5 px-2.5 py-2 rounded-md bg-transparent border min-w-0", defaultBorderMixin)}>
|
|
117
|
-
<span className="text-[10px] uppercase tracking-wider text-surface-400 dark:text-surface-500 truncate">
|
|
118
|
-
{title}
|
|
119
|
-
</span>
|
|
120
|
-
<div className="flex items-baseline gap-1.5">
|
|
121
|
-
<span className="text-sm font-semibold tabular-nums text-surface-800 dark:text-surface-100">
|
|
122
|
-
{formattedValue}
|
|
123
|
-
</span>
|
|
124
|
-
{comparisonElement}
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ── Standard scorecard layout ───────────────────────────────────────
|
|
131
|
-
const baseClass = embedded
|
|
132
|
-
? `flex flex-col min-w-0 h-full ${isSmall ? "px-3.5 py-3" : "px-5 py-4"}`
|
|
133
|
-
: cls("rounded-lg flex flex-col min-w-0 bg-transparent border", defaultBorderMixin, isSmall ? "px-3.5 py-3" : "px-5 py-4");
|
|
134
|
-
|
|
135
|
-
return (
|
|
136
|
-
<div ref={containerRef} className={baseClass} style={embedded ? undefined : fixedHeight ? { height: fixedHeight } : { minHeight: isSmall ? 68 : 92 }}>
|
|
137
|
-
{/* Title row */}
|
|
138
|
-
<div className={`flex items-center justify-between ${isSmall ? "mb-1" : "mb-2"}`}>
|
|
139
|
-
<div className="flex flex-col min-w-0">
|
|
140
|
-
<span className={`font-medium leading-snug truncate text-surface-500 dark:text-surface-400 ${isSmall ? "text-[11px]" : "text-xs"}`}>
|
|
141
|
-
{title}
|
|
142
|
-
</span>
|
|
143
|
-
{config.dateRange && !isSmall && (
|
|
144
|
-
<span className="text-[10px] text-surface-400 dark:text-surface-500 truncate mt-0.5">
|
|
145
|
-
{config.dateRange}
|
|
146
|
-
</span>
|
|
147
|
-
)}
|
|
148
|
-
</div>
|
|
149
|
-
{iconElement && (
|
|
150
|
-
<span className="ml-2 shrink-0">{iconElement}</span>
|
|
151
|
-
)}
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
{/* Main value */}
|
|
155
|
-
<div className={`font-semibold leading-tight tracking-tight break-all text-surface-800 dark:text-surface-100 ${isSmall ? "text-lg" : (containerWidth !== null && containerWidth < 300) ? "text-xl" : "text-2xl"}`}>
|
|
156
|
-
{formattedValue}
|
|
157
|
-
</div>
|
|
158
|
-
|
|
159
|
-
{/* Comparison */}
|
|
160
|
-
{comparisonElement && (
|
|
161
|
-
<div className={isSmall ? "mt-0.5" : "mt-1"}>
|
|
162
|
-
{comparisonElement}
|
|
163
|
-
</div>
|
|
164
|
-
)}
|
|
165
|
-
</div>
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
InsightsScorecardView.displayName = "InsightsScorecardView";
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { InsightsCache } from "./InsightsCache";
|
|
2
|
-
import type { InsightDataResult } from "../types";
|
|
3
|
-
|
|
4
|
-
describe("InsightsCache", () => {
|
|
5
|
-
it("should set and get values from cache", () => {
|
|
6
|
-
const cache = new InsightsCache();
|
|
7
|
-
const data: InsightDataResult = {
|
|
8
|
-
rows: [{ count: 10 }]
|
|
9
|
-
};
|
|
10
|
-
cache.set("query_key", data);
|
|
11
|
-
expect(cache.get("query_key")).toEqual(data);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("should return null for expired keys", () => {
|
|
15
|
-
const cache = new InsightsCache(-1); // -1ms TTL to force immediate expiry
|
|
16
|
-
const data: InsightDataResult = {
|
|
17
|
-
rows: [{ count: 10 }]
|
|
18
|
-
};
|
|
19
|
-
cache.set("query_key", data);
|
|
20
|
-
// Expired immediately
|
|
21
|
-
expect(cache.get("query_key")).toBeNull();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("should manage inflight requests", () => {
|
|
25
|
-
const cache = new InsightsCache();
|
|
26
|
-
const promise = Promise.resolve<InsightDataResult>({
|
|
27
|
-
rows: []
|
|
28
|
-
});
|
|
29
|
-
expect(cache.getInflight("query_key")).toBeNull();
|
|
30
|
-
|
|
31
|
-
cache.setInflight("query_key", promise);
|
|
32
|
-
expect(cache.getInflight("query_key")).toBe(promise);
|
|
33
|
-
|
|
34
|
-
// Setting a result should remove the inflight reference
|
|
35
|
-
const data: InsightDataResult = { rows: [] };
|
|
36
|
-
cache.set("query_key", data);
|
|
37
|
-
expect(cache.getInflight("query_key")).toBeNull();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("should invalidate entries", () => {
|
|
41
|
-
const cache = new InsightsCache();
|
|
42
|
-
const data: InsightDataResult = { rows: [] };
|
|
43
|
-
cache.set("key_1", data);
|
|
44
|
-
cache.set("key_2", data);
|
|
45
|
-
|
|
46
|
-
cache.invalidate("key_1");
|
|
47
|
-
expect(cache.get("key_1")).toBeNull();
|
|
48
|
-
expect(cache.get("key_2")).toEqual(data);
|
|
49
|
-
|
|
50
|
-
cache.invalidate();
|
|
51
|
-
expect(cache.get("key_2")).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { InsightDataResult } from "../types";
|
|
2
|
-
|
|
3
|
-
interface CacheEntry {
|
|
4
|
-
data: InsightDataResult;
|
|
5
|
-
timestamp: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* In-memory cache for insight query results.
|
|
10
|
-
* Supports TTL-based expiry and inflight request deduplication
|
|
11
|
-
* to prevent redundant network requests when multiple widgets
|
|
12
|
-
* share the same query.
|
|
13
|
-
*/
|
|
14
|
-
export class InsightsCache {
|
|
15
|
-
private cache = new Map<string, CacheEntry>();
|
|
16
|
-
private inflight = new Map<string, Promise<InsightDataResult>>();
|
|
17
|
-
|
|
18
|
-
constructor(private ttl = 60_000) {}
|
|
19
|
-
|
|
20
|
-
get(key: string): InsightDataResult | null {
|
|
21
|
-
const entry = this.cache.get(key);
|
|
22
|
-
if (!entry) return null;
|
|
23
|
-
if (Date.now() - entry.timestamp > this.ttl) {
|
|
24
|
-
this.cache.delete(key);
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
return entry.data;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
set(key: string, data: InsightDataResult): void {
|
|
31
|
-
this.cache.set(key, { data,
|
|
32
|
-
timestamp: Date.now() });
|
|
33
|
-
this.inflight.delete(key);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
getInflight(key: string): Promise<InsightDataResult> | null {
|
|
37
|
-
return this.inflight.get(key) ?? null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
setInflight(key: string, promise: Promise<InsightDataResult>): void {
|
|
41
|
-
this.inflight.set(key, promise);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
invalidate(key?: string): void {
|
|
45
|
-
if (key) {
|
|
46
|
-
this.cache.delete(key);
|
|
47
|
-
this.inflight.delete(key);
|
|
48
|
-
} else {
|
|
49
|
-
this.cache.clear();
|
|
50
|
-
this.inflight.clear();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import React, { createContext, useContext, useMemo, type PropsWithChildren } from "react";
|
|
2
|
-
import { InsightsCache } from "./InsightsCache";
|
|
3
|
-
|
|
4
|
-
interface InsightsContextValue {
|
|
5
|
-
cache: InsightsCache;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const InsightsContext = createContext<InsightsContextValue | null>(null);
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Root-level provider for the insights data engine.
|
|
12
|
-
* Injected automatically by the plugin via `providers: [{ scope: "root" }]`.
|
|
13
|
-
*
|
|
14
|
-
* Manages a single `InsightsCache` instance shared by all insight widgets
|
|
15
|
-
* for TTL-based caching and inflight request deduplication.
|
|
16
|
-
*/
|
|
17
|
-
export function InsightsProvider({
|
|
18
|
-
cacheTTL,
|
|
19
|
-
children
|
|
20
|
-
}: PropsWithChildren<{ cacheTTL?: number }>) {
|
|
21
|
-
const cache = useMemo(() => new InsightsCache(cacheTTL), [cacheTTL]);
|
|
22
|
-
const value = useMemo(() => ({ cache }), [cache]);
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<InsightsContext.Provider value={value}>
|
|
26
|
-
{children}
|
|
27
|
-
</InsightsContext.Provider>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Access the insights cache (for advanced usage).
|
|
33
|
-
* Returns null when called outside of an `InsightsProvider`
|
|
34
|
-
* (e.g. during auth-loading phase before plugin providers mount).
|
|
35
|
-
*/
|
|
36
|
-
export function useInsightsEngine(): InsightsContextValue | null {
|
|
37
|
-
return useContext(InsightsContext);
|
|
38
|
-
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
|
-
import type { InsightDefinition, InsightDataResult, InsightContext } from "../types";
|
|
3
|
-
import { useInsightsEngine } from "./InsightsProvider";
|
|
4
|
-
import { useAuthController } from "@rebasepro/app";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Hook that fetches and caches data for a single insight definition.
|
|
8
|
-
*
|
|
9
|
-
* Calls the definition's own `data()` callback and manages:
|
|
10
|
-
* - TTL-based caching via InsightsCache
|
|
11
|
-
* - Inflight request deduplication (multiple mounts of the same widget)
|
|
12
|
-
* - Loading and error state management
|
|
13
|
-
*
|
|
14
|
-
* @param definition - The insight to fetch data for
|
|
15
|
-
* @param collectionSlug - Optional collection context for cache key scoping
|
|
16
|
-
*/
|
|
17
|
-
export function useInsightsData(
|
|
18
|
-
definition: InsightDefinition,
|
|
19
|
-
context: InsightContext
|
|
20
|
-
): {
|
|
21
|
-
data: InsightDataResult | null;
|
|
22
|
-
loading: boolean;
|
|
23
|
-
error: Error | null;
|
|
24
|
-
} {
|
|
25
|
-
const engine = useInsightsEngine();
|
|
26
|
-
const cache = engine?.cache ?? null;
|
|
27
|
-
const { initialLoading, authLoading, user, loginSkipped } = useAuthController();
|
|
28
|
-
const authReady = !initialLoading && !authLoading && (Boolean(user) || loginSkipped);
|
|
29
|
-
const [data, setData] = useState<InsightDataResult | null>(null);
|
|
30
|
-
const [loading, setLoading] = useState(true);
|
|
31
|
-
const [error, setError] = useState<Error | null>(null);
|
|
32
|
-
|
|
33
|
-
const cacheKey = `${definition.id}:${context.path ?? context.collectionSlug ?? "global"}`;
|
|
34
|
-
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
// Keep showing skeleton until both auth and engine are ready
|
|
37
|
-
if (!authReady || !cache) {
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let cancelled = false;
|
|
42
|
-
|
|
43
|
-
// 1. Check cache
|
|
44
|
-
const cached = cache.get(cacheKey);
|
|
45
|
-
if (cached) {
|
|
46
|
-
setData(cached);
|
|
47
|
-
setLoading(false);
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// 2. Check inflight — deduplicate concurrent requests for the same widget
|
|
52
|
-
const inflight = cache.getInflight(cacheKey);
|
|
53
|
-
if (inflight) {
|
|
54
|
-
setLoading(true);
|
|
55
|
-
inflight
|
|
56
|
-
.then((result) => {
|
|
57
|
-
if (!cancelled) {
|
|
58
|
-
setData(result);
|
|
59
|
-
}
|
|
60
|
-
})
|
|
61
|
-
.catch((err) => {
|
|
62
|
-
if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));
|
|
63
|
-
})
|
|
64
|
-
.finally(() => {
|
|
65
|
-
if (!cancelled) setLoading(false);
|
|
66
|
-
});
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 3. Fresh fetch — invoke the definition's own data callback
|
|
71
|
-
setLoading(true);
|
|
72
|
-
setError(null);
|
|
73
|
-
|
|
74
|
-
const promise = definition.data(context);
|
|
75
|
-
|
|
76
|
-
cache.setInflight(cacheKey, promise);
|
|
77
|
-
|
|
78
|
-
promise
|
|
79
|
-
.then((result) => {
|
|
80
|
-
cache.set(cacheKey, result);
|
|
81
|
-
if (!cancelled) {
|
|
82
|
-
setData(result);
|
|
83
|
-
}
|
|
84
|
-
})
|
|
85
|
-
.catch((err) => {
|
|
86
|
-
cache.invalidate(cacheKey);
|
|
87
|
-
if (!cancelled) {
|
|
88
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
89
|
-
}
|
|
90
|
-
})
|
|
91
|
-
.finally(() => {
|
|
92
|
-
if (!cancelled) setLoading(false);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
return () => {
|
|
96
|
-
cancelled = true;
|
|
97
|
-
};
|
|
98
|
-
}, [definition.id, definition.data, context.path, context.collectionSlug, cacheKey, cache, authReady]);
|
|
99
|
-
|
|
100
|
-
return { data,
|
|
101
|
-
loading,
|
|
102
|
-
error };
|
|
103
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// ── Types ─────────────────────────────────────────────────────────────
|
|
2
|
-
export type {
|
|
3
|
-
DataRow,
|
|
4
|
-
ScorecardFormat,
|
|
5
|
-
ScorecardConfig,
|
|
6
|
-
InsightDataResult,
|
|
7
|
-
InsightDefinition,
|
|
8
|
-
InsightsPluginConfig
|
|
9
|
-
} from "./types";
|
|
10
|
-
|
|
11
|
-
// ── Plugin ────────────────────────────────────────────────────────────
|
|
12
|
-
export { useInsightsPlugin } from "./useInsightsPlugin";
|
|
13
|
-
|
|
14
|
-
// ── Engine (for advanced usage) ───────────────────────────────────────
|
|
15
|
-
export { InsightsProvider, useInsightsEngine } from "./engine/InsightsProvider";
|
|
16
|
-
export { InsightsCache } from "./engine/InsightsCache";
|
|
17
|
-
export { useInsightsData } from "./engine/useInsightsData";
|
|
18
|
-
|
|
19
|
-
// ── Widget components (for custom layouts) ────────────────────────────
|
|
20
|
-
export { InsightsScorecardView } from "./components/InsightsScorecardView";
|
|
21
|
-
export { InsightWidget } from "./components/InsightWidget";
|
|
22
|
-
export { InsightWidgetSkeleton } from "./components/InsightWidgetSkeleton";
|