From 4bbee36e1ee20c16419d6c9b949e713876ac2b2e Mon Sep 17 00:00:00 2001 From: corpi Date: Tue, 17 Mar 2026 12:45:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(space):=20explicit=20end=20session=20close?= =?UTF-8?q?=20flow=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/exit-hold/index.ts | 1 - .../exit-hold/model/useHoldToConfirm.ts | 141 -------------- src/features/exit-hold/ui/ExitHoldButton.tsx | 125 ------------ .../focus-session/api/focusSessionApi.ts | 6 +- src/shared/i18n/messages/space.ts | 14 +- .../ui/EndSessionConfirmModal.tsx | 103 ++++++++++ .../ui/SpaceFocusHudWidget.tsx | 179 +++++++++++------- .../model/useSpaceWorkspaceSessionControls.ts | 58 ++++-- .../ui/SpaceWorkspaceWidget.tsx | 13 +- 9 files changed, 272 insertions(+), 368 deletions(-) delete mode 100644 src/features/exit-hold/index.ts delete mode 100644 src/features/exit-hold/model/useHoldToConfirm.ts delete mode 100644 src/features/exit-hold/ui/ExitHoldButton.tsx create mode 100644 src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx diff --git a/src/features/exit-hold/index.ts b/src/features/exit-hold/index.ts deleted file mode 100644 index cddd328..0000000 --- a/src/features/exit-hold/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ui/ExitHoldButton'; diff --git a/src/features/exit-hold/model/useHoldToConfirm.ts b/src/features/exit-hold/model/useHoldToConfirm.ts deleted file mode 100644 index b6089da..0000000 --- a/src/features/exit-hold/model/useHoldToConfirm.ts +++ /dev/null @@ -1,141 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; - -const HOLD_DURATION_MS = 1000; -const BOOST_DURATION_MS = 50; -const COMPLETE_HOLD_MS = 160; - -const mapProgress = (elapsedMs: number) => { - if (elapsedMs <= 0) { - return 0; - } - - if (elapsedMs <= BOOST_DURATION_MS) { - return 0.2 * (elapsedMs / BOOST_DURATION_MS); - } - - const tailElapsedMs = Math.min(elapsedMs - BOOST_DURATION_MS, HOLD_DURATION_MS - BOOST_DURATION_MS); - return 0.2 + 0.8 * (tailElapsedMs / (HOLD_DURATION_MS - BOOST_DURATION_MS)); -}; - -export const useHoldToConfirm = (onConfirm: () => void) => { - const frameRef = useRef(null); - const confirmTimeoutRef = useRef(null); - const completeTimeoutRef = useRef(null); - const startRef = useRef(null); - const confirmedRef = useRef(false); - const [progress, setProgress] = useState(0); - const [isHolding, setHolding] = useState(false); - const [isCompleted, setCompleted] = useState(false); - - const clearFrame = () => { - if (frameRef.current !== null) { - window.cancelAnimationFrame(frameRef.current); - frameRef.current = null; - } - }; - - const clearTimers = () => { - if (confirmTimeoutRef.current !== null) { - window.clearTimeout(confirmTimeoutRef.current); - confirmTimeoutRef.current = null; - } - - if (completeTimeoutRef.current !== null) { - window.clearTimeout(completeTimeoutRef.current); - completeTimeoutRef.current = null; - } - }; - - const reset = (withCompleted = false) => { - clearFrame(); - clearTimers(); - startRef.current = null; - confirmedRef.current = false; - setHolding(false); - setCompleted(withCompleted); - setProgress(0); - }; - - useEffect(() => { - return () => { - clearFrame(); - clearTimers(); - }; - }, []); - - const step = (timestamp: number) => { - if (startRef.current === null) { - return; - } - - const elapsedMs = timestamp - startRef.current; - const nextProgress = mapProgress(elapsedMs); - const clampedProgress = Math.min(nextProgress, 1); - setProgress(clampedProgress); - - if (clampedProgress >= 1 && !confirmedRef.current) { - confirmedRef.current = true; - if (confirmTimeoutRef.current !== null) { - window.clearTimeout(confirmTimeoutRef.current); - confirmTimeoutRef.current = null; - } - setHolding(false); - setCompleted(true); - onConfirm(); - - completeTimeoutRef.current = window.setTimeout(() => { - reset(false); - }, COMPLETE_HOLD_MS); - return; - } - - frameRef.current = window.requestAnimationFrame(step); - }; - - const start = () => { - if (isHolding || isCompleted) { - return; - } - - clearTimers(); - clearFrame(); - confirmedRef.current = false; - setCompleted(false); - setProgress(0); - setHolding(true); - startRef.current = performance.now(); - frameRef.current = window.requestAnimationFrame(step); - confirmTimeoutRef.current = window.setTimeout(() => { - if (!confirmedRef.current) { - confirmedRef.current = true; - confirmTimeoutRef.current = null; - setProgress(1); - setHolding(false); - setCompleted(true); - onConfirm(); - - completeTimeoutRef.current = window.setTimeout(() => { - reset(false); - }, COMPLETE_HOLD_MS); - } - }, HOLD_DURATION_MS); - }; - - const cancel = () => { - if (!isHolding) { - return; - } - - reset(); - }; - - return { - progress, - isHolding, - isCompleted, - start, - cancel, - }; -}; diff --git a/src/features/exit-hold/ui/ExitHoldButton.tsx b/src/features/exit-hold/ui/ExitHoldButton.tsx deleted file mode 100644 index 97f3545..0000000 --- a/src/features/exit-hold/ui/ExitHoldButton.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client'; - -import type { KeyboardEvent } from 'react'; -import { copy } from '@/shared/i18n'; -import { cn } from '@/shared/lib/cn'; -import { useHoldToConfirm } from '../model/useHoldToConfirm'; - -interface ExitHoldButtonProps { - variant: 'bar' | 'ring'; - onConfirm: () => void; - className?: string; -} - -const RING_RADIUS = 13; -const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; - -export const ExitHoldButton = ({ - variant, - onConfirm, - className, -}: ExitHoldButtonProps) => { - const { progress, isHolding, isCompleted, start, cancel } = useHoldToConfirm(onConfirm); - const ringOffset = RING_CIRCUMFERENCE * (1 - progress); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - start(); - } - }; - - const handleKeyUp = (event: KeyboardEvent) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - cancel(); - } - }; - - if (variant === 'ring') { - return ( - - ); - } - - return ( - - ); -}; diff --git a/src/features/focus-session/api/focusSessionApi.ts b/src/features/focus-session/api/focusSessionApi.ts index 7906fdf..7ce6959 100644 --- a/src/features/focus-session/api/focusSessionApi.ts +++ b/src/features/focus-session/api/focusSessionApi.ts @@ -3,7 +3,7 @@ import { apiClient } from '@/shared/lib/apiClient'; export type FocusSessionPhase = 'focus' | 'break'; export type FocusSessionState = 'running' | 'paused'; -export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete'; +export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete' | 'manual-end'; interface RawFocusSession { id: number | string; @@ -41,7 +41,7 @@ interface RawCurrentSessionThought { interface RawCompletionResult { completedSessionId: string; - completionSource: 'timer-complete' | 'manual-end'; + completionSource: 'timer-complete' | 'manual-end' | 'goal-complete'; completedGoal: string; focusedSeconds: number; thoughts: RawCurrentSessionThought[]; @@ -78,7 +78,7 @@ export interface CurrentSessionThought { export interface CompletionResult { completedSessionId: string; - completionSource: 'timer-complete' | 'manual-end'; + completionSource: 'timer-complete' | 'manual-end' | 'goal-complete'; completedGoal: string; focusedSeconds: number; thoughts: CurrentSessionThought[]; diff --git a/src/shared/i18n/messages/space.ts b/src/shared/i18n/messages/space.ts index 3e47b2f..4499899 100644 --- a/src/shared/i18n/messages/space.ts +++ b/src/shared/i18n/messages/space.ts @@ -133,6 +133,16 @@ export const space = { thoughtCount: (count: number) => `${count}개`, confirmButton: '확인하고 돌아가기', }, + endSession: { + trigger: 'END SESSION', + eyebrow: 'END SESSION', + title: '세션을 여기서 마칠까요?', + description: '지금 종료하면 결과를 요약해서 보여준 뒤 앱 입구로 돌아갑니다.', + goalLabel: '현재 목표', + cancelButton: '계속 집중하기', + confirmButton: '종료하기', + confirmPending: '종료 중…', + }, controlCenter: { sectionTitles: { background: 'Background', @@ -267,8 +277,4 @@ export const space = { selectionPreferenceSaveFailed: '배경/사운드 기본 설정을 저장하지 못했어요.', selectionSessionSyncFailed: '현재 세션의 배경/사운드 선택을 동기화하지 못했어요.', }, - exitHold: { - holdToExitAriaLabel: '길게 눌러 나가기', - exit: '나가기', - }, } as const; diff --git a/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx b/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx new file mode 100644 index 0000000..e32fe38 --- /dev/null +++ b/src/widgets/space-focus-hud/ui/EndSessionConfirmModal.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { copy } from '@/shared/i18n'; +import { cn } from '@/shared/lib/cn'; + +interface EndSessionConfirmModalProps { + open: boolean; + goal: string; + isPending?: boolean; + onClose: () => void; + onConfirm: () => void | Promise; +} + +export const EndSessionConfirmModal = ({ + open, + goal, + isPending = false, + onClose, + onConfirm, +}: EndSessionConfirmModalProps) => { + const trimmedGoal = goal.trim(); + + return ( +
+
+ +
+
+
+
+
+ +
+

+ {copy.space.endSession.eyebrow} +

+

+ {copy.space.endSession.title} +

+

+ {copy.space.endSession.description} +

+
+ + {trimmedGoal ? ( +
+

+ {copy.space.endSession.goalLabel} +

+

+ {trimmedGoal} +

+
+ ) : null} + +
+ + +
+
+
+
+ ); +}; diff --git a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx index 3eff71c..1857d30 100644 --- a/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx +++ b/src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx @@ -1,11 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; -import { copy } from '@/shared/i18n'; -import { cn } from '@/shared/lib/cn'; -import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine'; -import { ExitHoldButton } from '@/features/exit-hold'; -import { GoalCompleteSheet } from './GoalCompleteSheet'; -import { InlineMicrostep } from './InlineMicrostep'; -import { ThoughtOrb } from './ThoughtOrb'; +import { copy } from "@/shared/i18n"; +import { cn } from "@/shared/lib/cn"; +import type { HudStatusLinePayload } from "@/shared/lib/useHudStatusLine"; +import { useEffect, useRef, useState } from "react"; +import { EndSessionConfirmModal } from "./EndSessionConfirmModal"; +import { GoalCompleteSheet } from "./GoalCompleteSheet"; +import { InlineMicrostep } from "./InlineMicrostep"; +import { ThoughtOrb } from "./ThoughtOrb"; interface SpaceFocusHudWidgetProps { sessionId?: string | null; @@ -15,16 +15,19 @@ interface SpaceFocusHudWidgetProps { phaseStartedAt?: string | null; timeDisplay?: string; hasActiveSession?: boolean; - playbackState?: 'running' | 'paused'; - sessionPhase?: 'focus' | 'break' | null; - onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise; + playbackState?: "running" | "paused"; + sessionPhase?: "focus" | "break" | null; + onIntentUpdate: (payload: { + goal?: string; + microStep?: string | null; + }) => boolean | Promise; onGoalUpdate: (nextGoal: string) => boolean | Promise; onGoalFinish: () => boolean | Promise; onTimerFinish: () => boolean | Promise; onAddTenMinutes: () => boolean | Promise; onStatusMessage: (payload: HudStatusLinePayload) => void; onCaptureThought: (note: string) => void; - onExitRequested: () => void; + onExitRequested: () => boolean | Promise; } export const SpaceFocusHudWidget = ({ @@ -35,8 +38,8 @@ export const SpaceFocusHudWidget = ({ phaseStartedAt = null, timeDisplay, hasActiveSession = false, - playbackState = 'paused', - sessionPhase = 'focus', + playbackState = "paused", + sessionPhase = "focus", onIntentUpdate, onGoalUpdate, onGoalFinish, @@ -46,33 +49,42 @@ 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" | "complete" | "timer-complete" + >("none"); + const [completePreferredView, setCompletePreferredView] = useState< + "choice" | "next" + >("choice"); 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 normalizedGoal = + goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback; + const isCompleteOpen = overlay === "complete" || overlay === "timer-complete"; const timerCompletionSignature = hasActiveSession && - sessionPhase === 'focus' && + sessionPhase === "focus" && remainingSeconds === 0 && phaseStartedAt - ? `${sessionId ?? 'session'}:${phaseStartedAt}` + ? `${sessionId ?? "session"}:${phaseStartedAt}` : null; useEffect(() => { if (!hasActiveSession) { - setOverlay('none'); + setOverlay("none"); setSavingIntent(false); - setCompletePreferredView('choice'); + setCompletePreferredView("choice"); + setEndSessionConfirmOpen(false); + setEndingSession(false); timerPromptSignatureRef.current = null; } }, [hasActiveSession]); useEffect(() => { - if (!visibleRef.current && playbackState === 'running') { + if (!visibleRef.current && playbackState === "running") { onStatusMessage({ message: copy.space.focusHud.goalToast(normalizedGoal), }); @@ -91,17 +103,12 @@ export const SpaceFocusHudWidget = ({ } timerPromptSignatureRef.current = timerCompletionSignature; - setOverlay('timer-complete'); + setOverlay("timer-complete"); }, [timerCompletionSignature]); - const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => { - setCompletePreferredView(preferredView); - setOverlay('complete'); - }; - const handleInlineMicrostepUpdate = async (nextStep: string | null) => { if (isSavingIntent) return false; - + setSavingIntent(true); try { const didUpdate = await onIntentUpdate({ microStep: nextStep }); @@ -118,22 +125,50 @@ export const SpaceFocusHudWidget = ({ } }; + const handleEndSessionConfirm = async () => { + if (isEndingSession) { + return; + } + + setEndingSession(true); + + try { + const didEnd = await onExitRequested(); + + if (didEnd) { + setEndSessionConfirmOpen(false); + } + } finally { + setEndingSession(false); + } + }; + return ( <> - - +
{/* The Monolith (Central Hub) */} -
+
{/* Massive Unstoppable Timer */}
-

+

{timeDisplay}

@@ -144,64 +179,66 @@ export const SpaceFocusHudWidget = ({

{normalizedGoal}

- + {/* Kinetic Inline Microstep */}
-
- {hasActiveSession && (sessionPhase === 'focus' || sessionPhase === 'break') && ( - - )} + {hasActiveSession && + (sessionPhase === "focus" || sessionPhase === "break") && ( + + )}
-
setOverlay('none')} + onClose={() => setOverlay("none")} onFinish={() => - overlay === 'timer-complete' + overlay === "timer-complete" ? Promise.resolve(onTimerFinish()) : Promise.resolve(onGoalFinish()) } onExtendTenMinutes={() => Promise.resolve(onAddTenMinutes())} onRest={() => { - setOverlay('none'); + setOverlay("none"); // The timer doesn't pause, they just rest within the flow. }} onConfirm={(nextGoal) => { - return overlay === 'timer-complete' + return overlay === "timer-complete" ? Promise.resolve(false) : Promise.resolve(onGoalUpdate(nextGoal)); }} />
+ { + if (isEndingSession) { + return; + } - {/* Emergency Tether (Exit) */} -
-
- -
-
+ setEndSessionConfirmOpen(false); + }} + onConfirm={handleEndSessionConfirm} + /> ); }; diff --git a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts index d5b0b52..69cfe94 100644 --- a/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts +++ b/src/widgets/space-workspace/model/useSpaceWorkspaceSessionControls.ts @@ -43,7 +43,7 @@ interface UseSpaceWorkspaceSessionControlsParams { microStep?: string | null; }) => Promise; completeSession: (payload: { - completionType: 'goal-complete' | 'timer-complete'; + completionType: 'goal-complete' | 'timer-complete' | 'manual-end'; completedGoal?: string; focusScore?: number; distractionCount?: number; @@ -57,7 +57,6 @@ interface UseSpaceWorkspaceSessionControlsParams { soundPresetId: string; focusPlanItemId?: string; }) => Promise<{ nextSession: FocusSession } | null>; - abandonSession: () => Promise; setGoalInput: (value: string) => void; setLinkedFocusPlanItemId: (value: string | null) => void; setSelectedGoalId: (value: string | null) => void; @@ -90,7 +89,6 @@ export const useSpaceWorkspaceSessionControls = ({ updateCurrentIntent, completeSession, advanceGoal, - abandonSession, setGoalInput, setLinkedFocusPlanItemId, setSelectedGoalId, @@ -199,21 +197,6 @@ export const useSpaceWorkspaceSessionControls = ({ unlockPlayback, ]); - const handleExitRequested = useCallback(async () => { - const didAbandon = await abandonSession(); - - if (!didAbandon) { - pushStatusLine({ - message: copy.space.workspace.abandonFailed, - }); - return; - } - - setPreviewPlaybackState('paused'); - setPendingSessionEntryPoint('space-setup'); - setWorkspaceMode('setup'); - }, [abandonSession, pushStatusLine, setPendingSessionEntryPoint, setPreviewPlaybackState, setWorkspaceMode]); - const handlePauseRequested = useCallback(async () => { if (!currentSession) { setPreviewPlaybackState('paused'); @@ -317,7 +300,7 @@ export const useSpaceWorkspaceSessionControls = ({ } const completionResult = await completeSession({ - completionType: 'goal-complete', + completionType: 'manual-end', completedGoal: trimmedCurrentGoal || undefined, }); @@ -344,6 +327,41 @@ export const useSpaceWorkspaceSessionControls = ({ setWorkspaceMode, ]); + const handleManualEnd = useCallback(async () => { + const trimmedCurrentGoal = goalInput.trim(); + + if (!currentSession) { + return null; + } + + const completionResult = await completeSession({ + completionType: 'manual-end', + completedGoal: trimmedCurrentGoal || undefined, + }); + + if (!completionResult) { + pushStatusLine({ + message: copy.space.workspace.abandonFailed, + }); + return null; + } + + setShowResumePrompt(false); + setPendingSessionEntryPoint('space-setup'); + setPreviewPlaybackState('paused'); + setWorkspaceMode('setup'); + return completionResult; + }, [ + completeSession, + currentSession, + goalInput, + pushStatusLine, + setPendingSessionEntryPoint, + setPreviewPlaybackState, + setShowResumePrompt, + setWorkspaceMode, + ]); + const handleTimerComplete = useCallback(async () => { const trimmedCurrentGoal = goalInput.trim(); @@ -521,11 +539,11 @@ export const useSpaceWorkspaceSessionControls = ({ openFocusMode, handleSetupFocusOpen, handleStartRequested, - handleExitRequested, handlePauseRequested, handleRestartRequested, handleIntentUpdate, handleGoalComplete, + handleManualEnd, handleTimerComplete, handleExtendCurrentPhase, handleGoalAdvance, diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 31622d6..7b4f8ff 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -113,7 +113,6 @@ export const SpaceWorkspaceWidget = () => { updateCurrentSelection, completeSession, advanceGoal, - abandonSession, } = useFocusSessionEngine(); const isCompletionResultOpen = pendingCompletionResult !== null; @@ -186,7 +185,6 @@ export const SpaceWorkspaceWidget = () => { updateCurrentIntent, completeSession, advanceGoal, - abandonSession, setGoalInput: selection.setGoalInput, setLinkedFocusPlanItemId: selection.setLinkedFocusPlanItemId, setSelectedGoalId: selection.setSelectedGoalId, @@ -410,7 +408,16 @@ export const SpaceWorkspaceWidget = () => { }); }); }} - onExitRequested={() => void controls.handleExitRequested()} + onExitRequested={async () => { + const completionResult = await controls.handleManualEnd(); + + if (completionResult) { + setPendingCompletionResult(completionResult); + setCurrentSessionThoughts([]); + } + + return Boolean(completionResult); + }} /> ) : null}