@hanzo/dashboard 0.2.0 → 0.2.2

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.
Files changed (39) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +245 -0
  3. package/package.json +38 -50
  4. package/src/analytics/index.ts +20 -0
  5. package/src/analytics/parts.tsx +91 -0
  6. package/src/analytics/product.tsx +64 -0
  7. package/src/analytics/source.ts +141 -0
  8. package/src/analytics/web.tsx +83 -0
  9. package/src/charts/Charts.tsx +470 -0
  10. package/src/dashboard.css +157 -0
  11. package/src/index.ts +119 -0
  12. package/src/landing/Hero.tsx +115 -0
  13. package/src/landing/Landing.tsx +47 -0
  14. package/src/landing/Metrics.tsx +134 -0
  15. package/src/landing/Rail.tsx +61 -0
  16. package/src/landing/Samples.tsx +78 -0
  17. package/src/landing/logic.ts +47 -0
  18. package/src/landing/parts.tsx +116 -0
  19. package/src/landing/types.ts +98 -0
  20. package/src/motion/hooks.ts +98 -0
  21. package/src/motion/motion.ts +76 -0
  22. package/src/overview/Overview.tsx +203 -0
  23. package/src/overview/config.ts +177 -0
  24. package/src/overview/logic.ts +204 -0
  25. package/src/overview/primitives.tsx +306 -0
  26. package/src/overview/superadmin.test.ts +36 -0
  27. package/src/overview/tiles.tsx +223 -0
  28. package/src/pipeline/Pipeline.tsx +178 -0
  29. package/src/pipeline/stages.ts +156 -0
  30. package/src/types.ts +12 -0
  31. package/readme.md +0 -57
  32. package/src/lib/api.ts +0 -155
  33. package/src/lib/components/ConfirmDialog.tsx +0 -53
  34. package/src/lib/components/TenantRow.tsx +0 -39
  35. package/src/lib/components/TenantStatusBadge.tsx +0 -33
  36. package/src/lib/index.ts +0 -30
  37. package/src/lib/nav.ts +0 -12
  38. package/src/lib/screens/NewTenantScreen.tsx +0 -137
  39. package/src/lib/screens/TenantsScreen.tsx +0 -229
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 hanzo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,245 @@
1
+ # @hanzo/dashboard
2
+
3
+ The reusable **dashboard layer on [`@hanzo/gui`](https://github.com/hanzoai/gui)**.
4
+ A set of composable, honest, reduced-motion-guarded dashboard primitives extracted
5
+ from the Hanzo Cloud Console so every Hanzo app — console, admin surfaces,
6
+ hanzo.team, desktop dashboards — builds the **same** dashboard system instead of
7
+ re-implementing it.
8
+
9
+ Everything here is **pure**: data comes in via props or a loader you supply, actions
10
+ are callbacks, and `@hanzo/gui` is a peer dependency (this layer sits _on_ it, never
11
+ forks it). No app-specific API imports.
12
+
13
+ ```bash
14
+ pnpm add @hanzo/dashboard @hanzo/gui @hanzogui/lucide-icons-2
15
+ ```
16
+
17
+ ```tsx
18
+ import '@hanzo/dashboard/dashboard.css' // once — ships the motion keyframes
19
+ import { Overview, Kpi, Sparkline, Landing, Pipeline } from '@hanzo/dashboard'
20
+ ```
21
+
22
+ > `dashboard.css` carries the loading shimmer, live-feed pulse, fade-up entrance, and
23
+ > the deploy-pipeline animations — **every one reduced-motion-guarded** (→ static).
24
+ > The count-up + live sparkline motion is driven in JS and gated on
25
+ > `prefers-reduced-motion` in the hooks.
26
+
27
+ ---
28
+
29
+ ## Charts — the ONE way to draw a trend / series / share / distribution
30
+
31
+ Monochrome SVG (dependency-free) that themes to the shell. Honest by construction: a
32
+ `Sparkline` with < 2 points renders nothing; `Donut`/`Bars` with no positive value
33
+ render an em-dash. Callers pass REAL series — these never fabricate data.
34
+
35
+ | Export | What it draws |
36
+ | --- | --- |
37
+ | `Sparkline` | A fixed-size mini trend (for stat tiles). |
38
+ | `Line` | A value over time (line + hover tooltip). |
39
+ | `Columns` | A value over time (vertical bars + hover). |
40
+ | `Donut` | Categorical share (optional legend, center slot). |
41
+ | `Bars` | A ranked distribution (horizontal, token-native). |
42
+ | `useContainerWidth` | Measure a container for responsive SVG. |
43
+ | `CHART_PALETTE`, `CHART_OTHER` | The categorical hues. |
44
+ | types `ChartPoint`, `Slice` | Data shapes. |
45
+
46
+ ```tsx
47
+ <Line data={[{ label: 'Mon', value: 12 }, { label: 'Tue', value: 30 }]} formatValue={(v) => `${v}`} />
48
+ <Donut slices={[{ label: 'zen', value: 60 }, { label: 'gpt', value: 40 }]} legend />
49
+ <Sparkline values={[3, 5, 4, 9, 12]} />
50
+ ```
51
+
52
+ ## Motion — pure math + rAF/interval hooks
53
+
54
+ Unit-testable pure functions plus the thin browser hooks that drive them; every hook
55
+ self-cleans on unmount (no leaked frames/timers).
56
+
57
+ - Pure: `easeOutCubic`, `countUpValue`, `progress`, `pushSample`, `shouldTick`, `effectiveInterval`.
58
+ - Hooks: `useCountUp`, `usePoll`, `useReducedMotion`, `usePageHidden`.
59
+
60
+ ```tsx
61
+ const value = useCountUp(target, /* enabled */ true) // snaps to target under reduced motion
62
+ const { tick, bump } = usePoll(15_000) // paused when interval ≤ 0
63
+ ```
64
+
65
+ ## Overview — the videogame-like living dashboard
66
+
67
+ Declare a product's tiles + a REAL data loader; `Overview` renders + animates the
68
+ board and keeps it alive (one throttled poll loop, hidden-tab-paused, range selector,
69
+ count-up on change). A product is added by writing a config, never overview UI.
70
+
71
+ ```tsx
72
+ import { Overview, type OverviewConfig } from '@hanzo/dashboard'
73
+ import { Cpu } from '@hanzogui/lucide-icons-2'
74
+
75
+ const config: OverviewConfig = {
76
+ id: 'gpus', title: 'GPUs',
77
+ live: { pollMs: 15_000, countUp: true },
78
+ rows: [
79
+ [{ tile: 'metric', key: 'active', label: 'Active', icon: Cpu }],
80
+ [{ tile: 'timeseries', key: 'usage', title: 'Usage over time' },
81
+ { tile: 'distribution', key: 'byModel', title: 'By model' }],
82
+ [{ tile: 'activity' }, { tile: 'health' }],
83
+ ],
84
+ load: async ({ range }) => fromMyApi(await MyApi.overview(range)), // → OverviewData
85
+ }
86
+
87
+ <Overview config={config} superAdmin={superAdmin(claims)} /> // @hanzo/iam's one reading
88
+ ```
89
+
90
+ The loader returns the normalized **`OverviewData`** (`kpi` / `series` / `distribution`
91
+ / `activity` / `alerts` / `health` maps). A missing slice renders an honest empty tile
92
+ — you can over-declare tiles a slow backend hasn't filled yet. First load shows a
93
+ spinner; a failure shows a retry card; a background refetch never blanks a board that
94
+ already has real data.
95
+
96
+ ### Composable primitives (use without the driver)
97
+
98
+ The tiles are also standalone, direct-props components — the valuable, reusable core:
99
+
100
+ | Export | Props (data in, view out) |
101
+ | --- | --- |
102
+ | `Kpi` | `label`, `value` (null → "—"), `unit`, `deltaPct`, `series`, `icon`, `color`, `caption`, `animate`, `loading` |
103
+ | `Feed` | `items: OverviewEvent[]`, `title`, `empty`, `loading` (virtualizes past a viewport) |
104
+ | `Board` | `items: OverviewHealth[]`, `title`, `empty`, `loading` (health tally + dots) |
105
+ | `Tile` | Render one declared tile spec against `OverviewData`. |
106
+ | `Panel`, `SkeletonBar`, `EmptyPanel`, `PanelSpinner`, `LiveDot` | Shared chrome. |
107
+
108
+ ```tsx
109
+ <Kpi label="Requests" value={32_412} unit="count" deltaPct={12} series={[10,14,22,31]} icon={Zap} />
110
+ ```
111
+
112
+ Plus the pure tile logic (`formatMetric`, `deltaOf`, `hasTrend`, `mergeActivity`,
113
+ `windowRows`, `statusColor`/`healthColor`/`severityColor`, `selectKpi`/`selectSeries`/
114
+ `selectDistribution`, …) and the `OverviewConfig` / `OverviewData` / tile types.
115
+
116
+ ## Landing — the polished product landing kit
117
+
118
+ Compose a `LandingConfig` and render `<Landing>` to get a hero + live count-up metrics
119
+ row + an interactive code-sample block + a resources rail. Parts are exported too
120
+ (`Hero`, `Metrics`, `Samples`, `Rail`).
121
+
122
+ ```tsx
123
+ import { Landing, type LandingConfig } from '@hanzo/dashboard'
124
+
125
+ const config: LandingConfig = {
126
+ productId: 'embeddings', title: 'Embeddings',
127
+ tagline: 'Vector search over your data.',
128
+ brandName: 'Hanzo Cloud', docsUrl: 'https://docs.hanzo.ai', docsProduct: 'embeddings',
129
+ primary: { label: 'Create collection', onPress: onCreate },
130
+ loadMetrics: async () => [{ key: 'vectors', label: 'Vectors', value: 1_240, series: [...] }],
131
+ samples: [{ lang: 'curl', label: 'cURL', code: 'curl https://api.hanzo.ai/v1/embeddings ...' }],
132
+ }
133
+
134
+ <Landing config={config}>{/* the product's own real content */}</Landing>
135
+ ```
136
+
137
+ All links (docs / quickstart / examples / API / support) derive from `config.docsUrl`,
138
+ so a Lux/Zoo console links to ITS OWN surfaces. Brand + accent come from the config
139
+ (`brandName`, `accent`) — no hardcoded brand. Pure link helpers are exported
140
+ (`apexFromDocs`, `apiBaseFromDocs`, `landingDocsUrl`, `standardResources`, `supportMailto`).
141
+
142
+ ## Pipeline — the deploy-stage line
143
+
144
+ A small, presentational animated pipeline (Queued → Building → Deploying → Live, or any
145
+ custom stages). Driven by a `stages` prop; derive it from a real status string with the
146
+ pure `pipelineModel`, and poll to pass fresh stages.
147
+
148
+ ```tsx
149
+ import { Pipeline, pipelineModel } from '@hanzo/dashboard'
150
+
151
+ const model = pipelineModel(app.status, furthestReached) // furthest tracked across polls
152
+ <Pipeline stages={model.stages} label={model.label} />
153
+ ```
154
+
155
+ Pure helpers: `pipelineModel`, `pipelinePhase`, `stageIndex`, `isTerminalPhase`,
156
+ `pipelineTone`, `PIPELINE_STAGES`, and the `PipelineStage`/`PipelineModel`/`StageState`
157
+ types. Completed stages fill with a check, the current stage pulses, the active leg
158
+ flows, a failure turns the reached stage red — all reduced-motion-guarded.
159
+
160
+ ---
161
+
162
+ ## Provenance — how each export maps back to the console
163
+
164
+ Extracted (and generalized: no `~/lib/*` / `~/config` imports) from
165
+ `~/work/hanzo/console2`:
166
+
167
+ | `@hanzo/dashboard` | console2 source |
168
+ | --- | --- |
169
+ | `charts/Charts` (`Sparkline`/`Line`/`Columns`/`Donut`/`Bars`) | `src/components/ui/Charts.tsx` (`Sparkline`/`LineChart`/`BarChart`/`Donut`/`BarRows`) |
170
+ | `motion/motion` + `motion/hooks` | `src/components/products/overview/living/{motion,hooks}.ts` |
171
+ | `overview/{config,logic}` | `.../overview/living/{config,logic}.ts` |
172
+ | `overview/primitives` (`Kpi`/`Feed`/`Board`) + `overview/tiles` (`Tile`) | `.../overview/living/tiles.tsx` (the tile views) |
173
+ | `overview/Overview` | `.../overview/living/LivingOverview.tsx` |
174
+ | `landing/*` (`Landing`/`Hero`/`Metrics`/`Samples`/`Rail`) | `src/components/products/landing/*` (`ProductLanding`/`LandingHero`/`LandingMetrics`/`CodeSamples`/`ResourceRail`) |
175
+ | `pipeline/{pipeline,Pipeline}` | `src/components/products/paas/{railway.ts,RailwayDeploy.tsx}` |
176
+
177
+ ### Generalizations made during extraction
178
+
179
+ - **Icons** are consumer-supplied (`IconComponent` = a `size`/`color` icon), replacing
180
+ the console's `ProductIcon` from its registry.
181
+ - **`Overview`** drops `~/lib/api` `ApiError`, the console's SuperAdmin hook, `PageHeader`,
182
+ `FadeIn`, and `States` — `superAdmin` is now a prop (the host's verdict) and the header/fade/error
183
+ chrome is inlined (self-contained).
184
+ - **Landing** takes brand/docs/support/discord/accent from `LandingConfig` (was
185
+ `~/config`). `LandingHero` inlines the hero graphic + accent button (was
186
+ `../inference/parts`).
187
+ - **Pipeline** takes a `stages` prop and drops the console's `PaasApi.getApp`
188
+ self-poll — the consumer polls and passes fresh stages.
189
+ - Naming is de-branded and single-word: `LivingOverview` → `Overview`,
190
+ `RailwayDeploy` → `Pipeline`, `ProductLanding` → `Landing`, `LineChart`/`BarChart`/
191
+ `BarRows` → `Line`/`Columns`/`Bars`. CSS classes `hz-rail-*` → `hz-pipe-*`.
192
+
193
+ ---
194
+
195
+ ## Follow-up: swapping console2 onto this package (a later pass)
196
+
197
+ console2 is **untouched** this pass. To consume `@hanzo/dashboard` back in console2,
198
+ per component:
199
+
200
+ 1. **Charts** — delete `src/components/ui/Charts.tsx`; re-export from the package
201
+ (`export { Sparkline, Line as LineChart, Columns as BarChart, Bars as BarRows, Donut, useContainerWidth, CHART_PALETTE, CHART_OTHER } from '@hanzo/dashboard'`)
202
+ or update call sites to the new names. `~/components/products/inference/parts`,
203
+ `landing/*`, and `overview/living/tiles` import `Charts` — the alias keeps them working.
204
+ 2. **Motion** — replace `overview/living/{motion,hooks}.ts` with re-exports from
205
+ `@hanzo/dashboard`; `RailwayDeploy` + `LandingMetrics` import these hooks.
206
+ 3. **Overview** — replace `overview/living/{config,logic,tiles,LivingOverview}` with
207
+ thin wrappers over the package. The console's `LivingOverviewModule` keeps its
208
+ registry-router wrapper and passes `superAdmin={useIsSuperAdmin()}` +
209
+ maps `ApiError` into the driver's caught error. `PageHeader`/`FadeIn`/`States`
210
+ stay in console (they're used elsewhere) — the driver now inlines its own.
211
+ 4. **Landing** — replace `products/landing/*` with the package; the console builds a
212
+ `LandingConfig` with `docsUrl: config.docsUrl`, `brandName: config.brandName`,
213
+ `discordUrl: 'https://discord.gg/CJCyAsm9Vr'`. `LandingHero`'s console-specific
214
+ `HeroGraphic`/`AccentButton` (from `inference/parts`) are now inlined in the package.
215
+ 5. **Pipeline** — swap `paas/RailwayDeploy.tsx` for `<Pipeline>`; the console owns the
216
+ `PaasApi.getApp` poll (`usePoll` + `PaasApi`) and the monotonic `furthest` state,
217
+ calling `pipelineModel(status, furthest)` and passing `.stages` + `.label`.
218
+
219
+ Once swapped, the console2 tests that already cover the pure logic
220
+ (`motion.test.ts`, `logic.test.ts`, `railway.test.ts`, `landing/logic.test.ts`) move
221
+ here (or re-run against the re-exports) so coverage follows the code.
222
+
223
+ ## Honest about what didn't extract cleanly
224
+
225
+ - The console's **`config.ts` registry** (`LivingOverviewConfig.load` adapters:
226
+ `fromCloudUsage`, `fromAdminOverview`, `fromFunctions`, `healthFromApps`) is
227
+ **product/API-specific** and stays in console2 — it's the data half. This package is
228
+ the view half; the `OverviewData` contract is the seam between them.
229
+ - Console tile icons come from the console's product registry; here they're plain
230
+ `IconComponent` props (the consumer supplies them).
231
+ - The `Pipeline` self-poll was PaaS-coupled and is intentionally dropped — the small,
232
+ minor pipeline primitive is presentational only.
233
+
234
+ ## Build
235
+
236
+ ```bash
237
+ pnpm typecheck # tsc --noEmit (strict) — clean
238
+ pnpm build # tsc → dist (proof; the package ships src)
239
+ ```
240
+
241
+ The package **ships `src`** (source-only, like `@hanzo/data`); the consumer's bundler
242
+ transpiles it, and the `@hanzo/gui` shorthand style props type-check against the real
243
+ token unions via `gui.config.ts` + `gui.d.ts`.
244
+
245
+ License: MIT · Hanzo AI, Inc. — see [LICENSE.md](../../LICENSE.md). HIP-0137 (`hanzoai/hips`).
package/package.json CHANGED
@@ -1,65 +1,53 @@
1
1
  {
2
2
  "name": "@hanzo/dashboard",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
- "description": "Hanzo Base tenant-management screens \u2014 host-agnostic @hanzo/gui v7 React surface, shared by the standalone dashboard (base.hanzo.ai) and the embedded console module (console.hanzo.ai).",
5
+ "description": "Hanzo Dashboard — the reusable dashboard layer on @hanzo/gui: monochrome SVG charts, a videogame-like LivingOverview (count-up KPIs, live sparklines, streaming feed, health), the ProductLanding kit, and a Pipeline deploy animation. Pure (data via props/loaders), typed, reduced-motion-guarded. Extracted from the Hanzo Cloud Console so every Hanzo app composes ONE dashboard system.",
6
6
  "exports": {
7
7
  ".": {
8
- "types": "./src/lib/index.ts",
9
- "default": "./src/lib/index.ts"
10
- }
11
- },
12
- "main": "./src/lib/index.ts",
13
- "module": "./src/lib/index.ts",
14
- "types": "./src/lib/index.ts",
15
- "sideEffects": false,
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ },
11
+ "./dashboard.css": "./src/dashboard.css"
12
+ },
13
+ "main": "./src/index.ts",
14
+ "module": "./src/index.ts",
15
+ "types": "./src/index.ts",
16
+ "sideEffects": [
17
+ "*.css"
18
+ ],
16
19
  "files": [
17
- "src/lib"
20
+ "src"
18
21
  ],
19
- "scripts": {
20
- "dev": "vite",
21
- "build": "vite build",
22
- "preview": "vite preview",
23
- "typecheck": "tsc --noEmit"
24
- },
25
22
  "peerDependencies": {
26
- "@hanzo/gui": ">=7.2.2",
27
- "react": ">=19",
28
- "react-dom": ">=19"
23
+ "@hanzo/gui": ">=8.3.5",
24
+ "@hanzogui/lucide-icons-2": "^8.3.5",
25
+ "react": ">=19.2.8"
29
26
  },
30
27
  "devDependencies": {
31
- "@hanzo/gui": "^7.2.2",
32
- "@hanzo/iam": "^0.9.4",
33
- "@hanzogui/config": "^7.0.0",
34
- "@hanzogui/vite-plugin": "7.0.0",
35
- "@types/react": "^19.1.10",
36
- "@types/react-dom": "^19.1.7",
37
- "@vitejs/plugin-react": "^5.0.0",
38
- "react": "^19.0.0",
39
- "react-dom": "^19.0.0",
40
- "react-native-web": "^0.21.0",
41
- "react-router-dom": "^7.0.2",
42
- "typescript": "^5.7.2",
43
- "vite": "^7.0.0"
44
- },
45
- "engines": {
46
- "node": ">=20"
28
+ "@hanzo/gui": "8.3.5",
29
+ "@hanzogui/config": "8.3.5",
30
+ "@hanzogui/core": "8.3.5",
31
+ "@hanzogui/lucide-icons-2": "8.3.5",
32
+ "@hanzogui/web": "8.3.5",
33
+ "@types/react": "19.2.18",
34
+ "react": "19.2.8",
35
+ "typescript": "7.0.2"
47
36
  },
48
37
  "publishConfig": {
49
38
  "access": "public"
50
39
  },
51
- "overrides": {
52
- "@hanzogui/vite-plugin": "7.0.0",
53
- "@hanzogui/web": "102.0.0-rc.41-hanzoai.1",
54
- "@hanzogui/core": "102.0.0-rc.41-hanzoai.1",
55
- "@hanzogui/themes": "102.0.0-rc.41-hanzoai.1"
56
- },
57
- "resolutions": {
58
- "@hanzogui/vite-plugin": "7.0.0",
59
- "@hanzogui/web": "102.0.0-rc.41-hanzoai.1",
60
- "@hanzogui/core": "102.0.0-rc.41-hanzoai.1",
61
- "@hanzogui/themes": "102.0.0-rc.41-hanzoai.1"
40
+ "license": "MIT",
41
+ "author": "Hanzo AI <dev@hanzo.ai>",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/hanzoai/ui.git",
45
+ "directory": "pkg/dashboard"
62
46
  },
63
- "license": "BSD-3-Clause",
64
- "author": "Hanzo AI <dev@hanzo.ai>"
65
- }
47
+ "scripts": {
48
+ "tc": "tsc --noEmit",
49
+ "typecheck": "tsc --noEmit",
50
+ "build": "tsc --outDir dist --declaration --emitDeclarationOnly false --module ESNext --moduleResolution bundler --jsx react-jsx --esModuleInterop true --skipLibCheck true --sourceMap true",
51
+ "test": "bun test"
52
+ }
53
+ }
@@ -0,0 +1,20 @@
1
+ // Analytics views, built once and mounted by analytics.hanzo.ai and by a project's
2
+ // page in the platform: the same components over the same source.
3
+ export { WebAnalytics } from './web'
4
+ export { ProductAnalytics, JOURNEY } from './product'
5
+ export {
6
+ analyticsSource,
7
+ windowOf,
8
+ RANGES,
9
+ ZONE,
10
+ SourceError,
11
+ type Source,
12
+ type Range,
13
+ type Stats,
14
+ type Series,
15
+ type Point,
16
+ type Dimension,
17
+ type Step,
18
+ type FunnelStep,
19
+ type Website,
20
+ } from './source'
@@ -0,0 +1,91 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import { Button, Text, XStack, YStack } from '@hanzo/gui'
3
+ import { EmptyPanel, Panel, PanelSpinner } from '../overview/primitives'
4
+ import { RANGES, type Point, type Range } from './source'
5
+
6
+ /** What a load is: loading, its value, or why there is none. */
7
+ export type Load<T> = { state: 'loading' } | { state: 'ok'; value: T } | { state: 'error'; error: string }
8
+
9
+ /** Run `read` whenever `deps` change, and answer its Load. */
10
+ export function useLoad<T>(read: () => Promise<T>, deps: unknown[]): Load<T> {
11
+ const [load, setLoad] = useState<Load<T>>({ state: 'loading' })
12
+ useEffect(() => {
13
+ let live = true
14
+ setLoad({ state: 'loading' })
15
+ read()
16
+ .then((value) => live && setLoad({ state: 'ok', value }))
17
+ .catch((err: unknown) => live && setLoad({ state: 'error', error: err instanceof Error ? err.message : String(err) }))
18
+ return () => {
19
+ live = false
20
+ }
21
+ // eslint-disable-next-line react-hooks/exhaustive-deps
22
+ }, deps)
23
+ return load
24
+ }
25
+
26
+ /** The range switch every view carries. */
27
+ export function RangeSwitch({ range, onRange }: { range: Range; onRange: (r: Range) => void }) {
28
+ return (
29
+ <XStack gap="$1" role="tablist" aria-label="Range">
30
+ {RANGES.map((r) => (
31
+ <Button
32
+ key={r}
33
+ size="$2"
34
+ role="tab"
35
+ aria-selected={r === range}
36
+ theme={r === range ? 'accent' : undefined}
37
+ chromeless={r !== range}
38
+ onPress={() => onRange(r)}
39
+ >
40
+ {r}
41
+ </Button>
42
+ ))}
43
+ </XStack>
44
+ )
45
+ }
46
+
47
+ /** A panel whose body is a Load: spinner, error note, or the rendered value. */
48
+ export function Loaded<T>({ title, load, empty, children, right, minW = 300 }: {
49
+ title: string
50
+ load: Load<T>
51
+ empty?: (value: T) => boolean
52
+ children: (value: T) => ReactNode
53
+ right?: ReactNode
54
+ minW?: number
55
+ }) {
56
+ return (
57
+ <Panel title={title} right={right} minW={minW} flex={1}>
58
+ {load.state === 'loading' ? (
59
+ <PanelSpinner />
60
+ ) : load.state === 'error' ? (
61
+ <EmptyPanel note={load.error} />
62
+ ) : empty?.(load.value) ? (
63
+ <EmptyPanel note="Nothing in this range yet." />
64
+ ) : (
65
+ children(load.value)
66
+ )}
67
+ </Panel>
68
+ )
69
+ }
70
+
71
+ /** A ranked list: label, a bar proportional to the leader, and the count. */
72
+ export function TopList({ rows, format = (x) => x || '(none)' }: { rows: Point[]; format?: (x: string) => string }) {
73
+ const max = Math.max(1, ...rows.map((r) => r.y))
74
+ return (
75
+ <YStack gap="$2">
76
+ {rows.map((r) => (
77
+ <XStack key={r.x} gap="$3" items="center">
78
+ <YStack flex={1} minW={0} position="relative" height={24} justify="center">
79
+ <YStack position="absolute" l={0} t={0} b={0} width={`${Math.max(2, (r.y / max) * 100)}%`} bg="$color3" rounded="$2" />
80
+ <Text px="$2" fontSize="$2" color="$color12" numberOfLines={1}>
81
+ {format(r.x)}
82
+ </Text>
83
+ </YStack>
84
+ <Text width={56} fontSize="$2" color="$color12" fontWeight="600" text="right">
85
+ {r.y.toLocaleString()}
86
+ </Text>
87
+ </XStack>
88
+ ))}
89
+ </YStack>
90
+ )
91
+ }
@@ -0,0 +1,64 @@
1
+ import { useState } from 'react'
2
+ import { Text, XStack, YStack } from '@hanzo/gui'
3
+ import { Loaded, RangeSwitch, TopList, useLoad } from './parts'
4
+ import type { FunnelStep, Range, Source, Step } from './source'
5
+
6
+ /** The sign-up journey, in @hanzo/events names (HIP-1190 §6.1). */
7
+ export const JOURNEY: Step[] = [
8
+ { type: 'event', value: 'signup_viewed' },
9
+ { type: 'event', value: 'signup_submitted' },
10
+ { type: 'event', value: 'signup_completed' },
11
+ { type: 'event', value: 'first_action' },
12
+ { type: 'event', value: 'checkout_started' },
13
+ { type: 'event', value: 'order_completed' },
14
+ ]
15
+
16
+ function Funnel({ steps }: { steps: FunnelStep[] }) {
17
+ const first = steps[0]?.visitors || 0
18
+ return (
19
+ <YStack gap="$2">
20
+ {steps.map((s, i) => (
21
+ <XStack key={s.value} gap="$3" items="center">
22
+ <Text width={150} fontSize="$2" color="$color11" numberOfLines={1}>
23
+ {i + 1}. {s.value}
24
+ </Text>
25
+ <YStack flex={1} height={12} bg="$color3" rounded="$2" overflow="hidden">
26
+ <YStack height={12} width={`${first ? Math.max(1, (s.visitors / first) * 100) : 0}%`} bg="$color9" rounded="$2" />
27
+ </YStack>
28
+ <Text width={64} fontSize="$2" color="$color12" fontWeight="600" text="right">
29
+ {s.visitors.toLocaleString()}
30
+ </Text>
31
+ <Text width={56} fontSize="$2" color="$color10" text="right">
32
+ {i === 0 || !steps[i - 1].visitors ? '' : `${Math.round((s.visitors / steps[i - 1].visitors) * 100)}%`}
33
+ </Text>
34
+ </XStack>
35
+ ))}
36
+ </YStack>
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Product analytics for one website: what people did (the named events) and how
42
+ * far they got through the sign-up journey, one visitor followed across steps.
43
+ */
44
+ export function ProductAnalytics({ source, website, steps = JOURNEY, initial = '7d' }: { source: Source; website: string; steps?: Step[]; initial?: Range }) {
45
+ const [range, setRange] = useState<Range>(initial)
46
+ const funnel = useLoad(() => source.funnel(website, steps, range), [source, website, range, steps])
47
+ const events = useLoad(() => source.top(website, 'event', range, 20), [source, website, range])
48
+ return (
49
+ <YStack gap="$4" width="100%">
50
+ <XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
51
+ <Text fontSize="$6" fontWeight="600" color="$color12">
52
+ Product analytics
53
+ </Text>
54
+ <RangeSwitch range={range} onRange={setRange} />
55
+ </XStack>
56
+ <Loaded title="Sign-up journey" load={funnel} empty={(v) => !v.length || !v[0].visitors}>
57
+ {(v) => <Funnel steps={v} />}
58
+ </Loaded>
59
+ <Loaded title="Events" load={events} empty={(v) => !v.length}>
60
+ {(rows) => <TopList rows={rows} />}
61
+ </Loaded>
62
+ </YStack>
63
+ )
64
+ }