feat(stats): observatory tone으로 review 재구성

This commit is contained in:
2026-03-16 13:49:01 +09:00
parent e16a182499
commit 6b25a18d5a
4 changed files with 131 additions and 77 deletions

View File

@@ -131,8 +131,8 @@ Last Updated: 2026-03-16
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀`만 limited note로 남긴다 - recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀`만 limited note로 남긴다
- `/stats` immersive review stage polish: - `/stats` immersive review stage polish:
- `/stats`를 밝은 대시보드 카드 반복 화면에서 dark immersive review stage로 재구성했다 - `/stats`를 밝은 대시보드 카드 반복 화면에서 dark immersive review stage로 재구성했다
- 중앙 hero summary, snapshot metric rail, start/recovery/completion panel, carry-forward closure stage를 같은 glass family로 통일했다 - 중앙 hero summary, snapshot signal board, start/recovery/completion observatory panel, carry-forward closure stage를 같은 glass family로 통일했다
- carry-forward ritual에 맞는 atmosphere 배경을 얇게 투영 `/app`과 같은 제품군으로 보이게 정리했다 - carry-forward ritual에 맞는 atmosphere 배경을 얇게 투영하되, `/app`의 entry ritual을 복제하지 않고 통계 화면 고유의 observatory 톤으로 분리했다
- `/app -> /stats` primary entry의 1차 연결: - `/app -> /stats` primary entry의 1차 연결:
- current session이 없고 최근 7일 데이터가 충분할 때 `/app`의 quiet secondary review dock에서 `Weekly Review` entry를 노출한다 - current session이 없고 최근 7일 데이터가 충분할 때 `/app`의 quiet secondary review dock에서 `Weekly Review` entry를 노출한다
- current session이 있으면 `/app` 자체가 `/space`로 이동하므로, `/app` review entry는 no-session entry shell 안에서만 다룬다 - current session이 있으면 `/app` 자체가 `/space`로 이동하므로, `/app` review entry는 no-session entry shell 안에서만 다룬다

View File

@@ -154,6 +154,8 @@ Weekly Review는 `/stats` 안에서 아래 5개 구역으로 재구성한다.
- 상단은 quiet accessory만 두고, hero summary가 화면의 중심이 된다 - 상단은 quiet accessory만 두고, hero summary가 화면의 중심이 된다
- snapshot metrics는 작은 glass tile rail로, start/recovery/completion은 같은 family의 review panel로 통일한다 - snapshot metrics는 작은 glass tile rail로, start/recovery/completion은 같은 family의 review panel로 통일한다
- 마지막 carry-forward CTA는 별도 버튼 묶음이 아니라 다음 세션 entry로 자연스럽게 이어지는 closure stage여야 한다 - 마지막 carry-forward CTA는 별도 버튼 묶음이 아니라 다음 세션 entry로 자연스럽게 이어지는 closure stage여야 한다
- `/app`의 entry ritual과는 다르게, `/stats``observatory / signal board` 감각으로 읽혀야 한다
- 즉 같은 glass family는 유지하되, 입력 ritual을 닮은 중앙 구조를 복제하지 않는다
### Section A. Weekly Snapshot ### Section A. Weekly Snapshot

View File

