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>
</>
);
};

View File

@@ -19,11 +19,9 @@ import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
import { copy } from "@/shared/i18n";
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
import { useAwayReturnRecovery } from "../model/useAwayReturnRecovery";
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
import {
@@ -31,7 +29,6 @@ import {
resolveInitialSceneId,
resolveInitialSoundPreset,
resolveInitialTimerLabel,
resolveTimerLabelFromPresetId,
TIMER_SELECTION_PRESETS,
} from "../model/workspaceSelection";
import { FocusTopToast } from "./FocusTopToast";
@@ -49,14 +46,7 @@ export const SpaceWorkspaceWidget = () => {
);
const {
thoughts,
thoughtCount,
addThought,
removeThought,
clearThoughts,
restoreThought,
restoreThoughts,
setThoughtCompleted,
} = useThoughtInbox();
const {
@@ -108,14 +98,11 @@ export const SpaceWorkspaceWidget = () => {
selectedPresetId,
setSelectedPresetId,
masterVolume,
setMasterVolume,
isMuted,
setMuted,
} = useSoundPresetSelection(initialSoundPresetId);
const {
currentSession,
isBootstrapping,
isMutating: isSessionMutating,
timeDisplay,
phase,
startSession,
@@ -127,7 +114,6 @@ export const SpaceWorkspaceWidget = () => {
completeSession,
advanceGoal,
abandonSession,
syncCurrentSession,
} = useFocusSessionEngine();
const isFocusMode = workspaceMode === "focus";
@@ -208,13 +194,7 @@ export const SpaceWorkspaceWidget = () => {
setSelectedGoalId: selection.setSelectedGoalId,
setShowResumePrompt: selection.setShowResumePrompt,
});
const handleStartRequested = controls.handleStartRequested;
const awayReturnRecovery = useAwayReturnRecovery({
currentSession,
isBootstrapping,
syncCurrentSession,
});
const hasEnoughWeeklyData =
weeklySummary.last7Days.startedSessions >= 3 &&
(weeklySummary.last7Days.completedSessions >= 2 ||
@@ -349,22 +329,6 @@ export const SpaceWorkspaceWidget = () => {
hasActiveSession={Boolean(currentSession)}
playbackState={resolvedPlaybackState}
sessionPhase={phase ?? 'focus'}
isSessionActionPending={isSessionMutating}
canStartSession={controls.canStartSession}
canPauseSession={controls.canPauseSession}
canRestartSession={controls.canRestartSession}
returnPromptMode={awayReturnRecovery.returnPromptMode}
onStartRequested={() => {
void handleStartRequested();
}}
onPauseRequested={() => {
void controls.handlePauseRequested();
}}
onRestartRequested={() => {
void controls.handleRestartRequested();
}}
onDismissReturnPrompt={awayReturnRecovery.dismissReturnPrompt}
onStatusMessage={pushStatusLine}
onIntentUpdate={controls.handleIntentUpdate}
onGoalFinish={async () => {
const didFinish = await controls.handleGoalComplete();
@@ -376,6 +340,9 @@ export const SpaceWorkspaceWidget = () => {
return didFinish;
}}
onGoalUpdate={controls.handleGoalAdvance}
onStatusMessage={pushStatusLine}
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}
onExitRequested={() => void controls.handleExitRequested()}
/>
) : null}
@@ -385,47 +352,6 @@ export const SpaceWorkspaceWidget = () => {
actionLabel={activeStatus?.action?.label}
onAction={runActiveAction}
/>
<SpaceToolsDockWidget
isFocusMode={isFocusMode}
scenes={selection.setupScenes}
sceneAssetMap={sceneAssetMap}
selectedSceneId={selection.selectedScene.id}
selectedTimerLabel={selection.selectedTimerLabel}
timerPresets={TIMER_SELECTION_PRESETS}
thoughts={thoughts}
thoughtCount={thoughtCount}
selectedPresetId={selectedPresetId}
onSceneSelect={selection.handleSelectScene}
onTimerSelect={(timerLabel) =>
selection.handleSelectTimer(timerLabel, true)
}
onQuickSoundSelect={(presetId) =>
selection.handleSelectSound(presetId, true)
}
sceneRecommendedSoundLabel={selection.selectedScene.recommendedSound}
sceneRecommendedTimerLabel={
resolveTimerLabelFromPresetId(
selection.selectedScene.recommendedTimerPresetId,
) ?? selection.selectedTimerLabel
}
soundVolume={masterVolume}
onSetSoundVolume={setMasterVolume}
isSoundMuted={isMuted}
onSetSoundMuted={setMuted}
onCaptureThought={(note) =>
addThought(note, selection.selectedScene.name)
}
onDeleteThought={removeThought}
onSetThoughtCompleted={setThoughtCompleted}
onRestoreThought={restoreThought}
onRestoreThoughts={restoreThoughts}
onClearInbox={clearThoughts}
onStatusMessage={pushStatusLine}
onExitRequested={() => {
void controls.handleExitRequested();
}}
/>
</div>
);
};