feat(stats): weekly review snapshot 1차 구현
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user