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:
2026-03-07 17:54:15 +09:00
parent 09b02f4168
commit d18d9b2bb9
23 changed files with 1370 additions and 184 deletions

View File

@@ -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>
);