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

@@ -8,22 +8,36 @@ import { GoalFlashOverlay } from './GoalFlashOverlay';
interface SpaceFocusHudWidgetProps {
goal: string;
timerLabel: string;
timeDisplay?: string;
visible: boolean;
onGoalUpdate: (nextGoal: string) => void;
playbackState?: 'running' | 'paused';
sessionPhase?: 'focus' | 'break' | null;
isSessionActionPending?: boolean;
onPauseRequested?: () => void;
onResumeRequested?: () => void;
onRestartRequested?: () => void;
onGoalUpdate: (nextGoal: string) => void | Promise<void>;
onStatusMessage: (payload: HudStatusLinePayload) => void;
}
export const SpaceFocusHudWidget = ({
goal,
timerLabel,
timeDisplay,
visible,
playbackState = 'running',
sessionPhase = 'focus',
isSessionActionPending = false,
onPauseRequested,
onResumeRequested,
onRestartRequested,
onGoalUpdate,
onStatusMessage,
}: SpaceFocusHudWidgetProps) => {
const reducedMotion = useReducedMotion();
const [flashVisible, setFlashVisible] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const playbackStateRef = useRef<'running' | 'paused'>('running');
const playbackStateRef = useRef<'running' | 'paused'>(playbackState);
const flashTimerRef = useRef<number | null>(null);
const restReminderTimerRef = useRef<number | null>(null);
@@ -59,13 +73,40 @@ export const SpaceFocusHudWidget = ({
useEffect(() => {
if (!visible || reducedMotion) {
setFlashVisible(false);
return;
const rafId = window.requestAnimationFrame(() => {
setFlashVisible(false);
});
return () => {
window.cancelAnimationFrame(rafId);
};
}
triggerFlash(2000);
const timeoutId = window.setTimeout(() => {
triggerFlash(2000);
}, 0);
return () => {
window.clearTimeout(timeoutId);
};
}, [visible, reducedMotion, triggerFlash]);
useEffect(() => {
if (playbackStateRef.current === 'paused' && playbackState === 'running' && visible && !reducedMotion) {
const timeoutId = window.setTimeout(() => {
triggerFlash(1000);
}, 0);
playbackStateRef.current = playbackState;
return () => {
window.clearTimeout(timeoutId);
};
}
playbackStateRef.current = playbackState;
}, [playbackState, reducedMotion, triggerFlash, visible]);
useEffect(() => {
const ENABLE_PERIODIC_FLASH = false;
@@ -96,21 +137,16 @@ export const SpaceFocusHudWidget = ({
<SpaceTimerHudWidget
timerLabel={timerLabel}
goal={goal}
timeDisplay={timeDisplay}
isImmersionMode
sessionPhase={sessionPhase}
playbackState={playbackState}
isControlsDisabled={isSessionActionPending}
className="pr-[4.2rem]"
onGoalCompleteRequest={handleOpenCompleteSheet}
onPlaybackStateChange={(state) => {
if (reducedMotion) {
playbackStateRef.current = state;
return;
}
if (playbackStateRef.current === 'paused' && state === 'running') {
triggerFlash(1000);
}
playbackStateRef.current = state;
}}
onStartClick={onResumeRequested}
onPauseClick={onPauseRequested}
onResetClick={onRestartRequested}
/>
<GoalCompleteSheet
open={sheetOpen}
@@ -129,7 +165,7 @@ export const SpaceFocusHudWidget = ({
}, 5 * 60 * 1000);
}}
onConfirm={(nextGoal) => {
onGoalUpdate(nextGoal);
void onGoalUpdate(nextGoal);
setSheetOpen(false);
onStatusMessage({
message: `이번 한 조각 · ${nextGoal}`,