feat(space): timer 종료 모달과 10분 연장 추가

This commit is contained in:
2026-03-16 16:17:41 +09:00
parent 627bd82706
commit ec941f3cde
9 changed files with 271 additions and 21 deletions

View File

@@ -72,6 +72,11 @@ Last Updated: 2026-03-16
- `break` phase에서도 expanded intent card 안에 `이번 목표 완료`를 유지하도록 수정 - `break` phase에서도 expanded intent card 안에 `이번 목표 완료`를 유지하도록 수정
- base card가 잠기는 recovery overlay(`pause / return / next-beat`) 안에서도 low-emphasis `여기서 마무리하기` 경로를 추가 - base card가 잠기는 recovery overlay(`pause / return / next-beat`) 안에서도 low-emphasis `여기서 마무리하기` 경로를 추가
- 이제 active session 상태에서는 `계속 / 다시 잡기 / 마무리` 중 최소 한 경로가 항상 보이도록 정리 - 이제 active session 상태에서는 `계속 / 다시 잡기 / 마무리` 중 최소 한 경로가 항상 보이도록 정리
- `/space` timer completion flow 재정의:
- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다
- timer가 `00:00`에 도달하면 same-session modal이 자동으로 열리고, `완료하고 종료하기 / 10분 더` 두 경로만 제안한다
- `10분 더`를 누르면 현재 focus phase에 10분이 추가되어 바로 running으로 돌아가고, 다시 시간이 끝나면 같은 modal이 다시 열린다
- server와 web 모두 `extend-phase` 계약 기준으로 동작한다
- `/space` intent HUD collapsed / expanded 재설계: - `/space` intent HUD collapsed / expanded 재설계:
- 상시 큰 goal 카드 대신 idle에서는 goal 1줄만 남는 collapsed glass rail 구조로 변경 - 상시 큰 goal 카드 대신 idle에서는 goal 1줄만 남는 collapsed glass rail 구조로 변경
- hover / focus / rail tap에서만 expanded card로 열리며, 이때만 microStep과 `이번 목표 완료` 액션이 노출됨 - hover / focus / rail tap에서만 expanded card로 열리며, 이때만 microStep과 `이번 목표 완료` 액션이 노출됨

View File

@@ -81,8 +81,11 @@ Last Updated: 2026-03-16
- `Return(focus)`는 재진입에 맞는 짧은 settle motion으로, - `Return(focus)`는 재진입에 맞는 짧은 settle motion으로,
- `Return(break)``Goal Complete`는 더 느슨한 release/closure reveal로 분리했다. - `Return(break)``Goal Complete`는 더 느슨한 release/closure reveal로 분리했다.
- `/space` active session에서는 goal closure 경로가 항상 남도록 정리했다. - `/space` active session에서는 goal closure 경로가 항상 남도록 정리했다.
- `break`에서도 expanded goal card 안에 `이번 목표 완료`가 보인다. - `break`에서도 expanded goal card 안에 `이번 목표 완료`가 보인다.
- `pause / return / next-beat`처럼 base card가 잠기는 overlay 안에는 low-emphasis `여기서 마무리하기`가 추가됐다. - `pause / return / next-beat`처럼 base card가 잠기는 overlay 안에는 low-emphasis `여기서 마무리하기`가 추가됐다.
- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다.
- `00:00`이 되면 `완료하고 종료하기 / 10분 더` 모달이 자동으로 열린다.
- `10분 더`는 server `extend-phase` 계약을 타고 현재 focus phase를 10분 연장한 뒤 다시 running으로 이어진다.
- 그래서 사용자는 recovery 상태에서도 `계속 / 다시 잡기 / 마무리` 중 하나를 바로 고를 수 있다. - 그래서 사용자는 recovery 상태에서도 `계속 / 다시 잡기 / 마무리` 중 하나를 바로 고를 수 있다.
- `/space` 목표 카드를 collapsed / expanded 구조로 재설계했다. - `/space` 목표 카드를 collapsed / expanded 구조로 재설계했다.
- idle에서는 goal 1줄만 남는 얇은 glass rail로 줄였다. - idle에서는 goal 1줄만 남는 얇은 glass rail로 줄였다.

View File

