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>