From 3aba789c97a78804ca20decf48c3d67e6681c884 Mon Sep 17 00:00:00 2001 From: corpi Date: Sun, 15 Mar 2026 19:18:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(stats):=20recovery=20=ED=86=B5=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B2=84=20=EA=B3=84=EC=95=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/12_core_loop_execution_roadmap.md | 7 +- docs/90_current_state.md | 6 +- docs/session_brief.md | 8 +- src/features/stats/api/statsApi.ts | 6 + src/features/stats/model/useFocusStats.ts | 113 +++++++++++++----- src/shared/i18n/messages/app.ts | 10 +- src/shared/i18n/messages/product.ts | 10 +- .../ui/FocusDashboardWidget.tsx | 2 +- .../ui/SpaceWorkspaceWidget.tsx | 2 +- 9 files changed, 122 insertions(+), 42 deletions(-) diff --git a/docs/12_core_loop_execution_roadmap.md b/docs/12_core_loop_execution_roadmap.md index 247a550..6726c03 100644 --- a/docs/12_core_loop_execution_roadmap.md +++ b/docs/12_core_loop_execution_roadmap.md @@ -181,7 +181,8 @@ VibeRoom은 아래 방식으로 진행한다. - 1차 snapshot 구현 완료 - `/app -> /stats -> /app` entry flow 구현 완료 - `/space` complete 이후 secondary teaser 구현 완료 -- 남은 것은 recovery 집계 연결, ritual fit, Free / Pro gating +- pause recovery 집계 연결 완료 +- 남은 것은 away recovery event schema, ritual fit, Free / Pro gating 문서: @@ -343,7 +344,7 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가 - `Paused Session Re-entry` 구현 - `/space` recovery browser QA -- Weekly Review recovery 집계 연결 +- Weekly Review ritual fit highlight - Premium Ambience System ### 방금 완료 @@ -360,7 +361,7 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가 - `Weekly Review Entry Flow` Slice 4 - `/space`에서 goal complete로 setup 상태로 돌아온 직후에만 secondary review teaser가 보인다 - full review 강제 이동 없이 작은 후행 경로로 `/stats`를 연다 - - 다음 구현은 weekly review의 실제 recovery 집계 연결이다 + - 다음 구현은 weekly review의 ritual fit highlight 또는 deeper recovery schema다 - `Paused Session Re-entry` spec - running / paused / break의 route policy를 한 문서에서 고정했다 - `/app`은 paused session의 resume gate가 되고, explicit continue 이후 `/space`에서는 자동 resume해야 한다 diff --git a/docs/90_current_state.md b/docs/90_current_state.md index be26113..66ae7c4 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -113,7 +113,7 @@ Last Updated: 2026-03-15 - factual card 반복 중심의 구조를 해체하고 `Weekly Review` 1차 IA로 전환 - `snapshot + start quality + recovery quality + completion quality + carry forward` 구조를 반영 - 기존 `focus-summary` 응답을 주간 review view model로 변환해서 사용 - - recovery는 API 집계가 아직 없을 때 limited state로 조용히 표시 + - recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀`만 limited note로 남긴다 - `/app -> /stats` primary entry의 1차 연결: - current session이 없고 최근 7일 데이터가 충분할 때 `/app` hero 아래에 low-emphasis `Weekly Review` teaser를 노출한다 - teaser는 `/stats`로 연결되며, hero CTA보다 한 단계 아래 시각 우선순위를 유지한다 @@ -129,6 +129,10 @@ Last Updated: 2026-03-15 - goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 low-emphasis review teaser가 보인다 - teaser는 `주간 review 보기`로 `/stats`를 열고, 방금 끝낸 흐름 반영을 과장하지 않는 카피만 사용한다 - 다시 시작하거나 dismiss하면 사라지며, live execution 중에는 보이지 않는다 +- `Weekly Review` recovery의 서버 연결: + - server `focus-summary` 응답에 `recovery`가 추가됐다 + - `pause_count / resume_count` 기반 `pause 뒤 복귀`를 실제 수치로 보여준다 + - 현재는 `away recovery` 이벤트 스키마가 없어 partial/limited 상태로 남긴다 - 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 0997857..ade4316 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -16,7 +16,7 @@ Last Updated: 2026-03-15 1. `Paused Session Takeover Flow` 2. `Core Loop Alignment` browser audit -3. `Weekly Review` recovery 집계 연결 +3. `Weekly Review` ritual fit highlight 4. `Premium Ambience` ## 최근 세션 상태 @@ -83,7 +83,7 @@ Last Updated: 2026-03-15 - `/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로 표시한다. + - recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery`만 limited state로 남긴다. - `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다. - current session이 없고 최근 7일 데이터가 충분하면 hero 아래에 weekly review teaser가 보인다. - teaser는 `/stats`로 이동시키되, main start CTA보다 낮은 강조로 유지한다. @@ -99,7 +99,9 @@ Last Updated: 2026-03-15 - goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 작은 review teaser가 보인다. - full review 강제 이동 없이 `/stats`를 여는 secondary entry로만 동작한다. - 방금 끝낸 흐름을 반영한다고 과장하지 않는 카피로 정리했다. - - 다음 구현은 weekly review의 실제 recovery 집계 연결이다. +- `Weekly Review` recovery의 서버 연결이 들어갔다. + - server `focus-summary` 응답에 `recovery`가 추가됐다. + - 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다. - paused session 재진입 정책을 별도 source of truth로 고정했다. - `running focus -> /space` - `running break -> /space` diff --git a/src/features/stats/api/statsApi.ts b/src/features/stats/api/statsApi.ts index 8705b71..e41d608 100644 --- a/src/features/stats/api/statsApi.ts +++ b/src/features/stats/api/statsApi.ts @@ -21,6 +21,12 @@ export interface FocusStatsSummary { completedSessions: number; carriedOverCount: number; }; + recovery: { + pausedSessions: number; + resumedSessions: number; + pauseRecoveryRate: number; + awayRecoveryReady: boolean; + }; trend: FocusTrendPoint[]; } diff --git a/src/features/stats/model/useFocusStats.ts b/src/features/stats/model/useFocusStats.ts index 5c0eca1..a90ed72 100644 --- a/src/features/stats/model/useFocusStats.ts +++ b/src/features/stats/model/useFocusStats.ts @@ -49,6 +49,12 @@ const buildMockSummary = (): FocusStatsSummary => { completedSessions: 4, carriedOverCount: 1, }, + recovery: { + pausedSessions: 3, + resumedSessions: 2, + pauseRecoveryRate: 2 / 3, + awayRecoveryReady: true, + }, trend: [], }; }; @@ -145,6 +151,83 @@ const buildCompletionSummary = (summary: FocusStatsSummary) => { ); }; +const buildRecoverySummary = (summary: FocusStatsSummary, source: StatsSource) => { + if (source === 'mock') { + return copy.stats.reviewRecoveryMockSummary; + } + + if (summary.recovery.pausedSessions === 0) { + return copy.stats.reviewRecoveryNoPauseSummary; + } + + return copy.stats.reviewRecoveryApiSummary( + formatPercent(summary.recovery.pauseRecoveryRate), + summary.recovery.resumedSessions, + summary.recovery.pausedSessions, + ); +}; + +const buildRecoverySection = ( + summary: FocusStatsSummary, + source: StatsSource, +): WeeklyReviewSection => { + if (source === 'mock') { + return { + title: copy.stats.reviewRecoveryTitle, + summary: copy.stats.reviewRecoveryMockSummary, + availability: 'ready', + metrics: [ + { + 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: copy.stats.reviewRecoveryMockNote, + }; + } + + const availability = summary.recovery.awayRecoveryReady ? 'ready' : 'limited'; + const metrics = + summary.recovery.pausedSessions > 0 + ? [ + { + id: 'recovery-pause-rate', + label: copy.stats.reviewPauseRecovery, + value: formatPercent(summary.recovery.pauseRecoveryRate), + hint: copy.stats.reviewPauseRecoveryHint, + }, + { + id: 'recovery-resumed-sessions', + label: copy.stats.reviewResumedSessions, + value: `${summary.recovery.resumedSessions}${copy.stats.countUnit}`, + hint: copy.stats.reviewResumedSessionsHint, + }, + { + id: 'recovery-paused-sessions', + label: copy.stats.reviewPausedSessions, + value: `${summary.recovery.pausedSessions}${copy.stats.countUnit}`, + hint: copy.stats.reviewPausedSessionsHint, + }, + ] + : []; + + return { + title: copy.stats.reviewRecoveryTitle, + summary: buildRecoverySummary(summary, source), + availability, + metrics, + note: availability === 'limited' ? copy.stats.reviewRecoveryPartialNote : undefined, + }; +}; + const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['carryForward'] => { const completionRate = summary.last7Days.startedSessions > 0 @@ -255,35 +338,7 @@ const buildReviewFromSummary = ( }, ], }, - 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, - }, + recoveryQuality: buildRecoverySection(summary, source), completionQuality: { title: copy.stats.reviewCompletionTitle, summary: buildCompletionSummary(summary), diff --git a/src/shared/i18n/messages/app.ts b/src/shared/i18n/messages/app.ts index b36cdf6..d3dec49 100644 --- a/src/shared/i18n/messages/app.ts +++ b/src/shared/i18n/messages/app.ts @@ -52,11 +52,17 @@ export const app = { reviewBestDayHint: (minutes: number) => `${minutes}분 동안 가장 오래 집중했어요.`, reviewRecoveryTitle: '복귀 품질', reviewRecoveryMockSummary: '이번 주에는 pause 뒤에도 다시 올라탄 흐름이 있었어요. 복귀는 적었지만 분명히 존재했어요.', - reviewRecoveryLimitedSummary: '복귀 패턴은 아직 이 review에 충분히 합쳐지지 않았어요. 지금은 시작과 마무리 흐름을 먼저 봅니다.', + reviewRecoveryNoPauseSummary: '이번 주에는 pause가 거의 없어 복귀보다 시작과 마무리 흐름이 더 선명했어요.', + reviewRecoveryApiSummary: (rate: string, resumedSessions: number, pausedSessions: number) => + `이번 주엔 pause가 있었던 ${pausedSessions}개 세션 중 ${resumedSessions}개를 다시 이어갔어요. pause 뒤 복귀율은 ${rate}였어요.`, reviewRecoveryMockNote: 'mock 기준으로 pause 뒤 복귀와 away 뒤 복귀를 함께 보여줍니다.', - reviewRecoveryLimitedNote: 'pause / away 복귀 집계는 다음 연결 단계에서 이 review에 포함됩니다.', + reviewRecoveryPartialNote: '현재는 pause 뒤 복귀만 집계하고 있어요. 자리 비움 뒤 복귀는 다음 서버 연결 단계에서 추가됩니다.', reviewPauseRecovery: 'pause 뒤 복귀', reviewPauseRecoveryHint: '멈춘 뒤 다시 돌아온 비율', + reviewResumedSessions: '다시 이어간 세션', + reviewResumedSessionsHint: 'pause가 있었던 세션 중 다시 focus로 돌아온 세션', + reviewPausedSessions: '멈춘 세션', + reviewPausedSessionsHint: 'pause가 한 번 이상 있었던 세션', reviewAwayRecovery: '자리 비움 뒤 복귀', reviewAwayRecoveryHint: '앱을 떠났다가 다시 돌아온 비율', reviewCompletionTitle: '마무리 품질', diff --git a/src/shared/i18n/messages/product.ts b/src/shared/i18n/messages/product.ts index 0a25699..1f3c31b 100644 --- a/src/shared/i18n/messages/product.ts +++ b/src/shared/i18n/messages/product.ts @@ -52,11 +52,17 @@ export const stats = { reviewBestDayHint: (minutes: number) => `${minutes}분 동안 가장 오래 집중했어요.`, reviewRecoveryTitle: '복귀 품질', reviewRecoveryMockSummary: '이번 주에는 pause 뒤에도 다시 올라탄 흐름이 있었어요. 복귀는 적었지만 분명히 존재했어요.', - reviewRecoveryLimitedSummary: '복귀 패턴은 아직 이 review에 충분히 합쳐지지 않았어요. 지금은 시작과 마무리 흐름을 먼저 봅니다.', + reviewRecoveryNoPauseSummary: '이번 주에는 pause가 거의 없어 복귀보다 시작과 마무리 흐름이 더 선명했어요.', + reviewRecoveryApiSummary: (rate: string, resumedSessions: number, pausedSessions: number) => + `이번 주엔 pause가 있었던 ${pausedSessions}개 세션 중 ${resumedSessions}개를 다시 이어갔어요. pause 뒤 복귀율은 ${rate}였어요.`, reviewRecoveryMockNote: 'mock 기준으로 pause 뒤 복귀와 away 뒤 복귀를 함께 보여줍니다.', - reviewRecoveryLimitedNote: 'pause / away 복귀 집계는 다음 연결 단계에서 이 review에 포함됩니다.', + reviewRecoveryPartialNote: '현재는 pause 뒤 복귀만 집계하고 있어요. 자리 비움 뒤 복귀는 다음 서버 연결 단계에서 추가됩니다.', reviewPauseRecovery: 'pause 뒤 복귀', reviewPauseRecoveryHint: '멈춘 뒤 다시 돌아온 비율', + reviewResumedSessions: '다시 이어간 세션', + reviewResumedSessionsHint: 'pause가 있었던 세션 중 다시 focus로 돌아온 세션', + reviewPausedSessions: '멈춘 세션', + reviewPausedSessionsHint: 'pause가 한 번 이상 있었던 세션', reviewAwayRecovery: '자리 비움 뒤 복귀', reviewAwayRecoveryHint: '앱을 떠났다가 다시 돌아온 비율', reviewCompletionTitle: '마무리 품질', diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx index f762c58..504de19 100644 --- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -160,7 +160,7 @@ export const FocusDashboardWidget = () => { const hasEnoughWeeklyData = weeklySummary.last7Days.startedSessions >= 3 && (weeklySummary.last7Days.completedSessions >= 2 || - review.recoveryQuality.availability === 'ready'); + weeklySummary.recovery.pausedSessions > 0); const reviewSource = searchParams.get('review'); const reviewCarryHint = searchParams.get('carryHint'); const normalizedReviewCarryHint: ReviewCarryHint | null = diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 712d82e..e5ba7b3 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -220,7 +220,7 @@ export const SpaceWorkspaceWidget = () => { const hasEnoughWeeklyData = weeklySummary.last7Days.startedSessions >= 3 && (weeklySummary.last7Days.completedSessions >= 2 || - review.recoveryQuality.availability === "ready"); + weeklySummary.recovery.pausedSessions > 0); const shouldShowSecondaryReviewTeaser = workspaceMode === "setup" && showReviewTeaserAfterComplete &&