Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/react-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p>Stream complete — everything has unlocked.</p>;

return (
<div>
<p>
{days}d {hours}h {minutes}m {seconds}s remaining
</p>
{progressFraction !== null && (
<progress value={progressFraction} max={1} />
)}
</div>
);
}
```

### 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:
Expand Down
177 changes: 177 additions & 0 deletions packages/react/src/__tests__/useStreamCountdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 1 addition & 1 deletion packages/react/src/hooks/useCircuitState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
111 changes: 111 additions & 0 deletions packages/react/src/hooks/useStreamCountdown.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
2 changes: 1 addition & 1 deletion packages/react/src/hooks/useTransactionHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down