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