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 { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; 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 { GoalCompleteSheet } from './GoalCompleteSheet';
import { InlineMicrostep } from './InlineMicrostep'; import { InlineMicrostep } from './InlineMicrostep';
import { ReturnPrompt } from './ReturnPrompt'; import { ThoughtOrb } from './ThoughtOrb';
interface SpaceFocusHudWidgetProps { interface SpaceFocusHudWidgetProps {
goal: string; goal: string;
@@ -14,21 +14,12 @@ interface SpaceFocusHudWidgetProps {
hasActiveSession?: boolean; hasActiveSession?: boolean;
playbackState?: 'running' | 'paused'; playbackState?: 'running' | 'paused';
sessionPhase?: 'focus' | 'break' | null; 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>; onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>; onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onGoalFinish: () => boolean | Promise<boolean>; onGoalFinish: () => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void; onStatusMessage: (payload: HudStatusLinePayload) => void;
onCaptureThought: (note: string) => void;
onExitRequested: () => void;
} }
export const SpaceFocusHudWidget = ({ export const SpaceFocusHudWidget = ({
@@ -38,42 +29,20 @@ export const SpaceFocusHudWidget = ({
hasActiveSession = false, hasActiveSession = false,
playbackState = 'paused', playbackState = 'paused',
sessionPhase = 'focus', sessionPhase = 'focus',
isSessionActionPending = false,
canStartSession = false,
canPauseSession = false,
canRestartSession = false,
entryOverlayIntent = null,
returnPromptMode = null,
onEntryOverlayIntentHandled,
onStartRequested,
onPauseRequested,
onRestartRequested,
onDismissReturnPrompt,
onIntentUpdate, onIntentUpdate,
onGoalUpdate, onGoalUpdate,
onGoalFinish, onGoalFinish,
onStatusMessage, onStatusMessage,
onCaptureThought,
onExitRequested,
}: SpaceFocusHudWidgetProps) => { }: SpaceFocusHudWidgetProps) => {
const [overlay, setOverlay] = useState<'none' | 'return' | 'complete'>('none'); const [overlay, setOverlay] = useState<'none' | 'complete'>('none');
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice'); const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
const [isSavingIntent, setSavingIntent] = useState(false); const [isSavingIntent, setSavingIntent] = useState(false);
const visibleRef = useRef(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 normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const isReturnPromptOpen = overlay === 'return';
const isCompleteOpen = overlay === 'complete'; const isCompleteOpen = overlay === 'complete';
const isIntentOverlayOpen = overlay !== 'none';
useEffect(() => {
return () => {
if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current);
restReminderTimerRef.current = null;
}
};
}, []);
useEffect(() => { useEffect(() => {
if (!hasActiveSession) { if (!hasActiveSession) {
@@ -83,21 +52,6 @@ export const SpaceFocusHudWidget = ({
} }
}, [hasActiveSession]); }, [hasActiveSession]);
useEffect(() => {
if (!returnPromptMode) {
if (overlay === 'return') {
setOverlay('none');
}
return;
}
if (overlay === 'complete') {
return;
}
setOverlay('return');
}, [overlay, returnPromptMode]);
useEffect(() => { useEffect(() => {
if (!visibleRef.current && playbackState === 'running') { if (!visibleRef.current && playbackState === 'running') {
onStatusMessage({ onStatusMessage({
@@ -108,36 +62,11 @@ export const SpaceFocusHudWidget = ({
visibleRef.current = true; visibleRef.current = true;
}, [normalizedGoal, onStatusMessage, playbackState]); }, [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') => { const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setCompletePreferredView(preferredView); setCompletePreferredView(preferredView);
setOverlay('complete'); setOverlay('complete');
}; };
const handleDismissReturnPrompt = () => {
onDismissReturnPrompt?.();
if (overlay === 'return') {
setOverlay('none');
}
};
const handleInlineMicrostepUpdate = async (nextStep: string | null) => { const handleInlineMicrostepUpdate = async (nextStep: string | null) => {
if (isSavingIntent) return false; if (isSavingIntent) return false;
@@ -159,18 +88,19 @@ export const SpaceFocusHudWidget = ({
return ( 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"> <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) */} {/* The Monolith (Central Hub) */}
<div className={cn( <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)]", "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 */} {/* Massive Unstoppable Timer */}
<div className="relative group cursor-pointer" onClick={() => playbackState === 'running' ? onPauseRequested?.() : onStartRequested?.()}> <div className="relative group cursor-default">
<p className={cn( <p className={cn(
"text-[8rem] md:text-[14rem] font-light tracking-tighter leading-none transition-colors duration-500", "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)]", 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"
)}> )}>
{timeDisplay} {timeDisplay}
</p> </p>
@@ -206,28 +136,6 @@ export const SpaceFocusHudWidget = ({
</div> </div>
<div className="pointer-events-none fixed inset-0 z-30"> <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 <GoalCompleteSheet
open={isCompleteOpen} open={isCompleteOpen}
currentGoal={goal} currentGoal={goal}
@@ -236,16 +144,7 @@ export const SpaceFocusHudWidget = ({
onFinish={() => Promise.resolve(onGoalFinish())} onFinish={() => Promise.resolve(onGoalFinish())}
onRest={() => { onRest={() => {
setOverlay('none'); setOverlay('none');
onPauseRequested?.(); // The timer doesn't pause, they just rest within the flow.
if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current);
}
restReminderTimerRef.current = window.setTimeout(() => {
onStatusMessage({ message: copy.space.focusHud.restReminder });
restReminderTimerRef.current = null;
}, 5 * 60 * 1000);
}} }}
onConfirm={(nextGoal) => { onConfirm={(nextGoal) => {
return Promise.resolve(onGoalUpdate(nextGoal)); return Promise.resolve(onGoalUpdate(nextGoal));
@@ -253,18 +152,16 @@ export const SpaceFocusHudWidget = ({
/> />
</div> </div>
<SpaceTimerHudWidget {/* Emergency Tether (Exit) */}
hasActiveSession={hasActiveSession} <div className="fixed bottom-8 inset-x-0 z-40 flex justify-center pointer-events-none">
sessionPhase={sessionPhase} <div className="pointer-events-auto opacity-10 hover:opacity-100 transition-opacity duration-500">
playbackState={playbackState} <ExitHoldButton
isControlsDisabled={isSessionActionPending} variant="bar"
canStart={canStartSession} onConfirm={onExitRequested}
canPause={canPauseSession} 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"
canReset={canRestartSession}
onStartClick={onStartRequested}
onPauseClick={onPauseRequested}
onResetClick={onRestartRequested}
/> />
</div>
</div>
</> </>
); );
}; };

View File

@@ -19,11 +19,9 @@ import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
import { copy } from "@/shared/i18n"; import { copy } from "@/shared/i18n";
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud"; import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer"; import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { SessionEntryPoint, WorkspaceMode } from "../model/types"; import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
import { useAwayReturnRecovery } from "../model/useAwayReturnRecovery";
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection"; import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls"; import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
import { import {
@@ -31,7 +29,6 @@ import {
resolveInitialSceneId, resolveInitialSceneId,
resolveInitialSoundPreset, resolveInitialSoundPreset,
resolveInitialTimerLabel, resolveInitialTimerLabel,
resolveTimerLabelFromPresetId,
TIMER_SELECTION_PRESETS, TIMER_SELECTION_PRESETS,
} from "../model/workspaceSelection"; } from "../model/workspaceSelection";
import { FocusTopToast } from "./FocusTopToast"; import { FocusTopToast } from "./FocusTopToast";
@@ -49,14 +46,7 @@ export const SpaceWorkspaceWidget = () => {
); );
const { const {
thoughts,
thoughtCount,
addThought, addThought,
removeThought,
clearThoughts,
restoreThought,
restoreThoughts,
setThoughtCompleted,
} = useThoughtInbox(); } = useThoughtInbox();
const { const {
@@ -108,14 +98,11 @@ export const SpaceWorkspaceWidget = () => {
selectedPresetId, selectedPresetId,
setSelectedPresetId, setSelectedPresetId,
masterVolume, masterVolume,
setMasterVolume,
isMuted, isMuted,
setMuted,
} = useSoundPresetSelection(initialSoundPresetId); } = useSoundPresetSelection(initialSoundPresetId);
const { const {
currentSession, currentSession,
isBootstrapping, isBootstrapping,
isMutating: isSessionMutating,
timeDisplay, timeDisplay,
phase, phase,
startSession, startSession,
@@ -127,7 +114,6 @@ export const SpaceWorkspaceWidget = () => {
completeSession, completeSession,
advanceGoal, advanceGoal,
abandonSession, abandonSession,
syncCurrentSession,
} = useFocusSessionEngine(); } = useFocusSessionEngine();
const isFocusMode = workspaceMode === "focus"; const isFocusMode = workspaceMode === "focus";
@@ -208,13 +194,7 @@ export const SpaceWorkspaceWidget = () => {
setSelectedGoalId: selection.setSelectedGoalId, setSelectedGoalId: selection.setSelectedGoalId,
setShowResumePrompt: selection.setShowResumePrompt, setShowResumePrompt: selection.setShowResumePrompt,
}); });
const handleStartRequested = controls.handleStartRequested;
const awayReturnRecovery = useAwayReturnRecovery({
currentSession,
isBootstrapping,
syncCurrentSession,
});
const hasEnoughWeeklyData = const hasEnoughWeeklyData =
weeklySummary.last7Days.startedSessions >= 3 && weeklySummary.last7Days.startedSessions >= 3 &&
(weeklySummary.last7Days.completedSessions >= 2 || (weeklySummary.last7Days.completedSessions >= 2 ||
@@ -349,22 +329,6 @@ export const SpaceWorkspaceWidget = () => {
hasActiveSession={Boolean(currentSession)} hasActiveSession={Boolean(currentSession)}
playbackState={resolvedPlaybackState} playbackState={resolvedPlaybackState}
sessionPhase={phase ?? 'focus'} 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} onIntentUpdate={controls.handleIntentUpdate}
onGoalFinish={async () => { onGoalFinish={async () => {
const didFinish = await controls.handleGoalComplete(); const didFinish = await controls.handleGoalComplete();
@@ -376,6 +340,9 @@ export const SpaceWorkspaceWidget = () => {
return didFinish; return didFinish;
}} }}
onGoalUpdate={controls.handleGoalAdvance} onGoalUpdate={controls.handleGoalAdvance}
onStatusMessage={pushStatusLine}
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}
onExitRequested={() => void controls.handleExitRequested()}
/> />
) : null} ) : null}
@@ -385,47 +352,6 @@ export const SpaceWorkspaceWidget = () => {
actionLabel={activeStatus?.action?.label} actionLabel={activeStatus?.action?.label}
onAction={runActiveAction} 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> </div>
); );
}; };