@rebasepro/plugin-insights 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,99 +16,122 @@ of it resolves too: Node has supported `require(esm)` since 22.12.
16
16
 
17
17
  ## What This Package Does
18
18
 
19
- This plugin injects data-driven scorecard widgets into the Rebase admin UI. You define insight definitions with custom `data()` callbacks — use the Rebase SDK, call a custom function, or hit any external API. The plugin handles caching, rendering, and slot injection.
19
+ This plugin puts figures — revenue, orders, open tickets — into the Rebase admin
20
+ UI, each beside its change over a comparison period. You declare named
21
+ `sources`, functions that fetch one record of figures for that period: use the
22
+ Rebase SDK, call a backend function, or hit any API. Each insight names the
23
+ source and the field it reads. The plugin fetches each source once per signed-in
24
+ user, caches it, works out the change, and renders it in the right slots.
20
25
 
21
- Widgets appear in three locations automatically:
26
+ Figures appear in three places:
22
27
 
23
- - **Home page header** — KPI overview cards via the `home.children.start` slot
24
- - **Collection list view** — Inline scorecards below the title, above the data list via `collection.widgets`
25
- - **Home page cards** — Compact metrics auto-extracted from collection insights via `home.card.widget`
28
+ - **Home page header**: a row of KPI cards via the `home.children.start` slot
29
+ - **Collection list view**: a row of figures below the title, above the list, via `collection.widgets`
30
+ - **Home page cards**: compact figures on each collection's card via `home.card.widget`
26
31
 
27
- Collection-level insights are the single source of truth: define once under `collections.<slug>`, and they render both in the collection view and on the home card.
32
+ Collection insights are defined once, under `collections.<slug>`, and render
33
+ both in the collection's list view and on its home card. Every insight that
34
+ reads a source shares one fetch of it, so a figure shown in two places is the
35
+ same number.
28
36
 
29
37
  ## Key Exports
30
38
 
31
39
  | Export | Type | Description |
32
40
  |---|---|---|
33
41
  | `useInsightsPlugin` | Hook | Creates the plugin from an `InsightsPluginConfig`. Returns a `RebasePlugin` |
34
- | `InsightsPluginConfig` | Type | Top-level config: `insights` (home + collections) and optional `cacheTTL` |
35
- | `InsightDefinition` | Type | Single insight: `id`, `title`, `data()` callback, `scorecard` config |
36
- | `InsightDataResult` | Type | Return type of `data()`: `{ rows: DataRow[] }` |
42
+ | `InsightsPluginConfig` | Type | Top-level config: `sources`, `insights` (home + collections), optional `period` and `cacheTTL` |
43
+ | `InsightSource` | Type | `(context: { period }) => Promise<DataRow>`: fetches one record of figures |
44
+ | `InsightSourceContext` | Type | What a source is called with: `{ period: InsightPeriod }` |
45
+ | `InsightPeriod` | Type | The resolved windows: `days`, `from`, `to`, `previousFrom` |
46
+ | `InsightPeriodConfig` | Type | `{ days }`, the comparison window |
47
+ | `InsightDefinition` | Type | One figure: `id`, `title`, `source`, `value`, optional `comparison` and `icon` |
48
+ | `InsightComparison` | Type | `previous` field, `intent`, and `show` (`percent` or `absolute`) |
49
+ | `InsightFormat` | Type | Number formatting: `style` (decimal/currency/percent), `notation`, `currency`, `decimals` |
37
50
  | `DataRow` | Type | `Record<string, string \| number \| boolean \| null>` |
38
- | `ScorecardConfig` | Type | Field mapping for value, comparison, icon, dateRange |
39
- | `ScorecardFormat` | Type | Number formatting: style (decimal/currency/percent), notation, currency, decimals, showSign |
40
51
  | `InsightsProvider` | Component | React context provider (injected automatically by the plugin) |
41
52
  | `useInsightsEngine` | Hook | Access the insights engine from context (advanced) |
