refactor(space): focus hud를 inline 구조로 단순화

This commit is contained in:
2026-03-16 15:17:01 +09:00
parent fb2729193f
commit b91fdbcb67
8 changed files with 254 additions and 913 deletions

View File

@@ -1,12 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
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 { GoalCompleteSheet } from './GoalCompleteSheet';
import { IntentCapsule } from './IntentCapsule';
import { NextMicroStepPrompt } from './NextMicroStepPrompt';
import { PauseRefocusPrompt } from './PauseRefocusPrompt';
import { RefocusSheet } from './RefocusSheet';
import { InlineMicrostep } from './InlineMicrostep';
import { ReturnPrompt } from './ReturnPrompt';
interface SpaceFocusHudWidgetProps {
@@ -56,25 +54,15 @@ export const SpaceFocusHudWidget = ({
onGoalFinish,
onStatusMessage,
}: SpaceFocusHudWidgetProps) => {
const [overlay, setOverlay] = useState<'none' | 'paused' | 'return' | 'refocus' | 'next-beat' | 'complete'>('none');
const [refocusOrigin, setRefocusOrigin] = useState<'manual' | 'pause' | 'next-beat' | 'return'>('manual');
const [overlay, setOverlay] = useState<'none' | 'return' | 'complete'>('none');
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
const [draftGoal, setDraftGoal] = useState('');
const [draftMicroStep, setDraftMicroStep] = useState('');
const [autoFocusField, setAutoFocusField] = useState<'goal' | 'microStep'>('goal');
const [isSavingIntent, setSavingIntent] = useState(false);
const [intentError, setIntentError] = useState<string | null>(null);
const visibleRef = useRef(false);
const resumePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
const pausePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
const suppressNextPausePromptRef = useRef(false);
const restReminderTimerRef = useRef<number | null>(null);
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const normalizedMicroStep = microStep?.trim() ? microStep.trim() : null;
const isPausedPromptOpen = overlay === 'paused';
const isReturnPromptOpen = overlay === 'return';
const isRefocusOpen = overlay === 'refocus';
const isMicroStepPromptOpen = overlay === 'next-beat';
const isCompleteOpen = overlay === 'complete';
const isIntentOverlayOpen = overlay !== 'none';
@@ -90,9 +78,7 @@ export const SpaceFocusHudWidget = ({
useEffect(() => {
if (!hasActiveSession) {
setOverlay('none');
setIntentError(null);
setSavingIntent(false);
setRefocusOrigin('manual');
setCompletePreferredView('choice');
}
}, [hasActiveSession]);
@@ -109,7 +95,6 @@ export const SpaceFocusHudWidget = ({
return;
}
setIntentError(null);
setOverlay('return');
}, [overlay, returnPromptMode]);
@@ -133,172 +118,94 @@ export const SpaceFocusHudWidget = ({
resumePlaybackStateRef.current = playbackState;
}, [normalizedGoal, onStatusMessage, playbackState]);
const openRefocus = useCallback((
field: 'goal' | 'microStep' = 'goal',
origin: 'manual' | 'pause' | 'next-beat' | 'return' = 'manual',
) => {
setDraftGoal(goal.trim());
setDraftMicroStep(normalizedMicroStep ?? '');
setAutoFocusField(field);
setIntentError(null);
setRefocusOrigin(origin);
setOverlay('refocus');
}, [goal, normalizedMicroStep]);
useEffect(() => {
if (
pausePlaybackStateRef.current === 'running' &&
playbackState === 'paused' &&
hasActiveSession &&
overlay === 'none'
) {
if (suppressNextPausePromptRef.current) {
suppressNextPausePromptRef.current = false;
pausePlaybackStateRef.current = playbackState;
return;
}
setIntentError(null);
setOverlay('paused');
onStatusMessage({
message: copy.space.focusHud.refocusOpenOnPause,
});
}
pausePlaybackStateRef.current = playbackState;
}, [hasActiveSession, onStatusMessage, overlay, playbackState]);
useEffect(() => {
if (playbackState === 'running' && overlay === 'paused') {
setOverlay('none');
}
}, [overlay, playbackState]);
useEffect(() => {
if (!normalizedMicroStep && overlay === 'next-beat') {
setOverlay('none');
}
}, [normalizedMicroStep, overlay]);
useEffect(() => {
if (entryOverlayIntent !== 'resume-refocus' || !hasActiveSession || overlay !== 'none') {
return;
}
openRefocus('microStep', 'manual');
// With inline microsteps, we just handle the intent and let the user click if they want.
onEntryOverlayIntentHandled?.();
}, [entryOverlayIntent, hasActiveSession, onEntryOverlayIntentHandled, openRefocus, overlay]);
}, [entryOverlayIntent, hasActiveSession, onEntryOverlayIntentHandled, overlay]);
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setIntentError(null);
setCompletePreferredView(preferredView);
setOverlay('complete');
};
const handleDismissReturnPrompt = () => {
onDismissReturnPrompt?.();
if (overlay === 'return') {
setOverlay('none');
}
};
const handleRefocusSubmit = async () => {
const trimmedGoal = draftGoal.trim();
if (!trimmedGoal || isSavingIntent) {
return;
}
const handleInlineMicrostepUpdate = async (nextStep: string | null) => {
if (isSavingIntent) return false;
setSavingIntent(true);
setIntentError(null);
try {
const didUpdate = await onIntentUpdate({
goal: trimmedGoal,
microStep: draftMicroStep.trim() || null,
});
if (!didUpdate) {
setIntentError(copy.space.workspace.intentSyncFailed);
return;
}
setOverlay('none');
onStatusMessage({
message: copy.space.focusHud.refocusSaved,
});
if (refocusOrigin === 'return') {
onDismissReturnPrompt?.();
}
if (refocusOrigin === 'pause' && playbackState === 'paused') {
onStartRequested?.();
const didUpdate = await onIntentUpdate({ microStep: nextStep });
if (didUpdate) {
if (nextStep) {
onStatusMessage({ message: copy.space.focusHud.refocusSaved });
} else {
onStatusMessage({ message: copy.space.focusHud.microStepCleared });
}
}
return didUpdate;
} finally {
setSavingIntent(false);
}
};
const handleKeepGoalOnly = async () => {
if (isSavingIntent) {
return;
}
setSavingIntent(true);
setIntentError(null);
try {
const didUpdate = await onIntentUpdate({
microStep: null,
});
if (!didUpdate) {
setIntentError(copy.space.workspace.intentSyncFailed);
return;
}
setOverlay('none');
onStatusMessage({
message: copy.space.focusHud.microStepCleared,
});
} finally {
setSavingIntent(false);
}
};
const handleDefineNextMicroStep = () => {
setDraftGoal(goal.trim());
setDraftMicroStep('');
setAutoFocusField('microStep');
setIntentError(null);
setRefocusOrigin('next-beat');
setOverlay('refocus');
};
return (
<>
<div className="pointer-events-none fixed left-6 top-6 z-20 w-[min(29rem,calc(100vw-3rem))] md:left-10 md:top-9">
<IntentCapsule
key={isIntentOverlayOpen ? 'intent-locked' : 'intent-interactive'}
goal={normalizedGoal}
microStep={microStep}
canRefocus={Boolean(hasActiveSession)}
canComplete={hasActiveSession && (sessionPhase === 'focus' || sessionPhase === 'break')}
showActions={!isIntentOverlayOpen}
onOpenRefocus={() => openRefocus('goal', 'manual')}
onMicroStepDone={() => {
if (!normalizedMicroStep) {
openRefocus('microStep', 'next-beat');
return;
}
<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"
)}>
{/* Massive Central Timer */}
<div className="relative group cursor-pointer" onClick={() => playbackState === 'running' ? onPauseRequested?.() : onStartRequested?.()}>
<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"
)}>
{timeDisplay}
</p>
</div>
setIntentError(null);
setOverlay('next-beat');
}}
onGoalCompleteRequest={handleOpenCompleteSheet}
/>
{/* Core Intent */}
<div className="mt-8 flex flex-col items-center group w-full">
{/* Immutable Goal */}
<h2 className="text-2xl md:text-4xl font-light tracking-tight text-white/95">
{normalizedGoal}
</h2>
{/* Kinetic Inline Microstep */}
<div className="mt-8 flex flex-col items-center w-full max-w-lg min-h-[4rem]">
<InlineMicrostep
microStep={microStep ?? null}
isBusy={isSavingIntent}
onUpdate={handleInlineMicrostepUpdate}
/>
</div>
{hasActiveSession && (sessionPhase === 'focus' || sessionPhase === 'break') && (
<button
type="button"
onClick={() => handleOpenCompleteSheet('choice')}
className="mt-8 text-[11px] font-bold uppercase tracking-[0.25em] text-white/30 transition hover:text-white/70"
>
End Session
</button>
)}
</div>
</div>
</div>
<div className="pointer-events-none fixed inset-0 z-30">
<ReturnPrompt
open={isReturnPromptOpen && Boolean(returnPromptMode)}
mode={returnPromptMode === 'break' ? 'break' : 'focus'}
@@ -308,7 +215,6 @@ export const SpaceFocusHudWidget = ({
}}
onRefocus={() => {
handleDismissReturnPrompt();
openRefocus('microStep', 'return');
}}
onRest={() => {
handleDismissReturnPrompt();
@@ -322,59 +228,6 @@ export const SpaceFocusHudWidget = ({
handleOpenCompleteSheet('choice');
}}
/>
<PauseRefocusPrompt
open={isPausedPromptOpen}
isBusy={isSavingIntent}
onRefocus={() => openRefocus('microStep', 'pause')}
onKeepCurrent={() => {
setOverlay('none');
onStartRequested?.();
}}
onFinish={() => {
setOverlay('none');
handleOpenCompleteSheet('choice');
}}
/>
<RefocusSheet
open={isRefocusOpen}
goalDraft={draftGoal}
microStepDraft={draftMicroStep}
autoFocusField={autoFocusField}
submitLabel={
refocusOrigin === 'pause' && playbackState === 'paused'
? copy.space.focusHud.refocusApplyAndResume
: copy.space.focusHud.refocusApply
}
isSaving={isSavingIntent}
error={intentError}
onGoalChange={setDraftGoal}
onMicroStepChange={setDraftMicroStep}
onClose={() => {
if (isSavingIntent) {
return;
}
setIntentError(null);
setOverlay('none');
}}
onSubmit={() => {
void handleRefocusSubmit();
}}
/>
<NextMicroStepPrompt
open={isMicroStepPromptOpen}
goal={normalizedGoal}
isSubmitting={isSavingIntent}
error={intentError}
onKeepGoalOnly={() => {
void handleKeepGoalOnly();
}}
onDefineNext={handleDefineNextMicroStep}
onFinish={() => {
setIntentError(null);
handleOpenCompleteSheet('choice');
}}
/>
<GoalCompleteSheet
open={isCompleteOpen}
currentGoal={goal}
@@ -383,7 +236,6 @@ export const SpaceFocusHudWidget = ({
onFinish={() => Promise.resolve(onGoalFinish())}
onRest={() => {
setOverlay('none');
suppressNextPausePromptRef.current = true;
onPauseRequested?.();
if (restReminderTimerRef.current) {
@@ -400,9 +252,8 @@ export const SpaceFocusHudWidget = ({
}}
/>
</div>
<SpaceTimerHudWidget
timeDisplay={timeDisplay}
isImmersionMode
hasActiveSession={hasActiveSession}
sessionPhase={sessionPhase}
playbackState={playbackState}
@@ -410,7 +261,6 @@ export const SpaceFocusHudWidget = ({
canStart={canStartSession}
canPause={canPauseSession}
canReset={canRestartSession}
className="pr-[4.2rem]"
onStartClick={onStartRequested}
onPauseClick={onPauseRequested}
onResetClick={onRestartRequested}