feat(flow): paused resume gate와 auto-resume 연결

This commit is contained in:
2026-03-15 18:52:19 +09:00
parent 6b70d07e3c
commit 1b01ceaa8b
7 changed files with 94 additions and 21 deletions

View File

@@ -42,9 +42,11 @@ const entryCopy = {
resumeEyebrow: 'Resume',
resumeRunning: '진행 중인 세션이 있어요.',
resumePaused: '잠시 멈춘 세션이 있어요.',
resumeCta: '이어서 들어가기',
resumeCta: '이어서 몰입하기',
resumeRefocusCta: '한 조각 다시 잡기',
resumeRouting: '진행 중인 세션으로 돌아가는 중이에요.',
resumeMicroStepLabel: '마지막 한 조각',
resumePausedHint: '같은 목표를 바로 이어가거나, 다시 시작할 한 조각만 먼저 정리할 수 있어요.',
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
reviewEyebrow: 'Weekly Review',
@@ -77,6 +79,8 @@ const inputShellClass =
'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
const primaryButtonClass =
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
const secondaryButtonClass =
'inline-flex items-center justify-center rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-medium text-white/84 transition hover:bg-white/[0.1] hover:text-white active:scale-[0.99]';
const timerLabelById: Record<string, string> = {
'25-5': '25/5',
@@ -262,6 +266,10 @@ export const FocusDashboardWidget = () => {
router.push('/space?resume=continue');
};
const handleResumeRefocus = () => {
router.push('/space?resume=refocus');
};
const shouldShowWeeklyReviewTeaser =
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
const shouldShowResumeReviewEntry =
@@ -344,13 +352,19 @@ export const FocusDashboardWidget = () => {
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
{entryCopy.resumeCta}
</button>
<p className="text-xs text-white/48 sm:text-right">{activeRitualMeta}</p>
<div className="flex flex-col gap-2.5 sm:flex-row sm:flex-wrap sm:items-center">
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
{entryCopy.resumeCta}
</button>
<button type="button" onClick={handleResumeRefocus} className={secondaryButtonClass}>
{entryCopy.resumeRefocusCta}
</button>
</div>
<p className="text-xs text-white/48 sm:max-w-[15rem] sm:text-right">{activeRitualMeta}</p>
</div>
<p className="text-sm text-white/56">{entryCopy.resumeNewGoalHint}</p>
<p className="text-sm text-white/56">{entryCopy.resumePausedHint}</p>
<p className="text-sm text-white/46">{entryCopy.resumeNewGoalHint}</p>
{shouldShowResumeReviewEntry ? (
<Link

View File

@@ -20,7 +20,9 @@ interface SpaceFocusHudWidgetProps {
canStartSession?: boolean;
canPauseSession?: boolean;
canRestartSession?: boolean;
entryOverlayIntent?: 'resume-refocus' | null;
returnPromptMode?: 'focus' | 'break' | null;
onEntryOverlayIntentHandled?: () => void;
onStartRequested?: () => void;
onPauseRequested?: () => void;
onRestartRequested?: () => void;
@@ -42,7 +44,9 @@ export const SpaceFocusHudWidget = ({
canStartSession = false,
canPauseSession = false,
canRestartSession = false,
entryOverlayIntent = null,
returnPromptMode = null,
onEntryOverlayIntentHandled,
onStartRequested,
onPauseRequested,
onRestartRequested,
@@ -176,6 +180,15 @@ export const SpaceFocusHudWidget = ({
}
}, [normalizedMicroStep, overlay]);
useEffect(() => {
if (entryOverlayIntent !== 'resume-refocus' || !hasActiveSession || overlay !== 'none') {
return;
}
openRefocus('microStep', 'manual');
onEntryOverlayIntentHandled?.();
}, [entryOverlayIntent, hasActiveSession, onEntryOverlayIntentHandled, openRefocus, overlay]);
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setIntentError(null);
setCompletePreferredView(preferredView);

View File

@@ -104,6 +104,7 @@ export const SpaceWorkspaceWidget = () => {
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
useState<SessionEntryPoint>("space-setup");
const [showReviewTeaserAfterComplete, setShowReviewTeaserAfterComplete] = useState(false);
const [hasConsumedEntryOverlayIntent, setHasConsumedEntryOverlayIntent] = useState(false);
const {
selectedPresetId,
@@ -209,6 +210,7 @@ export const SpaceWorkspaceWidget = () => {
setSelectedGoalId: selection.setSelectedGoalId,
setShowResumePrompt: selection.setShowResumePrompt,
});
const handleStartRequested = controls.handleStartRequested;
const awayReturnRecovery = useAwayReturnRecovery({
currentSession,
@@ -226,6 +228,13 @@ export const SpaceWorkspaceWidget = () => {
const allowsPausedReentry =
resumeIntent === "continue" || resumeIntent === "refocus";
const didResolveEntryRouteRef = useRef(false);
const didHandleResumeIntentRef = useRef(false);
const entryOverlayIntent =
!hasConsumedEntryOverlayIntent &&
resumeIntent === "refocus" &&
currentSession?.state === "paused"
? "resume-refocus"
: null;
const secondaryReviewTeaser = shouldShowSecondaryReviewTeaser
? {
title: isPro
@@ -262,6 +271,28 @@ export const SpaceWorkspaceWidget = () => {
}
}, [allowsPausedReentry, currentSession, isBootstrapping, router]);
useEffect(() => {
if (
isBootstrapping ||
!currentSession ||
currentSession.state !== "paused" ||
didHandleResumeIntentRef.current
) {
return;
}
if (resumeIntent === "continue") {
didHandleResumeIntentRef.current = true;
router.replace("/space");
void handleStartRequested();
return;
}
if (resumeIntent === "refocus") {
return;
}
}, [currentSession, handleStartRequested, isBootstrapping, resumeIntent, router]);
useEffect(() => {
const preferMobile =
typeof window !== "undefined"
@@ -355,14 +386,19 @@ export const SpaceWorkspaceWidget = () => {
hasActiveSession={Boolean(currentSession)}
playbackState={resolvedPlaybackState}
sessionPhase={phase ?? 'focus'}
isSessionActionPending={isSessionMutating}
canStartSession={controls.canStartSession}
canPauseSession={controls.canPauseSession}
canRestartSession={controls.canRestartSession}
returnPromptMode={awayReturnRecovery.returnPromptMode}
onStartRequested={() => {
void controls.handleStartRequested();
}}
isSessionActionPending={isSessionMutating}
canStartSession={controls.canStartSession}
canPauseSession={controls.canPauseSession}
canRestartSession={controls.canRestartSession}
entryOverlayIntent={entryOverlayIntent}
returnPromptMode={awayReturnRecovery.returnPromptMode}
onEntryOverlayIntentHandled={() => {
setHasConsumedEntryOverlayIntent(true);
router.replace("/space");
}}
onStartRequested={() => {
void handleStartRequested();
}}
onPauseRequested={() => {
void controls.handlePauseRequested();
}}