feat: enhance audit logging, add MCP settings, and production docker setup
continuous-integration/drone/push Build is passing

- Implemented comprehensive audit logging for Tasks, Users, Settings, Goals, Labels, AI Chat, and Rewards.
- Added Admin UI for MCP Server settings and Audit Logs.
- Created docker-compose-production.yml with Traefik configuration.
- Fixed backend bugs (missing storage methods, route closure).
- Added Audit Logging Guidelines.
This commit is contained in:
2025-12-15 15:53:31 +01:00
parent fdf321cde9
commit d1736c5991
35 changed files with 5165 additions and 499 deletions
+92 -45
View File
@@ -27,9 +27,9 @@ import ProjectTemplate from './components/ProjectTemplate';
import WeekListView from './components/WeekListView';
import Settings from './pages/settings';
import AchievementsPage from './pages/AchievementsPage';
import UnscheduledTasksPage from './pages/UnscheduledTasksPage';
import { Task, Label } from '@shared/schema';
import { useTimer } from './hooks/useTimer';
import { Plus } from 'lucide-react';
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "./components/AppSidebar"
@@ -45,16 +45,17 @@ import NotFound from "@/pages/not-found";
import { AiChat } from "@/components/AiChat";
import ForgotPasswordPage from "@/pages/ForgotPasswordPage";
import ResetPasswordPage from "@/pages/ResetPasswordPage";
import AiChatPage from "@/pages/AiChatPage";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "@shared/schema";
import { Loader2 } from "lucide-react";
function App() {
const { t } = useTranslation();
const [, setLocation] = useLocation();
const [tasks, setTasks] = useState<Task[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
@@ -62,6 +63,8 @@ function App() {
const [showPomodoro, setShowPomodoro] = useState(false);
const [activePomodoroTaskId, setActivePomodoroTaskId] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: user, isLoading: isLoadingUser } = useQuery<User>({
queryKey: ["/api/user"],
retry: false,
@@ -71,32 +74,20 @@ function App() {
queryKey: ['/api/setup/status'],
});
// Fetch tasks and labels
useEffect(() => {
if (!user) return;
const fetchData = async () => {
try {
const tasksResponse = await fetch('/api/tasks');
if (tasksResponse.ok) {
const serverTasks: Task[] = await tasksResponse.json();
const normalizedTasks = serverTasks.map(task => ({
...task,
dueDate: task.dueDate ? new Date(task.dueDate) : null
}));
setTasks(normalizedTasks);
}
// Fetch tasks and labels using React Query
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
enabled: !!user,
select: (data) => data.map(task => ({
...task,
dueDate: task.dueDate ? new Date(task.dueDate) : null
}))
});
const labelsResponse = await fetch('/api/labels');
if (labelsResponse.ok) {
const serverLabels: Label[] = await labelsResponse.json();
setLabels(serverLabels);
}
} catch (error) {
console.error('Error fetching data:', error);
}
};
fetchData();
}, [user]);
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels'],
enabled: !!user,
});
const handleCreateTask = async (newTask: Partial<Task>) => {
try {
@@ -122,17 +113,31 @@ function App() {
if (!response.ok) throw new Error('Failed to create task');
const createdTask: Task = await response.json();
const normalizedTask = {
...createdTask,
dueDate: createdTask.dueDate ? new Date(createdTask.dueDate) : null
};
setTasks(prev => [...prev, normalizedTask]);
// Update cache optimistically or invalidation
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => {
const normalizedTask = {
...createdTask,
dueDate: createdTask.dueDate ? new Date(createdTask.dueDate) : null
};
return [...old, normalizedTask];
});
// Invalidate to be sure
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
} catch (error) {
console.error('Error creating task:', error);
}
};
const handleTaskUpdate = async (taskId: string, updates: Partial<Task>) => {
// Optimistic Update
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
old.map(task => task.id === taskId ? { ...task, ...updates } : task)
);
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
@@ -143,19 +148,28 @@ function App() {
if (!response.ok) throw new Error('Failed to update task');
const updatedTask: Task = await response.json();
const normalizedTask = {
...updatedTask,
dueDate: updatedTask.dueDate ? new Date(updatedTask.dueDate) : null
};
setTasks(prev => prev.map(task => task.id === taskId ? normalizedTask : task));
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => uniqueTasks(old, updatedTask));
} catch (error) {
console.error('Error updating task:', error);
setTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...updates } : task));
// Rollback
if (previousTasks) {
queryClient.setQueryData(['/api/tasks'], previousTasks);
}
}
};
// Helper to merge updated task into array
const uniqueTasks = (tasks: Task[], updated: Task) => {
const normalized = { ...updated, dueDate: updated.dueDate ? new Date(updated.dueDate) : null };
return tasks.map(t => t.id === updated.id ? normalized : t);
};
const handleCreateFromTemplate = (templateId: string, startDate: Date, projectName: string) => {
console.log('Creating project from template:', { templateId, startDate, projectName });
// Assuming this might create tasks, we should invalidate
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }), 1000);
};
const { startTimer, stopTimer } = useTimer({
@@ -197,8 +211,14 @@ function App() {
const confirmTaskDelete = async () => {
if (!deleteTaskId) return;
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
const taskToDelete = tasks.find(task => task.id === deleteTaskId);
setTasks(prev => prev.filter(task => task.id !== deleteTaskId));
// Optimistic delete
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
old.filter(task => task.id !== deleteTaskId)
);
if (selectedTask?.id === deleteTaskId) {
handleTaskDetailsClose();
@@ -208,9 +228,13 @@ function App() {
try {
const response = await fetch(`/api/tasks/${deleteTaskId}`, { method: 'DELETE' });
if (!response.ok && response.status !== 404) throw new Error('Failed to delete task');
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
} catch (error) {
console.error('Error deleting task:', error);
if (taskToDelete) setTasks(prev => [...prev, taskToDelete]);
// Rollback
if (previousTasks) {
queryClient.setQueryData(['/api/tasks'], previousTasks);
}
}
};
@@ -315,16 +339,36 @@ function App() {
<Route path="/templates">
<ProjectTemplate
onCreateFromTemplate={handleCreateFromTemplate}
onCreateTasks={(newTasks) => setTasks(prev => [...prev, ...newTasks])}
onCreateTasks={(newTasks) => {
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => [...old, ...newTasks]);
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
}}
onNavigateToSettings={() => setLocation('/settings')}
/>
</Route>
<Route path="/achievements">
<AchievementsPage user={user} />
{user ? <AchievementsPage user={user} /> : <AuthPage />}
</Route>
<Route path="/unscheduled">
{user ? (
<UnscheduledTasksPage
user={user}
onToggleCompletion={(taskId, currentStatus) => handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')}
onDelete={(id) => setDeleteTaskId(id)}
onUpdate={handleTaskUpdate}
onSelect={setSelectedTask}
/>
) : (
<AuthPage />
)}
</Route>
<Route path="/leaderboard">
<LeaderboardPage />
</Route>
<Route path="/ai">
<AiChatPage />
</Route>
<Route path="/settings">
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
</Route>
@@ -370,7 +414,6 @@ function App() {
/>
<Toaster />
{user?.aiEnabled && <AiChat />}
<TaskCreationModal
isOpen={isCreateModalOpen}
@@ -384,6 +427,10 @@ function App() {
task={selectedTask}
onSave={handleTaskDetailsSave}
labels={labels}
onNavigate={(id) => {
const t = tasks.find(x => x.id === id);
if (t) setSelectedTask(t);
}}
/>
<AlertDialog open={deleteTaskId !== null} onOpenChange={(open) => !open && setDeleteTaskId(null)}>
+2 -2
View File
@@ -57,7 +57,7 @@ export function AiChat() {
return (
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-24 right-8 z-50 rounded-full h-14 w-14 shadow-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:scale-110 transition-transform duration-200"
className="fixed bottom-4 right-8 z-50 rounded-full h-14 w-14 shadow-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:scale-110 transition-transform duration-200"
size="icon"
>
<Sparkles className="h-6 w-6 text-white animate-pulse" />
@@ -66,7 +66,7 @@ export function AiChat() {
}
return (
<Card className="fixed bottom-24 right-8 z-50 w-80 md:w-96 h-[500px] flex flex-col shadow-2xl border-primary/20 animate-in slide-in-from-bottom-10 fade-in duration-200">
<Card className="fixed bottom-4 right-8 z-50 w-80 md:w-96 h-[500px] flex flex-col shadow-2xl border-primary/20 animate-in slide-in-from-bottom-10 fade-in duration-200">
<CardHeader className="p-4 border-b bg-primary/5 flex flex-row items-center justify-between shrink-0">
<div className="flex items-center gap-2 font-semibold">
<Bot className="w-5 h-5 text-primary" />
+16 -6
View File
@@ -1,5 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award } from 'lucide-react';
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff } from 'lucide-react';
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query';
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Sidebar,
@@ -13,12 +16,9 @@ import {
useSidebar,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { useToast } from "@/hooks/use-toast";
import { User } from "@shared/schema";
import { Task, User } from '@shared/schema';
import ThemeToggle from './ThemeToggle';
import { useLocation } from "wouter";
import { GamificationBar } from './GamificationBar';
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
@@ -32,6 +32,13 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
const queryClient = useQueryClient();
const [location, setLocation] = useLocation();
// Fetch unscheduled count
// const { data: unscheduledCount = 0 } = useQuery({
// queryKey: ['/api/tasks'],
// select: (tasks: Task[]) => tasks.filter(t => !t.dueDate && t.status !== 'done').length,
// enabled: !!user
// });
const logoutMutation = useMutation({
mutationFn: async () => {
await fetch("/api/logout", { method: "POST" });
@@ -47,9 +54,11 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
{ title: t('navigation.tasks'), id: 'tasks', path: '/tasks', icon: Home, color: 'text-blue-500' },
{ title: t('navigation.calendar'), id: 'calendar', path: '/calendar', icon: Calendar, color: 'text-violet-500' },
{ title: t('navigation.weekList'), id: 'weeklist', path: '/weeklist', icon: List, color: 'text-pink-500' },
{ title: t('unscheduled.title', 'Unscheduled'), id: 'unscheduled', path: '/unscheduled', icon: CalendarOff, color: 'text-slate-500' },
{ 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' },
...(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' },
]
@@ -82,7 +91,8 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
tooltip={item.title}
className={`h-12 transition-all duration-200 ${location === item.path || (item.path !== '/' && location.startsWith(item.path))
? 'bg-gradient-to-r from-violet-600 to-indigo-600 text-white shadow-md hover:from-violet-500 hover:to-indigo-500 hover:text-white'
: 'hover:bg-sidebar-accent hover:pl-4'}`}
: 'hover:bg-sidebar-accent hover:pl-4'
}`}
>
<item.icon className={`transition-all duration-200 ${state === 'collapsed' ? 'size-7' : 'size-5'} ${location === item.path || (item.path !== '/' && location.startsWith(item.path)) ? 'text-white' : item.color}`} />
{state !== 'collapsed' && (
+70 -7
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
import { ChevronLeft, ChevronRight, Calendar, Edit, Timer, CheckCircle, Play, Clock, Trash2 } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { addDays, format, isSameDay, startOfWeek, subWeeks } from 'date-fns';
import { addDays, format, isSameDay, startOfWeek, subWeeks, startOfDay } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import {
ContextMenu,
@@ -21,7 +21,14 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
interface CalendarViewProps {
tasks: Task[];
@@ -61,9 +68,22 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
const getTasksForDate = (date: Date) => {
return tasks.filter(task => {
if (!task.dueDate) return false;
const taskDate = new Date(task.dueDate);
return isSameDay(taskDate, date);
// Handle multi-day tasks
if (task.startDate && task.dueDate) {
const start = startOfDay(new Date(task.startDate));
const end = startOfDay(new Date(task.dueDate));
const current = startOfDay(date);
return current >= start && current <= end;
}
// Handle single date tasks (dueDate or startDate)
if (task.dueDate) {
return isSameDay(new Date(task.dueDate), date);
}
if (task.startDate) { // Fallback if only start date exists
return isSameDay(new Date(task.startDate), date);
}
return false;
});
};
@@ -367,9 +387,52 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
})}
{dayTasks.length > 3 && (
<Badge variant="secondary" className="w-full justify-center text-xs">
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
</Badge>
<HoverCard openDelay={0} closeDelay={100}>
<HoverCardTrigger asChild>
<Badge variant="secondary" className="w-full justify-center text-xs cursor-pointer hover:bg-secondary/80">
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
</Badge>
</HoverCardTrigger>
<HoverCardContent className="w-72 p-0">
<div className="p-3 border-b bg-muted/30">
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
<span>{t('calendar.moreItems', { count: dayTasks.length - 3 })}</span>
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
</h4>
</div>
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
{dayTasks.slice(3).map(task => {
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
return (
<div
key={task.id}
className="p-2 border rounded hover:bg-accent cursor-pointer flex items-center justify-between group transition-colors"
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}
>
<div className="min-w-0 flex-1 mr-2">
<div className="text-xs font-medium truncate">{task.title}</div>
{taskLabel && (
<div className="flex items-center gap-1 mt-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: taskLabel.color }} />
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{taskLabel.name}</span>
</div>
)}
</div>
<Button size="icon" variant="ghost" className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}>
<Edit className="w-3 h-3" />
</Button>
</div>
)
})}
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
</Card>
+16 -2
View File
@@ -99,14 +99,28 @@ export default function FocusMode({
};
const focusTasks = useMemo(() => {
// Return top 3 tasks for focus list
// Return tasks for focus list
const today = new Date();
today.setHours(0, 0, 0, 0);
return tasks.filter(t => t.status !== 'done')
.sort((a, b) => {
// 1. Due Date (Overdue/Today first)
if (a.dueDate && b.dueDate) {
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
}
// Tasks with due dates come before those without
if (a.dueDate && !b.dueDate) return -1;
if (!a.dueDate && b.dueDate) return 1;
// 2. Priority
if (a.priority === 'high' && b.priority !== 'high') return -1;
if (b.priority === 'high' && a.priority !== 'high') return 1;
return 0;
})
.slice(0, 3);
// Take top 5 to include more subtasks if relevant
.slice(0, 5);
}, [tasks]);
const sensors = useSensors(
+33 -7
View File
@@ -17,7 +17,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock } from "lucide-react";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock, CornerDownRight } from "lucide-react";
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { Checkbox } from "@/components/ui/checkbox";
@@ -150,6 +150,9 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
const blockingTasks = task.dependencies?.map(dId => allTasks.find(t => t.id === dId)).filter(t => t && t.status !== 'done') || [];
const isBlocked = blockingTasks.length > 0;
const subtasks = allTasks.filter(t => t.parentTaskId === task.id);
const completedSubtasks = subtasks.filter(t => t.status === 'done');
// Fetch labels to get the label color
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels'],
@@ -231,6 +234,11 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
}
};
/* Retrieve parent task if this is a subtask */
const parentTask = task.parentTaskId
? allTasks.find(t => String(t.id) === String(task.parentTaskId))
: null;
return (
<motion.div
drag="x"
@@ -245,11 +253,13 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
<ContextMenuTrigger asChild>
<Card
className={`p-5 hover-elevate active-elevate-2 transition-all duration-300 relative hover:-translate-y-1 hover:shadow-xl ${isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
} ${task.status === 'done' ? 'opacity-60' : ''}`}
} ${task.status === 'done' ? 'opacity-60' : ''} ${parentTask ? 'border-l-4 border-l-indigo-400 bg-indigo-50/10' : ''}`}
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
borderStyle: 'solid',
borderLeftWidth: parentTask ? '4px' : '2px', // Make left border thicker if subtask
borderLeftColor: parentTask ? '#818cf8' : taskLabel.color // Indigo for subtask
} : {}}
data-testid={`card-task-${task.id}`}
>
@@ -283,8 +293,17 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
console.log(`Task card clicked: ${task.title}`);
}}
>
{/* Parent Task Link rendered in title now */}
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
{task.title}
{parentTask ? (
<span className="flex items-center gap-1">
<span className="text-muted-foreground font-normal">{parentTask.title}</span>
<span className="text-muted-foreground"></span>
<span>{task.title}</span>
</span>
) : (
task.title
)}
</h3>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
@@ -312,11 +331,12 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{/* Shared Status Icon */}
<SharedTaskIcon task={task} />
{task.dueDate && (
{(task.dueDate || task.startDate) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
<span data-testid={`text-due-date-${task.id}`}>
{task.dueDate.toLocaleDateString()}
{task.startDate ? `${new Date(task.startDate).toLocaleDateString()} - ` : ''}
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : ''}
</span>
</div>
)}
@@ -328,7 +348,13 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
{task.estimatedDuration && (
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
{task.estimatedDuration}m
{formatTime(task.estimatedDuration)}
</Badge>
)}
{subtasks.length > 0 && (
<Badge variant="secondary" className="text-xs">
{completedSubtasks.length}/{subtasks.length} Subtasks
</Badge>
)}
+58 -12
View File
@@ -30,12 +30,14 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
const [energyLevel, setEnergyLevel] = useState<'low' | 'medium' | 'high'>('medium');
const [estimatedDuration, setEstimatedDuration] = useState<number | undefined>();
const [dueDate, setDueDate] = useState<Date | undefined>();
const [startDate, setStartDate] = useState<Date | undefined>();
const [labelId, setLabelId] = useState<string | undefined>();
const [dependencies, setDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [isStartDateOpen, setIsStartDateOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch labels
@@ -59,6 +61,8 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
priority,
energyLevel,
estimatedDuration,
startDate,
dueDate,
labelId,
dependencies,
@@ -75,7 +79,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setDescription('');
setPriority('medium');
setDueDate(undefined);
setDueDate(undefined);
setStartDate(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
@@ -94,7 +98,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setDescription('');
setPriority('medium');
setDueDate(undefined);
setDueDate(undefined);
setStartDate(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
@@ -180,13 +184,25 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
</Select>
<div className="col-span-1">
<Input
type="number"
placeholder={t('taskCreation.minutesPlaceholder')}
value={estimatedDuration || ''}
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
className="text-sm"
/>
<Select
value={estimatedDuration ? estimatedDuration.toString() : "0"}
onValueChange={(val) => setEstimatedDuration(val === "0" ? undefined : parseInt(val))}
>
<SelectTrigger>
<SelectValue placeholder={t('taskCreation.duration')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
<SelectItem value="15">15m</SelectItem>
<SelectItem value="30">30m</SelectItem>
<SelectItem value="45">45m</SelectItem>
<SelectItem value="60">1h</SelectItem>
<SelectItem value="90">1.5h</SelectItem>
<SelectItem value="120">2h</SelectItem>
<SelectItem value="240">4h</SelectItem>
<SelectItem value="480">8h (1 Day)</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
@@ -209,12 +225,43 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
</SelectContent>
</Select>
<div className="col-span-2">
<div className="col-span-1">
<Popover open={isStartDateOpen} onOpenChange={setIsStartDateOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!startDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? startDate.toLocaleDateString() : t('taskCreation.startDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={(date) => {
setStartDate(date);
setIsStartDateOpen(false);
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="col-span-1">
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
className={cn(
"w-full justify-start text-left font-normal",
!dueDate && "text-muted-foreground"
)}
data-testid="button-due-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
@@ -229,7 +276,6 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setDueDate(date);
setIsCalendarOpen(false);
}}
disabled={(date) => date < new Date()}
initialFocus
/>
</PopoverContent>
+249 -13
View File
@@ -11,8 +11,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from "@/lib/utils";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useQuery } from '@tanstack/react-query';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar, Tag, Link2, Check } from 'lucide-react';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar as CalendarIcon, Tag, Link2, Check, LayoutList, Trash2, ArrowRight } from 'lucide-react';
import { Calendar } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { Task, Label } from '@shared/schema';
import { useTranslation } from 'react-i18next';
@@ -29,6 +33,7 @@ interface TaskDetailsModalProps {
task: Task | null;
onSave: (updatedTask: Task) => void;
labels?: Label[];
onNavigate?: (taskId: string) => void;
}
export default function TaskDetailsModal({
@@ -36,9 +41,12 @@ export default function TaskDetailsModal({
onClose,
task,
onSave,
labels = []
labels = [],
onNavigate
}: TaskDetailsModalProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const currentLocale = i18n.language === 'de' ? de : enUS;
// Edit form state
const [editedTitle, setEditedTitle] = useState('');
@@ -47,9 +55,17 @@ export default function TaskDetailsModal({
const [editedPriority, setEditedPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [editedLabelId, setEditedLabelId] = useState<string | null>(null);
const [editedDueDate, setEditedDueDate] = useState('');
const [editedStartDate, setEditedStartDate] = useState('');
const [editedEstimatedDuration, setEditedEstimatedDuration] = useState<number | undefined>(undefined);
const [editedDependencies, setEditedDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
// Subtasks state
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [isCreatingSubtask, setIsCreatingSubtask] = useState(false);
const [isScheduling, setIsScheduling] = useState(false);
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
@@ -78,6 +94,8 @@ export default function TaskDetailsModal({
setEditedPriority(task.priority as 'low' | 'medium' | 'high');
setEditedLabelId(task.labelId || null);
setEditedDueDate(task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : '');
setEditedStartDate(task.startDate ? new Date(task.startDate).toISOString().split('T')[0] : '');
setEditedEstimatedDuration(task.estimatedDuration || undefined);
setEditedDependencies(task.dependencies || []);
setNotes(task.notes || '');
@@ -123,8 +141,11 @@ export default function TaskDetailsModal({
description: editedDescription || null,
status: editedStatus,
priority: editedPriority,
labelId: editedLabelId,
dueDate: editedDueDate ? new Date(editedDueDate) : null,
startDate: editedStartDate ? new Date(editedStartDate) : null,
estimatedDuration: editedEstimatedDuration ?? null,
dependencies: editedDependencies
};
onSave(updatedTask);
@@ -180,6 +201,55 @@ export default function TaskDetailsModal({
console.log(`Time entry saved: ${totalMinutes} minutes for task: ${task.title}`);
};
const handleCreateSubtask = async () => {
if (!task || !newSubtaskTitle.trim()) return;
try {
setIsCreatingSubtask(true);
const subtaskData = {
title: newSubtaskTitle,
description: '',
status: 'todo',
priority: 'medium',
parentTaskId: task.id,
dueDate: null,
estimatedDuration: null
};
await apiRequest('POST', '/api/tasks', subtaskData);
await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
setNewSubtaskTitle('');
// Switch to the newly created task? Or just show it in the list.
} catch (error) {
console.error('Failed to create subtask:', error);
} finally {
setIsCreatingSubtask(false);
}
};
const handleAutoSchedule = async () => {
if (!task) return;
setIsScheduling(true);
try {
const res = await apiRequest('POST', `/api/tasks/${task.id}/schedule`, {});
const data = await res.json();
if (data.success && data.scheduledDate) {
setEditedDueDate(new Date(data.scheduledDate).toISOString().split('T')[0]);
// Also update the local task object immediately for smoother UX, or let queryClient invalidate
await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
} else {
console.error("Scheduling failed: ", data.error);
// Could add a toast here ideally
}
} catch (error) {
console.error("Auto-schedule error", error);
} finally {
setIsScheduling(false);
}
};
const subtasks = tasks.filter(t => t.parentTaskId === task?.id);
const handleDeleteTimeEntry = (entryId: string) => {
if (!task || entryId === 'tracked-time') {
// Don't allow deleting the main tracked time entry
@@ -246,6 +316,10 @@ export default function TaskDetailsModal({
if (!task) return null;
const parentTask = task.parentTaskId ? tasks.find(t => String(t.id) === String(task.parentTaskId)) : null;
const isSubtask = !!task.parentTaskId;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
@@ -260,6 +334,16 @@ export default function TaskDetailsModal({
{/* Task Information */}
<Card className="p-4">
<div className="space-y-3">
{parentTask && (
<div
className="flex items-center gap-1 text-sm text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 px-2 py-1 rounded w-fit mb-1 cursor-pointer hover:underline"
onClick={() => onNavigate?.(String(parentTask.id))}
>
<ArrowRight className="w-3 h-3" />
<span className="font-medium">{t('taskDetails.subtaskOf')}</span>
<span>{parentTask.title}</span>
</div>
)}
<h3 className="font-semibold text-lg" data-testid="text-task-details-title">
{task.title}
</h3>
@@ -287,7 +371,7 @@ export default function TaskDetailsModal({
</Card>
<Tabs defaultValue="edit" className="space-y-4">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="edit" data-testid="tab-edit">
<Edit2 className="w-4 h-4 mr-2" />
{t('taskDetails.editTab')}
@@ -296,12 +380,74 @@ export default function TaskDetailsModal({
<FileText className="w-4 h-4 mr-2" />
{t('taskDetails.notesTab')}
</TabsTrigger>
<TabsTrigger value="subtasks" data-testid="tab-subtasks">
<LayoutList className="w-4 h-4 mr-2" />
{t('taskDetails.subtasksTab')}
</TabsTrigger>
<TabsTrigger value="time" data-testid="tab-time">
<Clock className="w-4 h-4 mr-2" />
{t('taskDetails.timeTab')}
</TabsTrigger>
</TabsList>
{/* Subtasks Tab */}
<TabsContent value="subtasks" className="space-y-4">
<Card className="p-4">
<h4 className="font-medium mb-4">{t('taskDetails.subtasks')}</h4>
{!isSubtask ? (
<div className="flex gap-2 mb-4">
<Input
placeholder={t('taskDetails.newSubtaskPlaceholder')}
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateSubtask();
}}
/>
<Button
onClick={handleCreateSubtask}
disabled={!newSubtaskTitle.trim() || isCreatingSubtask}
>
<Plus className="w-4 h-4 mr-1" />
{t('taskDetails.createSubtask')}
</Button>
</div>
) : (
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 text-sm rounded-md border border-yellow-200 dark:border-yellow-900 flex items-center gap-2">
<LayoutList className="w-4 h-4" />
{t('taskDetails.hierarchyRestriction')}
</div>
)}
<div className="space-y-2">
{subtasks.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
{t('taskDetails.noSubtasks')}
</div>
) : (
subtasks.map(subtask => (
<div
key={subtask.id}
className="flex items-center justify-between p-3 border rounded-md hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onNavigate?.(String(subtask.id))}
>
<div className="flex items-center gap-2">
<Badge variant={subtask.status === 'done' ? 'secondary' : 'outline'}>
{t(`taskDetails.statusValue.${subtask.status}`)}
</Badge>
<span className={subtask.status === 'done' ? 'line-through text-muted-foreground' : ''}>
{subtask.title}
</span>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
</div>
))
)}
</div>
</Card>
</TabsContent>
{/* Edit Tab */}
<TabsContent value="edit" className="space-y-4">
<Card className="p-4">
@@ -386,21 +532,111 @@ export default function TaskDetailsModal({
</div>
{/* Due Date */}
<div className="grid grid-cols-2 gap-4">
{/* Start Date */}
<div>
<div className="flex items-center justify-between mb-2 h-8">
<label className="text-sm font-medium">{t('taskDetails.startDate')}</label>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal",
!editedStartDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{editedStartDate ? format(new Date(editedStartDate), "PPP", { locale: i18n.language === 'de' ? de : enUS }) : <span>{t('taskDetails.pickDate')}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={editedStartDate ? new Date(editedStartDate) : undefined}
onSelect={(date) => setEditedStartDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
{/* Due Date */}
<div>
<div className="flex items-center justify-between mb-2 h-8">
<label className="text-sm font-medium">{t('taskDetails.dueDate')}</label>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs text-indigo-600 border-indigo-200 hover:text-indigo-700 hover:bg-indigo-50"
onClick={handleAutoSchedule}
disabled={isScheduling}
>
<CalendarIcon className="w-3 h-3 mr-1" />
{isScheduling ? t('taskDetails.scheduling') : t('taskDetails.autoSchedule')}
</Button>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal",
!editedDueDate && "text-muted-foreground"
)}
data-testid="input-due-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{editedDueDate ? format(new Date(editedDueDate), "PPP", { locale: i18n.language === 'de' ? de : enUS }) : <span>{t('taskDetails.pickDate')}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={editedDueDate ? new Date(editedDueDate) : undefined}
onSelect={(date) => setEditedDueDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* Estimated Duration */}
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.dueDate')}</label>
<Input
type="date"
value={editedDueDate}
onChange={(e) => setEditedDueDate(e.target.value)}
data-testid="input-due-date"
/>
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')}</label>
<Select
value={editedEstimatedDuration ? editedEstimatedDuration.toString() : "0"}
onValueChange={(val) => setEditedEstimatedDuration(val === "0" ? undefined : parseInt(val))}
>
<SelectTrigger>
<SelectValue placeholder={t('taskCreation.duration')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
<SelectItem value="15">15m</SelectItem>
<SelectItem value="30">30m</SelectItem>
<SelectItem value="45">45m</SelectItem>
<SelectItem value="60">1h</SelectItem>
<SelectItem value="90">1.5h</SelectItem>
<SelectItem value="120">2h</SelectItem>
<SelectItem value="240">4h</SelectItem>
<SelectItem value="480">8h (1 Day)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Dependencies */}
<div>
<label className="text-sm font-medium flex items-center gap-2 mb-2">
<Link2 className="w-4 h-4" />
Blocked By
{t('taskDetails.blockedBy')}
</label>
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
<PopoverTrigger asChild>
@@ -772,6 +1008,6 @@ export default function TaskDetailsModal({
</div>
</div>
</DialogContent>
</Dialog>
</Dialog >
);
}
+92 -19
View File
@@ -11,12 +11,17 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight, Edit, Timer, CheckCircle, Play, Clock, Trash2 } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import TaskCard from './TaskCard';
import { useQuery } from '@tanstack/react-query';
import TimeCompletionModal from './TimeCompletionModal';
import { addDays, format, isSameDay, startOfToday } from 'date-fns';
import { addDays, format, isSameDay, startOfToday, startOfDay, isWithinInterval } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useDateLocale } from '../hooks/use-date-locale';
@@ -125,13 +130,33 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
}
});
// Tasks that are NOT on the calendar (no due date or not scheduled)
const unscheduledTasks = filteredAndSortedTasks.filter(task => !task.dueDate);
// Tasks that are NOT on the calendar (no due date OR not in visible dates)
const unscheduledTasks = filteredAndSortedTasks.filter(task => {
if (!task.dueDate) return true;
const isVisible = dates.some(date => isSameDay(date, task.dueDate!));
return !isVisible;
});
const getTasksForDate = (date: Date) => {
return filteredAndSortedTasks.filter(task =>
task.dueDate && isSameDay(task.dueDate, date)
);
return filteredAndSortedTasks.filter(task => {
// Handle multi-day tasks
if (task.startDate && task.dueDate) {
const start = startOfDay(new Date(task.startDate));
const end = startOfDay(new Date(task.dueDate));
const current = startOfDay(date);
return current >= start && current <= end;
}
// Handle single date tasks (dueDate or startDate)
if (task.dueDate) {
return isSameDay(new Date(task.dueDate), date);
}
if (task.startDate) {
return isSameDay(new Date(task.startDate), date);
}
return false;
});
};
const getFilterCount = (filter: FilterOption) => {
@@ -214,9 +239,9 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
};
return (
<div className="flex flex-col min-h-[calc(100vh-5rem)] md:min-h-[calc(100vh-3rem)] relative space-y-4">
{/* Header and Search Filters - Sticky */}
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-3 mb-4">
<div className="flex flex-col h-[calc(100vh-6rem)] relative">
{/* Header and Search Filters - Fixed at Top */}
<div className="flex-none pb-3 mb-2 bg-background/95 backdrop-blur z-40 border-b">
<div className="space-y-3">
{/* Title Bar */}
<div className="flex items-center justify-between">
@@ -282,7 +307,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
</div>
<div className="space-y-3 flex-1">
{/* Scrollable Task List */}
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 px-1 pb-4">
{unscheduledTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
@@ -294,8 +320,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
) : (
<>
<div className="text-sm font-medium text-muted-foreground mb-2 mt-8 px-1">
{t('taskList.unscheduledTasksLabel')}
<div className="text-sm font-medium text-muted-foreground mb-2 mt-2">
Task List
</div>
{unscheduledTasks.map((task) => (
<div
@@ -335,9 +361,9 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
)}
</div>
{/* Calendar Section - Sticky at bottom */}
<div className="sticky bottom-0 -mx-4 sm:-mx-6 bg-background/95 backdrop-blur z-30 border-t mt-auto shadow-up-lg">
<div className="p-3 min-h-[200px] max-h-[40vh] overflow-y-auto">
{/* Calendar Section - Fixed at bottom */}
<div className="flex-none -mx-4 sm:-mx-6 bg-background/95 backdrop-blur z-30 border-t mt-auto shadow-up-lg">
<div className="p-3">
{/* Calendar Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
@@ -371,7 +397,11 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
{/* Full Width Calendar Grid - Dynamic */}
<div className={`grid gap-2`} style={{ gridTemplateColumns: `repeat(${visibleDays}, minmax(0, 1fr))` }}>
{dates.map((date, index) => {
const dayTasks = getTasksForDate(date);
// Sort by priority to ensure high priority items are shown first
const dayTasks = getTasksForDate(date).sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 0) - (priorityOrder[a.priority as keyof typeof priorityOrder] || 0);
});
const isToday = isSameDay(date, new Date());
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const isHovered = hoveredDate && isSameDay(date, hoveredDate);
@@ -552,9 +582,52 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
})}
{dayTasks.length > 2 && (
<Badge variant="secondary" className="w-full justify-center text-sm sm:text-xs py-1 sm:py-0">
+{dayTasks.length - 2}
</Badge>
<HoverCard openDelay={0} closeDelay={100}>
<HoverCardTrigger asChild>
<Badge variant="secondary" className="w-full justify-center text-sm sm:text-xs py-1 sm:py-0 cursor-pointer hover:bg-secondary/80">
+{dayTasks.length - 2} more
</Badge>
</HoverCardTrigger>
<HoverCardContent className="w-72 p-0" side="top">
<div className="p-3 border-b bg-muted/30">
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
<span>{t('calendar.moreItems', { count: dayTasks.length - 2 })}</span>
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
</h4>
</div>
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
{dayTasks.slice(2).map(task => {
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
return (
<div
key={task.id}
className="p-2 border rounded hover:bg-accent cursor-pointer flex items-center justify-between group transition-colors"
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}
>
<div className="min-w-0 flex-1 mr-2">
<div className="text-xs font-medium truncate">{task.title}</div>
{taskLabel && (
<div className="flex items-center gap-1 mt-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: taskLabel.color }} />
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{taskLabel.name}</span>
</div>
)}
</div>
<Button size="icon" variant="ghost" className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}>
<Edit className="w-3 h-3" />
</Button>
</div>
)
})}
</div>
</HoverCardContent>
</HoverCard>
)}
{dayTasks.length === 0 && (
@@ -0,0 +1,94 @@
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
interface AuditLog {
id: string;
userId: string | null;
action: string;
entityType: string;
entityId: string | null;
source: string;
details: any;
createdAt: string;
}
export function AuditLogsTable() {
const { t } = useTranslation();
const { data: logs, isLoading, error } = useQuery<AuditLog[]>({
queryKey: ['/api/admin/audit-logs'],
});
if (isLoading) {
return <div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin" /></div>;
}
if (error) {
return <div className="p-8 text-center text-red-500">Failed to load audit logs. Please check server logs.</div>;
}
return (
<Card>
<CardHeader>
<CardTitle>Audit Logs</CardTitle>
<CardDescription>Track all system changes and AI actions.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Source</TableHead>
<TableHead>Action</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs?.map((log) => (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap">
{format(new Date(log.createdAt), "MMM d, HH:mm:ss")}
</TableCell>
<TableCell>
<Badge variant={log.source === 'AI' ? 'secondary' : 'outline'}>
{log.source}
</Badge>
</TableCell>
<TableCell className="font-medium">{log.action}</TableCell>
<TableCell>
{log.entityType}
{log.entityId && <span className="text-xs text-muted-foreground block truncate max-w-[100px]">{log.entityId}</span>}
</TableCell>
<TableCell className="text-sm text-muted-foreground w-1/3">
<pre className="whitespace-pre-wrap font-mono text-xs">
{JSON.stringify(log.details, null, 2)}
</pre>
</TableCell>
</TableRow>
))}
{(!logs || logs.length === 0) && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
No logs found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,112 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import { Loader2, Server, Save } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export function McpSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
mcp_enabled: false,
mcp_port: "5001" // Default to main app port unless specific override logic is added
});
const { data: settings, isLoading } = useQuery<any>({
queryKey: ['/api/admin/settings'],
});
useEffect(() => {
if (settings) {
setFormData({
mcp_enabled: settings.mcp_enabled === true,
mcp_port: settings.mcp_port || "5001"
});
}
}, [settings]);
const mutation = useMutation({
mutationFn: async (data: any) => {
const res = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to save settings");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
toast({ title: t('settings.saved', 'Settings saved') });
},
onError: () => {
toast({ title: t('settings.error', 'Failed to save settings'), variant: "destructive" });
}
});
const handleSave = () => {
mutation.mutate({
mcp_enabled: formData.mcp_enabled,
mcp_port: formData.mcp_port
});
};
if (isLoading) return <div className="flex justify-center p-4"><Loader2 className="animate-spin" /></div>;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
<CardTitle>MCP Server Configuration</CardTitle>
</div>
<CardDescription>
Configure the Model Context Protocol (MCP) server settings.
The MCP server runs on the same port as the application (/api/mcp).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="mcp_enabled" className="flex flex-col gap-1">
<span>Enable MCP Server</span>
<span className="font-normal text-xs text-muted-foreground">
Allow external AI tools to connect via MCP protocol.
</span>
</Label>
<Switch
id="mcp_enabled"
checked={formData.mcp_enabled}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, mcp_enabled: checked }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="mcp_port">Port (Informational)</Label>
<Input
id="mcp_port"
value={formData.mcp_port}
onChange={(e) => setFormData(prev => ({ ...prev, mcp_port: e.target.value }))}
placeholder="5001"
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Currently runs on the main application port. Separate port configuration coming soon.
</p>
</div>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
{t('settings.save', 'Save Changes')}
</Button>
</CardContent>
</Card>
);
}
+10 -7
View File
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"
const badgeVariants = cva(
// Whitespace-nowrap: Badges should never wrap.
"whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" +
" hover-elevate " ,
" hover-elevate ",
{
variants: {
variant: {
@@ -27,12 +27,15 @@ const badgeVariants = cva(
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => {
return (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
)
Badge.displayName = "Badge"
export { Badge, badgeVariants }
+12 -10
View File
@@ -13,16 +13,18 @@ const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Task } from '@shared/schema';
import { useTranslation } from 'react-i18next';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
const { t, i18n } = useTranslation();
const [permission, setPermission] = useState<NotificationPermission>('default');
const [enabled, setEnabled] = useState(false);
// Track notified tasks to prevent duplicate alerts in same session
const notifiedTasksRef = useRef<Set<string>>(new Set());
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
const isEnabled = localStorage.getItem('taskflow-notifications-enabled') === 'true';
setEnabled(isEnabled && Notification.permission === 'granted');
}
}, []);
const requestPermission = useCallback(async () => {
if (!('Notification' in window)) return false;
const result = await Notification.requestPermission();
setPermission(result);
if (result === 'granted') {
setEnabled(true);
localStorage.setItem('taskflow-notifications-enabled', 'true');
new Notification(t('notifications.enabledTitle'), {
body: t('notifications.enabledBody'),
// icon: '/favicon.ico' // Chrome sometimes blocks if icon 404
});
return true;
}
return false;
}, [t]);
const toggleEnabled = useCallback((value: boolean) => {
if (value && permission !== 'granted') {
requestPermission();
} else {
setEnabled(value);
localStorage.setItem('taskflow-notifications-enabled', String(value));
}
}, [permission, requestPermission]);
// Query tasks for polling
const { data: tasks } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
enabled: enabled && poll // Only poll if enabled AND polling is active
});
// Polling Logic
useEffect(() => {
if (!poll || !enabled || !tasks || permission !== 'granted') return;
const checkTasks = () => {
const now = new Date();
tasks.forEach(task => {
if (!task.dueDate || task.status === 'done' || notifiedTasksRef.current.has(task.id)) return;
const dueDate = new Date(task.dueDate);
const diffMs = dueDate.getTime() - now.getTime();
const diffMinutes = diffMs / (1000 * 60);
// Alert: Upcoming (15 min before)
if (diffMinutes > 0 && diffMinutes <= 15) {
sendNotification(task, 'upcoming');
notifiedTasksRef.current.add(task.id);
}
// Alert: Just Overdue (within last 1 min to catch it once)
// or purely check if overdue and not notified?
// Let's stick to "Just became overdue" or "Is overdue" but protect with Set
if (diffMinutes < 0) {
sendNotification(task, 'overdue');
notifiedTasksRef.current.add(task.id);
}
});
};
const sendNotification = (task: Task, type: 'upcoming' | 'overdue') => {
const title = type === 'upcoming'
? t('notifications.upcomingTitle', { task: task.title })
: t('notifications.overdueTitle', { task: task.title });
const body = type === 'upcoming'
? t('notifications.upcomingBody', { time: formatDistanceToNow(new Date(task.dueDate!), { locale: i18n.language === 'de' ? de : enUS }) })
: t('notifications.overdueBody');
new Notification(title, {
body,
// icon: '/favicon.ico',
tag: `task-${task.id}-${type}` // prevent duplicate native notifications
});
};
// Check immediately and then interval
checkTasks();
const interval = setInterval(checkTasks, 60000); // Check every minute
return () => clearInterval(interval);
}, [enabled, tasks, permission, t, i18n.language]);
return {
permission,
enabled,
requestPermission,
toggleEnabled
};
}
+84 -7
View File
@@ -3,6 +3,23 @@
"title": "TaskFlow",
"backToTasks": "Zurück zu Aufgaben"
},
"notifications": {
"title": "Benachrichtigungen",
"enableBrowser": "Browser-Benachrichtigungen aktivieren",
"description": "Erhalten Sie Warnungen für bevorstehende und überfällige Aufgaben.",
"enabledTitle": "Benachrichtigungen aktiviert",
"enabledBody": "Sie erhalten nun Warnungen für Ihre Aufgaben.",
"upcomingTitle": "Demnächst: {{task}}",
"upcomingBody": "Fällig in {{time}}",
"overdueTitle": "Überfällig: {{task}}",
"overdueBody": "Diese Aufgabe ist jetzt überfällig!"
},
"unscheduled": {
"title": "Ungeplante Aufgaben",
"empty": "Keine ungeplanten Aufgaben!",
"schedule": "Planen",
"tab": "Ungeplant"
},
"navigation": {
"tasks": "Aufgaben",
"focus": "Fokus",
@@ -73,7 +90,10 @@
"create": "Aufgabe erstellen",
"smartInputActive": "Smart Input Aktiv",
"energy": "Energie",
"minutesPlaceholder": "Minuten (optional)"
"minutesPlaceholder": "Minuten (optional)",
"duration": "Dauer",
"durationNone": "Keine",
"startDate": "Startdatum"
},
"taskDetails": {
"title": "Aufgabendetails",
@@ -93,6 +113,11 @@
"notesTab": "Notizen",
"timeTab": "Zeiterfassung",
"editTask": "Aufgabe bearbeiten",
"subtasksTab": "Teilaufgaben",
"subtasks": "Teilaufgaben",
"newSubtaskPlaceholder": "Titel der Teilaufgabe...",
"createSubtask": "Teilaufgabe hinzufügen",
"noSubtasks": "Noch keine Teilaufgaben",
"titleLabel": "Titel",
"titlePlaceholder": "Aufgabentitel",
"taskNotes": "Aufgabennotizen",
@@ -117,7 +142,21 @@
"timeTracked": "Erfasste Zeit",
"delete": "Aufgabe löschen",
"cancel": "Abbrechen",
"save": "Änderungen speichern"
"save": "Änderungen speichern",
"startDate": "Startdatum",
"estimatedDuration": "Geschätzte Dauer",
"subtaskOf": "Teilaufgabe von:",
"hierarchyRestriction": "Teilaufgaben können keine eigenen Teilaufgaben haben. Dies ist eine strikte 1-Level-Hierarchie.",
"blockedBy": "Blockiert durch",
"autoSchedule": "Auto-Planen",
"pickDate": "Datum wählen",
"scheduling": "Plane...",
"scheduledFor": "Geplant für {{date}}",
"scheduleFail": "Planung fehlgeschlagen: {{error}}",
"durationUnits": {
"m": "Min",
"h": "Std"
}
},
"taskCard": {
"play": "Timer starten",
@@ -255,9 +294,25 @@
},
"ai": {
"title": "KI-Assistent",
"assistant": "KI Assistent",
"welcome": "Wie kann ich Ihnen heute bei Ihren Aufgaben helfen?",
"welcomeTitle": "Wie kann ich Ihnen helfen?",
"welcomeDesc": "Ich kann Sie bei Ihren Aufgaben, Ihrer Planung und mehr unterstützen.",
"thinking": "Denke nach...",
"placeholder": "Stellen Sie eine Frage...",
"newChat": "Neuer Chat",
"newChatDefault": "Neuer Chat",
"startChat": "Neuen Chat starten",
"selectConversation": "Chat wechseln",
"recentChats": "Letzte Chats",
"noChats": "Keine letzten Chats",
"chatRenamed": "Chat umbenannt",
"chatDeleted": "Chat gelöscht",
"deleteConfirm": "Diesen Chat wirklich löschen?",
"noConversation": "Kein Chat ausgewählt",
"sendFailed": "Senden fehlgeschlagen",
"errorSending": "Fehler beim Senden",
"disclaimer": "KI kann Fehler machen. Überprüfen Sie wichtige Informationen.",
"error": "Ich bin auf einen Fehler gestoßen"
},
"userManagement": {
@@ -425,14 +480,36 @@
"daily_streak": "Täglicher Serien-Bonus",
"daily_clear_bonus": "Tagesziel-Bonus",
"goal_completed": "Ziel erreicht"
},
"rules": {
"title": "Gamification Regeln",
"xpSystem": "XP System",
"levelRequirements": "Level Anforderungen",
"actions": "Aktionen & Belohnungen",
"level": "Level {{level}}",
"xp": "{{xp}} XP",
"action": "Aktion",
"points": "Punkte",
"createTask": "Aufgabe erstellen",
"createSubtask": "Unteraufgabe erstellen",
"updateTask": "Aufgabe aktualisieren",
"completeTask": "Aufgabe erledigen (Pünktlich)",
"completeTaskLate": "Aufgabe erledigen (Verspätet)",
"aiAction": "AI Funktion nutzen",
"dailyStreak": "Täglicher Serienbonus"
}
},
"ranks": {
"novice": "Einsteiger",
"builder": "Baumeister",
"planner": "Planer",
"architect": "Architekt",
"master": "Meister"
"novice": "Neuling",
"apprentice": "Lehrling",
"journeyman": "Geselle",
"artisan": "Handwerker",
"expert": "Experte",
"master": "Meister",
"grandmaster": "Großmeister",
"virtuoso": "Virtuose",
"legend": "Legende",
"mythic": "Mythisch"
},
"leaderboardPage": {
"title": "Bestenliste",
+67 -6
View File
@@ -3,6 +3,23 @@
"title": "TaskFlow",
"backToTasks": "Back to Tasks"
},
"notifications": {
"title": "Notifications",
"enableBrowser": "Enable Browser Notifications",
"description": "Get alerted about upcoming and overdue tasks.",
"enabledTitle": "Notifications Enabled",
"enabledBody": "You will now receive alerts for your tasks.",
"upcomingTitle": "Upcoming: {{task}}",
"upcomingBody": "Due in {{time}}",
"overdueTitle": "Overdue: {{task}}",
"overdueBody": "This task is now overdue!"
},
"unscheduled": {
"title": "Unscheduled Tasks",
"empty": "No unscheduled tasks!",
"schedule": "Schedule",
"tab": "Unscheduled"
},
"navigation": {
"tasks": "Tasks",
"focus": "Focus",
@@ -73,7 +90,10 @@
"cancel": "Cancel",
"smartInputActive": "Smart Input Active",
"energy": "Energy",
"minutesPlaceholder": "Minutes (optional)"
"minutesPlaceholder": "Minutes (optional)",
"duration": "Duration",
"durationNone": "None",
"startDate": "Start Date"
},
"taskDetails": {
"title": "Task Details",
@@ -107,6 +127,11 @@
"notesTab": "Notes",
"timeTab": "Time Tracking",
"editTask": "Edit Task",
"subtasksTab": "Subtasks",
"subtasks": "Subtasks",
"newSubtaskPlaceholder": "New subtask title...",
"createSubtask": "Add Subtask",
"noSubtasks": "No subtasks yet",
"titleLabel": "Title",
"titlePlaceholder": "Task title",
"taskNotes": "Task Notes",
@@ -117,7 +142,21 @@
"addTimeEntry": "Add Time Entry",
"clock": "Clock",
"manual": "Manual",
"previouslyTrackedTime": "Previously tracked time"
"previouslyTrackedTime": "Previously tracked time",
"startDate": "Start Date",
"estimatedDuration": "Estimated Duration",
"subtaskOf": "Subtask of:",
"hierarchyRestriction": "Subtasks cannot have their own subtasks. This is a strict 1-level hierarchy.",
"blockedBy": "Blocked By",
"autoSchedule": "Auto-Schedule",
"pickDate": "Pick a date",
"scheduling": "Scheduling...",
"scheduledFor": "Scheduled for {{date}}",
"scheduleFail": "Scheduling failed: {{error}}",
"durationUnits": {
"m": "m",
"h": "h"
}
},
"taskCard": {
"play": "Start timer",
@@ -428,10 +467,15 @@
},
"ranks": {
"novice": "Novice",
"builder": "Builder",
"planner": "Planner",
"architect": "Architect",
"master": "Master"
"apprentice": "Apprentice",
"journeyman": "Journeyman",
"artisan": "Artisan",
"expert": "Expert",
"master": "Master",
"grandmaster": "Grandmaster",
"virtuoso": "Virtuoso",
"legend": "Legend",
"mythic": "Mythic"
},
"leaderboardPage": {
"title": "Leaderboard",
@@ -519,6 +563,23 @@
"daily_streak": "Daily Streak Bonus",
"daily_clear_bonus": "Daily Clear Bonus",
"goal_completed": "Goal Completed"
},
"rules": {
"title": "Gamification Rules",
"xpSystem": "XP System",
"levelRequirements": "Level Requirements",
"actions": "Actions & Rewards",
"level": "Level {{level}}",
"xp": "{{xp}} XP",
"action": "Action",
"points": "Points",
"createTask": "Create Task",
"createSubtask": "Create Subtask",
"updateTask": "Update Task",
"completeTask": "Complete Task (On Time)",
"completeTaskLate": "Complete Task (Late)",
"aiAction": "Use AI Feature",
"dailyStreak": "Daily Streak Bonus"
}
},
"rewards": {
+1 -47
View File
@@ -1,47 +1 @@
export const LEVEL_THRESHOLDS = [
0, // Level 1: 0-99
100, // Level 2: 100-249
250, // Level 3: 250-499
500, // Level 4: 500-999
1000, // Level 5: 1000-1999
2000, // Level 6: 2000-3499
3500, // Level 7: 3500-4999
5000, // Level 8: 5000-7499
7500, // Level 9: 7500-9999
10000 // Level 10: 10000+
];
export function getLevelFromXP(xp: number): number {
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
if (xp >= LEVEL_THRESHOLDS[i]) {
return i + 1;
}
}
return 1;
}
export function getNextLevelXP(level: number): number {
if (level >= LEVEL_THRESHOLDS.length) {
return LEVEL_THRESHOLDS[LEVEL_THRESHOLDS.length - 1] * 1.5; // Scale indefinitely
}
return LEVEL_THRESHOLDS[level];
}
export function getLevelProgress(xp: number): number {
const currentLevel = getLevelFromXP(xp);
const currentLevelStart = LEVEL_THRESHOLDS[currentLevel - 1];
const nextLevelStart = getNextLevelXP(currentLevel);
if (xp >= nextLevelStart) return 100;
const progress = ((xp - currentLevelStart) / (nextLevelStart - currentLevelStart)) * 100;
return Math.min(100, Math.max(0, progress));
}
export function getRankKey(level: number): string {
if (level >= 50) return 'master';
if (level >= 20) return 'architect';
if (level >= 10) return 'planner';
if (level >= 5) return 'builder';
return 'novice';
}
export * from '@shared/gamification';
+118 -2
View File
@@ -7,12 +7,12 @@ import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame } from 'lucide-react';
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame, Scroll, BookOpen, Hammer, Award, Medal, Star, Crown, Zap, Sparkles, Sun } from 'lucide-react';
import { useState, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Goal, Reward, User } from '@shared/schema';
import { getLevelFromXP, getRankKey } from '@/lib/gamification';
import { getLevelFromXP, getRankKey, LEVEL_THRESHOLDS } from '@/lib/gamification';
import { RewardCard } from '@/components/gamification/RewardCard';
import { useToast } from "@/hooks/use-toast";
@@ -174,6 +174,7 @@ export default function AchievementsPage({ user }: { user: User }) {
<TabsTrigger value="rewards">{t('rewards.shopTitle')}</TabsTrigger>
<TabsTrigger value="inventory">{t('achievements.inventory')}</TabsTrigger>
<TabsTrigger value="history">{t('achievements.history')}</TabsTrigger>
<TabsTrigger value="rules">{t('gamification.rules.title')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
@@ -517,6 +518,121 @@ export default function AchievementsPage({ user }: { user: User }) {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules">
<div className="grid gap-6 md:grid-cols-2">
{/* Level Requirements */}
<Card className="border-primary/10 shadow-md">
<CardHeader className="bg-muted/30 pb-4">
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-primary" />
<div>
<CardTitle>{t('gamification.rules.levelRequirements')}</CardTitle>
<CardDescription>{t('gamification.rules.xpSystem')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-border">
{LEVEL_THRESHOLDS.slice(0, 10).map((threshold, index) => {
const level = index + 1;
const rankKey = getRankKey(level);
const isCurrentLevel = getLevelFromXP(user.xp) === level;
// Visual Configuration for Ranks
const getRankStyle = (l: number) => {
if (l >= 10) return { icon: Sun, color: "text-rose-500", bg: "bg-rose-500/10", border: "border-rose-500/20" };
if (l >= 9) return { icon: Sparkles, color: "text-purple-500", bg: "bg-purple-500/10", border: "border-purple-500/20" };
if (l >= 8) return { icon: Zap, color: "text-violet-500", bg: "bg-violet-500/10", border: "border-violet-500/20" };
if (l >= 7) return { icon: Crown, color: "text-yellow-600", bg: "bg-yellow-600/10", border: "border-yellow-600/20" };
if (l >= 6) return { icon: Star, color: "text-yellow-500", bg: "bg-yellow-500/10", border: "border-yellow-500/20" };
if (l >= 5) return { icon: Medal, color: "text-orange-500", bg: "bg-orange-500/10", border: "border-orange-500/20" };
if (l >= 4) return { icon: Award, color: "text-blue-500", bg: "bg-blue-500/10", border: "border-blue-500/20" };
if (l >= 3) return { icon: Hammer, color: "text-cyan-500", bg: "bg-cyan-500/10", border: "border-cyan-500/20" };
if (l >= 2) return { icon: BookOpen, color: "text-green-500", bg: "bg-green-500/10", border: "border-green-500/20" };
return { icon: Scroll, color: "text-slate-500", bg: "bg-slate-500/10", border: "border-slate-500/20" };
};
const style = getRankStyle(level);
const RankIcon = style.icon;
return (
<div
key={index}
className={`flex items-center justify-between p-4 transition-all hover:bg-muted/50 ${isCurrentLevel ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`}
>
<div className="flex items-center gap-4">
<div className={`h-10 w-10 rounded-lg ${style.bg} ${style.color} flex items-center justify-center border ${style.border}`}>
<RankIcon className="h-5 w-5" />
</div>
<div>
<div className={`font-semibold ${isCurrentLevel ? 'text-primary' : ''}`}>
{t(`ranks.${rankKey}`)}
{isCurrentLevel && <span className="ml-2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">{t('gamification.level', { level })}</span>}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
{t('gamification.rules.level', { level })}
</div>
</div>
</div>
<div className="text-right">
<div className="font-mono font-medium text-sm">
{t('gamification.rules.xp', { xp: threshold })}
</div>
{isCurrentLevel && (
<div className="text-[10px] text-primary font-medium mt-0.5">
Current
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* XP Rewards */}
<Card className="border-primary/10 shadow-md h-fit">
<CardHeader className="bg-muted/30 pb-4">
<div className="flex items-center gap-2">
<Target className="h-5 w-5 text-green-500" />
<div>
<CardTitle>{t('gamification.rules.actions')}</CardTitle>
<CardDescription>{t('gamification.rules.xpSystem')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-border">
{[
{ action: 'createTask', points: 10, icon: Plus, color: 'text-blue-500', bg: 'bg-blue-500/10' },
{ action: 'createSubtask', points: 5, icon: Plus, color: 'text-cyan-500', bg: 'bg-cyan-500/10' },
{ action: 'updateTask', points: 2, icon: CheckCircle2, color: 'text-slate-500', bg: 'bg-slate-500/10' },
{ action: 'completeTask', points: 50, icon: CheckCircle2, color: 'text-green-500', bg: 'bg-green-500/10' },
{ action: 'completeTaskLate', points: 20, icon: CheckCircle2, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
{ action: 'aiAction', points: 5, icon: Sparkles, color: 'text-purple-500', bg: 'bg-purple-500/10' },
{ action: 'dailyStreak', points: 100, icon: Flame, color: 'text-orange-500', bg: 'bg-orange-500/10' },
].map((item, index) => {
const ActionIcon = item.icon;
return (
<div key={index} className="flex items-center justify-between p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-md ${item.bg} ${item.color}`}>
<ActionIcon className="h-4 w-4" />
</div>
<span className="font-medium text-sm">{t(`gamification.rules.${item.action}`)}</span>
</div>
<div className="font-bold text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded text-xs">
+{item.points} XP
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs >
</div >
);
+26 -3
View File
@@ -3,8 +3,11 @@ import { SMTPSettingsCard } from "@/components/admin/SMTPSettingsCard";
import { AiSettingsCard } from "@/components/admin/AiSettingsCard";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, Shield, Bot, FileText, Settings } from "lucide-react";
import { useLocation } from "wouter";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AuditLogsTable } from "@/components/admin/AuditLogsTable";
import { McpSettingsCard } from "@/components/admin/McpSettingsCard";
export default function AdminSettings() {
const { t } = useTranslation();
@@ -23,8 +26,28 @@ export default function AdminSettings() {
</div>
<div className="grid gap-6">
<AiSettingsCard />
<SMTPSettingsCard />
<Tabs defaultValue="settings" className="space-y-4">
<TabsList>
<TabsTrigger value="settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
General & AI
</TabsTrigger>
<TabsTrigger value="audit" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Audit Logs
</TabsTrigger>
</TabsList>
<TabsContent value="settings" className="space-y-6">
<AiSettingsCard />
<McpSettingsCard />
<SMTPSettingsCard />
</TabsContent>
<TabsContent value="audit">
<AuditLogsTable />
</TabsContent>
</Tabs>
</div>
</div>
);
+652
View File
@@ -0,0 +1,652 @@
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Plus, MessageSquare, Trash2, Send, Bot, User as UserIcon, Loader2, Sparkles, AlertCircle, Pencil, Check, ChevronDown, History, X } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
import { de } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// Types
type Conversation = {
id: string;
title: string;
updatedAt: string;
};
type Message = {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: string;
};
const TypewriterMessage = ({ content, onComplete }: { content: string, onComplete?: () => void }) => {
const [displayedContent, setDisplayedContent] = useState("");
const indexRef = useRef(0);
// Optimization: Render faster
const SPEED_MS = 1;
const CHARS_PER_TICK = 5;
useEffect(() => {
indexRef.current = 0;
setDisplayedContent("");
const interval = setInterval(() => {
setDisplayedContent((prev) => {
if (indexRef.current >= content.length) {
clearInterval(interval);
onComplete?.();
return content;
}
const nextSlice = content.slice(indexRef.current, indexRef.current + CHARS_PER_TICK);
indexRef.current += CHARS_PER_TICK;
return prev + nextSlice;
});
}, SPEED_MS);
return () => clearInterval(interval);
}, [content]);
return (
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayedContent}</ReactMarkdown>
</div>
);
};
export default function AiChatPage() {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
// 'new' indicates a temporary draft state
const [selectedConversationId, setSelectedConversationId] = useState<string | 'new' | null>(null);
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const [latestMessageId, setLatestMessageId] = useState<string | null>(null);
// Rename state
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editMessageContent, setEditMessageContent] = useState("");
// Fetch Conversations
const { data: conversations, isLoading: isLoadingConvs } = useQuery<Conversation[]>({
queryKey: ["/api/ai/conversations"],
});
// Select latest conversation on load if none selected
useEffect(() => {
if (conversations && conversations.length > 0 && !selectedConversationId) {
setSelectedConversationId(conversations[0].id);
}
}, [conversations, selectedConversationId]);
// Fetch Messages for selected conversation (disabled if 'new')
const { data: messages, isLoading: isLoadingMessages } = useQuery<Message[]>({
queryKey: ["/api/ai/conversations", selectedConversationId, "messages"],
enabled: !!selectedConversationId && selectedConversationId !== 'new',
});
const scrollToBottom = () => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
}
}
useLayoutEffect(() => {
scrollToBottom();
}, [messages, selectedConversationId, latestMessageId]);
// Mutations
const createConversationMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/ai/conversations", { title: t('ai.newChatDefault', 'New Chat') });
if (!res.ok) throw new Error("Failed to create conversation");
return res.json();
},
onSuccess: (newConv) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
// Do NOT set selected ID here, handled in handleSend to avoid race conditions or double sets
},
});
const deleteConversationMutation = useMutation({
mutationFn: async (id: string) => {
await apiRequest("DELETE", `/api/ai/conversations/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
if (selectedConversationId !== 'new') {
setSelectedConversationId(null);
}
toast({ title: t('ai.chatDeleted', 'Chat deleted') });
},
});
const renameConversationMutation = useMutation({
mutationFn: async ({ id, title }: { id: string; title: string }) => {
await apiRequest("PATCH", `/api/ai/conversations/${id}`, { title });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
setEditingId(null);
toast({ title: t('ai.chatRenamed', 'Chat renamed') });
},
});
const generateTitleMutation = useMutation({
mutationFn: async ({ messages }: { conversationId: string, messages: any[] }) => {
const res = await apiRequest("POST", "/api/ai/generate-title", { messages });
if (!res.ok) throw new Error("Failed to generate title");
return res.json();
},
onSuccess: (data, vars) => {
renameConversationMutation.mutate({ id: vars.conversationId, title: data.title });
}
});
const sendMessageMutation = useMutation({
mutationFn: async ({ conversationId, content }: { conversationId: string, content: string }) => {
const res = await apiRequest("POST", "/api/ai/chat", {
conversationId,
content,
clientTime: new Date().toLocaleString()
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || t('ai.sendFailed', 'Failed to send message'));
}
return res.json();
},
onMutate: async ({ conversationId, content }) => {
await queryClient.cancelQueries({ queryKey: ["/api/ai/conversations", conversationId, "messages"] });
const previousMessages = queryClient.getQueryData<Message[]>(["/api/ai/conversations", conversationId, "messages"]);
queryClient.setQueryData(["/api/ai/conversations", conversationId, "messages"], (old: Message[] = []) => [
...old,
{ id: 'temp-' + Date.now(), role: 'user', content: content, createdAt: new Date().toISOString() }
]);
const previousInput = input;
setInput("");
return { previousMessages, newContent: previousInput };
},
onError: (err: any, vars, context: any) => {
if (context?.previousMessages) {
queryClient.setQueryData(["/api/ai/conversations", vars.conversationId, "messages"], context.previousMessages);
}
if (context?.newContent) {
setInput(context.newContent);
}
toast({
title: t('ai.errorSending', 'Error sending message'),
description: err.message,
variant: "destructive"
});
},
onSuccess: (botMessage, vars, context) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", vars.conversationId, "messages"] });
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
queryClient.invalidateQueries({ queryKey: ["/api/goals"] });
setLatestMessageId(botMessage.id);
// Auto-title if this was the first exchange (previous messages empty or undefined)
if (!context?.previousMessages || context.previousMessages.length === 0) {
const messagesForTitle = [
{ role: 'user', content: vars.content },
{ role: 'assistant', content: botMessage.content }
];
generateTitleMutation.mutate({ conversationId: vars.conversationId, messages: messagesForTitle });
}
},
});
const editMessageMutation = useMutation({
mutationFn: async ({ id, content }: { id: string, content: string }) => {
const res = await apiRequest("PUT", `/api/ai/chat/${id}`, {
content,
clientTime: new Date().toLocaleString()
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || t('ai.editFailed', 'Failed to edit message'));
}
return res.json();
},
onSuccess: (botMessage, vars) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", selectedConversationId, "messages"] });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); // In case tasks were updated during regen
setEditingMessageId(null);
setLatestMessageId(botMessage.id);
toast({ title: t('ai.messageEdited', 'Message edited & answer regenerated') });
},
onError: (err: any) => {
toast({
title: t('ai.errorEditing', 'Error editing message'),
description: err.message,
variant: "destructive"
});
}
});
const handleSend = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending) return;
let conversationId = selectedConversationId;
// If drafting a new chat, create it now
if (conversationId === 'new') {
try {
const newConv = await createConversationMutation.mutateAsync();
conversationId = newConv.id;
setSelectedConversationId(conversationId);
} catch (error) {
toast({
title: t('ai.error', 'Error'),
description: t('ai.createFailed', 'Failed to create new conversation'),
variant: "destructive"
});
return;
}
}
if (conversationId && conversationId !== 'new') {
sendMessageMutation.mutate({ conversationId, content: input });
}
};
const handleCreateNew = () => {
setSelectedConversationId('new');
setInput("");
// No mutation call here.
};
const handleDelete = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setDeleteId(id);
};
const confirmDelete = () => {
if (deleteId) {
deleteConversationMutation.mutate(deleteId);
setDeleteId(null);
}
};
const startEditing = (conv: Conversation, e: React.MouseEvent) => {
e.stopPropagation();
setEditingId(conv.id);
setEditName(conv.title);
};
const saveName = () => {
if (!editingId || !editName.trim()) {
setEditingId(null);
return;
}
renameConversationMutation.mutate({ id: editingId, title: editName });
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') saveName();
if (e.key === 'Escape') setEditingId(null);
e.stopPropagation(); // prevent closing dropdown
};
const handleStartEditMessage = (msg: Message) => {
setEditingMessageId(msg.id);
setEditMessageContent(msg.content);
};
const handleSaveEditMessage = () => {
if (!editingMessageId || !editMessageContent.trim()) return;
editMessageMutation.mutate({ id: editingMessageId, content: editMessageContent });
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowUp' && !input && !editingMessageId && messages) {
e.preventDefault();
// Find last user message
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (lastUserMsg) {
handleStartEditMessage(lastUserMsg);
}
}
};
const currentTitle = selectedConversationId === 'new'
? t('ai.newChat', 'New Chat')
: conversations?.find(c => c.id === selectedConversationId)?.title || t('ai.assistant', 'AI Assistant');
return (
<div className="flex flex-col h-[calc(100vh-4rem)] -m-4 md:-m-6 bg-background relative">
{/* Header / Top Navigation Bar */}
<div className="h-16 border-b flex items-center px-4 md:px-6 justify-between shrink-0 bg-background/80 backdrop-blur-sm z-30 sticky top-0 shadow-sm">
{/* Left: Branding, History & Current Title */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Sparkles className="w-4 h-4 text-violet-500" />
</div>
{/* Logic: If editing current title, show input. Else show Dropdown. */}
{editingId === selectedConversationId ? (
<div className="flex items-center gap-2 flex-1 max-w-[300px]">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={saveName}
autoFocus
className="h-9"
/>
<Button size="icon" variant="ghost" onClick={() => setEditingId(null)}>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex items-center gap-2 min-w-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-auto py-2 px-3 font-semibold text-lg flex gap-2 items-center hover:bg-muted/50 transition-colors rounded-lg max-w-full">
<div className="flex flex-col items-start leading-none min-w-0">
<span className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent text-lg truncate max-w-[200px] md:max-w-[400px]">
{currentTitle}
</span>
<span className="text-[10px] text-muted-foreground font-normal flex items-center gap-1">
{t('ai.selectConversation', 'Switch Chat')} <ChevronDown className="w-3 h-3" />
</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px] max-h-[500px] overflow-y-auto">
<DropdownMenuLabel>{t('ai.recentChats', 'Recent Chats')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{conversations?.map(conv => (
<DropdownMenuItem
key={conv.id}
onClick={() => setSelectedConversationId(conv.id)}
className={cn("flex justify-between items-center cursor-pointer py-3 group", selectedConversationId === conv.id ? "bg-muted" : "")}
>
{editingId === conv.id ? (
<div className="flex items-center gap-2 flex-1 onClick-stop">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onBlur={saveName}
autoFocus
className="h-8 text-xs"
/>
</div>
) : (
<>
<div className="flex items-center gap-2 overflow-hidden flex-1">
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="truncate font-medium">{conv.title}</span>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground"
onClick={(e) => startEditing(conv, e)}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={(e) => handleDelete(conv.id, e)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</>
)}
</DropdownMenuItem>
))}
{(!conversations || conversations.length === 0) && (
<div className="p-4 text-center text-sm text-muted-foreground">
{t('ai.noChats', 'No recent chats')}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Edit Button: Only show if we have a REAL selected conversation (not 'new') */}
{selectedConversationId && selectedConversationId !== 'new' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-muted"
onClick={(e) => {
const conv = conversations?.find(c => c.id === selectedConversationId);
if (conv) startEditing(conv, e);
}}
>
<Pencil className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
{/* Right: New Chat Action */}
<Button onClick={handleCreateNew} size="sm" className="gap-2 shadow-sm rounded-full shrink-0">
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('ai.newChat', 'New Chat')}</span>
</Button>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0 relative overflow-hidden">
{!selectedConversationId ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8 text-center animate-in fade-in zoom-in duration-300">
<div className="w-24 h-24 bg-gradient-to-br from-violet-500/10 to-indigo-500/10 rounded-full flex items-center justify-center mb-6 animate-pulse">
<Sparkles className="w-12 h-12 text-violet-500" />
</div>
<h3 className="text-3xl font-bold mb-3 tracking-tight text-foreground">
{t('ai.welcomeTitle', 'How can I help you?')}
</h3>
<p className="max-w-md mb-8 text-lg opacity-80 leading-relaxed">
{t('ai.welcomeDesc', 'I can assist you with your tasks, planning, and more.')}
</p>
<Button size="lg" onClick={handleCreateNew} className="rounded-full px-8 h-12 text-base shadow-lg hover:shadow-xl transition-all">
{t('ai.startChat', 'Start a New Chat')}
</Button>
</div>
) : (
<>
{/* Messages: Render if not 'new', or if 'new' show empty */}
<ScrollArea className="flex-1 px-4 md:px-8 pt-4 md:pt-8 pb-2">
<div className="space-y-8 max-w-3xl mx-auto pb-4">
{selectedConversationId !== 'new' && messages?.map((msg) => (
<div key={msg.id} className={cn("flex gap-4 animate-in slide-in-from-bottom-2 duration-300 group", msg.role === 'user' ? "flex-row-reverse" : "flex-row")}>
<div className={cn(
"w-9 h-9 rounded-full flex items-center justify-center shrink-0 shadow-sm transition-transform group-hover:scale-105",
msg.role === 'user' ? "bg-primary text-primary-foreground" : "bg-gradient-to-br from-violet-500 to-indigo-600 text-white"
)}>
{msg.role === 'user' ? <UserIcon className="w-5 h-5" /> : <Sparkles className="w-4 h-4" />}
</div>
<div className={cn(
"flex flex-col gap-1 min-w-0 max-w-[85%]",
msg.role === 'user' ? "items-end" : "items-start"
)}>
<div className={cn(
"rounded-2xl px-6 py-4 text-sm shadow-sm leading-relaxed relative",
editingMessageId === msg.id ? "w-full min-w-[300px] border-primary ring-2 ring-primary/20" : "",
msg.role === 'user'
? "bg-primary text-primary-foreground rounded-tr-none"
: "bg-background border shadow-md text-foreground rounded-tl-none"
)}>
{editingMessageId === msg.id ? (
<div className="flex flex-col gap-2 w-full">
<textarea
value={editMessageContent}
onChange={(e) => setEditMessageContent(e.target.value)}
className="w-full bg-transparent border-0 focus:ring-0 p-0 text-inherit resize-none min-h-[60px]"
autoFocus
/>
<div className="flex justify-end gap-2 mt-2">
<Button size="sm" variant="secondary" onClick={() => setEditingMessageId(null)} className="h-7 text-xs bg-white/20 hover:bg-white/30 text-inherit border-0">
{t('common.cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={handleSaveEditMessage} disabled={editMessageMutation.isPending} className="h-7 text-xs bg-white text-primary hover:bg-white/90">
{editMessageMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.save', 'Regenerate')}
</Button>
</div>
</div>
) : (
<>
{/* Edit Button for User Messages */}
{msg.role === 'user' && !editingMessageId && (
<button
onClick={() => handleStartEditMessage(msg)}
className="absolute -left-8 top-1/2 -translate-y-1/2 p-1.5 rounded-full bg-muted/80 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted hover:text-foreground"
title={t('common.edit', 'Edit')}
>
<Pencil className="w-3 h-3" />
</button>
)}
{msg.role === 'assistant' ? (
msg.id === latestMessageId ? (
<TypewriterMessage content={msg.content} onComplete={() => scrollToBottom()} />
) : (
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
)
) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}
</>
)}
</div>
{msg.createdAt && (
<span className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-60 transition-opacity px-2">
{formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true, locale: i18n.language?.startsWith('de') ? de : undefined })}
</span>
)}
</div>
</div>
))}
{(sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending) && (
<div className="flex gap-4 animate-pulse">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center shrink-0 opacity-80">
<Sparkles className="w-4 h-4 text-white" />
</div>
<div className="bg-background border rounded-2xl rounded-tl-none px-6 py-4 flex items-center gap-2 shadow-sm">
<span className="flex gap-1.5">
<span className="w-2 h-2 rounded-full bg-violet-500 animate-[bounce_1.4s_infinite] [animation-delay:-0.32s]"></span>
<span className="w-2 h-2 rounded-full bg-indigo-500 animate-[bounce_1.4s_infinite] [animation-delay:-0.16s]"></span>
<span className="w-2 h-2 rounded-full bg-blue-500 animate-[bounce_1.4s_infinite]"></span>
</span>
<span className="text-xs text-muted-foreground font-medium ml-2">{editMessageMutation.isPending ? t('ai.regenerating', 'Regenerating...') : t('ai.thinking', 'Thinking...')}</span>
</div>
</div>
)}
<div ref={scrollRef} className="h-px" />
</div>
</ScrollArea>
{/* Input Area */}
<div className="pt-2 px-4 md:px-6 md:pt-2 pb-6 bg-background/50 backdrop-blur-sm shrink-0">
<div className="max-w-3xl mx-auto space-y-3">
<form
className="relative flex items-center gap-2 bg-muted/40 border rounded-2xl px-4 py-2.5 shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all hover:bg-muted/60"
onSubmit={handleSend}
>
<Sparkles className="w-5 h-5 text-muted-foreground/70" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={t('ai.placeholder', 'Ask me anything about your tasks...')}
className="flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0 px-3 h-11 text-base placeholder:text-muted-foreground/60"
autoFocus
disabled={sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
/>
<Button
type="submit"
disabled={!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
size="icon"
className={cn(
"h-9 w-9 rounded-xl shrink-0 transition-all duration-300",
input.trim()
? "opacity-100 scale-100 bg-primary text-primary-foreground shadow-md hover:scale-105"
: "opacity-0 scale-75"
)}
>
<Send className="w-4 h-4" />
</Button>
</form>
<div className="text-center">
<span className="text-[11px] text-muted-foreground/60 flex items-center justify-center gap-1.5">
<AlertCircle className="w-3 h-3" />
{t('ai.disclaimer', 'AI can make mistakes. Verify important information.')}
</span>
</div>
</div>
</div>
</>
)}
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('ai.deleteConfirmTitle', 'Delete Chat')}</AlertDialogTitle>
<AlertDialogDescription>
{t('ai.deleteConfirmDesc', 'Are you sure you want to delete this conversation? This cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{t('common.delete', 'Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useQuery } from '@tanstack/react-query';
import { Task, Label, User } from '@shared/schema';
import TaskCard from '@/components/TaskCard';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { CalendarOff } from 'lucide-react';
interface UnscheduledTasksProps {
user: User;
onToggleCompletion: (taskId: string, currentStatus: string) => void;
onDelete: (taskId: string) => void;
onUpdate: (taskId: string, updates: Partial<Task>) => void;
onSelect: (task: Task) => void;
}
export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelete, onUpdate, onSelect }: UnscheduledTasksProps) {
const { t } = useTranslation();
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
select: (data) => data
.filter(task => !task.dueDate && task.status !== 'done')
.map(task => ({
...task,
dueDate: task.dueDate ? new Date(task.dueDate) : null
}))
});
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels']
});
// We need a dummy toggleTaskCompletion and onDelete for display purposes,
// or we pass the real ones if we lift state up.
// Ideally we use the mutations directly in TaskCard or pass them from App.tsx context.
// For now, let's assume TaskCard handles some, but it takes props.
// Checking TaskCard props... it needs `onToggleCompletion`, `onDelete`, `onUpdate`, `onSelect`.
// This suggests I should wrap this page in App.tsx or pass these handlers down.
// Refactoring: I'll create this component but it might need to accept props from App.tsx
// if I want it to be fully functional without duplicating handler logic.
// Alternatively, I can implement the handlers here using mutations.
return (
<div className="p-6 space-y-6 overflow-y-auto h-full">
<div className="flex items-center gap-3">
<div className="p-3 bg-muted rounded-xl">
<CalendarOff className="w-6 h-6 text-muted-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold">{t('unscheduled.title')}</h1>
<p className="text-muted-foreground">{tasks.length} {t('taskList.taskCount', { count: tasks.length })}</p>
</div>
</div>
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[50vh] text-center space-y-4">
<div className="p-6 bg-muted/30 rounded-full">
<CalendarOff className="w-12 h-12 text-muted-foreground/50" />
</div>
<div>
<h3 className="text-lg font-medium">{t('unscheduled.empty')}</h3>
<p className="text-muted-foreground max-w-sm mx-auto mt-2">
{t('taskList.noUnscheduledTasks')}
</p>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={(status) => onUpdate(task.id, { status })}
onDelete={() => onDelete(task.id)}
onUpdate={(updates) => onUpdate(task.id, updates)}
onEdit={() => onSelect(task)}
/>
))}
</div>
)}
</div>
);
}
+50 -1
View File
@@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon } from 'lucide-react';
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { ShareAccessModal } from '@/components/ShareAccessModal';
import { ChangePasswordModal } from '@/components/ChangePasswordModal';
@@ -17,6 +17,39 @@ import { User } from '@shared/schema';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useLocation } from "wouter";
import { useNotifications } from '@/hooks/use-notifications';
const NotificationSettings = () => {
const { t } = useTranslation();
const { enabled, toggleEnabled, permission, requestPermission } = useNotifications({ poll: false });
const handleToggle = (checked: boolean) => {
if (checked && permission !== 'granted') {
requestPermission();
} else {
toggleEnabled(checked);
}
};
return (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="font-medium">{t('notifications.enableBrowser')}</p>
<p className="text-sm text-muted-foreground">
{permission === 'denied' ?
<span className="text-destructive">Permission denied by browser. Please reset site permissions.</span> :
t('notifications.description')
}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={permission === 'denied'}
/>
</div>
);
};
interface SettingsProps {
onNavigateToTemplates: () => void;
@@ -228,6 +261,22 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</>
)}
{/* Notifications Settings */}
<Card data-testid="card-notifications">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
{t('notifications.title')}
</CardTitle>
<CardDescription>
{t('notifications.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<NotificationSettings />
</CardContent>
</Card>
{/* Social & Privacy */}
<Card>
<CardHeader>
+61
View File
@@ -0,0 +1,61 @@
version: '3.9'
services:
# PostgreSQL-Datenbank
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- /mnt/DockerMount/task-manager/data:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER}" ]
interval: 60s
timeout: 10s
retries: 10
networks:
- taskflow-network
# Backend und Frontend Anwendung
app:
image: registry.local.nothaft.cloud/taskflow:latest
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
PORT: 5000
SESSION_SECRET: ${SESSION_SECRET}
depends_on:
- postgres
healthcheck:
test: [ "CMD", "node", "-e", "require('http').get('http://localhost:5000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- taskflow-network
- proxy
restart: unless-stopped
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.task.rule=Host(`task.local.nothaft.cloud`) || Host(`task.nothaft.cloud`)"
- "traefik.http.routers.task.entrypoints=https"
- "traefik.http.routers.task.tls.certresolver=dns"
- "traefik.http.services.task.loadbalancer.server.port=5000"
- "traefik.docker.network=proxy"
- "homepage.group=Shared Services"
- "homepage.name=Task Manager"
- "homepage.icon=mdi-tasks"
- "homepage.href=https://task.local.nothaft.cloud/"
- "homepage.description=Task Manager"
networks:
proxy:
external: true
taskflow-network:
+33
View File
@@ -0,0 +1,33 @@
# Audit Logging Guidelines
## 1. Requirement
**Every state modification must be logged.**
Any action that creates, updates, or deletes data in the system must generate an entry in the `audit_logs` table. This applies to:
- User-initiated actions (API requests).
- AI-initiated actions.
- System-background actions (if impactful).
## 2. Implementation Mechanism
Use the `storage.createAuditLog` method available in `server/routes.ts` (via `storage` import) or `server/ai.ts`.
```typescript
await storage.createAuditLog({
userId: number; // The ID of the user performing the action (or context user).
action: string; // CREATE, UPDATE, DELETE, PURCHASE, SHARE, UNSHARE, etc.
entityType: string; // TASK, USER, LABEL, GOAL, REWARD, SYSTEM_SETTINGS, CONVERSATION.
entityId: number | string | null; // ID of the modified entity.
details: any; // JSON object with relevant details (e.g., specific field updates).
source: string; // 'USER' (API/UI), 'AI' (Agent), 'SYSTEM'.
});
```
## 3. Best Practices
- **Do not** log sensitive data (passwords, tokens) in `details`.
- **Do** log high-level "diffs" or summary of changes (e.g., `{ status: 'done' }`).
- **Always** ensure `userId` is accurate. If it's a system action, use a designated system user ID or handle nullable logic if allowed (currently schema expects generic link but strongly typed).
## 4. Checklist for New Features
- [ ] Schema update (if new entity).
- [ ] API Route implementation.
- [ ] `createAuditLog` call added to SUCCESS path of route.
- [ ] `createAuditLog` call added to AI tool handler (if applicable).
+19
View File
@@ -6,12 +6,31 @@ CREATE TABLE IF NOT EXISTS system_settings (
updated_at timestamp DEFAULT now()
);
-- AI Chat Tables
CREATE TABLE IF NOT EXISTS conversations (
id varchar(255) PRIMARY KEY DEFAULT gen_random_uuid(),
user_id varchar(255) NOT NULL REFERENCES users(id),
title text NOT NULL,
created_at timestamp DEFAULT now(),
updated_at timestamp DEFAULT now()
);
CREATE TABLE IF NOT EXISTS messages (
id varchar(255) PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id varchar(255) NOT NULL REFERENCES conversations(id),
role text NOT NULL,
content text NOT NULL,
created_at timestamp DEFAULT now()
);
-- Truncate users to avoid constraint issues and reset for First Run
TRUNCATE TABLE users CASCADE;
TRUNCATE TABLE user_rewards CASCADE;
TRUNCATE TABLE xp_events CASCADE;
TRUNCATE TABLE goals CASCADE;
TRUNCATE TABLE notes CASCADE;
TRUNCATE TABLE conversations CASCADE;
TRUNCATE TABLE messages CASCADE;
-- Add new columns to users
ALTER TABLE users ADD COLUMN IF NOT EXISTS email text NOT NULL UNIQUE;
+1469 -2
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -76,8 +76,10 @@
"react-hook-form": "^7.55.0",
"react-i18next": "^16.1.6",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tough-cookie": "^6.0.0",
+48 -1
View File
@@ -64,6 +64,9 @@ This document outlines the strategic plan for evolving TaskFlow into a multi-use
- **Profile Updates**: Show Email instead of ID, allow Email changes.
- [ ] **Infrastructure**:
- [x] Reverse Proxy Compatibility: Ensure app functions correctly behind Nginx/Traefik (Headers, WebSockets, Base URL).
- [x] **Audit Logging**:
- [x] Track all task modifications per user.
- [x] Explicitly distinguish between changes made by a human User vs. the AI Agent.
## 🛠️ Phase 6: UI/UX & Quality Assurance (Current)
- [x] **Localization Audit**:
@@ -85,7 +88,51 @@ This document outlines the strategic plan for evolving TaskFlow into a multi-use
- [x] "What should I do next?" / "Break down this project" (Interactive).
- [x] Natural language task operations.
- [x] **AI Agents**: Autonomous sub-agents handling email sorting and scheduling (via AI Chat context).
- [ ] **AI Knowledge Base**:
- Enable AI to read closed/completed tasks to learn from history.
- Analyze notes and past items to provide context-aware suggestions (Knowledge Platform).
- [ ] **Advanced Task Planning**:
- **Time Estimation**: Optional "rough" time estimates per task (e.g., "2 hours", "half a day").
- **Multi-day / Subtasks**:
- Support for tasks spanning multiple days.
- Ability to break down tasks into subtasks directly via Chat Agent.
- **AI Integration**: AI can read/set estimates and handle multi-day scheduling.
- **Localization**: Full translation support for time units and planning interface.
## 📱 Phase 8: Long-term Platform Vision
## 🔔 Phase 8: Notifications & Task Hygiene (New - User Requested)
### Smart Notifications
- [x] **Browser Notifications**:
- [x] Upcoming tasks for the day.
- [x] Overdue tasks (iOS + Browser).
- [x] Unscheduled tasks (iOS + Browser).
- [x] **iOS Integration (PWA/Native)**:
- [x] Push notifications for upcoming tasks (when installed).
- [x] Push notifications for overdue tasks.
- [x] Support for "not scheduled" task alerts.
### Task Cleanup
- [x] **Unscheduled Tasks List**: Dedicated view for tasks without due dates.
- [x] **Scheduling Reminder**:
- [x] Notification or In-App Popup preventing user from leaving items unscheduled indefinitely.
## 📱 Phase 9: Long-term Platform Vision
- [ ] **Mobile Native App** (React Native/Expo).
- [ ] **Desktop App** (Electron/Tauri).
## 🤖 Phase 10: AI Workflows & Task Hygiene (New - User Requested)
### Follow-up Task System
- [ ] **AI Analysis**: Automatically detect completed tasks requiring follow-up (e.g., "Email sent" -> "Check for reply").
- [ ] **Smart Prompts**: Pop-up on completion asking if a follow-up is needed.
- [ ] **Manual Action**: "Create Follow-up" option in Task Card menu (Three dots).
### Morning/Evening Routine Mode
- [ ] **Configuration**:
- [ ] Settings to enable/disable.
- [ ] Set Timezone, Morning start time (e.g., 9am), Evening start time (e.g., 10pm).
- [ ] **Morning Overview**:
- [ ] Restricted view showing only early morning tasks.
- [ ] "Plan the Day" button to unlock full functionality.
- [ ] **Evening Reflection**:
- [ ] Read-only view of completed tasks (Mood booster).
- [ ] Simple actions: "Mark as Done" for remaining items or "Move to Tomorrow".
- [ ] Blocking: Prevent adding new distractions after hours.
+6 -2
View File
@@ -25,7 +25,8 @@ async function run() {
await storage.updateUser(user.id, {
password: hashedPassword,
role: 'admin',
isActive: true
isActive: true,
aiEnabled: true
});
console.log("✅ Admin user updated successfully.");
} else {
@@ -38,7 +39,10 @@ async function run() {
username: "admin",
password: hashedPassword,
role: "admin",
isActive: true
isActive: true,
aiEnabled: true,
showOnLeaderboard: false,
isSearchable: false
});
console.log("✅ Admin user updated/renamed successfully.");
} else {
+512 -34
View File
@@ -1,5 +1,5 @@
import { IStorage } from "./storage";
import { User } from "../shared/schema";
import { User, InsertTask, insertTaskSchema } from "../shared/schema";
interface ChatMessage {
role: "system" | "user" | "assistant";
@@ -26,7 +26,33 @@ Current Context:
${context}
Answer the user's questions based on this context. Be concise, helpful, and friendly.
If needed, suggest they create tasks or manage their schedule (you cannot perform actions yet, only advise).
### 🛠 AVAILABLE TOOLS
You can create, search, update, and delete tasks using the provided tools.
**1. Task Management**
- **Create**: Use 'createTask'. Title is required.
- *Subtasks*: To break a task down, create new tasks with 'parentTaskId' set to the main task's ID.
- *Planning*: You can set 'estimatedDuration' (minutes) and 'startDate'.
- **Update/Delete**: First SEARCH for the task ID using 'searchTasks', then use 'updateTask' or 'deleteTask'.
- **Labels**: Use 'getLabels' to see available tags.
**2. 🧠 Smart Planning & Scheduling**
- **"Break this down"**: If a user asks to break down a project, create strictly hierarchical subtasks using 'createTask' with 'parentTaskId'.
- **"Find time for this"**: Use 'scheduleTask'. This tool automatically finds free slots in the user's calendar (based on task duration) and sets the due date.
- **Time Boxing**: If a user mentions how long something takes ("...will take 2 hours"), ALWAYS set 'estimatedDuration' (in minutes) when creating/updating.
**3. Gamification**
- Check achievements/XP with 'getAchievements'.
- Check highscores with 'getLeaderboard'.
### 📅 DATE & TIME RULES
- **"Today"**: Use the 'Current Date/Time' context.
- **Queries**: When asked for "today's tasks", only list tasks where 'dueDate' matches today.
- **Scheduling**: When using 'scheduleTask', inform the user specifically *when* you scheduled it (e.g., "I've scheduled this for tomorrow at 2:00 PM").
IMPORTANT: Do NOT show Task IDs to the user. Reference tasks by Title.
CRITICAL: After executing tools, provide a concise summary of your actions.
`;
const fullMessages = [
@@ -36,7 +62,7 @@ If needed, suggest they create tasks or manage their schedule (you cannot perfor
try {
if (provider === "openai" || provider === "ollama") {
return await this.chatOpenAI(provider, apiKey || "", model, baseUrl, fullMessages);
return await this.chatOpenAI(provider, apiKey || "", model, baseUrl, fullMessages, user);
} else if (provider === "anthropic") {
return await this.chatAnthropic(apiKey || "", model, fullMessages);
} else if (provider === "google") {
@@ -50,36 +76,496 @@ If needed, suggest they create tasks or manage their schedule (you cannot perfor
}
}
private async chatOpenAI(provider: string, apiKey: string, model: string, baseUrl: string | undefined, messages: any[]): Promise<string> {
const url = baseUrl || (provider === "ollama" ? "http://localhost:11434/v1" : "https://api.openai.com/v1") + "/chat/completions";
async generateTitle(messages: ChatMessage[]): Promise<string> {
const provider = await this.storage.getSystemSettings("ai_provider") || "openai";
const apiKey = await this.storage.getSystemSettings("ai_api_key");
const model = await this.storage.getSystemSettings("ai_model") || "gpt-4o";
const baseUrl = await this.storage.getSystemSettings("ai_base_url");
// Clean URL
const cleanUrl = url.replace(/([^:]\/)\/+/g, "$1"); // remove double slashes
if (!apiKey && provider !== "ollama") return "New Conversation";
const response = await fetch(cleanUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: 0.7,
}),
});
const conversationText = messages.slice(0, 3).map(m => `${m.role}: ${m.content}`).join('\n');
const prompt = `Based on the following conversation, generate a short, concise title (max 5 words). Return ONLY the title text, no quotes or labels.\n\nConversation:\n${conversationText}`;
if (!response.ok) {
const err = await response.text();
throw new Error(`OpenAI/Ollama API Error ${response.status}: ${err}`);
const contextMessages = [
{ role: "user", content: prompt }
];
try {
let generatedTitle = "";
if (provider === "openai" || provider === "ollama") {
generatedTitle = await this.chatOpenAI(provider, apiKey || "", model, baseUrl, contextMessages, undefined, []);
} else if (provider === "anthropic") {
generatedTitle = await this.chatAnthropic(apiKey || "", model, contextMessages);
} else if (provider === "google") {
generatedTitle = await this.chatGemini(apiKey || "", model, contextMessages);
}
generatedTitle = generatedTitle.trim();
if (generatedTitle && generatedTitle !== "No response generated." && generatedTitle.length < 60) {
return generatedTitle.replace(/^["']|["']$/g, '').replace(/[*_#`]/g, '').trim();
}
} catch (error) {
console.error("Title generation failed:", error);
}
const data = await response.json();
return data.choices[0]?.message?.content || "No response generated.";
return "New Conversation";
}
// New Public Method for Smart Scheduling
async scheduleTask(taskId: string, userId: string, startAfterStr?: string): Promise<{ success: boolean, scheduledDate?: string, message?: string, error?: string }> {
const task = await this.storage.getTask(taskId);
if (!task) {
return { success: false, error: "Task not found." };
}
const startAfter = startAfterStr ? new Date(startAfterStr) : new Date();
const durationMins = task.estimatedDuration || 60; // Default to 1h if not set
const workStartHour = 9;
// PRIORITY LOGIC: High priority tasks can be scheduled until 20:00 (8 PM)
const workEndHour = task.priority === 'high' ? 20 : 18;
let scheduledDate: Date | null = null;
// PLANNED TIME LOGIC: If startDate is set, do not schedule before it.
// If starteAfterStr is provided (e.g. "tomorrow"), use the max of both.
let effectiveStart = startAfter;
if (task.startDate) {
const plannedStart = new Date(task.startDate);
if (plannedStart > effectiveStart) {
effectiveStart = plannedStart;
}
}
let currentDay = new Date(effectiveStart);
// Reset to next slot if passed
if (currentDay.getHours() >= workEndHour) {
currentDay.setDate(currentDay.getDate() + 1);
currentDay.setHours(workStartHour, 0, 0, 0);
} else if (currentDay.getHours() < workStartHour) {
currentDay.setHours(workStartHour, 0, 0, 0);
}
for (let dayOffset = 0; dayOffset < 3; dayOffset++) { // Look ahead 3 days
const dayStart = new Date(currentDay);
dayStart.setHours(workStartHour, 0, 0, 0);
const dayEnd = new Date(currentDay);
dayEnd.setHours(workEndHour, 0, 0, 0);
// Get all tasks for this day that have a due date (and time)
const allTasks = await this.storage.searchTasks("", userId);
// Filter for tasks on this day
const dayTasks = allTasks.filter(t => {
if (!t.dueDate) return false;
const d = new Date(t.dueDate);
return d.getDate() === currentDay.getDate() &&
d.getMonth() === currentDay.getMonth() &&
d.getFullYear() === currentDay.getFullYear();
});
// Find gaps
// Sort by time
dayTasks.sort((a, b) => (a.dueDate!.getTime() - b.dueDate!.getTime()));
// Check slots
// Start checking from 'currentDay' time (if today) or 9am
let attemptTime = new Date(currentDay);
if (attemptTime < dayStart) attemptTime = dayStart;
while (attemptTime.getTime() + (durationMins * 60000) <= dayEnd.getTime()) {
const attemptEnd = new Date(attemptTime.getTime() + (durationMins * 60000));
// Check collision
const hasCollision = dayTasks.some(t => {
const tStart = new Date(t.dueDate!);
const tDuration = t.estimatedDuration || 60;
const tEnd = new Date(tStart.getTime() + (tDuration * 60000));
return (attemptTime < tEnd && attemptEnd > tStart);
});
if (!hasCollision) {
scheduledDate = attemptTime;
break;
}
// specific increment? 30 mins
attemptTime = new Date(attemptTime.getTime() + 30 * 60000);
}
if (scheduledDate) break;
// Move to next day
currentDay.setDate(currentDay.getDate() + 1);
currentDay.setHours(workStartHour, 0, 0, 0);
}
if (scheduledDate) {
await this.storage.updateTask(taskId, { dueDate: scheduledDate });
return {
success: true,
scheduledDate: scheduledDate.toISOString(),
message: `Scheduled for ${scheduledDate.toLocaleDateString()} at ${scheduledDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}.`
};
} else {
return { success: false, error: "Could not find a free slot in the next 3 days." };
}
}
private getTaskTools() {
return [
{
type: "function",
function: {
name: "createTask",
description: "Create a new task for the user.",
parameters: {
type: "object",
properties: {
title: { type: "string", description: "The title of the task (required)." },
description: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high"] },
status: { type: "string", enum: ["todo", "inProgress", "done"] },
dueDate: { type: "string", description: "ISO 8601 format (YYYY-MM-DD)." },
labelId: { type: "string" },
estimatedDuration: { type: "integer", description: "Estimated duration in minutes." },
parentTaskId: { type: "string" },
startDate: { type: "string" }
},
required: ["title"]
}
}
},
{
type: "function",
function: {
name: "searchTasks",
description: "Search for tasks by title, description, or label.",
parameters: {
type: "object",
properties: {
query: { type: "string" },
label: { type: "string" }
},
required: ["query"]
}
}
},
{
type: "function",
function: {
name: "updateTask",
description: "Update an existing task.",
parameters: {
type: "object",
properties: {
id: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high"] },
status: { type: "string", enum: ["todo", "inProgress", "done"] },
dueDate: { type: "string" },
timeTracked: { type: "number" },
labelId: { type: "string" },
estimatedDuration: { type: "integer" },
startDate: { type: "string" }
},
required: ["id"]
}
}
},
{
type: "function",
function: {
name: "deleteTask",
description: "Delete an existing task.",
parameters: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"]
}
}
},
{
type: "function",
function: {
name: "getLabels",
description: "Get all available labels.",
parameters: { type: "object", properties: {}, required: [] }
}
},
{
type: "function",
function: {
name: "getAchievements",
description: "Get user's XP and rewards.",
parameters: { type: "object", properties: {}, required: [] }
}
},
{
type: "function",
function: {
name: "getLeaderboard",
description: "Get the highscore leaderboard.",
parameters: { type: "object", properties: {}, required: [] }
}
},
{
type: "function",
function: {
name: "scheduleTask",
description: "Finds the first available time slot for a task based on its duration and existing schedule.",
parameters: {
type: "object",
properties: {
taskId: { type: "string", description: "The ID of the task to schedule." },
startAfter: { type: "string", description: "ISO 8601 Date to start searching from (default: now)." }
},
required: ["taskId"]
}
}
}
];
}
private async chatOpenAI(provider: string, apiKey: string, model: string, baseUrl: string | undefined, messages: any[], user?: User, tools: any[] = this.getTaskTools()): Promise<string> {
let apiBase = baseUrl;
if (!apiBase) {
if (provider === "ollama") {
apiBase = "http://host.docker.internal:11434/v1";
} else {
apiBase = "https://api.openai.com/v1";
}
}
if (apiBase.endsWith('/')) {
apiBase = apiBase.slice(0, -1);
}
if (provider === "ollama" && !apiBase.includes("/v1") && !apiBase.includes("/api")) {
apiBase += "/v1";
}
const url = `${apiBase}/chat/completions`;
console.log(`[AI] Sending Chat Request: provider=${provider} url=${url} model=${model}`);
const body: any = {
model: model,
messages: messages,
temperature: 0.7,
};
if (tools && tools.length > 0) {
body.tools = tools;
body.tool_choice = "auto";
}
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.text();
console.error(`[AI] API Error: ${response.status} - ${err}`);
throw new Error(`${provider} API Error ${response.status}: ${err}`);
}
const data = await response.json();
const choice = data.choices[0];
const message = choice.message;
if (choice.finish_reason === "tool_calls" && message.tool_calls) {
console.log(`[AI] Tool Calls detected: ${message.tool_calls.length}`);
if (!user) throw new Error("Tools invoked but no user context provided.");
messages.push(message);
for (const toolCall of message.tool_calls) {
const fnName = toolCall.function.name;
const args = JSON.parse(toolCall.function.arguments);
let result: any = {};
try {
console.log(`[AI] Executing ${fnName}:`, args);
if (fnName === "createTask") {
const taskData = {
title: args.title,
description: args.description || null,
priority: args.priority || "medium",
status: args.status || "todo",
dueDate: args.dueDate ? new Date(args.dueDate) : null,
labelId: args.labelId || null,
estimatedDuration: args.estimatedDuration || null,
parentTaskId: args.parentTaskId || null,
startDate: args.startDate ? new Date(args.startDate) : null,
userId: user.id
};
const createdTask = await this.storage.createTask(taskData);
await this.storage.createAuditLog({
userId: user.id,
action: "CREATE",
entityType: "TASK",
entityId: createdTask.id,
details: { title: createdTask.title },
source: "AI"
});
result = { success: true, taskId: createdTask.id, message: "Task created." };
} else if (fnName === "searchTasks") {
const tasks = await this.storage.searchTasks(args.query, user.id);
const labels = await this.storage.getLabels(user.id);
const labelMap = new Map(labels.map(l => [l.id, l.name]));
let filteredTasks = tasks;
if (args.label) {
const labelId = labels.find(l => l.name.toLowerCase() === args.label.toLowerCase())?.id;
if (labelId) {
filteredTasks = tasks.filter(t => t.labelId === labelId);
}
}
result = {
success: true,
tasks: filteredTasks.map(t => ({
id: t.id,
title: t.title,
status: t.status,
priority: t.priority,
dueDate: t.dueDate,
label: t.labelId ? labelMap.get(t.labelId) || "No Label" : "No Label"
}))
};
} else if (fnName === "updateTask") {
const updates: any = {};
if (args.title) updates.title = args.title;
if (args.description) updates.description = args.description;
if (args.priority) updates.priority = args.priority;
if (args.status) updates.status = args.status;
if (args.dueDate !== undefined) updates.dueDate = args.dueDate ? new Date(args.dueDate) : null;
if (args.timeTracked !== undefined) updates.timeTracked = args.timeTracked;
if (args.labelId !== undefined) updates.labelId = args.labelId;
if (args.estimatedDuration !== undefined) updates.estimatedDuration = args.estimatedDuration;
if (args.startDate !== undefined) updates.startDate = args.startDate ? new Date(args.startDate) : null;
const updatedTask = await this.storage.updateTask(args.id, updates);
if (updatedTask) {
await this.storage.createAuditLog({
userId: user.id,
action: "UPDATE",
entityType: "TASK",
entityId: updatedTask.id,
details: updates,
source: "AI"
});
result = { success: true, message: "Task updated successfully." };
} else {
result = { success: false, error: "Task not found." };
}
} else if (fnName === "deleteTask") {
const deleted = await this.storage.deleteTask(args.id);
if (deleted) {
await this.storage.createAuditLog({
userId: user.id,
action: "DELETE",
entityType: "TASK",
entityId: args.id,
details: null,
source: "AI"
});
result = { success: true, message: "Task deleted successfully." };
} else {
result = { success: false, error: "Task not found or could not be deleted." };
}
} else if (fnName === "getLabels") {
const labels = await this.storage.getLabels(user.id);
result = {
success: true,
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color }))
};
} else if (fnName === "getAchievements") {
const goals = await this.storage.getGoals();
const userGoals = goals.filter(g => g.userId === user.id);
const rewards = await this.storage.getUserRewards(user.id);
const xpEvents = await this.storage.getXpEvents(user.id);
result = {
success: true,
xp: user.xp,
level: user.level,
goals: userGoals.map(g => ({ title: g.title, current: g.current, target: g.target, completed: g.completed })),
rewards: rewards.length,
recentXp: xpEvents.slice(0, 5).map(e => ({ source: e.source, amount: e.amount }))
};
} else if (fnName === "getLeaderboard") {
const leaderboard = await this.storage.getLeaderboard();
result = {
success: true,
topUsers: leaderboard.slice(0, 10).map((u, index) => ({
rank: index + 1,
username: u.username,
xp: u.xp,
level: u.level
}))
};
} else if (fnName === "scheduleTask") {
// Call the reusable public method from inside the tool
result = await this.scheduleTask(args.taskId, user.id, args.startAfter);
} else {
result = { success: false, error: "Unknown tool function." };
}
messages.push({
role: "tool",
tool_call_id: toolCall.id,
name: fnName,
content: JSON.stringify(result)
});
} catch (err: any) {
console.error(`[AI] Tool Execution Error:`, err);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
name: fnName,
content: JSON.stringify({ success: false, error: err.message })
});
}
}
console.log(`[AI] Sending follow-up request with tool results...`);
return await this.chatOpenAI(provider, apiKey, model, baseUrl, messages, user, tools);
}
if (!message.content) {
console.warn("[AI] Warning: Empty content received from OpenAI.");
const hasExecutedTools = messages.some(m => m.role === 'tool');
if (hasExecutedTools) {
return "I have successfully processed your request and updated your tasks.";
}
}
return message.content || "No response generated.";
} catch (error: any) {
console.error(`[AI] Request Failed: ${error.message}`);
if (error.code === 'ECONNREFUSED' && url.includes('localhost')) {
throw new Error("Connection refused. If running in Docker, try using 'http://host.docker.internal:11434/v1' as Base URL.");
}
throw error;
}
}
private async chatAnthropic(apiKey: string, model: string, messages: any[]): Promise<string> {
// Anthropic doesn't support "system" role in messages list in the same way, need to extract it
const systemMessage = messages.find(m => m.role === "system")?.content || "";
const userAssistantMessages = messages.filter(m => m.role !== "system");
@@ -108,14 +594,6 @@ If needed, suggest they create tasks or manage their schedule (you cannot perfor
}
private async chatGemini(apiKey: string, model: string, messages: any[]): Promise<string> {
// Google Generative AI (Gemini)
// POST https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=YOUR_API_KEY
// Mapping messages to Gemini format (contents: [{ role, parts: [{ text }] }])
// System instruction is supported in v1beta/models/...:generateContent?
// Gemini 1.5 Pro supports systemInstructions.
// For simplicity, I'll prepend system prompt to first user message.
const systemMessage = messages.find(m => m.role === "system")?.content || "";
const contentMessages = messages.filter(m => m.role !== "system").map(m => ({
role: m.role === "user" ? "user" : "model",
+68
View File
@@ -0,0 +1,68 @@
import { IStorage } from "./storage";
import { User, InsertXpEvent } from "@shared/schema";
import { getLevelFromXP } from "@shared/gamification";
// Constants for XP Actions
export const XP_RULES = {
CREATE_TASK: 10,
CREATE_SUBTASK: 5,
UPDATE_TASK: 2, // Small amount for tweaking/updating
COMPLETE_TASK: 50,
COMPLETE_TASK_LATE: 20, // Reduced for overdue
AI_ACTION: 5, // For using AI features
LOGIN_STREAK: 100
};
export class GamificationService {
private storage: IStorage;
constructor(storage: IStorage) {
this.storage = storage;
}
async awardXP(userId: string, source: string, amount?: number, description?: string): Promise<{ user: User, levelUp: boolean, oldLevel: number, newLevel: number }> {
const user = await this.storage.getUser(userId);
if (!user) throw new Error("User not found");
const xpAmount = amount || this.getXPForSource(source);
const newTotalXP = (user.xp || 0) + xpAmount;
// Check for level up
const oldLevel = getLevelFromXP(user.xp || 0);
const newLevel = getLevelFromXP(newTotalXP);
const levelUp = newLevel > oldLevel;
// Update User
await this.storage.updateUserXP(userId, newTotalXP);
// Log Event
await this.storage.logXpEvent({
userId,
amount: xpAmount,
source,
});
// If Level Up, we could log a special event or notification here?
const updatedUser = await this.storage.getUser(userId);
return {
user: updatedUser!,
levelUp,
oldLevel,
newLevel
};
}
private getXPForSource(source: string): number {
switch (source) {
case 'create_task': return XP_RULES.CREATE_TASK;
case 'create_subtask': return XP_RULES.CREATE_SUBTASK;
case 'update_task': return XP_RULES.UPDATE_TASK;
case 'complete_task': return XP_RULES.COMPLETE_TASK;
case 'complete_task_late': return XP_RULES.COMPLETE_TASK_LATE;
case 'ai_action': return XP_RULES.AI_ACTION;
case 'daily_streak': return XP_RULES.LOGIN_STREAK;
default: return 0;
}
}
}
+475 -153
View File
@@ -4,11 +4,13 @@ import { storage } from "./storage.js";
import { insertLabelSchema, insertTaskSchema, insertNoteSchema, insertXpEventSchema, insertGoalSchema, insertRewardSchema, rewards, userRewards, User } from "../shared/schema.js";
import { z } from "zod";
import { EmailService } from "./email.js";
import { mcpServer } from "./mcp";
import { AiService } from "./ai.js";
import { GamificationService } from "./gamification.js";
const emailService = new EmailService(storage);
const aiService = new AiService(storage);
const gamificationService = new GamificationService(storage);
import { setupAuth, hashPassword, comparePassword } from "./auth.js";
@@ -148,6 +150,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
role: req.body.role || 'user',
isActive: true
});
await storage.createAuditLog({
userId: (req.user as User).id,
action: "CREATE",
entityType: "USER",
entityId: newUser.id,
details: { username: newUser.username, role: newUser.role },
source: "USER"
});
res.json(newUser);
} catch (e) {
res.status(500).json({ error: "Failed to create user" });
@@ -164,6 +176,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
const updated = await storage.updateUser(user.id, { isActive: !user.isActive });
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "USER",
entityId: user.id,
details: { isActive: !user.isActive },
source: "USER"
});
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to toggle user status" });
@@ -180,18 +202,55 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
await storage.deleteUser(user.id);
await storage.createAuditLog({
userId: (req.user as User).id,
action: "DELETE",
entityType: "USER",
entityId: user.id,
details: { username: user.username },
source: "USER"
});
res.sendStatus(204);
} catch (e) {
res.status(500).json({ error: "Failed to delete user" });
}
});
// --- MCP Routes ---
app.get("/api/mcp/sse", async (req, res) => {
const enabled = await storage.getSystemSettings("mcp_enabled");
if (enabled !== "true") return res.status(503).send("MCP Server Disabled");
await mcpServer.handleSse(req, res);
});
app.post("/api/mcp/messages", async (req, res) => {
const enabled = await storage.getSystemSettings("mcp_enabled");
if (enabled !== "true") return res.status(503).json({ error: "MCP Server Disabled" });
await mcpServer.handleMessage(req, res);
});
app.get("/api/admin/audit-logs", isAdmin, async (req, res) => {
try {
const logs = await storage.getAuditLogs();
res.json(logs);
} catch (e) {
res.status(500).json({ error: "Failed to fetch audit logs" });
}
});
app.get("/api/admin/settings", isAdmin, async (req, res) => {
const keys = ["registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure"];
const keys = [
"registration_enabled",
"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure",
"ai_provider", "ai_api_key", "ai_model", "ai_base_url",
"mcp_enabled", "mcp_port"
];
const settings: any = {};
for (const key of keys) {
const val = await storage.getSystemSettings(key);
if (key === "registration_enabled" || key === "smtp_secure") {
if (key === "registration_enabled" || key === "smtp_secure" || key === "mcp_enabled") {
settings[key] = val === "true";
} else {
settings[key] = val || ""; // Return empty string if undefined for inputs
@@ -201,12 +260,27 @@ export async function registerRoutes(app: Express): Promise<Server> {
});
app.post("/api/admin/settings", isAdmin, async (req, res) => {
const keys = ["registration_enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure"];
const keys = [
"registration_enabled",
"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_from", "smtp_secure",
"ai_provider", "ai_api_key", "ai_model", "ai_base_url",
"mcp_enabled", "mcp_port"
];
for (const key of keys) {
if (req.body[key] !== undefined) {
await storage.setSystemSettings(key, String(req.body[key]));
}
}
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "SYSTEM_SETTINGS",
entityId: null,
details: req.body,
source: "USER"
});
res.json({ success: true });
});
@@ -255,17 +329,197 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// --- AI Routes ---
app.post("/api/ai/chat", async (req, res) => {
// --- AI Chat History Routes ---
// Get all conversations for user
app.get("/api/ai/conversations", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const conversations = await storage.getConversations((req.user as User).id);
res.json(conversations);
} catch (e) {
res.status(500).json({ error: "Failed to fetch conversations" });
}
});
// Create new conversation
app.post("/api/ai/conversations", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
try {
const { title } = req.body;
const conversation = await storage.createConversation(user.id, title);
await storage.createAuditLog({
userId: user.id,
action: "CREATE",
entityType: "CONVERSATION",
entityId: conversation.id,
details: { title },
source: "USER"
});
res.json(conversation);
} catch (e) {
res.status(500).json({ error: "Failed to create conversation" });
}
});
// Rename conversation
app.patch("/api/ai/conversations/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
try {
const { title } = req.body;
if (!title) return res.status(400).json({ error: "Title is required" });
const conv = await storage.getConversation(req.params.id);
if (!conv) return res.status(404).json({ error: "Conversation not found" });
if (conv.userId !== user.id) return res.sendStatus(403);
const updated = await storage.updateConversation(req.params.id, title);
await storage.createAuditLog({
userId: user.id,
action: "UPDATE",
entityType: "CONVERSATION",
entityId: req.params.id,
details: { title },
source: "USER"
});
res.json(updated);
} catch (e) {
res.status(500).json({ error: "Failed to update conversation" });
}
});
// Purchase Reward
app.post("/api/rewards/:rewardId/purchase", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const userId = (req.user as User).id;
const rewardId = req.params.rewardId;
try {
const reward = await storage.getReward(rewardId);
if (!reward) return res.status(404).json({ error: "Reward not found" });
const purchase = await storage.purchaseReward(userId, reward.id, reward.cost);
await storage.createAuditLog({
userId: userId,
action: "PURCHASE",
entityType: "REWARD",
entityId: reward.id,
details: { name: reward.title, cost: reward.cost },
source: "USER"
});
res.json(purchase);
} catch (e) {
res.status(500).json({ error: "Purchase failed" });
}
});
// Get single conversation
app.get("/api/ai/conversations/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const conv = await storage.getConversation(req.params.id);
if (!conv) return res.status(404).json({ error: "Conversation not found" });
if (conv.userId !== (req.user as User).id) return res.sendStatus(403);
res.json(conv);
} catch (e) {
res.status(500).json({ error: "Failed to fetch conversation" });
}
});
// Delete conversation
app.delete("/api/ai/conversations/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const conv = await storage.getConversation(req.params.id);
if (!conv) return res.status(404).json({ error: "Conversation not found" });
if (conv.userId !== (req.user as User).id) return res.sendStatus(403);
await storage.deleteConversation(req.params.id);
await storage.createAuditLog({
userId: (req.user as User).id,
action: "DELETE",
entityType: "CONVERSATION",
entityId: req.params.id,
details: null,
source: "USER"
});
res.sendStatus(204);
} catch (e) {
res.status(500).json({ error: "Failed to delete conversation" });
}
});
// Get messages for conversation
app.get("/api/ai/conversations/:id/messages", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const conv = await storage.getConversation(req.params.id);
if (!conv) return res.status(404).json({ error: "Conversation not found" });
if (conv.userId !== (req.user as User).id) return res.sendStatus(403);
const messages = await storage.getMessages(req.params.id);
res.json(messages);
} catch (e) {
res.status(500).json({ error: "Failed to fetch messages" });
}
});
// Generate conversation title
app.post("/api/ai/generate-title", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
if (!user.aiEnabled) return res.status(403).json({ error: "AI Assistant is disabled for this user" });
try {
const { messages } = req.body;
if (!Array.isArray(messages)) return res.status(400).json({ error: "Messages must be an array" });
if (!messages || !Array.isArray(messages)) return res.status(400).json({ error: "Messages array is required" });
// Build User Context
const title = await aiService.generateTitle(messages);
res.json({ title });
} catch (e: any) {
console.error("Generate Title Error:", e);
res.status(500).json({ error: e.message });
}
});
// Send message (Chat)
app.post("/api/ai/chat", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
if (!user.aiEnabled) return res.status(403).json({ error: "AI Assistant is disabled for this user" });
try {
const { conversationId, content, clientTime } = req.body;
if (!conversationId || !content) return res.status(400).json({ error: "Missing conversationId or content" });
// Verify ownership
const conv = await storage.getConversation(conversationId);
if (!conv) return res.status(404).json({ error: "Conversation not found" });
if (conv.userId !== user.id) return res.sendStatus(403);
// Store User Message
await storage.addMessage({
conversationId,
role: 'user',
content
});
// Fetch history for context
const dbMessages = await storage.getMessages(conversationId);
// Convert to format expected by AiService (role, content)
const history = dbMessages.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }));
// Build User Context (Tasks etc)
const tasks = await storage.getTasksForUser(user.id);
const activeTasks = tasks.filter(t => t.status !== 'done');
const completedTasks = tasks.filter(t => t.status === 'done');
@@ -275,6 +529,7 @@ User Context:
- User ID: ${user.id}
- Username: ${user.username}
- XP: ${user.xp} (Level ${user.level})
- Current Date/Time: ${clientTime || new Date().toLocaleString()}
Task Summary:
- Total Active Tasks: ${activeTasks.length}
@@ -287,14 +542,100 @@ Recent Active Tasks:
${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
`;
const response = await aiService.chat(messages, user, context);
res.json({ role: "assistant", content: response });
// Call AI Service
const responseContent = await aiService.chat(history, user, context);
// Store AI Response
const botMessage = await storage.addMessage({
conversationId,
role: 'assistant',
content: responseContent
});
res.json(botMessage);
} catch (e: any) {
console.error("AI Route Error:", e);
res.status(500).json({ error: e.message || "Failed to generate AI response" });
}
});
// Edit message and regenerate (Regenerate Response)
app.put("/api/ai/chat/:messageId", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
const user = req.user as User;
if (!user.aiEnabled) return res.status(403).json({ error: "AI Assistant is disabled for this user" });
try {
const { messageId } = req.params;
const { content, clientTime } = req.body;
if (!content) return res.status(400).json({ error: "Content is required" });
// Verify message and ownership
const message = await storage.getMessage(messageId);
if (!message) return res.status(404).json({ error: "Message not found" });
const conv = await storage.getConversation(message.conversationId);
if (!conv || conv.userId !== user.id) return res.sendStatus(403);
if (message.role !== 'user') return res.status(400).json({ error: "Can only edit user messages" });
// 1. Update the message content
const updatedMessage = await storage.updateMessage(messageId, content);
// 2. Delete all subsequent messages (history truncation)
await storage.deleteMessagesAfter(message.conversationId, message.createdAt as Date, message.id);
// 3. Prepare context for regeneration
const dbMessages = await storage.getMessages(message.conversationId);
const history = dbMessages.map(m => ({ role: m.role as 'user' | 'assistant' | 'system', content: m.content }));
const tasks = await storage.searchTasks("", user.id); // Get all tasks
const activeTasks = tasks.filter(t => t.status !== 'done');
const completedTasks = tasks.filter(t => t.status === 'done');
// Fetch all labels to map IDs to names
const labels = await storage.getAllLabels();
const labelMap = new Map(labels.map(l => [l.id, l.name]));
const activeTasksWithLabels = activeTasks.map(t => ({
...t,
label: t.labelId ? labelMap.get(t.labelId) || "No Label" : "No Label"
}));
const context = `
User Context:
- User ID: ${user.id}
- Username: ${user.username}
- XP: ${user.xp} (Level ${user.level})
- Current Date/Time: ${clientTime || new Date().toLocaleString()}
Task Summary:
- Total Active Tasks: ${activeTasks.length}
- Total Completed Tasks: ${completedTasks.length}
High Priority Active Tasks:
${activeTasksWithLabels.filter(t => t.priority === 'high').map(t => `- [${t.label}] ${t.title} (Due: ${t.dueDate})`).join('\n') || 'None'}
Recent Active Tasks:
${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t.title}`).join('\n')}
`;
// 4. Call AI Service (Regenerate)
const responseContent = await aiService.chat(history, user, context);
// 5. Store AI Response
const botMessage = await storage.addMessage({
conversationId: message.conversationId,
role: 'assistant',
content: responseContent
});
res.json(botMessage);
} catch (e: any) {
console.error("AI Edit Error:", e);
res.status(500).json({ error: e.message || "Failed to regenerate AI response" });
}
});
// Health check endpoint
app.get("/api/health", (req, res) => {
res.status(200).json({ status: "ok", timestamp: new Date().toISOString() });
@@ -349,6 +690,16 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
...result.data,
creatorId: (req.user as User).id // Assign creator
});
await storage.createAuditLog({
userId: (req.user as User).id,
action: "CREATE",
entityType: "LABEL",
entityId: label.id,
details: { name: label.name },
source: "USER"
});
res.status(201).json(label);
} catch (error) {
console.error("Create Label Error:", error);
@@ -367,6 +718,16 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
if (!label) {
return res.status(404).json({ error: "Label not found" });
}
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "LABEL",
entityId: label.id,
details: updates.data,
source: "USER"
});
res.json(label);
} catch (error) {
res.status(500).json({ error: "Failed to update label" });
@@ -379,6 +740,17 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
if (!success) {
return res.status(404).json({ error: "Label not found" });
}
await storage.deleteLabel(req.params.id);
await storage.createAuditLog({
userId: (req.user as User).id,
action: "DELETE",
entityType: "LABEL",
entityId: req.params.id,
details: null,
source: "USER"
});
res.status(204).send();
} catch (error) {
res.status(500).json({ error: "Failed to delete label" });
@@ -488,6 +860,41 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
}
});
app.post("/api/tasks/:id/schedule", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
const taskId = req.params.id;
const task = await storage.getTask(taskId);
if (!task) return res.status(404).json({ error: "Task not found" });
if (task.userId !== (req.user as User).id) return res.sendStatus(403);
const result = await aiService.scheduleTask(taskId, (req.user as User).id);
if (result.success) {
res.json(result);
// Award XP for using AI scheduling
if (req.user) {
await gamificationService.awardXP((req.user as User).id, 'ai_action');
}
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "TASK",
entityId: taskId,
details: { action: "AI_SCHEDULE" },
source: "AI"
});
} else {
res.status(400).json(result);
}
} catch (e: any) {
console.error("Schedule Task Error:", e);
res.status(500).json({ error: e.message || "Failed to schedule task" });
}
});
app.post("/api/tasks", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
try {
@@ -500,6 +907,21 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
...result.data,
userId: (req.user as User).id
});
// Award XP for creating a task
if (req.user) {
const source = req.body.parentTaskId ? 'create_subtask' : 'create_task';
await gamificationService.awardXP((req.user as User).id, source);
}
await storage.createAuditLog({
userId: (req.user as User).id,
action: "CREATE",
entityType: "TASK",
entityId: task.id,
details: { title: task.title },
source: "USER"
});
res.status(201).json(task);
} catch (error) {
res.status(500).json({ error: "Failed to create task" });
@@ -516,149 +938,33 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
return res.status(400).json({ error: "Invalid task data", details: updates.error });
}
// Gamification: Award XP on completion
if (previousTask && previousTask.status !== 'done' && updates.data.status === 'done') {
try {
const xpEarned = calculateXP(previousTask);
const user = await storage.getUser((req.user as User).id);
if (!user) throw new Error("User not found for gamification");
let newStreak = user.currentStreak || 0;
let streakBonus = 0;
let diffDays = 0;
if (user) {
const now = new Date();
const lastDate = user.lastTaskDate ? new Date(user.lastTaskDate) : null;
if (!lastDate) {
newStreak = 1;
} else {
const diffTime = Math.abs(now.setHours(0, 0, 0, 0) - lastDate.setHours(0, 0, 0, 0));
diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
newStreak += 1;
streakBonus = Math.min(newStreak * 5, 50);
} else if (diffDays > 1) {
newStreak = 1;
newStreak = 1;
}
}
// --- Daily Clear Bonus Check ---
// Check if this was the last 'todo' task for today
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
// Re-fetch all tasks (inefficient but safe for now, better: optimize storage method)
const allTasks = await storage.getTasksForUser(user.id);
const remainingToday = allTasks.filter(t =>
t.id !== previousTask.id && // exclude current
t.status !== 'done' && // is remaining
t.dueDate && // has due date
new Date(t.dueDate) >= startOfDay &&
new Date(t.dueDate) <= endOfDay
);
if (remainingToday.length === 0) {
// Bonus!
const clearBonus = 50;
await storage.logXpEvent({
userId: user.id,
amount: clearBonus,
source: 'daily_clear_bonus', // Ensure translation key exists
});
console.log(`[Gamification] Awarded ${clearBonus} XP for Daily Clear`);
}
if (diffDays !== 0 || !lastDate) {
await storage.updateUser(user.id, {
currentStreak: newStreak,
lastTaskDate: new Date()
});
}
}
// Log XP Event (Task)
await storage.logXpEvent({
userId: (req.user as User).id,
amount: xpEarned,
source: 'task_completion',
taskId: previousTask.id
});
// Log XP Event (Streak Bonus)
if (streakBonus > 0) {
await storage.logXpEvent({
userId: (req.user as User).id,
amount: streakBonus,
source: 'daily_streak',
});
console.log(`[Gamification] Awarded ${streakBonus} XP for streak of ${newStreak}`);
}
console.log(`[Gamification] Awarded ${xpEarned} XP for task ${previousTask.title}`);
// --- Goal Progress Check ---
try {
// Fetch active goals
const goals = await storage.getGoals(); // TODO: Filter by userId in storage
const userGoals = goals.filter(g => g.userId === user.id && !g.completed);
for (const goal of userGoals) {
let progress = 0;
// Calculate progress based on type
if (goal.type === 'weekly_tasks') {
// Count tasks completed this week
// Simplified: just update goal.current + 1 for now if we don't have full count logic
// Ideally we recount from history, but incremental update is easier
progress = goal.current + 1;
} else if (goal.type === 'streak') {
progress = newStreak;
} else if (goal.type === 'total_xp') {
progress = user.xp + xpEarned; // XP updated via logXpEvent side-effect? No, explicitly.
// The user obj here is stale, user.xp is old.
// But we just added xpEarned in logXpEvent (via side effect in storage).
// Let's assume +xpEarned.
// A better way is to re-fetch user, or rely on client/server sync.
progress = user.xp + xpEarned + streakBonus;
}
// Update Goal
if (progress !== goal.current) {
await storage.updateGoal(goal.id, { current: progress, completed: progress >= goal.target });
if (progress >= goal.target && !goal.completed) {
// Goal Completion Bonus
const goalBonus = 100;
await storage.logXpEvent({
userId: user.id,
amount: goalBonus,
source: 'goal_completed'
});
console.log(`[Gamification] Goal "${goal.title}" Completed! +${goalBonus} XP`);
}
}
}
} catch (goalErr) {
console.error("[Gamification] Error checking goals:", goalErr);
}
} catch (err) {
console.error("[Gamification] Error processing rewards:", err);
// Do not fail the request, just log
// Award XP using GamificationService
if (req.user && previousTask) { // Ensure previousTask exists for comparison
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);
} else if (Object.keys(updates.data).length > 0) { // Only award if there are actual updates
// Small points for any other update (title, description, etc)
await gamificationService.awardXP((req.user as User).id, 'update_task');
}
}
const task = await storage.updateTask(req.params.id, updates.data);
if (!task) {
const updatedTask = await storage.updateTask(req.params.id, updates.data);
if (!updatedTask) {
return res.status(404).json({ error: "Task not found" });
}
res.json(task);
await storage.createAuditLog({
userId: (req.user as User).id,
action: "UPDATE",
entityType: "TASK",
entityId: updatedTask.id,
details: updates.data,
source: "USER"
});
res.json(updatedTask);
} catch (error: any) {
console.error("PATCH Task Error:", error);
res.status(500).json({ error: "Failed to update task", details: String(error) });
@@ -671,6 +977,16 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
if (!success) {
return res.status(404).json({ error: "Task not found" });
}
await storage.createAuditLog({
userId: (req.user as User).id,
action: "DELETE",
entityType: "TASK",
entityId: req.params.id,
details: null,
source: "USER"
});
res.status(204).send();
} catch (error) {
res.status(500).json({ error: "Failed to delete task" });
@@ -722,6 +1038,16 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
console.log("Validation passed, creating goal...");
const goal = await storage.createGoal(result.data);
console.log("Goal created:", goal);
await storage.createAuditLog({
userId: (req.user as User).id || null,
action: "CREATE",
entityType: "GOAL",
entityId: goal.id,
details: { title: goal.title },
source: "USER"
});
res.json(goal);
} catch (error) {
console.error("Error in POST /api/goals:", error);
@@ -860,11 +1186,6 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
isSystem: (req.user as User).role === 'admin' && req.body.isSystem !== false,
};
// Force isSystem=false for non-admins
if ((req.user as User).role !== 'admin') {
rewardData.isSystem = false;
}
const reward = await storage.createReward(rewardData);
res.json(reward);
} catch (err) {
@@ -1076,6 +1397,7 @@ ${activeTasks.slice(0, 5).map(t => `- [${t.priority}] ${t.title}`).join('\n')}
const httpServer = createServer(app);
// Storage needs to support Goal Update
// Storage needs to support Goal Update
app.patch("/api/goals/:id", async (req, res) => {
if (!req.isAuthenticated()) return res.sendStatus(401);
+305 -111
View File
@@ -1,9 +1,11 @@
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, xpEvents, goals, type Reward, type InsertReward, type UserReward, type InsertUserReward, rewards, userRewards, systemSettings, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, sharedTasks, userTaskAccess, type InsertPasswordResetToken, type PasswordResetToken } from "../shared/schema.js";
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 * as schema from "@shared/schema";
import { getDatabase, pool } from "./db";
import { eq, sql, and, desc, asc, gt, ne } from "drizzle-orm";
import { randomUUID } from "crypto";
import session from "express-session";
import createMemoryStore from "memorystore";
import connectPg from "connect-pg-simple";
import { pool } from "./db.js";
const MemoryStore = createMemoryStore(session);
const PostgresStore = connectPg(session);
@@ -29,7 +31,6 @@ export interface IStorage {
createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask>; // Alias for shareTask standard naming
createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess>; // Alias
getSharedTasks(userId: string): Promise<SharedTask[]>; // Tasks shared WITH user
getSharedTasks(userId: string): Promise<SharedTask[]>; // Tasks shared WITH user
getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]>; // Access Viewer has to Owners
getTaskSharedUsers(taskId: string): Promise<User[]>; // Get users a task is shared WITH
@@ -49,6 +50,7 @@ export interface IStorage {
// Labels
getAllLabels(): Promise<Label[]>;
getLabels(userId: string): Promise<Label[]>;
getLabel(id: string): Promise<Label | undefined>;
createLabel(label: InsertLabel): Promise<Label>;
updateLabel(id: string, updates: Partial<InsertLabel>): Promise<Label | undefined>;
@@ -60,6 +62,8 @@ export interface IStorage {
createTask(task: InsertTask & { userId?: string }): Promise<Task>;
updateTask(id: string, updates: Partial<InsertTask>): Promise<Task | undefined>;
deleteTask(id: string): Promise<boolean>;
searchTasks(query: string, userId: string): Promise<Task[]>;
getSubtasks(parentTaskId: string): Promise<Task[]>;
// Gamification
logXpEvent(event: InsertXpEvent): Promise<XpEvent>;
@@ -71,9 +75,11 @@ export interface IStorage {
getAllRewards(): Promise<Reward[]>;
getUserRewards(userId: string): Promise<UserReward[]>;
createReward(reward: InsertReward): Promise<Reward>;
createReward(reward: InsertReward): Promise<Reward>;
createUserReward(userReward: InsertUserReward): Promise<UserReward>;
getReward(id: string): Promise<Reward | undefined>;
purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }>;
// History
// History
getXpEvents(userId: string): Promise<XpEvent[]>;
@@ -81,6 +87,22 @@ export interface IStorage {
createPasswordResetToken(token: InsertPasswordResetToken): Promise<PasswordResetToken>;
getPasswordResetToken(token: string): Promise<PasswordResetToken | undefined>;
markPasswordResetTokenUsed(id: string): Promise<void>;
// AI Chat
createConversation(userId: string, title?: string): Promise<Conversation>;
getConversations(userId: string): Promise<Conversation[]>;
getConversation(id: string): Promise<Conversation | undefined>;
updateConversation(id: string, title: string): Promise<Conversation | undefined>;
deleteConversation(id: string): Promise<boolean>;
addMessage(message: InsertMessage): Promise<Message>;
getMessages(conversationId: string): Promise<Message[]>;
getMessage(id: string): Promise<Message | undefined>;
updateMessage(id: string, content: string): Promise<Message>;
deleteMessagesAfter(conversationId: string, after: Date, excludeMessageId?: string): Promise<void>;
// Audit Logs
createAuditLog(log: InsertAuditLog): Promise<AuditLog>;
getAuditLogs(limit?: number): Promise<AuditLog[]>;
}
export class MemStorage implements IStorage {
@@ -98,6 +120,7 @@ export class MemStorage implements IStorage {
private sharedLabels: Map<string, SharedLabel>;
private userTaskAccess: Map<string, UserTaskAccess>;
private passwordResetTokens: Map<string, PasswordResetToken>; // id -> Token
private auditLogs: Map<string, AuditLog>;
sessionStore: session.Store;
@@ -110,11 +133,11 @@ export class MemStorage implements IStorage {
this.goals = new Map();
this.rewards = new Map();
this.userRewards = new Map();
this.userRewards = new Map();
this.sharedTasks = new Map();
this.sharedLabels = new Map();
this.userTaskAccess = new Map();
this.passwordResetTokens = new Map();
this.auditLogs = new Map();
this.sessionStore = new MemoryStore({
checkPeriod: 86400000,
});
@@ -340,6 +363,12 @@ export class MemStorage implements IStorage {
return Array.from(this.labels.values());
}
async getLabels(userId: string): Promise<Label[]> {
return Array.from(this.labels.values()).filter(
l => l.creatorId === null || l.creatorId === userId
);
}
async getLabel(id: string): Promise<Label | undefined> {
return this.labels.get(id);
}
@@ -401,10 +430,23 @@ export class MemStorage implements IStorage {
// Merge and Dedupe
const combined = [...myTasks, ...sharedToMe, ...globalSharedTasks, ...tasksFromSharedLabels];
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
return unique;
}
async searchTasks(query: string, userId: string): Promise<Task[]> {
const allTasks = await this.getTasksForUser(userId);
if (!query) return allTasks;
const lowerQuery = query.toLowerCase();
return allTasks.filter(t =>
t.title.toLowerCase().includes(lowerQuery) ||
(t.description && t.description.toLowerCase().includes(lowerQuery))
);
}
async getSubtasks(parentTaskId: string): Promise<Task[]> {
return Array.from(this.tasks.values()).filter(t => t.parentTaskId === parentTaskId);
}
async getTask(id: string): Promise<Task | undefined> {
return this.tasks.get(id);
}
@@ -424,7 +466,10 @@ export class MemStorage implements IStorage {
notes: insertTask.notes || null,
labelId: insertTask.labelId || null,
energyLevel: insertTask.energyLevel || "medium",
estimatedDuration: insertTask.estimatedDuration || null,
parentTaskId: insertTask.parentTaskId || null,
startDate: insertTask.startDate || null,
dependencies: insertTask.dependencies || null,
userId: insertTask.userId || null // Set ownership
};
@@ -546,6 +591,25 @@ export class MemStorage implements IStorage {
return userReward;
}
async getReward(id: string): Promise<Reward | undefined> {
return this.rewards.get(id);
}
async purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }> {
const user = this.users.get(userId);
if (!user) throw new Error("User not found");
if (user.xp < cost) throw new Error("Insufficient XP");
// Deduct XP
user.xp -= cost;
this.users.set(userId, user);
// Create User Reward
const userReward = await this.createUserReward({ userId, rewardId });
return { success: true, user, userReward };
}
async getXpEvents(userId: string): Promise<XpEvent[]> {
return Array.from(this.xpEvents.values())
.filter(e => e.userId === userId)
@@ -576,11 +640,63 @@ export class MemStorage implements IStorage {
this.passwordResetTokens.set(id, token);
}
}
// AI Chat Stubs
async createConversation(userId: string, title?: string): Promise<Conversation> {
throw new Error("MemStorage: AI Chat not implemented.");
}
async getConversations(userId: string): Promise<Conversation[]> {
return [];
}
async getConversation(id: string): Promise<Conversation | undefined> {
return undefined;
}
async deleteConversation(id: string): Promise<boolean> {
return false;
}
async addMessage(message: InsertMessage): Promise<Message> {
throw new Error("MemStorage: AI Chat not implemented.");
}
async getMessages(conversationId: string): Promise<Message[]> {
return [];
}
async getMessage(id: string): Promise<Message | undefined> {
return undefined;
}
async updateMessage(id: string, content: string): Promise<Message> {
throw new Error("Not implemented");
}
async deleteMessagesAfter(conversationId: string, after: Date): Promise<void> {
// No-op
}
async updateConversation(id: string, title: string): Promise<Conversation | undefined> {
return undefined;
}
// Audit Logs (MemStorage)
async createAuditLog(insertLog: InsertAuditLog): Promise<AuditLog> {
const id = randomUUID();
const log: AuditLog = {
...insertLog,
id,
userId: insertLog.userId || null,
entityId: insertLog.entityId || null,
details: insertLog.details || null,
source: insertLog.source || "USER",
createdAt: new Date(),
};
this.auditLogs.set(id, log);
return log;
}
async getAuditLogs(limit = 100): Promise<AuditLog[]> {
return Array.from(this.auditLogs.values())
.sort((a, b) => (b.createdAt && a.createdAt ? b.createdAt.getTime() - a.createdAt.getTime() : 0))
.slice(0, limit);
}
}
import { getDatabase } from './db.js';
import { eq, sql, desc, and } from 'drizzle-orm';
import * as schema from '../shared/schema.js';
export class DbStorage implements IStorage {
private db = getDatabase();
@@ -671,11 +787,16 @@ export class DbStorage implements IStorage {
return result.length > 0;
}
// ... (rest of DbStorage labels, tasks, etc. implementation - unchanged mostly)
async getAllLabels(): Promise<Label[]> {
return await this.db.select().from(schema.labels);
}
async getLabels(userId: string): Promise<Label[]> {
return await this.db.select().from(schema.labels).where(
sql`${schema.labels.creatorId} IS NULL OR ${schema.labels.creatorId} = ${userId}`
);
}
async getLabel(id: string): Promise<Label | undefined> {
const result = await this.db.select().from(schema.labels).where(eq(schema.labels.id, id));
return result[0];
@@ -701,14 +822,6 @@ export class DbStorage implements IStorage {
}
async getTasksForUser(userId: string): Promise<Task[]> {
// Complex query:
// (tasks.userId = current)
// OR (id IN (select taskId from sharedTasks where sharedWith = current))
// OR (userId IN (select ownerId from userTaskAccess where viewerId = current))
// For simplicity in this generated code, we can do parallel queries or use `or`.
// Drizzle's `or` and `inArray` can be used.
// 1. My tasks
const result = await this.db.select().from(schema.tasks).where(eq(schema.tasks.userId, userId));
@@ -736,12 +849,25 @@ export class DbStorage implements IStorage {
tasksFromSharedLabels = await this.db.select().from(schema.tasks).where(sql`${schema.tasks.labelId} IN ${sharedLabelIds}`);
}
// Dedupe
const combined = [...result, ...sharedTasks, ...globalTasks, ...tasksFromSharedLabels];
const unique = Array.from(new Map(combined.map(t => [t.id, t])).values());
return unique;
}
async searchTasks(query: string, userId: string): Promise<Task[]> {
const allTasks = await this.getTasksForUser(userId);
if (!query) return allTasks;
const lowerQuery = query.toLowerCase();
return allTasks.filter(t =>
t.title.toLowerCase().includes(lowerQuery) ||
(t.description && t.description.toLowerCase().includes(lowerQuery))
);
}
async getSubtasks(parentTaskId: string): Promise<Task[]> {
return await this.db.select().from(schema.tasks).where(eq(schema.tasks.parentTaskId, parentTaskId));
}
async getTask(id: string): Promise<Task | undefined> {
const result = await this.db.select().from(schema.tasks).where(eq(schema.tasks.id, id));
return result[0];
@@ -802,8 +928,6 @@ export class DbStorage implements IStorage {
return result[0];
}
// Rewards
async getAllRewards(): Promise<Reward[]> {
return await this.db.select().from(schema.rewards);
}
@@ -822,32 +946,47 @@ export class DbStorage implements IStorage {
return result[0];
}
async getReward(id: string): Promise<Reward | undefined> {
const result = await this.db.select().from(schema.rewards).where(eq(schema.rewards.id, id));
return result[0];
}
async purchaseReward(userId: string, rewardId: string, cost: number): Promise<{ success: boolean; user: User; userReward: UserReward }> {
return await this.db.transaction(async (tx) => {
// 1. Get User and verify XP
const userRes = await tx.select().from(schema.users).where(eq(schema.users.id, userId));
const user = userRes[0];
if (!user) throw new Error("User not found");
if (user.xp < cost) throw new Error("Insufficient XP");
// 2. Deduct XP
const updatedUserRes = await tx.update(schema.users)
.set({ xp: user.xp - cost })
.where(eq(schema.users.id, userId))
.returning();
// 3. Create User Reward
const urRes = await tx.insert(schema.userRewards).values({
userId,
rewardId,
purchasedAt: new Date()
}).returning();
return { success: true, user: updatedUserRes[0], userReward: urRes[0] };
});
}
async getXpEvents(userId: string): Promise<XpEvent[]> {
return await this.db.select()
.from(schema.xpEvents)
.where(eq(schema.xpEvents.userId, userId))
.orderBy(desc(schema.xpEvents.createdAt));
return await this.db.select().from(schema.xpEvents).where(eq(schema.xpEvents.userId, userId));
}
// Social Methods (DbStorage)
async getLeaderboard(): Promise<User[]> {
return await this.db.select()
.from(schema.users)
.where(and(
eq(schema.users.showOnLeaderboard, true),
eq(schema.users.isActive, true)
))
.orderBy(desc(schema.users.xp));
}
// Auth - Password Reset (DbStorage)
async createPasswordResetToken(insertToken: InsertPasswordResetToken): Promise<PasswordResetToken> {
const result = await this.db.insert(schema.passwordResetTokens).values(insertToken).returning();
return result[0];
}
async getPasswordResetToken(tokenString: string): Promise<PasswordResetToken | undefined> {
const result = await this.db.select().from(schema.passwordResetTokens).where(eq(schema.passwordResetTokens.token, tokenString));
async getPasswordResetToken(token: string): Promise<PasswordResetToken | undefined> {
const result = await this.db.select().from(schema.passwordResetTokens).where(eq(schema.passwordResetTokens.token, token));
return result[0];
}
@@ -857,10 +996,57 @@ export class DbStorage implements IStorage {
.where(eq(schema.passwordResetTokens.id, id));
}
// AI Chat Implementation
async createConversation(userId: string, title?: string): Promise<Conversation> {
const result = await this.db.insert(schema.conversations).values({
userId,
title: title || "New Chat",
createdAt: new Date(),
updatedAt: new Date()
}).returning();
return result[0];
}
async getConversations(userId: string): Promise<Conversation[]> {
return await this.db.select()
.from(schema.conversations)
.where(eq(schema.conversations.userId, userId))
.orderBy(desc(schema.conversations.updatedAt));
}
async getConversation(id: string): Promise<Conversation | undefined> {
const result = await this.db.select().from(schema.conversations).where(eq(schema.conversations.id, id));
return result[0];
}
async deleteConversation(id: string): Promise<boolean> {
await this.db.delete(schema.messages).where(eq(schema.messages.conversationId, id));
const result = await this.db.delete(schema.conversations).where(eq(schema.conversations.id, id)).returning();
return result.length > 0;
}
async addMessage(message: InsertMessage): Promise<Message> {
const result = await this.db.insert(schema.messages).values(message).returning();
if (message.conversationId) {
await this.db.update(schema.conversations)
.set({ updatedAt: new Date() })
.where(eq(schema.conversations.id, message.conversationId));
}
return result[0];
}
// Social & Leaderboard
async getLeaderboard(): Promise<User[]> {
return await this.db.select().from(schema.users)
.where(and(eq(schema.users.isActive, true), eq(schema.users.showOnLeaderboard, true)))
.orderBy(desc(schema.users.xp));
}
async searchUsers(query: string): Promise<User[]> {
if (!query || query.length < 2) return [];
return await this.db.select()
.from(schema.users)
return await this.db.select().from(schema.users)
.where(and(
eq(schema.users.isSearchable, true),
eq(schema.users.isActive, true),
@@ -869,123 +1055,131 @@ export class DbStorage implements IStorage {
}
async shareTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
return this.createSharedTask(sharedTask);
}
async createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
const result = await this.db.insert(schema.sharedTasks).values(sharedTask).returning();
return result[0];
}
async shareAllTasks(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
return this.createUserTaskAccess(access);
}
async createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
// Upsert or simple insert. Let's assume one Record per pair
const result = await this.db.insert(schema.userTaskAccess).values(access).returning();
return result[0];
}
async createSharedTask(sharedTask: InsertSharedTask): Promise<SharedTask> {
return this.shareTask(sharedTask);
}
async createUserTaskAccess(access: InsertUserTaskAccess): Promise<UserTaskAccess> {
return this.shareAllTasks(access);
}
async getSharedTasks(userId: string): Promise<SharedTask[]> {
return await this.db.select()
.from(schema.sharedTasks)
.where(eq(schema.sharedTasks.sharedWithUserId, userId));
return await this.db.select().from(schema.sharedTasks).where(eq(schema.sharedTasks.sharedWithUserId, userId));
}
async getUserTaskAccess(viewerId: string): Promise<UserTaskAccess[]> {
return await this.db.select()
.from(schema.userTaskAccess)
.where(eq(schema.userTaskAccess.viewerId, viewerId));
return await this.db.select().from(schema.userTaskAccess).where(eq(schema.userTaskAccess.viewerId, viewerId));
}
async getTaskSharedUsers(taskId: string): Promise<User[]> {
const result = await this.db.select({
id: schema.users.id,
username: schema.users.username,
email: schema.users.email,
role: schema.users.role,
isActive: schema.users.isActive,
xp: schema.users.xp,
level: schema.users.level,
currentStreak: schema.users.currentStreak,
lastTaskDate: schema.users.lastTaskDate,
showOnLeaderboard: schema.users.showOnLeaderboard,
isSearchable: schema.users.isSearchable,
apiKey: schema.users.apiKey,
aiEnabled: schema.users.aiEnabled,
password: schema.users.password // Generally shouldn't return this, but following pattern
})
.from(schema.sharedTasks)
.innerJoin(schema.users, eq(schema.sharedTasks.sharedWithUserId, schema.users.id))
.where(eq(schema.sharedTasks.taskId, taskId));
return result;
const shares = await this.db.select().from(schema.sharedTasks).where(eq(schema.sharedTasks.taskId, taskId));
if (shares.length === 0) return [];
return await this.db.select().from(schema.users)
.where(sql`${schema.users.id} IN ${shares.map(s => s.sharedWithUserId)}`);
}
async unshareTask(taskId: string, userId: string): Promise<boolean> {
const result = await this.db.delete(schema.sharedTasks)
.where(and(
eq(schema.sharedTasks.taskId, taskId),
eq(schema.sharedTasks.sharedWithUserId, userId)
))
.where(and(eq(schema.sharedTasks.taskId, taskId), eq(schema.sharedTasks.sharedWithUserId, userId)))
.returning();
return result.length > 0;
}
// Shared Labels (DbStorage)
async shareLabel(labelId: string, sharedWithUserId: string, sharedByUserId: string, permission: string = 'read'): Promise<SharedLabel> {
const result = await this.db.insert(schema.sharedLabels).values({
labelId,
sharedWithUserId,
sharedByUserId,
permission
permission,
createdAt: new Date()
}).returning();
return result[0];
}
async getSharedLabels(userId: string): Promise<SharedLabel[]> {
return await this.db.select()
.from(schema.sharedLabels)
.where(eq(schema.sharedLabels.sharedWithUserId, userId));
return await this.db.select().from(schema.sharedLabels).where(eq(schema.sharedLabels.sharedWithUserId, userId));
}
async getLabelSharedUsers(labelId: string): Promise<User[]> {
const result = await this.db.select({
id: schema.users.id,
username: schema.users.username,
email: schema.users.email,
role: schema.users.role,
isActive: schema.users.isActive,
xp: schema.users.xp,
level: schema.users.level,
currentStreak: schema.users.currentStreak,
lastTaskDate: schema.users.lastTaskDate,
showOnLeaderboard: schema.users.showOnLeaderboard,
isSearchable: schema.users.isSearchable,
apiKey: schema.users.apiKey,
aiEnabled: schema.users.aiEnabled,
password: schema.users.password
})
.from(schema.sharedLabels)
.innerJoin(schema.users, eq(schema.sharedLabels.sharedWithUserId, schema.users.id))
.where(eq(schema.sharedLabels.labelId, labelId));
return result;
const shares = await this.db.select().from(schema.sharedLabels).where(eq(schema.sharedLabels.labelId, labelId));
if (shares.length === 0) return [];
return await this.db.select().from(schema.users)
.where(sql`${schema.users.id} IN ${shares.map(s => s.sharedWithUserId)}`);
}
async unshareLabel(labelId: string, userId: string): Promise<boolean> {
const result = await this.db.delete(schema.sharedLabels)
.where(and(
eq(schema.sharedLabels.labelId, labelId),
eq(schema.sharedLabels.sharedWithUserId, userId)
))
.where(and(eq(schema.sharedLabels.labelId, labelId), eq(schema.sharedLabels.sharedWithUserId, userId)))
.returning();
return result.length > 0;
}
async getLabelShares(labelId: string): Promise<SharedLabel[]> {
return await this.db.select().from(schema.sharedLabels).where(eq(schema.sharedLabels.labelId, labelId));
}
async updateConversation(id: string, title: string): Promise<Conversation | undefined> {
const result = await this.db.update(schema.conversations)
.set({ title, updatedAt: new Date() })
.where(eq(schema.conversations.id, id))
.returning();
return result[0];
}
async getMessages(conversationId: string): Promise<Message[]> {
return await this.db.select()
.from(schema.sharedLabels)
.where(eq(schema.sharedLabels.labelId, labelId));
.from(schema.messages)
.where(eq(schema.messages.conversationId, conversationId))
.orderBy(asc(schema.messages.createdAt));
}
async getMessage(id: string): Promise<Message | undefined> {
const result = await this.db.select().from(schema.messages).where(eq(schema.messages.id, id));
return result[0];
}
async updateMessage(id: string, content: string): Promise<Message> {
const result = await this.db.update(schema.messages)
.set({ content })
.where(eq(schema.messages.id, id))
.returning();
return result[0];
}
async deleteMessagesAfter(conversationId: string, after: Date, excludeMessageId?: string): Promise<void> {
const filters = [
eq(schema.messages.conversationId, conversationId),
gt(schema.messages.createdAt, after)
];
if (excludeMessageId) {
filters.push(ne(schema.messages.id, excludeMessageId));
}
await this.db.delete(schema.messages)
.where(and(...filters));
}
// Audit Logs (DbStorage)
async createAuditLog(insertLog: InsertAuditLog): Promise<AuditLog> {
const result = await this.db.insert(schema.auditLogs).values(insertLog).returning();
return result[0];
}
async getAuditLogs(limit = 100): Promise<AuditLog[]> {
return await this.db.select()
.from(schema.auditLogs)
.orderBy(desc(schema.auditLogs.createdAt))
.limit(limit);
}
}
+58
View File
@@ -0,0 +1,58 @@
export const LEVEL_THRESHOLDS = [
0, // Level 1: 0-99
100, // Level 2: 100-249
250, // Level 3: 250-499
500, // Level 4: 500-999
1000, // Level 5: 1000-1999
2000, // Level 6: 2000-3499
3500, // Level 7: 3500-4999
5000, // Level 8: 5000-7499
7500, // Level 9: 7500-9999
10000 // Level 10: 10000+
];
export function getLevelFromXP(xp: number): number {
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
if (xp >= LEVEL_THRESHOLDS[i]) {
return i + 1;
}
}
return 1;
}
export function getNextLevelXP(level: number): number {
if (level >= LEVEL_THRESHOLDS.length) {
return LEVEL_THRESHOLDS[LEVEL_THRESHOLDS.length - 1] * 1.5; // Scale indefinitely
}
return LEVEL_THRESHOLDS[level];
}
export function getLevelProgress(xp: number): number {
const currentLevel = getLevelFromXP(xp);
const currentLevelStart = LEVEL_THRESHOLDS[currentLevel - 1];
const nextLevelStart = getNextLevelXP(currentLevel);
if (xp >= nextLevelStart) return 100;
const progress = ((xp - currentLevelStart) / (nextLevelStart - currentLevelStart)) * 100;
return Math.min(100, Math.max(0, progress));
}
export const RANK_KEYS = [
'novice', // Level 1
'apprentice', // Level 2
'journeyman', // Level 3
'artisan', // Level 4
'expert', // Level 5
'master', // Level 6
'grandmaster', // Level 7
'virtuoso', // Level 8
'legend', // Level 9
'mythic' // Level 10
];
export function getRankKey(level: number): string {
if (level <= 0) return 'novice';
if (level > RANK_KEYS.length) return RANK_KEYS[RANK_KEYS.length - 1];
return RANK_KEYS[level - 1];
}
+56
View File
@@ -57,6 +57,8 @@ export const tasks = pgTable("tasks", {
labelId: varchar("label_id").references(() => labels.id),
energyLevel: text("energy_level").default("medium"), // 'low' | 'medium' | 'high'
estimatedDuration: integer("estimated_duration"), // in minutes
parentTaskId: varchar("parent_task_id").references((): any => tasks.id), // Self-reference for subtasks
startDate: timestamp("start_date"), // For multi-day tasks
dependencies: text("dependencies").array(), // Array of task IDs
userId: varchar("user_id").references(() => users.id), // Added for ownership
});
@@ -108,6 +110,7 @@ export const insertLabelSchema = createInsertSchema(labels).omit({
export const insertTaskSchema = createInsertSchema(tasks, {
dueDate: z.coerce.date().nullable(),
startDate: z.coerce.date().nullable(),
}).omit({
id: true,
userId: true, // We will set this server-side
@@ -250,3 +253,56 @@ export type InsertPasswordResetToken = z.infer<typeof insertPasswordResetTokenSc
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
export type InsertUserReward = z.infer<typeof insertUserRewardSchema>;
// AI Chat Tables
export const conversations = pgTable("conversations", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").references(() => users.id).notNull(),
title: text("title").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const messages = pgTable("messages", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
conversationId: varchar("conversation_id").references(() => conversations.id).notNull(),
role: text("role").notNull(), // 'user' | 'assistant' | 'system'
content: text("content").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
export const insertConversationSchema = createInsertSchema(conversations).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertMessageSchema = createInsertSchema(messages).omit({
id: true,
createdAt: true,
});
export type InsertConversation = z.infer<typeof insertConversationSchema>;
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)
action: text("action").notNull(), // 'CREATE', 'UPDATE', 'DELETE', 'LOGIN', etc.
entityType: text("entity_type").notNull(), // 'TASK', 'GOAL', 'USER', 'SYSTEM'
entityId: text("entity_id"), // ID of the modified entity
details: json("details"), // JSON object with changed fields
source: text("source").notNull().default("USER"), // 'USER' | 'AI' | 'SYSTEM'
createdAt: timestamp("created_at").defaultNow(),
});
export const insertAuditLogSchema = createInsertSchema(auditLogs).omit({
id: true,
createdAt: true,
});
export type InsertAuditLog = z.infer<typeof insertAuditLogSchema>;
export type AuditLog = typeof auditLogs.$inferSelect;