42
- | `InsightsCache` | Class | TTL-based cache for insight data (advanced) |
43
- | `useInsightsData` | Hook | Fetch and cache data for a specific insight (advanced) |
44
- | `InsightsScorecardView` | Component | Renders a scorecard from data + config (custom layouts) |
45
- | `InsightWidget` | Component | Single insight widget container (custom layouts) |
46
- | `InsightWidgetSkeleton` | Component | Loading placeholder for insight widgets |
53
+ | `InsightsEngine` | Class | Fetches and caches sources per user, and anchors the period (advanced) |
54
+ | `resolvePeriod` | Function | `(days, to) => InsightPeriod` (advanced) |
55
+ | `useInsightSource` | Hook | Reads one source for the signed-in user (custom layouts) |
56
+ | `InsightsRow` | Component | A row of insights under the period label (custom layouts) |
57
+ | `InsightsScorecardView` | Component | Renders one figure from a record and its definition (custom layouts) |
58
+ | `InsightWidget` | Component | Single insight container (custom layouts) |
47
59
 
48
60
  ### `InsightsPluginConfig`
49
61
 
50
62
  | Prop | Type | Default | Description |
51
63
  |---|---|---|---|
64
+ | `sources` | `Record<string, InsightSource>` | — | Where the figures come from, by name. Required |
52
65
  | `insights.home` | `InsightDefinition[]` | — | Insights shown at the top of the home page |
53
66
  | `insights.collections` | `Record<string, InsightDefinition[]>` | — | Insights per collection slug |
54
- | `cacheTTL` | `number` | `60_000` | Cache TTL in milliseconds |
67
+ | `period.days` | `number` | `30` | The comparison window: the last N days against the N days before them |
68
+ | `cacheTTL` | `number` | `60_000` | How long a fetched source is reused, in milliseconds |
55
69
 
56
- ### `ScorecardConfig`
70
+ Creating the plugin throws when an insight names a source that `sources` does
71
+ not declare, or when `period.days` is not a whole number of at least 1. Pass a
72
+ memoized config: a new `sources` object starts a new cache.
73
+
74
+ ### `InsightDefinition`
57
75
 
58
76
  | Prop | Type | Description |
59
77
  |---|---|---|
60
- | `value.field` | `string` | Column name from data rows for the main value |
61
- | `value.format` | `ScorecardFormat` | Number formatting options |
62
- | `comparison.field` | `string` | Column name for comparison/delta value |
63
- | `comparison.format` | `ScorecardFormat` | Formatting for comparison |
64
- | `comparison.intent` | `"increase_is_good" \| "decrease_is_good"` | Controls green/red coloring |
65
- | `icon` | `string` | Icon key (e.g., `"shopping_cart"`) |
66
- | `dateRange` | `string` | Label text (e.g., `"Last 30 days"`) |
78
+ | `source` | `string` | The key in `sources` this insight reads |
79
+ | `value.field` | `string` | The field holding the figure |
80
+ | `value.format` | `InsightFormat` | How to write it, in the admin panel's language |
81
+ | `comparison.previous` | `string` | The field holding the same figure for the previous period |
82
+ | `comparison.intent` | `"increase_is_good" \| "decrease_is_good"` | Which direction reads as good news |
83
+ | `comparison.show` | `"percent" \| "absolute"` | The relative change (default), or the difference. Use `absolute` for small counts |
84
+ | `icon` | `string` | Icon key (e.g., `"ShoppingCart"`), resolved via `getIcon` |
67
85
 
68
86
  ## Quick Start
69
87
 
