@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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.es.js","names":[],"sources":["../src/engine/InsightsCache.ts","../src/engine/InsightsProvider.tsx","../src/engine/useInsightsData.ts","../src/components/InsightsScorecardView.tsx","../src/components/InsightWidgetSkeleton.tsx","../src/components/InsightWidget.tsx","../src/components/HomeCardInsightSlot.tsx","../src/components/HomeInsightsSlot.tsx","../src/components/CollectionInsightsInline.tsx","../src/useInsightsPlugin.tsx"],"sourcesContent":["import type { InsightContext, InsightDataResult } from \"../types\";\n\n/**\n * The cache key for one insight, in one scope, for one user.\n *\n * The cache lives at the root of the app and outlives a sign-out, and a figure\n * is computed under the permissions and row-level security of whoever asked\n * for it. A key without the user served the previous account's numbers to the\n * next one that signed in on the same tab, until the TTL ran out.\n */\nexport function insightCacheKey(definitionId: string, context: InsightContext, userId: string | null): string {\n return JSON.stringify([userId, definitionId, context.path ?? context.collectionSlug ?? \"global\"]);\n}\n\ninterface CacheEntry {\n data: InsightDataResult;\n timestamp: number;\n}\n\n/**\n * In-memory cache for insight query results.\n * Supports TTL-based expiry and inflight request deduplication\n * to prevent redundant network requests when multiple widgets\n * share the same query.\n */\nexport class InsightsCache {\n private cache = new Map<string, CacheEntry>();\n private inflight = new Map<string, Promise<InsightDataResult>>();\n\n constructor(private ttl = 60_000) {}\n\n get(key: string): InsightDataResult | null {\n const entry = this.cache.get(key);\n if (!entry) return null;\n if (Date.now() - entry.timestamp > this.ttl) {\n this.cache.delete(key);\n return null;\n }\n return entry.data;\n }\n\n set(key: string, data: InsightDataResult): void {\n this.cache.set(key, { data,\ntimestamp: Date.now() });\n this.inflight.delete(key);\n }\n\n getInflight(key: string): Promise<InsightDataResult> | null {\n return this.inflight.get(key) ?? null;\n }\n\n setInflight(key: string, promise: Promise<InsightDataResult>): void {\n this.inflight.set(key, promise);\n }\n\n invalidate(key?: string): void {\n if (key) {\n this.cache.delete(key);\n this.inflight.delete(key);\n } else {\n this.cache.clear();\n this.inflight.clear();\n }\n }\n}\n","import React, { createContext, useContext, useMemo, type PropsWithChildren } from \"react\";\nimport { InsightsCache } from \"./InsightsCache\";\n\ninterface InsightsContextValue {\n cache: InsightsCache;\n}\n\nconst InsightsContext = createContext<InsightsContextValue | null>(null);\n\n/**\n * Root-level provider for the insights data engine.\n * Injected automatically by the plugin via `providers: [{ scope: \"root\" }]`.\n *\n * Manages a single `InsightsCache` instance shared by all insight widgets\n * for TTL-based caching and inflight request deduplication.\n */\nexport function InsightsProvider({\n cacheTTL,\n children\n}: PropsWithChildren<{ cacheTTL?: number }>) {\n const cache = useMemo(() => new InsightsCache(cacheTTL), [cacheTTL]);\n const value = useMemo(() => ({ cache }), [cache]);\n\n return (\n <InsightsContext.Provider value={value}>\n {children}\n </InsightsContext.Provider>\n );\n}\n\n/**\n * Access the insights cache (for advanced usage).\n * Returns null when called outside of an `InsightsProvider`\n * (e.g. during auth-loading phase before plugin providers mount).\n */\nexport function useInsightsEngine(): InsightsContextValue | null {\n return useContext(InsightsContext);\n}\n","import { useEffect, useState } from \"react\";\nimport type { InsightDefinition, InsightDataResult, InsightContext } from \"../types\";\nimport { useInsightsEngine } from \"./InsightsProvider\";\nimport { insightCacheKey } from \"./InsightsCache\";\nimport { useAuthController } from \"@rebasepro/app\";\n\n/**\n * Hook that fetches and caches data for a single insight definition.\n *\n * Calls the definition's own `data()` callback and manages:\n * - TTL-based caching via InsightsCache\n * - Inflight request deduplication (multiple mounts of the same widget)\n * - Loading and error state management\n *\n * @param definition - The insight to fetch data for\n * @param context - The collection scope; with the signed-in user, it scopes the cache key\n */\nexport function useInsightsData(\n definition: InsightDefinition,\n context: InsightContext\n): {\n data: InsightDataResult | null;\n loading: boolean;\n error: Error | null;\n} {\n const engine = useInsightsEngine();\n const cache = engine?.cache ?? null;\n const { initialLoading, authLoading, user, loginSkipped } = useAuthController();\n const authReady = !initialLoading && !authLoading && (Boolean(user) || loginSkipped);\n const [data, setData] = useState<InsightDataResult | null>(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<Error | null>(null);\n\n const cacheKey = insightCacheKey(definition.id, context, user?.uid ?? null);\n\n useEffect(() => {\n // Keep showing skeleton until both auth and engine are ready\n if (!authReady || !cache) {\n return;\n }\n\n let cancelled = false;\n\n // 1. Check cache\n const cached = cache.get(cacheKey);\n if (cached) {\n setData(cached);\n setLoading(false);\n return;\n }\n\n // 2. Check inflight — deduplicate concurrent requests for the same widget\n const inflight = cache.getInflight(cacheKey);\n if (inflight) {\n setLoading(true);\n inflight\n .then((result) => {\n if (!cancelled) {\n setData(result);\n }\n })\n .catch((err) => {\n if (!cancelled) setError(err instanceof Error ? err : new Error(String(err)));\n })\n .finally(() => {\n if (!cancelled) setLoading(false);\n });\n return;\n }\n\n // 3. Fresh fetch — invoke the definition's own data callback\n setLoading(true);\n setError(null);\n\n const promise = definition.data(context);\n\n cache.setInflight(cacheKey, promise);\n\n promise\n .then((result) => {\n cache.set(cacheKey, result);\n if (!cancelled) {\n setData(result);\n }\n })\n .catch((err) => {\n cache.invalidate(cacheKey);\n if (!cancelled) {\n setError(err instanceof Error ? err : new Error(String(err)));\n }\n })\n .finally(() => {\n if (!cancelled) setLoading(false);\n });\n\n return () => {\n cancelled = true;\n };\n }, [definition.id, definition.data, context.path, context.collectionSlug, cacheKey, cache, authReady]);\n\n return { data,\nloading,\nerror };\n}\n","import React, { useRef, useState } from \"react\";\nimport { getIcon } from \"@rebasepro/app\";\nimport { cls, defaultBorderMixin } from \"@rebasepro/ui\";\nimport type { DataRow, ScorecardConfig, ScorecardFormat } from \"../types\";\n\nfunction formatNumber(value: number, format?: ScorecardFormat): string {\n if (value === null || value === undefined) return \"N/A\";\n\n const options: Intl.NumberFormatOptions = {\n style: format?.style ?? \"decimal\",\n notation: format?.notation ?? \"standard\"\n };\n\n // Only pin the fraction digits when the config asks for a specific count.\n // Without this, Intl's per-style defaults apply: integers stay integers\n // (\"80\", not \"80.0\") while currency keeps its two decimals (\"$452.95\").\n if (format?.decimals !== undefined) {\n options.maximumFractionDigits = format.decimals;\n options.minimumFractionDigits = format.decimals;\n }\n\n if (format?.style === \"currency\") {\n options.currency = format.currency ?? \"USD\";\n }\n\n let formatted = new Intl.NumberFormat(\"en-US\", options).format(value);\n\n if (format?.showSign && value > 0) {\n formatted = \"+\" + formatted;\n }\n\n return formatted;\n}\n\n/**\n * Scorecard widget for the Rebase design system.\n *\n * Renders a single KPI metric with optional comparison value and icon.\n * Uses Tailwind `dark:` classes — no JS dark mode detection.\n * Icons are resolved via `getIcon` from `@rebasepro/app`.\n */\nexport function InsightsScorecardView({\n config,\n data,\n title,\n compact = false,\n embedded = false,\n fixedHeight\n}: {\n config: ScorecardConfig;\n data: DataRow;\n title: string;\n compact?: boolean;\n /** When true, skip own border/bg since the parent card provides them. */\n embedded?: boolean;\n /** Explicit height to prevent layout shift between skeleton → loaded. */\n fixedHeight?: number;\n}) {\n const containerRef = useRef<HTMLDivElement>(null);\n const [containerWidth, setContainerWidth] = useState<number | null>(null);\n\n React.useLayoutEffect(() => {\n if (!containerRef.current) return;\n // Read initial width synchronously before paint\n setContainerWidth(containerRef.current.offsetWidth);\n const observer = new ResizeObserver((entries) => {\n for (const entry of entries) {\n setContainerWidth(entry.contentRect.width);\n }\n });\n observer.observe(containerRef.current);\n return () => observer.disconnect();\n }, []);\n\n const mainValue = data[config.value.field];\n const formattedValue = typeof mainValue === \"number\"\n ? formatNumber(mainValue, config.value.format)\n : String(mainValue ?? \"N/A\");\n\n // Comparison rendering\n let comparisonElement: React.ReactNode = null;\n if (config.comparison) {\n const comparisonValue = data[config.comparison.field];\n if (typeof comparisonValue === \"number\") {\n const formattedComparison = formatNumber(comparisonValue, config.comparison.format);\n const isPositive = comparisonValue > 0;\n const isNegative = comparisonValue < 0;\n\n let colorClass = \"text-surface-500 dark:text-surface-400\";\n if (config.comparison.intent === \"increase_is_good\") {\n if (isPositive) colorClass = \"text-emerald-500\";\n if (isNegative) colorClass = \"text-red-500\";\n } else if (config.comparison.intent === \"decrease_is_good\") {\n if (isPositive) colorClass = \"text-red-500\";\n if (isNegative) colorClass = \"text-emerald-500\";\n }\n\n comparisonElement = (\n <span className={`font-mono tabular-nums font-medium ${compact ? \"text-[10px]\" : \"text-xs\"} ${colorClass}`}>\n {formattedComparison}\n </span>\n );\n }\n }\n\n const isSmall = compact || (containerWidth !== null && containerWidth < 200);\n\n // Resolve icon via getIcon (Lucide-based resolution)\n // 14px in the secondary tier, beside the label — the card header grammar.\n const iconElement = config.icon\n ? getIcon(config.icon, \"text-text-secondary dark:text-text-secondary-dark\", undefined, 14)\n : null;\n\n // ── Compact card-inline layout ──────────────────────────────────────\n if (compact) {\n return (\n <div className=\"flex items-baseline gap-1.5 min-w-0\">\n <span className=\"text-[10px] uppercase tracking-wider text-surface-400 dark:text-surface-500 truncate\">\n {title}\n </span>\n <div className=\"flex items-baseline gap-1.5\">\n <span className=\"text-sm font-semibold tabular-nums text-surface-800 dark:text-surface-100\">\n {formattedValue}\n </span>\n {comparisonElement}\n </div>\n </div>\n );\n }\n\n // ── Standard scorecard layout ───────────────────────────────────────\n const baseClass = embedded\n ? `flex flex-col min-w-0 h-full ${isSmall ? \"px-3.5 py-3\" : \"px-5 py-4\"}`\n // A card on the sheet: one step up and a hairline, like every other card.\n // It was transparent with a border, a box drawn on the sheet rather\n // than an object sitting on it.\n : cls(\"rounded-xl flex flex-col min-w-0 bg-surface-card border\", defaultBorderMixin, isSmall ? \"px-3.5 py-3\" : \"px-5 py-4\");\n\n return (\n <div ref={containerRef} className={baseClass} style={embedded ? undefined : fixedHeight ? { height: fixedHeight } : { minHeight: isSmall ? 68 : 92 }}>\n {/* Title row — the card header grammar the reference page documents:\n a small icon in the secondary tier, then the label in the micro\n tier, on ONE line. The icon used to sit alone at the far right,\n which made every tile read as two unrelated corners. */}\n <div className={`flex flex-col min-w-0 ${isSmall ? \"mb-1\" : \"mb-2.5\"}`}>\n <div className=\"flex items-center gap-1.5 min-w-0\">\n {iconElement && (\n <span className=\"shrink-0 flex items-center text-text-secondary dark:text-text-secondary-dark [&>svg]:size-3.5\">{iconElement}</span>\n )}\n <span className=\"typography-micro truncate text-surface-400 dark:text-surface-400\">\n {title}\n </span>\n </div>\n {config.dateRange && !isSmall && (\n <span className=\"font-mono tabular-nums text-[10px] text-surface-400 dark:text-surface-500 truncate mt-1\">\n {config.dateRange}\n </span>\n )}\n </div>\n\n {/* Main value */}\n <div 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\"}`}>\n {formattedValue}\n </div>\n\n {/* Comparison */}\n {comparisonElement && (\n <div className={isSmall ? \"mt-0.5\" : \"mt-1\"}>\n {comparisonElement}\n </div>\n )}\n </div>\n );\n}\n\nInsightsScorecardView.displayName = \"InsightsScorecardView\";\n","import React, { useRef, useState } from \"react\";\nimport { cls, defaultBorderMixin } from \"@rebasepro/ui\";\nimport type { ScorecardConfig } from \"../types\";\n\n/**\n * Skeleton loader for scorecard insight widgets — displays animated\n * shimmer placeholders that exactly match the final rendered layout\n * of InsightsScorecardView for a given config, preventing layout shift.\n *\n * The skeleton receives the scorecard config so it can conditionally\n * render placeholder lines for comparison, dateRange, and icon —\n * only when the loaded view will also render them.\n *\n * The standard skeleton mirrors InsightsScorecardView's responsive\n * container-width breakpoints (ResizeObserver → isSmall / isMedium)\n * and uses placeholder heights that exactly match the **computed**\n * Tailwind line-heights (accounting for `leading-*` overrides).\n * This guarantees a pixel-perfect skeleton → loaded transition.\n */\nexport function InsightWidgetSkeleton({\n config,\n compact = false,\n embedded = false,\n fixedHeight\n}: {\n /** Scorecard config — used to match optional elements (comparison, dateRange, icon). */\n config: ScorecardConfig;\n compact?: boolean;\n /** When true, skip own border since the parent card provides it. */\n embedded?: boolean;\n /** Explicit height to prevent layout shift between skeleton → loaded. */\n fixedHeight?: number;\n}) {\n const hasComparison = Boolean(config.comparison);\n const hasIcon = Boolean(config.icon);\n const hasDateRange = Boolean(config.dateRange);\n\n // ── Compact scorecard skeleton ──────────────────────────────────────\n // Matches InsightsScorecardView compact layout:\n // container: flex flex-col gap-0.5 px-2.5 py-2 rounded-md border\n // title: text-[10px] uppercase → line-height ~14px\n // value row: text-sm font-semibold → line-height 20px\n // + optional comparison text-[10px] inside value row\n if (compact) {\n return (\n <div\n className={cls(\n \"animate-pulse\",\n embedded\n ? \"h-full px-2.5 py-2\"\n : \"flex flex-col gap-0.5 rounded-md bg-transparent border min-w-0 px-2.5 py-2\",\n !embedded && defaultBorderMixin\n )}\n >\n {/* Title line */}\n <div className=\"bg-surface-200 dark:bg-surface-700 rounded-sm\"\n style={{ height: 14,\nwidth: 48 }}\n />\n {/* Value + optional comparison row */}\n <div className=\"flex items-baseline gap-1.5\">\n <div className=\"bg-surface-200 dark:bg-surface-700 rounded-sm\"\n style={{ height: 20,\nwidth: 40 }}\n />\n {hasComparison && (\n <div className=\"bg-surface-200/60 dark:bg-surface-700/60 rounded-sm\"\n style={{ height: 14,\nwidth: 28 }}\n />\n )}\n </div>\n </div>\n );\n }\n\n // ── Standard scorecard skeleton ─────────────────────────────────────\n return <StandardSkeleton\n hasComparison={hasComparison}\n hasIcon={hasIcon}\n hasDateRange={hasDateRange}\n embedded={embedded}\n fixedHeight={fixedHeight}\n />;\n}\n\n// ── Tailwind line-height reference ──────────────────────────────────────\n// All heights below are the **computed** CSS line-heights, accounting\n// for `leading-*` overrides that InsightsScorecardView applies.\n//\n// Title:\n// text-xs (12px) + leading-snug (1.375) → 12 × 1.375 = 16.5px\n// text-[11px] + leading-snug (1.375) → 11 × 1.375 = 15.125px\n//\n// DateRange:\n// text-[10px] with no explicit LH → normal ≈ 14px (browser)\n//\n// Value:\n// text-2xl (24px) + leading-tight (1.25) → 24 × 1.25 = 30px\n// text-xl (20px) + leading-tight (1.25) → 20 × 1.25 = 25px\n// text-lg (18px) + leading-tight (1.25) → 18 × 1.25 = 22.5px\n//\n// Comparison:\n// text-xs (12px) → built-in LH 1rem = 16px\n\n/**\n * Inner component for the standard scorecard skeleton.\n *\n * Mirrors InsightsScorecardView's layout by:\n * 1. Using the same ResizeObserver + containerWidth pattern for\n * responsive breakpoints (isSmall < 200px, isMedium < 300px).\n * 2. Using placeholder heights derived from the exact computed\n * Tailwind line-heights that InsightsScorecardView renders.\n * 3. Matching all container classes, margins, paddings, and flex\n * layout properties identically.\n */\nfunction StandardSkeleton({\n hasComparison,\n hasIcon,\n hasDateRange,\n embedded,\n fixedHeight\n}: {\n hasComparison: boolean;\n hasIcon: boolean;\n hasDateRange: boolean;\n embedded: boolean;\n fixedHeight?: number;\n}) {\n const containerRef = useRef<HTMLDivElement>(null);\n const [containerWidth, setContainerWidth] = useState<number | null>(null);\n\n React.useLayoutEffect(() => {\n if (!containerRef.current) return;\n setContainerWidth(containerRef.current.offsetWidth);\n const observer = new ResizeObserver((entries) => {\n for (const entry of entries) {\n setContainerWidth(entry.contentRect.width);\n }\n });\n observer.observe(containerRef.current);\n return () => observer.disconnect();\n }, []);\n\n // Mirror InsightsScorecardView's responsive breakpoints exactly\n const isSmall = containerWidth !== null && containerWidth < 200;\n\n // Computed line-heights for each breakpoint\n // Title: text-xs + leading-snug = 16.5px, text-[11px] + leading-snug = 15.125px\n const titleHeight = isSmall ? 15 : 16.5;\n // Value: leading-tight (×1.25) applied on top of font-size\n const valueHeight = isSmall\n ? 22.5 // text-lg: 18 × 1.25\n : (containerWidth !== null && containerWidth < 300)\n ? 25 // text-xl: 20 × 1.25\n : 30; // text-2xl: 24 × 1.25\n // Comparison: text-xs = 12px / 16px line-height (no leading override)\n const comparisonHeight = 16;\n // Icon: 14px when small, 18px otherwise\n const iconSize = isSmall ? 14 : 18;\n\n const baseClass = embedded\n ? `flex flex-col min-w-0 h-full ${isSmall ? \"px-3.5 py-3\" : \"px-5 py-4\"}`\n : cls(\"rounded-lg flex flex-col min-w-0 bg-transparent border\", defaultBorderMixin, isSmall ? \"px-3.5 py-3\" : \"px-5 py-4\");\n\n return (\n <div\n ref={containerRef}\n className={cls(\"animate-pulse\", baseClass)}\n style={embedded ? undefined : fixedHeight ? { height: fixedHeight } : { minHeight: isSmall ? 68 : 92 }}\n >\n {/* Title row — identical flex structure to InsightsScorecardView */}\n <div className={`flex items-center justify-between ${isSmall ? \"mb-1\" : \"mb-2\"}`}>\n <div className=\"flex flex-col min-w-0\">\n {/* Title placeholder */}\n <div className=\"bg-surface-200 dark:bg-surface-700 rounded\"\n style={{ height: titleHeight,\nwidth: \"60%\" }}\n />\n {/* DateRange — hidden when isSmall, same as real view (line 134) */}\n {hasDateRange && !isSmall && (\n <div className=\"bg-surface-200/60 dark:bg-surface-700/60 rounded mt-0.5\"\n style={{ height: 14,\nwidth: \"40%\" }}\n />\n )}\n </div>\n {/* Icon placeholder — same wrapper as real view */}\n {hasIcon && (\n <span className=\"ml-2 shrink-0\">\n <div className=\"bg-surface-200 dark:bg-surface-700 rounded\"\n style={{ height: iconSize,\nwidth: iconSize }}\n />\n </span>\n )}\n </div>\n\n {/* Main value placeholder */}\n <div className=\"bg-surface-200 dark:bg-surface-700 rounded\"\n style={{ height: valueHeight,\nwidth: \"40%\" }}\n />\n\n {/* Comparison placeholder */}\n {hasComparison && (\n <div className={isSmall ? \"mt-0.5\" : \"mt-1\"}>\n <div className=\"bg-surface-200/60 dark:bg-surface-700/60 rounded\"\n style={{ height: comparisonHeight,\nwidth: \"25%\" }}\n />\n </div>\n )}\n </div>\n );\n}\n\nInsightWidgetSkeleton.displayName = \"InsightWidgetSkeleton\";\n","import React from \"react\";\nimport type { InsightDefinition, DataRow, ScorecardConfig } from \"../types\";\nimport { useInsightsData } from \"../engine/useInsightsData\";\nimport { InsightsScorecardView } from \"./InsightsScorecardView\";\nimport { InsightWidgetSkeleton } from \"./InsightWidgetSkeleton\";\n\n/**\n * Compute a deterministic fixed height for a standard scorecard based\n * on which optional elements the config declares. This eliminates\n * layout shift between skeleton and loaded states.\n *\n * Breakdown (non-compact, non-small):\n * py-4 padding: 16 + 16 = 32\n * title row: 16.5 (text-xs leading-snug)\n * mb-2 margin: 8\n * value: 30 (text-2xl leading-tight)\n * ---\n * base: 86.5\n * + dateRange: +16 (14px text + 2px mt-0.5)\n * + comparison: +20 (16px text + 4px mt-1)\n */\nfunction computeFixedHeight(config: ScorecardConfig): number {\n let h = 86.5; // base: padding + title + mb-2 + value\n if (config.dateRange) h += 16;\n if (config.comparison) h += 20;\n return Math.ceil(h);\n}\n\n/**\n * Single insight widget orchestrator.\n *\n * Wraps skeleton and loaded states in a fixed-height container\n * (computed from the scorecard config) to prevent layout shift.\n *\n * All theme-awareness is handled via Tailwind `dark:` classes.\n */\nexport function InsightWidget({\n definition,\n collectionSlug,\n path,\n parentCollectionSlugs, parentEntityIds,\n compact = false,\n embedded = false\n}: {\n definition: InsightDefinition;\n collectionSlug?: string;\n path?: string;\n parentCollectionSlugs?: string[], parentEntityIds?: string[];\n compact?: boolean;\n /** When true, inner views skip their own borders since the parent card provides them. */\n embedded?: boolean;\n}) {\n const { data, loading, error } = useInsightsData(definition, { path,\ncollectionSlug,\nparentCollectionSlugs });\n\n // For non-compact, non-embedded standard scorecards, use a fixed height\n // derived from the config to prevent layout shift between skeleton → loaded.\n const fixedHeight = (!compact && !embedded) ? computeFixedHeight(definition.scorecard) : undefined;\n\n if (loading) {\n return <InsightWidgetSkeleton config={definition.scorecard} compact={compact} embedded={embedded} fixedHeight={fixedHeight} />;\n }\n\n if (error) {\n return (\n <div\n 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\"}`}`}\n style={fixedHeight ? { height: fixedHeight } : undefined}\n >\n <div className=\"font-semibold mb-1\">{definition.title}</div>\n <div>{error.message}</div>\n </div>\n );\n }\n\n if (!data || data.rows.length === 0) {\n return (\n <div\n 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\"}`}`}\n style={fixedHeight ? { height: fixedHeight } : undefined}\n >\n {definition.title} — No data\n </div>\n );\n }\n\n return (\n <InsightsScorecardView\n config={definition.scorecard}\n data={data.rows[0] as DataRow}\n title={definition.title}\n compact={compact}\n embedded={embedded}\n fixedHeight={fixedHeight}\n />\n );\n}\n\nInsightWidget.displayName = \"InsightWidget\";\n","import React from \"react\";\nimport type { InsightDefinition } from \"../types\";\nimport { InsightWidget } from \"./InsightWidget\";\n\n/**\n * Renders compact insight widgets inline within a home page collection card.\n * Injected via the `home.card.widget` slot.\n *\n * Uses a horizontal flex layout so multiple cards sit side by side.\n */\nexport function HomeCardInsightSlot({\n slug,\n insights\n}: {\n slug: string;\n collection: unknown;\n context: unknown;\n insights: InsightDefinition[];\n}) {\n if (!insights || insights.length === 0) return null;\n\n // Text readouts, not tiles: they wrap at their own height. The old\n // estimated minHeight (42px a row) is what kept every card on the home\n // page tall and empty.\n return (\n <div className=\"flex flex-wrap items-baseline gap-x-4 gap-y-1 mt-1.5\">\n {insights.map((def) => (\n <InsightWidget\n key={def.id}\n definition={def}\n collectionSlug={slug}\n compact={true}\n />\n ))}\n </div>\n );\n}\n\nHomeCardInsightSlot.displayName = \"HomeCardInsightSlot\";\n","import React from \"react\";\nimport type { InsightDefinition } from \"../types\";\nimport { InsightWidget } from \"./InsightWidget\";\n\n/**\n * Scorecard insights panel rendered at the top of the home page.\n * Injected via the `home.children.start` slot.\n *\n * Renders scorecards in a responsive grid (up to 4 columns).\n */\nexport function HomeInsightsSlot({\n insights\n}: {\n insights: InsightDefinition[];\n}) {\n if (!insights || insights.length === 0) return null;\n\n return (\n <div\n className=\"grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 pb-6\"\n style={{ minHeight: 92 }}\n >\n {insights.map((def) => (\n <InsightWidget key={def.id} definition={def} />\n ))}\n </div>\n );\n}\n\nHomeInsightsSlot.displayName = \"HomeInsightsSlot\";\n","import React from \"react\";\nimport type { InsightDefinition } from \"../types\";\nimport { InsightWidget } from \"./InsightWidget\";\n\n/**\n * Renders scorecard insight widgets inline within a collection's list view,\n * positioned below the title and above the main data list.\n *\n * Injected via the `collection.widgets` slot.\n */\nexport function CollectionInsightsInline({\n insights,\n path,\n parentCollectionSlugs,\n parentEntityIds\n}: {\n path: string;\n collection: unknown;\n parentCollectionSlugs: string[], parentEntityIds: string[];\n insights: InsightDefinition[];\n}) {\n if (!insights || insights.length === 0) return null;\n\n return (\n <div className=\"grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 pb-4\">\n {insights.map((def) => (\n <InsightWidget\n key={def.id}\n definition={def}\n path={path}\n parentCollectionSlugs={parentCollectionSlugs} parentEntityIds={parentEntityIds}\n />\n ))}\n </div>\n );\n}\n\nCollectionInsightsInline.displayName = \"CollectionInsightsInline\";\n","import React from \"react\";\nimport type {\n RebasePlugin,\n AnySlotContribution,\n PluginGenericProps,\n CollectionWidgetsSlotProps,\n HomeCardWidgetSlotProps\n} from \"@rebasepro/cms-types\";\nimport type { InsightsPluginConfig } from \"./types\";\nimport { InsightsProvider } from \"./engine/InsightsProvider\";\nimport { HomeCardInsightSlot } from \"./components/HomeCardInsightSlot\";\nimport { HomeInsightsSlot } from \"./components/HomeInsightsSlot\";\nimport { CollectionInsightsInline } from \"./components/CollectionInsightsInline\";\n\n/**\n * Creates the Insights plugin for Rebase.\n *\n * This plugin injects scorecard widgets into key UI locations:\n * - **Home page header**: KPI overview via `home.children.start` slot\n * - **Collection list view**: Scorecards inline (below title, above list) via `collection.widgets` slot\n * - **Home page cards**: Compact scorecard metrics auto-extracted from collection insights via `home.card.widget` slot\n *\n * Collection-level insights (`collections.<slug>`) are the single source of truth:\n * scorecards render in the collection list view and are automatically extracted\n * to show as compact widgets on the corresponding home page card.\n *\n * Each insight owns its own `data()` callback — use the Rebase client SDK,\n * call a custom function, or hit any external API. Full flexibility, zero new endpoints.\n *\n * @example\n * ```typescript\n * import { useInsightsPlugin } from \"@rebasepro/plugin-insights\";\n *\n * const insightsPlugin = useInsightsPlugin({\n * cacheTTL: 120_000,\n * insights: {\n * home: [\n * { id: \"revenue\", title: \"Revenue\", data: async () => ..., scorecard: { ... } },\n * ],\n * collections: {\n * orders: [\n * { id: \"total\", title: \"Total Orders\", data: async () => ..., scorecard: { ... } },\n * ],\n * },\n * },\n * });\n * ```\n */\nexport function useInsightsPlugin(config: InsightsPluginConfig): RebasePlugin {\n const { insights, cacheTTL } = config;\n\n return React.useMemo(() => {\n const slots: AnySlotContribution[] = [];\n\n // ── Home page insights ────────────────────────────────────────────\n if (insights.home && insights.home.length > 0) {\n const homeInsights = insights.home;\n slots.push({\n slot: \"home.children.start\" as const,\n Component: (props: PluginGenericProps) => (\n <HomeInsightsSlot\n {...props}\n insights={homeInsights}\n />\n ),\n order: 10\n });\n }\n\n // ── Per-collection insights ───────────────────────────────────────\n // A single `collections.<slug>` definition serves two slots:\n // 1. collection.widgets → inline scorecards in the list view\n // 2. home.card.widget → compact scorecards on the home card\n if (insights.collections) {\n for (const [slug, defs] of Object.entries(insights.collections)) {\n if (defs.length === 0) continue;\n const collectionInsights = defs;\n\n // 1. Inline in collection list view\n slots.push({\n slot: \"collection.widgets\" as const,\n Component: (props: CollectionWidgetsSlotProps) => {\n const collectionSlug = props.path?.split(\"/\").filter(Boolean).pop() ?? \"\";\n if (collectionSlug !== slug) return null;\n\n // Skip relation-scoped views (e.g. a single product's Orders\n // tab). These aggregations are collection-wide — `InsightContext`\n // carries no parent entity id, so a definition cannot narrow to\n // the parent — and rendering \"Revenue $36.2K\" above one product's\n // two orders reads as a figure for those orders.\n if (props.parentEntityIds && props.parentEntityIds.length > 0) return null;\n\n return (\n <CollectionInsightsInline\n {...props}\n insights={collectionInsights}\n />\n );\n },\n order: 10\n });\n\n // 2. Auto-extract scorecards for home page card\n slots.push({\n slot: \"home.card.widget\" as const,\n Component: (props: HomeCardWidgetSlotProps) => {\n if (props.slug !== slug) return null;\n return (\n <HomeCardInsightSlot\n {...props}\n insights={collectionInsights}\n />\n );\n },\n order: 10\n });\n }\n }\n\n return {\n key: \"plugin-insights\",\n slots,\n providers: [\n {\n scope: \"root\" as const,\n Component: InsightsProvider as React.ComponentType<React.PropsWithChildren<Record<string, unknown>>>,\n props: { cacheTTL }\n }\n ]\n };\n }, [insights, cacheTTL]);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.es.js","names":[],"sources":["../src/engine/InsightsEngine.ts","../src/engine/InsightsProvider.tsx","../src/validateConfig.ts","../src/engine/useInsightSource.ts","../src/format.ts","../src/components/InsightsScorecardView.tsx","../src/components/InsightWidget.tsx","../src/components/HomeCardInsightSlot.tsx","../src/components/InsightsRow.tsx","../src/components/HomeInsightsSlot.tsx","../src/components/CollectionInsightsInline.tsx","../src/useInsightsPlugin.tsx"],"sourcesContent":["import type { DataRow, InsightPeriod, InsightSource } from \"../types\";\n\nexport const DEFAULT_PERIOD_DAYS = 30;\n\nconst DAY_MS = 86_400_000;\n\n/** The current and previous windows of `days` days, ending at `to`. */\nexport function resolvePeriod(days: number, to: Date): InsightPeriod {\n const from = new Date(to.getTime() - days * DAY_MS);\n return {\n days,\n from,\n to,\n previousFrom: new Date(from.getTime() - days * DAY_MS)\n };\n}\n\n/** A source's record, with the period it was fetched for. */\nexport interface SourceResult {\n row: DataRow;\n period: InsightPeriod;\n}\n\n/**\n * The cache key for one source, for one user.\n *\n * The engine lives at the root of the app and outlives a sign-out, and a figure\n * is computed under the permissions and row-level security of whoever asked\n * for it. A key without the user would serve the previous account's numbers to\n * the next one that signs in on the same tab, until the TTL ran out.\n */\nexport function sourceCacheKey(sourceId: string, userId: string | null): string {\n return JSON.stringify([userId, sourceId]);\n}\n\n/**\n * Fetches and caches the plugin's sources.\n *\n * Every insight that reads a source shares one fetch of it: concurrent reads\n * join the request in flight, later ones read the cache until the TTL passes.\n * That is what keeps a figure shown on the home page and in a collection view\n * the same number.\n */\nexport class InsightsEngine {\n private readonly cache = new Map<string, { result: SourceResult; at: number }>();\n private readonly inflight = new Map<string, Promise<SourceResult>>();\n private anchor: { period: InsightPeriod; at: number } | null = null;\n\n constructor(\n private readonly sources: Record<string, InsightSource>,\n readonly periodDays: number = DEFAULT_PERIOD_DAYS,\n private readonly ttl: number = 60_000,\n private readonly now: () => number = Date.now\n ) {}\n\n /**\n * The period a fetch starting now is asked for. Its end is fixed by the\n * first fetch and kept until the TTL passes, so sources fetched for the\n * same screen describe the same window.\n */\n period(): InsightPeriod {\n const now = this.now();\n if (!this.anchor || now - this.anchor.at > this.ttl) {\n this.anchor = { period: resolvePeriod(this.periodDays, new Date(now)), at: now };\n }\n return this.anchor.period;\n }\n\n /** The cached result for this source and user, if it is still fresh. */\n peek(sourceId: string, userId: string | null): SourceResult | null {\n const entry = this.cache.get(sourceCacheKey(sourceId, userId));\n if (!entry || this.now() - entry.at > this.ttl) return null;\n return entry.result;\n }\n\n /** The source's record for this user: from the cache, the fetch in flight, or a new fetch. */\n load(sourceId: string, userId: string | null): Promise<SourceResult> {\n const cached = this.peek(sourceId, userId);\n if (cached) return Promise.resolve(cached);\n\n const key = sourceCacheKey(sourceId, userId);\n const pending = this.inflight.get(key);\n if (pending) return pending;\n\n const source = this.sources[sourceId];\n if (!source) {\n return Promise.reject(new Error(`No insights source is named \"${sourceId}\".`));\n }\n\n const period = this.period();\n let fetched: Promise<DataRow>;\n try {\n fetched = Promise.resolve(source({ period }));\n } catch (error: unknown) {\n // A source that throws before returning its promise fails like one that rejects.\n fetched = Promise.reject(error);\n }\n const promise: Promise<SourceResult> = fetched\n .then(\n (row) => {\n const result = { row, period };\n // A fetch that `invalidate()` dropped while it ran must not\n // write its result back over the cleared cache.\n if (this.inflight.get(key) === promise) {\n this.inflight.delete(key);\n this.cache.set(key, { result, at: this.now() });\n }\n return result;\n },\n (error: unknown) => {\n if (this.inflight.get(key) === promise) this.inflight.delete(key);\n throw error;\n }\n );\n this.inflight.set(key, promise);\n return promise;\n }\n\n /** Drops every cached result and fetch in flight, and re-anchors the period on the next fetch. */\n invalidate(): void {\n this.cache.clear();\n this.inflight.clear();\n this.anchor = null;\n }\n}\n","import React, { createContext, useContext, useMemo, type PropsWithChildren } from \"react\";\nimport type { InsightSource } from \"../types\";\nimport { InsightsEngine } from \"./InsightsEngine\";\n\nconst InsightsContext = createContext<InsightsEngine | null>(null);\n\n/**\n * Root-level provider for the insights data engine.\n * Injected automatically by the plugin via `providers: [{ scope: \"root\" }]`.\n *\n * Holds the one `InsightsEngine` every insight widget reads its source from.\n */\nexport function InsightsProvider({\n sources,\n periodDays,\n cacheTTL,\n children\n}: PropsWithChildren<{\n sources: Record<string, InsightSource>;\n periodDays?: number;\n cacheTTL?: number;\n}>) {\n const engine = useMemo(\n () => new InsightsEngine(sources, periodDays, cacheTTL),\n [sources, periodDays, cacheTTL]\n );\n\n return (\n <InsightsContext.Provider value={engine}>\n {children}\n </InsightsContext.Provider>\n );\n}\n\n/**\n * The insights engine (for advanced usage).\n * Returns null when called outside of an `InsightsProvider`\n * (e.g. during auth-loading phase before plugin providers mount).\n */\nexport function useInsightsEngine(): InsightsEngine | null {\n return useContext(InsightsContext);\n}\n","import type { InsightsPluginConfig } from \"./types\";\nimport { DEFAULT_PERIOD_DAYS } from \"./engine/InsightsEngine\";\n\n/**\n * Refuses a config whose insights read a source it does not declare, or whose\n * period is not a whole number of days. Both would otherwise surface as an\n * error tile per insight, on whichever screen a user opened first.\n */\nexport function assertValidConfig(config: InsightsPluginConfig): void {\n const days = config.period?.days ?? DEFAULT_PERIOD_DAYS;\n if (!Number.isInteger(days) || days < 1) {\n throw new Error(`Insights period.days must be a whole number of days, at least 1; got ${days}.`);\n }\n const declared = Object.keys(config.sources);\n const definitions = [\n ...(config.insights.home ?? []),\n ...Object.values(config.insights.collections ?? {}).flat()\n ];\n for (const definition of definitions) {\n if (!Object.hasOwn(config.sources, definition.source)) {\n throw new Error(\n `Insight \"${definition.id}\" reads source \"${definition.source}\", which is not in \\`sources\\`` +\n ` (declared: ${declared.length > 0 ? declared.join(\", \") : \"none\"}).`\n );\n }\n }\n}\n","import { useEffect, useState } from \"react\";\nimport { useAuthController } from \"@rebasepro/app\";\nimport { useInsightsEngine } from \"./InsightsProvider\";\nimport { sourceCacheKey, type SourceResult } from \"./InsightsEngine\";\n\ninterface Settled {\n key: string;\n result: SourceResult | null;\n error: Error | null;\n}\n\n/**\n * Reads one source for the signed-in user.\n *\n * Waits for auth to settle: a source runs under the caller's permissions, so\n * fetching before the user is known would compute the figures for nobody.\n */\nexport function useInsightSource(sourceId: string): {\n result: SourceResult | null;\n loading: boolean;\n error: Error | null;\n} {\n const engine = useInsightsEngine();\n const { initialLoading, authLoading, user, loginSkipped } = useAuthController();\n const authReady = !initialLoading && !authLoading && (Boolean(user) || loginSkipped);\n const userId = user?.uid ?? null;\n const key = sourceCacheKey(sourceId, userId);\n\n const [settled, setSettled] = useState<Settled | null>(null);\n\n useEffect(() => {\n if (!authReady || !engine) return;\n\n let cancelled = false;\n engine.load(sourceId, userId).then(\n (result) => {\n if (!cancelled) setSettled({ key, result, error: null });\n },\n (error: unknown) => {\n if (!cancelled) setSettled({ key, result: null, error: error instanceof Error ? error : new Error(String(error)) });\n }\n );\n return () => {\n cancelled = true;\n };\n }, [engine, sourceId, userId, key, authReady]);\n\n // What settled for another source or another user is not this one's.\n const current = settled?.key === key ? settled : null;\n // A cache hit renders on the first pass rather than after a skeleton frame.\n const cached = !current && authReady && engine ? engine.peek(sourceId, userId) : null;\n\n return {\n result: current?.result ?? cached,\n loading: !current && !cached,\n error: current?.error ?? null\n };\n}\n","import type { InsightComparison, InsightFormat } from \"./types\";\n\n/**\n * A number formatter in the given language, falling back to the browser's\n * when the language is not a tag `Intl` accepts (i18next's `cimode`, a custom\n * bundle name).\n */\nfunction numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n try {\n return new Intl.NumberFormat(locale, options);\n } catch {\n return new Intl.NumberFormat(undefined, options);\n }\n}\n\n/** Writes a value the way its insight asks, in the admin panel's language. */\nexport function formatValue(value: number, format: InsightFormat | undefined, locale: string | undefined): string {\n const options: Intl.NumberFormatOptions = {\n style: format?.style ?? \"decimal\",\n notation: format?.notation ?? \"standard\"\n };\n\n // Only pin the fraction digits when the config asks for a specific count.\n // Without this, Intl's per-style defaults apply: integers stay integers\n // (\"80\", not \"80.0\") while currency keeps its two decimals (\"$452.95\").\n if (format?.decimals !== undefined) {\n options.maximumFractionDigits = format.decimals;\n options.minimumFractionDigits = format.decimals;\n }\n\n if (format?.style === \"currency\") {\n options.currency = format.currency ?? \"USD\";\n }\n\n return numberFormat(locale, options).format(value);\n}\n\nexport type ChangeDirection = \"up\" | \"down\" | \"flat\";\n\nexport interface FormattedChange {\n direction: ChangeDirection;\n /** The size of the change, unsigned: the direction carries the sign. */\n magnitude: string;\n}\n\n/**\n * The change from `previous` to `current`, written as the comparison asks.\n *\n * A percentage needs a previous figure to be a percentage of, so a change\n * from zero is written as the difference instead. Under 10% keeps one\n * decimal (`0.2%`), above it none (`42%`): the decimal stops carrying\n * information once the change is that large.\n */\nexport function formatChange(\n current: number,\n previous: number,\n comparison: InsightComparison,\n format: InsightFormat | undefined,\n locale: string | undefined\n): FormattedChange {\n const delta = current - previous;\n const direction: ChangeDirection = delta > 0 ? \"up\" : delta < 0 ? \"down\" : \"flat\";\n\n if (comparison.show !== \"absolute\" && previous !== 0) {\n const ratio = Math.abs(delta / previous);\n const decimals = ratio < 0.1 ? 1 : 0;\n return {\n direction,\n magnitude: numberFormat(locale, {\n style: \"percent\",\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals\n }).format(ratio)\n };\n }\n\n return { direction, magnitude: formatValue(Math.abs(delta), format, locale) };\n}\n\nexport type ChangeTone = \"positive\" | \"negative\" | \"neutral\";\n\n/** Whether a change in this direction is good news for this insight. */\nexport function changeTone(direction: ChangeDirection, intent: InsightComparison[\"intent\"]): ChangeTone {\n if (direction === \"flat\") return \"neutral\";\n const good = intent === \"increase_is_good\" ? \"up\" : \"down\";\n return direction === good ? \"positive\" : \"negative\";\n}\n","import React from \"react\";\nimport { getIcon, useTranslation } from \"@rebasepro/app\";\nimport { cls, defaultBorderMixin, Tooltip, Typography } from \"@rebasepro/ui\";\nimport type { DataRow, InsightDefinition, InsightPeriod } from \"../types\";\nimport type { SourceResult } from \"../engine/InsightsEngine\";\nimport { changeTone, formatChange, formatValue, type ChangeTone } from \"../format\";\n\nconst toneClasses: Record<ChangeTone, string> = {\n // The kit's error tier, and its success hue a step darker in light mode:\n // emerald-600 on the card is 3.8:1, under what 12px text needs.\n positive: \"text-emerald-700 dark:text-emerald-400\",\n negative: \"text-red-600 dark:text-red-500\",\n neutral: \"text-text-secondary dark:text-text-secondary-dark\"\n};\n\n/** A pulsing bar standing in for a figure that has not arrived, sized by its line box. */\nfunction Placeholder({ className }: { className: string }) {\n return <span className={cls(\"inline-block align-middle rounded-sm bg-surface-200 dark:bg-surface-700 animate-pulse\", className)}/>;\n}\n\nfunction displayValue(row: DataRow | undefined, definition: InsightDefinition, locale: string): string {\n const value = row?.[definition.value.field];\n if (typeof value === \"number\") return formatValue(value, definition.value.format, locale);\n if (typeof value === \"string\" && value !== \"\") return value;\n return \"—\";\n}\n\n/**\n * The change against the previous period: an arrow and its size, coloured by\n * whether it is good news. The arrow is what carries the direction, so it\n * reads without the colour.\n */\nfunction InsightChange({\n definition,\n row,\n period,\n locale,\n tooltip\n}: {\n definition: InsightDefinition;\n row: DataRow;\n period: InsightPeriod;\n locale: string;\n tooltip: boolean;\n}) {\n const { t } = useTranslation();\n const comparison = definition.comparison;\n if (!comparison) return null;\n\n const current = row[definition.value.field];\n const previous = row[comparison.previous];\n if (typeof current !== \"number\" || typeof previous !== \"number\") return null;\n\n const change = formatChange(current, previous, comparison, definition.value.format, locale);\n const arrow = change.direction === \"up\" ? \"↑\" : change.direction === \"down\" ? \"↓\" : null;\n const spoken = change.direction === \"up\"\n ? t(\"insights_change_up\", { change: change.magnitude })\n : change.direction === \"down\"\n ? t(\"insights_change_down\", { change: change.magnitude })\n : t(\"insights_change_none\");\n const previousLabel = t(\"insights_previous_period_value\", {\n count: period.days,\n value: formatValue(previous, definition.value.format, locale)\n });\n\n const label = (\n <span className={cls(\"typography-mono text-xs font-medium whitespace-nowrap\", toneClasses[changeTone(change.direction, comparison.intent)])}>\n <span aria-hidden=\"true\">{arrow ? `${arrow} ${change.magnitude}` : change.magnitude}</span>\n <span className=\"sr-only\">{`${spoken}. ${previousLabel}`}</span>\n </span>\n );\n\n return tooltip ? <Tooltip title={previousLabel}>{label}</Tooltip> : label;\n}\n\n/**\n * One insight: its label, its value and its change on the previous period.\n *\n * The label and icon are known before the figures are, so they render while\n * the source loads and only the figures pulse. Loading, loaded and failed\n * share one shell, so nothing moves when the data arrives.\n *\n * `compact` is the inline readout on a home-page card; the default is a tile.\n */\nexport function InsightsScorecardView({\n definition,\n result,\n loading = false,\n error = null,\n compact = false\n}: {\n definition: InsightDefinition;\n /** The source's record and the period it was fetched for. */\n result: SourceResult | null;\n loading?: boolean;\n error?: Error | null;\n compact?: boolean;\n}) {\n const { i18n } = useTranslation();\n const locale = i18n.language;\n const row = result?.row;\n const value = displayValue(row, definition, locale);\n\n if (compact) {\n return (\n <div className=\"flex items-baseline gap-1.5 min-w-0\" title={error?.message}>\n <Typography variant=\"micro\" color=\"secondary\" className=\"truncate\">\n {definition.title}\n </Typography>\n {loading\n ? <Placeholder className=\"h-3 w-8\"/>\n : (\n <span className=\"text-sm font-semibold tabular-nums text-text-primary dark:text-text-primary-dark\">\n {value}\n </span>\n )}\n {!loading && row && result && (\n <InsightChange definition={definition} row={row} period={result.period} locale={locale} tooltip={false}/>\n )}\n </div>\n );\n }\n\n const icon = definition.icon\n ? getIcon(definition.icon, \"text-text-secondary dark:text-text-secondary-dark\", undefined, 14)\n : null;\n\n return (\n // A card on the sheet: one step up and a hairline, like every other card.\n <div className={cls(\"@container rounded-xl bg-surface-card border min-w-0\", defaultBorderMixin)}>\n <div className=\"flex flex-col min-w-0 h-full px-5 py-4 @max-[200px]:px-3.5 @max-[200px]:py-3\">\n {/* The card header grammar the reference page documents: a small\n icon in the secondary tier, then the label in the micro tier. */}\n <div className=\"flex items-center gap-1.5 min-w-0 mb-2.5 @max-[200px]:mb-1\">\n {icon && (\n <span className=\"shrink-0 flex items-center text-text-secondary dark:text-text-secondary-dark [&>svg]:size-3.5\">{icon}</span>\n )}\n <Typography variant=\"micro\" color=\"secondary\" className=\"truncate\">\n {definition.title}\n </Typography>\n </div>\n\n <div className=\"typography-stat leading-tight break-all text-text-primary dark:text-text-primary-dark @max-[200px]:text-xl\">\n {loading ? <Placeholder className=\"h-[0.8em] w-24\"/> : value}\n </div>\n\n {error\n ? (\n <Typography variant=\"caption\" color=\"error\" className=\"block mt-1 truncate\" title={error.message}>\n {error.message}\n </Typography>\n )\n : definition.comparison && (\n <div className=\"mt-1 leading-4\">\n {loading || !row || !result\n ? <Placeholder className=\"h-3 w-12\"/>\n : <InsightChange definition={definition} row={row} period={result.period} locale={locale} tooltip={true}/>}\n </div>\n )}\n </div>\n </div>\n );\n}\n\nInsightsScorecardView.displayName = \"InsightsScorecardView\";\n","import React from \"react\";\nimport type { InsightDefinition } from \"../types\";\nimport { useInsightSource } from \"../engine/useInsightSource\";\nimport { InsightsScorecardView } from \"./InsightsScorecardView\";\n\n/** One insight, reading its source through the shared engine. */\nexport function InsightWidget({\n definition,\n compact = false\n}: {\n definition: InsightDefinition;\n compact?: boolean;\n}) {\n const { result, loading, error } = useInsightSource(definition.source);\n\n return (\n <InsightsScorecardView\n definition={definition}\n result={result}\n loading={loading}\n error={error}\n compact={compact}\n />\n );\n}\n\nInsightWidget.displayName = \"InsightWidget\";\n","import React from \"react\";\nimport type { InsightDefinition } from \"../types\";\nimport { InsightWidget } from \"./InsightWidget\";\n\n/**\n * Renders compact insight readouts inside a home page collection card.\n * Injected via the `home.card.widget` slot.\n */\nexport function HomeCardInsightSlot({\n insights\n}: {\n insights: InsightDefinition[];\n}) {\n if (!insights || insights.length === 0) return null;\n\n // Text readouts, not tiles: they wrap at their own height.\n return (\n <div className=\"flex flex-wrap items-baseline gap-x-4 gap-y-1 mt-1.5\">\n {insights.map((definition) => (\n <InsightWidget\n key={definition.id}\n definition={definition}\n compact={true}\n />\n ))}\n </div>\n );\n}\n\nHomeCardInsightSlot.displayName = \"HomeCardInsightSlot\";\n","import React from \"react\";\nimport { useTranslation } from \"@rebasepro/app\";\nimport { cls, Typography } from \"@rebasepro/ui\";\nimport type { InsightDefinition } from \"../types\";\nimport { useInsightsEngine } from \"../engine/InsightsProvider\";\nimport { DEFAULT_PERIOD_DAYS } from \"../engine/InsightsEngine\";\nimport { InsightWidget } from \"./InsightWidget\";\n\n/**\n * A row of insight tiles. When any of them compares with the previous period,\n * the row says which period once, above the tiles, rather than on each.\n */\nexport function InsightsRow({\n insights,\n className\n}: {\n insights: InsightDefinition[];\n className?: string;\n}) {\n const { t } = useTranslation();\n const engine = useInsightsEngine();\n const days = engine?.periodDays ?? DEFAULT_PERIOD_DAYS;\n const compares = insights.some((definition) => definition.comparison);\n\n return (\n <section className={cls(\"w-full\", className)}>\n {compares && (\n <Typography variant=\"micro\" color=\"secondary\" component=\"h2\" className=\"block py-1 mb-4\">\n {t(\"insights_period_last_days\", { count: days })}\n </Typography>\n )}\n <div className=\"grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4\">\n {insights.map((definition) => (\n <InsightWidget key={definition.id} definition={definition}/>\n ))}\n </div>\n </section>\n );\n}\n\nInsightsRow.displayName = \"InsightsRow\";\n","import React from \"react\";\nimport type { InsightDefinition } from \"../types\";\nimport { InsightsRow } from \"./InsightsRow\";\n\n/**\n * Scorecard insights panel rendered at the top of the home page.\n * Injected via the `home.children.start` slot.\n */\nexport function HomeInsightsSlot({\n insights\n}: {\n insights: InsightDefinition[];\n}) {\n if (!insights || insights.length === 0) return null;\n\n return <InsightsRow insights={insights} className=\"mt-6 pb-2\"/>;\n}\n\nHomeInsightsSlot.displayName = \"HomeInsightsSlot\";\n","import React from \"react\";\nimport type { InsightDefinition } from \"../types\";\nimport { InsightsRow } from \"./InsightsRow\";\n\n/**\n * Renders scorecard insight widgets inline within a collection's list view,\n * positioned below the title and above the main data list.\n *\n * Injected via the `collection.widgets` slot.\n */\nexport function CollectionInsightsInline({\n insights\n}: {\n insights: InsightDefinition[];\n}) {\n if (!insights || insights.length === 0) return null;\n\n return <InsightsRow insights={insights} className=\"pb-4\"/>;\n}\n\nCollectionInsightsInline.displayName = \"CollectionInsightsInline\";\n","import React from \"react\";\nimport type {\n RebasePlugin,\n AnySlotContribution,\n CollectionWidgetsSlotProps,\n HomeCardWidgetSlotProps\n} from \"@rebasepro/cms-types\";\nimport type { InsightsPluginConfig } from \"./types\";\nimport { InsightsProvider } from \"./engine/InsightsProvider\";\nimport { DEFAULT_PERIOD_DAYS } from \"./engine/InsightsEngine\";\nimport { assertValidConfig } from \"./validateConfig\";\nimport { HomeCardInsightSlot } from \"./components/HomeCardInsightSlot\";\nimport { HomeInsightsSlot } from \"./components/HomeInsightsSlot\";\nimport { CollectionInsightsInline } from \"./components/CollectionInsightsInline\";\n\n/**\n * Creates the Insights plugin for Rebase.\n *\n * This plugin injects scorecard widgets into key UI locations:\n * - **Home page header**: KPI overview via `home.children.start` slot\n * - **Collection list view**: Scorecards inline (below title, above list) via `collection.widgets` slot\n * - **Home page cards**: Compact readouts of the collection's insights via `home.card.widget` slot\n *\n * Figures come from named `sources`, each fetched once per user however many\n * insights read it: a value shown on the home page and in a collection view\n * is the same number. Every source is handed the comparison period, so the\n * query and the label above the tiles describe the same window.\n *\n * Pass a memoized config: a new `sources` object starts a new cache.\n *\n * @example\n * ```typescript\n * import { useInsightsPlugin } from \"@rebasepro/plugin-insights\";\n *\n * const insightsPlugin = useInsightsPlugin(useMemo(() => ({\n * period: { days: 30 },\n * sources: {\n * orders: ({ period }) => fetchOrderStats(period.from, period.to, period.previousFrom)\n * },\n * insights: {\n * home: [{\n * id: \"revenue\",\n * title: \"Revenue\",\n * source: \"orders\",\n * value: { field: \"revenue\", format: { style: \"currency\", currency: \"USD\" } },\n * comparison: { previous: \"previousRevenue\", intent: \"increase_is_good\" }\n * }],\n * collections: {\n * orders: [{\n * id: \"shipped\",\n * title: \"Shipped\",\n * source: \"orders\",\n * value: { field: \"shipped\" },\n * comparison: { previous: \"previousShipped\", intent: \"increase_is_good\", show: \"absolute\" }\n * }]\n * }\n * }\n * }), []));\n * ```\n */\nexport function useInsightsPlugin(config: InsightsPluginConfig): RebasePlugin {\n const { insights, sources, period, cacheTTL } = config;\n const periodDays = period?.days ?? DEFAULT_PERIOD_DAYS;\n\n return React.useMemo(() => {\n assertValidConfig({ insights, sources, period: { days: periodDays } });\n const slots: AnySlotContribution[] = [];\n\n // ── Home page insights ────────────────────────────────────────────\n if (insights.home && insights.home.length > 0) {\n const homeInsights = insights.home;\n slots.push({\n slot: \"home.children.start\" as const,\n Component: () => <HomeInsightsSlot insights={homeInsights}/>,\n order: 10\n });\n }\n\n // ── Per-collection insights ───────────────────────────────────────\n // A single `collections.<slug>` definition serves two slots:\n // 1. collection.widgets → inline scorecards in the list view\n // 2. home.card.widget → compact scorecards on the home card\n if (insights.collections) {\n for (const [slug, defs] of Object.entries(insights.collections)) {\n if (defs.length === 0) continue;\n const collectionInsights = defs;\n\n // 1. Inline in collection list view\n slots.push({\n slot: \"collection.widgets\" as const,\n Component: (props: CollectionWidgetsSlotProps) => {\n const collectionSlug = props.path?.split(\"/\").filter(Boolean).pop() ?? \"\";\n if (collectionSlug !== slug) return null;\n\n // Skip relation-scoped views (e.g. a single product's Orders\n // tab). Sources are collection-wide — a source is handed the\n // period and nothing about a parent entity — and rendering\n // \"Revenue $36.2K\" above one product's two orders reads as a\n // figure for those orders.\n if (props.parentEntityIds && props.parentEntityIds.length > 0) return null;\n\n return <CollectionInsightsInline insights={collectionInsights}/>;\n },\n order: 10\n });\n\n // 2. Auto-extract scorecards for home page card\n slots.push({\n slot: \"home.card.widget\" as const,\n Component: (props: HomeCardWidgetSlotProps) => {\n if (props.slug !== slug) return null;\n return <HomeCardInsightSlot insights={collectionInsights}/>;\n },\n order: 10\n });\n }\n }\n\n return {\n key: \"plugin-insights\",\n slots,\n providers: [\n {\n scope: \"root\" as const,\n Component: InsightsProvider as React.ComponentType<React.PropsWithChildren<Record<string, unknown>>>,\n props: { sources, periodDays, cacheTTL }\n }\n ]\n };\n }, [insights, sources, periodDays, cacheTTL]);\n}\n"],"mappings":"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"}
@@ -1,83 +1,111 @@
1
- import type { DataRow, ScorecardConfig } from "./widgets.js";
2
- export interface InsightContext {
3
- /** The resolved path of the collection (e.g., "products/123/orders" or "orders") */
4
- path?: string;
5
- parentCollectionSlugs?: string[];
6
- /** The parent entity IDs if this is a subcollection (e.g., ["123"]) */
7
- parentEntityIds?: string[];
8
- /** The collection slug if this is an insight at the collection level */
9
- collectionSlug?: string;
1
+ import type { DataRow, InsightFormat } from "./widgets.js";
2
+ /**
3
+ * The window every comparison is measured over: the last `days` days against
4
+ * the `days` days before them.
5
+ */
6
+ export interface InsightPeriodConfig {
7
+ days: number;
10
8
  }
11
9
  /**
12
- * Result returned by an insight's data callback.
10
+ * A period resolved to instants. The current window is `[from, to)` and the
11
+ * previous one is `[previousFrom, from)`, both `days` long.
12
+ *
13
+ * `to` is fixed when the first source of a page load is fetched and shared by
14
+ * every source fetched while the cache holds, so two sources on one screen
15
+ * describe the same window.
13
16
  */
14
- export interface InsightDataResult {
15
- rows: DataRow[];
17
+ export interface InsightPeriod {
18
+ days: number;
19
+ /** Start of the current window, inclusive. */
20
+ from: Date;
21
+ /** End of the current window, exclusive. */
22
+ to: Date;
23
+ /** Start of the previous window, inclusive. It ends at `from`. */
24
+ previousFrom: Date;
25
+ }
26
+ /** What a source is called with. */
27
+ export interface InsightSourceContext {
28
+ period: InsightPeriod;
16
29
  }
17
30
  /**
18
- * A single insight definition — the "dry" configuration that describes
19
- * what data to fetch and how to render it.
31
+ * Fetches one record of figures. Use the Rebase client, call a backend
32
+ * function, hit any API: the plugin only reads the fields its insights name.
33
+ *
34
+ * A source is fetched once per signed-in user however many insights read it,
35
+ * on the home page and in the collection views alike, so a figure shown in two
36
+ * places is always the same number.
20
37
  *
21
- * Each insight owns its own `data()` callback, giving the developer
22
- * full flexibility: use the Rebase client SDK, call a custom function,
23
- * hit an external API — whatever makes sense for that widget.
38
+ * @example
39
+ * ```typescript
40
+ * orders: ({ period }) => rebaseClient.functions.invoke("insights", undefined, {
41
+ * method: "GET",
42
+ * path: `orders?from=${period.from.toISOString()}&to=${period.to.toISOString()}`
43
+ * })
44
+ * ```
45
+ */
46
+ export type InsightSource = (context: InsightSourceContext) => Promise<DataRow>;
47
+ /**
48
+ * How an insight compares its value with the previous period. The plugin
49
+ * works out the change from the two figures, so it can choose how to show it.
50
+ */
51
+ export interface InsightComparison {
52
+ /** The field holding the same figure for the previous period. */
53
+ previous: string;
54
+ /**
55
+ * Which direction is good news.
56
+ * - `increase_is_good`: an increase reads as positive (revenue, sign-ups).
57
+ * - `decrease_is_good`: a decrease reads as positive (refunds, churn).
58
+ */
59
+ intent: "increase_is_good" | "decrease_is_good";
60
+ /**
61
+ * - `percent` (default): the relative change, `↑ 42%`. Shown as an
62
+ * absolute change when the previous figure is zero.
63
+ * - `absolute`: the difference in the value's own format, `↑ 9`. Right for
64
+ * small counts, where going from 3 to 12 would read as `↑ 300%`.
65
+ */
66
+ show?: "percent" | "absolute";
67
+ }
68
+ /**
69
+ * One figure on a scorecard: which source to read, which field holds the
70
+ * value, and how to write it.
24
71
  */
25
72
  export interface InsightDefinition {
26
73
  /** Unique identifier for this insight */
27
74
  id: string;
28
75
  /** Display title */
29
76
  title: string;
30
- /** Optional description */
31
- description?: string;
32
- /**
33
- * Async callback that fetches data for this insight.
34
- *
35
- * The developer has full control — they can use any data source:
36
- * - `rebaseClient.data.orders.find({ limit: 100 })`
37
- * - `rebaseClient.call("functions/my-analytics", { ... })`
38
- * - A plain `fetch()` to any external API
39
- * - Static data for prototyping
40
- *
41
- * @returns Tabular data as `{ rows: DataRow[] }`.
42
- *
43
- * @example
44
- * ```typescript
45
- * data: async () => {
46
- * const res = await rebaseClient.data.orders.find({
47
- * limit: 1000,
48
- * orderBy: "created_at",
49
- * });
50
- * return { rows: res.data };
51
- * }
52
- * ```
53
- */
54
- data: (context: InsightContext) => Promise<InsightDataResult>;
55
- /** Scorecard field mapping + formatting. */
56
- scorecard: ScorecardConfig;
77
+ /** The key in {@link InsightsPluginConfig.sources} this insight reads. */
78
+ source: string;
79
+ /** The field holding the value, and how to write it. */
80
+ value: {
81
+ field: string;
82
+ format?: InsightFormat;
83
+ };
84
+ comparison?: InsightComparison;
85
+ /** Optional icon key (e.g., "ShoppingCart", "Users"), resolved via `getIcon` */
86
+ icon?: string;
57
87
  }
58
88
  /**
59
89
  * Full plugin configuration passed to `useInsightsPlugin`.
60
90
  *
61
- * The developer defines scorecard widgets by placement and provides
62
- * their own data callbacks. No global fetch function needed — each
63
- * widget is self-contained.
64
- *
65
91
  * Collection-level insights (`collections.<slug>`) are rendered in two places
66
92
  * automatically:
67
93
  * - **Collection list view**: Scorecards appear inline below the title and
68
94
  * above the data list.
69
- * - **Home page cards**: Scorecards are auto-extracted and rendered as compact
70
- * widgets inside each collection's card on the home page.
71
- *
72
- * This eliminates the need to duplicate definitions across different locations.
95
+ * - **Home page cards**: Scorecards are rendered as compact figures inside
96
+ * each collection's card on the home page.
73
97
  */
74
98
  export interface InsightsPluginConfig {
99
+ /** The comparison window. Defaults to 30 days. */
100
+ period?: InsightPeriodConfig;
101
+ /** Where the figures come from, by name. Insights refer to these keys. */
102
+ sources: Record<string, InsightSource>;
75
103
  /**
76
104
  * Insight definitions keyed by placement.
77
105
  *
78
106
  * - `home`: Rendered at the top of the home page via `home.children.start`.
79
107
  * - `collections.<slug>`: Rendered inline in that collection's list view
80
- * and auto-extracted as compact scorecards on the home card.
108
+ * and as compact figures on its home card.
81
109
  */
82
110
  insights: {
83
111
  home?: InsightDefinition[];
@@ -1,18 +1,18 @@
1
1
  /**
2
- * Tabular data types used by insight widgets.
2
+ * Data and formatting types used by insight widgets.
3
3
  */
4
- /** A single row of data as key-value pairs. */
4
+ /** One record a source returns: the figures its insights read, by field name. */
5
5
  export type DataRow = Record<string, string | number | boolean | null>;
6
6
  /**
7
- * Formatting options for scorecard numbers.
8
- * Uses Intl.NumberFormat standard.
7
+ * How a figure is written. Uses `Intl.NumberFormat` in the admin panel's
8
+ * language, so `1234.5` reads `1,234.5` in English and `1.234,5` in German.
9
9
  */
10
- export interface ScorecardFormat {
10
+ export interface InsightFormat {
11
11
  /**
12
12
  * The style of formatting.
13
13
  * - `decimal`: 1,234.5
14
14
  * - `currency`: $1,234.50
15
- * - `percent`: 12.5%
15
+ * - `percent`: 12.5% (the value is a ratio: 0.125)
16
16
  */
17
17
  style: "decimal" | "currency" | "percent";
18
18
  /**
@@ -25,35 +25,4 @@ export interface ScorecardFormat {
25
25
  currency?: string;
26
26
  /** Number of decimal places to show */
27
27
  decimals?: number;
28
- /** If true, adds a '+' sign for positive numbers (e.g., +12.5%) */
29
- showSign?: boolean;
30
- }
31
- /**
32
- * Scorecard widget configuration — field mapping + formatting.
33
- */
34
- export interface ScorecardConfig {
35
- /** Main value configuration */
36
- value: {
37
- /** The column name from the query result for the main value */
38
- field: string;
39
- /** How to format this number */
40
- format?: ScorecardFormat;
41
- };
42
- /** Comparison value configuration (optional) */
43
- comparison?: {
44
- /** The column name from the query result for the comparison value */
45
- field: string;
46
- /** How to format this number */
47
- format?: ScorecardFormat;
48
- /**
49
- * Determines the color (green/red) based on the value.
50
- * - `increase_is_good`: Positive = green, negative = red.
51
- * - `decrease_is_good`: Positive = red, negative = green.
52
- */
53
- intent: "increase_is_good" | "decrease_is_good";
54
- };
55
- /** Optional icon key (e.g., "shopping_cart", "users") — resolved via getIcon */
56
- icon?: string;
57
- /** Optional date range text (e.g., "Last 30 days") */
58
- dateRange?: string;
59
28
  }
@@ -6,32 +6,43 @@ import type { InsightsPluginConfig } from "./types/index.js";
6
6
  * This plugin injects scorecard widgets into key UI locations:
7
7
  * - **Home page header**: KPI overview via `home.children.start` slot
8
8
  * - **Collection list view**: Scorecards inline (below title, above list) via `collection.widgets` slot
9
- * - **Home page cards**: Compact scorecard metrics auto-extracted from collection insights via `home.card.widget` slot
9
+ * - **Home page cards**: Compact readouts of the collection's insights via `home.card.widget` slot
10
10
  *
11
- * Collection-level insights (`collections.<slug>`) are the single source of truth:
12
- * scorecards render in the collection list view and are automatically extracted
13
- * to show as compact widgets on the corresponding home page card.
11
+ * Figures come from named `sources`, each fetched once per user however many
12
+ * insights read it: a value shown on the home page and in a collection view
13
+ * is the same number. Every source is handed the comparison period, so the
14
+ * query and the label above the tiles describe the same window.
14
15
  *
15
- * Each insight owns its own `data()` callback — use the Rebase client SDK,
16
- * call a custom function, or hit any external API. Full flexibility, zero new endpoints.
16
+ * Pass a memoized config: a new `sources` object starts a new cache.
17
17
  *
18
18
  * @example
19
19
  * ```typescript
20
20
  * import { useInsightsPlugin } from "@rebasepro/plugin-insights";
21
21
  *
22
- * const insightsPlugin = useInsightsPlugin({
23
- * cacheTTL: 120_000,
22
+ * const insightsPlugin = useInsightsPlugin(useMemo(() => ({
23
+ * period: { days: 30 },
24
+ * sources: {
25
+ * orders: ({ period }) => fetchOrderStats(period.from, period.to, period.previousFrom)
26
+ * },
24
27
  * insights: {
25
- * home: [
26
- * { id: "revenue", title: "Revenue", data: async () => ..., scorecard: { ... } },
27
- * ],
28
+ * home: [{
29
+ * id: "revenue",
30
+ * title: "Revenue",
31
+ * source: "orders",
32
+ * value: { field: "revenue", format: { style: "currency", currency: "USD" } },
33
+ * comparison: { previous: "previousRevenue", intent: "increase_is_good" }
34
+ * }],
28
35
  * collections: {
29
- * orders: [
30
- * { id: "total", title: "Total Orders", data: async () => ..., scorecard: { ... } },
31
- * ],
32
- * },
33
- * },
34
- * });
36
+ * orders: [{
37
+ * id: "shipped",
38
+ * title: "Shipped",
39
+ * source: "orders",
40
+ * value: { field: "shipped" },
41
+ * comparison: { previous: "previousShipped", intent: "increase_is_good", show: "absolute" }
42
+ * }]
43
+ * }
44
+ * }
45
+ * }), []));
35
46
  * ```
36
47
  */
37
48
  export declare function useInsightsPlugin(config: InsightsPluginConfig): RebasePlugin;
@@ -0,0 +1,7 @@
1
+ import type { InsightsPluginConfig } from "./types/index.js";
2
+ /**
3
+ * Refuses a config whose insights read a source it does not declare, or whose
4
+ * period is not a whole number of days. Both would otherwise surface as an
5
+ * error tile per insight, on whichever screen a user opened first.
6
+ */
7
+ export declare function assertValidConfig(config: InsightsPluginConfig): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebasepro/plugin-insights",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Insights plugin for the Rebase admin panel: charts and saved queries over your collections.",
5
5
  "keywords": [
6
6
  "rebase",
@@ -36,10 +36,10 @@
36
36
  "./package.json": "./package.json"
37
37
  },
38
38
  "dependencies": {
39
- "@rebasepro/ui": "0.23.0",
40
- "@rebasepro/cms-types": "0.23.0",
41
- "@rebasepro/types": "0.23.0",
42
- "@rebasepro/app": "0.23.0"
39
+ "@rebasepro/cms-types": "0.24.0",
40
+ "@rebasepro/app": "0.24.0",
41
+ "@rebasepro/types": "0.24.0",
42
+ "@rebasepro/ui": "0.24.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "react": "^19.2.7",
@@ -1,29 +0,0 @@
1
- import React from "react";
2
- import type { ScorecardConfig } from "../types/index.js";
3
- /**
4
- * Skeleton loader for scorecard insight widgets — displays animated
5
- * shimmer placeholders that exactly match the final rendered layout
6
- * of InsightsScorecardView for a given config, preventing layout shift.
7
- *
8
- * The skeleton receives the scorecard config so it can conditionally
9
- * render placeholder lines for comparison, dateRange, and icon —
10
- * only when the loaded view will also render them.
11
- *
12
- * The standard skeleton mirrors InsightsScorecardView's responsive
13
- * container-width breakpoints (ResizeObserver → isSmall / isMedium)
14
- * and uses placeholder heights that exactly match the **computed**
15
- * Tailwind line-heights (accounting for `leading-*` overrides).
16
- * This guarantees a pixel-perfect skeleton → loaded transition.
17
- */
18
- export declare function InsightWidgetSkeleton({ config, compact, embedded, fixedHeight }: {
19
- /** Scorecard config — used to match optional elements (comparison, dateRange, icon). */
20
- config: ScorecardConfig;
21
- compact?: boolean;
22
- /** When true, skip own border since the parent card provides it. */
23
- embedded?: boolean;
24
- /** Explicit height to prevent layout shift between skeleton → loaded. */
25
- fixedHeight?: number;
26
- }): React.JSX.Element;
27
- export declare namespace InsightWidgetSkeleton {
28
- var displayName: string;
29
- }
@@ -1,27 +0,0 @@
1
- import type { InsightContext, InsightDataResult } from "../types/index.js";
2
- /**
3
- * The cache key for one insight, in one scope, for one user.
4
- *
5
- * The cache lives at the root of the app and outlives a sign-out, and a figure
6
- * is computed under the permissions and row-level security of whoever asked
7
- * for it. A key without the user served the previous account's numbers to the
8
- * next one that signed in on the same tab, until the TTL ran out.
9
- */
10
- export declare function insightCacheKey(definitionId: string, context: InsightContext, userId: string | null): string;
11
- /**
12
- * In-memory cache for insight query results.
13
- * Supports TTL-based expiry and inflight request deduplication
14
- * to prevent redundant network requests when multiple widgets
15
- * share the same query.
16
- */
17
- export declare class InsightsCache {
18
- private ttl;
19
- private cache;
20
- private inflight;
21
- constructor(ttl?: number);
22
- get(key: string): InsightDataResult | null;
23
- set(key: string, data: InsightDataResult): void;
24
- getInflight(key: string): Promise<InsightDataResult> | null;
25
- setInflight(key: string, promise: Promise<InsightDataResult>): void;
26
- invalidate(key?: string): void;
27
- }
@@ -1,17 +0,0 @@
1
- import type { InsightDefinition, InsightDataResult, InsightContext } from "../types/index.js";
2
- /**
3
- * Hook that fetches and caches data for a single insight definition.
4
- *
5
- * Calls the definition's own `data()` callback and manages:
6
- * - TTL-based caching via InsightsCache
7
- * - Inflight request deduplication (multiple mounts of the same widget)
8
- * - Loading and error state management
9
- *
10
- * @param definition - The insight to fetch data for
11
- * @param context - The collection scope; with the signed-in user, it scopes the cache key
12
- */
13
- export declare function useInsightsData(definition: InsightDefinition, context: InsightContext): {
14
- data: InsightDataResult | null;
15
- loading: boolean;
16
- error: Error | null;
17
- };