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 && ( + + )} +
+ ); +} +``` + +### Return Type + +```ts +interface UseStreamCountdownResult { + days: number; + hours: number; + minutes: number; + seconds: number; + isPast: boolean; + progressFraction: number | null; +} +``` + +### Parameters + +- `targetTimestamp`: The deadline to count down to, in Unix seconds. Pass + `null` or `undefined` to render a zeroed, not-yet-started state (handy + while a stream is still loading). +- `startTimestamp` (optional): The start of the window, in Unix seconds. + Supplying it adds `progressFraction` — the elapsed share of the window, + clamped to `0`-`1`. Without it `progressFraction` is `null`, since a + deadline alone cannot define a range. + +--- + ## Error Handling Each hook exposes an `error` field that captures: diff --git a/packages/react/src/__tests__/useStreamCountdown.test.tsx b/packages/react/src/__tests__/useStreamCountdown.test.tsx new file mode 100644 index 0000000..3e45f15 --- /dev/null +++ b/packages/react/src/__tests__/useStreamCountdown.test.tsx @@ -0,0 +1,177 @@ +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 }); + }); + + 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(); + }); +}); diff --git a/packages/react/src/hooks/useCircuitState.ts b/packages/react/src/hooks/useCircuitState.ts index c98db3a..80fc24c 100644 --- a/packages/react/src/hooks/useCircuitState.ts +++ b/packages/react/src/hooks/useCircuitState.ts @@ -4,7 +4,7 @@ import { onCircuitChange, resetCircuit, type CircuitStatus, -} from '../../src/rpc-circuit-state.js'; +} from '../../../../src/rpc-circuit-state.js'; export interface UseCircuitStateResult { /** Current circuit status for the given scope. */ 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/hooks/useTransactionHistory.ts b/packages/react/src/hooks/useTransactionHistory.ts index 90f5e9d..8a6a345 100644 --- a/packages/react/src/hooks/useTransactionHistory.ts +++ b/packages/react/src/hooks/useTransactionHistory.ts @@ -13,7 +13,7 @@ import { selectVisibleTransactions, selectTotalPages, selectViewStatus, -} from '../../src/dashboard/transaction-history.js'; +} from '../../../../src/dashboard/transaction-history.js'; export interface UseTransactionHistoryOptions { /** Wallet address used to derive transaction direction. */ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c088551..09a76ca 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -66,6 +66,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,