From 2afbe3ce7a37f4bb25e1d128f901ec538f87d205 Mon Sep 17 00:00:00 2001 From: corpi Date: Tue, 17 Mar 2026 14:04:13 +0900 Subject: [PATCH] feat(space): unify end session flow and en-first copy --- src/app/layout.tsx | 2 +- src/shared/i18n/en.ts | 47 +++ src/shared/i18n/index.ts | 4 +- src/shared/i18n/messages/space.en.ts | 208 +++++++++++++ src/shared/i18n/messages/space.ts | 18 +- .../ui/EndSessionConfirmModal.tsx | 277 +++++++++++++++-- .../space-focus-hud/ui/GoalCompleteSheet.tsx | 282 +++--------------- .../ui/SpaceFocusHudWidget.tsx | 98 ++---- .../model/useSpaceWorkspaceSessionControls.ts | 15 + .../ui/SpaceWorkspaceWidget.tsx | 2 + 10 files changed, 609 insertions(+), 344 deletions(-) create mode 100644 src/shared/i18n/en.ts create mode 100644 src/shared/i18n/messages/space.en.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 85a72a7..8da0eb8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,7 +23,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts new file mode 100644 index 0000000..82f8e94 --- /dev/null +++ b/src/shared/i18n/en.ts @@ -0,0 +1,47 @@ +import { ko } from './ko'; +import { spaceEn } from './messages/space.en'; + +export const en = { + ...ko, + metadata: { + title: 'VibeRoom - Calm Session OS for Quiet Focus', + description: + 'A calm focus system for freelancers and creators. Start faster, recover better, and close your work with clearer rhythm.', + }, + common: { + ...ko.common, + close: 'Close', + cancel: 'Cancel', + save: 'Save', + delete: 'Delete', + complete: 'Complete', + select: 'Select', + hub: 'Hub', + loading: 'Loading…', + default: 'Default', + defaultBackground: 'Default background', + admin: 'Admin', + requestFailed: (status: number) => `Request failed: ${status}`, + apiRequestFailed: (status: number) => `API request failed: ${status}`, + }, + modal: { + ...ko.modal, + closeAriaLabel: 'Close modal', + closeButton: 'Close', + }, + focusSession: { + syncFailed: 'Could not sync the session engine.', + startFailed: 'Could not start the session.', + pauseFailed: 'Could not pause the session.', + resumeFailed: 'Could not resume the session.', + restartPhaseFailed: 'Could not restart the current phase.', + intentUpdateFailed: 'Could not save the current session direction.', + completeFailed: 'Could not complete the session.', + abandonFailed: 'Could not end the session.', + }, + soundPlayback: { + loadFailed: 'Could not load the sound file.', + browserDeferred: 'The browser deferred sound playback.', + }, + space: spaceEn, +} as const; diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts index f88fe5a..716ba07 100644 --- a/src/shared/i18n/index.ts +++ b/src/shared/i18n/index.ts @@ -1 +1,3 @@ -export { ko as copy } from './ko'; +export { en as copy } from './en'; +export { en } from './en'; +export { ko } from './ko'; diff --git a/src/shared/i18n/messages/space.en.ts b/src/shared/i18n/messages/space.en.ts new file mode 100644 index 0000000..f1128d8 --- /dev/null +++ b/src/shared/i18n/messages/space.en.ts @@ -0,0 +1,208 @@ +import { space as koSpace } from './space'; + +export const spaceEn = { + ...koSpace, + sessionGoal: { + ...koSpace.sessionGoal, + label: 'One goal for this session', + required: '(Required)', + placeholder: 'e.g. Draft the first page of the contract', + hint: 'Keep it small. Just the next concrete piece.', + }, + setup: { + ...koSpace.setup, + panelAriaLabel: 'Focus entry panel', + eyebrow: 'Execution Setup', + title: 'Set the goal, time, and atmosphere. Then step in.', + description: 'Pick a goal, background, timer, and sound. Then move straight into the focus stage.', + resumeTitle: 'Pick up the last block', + startFresh: 'Start fresh', + resumePrepare: 'Prepare to resume', + sceneLabel: 'Background', + timerLabel: 'Time', + soundLabel: 'Sound', + reviewTeaserEyebrow: 'Weekly Review', + reviewTeaserTitle: 'Take another look at this week?', + reviewTeaserTitlePro: 'Review this week and reopen the rhythm that worked best?', + reviewTeaserHelper: 'You can jump right back in, or pause for a quick weekly review first.', + reviewTeaserHelperPro: 'You can jump right back in, or check this week’s flow and recommended atmosphere first.', + reviewTeaserCta: 'Open weekly review', + reviewTeaserDismiss: 'Later', + readyHint: 'Add a goal to unlock the start flow.', + openFocusScreen: 'Open focus stage', + }, + timerHud: { + ...koSpace.timerHud, + actions: [ + { id: 'start', label: 'Start', icon: '▶' }, + { id: 'pause', label: 'Pause', icon: '⏸' }, + { id: 'reset', label: 'Reset', icon: '↺' }, + ], + readyMode: 'Ready', + focusMode: 'Focus', + breakMode: 'Break', + goalFallback: 'Set the next block to begin.', + goalPrefix: 'Current block · ', + completeButton: 'Complete', + }, + focusHud: { + ...koSpace.focusHud, + goalFallback: 'Start the next block.', + goalToast: (goal: string) => `Current block · ${goal}`, + restReminder: 'Five minutes passed. Come back to the next block.', + intentLabel: 'Current session goal', + microStepLabel: 'Current microstep', + intentExpandAriaLabel: 'Expand goal card', + refocusButton: 'Edit goal', + intentEditLabel: 'Edit', + refocusTitle: 'Reset the direction', + refocusDescription: 'Tighten one line, then go again.', + refocusApply: 'Apply', + refocusApplyAndResume: 'Apply and resume', + refocusApplying: 'Applying…', + refocusSaved: 'The session direction is updated.', + refocusOpenOnPause: 'If you paused, want to realign the next tiny piece before you continue?', + pausePromptEyebrow: 'Paused', + pausePromptTitle: 'You only need one line to restart.', + pausePromptDescription: 'You do not have to explain why you stopped. Just set the next line your hands can land on.', + pausePromptRefocus: 'Reset the next piece', + pausePromptRefocusHint: 'Keep the goal. Rewrite only the next line you will actually start with.', + pausePromptKeep: 'Resume now', + pausePromptKeepHint: 'Keep the current direction and continue from where you stopped.', + pausePromptFinish: 'Finish here', + pausePromptFinishHint: 'Close this block quietly instead of trying to force your way back in.', + returnPromptEyebrow: 'You are back', + returnPromptFocusTitle: 'The flow is still here.', + returnPromptFocusDescription: 'You can continue exactly where you paused, or softly reset the next piece before you return.', + returnPromptBreakTitle: 'A break started while you were away.', + returnPromptBreakDescription: 'Keep the break going, or move gently into the next block.', + returnPromptContinue: 'Resume where I left off', + returnPromptContinueHint: 'Keep the timer and the flow exactly as they are and return to focus.', + returnPromptRest: 'Stay on break', + returnPromptRestHint: 'Keep the break that already started and breathe for a little longer.', + returnPromptNext: 'Next block', + returnPromptNextHint: 'Set the next piece and continue inside the same session flow.', + returnPromptRefocus: 'Reset the next piece', + returnPromptRefocusHint: 'Skip the explanation. Just leave yourself the next line to start with.', + returnPromptFinish: 'Finish here', + returnPromptFinishHint: 'Close this flow here and leave the next entry light.', + microStepCompleteAriaLabel: 'Complete current microstep', + microStepPromptEyebrow: 'Next step', + microStepPromptTitle: 'Is there a clear next step?', + microStepPromptDescription: 'If yes, write one line. If not, keep the goal and continue.', + microStepPromptKeep: 'Keep going with the goal', + microStepPromptKeepHint: 'Clear the next step and continue inside the same goal.', + microStepPromptDefine: 'Write the next step', + microStepPromptDefineHint: 'Capture the smallest next action you can immediately start.', + microStepPromptFinish: 'Finish this goal here', + microStepPromptFinishHint: 'Do not grow the next step. Close this block cleanly here.', + microStepCleared: 'The microstep is cleared. The goal stays.', + completeAction: 'END SESSION', + }, + goalComplete: { + ...koSpace.goalComplete, + placeholderFallback: 'Write the next block', + placeholderExample: (goal: string) => `e.g. ${goal}`, + title: 'How do you want to continue this block?', + description: 'Pick one path and keep the momentum simple.', + timerTitle: 'Time is up. What should happen to this goal?', + timerDescription: 'Finish the session here, or keep the same flow alive for ten more minutes.', + nextTitle: 'Good. Define the next block.', + nextDescription: 'Do not make it bigger. Leave only the next line your hands can start with.', + currentGoalLabel: 'Current block', + nextGoalLabel: 'Next block', + chooseNextButton: 'Next block', + chooseNextDescription: 'Define the next piece and continue inside the same flow.', + backButton: 'Back', + closeAriaLabel: 'Close', + finishButton: 'Finish here', + finishDescription: 'Close this block here and leave the next entry light.', + timerFinishButton: 'Finish session', + timerFinishDescription: 'Record this goal as complete and close the session.', + timerFinishPending: 'Finishing…', + restButton: 'Step away', + restDescription: 'Keep the block alive, pause briefly, and come back later.', + extendButton: '10 more minutes', + extendDescription: 'Keep the current flow and continue for ten more minutes.', + extendPending: 'Adding 10 minutes…', + confirmButton: 'Start next block', + confirmPending: 'Starting…', + finishPending: 'Finishing…', + }, + completionResult: { + ...koSpace.completionResult, + eyebrow: 'SESSION COMPLETE', + title: 'This session is now gently closed.', + description: 'Here is what you focused on, what you finished, and what you parked in your thought capsule.', + focusedLabel: 'Focused time', + focusedValue: (minutes: number) => `${minutes} min`, + goalLabel: 'Completed goal', + thoughtsLabel: 'Thought capsule', + thoughtCount: (count: number) => `${count} items`, + confirmButton: 'Back to lobby', + }, + 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', + }, + quickNotes: { + ...koSpace.quickNotes, + title: 'Park the thought for later', + placeholder: 'Drop the thought here…', + submit: 'Save', + hint: 'You can sort it in the inbox later.', + }, + inbox: { + ...koSpace.inbox, + empty: 'Nothing is parked yet. Thoughts that pop up during focus can be dropped here.', + complete: 'Done', + completed: 'Done', + delete: 'Delete', + readOnly: 'A read-only inbox for later review', + clearAll: 'Clear all', + clearConfirmTitle: 'Clear the inbox?', + clearConfirmDescription: 'If this was a mistake, you can undo it in the toast.', + clearButton: 'Clear', + openInboxAriaLabel: 'Open inbox', + openInboxTitle: 'Inbox', + }, + workspace: { + ...koSpace.workspace, + readyToStart: 'Ready. Press start and step into focus.', + startFailed: 'Could not start the session. Please try again in a moment.', + resumeFailed: 'Could not resume the session.', + abandonFailed: 'Could not close the session.', + pauseFailed: 'Could not pause the session.', + restartFailed: 'Could not restart the current phase.', + restarted: 'The current phase has been restarted.', + intentSyncFailed: 'Could not sync the session direction to the server.', + goalCompleteSyncFailed: 'Could not record the goal-complete finish on the server.', + timerCompleteSyncFailed: 'Could not finish the timer-complete session.', + timerExtendFailed: 'Could not add 10 more minutes.', + timerExtendConflict: 'This session was already extended in another window.', + timerExtended: (minutes: number) => `${minutes} more minutes added.`, + nextGoalStarted: 'The next block started right away.', + selectionPreferenceSaveFailed: 'Could not save the default background/sound selection.', + selectionSessionSyncFailed: 'Could not sync the current session background/sound selection.', + }, +} as const; diff --git a/src/shared/i18n/messages/space.ts b/src/shared/i18n/messages/space.ts index 4499899..0d29dd1 100644 --- a/src/shared/i18n/messages/space.ts +++ b/src/shared/i18n/messages/space.ts @@ -136,12 +136,25 @@ export const space = { endSession: { trigger: 'END SESSION', eyebrow: 'END SESSION', - title: '세션을 여기서 마칠까요?', - description: '지금 종료하면 결과를 요약해서 보여준 뒤 앱 입구로 돌아갑니다.', + title: '이번 목표를 끝냈나요?', + description: '이 블록에 맞는 길을 고르면, 세션을 닫을 때 결과를 요약해서 보여준 뒤 앱 입구로 돌아갑니다.', goalLabel: '현재 목표', + finishedTitle: '좋아요. 이 블록을 어떻게 닫을까요?', + finishedDescription: '다음 블록을 바로 열거나, 이 세션을 여기서 조용히 닫을 수 있어요.', + unfinishedTitle: '괜찮아요. 이 세션을 어떻게 할까요?', + unfinishedDescription: '목표를 다 끝내지 못했어도, 이 세션은 여기서 닫을 수 있어요.', + finishedAnswer: '네, 끝냈어요', + unfinishedAnswer: '아직 아니에요', + nextBlockButton: '다음 블록', + finishHereButton: '여기서 마무리하기', + endHereButton: '세션 종료하기', + backButton: '돌아가기', + nextGoalLabel: '다음 블록', + nextGoalPlaceholder: '예: 여행 예산 정리하기', cancelButton: '계속 집중하기', confirmButton: '종료하기', confirmPending: '종료 중…', + nextGoalConfirmButton: '다음 블록 시작', }, controlCenter: { sectionTitles: { @@ -272,6 +285,7 @@ export const space = { goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.', timerCompleteSyncFailed: '타이머 종료 후 세션 마무리를 반영하지 못했어요.', timerExtendFailed: '10분 추가를 반영하지 못했어요.', + timerExtendConflict: '다른 창에서 이미 시간이 연장됐어요.', timerExtended: (minutes: number) => `${minutes}분을 더 이어갑니다.`, nextGoalStarted: '다음 한 조각을 바로 시작했어요.', selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.', diff --git a/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx b/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx index e32fe38..2a0c19e 100644 --- a/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx +++ b/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx @@ -1,24 +1,132 @@ 'use client'; +import { type FormEvent, useEffect, useRef, useState } from 'react'; import { copy } from '@/shared/i18n'; import { cn } from '@/shared/lib/cn'; +import { HUD_FIELD } from './overlayStyles'; + +type EndSessionStage = 'decision' | 'finished' | 'unfinished' | 'next'; interface EndSessionConfirmModalProps { open: boolean; - goal: string; - isPending?: boolean; + currentGoal: string; onClose: () => void; - onConfirm: () => void | Promise; + onAdvanceGoal: (nextGoal: string) => Promise | boolean; + onFinishHere: () => Promise | boolean; + onEndSession: () => Promise | boolean; } export const EndSessionConfirmModal = ({ open, - goal, - isPending = false, + currentGoal, onClose, - onConfirm, + onAdvanceGoal, + onFinishHere, + onEndSession, }: EndSessionConfirmModalProps) => { - const trimmedGoal = goal.trim(); + const inputRef = useRef(null); + const [stage, setStage] = useState('decision'); + const [draft, setDraft] = useState(''); + const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | 'end' | null>(null); + + const trimmedGoal = currentGoal.trim(); + const trimmedDraft = draft.trim(); + const isSubmitting = submissionMode !== null; + const canConfirmNext = trimmedDraft.length > 0; + + useEffect(() => { + if (!open) { + setStage('decision'); + setDraft(''); + setSubmissionMode(null); + return; + } + + if (stage !== 'next') { + return; + } + + const rafId = window.requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + + return () => { + window.cancelAnimationFrame(rafId); + }; + }, [open, stage]); + + const handleFinishHere = async () => { + if (isSubmitting) { + return; + } + + setSubmissionMode('finish'); + + try { + const didFinish = await onFinishHere(); + + if (didFinish) { + onClose(); + } + } finally { + setSubmissionMode(null); + } + }; + + const handleEndSession = async () => { + if (isSubmitting) { + return; + } + + setSubmissionMode('end'); + + try { + const didEnd = await onEndSession(); + + if (didEnd) { + onClose(); + } + } finally { + setSubmissionMode(null); + } + }; + + const handleAdvanceGoal = async (event: FormEvent) => { + event.preventDefault(); + + if (!canConfirmNext || isSubmitting) { + return; + } + + setSubmissionMode('next'); + + try { + const didAdvance = await onAdvanceGoal(trimmedDraft); + + if (didAdvance) { + onClose(); + } + } finally { + setSubmissionMode(null); + } + }; + + const title = + stage === 'finished' + ? copy.space.endSession.finishedTitle + : stage === 'unfinished' + ? copy.space.endSession.unfinishedTitle + : stage === 'next' + ? copy.space.goalComplete.nextTitle + : copy.space.endSession.title; + const description = + stage === 'finished' + ? copy.space.endSession.finishedDescription + : stage === 'unfinished' + ? copy.space.endSession.unfinishedDescription + : stage === 'next' + ? copy.space.goalComplete.nextDescription + : copy.space.endSession.description; return (
+
@@ -60,10 +177,10 @@ export const EndSessionConfirmModal = ({ id="end-session-confirm-title" className="mx-auto mt-2 max-w-[24rem] text-[1.65rem] font-light leading-[1.16] tracking-[-0.03em] text-white/96 md:text-[1.9rem]" > - {copy.space.endSession.title} + {title} -

- {copy.space.endSession.description} +

+ {description}

@@ -78,24 +195,126 @@ export const EndSessionConfirmModal = ({
) : null} -
- - -
+ {stage === 'decision' ? ( +
+ + +
+ ) : null} + + {stage === 'finished' ? ( +
+ + + +
+ ) : null} + + {stage === 'unfinished' ? ( +
+ + +
+ ) : null} + + {stage === 'next' ? ( +
+
+ + setDraft(event.target.value)} + placeholder={copy.space.endSession.nextGoalPlaceholder} + className={cn(HUD_FIELD, 'mt-2 h-[3.25rem] rounded-[20px] bg-white/[0.05]')} + /> +
+ +
+ + +
+
+ ) : null}
diff --git a/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx b/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx index 659511b..0f8b993 100644 --- a/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx +++ b/src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx @@ -1,118 +1,46 @@ 'use client'; -import type { FormEvent } from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { copy } from '@/shared/i18n'; import { cn } from '@/shared/lib/cn'; -import { HUD_FIELD } from './overlayStyles'; 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; } 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' | 'extend' | null>(null); - const [view, setView] = useState<'choice' | 'next'>('choice'); - const isTimerCompleteMode = mode === 'timer-complete'; + const [submissionMode, setSubmissionMode] = useState<'finish' | 'extend' | null>(null); - useEffect(() => { - if (!open) { - const timeoutId = window.setTimeout(() => { - setDraft(''); - setView(preferredView); - }, 0); - - return () => { - window.clearTimeout(timeoutId); - }; - } - - if (isTimerCompleteMode || view !== 'next') { - return; - } - - const rafId = window.requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - - return () => { - window.cancelAnimationFrame(rafId); - }; - }, [isTimerCompleteMode, open, preferredView, view]); - - useEffect(() => { - if (!open) { - return; - } - - setView(preferredView); - }, [open, preferredView]); - - const placeholder = useMemo(() => { - const trimmed = currentGoal.trim(); - - if (!trimmed) { - return copy.space.goalComplete.placeholderFallback; - } - - return copy.space.goalComplete.placeholderExample(trimmed); - }, [currentGoal]); - - const canConfirm = draft.trim().length > 0; - const isSubmitting = submissionMode !== null; const trimmedCurrentGoal = currentGoal.trim(); - const title = - isTimerCompleteMode - ? copy.space.goalComplete.timerTitle - : view === 'next' - ? copy.space.goalComplete.nextTitle - : copy.space.goalComplete.title; - const description = - isTimerCompleteMode - ? copy.space.goalComplete.timerDescription - : view === 'next' - ? copy.space.goalComplete.nextDescription - : copy.space.goalComplete.description; + const isSubmitting = submissionMode !== null; + const canExtend = Boolean(onExtendTenMinutes); - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - - if (!canConfirm || isSubmitting) { - return; + const goalCard = useMemo(() => { + if (!trimmedCurrentGoal) { + return null; } - setSubmissionMode('next'); - - try { - const didAdvance = await onConfirm(draft.trim()); - - if (didAdvance) { - onClose(); - } - } finally { - setSubmissionMode(null); - } - }; + return ( +
+

+ {copy.space.goalComplete.currentGoalLabel} +

+

+ {trimmedCurrentGoal} +

+
+ ); + }, [trimmedCurrentGoal]); const handleFinish = async () => { if (isSubmitting) { @@ -150,32 +78,6 @@ export const GoalCompleteSheet = ({ } }; - const baseButtonClass = - 'inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40'; - const secondaryButtonClass = - 'border-white/10 bg-white/[0.04] text-white/72 hover:border-white/16 hover:bg-white/[0.07] hover:text-white'; - const primaryButtonClass = - 'border-white/14 bg-white/[0.12] text-white hover:border-white/22 hover:bg-white/[0.17]'; - const breakButtonClass = - 'border-emerald-200/14 bg-[rgba(16,38,31,0.44)] text-emerald-50 hover:border-emerald-200/20 hover:bg-[rgba(16,38,31,0.58)]'; - - const renderGoalCard = () => { - if (!trimmedCurrentGoal) { - return null; - } - - return ( -
-

- {copy.space.goalComplete.currentGoalLabel} -

-

- {trimmedCurrentGoal} -

-
- ); - }; - return (
-
- {isTimerCompleteMode ? null : ( - - )} - +
- {isTimerCompleteMode ? '⌛' : '✦'} +
-

GOAL COMPLETE

+

+ {copy.space.completionResult.eyebrow} +

- {title} + {copy.space.goalComplete.timerTitle}

- {description} + {copy.space.goalComplete.timerDescription}

- {renderGoalCard()} + {goalCard} - {isTimerCompleteMode ? ( -
- - -
- ) : view === 'choice' ? ( -
- - - -
- ) : ( -
-
- - setDraft(event.target.value)} - placeholder={placeholder} - className={cn(HUD_FIELD, 'mt-2 h-[3.25rem] rounded-[20px] bg-white/[0.05]')} - /> -
- -
- - -
-
- )} +
+ + +
diff --git a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx index 8fe6151..305a5bc 100644 --- a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx +++ b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx @@ -49,21 +49,14 @@ export const SpaceFocusHudWidget = ({ onCaptureThought, onExitRequested, }: SpaceFocusHudWidgetProps) => { - const [overlay, setOverlay] = useState< - "none" | "complete" | "timer-complete" - >("none"); - const [completePreferredView, setCompletePreferredView] = useState< - "choice" | "next" - >("choice"); + const [overlay, setOverlay] = useState<"none" | "end-session" | "timer-complete">("none"); const [isSavingIntent, setSavingIntent] = useState(false); - const [isEndSessionConfirmOpen, setEndSessionConfirmOpen] = useState(false); - const [isEndingSession, setEndingSession] = 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" || overlay === "timer-complete"; + const isTimerCompleteOpen = overlay === "timer-complete"; const timerCompletionSignature = hasActiveSession && sessionPhase === "focus" && @@ -76,9 +69,6 @@ export const SpaceFocusHudWidget = ({ if (!hasActiveSession) { setOverlay("none"); setSavingIntent(false); - setCompletePreferredView("choice"); - setEndSessionConfirmOpen(false); - setEndingSession(false); timerPromptSignatureRef.current = null; } }, [hasActiveSession]); @@ -93,6 +83,14 @@ export const SpaceFocusHudWidget = ({ visibleRef.current = true; }, [normalizedGoal, onStatusMessage, playbackState]); + useEffect(() => { + if (overlay !== "timer-complete" || timerCompletionSignature) { + return; + } + + setOverlay("none"); + }, [overlay, timerCompletionSignature]); + useEffect(() => { if (!timerCompletionSignature) { return; @@ -103,16 +101,9 @@ export const SpaceFocusHudWidget = ({ } timerPromptSignatureRef.current = timerCompletionSignature; - setEndSessionConfirmOpen(false); setOverlay("timer-complete"); }, [timerCompletionSignature]); - const handleOpenGoalComplete = () => { - setEndSessionConfirmOpen(false); - setCompletePreferredView("choice"); - setOverlay("complete"); - }; - const handleInlineMicrostepUpdate = async (nextStep: string | null) => { if (isSavingIntent) return false; @@ -132,24 +123,6 @@ export const SpaceFocusHudWidget = ({ } }; - const handleEndSessionConfirm = async () => { - if (isEndingSession) { - return; - } - - setEndingSession(true); - - try { - const didEnd = await onExitRequested(); - - if (didEnd) { - setEndSessionConfirmOpen(false); - } - } finally { - setEndingSession(false); - } - }; - return ( <> - - @@ -224,40 +186,20 @@ export const SpaceFocusHudWidget = ({
setOverlay("none")} - onFinish={() => - overlay === "timer-complete" - ? Promise.resolve(onTimerFinish()) - : Promise.resolve(onGoalCompleteFinish()) - } + onFinish={() => Promise.resolve(onTimerFinish())} onExtendTenMinutes={() => Promise.resolve(onAddTenMinutes())} - onRest={() => { - setOverlay("none"); - // The timer doesn't pause, they just rest within the flow. - }} - onConfirm={(nextGoal) => { - return overlay === "timer-complete" - ? Promise.resolve(false) - : Promise.resolve(onGoalUpdate(nextGoal)); - }} />
{ - if (isEndingSession) { - return; - } - - setEndSessionConfirmOpen(false); - }} - onConfirm={handleEndSessionConfirm} + open={overlay === "end-session"} + currentGoal={goal} + onClose={() => setOverlay("none")} + onAdvanceGoal={(nextGoal) => Promise.resolve(onGoalUpdate(nextGoal))} + onFinishHere={() => Promise.resolve(onGoalCompleteFinish())} + onEndSession={() => Promise.resolve(onExitRequested())} /> ); diff --git a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts index c243fd5..2f50e00 100644 --- a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts +++ b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts @@ -38,6 +38,7 @@ interface UseSpaceWorkspaceSessionControlsParams { resumeSession: () => Promise; restartCurrentPhase: () => Promise; extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise; + syncCurrentSession: () => Promise; updateCurrentIntent: (payload: { goal?: string; microStep?: string | null; @@ -86,6 +87,7 @@ export const useSpaceWorkspaceSessionControls = ({ resumeSession, restartCurrentPhase, extendCurrentPhase, + syncCurrentSession, updateCurrentIntent, completeSession, advanceGoal, @@ -409,6 +411,18 @@ export const useSpaceWorkspaceSessionControls = ({ }); if (!extendedSession) { + const syncedSession = await syncCurrentSession(); + + if ( + syncedSession && + (syncedSession.state === 'running' || syncedSession.phaseRemainingSeconds > 0) + ) { + pushStatusLine({ + message: copy.space.workspace.timerExtendConflict, + }); + return false; + } + pushStatusLine({ message: copy.space.workspace.timerExtendFailed, }); @@ -427,6 +441,7 @@ export const useSpaceWorkspaceSessionControls = ({ resolveSoundPlaybackUrl, selectedPresetId, setPreviewPlaybackState, + syncCurrentSession, unlockPlayback, ]); diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 3d7bc99..6f93318 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -104,6 +104,7 @@ export const SpaceWorkspaceWidget = () => { remainingSeconds, timeDisplay, phase, + syncCurrentSession, startSession, pauseSession, resumeSession, @@ -182,6 +183,7 @@ export const SpaceWorkspaceWidget = () => { resumeSession, restartCurrentPhase, extendCurrentPhase, + syncCurrentSession, updateCurrentIntent, completeSession, advanceGoal,