feat(stats): recovery 통계를 서버 계약으로 연결

This commit is contained in:
2026-03-15 19:18:05 +09:00
parent 1b01ceaa8b
commit 3aba789c97
9 changed files with 122 additions and 42 deletions

View File

@@ -181,7 +181,8 @@ VibeRoom은 아래 방식으로 진행한다.
- 1차 snapshot 구현 완료 - 1차 snapshot 구현 완료
- `/app -> /stats -> /app` entry flow 구현 완료 - `/app -> /stats -> /app` entry flow 구현 완료
- `/space` complete 이후 secondary teaser 구현 완료 - `/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` 구현 - `Paused Session Re-entry` 구현
- `/space` recovery browser QA - `/space` recovery browser QA
- Weekly Review recovery 집계 연결 - Weekly Review ritual fit highlight
- Premium Ambience System - Premium Ambience System
### 방금 완료 ### 방금 완료
@@ -360,7 +361,7 @@ Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가
- `Weekly Review Entry Flow` Slice 4 - `Weekly Review Entry Flow` Slice 4
- `/space`에서 goal complete로 setup 상태로 돌아온 직후에만 secondary review teaser가 보인다 - `/space`에서 goal complete로 setup 상태로 돌아온 직후에만 secondary review teaser가 보인다
- full review 강제 이동 없이 작은 후행 경로로 `/stats`를 연다 - full review 강제 이동 없이 작은 후행 경로로 `/stats`를 연다
- 다음 구현은 weekly review의 실제 recovery 집계 연결이 - 다음 구현은 weekly review의 ritual fit highlight 또는 deeper recovery schema
- `Paused Session Re-entry` spec - `Paused Session Re-entry` spec
- running / paused / break의 route policy를 한 문서에서 고정했다 - running / paused / break의 route policy를 한 문서에서 고정했다
- `/app`은 paused session의 resume gate가 되고, explicit continue 이후 `/space`에서는 자동 resume해야 한다 - `/app`은 paused session의 resume gate가 되고, explicit continue 이후 `/space`에서는 자동 resume해야 한다

View File

