hazo_ui 3.3.0 → 3.5.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/CHANGE_LOG.md CHANGED
@@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v3.5.0 (2026-06-12)
9
+
10
+ ### New
11
+ - `FunnelChart` — horizontal centered-bar conversion funnel with optional segment breakdown, hover tooltip (% of first / % of previous / drop-off), and edge-case handling (empty steps, zero values, single step). Closes FR-002.
12
+
13
+ ## v3.4.1 — 2026-06-12
14
+
15
+ **Fix:** Webpack compatibility in test-harness format.ts + source sync.
16
+
17
+ - Changed `require("node:fs")` → `require("fs")` in `src/test-harness/scenarios/format.ts`. The `"node:"` prefix triggers webpack's `UnhandledSchemeError` at build time before the try/catch can degrade in the browser. The bare specifier allows webpack to stub it as an empty module on the client (resolve.fallback), and Next.js gracefully falls through the catch on the server. Both forms transpile to identical `__require("fs")` in dist.
18
+ - Added `"use client"` directive to `src/index.ts` (already applied by tsup post-processing in dist, now the source matches).
19
+ - Test-app: added `/device-hooks` nav entry for the new interactive demo.
20
+
21
+ ## v3.4.0 — 2026-06-12
22
+
23
+ **New:** `use_wake_lock(active)` and `use_fullscreen()` — snake_case declarative hook variants (FR-001).
24
+
25
+ Adds two thin wrapper hooks for snake_case consumers (the gotimer family) that can't adopt
26
+ `useWakeLock` / `useFullscreen` verbatim because the existing hooks have different contracts.
27
+ Both hooks delegate to their camelCase counterparts for all DOM-API logic (visibility-reacquire,
28
+ unmount-release, SSR safety).
29
+
30
+ - **`use_wake_lock(active: boolean): void`** — declarative; the screen wake lock is held while
31
+ `active` is `true` and released when it becomes `false`. No imperative `request()`/`release()`
32
+ calls required.
33
+ - **`use_fullscreen<T>(): { is_fullscreen, toggle, ref }`** — self-binding; creates the ref
34
+ internally and returns it alongside `is_fullscreen: boolean` and `toggle: () => Promise<void>`.
35
+ The consumer attaches `ref` to the element to fullscreen.
36
+
37
+ ```ts
38
+ import { use_wake_lock, use_fullscreen } from "hazo_ui";
39
+
40
+ // Declarative wake lock
41
+ use_wake_lock(timer.is_running);
42
+
43
+ // Self-binding fullscreen
44
+ const { is_fullscreen, toggle, ref } = use_fullscreen();
45
+ return <div ref={ref}><button onClick={toggle}>Fullscreen</button></div>;
46
+ ```
47
+
48
+ **Note:** The base `useFullscreen` hook syncs on the native `fullscreenchange` event. gotimer's
49
+ local hook also had an Escape-key fallback for a browser-specific optimistic CSS state — this
50
+ edge case is not included in `use_fullscreen` to avoid changing base-hook behaviour. Escape still
51
+ exits real fullscreen via the `fullscreenchange` listener.
52
+
53
+ **Exports added:** `use_wake_lock`, `use_fullscreen`, `UseFullscreenRefResult`.
54
+
8
55
  ## v3.3.0 — 2026-06-11
9
56
 
10
57
  **New:** Required `doc` field on `Case` + per-case documentation accordion in `AutoTestRunner`.
