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
+42 -40
View File
@@ -95,8 +95,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
await storage.setSystemSettings("smtp_pass", "");
await storage.setSystemSettings("smtp_from", "noreply@example.com");
await storage.setSystemSettings("smtp_secure", "false");
// Ensure daily routine time is fixed to 22:00
await storage.setSystemSettings("evening_routine_time", "22:00");
await storage.setSystemSettings("morning_routine_time", "09:00");
// Also mark setup as NOT completed if no admin exists, or just ensure registration is open
res.json({ message: "Settings fixed, registration enabled" });
res.json({ message: "Settings fixed, registration enabled, routine times reset" });
});
app.post("/api/debug/force-enable-registration", async (req, res) => {
@@ -927,43 +930,7 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
}
});
app.patch("/api/tasks/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
// Validate request body against schema
const cleanBody = insertTaskSchema.partial().safeParse(req.body);
if (!cleanBody.success) {
return res.status(400).json({ error: cleanBody.error });
}
try {
const { hasAccess, task } = await checkTaskAccess(req.user as User, req.params.id, 'write');
if (!task) return res.status(404).json({ error: "Task not found" });
if (!hasAccess) return res.status(403).json({ error: "Access denied" });
const updatedTask = await storage.updateTask(task.id, cleanBody.data);
if (cleanBody.data.status === 'done' && task.status !== 'done') {
if (req.user) {
// Check if late
const now = new Date();
const isLate = task.dueDate && new Date(task.dueDate) < now;
const xpSource = isLate ? "complete_task_late" : "complete_task";
// Award XP for completion with Task Title context
await gamificationService.awardXP(
(req.user as User).id,
xpSource,
undefined,
{ taskId: task.id, taskTitle: task.title }
);
}
}
res.json(updatedTask);
} catch (error) {
res.status(500).json({ error: "Failed to update task" });
}
});
app.patch("/api/labels/:id", async (req, res) => {
try {
@@ -1226,7 +1193,13 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
app.patch("/api/tasks/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const previousTask = await storage.getTask(req.params.id);
// 1. Check Access (Security Fix + Fetch)
// We use checkTaskAccess to ensure the user has write permissions (ownership or shared write access)
const { hasAccess, task: previousTask } = await checkTaskAccess(req.user as User, req.params.id, 'write');
if (!previousTask) return res.status(404).json({ error: "Task not found" });
if (!hasAccess) return res.status(403).json({ error: "Access denied" });
const updates = insertTaskSchema.partial().safeParse(req.body);
if (!updates.success) {
@@ -1234,12 +1207,12 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
}
// Award XP using GamificationService
if (req.user && previousTask) { // Ensure previousTask exists for comparison
if (req.user) {
if (updates.data.status === 'done' && previousTask.status !== 'done') {
const isLate = previousTask.dueDate && new Date(previousTask.dueDate) < new Date();
const source = isLate ? 'complete_task_late' : 'complete_task';
await gamificationService.awardXP((req.user as User).id, source, undefined, { taskId: previousTask.id, taskTitle: previousTask.title });
} else if (Object.keys(updates.data).length > 0) { // Only award if there are actual updates
} else if (Object.keys(updates.data).length > 0) {
// Small points for any other update (title, description, etc)
await gamificationService.awardXP((req.user as User).id, 'update_task', undefined, { taskId: previousTask.id, taskTitle: previousTask.title });
}
@@ -1250,6 +1223,17 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
return res.status(404).json({ error: "Task not found" });
}
// 2. Time Tracking Logic
// If timeTracked has increased, log the delta
if (updates.data.timeTracked && updatedTask.timeTracked > previousTask.timeTracked) {
const delta = updatedTask.timeTracked - previousTask.timeTracked;
await storage.logTaskTime({
taskId: updatedTask.id,
userId: (req.user as User).id,
timeSpent: delta
});
}
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
@@ -1266,6 +1250,24 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
}
});
// Time Analytics Endpoint
app.get("/api/analytics/time-distribution", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const period = req.query.period as 'day' | 'week' | 'month' | 'year';
if (!['day', 'week', 'month', 'year'].includes(period)) {
return res.status(400).json({ error: "Invalid period. Must be day, week, month, or year." });
}
try {
const distribution = await storage.getAnalyticsTimeDistribution((req.user as User).id, period);
res.json(distribution);
} catch (error) {
console.error("Analytics Error:", error);
res.status(500).json({ error: "Failed to fetch time distribution" });
}
});
app.delete("/api/tasks/:id", async (req, res) => {
try {
const success = await storage.deleteTask(req.params.id);