feat: Add Time Tracking page with analytics and time distribution charts
continuous-integration/drone/push Build is passing

- Add TimeTrackingPage with pie/bar charts for time spent per label
- Add task_time_logs schema for tracking time entries
- Add /api/analytics/time-distribution endpoint
- Update sidebar navigation with time tracking link
- Update App routing for new pages
- Fix routine blocker and settings card issues
- Add German translations for analytics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-08 23:50:10 +01:00
parent 877d0c897e
commit 74ffef48d3
12 changed files with 412 additions and 62 deletions
+6 -2
View File
@@ -59,6 +59,8 @@ import { User } from "@shared/schema";
import { Loader2 } from "lucide-react";
import TimeTrackingPage from './pages/TimeTrackingPage';
function App() {
const { t } = useTranslation();
const { toast } = useToast();
@@ -299,8 +301,10 @@ function App() {
<div className="min-h-screen bg-background w-full">
<Switch>
<Route path="/auth" component={AuthPage} />
<Route path="/forgot-password" component={ForgotPasswordPage} />
<Route path="/reset-password" component={ResetPasswordPage} />
<Route path="/achievements" component={AchievementsPage} />
<Route path="/leaderboard" component={LeaderboardPage} />
<Route path="/time-tracking" component={TimeTrackingPage} />
<Route path="/ai" component={AiChatPage} />
<Route component={AuthPage} />
</Switch>
<Toaster />
+2 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff, Bell } from 'lucide-react';
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff, Bell, Clock } from 'lucide-react';
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query';
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
@@ -60,6 +60,7 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
{ title: t('navigation.kanban'), id: 'kanban', path: '/kanban', icon: LayoutGrid, color: 'text-orange-500' },
{ title: t('navigation.achievements'), id: 'achievements', path: '/achievements', icon: Trophy, color: 'text-yellow-500' },
{ title: t('navigation.leaderboard'), id: 'leaderboard', path: '/leaderboard', icon: Award, color: 'text-yellow-500' },
{ title: t('analytics.timeTracking'), id: 'time-tracking', path: '/time-tracking', icon: Clock, color: 'text-teal-500' },
...(user?.aiEnabled ? [{ title: t('navigation.aiChat', 'AI Chat'), id: 'ai-chat', path: '/ai', icon: Bot, color: 'text-indigo-500' }] : []),
{ title: t('navigation.settings'), id: 'settings', path: '/settings', icon: Settings, color: 'text-gray-500' },
]
+4 -3
View File
@@ -34,7 +34,7 @@ export function useRoutineBlocker() {
// Evening Routine Check
const eveningEnabled = settings.evening_routine_enabled !== "false";
const eveningStartTime = parseTime(settings.evening_routine_time || "17:00"); // 17:00 default
const eveningStartTime = parseTime(settings.evening_routine_time || "22:00"); // 22:00 default
// If it is Evening time (>= start time), we primarily check Evening Routine.
if (eveningEnabled) {
@@ -89,7 +89,7 @@ export function useRoutineBlocker() {
const eveningEnabled = settings.evening_routine_enabled !== "false";
// Safe parse
const [eh, em] = (settings.evening_routine_time || "17:00").split(':').map(Number);
const [eh, em] = (settings.evening_routine_time || "22:00").split(':').map(Number);
const eveningStartVal = (eh || 0) * 60 + (em || 0);
const morningEnabled = settings.morning_routine_enabled !== "false";
@@ -118,7 +118,8 @@ export function useRoutineBlocker() {
const eveningEnabled = settings.evening_routine_enabled !== "false";
if (!eveningEnabled) return false;
const [eh, em] = (settings.evening_routine_time || "17:00").split(':').map(Number);
const rawTime = settings.evening_routine_time || "22:00";
const [eh, em] = rawTime.split(':').map(Number);
const startVal = (eh || 0) * 60 + (em || 0);
if (currentTimeVal >= startVal && !isSameDay(user.lastEveningRoutine, now)) return true;
@@ -18,7 +18,7 @@ export function RoutineSettingsCard() {
const [morningEnabled, setMorningEnabled] = useState(true);
const [morningTime, setMorningTime] = useState("09:00");
const [eveningEnabled, setEveningEnabled] = useState(true);
const [eveningTime, setEveningTime] = useState("17:00");
const [eveningTime, setEveningTime] = useState("22:00");
const { data: settings, isLoading } = useQuery<Record<string, string>>({
queryKey: ['/api/admin/settings'],
@@ -34,7 +34,7 @@ export function RoutineSettingsCard() {
setMorningEnabled(settings.morning_routine_enabled !== "false");
setMorningTime(settings.morning_routine_time || "09:00");
setEveningEnabled(settings.evening_routine_enabled !== "false");
setEveningTime(settings.evening_routine_time || "17:00");
setEveningTime(settings.evening_routine_time || "22:00");
}
}, [settings]);
+18
View File
@@ -477,6 +477,24 @@
"admin": "Administrator",
"user": "Benutzer"
},
"analytics": {
"title": "Analysen",
"tasksCompleted": "Erledigte Aufgaben",
"completionRate": "Abschlussrate",
"onTime": "Pünktlich",
"late": "Verspätet",
"productivityScore": "Produktivitätswert",
"timeDistribution": "Zeitverteilung",
"timeTracking": "Zeiterfassung",
"totalTime": "Gesamtzeit",
"noData": "Keine Zeit für diesen Zeitraum erfasst",
"period": {
"day": "Tag",
"week": "Woche",
"month": "Monat",
"year": "Jahr"
}
},
"table": {
"user": "Benutzer",
"role": "Rolle",
+166
View File
@@ -0,0 +1,166 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import { Loader2, Clock, CalendarDays } from 'lucide-react';
interface TimeDistribution {
labelId: string | null;
labelName: string | null;
labelColor: string | null;
timeSpent: number; // minutes
}
export default function TimeTrackingPage() {
const { t } = useTranslation();
const [period, setPeriod] = useState<'day' | 'week' | 'month' | 'year'>('day');
const { data: distribution, isLoading } = useQuery<TimeDistribution[]>({
queryKey: ['/api/analytics/time-distribution', period],
queryFn: async () => {
const res = await fetch(`/api/analytics/time-distribution?period=${period}`);
if (!res.ok) throw new Error('Failed to fetch data');
return res.json();
}
});
// Calculate total time
const totalMinutes = distribution?.reduce((acc, curr) => acc + curr.timeSpent, 0) || 0;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const formatTime = (totalMins: number) => {
const h = Math.floor(totalMins / 60);
const m = totalMins % 60;
return `${h}h ${m}m`;
};
const hasData = totalMinutes > 0;
return (
<div className="container mx-auto p-4 md:p-8 space-y-8 max-w-7xl">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
{t('analytics.timeTracking')}
</h1>
<p className="text-muted-foreground mt-1">
{t('analytics.timeDistribution')}
</p>
</div>
<Tabs value={period} onValueChange={(v) => setPeriod(v as any)} className="w-[400px]">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="day">{t('analytics.period.day')}</TabsTrigger>
<TabsTrigger value="week">{t('analytics.period.week')}</TabsTrigger>
<TabsTrigger value="month">{t('analytics.period.month')}</TabsTrigger>
<TabsTrigger value="year">{t('analytics.period.year')}</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Main Stats Card */}
<Card className="col-span-2">
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('analytics.totalTime')}
</CardTitle>
<CardDescription className="text-4xl font-bold flex items-center gap-2">
<Clock className="w-8 h-8 text-indigo-500" />
{hours}h <span className="text-xl text-muted-foreground">{minutes}m</span>
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-xs text-muted-foreground">
{period === 'day' && "Today's activity"}
{period === 'week' && "This week's activity"}
{period === 'month' && "This month's activity"}
{period === 'year' && "This year's activity"}
</div>
</CardContent>
</Card>
{/* Chart Card */}
<Card className="col-span-2 lg:col-span-5">
<CardHeader>
<CardTitle>{t('analytics.timeDistribution')}</CardTitle>
</CardHeader>
<CardContent className="h-[350px]">
{isLoading ? (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : !hasData ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<CalendarDays className="w-12 h-12 mb-2 opacity-20" />
<p>{t('analytics.noData')}</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={distribution}
dataKey="timeSpent"
nameKey="labelName"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={5}
>
{distribution?.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.labelColor || '#808080'} stroke="none" />
))}
</Pie>
<Tooltip
formatter={(value: number) => formatTime(value)}
contentStyle={{ backgroundColor: 'rgba(255, 255, 255, 0.95)', borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
/>
<Legend verticalAlign="middle" align="right" layout="vertical" iconType="circle" />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
{/* Detailed Table */}
<Card>
<CardHeader>
<CardTitle>{t('analytics.title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{distribution?.sort((a, b) => b.timeSpent - a.timeSpent).map((item) => (
<div key={item.labelId || 'unknown'} className="flex items-center justify-between p-3 rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: item.labelColor || '#808080' }}
/>
<span className="font-medium">{item.labelName || 'No Label'}</span>
</div>
<div className="flex items-center gap-4">
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${(item.timeSpent / totalMinutes) * 100}%`,
backgroundColor: item.labelColor || '#808080'
}}
/>
</div>
<span className="text-sm font-bold w-16 text-right">
{formatTime(item.timeSpent)}
</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}