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,20 +1,22 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import {
DEFAULT_PRESET_OPTIONS,
NOTIFICATION_INTENSITY_OPTIONS,
} from '@/shared/config/settingsOptions';
import { useUserFocusPreferences } from '@/features/preferences';
import { cn } from '@/shared/lib/cn';
export const SettingsPanelWidget = () => {
const [reduceMotion, setReduceMotion] = useState(false);
const [notificationIntensity, setNotificationIntensity] =
useState<(typeof NOTIFICATION_INTENSITY_OPTIONS)[number]>('기본');
const [defaultPresetId, setDefaultPresetId] = useState<
(typeof DEFAULT_PRESET_OPTIONS)[number]['id']
>(DEFAULT_PRESET_OPTIONS[0].id);
const {
preferences,
isLoading,
isSaving,
error,
saveStateLabel,
updatePreferences,
} = useUserFocusPreferences();
return (
<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>
<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">
<div className="flex items-center justify-between gap-3">
<div>
@@ -41,11 +64,15 @@ export const SettingsPanelWidget = () => {
<button
type="button"
role="switch"
aria-checked={reduceMotion}
onClick={() => setReduceMotion((current) => !current)}
aria-checked={preferences.reduceMotion}
onClick={() => {
void updatePreferences({
reduceMotion: !preferences.reduceMotion,
});
}}
className={cn(
'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-dark/20 bg-white/85',
)}
@@ -53,7 +80,7 @@ export const SettingsPanelWidget = () => {
<span
className={cn(
'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>
@@ -68,10 +95,12 @@ export const SettingsPanelWidget = () => {
<button
key={option}
type="button"
onClick={() => setNotificationIntensity(option)}
onClick={() => {
void updatePreferences({ notificationIntensity: option });
}}
className={cn(
'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-dark/18 bg-white/75 text-brand-dark/78 hover:bg-white',
)}
@@ -90,10 +119,12 @@ export const SettingsPanelWidget = () => {
<button
key={preset.id}
type="button"
onClick={() => setDefaultPresetId(preset.id)}
onClick={() => {
void updatePreferences({ defaultPresetId: preset.id });
}}
className={cn(
'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-dark/16 bg-white/72 text-brand-dark/82 hover:bg-white',
)}

View File

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

View File

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

View File

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

View File

@@ -10,9 +10,15 @@ import {
interface SpaceTimerHudWidgetProps {
timerLabel: string;
goal: string;
timeDisplay?: string;
className?: string;
sessionPhase?: 'focus' | 'break' | null;
playbackState?: 'running' | 'paused' | null;
isControlsDisabled?: boolean;
isImmersionMode?: boolean;
onPlaybackStateChange?: (state: 'running' | 'paused') => void;
onStartClick?: () => void;
onPauseClick?: () => void;
onResetClick?: () => void;
onGoalCompleteRequest?: () => void;
}
@@ -25,13 +31,24 @@ const HUD_ACTIONS = [
export const SpaceTimerHudWidget = ({
timerLabel,
goal,
timeDisplay = '25:00',
className,
sessionPhase = 'focus',
playbackState = 'running',
isControlsDisabled = false,
isImmersionMode = false,
onPlaybackStateChange,
onStartClick,
onPauseClick,
onResetClick,
onGoalCompleteRequest,
}: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
const modeLabel = isBreatheMode
? RECOVERY_30S_MODE_LABEL
: sessionPhase === 'break'
? 'Break'
: 'Focus';
return (
<div
@@ -62,7 +79,7 @@ export const SpaceTimerHudWidget = ({
isImmersionMode ? 'text-white/90' : 'text-white/88',
)}
>
{isBreatheMode ? RECOVERY_30S_MODE_LABEL : 'Focus'}
{modeLabel}
</span>
<span
className={cn(
@@ -70,7 +87,7 @@ export const SpaceTimerHudWidget = ({
isImmersionMode ? 'text-white/90' : 'text-white/92',
)}
>
25:00
{timeDisplay}
</span>
<span className={cn('text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
{timerLabel}
@@ -98,20 +115,31 @@ export const SpaceTimerHudWidget = ({
key={action.id}
type="button"
title={action.label}
disabled={isControlsDisabled}
onClick={() => {
if (action.id === 'start') {
onPlaybackStateChange?.('running');
onStartClick?.();
}
if (action.id === 'pause') {
onPlaybackStateChange?.('paused');
onPauseClick?.();
}
if (action.id === 'reset') {
onResetClick?.();
}
}}
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
? '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',
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>

View File

@@ -15,6 +15,7 @@ import {
type GoalChip,
type TimerPreset,
} from '@/entities/session';
import { useFocusSessionEngine } from '@/features/focus-session';
import { useSoundPresetSelection } from '@/features/sound-preset';
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
@@ -141,6 +142,12 @@ const resolveInitialTimerLabel = (
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 = () => {
const searchParams = useSearchParams();
const roomQuery = searchParams.get('room');
@@ -181,6 +188,7 @@ export const SpaceWorkspaceWidget = () => {
const [resumeGoal, setResumeGoal] = useState('');
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [hasHydratedSelection, setHasHydratedSelection] = useState(false);
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('running');
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
sound: false,
timer: false,
@@ -194,6 +202,19 @@ export const SpaceWorkspaceWidget = () => {
isMuted,
setMuted,
} = useSoundPresetSelection(initialSoundPresetId);
const {
currentSession,
isMutating: isSessionMutating,
timeDisplay,
playbackState,
phase,
startSession,
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
abandonSession,
} = useFocusSessionEngine();
const selectedRoom = useMemo(() => {
return getRoomById(selectedRoomId) ?? ROOM_THEMES[0];
@@ -212,15 +233,20 @@ export const SpaceWorkspaceWidget = () => {
const canStart = goalInput.trim().length > 0;
const isFocusMode = workspaceMode === 'focus';
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);
if (!room) {
return;
}
if (!selectionOverride.timer) {
if (!overrideState.timer) {
const recommendedTimerLabel = resolveTimerLabelFromPresetId(room.recommendedTimerPresetId);
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);
}
}, [selectionOverride.sound, selectionOverride.timer, setSelectedPresetId]);
}, [selectionOverride, setSelectedPresetId]);
useEffect(() => {
const storedSelection = readStoredWorkspaceSelection();
@@ -239,41 +268,77 @@ export const SpaceWorkspaceWidget = () => {
sound: Boolean(storedSelection.override?.sound),
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 (!roomQuery && storedSelection.sceneId && getRoomById(storedSelection.sceneId)) {
setSelectedRoomId(storedSelection.sceneId);
}
if (!timerQuery) {
const restoredTimerLabel = resolveTimerLabelFromPresetId(storedSelection.timerPresetId);
if (restoredRoomId) {
setSelectedRoomId(restoredRoomId);
}
if (restoredTimerLabel) {
setSelectedTimerLabel(restoredTimerLabel);
}
}
if (!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)) {
setSelectedPresetId(storedSelection.soundPresetId);
}
if (restoredSoundPresetId) {
setSelectedPresetId(restoredSoundPresetId);
}
const restoredGoal = storedSelection.goal?.trim() ?? '';
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) {
setResumeGoal(restoredGoal);
setShowResumePrompt(true);
}
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) {
setResumeGoal(restoredGoal);
setShowResumePrompt(true);
}
setHasHydratedSelection(true);
});
setHasHydratedSelection(true);
return () => {
window.cancelAnimationFrame(rafId);
};
}, [goalQuery, hasQueryOverrides, roomQuery, setSelectedPresetId, soundQuery, timerQuery]);
useEffect(() => {
applyRecommendedSelections(selectedRoomId);
}, [applyRecommendedSelections, selectedRoomId]);
if (!currentSession) {
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) => {
setSelectedRoomId(roomId);
applyRecommendedSelections(roomId);
};
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 = () => {
if (!canStart) {
return;
}
setShowResumePrompt(false);
setWorkspaceMode('focus');
void startFocusFlow(goalInput, 'space-setup');
};
const handleExitRequested = () => {
const handleExitRequested = async () => {
const didAbandon = await abandonSession();
if (!didAbandon) {
pushStatusLine({
message: '세션 종료를 완료하지 못했어요.',
});
return;
}
setPreviewPlaybackState('running');
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(() => {
const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow;
@@ -418,7 +599,7 @@ export const SpaceWorkspaceWidget = () => {
setGoalInput(resumeGoal);
setSelectedGoalId(null);
setShowResumePrompt(false);
setWorkspaceMode('focus');
void startFocusFlow(resumeGoal, 'resume-restore');
},
onStartFresh: () => {
setGoalInput('');
@@ -433,12 +614,22 @@ export const SpaceWorkspaceWidget = () => {
<SpaceFocusHudWidget
goal={goalInput.trim()}
timerLabel={selectedTimerLabel}
timeDisplay={resolvedTimeDisplay}
visible={isFocusMode}
onStatusMessage={pushStatusLine}
onGoalUpdate={(nextGoal) => {
setGoalInput(nextGoal);
setSelectedGoalId(null);
playbackState={resolvedPlaybackState}
sessionPhase={phase ?? 'focus'}
isSessionActionPending={isSessionMutating}
onPauseRequested={() => {
void handlePauseRequested();
}}
onResumeRequested={() => {
void handleResumeRequested();
}}
onRestartRequested={() => {
void handleRestartRequested();
}}
onStatusMessage={pushStatusLine}
onGoalUpdate={handleGoalAdvance}
/>
<FocusTopToast
@@ -473,7 +664,9 @@ export const SpaceWorkspaceWidget = () => {
onRestoreThoughts={restoreThoughts}
onClearInbox={clearThoughts}
onStatusMessage={pushStatusLine}
onExitRequested={handleExitRequested}
onExitRequested={() => {
void handleExitRequested();
}}
/>
</div>
);

View File

@@ -1,5 +1,7 @@
'use client';
import Link from 'next/link';
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
import { useFocusStats } from '@/features/stats';
const StatSection = ({
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 = () => {
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 (
<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">
@@ -42,14 +98,65 @@ export const StatsOverviewWidget = () => {
</header>
<div className="space-y-6">
<StatSection title="오늘" items={TODAY_STATS} />
<StatSection title="최근 7일" items={WEEKLY_STATS} />
<section className="rounded-xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
<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">
<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="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 className="mt-3 text-xs text-brand-dark/56"> </p>
<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">
{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>
</section>
</div>