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
+146 -10
View File
@@ -1,4 +1,4 @@
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, type Reward, type InsertReward, type UserReward, type InsertUserReward, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, type InsertPasswordResetToken, type PasswordResetToken, type Conversation, type InsertConversation, type Message, type InsertMessage, type AuditLog, type InsertAuditLog } from "@shared/schema";
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, type Reward, type InsertReward, type UserReward, type InsertUserReward, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, type InsertPasswordResetToken, type PasswordResetToken, type Conversation, type InsertConversation, type Message, type InsertMessage, type AuditLog, type InsertAuditLog, type TaskTimeLog, type InsertTaskTimeLog } from "@shared/schema";
import * as schema from "@shared/schema";
import { getDatabase, pool } from "./db";
import { eq, sql, and, desc, asc, gt, ne, or, isNull } from "drizzle-orm";
@@ -105,6 +105,10 @@ export interface IStorage {
// Audit Logs
createAuditLog(log: InsertAuditLog): Promise<AuditLog>;
getAuditLogs(limit?: number): Promise<AuditLog[]>;
// Time Tracking
logTaskTime(log: InsertTaskTimeLog): Promise<TaskTimeLog>;
getAnalyticsTimeDistribution(userId: string, period: 'day' | 'week' | 'month' | 'year'): Promise<{ labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }[]>;
}
export class MemStorage implements IStorage {
@@ -123,6 +127,7 @@ export class MemStorage implements IStorage {
private userTaskAccess: Map<string, UserTaskAccess>;
private passwordResetTokens: Map<string, PasswordResetToken>; // id -> Token
private auditLogs: Map<string, AuditLog>;
private taskTimeLogs: Map<string, TaskTimeLog>;
sessionStore: session.Store;
@@ -140,6 +145,7 @@ export class MemStorage implements IStorage {
this.userTaskAccess = new Map();
this.passwordResetTokens = new Map();
this.auditLogs = new Map();
this.taskTimeLogs = new Map();
this.sessionStore = new MemoryStore({
checkPeriod: 86400000,
});
@@ -154,10 +160,10 @@ export class MemStorage implements IStorage {
private async createDefaultLabels() {
// Use fixed IDs to prevent ID churn on server restarts
const defaultLabels = [
{ id: 'cb44bed1-8ba3-43fe-9498-bb28e483ed1f', name: "Work", color: "#3B82F6", creatorId: null },
{ id: '274f0ba4-a133-471a-bbe9-8189aa3b0106', name: "Personal", color: "#10B981", creatorId: null },
{ id: '2e8c3aa0-278f-4220-b2c5-aa109b4279b7', name: "Urgent", color: "#EF4444", creatorId: null },
{ id: 'c5eccac9-7919-4eb1-bb50-badacad6b1c1', name: "Study", color: "#8B5CF6", creatorId: null },
{ id: 'cb44bed1-8ba3-43fe-9498-bb28e483ed1f', name: "Work", color: "#3B82F6", creatorId: null, domain: "work" },
{ id: '274f0ba4-a133-471a-bbe9-8189aa3b0106', name: "Personal", color: "#10B981", creatorId: null, domain: "personal" },
{ id: '2e8c3aa0-278f-4220-b2c5-aa109b4279b7', name: "Urgent", color: "#EF4444", creatorId: null, domain: "neutral" },
{ id: 'c5eccac9-7919-4eb1-bb50-badacad6b1c1', name: "Study", color: "#8B5CF6", creatorId: null, domain: "personal" },
];
for (const label of defaultLabels) {
if (!this.labels.has(label.id)) {
@@ -212,10 +218,10 @@ export class MemStorage implements IStorage {
lastTaskDate: null,
showOnLeaderboard: insertUser.showOnLeaderboard ?? false,
isSearchable: insertUser.isSearchable ?? false,
apiKey: null,
aiEnabled: insertUser.aiEnabled ?? true,
// Missing fields fix:
language: insertUser.language ?? "en",
workHours: insertUser.workHours || null,
availability: insertUser.availability || null,
aiEnabled: insertUser.aiEnabled ?? false,
language: insertUser.language ?? 'en',
is2faEnabled: false,
otpCode: null,
otpExpiresAt: null,
@@ -397,7 +403,8 @@ export class MemStorage implements IStorage {
const label: Label = {
...insertLabel,
id,
creatorId: insertLabel.creatorId ?? null
creatorId: insertLabel.creatorId ?? null,
domain: insertLabel.domain || "personal"
};
this.labels.set(id, label);
return label;
@@ -719,6 +726,67 @@ export class MemStorage implements IStorage {
.sort((a, b) => (b.createdAt && a.createdAt ? b.createdAt.getTime() - a.createdAt.getTime() : 0))
.slice(0, limit);
}
async logTaskTime(insertLog: InsertTaskTimeLog): Promise<TaskTimeLog> {
const id = randomUUID();
const log: TaskTimeLog = { ...insertLog, id, createdAt: new Date() };
this.taskTimeLogs.set(id, log);
return log;
}
async getAnalyticsTimeDistribution(userId: string, period: 'day' | 'week' | 'month' | 'year'): Promise<{ labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }[]> {
const now = new Date();
let startDate = new Date();
switch (period) {
case 'day':
startDate.setHours(0, 0, 0, 0); // Start of today
break;
case 'week':
const day = startDate.getDay() || 7; // 1 (Mon) to 7 (Sun)
if (day !== 1) startDate.setHours(-24 * (day - 1)); // Go back to Monday
startDate.setHours(0, 0, 0, 0);
break;
case 'month':
startDate.setDate(1); // 1st of month
startDate.setHours(0, 0, 0, 0);
break;
case 'year':
startDate.setMonth(0, 1); // Jan 1st
startDate.setHours(0, 0, 0, 0);
break;
}
const logs = Array.from(this.taskTimeLogs.values()).filter(log => {
const logDate = log.createdAt ? new Date(log.createdAt) : new Date(0);
return log.userId === userId && logDate >= startDate;
});
const distribution = new Map<string, { labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }>();
for (const log of logs) {
const task = this.tasks.get(log.taskId);
if (!task) continue;
const labelId = task.labelId || 'no_label';
let labelName = 'No Label';
let labelColor = '#808080';
if (task.labelId) {
const label = this.labels.get(task.labelId);
if (label) {
labelName = label.name;
labelColor = label.color;
}
}
const existing = distribution.get(labelId) || { labelId: task.labelId || null, labelName, labelColor, timeSpent: 0 };
existing.timeSpent += log.timeSpent;
distribution.set(labelId, existing);
}
return Array.from(distribution.values());
}
}
@@ -774,6 +842,8 @@ export class DbStorage implements IStorage {
isActive: insertUser.isActive ?? true,
showOnLeaderboard: insertUser.showOnLeaderboard ?? false,
isSearchable: insertUser.isSearchable ?? false,
workHours: insertUser.workHours || null,
availability: insertUser.availability || null,
}).returning();
return result[0];
}
@@ -1233,6 +1303,72 @@ export class DbStorage implements IStorage {
.orderBy(desc(schema.auditLogs.createdAt))
.limit(limit);
}
// Time Tracking (DbStorage)
async logTaskTime(insertLog: InsertTaskTimeLog): Promise<TaskTimeLog> {
const result = await this.db.insert(schema.taskTimeLogs).values(insertLog).returning();
return result[0];
}
async getAnalyticsTimeDistribution(userId: string, period: 'day' | 'week' | 'month' | 'year'): Promise<{ labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }[]> {
// Calculate start date based on period
// Simple approach: standard SQL date truncation or JS date calculation passed as param
// Drizzle doesn't have easy interval manipulation across specific drivers universally, but for PG we can use sql
let timeFilter;
const now = new Date();
// We can filter in JS or SQL. SQL is better.
// period logic:
// day: >= start of today
// week: >= start of this week (Monday)
// month: >= start of this month
// year: >= start of this year
let startDate = new Date();
startDate.setHours(0, 0, 0, 0); // reset time
if (period === 'week') {
const day = startDate.getDay() || 7;
startDate.setDate(startDate.getDate() - (day - 1));
} else if (period === 'month') {
startDate.setDate(1);
} else if (period === 'year') {
startDate.setMonth(0, 1);
}
// If period is 'day', startDate is already today 00:00
// Join taskTimeLogs -> tasks -> labels
// Sum timeSpent by label
const logs = await this.db.select({
labelId: schema.labels.id,
labelName: schema.labels.name,
labelColor: schema.labels.color,
timeSpent: sql<number>`sum(${schema.taskTimeLogs.timeSpent})::int`
})
.from(schema.taskTimeLogs)
.innerJoin(schema.tasks, eq(schema.taskTimeLogs.taskId, schema.tasks.id))
.leftJoin(schema.labels, eq(schema.tasks.labelId, schema.labels.id))
.where(and(
eq(schema.taskTimeLogs.userId, userId),
gt(schema.taskTimeLogs.createdAt, startDate)
))
.groupBy(schema.labels.id, schema.labels.name, schema.labels.color);
// Drizzle returns { labelId: ..., timeSpent: ... }
// We need to handle null labels too (left join) -> "No Label" logic
// Actually the left join returns null for label fields if tasks.labelId is null or invalid
// We can map the result
return logs.map(log => ({
labelId: log.labelId || null,
labelName: log.labelName || "No Label",
labelColor: log.labelColor || "#808080",
timeSpent: log.timeSpent || 0
}));
}
}
// Export storage based on environment