tududi/frontend/components/Project/useProjectMetrics.ts
Chris e73c354e7e
Fix bug 733 (#735)
* Refactor today

* fixup! Refactor today

* fixup! fixup! Refactor today
2025-12-27 21:00:52 +02:00

498 lines
15 KiB
TypeScript

import { useMemo, useCallback } from 'react';
import React from 'react';
import { Task } from '../../entities/Task';
import { TFunction } from 'i18next';
import {
isTaskInProgress,
isTaskPlanned,
isTaskWaiting,
} from '../../constants/taskStatus';
// Check if task is in today's plan (has active status)
const isTaskInTodayPlan = (task: Task): boolean =>
isTaskInProgress(task.status) ||
isTaskPlanned(task.status) ||
isTaskWaiting(task.status);
export const useProjectMetrics = (
tasks: Task[],
handleTaskUpdate: (task: Task) => Promise<void>,
t: TFunction,
showSuccessToast?: (message: string | React.ReactNode) => void
) => {
const taskStats = useMemo(() => {
const stats = {
total: tasks.length,
completed: 0,
inProgress: 0,
notStarted: 0,
overdue: 0,
dueSoon: 0,
};
const today = new Date();
const startOfToday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate()
);
const soonBoundary = new Date(startOfToday);
soonBoundary.setDate(startOfToday.getDate() + 7);
const isCompleted = (status: Task['status']) =>
status === 'done' ||
status === 'archived' ||
status === 2 ||
status === 3;
const isInProgress = (status: Task['status']) =>
status === 'in_progress' || status === 1;
const isNotStarted = (status: Task['status']) =>
status === 'not_started' || status === 0;
tasks.forEach((task) => {
const status = task.status;
if (isCompleted(status)) {
stats.completed += 1;
} else if (isInProgress(status)) {
stats.inProgress += 1;
} else if (isNotStarted(status)) {
stats.notStarted += 1;
} else {
stats.notStarted += 1;
}
if (!isCompleted(status) && task.due_date) {
const dueDate = new Date(task.due_date);
if (!Number.isNaN(dueDate.getTime())) {
if (dueDate < startOfToday) {
stats.overdue += 1;
} else if (dueDate <= soonBoundary) {
stats.dueSoon += 1;
}
}
}
});
const completionRate =
stats.total > 0
? Math.round((stats.completed / stats.total) * 100)
: 0;
return {
...stats,
completionRate,
};
}, [tasks]);
const completionGradient = useMemo(() => {
if (taskStats.total === 0) {
return 'conic-gradient(#e5e7eb 0% 100%)';
}
const segments = [
{ value: taskStats.completed, color: '#22c55e' },
{ value: taskStats.inProgress, color: '#3b82f6' },
{ value: taskStats.notStarted, color: '#9ca3af' },
];
let current = 0;
const gradientStops: string[] = [];
segments.forEach((segment) => {
if (segment.value === 0) return;
const start = current;
const percentage = (segment.value / taskStats.total) * 100;
const end = start + percentage;
gradientStops.push(
`${segment.color} ${start}% ${Math.min(end, 100)}%`
);
current += percentage;
});
return gradientStops.length
? `conic-gradient(${gradientStops.join(', ')})`
: 'conic-gradient(#e5e7eb 0% 100%)';
}, [taskStats]);
const dueBuckets = useMemo(() => {
const buckets = {
overdue: [] as Task[],
week: [] as Task[],
month: [] as Task[],
unscheduled: [] as Task[],
};
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
const weekBoundary = new Date(startOfToday);
weekBoundary.setDate(startOfToday.getDate() + 7);
const monthBoundary = new Date(startOfToday);
monthBoundary.setDate(startOfToday.getDate() + 30);
const isCompleted = (status: Task['status']) =>
status === 'done' ||
status === 'archived' ||
status === 2 ||
status === 3;
tasks.forEach((task) => {
if (isCompleted(task.status)) return;
if (!task.due_date) {
buckets.unscheduled.push(task);
return;
}
const due = new Date(task.due_date);
if (Number.isNaN(due.getTime())) {
buckets.unscheduled.push(task);
return;
}
if (due < startOfToday) {
buckets.overdue.push(task);
} else if (due <= weekBoundary) {
buckets.week.push(task);
} else if (due <= monthBoundary) {
buckets.month.push(task);
} else {
buckets.unscheduled.push(task);
}
});
const totalDue =
buckets.overdue.length + buckets.week.length + buckets.month.length;
return { ...buckets, totalDue };
}, [tasks]);
const completionTrend = useMemo(() => {
const days = 14;
const today = new Date();
const labels: { dateKey: string; label: string }[] = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const key = d.toISOString().split('T')[0];
labels.push({
dateKey: key,
label: `${d.getMonth() + 1}/${d.getDate()}`,
});
}
const counts: Record<string, number> = {};
labels.forEach((l) => (counts[l.dateKey] = 0));
tasks.forEach((task) => {
if (!task.completed_at) return;
const key = new Date(task.completed_at).toISOString().split('T')[0];
if (counts[key] !== undefined) {
counts[key] += 1;
}
});
return labels.map((l) => ({
label: l.label,
dateKey: l.dateKey,
count: counts[l.dateKey] || 0,
}));
}, [tasks]);
const createdTrend = useMemo(() => {
const days = 14;
const today = new Date();
const labels: { dateKey: string; label: string }[] = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const key = d.toISOString().split('T')[0];
labels.push({
dateKey: key,
label: `${d.getMonth() + 1}/${d.getDate()}`,
});
}
const counts: Record<string, number> = {};
labels.forEach((l) => (counts[l.dateKey] = 0));
tasks.forEach((task) => {
if (!task.created_at) return;
const key = new Date(task.created_at).toISOString().split('T')[0];
if (counts[key] !== undefined) {
counts[key] += 1;
}
});
return labels.map((l) => ({
label: l.label,
dateKey: l.dateKey,
count: counts[l.dateKey] || 0,
}));
}, [tasks]);
const upcomingDueTrend = useMemo(() => {
const days = 14;
const today = new Date();
const labels: { dateKey: string; label: string }[] = [];
for (let i = 0; i < days; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
const key = d.toISOString().split('T')[0];
labels.push({
dateKey: key,
label: `${d.getMonth() + 1}/${d.getDate()}`,
});
}
const counts: Record<string, number> = {};
labels.forEach((l) => (counts[l.dateKey] = 0));
const isCompleted = (status: Task['status']) =>
status === 'done' ||
status === 'archived' ||
status === 2 ||
status === 3;
tasks.forEach((task) => {
if (!task.due_date || isCompleted(task.status)) return;
const key = new Date(task.due_date).toISOString().split('T')[0];
if (counts[key] !== undefined) {
counts[key] += 1;
}
});
return labels.map((l) => ({
label: l.label,
dateKey: l.dateKey,
count: counts[l.dateKey] || 0,
}));
}, [tasks]);
const upcomingInsights = useMemo(() => {
const peak = upcomingDueTrend.reduce(
(acc, cur) => (cur.count > acc.count ? cur : acc),
{ label: '', count: 0 }
);
const nextThreeDays = upcomingDueTrend
.slice(0, 3)
.reduce((sum, d) => sum + d.count, 0);
const nextWeek = upcomingDueTrend
.slice(0, 7)
.reduce((sum, d) => sum + d.count, 0);
return {
peakLabel: peak.label,
peakCount: peak.count,
nextThreeDays,
nextWeek,
};
}, [upcomingDueTrend]);
const dueHighlights = useMemo(() => {
const combined = [
...dueBuckets.overdue,
...dueBuckets.week,
...dueBuckets.month,
];
return combined
.sort((a, b) => {
const aDate = a.due_date ? new Date(a.due_date).getTime() : 0;
const bDate = b.due_date ? new Date(b.due_date).getTime() : 0;
return aDate - bDate;
})
.slice(0, 3);
}, [dueBuckets]);
const nextBestAction = useMemo(() => {
const isCompleted = (status: Task['status']) =>
status === 'done' ||
status === 'archived' ||
status === 2 ||
status === 3;
const candidates = tasks.filter(
(task) =>
!isCompleted(task.status) &&
task.status !== 'in_progress' &&
task.status !== 1
);
if (candidates.length === 0) return null;
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const getPriorityScore = (priority: Task['priority']) => {
if (priority === 'high' || priority === 2) return -8;
if (priority === 'medium' || priority === 1) return -4;
return 0;
};
const scored = candidates
.map((task) => {
let score = 0;
if (task.status === 'in_progress' || task.status === 1) {
score -= 30;
}
if (task.due_date) {
const due = new Date(task.due_date);
const diffDays = Math.floor(
(due.getTime() - startOfToday.getTime()) /
(1000 * 60 * 60 * 24)
);
if (diffDays < 0) score -= 25;
else if (diffDays === 0) score -= 20;
else if (diffDays <= 2) score -= 15;
else if (diffDays <= 7) score -= 10;
}
score += getPriorityScore(task.priority);
if (isTaskInTodayPlan(task)) {
score -= 6;
}
const createdAt = task.created_at
? new Date(task.created_at).getTime()
: 0;
return {
task,
score,
createdAt,
};
})
.sort((a, b) => {
if (a.score !== b.score) return a.score - b.score;
if (a.createdAt !== b.createdAt)
return a.createdAt - b.createdAt;
return (a.task.id || 0) - (b.task.id || 0);
});
return scored[0]?.task ?? null;
}, [tasks]);
const getDueDescriptor = useCallback(
(task: Task): string => {
if (!task.due_date)
return t('tasks.noDue', 'No due date') as string;
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
);
const due = new Date(task.due_date);
if (Number.isNaN(due.getTime()))
return t('tasks.noDue', 'No due date') as string;
const diffDays = Math.floor(
(due.getTime() - startOfToday.getTime()) / (1000 * 60 * 60 * 24)
);
if (diffDays < 0) {
return t('tasks.overdueBy', {
defaultValue: 'Overdue by {{days}}d',
days: Math.abs(diffDays),
}) as string;
}
if (diffDays === 0)
return t('dateIndicators.today', 'Today') as string;
if (diffDays === 1)
return t('dateIndicators.tomorrow', 'Tomorrow') as string;
if (diffDays <= 7)
return t('tasks.dueInDays', {
defaultValue: 'Due in {{days}}d',
days: diffDays,
}) as string;
return t('tasks.dueInDays', {
defaultValue: 'Due in {{days}}d',
days: diffDays,
}) as string;
},
[t]
);
const handleStartNextAction = useCallback(async () => {
if (!nextBestAction?.id) return;
const isAlreadyInProgress =
nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1;
if (isAlreadyInProgress) {
return;
}
try {
await handleTaskUpdate({
...nextBestAction,
status: 'in_progress',
});
if (showSuccessToast) {
showSuccessToast(
t('tasks.startedSuccessfully', 'Task started successfully!')
);
}
} catch {
// Silent fail
}
}, [handleTaskUpdate, nextBestAction, showSuccessToast, t]);
const weeklyPace = useMemo(() => {
const lastWeek = completionTrend
.slice(-7)
.reduce((sum, d) => sum + d.count, 0);
const prevWeek = completionTrend
.slice(0, -7)
.reduce((sum, d) => sum + d.count, 0);
const delta = lastWeek - prevWeek;
return { lastWeek, prevWeek, delta };
}, [completionTrend]);
const monthlyCompleted = useMemo(() => {
const today = new Date();
const startWindow = new Date();
startWindow.setDate(today.getDate() - 30);
let count = 0;
tasks.forEach((task) => {
if (!task.completed_at) return;
const completedDate = new Date(task.completed_at);
if (
!Number.isNaN(completedDate.getTime()) &&
completedDate >= startWindow
) {
count += 1;
}
});
return count;
}, [tasks]);
return {
taskStats,
completionGradient,
dueBuckets,
dueHighlights,
nextBestAction,
getDueDescriptor,
handleStartNextAction,
completionTrend,
upcomingDueTrend,
createdTrend,
upcomingInsights,
weeklyPace,
monthlyCompleted,
};
};