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,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',
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user