feat(core-loop): /app 진입과 /space 복구 흐름 구현

This commit is contained in:
2026-03-14 18:02:50 +09:00
parent bc08a049b6
commit b4ed94cf1b
19 changed files with 2638 additions and 619 deletions

View File

@@ -0,0 +1,184 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FocusSession } from '@/features/focus-session';
const AWAY_HIDDEN_THRESHOLD_MS = 20_000;
const AWAY_SLEEP_GAP_THRESHOLD_MS = 90_000;
const HEARTBEAT_INTERVAL_MS = 15_000;
export type ReturnPromptMode = 'focus' | 'break';
interface UseAwayReturnRecoveryParams {
currentSession: FocusSession | null;
isBootstrapping: boolean;
syncCurrentSession: () => Promise<FocusSession | null>;
}
interface UseAwayReturnRecoveryResult {
returnPromptMode: ReturnPromptMode | null;
dismissReturnPrompt: () => void;
}
export const useAwayReturnRecovery = ({
currentSession,
isBootstrapping,
syncCurrentSession,
}: UseAwayReturnRecoveryParams): UseAwayReturnRecoveryResult => {
const [returnPromptMode, setReturnPromptMode] = useState<ReturnPromptMode | null>(null);
const hiddenAtRef = useRef<number | null>(null);
const awayCandidateRef = useRef(false);
const heartbeatAtRef = useRef(Date.now());
const isHandlingReturnRef = useRef(false);
const isRunningFocusSession =
currentSession?.state === 'running' && currentSession.phase === 'focus';
const clearAwayCandidate = useCallback(() => {
hiddenAtRef.current = null;
awayCandidateRef.current = false;
}, []);
const dismissReturnPrompt = useCallback(() => {
setReturnPromptMode(null);
clearAwayCandidate();
}, [clearAwayCandidate]);
useEffect(() => {
heartbeatAtRef.current = Date.now();
}, [currentSession?.id]);
useEffect(() => {
if (!isRunningFocusSession) {
clearAwayCandidate();
return;
}
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {
heartbeatAtRef.current = Date.now();
}
}, HEARTBEAT_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [clearAwayCandidate, isRunningFocusSession]);
useEffect(() => {
if (currentSession?.state !== 'running') {
if (returnPromptMode === 'focus') {
setReturnPromptMode(null);
}
return;
}
if (currentSession.phase !== 'break' && returnPromptMode === 'break') {
setReturnPromptMode(null);
}
}, [currentSession?.phase, currentSession?.state, returnPromptMode]);
useEffect(() => {
if (isBootstrapping) {
return;
}
const maybeHandleReturn = async () => {
if (isHandlingReturnRef.current) {
return;
}
const hiddenDuration =
hiddenAtRef.current == null ? 0 : Date.now() - hiddenAtRef.current;
const sleepGap = Date.now() - heartbeatAtRef.current;
if (!awayCandidateRef.current) {
if (
isRunningFocusSession &&
document.visibilityState === 'visible' &&
sleepGap >= AWAY_SLEEP_GAP_THRESHOLD_MS
) {
awayCandidateRef.current = true;
} else {
return;
}
}
if (hiddenAtRef.current != null && hiddenDuration < AWAY_HIDDEN_THRESHOLD_MS) {
clearAwayCandidate();
heartbeatAtRef.current = Date.now();
return;
}
isHandlingReturnRef.current = true;
try {
const syncedSession = await syncCurrentSession();
const resolvedSession = syncedSession ?? currentSession;
clearAwayCandidate();
heartbeatAtRef.current = Date.now();
if (!resolvedSession || resolvedSession.state !== 'running') {
setReturnPromptMode(null);
return;
}
if (resolvedSession.phase === 'focus') {
setReturnPromptMode('focus');
return;
}
if (resolvedSession.phase === 'break') {
setReturnPromptMode('break');
return;
}
setReturnPromptMode(null);
} finally {
isHandlingReturnRef.current = false;
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
if (isRunningFocusSession) {
hiddenAtRef.current = Date.now();
awayCandidateRef.current = true;
}
return;
}
void maybeHandleReturn();
};
const handlePageHide = () => {
if (isRunningFocusSession) {
hiddenAtRef.current = Date.now();
awayCandidateRef.current = true;
}
};
const handleWindowFocus = () => {
void maybeHandleReturn();
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('pagehide', handlePageHide);
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('pageshow', handleWindowFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('pagehide', handlePageHide);
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener('pageshow', handleWindowFocus);
};
}, [clearAwayCandidate, currentSession, isBootstrapping, isRunningFocusSession, syncCurrentSession]);
return {
returnPromptMode,
dismissReturnPrompt,
};
};

View File

@@ -40,6 +40,12 @@ interface UseSpaceWorkspaceSessionControlsParams {
goal?: string;
microStep?: string | null;
}) => Promise<FocusSession | null>;
completeSession: (payload: {
completionType: 'goal-complete' | 'timer-complete';
completedGoal?: string;
focusScore?: number;
distractionCount?: number;
}) => Promise<FocusSession | null>;
advanceGoal: (input: {
completedGoal: string;
nextGoal: string;
@@ -78,6 +84,7 @@ export const useSpaceWorkspaceSessionControls = ({
resumeSession,
restartCurrentPhase,
updateCurrentIntent,
completeSession,
advanceGoal,
abandonSession,
setGoalInput,
@@ -294,6 +301,47 @@ export const useSpaceWorkspaceSessionControls = ({
unlockPlayback,
]);
const handleGoalComplete = useCallback(async () => {
const trimmedCurrentGoal = goalInput.trim();
if (!currentSession) {
return false;
}
const completedSession = await completeSession({
completionType: 'goal-complete',
completedGoal: trimmedCurrentGoal || undefined,
});
if (!completedSession) {
pushStatusLine({
message: copy.space.workspace.goalCompleteSyncFailed,
});
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 handleIntentUpdate = useCallback(async (input: {
goal?: string;
microStep?: string | null;
@@ -407,6 +455,7 @@ export const useSpaceWorkspaceSessionControls = ({
handlePauseRequested,
handleRestartRequested,
handleIntentUpdate,
handleGoalComplete,
handleGoalAdvance,
};
};

View File

@@ -20,6 +20,7 @@ import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
import { useAwayReturnRecovery } from "../model/useAwayReturnRecovery";
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
import {
@@ -117,8 +118,10 @@ export const SpaceWorkspaceWidget = () => {
restartCurrentPhase,
updateCurrentIntent,
updateCurrentSelection,
completeSession,
advanceGoal,
abandonSession,
syncCurrentSession,
} = useFocusSessionEngine();
const isFocusMode = workspaceMode === "focus";
@@ -191,6 +194,7 @@ export const SpaceWorkspaceWidget = () => {
resumeSession,
restartCurrentPhase,
updateCurrentIntent,
completeSession,
advanceGoal,
abandonSession,
setGoalInput: selection.setGoalInput,
@@ -199,6 +203,12 @@ export const SpaceWorkspaceWidget = () => {
setShowResumePrompt: selection.setShowResumePrompt,
});
const awayReturnRecovery = useAwayReturnRecovery({
currentSession,
isBootstrapping,
syncCurrentSession,
});
useEffect(() => {
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
router.replace("/app");
@@ -296,6 +306,7 @@ export const SpaceWorkspaceWidget = () => {
canStartSession={controls.canStartSession}
canPauseSession={controls.canPauseSession}
canRestartSession={controls.canRestartSession}
returnPromptMode={awayReturnRecovery.returnPromptMode}
onStartRequested={() => {
void controls.handleStartRequested();
}}
@@ -305,8 +316,10 @@ export const SpaceWorkspaceWidget = () => {
onRestartRequested={() => {
void controls.handleRestartRequested();
}}
onDismissReturnPrompt={awayReturnRecovery.dismissReturnPrompt}
onStatusMessage={pushStatusLine}
onIntentUpdate={controls.handleIntentUpdate}
onGoalFinish={controls.handleGoalComplete}
onGoalUpdate={controls.handleGoalAdvance}
/>
) : null}