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>
|
||||
);
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rest-express",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rest-express",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rest-express",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -70,6 +70,13 @@ You can create, search, update, and delete tasks using the provided tools.
|
||||
- *Delete All*: Search all, then delete each.
|
||||
- **Labels**: Use 'getLabels' tags.
|
||||
|
||||
### 🛡️ SAFETY RULES
|
||||
- **DELETION CONFIRMATION**: Before using 'deleteTask', you MUST:
|
||||
1. LIST the tasks you are about to delete (Title + ID).
|
||||
2. ASK the user for explicit confirmation ("Are you sure you want to delete these X tasks?").
|
||||
3. WAIT for the user to say "Yes".
|
||||
- ONLY then proceed to valid 'deleteTask' calls.
|
||||
|
||||
**2. 🧠 Smart Planning & Scheduling**
|
||||
- **"Break this down"**: Create subtasks with 'parentTaskId'.
|
||||
- **"Find time for this"**: Use 'scheduleTask'.
|
||||
|
||||
+42
-40
@@ -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);
|
||||
|
||||
+146
-10
@@ -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
|
||||
|
||||
+16
-1
@@ -317,7 +317,6 @@ export type Conversation = typeof conversations.$inferSelect;
|
||||
export type InsertMessage = z.infer<typeof insertMessageSchema>;
|
||||
export type Message = typeof messages.$inferSelect;
|
||||
|
||||
// Audit Logging
|
||||
export const auditLogs = pgTable("audit_logs", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id").references(() => users.id), // Nullable if system action (though usually we track actor)
|
||||
@@ -336,3 +335,19 @@ export const insertAuditLogSchema = createInsertSchema(auditLogs).omit({
|
||||
|
||||
export type InsertAuditLog = z.infer<typeof insertAuditLogSchema>;
|
||||
export type AuditLog = typeof auditLogs.$inferSelect;
|
||||
|
||||
export const taskTimeLogs = pgTable("task_time_logs", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
taskId: varchar("task_id").references(() => tasks.id).notNull(),
|
||||
userId: varchar("user_id").references(() => users.id).notNull(),
|
||||
timeSpent: integer("time_spent").notNull(), // in minutes
|
||||
createdAt: timestamp("created_at").defaultNow(), // WHEN the time was logged
|
||||
});
|
||||
|
||||
export const insertTaskTimeLogSchema = createInsertSchema(taskTimeLogs).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
export type InsertTaskTimeLog = z.infer<typeof insertTaskTimeLogSchema>;
|
||||
export type TaskTimeLog = typeof taskTimeLogs.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user