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,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>