diff --git a/src/features/auth/api/authApi.ts b/src/features/auth/api/authApi.ts index 8a043bd..ab360d3 100644 --- a/src/features/auth/api/authApi.ts +++ b/src/features/auth/api/authApi.ts @@ -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 => { - return apiClient("api/v1/auth/social", { - method: "POST", + return apiClient('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 => { + return apiClient('api/v1/auth/me', { + method: 'GET', + }); + }, + + /** + * Backend Codex: + * - refresh token을 받아 새 access/refresh token 쌍을 재발급한다. + * - refresh token이 유효하지 않으면 401을 반환한다. + * - 클라이언트는 이 응답으로 쿠키와 전역 인증 상태를 갱신한다. + */ + refreshToken: async (refreshToken: string): Promise => { + return apiClient('api/v1/auth/refresh', { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }); + }, + + /** + * Backend Codex: + * - 현재 로그인 세션을 서버에서 종료한다. + * - 토큰 블랙리스트 또는 세션 무효화 정책이 있다면 여기서 처리한다. + * - 이미 만료된 세션이어도 멱등적으로 204 또는 성공 응답을 반환한다. + */ + logout: async (): Promise => { + return apiClient('api/v1/auth/logout', { + method: 'POST', + expectNoContent: true, + }); + }, }; diff --git a/src/features/auth/components/SocialLoginGroup.tsx b/src/features/auth/components/SocialLoginGroup.tsx index b9ede44..2b194ca 100644 --- a/src/features/auth/components/SocialLoginGroup.tsx +++ b/src/features/auth/components/SocialLoginGroup.tsx @@ -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(); diff --git a/src/features/auth/hooks/useSocialLogin.ts b/src/features/auth/hooks/useSocialLogin.ts index 9e832e0..537e38b 100644 --- a/src/features/auth/hooks/useSocialLogin.ts +++ b/src/features/auth/hooks/useSocialLogin.ts @@ -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('페이스북 로그인에 실패했습니다.'); } }; diff --git a/src/features/exit-hold/model/useHoldToConfirm.ts b/src/features/exit-hold/model/useHoldToConfirm.ts index acd1eb0..b6089da 100644 --- a/src/features/exit-hold/model/useHoldToConfirm.ts +++ b/src/features/exit-hold/model/useHoldToConfirm.ts @@ -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); diff --git a/src/features/focus-session/api/focusSessionApi.ts b/src/features/focus-session/api/focusSessionApi.ts new file mode 100644 index 0000000..d31ec1f --- /dev/null +++ b/src/features/focus-session/api/focusSessionApi.ts @@ -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 => { + return apiClient('api/v1/focus-sessions/current', { + method: 'GET', + }); + }, + + /** + * Backend Codex: + * - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다. + * - roomId, goal, timerPresetId, soundPresetId를 저장한다. + * - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다. + */ + startSession: async (payload: StartFocusSessionRequest): Promise => { + return apiClient('api/v1/focus-sessions', { + method: 'POST', + body: JSON.stringify(payload), + }); + }, + + /** + * Backend Codex: + * - 현재 세션의 현재 phase를 일시정지한다. + * - phaseRemainingSeconds를 정확히 저장해 재개 시 이어서 동작하게 한다. + * - 이미 paused 상태여도 멱등적으로 최신 세션 상태를 반환한다. + */ + pauseSession: async (sessionId: string): Promise => { + return apiClient(`api/v1/focus-sessions/${sessionId}/pause`, { + method: 'POST', + }); + }, + + /** + * Backend Codex: + * - 일시정지된 세션을 재개하고 새 phaseEndsAt/serverNow를 반환한다. + * - 이미 running 상태여도 멱등적으로 최신 세션 상태를 반환한다. + * - 남은 시간을 다시 계산할 수 있게 phaseRemainingSeconds도 함께 내려준다. + */ + resumeSession: async (sessionId: string): Promise => { + return apiClient(`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 => { + return apiClient(`api/v1/focus-sessions/${sessionId}/restart-phase`, { + method: 'POST', + }); + }, + + /** + * Backend Codex: + * - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다. + * - completionType으로 goal-complete / timer-complete을 구분해 저장한다. + * - 완료된 세션 스냅샷을 반환하거나, 최소한 성공적으로 완료되었음을 알 수 있는 응답을 보낸다. + */ + completeSession: async ( + sessionId: string, + payload: CompleteFocusSessionRequest, + ): Promise => { + return apiClient(`api/v1/focus-sessions/${sessionId}/complete`, { + method: 'POST', + body: JSON.stringify(payload), + }); + }, + + /** + * Backend Codex: + * - 현재 세션을 중도 종료(abandon) 처리한다. + * - 통계에서는 abandon 여부를 구분할 수 있게 저장한다. + * - 성공 시 204 또는 빈 성공 응답을 반환한다. + */ + abandonSession: async (sessionId: string): Promise => { + return apiClient(`api/v1/focus-sessions/${sessionId}/abandon`, { + method: 'POST', + expectNoContent: true, + }); + }, +}; diff --git a/src/features/focus-session/index.ts b/src/features/focus-session/index.ts new file mode 100644 index 0000000..f5b6d6e --- /dev/null +++ b/src/features/focus-session/index.ts @@ -0,0 +1,2 @@ +export * from './api/focusSessionApi'; +export * from './model/useFocusSessionEngine'; diff --git a/src/features/focus-session/model/useFocusSessionEngine.ts b/src/features/focus-session/model/useFocusSessionEngine.ts new file mode 100644 index 0000000..761c64a --- /dev/null +++ b/src/features/focus-session/model/useFocusSessionEngine.ts @@ -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; + startSession: (payload: StartFocusSessionRequest) => Promise; + pauseSession: () => Promise; + resumeSession: () => Promise; + restartCurrentPhase: () => Promise; + completeSession: (payload: CompleteFocusSessionRequest) => Promise; + abandonSession: () => Promise; + clearError: () => void; +} + +export const useFocusSessionEngine = (): UseFocusSessionEngineResult => { + const [currentSession, setCurrentSession] = useState(null); + const [isBootstrapping, setBootstrapping] = useState(true); + const [isMutating, setMutating] = useState(false); + const [error, setError] = useState(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 (task: () => Promise, 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), + }; +}; diff --git a/src/features/preferences/api/preferencesApi.ts b/src/features/preferences/api/preferencesApi.ts new file mode 100644 index 0000000..ad49751 --- /dev/null +++ b/src/features/preferences/api/preferencesApi.ts @@ -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; + +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 => { + return apiClient('api/v1/users/me/focus-preferences', { + method: 'GET', + }); + }, + + /** + * Backend Codex: + * - 사용자의 집중 개인 설정 일부 또는 전체를 patch 방식으로 저장한다. + * - 저장 후 최종 스냅샷 전체를 반환해 프론트가 로컬 상태를 서버 기준으로 맞출 수 있게 한다. + * - 알 수 없는 필드는 무시하지 말고 400으로 검증 오류를 돌려준다. + */ + updateFocusPreferences: async ( + payload: UpdateUserFocusPreferencesRequest, + ): Promise => { + return apiClient('api/v1/users/me/focus-preferences', { + method: 'PATCH', + body: JSON.stringify(payload), + }); + }, +}; diff --git a/src/features/preferences/index.ts b/src/features/preferences/index.ts new file mode 100644 index 0000000..13d30c4 --- /dev/null +++ b/src/features/preferences/index.ts @@ -0,0 +1,2 @@ +export * from './api/preferencesApi'; +export * from './model/useUserFocusPreferences'; diff --git a/src/features/preferences/model/useUserFocusPreferences.ts b/src/features/preferences/model/useUserFocusPreferences.ts new file mode 100644 index 0000000..9acfca8 --- /dev/null +++ b/src/features/preferences/model/useUserFocusPreferences.ts @@ -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; + refetch: () => Promise; +} + +export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => { + const [preferences, setPreferences] = useState(DEFAULT_USER_FOCUS_PREFERENCES); + const [isLoading, setLoading] = useState(true); + const [isSaving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saveStateLabel, setSaveStateLabel] = useState(null); + const saveStateTimerRef = useRef(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, + }; +}; diff --git a/src/features/stats/api/statsApi.ts b/src/features/stats/api/statsApi.ts new file mode 100644 index 0000000..4293e49 --- /dev/null +++ b/src/features/stats/api/statsApi.ts @@ -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 => { + return apiClient('api/v1/stats/focus-summary', { + method: 'GET', + }); + }, +}; diff --git a/src/features/stats/index.ts b/src/features/stats/index.ts new file mode 100644 index 0000000..0a35a28 --- /dev/null +++ b/src/features/stats/index.ts @@ -0,0 +1,2 @@ +export * from './api/statsApi'; +export * from './model/useFocusStats'; diff --git a/src/features/stats/model/useFocusStats.ts b/src/features/stats/model/useFocusStats.ts new file mode 100644 index 0000000..8bc70be --- /dev/null +++ b/src/features/stats/model/useFocusStats.ts @@ -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; +} + +export const useFocusStats = (): UseFocusStatsResult => { + const [summary, setSummary] = useState(buildMockSummary); + const [isLoading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [source, setSource] = useState('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, + }; +}; diff --git a/src/shared/lib/apiClient.ts b/src/shared/lib/apiClient.ts index f11c68d..5acd569 100644 --- a/src/shared/lib/apiClient.ts +++ b/src/shared/lib/apiClient.ts @@ -6,45 +6,107 @@ * - 빌드 후 운영 환경: https://api.viberoom.io (from .env.production) */ -import Cookies from "js-cookie"; +import Cookies from 'js-cookie'; -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080"; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080'; +const TOKEN_COOKIE_KEY = 'vr_access_token'; -const TOKEN_COOKIE_KEY = "vr_access_token"; +interface ApiEnvelope { + data: T; + message?: string; + error?: string; +} + +interface ApiErrorPayload { + message?: string; + error?: string; +} + +export class ApiClientError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ApiClientError'; + this.status = status; + } +} + +interface ApiClientOptions extends RequestInit { + expectNoContent?: boolean; + unwrapData?: boolean; +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const readErrorMessage = async (response: Response) => { + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.includes('application/json')) { + return `API 요청 실패: ${response.status}`; + } + + try { + const payload = (await response.json()) as ApiErrorPayload; + return payload.message ?? payload.error ?? `API 요청 실패: ${response.status}`; + } catch { + return `API 요청 실패: ${response.status}`; + } +}; export const apiClient = async ( endpoint: string, - options: RequestInit = {}, + options: ApiClientOptions = {}, ): Promise => { - const url = `${API_BASE_URL}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`; - - // 쿠키에서 토큰 가져오기 + const { + expectNoContent = false, + unwrapData = true, + headers, + ...requestOptions + } = options; + const url = `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`; const token = Cookies.get(TOKEN_COOKIE_KEY); - const defaultHeaders: Record = { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }; - // 토큰이 있으면 Authorization 헤더 추가 if (token) { - defaultHeaders["Authorization"] = `Bearer ${token}`; + defaultHeaders.Authorization = `Bearer ${token}`; } const response = await fetch(url, { - ...options, + ...requestOptions, headers: { ...defaultHeaders, - ...options.headers, + ...headers, }, }); if (!response.ok) { - // 향후 서비스 무드에 맞춰 "잠시 연결이 원활하지 않아요. 다시 시도해 주세요." 와 같이 - // 부드러운 에러 핸들링을 추가할 수 있는 진입점입니다. - throw new Error(`API 요청 실패: ${response.status}`); + throw new ApiClientError(await readErrorMessage(response), response.status); } - const result = await response.json(); - return result.data as T; + if (expectNoContent || response.status === 204) { + return undefined as T; + } + + const contentType = response.headers.get('content-type') ?? ''; + + if (!contentType.includes('application/json')) { + return undefined as T; + } + + const result = (await response.json()) as T | ApiEnvelope; + + if (!unwrapData) { + return result as T; + } + + if (isRecord(result) && 'data' in result) { + return result.data as T; + } + + return result as T; }; diff --git a/src/shared/lib/useHudStatusLine.ts b/src/shared/lib/useHudStatusLine.ts index 5bcca67..0489db6 100644 --- a/src/shared/lib/useHudStatusLine.ts +++ b/src/shared/lib/useHudStatusLine.ts @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; export type HudStatusLinePriority = 'normal' | 'undo'; @@ -28,21 +28,14 @@ const DEFAULT_UNDO_DURATION_MS = 4200; export const useHudStatusLine = (enabled = true) => { const [queue, setQueue] = useState([]); + const visibleQueue = useMemo(() => (enabled ? queue : []), [enabled, queue]); useEffect(() => { - if (enabled) { + if (visibleQueue.length === 0) { return; } - setQueue([]); - }, [enabled]); - - useEffect(() => { - if (!enabled || queue.length === 0) { - return; - } - - const active = queue[0]; + const active = visibleQueue[0]; const durationMs = active.durationMs ?? (active.action ? DEFAULT_UNDO_DURATION_MS : DEFAULT_DURATION_MS); const timerId = window.setTimeout(() => { @@ -52,9 +45,13 @@ export const useHudStatusLine = (enabled = true) => { return () => { window.clearTimeout(timerId); }; - }, [enabled, queue]); + }, [visibleQueue]); + + const pushStatusLine = (payload: HudStatusLinePayload) => { + if (!enabled) { + return; + } - const pushStatusLine = useCallback((payload: HudStatusLinePayload) => { const nextItem: HudStatusLineItem = { id: Date.now() + Math.floor(Math.random() * 10000), ...payload, @@ -74,14 +71,14 @@ export const useHudStatusLine = (enabled = true) => { return nextQueue.slice(0, MAX_TOTAL_MESSAGES); }); - }, []); + }; - const dismissActiveStatus = useCallback(() => { + const dismissActiveStatus = () => { setQueue((current) => current.slice(1)); - }, []); + }; - const runActiveAction = useCallback(() => { - const active = queue[0]; + const runActiveAction = () => { + const active = visibleQueue[0]; if (!active?.action) { dismissActiveStatus(); @@ -90,14 +87,12 @@ export const useHudStatusLine = (enabled = true) => { active.action.onClick(); dismissActiveStatus(); - }, [dismissActiveStatus, queue]); + }; - return useMemo(() => { - return { - activeStatus: queue[0] ?? null, - pushStatusLine, - runActiveAction, - dismissActiveStatus, - }; - }, [dismissActiveStatus, pushStatusLine, queue, runActiveAction]); + return { + activeStatus: visibleQueue[0] ?? null, + pushStatusLine, + runActiveAction, + dismissActiveStatus, + }; }; diff --git a/src/shared/ui/Toast.tsx b/src/shared/ui/Toast.tsx index 5a39a61..d1b14a3 100644 --- a/src/shared/ui/Toast.tsx +++ b/src/shared/ui/Toast.tsx @@ -20,7 +20,9 @@ interface ToastContextValue { const ToastContext = createContext(null); export const ToastProvider = ({ children }: { children: ReactNode }) => { - const pushToast = useCallback((_payload: ToastPayload) => {}, []); + const pushToast = useCallback((payload: ToastPayload) => { + void payload; + }, []); const value = useMemo(() => ({ pushToast }), [pushToast]); diff --git a/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx b/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx index 357927d..9d5c7f5 100644 --- a/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx +++ b/src/widgets/settings-panel/ui/SettingsPanelWidget.tsx @@ -1,20 +1,22 @@ 'use client'; import Link from 'next/link'; -import { useState } from 'react'; import { DEFAULT_PRESET_OPTIONS, NOTIFICATION_INTENSITY_OPTIONS, } from '@/shared/config/settingsOptions'; +import { useUserFocusPreferences } from '@/features/preferences'; import { cn } from '@/shared/lib/cn'; export const SettingsPanelWidget = () => { - const [reduceMotion, setReduceMotion] = useState(false); - const [notificationIntensity, setNotificationIntensity] = - useState<(typeof NOTIFICATION_INTENSITY_OPTIONS)[number]>('기본'); - const [defaultPresetId, setDefaultPresetId] = useState< - (typeof DEFAULT_PRESET_OPTIONS)[number]['id'] - >(DEFAULT_PRESET_OPTIONS[0].id); + const { + preferences, + isLoading, + isSaving, + error, + saveStateLabel, + updatePreferences, + } = useUserFocusPreferences(); return (
@@ -30,6 +32,27 @@ export const SettingsPanelWidget = () => {
+
+
+
+

Focus Preferences API

+

+ {isLoading + ? '저장된 설정을 불러오는 중이에요.' + : isSaving + ? '변경 사항을 저장하는 중이에요.' + : '변경 즉시 서버에 저장합니다.'} +

+
+ {saveStateLabel ? ( + + {saveStateLabel} + + ) : null} +
+ {error ?

{error}

: null} +
+
@@ -41,11 +64,15 @@ export const SettingsPanelWidget = () => { @@ -68,10 +95,12 @@ export const SettingsPanelWidget = () => { +
+
+ + +

집중 흐름 그래프

-
-

더미 그래프 플레이스홀더

+
+ {summary.trend.length > 0 ? ( +
+ {summary.trend.map((point) => { + const barHeight = Math.max(14, Math.min(100, point.focusMinutes)); + + return ( +
+
+ + {point.date.slice(5)} + +
+ ); + })} +
+ ) : null} +
+

+ {summary.trend.length > 0 + ? 'trend 응답으로 간단한 막대 그래프를 렌더링합니다.' + : 'trend 응답이 비어 있어 플레이스홀더 상태입니다.'} +