feat(space): unify end session flow and en-first copy

This commit is contained in:
2026-03-17 14:04:13 +09:00
parent 5026138ad9
commit 2afbe3ce7a
10 changed files with 609 additions and 344 deletions

View File

@@ -23,7 +23,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="ko" className={notoSans.variable}> <html lang="en" className={notoSans.variable}>
<body className="antialiased font-sans"> <body className="antialiased font-sans">
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>

47
src/shared/i18n/en.ts Normal file
View File

@@ -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;

View File

@@ -1 +1,3 @@
export { ko as copy } from './ko'; export { en as copy } from './en';
export { en } from './en';
export { ko } from './ko';

View File

@@ -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 weeks 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;

View File

@@ -136,12 +136,25 @@ export const space = {
endSession: { endSession: {
trigger: 'END SESSION', trigger: 'END SESSION',
eyebrow: 'END SESSION', eyebrow: 'END SESSION',
title: '세션을 여기서 마칠까요?', title: '이번 목표를 끝냈나요?',
description: '지금 종료하면 결과를 요약해서 보여준 뒤 앱 입구로 돌아갑니다.', description: '이 블록에 맞는 길을 고르면, 세션을 닫을 때 결과를 요약해서 보여준 뒤 앱 입구로 돌아갑니다.',
goalLabel: '현재 목표', goalLabel: '현재 목표',
finishedTitle: '좋아요. 이 블록을 어떻게 닫을까요?',
finishedDescription: '다음 블록을 바로 열거나, 이 세션을 여기서 조용히 닫을 수 있어요.',
unfinishedTitle: '괜찮아요. 이 세션을 어떻게 할까요?',
unfinishedDescription: '목표를 다 끝내지 못했어도, 이 세션은 여기서 닫을 수 있어요.',
finishedAnswer: '네, 끝냈어요',
unfinishedAnswer: '아직 아니에요',
nextBlockButton: '다음 블록',
finishHereButton: '여기서 마무리하기',
endHereButton: '세션 종료하기',
backButton: '돌아가기',
nextGoalLabel: '다음 블록',
nextGoalPlaceholder: '예: 여행 예산 정리하기',
cancelButton: '계속 집중하기', cancelButton: '계속 집중하기',
confirmButton: '종료하기', confirmButton: '종료하기',
confirmPending: '종료 중…', confirmPending: '종료 중…',
nextGoalConfirmButton: '다음 블록 시작',
}, },
controlCenter: { controlCenter: {
sectionTitles: { sectionTitles: {
@@ -272,6 +285,7 @@ export const space = {
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.', goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
timerCompleteSyncFailed: '타이머 종료 후 세션 마무리를 반영하지 못했어요.', timerCompleteSyncFailed: '타이머 종료 후 세션 마무리를 반영하지 못했어요.',
timerExtendFailed: '10분 추가를 반영하지 못했어요.', timerExtendFailed: '10분 추가를 반영하지 못했어요.',
timerExtendConflict: '다른 창에서 이미 시간이 연장됐어요.',
timerExtended: (minutes: number) => `${minutes}분을 더 이어갑니다.`, timerExtended: (minutes: number) => `${minutes}분을 더 이어갑니다.`,
nextGoalStarted: '다음 한 조각을 바로 시작했어요.', nextGoalStarted: '다음 한 조각을 바로 시작했어요.',
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.', selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',

View File

@@ -1,24 +1,132 @@
'use client'; 'use client';
import { type FormEvent, 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 { HUD_FIELD } from './overlayStyles';
type EndSessionStage = 'decision' | 'finished' | 'unfinished' | 'next';
interface EndSessionConfirmModalProps { interface EndSessionConfirmModalProps {
open: boolean; open: boolean;
goal: string; currentGoal: string;
isPending?: boolean;
onClose: () => void; onClose: () => void;
onConfirm: () => void | Promise<void>; onAdvanceGoal: (nextGoal: string) => Promise<boolean> | boolean;
onFinishHere: () => Promise<boolean> | boolean;
onEndSession: () => Promise<boolean> | boolean;
} }
export const EndSessionConfirmModal = ({ export const EndSessionConfirmModal = ({
open, open,
goal, currentGoal,
isPending = false,
onClose, onClose,
onConfirm, onAdvanceGoal,
onFinishHere,
onEndSession,
}: EndSessionConfirmModalProps) => { }: EndSessionConfirmModalProps) => {
const trimmedGoal = goal.trim(); const inputRef = useRef<HTMLInputElement | null>(null);
const [stage, setStage] = useState<EndSessionStage>('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<HTMLFormElement>) => {
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 ( return (
<div <div
@@ -37,7 +145,7 @@ export const EndSessionConfirmModal = ({
<section <section
className={cn( className={cn(
'relative w-full max-w-[34rem] overflow-hidden rounded-[30px] border border-white/12 bg-[linear-gradient(180deg,rgba(18,22,30,0.95)_0%,rgba(8,11,17,0.93)_100%)] text-white shadow-[0_28px_90px_rgba(2,6,23,0.52)] transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]', 'relative w-full max-w-[38rem] overflow-hidden rounded-[30px] border border-white/12 bg-[linear-gradient(180deg,rgba(18,22,30,0.95)_0%,rgba(8,11,17,0.93)_100%)] text-white shadow-[0_28px_90px_rgba(2,6,23,0.52)] transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]',
open ? 'pointer-events-auto translate-y-0 scale-100' : 'pointer-events-none translate-y-4 scale-[0.975]', open ? 'pointer-events-auto translate-y-0 scale-100' : 'pointer-events-none translate-y-4 scale-[0.975]',
)} )}
role="dialog" role="dialog"
@@ -50,6 +158,15 @@ export const EndSessionConfirmModal = ({
/> />
<div className="relative px-7 py-7 md:px-9 md:py-8"> <div className="relative px-7 py-7 md:px-9 md:py-8">
<header className="text-center"> <header className="text-center">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="absolute right-0 top-0 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-[12px] text-white/64 transition-all hover:border-white/16 hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
aria-label={copy.modal.closeAriaLabel}
>
</button>
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.06] shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.06] shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
<span className="text-[20px] text-white/88"></span> <span className="text-[20px] text-white/88"></span>
</div> </div>
@@ -60,10 +177,10 @@ export const EndSessionConfirmModal = ({
id="end-session-confirm-title" 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]" 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}
</h3> </h3>
<p className="mx-auto mt-3 max-w-[26rem] text-[14px] leading-[1.7] text-white/56"> <p className="mx-auto mt-3 max-w-[28rem] text-[14px] leading-[1.7] text-white/56">
{copy.space.endSession.description} {description}
</p> </p>
</header> </header>
@@ -78,24 +195,126 @@ export const EndSessionConfirmModal = ({
</div> </div>
) : null} ) : null}
<footer className="mt-6 grid grid-cols-2 gap-3"> {stage === 'decision' ? (
<button <footer className="mt-6 grid grid-cols-2 gap-3">
type="button" <button
onClick={onClose} type="button"
disabled={isPending} onClick={() => setStage('finished')}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white/72 transition-all duration-200 hover:border-white/16 hover:bg-white/[0.07] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40" disabled={isSubmitting}
> className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/14 bg-white/[0.12] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white transition-all duration-200 hover:border-white/22 hover:bg-white/[0.17] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
{copy.space.endSession.cancelButton} >
</button> {copy.space.endSession.finishedAnswer}
<button </button>
type="button" <button
onClick={() => void onConfirm()} type="button"
disabled={isPending} onClick={() => setStage('unfinished')}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/14 bg-white/[0.12] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white transition-all duration-200 hover:border-white/22 hover:bg-white/[0.17] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40" disabled={isSubmitting}
> className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white/72 transition-all duration-200 hover:border-white/16 hover:bg-white/[0.07] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
{isPending ? copy.space.endSession.confirmPending : copy.space.endSession.confirmButton} >
</button> {copy.space.endSession.unfinishedAnswer}
</footer> </button>
</footer>
) : null}
{stage === 'finished' ? (
<footer className="mt-6 grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setStage('decision')}
disabled={isSubmitting}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white/72 transition-all duration-200 hover:border-white/16 hover:bg-white/[0.07] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
>
{copy.space.endSession.backButton}
</button>
<button
type="button"
onClick={() => setStage('next')}
disabled={isSubmitting}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white/72 transition-all duration-200 hover:border-white/16 hover:bg-white/[0.07] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
>
{copy.space.endSession.nextBlockButton}
</button>
<button
type="button"
onClick={handleFinishHere}
disabled={isSubmitting}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/14 bg-white/[0.12] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white transition-all duration-200 hover:border-white/22 hover:bg-white/[0.17] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
>
{submissionMode === 'finish'
? copy.space.goalComplete.finishPending
: copy.space.endSession.finishHereButton}
</button>
</footer>
) : null}
{stage === 'unfinished' ? (
<footer className="mt-6 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setStage('decision')}
disabled={isSubmitting}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white/72 transition-all duration-200 hover:border-white/16 hover:bg-white/[0.07] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
>
{copy.space.endSession.backButton}
</button>
<button
type="button"
onClick={handleEndSession}
disabled={isSubmitting}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/14 bg-white/[0.12] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white transition-all duration-200 hover:border-white/22 hover:bg-white/[0.17] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
>
{submissionMode === 'end'
? copy.space.endSession.confirmPending
: copy.space.endSession.endHereButton}
</button>
</footer>
) : null}
{stage === 'next' ? (
<form className="mt-6 space-y-4" onSubmit={handleAdvanceGoal}>
<div className="text-left">
<label
htmlFor="end-session-next-goal"
className="text-[11px] font-medium tracking-[0.12em] text-white/36"
>
{copy.space.endSession.nextGoalLabel}
</label>
<input
ref={inputRef}
id="end-session-next-goal"
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={copy.space.endSession.nextGoalPlaceholder}
className={cn(HUD_FIELD, 'mt-2 h-[3.25rem] rounded-[20px] bg-white/[0.05]')}
/>
</div>
<footer className="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
onClick={() => {
if (isSubmitting) {
return;
}
setStage('finished');
}}
disabled={isSubmitting}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white/72 transition-all duration-200 hover:border-white/16 hover:bg-white/[0.07] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
>
{copy.space.endSession.backButton}
</button>
<button
type="submit"
disabled={!canConfirmNext || isSubmitting}
className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/14 bg-white/[0.12] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white transition-all duration-200 hover:border-white/22 hover:bg-white/[0.17] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
>
{submissionMode === 'next'
? copy.space.goalComplete.confirmPending
: copy.space.endSession.nextGoalConfirmButton}
</button>
</footer>
</form>
) : null}
</div> </div>
</section> </section>
</div> </div>

View File

@@ -1,118 +1,46 @@
'use client'; 'use client';
import type { FormEvent } from 'react'; import { useMemo, useState } from 'react';
import { useEffect, useMemo, 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 { HUD_FIELD } from './overlayStyles';
interface GoalCompleteSheetProps { interface GoalCompleteSheetProps {
open: boolean; open: boolean;
currentGoal: string; currentGoal: string;
preferredView?: 'choice' | 'next';
mode?: 'manual' | 'timer-complete';
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
onFinish: () => Promise<boolean> | boolean; onFinish: () => Promise<boolean> | boolean;
onExtendTenMinutes?: () => Promise<boolean> | boolean; onExtendTenMinutes?: () => Promise<boolean> | boolean;
onRest: () => void;
onClose: () => void; onClose: () => void;
} }
export const GoalCompleteSheet = ({ export const GoalCompleteSheet = ({
open, open,
currentGoal, currentGoal,
preferredView = 'choice',
mode = 'manual',
onConfirm,
onFinish, onFinish,
onExtendTenMinutes, onExtendTenMinutes,
onRest,
onClose, onClose,
}: GoalCompleteSheetProps) => { }: GoalCompleteSheetProps) => {
const inputRef = useRef<HTMLInputElement | null>(null); const [submissionMode, setSubmissionMode] = useState<'finish' | 'extend' | null>(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';
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 trimmedCurrentGoal = currentGoal.trim();
const title = const isSubmitting = submissionMode !== null;
isTimerCompleteMode const canExtend = Boolean(onExtendTenMinutes);
? 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 handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const goalCard = useMemo(() => {
event.preventDefault(); if (!trimmedCurrentGoal) {
return null;
if (!canConfirm || isSubmitting) {
return;
} }
setSubmissionMode('next'); return (
<div className="rounded-[24px] border border-white/8 bg-black/14 px-4 py-4 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]">
try { <p className="text-[10px] font-medium uppercase tracking-[0.22em] text-white/34">
const didAdvance = await onConfirm(draft.trim()); {copy.space.goalComplete.currentGoalLabel}
</p>
if (didAdvance) { <p className="mt-2 text-[15px] font-medium leading-[1.45] tracking-[-0.01em] text-white/88">
onClose(); {trimmedCurrentGoal}
} </p>
} finally { </div>
setSubmissionMode(null); );
} }, [trimmedCurrentGoal]);
};
const handleFinish = async () => { const handleFinish = async () => {
if (isSubmitting) { 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 (
<div className="rounded-[24px] border border-white/8 bg-black/14 px-4 py-4 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]">
<p className="text-[10px] font-medium uppercase tracking-[0.22em] text-white/34">
{copy.space.goalComplete.currentGoalLabel}
</p>
<p className="mt-2 text-[15px] font-medium leading-[1.45] tracking-[-0.01em] text-white/88">
{trimmedCurrentGoal}
</p>
</div>
);
};
return ( return (
<div <div
className={cn( className={cn(
@@ -194,8 +96,7 @@ export const GoalCompleteSheet = ({
<section <section
className={cn( className={cn(
'relative w-full max-w-[42rem] overflow-hidden rounded-[30px] border border-white/12 bg-[linear-gradient(180deg,rgba(18,22,30,0.94)_0%,rgba(9,12,18,0.92)_100%)] text-white shadow-[0_28px_90px_rgba(2,6,23,0.48)] transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]', 'relative w-full max-w-[42rem] overflow-hidden rounded-[30px] border border-white/12 bg-[linear-gradient(180deg,rgba(18,22,30,0.94)_0%,rgba(9,12,18,0.92)_100%)] text-white shadow-[0_28px_90px_rgba(2,6,23,0.48)] transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]',
open ? 'pointer-events-auto' : 'pointer-events-none', open ? 'pointer-events-auto translate-y-0 scale-100' : 'pointer-events-none translate-y-4 scale-[0.975]',
open ? 'translate-y-0 scale-100' : 'translate-y-4 scale-[0.975]',
)} )}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -211,134 +112,49 @@ export const GoalCompleteSheet = ({
/> />
<div className="relative px-7 py-7 md:px-9 md:py-8"> <div className="relative px-7 py-7 md:px-9 md:py-8">
<header className="relative text-center"> <header className="text-center">
{isTimerCompleteMode ? null : (
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="absolute right-0 top-0 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-[12px] text-white/64 transition-all hover:border-white/16 hover:bg-white/[0.08] hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
aria-label={copy.space.goalComplete.closeAriaLabel}
>
</button>
)}
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.06] shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]"> <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.06] shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]">
<span className="text-[20px] text-white/88">{isTimerCompleteMode ? '⌛' : '✦'}</span> <span className="text-[20px] text-white/88"></span>
</div> </div>
<p className="mt-5 text-[11px] font-medium tracking-[0.14em] text-white/34">GOAL COMPLETE</p> <p className="mt-5 text-[11px] font-medium tracking-[0.14em] text-white/34">
{copy.space.completionResult.eyebrow}
</p>
<h3 <h3
id="goal-complete-title" id="goal-complete-title"
className="mx-auto mt-2 max-w-[28rem] text-[1.65rem] font-light leading-[1.16] tracking-[-0.03em] text-white/96 md:text-[1.9rem]" className="mx-auto mt-2 max-w-[28rem] text-[1.65rem] font-light leading-[1.16] tracking-[-0.03em] text-white/96 md:text-[1.9rem]"
> >
{title} {copy.space.goalComplete.timerTitle}
</h3> </h3>
<p className="mx-auto mt-3 max-w-[28rem] text-[14px] leading-[1.7] text-white/56"> <p className="mx-auto mt-3 max-w-[28rem] text-[14px] leading-[1.7] text-white/56">
{description} {copy.space.goalComplete.timerDescription}
</p> </p>
</header> </header>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
{renderGoalCard()} {goalCard}
{isTimerCompleteMode ? ( <footer className="grid grid-cols-2 gap-3 pt-2">
<footer className="grid grid-cols-2 gap-3 pt-2"> <button
<button type="button"
type="button" onClick={handleExtend}
onClick={handleExtend} disabled={isSubmitting || !canExtend}
disabled={isSubmitting} className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/10 bg-white/[0.04] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white/72 transition-all duration-200 hover:border-white/16 hover:bg-white/[0.07] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
className={cn(baseButtonClass, secondaryButtonClass)} >
> {submissionMode === 'extend'
{submissionMode === 'extend' ? copy.space.goalComplete.extendPending
? copy.space.goalComplete.extendPending : copy.space.goalComplete.extendButton}
: copy.space.goalComplete.extendButton} </button>
</button> <button
<button type="button"
type="button" onClick={handleFinish}
onClick={handleFinish} disabled={isSubmitting}
disabled={isSubmitting} className="inline-flex min-h-[3.5rem] items-center justify-center rounded-[18px] border border-white/14 bg-white/[0.12] px-4 py-3 text-center text-[13px] font-medium tracking-[0.01em] text-white transition-all duration-200 hover:border-white/22 hover:bg-white/[0.17] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/12 disabled:cursor-not-allowed disabled:opacity-40"
className={cn(baseButtonClass, primaryButtonClass)} >
> {submissionMode === 'finish'
{submissionMode === 'finish' ? copy.space.goalComplete.timerFinishPending
? copy.space.goalComplete.timerFinishPending : copy.space.goalComplete.timerFinishButton}
: copy.space.goalComplete.timerFinishButton} </button>
</button> </footer>
</footer>
) : view === 'choice' ? (
<footer className="grid grid-cols-3 gap-3 pt-2">
<button
type="button"
onClick={onRest}
disabled={isSubmitting}
className={cn(baseButtonClass, breakButtonClass)}
>
{copy.space.goalComplete.restButton}
</button>
<button
type="button"
onClick={handleFinish}
disabled={isSubmitting}
className={cn(baseButtonClass, secondaryButtonClass)}
>
{submissionMode === 'finish'
? copy.space.goalComplete.finishPending
: copy.space.goalComplete.finishButton}
</button>
<button
type="button"
onClick={() => setView('next')}
disabled={isSubmitting}
className={cn(baseButtonClass, primaryButtonClass)}
>
{copy.space.goalComplete.chooseNextButton}
</button>
</footer>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="text-left">
<label
htmlFor="goal-complete-next-goal"
className="text-[11px] font-medium tracking-[0.12em] text-white/36"
>
{copy.space.goalComplete.nextGoalLabel}
</label>
<input
ref={inputRef}
id="goal-complete-next-goal"
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={placeholder}
className={cn(HUD_FIELD, 'mt-2 h-[3.25rem] rounded-[20px] bg-white/[0.05]')}
/>
</div>
<footer className="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
onClick={() => {
if (isSubmitting) {
return;
}
setView('choice');
}}
disabled={isSubmitting}
className={cn(baseButtonClass, secondaryButtonClass)}
>
{copy.space.goalComplete.backButton}
</button>
<button
type="submit"
disabled={!canConfirm || isSubmitting}
className={cn(baseButtonClass, primaryButtonClass)}
>
{submissionMode === 'next'
? copy.space.goalComplete.confirmPending
: copy.space.goalComplete.confirmButton}
</button>
</footer>
</form>
)}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -49,21 +49,14 @@ export const SpaceFocusHudWidget = ({
onCaptureThought, onCaptureThought,
onExitRequested, onExitRequested,
}: SpaceFocusHudWidgetProps) => { }: SpaceFocusHudWidgetProps) => {
const [overlay, setOverlay] = useState< const [overlay, setOverlay] = useState<"none" | "end-session" | "timer-complete">("none");
"none" | "complete" | "timer-complete"
>("none");
const [completePreferredView, setCompletePreferredView] = useState<
"choice" | "next"
>("choice");
const [isSavingIntent, setSavingIntent] = useState(false); const [isSavingIntent, setSavingIntent] = useState(false);
const [isEndSessionConfirmOpen, setEndSessionConfirmOpen] = useState(false);
const [isEndingSession, setEndingSession] = useState(false);
const visibleRef = useRef(false); const visibleRef = useRef(false);
const timerPromptSignatureRef = useRef<string | null>(null); const timerPromptSignatureRef = useRef<string | null>(null);
const normalizedGoal = const normalizedGoal =
goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback; goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const isCompleteOpen = overlay === "complete" || overlay === "timer-complete"; const isTimerCompleteOpen = overlay === "timer-complete";
const timerCompletionSignature = const timerCompletionSignature =
hasActiveSession && hasActiveSession &&
sessionPhase === "focus" && sessionPhase === "focus" &&
@@ -76,9 +69,6 @@ export const SpaceFocusHudWidget = ({
if (!hasActiveSession) { if (!hasActiveSession) {
setOverlay("none"); setOverlay("none");
setSavingIntent(false); setSavingIntent(false);
setCompletePreferredView("choice");
setEndSessionConfirmOpen(false);
setEndingSession(false);
timerPromptSignatureRef.current = null; timerPromptSignatureRef.current = null;
} }
}, [hasActiveSession]); }, [hasActiveSession]);
@@ -93,6 +83,14 @@ export const SpaceFocusHudWidget = ({
visibleRef.current = true; visibleRef.current = true;
}, [normalizedGoal, onStatusMessage, playbackState]); }, [normalizedGoal, onStatusMessage, playbackState]);
useEffect(() => {
if (overlay !== "timer-complete" || timerCompletionSignature) {
return;
}
setOverlay("none");
}, [overlay, timerCompletionSignature]);
useEffect(() => { useEffect(() => {
if (!timerCompletionSignature) { if (!timerCompletionSignature) {
return; return;
@@ -103,16 +101,9 @@ export const SpaceFocusHudWidget = ({
} }
timerPromptSignatureRef.current = timerCompletionSignature; timerPromptSignatureRef.current = timerCompletionSignature;
setEndSessionConfirmOpen(false);
setOverlay("timer-complete"); setOverlay("timer-complete");
}, [timerCompletionSignature]); }, [timerCompletionSignature]);
const handleOpenGoalComplete = () => {
setEndSessionConfirmOpen(false);
setCompletePreferredView("choice");
setOverlay("complete");
};
const handleInlineMicrostepUpdate = async (nextStep: string | null) => { const handleInlineMicrostepUpdate = async (nextStep: string | null) => {
if (isSavingIntent) return false; 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 ( return (
<> <>
<ThoughtOrb <ThoughtOrb
@@ -161,7 +134,7 @@ export const SpaceFocusHudWidget = ({
<div <div
className={cn( 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)]",
isCompleteOpen overlay !== "none"
? "opacity-0 scale-95 blur-md" ? "opacity-0 scale-95 blur-md"
: "opacity-100 scale-100 blur-0", : "opacity-100 scale-100 blur-0",
)} )}
@@ -201,19 +174,8 @@ export const SpaceFocusHudWidget = ({
<div className="mt-8 flex items-center gap-4"> <div className="mt-8 flex items-center gap-4">
<button <button
type="button" type="button"
onClick={handleOpenGoalComplete} onClick={() => setOverlay("end-session")}
className="text-[11px] font-bold uppercase tracking-[0.25em] text-white/34 transition hover:text-white/76" className="text-[11px] font-bold uppercase tracking-[0.25em] text-white/34 transition hover:text-white/76"
>
{copy.space.focusHud.completeAction}
</button>
<span aria-hidden className="h-3 w-px bg-white/10" />
<button
type="button"
onClick={() => {
setOverlay("none");
setEndSessionConfirmOpen(true);
}}
className="text-[11px] font-bold uppercase tracking-[0.25em] text-white/30 transition hover:text-white/70"
> >
{copy.space.endSession.trigger} {copy.space.endSession.trigger}
</button> </button>
@@ -224,40 +186,20 @@ 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">
<GoalCompleteSheet <GoalCompleteSheet
open={isCompleteOpen} open={isTimerCompleteOpen}
currentGoal={goal} currentGoal={goal}
mode={overlay === "timer-complete" ? "timer-complete" : "manual"}
preferredView={completePreferredView}
onClose={() => setOverlay("none")} onClose={() => setOverlay("none")}
onFinish={() => onFinish={() => Promise.resolve(onTimerFinish())}
overlay === "timer-complete"
? Promise.resolve(onTimerFinish())
: Promise.resolve(onGoalCompleteFinish())
}
onExtendTenMinutes={() => Promise.resolve(onAddTenMinutes())} 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));
}}
/> />
</div> </div>
<EndSessionConfirmModal <EndSessionConfirmModal
open={isEndSessionConfirmOpen} open={overlay === "end-session"}
goal={goal} currentGoal={goal}
isPending={isEndingSession} onClose={() => setOverlay("none")}
onClose={() => { onAdvanceGoal={(nextGoal) => Promise.resolve(onGoalUpdate(nextGoal))}
if (isEndingSession) { onFinishHere={() => Promise.resolve(onGoalCompleteFinish())}
return; onEndSession={() => Promise.resolve(onExitRequested())}
}
setEndSessionConfirmOpen(false);
}}
onConfirm={handleEndSessionConfirm}
/> />
</> </>
); );

View File

@@ -38,6 +38,7 @@ interface UseSpaceWorkspaceSessionControlsParams {
resumeSession: () => Promise<FocusSession | null>; resumeSession: () => Promise<FocusSession | null>;
restartCurrentPhase: () => Promise<FocusSession | null>; restartCurrentPhase: () => Promise<FocusSession | null>;
extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise<FocusSession | null>; extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise<FocusSession | null>;
syncCurrentSession: () => Promise<FocusSession | null>;
updateCurrentIntent: (payload: { updateCurrentIntent: (payload: {
goal?: string; goal?: string;
microStep?: string | null; microStep?: string | null;
@@ -86,6 +87,7 @@ export const useSpaceWorkspaceSessionControls = ({
resumeSession, resumeSession,
restartCurrentPhase, restartCurrentPhase,
extendCurrentPhase, extendCurrentPhase,
syncCurrentSession,
updateCurrentIntent, updateCurrentIntent,
completeSession, completeSession,
advanceGoal, advanceGoal,
@@ -409,6 +411,18 @@ export const useSpaceWorkspaceSessionControls = ({
}); });
if (!extendedSession) { if (!extendedSession) {
const syncedSession = await syncCurrentSession();
if (
syncedSession &&
(syncedSession.state === 'running' || syncedSession.phaseRemainingSeconds > 0)
) {
pushStatusLine({
message: copy.space.workspace.timerExtendConflict,
});
return false;
}
pushStatusLine({ pushStatusLine({
message: copy.space.workspace.timerExtendFailed, message: copy.space.workspace.timerExtendFailed,
}); });
@@ -427,6 +441,7 @@ export const useSpaceWorkspaceSessionControls = ({
resolveSoundPlaybackUrl, resolveSoundPlaybackUrl,
selectedPresetId, selectedPresetId,
setPreviewPlaybackState, setPreviewPlaybackState,
syncCurrentSession,
unlockPlayback, unlockPlayback,
]); ]);

View File

@@ -104,6 +104,7 @@ export const SpaceWorkspaceWidget = () => {
remainingSeconds, remainingSeconds,
timeDisplay, timeDisplay,
phase, phase,
syncCurrentSession,
startSession, startSession,
pauseSession, pauseSession,
resumeSession, resumeSession,
@@ -182,6 +183,7 @@ export const SpaceWorkspaceWidget = () => {
resumeSession, resumeSession,
restartCurrentPhase, restartCurrentPhase,
extendCurrentPhase, extendCurrentPhase,
syncCurrentSession,
updateCurrentIntent, updateCurrentIntent,
completeSession, completeSession,
advanceGoal, advanceGoal,