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

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

View File

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

View File

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

View File

@@ -25,8 +25,10 @@ interface GoalCompleteSheetProps {
open: boolean;
currentGoal: string;
preferredView?: 'choice' | 'next';
mode?: 'manual' | 'timer-complete';
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
onFinish: () => Promise<boolean> | boolean;
onExtendTenMinutes?: () => Promise<boolean> | boolean;
onRest: () => void;
onClose: () => void;
}
@@ -35,15 +37,18 @@ export const GoalCompleteSheet = ({
open,
currentGoal,
preferredView = 'choice',
mode = 'manual',
onConfirm,
onFinish,
onExtendTenMinutes,
onRest,
onClose,
}: GoalCompleteSheetProps) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState('');
const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | null>(null);
const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | 'extend' | null>(null);
const [view, setView] = useState<'choice' | 'next'>('choice');
const isTimerCompleteMode = mode === 'timer-complete';
useEffect(() => {
if (!open) {
@@ -57,7 +62,7 @@ export const GoalCompleteSheet = ({
};
}
if (view !== 'next') {
if (isTimerCompleteMode || view !== 'next') {
return;
}
@@ -68,7 +73,7 @@ export const GoalCompleteSheet = ({
return () => {
window.cancelAnimationFrame(rafId);
};
}, [open, preferredView, view]);
}, [isTimerCompleteMode, open, preferredView, view]);
useEffect(() => {
if (!open) {
@@ -92,11 +97,15 @@ export const GoalCompleteSheet = ({
const isSubmitting = submissionMode !== null;
const trimmedCurrentGoal = currentGoal.trim();
const title =
view === 'next'
isTimerCompleteMode
? copy.space.goalComplete.timerTitle
: view === 'next'
? copy.space.goalComplete.nextTitle
: copy.space.goalComplete.title;
const description =
view === 'next'
isTimerCompleteMode
? copy.space.goalComplete.timerDescription
: view === 'next'
? copy.space.goalComplete.nextDescription
: copy.space.goalComplete.description;
@@ -138,6 +147,24 @@ export const GoalCompleteSheet = ({
}
};
const handleExtend = async () => {
if (isSubmitting || !onExtendTenMinutes) {
return;
}
setSubmissionMode('extend');
try {
const didExtend = await onExtendTenMinutes();
if (didExtend) {
onClose();
}
} finally {
setSubmissionMode(null);
}
};
return (
<div
className={cn(
@@ -157,18 +184,70 @@ export const GoalCompleteSheet = ({
<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>
</div>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/14 text-[11px] text-white/72 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
aria-label={copy.space.goalComplete.closeAriaLabel}
>
</button>
{isTimerCompleteMode ? null : (
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/14 text-[11px] text-white/72 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
aria-label={copy.space.goalComplete.closeAriaLabel}
>
</button>
)}
</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">
{trimmedCurrentGoal ? (
<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';
interface SpaceFocusHudWidgetProps {
sessionId?: string | null;
goal: string;
microStep?: string | null;
remainingSeconds?: number | null;
phaseStartedAt?: string | null;
timeDisplay?: string;
hasActiveSession?: boolean;
playbackState?: 'running' | 'paused';
@@ -17,14 +20,19 @@ interface SpaceFocusHudWidgetProps {
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onGoalFinish: () => boolean | Promise<boolean>;
onTimerFinish: () => boolean | Promise<boolean>;
onAddTenMinutes: () => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void;
onCaptureThought: (note: string) => void;
onExitRequested: () => void;
}
export const SpaceFocusHudWidget = ({
sessionId = null,
goal,
microStep,
remainingSeconds = null,
phaseStartedAt = null,
timeDisplay,
hasActiveSession = false,
playbackState = 'paused',
@@ -32,23 +40,34 @@ export const SpaceFocusHudWidget = ({
onIntentUpdate,
onGoalUpdate,
onGoalFinish,
onTimerFinish,
onAddTenMinutes,
onStatusMessage,
onCaptureThought,
onExitRequested,
}: SpaceFocusHudWidgetProps) => {
const [overlay, setOverlay] = useState<'none' | 'complete'>('none');
const [overlay, setOverlay] = useState<'none' | 'complete' | 'timer-complete'>('none');
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
const [isSavingIntent, setSavingIntent] = useState(false);
const visibleRef = useRef(false);
const timerPromptSignatureRef = useRef<string | null>(null);
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const isCompleteOpen = overlay === 'complete';
const isCompleteOpen = overlay === 'complete' || overlay === 'timer-complete';
const timerCompletionSignature =
hasActiveSession &&
sessionPhase === 'focus' &&
remainingSeconds === 0 &&
phaseStartedAt
? `${sessionId ?? 'session'}:${phaseStartedAt}`
: null;
useEffect(() => {
if (!hasActiveSession) {
setOverlay('none');
setSavingIntent(false);
setCompletePreferredView('choice');
timerPromptSignatureRef.current = null;
}
}, [hasActiveSession]);
@@ -62,6 +81,19 @@ export const SpaceFocusHudWidget = ({
visibleRef.current = true;
}, [normalizedGoal, onStatusMessage, playbackState]);
useEffect(() => {
if (!timerCompletionSignature) {
return;
}
if (timerPromptSignatureRef.current === timerCompletionSignature) {
return;
}
timerPromptSignatureRef.current = timerCompletionSignature;
setOverlay('timer-complete');
}, [timerCompletionSignature]);
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setCompletePreferredView(preferredView);
setOverlay('complete');
@@ -139,15 +171,23 @@ export const SpaceFocusHudWidget = ({
<GoalCompleteSheet
open={isCompleteOpen}
currentGoal={goal}
mode={overlay === 'timer-complete' ? 'timer-complete' : 'manual'}
preferredView={completePreferredView}
onClose={() => setOverlay('none')}
onFinish={() => Promise.resolve(onGoalFinish())}
onFinish={() =>
overlay === 'timer-complete'
? Promise.resolve(onTimerFinish())
: Promise.resolve(onGoalFinish())
}
onExtendTenMinutes={() => Promise.resolve(onAddTenMinutes())}
onRest={() => {
setOverlay('none');
// The timer doesn't pause, they just rest within the flow.
}}
onConfirm={(nextGoal) => {
return Promise.resolve(onGoalUpdate(nextGoal));
return overlay === 'timer-complete'
? Promise.resolve(false)
: Promise.resolve(onGoalUpdate(nextGoal));
}}
/>
</div>

View File

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

View File

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