@@ -79,6 +79,10 @@ export interface UpdateCurrentFocusSessionIntentRequest {
microStep?: string | null; microStep?: string | null;
} }
export interface ExtendCurrentPhaseRequest {
additionalMinutes: number;
}
export interface AdvanceCurrentGoalRequest { export interface AdvanceCurrentGoalRequest {
completedGoal: string; completedGoal: string;
nextGoal: string; nextGoal: string;
@@ -164,6 +168,15 @@ export const focusSessionApi = {
return normalizeFocusSession(response); return normalizeFocusSession(response);
}, },
extendCurrentPhase: async (payload: ExtendCurrentPhaseRequest): Promise<FocusSession> => {
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/extend-phase', {
method: 'POST',
body: JSON.stringify(payload),
});
return normalizeFocusSession(response);
},
updateCurrentSelection: async ( updateCurrentSelection: async (
payload: UpdateCurrentFocusSessionSelectionRequest, payload: UpdateCurrentFocusSessionSelectionRequest,
): Promise<FocusSession> => { ): Promise<FocusSession> => {

View File

@@ -73,6 +73,7 @@ interface UseFocusSessionEngineResult {
pauseSession: () => Promise<FocusSession | null>; pauseSession: () => Promise<FocusSession | null>;
resumeSession: () => Promise<FocusSession | null>; resumeSession: () => Promise<FocusSession | null>;
restartCurrentPhase: () => Promise<FocusSession | null>; restartCurrentPhase: () => Promise<FocusSession | null>;
extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise<FocusSession | null>;
updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise<FocusSession | null>; updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise<FocusSession | null>;
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>; updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>; completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
@@ -237,6 +238,18 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
return applySession(session); return applySession(session);
}, },
extendCurrentPhase: async (payload) => {
if (!currentSession) {
return null;
}
const session = await runMutation(
() => focusSessionApi.extendCurrentPhase(payload),
copy.focusSession.resumeFailed,
);
return applySession(session);
},
updateCurrentIntent: async (payload) => { updateCurrentIntent: async (payload) => {
if (!currentSession) { if (!currentSession) {
return null; return null;

View File

@@ -98,6 +98,8 @@ export const space = {
placeholderExample: (goal: string) => `예: ${goal}`, placeholderExample: (goal: string) => `예: ${goal}`,
title: '이 블록을 어떻게 이어갈까요?', title: '이 블록을 어떻게 이어갈까요?',
description: '다음으로 이어가기, 잠시 비우기, 여기서 마무리하기 중 하나만 고르면 돼요.', description: '다음으로 이어가기, 잠시 비우기, 여기서 마무리하기 중 하나만 고르면 돼요.',
timerTitle: '시간이 끝났어요. 이 목표를 어떻게 할까요?',
timerDescription: '여기서 완료하고 닫거나, 10분 더 이어서 마무리할 수 있어요.',
nextTitle: '좋아요. 다음 한 조각만 정해요.', nextTitle: '좋아요. 다음 한 조각만 정해요.',
nextDescription: '너무 크게 잡지 말고, 바로 손을 올릴 한 줄만 남겨요.', nextDescription: '너무 크게 잡지 말고, 바로 손을 올릴 한 줄만 남겨요.',
currentGoalLabel: '방금 끝낸 블록', currentGoalLabel: '방금 끝낸 블록',
@@ -108,8 +110,14 @@ export const space = {
closeAriaLabel: '닫기', closeAriaLabel: '닫기',
finishButton: '여기서 마무리하기', finishButton: '여기서 마무리하기',
finishDescription: '이 블록은 여기서 닫고, 다음 진입은 가볍게 남겨둡니다.', finishDescription: '이 블록은 여기서 닫고, 다음 진입은 가볍게 남겨둡니다.',
timerFinishButton: '완료하고 종료하기',
timerFinishDescription: '이 목표는 여기서 끝냈다고 기록하고 세션을 닫습니다.',
timerFinishPending: '종료 중…',
restButton: '잠시 비우기', restButton: '잠시 비우기',
restDescription: '이 블록은 아직 닫지 않고, 잠깐 멈춘 뒤 돌아오라고 알려드려요.', restDescription: '이 블록은 아직 닫지 않고, 잠깐 멈춘 뒤 돌아오라고 알려드려요.',
extendButton: '10분 더',
extendDescription: '지금 흐름을 그대로 두고 10분만 더 이어갑니다.',
extendPending: '10분 추가 중…',
confirmButton: '다음 목표로 바로 시작', confirmButton: '다음 목표로 바로 시작',
confirmPending: '시작 중…', confirmPending: '시작 중…',
finishPending: '마무리 중…', finishPending: '마무리 중…',
@@ -241,6 +249,9 @@ export const space = {
restarted: '현재 페이즈를 처음부터 다시 시작했어요.', restarted: '현재 페이즈를 처음부터 다시 시작했어요.',
intentSyncFailed: '현재 세션 방향을 서버에 반영하지 못했어요.', intentSyncFailed: '현재 세션 방향을 서버에 반영하지 못했어요.',
goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.', goalCompleteSyncFailed: '현재 세션 완료를 서버에 반영하지 못했어요.',
timerCompleteSyncFailed: '타이머 종료 후 세션 마무리를 반영하지 못했어요.',
timerExtendFailed: '10분 추가를 반영하지 못했어요.',
timerExtended: (minutes: number) => `${minutes}분을 더 이어갑니다.`,
nextGoalStarted: '다음 한 조각을 바로 시작했어요.', nextGoalStarted: '다음 한 조각을 바로 시작했어요.',
selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.', selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.',
selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.', selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.',

View File

@@ -25,8 +25,10 @@ interface GoalCompleteSheetProps {
open: boolean; open: boolean;
currentGoal: string; currentGoal: string;
preferredView?: 'choice' | 'next'; preferredView?: 'choice' | 'next';
mode?: 'manual' | 'timer-complete';
onConfirm: (nextGoal: string) => Promise<boolean> | boolean; onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
onFinish: () => Promise<boolean> | boolean; onFinish: () => Promise<boolean> | boolean;
onExtendTenMinutes?: () => Promise<boolean> | boolean;
onRest: () => void; onRest: () => void;
onClose: () => void; onClose: () => void;
} }
@@ -35,15 +37,18 @@ export const GoalCompleteSheet = ({
open, open,
currentGoal, currentGoal,
preferredView = 'choice', preferredView = 'choice',
mode = 'manual',
onConfirm, onConfirm,
onFinish, onFinish,
onExtendTenMinutes,
onRest, onRest,
onClose, onClose,
}: GoalCompleteSheetProps) => { }: GoalCompleteSheetProps) => {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | null>(null); const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | 'extend' | null>(null);
const [view, setView] = useState<'choice' | 'next'>('choice'); const [view, setView] = useState<'choice' | 'next'>('choice');
const isTimerCompleteMode = mode === 'timer-complete';
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -57,7 +62,7 @@ export const GoalCompleteSheet = ({
}; };
} }
if (view !== 'next') { if (isTimerCompleteMode || view !== 'next') {
return; return;
} }
@@ -68,7 +73,7 @@ export const GoalCompleteSheet = ({
return () => { return () => {
window.cancelAnimationFrame(rafId); window.cancelAnimationFrame(rafId);
}; };
}, [open, preferredView, view]); }, [isTimerCompleteMode, open, preferredView, view]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -92,11 +97,15 @@ export const GoalCompleteSheet = ({
const isSubmitting = submissionMode !== null; const isSubmitting = submissionMode !== null;
const trimmedCurrentGoal = currentGoal.trim(); const trimmedCurrentGoal = currentGoal.trim();
const title = const title =
view === 'next' isTimerCompleteMode
? copy.space.goalComplete.timerTitle
: view === 'next'
? copy.space.goalComplete.nextTitle ? copy.space.goalComplete.nextTitle
: copy.space.goalComplete.title; : copy.space.goalComplete.title;
const description = const description =
view === 'next' isTimerCompleteMode
? copy.space.goalComplete.timerDescription
: view === 'next'
? copy.space.goalComplete.nextDescription ? copy.space.goalComplete.nextDescription
: copy.space.goalComplete.description; : copy.space.goalComplete.description;
@@ -138,6 +147,24 @@ export const GoalCompleteSheet = ({
} }
}; };
const handleExtend = async () => {
if (isSubmitting || !onExtendTenMinutes) {
return;
}
setSubmissionMode('extend');
try {
const didExtend = await onExtendTenMinutes();
if (didExtend) {
onClose();
}
} finally {
setSubmissionMode(null);
}
};
return ( return (
<div <div
className={cn( className={cn(
@@ -157,6 +184,7 @@ export const GoalCompleteSheet = ({
<h3 className="mt-1 max-w-[22rem] text-[1rem] font-medium tracking-tight text-white/94">{title}</h3> <h3 className="mt-1 max-w-[22rem] text-[1rem] font-medium tracking-tight text-white/94">{title}</h3>
<p className="mt-1 max-w-[21rem] text-[12px] leading-[1.55] text-white/56">{description}</p> <p className="mt-1 max-w-[21rem] text-[12px] leading-[1.55] text-white/56">{description}</p>
</div> </div>
{isTimerCompleteMode ? null : (
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -166,9 +194,60 @@ export const GoalCompleteSheet = ({
> >
</button> </button>
)}
</header> </header>
{view === 'choice' ? ( {isTimerCompleteMode ? (
<div className="mt-3 space-y-3">
{trimmedCurrentGoal ? (
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
{copy.space.goalComplete.currentGoalLabel}
</p>
<p className="mt-1 truncate text-[14px] text-white/86">{trimmedCurrentGoal}</p>
</div>
) : null}
<footer className="mt-4 space-y-2">
<button
type="button"
onClick={handleFinish}
disabled={isSubmitting}
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
>
<div className="max-w-[20.5rem]">
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
{submissionMode === 'finish'
? copy.space.goalComplete.timerFinishPending
: copy.space.goalComplete.timerFinishButton}
</p>
<p className="mt-1 text-[12px] leading-[1.55] text-white/48">
{copy.space.goalComplete.timerFinishDescription}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
<button
type="button"
onClick={handleExtend}
disabled={isSubmitting}
className={HUD_OPTION_ROW}
>
<div className="max-w-[20.5rem]">
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{submissionMode === 'extend'
? copy.space.goalComplete.extendPending
: copy.space.goalComplete.extendButton}
</p>
<p className="mt-1 text-[12px] leading-[1.55] text-white/44">
{copy.space.goalComplete.extendDescription}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
</footer>
</div>
) : view === 'choice' ? (
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
{trimmedCurrentGoal ? ( {trimmedCurrentGoal ? (
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3"> <div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">

View File

@@ -8,8 +8,11 @@ import { InlineMicrostep } from './InlineMicrostep';
import { ThoughtOrb } from './ThoughtOrb'; import { ThoughtOrb } from './ThoughtOrb';
interface SpaceFocusHudWidgetProps { interface SpaceFocusHudWidgetProps {
sessionId?: string | null;
goal: string; goal: string;
microStep?: string | null; microStep?: string | null;
remainingSeconds?: number | null;
phaseStartedAt?: string | null;
timeDisplay?: string; timeDisplay?: string;
hasActiveSession?: boolean; hasActiveSession?: boolean;
playbackState?: 'running' | 'paused'; playbackState?: 'running' | 'paused';
@@ -17,14 +20,19 @@ interface SpaceFocusHudWidgetProps {
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>; onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>; onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onGoalFinish: () => boolean | Promise<boolean>; onGoalFinish: () => boolean | Promise<boolean>;
onTimerFinish: () => boolean | Promise<boolean>;
onAddTenMinutes: () => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void; onStatusMessage: (payload: HudStatusLinePayload) => void;
onCaptureThought: (note: string) => void; onCaptureThought: (note: string) => void;
onExitRequested: () => void; onExitRequested: () => void;
} }
export const SpaceFocusHudWidget = ({ export const SpaceFocusHudWidget = ({
sessionId = null,
goal, goal,
microStep, microStep,
remainingSeconds = null,
phaseStartedAt = null,
timeDisplay, timeDisplay,
hasActiveSession = false, hasActiveSession = false,
playbackState = 'paused', playbackState = 'paused',
@@ -32,23 +40,34 @@ export const SpaceFocusHudWidget = ({
onIntentUpdate, onIntentUpdate,
onGoalUpdate, onGoalUpdate,
onGoalFinish, onGoalFinish,
onTimerFinish,
onAddTenMinutes,
onStatusMessage, onStatusMessage,
onCaptureThought, onCaptureThought,
onExitRequested, onExitRequested,
}: SpaceFocusHudWidgetProps) => { }: SpaceFocusHudWidgetProps) => {
const [overlay, setOverlay] = useState<'none' | 'complete'>('none'); const [overlay, setOverlay] = useState<'none' | 'complete' | 'timer-complete'>('none');
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice'); const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
const [isSavingIntent, setSavingIntent] = useState(false); const [isSavingIntent, setSavingIntent] = useState(false);
const visibleRef = useRef(false); const visibleRef = useRef(false);
const timerPromptSignatureRef = useRef<string | null>(null);
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback; const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const isCompleteOpen = overlay === 'complete'; const isCompleteOpen = overlay === 'complete' || overlay === 'timer-complete';
const timerCompletionSignature =
hasActiveSession &&
sessionPhase === 'focus' &&
remainingSeconds === 0 &&
phaseStartedAt
? `${sessionId ?? 'session'}:${phaseStartedAt}`
: null;
useEffect(() => { useEffect(() => {
if (!hasActiveSession) { if (!hasActiveSession) {
setOverlay('none'); setOverlay('none');
setSavingIntent(false); setSavingIntent(false);
setCompletePreferredView('choice'); setCompletePreferredView('choice');
timerPromptSignatureRef.current = null;
} }
}, [hasActiveSession]); }, [hasActiveSession]);
@@ -62,6 +81,19 @@ export const SpaceFocusHudWidget = ({
visibleRef.current = true; visibleRef.current = true;
}, [normalizedGoal, onStatusMessage, playbackState]); }, [normalizedGoal, onStatusMessage, playbackState]);
useEffect(() => {
if (!timerCompletionSignature) {
return;
}
if (timerPromptSignatureRef.current === timerCompletionSignature) {
return;
}
timerPromptSignatureRef.current = timerCompletionSignature;
setOverlay('timer-complete');
}, [timerCompletionSignature]);
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => { const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setCompletePreferredView(preferredView); setCompletePreferredView(preferredView);
setOverlay('complete'); setOverlay('complete');
@@ -139,15 +171,23 @@ export const SpaceFocusHudWidget = ({
<GoalCompleteSheet <GoalCompleteSheet
open={isCompleteOpen} open={isCompleteOpen}
currentGoal={goal} currentGoal={goal}
mode={overlay === 'timer-complete' ? 'timer-complete' : 'manual'}
preferredView={completePreferredView} preferredView={completePreferredView}
onClose={() => setOverlay('none')} onClose={() => setOverlay('none')}
onFinish={() => Promise.resolve(onGoalFinish())} onFinish={() =>
overlay === 'timer-complete'
? Promise.resolve(onTimerFinish())
: Promise.resolve(onGoalFinish())
}
onExtendTenMinutes={() => Promise.resolve(onAddTenMinutes())}
onRest={() => { onRest={() => {
setOverlay('none'); setOverlay('none');
// The timer doesn't pause, they just rest within the flow. // The timer doesn't pause, they just rest within the flow.
}} }}
onConfirm={(nextGoal) => { onConfirm={(nextGoal) => {
return Promise.resolve(onGoalUpdate(nextGoal)); return overlay === 'timer-complete'
? Promise.resolve(false)
: Promise.resolve(onGoalUpdate(nextGoal));
}} }}
/> />
</div> </div>

View File

@@ -36,6 +36,7 @@ interface UseSpaceWorkspaceSessionControlsParams {
pauseSession: () => Promise<FocusSession | null>; pauseSession: () => Promise<FocusSession | null>;
resumeSession: () => Promise<FocusSession | null>; resumeSession: () => Promise<FocusSession | null>;
restartCurrentPhase: () => Promise<FocusSession | null>; restartCurrentPhase: () => Promise<FocusSession | null>;
extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise<FocusSession | null>;
updateCurrentIntent: (payload: { updateCurrentIntent: (payload: {
goal?: string; goal?: string;
microStep?: string | null; microStep?: string | null;
@@ -83,6 +84,7 @@ export const useSpaceWorkspaceSessionControls = ({
pauseSession, pauseSession,
resumeSession, resumeSession,
restartCurrentPhase, restartCurrentPhase,
extendCurrentPhase,
updateCurrentIntent, updateCurrentIntent,
completeSession, completeSession,
advanceGoal, advanceGoal,
@@ -342,6 +344,80 @@ export const useSpaceWorkspaceSessionControls = ({
setWorkspaceMode, setWorkspaceMode,
]); ]);
const handleTimerComplete = useCallback(async () => {
const trimmedCurrentGoal = goalInput.trim();
if (!currentSession) {
return false;
}
const completedSession = await completeSession({
completionType: 'timer-complete',
completedGoal: trimmedCurrentGoal || undefined,
});
if (!completedSession) {
pushStatusLine({
message: copy.space.workspace.timerCompleteSyncFailed,
});
return false;
}
setGoalInput('');
setLinkedFocusPlanItemId(null);
setSelectedGoalId(null);
setShowResumePrompt(false);
setPendingSessionEntryPoint('space-setup');
setPreviewPlaybackState('paused');
setWorkspaceMode('setup');
return true;
}, [
completeSession,
currentSession,
goalInput,
pushStatusLine,
setGoalInput,
setLinkedFocusPlanItemId,
setPendingSessionEntryPoint,
setPreviewPlaybackState,
setSelectedGoalId,
setShowResumePrompt,
setWorkspaceMode,
]);
const handleExtendCurrentPhase = useCallback(async (additionalMinutes: number) => {
if (!currentSession) {
return false;
}
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
const extendedSession = await extendCurrentPhase({
additionalMinutes,
});
if (!extendedSession) {
pushStatusLine({
message: copy.space.workspace.timerExtendFailed,
});
return false;
}
setPreviewPlaybackState('running');
pushStatusLine({
message: copy.space.workspace.timerExtended(additionalMinutes),
});
return true;
}, [
currentSession,
extendCurrentPhase,
pushStatusLine,
resolveSoundPlaybackUrl,
selectedPresetId,
setPreviewPlaybackState,
unlockPlayback,
]);
const handleIntentUpdate = useCallback(async (input: { const handleIntentUpdate = useCallback(async (input: {
goal?: string; goal?: string;
microStep?: string | null; microStep?: string | null;
@@ -456,6 +532,8 @@ export const useSpaceWorkspaceSessionControls = ({
handleRestartRequested, handleRestartRequested,
handleIntentUpdate, handleIntentUpdate,
handleGoalComplete, handleGoalComplete,
handleTimerComplete,
handleExtendCurrentPhase,
handleGoalAdvance, handleGoalAdvance,
}; };
}; };

View File

@@ -103,12 +103,14 @@ export const SpaceWorkspaceWidget = () => {
const { const {
currentSession, currentSession,
isBootstrapping, isBootstrapping,
remainingSeconds,
timeDisplay, timeDisplay,
phase, phase,
startSession, startSession,
pauseSession, pauseSession,
resumeSession, resumeSession,
restartCurrentPhase, restartCurrentPhase,
extendCurrentPhase,
updateCurrentIntent, updateCurrentIntent,
updateCurrentSelection, updateCurrentSelection,
completeSession, completeSession,
@@ -185,6 +187,7 @@ export const SpaceWorkspaceWidget = () => {
pauseSession, pauseSession,
resumeSession, resumeSession,
restartCurrentPhase, restartCurrentPhase,
extendCurrentPhase,
updateCurrentIntent, updateCurrentIntent,
completeSession, completeSession,
advanceGoal, advanceGoal,
@@ -323,8 +326,11 @@ export const SpaceWorkspaceWidget = () => {
{isFocusMode ? ( {isFocusMode ? (
<SpaceFocusHudWidget <SpaceFocusHudWidget
sessionId={currentSession?.id ?? null}
goal={selection.goalInput.trim()} goal={selection.goalInput.trim()}
microStep={currentSession?.microStep ?? null} microStep={currentSession?.microStep ?? null}
remainingSeconds={remainingSeconds}
phaseStartedAt={currentSession?.phaseStartedAt ?? null}
timeDisplay={resolvedTimeDisplay} timeDisplay={resolvedTimeDisplay}
hasActiveSession={Boolean(currentSession)} hasActiveSession={Boolean(currentSession)}
playbackState={resolvedPlaybackState} playbackState={resolvedPlaybackState}
@@ -339,6 +345,8 @@ export const SpaceWorkspaceWidget = () => {
return didFinish; return didFinish;
}} }}
onTimerFinish={controls.handleTimerComplete}
onAddTenMinutes={() => controls.handleExtendCurrentPhase(10)}
onGoalUpdate={controls.handleGoalAdvance} onGoalUpdate={controls.handleGoalAdvance}
onStatusMessage={pushStatusLine} onStatusMessage={pushStatusLine}
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)} onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}