@@ -113,7 +113,7 @@ Last Updated: 2026-03-15
- factual card 반복 중심의 구조를 해체하고 `Weekly Review` 1차 IA로 전환 - factual card 반복 중심의 구조를 해체하고 `Weekly Review` 1차 IA로 전환
- `snapshot + start quality + recovery quality + completion quality + carry forward` 구조를 반영 - `snapshot + start quality + recovery quality + completion quality + carry forward` 구조를 반영
- 기존 `focus-summary` 응답을 주간 review view model로 변환해서 사용 - 기존 `focus-summary` 응답을 주간 review view model로 변환해서 사용
- recovery는 API 집계가 아직 없을 때 limited state로 조용히 표시 - recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀` limited note로 남긴다
- `/app -> /stats` primary entry의 1차 연결: - `/app -> /stats` primary entry의 1차 연결:
- current session이 없고 최근 7일 데이터가 충분할 때 `/app` hero 아래에 low-emphasis `Weekly Review` teaser를 노출한다 - current session이 없고 최근 7일 데이터가 충분할 때 `/app` hero 아래에 low-emphasis `Weekly Review` teaser를 노출한다
- teaser는 `/stats`로 연결되며, hero CTA보다 한 단계 아래 시각 우선순위를 유지한다 - teaser는 `/stats`로 연결되며, hero CTA보다 한 단계 아래 시각 우선순위를 유지한다
@@ -129,6 +129,10 @@ Last Updated: 2026-03-15
- goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 low-emphasis review teaser가 보인다 - goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 low-emphasis review teaser가 보인다
- teaser는 `주간 review 보기``/stats`를 열고, 방금 끝낸 흐름 반영을 과장하지 않는 카피만 사용한다 - teaser는 `주간 review 보기``/stats`를 열고, 방금 끝낸 흐름 반영을 과장하지 않는 카피만 사용한다
- 다시 시작하거나 dismiss하면 사라지며, live execution 중에는 보이지 않는다 - 다시 시작하거나 dismiss하면 사라지며, live execution 중에는 보이지 않는다
- `Weekly Review` recovery의 서버 연결:
- server `focus-summary` 응답에 `recovery`가 추가됐다
- `pause_count / resume_count` 기반 `pause 뒤 복귀`를 실제 수치로 보여준다
- 현재는 `away recovery` 이벤트 스키마가 없어 partial/limited 상태로 남긴다
- paywall / plan / landing 메시지 재정렬: - paywall / plan / landing 메시지 재정렬:
- paywall 가치 포인트를 multi-queue, rituals, weekly review 중심으로 재작성 - paywall 가치 포인트를 multi-queue, rituals, weekly review 중심으로 재작성
- landing pricing에서 구현되지 않은 1:1 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거 - landing pricing에서 구현되지 않은 1:1 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거

View File

@@ -16,7 +16,7 @@ Last Updated: 2026-03-15
1. `Paused Session Takeover Flow` 1. `Paused Session Takeover Flow`
2. `Core Loop Alignment` browser audit 2. `Core Loop Alignment` browser audit
3. `Weekly Review` recovery 집계 연결 3. `Weekly Review` ritual fit highlight
4. `Premium Ambience` 4. `Premium Ambience`
## 최근 세션 상태 ## 최근 세션 상태
@@ -83,7 +83,7 @@ Last Updated: 2026-03-15
- `/stats`는 factual summary에서 `Weekly Review` 1차 구조로 올라갔다. - `/stats`는 factual summary에서 `Weekly Review` 1차 구조로 올라갔다.
- hero snapshot, start quality, recovery quality, completion quality, carry forward 구조를 사용한다. - hero snapshot, start quality, recovery quality, completion quality, carry forward 구조를 사용한다.
- 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다. - 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다.
- recovery는 API 집계가 아직 없을 때 limited state로 표시한다. - recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery` limited state로 남긴다.
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다. - `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
- current session이 없고 최근 7일 데이터가 충분하면 hero 아래에 weekly review teaser가 보인다. - current session이 없고 최근 7일 데이터가 충분하면 hero 아래에 weekly review teaser가 보인다.
- teaser는 `/stats`로 이동시키되, main start CTA보다 낮은 강조로 유지한다. - teaser는 `/stats`로 이동시키되, main start CTA보다 낮은 강조로 유지한다.
@@ -99,7 +99,9 @@ Last Updated: 2026-03-15
- goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 작은 review teaser가 보인다. - goal complete로 setup 상태로 돌아왔을 때만 setup drawer 아래에 작은 review teaser가 보인다.
- full review 강제 이동 없이 `/stats`를 여는 secondary entry로만 동작한다. - full review 강제 이동 없이 `/stats`를 여는 secondary entry로만 동작한다.
- 방금 끝낸 흐름을 반영한다고 과장하지 않는 카피로 정리했다. - 방금 끝낸 흐름을 반영한다고 과장하지 않는 카피로 정리했다.
- 다음 구현은 weekly review의 실제 recovery 집계 연결이다. - `Weekly Review` recovery의 서버 연결이 들어갔다.
- server `focus-summary` 응답에 `recovery`가 추가됐다.
- 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다.
- paused session 재진입 정책을 별도 source of truth로 고정했다. - paused session 재진입 정책을 별도 source of truth로 고정했다.
- `running focus -> /space` - `running focus -> /space`
- `running break -> /space` - `running break -> /space`

View File

@@ -21,6 +21,12 @@ export interface FocusStatsSummary {
completedSessions: number; completedSessions: number;
carriedOverCount: number; carriedOverCount: number;
}; };
recovery: {
pausedSessions: number;
resumedSessions: number;
pauseRecoveryRate: number;
awayRecoveryReady: boolean;
};
trend: FocusTrendPoint[]; trend: FocusTrendPoint[];
} }

View File

@@ -49,6 +49,12 @@ const buildMockSummary = (): FocusStatsSummary => {
completedSessions: 4, completedSessions: 4,
carriedOverCount: 1, carriedOverCount: 1,
}, },
recovery: {
pausedSessions: 3,
resumedSessions: 2,
pauseRecoveryRate: 2 / 3,
awayRecoveryReady: true,
},
trend: [], 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 buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['carryForward'] => {
const completionRate = const completionRate =
summary.last7Days.startedSessions > 0 summary.last7Days.startedSessions > 0
@@ -255,35 +338,7 @@ const buildReviewFromSummary = (
}, },
], ],
}, },
recoveryQuality: { recoveryQuality: buildRecoverySection(summary, source),
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: { completionQuality: {
title: copy.stats.reviewCompletionTitle, title: copy.stats.reviewCompletionTitle,
summary: buildCompletionSummary(summary), summary: buildCompletionSummary(summary),

View File

@@ -52,11 +52,17 @@ export const app = {
reviewBestDayHint: (minutes: number) => `${minutes}분 동안 가장 오래 집중했어요.`, reviewBestDayHint: (minutes: number) => `${minutes}분 동안 가장 오래 집중했어요.`,
reviewRecoveryTitle: '복귀 품질', reviewRecoveryTitle: '복귀 품질',
reviewRecoveryMockSummary: '이번 주에는 pause 뒤에도 다시 올라탄 흐름이 있었어요. 복귀는 적었지만 분명히 존재했어요.', reviewRecoveryMockSummary: '이번 주에는 pause 뒤에도 다시 올라탄 흐름이 있었어요. 복귀는 적었지만 분명히 존재했어요.',
reviewRecoveryLimitedSummary: '복귀 패턴은 아직 이 review에 충분히 합쳐지지 않았어요. 지금은 시작과 마무리 흐름을 먼저 봅니다.', reviewRecoveryNoPauseSummary: '이번 주에는 pause가 거의 없어 복귀보다 시작과 마무리 흐름이 더 선명했어요.',
reviewRecoveryApiSummary: (rate: string, resumedSessions: number, pausedSessions: number) =>
`이번 주엔 pause가 있었던 ${pausedSessions}개 세션 중 ${resumedSessions}개를 다시 이어갔어요. pause 뒤 복귀율은 ${rate}였어요.`,
reviewRecoveryMockNote: 'mock 기준으로 pause 뒤 복귀와 away 뒤 복귀를 함께 보여줍니다.', reviewRecoveryMockNote: 'mock 기준으로 pause 뒤 복귀와 away 뒤 복귀를 함께 보여줍니다.',
reviewRecoveryLimitedNote: 'pause / away 복귀 집계는 다음 연결 단계에서 이 review에 포함됩니다.', reviewRecoveryPartialNote: '현재는 pause 복귀 집계하고 있어요. 자리 비움 뒤 복귀는 다음 서버 연결 단계에서 추가됩니다.',
reviewPauseRecovery: 'pause 뒤 복귀', reviewPauseRecovery: 'pause 뒤 복귀',
reviewPauseRecoveryHint: '멈춘 뒤 다시 돌아온 비율', reviewPauseRecoveryHint: '멈춘 뒤 다시 돌아온 비율',
reviewResumedSessions: '다시 이어간 세션',
reviewResumedSessionsHint: 'pause가 있었던 세션 중 다시 focus로 돌아온 세션',
reviewPausedSessions: '멈춘 세션',
reviewPausedSessionsHint: 'pause가 한 번 이상 있었던 세션',
reviewAwayRecovery: '자리 비움 뒤 복귀', reviewAwayRecovery: '자리 비움 뒤 복귀',
reviewAwayRecoveryHint: '앱을 떠났다가 다시 돌아온 비율', reviewAwayRecoveryHint: '앱을 떠났다가 다시 돌아온 비율',
reviewCompletionTitle: '마무리 품질', reviewCompletionTitle: '마무리 품질',

View File

@@ -52,11 +52,17 @@ export const stats = {
reviewBestDayHint: (minutes: number) => `${minutes}분 동안 가장 오래 집중했어요.`, reviewBestDayHint: (minutes: number) => `${minutes}분 동안 가장 오래 집중했어요.`,
reviewRecoveryTitle: '복귀 품질', reviewRecoveryTitle: '복귀 품질',
reviewRecoveryMockSummary: '이번 주에는 pause 뒤에도 다시 올라탄 흐름이 있었어요. 복귀는 적었지만 분명히 존재했어요.', reviewRecoveryMockSummary: '이번 주에는 pause 뒤에도 다시 올라탄 흐름이 있었어요. 복귀는 적었지만 분명히 존재했어요.',
reviewRecoveryLimitedSummary: '복귀 패턴은 아직 이 review에 충분히 합쳐지지 않았어요. 지금은 시작과 마무리 흐름을 먼저 봅니다.', reviewRecoveryNoPauseSummary: '이번 주에는 pause가 거의 없어 복귀보다 시작과 마무리 흐름이 더 선명했어요.',
reviewRecoveryApiSummary: (rate: string, resumedSessions: number, pausedSessions: number) =>
`이번 주엔 pause가 있었던 ${pausedSessions}개 세션 중 ${resumedSessions}개를 다시 이어갔어요. pause 뒤 복귀율은 ${rate}였어요.`,
reviewRecoveryMockNote: 'mock 기준으로 pause 뒤 복귀와 away 뒤 복귀를 함께 보여줍니다.', reviewRecoveryMockNote: 'mock 기준으로 pause 뒤 복귀와 away 뒤 복귀를 함께 보여줍니다.',
reviewRecoveryLimitedNote: 'pause / away 복귀 집계는 다음 연결 단계에서 이 review에 포함됩니다.', reviewRecoveryPartialNote: '현재는 pause 복귀 집계하고 있어요. 자리 비움 뒤 복귀는 다음 서버 연결 단계에서 추가됩니다.',
reviewPauseRecovery: 'pause 뒤 복귀', reviewPauseRecovery: 'pause 뒤 복귀',
reviewPauseRecoveryHint: '멈춘 뒤 다시 돌아온 비율', reviewPauseRecoveryHint: '멈춘 뒤 다시 돌아온 비율',
reviewResumedSessions: '다시 이어간 세션',
reviewResumedSessionsHint: 'pause가 있었던 세션 중 다시 focus로 돌아온 세션',
reviewPausedSessions: '멈춘 세션',
reviewPausedSessionsHint: 'pause가 한 번 이상 있었던 세션',
reviewAwayRecovery: '자리 비움 뒤 복귀', reviewAwayRecovery: '자리 비움 뒤 복귀',
reviewAwayRecoveryHint: '앱을 떠났다가 다시 돌아온 비율', reviewAwayRecoveryHint: '앱을 떠났다가 다시 돌아온 비율',
reviewCompletionTitle: '마무리 품질', reviewCompletionTitle: '마무리 품질',

View File

@@ -160,7 +160,7 @@ export const FocusDashboardWidget = () => {
const hasEnoughWeeklyData = const hasEnoughWeeklyData =
weeklySummary.last7Days.startedSessions >= 3 && weeklySummary.last7Days.startedSessions >= 3 &&
(weeklySummary.last7Days.completedSessions >= 2 || (weeklySummary.last7Days.completedSessions >= 2 ||
review.recoveryQuality.availability === 'ready'); weeklySummary.recovery.pausedSessions > 0);
const reviewSource = searchParams.get('review'); const reviewSource = searchParams.get('review');
const reviewCarryHint = searchParams.get('carryHint'); const reviewCarryHint = searchParams.get('carryHint');
const normalizedReviewCarryHint: ReviewCarryHint | null = const normalizedReviewCarryHint: ReviewCarryHint | null =

View File

@@ -220,7 +220,7 @@ export const SpaceWorkspaceWidget = () => {
const hasEnoughWeeklyData = const hasEnoughWeeklyData =
weeklySummary.last7Days.startedSessions >= 3 && weeklySummary.last7Days.startedSessions >= 3 &&
(weeklySummary.last7Days.completedSessions >= 2 || (weeklySummary.last7Days.completedSessions >= 2 ||
review.recoveryQuality.availability === "ready"); weeklySummary.recovery.pausedSessions > 0);
const shouldShowSecondaryReviewTeaser = const shouldShowSecondaryReviewTeaser =
workspaceMode === "setup" && workspaceMode === "setup" &&
showReviewTeaserAfterComplete && showReviewTeaserAfterComplete &&