package/README.md CHANGED
@@ -3285,6 +3285,47 @@ try { await sync(); } catch (e) {
3285
3285
 
3286
3286
  Also exports `rawToast` (re-export of sonner's `toast`) for advanced use cases.
3287
3287
 
3288
+ ### useWakeLock / use_wake_lock
3289
+
3290
+ Screen Wake Lock API wrapper. Prevents screen sleep while the lock is held. Automatically
3291
+ reacquires when the tab becomes visible again. Returns `supported: false` in environments
3292
+ that don't implement the API (SSR, older browsers).
3293
+
3294
+ **Imperative variant** (`useWakeLock`) — call `request()` / `release()` directly:
3295
+
3296
+ ```ts
3297
+ import { useWakeLock } from "hazo_ui";
3298
+ const { supported, acquired, request, release } = useWakeLock();
3299
+ ```
3300
+
3301
+ **Declarative variant** (`use_wake_lock`) — hold the lock while a condition is true:
3302
+
3303
+ ```ts
3304
+ import { use_wake_lock } from "hazo_ui";
3305
+ use_wake_lock(timer.is_running); // acquires when true, releases when false
3306
+ ```
3307
+
3308
+ ### useFullscreen / use_fullscreen
3309
+
3310
+ Fullscreen API wrapper. Syncs `isFullscreen`/`is_fullscreen` with the native
3311
+ `fullscreenchange` event; SSR-safe.
3312
+
3313
+ **Explicit-ref variant** (`useFullscreen`) — pass your own element ref:
3314
+
3315
+ ```ts
3316
+ import { useFullscreen } from "hazo_ui";
3317
+ const container_ref = useRef<HTMLDivElement>(null);
3318
+ const { isFullscreen, toggle } = useFullscreen(container_ref);
3319
+ ```
3320
+
3321
+ **Self-binding variant** (`use_fullscreen`) — ref created and returned internally:
3322
+
3323
+ ```ts
3324
+ import { use_fullscreen } from "hazo_ui";
3325
+ const { is_fullscreen, toggle, ref } = use_fullscreen();
3326
+ return <div ref={ref}><button onClick={toggle}>Fullscreen</button></div>;
3327
+ ```
3328
+
3288
3329
  ### useLoadingState
3289
3330
 
3290
3331
  Hook that returns a controlled loading flag plus an async wrapper.
@@ -3321,6 +3362,66 @@ return error ? <ErrorBanner message={error} onDismiss={clearError} /> : null;
3321
3362
 
3322
3363
  Returns `{ error: string | null; setError: (v: unknown) => void; clearError: () => void }`. Pass `null` (or any nullish value) to `setError` to clear.
3323
3364
 
3365
+ ### HazoUiEtaProgress (Requires optional `hazo_state` peer dependency)
3366
+
3367
+ Smart ETA progress bar that learns from historical operation durations. Fetches a rolling window of past durations from `hazo_state`, estimates completion time based on remaining work units and concurrency, and animates smoothly toward the estimate. Requires `hazo_state` to be installed.
3368
+
3369
+ ```tsx
3370
+ import { HazoUiEtaProgress, type EtaProgressHandle } from "hazo_ui";
3371
+ import { useRef } from "react";
3372
+
3373
+ function FileProcessor() {
3374
+ const handleRef = useRef<EtaProgressHandle>(null);
3375
+
3376
+ async function processFiles(files: File[]) {
3377
+ handleRef.current?.start();
3378
+
3379
+ for (let i = 0; i < files.length; i++) {
3380
+ await processFile(files[i]);
3381
+ handleRef.current?.unitDone();
3382
+ }
3383
+
3384
+ handleRef.current?.finish();
3385
+ }
3386
+
3387
+ return (
3388
+ <>
3389
+ <HazoUiEtaProgress
3390
+ estimateKey="file_processing"
3391
+ unitCount={totalFiles}
3392
+ concurrency={3}
3393
+ windowSize={10}
3394
+ fallbackUnitMs={2000}
3395
+ label="Processing files…"
3396
+ handleRef={handleRef}
3397
+ />
3398
+ <button onClick={() => processFiles(files)}>Start</button>
3399
+ </>
3400
+ );
3401
+ }
3402
+ ```
3403
+
3404
+ **Installation requirement:** This component requires `hazo_state` as a peer dependency. Install it before using `HazoUiEtaProgress`:
3405
+
3406
+ ```bash
3407
+ npm install hazo_state
3408
+ ```
3409
+
3410
+ | Prop | Type | Default | Description |
3411
+ |---|---|---|---|
3412
+ | `estimateKey` | `string` | **required** | Unique key for storing duration history in `hazo_state` |
3413
+ | `unitCount` | `number` | **required** | Total number of work units |
3414
+ | `concurrency` | `number` | `1` | How many units run in parallel |
3415
+ | `windowSize` | `number` | `5` | Number of past durations to average over |
3416
+ | `fallbackUnitMs` | `number` | `5000` | Per-unit estimate (ms) on cold start when no history exists |
3417
+ | `level` | `"global" \| "session" \| "local"` | `"global"` | hazo_state visibility level |
3418
+ | `label` | `string` | — | Label shown above the progress bar |
3419
+ | `endpoint` | `string` | `"/api/hazo-state"` | hazo_state API endpoint (for fetching history) |
3420
+ | `handleRef` | `RefObject<EtaProgressHandle>` | — | Ref exposing `start()`, `unitDone()`, `finish()` methods |
3421
+ | `className` | `string` | — | Additional classes |
3422
+
3423
+ **Headless hook alternative:** For more control, use `useEtaProgress` directly without the component.
3424
+
3324
3425
  ---
3325
3426
 
3326
3427
  ## HazoUiKanban — Drag-Drop Kanban (v2.13.0+)
@@ -3723,6 +3824,38 @@ Props:
3723
3824
  | `showYAxis` | `boolean` | `true` | Render Y-axis ticks (max / mid / 0). |
3724
3825
  | `className` | `string` | — | Container className passthrough. |
3725
3826
 
3827
+ ### FunnelChart
3828
+
3829
+ Conversion-funnel visualization. Horizontal centered bars, one row per step.
3830
+
3831
+ ```tsx
3832
+ import { FunnelChart } from 'hazo_ui';
3833
+
3834
+ <FunnelChart
3835
+ steps={[
3836
+ { label: 'Landing', value: 1000 },
3837
+ { label: 'Timer start', value: 420 },
3838
+ { label: 'Timer complete', value: 180 },
3839
+ ]}
3840
+ showDropoff
3841
+ />
3842
+ ```
3843
+
3844
+ Optional `segments` prop splits each bar into colored sub-bands (mobile/desktop split, traffic-source mix, etc.).
3845
+
3846
+ Props:
3847
+
3848
+ | Prop | Type | Default | Description |
3849
+ |----------------|-------------------------------|----------------|----------------------------------------------------------|
3850
+ | `steps` | `FunnelStep[]` | — | Ordered funnel steps. Each has `label`, optional `value`, optional `segments`. |
3851
+ | `width` | `number` | `360` | viewBox width. |
3852
+ | `height` | `number` | auto | viewBox height. Defaults to `PAD_TOP + steps.length * 44 + PAD_BOTTOM`. |
3853
+ | `color` | `string` | `"#3b82f6"` | Bar fill for steps without segments. |
3854
+ | `valueFormat` | `(n: number) => string` | `format_num` | Override value formatting. |
3855
+ | `showDropoff` | `boolean` | `true` | Show per-step drop-off count below the value label. |
3856
+ | `showTooltip` | `boolean` | `true` | Enable hover tooltip with full step metrics. |
3857
+ | `className` | `string` | — | Container className passthrough. |
3858
+
3726
3859
  ### DateRangeSelector
3727
3860
 
3728
3861
  Framework-agnostic segmented control (`value` + `onChange`) for picking
package/dist/index.cjs CHANGED
@@ -74,6 +74,7 @@ var TogglePrimitive = require('@radix-ui/react-toggle');
74
74
  var ToggleGroupPrimitive = require('@radix-ui/react-toggle-group');
75
75
  var AlertDialogPrimitive = require('@radix-ui/react-alert-dialog');
76
76
  var sonner = require('sonner');
77
+ var client$1 = require('hazo_state/client');
77
78
 
78
79
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
79
80
 
@@ -7999,6 +8000,18 @@ function useFullscreen(elementRef) {
7999
8000
  }, [enter, exit]);
8000
8001
  return { isFullscreen: is_fullscreen, enter, exit, toggle };
8001
8002
  }
