feat(api): 세션·통계·설정 API 연동 기반을 추가
맥락: - 실제 세션 엔진과 통계·설정 저장을 백엔드와 연결할 프론트 API 경계를 먼저 정리할 필요가 있었다. 변경사항: - focus session, stats, preferences API 계층과 타입을 추가하고 메서드 주석에 Backend Codex 지시 사항을 작성했다. - /space를 현재 세션 조회, 시작, 일시정지, 재개, 다시 시작, 완료, 종료 API 흐름에 연결하고 API 실패 시 로컬 미리보기 fallback을 유지했다. - /stats와 /settings를 API 기반 fetch/save 구조로 전환하고 auth/apiClient를 보강했다. - React 19 규칙에 맞게 관련 훅과 HUD/시트 구현을 정리해 lint/build가 통과하도록 보정했다. 검증: - npm run lint - npm run build 세션-상태: 프론트에서 세션·통계·설정 API를 호출할 준비가 된 상태 세션-다음: 백엔드가 주석에 맞춘 엔드포인트와 응답 스키마를 구현하도록 협업 세션-리스크: 실제 서버 응답 필드명이 현재 타입과 다르면 프론트 매핑 조정이 추가로 필요
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
type GoalChip,
|
||||
type TimerPreset,
|
||||
} from '@/entities/session';
|
||||
import { useFocusSessionEngine } from '@/features/focus-session';
|
||||
import { useSoundPresetSelection } from '@/features/sound-preset';
|
||||
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
||||
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
||||
@@ -141,6 +142,12 @@ const resolveInitialTimerLabel = (
|
||||
return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5';
|
||||
};
|
||||
|
||||
const resolveFocusTimeDisplayFromTimerLabel = (timerLabel: string) => {
|
||||
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel);
|
||||
const focusMinutes = preset?.focusMinutes ?? 25;
|
||||
return `${String(focusMinutes).padStart(2, '0')}:00`;
|
||||
};
|
||||
|
||||
export const SpaceWorkspaceWidget = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const roomQuery = searchParams.get('room');
|
||||
@@ -181,6 +188,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const [resumeGoal, setResumeGoal] = useState('');
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||
const [hasHydratedSelection, setHasHydratedSelection] = useState(false);
|
||||
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('running');
|
||||
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
|
||||
sound: false,
|
||||
timer: false,
|
||||
@@ -194,6 +202,19 @@ export const SpaceWorkspaceWidget = () => {
|
||||
isMuted,
|
||||
setMuted,
|
||||
} = useSoundPresetSelection(initialSoundPresetId);
|
||||
const {
|
||||
currentSession,
|
||||
isMutating: isSessionMutating,
|
||||
timeDisplay,
|
||||
playbackState,
|
||||
phase,
|
||||
startSession,
|
||||
pauseSession,
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
completeSession,
|
||||
abandonSession,
|
||||
} = useFocusSessionEngine();
|
||||
|
||||
const selectedRoom = useMemo(() => {
|
||||
return getRoomById(selectedRoomId) ?? ROOM_THEMES[0];
|
||||
@@ -212,15 +233,20 @@ export const SpaceWorkspaceWidget = () => {
|
||||
const canStart = goalInput.trim().length > 0;
|
||||
const isFocusMode = workspaceMode === 'focus';
|
||||
const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode);
|
||||
const resolvedPlaybackState = playbackState ?? previewPlaybackState;
|
||||
const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selectedTimerLabel);
|
||||
|
||||
const applyRecommendedSelections = useCallback((roomId: string) => {
|
||||
const applyRecommendedSelections = useCallback((
|
||||
roomId: string,
|
||||
overrideState: SelectionOverride = selectionOverride,
|
||||
) => {
|
||||
const room = getRoomById(roomId);
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectionOverride.timer) {
|
||||
if (!overrideState.timer) {
|
||||
const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId);
|
||||
|
||||
if (recommendedTimerLabel) {
|
||||
@@ -228,10 +254,13 @@ export const SpaceWorkspaceWidget = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectionOverride.sound && SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId)) {
|
||||
if (
|
||||
!overrideState.sound &&
|
||||
SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId)
|
||||
) {
|
||||
setSelectedPresetId(room.recommendedSoundPresetId);
|
||||
}
|
||||
}, [selectionOverride.sound, selectionOverride.timer, setSelectedPresetId]);
|
||||
}, [selectionOverride, setSelectedPresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedSelection = readStoredWorkspaceSelection();
|
||||
@@ -239,41 +268,77 @@ export const SpaceWorkspaceWidget = () => {
|
||||
sound: Boolean(storedSelection.override?.sound),
|
||||
timer: Boolean(storedSelection.override?.timer),
|
||||
};
|
||||
const restoredRoomId =
|
||||
!roomQuery && storedSelection.sceneId && getRoomById(storedSelection.sceneId)
|
||||
? storedSelection.sceneId
|
||||
: null;
|
||||
const restoredTimerLabel = !timerQuery
|
||||
? resolveTimerLabelFromPresetId(storedSelection.timerPresetId)
|
||||
: null;
|
||||
const restoredSoundPresetId =
|
||||
!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)
|
||||
? storedSelection.soundPresetId
|
||||
: null;
|
||||
const restoredGoal = storedSelection.goal?.trim() ?? '';
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
setSelectionOverride(restoredSelectionOverride);
|
||||
|
||||
setSelectionOverride(restoredSelectionOverride);
|
||||
|
||||
if (!roomQuery && storedSelection.sceneId && getRoomById(storedSelection.sceneId)) {
|
||||
setSelectedRoomId(storedSelection.sceneId);
|
||||
}
|
||||
|
||||
if (!timerQuery) {
|
||||
const restoredTimerLabel = resolveTimerLabelFromPresetId(storedSelection.timerPresetId);
|
||||
if (restoredRoomId) {
|
||||
setSelectedRoomId(restoredRoomId);
|
||||
}
|
||||
|
||||
if (restoredTimerLabel) {
|
||||
setSelectedTimerLabel(restoredTimerLabel);
|
||||
}
|
||||
}
|
||||
|
||||
if (!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)) {
|
||||
setSelectedPresetId(storedSelection.soundPresetId);
|
||||
}
|
||||
if (restoredSoundPresetId) {
|
||||
setSelectedPresetId(restoredSoundPresetId);
|
||||
}
|
||||
|
||||
const restoredGoal = storedSelection.goal?.trim() ?? '';
|
||||
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) {
|
||||
setResumeGoal(restoredGoal);
|
||||
setShowResumePrompt(true);
|
||||
}
|
||||
|
||||
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) {
|
||||
setResumeGoal(restoredGoal);
|
||||
setShowResumePrompt(true);
|
||||
}
|
||||
setHasHydratedSelection(true);
|
||||
});
|
||||
|
||||
setHasHydratedSelection(true);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [goalQuery, hasQueryOverrides, roomQuery, setSelectedPresetId, soundQuery, timerQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
applyRecommendedSelections(selectedRoomId);
|
||||
}, [applyRecommendedSelections, selectedRoomId]);
|
||||
if (!currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTimerLabel =
|
||||
resolveTimerLabelFromPresetId(currentSession.timerPresetId) ?? selectedTimerLabel;
|
||||
const nextSoundPresetId =
|
||||
currentSession.soundPresetId &&
|
||||
SOUND_PRESETS.some((preset) => preset.id === currentSession.soundPresetId)
|
||||
? currentSession.soundPresetId
|
||||
: selectedPresetId;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
setSelectedRoomId(currentSession.roomId);
|
||||
setSelectedTimerLabel(nextTimerLabel);
|
||||
setSelectedPresetId(nextSoundPresetId);
|
||||
setGoalInput(currentSession.goal);
|
||||
setSelectedGoalId(null);
|
||||
setShowResumePrompt(false);
|
||||
setPreviewPlaybackState(currentSession.state);
|
||||
setWorkspaceMode('focus');
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
|
||||
|
||||
const handleSelectRoom = (roomId: string) => {
|
||||
setSelectedRoomId(roomId);
|
||||
applyRecommendedSelections(roomId);
|
||||
};
|
||||
|
||||
const handleSelectTimer = (timerLabel: string, markOverride = false) => {
|
||||
@@ -326,19 +391,135 @@ export const SpaceWorkspaceWidget = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const startFocusFlow = async (
|
||||
nextGoal: string,
|
||||
entryPoint: 'space-setup' | 'goal-complete' | 'resume-restore' = 'space-setup',
|
||||
) => {
|
||||
const trimmedGoal = nextGoal.trim();
|
||||
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
||||
|
||||
if (!trimmedGoal || !timerPresetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowResumePrompt(false);
|
||||
setPreviewPlaybackState('running');
|
||||
setWorkspaceMode('focus');
|
||||
|
||||
const startedSession = await startSession({
|
||||
roomId: selectedRoomId,
|
||||
goal: trimmedGoal,
|
||||
timerPresetId,
|
||||
soundPresetId: selectedPresetId,
|
||||
entryPoint,
|
||||
});
|
||||
|
||||
if (!startedSession) {
|
||||
pushStatusLine({
|
||||
message: '세션 API 연결 실패 · 로컬 미리보기 모드로 계속해요.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (!canStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowResumePrompt(false);
|
||||
setWorkspaceMode('focus');
|
||||
void startFocusFlow(goalInput, 'space-setup');
|
||||
};
|
||||
|
||||
const handleExitRequested = () => {
|
||||
const handleExitRequested = async () => {
|
||||
const didAbandon = await abandonSession();
|
||||
|
||||
if (!didAbandon) {
|
||||
pushStatusLine({
|
||||
message: '세션 종료를 완료하지 못했어요.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewPlaybackState('running');
|
||||
setWorkspaceMode('setup');
|
||||
};
|
||||
|
||||
const handlePauseRequested = async () => {
|
||||
if (!currentSession) {
|
||||
setPreviewPlaybackState('paused');
|
||||
return;
|
||||
}
|
||||
|
||||
const pausedSession = await pauseSession();
|
||||
|
||||
if (!pausedSession) {
|
||||
pushStatusLine({
|
||||
message: '세션을 일시정지하지 못했어요.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeRequested = async () => {
|
||||
if (!currentSession) {
|
||||
setPreviewPlaybackState('running');
|
||||
return;
|
||||
}
|
||||
|
||||
const resumedSession = await resumeSession();
|
||||
|
||||
if (!resumedSession) {
|
||||
pushStatusLine({
|
||||
message: '세션을 다시 시작하지 못했어요.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartRequested = async () => {
|
||||
if (!currentSession) {
|
||||
pushStatusLine({
|
||||
message: '실제 세션이 시작된 뒤에만 다시 시작할 수 있어요.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const restartedSession = await restartCurrentPhase();
|
||||
|
||||
if (!restartedSession) {
|
||||
pushStatusLine({
|
||||
message: '현재 페이즈를 다시 시작하지 못했어요.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pushStatusLine({
|
||||
message: '현재 페이즈를 처음부터 다시 시작했어요.',
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoalAdvance = async (nextGoal: string) => {
|
||||
const trimmedNextGoal = nextGoal.trim();
|
||||
|
||||
if (!trimmedNextGoal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSession) {
|
||||
const completedSession = await completeSession({
|
||||
completionType: 'goal-complete',
|
||||
completedGoal: goalInput.trim(),
|
||||
});
|
||||
|
||||
if (!completedSession) {
|
||||
pushStatusLine({
|
||||
message: '현재 세션 완료를 서버에 반영하지 못했어요.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setGoalInput(trimmedNextGoal);
|
||||
setSelectedGoalId(null);
|
||||
void startFocusFlow(trimmedNextGoal, 'goal-complete');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const previousBodyOverflow = document.body.style.overflow;
|
||||
const previousHtmlOverflow = document.documentElement.style.overflow;
|
||||
@@ -418,7 +599,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
setGoalInput(resumeGoal);
|
||||
setSelectedGoalId(null);
|
||||
setShowResumePrompt(false);
|
||||
setWorkspaceMode('focus');
|
||||
void startFocusFlow(resumeGoal, 'resume-restore');
|
||||
},
|
||||
onStartFresh: () => {
|
||||
setGoalInput('');
|
||||
@@ -433,12 +614,22 @@ export const SpaceWorkspaceWidget = () => {
|
||||
<SpaceFocusHudWidget
|
||||
goal={goalInput.trim()}
|
||||
timerLabel={selectedTimerLabel}
|
||||
timeDisplay={resolvedTimeDisplay}
|
||||
visible={isFocusMode}
|
||||
onStatusMessage={pushStatusLine}
|
||||
onGoalUpdate={(nextGoal) => {
|
||||
setGoalInput(nextGoal);
|
||||
setSelectedGoalId(null);
|
||||
playbackState={resolvedPlaybackState}
|
||||
sessionPhase={phase ?? 'focus'}
|
||||
isSessionActionPending={isSessionMutating}
|
||||
onPauseRequested={() => {
|
||||
void handlePauseRequested();
|
||||
}}
|
||||
onResumeRequested={() => {
|
||||
void handleResumeRequested();
|
||||
}}
|
||||
onRestartRequested={() => {
|
||||
void handleRestartRequested();
|
||||
}}
|
||||
onStatusMessage={pushStatusLine}
|
||||
onGoalUpdate={handleGoalAdvance}
|
||||
/>
|
||||
|
||||
<FocusTopToast
|
||||
@@ -473,7 +664,9 @@ export const SpaceWorkspaceWidget = () => {
|
||||
onRestoreThoughts={restoreThoughts}
|
||||
onClearInbox={clearThoughts}
|
||||
onStatusMessage={pushStatusLine}
|
||||
onExitRequested={handleExitRequested}
|
||||
onExitRequested={() => {
|
||||
void handleExitRequested();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user