@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
@@ -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
+ }