Add live countdown to Blobbi overstimulation toast

Show remaining seconds in the blocked toast (e.g. 'calm down for 4s')
and update it every second via the toast update mechanism. The toast is
automatically dismissed when the blocked phase ends. All timer state is
owned by useOverstimulationReaction so the UI layer stays simple.
This commit is contained in:
filemon
2026-04-18 01:53:19 -03:00
parent 12c19ac4c2
commit 6d9e750251
@@ -162,6 +162,8 @@ export function useOverstimulationReaction({
const rafRef = useRef<number | null>(null);
const blockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastShownRef = useRef(false);
const toastHandleRef = useRef<ReturnType<typeof toast> | null>(null);
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const prevTimeRef = useRef(0);
// Keep profile in a ref so the rAF loop doesn't need it as a dep
@@ -184,6 +186,17 @@ export function useOverstimulationReaction({
}
}, []);
const clearCountdown = useCallback(() => {
if (countdownIntervalRef.current !== null) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
if (toastHandleRef.current) {
toastHandleRef.current.dismiss();
toastHandleRef.current = null;
}
}, []);
/**
* Push visible state if the delta is meaningful.
*
@@ -257,25 +270,37 @@ export function useOverstimulationReaction({
phaseRef.current = 'blocked';
setPhase('blocked');
const duration = BLOCK_MIN_MS + Math.random() * (BLOCK_MAX_MS - BLOCK_MIN_MS);
const totalSeconds = Math.ceil(duration / 1000);
let remaining = totalSeconds;
if (!toastShownRef.current) {
toastShownRef.current = true;
toast({
const handle = toast({
title: 'Too many clicks!',
description: 'Blobbi is overwhelmed and blocked clicks for a few seconds.',
description: `Blobbi is overwhelmed\u2026 calm down for ${remaining}s`,
});
}
toastHandleRef.current = handle;
const duration = BLOCK_MIN_MS + Math.random() * (BLOCK_MAX_MS - BLOCK_MIN_MS);
clearCountdown();
countdownIntervalRef.current = setInterval(() => {
remaining -= 1;
if (remaining > 0 && toastHandleRef.current) {
toastHandleRef.current.update({ id: toastHandleRef.current.id, title: 'Too many clicks!', description: `Blobbi is overwhelmed\u2026 calm down for ${remaining}s` });
}
}, 1000);
}
clearBlockTimer();
blockTimerRef.current = setTimeout(() => {
blockTimerRef.current = null;
clearCountdown();
phaseRef.current = 'cooling';
setPhase('cooling');
prevTimeRef.current = performance.now();
startRafLoopRef.current();
}, duration);
}, [clearBlockTimer]);
}, [clearBlockTimer, clearCountdown]);
// ── Global click handler ──
@@ -284,6 +309,7 @@ export function useOverstimulationReaction({
// Reset everything
clearRaf();
clearBlockTimer();
clearCountdown();
clicksRef.current = [];
levelRef.current = 0;
phaseRef.current = 'idle';
@@ -341,15 +367,16 @@ export function useOverstimulationReaction({
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [isActive, clearRaf, clearBlockTimer, pushVisible, enterBlocked]);
}, [isActive, clearRaf, clearBlockTimer, clearCountdown, pushVisible, enterBlocked]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearRaf();
clearBlockTimer();
clearCountdown();
};
}, [clearRaf, clearBlockTimer]);
}, [clearRaf, clearBlockTimer, clearCountdown]);
// ── Resolve level + phase → recipe ──