feat(stats): weekly review snapshot 1차 구현

This commit is contained in:
2026-03-14 19:22:58 +09:00
parent 679601d201
commit dc97a78fdd
8 changed files with 563 additions and 167 deletions

View File

@@ -3,20 +3,9 @@
import Link from 'next/link';
import { useFocusStats } from '@/features/stats';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
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`;
};
const FactualStatCard = ({
const ReviewMetric = ({
label,
value,
hint,
@@ -26,155 +15,193 @@ const FactualStatCard = ({
hint: string;
}) => {
return (
<article className="rounded-2xl border border-brand-dark/10 bg-white/82 p-4 backdrop-blur-sm">
<p className="text-xs text-brand-dark/58">{label}</p>
<p className="mt-2 text-xl font-semibold text-brand-dark">{value}</p>
<p className="mt-1 text-xs text-brand-dark/48">{hint}</p>
</article>
<div className="rounded-2xl border border-brand-dark/8 bg-white/70 px-4 py-3">
<p className="text-[11px] font-medium tracking-[0.08em] text-brand-dark/44">{label}</p>
<p className="mt-2 text-[1.05rem] font-semibold text-brand-dark">{value}</p>
<p className="mt-1 text-[12px] leading-[1.45] text-brand-dark/52">{hint}</p>
</div>
);
};
const ReviewSection = ({
title,
summary,
metrics,
availability,
note,
toneClass,
}: {
title: string;
summary: string;
metrics: Array<{ id: string; label: string; value: string; hint: string }>;
availability: 'ready' | 'limited';
note?: string;
toneClass: string;
}) => {
return (
<section className="overflow-hidden rounded-[28px] border border-brand-dark/10 bg-white/76 backdrop-blur-md">
<div className={cn('h-1.5 w-full', toneClass)} />
<div className="px-5 py-5 sm:px-6">
<div className="flex items-start justify-between gap-4">
<div className="max-w-2xl">
<h2 className="text-[1.05rem] font-semibold tracking-tight text-brand-dark">{title}</h2>
<p className="mt-2 text-[14px] leading-[1.65] text-brand-dark/68">{summary}</p>
</div>
{availability === 'limited' ? (
<span className="shrink-0 rounded-full border border-brand-dark/10 bg-white/70 px-3 py-1 text-[11px] font-medium tracking-[0.08em] text-brand-dark/46">
Limited
</span>
) : null}
</div>
{metrics.length > 0 ? (
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{metrics.map((metric) => (
<ReviewMetric
key={metric.id}
label={metric.label}
value={metric.value}
hint={metric.hint}
/>
))}
</div>
) : null}
{note ? (
<p className="mt-5 rounded-2xl border border-brand-dark/8 bg-white/56 px-4 py-3 text-[13px] leading-[1.6] text-brand-dark/54">
{note}
</p>
) : null}
</div>
</section>
);
};
export const StatsOverviewWidget = () => {
const { stats } = copy;
const { summary, isLoading, error, source, refetch } = useFocusStats();
const todayItems = [
{
id: 'today-focus',
label: stats.todayFocus,
value: formatMinutes(summary.today.focusMinutes),
hint: source === 'api' ? stats.apiLabel : stats.mockLabel,
},
{
id: 'today-complete',
label: stats.completedCycles,
value: `${summary.today.completedCycles}${stats.countUnit}`,
hint: '오늘 완료한 focus cycle',
},
{
id: 'today-entry',
label: stats.sessionEntries,
value: `${summary.today.sessionEntries}${stats.countUnit}`,
hint: '오늘 space에 들어간 횟수',
},
];
const weeklyItems = [
{
id: 'week-focus',
label: stats.last7DaysFocus,
value: formatMinutes(summary.last7Days.focusMinutes),
hint: '최근 7일 총 focus 시간',
},
{
id: 'week-started',
label: '시작한 세션',
value: `${summary.last7Days.startedSessions}${stats.countUnit}`,
hint: '최근 7일 시작 횟수',
},
{
id: 'week-completed',
label: '완료한 세션',
value: `${summary.last7Days.completedSessions}${stats.countUnit}`,
hint: '최근 7일 goal/timer 완료 횟수',
},
{
id: 'week-carry',
label: '이월된 블록',
value: `${summary.last7Days.carriedOverCount}${stats.countUnit}`,
hint: '다음 날로 이어진 계획 수',
},
];
const { review, isLoading, error, source, refetch } = useFocusStats();
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-5xl px-4 pb-10 pt-6 sm:px-6">
<header className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
<div className="min-h-screen bg-[radial-gradient(circle_at_14%_0%,rgba(198,219,244,0.52),transparent_42%),radial-gradient(circle_at_88%_12%,rgba(232,240,249,0.8),transparent_34%),linear-gradient(180deg,#f8fbff_0%,#f2f7fc_46%,#edf3f9_100%)] text-brand-dark">
<div className="mx-auto w-full max-w-6xl px-4 pb-12 pt-6 sm:px-6 lg:px-8">
<header className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-brand-dark/10 bg-white/74 px-4 py-3 backdrop-blur-md sm:px-5">
<div>
<h1 className="text-xl font-semibold">{stats.title}</h1>
<p className="mt-1 text-xs text-brand-dark/56"> insight factual summary만 .</p>
<p className="text-[11px] font-medium tracking-[0.14em] text-brand-dark/42">
{review.periodLabel}
</p>
<h1 className="mt-1 text-[1.2rem] font-semibold tracking-tight text-brand-dark">
{review.snapshotTitle}
</h1>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
void refetch();
}}
className="rounded-full border border-brand-dark/12 bg-white/74 px-3.5 py-2 text-[12px] font-medium text-brand-dark/78 transition hover:bg-white/94"
>
{stats.refresh}
</button>
<Link
href="/app"
className="rounded-full border border-brand-dark/12 bg-white/74 px-3.5 py-2 text-[12px] font-medium text-brand-dark/78 transition hover:bg-white/94"
>
{copy.common.hub}
</Link>
</div>
<Link
href="/app"
className="rounded-lg border border-brand-dark/16 px-3 py-1.5 text-xs text-brand-dark/82 transition hover:bg-white/90"
>
{copy.common.hub}
</Link>
</header>
<div className="space-y-6">
<section className="rounded-2xl 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">
<section className="overflow-hidden rounded-[32px] border border-brand-dark/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.84),rgba(255,255,255,0.7))] px-5 py-5 shadow-[0_20px_56px_rgba(148,163,184,0.12)] backdrop-blur-md sm:px-6 sm:py-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="max-w-3xl">
<p className="text-[11px] font-medium tracking-[0.14em] text-brand-dark/42">
{source === 'api' ? stats.sourceApi : stats.sourceMock}
</p>
{error ? (
<p className="mt-1 text-xs text-rose-500">{error}</p>
) : (
<p className="mt-1 text-xs text-brand-dark/56">
{isLoading ? stats.loading : stats.synced}
<p className="mt-3 text-[1.55rem] font-semibold leading-[1.3] tracking-tight text-brand-dark sm:text-[1.9rem]">
{review.snapshotSummary}
</p>
<p className="mt-3 text-[13px] leading-[1.6] text-brand-dark/54">
{error ? error : isLoading ? stats.loading : stats.synced}
</p>
</div>
<div className="grid w-full gap-3 sm:grid-cols-2 xl:w-[26rem]">
{review.snapshotMetrics.map((metric) => (
<ReviewMetric
key={metric.id}
label={metric.label}
value={metric.value}
hint={metric.hint}
/>
))}
</div>
</div>
</section>
<div className="grid gap-5 xl:grid-cols-[1.08fr_0.92fr]">
<ReviewSection
title={review.startQuality.title}
summary={review.startQuality.summary}
metrics={review.startQuality.metrics}
availability={review.startQuality.availability}
note={review.startQuality.note}
toneClass="bg-[linear-gradient(90deg,rgba(96,165,250,0.68),rgba(191,219,254,0.22))]"
/>
<ReviewSection
title={review.recoveryQuality.title}
summary={review.recoveryQuality.summary}
metrics={review.recoveryQuality.metrics}
availability={review.recoveryQuality.availability}
note={review.recoveryQuality.note}
toneClass="bg-[linear-gradient(90deg,rgba(20,184,166,0.68),rgba(153,246,228,0.22))]"
/>
</div>
<div className="grid gap-5 xl:grid-cols-[1fr_0.88fr]">
<ReviewSection
title={review.completionQuality.title}
summary={review.completionQuality.summary}
metrics={review.completionQuality.metrics}
availability={review.completionQuality.availability}
note={review.completionQuality.note}
toneClass="bg-[linear-gradient(90deg,rgba(251,191,36,0.72),rgba(253,230,138,0.22))]"
/>
<section className="overflow-hidden rounded-[28px] border border-brand-dark/10 bg-white/78 backdrop-blur-md">
<div className="h-1.5 w-full bg-[linear-gradient(90deg,rgba(139,92,246,0.72),rgba(221,214,254,0.2))]" />
<div className="px-5 py-5 sm:px-6">
<p className="text-[11px] font-medium tracking-[0.14em] text-brand-dark/42">
{review.periodLabel}
</p>
<h2 className="mt-2 text-[1.05rem] font-semibold tracking-tight text-brand-dark">
{stats.reviewCarryKeepTitle}
</h2>
<p className="mt-2 text-[14px] leading-[1.65] text-brand-dark/68">
{review.carryForward.keepDoing}
</p>
<div className="mt-5 border-t border-brand-dark/8 pt-5">
<h3 className="text-[1.02rem] font-semibold tracking-tight text-brand-dark">
{stats.reviewCarryTryTitle}
</h3>
<p className="mt-2 text-[14px] leading-[1.65] text-brand-dark/68">
{review.carryForward.tryNext}
</p>
)}
</div>
<Link
href={review.carryForward.ctaHref}
className="mt-6 inline-flex rounded-full border border-brand-dark/14 bg-brand-dark px-4 py-2.5 text-[12px] font-medium tracking-[0.04em] text-white transition hover:bg-brand-dark/92"
>
{review.carryForward.ctaLabel}
</Link>
</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"
>
{stats.refresh}
</button>
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{stats.today}</h2>
<div className="grid gap-3 sm:grid-cols-3">
{todayItems.map((item) => (
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
))}
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{stats.last7Days}</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{weeklyItems.map((item) => (
<FactualStatCard key={item.id} label={item.label} value={item.value} hint={item.hint} />
))}
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold text-brand-dark">{stats.chartTitle}</h2>
<div className="rounded-2xl 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-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={stats.barTitle(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 ? stats.chartWithTrend : stats.chartWithoutTrend}
</p>
</div>
</section>
</section>
</div>
</div>
</div>
</div>