feat(core-loop): /app 진입과 /space 복구 흐름 구현
This commit is contained in:
184
src/widgets/space-workspace/model/useAwayReturnRecovery.ts
Normal file
184
src/widgets/space-workspace/model/useAwayReturnRecovery.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user