70
88
  ```tsx
89
+ import { useMemo } from "react";
71
90
  import { Rebase } from "@rebasepro/app";
72
91
  import { RebaseShell } from "@rebasepro/cms";
73
92
  import { useInsightsPlugin } from "@rebasepro/plugin-insights";
74
-
75
- const insightsPlugin = useInsightsPlugin({
76
- cacheTTL: 120_000,
93
+ import type { DataRow, InsightPeriod } from "@rebasepro/plugin-insights";
94
+
95
+ const windowQuery = (period: InsightPeriod) => new URLSearchParams({
96
+ previousFrom: period.previousFrom.toISOString(),
97
+ from: period.from.toISOString(),
98
+ to: period.to.toISOString()
99
+ }).toString();
100
+
101
+ const insightsPlugin = useInsightsPlugin(useMemo(() => ({
102
+ period: { days: 30 },
103
+ sources: {
104
+ // One request answers every insight below: { revenue, previousRevenue, shipped, previousShipped }
105
+ orders: ({ period }) => rebaseClient.functions.invoke<DataRow>("insights", undefined, {
106
+ method: "GET",
107
+ path: `orders?${windowQuery(period)}`
108
+ })
109
+ },
77
110
  insights: {
78
111
  home: [
79
112
  {
80
113
  id: "revenue",
81
114
  title: "Revenue",
82
- data: async () => {
83
- const res = await fetch("/api/analytics/revenue");
84
- return { rows: [await res.json()] };
85
- },
86
- scorecard: {
87
- value: { field: "total", format: { style: "currency", currency: "USD", notation: "compact" } },
88
- comparison: { field: "delta_pct", format: { style: "percent", decimals: 1 }, intent: "increase_is_good" },
89
- icon: "attach_money",
90
- dateRange: "Last 30 days"
91
- }
115
+ source: "orders",
116
+ icon: "DollarSign",
117
+ value: { field: "revenue", format: { style: "currency", currency: "USD", notation: "compact" } },
118
+ comparison: { previous: "previousRevenue", intent: "increase_is_good" }
92
119
  }
93
120
  ],
94
121
  collections: {
95
122
  orders: [
96
123
  {
97
- id: "total_orders",
98
- title: "Total Orders",
99
- data: async () => {
100
- const count = await rebaseClient.data.orders.count();
101
- return { rows: [{ total: count }] };
102
- },
103
- scorecard: {
104
- value: { field: "total", format: { style: "decimal", notation: "compact" } },
105
- icon: "shopping_cart"
106
- }
124
+ id: "shipped",
125
+ title: "Shipped",
126
+ source: "orders",
127
+ icon: "Truck",
128
+ value: { field: "shipped" },
129
+ comparison: { previous: "previousShipped", intent: "increase_is_good", show: "absolute" }
107
130
  }
108
131
  ]
109
132
  }
110
133
  }
111
- });
134
+ }), []));
112
135
 
113
136
  // Pass to your Rebase app:
114
137
  <Rebase client={rebaseClient} plugins={[insightsPlugin]}>
@@ -116,6 +139,10 @@ const insightsPlugin = useInsightsPlugin({
116
139
  </Rebase>
117
140
  ```
118
141
 
142
+ A source runs in the browser under the signed-in user's permissions. The demo
143
+ app's `insights` backend function (`app/backend/functions/insights.ts`) is one
144
+ way to compute both windows in a single query.
145
+
119
146
  ## Related Packages
120
147
 
121
148
  - `@rebasepro/app` — Core framework providing the plugin system
@@ -6,11 +6,7 @@ import type { InsightDefinition } from "../types/index.js";
6
6
  *
7
7
  * Injected via the `collection.widgets` slot.
8
8
  */
