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

* fixup! Refactor today

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

406 lines
21 KiB
TypeScript

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);
interface DueBuckets {
overdue: Task[];
week: Task[];
month: Task[];
unscheduled: Task[];
totalDue: number;
}
interface TaskStats {
total: number;
completed: number;
inProgress: number;
notStarted: number;
overdue: number;
dueSoon: number;
completionRate: number;
}
interface ProjectInsightsPanelProps {
taskStats: TaskStats;
completionGradient: string;
dueBuckets: DueBuckets;
dueHighlights: Task[];
nextBestAction: Task | null;
getDueDescriptor: (task: Task) => string;
onStartNextAction: () => Promise<void> | void;
t: TFunction;
completionTrend: { label: string; count: number }[];
upcomingDueTrend: { label: string; count: number }[];
createdTrend: { label: string; count: number }[];
weeklyPace: { lastWeek: number; prevWeek: number; delta: number };
monthlyCompleted: number;
upcomingInsights?: {
peakLabel: string;
peakCount: number;
nextThreeDays: number;
nextWeek: number;
};
}
const ProjectInsightsPanel: React.FC<ProjectInsightsPanelProps> = ({
taskStats,
completionGradient,
nextBestAction,
getDueDescriptor,
onStartNextAction,
t,
upcomingDueTrend,
weeklyPace,
monthlyCompleted,
upcomingInsights,
}) => {
const maxUpcoming = Math.max(...upcomingDueTrend.map((d) => d.count), 1);
return (
<div className="space-y-4">
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('projects.progress', 'Progress')}
</p>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.taskMomentum', 'Task momentum')}
</h3>
</div>
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
{taskStats.total} {t('tasks.tasks', 'tasks')}
</span>
</div>
<div className="mt-4 flex items-center gap-4">
<div className="relative w-28 h-28">
<div
className="w-full h-full rounded-full shadow-inner"
style={{
background: completionGradient,
}}
></div>
<div className="absolute inset-3 rounded-full bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 flex flex-col items-center justify-center text-center">
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">
{taskStats.completionRate}%
</span>
<span className="text-[11px] text-gray-500 dark:text-gray-400">
{t('common.done', 'done')}
</span>
</div>
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300">
<span>
{t('projects.activeTasks', 'Active tasks')}
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{Math.max(
taskStats.total - taskStats.completed,
0
)}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span className="w-2 h-2 rounded-full bg-red-500"></span>
<span>
{taskStats.overdue}{' '}
{t('tasks.overdue', 'overdue')},{' '}
{taskStats.dueSoon}{' '}
{t('tasks.dueSoon', 'due soon')}
</span>
</div>
<div>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{t('tasks.progress', 'Progress')}</span>
<span className="font-semibold text-gray-700 dark:text-gray-200">
{taskStats.completionRate}%
</span>
</div>
<div className="mt-1 h-2 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
<div
className="h-full bg-blue-500 dark:bg-blue-400 transition-all duration-300 ease-in-out"
style={{
width: `${taskStats.total > 0 ? taskStats.completionRate : 0}%`,
}}
></div>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.dueSchedule', 'Due schedule')}
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('projects.next14Days', 'Next 14 days')}
</span>
</div>
{upcomingDueTrend.some((d) => d.count > 0) ? (
<>
<div className="mt-3 flex flex-wrap gap-1">
{upcomingDueTrend.map((d, idx) => {
const intensity =
maxUpcoming > 0
? Math.max(
(d.count / maxUpcoming) * 0.8,
0.12
)
: 0;
return (
<div
key={idx}
className="flex flex-col items-center"
style={{
width: 'calc(100% / 7 - 4px)',
}}
>
<div
className="w-full h-10 rounded-md border border-amber-200 dark:border-amber-800 transition-all duration-300"
style={{
backgroundColor: `rgba(251, 191, 36, ${intensity})`,
}}
></div>
<span className="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{d.label}
</span>
<span className="text-[10px] text-gray-600 dark:text-gray-300">
{d.count}
</span>
</div>
);
})}
</div>
{upcomingInsights && (
<div className="mt-4 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300 flex-wrap">
<span className="px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-200">
{t('projects.peakDay', 'Peak')}:{' '}
{upcomingInsights.peakCount > 0
? `${upcomingInsights.peakLabel} · ${upcomingInsights.peakCount}`
: t('projects.none', 'None')}
</span>
<span className="px-2 py-1 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200">
{t('projects.next3days', 'Next 3 days')}:{' '}
{upcomingInsights.nextThreeDays}
</span>
<span className="px-2 py-1 rounded-full bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-200">
{t('projects.nextWeek', 'Next 7 days')}:{' '}
{upcomingInsights.nextWeek}
</span>
</div>
)}
</>
) : (
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
{t(
'projects.noUpcomingDue',
'No due dates in the next 14 days.'
)}
</p>
)}
</div>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.recentCompletion', 'Recent completion')}
</h3>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('projects.last7And30', 'Last 7 & 30 days')}
</span>
</div>
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('projects.weeklyPace', 'Weekly pace')}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{weeklyPace.lastWeek}
</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">
{t(
'projects.prevWeekCompleted',
'{{count}} prior week',
{
count: weeklyPace.prevWeek,
}
)}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div
className={`px-2 py-1 rounded-full text-[11px] font-semibold ${
weeklyPace.delta > 0
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200'
: weeklyPace.delta === 0
? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-200'
}`}
>
{weeklyPace.delta > 0 ? '+' : ''}
{weeklyPace.delta}{' '}
{t('projects.vsPrevWeek', 'vs prev week')}
</div>
<div className="w-32 h-1.5 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
<div
className="h-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
style={{
width: `${Math.min(
(weeklyPace.lastWeek /
Math.max(
Math.max(
weeklyPace.lastWeek,
weeklyPace.prevWeek
),
1
)) *
100,
100
)}%`,
}}
></div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t(
'projects.monthlyCompletion',
'30-day completions'
)}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{monthlyCompleted}
</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">
{t('projects.last30Days', 'Last 30 days')}
</p>
</div>
<div className="w-32 h-1.5 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
<div
className="h-full bg-indigo-500 dark:bg-indigo-400 transition-all duration-300"
style={{
width: `${Math.min(monthlyCompleted * 3, 100)}%`,
}}
></div>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('projects.nextUp', 'Next best action')}
</p>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
{t('projects.focusTask', 'Most impactful task')}
</h3>
</div>
{nextBestAction && (
<span className="px-2 py-1 text-[11px] rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
{getDueDescriptor(nextBestAction)}
</span>
)}
</div>
{nextBestAction ? (
<div className="mt-4 space-y-3">
<div className="flex items-start gap-3">
<div className="mt-1 w-2 h-2 rounded-full bg-blue-500" />
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{nextBestAction.name}
</p>
{nextBestAction.note && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{nextBestAction.note}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
{nextBestAction.priority && (
<span className="px-2 py-1 rounded-full bg-gray-100 dark:bg-gray-800">
{t('tasks.priority', 'Priority')}:{' '}
{String(nextBestAction.priority)}
</span>
)}
{isTaskInTodayPlan(nextBestAction) && (
<span className="px-2 py-1 rounded-full bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-200">
{t('tasks.todayPlan', 'Today plan')}
</span>
)}
{(nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1) && (
<span className="px-2 py-1 rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
{t('task.status.inProgress', 'In progress')}
</span>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onStartNextAction}
disabled={
(nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1) &&
isTaskInTodayPlan(nextBestAction)
}
className={`inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-white rounded-md transition-colors ${
(nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1) &&
isTaskInTodayPlan(nextBestAction)
? 'bg-gray-400 dark:bg-gray-700 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{(nextBestAction.status === 'in_progress' ||
nextBestAction.status === 1) &&
isTaskInTodayPlan(nextBestAction)
? t('tasks.inProgress', 'In progress')
: t('tasks.startNow', 'Start now')}
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t(
'projects.focusHint',
'Shifts this task to in progress and today'
)}
</span>
</div>
</div>
) : (
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
{t(
'projects.noNextAction',
'All clear—no outstanding tasks.'
)}
</p>
)}
</div>
</div>
);
};
export default ProjectInsightsPanel;