@@ -108,8 +108,8 @@ Last Updated: 2026-03-16
- 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다. - 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다.
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery`만 limited state로 남긴다. - recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery`만 limited state로 남긴다.
- `/stats` immersive review stage polish를 반영했다. - `/stats` immersive review stage polish를 반영했다.
- dark atmosphere 배경 위에 중앙 hero summary, snapshot metric rail, review panel, carry-forward closure stage를 같은 glass family로 재구성했다. - dark atmosphere 배경 위에 중앙 hero summary, snapshot signal board, review panel, carry-forward closure stage를 같은 glass family로 재구성했다.
- `/app`의 premium immersive tone과 같은 제품군으로 읽히도록 `/stats`의 위계와 재질을 다시 맞췄다. - `/app`의 premium immersive tone과 같은 제품군은 유지하되, 입력 ritual을 닮지 않는 stats 고유의 observatory 톤으로 분리했다.
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다. - `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
- current session이 없을 때는 quiet review dock에서 `/stats`로 진입할 수 있다. - current session이 없을 때는 quiet review dock에서 `/stats`로 진입할 수 있다.
- review entry는 main start CTA보다 항상 낮은 강조를 유지한다. - review entry는 main start CTA보다 항상 낮은 강조를 유지한다.

View File

@@ -9,10 +9,10 @@ import { useFocusStats } from '@/features/stats';
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
const glassPanelClass = const panelClass =
'rounded-[2rem] border border-white/10 bg-[linear-gradient(160deg,rgba(8,12,18,0.46)_0%,rgba(8,12,18,0.2)_58%,rgba(8,12,18,0.52)_100%)] shadow-[0_24px_80px_rgba(3,7,18,0.28)] backdrop-blur-[24px]'; 'relative overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(160deg,rgba(8,12,18,0.46)_0%,rgba(8,12,18,0.2)_58%,rgba(8,12,18,0.52)_100%)] shadow-[0_24px_80px_rgba(3,7,18,0.28)] backdrop-blur-[24px]';
const metricTileClass = const innerTileClass =
'rounded-[1.45rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.04)_100%)] px-4 py-4 backdrop-blur-xl'; 'rounded-[1.4rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.04)_100%)] px-4 py-4 backdrop-blur-xl';
const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id; const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
@@ -24,7 +24,7 @@ const reviewStageSceneByPreset = (presetId: string) => {
return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0]; return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0];
}; };
const StatusAccessory = ({ const AccessoryPill = ({
label, label,
subtle = false, subtle = false,
}: { }: {
@@ -36,7 +36,7 @@ const StatusAccessory = ({
className={cn( className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.2em] backdrop-blur-md', 'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.2em] backdrop-blur-md',
subtle subtle
? 'border-white/8 bg-white/[0.05] text-white/44' ? 'border-white/8 bg-white/[0.05] text-white/46'
: 'border-white/12 bg-white/[0.07] text-white/64', : 'border-white/12 bg-white/[0.07] text-white/64',
)} )}
> >
@@ -46,7 +46,7 @@ const StatusAccessory = ({
); );
}; };
const SnapshotMetric = ({ const SnapshotCell = ({
label, label,
value, value,
hint, hint,
@@ -56,15 +56,15 @@ const SnapshotMetric = ({
hint: string; hint: string;
}) => { }) => {
return ( return (
<div className={metricTileClass}> <div className={innerTileClass}>
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">{label}</p> <p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">{label}</p>
<p className="mt-3 text-[1.05rem] font-medium tracking-[-0.03em] text-white/92">{value}</p> <p className="mt-3 text-[1.1rem] font-medium tracking-[-0.03em] text-white/92">{value}</p>
<p className="mt-2 text-[12px] leading-[1.58] text-white/54">{hint}</p> <p className="mt-2 text-[12px] leading-[1.58] text-white/54">{hint}</p>
</div> </div>
); );
}; };
const ReviewSection = ({ const InsightBoard = ({
title, title,
summary, summary,
metrics, metrics,
@@ -79,38 +79,75 @@ const ReviewSection = ({
note?: string; note?: string;
accentClass: string; accentClass: string;
}) => { }) => {
const heroMetric = metrics[0];
const supportMetrics = metrics.slice(1);
return ( return (
<section className={cn(glassPanelClass, 'relative overflow-hidden p-5 sm:p-6')}> <section className={cn(panelClass, 'p-5 sm:p-6')}>
<div className={cn('pointer-events-none absolute inset-x-0 top-0 h-20 opacity-90', accentClass)} /> <div className={cn('pointer-events-none absolute inset-x-0 top-0 h-24 opacity-90', accentClass)} />
<div className="relative"> <div className="relative">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="max-w-[34rem]"> <div className="max-w-[34rem]">
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/40"> <p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/40">
Weekly Review Review Signal
</p> </p>
<h2 className="mt-3 text-[1.2rem] font-medium tracking-[-0.04em] text-white"> <h2 className="mt-3 text-[1.18rem] font-medium tracking-[-0.04em] text-white">
{title} {title}
</h2> </h2>
<p className="mt-3 text-[13px] leading-[1.68] text-white/64">{summary}</p> <p className="mt-3 text-[13px] leading-[1.68] text-white/64">{summary}</p>
</div> </div>
{availability === 'limited' ? <StatusAccessory label="Limited" subtle /> : null} {availability === 'limited' ? <AccessoryPill label="Limited" subtle /> : null}
</div> </div>
{metrics.length > 0 ? ( <div className="mt-6 grid gap-4 xl:grid-cols-[minmax(0,0.94fr)_minmax(15rem,0.78fr)]">
<div className="mt-5 grid gap-3 sm:grid-cols-3"> <div className={cn(innerTileClass, 'min-h-[11.5rem]')}>
{metrics.map((metric) => ( {heroMetric ? (
<SnapshotMetric <>
key={metric.id} <p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
label={metric.label} {heroMetric.label}
value={metric.value} </p>
hint={metric.hint} <div className="mt-5 flex items-end gap-3">
/> <p className="text-[2.55rem] font-light leading-none tracking-[-0.06em] text-white md:text-[3rem]">
))} {heroMetric.value}
</p>
</div>
<p className="mt-4 max-w-[24rem] text-[12px] leading-[1.62] text-white/54">
{heroMetric.hint}
</p>
</>
) : (
<p className="text-[12px] leading-[1.62] text-white/52"> .</p>
)}
</div>
<div className="space-y-3">
{supportMetrics.length > 0 ? (
supportMetrics.map((metric) => (
<div key={metric.id} className={innerTileClass}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
{metric.label}
</p>
<p className="mt-2 text-[1rem] font-medium tracking-[-0.03em] text-white/88">
{metric.value}
</p>
</div>
</div>
<p className="mt-3 text-[12px] leading-[1.58] text-white/54">{metric.hint}</p>
</div>
))
) : (
<div className={innerTileClass}>
<p className="text-[12px] leading-[1.58] text-white/52"> .</p>
</div>
)}
</div>
</div> </div>
) : null}
{note ? ( {note ? (
<p className="mt-5 rounded-[1.35rem] border border-white/8 bg-white/[0.05] px-4 py-3 text-[12px] leading-[1.62] text-white/52"> <p className="mt-4 rounded-[1.3rem] border border-white/8 bg-white/[0.05] px-4 py-3 text-[12px] leading-[1.62] text-white/52">
{note} {note}
</p> </p>
) : null} ) : null}
@@ -140,8 +177,9 @@ export const StatsOverviewWidget = () => {
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])} style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
/> />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.62)_100%)] mix-blend-multiply pointer-events-none" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.62)_100%)] mix-blend-multiply pointer-events-none" />
<div className="absolute inset-0 bg-black/18 pointer-events-none" /> <div className="absolute inset-0 bg-black/20 pointer-events-none" />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.12)_0%,rgba(2,6,23,0.32)_44%,rgba(2,6,23,0.62)_100%)] pointer-events-none" /> <div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.14)_0%,rgba(2,6,23,0.34)_44%,rgba(2,6,23,0.64)_100%)] pointer-events-none" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:72px_72px] opacity-[0.14] pointer-events-none" />
<header className="absolute inset-x-0 top-0 z-40 flex items-center justify-between px-5 py-5 md:px-8 md:py-8"> <header className="absolute inset-x-0 top-0 z-40 flex items-center justify-between px-5 py-5 md:px-8 md:py-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -174,69 +212,83 @@ export const StatsOverviewWidget = () => {
</div> </div>
</header> </header>
<main className="relative z-10 mx-auto flex min-h-dvh w-full max-w-[90rem] flex-col px-4 pb-10 pt-28 md:px-8 md:pb-12 md:pt-32"> <main className="relative z-10 mx-auto flex min-h-dvh w-full max-w-[92rem] flex-col px-4 pb-10 pt-28 md:px-8 md:pb-12 md:pt-32">
<div className="flex flex-col items-center text-center"> <section className="grid gap-5 xl:grid-cols-[minmax(0,1.08fr)_minmax(20rem,0.92fr)]">
<div className="flex flex-wrap items-center justify-center gap-2"> <div className={cn(panelClass, 'p-6 sm:p-7')}>
<StatusAccessory label={review.periodLabel} /> <div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),rgba(255,255,255,0)_62%)]" />
<StatusAccessory label={sourceLabel} subtle />
<div className="relative">
<div className="flex flex-wrap items-center gap-2">
<AccessoryPill label={review.periodLabel} />
<AccessoryPill label={sourceLabel} subtle />
</div> </div>
<div className="mt-8 max-w-[58rem] space-y-4"> <div className="mt-8 max-w-[44rem]">
<p className="text-[12px] font-medium uppercase tracking-[0.22em] text-white/42"> <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-white/42">
{review.snapshotTitle}
</p> </p>
<h1 className="text-[2.5rem] font-light leading-[0.98] tracking-[-0.06em] text-white md:text-[4rem]"> <h1 className="mt-4 text-[2.35rem] font-light leading-[0.98] tracking-[-0.06em] text-white md:text-[3.65rem]">
{review.snapshotSummary} {review.snapshotSummary}
</h1> </h1>
<p className="mx-auto max-w-[38rem] text-[13px] leading-[1.7] text-white/54 md:text-[14px]"> <p className="mt-4 max-w-[34rem] text-[13px] leading-[1.72] text-white/54 md:text-[14px]">
{syncLabel} {syncLabel}
</p> </p>
</div> </div>
</div> </div>
</div>
<section className="mt-10 grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className={cn(panelClass, 'p-5 sm:p-6')}>
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-[radial-gradient(circle_at_top_left,rgba(96,165,250,0.24),rgba(96,165,250,0)_62%)]" />
<div className="relative">
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/40">
Snapshot Signals
</p>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{review.snapshotMetrics.map((metric) => ( {review.snapshotMetrics.map((metric) => (
<SnapshotMetric <SnapshotCell
key={metric.id} key={metric.id}
label={metric.label} label={metric.label}
value={metric.value} value={metric.value}
hint={metric.hint} hint={metric.hint}
/> />
))} ))}
</div>
</div>
</div>
</section> </section>
<section className="mt-12 grid gap-5 xl:grid-cols-[1.08fr_0.92fr]"> <section className="mt-12 grid gap-5 xl:grid-cols-[1.06fr_0.94fr]">
<ReviewSection <InsightBoard
title={review.startQuality.title} title={review.startQuality.title}
summary={review.startQuality.summary} summary={review.startQuality.summary}
metrics={review.startQuality.metrics} metrics={review.startQuality.metrics}
availability={review.startQuality.availability} availability={review.startQuality.availability}
note={review.startQuality.note} note={review.startQuality.note}
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(96,165,250,0.34),rgba(96,165,250,0)_62%)]" accentClass="bg-[radial-gradient(circle_at_top_left,rgba(96,165,250,0.32),rgba(96,165,250,0)_62%)]"
/> />
<ReviewSection <InsightBoard
title={review.recoveryQuality.title} title={review.recoveryQuality.title}
summary={review.recoveryQuality.summary} summary={review.recoveryQuality.summary}
metrics={review.recoveryQuality.metrics} metrics={review.recoveryQuality.metrics}
availability={review.recoveryQuality.availability} availability={review.recoveryQuality.availability}
note={review.recoveryQuality.note} note={review.recoveryQuality.note}
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(20,184,166,0.32),rgba(20,184,166,0)_62%)]" accentClass="bg-[radial-gradient(circle_at_top_left,rgba(20,184,166,0.3),rgba(20,184,166,0)_62%)]"
/> />
</section> </section>
<section className="mt-5 grid gap-5 xl:grid-cols-[0.94fr_1.06fr]"> <section className="mt-5 grid gap-5 xl:grid-cols-[0.9fr_1.1fr]">
<ReviewSection <InsightBoard
title={review.completionQuality.title} title={review.completionQuality.title}
summary={review.completionQuality.summary} summary={review.completionQuality.summary}
metrics={review.completionQuality.metrics} metrics={review.completionQuality.metrics}
availability={review.completionQuality.availability} availability={review.completionQuality.availability}
note={review.completionQuality.note} note={review.completionQuality.note}
accentClass="bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.34),rgba(245,158,11,0)_62%)]" accentClass="bg-[radial-gradient(circle_at_top_left,rgba(245,158,11,0.32),rgba(245,158,11,0)_62%)]"
/> />
<section className={cn(glassPanelClass, 'relative overflow-hidden p-6 sm:p-7')}> <section className={cn(panelClass, 'p-6 sm:p-7')}>
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-[radial-gradient(circle_at_top_left,rgba(168,85,247,0.32),rgba(168,85,247,0)_62%)]" /> <div className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-[radial-gradient(circle_at_top_left,rgba(168,85,247,0.32),rgba(168,85,247,0)_62%)]" />
<div className="relative"> <div className="relative">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
@@ -247,25 +299,25 @@ export const StatsOverviewWidget = () => {
<h2 className="mt-3 text-[1.45rem] font-medium tracking-[-0.04em] text-white md:text-[1.8rem]"> <h2 className="mt-3 text-[1.45rem] font-medium tracking-[-0.04em] text-white md:text-[1.8rem]">
</h2> </h2>
<p className="mt-3 text-[14px] leading-[1.7] text-white/64"> <p className="mt-4 text-[14px] leading-[1.72] text-white/66">
{review.carryForward.keepDoing} {review.carryForward.keepDoing}
</p> </p>
</div> </div>
{isPro ? <StatusAccessory label="Recommended Ritual" subtle /> : null} {isPro ? <AccessoryPill label="Recommended Ritual" subtle /> : null}
</div> </div>
<div className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(15rem,0.78fr)]"> <div className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(15rem,0.78fr)]">
<div className={metricTileClass}> <div className={innerTileClass}>
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42"> <p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
</p> </p>
<p className="mt-3 text-[14px] leading-[1.7] text-white/72"> <p className="mt-3 text-[14px] leading-[1.72] text-white/72">
{review.carryForward.tryNext} {review.carryForward.tryNext}
</p> </p>
</div> </div>
<div className={metricTileClass}> <div className={innerTileClass}>
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42"> <p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
Atmosphere Atmosphere
</p> </p>
@@ -273,15 +325,15 @@ export const StatsOverviewWidget = () => {
{review.carryForward.presetLabel} {review.carryForward.presetLabel}
</p> </p>
<p className="mt-2 text-[12px] leading-[1.58] text-white/52"> <p className="mt-2 text-[12px] leading-[1.58] text-white/52">
. .
</p> </p>
</div> </div>
</div> </div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-5"> <div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-white/10 pt-5">
<p className="max-w-[30rem] text-[12px] leading-[1.65] text-white/44"> <p className="max-w-[30rem] text-[12px] leading-[1.65] text-white/44">
review는 , review는 ,
. .
</p> </p>
<Link <Link