9
- export declare function CollectionInsightsInline({ insights, path, parentCollectionSlugs, parentEntityIds }: {
10
- path: string;
11
- collection: unknown;
12
- parentCollectionSlugs: string[];
13
- parentEntityIds: string[];
9
+ export declare function CollectionInsightsInline({ insights }: {
14
10
  insights: InsightDefinition[];
15
11
  }): React.JSX.Element | null;
16
12
  export declare namespace CollectionInsightsInline {
@@ -1,15 +1,10 @@
1
1
  import React from "react";
2
2
  import type { InsightDefinition } from "../types/index.js";
3
3
  /**
4
- * Renders compact insight widgets inline within a home page collection card.
4
+ * Renders compact insight readouts inside a home page collection card.
5
5
  * Injected via the `home.card.widget` slot.
6
- *
7
- * Uses a horizontal flex layout so multiple cards sit side by side.
8
6
  */
9
- export declare function HomeCardInsightSlot({ slug, insights }: {
10
- slug: string;
11
- collection: unknown;
12
- context: unknown;
7
+ export declare function HomeCardInsightSlot({ insights }: {
13
8
  insights: InsightDefinition[];
14
9
  }): React.JSX.Element | null;
15
10
  export declare namespace HomeCardInsightSlot {
@@ -3,8 +3,6 @@ import type { InsightDefinition } from "../types/index.js";
3
3
  /**
4
4
  * Scorecard insights panel rendered at the top of the home page.
5
5
  * Injected via the `home.children.start` slot.
6
- *
7
- * Renders scorecards in a responsive grid (up to 4 columns).
8
6
  */
9
7
  export declare function HomeInsightsSlot({ insights }: {
10
8
  insights: InsightDefinition[];
@@ -1,22 +1,9 @@
1
1
  import React from "react";
2
2
  import type { InsightDefinition } from "../types/index.js";
3
- /**
4
- * Single insight widget orchestrator.
5
- *
6
- * Wraps skeleton and loaded states in a fixed-height container
7
- * (computed from the scorecard config) to prevent layout shift.
8
- *
9
- * All theme-awareness is handled via Tailwind `dark:` classes.
10
- */
11
- export declare function InsightWidget({ definition, collectionSlug, path, parentCollectionSlugs, parentEntityIds, compact, embedded }: {
3
+ /** One insight, reading its source through the shared engine. */
4
+ export declare function InsightWidget({ definition, compact }: {
12
5
  definition: InsightDefinition;
13
- collectionSlug?: string;
14
- path?: string;
15
- parentCollectionSlugs?: string[];
16
- parentEntityIds?: string[];
17
6
  compact?: boolean;
18
- /** When true, inner views skip their own borders since the parent card provides them. */
19
- embedded?: boolean;
20
7
  }): React.JSX.Element;
21
8
  export declare namespace InsightWidget {
22
9
  var displayName: string;
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import type { InsightDefinition } from "../types/index.js";
3
+ /**
4
+ * A row of insight tiles. When any of them compares with the previous period,
5
+ * the row says which period once, above the tiles, rather than on each.
6
+ */
7
+ export declare function InsightsRow({ insights, className }: {
8
+ insights: InsightDefinition[];
9
+ className?: string;
10
+ }): React.JSX.Element;
11
+ export declare namespace InsightsRow {
12
+ var displayName: string;
13
+ }
@@ -1,21 +1,22 @@
1
1
  import React from "react";
2
- import type { DataRow, ScorecardConfig } from "../types/index.js";
2
+ import type { InsightDefinition } from "../types/index.js";
3
+ import type { SourceResult } from "../engine/InsightsEngine.js";
3
4
  /**
4
- * Scorecard widget for the Rebase design system.
5
+ * One insight: its label, its value and its change on the previous period.
5
6
  *
6
- * Renders a single KPI metric with optional comparison value and icon.
7
- * Uses Tailwind `dark:` classes — no JS dark mode detection.
8
- * Icons are resolved via `getIcon` from `@rebasepro/app`.
7
+ * The label and icon are known before the figures are, so they render while
8
+ * the source loads and only the figures pulse. Loading, loaded and failed
9
+ * share one shell, so nothing moves when the data arrives.
10
+ *
11
+ * `compact` is the inline readout on a home-page card; the default is a tile.
9
12
  */
10
- export declare function InsightsScorecardView({ config, data, title, compact, embedded, fixedHeight }: {
11
- config: ScorecardConfig;
12
- data: DataRow;
13
- title: string;
13
+ export declare function InsightsScorecardView({ definition, result, loading, error, compact }: {
14
+ definition: InsightDefinition;
15
+ /** The source's record and the period it was fetched for. */
16
+ result: SourceResult | null;
17
+ loading?: boolean;
18
+ error?: Error | null;
14
19
  compact?: boolean;
15
- /** When true, skip own border/bg since the parent card provides them. */
16
- embedded?: boolean;
17
- /** Explicit height to prevent layout shift between skeleton → loaded. */
18
- fixedHeight?: number;
19
20
  }): React.JSX.Element;
20
21
  export declare namespace InsightsScorecardView {
21
22
  var displayName: string;
@@ -0,0 +1,48 @@
1
+ import type { DataRow, InsightPeriod, InsightSource } from "../types/index.js";
2
+ export declare const DEFAULT_PERIOD_DAYS = 30;
3
+ /** The current and previous windows of `days` days, ending at `to`. */
4
+ export declare function resolvePeriod(days: number, to: Date): InsightPeriod;
5
+ /** A source's record, with the period it was fetched for. */
6
+ export interface SourceResult {
7
+ row: DataRow;
8
+ period: InsightPeriod;
9
+ }
10
+ /**
11
+ * The cache key for one source, for one user.
12
+ *
13
+ * The engine lives at the root of the app and outlives a sign-out, and a figure
14
+ * is computed under the permissions and row-level security of whoever asked
15
+ * for it. A key without the user would serve the previous account's numbers to
16
+ * the next one that signs in on the same tab, until the TTL ran out.
17
+ */
18
+ export declare function sourceCacheKey(sourceId: string, userId: string | null): string;
19
+ /**
20
+ * Fetches and caches the plugin's sources.
21
+ *
22
+ * Every insight that reads a source shares one fetch of it: concurrent reads
23
+ * join the request in flight, later ones read the cache until the TTL passes.
24
+ * That is what keeps a figure shown on the home page and in a collection view
25
+ * the same number.
26
+ */
27
+ export declare class InsightsEngine {
28
+ private readonly sources;
29
+ readonly periodDays: number;
30
+ private readonly ttl;
31
+ private readonly now;
32
+ private readonly cache;
33
+ private readonly inflight;
34
+ private anchor;
35
+ constructor(sources: Record<string, InsightSource>, periodDays?: number, ttl?: number, now?: () => number);
36
+ /**
37
+ * The period a fetch starting now is asked for. Its end is fixed by the
38
+ * first fetch and kept until the TTL passes, so sources fetched for the
39
+ * same screen describe the same window.
40
+ */
41
+ period(): InsightPeriod;
42
+ /** The cached result for this source and user, if it is still fresh. */
43
+ peek(sourceId: string, userId: string | null): SourceResult | null;
44
+ /** The source's record for this user: from the cache, the fetch in flight, or a new fetch. */
45
+ load(sourceId: string, userId: string | null): Promise<SourceResult>;
46
+ /** Drops every cached result and fetch in flight, and re-anchors the period on the next fetch. */
47
+ invalidate(): void;
48
+ }
@@ -1,22 +1,20 @@
1
1
  import React, { type PropsWithChildren } from "react";
2
- import { InsightsCache } from "./InsightsCache.js";
3
- interface InsightsContextValue {
4
- cache: InsightsCache;
5
- }
2
+ import type { InsightSource } from "../types/index.js";
3
+ import { InsightsEngine } from "./InsightsEngine.js";
6
4
  /**
7
5
  * Root-level provider for the insights data engine.
8
6
  * Injected automatically by the plugin via `providers: [{ scope: "root" }]`.
9
7
  *
10
- * Manages a single `InsightsCache` instance shared by all insight widgets
11
- * for TTL-based caching and inflight request deduplication.
8
+ * Holds the one `InsightsEngine` every insight widget reads its source from.
12
9
  */
13
- export declare function InsightsProvider({ cacheTTL, children }: PropsWithChildren<{
10
+ export declare function InsightsProvider({ sources, periodDays, cacheTTL, children }: PropsWithChildren<{
11
+ sources: Record<string, InsightSource>;
12
+ periodDays?: number;
14
13
  cacheTTL?: number;
15
14
  }>): React.JSX.Element;
16
15
  /**
17
- * Access the insights cache (for advanced usage).
16
+ * The insights engine (for advanced usage).
18
17
  * Returns null when called outside of an `InsightsProvider`
19
18
  * (e.g. during auth-loading phase before plugin providers mount).
20
19
  */
21
- export declare function useInsightsEngine(): InsightsContextValue | null;
22
- export {};
20
+ export declare function useInsightsEngine(): InsightsEngine | null;
@@ -0,0 +1,12 @@
1
+ import { type SourceResult } from "./InsightsEngine.js";
2
+ /**
3
+ * Reads one source for the signed-in user.
4
+ *
5
+ * Waits for auth to settle: a source runs under the caller's permissions, so
6
+ * fetching before the user is known would compute the figures for nobody.
7
+ */
8
+ export declare function useInsightSource(sourceId: string): {
9
+ result: SourceResult | null;
10
+ loading: boolean;
11
+ error: Error | null;
12
+ };
@@ -0,0 +1,21 @@
1
+ import type { InsightComparison, InsightFormat } from "./types/index.js";
2
+ /** Writes a value the way its insight asks, in the admin panel's language. */
3
+ export declare function formatValue(value: number, format: InsightFormat | undefined, locale: string | undefined): string;
4
+ export type ChangeDirection = "up" | "down" | "flat";
5
+ export interface FormattedChange {
6
+ direction: ChangeDirection;
7
+ /** The size of the change, unsigned: the direction carries the sign. */
8
+ magnitude: string;
9
+ }
10
+ /**
11
+ * The change from `previous` to `current`, written as the comparison asks.
12
+ *
13
+ * A percentage needs a previous figure to be a percentage of, so a change
14
+ * from zero is written as the difference instead. Under 10% keeps one
15
+ * decimal (`0.2%`), above it none (`42%`): the decimal stops carrying
16
+ * information once the change is that large.
17
+ */
18
+ export declare function formatChange(current: number, previous: number, comparison: InsightComparison, format: InsightFormat | undefined, locale: string | undefined): FormattedChange;
19
+ export type ChangeTone = "positive" | "negative" | "neutral";
20
+ /** Whether a change in this direction is good news for this insight. */
21
+ export declare function changeTone(direction: ChangeDirection, intent: InsightComparison["intent"]): ChangeTone;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- export type { DataRow, ScorecardFormat, ScorecardConfig, InsightDataResult, InsightDefinition, InsightsPluginConfig } from "./types/index.js";
1
+ export type { DataRow, InsightFormat, InsightComparison, InsightDefinition, InsightPeriod, InsightPeriodConfig, InsightSource, InsightSourceContext, InsightsPluginConfig } from "./types/index.js";
2
2
  export { useInsightsPlugin } from "./useInsightsPlugin.js";
3
3
  export { InsightsProvider, useInsightsEngine } from "./engine/InsightsProvider.js";
4
- export { InsightsCache } from "./engine/InsightsCache.js";
5
- export { useInsightsData } from "./engine/useInsightsData.js";
4
+ export { InsightsEngine, resolvePeriod } from "./engine/InsightsEngine.js";
5
+ export type { SourceResult } from "./engine/InsightsEngine.js";
6
+ export { useInsightSource } from "./engine/useInsightSource.js";
6
7
  export { InsightsScorecardView } from "./components/InsightsScorecardView.js";
7
8
  export { InsightWidget } from "./components/InsightWidget.js";
8
- export { InsightWidgetSkeleton } from "./components/InsightWidgetSkeleton.js";
9
+ export { InsightsRow } from "./components/InsightsRow.js";