feat(api): 세션·통계·설정 API 연동 기반을 추가
맥락: - 실제 세션 엔진과 통계·설정 저장을 백엔드와 연결할 프론트 API 경계를 먼저 정리할 필요가 있었다. 변경사항: - focus session, stats, preferences API 계층과 타입을 추가하고 메서드 주석에 Backend Codex 지시 사항을 작성했다. - /space를 현재 세션 조회, 시작, 일시정지, 재개, 다시 시작, 완료, 종료 API 흐름에 연결하고 API 실패 시 로컬 미리보기 fallback을 유지했다. - /stats와 /settings를 API 기반 fetch/save 구조로 전환하고 auth/apiClient를 보강했다. - React 19 규칙에 맞게 관련 훅과 HUD/시트 구현을 정리해 lint/build가 통과하도록 보정했다. 검증: - npm run lint - npm run build 세션-상태: 프론트에서 세션·통계·설정 API를 호출할 준비가 된 상태 세션-다음: 백엔드가 주석에 맞춘 엔드포인트와 응답 스키마를 구현하도록 협업 세션-리스크: 실제 서버 응답 필드명이 현재 타입과 다르면 프론트 매핑 조정이 추가로 필요
This commit is contained in:
@@ -1,17 +1,60 @@
|
|||||||
import { apiClient } from "@/shared/lib/apiClient";
|
import { 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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('페이스북 로그인에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
130
src/features/focus-session/api/focusSessionApi.ts
Normal file
130
src/features/focus-session/api/focusSessionApi.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
|
|
||||||
|
export type FocusSessionPhase = 'focus' | 'break';
|
||||||
|
export type FocusSessionState = 'running' | 'paused';
|
||||||
|
export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete';
|
||||||
|
|
||||||
|
export interface FocusSession {
|
||||||
|
id: string;
|
||||||
|
roomId: string;
|
||||||
|
goal: string;
|
||||||
|
timerPresetId: string;
|
||||||
|
soundPresetId: string | null;
|
||||||
|
phase: FocusSessionPhase;
|
||||||
|
state: FocusSessionState;
|
||||||
|
phaseStartedAt: string;
|
||||||
|
phaseEndsAt: string | null;
|
||||||
|
phaseRemainingSeconds: number;
|
||||||
|
focusDurationSeconds: number;
|
||||||
|
breakDurationSeconds: number;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
abandonedAt: string | null;
|
||||||
|
serverNow: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartFocusSessionRequest {
|
||||||
|
roomId: string;
|
||||||
|
goal: string;
|
||||||
|
timerPresetId: string;
|
||||||
|
soundPresetId?: string | null;
|
||||||
|
entryPoint?: 'space-setup' | 'goal-complete' | 'resume-restore';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompleteFocusSessionRequest {
|
||||||
|
completionType: FocusSessionCompletionType;
|
||||||
|
completedGoal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const focusSessionApi = {
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 로그인한 사용자의 진행 중 세션을 0개 또는 1개 반환한다.
|
||||||
|
* - 응답에는 serverNow, phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 포함해
|
||||||
|
* 클라이언트가 남은 시간을 안정적으로 계산할 수 있게 한다.
|
||||||
|
* - 진행 중 세션이 없으면 `data: null`을 반환한다.
|
||||||
|
*/
|
||||||
|
getCurrentSession: async (): Promise<FocusSession | null> => {
|
||||||
|
return apiClient<FocusSession | null>('api/v1/focus-sessions/current', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다.
|
||||||
|
* - roomId, goal, timerPresetId, soundPresetId를 저장한다.
|
||||||
|
* - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다.
|
||||||
|
*/
|
||||||
|
startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => {
|
||||||
|
return apiClient<FocusSession>('api/v1/focus-sessions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 현재 세션의 현재 phase를 일시정지한다.
|
||||||
|
* - phaseRemainingSeconds를 정확히 저장해 재개 시 이어서 동작하게 한다.
|
||||||
|
* - 이미 paused 상태여도 멱등적으로 최신 세션 상태를 반환한다.
|
||||||
|
*/
|
||||||
|
pauseSession: async (sessionId: string): Promise<FocusSession> => {
|
||||||
|
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 일시정지된 세션을 재개하고 새 phaseEndsAt/serverNow를 반환한다.
|
||||||
|
* - 이미 running 상태여도 멱등적으로 최신 세션 상태를 반환한다.
|
||||||
|
* - 남은 시간을 다시 계산할 수 있게 phaseRemainingSeconds도 함께 내려준다.
|
||||||
|
*/
|
||||||
|
resumeSession: async (sessionId: string): Promise<FocusSession> => {
|
||||||
|
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 현재 진행 중인 phase를 처음 길이로 다시 시작한다.
|
||||||
|
* - focus/break 어느 phase인지 유지한 채 phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 갱신한다.
|
||||||
|
* - 클라이언트의 Reset 버튼은 이 API 응답으로 즉시 HUD를 다시 그린다.
|
||||||
|
*/
|
||||||
|
restartCurrentPhase: async (sessionId: string): Promise<FocusSession> => {
|
||||||
|
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/restart-phase`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
|
||||||
|
* - completionType으로 goal-complete / timer-complete을 구분해 저장한다.
|
||||||
|
* - 완료된 세션 스냅샷을 반환하거나, 최소한 성공적으로 완료되었음을 알 수 있는 응답을 보낸다.
|
||||||
|
*/
|
||||||
|
completeSession: async (
|
||||||
|
sessionId: string,
|
||||||
|
payload: CompleteFocusSessionRequest,
|
||||||
|
): Promise<FocusSession> => {
|
||||||
|
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 현재 세션을 중도 종료(abandon) 처리한다.
|
||||||
|
* - 통계에서는 abandon 여부를 구분할 수 있게 저장한다.
|
||||||
|
* - 성공 시 204 또는 빈 성공 응답을 반환한다.
|
||||||
|
*/
|
||||||
|
abandonSession: async (sessionId: string): Promise<void> => {
|
||||||
|
return apiClient<void>(`api/v1/focus-sessions/${sessionId}/abandon`, {
|
||||||
|
method: 'POST',
|
||||||
|
expectNoContent: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
2
src/features/focus-session/index.ts
Normal file
2
src/features/focus-session/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './api/focusSessionApi';
|
||||||
|
export * from './model/useFocusSessionEngine';
|
||||||
267
src/features/focus-session/model/useFocusSessionEngine.ts
Normal file
267
src/features/focus-session/model/useFocusSessionEngine.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
focusSessionApi,
|
||||||
|
type CompleteFocusSessionRequest,
|
||||||
|
type FocusSession,
|
||||||
|
type FocusSessionPhase,
|
||||||
|
type FocusSessionState,
|
||||||
|
type StartFocusSessionRequest,
|
||||||
|
} from '../api/focusSessionApi';
|
||||||
|
|
||||||
|
const SESSION_SYNC_INTERVAL_MS = 30_000;
|
||||||
|
const TIMER_TICK_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
|
const padClock = (value: number) => String(value).padStart(2, '0');
|
||||||
|
|
||||||
|
export const formatDurationClock = (totalSeconds: number) => {
|
||||||
|
const safeSeconds = Math.max(0, totalSeconds);
|
||||||
|
const minutes = Math.floor(safeSeconds / 60);
|
||||||
|
const seconds = safeSeconds % 60;
|
||||||
|
return `${padClock(minutes)}:${padClock(seconds)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServerOffsetMs = (session: FocusSession) => {
|
||||||
|
const serverNowMs = Date.parse(session.serverNow);
|
||||||
|
|
||||||
|
if (Number.isNaN(serverNowMs)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverNowMs - Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRemainingSeconds = (
|
||||||
|
session: FocusSession | null,
|
||||||
|
serverOffsetMs: number,
|
||||||
|
tickMs: number,
|
||||||
|
) => {
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.state === 'paused' || !session.phaseEndsAt) {
|
||||||
|
return Math.max(0, session.phaseRemainingSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseEndMs = Date.parse(session.phaseEndsAt);
|
||||||
|
|
||||||
|
if (Number.isNaN(phaseEndMs)) {
|
||||||
|
return Math.max(0, session.phaseRemainingSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.ceil((phaseEndMs - (tickMs + serverOffsetMs)) / 1000));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseFocusSessionEngineResult {
|
||||||
|
currentSession: FocusSession | null;
|
||||||
|
isBootstrapping: boolean;
|
||||||
|
isMutating: boolean;
|
||||||
|
error: string | null;
|
||||||
|
remainingSeconds: number | null;
|
||||||
|
timeDisplay: string | null;
|
||||||
|
playbackState: FocusSessionState | null;
|
||||||
|
phase: FocusSessionPhase | null;
|
||||||
|
syncCurrentSession: () => Promise<FocusSession | null>;
|
||||||
|
startSession: (payload: StartFocusSessionRequest) => Promise<FocusSession | null>;
|
||||||
|
pauseSession: () => Promise<FocusSession | null>;
|
||||||
|
resumeSession: () => Promise<FocusSession | null>;
|
||||||
|
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||||
|
completeSession: (payload: CompleteFocusSessionRequest) => Promise<FocusSession | null>;
|
||||||
|
abandonSession: () => Promise<boolean>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||||
|
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||||
|
const [isBootstrapping, setBootstrapping] = useState(true);
|
||||||
|
const [isMutating, setMutating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [serverOffsetMs, setServerOffsetMs] = useState(0);
|
||||||
|
const [tickMs, setTickMs] = useState(Date.now());
|
||||||
|
const didHydrateRef = useRef(false);
|
||||||
|
|
||||||
|
const applySession = useCallback((session: FocusSession | null) => {
|
||||||
|
setCurrentSession(session);
|
||||||
|
setServerOffsetMs(session ? getServerOffsetMs(session) : 0);
|
||||||
|
setTickMs(Date.now());
|
||||||
|
return session;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncCurrentSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const session = await focusSessionApi.getCurrentSession();
|
||||||
|
setError(null);
|
||||||
|
return applySession(session);
|
||||||
|
} catch (nextError) {
|
||||||
|
const message =
|
||||||
|
nextError instanceof Error ? nextError.message : '세션 엔진과 동기화하지 못했어요.';
|
||||||
|
setError(message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setBootstrapping(false);
|
||||||
|
}
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (didHydrateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
didHydrateRef.current = true;
|
||||||
|
void syncCurrentSession();
|
||||||
|
}, [syncCurrentSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void syncCurrentSession();
|
||||||
|
}, SESSION_SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [currentSession, syncCurrentSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSession || currentSession.state !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setTickMs(Date.now());
|
||||||
|
}, TIMER_TICK_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [currentSession]);
|
||||||
|
|
||||||
|
const remainingSeconds = useMemo(() => {
|
||||||
|
return getRemainingSeconds(currentSession, serverOffsetMs, tickMs);
|
||||||
|
}, [currentSession, serverOffsetMs, tickMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSession || currentSession.state !== 'running' || remainingSeconds !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
void syncCurrentSession();
|
||||||
|
}, 1_200);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [currentSession, remainingSeconds, syncCurrentSession]);
|
||||||
|
|
||||||
|
const runMutation = async <T>(task: () => Promise<T>, fallbackMessage: string) => {
|
||||||
|
setMutating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await task();
|
||||||
|
setError(null);
|
||||||
|
return result;
|
||||||
|
} catch (nextError) {
|
||||||
|
const message = nextError instanceof Error ? nextError.message : fallbackMessage;
|
||||||
|
setError(message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setMutating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentSession,
|
||||||
|
isBootstrapping,
|
||||||
|
isMutating,
|
||||||
|
error,
|
||||||
|
remainingSeconds,
|
||||||
|
timeDisplay: remainingSeconds === null ? null : formatDurationClock(remainingSeconds),
|
||||||
|
playbackState: currentSession?.state ?? null,
|
||||||
|
phase: currentSession?.phase ?? null,
|
||||||
|
syncCurrentSession,
|
||||||
|
startSession: async (payload) => {
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.startSession(payload),
|
||||||
|
'세션을 시작하지 못했어요.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
pauseSession: async () => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.pauseSession(currentSession.id),
|
||||||
|
'세션을 일시정지하지 못했어요.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
resumeSession: async () => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.resumeSession(currentSession.id),
|
||||||
|
'세션을 다시 시작하지 못했어요.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
restartCurrentPhase: async () => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.restartCurrentPhase(currentSession.id),
|
||||||
|
'현재 페이즈를 다시 시작하지 못했어요.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return applySession(session);
|
||||||
|
},
|
||||||
|
completeSession: async (payload) => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await runMutation(
|
||||||
|
() => focusSessionApi.completeSession(currentSession.id, payload),
|
||||||
|
'세션을 완료 처리하지 못했어요.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
applySession(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
abandonSession: async () => {
|
||||||
|
if (!currentSession) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runMutation(
|
||||||
|
() => focusSessionApi.abandonSession(currentSession.id),
|
||||||
|
'세션을 종료하지 못했어요.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySession(null);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
clearError: () => setError(null),
|
||||||
|
};
|
||||||
|
};
|
||||||
51
src/features/preferences/api/preferencesApi.ts
Normal file
51
src/features/preferences/api/preferencesApi.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_PRESET_OPTIONS,
|
||||||
|
NOTIFICATION_INTENSITY_OPTIONS,
|
||||||
|
} from '@/shared/config/settingsOptions';
|
||||||
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
|
|
||||||
|
export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number];
|
||||||
|
export type DefaultPresetId = (typeof DEFAULT_PRESET_OPTIONS)[number]['id'];
|
||||||
|
|
||||||
|
export interface UserFocusPreferences {
|
||||||
|
reduceMotion: boolean;
|
||||||
|
notificationIntensity: NotificationIntensity;
|
||||||
|
defaultPresetId: DefaultPresetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
|
||||||
|
|
||||||
|
export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
|
||||||
|
reduceMotion: false,
|
||||||
|
notificationIntensity: '기본',
|
||||||
|
defaultPresetId: DEFAULT_PRESET_OPTIONS[0].id,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preferencesApi = {
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 로그인한 사용자의 집중 관련 개인 설정을 반환한다.
|
||||||
|
* - 최소 reduceMotion, notificationIntensity, defaultPresetId를 포함한다.
|
||||||
|
* - 아직 저장된 값이 없으면 서버 기본값을 내려주거나 null 필드 없이 기본 스키마로 응답한다.
|
||||||
|
*/
|
||||||
|
getFocusPreferences: async (): Promise<UserFocusPreferences> => {
|
||||||
|
return apiClient<UserFocusPreferences>('api/v1/users/me/focus-preferences', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 사용자의 집중 개인 설정 일부 또는 전체를 patch 방식으로 저장한다.
|
||||||
|
* - 저장 후 최종 스냅샷 전체를 반환해 프론트가 로컬 상태를 서버 기준으로 맞출 수 있게 한다.
|
||||||
|
* - 알 수 없는 필드는 무시하지 말고 400으로 검증 오류를 돌려준다.
|
||||||
|
*/
|
||||||
|
updateFocusPreferences: async (
|
||||||
|
payload: UpdateUserFocusPreferencesRequest,
|
||||||
|
): Promise<UserFocusPreferences> => {
|
||||||
|
return apiClient<UserFocusPreferences>('api/v1/users/me/focus-preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
2
src/features/preferences/index.ts
Normal file
2
src/features/preferences/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './api/preferencesApi';
|
||||||
|
export * from './model/useUserFocusPreferences';
|
||||||
104
src/features/preferences/model/useUserFocusPreferences.ts
Normal file
104
src/features/preferences/model/useUserFocusPreferences.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
DEFAULT_USER_FOCUS_PREFERENCES,
|
||||||
|
preferencesApi,
|
||||||
|
type UpdateUserFocusPreferencesRequest,
|
||||||
|
type UserFocusPreferences,
|
||||||
|
} from '../api/preferencesApi';
|
||||||
|
|
||||||
|
interface UseUserFocusPreferencesResult {
|
||||||
|
preferences: UserFocusPreferences;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
saveStateLabel: string | null;
|
||||||
|
updatePreferences: (patch: UpdateUserFocusPreferencesRequest) => Promise<void>;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => {
|
||||||
|
const [preferences, setPreferences] = useState<UserFocusPreferences>(DEFAULT_USER_FOCUS_PREFERENCES);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [isSaving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saveStateLabel, setSaveStateLabel] = useState<string | null>(null);
|
||||||
|
const saveStateTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveStateTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(saveStateTimerRef.current);
|
||||||
|
saveStateTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pushSavedLabel = (label: string) => {
|
||||||
|
setSaveStateLabel(label);
|
||||||
|
|
||||||
|
if (saveStateTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(saveStateTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStateTimerRef.current = window.setTimeout(() => {
|
||||||
|
setSaveStateLabel(null);
|
||||||
|
saveStateTimerRef.current = null;
|
||||||
|
}, 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPreferences = await preferencesApi.getFocusPreferences();
|
||||||
|
setPreferences(nextPreferences);
|
||||||
|
setError(null);
|
||||||
|
} catch (nextError) {
|
||||||
|
const message =
|
||||||
|
nextError instanceof Error ? nextError.message : '설정을 불러오지 못했어요.';
|
||||||
|
setPreferences(DEFAULT_USER_FOCUS_PREFERENCES);
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refetch();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updatePreferences = async (patch: UpdateUserFocusPreferencesRequest) => {
|
||||||
|
const previous = preferences;
|
||||||
|
const optimistic = { ...preferences, ...patch };
|
||||||
|
|
||||||
|
setPreferences(optimistic);
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const persisted = await preferencesApi.updateFocusPreferences(patch);
|
||||||
|
setPreferences(persisted);
|
||||||
|
pushSavedLabel('저장됨');
|
||||||
|
} catch (nextError) {
|
||||||
|
const message =
|
||||||
|
nextError instanceof Error ? nextError.message : '설정을 저장하지 못했어요.';
|
||||||
|
setPreferences(previous);
|
||||||
|
setError(message);
|
||||||
|
pushSavedLabel('저장 실패');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferences,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
saveStateLabel,
|
||||||
|
updatePreferences,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
36
src/features/stats/api/statsApi.ts
Normal file
36
src/features/stats/api/statsApi.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { apiClient } from '@/shared/lib/apiClient';
|
||||||
|
|
||||||
|
export interface FocusTrendPoint {
|
||||||
|
date: string;
|
||||||
|
focusMinutes: number;
|
||||||
|
completedCycles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FocusStatsSummary {
|
||||||
|
today: {
|
||||||
|
focusMinutes: number;
|
||||||
|
completedCycles: number;
|
||||||
|
sessionEntries: number;
|
||||||
|
};
|
||||||
|
last7Days: {
|
||||||
|
focusMinutes: number;
|
||||||
|
bestDayLabel: string;
|
||||||
|
bestDayFocusMinutes: number;
|
||||||
|
streakDays: number;
|
||||||
|
};
|
||||||
|
trend: FocusTrendPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statsApi = {
|
||||||
|
/**
|
||||||
|
* Backend Codex:
|
||||||
|
* - 로그인한 사용자의 집중 통계 요약을 반환한다.
|
||||||
|
* - today, last7Days, trend를 한 번에 내려 프론트가 화면 진입 시 즉시 렌더링할 수 있게 한다.
|
||||||
|
* - 단위는 계산이 쉬운 숫자형(minutes, counts)으로 내려주고, 라벨 포맷팅은 프론트가 맡는다.
|
||||||
|
*/
|
||||||
|
getFocusStatsSummary: async (): Promise<FocusStatsSummary> => {
|
||||||
|
return apiClient<FocusStatsSummary>('api/v1/stats/focus-summary', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
2
src/features/stats/index.ts
Normal file
2
src/features/stats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './api/statsApi';
|
||||||
|
export * from './model/useFocusStats';
|
||||||
78
src/features/stats/model/useFocusStats.ts
Normal file
78
src/features/stats/model/useFocusStats.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
|
||||||
|
import { statsApi, type FocusStatsSummary } from '../api/statsApi';
|
||||||
|
|
||||||
|
type StatsSource = 'api' | 'mock';
|
||||||
|
|
||||||
|
const parseDurationLabelToMinutes = (label: string) => {
|
||||||
|
const hourMatch = label.match(/(\d+)h/);
|
||||||
|
const minuteMatch = label.match(/(\d+)m/);
|
||||||
|
const hours = hourMatch ? Number(hourMatch[1]) : 0;
|
||||||
|
const minutes = minuteMatch ? Number(minuteMatch[1]) : 0;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMockSummary = (): FocusStatsSummary => {
|
||||||
|
return {
|
||||||
|
today: {
|
||||||
|
focusMinutes: parseDurationLabelToMinutes(TODAY_STATS[0]?.value ?? '0m'),
|
||||||
|
completedCycles: Number.parseInt(TODAY_STATS[1]?.value ?? '0', 10) || 0,
|
||||||
|
sessionEntries: Number.parseInt(TODAY_STATS[2]?.value ?? '0', 10) || 0,
|
||||||
|
},
|
||||||
|
last7Days: {
|
||||||
|
focusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[0]?.value ?? '0m'),
|
||||||
|
bestDayLabel: WEEKLY_STATS[1]?.value ?? '-',
|
||||||
|
bestDayFocusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[1]?.delta ?? '0m'),
|
||||||
|
streakDays: Number.parseInt(WEEKLY_STATS[2]?.value ?? '0', 10) || 0,
|
||||||
|
},
|
||||||
|
trend: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseFocusStatsResult {
|
||||||
|
summary: FocusStatsSummary;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
source: StatsSource;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFocusStats = (): UseFocusStatsResult => {
|
||||||
|
const [summary, setSummary] = useState<FocusStatsSummary>(buildMockSummary);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [source, setSource] = useState<StatsSource>('mock');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextSummary = await statsApi.getFocusStatsSummary();
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setSource('api');
|
||||||
|
setError(null);
|
||||||
|
} catch (nextError) {
|
||||||
|
const message =
|
||||||
|
nextError instanceof Error ? nextError.message : '통계를 불러오지 못했어요.';
|
||||||
|
setSummary(buildMockSummary());
|
||||||
|
setSource('mock');
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
source,
|
||||||
|
refetch: load,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user