feat(stats): weekly review snapshot 1차 구현
This commit is contained in:
@@ -175,7 +175,8 @@ VibeRoom은 아래 방식으로 진행한다.
|
|||||||
상태:
|
상태:
|
||||||
|
|
||||||
- 상세 기획 문서 작성 완료
|
- 상세 기획 문서 작성 완료
|
||||||
- 구현 전
|
- 1차 snapshot 구현 완료
|
||||||
|
- 남은 것은 recovery 집계 연결, ritual fit, Free / Pro gating
|
||||||
|
|
||||||
문서:
|
문서:
|
||||||
|
|
||||||
|
|||||||
@@ -110,8 +110,10 @@ Last Updated: 2026-03-14
|
|||||||
- setup drawer에서 Daily Plan / Ritual Library 진입 섹션 제거
|
- setup drawer에서 Daily Plan / Ritual Library 진입 섹션 제거
|
||||||
- `/app`에서 넘긴 goal + `planItemId`를 받아 execution-only surface로 집중
|
- `/app`에서 넘긴 goal + `planItemId`를 받아 execution-only surface로 집중
|
||||||
- `/stats` factual summary 정착:
|
- `/stats` factual summary 정착:
|
||||||
- 기존 API summary/trend 유지
|
- factual card 반복 중심의 구조를 해체하고 `Weekly Review` 1차 IA로 전환
|
||||||
- 해석형 insight/quiet accountability preview를 제거하고 factual card만 유지
|
- `snapshot + start quality + recovery quality + completion quality + carry forward` 구조를 반영
|
||||||
|
- 기존 `focus-summary` 응답을 주간 review view model로 변환해서 사용
|
||||||
|
- recovery는 API 집계가 아직 없을 때 limited state로 조용히 표시
|
||||||
- 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 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거
|
||||||
|
|||||||
@@ -79,9 +79,10 @@ Last Updated: 2026-03-14
|
|||||||
- GoalCompleteSheet confirm 시 `advance-goal` endpoint를 사용한다.
|
- GoalCompleteSheet confirm 시 `advance-goal` endpoint를 사용한다.
|
||||||
- 현재 세션 완료, linked plan item 완료, 새 current item 생성, 다음 세션 시작을 한 번에 처리한다.
|
- 현재 세션 완료, linked plan item 완료, 새 current item 생성, 다음 세션 시작을 한 번에 처리한다.
|
||||||
- 실패 시 시트를 닫지 않고 그대로 재시도할 수 있다.
|
- 실패 시 시트를 닫지 않고 그대로 재시도할 수 있다.
|
||||||
- `/stats`는 해석형 review 화면이 아니라 factual summary로 정리됐다.
|
- `/stats`는 factual summary에서 `Weekly Review` 1차 구조로 올라갔다.
|
||||||
- today / last7Days / trend만 유지한다.
|
- hero snapshot, start quality, recovery quality, completion quality, carry forward 구조를 사용한다.
|
||||||
- started/completed/carried over/focus minutes 중심으로 표시한다.
|
- 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다.
|
||||||
|
- recovery는 API 집계가 아직 없을 때 limited state로 표시한다.
|
||||||
- 유료화 포지셔닝을 `Calm Session OS`로 재정의했다.
|
- 유료화 포지셔닝을 `Calm Session OS`로 재정의했다.
|
||||||
- Free는 기본 집중 시작, Pro는 더 잘 이어가기라는 메시지로 정리했다.
|
- Free는 기본 집중 시작, Pro는 더 잘 이어가기라는 메시지로 정리했다.
|
||||||
- old `Scene Packs / Sound Packs / Profiles` 중심 카피를 `Daily plan / Rituals / Weekly review` 구조로 교체했다.
|
- old `Scene Packs / Sound Packs / Profiles` 중심 카피를 `Daily plan / Rituals / Weekly review` 구조로 교체했다.
|
||||||
|
|||||||
20
docs/work.md
20
docs/work.md
@@ -91,21 +91,23 @@
|
|||||||
|
|
||||||
## 작업 4
|
## 작업 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 설계
|
- recovery quality 실제 집계 연결
|
||||||
- mock summary 구조 재정의
|
- ritual fit highlight 1차
|
||||||
- `/stats` IA 재배치 초안
|
- Free / Pro review gating
|
||||||
- 제외 범위:
|
- 제외 범위:
|
||||||
- planner/dashboard 확장 금지
|
- planner/dashboard 확장 금지
|
||||||
- 과한 해석형 카피 금지
|
- 과한 해석형 카피 금지
|
||||||
- Premium Ambience 작업 선행 금지
|
- 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
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ const parseDurationLabelToMinutes = (label: string) => {
|
|||||||
return hours * 60 + minutes;
|
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 => {
|
const buildMockSummary = (): FocusStatsSummary => {
|
||||||
return {
|
return {
|
||||||
today: {
|
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 {
|
interface UseFocusStatsResult {
|
||||||
summary: FocusStatsSummary;
|
summary: FocusStatsSummary;
|
||||||
|
review: WeeklyReviewViewModel;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
source: StatsSource;
|
source: StatsSource;
|
||||||
@@ -44,7 +305,11 @@ interface UseFocusStatsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useFocusStats = (): UseFocusStatsResult => {
|
export const useFocusStats = (): UseFocusStatsResult => {
|
||||||
const [summary, setSummary] = useState<FocusStatsSummary>(buildMockSummary);
|
const initialSummary = buildMockSummary();
|
||||||
|
const [summary, setSummary] = useState<FocusStatsSummary>(initialSummary);
|
||||||
|
const [review, setReview] = useState<WeeklyReviewViewModel>(
|
||||||
|
buildReviewFromSummary(initialSummary, 'mock'),
|
||||||
|
);
|
||||||
const [isLoading, setLoading] = useState(true);
|
const [isLoading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [source, setSource] = useState<StatsSource>('mock');
|
const [source, setSource] = useState<StatsSource>('mock');
|
||||||
@@ -55,12 +320,15 @@ export const useFocusStats = (): UseFocusStatsResult => {
|
|||||||
try {
|
try {
|
||||||
const nextSummary = await statsApi.getFocusStatsSummary();
|
const nextSummary = await statsApi.getFocusStatsSummary();
|
||||||
setSummary(nextSummary);
|
setSummary(nextSummary);
|
||||||
|
setReview(buildReviewFromSummary(nextSummary, 'api'));
|
||||||
setSource('api');
|
setSource('api');
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
const message =
|
const message =
|
||||||
nextError instanceof Error ? nextError.message : copy.stats.loadFailed;
|
nextError instanceof Error ? nextError.message : copy.stats.loadFailed;
|
||||||
setSummary(buildMockSummary());
|
const nextSummary = buildMockSummary();
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setReview(buildReviewFromSummary(nextSummary, 'mock'));
|
||||||
setSource('mock');
|
setSource('mock');
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -74,6 +342,7 @@ export const useFocusStats = (): UseFocusStatsResult => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
summary,
|
summary,
|
||||||
|
review,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -19,15 +19,62 @@ export const app = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
stats: {
|
stats: {
|
||||||
title: 'Stats',
|
title: 'Weekly Review',
|
||||||
apiLabel: 'API',
|
apiLabel: 'API',
|
||||||
mockLabel: 'Mock',
|
mockLabel: 'Mock',
|
||||||
sourceApi: 'API 통계 사용 중',
|
sourceApi: 'API 통계 사용 중',
|
||||||
sourceMock: 'API 실패로 mock 통계 표시 중',
|
sourceMock: 'API 실패로 mock 통계 표시 중',
|
||||||
loading: '통계를 불러오는 중이에요.',
|
loading: '통계를 불러오는 중이에요.',
|
||||||
loadFailed: '통계를 불러오지 못했어요.',
|
loadFailed: '통계를 불러오지 못했어요.',
|
||||||
synced: '화면 진입 시 최신 요약을 동기화합니다.',
|
synced: '최근 7일 review를 최신 요약으로 동기화합니다.',
|
||||||
refresh: '새로고침',
|
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: '오늘',
|
today: '오늘',
|
||||||
last7Days: '최근 7일',
|
last7Days: '최근 7일',
|
||||||
chartTitle: '집중 흐름 그래프',
|
chartTitle: '집중 흐름 그래프',
|
||||||
|
|||||||
@@ -19,15 +19,62 @@ export const settings = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const stats = {
|
export const stats = {
|
||||||
title: 'Stats',
|
title: 'Weekly Review',
|
||||||
apiLabel: 'API',
|
apiLabel: 'API',
|
||||||
mockLabel: 'Mock',
|
mockLabel: 'Mock',
|
||||||
sourceApi: 'API 통계 사용 중',
|
sourceApi: 'API 통계 사용 중',
|
||||||
sourceMock: 'API 실패로 mock 통계 표시 중',
|
sourceMock: 'API 실패로 mock 통계 표시 중',
|
||||||
loading: '통계를 불러오는 중이에요.',
|
loading: '통계를 불러오는 중이에요.',
|
||||||
loadFailed: '통계를 불러오지 못했어요.',
|
loadFailed: '통계를 불러오지 못했어요.',
|
||||||
synced: '화면 진입 시 최신 요약을 동기화합니다.',
|
synced: '최근 7일 review를 최신 요약으로 동기화합니다.',
|
||||||
refresh: '새로고침',
|
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: '오늘',
|
today: '오늘',
|
||||||
last7Days: '최근 7일',
|
last7Days: '최근 7일',
|
||||||
chartTitle: '집중 흐름 그래프',
|
chartTitle: '집중 흐름 그래프',
|
||||||
|
|||||||
@@ -3,20 +3,9 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useFocusStats } from '@/features/stats';
|
import { useFocusStats } from '@/features/stats';
|
||||||
import { copy } from '@/shared/i18n';
|
import { copy } from '@/shared/i18n';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
const formatMinutes = (minutes: number) => {
|
const ReviewMetric = ({
|
||||||
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 = ({
|
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
@@ -26,155 +15,193 @@ const FactualStatCard = ({
|
|||||||
hint: string;
|
hint: string;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<article className="rounded-2xl border border-brand-dark/10 bg-white/82 p-4 backdrop-blur-sm">
|
<div className="rounded-2xl border border-brand-dark/8 bg-white/70 px-4 py-3">
|
||||||
<p className="text-xs text-brand-dark/58">{label}</p>
|
<p className="text-[11px] font-medium tracking-[0.08em] text-brand-dark/44">{label}</p>
|
||||||
<p className="mt-2 text-xl font-semibold text-brand-dark">{value}</p>
|
<p className="mt-2 text-[1.05rem] font-semibold text-brand-dark">{value}</p>
|
||||||
<p className="mt-1 text-xs text-brand-dark/48">{hint}</p>
|
<p className="mt-1 text-[12px] leading-[1.45] text-brand-dark/52">{hint}</p>
|
||||||
</article>
|
</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 = () => {
|
export const StatsOverviewWidget = () => {
|
||||||
const { stats } = copy;
|
const { stats } = copy;
|
||||||
const { summary, isLoading, error, source, refetch } = useFocusStats();
|
const { review, 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: '다음 날로 이어진 계획 수',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
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="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-5xl px-4 pb-10 pt-6 sm:px-6">
|
<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-2xl border border-brand-dark/12 bg-white/72 px-4 py-3 backdrop-blur-sm">
|
<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>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">{stats.title}</h1>
|
<p className="text-[11px] font-medium tracking-[0.14em] text-brand-dark/42">
|
||||||
<p className="mt-1 text-xs text-brand-dark/56">해석형 insight 없이 factual summary만 표시합니다.</p>
|
{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>
|
</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>
|
</header>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="rounded-2xl border border-brand-dark/12 bg-white/70 px-4 py-3 backdrop-blur-sm">
|
<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-center justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div className="max-w-3xl">
|
||||||
<p className="text-xs font-medium text-brand-dark/72">
|
<p className="text-[11px] font-medium tracking-[0.14em] text-brand-dark/42">
|
||||||
{source === 'api' ? stats.sourceApi : stats.sourceMock}
|
{source === 'api' ? stats.sourceApi : stats.sourceMock}
|
||||||
</p>
|
</p>
|
||||||
{error ? (
|
<p className="mt-3 text-[1.55rem] font-semibold leading-[1.3] tracking-tight text-brand-dark sm:text-[1.9rem]">
|
||||||
<p className="mt-1 text-xs text-rose-500">{error}</p>
|
{review.snapshotSummary}
|
||||||
) : (
|
</p>
|
||||||
<p className="mt-1 text-xs text-brand-dark/56">
|
<p className="mt-3 text-[13px] leading-[1.6] text-brand-dark/54">
|
||||||
{isLoading ? stats.loading : stats.synced}
|
{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>
|
</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>
|
</div>
|
||||||
<button
|
</section>
|
||||||
type="button"
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user