fix(space): stale hud 참조 정리

This commit is contained in:
2026-03-16 15:37:58 +09:00
parent b91fdbcb67
commit 627bd82706
2 changed files with 27 additions and 204 deletions

View File

@@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
import { ExitHoldButton } from '@/features/exit-hold';
import { GoalCompleteSheet } from './GoalCompleteSheet';
import { InlineMicrostep } from './InlineMicrostep';
import { ReturnPrompt } from './ReturnPrompt';
import { ThoughtOrb } from './ThoughtOrb';
interface SpaceFocusHudWidgetProps {
goal: string;
@@ -14,21 +14,12 @@ interface SpaceFocusHudWidgetProps {
hasActiveSession?: boolean;
playbackState?: 'running' | 'paused';
sessionPhase?: 'focus' | 'break' | null;
isSessionActionPending?: boolean;
canStartSession?: boolean;
canPauseSession?: boolean;
canRestartSession?: boolean;
entryOverlayIntent?: 'resume-refocus' | null;
returnPromptMode?: 'focus' | 'break' | null;
onEntryOverlayIntentHandled?: () => void;
onStartRequested?: () => void;
onPauseRequested?: () => void;
onRestartRequested?: () => void;
onDismissReturnPrompt?: () => void;
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onGoalFinish: () => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void;
onCaptureThought: (note: string) => void;
onExitRequested: () => void;
}
export const SpaceFocusHudWidget = ({
@@ -38,42 +29,20 @@ export const SpaceFocusHudWidget = ({
hasActiveSession = false,
playbackState = 'paused',
sessionPhase = 'focus',
isSessionActionPending = false,
canStartSession = false,
canPauseSession = false,
canRestartSession = false,
entryOverlayIntent = null,
returnPromptMode = null,
onEntryOverlayIntentHandled,
onStartRequested,
onPauseRequested,
onRestartRequested,
onDismissReturnPrompt,
onIntentUpdate,
onGoalUpdate,
onGoalFinish,
onStatusMessage,
onCaptureThought,
onExitRequested,
}: SpaceFocusHudWidgetProps) => {
const [overlay, setOverlay] = useState<'none' | 'return' | 'complete'>('none');
const [overlay, setOverlay] = useState<'none' | 'complete'>('none');
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
const [isSavingIntent, setSavingIntent] = useState(false);
const visibleRef = useRef(false);
const resumePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
const restReminderTimerRef = useRef<number | null>(null);
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const isReturnPromptOpen = overlay === 'return';
const isCompleteOpen = overlay === 'complete';
const isIntentOverlayOpen = overlay !== 'none';
useEffect(() => {
return () => {
if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current);
restReminderTimerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!hasActiveSession) {
@@ -83,21 +52,6 @@ export const SpaceFocusHudWidget = ({
}
}, [hasActiveSession]);
useEffect(() => {
if (!returnPromptMode) {
if (overlay === 'return') {
setOverlay('none');
}
return;
}
if (overlay === 'complete') {
return;
}
setOverlay('return');
}, [overlay, returnPromptMode]);
useEffect(() => {
if (!visibleRef.current && playbackState === 'running') {
onStatusMessage({
@@ -108,36 +62,11 @@ export const SpaceFocusHudWidget = ({
visibleRef.current = true;
}, [normalizedGoal, onStatusMessage, playbackState]);
useEffect(() => {
if (resumePlaybackStateRef.current === 'paused' && playbackState === 'running') {
onStatusMessage({
message: copy.space.focusHud.goalToast(normalizedGoal),
});
}
resumePlaybackStateRef.current = playbackState;
}, [normalizedGoal, onStatusMessage, playbackState]);
useEffect(() => {
if (entryOverlayIntent !== 'resume-refocus' || !hasActiveSession || overlay !== 'none') {
return;
}
// With inline microsteps, we just handle the intent and let the user click if they want.
onEntryOverlayIntentHandled?.();
}, [entryOverlayIntent, hasActiveSession, onEntryOverlayIntentHandled, overlay]);
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setCompletePreferredView(preferredView);
setOverlay('complete');
};
const handleDismissReturnPrompt = () => {
onDismissReturnPrompt?.();
if (overlay === 'return') {
setOverlay('none');
}
};
const handleInlineMicrostepUpdate = async (nextStep: string | null) => {
if (isSavingIntent) return false;
@@ -159,18 +88,19 @@ export const SpaceFocusHudWidget = ({
return (
<>
<ThoughtOrb isFocusMode={hasActiveSession} onCaptureThought={onCaptureThought} />
<div className="pointer-events-none fixed inset-0 z-20 flex flex-col items-center justify-center pt-10 pb-32">
{/* The Monolith (Central Hub) */}
<div className={cn(
"pointer-events-auto flex flex-col items-center text-center max-w-4xl px-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]",
isIntentOverlayOpen ? "opacity-0 scale-95 blur-md" : "opacity-100 scale-100 blur-0"
isCompleteOpen ? "opacity-0 scale-95 blur-md" : "opacity-100 scale-100 blur-0"
)}>
{/* Massive Central Timer */}
<div className="relative group cursor-pointer" onClick={() => playbackState === 'running' ? onPauseRequested?.() : onStartRequested?.()}>
{/* Massive Unstoppable Timer */}
<div className="relative group cursor-default">
<p className={cn(
"text-[8rem] md:text-[14rem] font-light tracking-tighter leading-none transition-colors duration-500",
sessionPhase === 'break' ? "text-emerald-300 drop-shadow-[0_0_40px_rgba(16,185,129,0.3)]" : "text-white drop-shadow-[0_0_40px_rgba(255,255,255,0.15)]",
playbackState === 'paused' && "opacity-60"
sessionPhase === 'break' ? "text-emerald-300 drop-shadow-[0_0_40px_rgba(16,185,129,0.3)]" : "text-white drop-shadow-[0_0_40px_rgba(255,255,255,0.15)]"
)}>
{timeDisplay}
</p>
@@ -206,28 +136,6 @@ export const SpaceFocusHudWidget = ({
</div>
<div className="pointer-events-none fixed inset-0 z-30">
<ReturnPrompt
open={isReturnPromptOpen && Boolean(returnPromptMode)}
mode={returnPromptMode === 'break' ? 'break' : 'focus'}
isBusy={isSavingIntent}
onContinue={() => {
handleDismissReturnPrompt();
}}
onRefocus={() => {
handleDismissReturnPrompt();
}}
onRest={() => {
handleDismissReturnPrompt();
}}
onNextGoal={() => {
handleDismissReturnPrompt();
handleOpenCompleteSheet('next');
}}
onFinish={() => {
handleDismissReturnPrompt();
handleOpenCompleteSheet('choice');
}}
/>
<GoalCompleteSheet
open={isCompleteOpen}
currentGoal={goal}
@@ -236,16 +144,7 @@ export const SpaceFocusHudWidget = ({
onFinish={() => Promise.resolve(onGoalFinish())}
onRest={() => {
setOverlay('none');
onPauseRequested?.();
if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current);
}
restReminderTimerRef.current = window.setTimeout(() => {
onStatusMessage({ message: copy.space.focusHud.restReminder });
restReminderTimerRef.current = null;
}, 5 * 60 * 1000);
// The timer doesn't pause, they just rest within the flow.
}}
onConfirm={(nextGoal) => {
return Promise.resolve(onGoalUpdate(nextGoal));
@@ -253,18 +152,16 @@ export const SpaceFocusHudWidget = ({
/>
</div>
<SpaceTimerHudWidget
hasActiveSession={hasActiveSession}
sessionPhase={sessionPhase}
playbackState={playbackState}
isControlsDisabled={isSessionActionPending}
canStart={canStartSession}
canPause={canPauseSession}
canReset={canRestartSession}
onStartClick={onStartRequested}
onPauseClick={onPauseRequested}
onResetClick={onRestartRequested}
/>
{/* Emergency Tether (Exit) */}
<div className="fixed bottom-8 inset-x-0 z-40 flex justify-center pointer-events-none">
<div className="pointer-events-auto opacity-10 hover:opacity-100 transition-opacity duration-500">
<ExitHoldButton
variant="bar"
onConfirm={onExitRequested}
className="bg-black/20 text-white/70 hover:bg-black/40 hover:text-white border border-white/5 backdrop-blur-md px-6 py-2 rounded-full"
/>
</div>
</div>
</>
);
};