Files
task-manager/client/src/App.tsx
T
Paul Nothaft f91978c078
continuous-integration/drone/push Build is failing
feat: Add Web Push notifications and multiple UX improvements
- Add PWA manifest and service worker for push notifications
- Implement VAPID key generation and push subscription management
- Add push notification API endpoints (/api/push/*)
- Add push_subscriptions table to database schema
- Update notification settings UI with push support and iOS hints
- Fix translation issue showing "{ task } created" - add missing keys
- Fix mobile sidebar visibility for iOS home screen app
- Change default task filter from "all" to "open" (excludes done tasks)
- Add "Open" filter option to show only todo + inProgress tasks
2026-01-19 20:53:13 +01:00

557 lines
20 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Switch, Route, useLocation } from "wouter";
// ADHD Mode Components
import {
ADHDModeProvider,
useADHDMode,
BreakReminderProvider,
BreakReminderOverlay,
EnergyCheckIn,
HyperfocusGuard,
EncouragementToast
} from '@/components/adhd';
// Components
import TaskCreationModal from './components/TaskCreationModal';
import TaskDetailsModal from './components/TaskDetailsModal';
import TasksWithCalendar from './components/TasksWithCalendar';
import FocusMode from './components/FocusMode';
import CalendarView from './components/CalendarView';
import KanbanBoard from './components/KanbanBoard';
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"
import { useNotifications } from './hooks/use-notifications';
import { useToast } from "@/hooks/use-toast";
import { CommandPalette } from './components/CommandPalette';
import PomodoroOverlay from './components/PomodoroOverlay';
import AuthPage from "@/pages/AuthPage";
import LeaderboardPage from "@/pages/LeaderboardPage";
import SetupWizard from "@/pages/SetupWizard";
import AdminUserManagement from "@/pages/AdminUserManagement";
import AdminSettings from "@/pages/AdminSettings";
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 NotificationsPage from "@/pages/NotificationsPage";
import FocusRoutinePage from "@/pages/FocusRoutinePage";
import { useRoutineBlocker } from './components/RoutineBlocker';
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "@shared/schema";
import { Loader2 } from "lucide-react";
import TimeTrackingPage from './pages/TimeTrackingPage';
// ADHD Pages
import QuickWinsPage from './pages/QuickWinsPage';
import SingleTaskPage from './pages/SingleTaskPage';
import BodyDoublingPage from './pages/BodyDoublingPage';
import ADHDDashboardPage from './pages/ADHDDashboardPage';
// Inner App component that uses ADHD hooks
function AppContent() {
const { t } = useTranslation();
const { toast } = useToast();
const [, setLocation] = useLocation();
const isBlocked = useRoutineBlocker();
const { isEnabled: adhdEnabled, settings: adhdSettings } = useADHDMode();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null);
const [showPomodoro, setShowPomodoro] = useState(false);
const [activePomodoroTaskId, setActivePomodoroTaskId] = useState<string | null>(null);
// Enable global notifications polling
useNotifications({ poll: true });
const queryClient = useQueryClient();
const { data: user, isLoading: isLoadingUser } = useQuery<User>({
queryKey: ["/api/user"],
retry: false,
});
const { data: setupStatus } = useQuery<{ isSetup: boolean }>({
queryKey: ['/api/setup/status'],
});
// Fetch tasks and labels using React Query
const { data: tasksData } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
enabled: !!user,
});
const tasks = (tasksData ?? []).map(task => ({
...task,
dueDate: task.dueDate ? new Date(task.dueDate) : null
}));
const { data: labelsData } = useQuery<Label[]>({
queryKey: ['/api/labels'],
enabled: !!user,
});
const labels = labelsData ?? [];
const handleCreateTask = async (newTask: Partial<Task>) => {
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newTask.title || '',
description: newTask.description || undefined,
status: 'todo',
priority: newTask.priority || 'medium',
dueDate: newTask.dueDate || null, // Explicitly handled as nullable in schema
timeTracked: 0,
isTracking: false,
projectId: newTask.projectId || undefined,
notes: newTask.notes || undefined,
labelId: newTask.labelId || undefined,
energyLevel: newTask.energyLevel || 'medium',
estimatedDuration: newTask.estimatedDuration || undefined
})
});
if (!response.ok) throw new Error('Failed to create task');
const createdTask: Task = await response.json();
// 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'] });
toast({
title: t('task.created', 'Task Created'),
description: t('task.createdDescription', '"{title}" has been added.', { title: createdTask.title }),
});
} catch (error) {
console.error('Error creating task:', error);
toast({
title: t('error.createTask', 'Error'),
description: t('error.createTaskDescription', 'Failed to create task. Please try again.'),
variant: "destructive"
});
}
};
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) throw new Error('Failed to update task');
const updatedTask: Task = await response.json();
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => uniqueTasks(old, updatedTask));
} catch (error) {
console.error('Error updating task:', error);
// 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({
tasks,
onTaskUpdate: handleTaskUpdate
});
const handleStartPomodoro = (taskId: string) => {
setActivePomodoroTaskId(taskId);
setShowPomodoro(true);
startTimer(taskId);
};
const handleTaskStatusChange = (taskId: string, newStatus: Task['status']) => {
handleTaskUpdate(taskId, { status: newStatus });
};
const handleTaskDrop = (taskId: string, newDate: Date) => {
handleTaskUpdate(taskId, { dueDate: newDate });
};
const handleTaskClick = (task: Task) => {
setSelectedTask(task);
setIsTaskDetailsOpen(true);
};
const handleTaskDetailsClose = () => {
setIsTaskDetailsOpen(false);
setSelectedTask(null);
};
const handleTaskDetailsSave = (updatedTask: Task) => {
handleTaskUpdate(updatedTask.id, updatedTask);
};
const handleTaskDelete = async (taskId: string) => {
setDeleteTaskId(taskId);
};
const confirmTaskDelete = async () => {
if (!deleteTaskId) return;
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
const taskToDelete = tasks.find(task => task.id === deleteTaskId);
// Optimistic delete
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
old.filter(task => task.id !== deleteTaskId)
);
if (selectedTask?.id === deleteTaskId) {
handleTaskDetailsClose();
}
setDeleteTaskId(null);
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);
// Rollback
if (previousTasks) {
queryClient.setQueryData(['/api/tasks'], previousTasks);
}
}
};
if (isLoadingUser) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
// Setup Redirect
if (setupStatus && !setupStatus.isSetup) {
return (
<Switch>
<Route path="/setup" component={SetupWizard} />
<Route component={() => <SetupWizard />} />
</Switch>
);
}
if (isBlocked) {
return (
<div className="min-h-screen bg-background w-full">
<Switch>
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
{/* Catch all redirects to nothing, the hook handles the push to routine page */}
<Route component={() => <div />} />
</Switch>
<Toaster />
</div>
);
}
if (!user) {
return (
<div className="min-h-screen bg-background w-full">
<Switch>
<Route path="/auth" component={AuthPage} />
<Route path="/achievements" component={AchievementsPage} />
<Route path="/leaderboard" component={LeaderboardPage} />
<Route path="/time-tracking" component={TimeTrackingPage} />
<Route path="/ai" component={AiChatPage} />
<Route component={AuthPage} />
</Switch>
<Toaster />
</div>
);
}
return (
<TooltipProvider>
<SidebarProvider>
<AppSidebar user={user} />
<SidebarInset>
<div className={`min-h-screen bg-background flex flex-col ${adhdEnabled ? 'adhd-mode' : ''} ${adhdEnabled && adhdSettings.reducedAnimations ? 'reduced-motion' : ''} ${adhdEnabled && adhdSettings.largerTargets ? 'larger-targets' : ''}`}>
{/* Mobile Header with Sidebar Toggle */}
<header className="sticky top-0 z-50 flex items-center gap-3 px-4 py-3 bg-background/80 backdrop-blur-xl border-b border-border/50 md:hidden">
<SidebarTrigger className="h-9 w-9" />
<div className="flex items-center gap-2">
<img src="/favicon.png" alt="Logo" className="w-7 h-7 rounded-lg" />
<span className="font-bold text-lg">{t('app.title')}</span>
</div>
</header>
<main className="flex-1 p-4 md:p-6 max-w-screen-2xl mx-auto w-full relative">
<Switch>
<Route path="/">
<FocusMode
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
onNavigateToTasks={() => setLocation('/tasks')}
/>
</Route>
<Route path="/focus">
<FocusMode
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
onNavigateToTasks={() => setLocation('/tasks')}
/>
</Route>
<Route path="/calendar">
<CalendarView tasks={tasks} />
</Route>
<Route path="/weeklist">
<WeekListView tasks={tasks} onTaskUpdate={handleTaskUpdate} />
</Route>
<Route path="/kanban">
<KanbanBoard
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
/>
</Route>
<Route path="/auth">
{() => {
setLocation('/');
return null;
}}
</Route>
<Route path="/tasks">
<TasksWithCalendar
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
</Route>
<Route path="/templates">
<ProjectTemplate
onCreateFromTemplate={handleCreateFromTemplate}
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} />
</Route>
<Route path="/unscheduled">
<UnscheduledTasksPage
user={user}
onToggleCompletion={(taskId, currentStatus) => handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')}
onDelete={(id) => setDeleteTaskId(id)}
onUpdate={handleTaskUpdate}
onSelect={setSelectedTask}
/>
</Route>
<Route path="/leaderboard">
<LeaderboardPage />
</Route>
<Route path="/leaderboard">
<LeaderboardPage />
</Route>
<Route path="/ai">
<AiChatPage />
</Route>
<Route path="/notifications">
<NotificationsPage />
</Route>
<Route path="/time-tracking">
<TimeTrackingPage />
</Route>
<Route path="/settings">
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
</Route>
{/* Admin Route */}
{user?.role === 'admin' && (
<>
<Route path="/admin/users" component={AdminUserManagement} />
<Route path="/admin/settings" component={AdminSettings} />
</>
)}
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
{/* ADHD Mode Routes */}
<Route path="/adhd" component={ADHDDashboardPage} />
<Route path="/adhd/quick-wins" component={QuickWinsPage} />
<Route path="/adhd/single-task" component={SingleTaskPage} />
<Route path="/adhd/body-doubling" component={BodyDoublingPage} />
<Route component={NotFound} />
</Switch>
</main>
{/* Floating Action Button */}
<div className="fixed bottom-8 right-8 z-[100]">
<Button
size="icon"
className="h-14 w-14 rounded-full shadow-2xl bg-gradient-to-r from-violet-600 to-indigo-600 hover:scale-110 transition-transform duration-200"
onClick={() => setIsCreateModalOpen(true)}
data-testid="fab-create-task"
>
<Plus className="h-6 w-6 text-white" />
</Button>
</div>
<CommandPalette
onNavigate={(path) => setLocation(path.startsWith('/') ? path : `/${path}`)}
onCreateTask={() => setIsCreateModalOpen(true)}
/>
<PomodoroOverlay
isOpen={showPomodoro}
onClose={() => setShowPomodoro(false)}
taskId={activePomodoroTaskId}
taskTitle={tasks.find(t => t.id === activePomodoroTaskId)?.title || 'Quick Focus'}
onCompleteTask={(id) => handleTaskUpdate(id, { status: 'done', isTracking: false })}
/>
<Toaster />
{/* ADHD Mode Overlays */}
{adhdEnabled && <BreakReminderOverlay />}
{adhdEnabled && adhdSettings.hyperfocusProtection && <HyperfocusGuard maxMinutes={adhdSettings.hyperfocusMaxMinutes} />}
{adhdEnabled && adhdSettings.positiveMessagingLevel !== 'off' && <EncouragementToast />}
<TaskCreationModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSave={handleCreateTask}
/>
<TaskDetailsModal
isOpen={isTaskDetailsOpen}
onClose={handleTaskDetailsClose}
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)}>
<AlertDialogContent data-testid="dialog-delete-confirmation">
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteConfirmation.title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteConfirmation.description')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-cancel-delete">
{t('deleteConfirmation.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmTaskDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
data-testid="button-confirm-delete"
>
{t('deleteConfirmation.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
);
}
// Main App wrapper with ADHD providers
function App() {
return (
<ADHDModeProvider>
<BreakReminderProvider>
<AppContent />
</BreakReminderProvider>
</ADHDModeProvider>
);
}
export default App;