@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.
- package/LICENSE.md +21 -0
- package/README.md +245 -0
- package/package.json +38 -50
- package/src/analytics/index.ts +20 -0
- package/src/analytics/parts.tsx +91 -0
- package/src/analytics/product.tsx +64 -0
- package/src/analytics/source.ts +141 -0
- package/src/analytics/web.tsx +83 -0
- package/src/charts/Charts.tsx +470 -0
- package/src/dashboard.css +157 -0
- package/src/index.ts +119 -0
- package/src/landing/Hero.tsx +115 -0
- package/src/landing/Landing.tsx +47 -0
- package/src/landing/Metrics.tsx +134 -0
- package/src/landing/Rail.tsx +61 -0
- package/src/landing/Samples.tsx +78 -0
- package/src/landing/logic.ts +47 -0
- package/src/landing/parts.tsx +116 -0
- package/src/landing/types.ts +98 -0
- package/src/motion/hooks.ts +98 -0
- package/src/motion/motion.ts +76 -0
- package/src/overview/Overview.tsx +203 -0
- package/src/overview/config.ts +177 -0
- package/src/overview/logic.ts +204 -0
- package/src/overview/primitives.tsx +306 -0
- package/src/overview/superadmin.test.ts +36 -0
- package/src/overview/tiles.tsx +223 -0
- package/src/pipeline/Pipeline.tsx +178 -0
- package/src/pipeline/stages.ts +156 -0
- package/src/types.ts +12 -0
- package/readme.md +0 -57
- package/src/lib/api.ts +0 -155
- package/src/lib/components/ConfirmDialog.tsx +0 -53
- package/src/lib/components/TenantRow.tsx +0 -39
- package/src/lib/components/TenantStatusBadge.tsx +0 -33
- package/src/lib/index.ts +0 -30
- package/src/lib/nav.ts +0 -12
- package/src/lib/screens/NewTenantScreen.tsx +0 -137
- package/src/lib/screens/TenantsScreen.tsx +0 -229
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Samples — the interactive Quick Start / code block. Tab-switch between the REAL
|
|
5
|
+
* API call in curl / Python / TS / JS, copy the current sample, and (optionally)
|
|
6
|
+
* Run / Try in Playground. Not a screenshot — real, copyable, tab-switchable code.
|
|
7
|
+
* The block scrolls horizontally inside its own container so a wide line never
|
|
8
|
+
* scrolls the page.
|
|
9
|
+
*/
|
|
10
|
+
import { useState } from 'react'
|
|
11
|
+
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
|
12
|
+
import { Check, Copy, Play } from '@hanzogui/lucide-icons-2'
|
|
13
|
+
|
|
14
|
+
import { LandingCard, openExternal } from './parts'
|
|
15
|
+
import type { CodeSample, LandingAction } from './types'
|
|
16
|
+
|
|
17
|
+
export function Samples({ samples, run, title = 'Quick start' }: { samples: CodeSample[]; run?: LandingAction; title?: string }) {
|
|
18
|
+
const [active, setActive] = useState(0)
|
|
19
|
+
const [copied, setCopied] = useState(false)
|
|
20
|
+
const idx = Math.min(active, samples.length - 1)
|
|
21
|
+
const sample = samples[idx]
|
|
22
|
+
|
|
23
|
+
const copy = async () => {
|
|
24
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard) return
|
|
25
|
+
try {
|
|
26
|
+
await navigator.clipboard.writeText(sample.code)
|
|
27
|
+
setCopied(true)
|
|
28
|
+
setTimeout(() => setCopied(false), 1400)
|
|
29
|
+
} catch {
|
|
30
|
+
/* clipboard denied — no-op (the code is still visible + selectable) */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<LandingCard
|
|
36
|
+
title={title}
|
|
37
|
+
actions={
|
|
38
|
+
run ? (
|
|
39
|
+
<Button size="$2" icon={<Play size={14} />} onPress={() => (run.href ? openExternal(run.href) : run.onPress?.())}>
|
|
40
|
+
{run.label}
|
|
41
|
+
</Button>
|
|
42
|
+
) : undefined
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
<XStack items="center" justify="space-between" gap="$2" flexWrap="wrap">
|
|
46
|
+
<XStack gap="$1" flexWrap="wrap" items="center">
|
|
47
|
+
{samples.map((s, i) => {
|
|
48
|
+
const on = i === idx
|
|
49
|
+
return (
|
|
50
|
+
<Button key={s.lang} size="$2" bg={on ? '$color5' : 'transparent'} borderWidth={1} borderColor={on ? '$color7' : '$borderColor'} onPress={() => setActive(i)}>
|
|
51
|
+
<Text fontSize="$2" fontWeight={on ? '700' : '500'} color={on ? '$color12' : '$color11'}>
|
|
52
|
+
{s.label}
|
|
53
|
+
</Text>
|
|
54
|
+
</Button>
|
|
55
|
+
)
|
|
56
|
+
})}
|
|
57
|
+
</XStack>
|
|
58
|
+
<Button size="$2" chromeless icon={copied ? <Check size={14} color="#23c562" /> : <Copy size={14} />} onPress={copy}>
|
|
59
|
+
{copied ? 'Copied' : 'Copy'}
|
|
60
|
+
</Button>
|
|
61
|
+
</XStack>
|
|
62
|
+
|
|
63
|
+
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$4" p="$3" style={{ overflowX: 'auto' }}>
|
|
64
|
+
<Text
|
|
65
|
+
color="$color11"
|
|
66
|
+
style={{
|
|
67
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
68
|
+
whiteSpace: 'pre',
|
|
69
|
+
fontSize: 12,
|
|
70
|
+
lineHeight: 1.6,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{sample.code}
|
|
74
|
+
</Text>
|
|
75
|
+
</YStack>
|
|
76
|
+
</LandingCard>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Landing — PURE link helpers (no UI, no I/O), unit-testable. Builds the real docs
|
|
3
|
+
* / API / support links a landing rail and code samples point at, derived from the
|
|
4
|
+
* brand docs host so a Lux/Zoo console links to ITS OWN surfaces (never hardcoded).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Apex domain from a docs URL (https://docs.hanzo.ai → hanzo.ai). */
|
|
8
|
+
export function apexFromDocs(docsUrl: string): string {
|
|
9
|
+
return docsUrl
|
|
10
|
+
.replace(/^https?:\/\//, '')
|
|
11
|
+
.replace(/\/.*$/, '')
|
|
12
|
+
.replace(/^docs\./, '')
|
|
13
|
+
.trim()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The brand API base for code samples, e.g. https://api.hanzo.ai/v1. */
|
|
17
|
+
export function apiBaseFromDocs(docsUrl: string): string {
|
|
18
|
+
return `https://api.${apexFromDocs(docsUrl)}/v1`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A docs URL for a product, with an optional sub-path. */
|
|
22
|
+
export function landingDocsUrl(docsUrl: string, product: string, sub?: string): string {
|
|
23
|
+
const base = `${docsUrl.replace(/\/+$/, '')}/${product}`
|
|
24
|
+
return sub ? `${base}/${sub}` : base
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The brand support mailbox derived from the docs host (docs.hanzo.ai → support@hanzo.ai). */
|
|
28
|
+
export function supportMailto(docsUrl: string): string {
|
|
29
|
+
return `mailto:support@${apexFromDocs(docsUrl)}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The standard doc links every product's resource rail shows. */
|
|
33
|
+
export interface StandardResources {
|
|
34
|
+
docs: string
|
|
35
|
+
quickstart: string
|
|
36
|
+
examples: string
|
|
37
|
+
api: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function standardResources(docsUrl: string, product: string): StandardResources {
|
|
41
|
+
return {
|
|
42
|
+
docs: landingDocsUrl(docsUrl, product),
|
|
43
|
+
quickstart: landingDocsUrl(docsUrl, product, 'quickstart'),
|
|
44
|
+
examples: landingDocsUrl(docsUrl, product, 'examples'),
|
|
45
|
+
api: landingDocsUrl(docsUrl, product, 'api-reference'),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Landing UI atoms — the shared card / action-row / delta-chip language, in a
|
|
5
|
+
* dark-card idiom. Strictly @hanzo/gui v5 shorthands.
|
|
6
|
+
*/
|
|
7
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
8
|
+
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
|
9
|
+
import { TrendingDown, TrendingUp } from '@hanzogui/lucide-icons-2'
|
|
10
|
+
|
|
11
|
+
/** Open an external URL in a new tab (SSR-guarded). */
|
|
12
|
+
export function openExternal(href: string): void {
|
|
13
|
+
if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A titled dark card — the ONE section container across the landing. */
|
|
17
|
+
export function LandingCard({
|
|
18
|
+
title,
|
|
19
|
+
subtitle,
|
|
20
|
+
actions,
|
|
21
|
+
children,
|
|
22
|
+
p = '$4',
|
|
23
|
+
...rest
|
|
24
|
+
}: {
|
|
25
|
+
title?: ReactNode
|
|
26
|
+
subtitle?: ReactNode
|
|
27
|
+
actions?: ReactNode
|
|
28
|
+
children: ReactNode
|
|
29
|
+
p?: ComponentProps<typeof Card>['p']
|
|
30
|
+
} & Omit<ComponentProps<typeof Card>, 'children' | 'title'>) {
|
|
31
|
+
return (
|
|
32
|
+
<Card borderWidth={1} borderColor="$borderColor" bg="$color2" rounded="$5" p={p} gap="$3.5" {...rest}>
|
|
33
|
+
{title || actions ? (
|
|
34
|
+
<XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
|
|
35
|
+
<YStack gap="$0.5" flex={1} minW={120}>
|
|
36
|
+
{title ? (
|
|
37
|
+
<Text fontSize="$5" fontWeight="800" color="$color12">
|
|
38
|
+
{title}
|
|
39
|
+
</Text>
|
|
40
|
+
) : null}
|
|
41
|
+
{subtitle ? (
|
|
42
|
+
<Text fontSize="$2" color="$color10">
|
|
43
|
+
{subtitle}
|
|
44
|
+
</Text>
|
|
45
|
+
) : null}
|
|
46
|
+
</YStack>
|
|
47
|
+
{actions ? (
|
|
48
|
+
<XStack gap="$2" items="center">
|
|
49
|
+
{actions}
|
|
50
|
+
</XStack>
|
|
51
|
+
) : null}
|
|
52
|
+
</XStack>
|
|
53
|
+
) : null}
|
|
54
|
+
{children}
|
|
55
|
+
</Card>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One tappable action row (icon + label + optional sub-line). */
|
|
60
|
+
export function ActionRow({
|
|
61
|
+
icon,
|
|
62
|
+
label,
|
|
63
|
+
sub,
|
|
64
|
+
onPress,
|
|
65
|
+
}: {
|
|
66
|
+
icon?: ReactNode
|
|
67
|
+
label: string
|
|
68
|
+
sub?: string
|
|
69
|
+
onPress: () => void
|
|
70
|
+
}) {
|
|
71
|
+
return (
|
|
72
|
+
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" cursor="pointer" hoverStyle={{ bg: '$color3' }} onPress={onPress}>
|
|
73
|
+
<YStack width={30} height={30} items="center" justify="center" rounded="$3" bg="$color3">
|
|
74
|
+
{icon}
|
|
75
|
+
</YStack>
|
|
76
|
+
<YStack flex={1} minW={0} gap="$0.5">
|
|
77
|
+
<Text fontSize="$3" color="$color12" numberOfLines={1}>
|
|
78
|
+
{label}
|
|
79
|
+
</Text>
|
|
80
|
+
{sub ? (
|
|
81
|
+
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
|
82
|
+
{sub}
|
|
83
|
+
</Text>
|
|
84
|
+
) : null}
|
|
85
|
+
</YStack>
|
|
86
|
+
<Text fontSize="$4" color="$color9">
|
|
87
|
+
›
|
|
88
|
+
</Text>
|
|
89
|
+
</XStack>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** An ↑/↓ delta chip. Up→green, down→red, null→muted em-dash. Direction only (honest). */
|
|
94
|
+
export function DeltaChip({ pct }: { pct: number | null }) {
|
|
95
|
+
if (pct == null || !Number.isFinite(pct)) {
|
|
96
|
+
return (
|
|
97
|
+
<Text fontSize="$1" color="$color10">
|
|
98
|
+
—
|
|
99
|
+
</Text>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
const rounded = Math.round(pct)
|
|
103
|
+
const up = rounded >= 0
|
|
104
|
+
const color = up ? '#23c562' : '#ff5d8f'
|
|
105
|
+
const Icon = up ? TrendingUp : TrendingDown
|
|
106
|
+
const sign = rounded > 0 ? '+' : rounded < 0 ? '−' : ''
|
|
107
|
+
return (
|
|
108
|
+
<XStack items="center" gap="$1">
|
|
109
|
+
<Icon size={13} color={color} />
|
|
110
|
+
<Text fontSize="$1" fontWeight="700" style={{ color }}>
|
|
111
|
+
{sign}
|
|
112
|
+
{Math.abs(rounded)}%
|
|
113
|
+
</Text>
|
|
114
|
+
</XStack>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Landing — the shared config contract every product supplies to get a polished
|
|
3
|
+
* landing/overview: a hero, live count-up metrics, an interactive code-sample
|
|
4
|
+
* block, and a resources rail. One landing system, DRY across products.
|
|
5
|
+
*
|
|
6
|
+
* Types only — erased at build; the pure link logic lives in `logic.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
9
|
+
|
|
10
|
+
import type { IconComponent } from '../types'
|
|
11
|
+
|
|
12
|
+
/** Languages the interactive code block offers. */
|
|
13
|
+
export type CodeLang = 'curl' | 'python' | 'ts' | 'js'
|
|
14
|
+
|
|
15
|
+
/** One copyable code sample — the REAL API call for the product. */
|
|
16
|
+
export interface CodeSample {
|
|
17
|
+
lang: CodeLang
|
|
18
|
+
/** Tab label, e.g. "cURL", "Python", "TypeScript", "JavaScript". */
|
|
19
|
+
label: string
|
|
20
|
+
code: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A call-to-action — an in-app handler OR an external link. `icon` is a rendered
|
|
24
|
+
* element so it satisfies the Button `icon` prop. */
|
|
25
|
+
export interface LandingAction {
|
|
26
|
+
label: string
|
|
27
|
+
onPress?: () => void
|
|
28
|
+
href?: string
|
|
29
|
+
icon?: ReactElement
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One headline metric — a real value (null → honest "—"), optional trend + delta. */
|
|
33
|
+
export interface LandingMetric {
|
|
34
|
+
key: string
|
|
35
|
+
label: string
|
|
36
|
+
/** Current value, or null when the backend doesn't report it (renders "—"). */
|
|
37
|
+
value: number | null
|
|
38
|
+
/** Formats the (count-up) value; defaults to a localized integer. */
|
|
39
|
+
format?: (n: number) => string
|
|
40
|
+
/** Real time series for the sparkline (≥2 points, else omitted — never faked). */
|
|
41
|
+
series?: number[]
|
|
42
|
+
/** Fractional/percent change vs the prior window; null → honest em-dash. */
|
|
43
|
+
deltaPct?: number | null
|
|
44
|
+
icon?: ReactNode
|
|
45
|
+
/** A small honest caption shown when there is no series (e.g. "Awaiting metering"). */
|
|
46
|
+
hint?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A resource rail row (Documentation / Quick Start / Examples / Support …). */
|
|
50
|
+
export interface ResourceLink {
|
|
51
|
+
id: string
|
|
52
|
+
label: string
|
|
53
|
+
sub?: string
|
|
54
|
+
href?: string
|
|
55
|
+
onPress?: () => void
|
|
56
|
+
icon?: ReactNode
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The full landing config a product composes. `metrics` (controlled by the parent)
|
|
61
|
+
* OR `loadMetrics` (self-loading + polling) drives the live KPI row; supply one.
|
|
62
|
+
*/
|
|
63
|
+
export interface LandingConfig {
|
|
64
|
+
productId: string
|
|
65
|
+
title: string
|
|
66
|
+
/** One-line "what it is". */
|
|
67
|
+
tagline: string
|
|
68
|
+
icon?: IconComponent
|
|
69
|
+
/** Docs host base the rail/samples derive links from (e.g. 'https://docs.hanzo.ai'). */
|
|
70
|
+
docsUrl: string
|
|
71
|
+
/** Docs path segment under the docs host (e.g. 'embeddings'). */
|
|
72
|
+
docsProduct: string
|
|
73
|
+
/** Brand name shown in the hero kicker (omitted when unset). */
|
|
74
|
+
brandName?: string
|
|
75
|
+
/** Accent color for the hero + sparklines (defaults to the palette head). */
|
|
76
|
+
accent?: string
|
|
77
|
+
/** Community link for the resource rail (row omitted when unset). */
|
|
78
|
+
discordUrl?: string
|
|
79
|
+
/** Support email override (defaults to `support@<docs apex>`). */
|
|
80
|
+
supportEmail?: string
|
|
81
|
+
/** The hero's primary + optional secondary CTA. */
|
|
82
|
+
primary?: LandingAction
|
|
83
|
+
secondary?: LandingAction
|
|
84
|
+
/** Controlled metrics (parent owns the data). */
|
|
85
|
+
metrics?: LandingMetric[]
|
|
86
|
+
/** Self-loading metrics (real data / honest gaps) — used with `pollMs`. */
|
|
87
|
+
loadMetrics?: () => Promise<LandingMetric[]>
|
|
88
|
+
/** Poll interval for `loadMetrics` in ms (0/undefined → load once, no poll). */
|
|
89
|
+
pollMs?: number
|
|
90
|
+
/** Interactive, copyable code samples (curl/python/ts/js). */
|
|
91
|
+
samples?: CodeSample[]
|
|
92
|
+
/** A "Run"/"Try in Playground" affordance for the code block. */
|
|
93
|
+
run?: LandingAction
|
|
94
|
+
/** Quick-action rows appended to the resource rail. */
|
|
95
|
+
actions?: LandingAction[]
|
|
96
|
+
/** Extra resource rows appended after the standard ones. */
|
|
97
|
+
resources?: ResourceLink[]
|
|
98
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Overview React hooks — the thin rAF/interval drivers on top of the pure
|
|
5
|
+
* `motion.ts` math. Each hook owns exactly one browser concern (animation frames,
|
|
6
|
+
* the poll clock, reduced-motion, tab visibility) and cleans itself up on unmount
|
|
7
|
+
* (no leaked intervals/frames).
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState } from 'react'
|
|
10
|
+
|
|
11
|
+
import { countUpValue, progress } from './motion'
|
|
12
|
+
|
|
13
|
+
/** `true` when the user asked for reduced motion — every animation short-circuits to its end state. */
|
|
14
|
+
export function useReducedMotion(): boolean {
|
|
15
|
+
const [reduced, setReduced] = useState(false)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (typeof window === 'undefined' || !window.matchMedia) return
|
|
18
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
19
|
+
const on = () => setReduced(mq.matches)
|
|
20
|
+
on()
|
|
21
|
+
// addEventListener is the modern API; older Safari uses addListener.
|
|
22
|
+
if (mq.addEventListener) mq.addEventListener('change', on)
|
|
23
|
+
else mq.addListener(on)
|
|
24
|
+
return () => {
|
|
25
|
+
if (mq.removeEventListener) mq.removeEventListener('change', on)
|
|
26
|
+
else mq.removeListener(on)
|
|
27
|
+
}
|
|
28
|
+
}, [])
|
|
29
|
+
return reduced
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `true` when the document is hidden (background tab) — the poll clock pauses. */
|
|
33
|
+
export function usePageHidden(): boolean {
|
|
34
|
+
const [hidden, setHidden] = useState(false)
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (typeof document === 'undefined') return
|
|
37
|
+
const on = () => setHidden(document.visibilityState === 'hidden')
|
|
38
|
+
on()
|
|
39
|
+
document.addEventListener('visibilitychange', on)
|
|
40
|
+
return () => document.removeEventListener('visibilitychange', on)
|
|
41
|
+
}, [])
|
|
42
|
+
return hidden
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Animate a number from its previous value to `target` on the eased count-up curve.
|
|
47
|
+
* Returns the value to display right now. When `enabled` is false (reduced motion,
|
|
48
|
+
* or count-up turned off in config), it snaps to `target` — the tile still shows the
|
|
49
|
+
* real number, just without the tween. Cleans up its rAF on unmount/retarget.
|
|
50
|
+
*/
|
|
51
|
+
export function useCountUp(target: number, enabled: boolean, durationMs = 700): number {
|
|
52
|
+
const [value, setValue] = useState(target)
|
|
53
|
+
// The value currently ON SCREEN, tracked every frame. A retarget mid-flight
|
|
54
|
+
// animates from HERE (not the previous target), so a new poll never snaps the
|
|
55
|
+
// digits back before easing — smooth all the way.
|
|
56
|
+
const shownRef = useRef(target)
|
|
57
|
+
const frameRef = useRef<number | null>(null)
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const from = shownRef.current
|
|
61
|
+
if (!enabled || from === target) {
|
|
62
|
+
shownRef.current = target
|
|
63
|
+
setValue(target)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
|
67
|
+
const tick = () => {
|
|
68
|
+
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
|
69
|
+
const t = progress(startedAt, now, durationMs)
|
|
70
|
+
const v = countUpValue(from, target, t)
|
|
71
|
+
shownRef.current = v
|
|
72
|
+
setValue(v)
|
|
73
|
+
if (t < 1) frameRef.current = requestAnimationFrame(tick)
|
|
74
|
+
}
|
|
75
|
+
frameRef.current = requestAnimationFrame(tick)
|
|
76
|
+
return () => {
|
|
77
|
+
if (frameRef.current != null) cancelAnimationFrame(frameRef.current)
|
|
78
|
+
}
|
|
79
|
+
}, [target, enabled, durationMs])
|
|
80
|
+
|
|
81
|
+
return value
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A monotonic tick that fires every `everyMs` while the tab is visible, paused when
|
|
86
|
+
* `everyMs <= 0`. The consumer refetches on each tick. Self-clearing on unmount and
|
|
87
|
+
* on interval change — no stacked timers. Returns the tick count (a dependency the
|
|
88
|
+
* caller can watch) and a `bump()` to force an immediate refetch (e.g. range change).
|
|
89
|
+
*/
|
|
90
|
+
export function usePoll(everyMs: number): { tick: number; bump: () => void } {
|
|
91
|
+
const [tick, setTick] = useState(0)
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (everyMs <= 0) return
|
|
94
|
+
const id = setInterval(() => setTick((n) => n + 1), everyMs)
|
|
95
|
+
return () => clearInterval(id)
|
|
96
|
+
}, [everyMs])
|
|
97
|
+
return { tick, bump: () => setTick((n) => n + 1) }
|
|
98
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overview motion — the PURE math behind the "videogame-like" feel, kept
|
|
3
|
+
* out of the React components so it is unit-testable in the node env (no jsdom).
|
|
4
|
+
* The `.tsx` tiles are thin wrappers that drive `requestAnimationFrame` with these
|
|
5
|
+
* functions.
|
|
6
|
+
*
|
|
7
|
+
* Three primitives, one law each:
|
|
8
|
+
* - `easeOutCubic` / `countUpValue` — a number animates from a→b on an eased
|
|
9
|
+
* curve; at `t>=1` it lands EXACTLY on `b` (no float drift, no fabricated
|
|
10
|
+
* overshoot). Reduced-motion callers pass `t=1` and get `b` immediately.
|
|
11
|
+
* - `pushSample` — a live sparkline is a bounded ring of the most-recent real
|
|
12
|
+
* samples; a new reading appends and evicts the oldest past `cap`. It never
|
|
13
|
+
* invents points — an empty history stays empty (the tile shows no trend).
|
|
14
|
+
* - `shouldTick` — a self-correcting poll clock: given the last tick and now, it
|
|
15
|
+
* answers whether it is time to refetch, so a throttled live loop can't drift
|
|
16
|
+
* or stack overlapping fetches.
|
|
17
|
+
*
|
|
18
|
+
* Honesty: none of these create data. Count-up interpolates BETWEEN two real
|
|
19
|
+
* values the caller already has; the sparkline ring holds only real samples.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Ease-out cubic — fast then settling, the house count-up curve. Clamps to [0,1]. */
|
|
23
|
+
export const easeOutCubic = (t: number): number => {
|
|
24
|
+
const x = t <= 0 ? 0 : t >= 1 ? 1 : t
|
|
25
|
+
return 1 - Math.pow(1 - x, 3)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The displayed value at normalized progress `t` (0..1) animating `from`→`to`.
|
|
30
|
+
* Lands exactly on `to` at t>=1 (and returns `to` for a non-finite `from`, so a
|
|
31
|
+
* first-ever value counts up from 0 only when the caller seeds `from=0`).
|
|
32
|
+
*/
|
|
33
|
+
export function countUpValue(from: number, to: number, t: number): number {
|
|
34
|
+
if (!Number.isFinite(to)) return Number.isFinite(from) ? from : 0
|
|
35
|
+
if (t >= 1) return to
|
|
36
|
+
const base = Number.isFinite(from) ? from : to
|
|
37
|
+
return base + (to - base) * easeOutCubic(t)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalized progress for an animation started at `startedAt`, now `now`, over `durationMs`. */
|
|
41
|
+
export function progress(startedAt: number, now: number, durationMs: number): number {
|
|
42
|
+
if (durationMs <= 0) return 1
|
|
43
|
+
return Math.min(1, Math.max(0, (now - startedAt) / durationMs))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Append `sample` to a live-sparkline ring, evicting oldest beyond `cap`. Returns
|
|
48
|
+
* a NEW array (never mutates) so React state updates cleanly. A non-finite sample
|
|
49
|
+
* is dropped — the trend only ever holds real readings.
|
|
50
|
+
*/
|
|
51
|
+
export function pushSample(history: readonly number[], sample: number, cap: number): number[] {
|
|
52
|
+
if (!Number.isFinite(sample)) return history.slice()
|
|
53
|
+
const next = [...history, sample]
|
|
54
|
+
return next.length > cap ? next.slice(next.length - cap) : next
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Poll clock. `true` when `now - lastTickAt >= everyMs` (time to refetch). A
|
|
59
|
+
* non-positive interval disables polling (always `false`) so a config can turn
|
|
60
|
+
* live updates off without a special case.
|
|
61
|
+
*/
|
|
62
|
+
export function shouldTick(lastTickAt: number, now: number, everyMs: number): boolean {
|
|
63
|
+
if (everyMs <= 0) return false
|
|
64
|
+
return now - lastTickAt >= everyMs
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Effective poll interval given a requested `everyMs`, a floor, and whether the
|
|
69
|
+
* document is hidden. Returns 0 (paused) when hidden — a background tab must not
|
|
70
|
+
* burn the backend — and never goes below `floorMs` (throttle guard) when active.
|
|
71
|
+
*/
|
|
72
|
+
export function effectiveInterval(everyMs: number, floorMs: number, hidden: boolean): number {
|
|
73
|
+
if (everyMs <= 0) return 0
|
|
74
|
+
if (hidden) return 0
|
|
75
|
+
return Math.max(floorMs, everyMs)
|
|
76
|
+
}
|