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 { apiClient } from '@/shared/lib/apiClient';
import { AuthResponse, SocialLoginRequest } from "../types"; import type { AuthResponse, SocialLoginRequest, UserMeResponse } from '../types';
interface RefreshTokenResponse {
accessToken: string;
refreshToken: string;
}
export const authApi = { export const authApi = {
/** /**
* 프론트엔드에서 발급받은 소셜 토큰을 백엔드로 전송하여 VibeRoom 전용 토큰으로 교환합니다. * Backend Codex:
* @param data 구글/애플/페이스북에서 발급받은 Provider 이름과 Token * - 구글/애플/페이스북에서 받은 소셜 토큰을 검증하고 VibeRoom 전용 access/refresh token으로 교환한다.
* - 응답에는 accessToken, refreshToken, user를 포함한다.
* - user는 최소 id, name, email, grade를 포함해 로그인 직후 헤더/프로필 UI를 채울 수 있게 한다.
*/ */
loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => { loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => {
return apiClient<AuthResponse>("api/v1/auth/social", { return apiClient<AuthResponse>('api/v1/auth/social', {
method: "POST", method: 'POST',
body: JSON.stringify(data), 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"; import { useSocialLogin } from "../hooks/useSocialLogin";
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""; const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
/** /**
* 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다. * 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다.
@@ -13,8 +12,6 @@ const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
const SocialLoginButtons = () => { const SocialLoginButtons = () => {
const { const {
loginWithGoogle, loginWithGoogle,
loginWithApple,
handleFacebookCallback,
isLoading, isLoading,
error, error,
} = useSocialLogin(); } = useSocialLogin();

View File

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

View File

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

View File

@@ -6,45 +6,107 @@
* - 빌드 후 운영 환경: https://api.viberoom.io (from .env.production) * - 빌드 후 운영 환경: https://api.viberoom.io (from .env.production)
*/ */
import Cookies from "js-cookie"; import Cookies from 'js-cookie';
const API_BASE_URL = const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
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<T> {
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<string, unknown> => {
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 <T>( export const apiClient = async <T>(
endpoint: string, endpoint: string,
options: RequestInit = {}, options: ApiClientOptions = {},
): Promise<T> => { ): Promise<T> => {
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 token = Cookies.get(TOKEN_COOKIE_KEY);
const defaultHeaders: Record<string, string> = { const defaultHeaders: Record<string, string> = {
"Content-Type": "application/json", 'Content-Type': 'application/json',
}; };
// 토큰이 있으면 Authorization 헤더 추가
if (token) { if (token) {
defaultHeaders["Authorization"] = `Bearer ${token}`; defaultHeaders.Authorization = `Bearer ${token}`;
} }
const response = await fetch(url, { const response = await fetch(url, {
...options, ...requestOptions,
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
...options.headers, ...headers,
}, },
}); });
if (!response.ok) { if (!response.ok) {
// 향후 서비스 무드에 맞춰 "잠시 연결이 원활하지 않아요. 다시 시도해 주세요." 와 같이 throw new ApiClientError(await readErrorMessage(response), response.status);
// 부드러운 에러 핸들링을 추가할 수 있는 진입점입니다.
throw new Error(`API 요청 실패: ${response.status}`);
} }
const result = await response.json(); if (expectNoContent || response.status === 204) {
return result.data as T; 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<T>;
if (!unwrapData) {
return result as T;
}
if (isRecord(result) && 'data' in result) {
return result.data as T;
}
return result as T;
}; };

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
export type HudStatusLinePriority = 'normal' | 'undo'; export type HudStatusLinePriority = 'normal' | 'undo';
@@ -28,21 +28,14 @@ const DEFAULT_UNDO_DURATION_MS = 4200;
export const useHudStatusLine = (enabled = true) => { export const useHudStatusLine = (enabled = true) => {
const [queue, setQueue] = useState<HudStatusLineItem[]>([]); const [queue, setQueue] = useState<HudStatusLineItem[]>([]);
const visibleQueue = useMemo(() => (enabled ? queue : []), [enabled, queue]);
useEffect(() => { useEffect(() => {
if (enabled) { if (visibleQueue.length === 0) {
return; return;
} }
setQueue([]); const active = visibleQueue[0];
}, [enabled]);
useEffect(() => {
if (!enabled || queue.length === 0) {
return;
}
const active = queue[0];
const durationMs = const durationMs =
active.durationMs ?? (active.action ? DEFAULT_UNDO_DURATION_MS : DEFAULT_DURATION_MS); active.durationMs ?? (active.action ? DEFAULT_UNDO_DURATION_MS : DEFAULT_DURATION_MS);
const timerId = window.setTimeout(() => { const timerId = window.setTimeout(() => {
@@ -52,9 +45,13 @@ export const useHudStatusLine = (enabled = true) => {
return () => { return () => {
window.clearTimeout(timerId); window.clearTimeout(timerId);
}; };
}, [enabled, queue]); }, [visibleQueue]);
const pushStatusLine = (payload: HudStatusLinePayload) => {
if (!enabled) {
return;
}
const pushStatusLine = useCallback((payload: HudStatusLinePayload) => {
const nextItem: HudStatusLineItem = { const nextItem: HudStatusLineItem = {
id: Date.now() + Math.floor(Math.random() * 10000), id: Date.now() + Math.floor(Math.random() * 10000),
...payload, ...payload,
@@ -74,14 +71,14 @@ export const useHudStatusLine = (enabled = true) => {
return nextQueue.slice(0, MAX_TOTAL_MESSAGES); return nextQueue.slice(0, MAX_TOTAL_MESSAGES);
}); });
}, []); };
const dismissActiveStatus = useCallback(() => { const dismissActiveStatus = () => {
setQueue((current) => current.slice(1)); setQueue((current) => current.slice(1));
}, []); };
const runActiveAction = useCallback(() => { const runActiveAction = () => {
const active = queue[0]; const active = visibleQueue[0];
if (!active?.action) { if (!active?.action) {
dismissActiveStatus(); dismissActiveStatus();
@@ -90,14 +87,12 @@ export const useHudStatusLine = (enabled = true) => {
active.action.onClick(); active.action.onClick();
dismissActiveStatus(); dismissActiveStatus();
}, [dismissActiveStatus, queue]); };
return useMemo(() => { return {
return { activeStatus: visibleQueue[0] ?? null,
activeStatus: queue[0] ?? null, pushStatusLine,
pushStatusLine, runActiveAction,
runActiveAction, dismissActiveStatus,
dismissActiveStatus, };
};
}, [dismissActiveStatus, pushStatusLine, queue, runActiveAction]);
}; };

View File

@@ -20,7 +20,9 @@ interface ToastContextValue {
const ToastContext = createContext<ToastContextValue | null>(null); const ToastContext = createContext<ToastContextValue | null>(null);
export const ToastProvider = ({ children }: { children: ReactNode }) => { export const ToastProvider = ({ children }: { children: ReactNode }) => {
const pushToast = useCallback((_payload: ToastPayload) => {}, []); const pushToast = useCallback((payload: ToastPayload) => {
void payload;
}, []);
const value = useMemo(() => ({ pushToast }), [pushToast]); const value = useMemo(() => ({ pushToast }), [pushToast]);

View File

@@ -1,20 +1,22 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react';
import { import {
DEFAULT_PRESET_OPTIONS, DEFAULT_PRESET_OPTIONS,
NOTIFICATION_INTENSITY_OPTIONS, NOTIFICATION_INTENSITY_OPTIONS,
} from '@/shared/config/settingsOptions'; } from '@/shared/config/settingsOptions';
import { useUserFocusPreferences } from '@/features/preferences';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
export const SettingsPanelWidget = () => { export const SettingsPanelWidget = () => {
const [reduceMotion, setReduceMotion] = useState(false); const {
const [notificationIntensity, setNotificationIntensity] = preferences,
useState<(typeof NOTIFICATION_INTENSITY_OPTIONS)[number]>('기본'); isLoading,
const [defaultPresetId, setDefaultPresetId] = useState< isSaving,
(typeof DEFAULT_PRESET_OPTIONS)[number]['id'] error,
>(DEFAULT_PRESET_OPTIONS[0].id); saveStateLabel,
updatePreferences,
} = useUserFocusPreferences();
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_82%_0%,rgba(167,204,237,0.42),transparent_50%),radial-gradient(circle_at_12%_8%,rgba(191,219,254,0.4),transparent_46%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_54%,#e8f1fa_100%)] text-brand-dark"> <div className="min-h-screen bg-[radial-gradient(circle_at_82%_0%,rgba(167,204,237,0.42),transparent_50%),radial-gradient(circle_at_12%_8%,rgba(191,219,254,0.4),transparent_46%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_54%,#e8f1fa_100%)] text-brand-dark">
@@ -30,6 +32,27 @@ export const SettingsPanelWidget = () => {
</header> </header>
<div className="space-y-4"> <div className="space-y-4">
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-base font-semibold text-brand-dark">Focus Preferences API</h2>
<p className="mt-1 text-sm text-brand-dark/64">
{isLoading
? '저장된 설정을 불러오는 중이에요.'
: isSaving
? '변경 사항을 저장하는 중이에요.'
: '변경 즉시 서버에 저장합니다.'}
</p>
</div>
{saveStateLabel ? (
<span className="rounded-full border border-brand-dark/14 bg-white/75 px-2.5 py-1 text-[11px] text-brand-dark/72">
{saveStateLabel}
</span>
) : null}
</div>
{error ? <p className="mt-3 text-sm text-rose-500">{error}</p> : null}
</section>
<section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm"> <section className="rounded-xl border border-brand-dark/12 bg-white/78 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
@@ -41,11 +64,15 @@ export const SettingsPanelWidget = () => {
<button <button
type="button" type="button"
role="switch" role="switch"
aria-checked={reduceMotion} aria-checked={preferences.reduceMotion}
onClick={() => setReduceMotion((current) => !current)} onClick={() => {
void updatePreferences({
reduceMotion: !preferences.reduceMotion,
});
}}
className={cn( className={cn(
'inline-flex w-16 items-center rounded-full border px-1 py-1 transition-colors', 'inline-flex w-16 items-center rounded-full border px-1 py-1 transition-colors',
reduceMotion preferences.reduceMotion
? 'border-brand-primary/45 bg-brand-soft/60' ? 'border-brand-primary/45 bg-brand-soft/60'
: 'border-brand-dark/20 bg-white/85', : 'border-brand-dark/20 bg-white/85',
)} )}
@@ -53,7 +80,7 @@ export const SettingsPanelWidget = () => {
<span <span
className={cn( className={cn(
'h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 motion-reduce:transition-none', 'h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 motion-reduce:transition-none',
reduceMotion ? 'translate-x-9' : 'translate-x-0', preferences.reduceMotion ? 'translate-x-9' : 'translate-x-0',
)} )}
/> />
</button> </button>
@@ -68,10 +95,12 @@ export const SettingsPanelWidget = () => {
<button <button
key={option} key={option}
type="button" type="button"
onClick={() => setNotificationIntensity(option)} onClick={() => {
void updatePreferences({ notificationIntensity: option });
}}
className={cn( className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors', 'rounded-full border px-3 py-1.5 text-xs transition-colors',
notificationIntensity === option preferences.notificationIntensity === option
? 'border-brand-primary/45 bg-brand-soft/60 text-brand-dark' ? 'border-brand-primary/45 bg-brand-soft/60 text-brand-dark'
: 'border-brand-dark/18 bg-white/75 text-brand-dark/78 hover:bg-white', : 'border-brand-dark/18 bg-white/75 text-brand-dark/78 hover:bg-white',
)} )}
@@ -90,10 +119,12 @@ export const SettingsPanelWidget = () => {
<button <button
key={preset.id} key={preset.id}
type="button" type="button"
onClick={() => setDefaultPresetId(preset.id)} onClick={() => {
void updatePreferences({ defaultPresetId: preset.id });
}}
className={cn( className={cn(
'w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors', 'w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors',
defaultPresetId === preset.id preferences.defaultPresetId === preset.id
? 'border-brand-primary/45 bg-brand-soft/58 text-brand-dark' ? 'border-brand-primary/45 bg-brand-soft/58 text-brand-dark'
: 'border-brand-dark/16 bg-white/72 text-brand-dark/82 hover:bg-white', : 'border-brand-dark/16 bg-white/72 text-brand-dark/82 hover:bg-white',
)} )}

View File

@@ -31,8 +31,13 @@ export const GoalCompleteSheet = ({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setDraft(''); const timeoutId = window.setTimeout(() => {
return; setDraft('');
}, 0);
return () => {
window.clearTimeout(timeoutId);
};
} }
const rafId = window.requestAnimationFrame(() => { const rafId = window.requestAnimationFrame(() => {

View File

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

View File

@@ -42,23 +42,30 @@ export const SpaceSideSheet = ({
} }
if (open) { if (open) {
setShouldRender(true); let nestedRaf = 0;
const raf = window.requestAnimationFrame(() => { const raf = window.requestAnimationFrame(() => {
setVisible(true); setShouldRender(true);
nestedRaf = window.requestAnimationFrame(() => {
setVisible(true);
});
}); });
return () => { return () => {
window.cancelAnimationFrame(raf); window.cancelAnimationFrame(raf);
window.cancelAnimationFrame(nestedRaf);
}; };
} }
setVisible(false); const hideRaf = window.requestAnimationFrame(() => {
setVisible(false);
});
closeTimerRef.current = window.setTimeout(() => { closeTimerRef.current = window.setTimeout(() => {
setShouldRender(false); setShouldRender(false);
closeTimerRef.current = null; closeTimerRef.current = null;
}, transitionMs); }, transitionMs);
return () => { return () => {
window.cancelAnimationFrame(hideRaf);
if (closeTimerRef.current) { if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current); window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null; closeTimerRef.current = null;

View File

@@ -10,9 +10,15 @@ import {
interface SpaceTimerHudWidgetProps { interface SpaceTimerHudWidgetProps {
timerLabel: string; timerLabel: string;
goal: string; goal: string;
timeDisplay?: string;
className?: string; className?: string;
sessionPhase?: 'focus' | 'break' | null;
playbackState?: 'running' | 'paused' | null;
isControlsDisabled?: boolean;
isImmersionMode?: boolean; isImmersionMode?: boolean;
onPlaybackStateChange?: (state: 'running' | 'paused') => void; onStartClick?: () => void;
onPauseClick?: () => void;
onResetClick?: () => void;
onGoalCompleteRequest?: () => void; onGoalCompleteRequest?: () => void;
} }
@@ -25,13 +31,24 @@ const HUD_ACTIONS = [
export const SpaceTimerHudWidget = ({ export const SpaceTimerHudWidget = ({
timerLabel, timerLabel,
goal, goal,
timeDisplay = '25:00',
className, className,
sessionPhase = 'focus',
playbackState = 'running',
isControlsDisabled = false,
isImmersionMode = false, isImmersionMode = false,
onPlaybackStateChange, onStartClick,
onPauseClick,
onResetClick,
onGoalCompleteRequest, onGoalCompleteRequest,
}: SpaceTimerHudWidgetProps) => { }: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, triggerRestart } = useRestart30s(); const { isBreatheMode, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.'; const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
const modeLabel = isBreatheMode
? RECOVERY_30S_MODE_LABEL
: sessionPhase === 'break'
? 'Break'
: 'Focus';
return ( return (
<div <div
@@ -62,7 +79,7 @@ export const SpaceTimerHudWidget = ({
isImmersionMode ? 'text-white/90' : 'text-white/88', isImmersionMode ? 'text-white/90' : 'text-white/88',
)} )}
> >
{isBreatheMode ? RECOVERY_30S_MODE_LABEL : 'Focus'} {modeLabel}
</span> </span>
<span <span
className={cn( className={cn(
@@ -70,7 +87,7 @@ export const SpaceTimerHudWidget = ({
isImmersionMode ? 'text-white/90' : 'text-white/92', isImmersionMode ? 'text-white/90' : 'text-white/92',
)} )}
> >
25:00 {timeDisplay}
</span> </span>
<span className={cn('text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}> <span className={cn('text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
{timerLabel} {timerLabel}
@@ -98,20 +115,31 @@ export const SpaceTimerHudWidget = ({
key={action.id} key={action.id}
type="button" type="button"
title={action.label} title={action.label}
disabled={isControlsDisabled}
onClick={() => { onClick={() => {
if (action.id === 'start') { if (action.id === 'start') {
onPlaybackStateChange?.('running'); onStartClick?.();
} }
if (action.id === 'pause') { if (action.id === 'pause') {
onPlaybackStateChange?.('paused'); onPauseClick?.();
}
if (action.id === 'reset') {
onResetClick?.();
} }
}} }}
className={cn( className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-full border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80', 'inline-flex h-8 w-8 items-center justify-center rounded-full border text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80 disabled:cursor-not-allowed disabled:opacity-45',
isImmersionMode isImmersionMode
? 'border-white/14 bg-black/26 text-white/82 hover:bg-black/34' ? 'border-white/14 bg-black/26 text-white/82 hover:bg-black/34'
: 'border-white/14 bg-black/26 text-white/84 hover:bg-black/34', : 'border-white/14 bg-black/26 text-white/84 hover:bg-black/34',
action.id === 'start' && playbackState === 'running'
? 'border-sky-200/42 bg-sky-200/18 text-white'
: '',
action.id === 'pause' && playbackState === 'paused'
? 'border-amber-200/42 bg-amber-200/16 text-white'
: '',
)} )}
> >
<span aria-hidden>{action.icon}</span> <span aria-hidden>{action.icon}</span>

View File

@@ -15,6 +15,7 @@ import {
type GoalChip, type GoalChip,
type TimerPreset, type TimerPreset,
} from '@/entities/session'; } from '@/entities/session';
import { useFocusSessionEngine } from '@/features/focus-session';
import { useSoundPresetSelection } from '@/features/sound-preset'; import { useSoundPresetSelection } from '@/features/sound-preset';
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine'; import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud'; import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
@@ -141,6 +142,12 @@ const resolveInitialTimerLabel = (
return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5'; return TIMER_SELECTION_PRESETS[0]?.label ?? '25/5';
}; };
const resolveFocusTimeDisplayFromTimerLabel = (timerLabel: string) => {
const preset = TIMER_SELECTION_PRESETS.find((candidate) => candidate.label === timerLabel);
const focusMinutes = preset?.focusMinutes ?? 25;
return `${String(focusMinutes).padStart(2, '0')}:00`;
};
export const SpaceWorkspaceWidget = () => { export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const roomQuery = searchParams.get('room'); const roomQuery = searchParams.get('room');
@@ -181,6 +188,7 @@ export const SpaceWorkspaceWidget = () => {
const [resumeGoal, setResumeGoal] = useState(''); const [resumeGoal, setResumeGoal] = useState('');
const [showResumePrompt, setShowResumePrompt] = useState(false); const [showResumePrompt, setShowResumePrompt] = useState(false);
const [hasHydratedSelection, setHasHydratedSelection] = useState(false); const [hasHydratedSelection, setHasHydratedSelection] = useState(false);
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('running');
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({ const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
sound: false, sound: false,
timer: false, timer: false,
@@ -194,6 +202,19 @@ export const SpaceWorkspaceWidget = () => {
isMuted, isMuted,
setMuted, setMuted,
} = useSoundPresetSelection(initialSoundPresetId); } = useSoundPresetSelection(initialSoundPresetId);
const {
currentSession,
isMutating: isSessionMutating,
timeDisplay,
playbackState,
phase,
startSession,
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
abandonSession,
} = useFocusSessionEngine();
const selectedRoom = useMemo(() => { const selectedRoom = useMemo(() => {
return getRoomById(selectedRoomId) ?? ROOM_THEMES[0]; return getRoomById(selectedRoomId) ?? ROOM_THEMES[0];
@@ -212,15 +233,20 @@ export const SpaceWorkspaceWidget = () => {
const canStart = goalInput.trim().length > 0; const canStart = goalInput.trim().length > 0;
const isFocusMode = workspaceMode === 'focus'; const isFocusMode = workspaceMode === 'focus';
const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode); const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode);
const resolvedPlaybackState = playbackState ?? previewPlaybackState;
const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selectedTimerLabel);
const applyRecommendedSelections = useCallback((roomId: string) => { const applyRecommendedSelections = useCallback((
roomId: string,
overrideState: SelectionOverride = selectionOverride,
) => {
const room = getRoomById(roomId); const room = getRoomById(roomId);
if (!room) { if (!room) {
return; return;
} }
if (!selectionOverride.timer) { if (!overrideState.timer) {
const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId); const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId);
if (recommendedTimerLabel) { if (recommendedTimerLabel) {
@@ -228,10 +254,13 @@ export const SpaceWorkspaceWidget = () => {
} }
} }
if (!selectionOverride.sound && SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId)) { if (
!overrideState.sound &&
SOUND_PRESETS.some((preset) => preset.id === room.recommendedSoundPresetId)
) {
setSelectedPresetId(room.recommendedSoundPresetId); setSelectedPresetId(room.recommendedSoundPresetId);
} }
}, [selectionOverride.sound, selectionOverride.timer, setSelectedPresetId]); }, [selectionOverride, setSelectedPresetId]);
useEffect(() => { useEffect(() => {
const storedSelection = readStoredWorkspaceSelection(); const storedSelection = readStoredWorkspaceSelection();
@@ -239,41 +268,77 @@ export const SpaceWorkspaceWidget = () => {
sound: Boolean(storedSelection.override?.sound), sound: Boolean(storedSelection.override?.sound),
timer: Boolean(storedSelection.override?.timer), timer: Boolean(storedSelection.override?.timer),
}; };
const restoredRoomId =
!roomQuery && storedSelection.sceneId && getRoomById(storedSelection.sceneId)
? storedSelection.sceneId
: null;
const restoredTimerLabel = !timerQuery
? resolveTimerLabelFromPresetId(storedSelection.timerPresetId)
: null;
const restoredSoundPresetId =
!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)
? storedSelection.soundPresetId
: null;
const restoredGoal = storedSelection.goal?.trim() ?? '';
const rafId = window.requestAnimationFrame(() => {
setSelectionOverride(restoredSelectionOverride);
setSelectionOverride(restoredSelectionOverride); if (restoredRoomId) {
setSelectedRoomId(restoredRoomId);
if (!roomQuery && storedSelection.sceneId && getRoomById(storedSelection.sceneId)) { }
setSelectedRoomId(storedSelection.sceneId);
}
if (!timerQuery) {
const restoredTimerLabel = resolveTimerLabelFromPresetId(storedSelection.timerPresetId);
if (restoredTimerLabel) { if (restoredTimerLabel) {
setSelectedTimerLabel(restoredTimerLabel); setSelectedTimerLabel(restoredTimerLabel);
} }
}
if (!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)) { if (restoredSoundPresetId) {
setSelectedPresetId(storedSelection.soundPresetId); setSelectedPresetId(restoredSoundPresetId);
} }
const restoredGoal = storedSelection.goal?.trim() ?? ''; if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) {
setResumeGoal(restoredGoal);
setShowResumePrompt(true);
}
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) { setHasHydratedSelection(true);
setResumeGoal(restoredGoal); });
setShowResumePrompt(true);
}
setHasHydratedSelection(true); return () => {
window.cancelAnimationFrame(rafId);
};
}, [goalQuery, hasQueryOverrides, roomQuery, setSelectedPresetId, soundQuery, timerQuery]); }, [goalQuery, hasQueryOverrides, roomQuery, setSelectedPresetId, soundQuery, timerQuery]);
useEffect(() => { useEffect(() => {
applyRecommendedSelections(selectedRoomId); if (!currentSession) {
}, [applyRecommendedSelections, selectedRoomId]); return;
}
const nextTimerLabel =
resolveTimerLabelFromPresetId(currentSession.timerPresetId) ?? selectedTimerLabel;
const nextSoundPresetId =
currentSession.soundPresetId &&
SOUND_PRESETS.some((preset) => preset.id === currentSession.soundPresetId)
? currentSession.soundPresetId
: selectedPresetId;
const rafId = window.requestAnimationFrame(() => {
setSelectedRoomId(currentSession.roomId);
setSelectedTimerLabel(nextTimerLabel);
setSelectedPresetId(nextSoundPresetId);
setGoalInput(currentSession.goal);
setSelectedGoalId(null);
setShowResumePrompt(false);
setPreviewPlaybackState(currentSession.state);
setWorkspaceMode('focus');
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [currentSession, selectedPresetId, selectedTimerLabel, setSelectedPresetId]);
const handleSelectRoom = (roomId: string) => { const handleSelectRoom = (roomId: string) => {
setSelectedRoomId(roomId); setSelectedRoomId(roomId);
applyRecommendedSelections(roomId);
}; };
const handleSelectTimer = (timerLabel: string, markOverride = false) => { const handleSelectTimer = (timerLabel: string, markOverride = false) => {
@@ -326,19 +391,135 @@ export const SpaceWorkspaceWidget = () => {
} }
}; };
const startFocusFlow = async (
nextGoal: string,
entryPoint: 'space-setup' | 'goal-complete' | 'resume-restore' = 'space-setup',
) => {
const trimmedGoal = nextGoal.trim();
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
if (!trimmedGoal || !timerPresetId) {
return;
}
setShowResumePrompt(false);
setPreviewPlaybackState('running');
setWorkspaceMode('focus');
const startedSession = await startSession({
roomId: selectedRoomId,
goal: trimmedGoal,
timerPresetId,
soundPresetId: selectedPresetId,
entryPoint,
});
if (!startedSession) {
pushStatusLine({
message: '세션 API 연결 실패 · 로컬 미리보기 모드로 계속해요.',
});
}
};
const handleStart = () => { const handleStart = () => {
if (!canStart) { if (!canStart) {
return; return;
} }
setShowResumePrompt(false); void startFocusFlow(goalInput, 'space-setup');
setWorkspaceMode('focus');
}; };
const handleExitRequested = () => { const handleExitRequested = async () => {
const didAbandon = await abandonSession();
if (!didAbandon) {
pushStatusLine({
message: '세션 종료를 완료하지 못했어요.',
});
return;
}
setPreviewPlaybackState('running');
setWorkspaceMode('setup'); setWorkspaceMode('setup');
}; };
const handlePauseRequested = async () => {
if (!currentSession) {
setPreviewPlaybackState('paused');
return;
}
const pausedSession = await pauseSession();
if (!pausedSession) {
pushStatusLine({
message: '세션을 일시정지하지 못했어요.',
});
}
};
const handleResumeRequested = async () => {
if (!currentSession) {
setPreviewPlaybackState('running');
return;
}
const resumedSession = await resumeSession();
if (!resumedSession) {
pushStatusLine({
message: '세션을 다시 시작하지 못했어요.',
});
}
};
const handleRestartRequested = async () => {
if (!currentSession) {
pushStatusLine({
message: '실제 세션이 시작된 뒤에만 다시 시작할 수 있어요.',
});
return;
}
const restartedSession = await restartCurrentPhase();
if (!restartedSession) {
pushStatusLine({
message: '현재 페이즈를 다시 시작하지 못했어요.',
});
return;
}
pushStatusLine({
message: '현재 페이즈를 처음부터 다시 시작했어요.',
});
};
const handleGoalAdvance = async (nextGoal: string) => {
const trimmedNextGoal = nextGoal.trim();
if (!trimmedNextGoal) {
return;
}
if (currentSession) {
const completedSession = await completeSession({
completionType: 'goal-complete',
completedGoal: goalInput.trim(),
});
if (!completedSession) {
pushStatusLine({
message: '현재 세션 완료를 서버에 반영하지 못했어요.',
});
}
}
setGoalInput(trimmedNextGoal);
setSelectedGoalId(null);
void startFocusFlow(trimmedNextGoal, 'goal-complete');
};
useEffect(() => { useEffect(() => {
const previousBodyOverflow = document.body.style.overflow; const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow; const previousHtmlOverflow = document.documentElement.style.overflow;
@@ -418,7 +599,7 @@ export const SpaceWorkspaceWidget = () => {
setGoalInput(resumeGoal); setGoalInput(resumeGoal);
setSelectedGoalId(null); setSelectedGoalId(null);
setShowResumePrompt(false); setShowResumePrompt(false);
setWorkspaceMode('focus'); void startFocusFlow(resumeGoal, 'resume-restore');
}, },
onStartFresh: () => { onStartFresh: () => {
setGoalInput(''); setGoalInput('');
@@ -433,12 +614,22 @@ export const SpaceWorkspaceWidget = () => {
<SpaceFocusHudWidget <SpaceFocusHudWidget
goal={goalInput.trim()} goal={goalInput.trim()}
timerLabel={selectedTimerLabel} timerLabel={selectedTimerLabel}
timeDisplay={resolvedTimeDisplay}
visible={isFocusMode} visible={isFocusMode}
onStatusMessage={pushStatusLine} playbackState={resolvedPlaybackState}
onGoalUpdate={(nextGoal) => { sessionPhase={phase ?? 'focus'}
setGoalInput(nextGoal); isSessionActionPending={isSessionMutating}
setSelectedGoalId(null); onPauseRequested={() => {
void handlePauseRequested();
}} }}
onResumeRequested={() => {
void handleResumeRequested();
}}
onRestartRequested={() => {
void handleRestartRequested();
}}
onStatusMessage={pushStatusLine}
onGoalUpdate={handleGoalAdvance}
/> />
<FocusTopToast <FocusTopToast
@@ -473,7 +664,9 @@ export const SpaceWorkspaceWidget = () => {
onRestoreThoughts={restoreThoughts} onRestoreThoughts={restoreThoughts}
onClearInbox={clearThoughts} onClearInbox={clearThoughts}
onStatusMessage={pushStatusLine} onStatusMessage={pushStatusLine}
onExitRequested={handleExitRequested} onExitRequested={() => {
void handleExitRequested();
}}
/> />
</div> </div>
); );

View File

@@ -1,5 +1,7 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session'; import { useFocusStats } from '@/features/stats';
const StatSection = ({ const StatSection = ({
title, title,
@@ -27,7 +29,61 @@ const StatSection = ({
); );
}; };
const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, minutes);
const hourPart = Math.floor(safeMinutes / 60);
const minutePart = safeMinutes % 60;
if (hourPart === 0) {
return `${minutePart}m`;
}
return `${hourPart}h ${minutePart}m`;
};
export const StatsOverviewWidget = () => { export const StatsOverviewWidget = () => {
const { summary, isLoading, error, source, refetch } = useFocusStats();
const todayItems = [
{
id: 'today-focus',
label: '오늘 집중 시간',
value: formatMinutes(summary.today.focusMinutes),
delta: source === 'api' ? 'API' : 'Mock',
},
{
id: 'today-cycles',
label: '완료한 사이클',
value: `${summary.today.completedCycles}`,
delta: `${summary.today.completedCycles > 0 ? '+' : ''}${summary.today.completedCycles}`,
},
{
id: 'today-entry',
label: '입장 횟수',
value: `${summary.today.sessionEntries}`,
delta: source === 'api' ? '동기화됨' : '임시값',
},
];
const weeklyItems = [
{
id: 'week-focus',
label: '최근 7일 집중 시간',
value: formatMinutes(summary.last7Days.focusMinutes),
delta: source === 'api' ? '실집계' : '목업',
},
{
id: 'week-best-day',
label: '최고 몰입일',
value: summary.last7Days.bestDayLabel,
delta: formatMinutes(summary.last7Days.bestDayFocusMinutes),
},
{
id: 'week-consistency',
label: '연속 달성',
value: `${summary.last7Days.streakDays}`,
delta: summary.last7Days.streakDays > 0 ? '유지 중' : '시작 전',
},
];
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_18%_0%,rgba(167,204,237,0.45),transparent_50%),radial-gradient(circle_at_88%_8%,rgba(191,219,254,0.4),transparent_42%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_52%,#e9f1fa_100%)] text-brand-dark"> <div className="min-h-screen bg-[radial-gradient(circle_at_18%_0%,rgba(167,204,237,0.45),transparent_50%),radial-gradient(circle_at_88%_8%,rgba(191,219,254,0.4),transparent_42%),linear-gradient(170deg,#f8fafc_0%,#eef4fb_52%,#e9f1fa_100%)] text-brand-dark">
<div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-6 sm:px-6"> <div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-6 sm:px-6">
@@ -42,14 +98,65 @@ export const StatsOverviewWidget = () => {
</header> </header>
<div className="space-y-6"> <div className="space-y-6">
<StatSection title="오늘" items={TODAY_STATS} /> <section className="rounded-xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
<StatSection title="최근 7일" items={WEEKLY_STATS} /> <div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-xs font-medium text-brand-dark/72">
{source === 'api' ? 'API 통계 사용 중' : 'API 실패로 mock 통계 표시 중'}
</p>
{error ? (
<p className="mt-1 text-xs text-rose-500">{error}</p>
) : (
<p className="mt-1 text-xs text-brand-dark/56">
{isLoading ? '통계를 불러오는 중이에요.' : '화면 진입 시 최신 요약을 동기화합니다.'}
</p>
)}
</div>
<button
type="button"
onClick={() => {
void refetch();
}}
className="rounded-lg border border-brand-dark/16 px-3 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/90"
>
</button>
</div>
</section>
<StatSection title="오늘" items={todayItems} />
<StatSection title="최근 7일" items={weeklyItems} />
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark"> </h2> <h2 className="text-lg font-semibold text-brand-dark"> </h2>
<div className="rounded-xl border border-dashed border-brand-dark/18 bg-white/65 p-5"> <div className="rounded-xl border border-dashed border-brand-dark/18 bg-white/65 p-5">
<div className="h-52 rounded-lg border border-brand-dark/12 bg-[linear-gradient(180deg,rgba(148,163,184,0.15),rgba(148,163,184,0.04))]" /> <div className="h-52 rounded-lg border border-brand-dark/12 bg-[linear-gradient(180deg,rgba(148,163,184,0.15),rgba(148,163,184,0.04))] p-4">
<p className="mt-3 text-xs text-brand-dark/56"> </p> {summary.trend.length > 0 ? (
<div className="flex h-full items-end gap-2">
{summary.trend.map((point) => {
const barHeight = Math.max(14, Math.min(100, point.focusMinutes));
return (
<div key={point.date} className="flex flex-1 flex-col items-center gap-2">
<div
className="w-full rounded-md bg-brand-primary/55"
style={{ height: `${barHeight}%` }}
title={`${point.date} · ${point.focusMinutes}`}
/>
<span className="text-[10px] text-brand-dark/56">
{point.date.slice(5)}
</span>
</div>
);
})}
</div>
) : null}
</div>
<p className="mt-3 text-xs text-brand-dark/56">
{summary.trend.length > 0
? 'trend 응답으로 간단한 막대 그래프를 렌더링합니다.'
: 'trend 응답이 비어 있어 플레이스홀더 상태입니다.'}
</p>
</div> </div>
</section> </section>
</div> </div>