From ec941f3cde0c952782a07d7ad667621d140104f1 Mon Sep 17 00:00:00 2001 From: corpi Date: Mon, 16 Mar 2026 16:17:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(space):=20timer=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EA=B3=BC=2010=EB=B6=84=20=EC=97=B0=EC=9E=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/90_current_state.md | 5 + docs/session_brief.md | 7 +- .../focus-session/api/focusSessionApi.ts | 13 +++ .../model/useFocusSessionEngine.ts | 13 +++ src/shared/i18n/messages/space.ts | 11 ++ .../space-focus-hud/ui/GoalCompleteSheet.tsx | 109 +++++++++++++++--- .../ui/SpaceFocusHudWidget.tsx | 48 +++++++- .../model/useSpaceWorkspaceSessionControls.ts | 78 +++++++++++++ .../ui/SpaceWorkspaceWidget.tsx | 8 ++ 9 files changed, 271 insertions(+), 21 deletions(-) diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 79661f0..ef90b38 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -72,6 +72,11 @@ Last Updated: 2026-03-16 - `break` phase에서도 expanded intent card 안에 `이번 목표 완료`를 유지하도록 수정 - base card가 잠기는 recovery overlay(`pause / return / next-beat`) 안에서도 low-emphasis `여기서 마무리하기` 경로를 추가 - 이제 active session 상태에서는 `계속 / 다시 잡기 / 마무리` 중 최소 한 경로가 항상 보이도록 정리 +- `/space` timer completion flow 재정의: + - focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다 + - timer가 `00:00`에 도달하면 same-session modal이 자동으로 열리고, `완료하고 종료하기 / 10분 더` 두 경로만 제안한다 + - `10분 더`를 누르면 현재 focus phase에 10분이 추가되어 바로 running으로 돌아가고, 다시 시간이 끝나면 같은 modal이 다시 열린다 + - server와 web 모두 `extend-phase` 계약 기준으로 동작한다 - `/space` intent HUD collapsed / expanded 재설계: - 상시 큰 goal 카드 대신 idle에서는 goal 1줄만 남는 collapsed glass rail 구조로 변경 - hover / focus / rail tap에서만 expanded card로 열리며, 이때만 microStep과 `이번 목표 완료` 액션이 노출됨 diff --git a/docs/session_brief.md b/docs/session_brief.md index 06c7fb8..4238da0 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -81,8 +81,11 @@ Last Updated: 2026-03-16 - `Return(focus)`는 재진입에 맞는 짧은 settle motion으로, - `Return(break)`와 `Goal Complete`는 더 느슨한 release/closure reveal로 분리했다. - `/space` active session에서는 goal closure 경로가 항상 남도록 정리했다. - - `break`에서도 expanded goal card 안에 `이번 목표 완료`가 보인다. - - `pause / return / next-beat`처럼 base card가 잠기는 overlay 안에는 low-emphasis `여기서 마무리하기`가 추가됐다. +- `break`에서도 expanded goal card 안에 `이번 목표 완료`가 보인다. +- `pause / return / next-beat`처럼 base card가 잠기는 overlay 안에는 low-emphasis `여기서 마무리하기`가 추가됐다. +- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다. + - `00:00`이 되면 `완료하고 종료하기 / 10분 더` 모달이 자동으로 열린다. + - `10분 더`는 server `extend-phase` 계약을 타고 현재 focus phase를 10분 연장한 뒤 다시 running으로 이어진다. - 그래서 사용자는 recovery 상태에서도 `계속 / 다시 잡기 / 마무리` 중 하나를 바로 고를 수 있다. - `/space` 목표 카드를 collapsed / expanded 구조로 재설계했다. - idle에서는 goal 1줄만 남는 얇은 glass rail로 줄였다. diff --git a/src/features/focus-session/api/focusSessionApi.ts b/src/features/focus-session/api/focusSessionApi.ts index 638fe5d..40df745 100644 --- a/src/features/focus-session/api/focusSessionApi.ts +++ b/src/features/focus-session/api/focusSessionApi.ts @@ -79,6 +79,10 @@ export interface UpdateCurrentFocusSessionIntentRequest { microStep?: string | null; } +export interface ExtendCurrentPhaseRequest { + additionalMinutes: number; +} + export interface AdvanceCurrentGoalRequest { completedGoal: string; nextGoal: string; @@ -164,6 +168,15 @@ export const focusSessionApi = { return normalizeFocusSession(response); }, + extendCurrentPhase: async (payload: ExtendCurrentPhaseRequest): Promise => { + const response = await apiClient('api/v1/focus-sessions/current/extend-phase', { + method: 'POST', + body: JSON.stringify(payload), + }); + + return normalizeFocusSession(response); + }, + updateCurrentSelection: async ( payload: UpdateCurrentFocusSessionSelectionRequest, ): Promise => { diff --git a/src/features/focus-session/model/useFocusSessionEngine.ts b/src/features/focus-session/model/useFocusSessionEngine.ts index d5029b0..6d0dea7 100644 --- a/src/features/focus-session/model/useFocusSessionEngine.ts +++ b/src/features/focus-session/model/useFocusSessionEngine.ts @@ -73,6 +73,7 @@ interface UseFocusSessionEngineResult { pauseSession: () => Promise; resumeSession: () => Promise; restartCurrentPhase: () => Promise; + extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise; updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise; updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise; completeSession: (payload: CompleteFocusSessionRequest) => Promise; @@ -237,6 +238,18 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => { return applySession(session); }, + extendCurrentPhase: async (payload) => { + if (!currentSession) { + return null; + } + + const session = await runMutation( + () => focusSessionApi.extendCurrentPhase(payload), + copy.focusSession.resumeFailed, + ); + + return applySession(session); + }, updateCurrentIntent: async (payload) => { if (!currentSession) { return null; diff --git a/src/shared/i18n/messages/space.ts b/src/shared/i18n/messages/space.ts index 21f1e22..a389b91 100644 --- a/src/shared/i18n/messages/space.ts +++ b/src/shared/i18n/messages/space.ts @@ -98,6 +98,8 @@ export const space = { placeholderExample: (goal: string) => `예: ${goal}`, title: '이 블록을 어떻게 이어갈까요?', description: '다음으로 이어가기, 잠시 비우기, 여기서 마무리하기 중 하나만 고르면 돼요.', + timerTitle: '시간이 끝났어요. 이 목표를 어떻게 할까요?', + timerDescription: '여기서 완료하고 닫거나, 10분 더 이어서 마무리할 수 있어요.', nextTitle: '좋아요. 다음 한 조각만 정해요.', nextDescription: '너무 크게 잡지 말고, 바로 손을 올릴 한 줄만 남겨요.', currentGoalLabel: '방금 끝낸 블록', @@ -108,8 +110,14 @@ export const space = { closeAriaLabel: '닫기', finishButton: '여기서 마무리하기', finishDescription: '이 블록은 여기서 닫고, 다음 진입은 가볍게 남겨둡니다.', + timerFinishButton: '완료하고 종료하기', + timerFinishDescription: '이 목표는 여기서 끝냈다고 기록하고 세션을 닫습니다.', + timerFinishPending: '종료 중…', restButton: '잠시 비우기', restDescription: '이 블록은 아직 닫지 않고, 잠깐 멈춘 뒤 돌아오라고 알려드려요.', + extendButton: '10분 더', + extendDescription: '지금 흐름을 그대로 두고 10분만 더 이어갑니다.', + extendPending: '10분 추가 중…', confirmButton: '다음 목표로 바로 시작', confirmPending: '시작 중…', finishPending: '마무리 중…', @@ -241,6 +249,9 @@ export const space = { restarted: '현재 페이즈를 처음부터 다시 시작했어요.', intentSyncFailed: '현재 세션 방향을 서버에 반영하지 못했어요.', goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.', + timerCompleteSyncFailed: '타이머 종료 후 세션 마무리를 반영하지 못했어요.', + timerExtendFailed: '10분 추가를 반영하지 못했어요.', + timerExtended: (minutes: number) => `${minutes}분을 더 이어갑니다.`, nextGoalStarted: '다음 한 조각을 바로 시작했어요.', selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.', selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.', diff --git a/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx b/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx index 3c673f3..cf3396a 100644 --- a/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx +++ b/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx @@ -25,8 +25,10 @@ interface GoalCompleteSheetProps { open: boolean; currentGoal: string; preferredView?: 'choice' | 'next'; + mode?: 'manual' | 'timer-complete'; onConfirm: (nextGoal: string) => Promise | boolean; onFinish: () => Promise | boolean; + onExtendTenMinutes?: () => Promise | boolean; onRest: () => void; onClose: () => void; } @@ -35,15 +37,18 @@ export const GoalCompleteSheet = ({ open, currentGoal, preferredView = 'choice', + mode = 'manual', onConfirm, onFinish, + onExtendTenMinutes, onRest, onClose, }: GoalCompleteSheetProps) => { const inputRef = useRef(null); const [draft, setDraft] = useState(''); - const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | null>(null); + const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | 'extend' | null>(null); const [view, setView] = useState<'choice' | 'next'>('choice'); + const isTimerCompleteMode = mode === 'timer-complete'; useEffect(() => { if (!open) { @@ -57,7 +62,7 @@ export const GoalCompleteSheet = ({ }; } - if (view !== 'next') { + if (isTimerCompleteMode || view !== 'next') { return; } @@ -68,7 +73,7 @@ export const GoalCompleteSheet = ({ return () => { window.cancelAnimationFrame(rafId); }; - }, [open, preferredView, view]); + }, [isTimerCompleteMode, open, preferredView, view]); useEffect(() => { if (!open) { @@ -92,11 +97,15 @@ export const GoalCompleteSheet = ({ const isSubmitting = submissionMode !== null; const trimmedCurrentGoal = currentGoal.trim(); const title = - view === 'next' + isTimerCompleteMode + ? copy.space.goalComplete.timerTitle + : view === 'next' ? copy.space.goalComplete.nextTitle : copy.space.goalComplete.title; const description = - view === 'next' + isTimerCompleteMode + ? copy.space.goalComplete.timerDescription + : view === 'next' ? copy.space.goalComplete.nextDescription : copy.space.goalComplete.description; @@ -138,6 +147,24 @@ export const GoalCompleteSheet = ({ } }; + const handleExtend = async () => { + if (isSubmitting || !onExtendTenMinutes) { + return; + } + + setSubmissionMode('extend'); + + try { + const didExtend = await onExtendTenMinutes(); + + if (didExtend) { + onClose(); + } + } finally { + setSubmissionMode(null); + } + }; + return (
{title}

{description}

- + {isTimerCompleteMode ? null : ( + + )} - {view === 'choice' ? ( + {isTimerCompleteMode ? ( +
+ {trimmedCurrentGoal ? ( +
+

+ {copy.space.goalComplete.currentGoalLabel} +

+

{trimmedCurrentGoal}

+
+ ) : null} + +
+ + +
+
+ ) : view === 'choice' ? (
{trimmedCurrentGoal ? (
diff --git a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx index e5f24ed..3eff71c 100644 --- a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx +++ b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx @@ -8,8 +8,11 @@ import { InlineMicrostep } from './InlineMicrostep'; import { ThoughtOrb } from './ThoughtOrb'; interface SpaceFocusHudWidgetProps { + sessionId?: string | null; goal: string; microStep?: string | null; + remainingSeconds?: number | null; + phaseStartedAt?: string | null; timeDisplay?: string; hasActiveSession?: boolean; playbackState?: 'running' | 'paused'; @@ -17,14 +20,19 @@ interface SpaceFocusHudWidgetProps { onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise; onGoalUpdate: (nextGoal: string) => boolean | Promise; onGoalFinish: () => boolean | Promise; + onTimerFinish: () => boolean | Promise; + onAddTenMinutes: () => boolean | Promise; onStatusMessage: (payload: HudStatusLinePayload) => void; onCaptureThought: (note: string) => void; onExitRequested: () => void; } export const SpaceFocusHudWidget = ({ + sessionId = null, goal, microStep, + remainingSeconds = null, + phaseStartedAt = null, timeDisplay, hasActiveSession = false, playbackState = 'paused', @@ -32,23 +40,34 @@ export const SpaceFocusHudWidget = ({ onIntentUpdate, onGoalUpdate, onGoalFinish, + onTimerFinish, + onAddTenMinutes, onStatusMessage, onCaptureThought, onExitRequested, }: SpaceFocusHudWidgetProps) => { - const [overlay, setOverlay] = useState<'none' | 'complete'>('none'); + const [overlay, setOverlay] = useState<'none' | 'complete' | 'timer-complete'>('none'); const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice'); const [isSavingIntent, setSavingIntent] = useState(false); const visibleRef = useRef(false); + const timerPromptSignatureRef = useRef(null); const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback; - const isCompleteOpen = overlay === 'complete'; + const isCompleteOpen = overlay === 'complete' || overlay === 'timer-complete'; + const timerCompletionSignature = + hasActiveSession && + sessionPhase === 'focus' && + remainingSeconds === 0 && + phaseStartedAt + ? `${sessionId ?? 'session'}:${phaseStartedAt}` + : null; useEffect(() => { if (!hasActiveSession) { setOverlay('none'); setSavingIntent(false); setCompletePreferredView('choice'); + timerPromptSignatureRef.current = null; } }, [hasActiveSession]); @@ -62,6 +81,19 @@ export const SpaceFocusHudWidget = ({ visibleRef.current = true; }, [normalizedGoal, onStatusMessage, playbackState]); + useEffect(() => { + if (!timerCompletionSignature) { + return; + } + + if (timerPromptSignatureRef.current === timerCompletionSignature) { + return; + } + + timerPromptSignatureRef.current = timerCompletionSignature; + setOverlay('timer-complete'); + }, [timerCompletionSignature]); + const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => { setCompletePreferredView(preferredView); setOverlay('complete'); @@ -139,15 +171,23 @@ export const SpaceFocusHudWidget = ({ setOverlay('none')} - onFinish={() => Promise.resolve(onGoalFinish())} + onFinish={() => + overlay === 'timer-complete' + ? Promise.resolve(onTimerFinish()) + : Promise.resolve(onGoalFinish()) + } + onExtendTenMinutes={() => Promise.resolve(onAddTenMinutes())} onRest={() => { setOverlay('none'); // The timer doesn't pause, they just rest within the flow. }} onConfirm={(nextGoal) => { - return Promise.resolve(onGoalUpdate(nextGoal)); + return overlay === 'timer-complete' + ? Promise.resolve(false) + : Promise.resolve(onGoalUpdate(nextGoal)); }} />
diff --git a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts index bec6e02..aa4316f 100644 --- a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts +++ b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts @@ -36,6 +36,7 @@ interface UseSpaceWorkspaceSessionControlsParams { pauseSession: () => Promise; resumeSession: () => Promise; restartCurrentPhase: () => Promise; + extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise; updateCurrentIntent: (payload: { goal?: string; microStep?: string | null; @@ -83,6 +84,7 @@ export const useSpaceWorkspaceSessionControls = ({ pauseSession, resumeSession, restartCurrentPhase, + extendCurrentPhase, updateCurrentIntent, completeSession, advanceGoal, @@ -342,6 +344,80 @@ export const useSpaceWorkspaceSessionControls = ({ setWorkspaceMode, ]); + const handleTimerComplete = useCallback(async () => { + const trimmedCurrentGoal = goalInput.trim(); + + if (!currentSession) { + return false; + } + + const completedSession = await completeSession({ + completionType: 'timer-complete', + completedGoal: trimmedCurrentGoal || undefined, + }); + + if (!completedSession) { + pushStatusLine({ + message: copy.space.workspace.timerCompleteSyncFailed, + }); + return false; + } + + setGoalInput(''); + setLinkedFocusPlanItemId(null); + setSelectedGoalId(null); + setShowResumePrompt(false); + setPendingSessionEntryPoint('space-setup'); + setPreviewPlaybackState('paused'); + setWorkspaceMode('setup'); + return true; + }, [ + completeSession, + currentSession, + goalInput, + pushStatusLine, + setGoalInput, + setLinkedFocusPlanItemId, + setPendingSessionEntryPoint, + setPreviewPlaybackState, + setSelectedGoalId, + setShowResumePrompt, + setWorkspaceMode, + ]); + + const handleExtendCurrentPhase = useCallback(async (additionalMinutes: number) => { + if (!currentSession) { + return false; + } + + await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId)); + + const extendedSession = await extendCurrentPhase({ + additionalMinutes, + }); + + if (!extendedSession) { + pushStatusLine({ + message: copy.space.workspace.timerExtendFailed, + }); + return false; + } + + setPreviewPlaybackState('running'); + pushStatusLine({ + message: copy.space.workspace.timerExtended(additionalMinutes), + }); + return true; + }, [ + currentSession, + extendCurrentPhase, + pushStatusLine, + resolveSoundPlaybackUrl, + selectedPresetId, + setPreviewPlaybackState, + unlockPlayback, + ]); + const handleIntentUpdate = useCallback(async (input: { goal?: string; microStep?: string | null; @@ -456,6 +532,8 @@ export const useSpaceWorkspaceSessionControls = ({ handleRestartRequested, handleIntentUpdate, handleGoalComplete, + handleTimerComplete, + handleExtendCurrentPhase, handleGoalAdvance, }; }; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 05d2467..92ee214 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -103,12 +103,14 @@ export const SpaceWorkspaceWidget = () => { const { currentSession, isBootstrapping, + remainingSeconds, timeDisplay, phase, startSession, pauseSession, resumeSession, restartCurrentPhase, + extendCurrentPhase, updateCurrentIntent, updateCurrentSelection, completeSession, @@ -185,6 +187,7 @@ export const SpaceWorkspaceWidget = () => { pauseSession, resumeSession, restartCurrentPhase, + extendCurrentPhase, updateCurrentIntent, completeSession, advanceGoal, @@ -323,8 +326,11 @@ export const SpaceWorkspaceWidget = () => { {isFocusMode ? ( { return didFinish; }} + onTimerFinish={controls.handleTimerComplete} + onAddTenMinutes={() => controls.handleExtendCurrentPhase(10)} onGoalUpdate={controls.handleGoalAdvance} onStatusMessage={pushStatusLine} onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}