8003
+ function use_wake_lock(active) {
8004
+ const { acquired, request, release } = useWakeLock();
8005
+ React26.useEffect(() => {
8006
+ if (active && !acquired) void request();
8007
+ if (!active && acquired) void release();
8008
+ }, [active, acquired, request, release]);
8009
+ }
8010
+ function use_fullscreen() {
8011
+ const ref = React26.useRef(null);
8012
+ const { isFullscreen, toggle } = useFullscreen(ref);
8013
+ return { is_fullscreen: isFullscreen, toggle, ref };
8014
+ }
8002
8015
  function KanbanCard({
8003
8016
  item,
8004
8017
  renderCard,
@@ -10415,6 +10428,219 @@ function DateRangeSelector({
10415
10428
  }
10416
10429
  );
10417
10430
  }
10431
+ var PAD_LEFT4 = 80;
10432
+ var PAD_RIGHT4 = 72;
10433
+ var PAD_TOP4 = 12;
10434
+ var PAD_BOTTOM4 = 12;
10435
+ var ROW_H = 44;
10436
+ var LABEL_H = 14;
10437
+ var BAR_H = 22;
10438
+ var AXIS_LABEL_COLOR4 = "#8b949e";
10439
+ var GRIDLINE_COLOR3 = "#2a3441";
10440
+ function step_total(step) {
10441
+ if (step.value !== void 0) return step.value;
10442
+ if (step.segments) return step.segments.reduce((s, g) => s + g.value, 0);
10443
+ return 0;
10444
+ }
10445
+ function FunnelChart({
10446
+ steps,
10447
+ width = 360,
10448
+ height,
10449
+ color: color2 = "#3b82f6",
10450
+ valueFormat,
10451
+ showDropoff = true,
10452
+ showTooltip = true,
10453
+ className
10454
+ }) {
10455
+ const [hover_idx, set_hover_idx] = React26__namespace.useState(null);
10456
+ const fmt = valueFormat ?? format_num;
10457
+ const totals = steps.map(step_total);
10458
+ const value_max = Math.max(1, ...totals);
10459
+ const plot_w = width - PAD_LEFT4 - PAD_RIGHT4;
10460
+ const vbox_h = height ?? PAD_TOP4 + steps.length * ROW_H + PAD_BOTTOM4;
10461
+ const handle_mouse_move = React26__namespace.useCallback(
10462
+ (e) => {
10463
+ if (steps.length === 0) return;
10464
+ const rect = e.currentTarget.getBoundingClientRect();
10465
+ if (rect.height === 0) return;
10466
+ const vbox_y = (e.clientY - rect.top) / rect.height * vbox_h;
10467
+ const row_idx = Math.floor((vbox_y - PAD_TOP4) / ROW_H);
10468
+ if (row_idx < 0 || row_idx >= steps.length) {
10469
+ set_hover_idx(null);
10470
+ return;
10471
+ }
10472
+ set_hover_idx(row_idx);
10473
+ },
10474
+ [steps.length, vbox_h]
10475
+ );
10476
+ const handle_mouse_leave = React26__namespace.useCallback(() => set_hover_idx(null), []);
10477
+ return /* @__PURE__ */ jsxRuntime.jsxs(
10478
+ "svg",
10479
+ {
10480
+ viewBox: `0 0 ${width} ${vbox_h}`,
10481
+ onMouseMove: showTooltip ? handle_mouse_move : void 0,
10482
+ onMouseLeave: showTooltip ? handle_mouse_leave : void 0,
10483
+ className: cn("cls_hazo_chart cls_hazo_chart_funnel", className),
10484
+ style: {
10485
+ width: "100%",
10486
+ height: "auto",
10487
+ display: "block",
10488
+ cursor: showTooltip ? "crosshair" : "default"
10489
+ },
10490
+ children: [
10491
+ steps.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(
10492
+ "line",
10493
+ {
10494
+ x1: PAD_LEFT4 + plot_w / 2,
10495
+ x2: PAD_LEFT4 + plot_w / 2,
10496
+ y1: PAD_TOP4,
10497
+ y2: PAD_TOP4 + steps.length * ROW_H,
10498
+ stroke: GRIDLINE_COLOR3,
10499
+ strokeWidth: 0.5,
10500
+ strokeDasharray: "2,3"
10501
+ }
10502
+ ),
10503
+ steps.map((step, i) => {
10504
+ const total = totals[i];
10505
+ const bar_w = total / value_max * plot_w;
10506
+ const bar_x = PAD_LEFT4 + (plot_w - bar_w) / 2;
10507
+ const bar_y = PAD_TOP4 + i * ROW_H + LABEL_H;
10508
+ const pct_first = i === 0 ? 100 : Math.round(total / (totals[0] || 1) * 100);
10509
+ const value_label = i === 0 ? `${fmt(total)} (100%)` : `${fmt(total)} (${pct_first}%)`;
10510
+ const dropoff = i > 0 ? totals[i - 1] - total : 0;
10511
+ const label_x = bar_x + bar_w + 6;
10512
+ return /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
10513
+ /* @__PURE__ */ jsxRuntime.jsx(
10514
+ "text",
10515
+ {
10516
+ x: PAD_LEFT4 - 6,
10517
+ y: bar_y + BAR_H / 2 + 4,
10518
+ textAnchor: "end",
10519
+ fill: AXIS_LABEL_COLOR4,
10520
+ fontSize: 9,
10521
+ children: step.label
10522
+ }
10523
+ ),
10524
+ step.segments ? (() => {
10525
+ let cursor_x = bar_x;
10526
+ return step.segments.map((seg, s_idx) => {
10527
+ const seg_w = seg.value / value_max * plot_w;
10528
+ const x = cursor_x;
10529
+ cursor_x += seg_w;
10530
+ return /* @__PURE__ */ jsxRuntime.jsx(
10531
+ "rect",
10532
+ {
10533
+ x,
10534
+ y: bar_y,
10535
+ width: seg_w,
10536
+ height: BAR_H,
10537
+ fill: seg.color
10538
+ },
10539
+ `seg_${s_idx}`
10540
+ );
10541
+ });
10542
+ })() : /* @__PURE__ */ jsxRuntime.jsx("rect", { x: bar_x, y: bar_y, width: bar_w, height: BAR_H, fill: color2 }),
10543
+ /* @__PURE__ */ jsxRuntime.jsx(
10544
+ "text",
10545
+ {
10546
+ x: label_x,
10547
+ y: bar_y + BAR_H / 2,
10548
+ textAnchor: "start",
10549
+ fill: color2,
10550
+ fontSize: 9,
10551
+ dominantBaseline: "middle",
10552
+ children: value_label
10553
+ }
10554
+ ),
10555
+ showDropoff && i > 0 && /* @__PURE__ */ jsxRuntime.jsx(
10556
+ "text",
10557
+ {
10558
+ x: label_x,
10559
+ y: bar_y + BAR_H / 2 + 10,
10560
+ textAnchor: "start",
10561
+ fill: AXIS_LABEL_COLOR4,
10562
+ fontSize: 8,
10563
+ children: `\u2212${fmt(dropoff)}`
10564
+ }
10565
+ )
10566
+ ] }, `step_${i}`);
10567
+ }),
10568
+ showTooltip && hover_idx !== null && (() => {
10569
+ const i = hover_idx;
10570
+ const step = steps[i];
10571
+ const total = totals[i];
10572
+ const pct_first = i === 0 ? 100 : Math.round(total / (totals[0] || 1) * 100);
10573
+ const pct_prev = i === 0 ? 100 : Math.round(total / (totals[i - 1] || 1) * 100);
10574
+ const dropoff = i > 0 ? totals[i - 1] - total : 0;
10575
+ const bar_w = total / value_max * plot_w;
10576
+ const bar_x = PAD_LEFT4 + (plot_w - bar_w) / 2;
10577
+ const bar_y = PAD_TOP4 + i * ROW_H + LABEL_H;
10578
+ const bubble_anchor_y = bar_y + BAR_H / 2;
10579
+ const lines = [
10580
+ { text: step.label, accent: true },
10581
+ { text: `Total: ${fmt(total)}`, accent: false },
10582
+ { text: `% of first: ${pct_first}%`, accent: false }
10583
+ ];
10584
+ if (i > 0) {
10585
+ lines.push({ text: `% of prev: ${pct_prev}%`, accent: false });
10586
+ lines.push({ text: `Drop-off: \u2212${fmt(dropoff)}`, accent: false });
10587
+ }
10588
+ if (step.segments) {
10589
+ step.segments.forEach((seg) => {
10590
+ lines.push({ text: `${seg.label}: ${fmt(seg.value)}`, accent: false });
10591
+ });
10592
+ }
10593
+ const line_h = 13;
10594
+ const pad_v = 8;
10595
+ const bubble_w = 120;
10596
+ const bubble_h = lines.length * line_h + pad_v * 2;
10597
+ const right_x = bar_x + bar_w + 6;
10598
+ const left_x = bar_x - bubble_w - 6;
10599
+ const fits_right = right_x + bubble_w <= width - 4;
10600
+ const fits_left = left_x >= 4;
10601
+ let bubble_x;
10602
+ let bubble_y;
10603
+ if (fits_right || fits_left) {
10604
+ bubble_x = fits_right ? right_x : left_x;
10605
+ bubble_y = Math.max(4, Math.min(bubble_anchor_y - bubble_h / 2, vbox_h - bubble_h - 4));
10606
+ } else {
10607
+ bubble_x = Math.min(Math.max(bar_x + bar_w / 2 - bubble_w / 2, 4), width - bubble_w - 4);
10608
+ const below_y = bar_y + BAR_H + 6;
10609
+ bubble_y = below_y + bubble_h <= vbox_h - 4 ? below_y : Math.max(4, bar_y - bubble_h - 6);
10610
+ }
10611
+ return /* @__PURE__ */ jsxRuntime.jsxs("g", { children: [
10612
+ /* @__PURE__ */ jsxRuntime.jsx(
10613
+ "rect",
10614
+ {
10615
+ x: bubble_x,
10616
+ y: bubble_y,
10617
+ width: bubble_w,
10618
+ height: bubble_h,
10619
+ rx: 3,
10620
+ fill: "#0d1117",
10621
+ stroke: color2,
10622
+ strokeWidth: 0.5,
10623
+ fillOpacity: 0.92
10624
+ }
10625
+ ),
10626
+ lines.map((line, k) => /* @__PURE__ */ jsxRuntime.jsx(
10627
+ "text",
10628
+ {
10629
+ x: bubble_x + 7,
10630
+ y: bubble_y + pad_v + k * line_h + line_h * 0.75,
10631
+ fill: line.accent ? color2 : AXIS_LABEL_COLOR4,
10632
+ fontSize: line.accent ? 10 : 8,
10633
+ fontWeight: line.accent ? 700 : 400,
10634
+ children: line.text
10635
+ },
10636
+ k
10637
+ ))
10638
+ ] });
10639
+ })()
10640
+ ]
10641
+ }
10642
+ );
10643
+ }
10418
10644
 
