From 4e0c36d11ec9dd5b0893f7f168a1bec599a8a0e2 Mon Sep 17 00:00:00 2001 From: Esther Adaeze Eze <102748488+esthereze@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:10:42 +0000 Subject: [PATCH 1/5] test(react): add failing tests for useStreamCountdown (#830) Describe the countdown contract for cliff and cancellation deadlines before the hook exists: remaining days/hours/minutes/seconds, the 1-second auto-tick, and the isPast clamp at zero. These fail on main with an unresolved import, matching the repo's test-first convention. --- .../src/__tests__/useStreamCountdown.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 packages/react/src/__tests__/useStreamCountdown.test.tsx diff --git a/packages/react/src/__tests__/useStreamCountdown.test.tsx b/packages/react/src/__tests__/useStreamCountdown.test.tsx new file mode 100644 index 0000000..a909094 --- /dev/null +++ b/packages/react/src/__tests__/useStreamCountdown.test.tsx @@ -0,0 +1,63 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useStreamCountdown } from '../hooks/useStreamCountdown.js'; + +const BASE_MS = Date.UTC(2026, 0, 1, 0, 0, 0); // 2026-01-01T00:00:00Z +const BASE_SEC = Math.floor(BASE_MS / 1000); + +describe('useStreamCountdown', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(BASE_MS)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('splits the remaining time into days, hours, minutes and seconds', () => { + const target = BASE_SEC + 86_400 + 2 * 3_600 + 3 * 60 + 4; + + const { result } = renderHook(() => useStreamCountdown(target)); + + expect(result.current).toEqual({ + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + isPast: false, + progressFraction: null, + }); + }); + + it('auto-ticks once per second', () => { + const target = BASE_SEC + 5; + const { result } = renderHook(() => useStreamCountdown(target)); + + expect(result.current.seconds).toBe(5); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + expect(result.current.seconds).toBe(4); + + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(result.current.seconds).toBe(2); + }); + + it('reports isPast once the target is reached and clamps at zero', () => { + const target = BASE_SEC + 1; + const { result } = renderHook(() => useStreamCountdown(target)); + + expect(result.current.isPast).toBe(false); + + act(() => { + vi.advanceTimersByTime(5_000); + }); + + expect(result.current.isPast).toBe(true); + expect(result.current).toMatchObject({ days: 0, hours: 0, minutes: 0, seconds: 0 }); + }); +}); From 460a3ffca6f3c0390f214f9022af99fb126fa070 Mon Sep 17 00:00:00 2001 From: Esther Adaeze Eze <102748488+esthereze@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:11:42 +0000 Subject: [PATCH 2/5] feat(react): implement useStreamCountdown for cliff/cancellation deadlines (#830) Add a dependency-free hook that turns a Unix-seconds deadline into days/hours/minutes/seconds with a 1-second auto-tick, so vesting and payroll cards no longer hand-roll timers. An optional startTimestamp adds progressFraction for the elapsed share of the window; without it the fraction is null since a deadline alone cannot define a range. Closes #830 --- .../react/src/hooks/useStreamCountdown.ts | 111 ++++++++++++++++++ packages/react/src/index.ts | 2 + 2 files changed, 113 insertions(+) create mode 100644 packages/react/src/hooks/useStreamCountdown.ts diff --git a/packages/react/src/hooks/useStreamCountdown.ts b/packages/react/src/hooks/useStreamCountdown.ts new file mode 100644 index 0000000..d474640 --- /dev/null +++ b/packages/react/src/hooks/useStreamCountdown.ts @@ -0,0 +1,111 @@ +import { useEffect, useMemo, useState } from 'react'; + +const SECONDS_PER_MINUTE = 60; +const SECONDS_PER_HOUR = 3_600; +const SECONDS_PER_DAY = 86_400; + +export interface UseStreamCountdownResult { + /** Whole days remaining (0 once the target has passed). */ + days: number; + /** Whole hours remaining within the day, 0-23. */ + hours: number; + /** Whole minutes remaining within the hour, 0-59. */ + minutes: number; + /** Whole seconds remaining within the minute, 0-59. */ + seconds: number; + /** True once the current time is at or past `targetTimestamp`. */ + isPast: boolean; + /** + * Fraction (0-1) of the window between `startTimestamp` and + * `targetTimestamp` that has elapsed, clamped to [0, 1]. + * + * `null` when no `startTimestamp` is supplied (or when either timestamp is + * not a finite number), since a fraction needs both ends of the window. + */ + progressFraction: number | null; +} + +function currentUnixSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function isFiniteTimestamp(value: number | null | undefined): value is number { + return value != null && Number.isFinite(value); +} + +/** + * Live countdown to a cliff or cancellation/completion deadline. + * + * `targetTimestamp` and the optional `startTimestamp` are Unix timestamps in + * **seconds** (the same unit as `StreamInfo.startTime`/`endTime`), not + * milliseconds. The hook re-renders every second so a vesting or payroll card + * can render `{days}d {hours}h {minutes}m {seconds}s` without wiring up its + * own timer. + * + * Pass `startTimestamp` as well to also get `progressFraction`, the share of + * the window that has already elapsed. Without it `progressFraction` is + * `null`; a countdown does not have enough information to derive a fraction + * from the deadline alone. + * + * @example + * ```tsx + * const { days, hours, minutes, seconds, isPast, progressFraction } = + * useStreamCountdown(stream.endTime, stream.startTime); + * ``` + */ +export function useStreamCountdown( + targetTimestamp: number | null | undefined, + startTimestamp?: number | null, +): UseStreamCountdownResult { + const [now, setNow] = useState(currentUnixSeconds); + + const hasTarget = isFiniteTimestamp(targetTimestamp); + + // Re-sync on mount and whenever the target changes, then tick once per + // second. `targetTimestamp` is intentionally a dependency: a changed + // deadline must not keep counting down against the old value. + useEffect(() => { + if (!hasTarget) return; + + setNow(currentUnixSeconds()); + const interval = setInterval(() => { + setNow(currentUnixSeconds()); + }, 1_000); + + return () => clearInterval(interval); + }, [hasTarget, targetTimestamp]); + + return useMemo(() => { + if (!hasTarget) { + return { + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + isPast: false, + progressFraction: null, + }; + } + + const remaining = Math.max(0, targetTimestamp - now); + const isPast = now >= targetTimestamp; + + const days = Math.floor(remaining / SECONDS_PER_DAY); + const hours = Math.floor((remaining % SECONDS_PER_DAY) / SECONDS_PER_HOUR); + const minutes = Math.floor((remaining % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); + const seconds = remaining % SECONDS_PER_MINUTE; + + let progressFraction: number | null = null; + if (isFiniteTimestamp(startTimestamp)) { + const span = targetTimestamp - startTimestamp; + progressFraction = + span <= 0 + ? isPast + ? 1 + : 0 + : Math.min(1, Math.max(0, (now - startTimestamp) / span)); + } + + return { days, hours, minutes, seconds, isPast, progressFraction }; + }, [hasTarget, targetTimestamp, startTimestamp, now]); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0630cb6..dc4a2a1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -62,6 +62,8 @@ export { useTransactionHistory } from './hooks/useTransactionHistory.js'; export type { UseTransactionHistoryResult, UseTransactionHistoryOptions } from './hooks/useTransactionHistory.js'; export { useCircuitState } from './hooks/useCircuitState.js'; export type { UseCircuitStateResult } from './hooks/useCircuitState.js'; +export { useStreamCountdown } from './hooks/useStreamCountdown.js'; +export type { UseStreamCountdownResult } from './hooks/useStreamCountdown.js'; export { useTokenAllowance } from './hooks/useTokenAllowance.js'; export type { ApproveTokenAllowanceFn, From 3d19e336ab5959993d19f4c234dafbedc291a66b Mon Sep 17 00:00:00 2001 From: Esther Adaeze Eze <102748488+esthereze@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:11:58 +0000 Subject: [PATCH 3/5] test(react): cover useStreamCountdown boundaries and edge cases Exercise the paths a card actually hits: progressFraction before, during and past the window, zero-length windows, missing/non-finite timestamps, target changes mid-countdown, long-duration decomposition, and interval cleanup on unmount. --- .../src/__tests__/useStreamCountdown.test.tsx | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/packages/react/src/__tests__/useStreamCountdown.test.tsx b/packages/react/src/__tests__/useStreamCountdown.test.tsx index a909094..3e45f15 100644 --- a/packages/react/src/__tests__/useStreamCountdown.test.tsx +++ b/packages/react/src/__tests__/useStreamCountdown.test.tsx @@ -60,4 +60,118 @@ describe('useStreamCountdown', () => { expect(result.current.isPast).toBe(true); expect(result.current).toMatchObject({ days: 0, hours: 0, minutes: 0, seconds: 0 }); }); + + it('derives progressFraction from the start/end window', () => { + const start = BASE_SEC; + const target = BASE_SEC + 100; + const { result } = renderHook(() => useStreamCountdown(target, start)); + + expect(result.current.progressFraction).toBe(0); + + act(() => { + vi.advanceTimersByTime(50_000); + }); + expect(result.current.progressFraction).toBe(0.5); + + act(() => { + vi.advanceTimersByTime(50_000); + }); + expect(result.current.progressFraction).toBe(1); + }); + + it('clamps progressFraction to 0 before the window starts', () => { + const { result } = renderHook(() => + useStreamCountdown(BASE_SEC + 150, BASE_SEC + 50), + ); + + expect(result.current.progressFraction).toBe(0); + expect(result.current.isPast).toBe(false); + }); + + it('treats a zero-length window as complete only once past', () => { + const target = BASE_SEC + 10; + const { result } = renderHook(() => useStreamCountdown(target, target)); + + expect(result.current.progressFraction).toBe(0); + expect(result.current.isPast).toBe(false); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + + expect(result.current.progressFraction).toBe(1); + expect(result.current.isPast).toBe(true); + }); + + it('returns a zeroed, not-started result when the target is missing', () => { + const { result } = renderHook(() => useStreamCountdown(null, BASE_SEC)); + + expect(result.current).toEqual({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + isPast: false, + progressFraction: null, + }); + }); + + it('treats non-finite targets as missing', () => { + const { result } = renderHook(() => useStreamCountdown(Number.NaN, BASE_SEC)); + + expect(result.current.isPast).toBe(false); + expect(result.current.progressFraction).toBeNull(); + }); + + it('ignores a non-finite start timestamp for progressFraction', () => { + const { result } = renderHook(() => + useStreamCountdown(BASE_SEC + 60, Number.POSITIVE_INFINITY), + ); + + expect(result.current.progressFraction).toBeNull(); + }); + + it('re-syncs when the target changes', () => { + const { result, rerender } = renderHook( + ({ target }: { target: number }) => useStreamCountdown(target), + { initialProps: { target: BASE_SEC + 10 } }, + ); + + expect(result.current.seconds).toBe(10); + + rerender({ target: BASE_SEC + 3 }); + expect(result.current.seconds).toBe(3); + }); + + it('clears its interval on unmount', () => { + const { unmount } = renderHook(() => useStreamCountdown(BASE_SEC + 60)); + expect(vi.getTimerCount()).toBeGreaterThan(0); + + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('decomposes long durations into whole units', () => { + const target = BASE_SEC + 2 * 86_400 + 5 * 3_600 + 59 * 60 + 59; + const { result } = renderHook(() => useStreamCountdown(target)); + + expect(result.current).toMatchObject({ + days: 2, + hours: 5, + minutes: 59, + seconds: 59, + }); + }); + + it('flags isPast exactly at the target', () => { + const target = BASE_SEC + 1; + const { result } = renderHook(() => useStreamCountdown(target)); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + + expect(result.current.isPast).toBe(true); + expect(result.current.progressFraction).toBeNull(); + }); }); From 8cfcdcd7bc8dbe6ae6a49e3c896bbca071e9ad38 Mon Sep 17 00:00:00 2001 From: Esther Adaeze Eze <102748488+esthereze@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:12:07 +0000 Subject: [PATCH 4/5] docs(react): document useStreamCountdown in the hooks guide Add a usage example, return type and parameter notes to docs/react-hooks.md, and an [Unreleased] changelog entry, so the new public export is discoverable (the repo requires new public hooks to be documented). --- CHANGELOG.md | 1 + docs/react-hooks.md | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce7fc1..5fca5bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http ## [Unreleased] ### Added +- `@streamfi/react` exports `useStreamCountdown(targetTimestamp, startTimestamp?)` — a 1-second-tick countdown (`days`/`hours`/`minutes`/`seconds`, `isPast`, and an optional `progressFraction`) for cliff and cancellation/completion deadlines, so vesting and payroll cards no longer hand-roll timers (#830). - `NETWORK_NAMES`, `EXPLORER_URLS`, and `NetworkType` provide shared human-readable Stellar network labels and Stellar Expert transaction, contract, and account URL bases (#832). - `TokenModule` exposes SEP-41 `allowance()` and `approve()` operations through `client.tokens`, and `@streamfi/react` now exports `useTokenAllowance()` for allowance verification and approval state (#851). - `timeoutSignal(ms)` utility (exported from the package root and `/utils`) — a portable `AbortSignal` that aborts after `ms`, using the native `AbortSignal.timeout()` when available and falling back to `AbortController` + `setTimeout` (with `unref()` on Node) otherwise. Pass it as `signal` to any method that accepts one (#634). diff --git a/docs/react-hooks.md b/docs/react-hooks.md index 2d67266..b83adf5 100644 --- a/docs/react-hooks.md +++ b/docs/react-hooks.md @@ -267,6 +267,63 @@ also exposes `refetch()` and `reset()`. --- +## useStreamCountdown + +Live countdown to a cliff, cancellation, or completion deadline. The hook +ticks once per second, so a vesting or payroll card can render the remaining +time without wiring up its own timer. + +`targetTimestamp` and the optional `startTimestamp` are Unix timestamps in +**seconds** (the same unit as `StreamInfo.startTime` / `StreamInfo.endTime`), +not milliseconds. + +```tsx +import { useStreamCountdown } from '@streamfi/react'; + +function CliffCountdown({ stream }) { + const { days, hours, minutes, seconds, isPast, progressFraction } = + useStreamCountdown(stream.endTime, stream.startTime); + + if (isPast) return
Stream complete — everything has unlocked.
; + + return ( ++ {days}d {hours}h {minutes}m {seconds}s remaining +
+ {progressFraction !== null && ( + + )} +