From dc97a78fdd2692297e373a6bd0a9ccb5339b9820 Mon Sep 17 00:00:00 2001 From: corpi Date: Sat, 14 Mar 2026 19:22:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(stats):=20weekly=20review=20snapshot=201?= =?UTF-8?q?=EC=B0=A8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/12_core_loop_execution_roadmap.md | 3 +- docs/90_current_state.md | 6 +- docs/session_brief.md | 7 +- docs/work.md | 20 +- src/features/stats/model/useFocusStats.ts | 273 ++++++++++++++- src/shared/i18n/messages/app.ts | 51 ++- src/shared/i18n/messages/product.ts | 51 ++- .../stats-overview/ui/StatsOverviewWidget.tsx | 319 ++++++++++-------- 8 files changed, 563 insertions(+), 167 deletions(-) diff --git a/docs/12_core_loop_execution_roadmap.md b/docs/12_core_loop_execution_roadmap.md index d2b6e26..22b44fe 100644 --- a/docs/12_core_loop_execution_roadmap.md +++ b/docs/12_core_loop_execution_roadmap.md @@ -175,7 +175,8 @@ VibeRoom은 아래 방식으로 진행한다. 상태: - 상세 기획 문서 작성 완료 -- 구현 전 +- 1차 snapshot 구현 완료 +- 남은 것은 recovery 집계 연결, ritual fit, Free / Pro gating 문서: diff --git a/docs/90_current_state.md b/docs/90_current_state.md index f5c8f33..4c9ae3e 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -110,8 +110,10 @@ Last Updated: 2026-03-14 - setup drawer에서 Daily Plan / Ritual Library 진입 섹션 제거 - `/app`에서 넘긴 goal + `planItemId`를 받아 execution-only surface로 집중 - `/stats` factual summary 정착: - - 기존 API summary/trend 유지 - - 해석형 insight/quiet accountability preview를 제거하고 factual card만 유지 + - factual card 반복 중심의 구조를 해체하고 `Weekly Review` 1차 IA로 전환 + - `snapshot + start quality + recovery quality + completion quality + carry forward` 구조를 반영 + - 기존 `focus-summary` 응답을 주간 review view model로 변환해서 사용 + - recovery는 API 집계가 아직 없을 때 limited state로 조용히 표시 - paywall / plan / landing 메시지 재정렬: - paywall 가치 포인트를 multi-queue, rituals, weekly review 중심으로 재작성 - landing pricing에서 구현되지 않은 1:1 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거 diff --git a/docs/session_brief.md b/docs/session_brief.md index 1432a0f..6761df1 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -79,9 +79,10 @@ Last Updated: 2026-03-14 - GoalCompleteSheet confirm 시 `advance-goal` endpoint를 사용한다. - 현재 세션 완료, linked plan item 완료, 새 current item 생성, 다음 세션 시작을 한 번에 처리한다. - 실패 시 시트를 닫지 않고 그대로 재시도할 수 있다. -- `/stats`는 해석형 review 화면이 아니라 factual summary로 정리됐다. - - today / last7Days / trend만 유지한다. - - started/completed/carried over/focus minutes 중심으로 표시한다. +- `/stats`는 factual summary에서 `Weekly Review` 1차 구조로 올라갔다. + - hero snapshot, start quality, recovery quality, completion quality, carry forward 구조를 사용한다. + - 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다. + - recovery는 API 집계가 아직 없을 때 limited state로 표시한다. - 유료화 포지셔닝을 `Calm Session OS`로 재정의했다. - Free는 기본 집중 시작, Pro는 더 잘 이어가기라는 메시지로 정리했다. - old `Scene Packs / Sound Packs / Profiles` 중심 카피를 `Daily plan / Rituals / Weekly review` 구조로 교체했다. diff --git a/docs/work.md b/docs/work.md index 7b9215d..6420b62 100644 --- a/docs/work.md +++ b/docs/work.md @@ -91,21 +91,23 @@ ## 작업 4 -- 제목: `Weekly Review Reframe` 구현 준비 +- 제목: `Weekly Review Reframe` 2차 구현 - 목적: - - `14_weekly_review_reframe_spec.md` 기준으로 `/stats`를 행동 변화 중심 review surface로 옮기기 위한 첫 구현 slice를 연다. + - `14_weekly_review_reframe_spec.md` 기준으로 `/stats` review를 더 실제 제품 수준으로 끌어올린다. - 변경 범위: - - weekly-review API shape 설계 - - mock summary 구조 재정의 - - `/stats` IA 재배치 초안 + - recovery quality 실제 집계 연결 + - ritual fit highlight 1차 + - Free / Pro review gating - 제외 범위: - planner/dashboard 확장 금지 - 과한 해석형 카피 금지 - Premium Ambience 작업 선행 금지 - 완료 조건: - - `/stats`가 더 이상 단순 factual card 반복이 아니라 snapshot + start/recovery/completion 구조로 전환되기 시작한다. + - `/stats`의 recovery가 limited state가 아니라 실제 데이터 기반으로 보인다. + - ritual fit이 mock 문구가 아니라 실제 주간 review 흐름에 들어간다. + - Free와 Pro의 review 차이가 분명해진다. - 검증: - - 상세 spec과 구현 초안 일치 - - 브라우저 구조 확인 + - 상세 spec과 구현 일치 + - 브라우저 구조 / 문장 / hierarchy 확인 - 커밋 힌트: - - feat(stats): weekly-review-snapshot + - feat(stats): weekly-review-recovery diff --git a/src/features/stats/model/useFocusStats.ts b/src/features/stats/model/useFocusStats.ts index b00cb92..3971d41 100644 --- a/src/features/stats/model/useFocusStats.ts +++ b/src/features/stats/model/useFocusStats.ts @@ -15,6 +15,24 @@ const parseDurationLabelToMinutes = (label: string) => { return hours * 60 + minutes; }; +const formatMinutesLabel = (minutes: number) => { + const safeMinutes = Math.max(0, minutes); + const hourPart = Math.floor(safeMinutes / 60); + const minutePart = safeMinutes % 60; + + if (hourPart === 0) { + return `${minutePart}분`; + } + + if (minutePart === 0) { + return `${hourPart}시간`; + } + + return `${hourPart}시간 ${minutePart}분`; +}; + +const formatPercent = (value: number) => `${Math.round(value * 100)}%`; + const buildMockSummary = (): FocusStatsSummary => { return { today: { @@ -35,8 +53,251 @@ const buildMockSummary = (): FocusStatsSummary => { }; }; +export interface WeeklyReviewMetric { + id: string; + label: string; + value: string; + hint: string; +} + +export interface WeeklyReviewSection { + title: string; + summary: string; + metrics: WeeklyReviewMetric[]; + availability: 'ready' | 'limited'; + note?: string; +} + +export interface WeeklyReviewViewModel { + periodLabel: string; + snapshotTitle: string; + snapshotSummary: string; + snapshotMetrics: WeeklyReviewMetric[]; + startQuality: WeeklyReviewSection; + recoveryQuality: WeeklyReviewSection; + completionQuality: WeeklyReviewSection; + carryForward: { + keepDoing: string; + tryNext: string; + ctaLabel: string; + ctaHref: string; + }; +} + +const buildSnapshotSummary = (summary: FocusStatsSummary) => { + const { startedSessions, completedSessions, carriedOverCount } = summary.last7Days; + const completionRate = startedSessions > 0 ? completedSessions / startedSessions : 0; + + if (startedSessions === 0) { + return copy.stats.reviewSnapshotEmpty; + } + + if (completionRate >= 0.6 && carriedOverCount === 0) { + return copy.stats.reviewSnapshotStrong; + } + + if (completionRate >= 0.4) { + return copy.stats.reviewSnapshotSteady; + } + + return copy.stats.reviewSnapshotRecoveryNeeded; +}; + +const buildStartSummary = (summary: FocusStatsSummary) => { + const averageDepthMinutes = + summary.last7Days.startedSessions > 0 + ? Math.round(summary.last7Days.focusMinutes / summary.last7Days.startedSessions) + : 0; + + if (summary.last7Days.startedSessions === 0) { + return copy.stats.reviewStartEmpty; + } + + return copy.stats.reviewStartSummary( + summary.last7Days.startedSessions, + summary.last7Days.bestDayLabel, + formatMinutesLabel(averageDepthMinutes), + ); +}; + +const buildCompletionSummary = (summary: FocusStatsSummary) => { + const completionRate = + summary.last7Days.startedSessions > 0 + ? summary.last7Days.completedSessions / summary.last7Days.startedSessions + : 0; + + if (summary.last7Days.startedSessions === 0) { + return copy.stats.reviewCompletionEmpty; + } + + if (summary.last7Days.carriedOverCount === 0 && completionRate >= 0.6) { + return copy.stats.reviewCompletionStrong; + } + + return copy.stats.reviewCompletionSummary( + formatPercent(completionRate), + summary.last7Days.carriedOverCount, + ); +}; + +const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['carryForward'] => { + const completionRate = + summary.last7Days.startedSessions > 0 + ? summary.last7Days.completedSessions / summary.last7Days.startedSessions + : 0; + + const keepDoing = + summary.last7Days.bestDayFocusMinutes > 0 + ? copy.stats.reviewCarryKeep(summary.last7Days.bestDayLabel) + : copy.stats.reviewCarryKeepGeneric; + + let tryNext: string = copy.stats.reviewCarryTryDefault; + + if (summary.last7Days.carriedOverCount >= 2) { + tryNext = copy.stats.reviewCarryTrySmaller; + } else if (completionRate < 0.45) { + tryNext = copy.stats.reviewCarryTryClosure; + } else if (summary.last7Days.startedSessions <= 3) { + tryNext = copy.stats.reviewCarryTryStart; + } + + return { + keepDoing, + tryNext, + ctaLabel: copy.stats.reviewCarryCta, + ctaHref: '/app', + }; +}; + +const buildReviewFromSummary = ( + summary: FocusStatsSummary, + source: StatsSource, +): WeeklyReviewViewModel => { + const completionRate = + summary.last7Days.startedSessions > 0 + ? summary.last7Days.completedSessions / summary.last7Days.startedSessions + : 0; + const averageDepthMinutes = + summary.last7Days.startedSessions > 0 + ? Math.round(summary.last7Days.focusMinutes / summary.last7Days.startedSessions) + : 0; + + return { + periodLabel: copy.stats.reviewPeriodLabel, + snapshotTitle: copy.stats.reviewTitle, + snapshotSummary: buildSnapshotSummary(summary), + snapshotMetrics: [ + { + id: 'snapshot-started', + label: copy.stats.reviewStarted, + value: `${summary.last7Days.startedSessions}${copy.stats.countUnit}`, + hint: copy.stats.reviewStartedHint, + }, + { + id: 'snapshot-completed', + label: copy.stats.reviewCompleted, + value: `${summary.last7Days.completedSessions}${copy.stats.countUnit}`, + hint: copy.stats.reviewCompletedHint, + }, + { + id: 'snapshot-focus', + label: copy.stats.reviewFocusMinutes, + value: formatMinutesLabel(summary.last7Days.focusMinutes), + hint: copy.stats.reviewFocusMinutesHint, + }, + { + id: 'snapshot-carried', + label: copy.stats.reviewCarriedOver, + value: `${summary.last7Days.carriedOverCount}${copy.stats.countUnit}`, + hint: copy.stats.reviewCarriedOverHint, + }, + ], + startQuality: { + title: copy.stats.reviewStartTitle, + summary: buildStartSummary(summary), + availability: 'ready', + metrics: [ + { + id: 'start-sessions', + label: copy.stats.reviewStarted, + value: `${summary.last7Days.startedSessions}${copy.stats.countUnit}`, + hint: copy.stats.reviewStartedHint, + }, + { + id: 'start-depth', + label: copy.stats.reviewAverageDepth, + value: formatMinutesLabel(averageDepthMinutes), + hint: copy.stats.reviewAverageDepthHint, + }, + { + id: 'start-best-day', + label: copy.stats.reviewBestDay, + value: summary.last7Days.bestDayLabel, + hint: copy.stats.reviewBestDayHint(summary.last7Days.bestDayFocusMinutes), + }, + ], + }, + recoveryQuality: { + title: copy.stats.reviewRecoveryTitle, + summary: + source === 'mock' + ? copy.stats.reviewRecoveryMockSummary + : copy.stats.reviewRecoveryLimitedSummary, + availability: source === 'mock' ? 'ready' : 'limited', + metrics: + source === 'mock' + ? [ + { + id: 'recovery-pause', + label: copy.stats.reviewPauseRecovery, + value: '67%', + hint: copy.stats.reviewPauseRecoveryHint, + }, + { + id: 'recovery-away', + label: copy.stats.reviewAwayRecovery, + value: '50%', + hint: copy.stats.reviewAwayRecoveryHint, + }, + ] + : [], + note: + source === 'mock' + ? copy.stats.reviewRecoveryMockNote + : copy.stats.reviewRecoveryLimitedNote, + }, + completionQuality: { + title: copy.stats.reviewCompletionTitle, + summary: buildCompletionSummary(summary), + availability: 'ready', + metrics: [ + { + id: 'completion-rate', + label: copy.stats.reviewCompletionRate, + value: formatPercent(completionRate), + hint: copy.stats.reviewCompletionRateHint, + }, + { + id: 'completion-goals', + label: copy.stats.reviewCompleted, + value: `${summary.last7Days.completedSessions}${copy.stats.countUnit}`, + hint: copy.stats.reviewCompletedHint, + }, + { + id: 'completion-carry', + label: copy.stats.reviewCarriedOver, + value: `${summary.last7Days.carriedOverCount}${copy.stats.countUnit}`, + hint: copy.stats.reviewCarriedOverHint, + }, + ], + }, + carryForward: buildCarryForward(summary), + }; +}; + interface UseFocusStatsResult { summary: FocusStatsSummary; + review: WeeklyReviewViewModel; isLoading: boolean; error: string | null; source: StatsSource; @@ -44,7 +305,11 @@ interface UseFocusStatsResult { } export const useFocusStats = (): UseFocusStatsResult => { - const [summary, setSummary] = useState(buildMockSummary); + const initialSummary = buildMockSummary(); + const [summary, setSummary] = useState(initialSummary); + const [review, setReview] = useState( + buildReviewFromSummary(initialSummary, 'mock'), + ); const [isLoading, setLoading] = useState(true); const [error, setError] = useState(null); const [source, setSource] = useState('mock'); @@ -55,12 +320,15 @@ export const useFocusStats = (): UseFocusStatsResult => { try { const nextSummary = await statsApi.getFocusStatsSummary(); setSummary(nextSummary); + setReview(buildReviewFromSummary(nextSummary, 'api')); setSource('api'); setError(null); } catch (nextError) { const message = nextError instanceof Error ? nextError.message : copy.stats.loadFailed; - setSummary(buildMockSummary()); + const nextSummary = buildMockSummary(); + setSummary(nextSummary); + setReview(buildReviewFromSummary(nextSummary, 'mock')); setSource('mock'); setError(message); } finally { @@ -74,6 +342,7 @@ export const useFocusStats = (): UseFocusStatsResult => { return { summary, + review, isLoading, error, source, diff --git a/src/shared/i18n/messages/app.ts b/src/shared/i18n/messages/app.ts index 6e8ae6b..93407b6 100644 --- a/src/shared/i18n/messages/app.ts +++ b/src/shared/i18n/messages/app.ts @@ -19,15 +19,62 @@ export const app = { ], }, stats: { - title: 'Stats', + title: 'Weekly Review', apiLabel: 'API', mockLabel: 'Mock', sourceApi: 'API 통계 사용 중', sourceMock: 'API 실패로 mock 통계 표시 중', loading: '통계를 불러오는 중이에요.', loadFailed: '통계를 불러오지 못했어요.', - synced: '화면 진입 시 최신 요약을 동기화합니다.', + synced: '최근 7일 review를 최신 요약으로 동기화합니다.', refresh: '새로고침', + reviewPeriodLabel: '최근 7일 review', + reviewTitle: '이번 주 집중 리듬', + reviewSnapshotEmpty: '이번 주에는 아직 집중 기록이 많지 않아요. 다음 세션 하나만 다시 만들면 충분해요.', + reviewSnapshotStrong: '이번 주에는 시작한 흐름을 끝까지 가져간 편이에요.', + reviewSnapshotSteady: '이번 주에는 시작은 꾸준했고, 마무리 리듬은 조금 더 다듬을 여지가 있어요.', + reviewSnapshotRecoveryNeeded: '이번 주에는 시작은 있었지만, 닫힘보다 이월이 더 많았어요.', + reviewStarted: '시작한 세션', + reviewStartedHint: '최근 7일 space에 들어간 흐름', + reviewCompleted: '마무리한 세션', + reviewCompletedHint: 'goal 또는 timer를 닫은 세션', + reviewFocusMinutes: '집중 시간', + reviewFocusMinutesHint: '최근 7일 누적 focus 시간', + reviewCarriedOver: '이월된 블록', + reviewCarriedOverHint: '다음 날로 이어진 블록', + reviewStartTitle: '시작 품질', + reviewStartEmpty: '아직 시작 기록이 많지 않아요. 다음 주에는 시작 횟수 하나를 더 만드는 것이 우선이에요.', + reviewStartSummary: (startedSessions: number, bestDayLabel: string, averageDepthLabel: string) => + `이번 주엔 ${startedSessions}번 시작했고, ${bestDayLabel}에 가장 길게 이어졌어요. 한 번 시작했을 때 평균 ${averageDepthLabel} 정도 머물렀어요.`, + reviewAverageDepth: '평균 세션 깊이', + reviewAverageDepthHint: '한 번 시작했을 때 이어진 평균 시간', + reviewBestDay: '가장 길게 이어진 날', + reviewBestDayHint: (minutes: number) => `${minutes}분 동안 가장 오래 집중했어요.`, + reviewRecoveryTitle: '복귀 품질', + reviewRecoveryMockSummary: '이번 주에는 pause 뒤에도 다시 올라탄 흐름이 있었어요. 복귀는 적었지만 분명히 존재했어요.', + reviewRecoveryLimitedSummary: '복귀 패턴은 아직 이 review에 충분히 합쳐지지 않았어요. 지금은 시작과 마무리 흐름을 먼저 봅니다.', + reviewRecoveryMockNote: 'mock 기준으로 pause 뒤 복귀와 away 뒤 복귀를 함께 보여줍니다.', + reviewRecoveryLimitedNote: 'pause / away 복귀 집계는 다음 연결 단계에서 이 review에 포함됩니다.', + reviewPauseRecovery: 'pause 뒤 복귀', + reviewPauseRecoveryHint: '멈춘 뒤 다시 돌아온 비율', + reviewAwayRecovery: '자리 비움 뒤 복귀', + reviewAwayRecoveryHint: '앱을 떠났다가 다시 돌아온 비율', + reviewCompletionTitle: '마무리 품질', + reviewCompletionEmpty: '이번 주에는 아직 마무리 흐름을 읽을 만큼 세션이 쌓이지 않았어요.', + reviewCompletionStrong: '이번 주에는 시작한 세션을 끝까지 가져가는 힘이 비교적 안정적이었어요.', + reviewCompletionSummary: (completionRate: string, carriedOverCount: number) => + `완료율은 ${completionRate}였고, 이월된 블록은 ${carriedOverCount}개였어요. 다음 주에는 닫힘 리듬을 더 분명히 만들 여지가 있어요.`, + reviewCompletionRate: '완료율', + reviewCompletionRateHint: '시작한 세션 중 마무리까지 간 비율', + reviewCarryKeep: (bestDayLabel: string) => `${bestDayLabel}처럼 길게 이어졌던 흐름을 다음 주에도 기본 리듬으로 유지해 보세요.`, + reviewCarryKeepGeneric: '이번 주에 가장 오래 이어진 흐름의 길이를 다음 주 기본 리듬으로 유지해 보세요.', + reviewCarryTryDefault: '다음 주 첫 세션은 시간을 늘리기보다, 바로 시작할 한 줄을 더 작게 잡아 보세요.', + reviewCarryTrySmaller: '이월된 블록이 많았어요. 다음 주에는 목표를 더 작게 잡고 첫 microStep을 더 구체적으로 적어보세요.', + reviewCarryTryClosure: '시작은 있었지만 마무리가 약했어요. 다음 주에는 완료 직전에 다른 블록으로 넘어가지 않는 흐름을 한 번 만들어 보세요.', + reviewCarryTryStart: '시작 횟수가 적었어요. 다음 주에는 길이를 늘리기보다 첫 세션을 한 번 더 여는 것에 집중해 보세요.', + reviewCarryCta: '이 흐름으로 다음 세션 시작', + reviewCarryKeepTitle: '다음 주에 유지할 것', + reviewCarryTryTitle: '다음 주에 바꿔볼 것', today: '오늘', last7Days: '최근 7일', chartTitle: '집중 흐름 그래프', diff --git a/src/shared/i18n/messages/product.ts b/src/shared/i18n/messages/product.ts index ed44858..0a25699 100644 --- a/src/shared/i18n/messages/product.ts +++ b/src/shared/i18n/messages/product.ts @@ -19,15 +19,62 @@ export const settings = { } as const; export const stats = { - title: 'Stats', + title: 'Weekly Review', apiLabel: 'API', mockLabel: 'Mock', sourceApi: 'API 통계 사용 중', sourceMock: 'API 실패로 mock 통계 표시 중', loading: '통계를 불러오는 중이에요.', loadFailed: '통계를 불러오지 못했어요.', - synced: '화면 진입 시 최신 요약을 동기화합니다.', + synced: '최근 7일 review를 최신 요약으로 동기화합니다.', refresh: '새로고침', + reviewPeriodLabel: '최근 7일 review', + reviewTitle: '이번 주 집중 리듬', + reviewSnapshotEmpty: '이번 주에는 아직 집중 기록이 많지 않아요. 다음 세션 하나만 다시 만들면 충분해요.', + reviewSnapshotStrong: '이번 주에는 시작한 흐름을 끝까지 가져간 편이에요.', + reviewSnapshotSteady: '이번 주에는 시작은 꾸준했고, 마무리 리듬은 조금 더 다듬을 여지가 있어요.', + reviewSnapshotRecoveryNeeded: '이번 주에는 시작은 있었지만, 닫힘보다 이월이 더 많았어요.', + reviewStarted: '시작한 세션', + reviewStartedHint: '최근 7일 space에 들어간 흐름', + reviewCompleted: '마무리한 세션', + reviewCompletedHint: 'goal 또는 timer를 닫은 세션', + reviewFocusMinutes: '집중 시간', + reviewFocusMinutesHint: '최근 7일 누적 focus 시간', + reviewCarriedOver: '이월된 블록', + reviewCarriedOverHint: '다음 날로 이어진 블록', + reviewStartTitle: '시작 품질', + reviewStartEmpty: '아직 시작 기록이 많지 않아요. 다음 주에는 시작 횟수 하나를 더 만드는 것이 우선이에요.', + reviewStartSummary: (startedSessions: number, bestDayLabel: string, averageDepthLabel: string) => + `이번 주엔 ${startedSessions}번 시작했고, ${bestDayLabel}에 가장 길게 이어졌어요. 한 번 시작했을 때 평균 ${averageDepthLabel} 정도 머물렀어요.`, + reviewAverageDepth: '평균 세션 깊이', + reviewAverageDepthHint: '한 번 시작했을 때 이어진 평균 시간', + reviewBestDay: '가장 길게 이어진 날', + reviewBestDayHint: (minutes: number) => `${minutes}분 동안 가장 오래 집중했어요.`, + reviewRecoveryTitle: '복귀 품질', + reviewRecoveryMockSummary: '이번 주에는 pause 뒤에도 다시 올라탄 흐름이 있었어요. 복귀는 적었지만 분명히 존재했어요.', + reviewRecoveryLimitedSummary: '복귀 패턴은 아직 이 review에 충분히 합쳐지지 않았어요. 지금은 시작과 마무리 흐름을 먼저 봅니다.', + reviewRecoveryMockNote: 'mock 기준으로 pause 뒤 복귀와 away 뒤 복귀를 함께 보여줍니다.', + reviewRecoveryLimitedNote: 'pause / away 복귀 집계는 다음 연결 단계에서 이 review에 포함됩니다.', + reviewPauseRecovery: 'pause 뒤 복귀', + reviewPauseRecoveryHint: '멈춘 뒤 다시 돌아온 비율', + reviewAwayRecovery: '자리 비움 뒤 복귀', + reviewAwayRecoveryHint: '앱을 떠났다가 다시 돌아온 비율', + reviewCompletionTitle: '마무리 품질', + reviewCompletionEmpty: '이번 주에는 아직 마무리 흐름을 읽을 만큼 세션이 쌓이지 않았어요.', + reviewCompletionStrong: '이번 주에는 시작한 세션을 끝까지 가져가는 힘이 비교적 안정적이었어요.', + reviewCompletionSummary: (completionRate: string, carriedOverCount: number) => + `완료율은 ${completionRate}였고, 이월된 블록은 ${carriedOverCount}개였어요. 다음 주에는 닫힘 리듬을 더 분명히 만들 여지가 있어요.`, + reviewCompletionRate: '완료율', + reviewCompletionRateHint: '시작한 세션 중 마무리까지 간 비율', + reviewCarryKeep: (bestDayLabel: string) => `${bestDayLabel}처럼 길게 이어졌던 흐름을 다음 주에도 기본 리듬으로 유지해 보세요.`, + reviewCarryKeepGeneric: '이번 주에 가장 오래 이어진 흐름의 길이를 다음 주 기본 리듬으로 유지해 보세요.', + reviewCarryTryDefault: '다음 주 첫 세션은 시간을 늘리기보다, 바로 시작할 한 줄을 더 작게 잡아 보세요.', + reviewCarryTrySmaller: '이월된 블록이 많았어요. 다음 주에는 목표를 더 작게 잡고 첫 microStep을 더 구체적으로 적어보세요.', + reviewCarryTryClosure: '시작은 있었지만 마무리가 약했어요. 다음 주에는 완료 직전에 다른 블록으로 넘어가지 않는 흐름을 한 번 만들어 보세요.', + reviewCarryTryStart: '시작 횟수가 적었어요. 다음 주에는 길이를 늘리기보다 첫 세션을 한 번 더 여는 것에 집중해 보세요.', + reviewCarryCta: '이 흐름으로 다음 세션 시작', + reviewCarryKeepTitle: '다음 주에 유지할 것', + reviewCarryTryTitle: '다음 주에 바꿔볼 것', today: '오늘', last7Days: '최근 7일', chartTitle: '집중 흐름 그래프', diff --git a/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx b/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx index 96433be..70d14e7 100644 --- a/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx +++ b/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx @@ -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 ( -
-

