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

@@ -1,17 +1,60 @@
import { apiClient } from "@/shared/lib/apiClient";
import { AuthResponse, SocialLoginRequest } from "../types";
import { apiClient } from '@/shared/lib/apiClient';
import type { AuthResponse, SocialLoginRequest, UserMeResponse } from '../types';
interface RefreshTokenResponse {
accessToken: string;
refreshToken: string;
}
export const authApi = {
/**
* 프론트엔드에서 발급받은 소셜 토큰을 백엔드로 전송하여 VibeRoom 전용 토큰으로 교환합니다.
* @param data 구글/애플/페이스북에서 발급받은 Provider 이름과 Token
* Backend Codex:
* - 구글/애플/페이스북에서 받은 소셜 토큰을 검증하고 VibeRoom 전용 access/refresh token으로 교환한다.
* - 응답에는 accessToken, refreshToken, user를 포함한다.
* - user는 최소 id, name, email, grade를 포함해 로그인 직후 헤더/프로필 UI를 채울 수 있게 한다.
*/
loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => {
return apiClient<AuthResponse>("api/v1/auth/social", {
method: "POST",
return apiClient<AuthResponse>('api/v1/auth/social', {
method: 'POST',
body: JSON.stringify(data),
});
},
// TODO: 이후 필요 시 logout, refreshAccessToken 등 인증 관련 API 추가
/**
* Backend Codex:
* - 현재 액세스 토큰의 사용자 정보를 반환한다.
* - 응답에는 최소 id, name, email, grade를 포함한다.
* - 인증이 만료되었으면 401을 반환하고, 클라이언트가 refresh 흐름으로 넘어갈 수 있게 한다.
*/
getMe: async (): Promise<UserMeResponse> => {
return apiClient<UserMeResponse>('api/v1/auth/me', {
method: 'GET',
});
},
/**
* Backend Codex:
* - refresh token을 받아 새 access/refresh token 쌍을 재발급한다.
* - refresh token이 유효하지 않으면 401을 반환한다.
* - 클라이언트는 이 응답으로 쿠키와 전역 인증 상태를 갱신한다.
*/
refreshToken: async (refreshToken: string): Promise<RefreshTokenResponse> => {
return apiClient<RefreshTokenResponse>('api/v1/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
},
/**
* Backend Codex:
* - 현재 로그인 세션을 서버에서 종료한다.
* - 토큰 블랙리스트 또는 세션 무효화 정책이 있다면 여기서 처리한다.
* - 이미 만료된 세션이어도 멱등적으로 204 또는 성공 응답을 반환한다.
*/
logout: async (): Promise<void> => {
return apiClient<void>('api/v1/auth/logout', {
method: 'POST',
expectNoContent: true,
});
},
};

View File

@@ -4,7 +4,6 @@ import { GoogleOAuthProvider } from "@react-oauth/google";
import { useSocialLogin } from "../hooks/useSocialLogin";
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
/**
* 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다.
@@ -13,8 +12,6 @@ const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
const SocialLoginButtons = () => {
const {
loginWithGoogle,
loginWithApple,
handleFacebookCallback,
isLoading,
error,
} = useSocialLogin();

View File

@@ -1,9 +1,36 @@
import { useAuthStore } from "@/store/useAuthStore";
import { useGoogleLogin } from "@react-oauth/google";
import { useRouter } from "next/navigation";
import { useState } from "react";
import appleAuthHelpers from "react-apple-signin-auth";
import { authApi } from "../api/authApi";
import { useGoogleLogin } from '@react-oauth/google';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import appleAuthHelpers from 'react-apple-signin-auth';
import { useAuthStore } from '@/store/useAuthStore';
import { authApi } from '../api/authApi';
interface AppleSignInResponse {
authorization?: {
id_token?: string;
};
}
interface AppleSignInError {
error?: string;
}
interface AppleAuthHelperBridge {
signIn: (options: {
authOptions: {
clientId: string;
scope: string;
redirectURI: string;
usePopup: boolean;
};
onSuccess: (response: AppleSignInResponse) => void;
onError: (err: AppleSignInError) => void;
}) => void;
}
interface FacebookLoginResponse {
accessToken?: string;
}
export const useSocialLogin = () => {
const router = useRouter();
@@ -15,30 +42,23 @@ export const useSocialLogin = () => {
* 플랫폼별 SDK에서 획득한 토큰을 백엔드로 보내어 VibeRoom 전용 JWT로 교환합니다.
*/
const handleSocialLogin = async (
provider: "google" | "apple" | "facebook",
provider: 'google' | 'apple' | 'facebook',
socialToken: string,
) => {
setIsLoading(true);
setError(null);
console.log(`[${provider}] token:`, socialToken);
try {
// 1. 백엔드로 소셜 토큰 전송 (토큰 교환)
const response = await authApi.loginWithSocial({
provider,
token: socialToken,
});
console.log(`[${provider}] 백엔드 연동 성공! JWT:`, response.accessToken);
// 2. 응답받은 VibeRoom 전용 토큰과 유저 정보를 전역 상태 및 쿠키에 저장
useAuthStore.getState().setAuth(response);
// 3. 메인 허브 화면으로 이동
router.push("/app");
router.push('/app');
} catch (err) {
console.error(`[${provider}] 로그인 실패:`, err);
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
setError('로그인에 실패했습니다. 다시 시도해 주세요.');
} finally {
setIsLoading(false);
}
@@ -50,11 +70,10 @@ export const useSocialLogin = () => {
*/
const loginWithGoogle = useGoogleLogin({
onSuccess: (tokenResponse) => {
console.log(tokenResponse);
handleSocialLogin("google", tokenResponse.access_token);
handleSocialLogin('google', tokenResponse.access_token);
},
onError: () => {
setError("구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.");
setError('구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.');
},
});
@@ -64,39 +83,28 @@ export const useSocialLogin = () => {
*/
const loginWithApple = () => {
try {
const appleHelperBridge = appleAuthHelpers as unknown as {
signIn: (options: {
authOptions: {
clientId: string;
scope: string;
redirectURI: string;
usePopup: boolean;
};
onSuccess: (response: any) => void;
onError: (err: any) => void;
}) => void;
};
const appleHelperBridge = appleAuthHelpers as unknown as AppleAuthHelperBridge;
appleHelperBridge.signIn({
authOptions: {
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || "",
scope: "email name",
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || '',
scope: 'email name',
redirectURI: window.location.origin, // Apple 요구사항: 현재 도메인 입력 필요
usePopup: true,
},
onSuccess: (response: any) => {
onSuccess: (response: AppleSignInResponse) => {
if (response.authorization?.id_token) {
handleSocialLogin("apple", response.authorization.id_token);
handleSocialLogin('apple', response.authorization.id_token);
}
},
onError: (err: any) => {
console.error("Apple SignIn error:", err);
setError("애플 로그인 중 오류가 발생했습니다.");
onError: (err: AppleSignInError) => {
console.error('Apple SignIn error:', err);
setError('애플 로그인 중 오류가 발생했습니다.');
},
});
} catch (err) {
console.error(err);
setError("애플 로그인 초기화 실패");
setError('애플 로그인 초기화 실패');
}
};
@@ -104,11 +112,11 @@ export const useSocialLogin = () => {
* [비즈니스 로직 4] Facebook Callback
* react-facebook-login 컴포넌트에서 콜백으로 받은 토큰을 처리합니다.
*/
const handleFacebookCallback = (response: any) => {
const handleFacebookCallback = (response: FacebookLoginResponse) => {
if (response?.accessToken) {
handleSocialLogin("facebook", response.accessToken);
handleSocialLogin('facebook', response.accessToken);
} else {
setError("페이스북 로그인에 실패했습니다.");
setError('페이스북 로그인에 실패했습니다.');
}
};

View File

@@ -65,12 +65,12 @@ export const useHoldToConfirm = (onConfirm: () => void) => {
};
}, []);
const step = () => {
const step = (timestamp: number) => {
if (startRef.current === null) {
return;
}
const elapsedMs = performance.now() - startRef.current;
const elapsedMs = timestamp - startRef.current;
const nextProgress = mapProgress(elapsedMs);
const clampedProgress = Math.min(nextProgress, 1);
setProgress(clampedProgress);

View File

@@ -0,0 +1,130 @@
import { apiClient } from '@/shared/lib/apiClient';
export type FocusSessionPhase = 'focus' | 'break';
export type FocusSessionState = 'running' | 'paused';
export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete';
export interface FocusSession {
id: string;
roomId: string;
goal: string;
timerPresetId: string;
soundPresetId: string | null;
phase: FocusSessionPhase;
state: FocusSessionState;
phaseStartedAt: string;
phaseEndsAt: string | null;
phaseRemainingSeconds: number;
focusDurationSeconds: number;
breakDurationSeconds: number;
startedAt: string;
completedAt: string | null;
abandonedAt: string | null;
serverNow: string;
}
export interface StartFocusSessionRequest {
roomId: string;
goal: string;
timerPresetId: string;
soundPresetId?: string | null;
entryPoint?: 'space-setup' | 'goal-complete' | 'resume-restore';
}
export interface CompleteFocusSessionRequest {
completionType: FocusSessionCompletionType;
completedGoal?: string;
}
export const focusSessionApi = {
/**
* Backend Codex:
* - 로그인한 사용자의 진행 중 세션을 0개 또는 1개 반환한다.
* - 응답에는 serverNow, phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 포함해
* 클라이언트가 남은 시간을 안정적으로 계산할 수 있게 한다.
* - 진행 중 세션이 없으면 `data: null`을 반환한다.
*/
getCurrentSession: async (): Promise<FocusSession | null> => {
return apiClient<FocusSession | null>('api/v1/focus-sessions/current', {
method: 'GET',
});
},
/**
* Backend Codex:
* - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다.
* - roomId, goal, timerPresetId, soundPresetId를 저장한다.
* - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다.
*/
startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions', {
method: 'POST',
body: JSON.stringify(payload),
});
},
/**
* Backend Codex:
* - 현재 세션의 현재 phase를 일시정지한다.
* - phaseRemainingSeconds를 정확히 저장해 재개 시 이어서 동작하게 한다.
* - 이미 paused 상태여도 멱등적으로 최신 세션 상태를 반환한다.
*/
pauseSession: async (sessionId: string): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/pause`, {
method: 'POST',
});
},
/**
* Backend Codex:
* - 일시정지된 세션을 재개하고 새 phaseEndsAt/serverNow를 반환한다.
* - 이미 running 상태여도 멱등적으로 최신 세션 상태를 반환한다.
* - 남은 시간을 다시 계산할 수 있게 phaseRemainingSeconds도 함께 내려준다.
*/
resumeSession: async (sessionId: string): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/resume`, {
method: 'POST',
});
},
/**
* Backend Codex:
* - 현재 진행 중인 phase를 처음 길이로 다시 시작한다.
* - focus/break 어느 phase인지 유지한 채 phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 갱신한다.
* - 클라이언트의 Reset 버튼은 이 API 응답으로 즉시 HUD를 다시 그린다.
*/
restartCurrentPhase: async (sessionId: string): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/restart-phase`, {
method: 'POST',
});
},
/**
* Backend Codex:
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
* - completionType으로 goal-complete / timer-complete을 구분해 저장한다.
* - 완료된 세션 스냅샷을 반환하거나, 최소한 성공적으로 완료되었음을 알 수 있는 응답을 보낸다.
*/
completeSession: async (
sessionId: string,
payload: CompleteFocusSessionRequest,
): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/complete`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
/**
* Backend Codex:
* - 현재 세션을 중도 종료(abandon) 처리한다.
* - 통계에서는 abandon 여부를 구분할 수 있게 저장한다.
* - 성공 시 204 또는 빈 성공 응답을 반환한다.
*/
abandonSession: async (sessionId: string): Promise<void> => {
return apiClient<void>(`api/v1/focus-sessions/${sessionId}/abandon`, {
method: 'POST',
expectNoContent: true,
});
},
};

View File

@@ -0,0 +1,2 @@
export * from './api/focusSessionApi';
export * from './model/useFocusSessionEngine';

View File

@@ -0,0 +1,267 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
focusSessionApi,
type CompleteFocusSessionRequest,
type FocusSession,
type FocusSessionPhase,
type FocusSessionState,
type StartFocusSessionRequest,
} from '../api/focusSessionApi';
const SESSION_SYNC_INTERVAL_MS = 30_000;
const TIMER_TICK_INTERVAL_MS = 1_000;
const padClock = (value: number) => String(value).padStart(2, '0');
export const formatDurationClock = (totalSeconds: number) => {
const safeSeconds = Math.max(0, totalSeconds);
const minutes = Math.floor(safeSeconds / 60);
const seconds = safeSeconds % 60;
return `${padClock(minutes)}:${padClock(seconds)}`;
};
const getServerOffsetMs = (session: FocusSession) => {
const serverNowMs = Date.parse(session.serverNow);
if (Number.isNaN(serverNowMs)) {
return 0;
}
return serverNowMs - Date.now();
};
const getRemainingSeconds = (
session: FocusSession | null,
serverOffsetMs: number,
tickMs: number,
) => {
if (!session) {
return null;
}
if (session.state === 'paused' || !session.phaseEndsAt) {
return Math.max(0, session.phaseRemainingSeconds);
}
const phaseEndMs = Date.parse(session.phaseEndsAt);
if (Number.isNaN(phaseEndMs)) {
return Math.max(0, session.phaseRemainingSeconds);
}
return Math.max(0, Math.ceil((phaseEndMs - (tickMs + serverOffsetMs)) / 1000));
};
interface UseFocusSessionEngineResult {
currentSession: FocusSession | null;
isBootstrapping: boolean;
isMutating: boolean;
error: string | null;
remainingSeconds: number | null;
timeDisplay: string | null;
playbackState: FocusSessionState | null;
phase: FocusSessionPhase | null;
syncCurrentSession: () => Promise<FocusSession | null>;
startSession: (payload: StartFocusSessionRequest) => Promise<FocusSession | null>;
pauseSession: () => Promise<FocusSession | null>;
resumeSession: () => Promise<FocusSession | null>;
restartCurrentPhase: () => Promise<FocusSession | null>;
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
abandonSession: () => Promise<boolean>;
clearError: () => void;
}
export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
const [isBootstrapping, setBootstrapping] = useState(true);
const [isMutating, setMutating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [serverOffsetMs, setServerOffsetMs] = useState(0);
const [tickMs, setTickMs] = useState(Date.now());
const didHydrateRef = useRef(false);
const applySession = useCallback((session: FocusSession | null) => {
setCurrentSession(session);
setServerOffsetMs(session ? getServerOffsetMs(session) : 0);
setTickMs(Date.now());
return session;
}, []);
const syncCurrentSession = useCallback(async () => {
try {
const session = await focusSessionApi.getCurrentSession();
setError(null);
return applySession(session);
} catch (nextError) {
const message =
nextError instanceof Error ? nextError.message : '세션 엔진과 동기화하지 못했어요.';
setError(message);
return null;
} finally {
setBootstrapping(false);
}
}, [applySession]);
useEffect(() => {
if (didHydrateRef.current) {
return;
}
didHydrateRef.current = true;
void syncCurrentSession();
}, [syncCurrentSession]);
useEffect(() => {
if (!currentSession) {
return;
}
const intervalId = window.setInterval(() => {
void syncCurrentSession();
}, SESSION_SYNC_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [currentSession, syncCurrentSession]);
useEffect(() => {
if (!currentSession || currentSession.state !== 'running') {
return;
}
const intervalId = window.setInterval(() => {
setTickMs(Date.now());
}, TIMER_TICK_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [currentSession]);
const remainingSeconds = useMemo(() => {
return getRemainingSeconds(currentSession, serverOffsetMs, tickMs);
}, [currentSession, serverOffsetMs, tickMs]);
useEffect(() => {
if (!currentSession || currentSession.state !== 'running' || remainingSeconds !== 0) {
return;
}
const timeoutId = window.setTimeout(() => {
void syncCurrentSession();
}, 1_200);
return () => {
window.clearTimeout(timeoutId);
};
}, [currentSession, remainingSeconds, syncCurrentSession]);
const runMutation = async <T>(task: () => Promise<T>, fallbackMessage: string) => {
setMutating(true);
try {
const result = await task();
setError(null);
return result;
} catch (nextError) {
const message = nextError instanceof Error ? nextError.message : fallbackMessage;
setError(message);
return null;
} finally {
setMutating(false);
}
};
return {
currentSession,
isBootstrapping,
isMutating,
error,
remainingSeconds,
timeDisplay: remainingSeconds === null ? null : formatDurationClock(remainingSeconds),
playbackState: currentSession?.state ?? null,
phase: currentSession?.phase ?? null,
syncCurrentSession,
startSession: async (payload) => {
const session = await runMutation(
() => focusSessionApi.startSession(payload),
'세션을 시작하지 못했어요.',
);
return applySession(session);
},
pauseSession: async () => {
if (!currentSession) {
return null;
}
const session = await runMutation(
() => focusSessionApi.pauseSession(currentSession.id),
'세션을 일시정지하지 못했어요.',
);
return applySession(session);
},
resumeSession: async () => {
if (!currentSession) {
return null;
}
const session = await runMutation(
() => focusSessionApi.resumeSession(currentSession.id),
'세션을 다시 시작하지 못했어요.',
);
return applySession(session);
},
restartCurrentPhase: async () => {
if (!currentSession) {
return null;
}
const session = await runMutation(
() => focusSessionApi.restartCurrentPhase(currentSession.id),
'현재 페이즈를 다시 시작하지 못했어요.',
);
return applySession(session);
},
completeSession: async (payload) => {
if (!currentSession) {
return null;
}
const session = await runMutation(
() => focusSessionApi.completeSession(currentSession.id, payload),
'세션을 완료 처리하지 못했어요.',
);
if (session) {
applySession(null);
}
return session;
},
abandonSession: async () => {
if (!currentSession) {
return true;
}
const result = await runMutation(
() => focusSessionApi.abandonSession(currentSession.id),
'세션을 종료하지 못했어요.',
);
if (result === null) {
return false;
}
applySession(null);
return true;
},
clearError: () => setError(null),
};
};

View File

@@ -0,0 +1,51 @@
import {
DEFAULT_PRESET_OPTIONS,
NOTIFICATION_INTENSITY_OPTIONS,
} from '@/shared/config/settingsOptions';
import { apiClient } from '@/shared/lib/apiClient';
export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number];
export type DefaultPresetId = (typeof DEFAULT_PRESET_OPTIONS)[number]['id'];
export interface UserFocusPreferences {
reduceMotion: boolean;
notificationIntensity: NotificationIntensity;
defaultPresetId: DefaultPresetId;
}
export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
reduceMotion: false,
notificationIntensity: '기본',
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id,
};
export const preferencesApi = {
/**
* Backend Codex:
* - 로그인한 사용자의 집중 관련 개인 설정을 반환한다.
* - 최소 reduceMotion, notificationIntensity, defaultPresetId를 포함한다.
* - 아직 저장된 값이 없으면 서버 기본값을 내려주거나 null 필드 없이 기본 스키마로 응답한다.
*/
getFocusPreferences: async (): Promise<UserFocusPreferences> => {
return apiClient<UserFocusPreferences>('api/v1/users/me/focus-preferences', {
method: 'GET',
});
},
/**
* Backend Codex:
* - 사용자의 집중 개인 설정 일부 또는 전체를 patch 방식으로 저장한다.
* - 저장 후 최종 스냅샷 전체를 반환해 프론트가 로컬 상태를 서버 기준으로 맞출 수 있게 한다.
* - 알 수 없는 필드는 무시하지 말고 400으로 검증 오류를 돌려준다.
*/
updateFocusPreferences: async (
payload: UpdateUserFocusPreferencesRequest,
): Promise<UserFocusPreferences> => {
return apiClient<UserFocusPreferences>('api/v1/users/me/focus-preferences', {
method: 'PATCH',
body: JSON.stringify(payload),
});
},
};

View File

@@ -0,0 +1,2 @@
export * from './api/preferencesApi';
export * from './model/useUserFocusPreferences';

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import {
DEFAULT_USER_FOCUS_PREFERENCES,
preferencesApi,
type UpdateUserFocusPreferencesRequest,
type UserFocusPreferences,
} from '../api/preferencesApi';
interface UseUserFocusPreferencesResult {
preferences: UserFocusPreferences;
isLoading: boolean;
isSaving: boolean;
error: string | null;
saveStateLabel: string | null;
updatePreferences: (patch: UpdateUserFocusPreferencesRequest) => Promise<void>;
refetch: () => Promise<void>;
}
export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => {
const [preferences, setPreferences] = useState<UserFocusPreferences>(DEFAULT_USER_FOCUS_PREFERENCES);
const [isLoading, setLoading] = useState(true);
const [isSaving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveStateLabel, setSaveStateLabel] = useState<string | null>(null);
const saveStateTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (saveStateTimerRef.current !== null) {
window.clearTimeout(saveStateTimerRef.current);
saveStateTimerRef.current = null;
}
};
}, []);
const pushSavedLabel = (label: string) => {
setSaveStateLabel(label);
if (saveStateTimerRef.current !== null) {
window.clearTimeout(saveStateTimerRef.current);
}
saveStateTimerRef.current = window.setTimeout(() => {
setSaveStateLabel(null);
saveStateTimerRef.current = null;
}, 2200);
};
const refetch = async () => {
setLoading(true);
try {
const nextPreferences = await preferencesApi.getFocusPreferences();
setPreferences(nextPreferences);
setError(null);
} catch (nextError) {
const message =
nextError instanceof Error ? nextError.message : '설정을 불러오지 못했어요.';
setPreferences(DEFAULT_USER_FOCUS_PREFERENCES);
setError(message);
} finally {
setLoading(false);
}
};
useEffect(() => {
void refetch();
}, []);
const updatePreferences = async (patch: UpdateUserFocusPreferencesRequest) => {
const previous = preferences;
const optimistic = { ...preferences, ...patch };
setPreferences(optimistic);
setSaving(true);
setError(null);
try {
const persisted = await preferencesApi.updateFocusPreferences(patch);
setPreferences(persisted);
pushSavedLabel('저장됨');
} catch (nextError) {
const message =
nextError instanceof Error ? nextError.message : '설정을 저장하지 못했어요.';
setPreferences(previous);
setError(message);
pushSavedLabel('저장 실패');
} finally {
setSaving(false);
}
};
return {
preferences,
isLoading,
isSaving,
error,
saveStateLabel,
updatePreferences,
refetch,
};
};

View File

@@ -0,0 +1,36 @@
import { apiClient } from '@/shared/lib/apiClient';
export interface FocusTrendPoint {
date: string;
focusMinutes: number;
completedCycles: number;
}
export interface FocusStatsSummary {
today: {
focusMinutes: number;
completedCycles: number;
sessionEntries: number;
};
last7Days: {
focusMinutes: number;
bestDayLabel: string;
bestDayFocusMinutes: number;
streakDays: number;
};
trend: FocusTrendPoint[];
}
export const statsApi = {
/**
* Backend Codex:
* - 로그인한 사용자의 집중 통계 요약을 반환한다.
* - today, last7Days, trend를 한 번에 내려 프론트가 화면 진입 시 즉시 렌더링할 수 있게 한다.
* - 단위는 계산이 쉬운 숫자형(minutes, counts)으로 내려주고, 라벨 포맷팅은 프론트가 맡는다.
*/
getFocusStatsSummary: async (): Promise<FocusStatsSummary> => {
return apiClient<FocusStatsSummary>('api/v1/stats/focus-summary', {
method: 'GET',
});
},
};

View File

@@ -0,0 +1,2 @@
export * from './api/statsApi';
export * from './model/useFocusStats';

View File

@@ -0,0 +1,78 @@
'use client';
import { useEffect, useState } from 'react';
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
import { statsApi, type FocusStatsSummary } from '../api/statsApi';
type StatsSource = 'api' | 'mock';
const parseDurationLabelToMinutes = (label: string) => {
const hourMatch = label.match(/(\d+)h/);
const minuteMatch = label.match(/(\d+)m/);
const hours = hourMatch ? Number(hourMatch[1]) : 0;
const minutes = minuteMatch ? Number(minuteMatch[1]) : 0;
return hours * 60 + minutes;
};
const buildMockSummary = (): FocusStatsSummary => {
return {
today: {
focusMinutes: parseDurationLabelToMinutes(TODAY_STATS[0]?.value ?? '0m'),
completedCycles: Number.parseInt(TODAY_STATS[1]?.value ?? '0', 10) || 0,
sessionEntries: Number.parseInt(TODAY_STATS[2]?.value ?? '0', 10) || 0,
},
last7Days: {
focusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[0]?.value ?? '0m'),
bestDayLabel: WEEKLY_STATS[1]?.value ?? '-',
bestDayFocusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[1]?.delta ?? '0m'),
streakDays: Number.parseInt(WEEKLY_STATS[2]?.value ?? '0', 10) || 0,
},
trend: [],
};
};
interface UseFocusStatsResult {
summary: FocusStatsSummary;
isLoading: boolean;
error: string | null;
source: StatsSource;
refetch: () => Promise<void>;
}
export const useFocusStats = (): UseFocusStatsResult => {
const [summary, setSummary] = useState<FocusStatsSummary>(buildMockSummary);
const [isLoading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [source, setSource] = useState<StatsSource>('mock');
const load = async () => {
setLoading(true);
try {
const nextSummary = await statsApi.getFocusStatsSummary();
setSummary(nextSummary);
setSource('api');
setError(null);
} catch (nextError) {
const message =
nextError instanceof Error ? nextError.message : '통계를 불러오지 못했어요.';
setSummary(buildMockSummary());
setSource('mock');
setError(message);
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
return {
summary,
isLoading,
error,
source,
refetch: load,
};
};