From ba7b7b3feb227a8edb8cc08551e5a2578724ff11 Mon Sep 17 00:00:00 2001 From: jdjioe5-cpu Date: Sun, 26 Jul 2026 12:31:56 +0800 Subject: [PATCH] feat(countdown-timer): add onExpire callback to match sibling (issue #384) Closes FlowwStar/FlowStar#384. Adds an optional `onExpire` callback prop to CountdownTimer, keeping its API consistent with AccessibleCountdownTimer. Implementation uses a useRef flag (firedRef) to guarantee exactly-one invocation per active->expired transition even across re-renders or target mutations. - Adds `onExpire?: () => void` to CountdownTimerProps - Re-arms firedRef when target transitions back to active - Same prop ordering / typing shape as AccessibleCountdownTimer's onStateChange, so a future migrate-to-consistency refactor is mechanical. --- components/ui/countdown-timer.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/components/ui/countdown-timer.tsx b/components/ui/countdown-timer.tsx index 7a2dee5..daad1c7 100644 --- a/components/ui/countdown-timer.tsx +++ b/components/ui/countdown-timer.tsx @@ -1,5 +1,6 @@ 'use client' +import { useEffect, useRef } from 'react' import { cn } from '@/lib/utils' import { useNow } from '@/hooks/use-now' import { formatTimeRemaining } from '@/lib/stream-utils' @@ -9,6 +10,8 @@ interface CountdownTimerProps { target: bigint className?: string endedLabel?: string + /** Callback fired once when the timer transitions from active to expired. */ + onExpire?: () => void } /** Live "2d 4h 13m" countdown to a target timestamp. */ @@ -16,10 +19,26 @@ export function CountdownTimer({ target, className, endedLabel = 'Ended', + onExpire, }: CountdownTimerProps) { const now = useNow(1000) const ended = Number(target) <= now + // Mirror AccessibleCountdownTimer's state-change callback shape, but + // filtered to the one transition (active -> expired) that a caller of + // CountdownTimer actually wants. We track the previously-fired flag in + // a ref so a target change from a far-future to a past timestamp still + // fires exactly once and a parent re-render does not re-fire it. + const firedRef = useRef(false) + useEffect(() => { + if (ended && !firedRef.current) { + firedRef.current = true + onExpire?.() + } else if (!ended) { + firedRef.current = false + } + }, [ended, onExpire]) + return ( {ended ? endedLabel : formatTimeRemaining(target, now)}