diff --git a/src/features/focus-session/api/focusSessionApi.ts b/src/features/focus-session/api/focusSessionApi.ts index 8b209fa..63071b4 100644 --- a/src/features/focus-session/api/focusSessionApi.ts +++ b/src/features/focus-session/api/focusSessionApi.ts @@ -42,7 +42,7 @@ interface RawCurrentSessionThought { interface RawCompletionResult { completedSessionId: string; completionSource: 'timer-complete' | 'manual-end' | 'goal-complete'; - completedGoal: string; + goalText: string; focusedSeconds: number; thoughts: RawCurrentSessionThought[]; } @@ -79,7 +79,7 @@ export interface CurrentSessionThought { export interface CompletionResult { completedSessionId: string; completionSource: 'timer-complete' | 'manual-end' | 'goal-complete'; - completedGoal: string; + goalText: string; focusedSeconds: number; thoughts: CurrentSessionThought[]; } diff --git a/src/shared/i18n/messages/space.en.ts b/src/shared/i18n/messages/space.en.ts index 2cddad3..1d0aebb 100644 --- a/src/shared/i18n/messages/space.en.ts +++ b/src/shared/i18n/messages/space.en.ts @@ -122,26 +122,47 @@ export const spaceEn = { endSession: { ...koSpace.endSession, trigger: 'END SESSION', - eyebrow: 'END SESSION', - title: 'Did you finish this goal?', - description: 'Choose the path that matches this block. If you close the session, we will show the result summary before you head back.', - goalLabel: 'Current goal', - cancelButton: 'Keep focusing', - confirmButton: 'End session', - confirmPending: 'Ending…', - finishedTitle: 'Nice. How do you want to close it?', - finishedDescription: 'You can open the next block right away, or close this session here.', - unfinishedTitle: 'Okay. What do you want to do with this session?', - unfinishedDescription: 'If the goal is not done yet, you can still close the session here.', - finishedAnswer: 'Yes, I finished it', - unfinishedAnswer: 'No, not yet', - nextBlockButton: 'Next block', - finishHereButton: 'Finish here', - endHereButton: 'End session', - backButton: 'Back', - nextGoalLabel: 'Next block', - nextGoalPlaceholder: 'e.g. Refine the travel budget', - nextGoalConfirmButton: 'Start next block', + decision: { + ...koSpace.endSession.decision, + eyebrow: 'END SESSION', + title: 'Did you finish this goal?', + description: 'Decide how you want to close this block first.', + goalLabel: 'Current goal', + finishedAnswer: 'Yes, I finished it', + unfinishedAnswer: 'No, not yet', + cancelButton: 'Keep focusing', + }, + unfinishedConfirm: { + ...koSpace.endSession.unfinishedConfirm, + eyebrow: 'SAVE PROGRESS', + title: 'Save this session and leave?', + description: 'The goal is not finished yet, but your focused time and thought capsule will stay here for later.', + keepFocusingButton: 'Keep focusing', + saveAndReturnButton: 'Save and return', + saveAndReturnPending: 'Saving…', + }, + resultSuccess: { + ...koSpace.endSession.resultSuccess, + eyebrow: 'SESSION CLOSED', + title: 'You finished the block.', + description: 'Your focused time, completed goal, and thought capsule are all captured here.', + focusedLabel: 'Focused time', + goalLabel: 'Completed goal', + thoughtsLabel: 'Thought capsule', + backToLobby: 'Back to lobby', + }, + resultSaved: { + ...koSpace.endSession.resultSaved, + eyebrow: 'SESSION SAVED', + title: 'This session is saved.', + description: 'The goal is not finished yet, but your time and notes are kept for later.', + focusedLabel: 'Focused time', + goalStatusLabel: 'Goal status', + goalStatusValue: 'Not finished yet', + goalLabel: 'Current goal', + thoughtsLabel: 'Thought capsule', + backToLobby: 'Back to lobby', + }, }, quickNotes: { ...koSpace.quickNotes, diff --git a/src/shared/i18n/messages/space.ts b/src/shared/i18n/messages/space.ts index ffdb5da..99244b1 100644 --- a/src/shared/i18n/messages/space.ts +++ b/src/shared/i18n/messages/space.ts @@ -114,26 +114,43 @@ export const space = { }, endSession: { trigger: 'END SESSION', - eyebrow: 'END SESSION', - title: '이번 목표를 끝냈나요?', - description: '이 블록에 맞는 길을 고르면, 세션을 닫을 때 결과를 요약해서 보여준 뒤 앱 입구로 돌아갑니다.', - goalLabel: '현재 목표', - finishedTitle: '좋아요. 이 블록을 어떻게 닫을까요?', - finishedDescription: '다음 블록을 바로 열거나, 이 세션을 여기서 조용히 닫을 수 있어요.', - unfinishedTitle: '괜찮아요. 이 세션을 어떻게 할까요?', - unfinishedDescription: '목표를 다 끝내지 못했어도, 이 세션은 여기서 닫을 수 있어요.', - finishedAnswer: '네, 끝냈어요', - unfinishedAnswer: '아직 아니에요', - nextBlockButton: '다음 블록', - finishHereButton: '여기서 마무리하기', - endHereButton: '세션 종료하기', - backButton: '돌아가기', - nextGoalLabel: '다음 블록', - nextGoalPlaceholder: '예: 여행 예산 정리하기', - cancelButton: '계속 집중하기', - confirmButton: '종료하기', - confirmPending: '종료 중…', - nextGoalConfirmButton: '다음 블록 시작', + decision: { + eyebrow: 'END SESSION', + title: '이번 목표를 끝냈나요?', + description: '이 블록을 어떻게 닫을지 먼저 정해요.', + goalLabel: '현재 목표', + finishedAnswer: '네, 끝냈어요', + unfinishedAnswer: '아직 아니에요', + cancelButton: '계속 집중하기', + }, + unfinishedConfirm: { + eyebrow: 'SAVE PROGRESS', + title: '이 세션을 저장하고 나갈까요?', + description: '목표는 아직 끝나지 않았지만, 이번 집중 시간과 생각 캡슐은 그대로 남겨둘 수 있어요.', + keepFocusingButton: '계속 집중하기', + saveAndReturnButton: '저장하고 로비로 돌아가기', + saveAndReturnPending: '저장 중…', + }, + resultSuccess: { + eyebrow: 'SESSION CLOSED', + title: '이 블록을 끝냈어요.', + description: '이번 세션의 집중 시간, 완료한 목표, 생각 캡슐을 정리해뒀어요.', + focusedLabel: '집중한 시간', + goalLabel: '완료한 목표', + thoughtsLabel: '이번 세션 생각 캡슐', + backToLobby: '로비로 돌아가기', + }, + resultSaved: { + eyebrow: 'SESSION SAVED', + title: '이 세션은 저장됐어요.', + description: '목표는 아직 끝나지 않았지만, 이번 집중 시간과 생각 캡슐은 다음을 위해 남겨뒀어요.', + focusedLabel: '집중한 시간', + goalStatusLabel: '목표 상태', + goalStatusValue: '아직 끝나지 않았어요', + goalLabel: '현재 목표', + thoughtsLabel: '이번 세션 생각 캡슐', + backToLobby: '로비로 돌아가기', + }, }, controlCenter: { sectionTitles: { diff --git a/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx b/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx index 96123e3..628b0df 100644 --- a/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx +++ b/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx @@ -1,56 +1,61 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { cn } from '@/shared/lib/cn'; import type { CompletionResult } from '@/features/focus-session'; +import { copy } from '@/shared/i18n'; +import { cn } from '@/shared/lib/cn'; +import { useEffect, useState } from 'react'; -type EndSessionStage = 'decision' | 'success' | 'unfinished'; +type EndSessionStage = 'decision' | 'unfinished-confirm'; interface EndSessionConfirmModalProps { open: boolean; currentGoal: string; + completionResult?: CompletionResult | null; onClose: () => void; - onAdvanceGoal: (nextGoal: string) => Promise | boolean; // kept for compatibility if needed - onFinishHere: () => Promise | CompletionResult | null; // User achieved goal -> returns result - onEndSession: () => Promise | boolean; // User did not achieve -> exit + onFinishHere: () => Promise | CompletionResult | null; + onSaveAndReturn: () => Promise | CompletionResult | null; + onBackToLobby: () => void; } const formatFocusedMinutes = (focusedSeconds: number) => { const safeSeconds = Math.max(0, focusedSeconds); - return Math.round(safeSeconds / 60); + return Math.max(1, Math.round(safeSeconds / 60)); }; export const EndSessionConfirmModal = ({ open, currentGoal, + completionResult = null, onClose, onFinishHere, - onEndSession, + onSaveAndReturn, + onBackToLobby, }: EndSessionConfirmModalProps) => { const [stage, setStage] = useState('decision'); const [isSubmitting, setIsSubmitting] = useState(false); - const [result, setResult] = useState(null); - const resetTimerRef = useRef(null); - const trimmedGoal = currentGoal.trim() || '목표 없음'; + const endSessionCopy = copy.space.endSession; + const trimmedGoal = currentGoal.trim() || copy.space.focusHud.goalFallback; + const activeStage = completionResult + ? completionResult.completionSource === 'manual-end' + ? 'result-saved' + : 'result-success' + : stage; + const focusedMinutes = completionResult + ? formatFocusedMinutes(completionResult.focusedSeconds) + : 0; + const hasThoughts = Boolean(completionResult && completionResult.thoughts.length > 0); useEffect(() => { if (!open) { - resetTimerRef.current = window.setTimeout(() => { - setStage('decision'); - setResult(null); - }, 500); + setStage('decision'); setIsSubmitting(false); return; } - if (resetTimerRef.current !== null) { - window.clearTimeout(resetTimerRef.current); - resetTimerRef.current = null; - } - - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !isSubmitting) { + const allowEscape = !isSubmitting && !completionResult; + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && allowEscape) { onClose(); } }; @@ -59,59 +64,46 @@ export const EndSessionConfirmModal = ({ return () => { window.removeEventListener('keydown', handleEscape); }; - }, [open, isSubmitting, onClose]); + }, [completionResult, isSubmitting, onClose, open]); - useEffect(() => { - return () => { - if (resetTimerRef.current !== null) { - window.clearTimeout(resetTimerRef.current); - } - }; - }, []); + const handleFinishHere = async () => { + if (isSubmitting) { + return; + } - const handleFinish = async () => { - if (isSubmitting) return; setIsSubmitting(true); try { - const completionResult = await onFinishHere(); - if (completionResult) { - setResult(completionResult); - setStage('success'); // Transition to the grand finale instead of closing - } + await onFinishHere(); } finally { setIsSubmitting(false); } }; - const handleEnd = async () => { - if (isSubmitting) return; + const handleSaveAndReturn = async () => { + if (isSubmitting) { + return; + } + setIsSubmitting(true); try { - const didEnd = await onEndSession(); - if (didEnd) onClose(); + await onSaveAndReturn(); } finally { setIsSubmitting(false); } }; - const hasThoughts = result && result.thoughts.length > 0; - const focusedMinutes = result ? formatFocusedMinutes(result.focusedSeconds) : 0; - return (
@@ -121,108 +113,162 @@ export const EndSessionConfirmModal = ({ open ? 'pointer-events-auto translate-y-0 scale-100' : 'pointer-events-none translate-y-8 scale-95', )} > - {stage === 'decision' && ( -
-

- Session Review + {!completionResult ? ( + + ) : null} + + {activeStage === 'decision' ? ( +

+

+ {endSessionCopy.decision.eyebrow}

-

- 이번 세션의 목표를
달성하셨나요? +

+ {endSessionCopy.decision.title}

-
-

- 현재 목표 -

-

- {trimmedGoal} +

+

+ {endSessionCopy.decision.goalLabel}

+

{trimmedGoal}

-
+
- - {/* The Subdued Cancel Action */} +
- )} + ) : null} - {stage === 'success' && result && ( -
- {/* Glowing Success Icon */} -
- 🏆 + {activeStage === 'unfinished-confirm' ? ( +
+
+

+ {endSessionCopy.unfinishedConfirm.eyebrow} +

+

+ {endSessionCopy.unfinishedConfirm.title} +

+

+ {endSessionCopy.unfinishedConfirm.description} +

+ +
+

+ {endSessionCopy.decision.goalLabel} +

+

{trimmedGoal}

-

- Session Closed +

+ + +
+
+ ) : null} + + {activeStage === 'result-success' && completionResult ? ( +
+

+ {endSessionCopy.resultSuccess.eyebrow}

- -

- 완벽합니다. +

+ {endSessionCopy.resultSuccess.title}

- -

- 성공적으로 목표를 완수했습니다.
스스로에게 보상을 줄 시간입니다. +

+ {endSessionCopy.resultSuccess.description}

-
- {/* Massive Time Stat */} -
-

- Total Focus Time +

+
+

+ {endSessionCopy.resultSuccess.focusedLabel}

-

- {focusedMinutes}m +

+ {focusedMinutes} + m

- {/* Goal Card */}
-

- Completed Goal +

+ {endSessionCopy.resultSuccess.goalLabel}

- {result.completedGoal} + {completionResult.goalText}

- {/* Thoughts Dumped */} {hasThoughts ? (
-
+

- Distractions Dumped + {endSessionCopy.resultSuccess.thoughtsLabel}

- {result.thoughts.length} + {completionResult.thoughts.length}
- {result.thoughts.map((thought) => ( + {completionResult.thoughts.map((thought) => (
- 로비로 돌아가기 + {endSessionCopy.resultSuccess.backToLobby}
- )} + ) : null} - {stage === 'unfinished' && ( -
-
- 🌱 -
-

- 괜찮습니다.
집중은 근육이니까요. -

-

- 조금씩 단련해나가면 됩니다.
이 흐름을 다음 세션에 이어서 해볼까요? + {activeStage === 'result-saved' && completionResult ? ( +

+
+

+ {endSessionCopy.resultSaved.eyebrow}

+

+ {endSessionCopy.resultSaved.title} +

+

+ {endSessionCopy.resultSaved.description} +

+ +
+
+

+ {endSessionCopy.resultSaved.focusedLabel} +

+

+ {focusedMinutes} + m +

+
+ +
+
+

+ {endSessionCopy.resultSaved.goalStatusLabel} +

+

+ {endSessionCopy.resultSaved.goalStatusValue} +

+
+ +
+

+ {endSessionCopy.resultSaved.goalLabel} +

+

+ {completionResult.goalText} +

+
+
+ + {hasThoughts ? ( +
+
+

+ {endSessionCopy.resultSaved.thoughtsLabel} +

+ + {completionResult.thoughts.length} + +
+
+ {completionResult.thoughts.map((thought) => ( +
+

+ {thought.text} +

+
+ ))} +
+
+ ) : null} +
+ -
- )} + ) : null}
); diff --git a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx index ee19b8b..201bbbf 100644 --- a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx +++ b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx @@ -23,13 +23,13 @@ interface SpaceFocusHudWidgetProps { goal?: string; microStep?: string | null; }) => boolean | Promise; - onGoalUpdate: (nextGoal: string) => boolean | Promise; onGoalCompleteFinish: () => Promise | CompletionResult | null; onTimerFinish: () => Promise | CompletionResult | null; + onSaveAndReturn: () => Promise | CompletionResult | null; onAddTenMinutes: () => boolean | Promise; onStatusMessage: (payload: HudStatusLinePayload) => void; onCaptureThought: (note: string) => void; - onExitRequested: () => boolean | Promise; + onBackToLobby: () => void; } export const SpaceFocusHudWidget = ({ @@ -44,13 +44,13 @@ export const SpaceFocusHudWidget = ({ sessionPhase = "focus", completionResult = null, onIntentUpdate, - onGoalUpdate, onGoalCompleteFinish, onTimerFinish, + onSaveAndReturn, onAddTenMinutes, onStatusMessage, onCaptureThought, - onExitRequested, + onBackToLobby, }: SpaceFocusHudWidgetProps) => { const [overlay, setOverlay] = useState<"none" | "end-session" | "timer-complete">("none"); const [isSavingIntent, setSavingIntent] = useState(false); @@ -207,16 +207,13 @@ export const SpaceFocusHudWidget = ({ { - if (completionResult) { - void onExitRequested(); - } else { - setOverlay("none"); - } + setOverlay("none"); }} - onAdvanceGoal={(nextGoal) => Promise.resolve(onGoalUpdate(nextGoal))} onFinishHere={() => Promise.resolve(onGoalCompleteFinish())} - onEndSession={() => Promise.resolve(onExitRequested())} + onSaveAndReturn={() => Promise.resolve(onSaveAndReturn())} + onBackToLobby={onBackToLobby} /> ); diff --git a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts index 88a2010..ec28f8e 100644 --- a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts +++ b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts @@ -323,15 +323,12 @@ export const useSpaceWorkspaceSessionControls = ({ ]); const handleManualEnd = useCallback(async () => { - const trimmedCurrentGoal = goalInput.trim(); - if (!currentSession) { return null; } const completionResult = await completeSession({ completionType: 'manual-end', - completedGoal: trimmedCurrentGoal || undefined, }); if (!completionResult) { @@ -348,7 +345,6 @@ export const useSpaceWorkspaceSessionControls = ({ }, [ completeSession, currentSession, - goalInput, pushStatusLine, setPendingSessionEntryPoint, setPreviewPlaybackState, diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 331d9fe..52ee5d4 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -298,8 +298,17 @@ export const SpaceWorkspaceWidget = () => { } return completionResult; }} + onSaveAndReturn={async () => { + const completionResult = await controls.handleManualEnd(); + + if (completionResult) { + setPendingCompletionResult(completionResult); + setCurrentSessionThoughts([]); + } + + return completionResult; + }} onAddTenMinutes={() => controls.handleExtendCurrentPhase(10)} - onGoalUpdate={controls.handleGoalAdvance} onStatusMessage={pushStatusLine} onCaptureThought={(note) => { void addThought(note, selection.selectedScene.name).then((savedThought) => { @@ -322,25 +331,10 @@ export const SpaceWorkspaceWidget = () => { }); }); }} - onExitRequested={async () => { - if (pendingCompletionResult) { - setPendingCompletionResult(null); - setCurrentSessionThoughts([]); - void router.replace('/app'); - return true; - } - - const completionResult = await controls.handleManualEnd(); - if (completionResult) { - setCurrentSessionThoughts([]); - void router.replace('/app'); - return true; - } else { - if (!currentSession) { - void router.replace('/app'); - } - } - return false; + onBackToLobby={() => { + setPendingCompletionResult(null); + setCurrentSessionThoughts([]); + void router.replace('/app'); }} /> ) : null}