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:
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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('페이스북 로그인에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
130
src/features/focus-session/api/focusSessionApi.ts
Normal file
130
src/features/focus-session/api/focusSessionApi.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
2
src/features/focus-session/index.ts
Normal file
2
src/features/focus-session/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api/focusSessionApi';
|
||||
export * from './model/useFocusSessionEngine';
|
||||
267
src/features/focus-session/model/useFocusSessionEngine.ts
Normal file
267
src/features/focus-session/model/useFocusSessionEngine.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
51
src/features/preferences/api/preferencesApi.ts
Normal file
51
src/features/preferences/api/preferencesApi.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
};
|
||||
2
src/features/preferences/index.ts
Normal file
2
src/features/preferences/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api/preferencesApi';
|
||||
export * from './model/useUserFocusPreferences';
|
||||
104
src/features/preferences/model/useUserFocusPreferences.ts
Normal file
104
src/features/preferences/model/useUserFocusPreferences.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
36
src/features/stats/api/statsApi.ts
Normal file
36
src/features/stats/api/statsApi.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
};
|
||||
2
src/features/stats/index.ts
Normal file
2
src/features/stats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api/statsApi';
|
||||
export * from './model/useFocusStats';
|
||||
78
src/features/stats/model/useFocusStats.ts
Normal file
78
src/features/stats/model/useFocusStats.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user