feat: Add Time Tracking page with analytics and time distribution charts
continuous-integration/drone/push Build is passing
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:
+6
-2
@@ -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 />
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user