{label}

-

{value}

-

{hint}

-
+
+

{label}

+

{value}

+

{hint}

+
+ ); +}; + +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 ( +
+
+
+
+
+

{title}

+

{summary}

+
+ {availability === 'limited' ? ( + + Limited + + ) : null} +
+ + {metrics.length > 0 ? ( +
+ {metrics.map((metric) => ( + + ))} +
+ ) : null} + + {note ? ( +

+ {note} +

+ ) : null} +
+
); }; 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 ( -
-
-
+
+
+
-

{stats.title}

-

해석형 insight 없이 factual summary만 표시합니다.

+

+ {review.periodLabel} +

+

+ {review.snapshotTitle} +

+
+ +
+ + + {copy.common.hub} +
- - {copy.common.hub} -
-
-
-
-

+

+
+
+

{source === 'api' ? stats.sourceApi : stats.sourceMock}

- {error ? ( -

{error}

- ) : ( -

- {isLoading ? stats.loading : stats.synced} +

+ {review.snapshotSummary} +

+

+ {error ? error : isLoading ? stats.loading : stats.synced} +

+
+ +
+ {review.snapshotMetrics.map((metric) => ( + + ))} +
+
+
+ +
+ + + +
+ +
+ + +
+
+
+

+ {review.periodLabel} +

+

+ {stats.reviewCarryKeepTitle} +

+

+ {review.carryForward.keepDoing} +

+ +
+

+ {stats.reviewCarryTryTitle} +

+

+ {review.carryForward.tryNext}

- )} +
+ + + {review.carryForward.ctaLabel} +
- -
-
- -
-

{stats.today}

-
- {todayItems.map((item) => ( - - ))} -
-
- -
-

{stats.last7Days}

-
- {weeklyItems.map((item) => ( - - ))} -
-
- -
-

{stats.chartTitle}

-
-
- {summary.trend.length > 0 ? ( -
- {summary.trend.map((point) => { - const barHeight = Math.max(14, Math.min(100, point.focusMinutes)); - - return ( -
-
- {point.date.slice(5)} -
- ); - })} -
- ) : null} -
-

- {summary.trend.length > 0 ? stats.chartWithTrend : stats.chartWithoutTrend} -

-
-
+
+