feat(stats): weekly review snapshot 1차 구현

This commit is contained in:
2026-03-14 19:22:58 +09:00
parent 679601d201
commit dc97a78fdd
8 changed files with 563 additions and 167 deletions

View File

@@ -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<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 [error, setError] = useState<string | null>(null);
const [source, setSource] = useState<StatsSource>('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,