10419
10645
  // src/assets/celebration-chime.mp3
10420
10646
  var celebration_chime_default = "data:text/plain;charset=utf-8,";
@@ -10701,6 +10927,177 @@ function CelebrationModalInner({
10701
10927
  );
10702
10928
  }
10703
10929
 
10930
+ // src/components/hazo_ui_eta_progress/eta.ts
10931
+ function median(values) {
10932
+ if (values.length === 0) return 0;
10933
+ const sorted = [...values].sort((a, b) => a - b);
10934
+ const mid = Math.floor(sorted.length / 2);
10935
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
10936
+ }
10937
+ function computeEta(durationWindow, unitCount, concurrency = 1, fallbackUnitMs = 5e3) {
10938
+ const unitMs = durationWindow.length > 0 ? median(durationWindow) : fallbackUnitMs;
10939
+ return unitMs * Math.ceil(unitCount / Math.max(1, concurrency));
10940
+ }
10941
+ function easeToward(elapsed, eta, maxCap = 0.95) {
10942
+ if (eta <= 0 || elapsed <= 0) return 0;
10943
+ const t = elapsed / eta;
10944
+ return maxCap * (1 - Math.exp(-1.5 * t));
10945
+ }
10946
+ function HazoUiProgressBar({
10947
+ value,
10948
+ label,
10949
+ showPercent = false,
10950
+ size = 8,
10951
+ className
10952
+ }) {
10953
+ const pct = Math.round(Math.min(1, Math.max(0, value)) * 100);
10954
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn("w-full", className), children: [
10955
+ (label || showPercent) && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between mb-1", children: [
10956
+ label && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm text-muted-foreground", children: label }),
10957
+ showPercent && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-xs text-muted-foreground tabular-nums", children: [
10958
+ pct,
10959
+ "%"
10960
+ ] })
10961
+ ] }),
10962
+ /* @__PURE__ */ jsxRuntime.jsx(
10963
+ "div",
10964
+ {
10965
+ role: "progressbar",
10966
+ "aria-valuenow": pct,
10967
+ "aria-valuemin": 0,
10968
+ "aria-valuemax": 100,
10969
+ "aria-label": label,
10970
+ className: "w-full rounded-full overflow-hidden bg-muted",
10971
+ style: { height: size },
10972
+ children: /* @__PURE__ */ jsxRuntime.jsx(
10973
+ "div",
10974
+ {
10975
+ className: "h-full rounded-full bg-accent transition-[width] duration-200 ease-out motion-reduce:transition-none",
10976
+ style: { width: `${pct}%` }
10977
+ }
10978
+ )
10979
+ }
10980
+ )
10981
+ ] });
10982
+ }
10983
+ function useEtaProgress(opts) {
10984
+ const {
10985
+ unitCount,
10986
+ concurrency = 1,
10987
+ fallbackUnitMs = 5e3,
10988
+ loadDurations,
10989
+ appendDuration
10990
+ } = opts;
10991
+ const [value, setValue] = React26__namespace.useState(0);
10992
+ const stateRef = React26__namespace.useRef({
10993
+ started: false,
10994
+ finished: false,
10995
+ startTime: 0,
10996
+ unitsDone: 0,
10997
+ unitTimestamps: [],
10998
+ // timestamps of each unitDone() call
10999
+ eta: 0,
11000
+ rafId: 0
11001
+ });
11002
+ const stop = React26__namespace.useCallback(() => {
11003
+ if (stateRef.current.rafId) {
11004
+ cancelAnimationFrame(stateRef.current.rafId);
11005
+ stateRef.current.rafId = 0;
11006
+ }
11007
+ }, []);
11008
+ React26__namespace.useEffect(() => stop, [stop]);
11009
+ const tick = React26__namespace.useCallback(() => {
11010
+ const s = stateRef.current;
11011
+ if (s.finished || !s.started) return;
11012
+ const elapsed = Date.now() - s.startTime;
11013
+ const timeValue = easeToward(elapsed, s.eta);
11014
+ const unitValue = unitCount > 0 ? s.unitsDone / unitCount : 0;
11015
+ setValue(Math.max(timeValue, unitValue));
11016
+ s.rafId = requestAnimationFrame(tick);
11017
+ }, [unitCount]);
11018
+ const start = React26__namespace.useCallback(() => {
11019
+ const s = stateRef.current;
11020
+ if (s.started) return;
11021
+ const window2 = loadDurations();
11022
+ const eta = computeEta(window2, unitCount, concurrency, fallbackUnitMs);
11023
+ s.started = true;
11024
+ s.finished = false;
11025
+ s.startTime = Date.now();
11026
+ s.unitsDone = 0;
11027
+ s.unitTimestamps = [];
11028
+ s.eta = eta;
11029
+ stop();
11030
+ s.rafId = requestAnimationFrame(tick);
11031
+ }, [loadDurations, unitCount, concurrency, fallbackUnitMs, stop, tick]);
11032
+ const unitDone = React26__namespace.useCallback(() => {
11033
+ const s = stateRef.current;
11034
+ if (!s.started || s.finished) return;
11035
+ s.unitsDone = Math.min(s.unitsDone + 1, unitCount);
11036
+ s.unitTimestamps.push(Date.now());
11037
+ }, [unitCount]);
11038
+ const finish = React26__namespace.useCallback(() => {
11039
+ const s = stateRef.current;
11040
+ if (s.finished) return;
11041
+ s.finished = true;
11042
+ stop();
11043
+ setValue(1);
11044
+ if (s.started && s.unitTimestamps.length > 0) {
11045
+ const totalMs = Date.now() - s.startTime;
11046
+ const perUnitMs = totalMs / Math.ceil(unitCount / Math.max(1, concurrency));
11047
+ appendDuration(perUnitMs);
11048
+ }
11049
+ s.started = false;
11050
+ s.unitsDone = 0;
11051
+ }, [stop, unitCount, concurrency, appendDuration]);
11052
+ return { value, start, unitDone, finish };
11053
+ }
11054
+ function HazoUiEtaProgress({
11055
+ estimateKey,
11056
+ unitCount,
11057
+ concurrency = 1,
11058
+ windowSize = 5,
11059
+ fallbackUnitMs = 5e3,
11060
+ level = "global",
11061
+ label,
11062
+ endpoint,
11063
+ handleRef,
11064
+ className
11065
+ }) {
11066
+ const { value: stored, append } = client$1.useHazoState(estimateKey, {
11067
+ level,
11068
+ fallback: [],
11069
+ ...endpoint ? { endpoint } : {}
11070
+ });
11071
+ const durationWindow = Array.isArray(stored) ? stored : [];
11072
+ const loadDurations = React26__namespace.useCallback(() => durationWindow, [durationWindow]);
11073
+ const appendDuration = React26__namespace.useCallback(
11074
+ (ms) => {
11075
+ append(ms, windowSize);
11076
+ },
11077
+ [append, windowSize]
11078
+ );
11079
+ const handle = useEtaProgress({
11080
+ unitCount,
11081
+ concurrency,
11082
+ fallbackUnitMs,
11083
+ loadDurations,
11084
+ appendDuration
11085
+ });
11086
+ React26__namespace.useEffect(() => {
11087
+ if (handleRef) {
11088
+ handleRef.current = handle;
11089
+ }
11090
+ }, [handleRef, handle]);
11091
+ return /* @__PURE__ */ jsxRuntime.jsx(
11092
+ HazoUiProgressBar,
11093
+ {
11094
+ value: handle.value,
11095
+ label,
11096
+ className
11097
+ }
11098
+ );
11099
+ }
11100
+
10704
11101
  Object.defineProperty(exports, "rawToast", {
10705
11102
  enumerable: true,
10706
11103
  get: function () { return sonner.toast; }
@@ -10770,6 +11167,7 @@ exports.DropdownMenuTrigger = DropdownMenuTrigger;
10770
11167
  exports.EmptyState = EmptyState;
10771
11168
  exports.ErrorBanner = ErrorBanner;
10772
11169
  exports.ErrorPage = ErrorPage;
11170
+ exports.FunnelChart = FunnelChart;
10773
11171
  exports.HazoContextProvider = HazoContextProvider;
10774
11172
  exports.HazoUiConfirmDialog = HazoUiConfirmDialog;
10775
11173
  exports.HazoUiDialog = HazoUiDialog;
@@ -10783,6 +11181,7 @@ exports.HazoUiDialogPortal = DialogPortal;
10783
11181
  exports.HazoUiDialogRoot = Dialog;
10784
11182
  exports.HazoUiDialogTitle = DialogTitle;
10785
11183
  exports.HazoUiDialogTrigger = DialogTrigger;
11184
+ exports.HazoUiEtaProgress = HazoUiEtaProgress;
10786
11185
  exports.HazoUiFlexInput = HazoUiFlexInput;
10787
11186
  exports.HazoUiFlexRadio = HazoUiFlexRadio;
10788
11187
  exports.HazoUiKanban = HazoUiKanban;
@@ -10790,6 +11189,7 @@ exports.HazoUiKanbanFilter = HazoUiKanbanFilter;
10790
11189
  exports.HazoUiMultiFilterDialog = HazoUiMultiFilterDialog;
10791
11190
  exports.HazoUiMultiSortDialog = HazoUiMultiSortDialog;
10792
11191
  exports.HazoUiPillRadio = HazoUiPillRadio;
11192
+ exports.HazoUiProgressBar = HazoUiProgressBar;
10793
11193
  exports.HazoUiRte = HazoUiRte;
10794
11194
  exports.HazoUiTable = HazoUiTable;
10795
11195
  exports.HazoUiTextarea = HazoUiTextarea;
@@ -10863,12 +11263,15 @@ exports.applyKanbanFilter = applyKanbanFilter;
10863
11263
  exports.buttonGroupVariants = buttonGroupVariants;
10864
11264
  exports.celebrate = celebrate;
10865
11265
  exports.cn = cn;
11266
+ exports.computeEta = computeEta;
10866
11267
  exports.create_command_suggestion_extension = create_command_suggestion_extension;
11268
+ exports.easeToward = easeToward;
10867
11269
  exports.errorToast = errorToast;
10868
11270
  exports.format_num = format_num;
10869
11271
  exports.generateUUID = generateUUID;
10870
11272
  exports.get_hazo_ui_config = get_hazo_ui_config;
10871
11273
  exports.get_logger = get_logger;
11274
+ exports.median = median;
10872
11275
  exports.parse_commands_from_text = parse_commands_from_text;
10873
11276
  exports.pick_x_label_indices = pick_x_label_indices;
10874
11277
  exports.reset_hazo_ui_config = reset_hazo_ui_config;
@@ -10882,6 +11285,7 @@ exports.useClickOutside = useClickOutside;
10882
11285
  exports.useCopyToClipboard = useCopyToClipboard;
10883
11286
  exports.useDebounce = useDebounce;
10884
11287
  exports.useErrorDisplay = useErrorDisplay;
11288
+ exports.useEtaProgress = useEtaProgress;
10885
11289
  exports.useFullscreen = useFullscreen;
10886
11290
  exports.useIsMobile = useIsMobile;
10887
11291
  exports.useLoadingState = useLoadingState;
@@ -10890,5 +11294,7 @@ exports.useMediaQuery = useMediaQuery;
10890
11294
  exports.useSessionStorage = useSessionStorage;
10891
11295
  exports.useViewport = useViewport;
10892
11296
  exports.useWakeLock = useWakeLock;
11297
+ exports.use_fullscreen = use_fullscreen;
11298
+ exports.use_wake_lock = use_wake_lock;
10893
11299
  //# sourceMappingURL=index.cjs.map
10894
11300
  //# sourceMappingURL=index.cjs.map