feat: add social features, leaderboard, auth enhancements, and admin fixes
continuous-integration/drone/push Build is passing

- Implement Social Features: Shared Tasks, Global Access, Privacy Settings (Leaderboard/Searchable).
- Add Leaderboard Page and API.
- Enhance Auth: Support Email/Username login, explicit duplicate registration errors.
- Fix: Admin login password hash regression.
- Refactor: Move to wouter for routing, add Admin Dashboard and User Management.
- Add Setup Wizard.
- Update UI with Sidebar and Gamification elements.
This commit is contained in:
2025-12-10 14:04:26 +01:00
parent d5b045158a
commit ccfb674318
52 changed files with 9206 additions and 1505 deletions
+21 -17
View File
@@ -1,20 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>TaskFlow - Personal Task Management</title>
<meta name="description" content="Personal task management app with calendar scheduling, kanban boards, and time tracking" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
<link rel="icon" type="image/png" href="/attached_assets/generated_images/TaskFlow_app_logo_icon_3a0e77e9.png" />
<link rel="apple-touch-icon" href="/attached_assets/generated_images/TaskFlow_app_logo_icon_3a0e77e9.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..600;1,14..32,400..600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
</body>
<head>
<meta charset="UTF-8" />
<title>TaskFlow - Personal Task Management</title>
<meta name="description"
content="Personal task management app with calendar scheduling, kanban boards, and time tracking" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
<link rel="icon" type="image/png" href="/attached_assets/generated_images/TaskFlow_app_logo_icon_3a0e77e9.png" />
<link rel="apple-touch-icon" href="/attached_assets/generated_images/TaskFlow_app_logo_icon_3a0e77e9.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..600;1,14..32,400..600&family=Outfit:wght@400;500;600;700&display=swap"
rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+235 -300
View File
@@ -1,11 +1,8 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { queryClient } from "./lib/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
@@ -16,122 +13,75 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import logoImage from '@assets/generated_images/TaskFlow_app_logo_icon_3a0e77e9.png';
import { Switch, Route, useLocation } from "wouter";
// Components
import BottomNavigation from './components/BottomNavigation';
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 ThemeToggle from './components/ThemeToggle';
import Settings from './pages/settings';
import AchievementsPage from './pages/AchievementsPage';
import { Task, Label } from '@shared/schema';
import { addDays, subDays } from 'date-fns';
import { useTimer } from './hooks/useTimer';
// Mock data for prototype
const initialTasks: Task[] = [
{
id: '1',
title: 'Review quarterly reports',
description: 'Analyze Q3 performance metrics and prepare summary',
status: 'todo',
priority: 'high',
dueDate: addDays(new Date(), 2),
timeTracked: 0,
isTracking: false,
projectId: 'project-1',
notes: null,
labelId: '2e8c3aa0-278f-4220-b2c5-aa109b4279b7' // Urgent label
},
{
id: '2',
title: 'Update mobile app interface',
description: 'Implement new design system and improve user experience',
status: 'inProgress',
priority: 'high',
dueDate: addDays(new Date(), 1),
timeTracked: 120,
isTracking: true,
projectId: 'project-2',
notes: null,
labelId: 'cb44bed1-8ba3-43fe-9498-bb28e483ed1f' // Work label
},
{
id: '3',
title: 'Team standup meeting',
description: null,
status: 'todo',
priority: 'low',
dueDate: null,
timeTracked: 0,
isTracking: false,
projectId: null,
notes: null,
labelId: '274f0ba4-a133-471a-bbe9-8189aa3b0106' // Personal label
},
{
id: '4',
title: 'Fix critical login bug',
description: 'Users unable to login with special characters in password',
status: 'todo',
priority: 'high',
dueDate: null,
timeTracked: 30,
isTracking: false,
projectId: 'project-2',
notes: null,
labelId: 'c5eccac9-7919-4eb1-bb50-badacad6b1c1' // Study label
},
{
id: '5',
title: 'Deploy new features',
description: null,
status: 'done',
priority: 'medium',
dueDate: subDays(new Date(), 2),
timeTracked: 120,
isTracking: false,
projectId: 'project-1',
notes: null,
labelId: null
}
];
import { Plus } from 'lucide-react';
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "./components/AppSidebar"
import { CommandPalette } from './components/CommandPalette';
import PomodoroOverlay from './components/PomodoroOverlay';
import AuthPage from "@/pages/AuthPage";
import SettingsPage from "@/pages/settings";
import LeaderboardPage from "@/pages/LeaderboardPage";
import SetupWizard from "@/pages/SetupWizard";
import AdminUserManagement from "@/pages/AdminUserManagement";
import NotFound from "@/pages/not-found";
import { useQuery } from "@tanstack/react-query";
import { User } from "@shared/schema";
import { Loader2 } from "lucide-react";
function App() {
const { t } = useTranslation();
const [currentTab, setCurrentTab] = useState('tasks');
const [tasks, setTasks] = useState<Task[]>(initialTasks);
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);
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null);
const [showPomodoro, setShowPomodoro] = useState(false);
const [activePomodoroTaskId, setActivePomodoroTaskId] = useState<string | null>(null);
// Fetch tasks and labels from server on mount
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
useEffect(() => {
if (!user) return;
const fetchData = async () => {
try {
// Fetch tasks
const tasksResponse = await fetch('/api/tasks');
if (tasksResponse.ok) {
const serverTasks: Task[] = await tasksResponse.json();
// Normalize server tasks - convert date strings to Date objects
const normalizedTasks = serverTasks.map(task => ({
...task,
dueDate: task.dueDate ? new Date(task.dueDate) : null
}));
// Merge server tasks with initial tasks (avoiding duplicates)
const existingIds = new Set(normalizedTasks.map(t => t.id));
const uniqueInitialTasks = initialTasks.filter(t => !existingIds.has(t.id));
setTasks([...normalizedTasks, ...uniqueInitialTasks]);
setTasks(normalizedTasks);
}
// Fetch labels
const labelsResponse = await fetch('/api/labels');
if (labelsResponse.ok) {
const serverLabels: Label[] = await labelsResponse.json();
@@ -139,11 +89,10 @@ function App() {
}
} catch (error) {
console.error('Error fetching data:', error);
// Keep initial data on error
}
};
fetchData();
}, []);
}, [user]);
const handleCreateTask = async (newTask: Partial<Task>) => {
try {
@@ -160,22 +109,20 @@ function App() {
isTracking: false,
projectId: newTask.projectId || null,
notes: newTask.notes || null,
labelId: newTask.labelId || null
labelId: newTask.labelId || null,
energyLevel: newTask.energyLevel || 'medium',
estimatedDuration: newTask.estimatedDuration || null
})
});
if (!response.ok) {
throw new Error('Failed to create task');
}
if (!response.ok) throw new Error('Failed to create task');
const createdTask: Task = await response.json();
// Normalize the created task's date
const normalizedTask = {
...createdTask,
dueDate: createdTask.dueDate ? new Date(createdTask.dueDate) : null
};
setTasks(prev => [...prev, normalizedTask]);
console.log('Task created:', normalizedTask);
} catch (error) {
console.error('Error creating task:', error);
}
@@ -188,36 +135,36 @@ function App() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error('Failed to update task');
}
if (!response.ok) throw new Error('Failed to update task');
const updatedTask: Task = await response.json();
// Normalize the updated task's date
const normalizedTask = {
...updatedTask,
dueDate: updatedTask.dueDate ? new Date(updatedTask.dueDate) : null
};
setTasks(prev => prev.map(task =>
task.id === taskId ? normalizedTask : task
));
console.log('Task updated:', taskId, updates);
setTasks(prev => prev.map(task => task.id === taskId ? normalizedTask : task));
} catch (error) {
console.error('Error updating task:', error);
// Optimistic update fallback
setTasks(prev => prev.map(task =>
task.id === taskId ? { ...task, ...updates } : task
));
setTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...updates } : task));
}
};
// Initialize the timer hook after handleTaskUpdate is defined
const handleCreateFromTemplate = (templateId: string, startDate: Date, projectName: string) => {
console.log('Creating project from template:', { templateId, startDate, projectName });
};
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 });
};
@@ -229,7 +176,6 @@ function App() {
const handleTaskClick = (task: Task) => {
setSelectedTask(task);
setIsTaskDetailsOpen(true);
console.log('Task clicked:', task.title);
};
const handleTaskDetailsClose = () => {
@@ -239,7 +185,6 @@ function App() {
const handleTaskDetailsSave = (updatedTask: Task) => {
handleTaskUpdate(updatedTask.id, updatedTask);
console.log('Task details saved:', updatedTask.title);
};
const handleTaskDelete = async (taskId: string) => {
@@ -248,215 +193,205 @@ function App() {
const confirmTaskDelete = async () => {
if (!deleteTaskId) return;
// Optimistic deletion - remove from UI immediately
const taskToDelete = tasks.find(task => task.id === deleteTaskId);
setTasks(prev => prev.filter(task => task.id !== deleteTaskId));
// Close task details modal if the deleted task was open
if (selectedTask?.id === deleteTaskId) {
handleTaskDetailsClose();
}
setDeleteTaskId(null);
try {
const response = await fetch(`/api/tasks/${deleteTaskId}`, {
method: 'DELETE'
});
// 404 is acceptable - task doesn't exist on backend (seeded task)
if (!response.ok && response.status !== 404) {
throw new Error('Failed to delete task');
}
console.log('Task deleted:', deleteTaskId);
const response = await fetch(`/api/tasks/${deleteTaskId}`, { method: 'DELETE' });
if (!response.ok && response.status !== 404) throw new Error('Failed to delete task');
} catch (error) {
console.error('Error deleting task:', error);
// Restore task on error
if (taskToDelete) {
setTasks(prev => [...prev, taskToDelete]);
}
if (taskToDelete) setTasks(prev => [...prev, taskToDelete]);
}
};
const handleCreateFromTemplate = (templateId: string, startDate: Date, projectName: string) => {
console.log('Creating project from template:', { templateId, startDate, projectName });
// In a real app, this would create multiple tasks based on the template
};
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>
);
}
const renderContent = () => {
switch (currentTab) {
case 'tasks':
return (
<TasksWithCalendar
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
);
case 'calendar':
return (
<CalendarView
tasks={tasks}
onTaskDrop={handleTaskDrop}
onTaskClick={handleTaskClick}
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
);
case 'kanban':
return (
<KanbanBoard
tasks={tasks}
onTaskStatusChange={handleTaskStatusChange}
onTaskUpdate={handleTaskUpdate}
onTaskClick={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
);
case 'weeklist':
return (
<WeekListView
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
);
case 'templates':
return (
<ProjectTemplate
onCreateFromTemplate={handleCreateFromTemplate}
onCreateTasks={(newTasks) => setTasks(prev => [...prev, ...newTasks])}
onNavigateToSettings={() => setCurrentTab('settings')}
/>
);
case 'settings':
return <Settings onNavigateToTemplates={() => setCurrentTab('templates')} />;
default:
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Page not found</p>
</div>
);
}
};
// Setup Redirect
if (setupStatus && !setupStatus.isSetup) {
return (
<Switch>
<Route path="/setup" component={SetupWizard} />
<Route component={() => <SetupWizard />} />
</Switch>
);
}
if (!user) {
return <AuthPage />;
}
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-16 items-center justify-between px-4">
<div className="flex items-center gap-2">
<img
src={logoImage}
alt="TaskFlow Logo"
className="w-8 h-8 object-contain rounded-lg"
data-testid="img-app-logo"
/>
<h1 className="text-lg font-semibold" data-testid="text-app-title">
{t('app.title')}
</h1>
</div>
<div className="flex items-center gap-2">
{(currentTab === 'settings' || currentTab === 'templates') && (
<Button
variant="outline"
size="sm"
onClick={() => setCurrentTab('tasks')}
data-testid="button-back-to-tasks"
>
{t('app.backToTasks')}
</Button>
<TooltipProvider>
<SidebarProvider>
<AppSidebar user={user} />
<SidebarInset>
<div className="flex flex-col min-h-screen bg-background">
{/* Mobile Header trigger */}
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 md:hidden">
<SidebarTrigger className="-ml-1" />
<div className="font-semibold">{t('app.title')}</div>
</header>
{/* Main Content */}
<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="/tasks">
<TasksWithCalendar
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
</Route>
<Route path="/calendar">
<CalendarView
tasks={tasks}
onTaskDrop={handleTaskDrop}
onTaskClick={handleTaskClick}
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
</Route>
<Route path="/kanban">
<KanbanBoard
tasks={tasks}
onTaskStatusChange={handleTaskStatusChange}
onTaskUpdate={handleTaskUpdate}
onTaskClick={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
</Route>
<Route path="/weeklist">
<WeekListView
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
</Route>
<Route path="/templates">
<ProjectTemplate
onCreateFromTemplate={handleCreateFromTemplate}
onCreateTasks={(newTasks) => setTasks(prev => [...prev, ...newTasks])}
onNavigateToSettings={() => setLocation('/settings')}
/>
</Route>
<Route path="/achievements">
<AchievementsPage user={user} />
</Route>
<Route path="/settings">
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
</Route>
{/* Admin Route */}
{user.role === 'admin' && (
<Route path="/admin/users" component={AdminUserManagement} />
)}
<ThemeToggle />
</div>
<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>
</header>
</div>
</SidebarInset>
</SidebarProvider>
{/* Main Content */}
<main className="pb-20 px-3 py-4 sm:px-4 sm:py-6 max-w-screen-2xl mx-auto">
{renderContent()}
</main>
<CommandPalette
onNavigate={(path) => setLocation(path.startsWith('/') ? path : `/${path}`)}
onCreateTask={() => setIsCreateModalOpen(true)}
/>
{/* Bottom Navigation */}
<BottomNavigation
activeTab={currentTab}
onTabChange={(tab) => {
if (tab === 'settings') {
setCurrentTab('settings');
} else {
setCurrentTab(tab);
}
}}
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 })}
/>
{/* Task Creation Modal */}
<TaskCreationModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSave={handleCreateTask}
/>
<Toaster />
{/* Task Details Modal */}
<TaskDetailsModal
isOpen={isTaskDetailsOpen}
onClose={handleTaskDetailsClose}
task={selectedTask}
onSave={handleTaskDetailsSave}
labels={labels}
/>
<TaskCreationModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSave={handleCreateTask}
/>
{/* Delete Confirmation Dialog */}
<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>
<Toaster />
</TooltipProvider>
</QueryClientProvider>
<TaskDetailsModal
isOpen={isTaskDetailsOpen}
onClose={handleTaskDetailsClose}
task={selectedTask}
onSave={handleTaskDetailsSave}
labels={labels}
/>
<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>
</TooltipProvider>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useTranslation } from 'react-i18next';
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award } from 'lucide-react';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarRail,
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 ThemeToggle from './ThemeToggle';
import { useLocation } from "wouter";
import { GamificationBar } from './GamificationBar';
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
user: User | undefined;
}
export function AppSidebar({ user, ...props }: AppSidebarProps) {
const { t } = useTranslation();
const { state } = useSidebar();
const { toast } = useToast();
const queryClient = useQueryClient();
const [location, setLocation] = useLocation();
const logoutMutation = useMutation({
mutationFn: async () => {
await fetch("/api/logout", { method: "POST" });
},
onSuccess: () => {
queryClient.setQueryData(["/api/user"], null);
toast({ title: "Logged out successfully" });
},
});
const items = [
{ title: t('navigation.focus'), id: 'focus', path: '/', icon: Target, color: 'text-red-500' },
{ 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('navigation.kanban'), id: 'kanban', path: '/kanban', icon: LayoutGrid, color: 'text-orange-500' },
{ title: t('navigation.achievements'), id: 'achievements', path: '/achievements', icon: Trophy, color: 'text-yellow-500' },
{ title: t('navigation.leaderboard'), id: 'leaderboard', path: '/leaderboard', icon: Award, color: 'text-yellow-500' },
{ title: t('navigation.settings'), id: 'settings', path: '/settings', icon: Settings, color: 'text-gray-500' },
]
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground h-14">
<div className="flex aspect-square size-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-600 to-indigo-600 text-white shadow-lg">
<CheckSquare className="size-5" />
</div>
{state !== 'collapsed' && (
<div className="grid flex-1 text-left text-sm leading-tight ml-2">
<span className="truncate font-bold text-base">{t('app.title')}</span>
<span className="truncate text-xs opacity-70">Personal</span>
</div>
)}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarMenu className="gap-2 px-2">
{items.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={location === item.path || (item.path !== '/' && location.startsWith(item.path))}
onClick={() => setLocation(item.path)}
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'}`}
>
<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' && (
<span className="font-medium text-base ml-2">{item.title}</span>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
{state !== 'collapsed' && user && (
<GamificationBar xp={user.xp} level={user.level} streak={user.currentStreak} />
)}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
>
<LogOut className={state === 'collapsed' ? 'size-5' : 'size-4'} />
{state !== 'collapsed' && <span className="font-medium ml-2">Logout</span>}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div className={`p-4 flex items-center ${state === 'collapsed' ? 'justify-center flex-col gap-4' : 'justify-between'}`}>
<ThemeToggle />
<SidebarTrigger className={state === 'collapsed' ? '' : 'ml-auto'} />
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
@@ -1,83 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Home, Calendar, List, LayoutGrid, Plus, Settings } from 'lucide-react';
import { useState } from 'react';
interface BottomNavigationProps {
onTabChange?: (tab: string) => void;
onCreateTask?: () => void;
activeTab?: string;
}
export default function BottomNavigation({ onTabChange, onCreateTask, activeTab = 'tasks' }: BottomNavigationProps) {
const { t } = useTranslation();
const [currentTab, setCurrentTab] = useState(activeTab);
const tabs = [
{ id: 'tasks', label: t('navigation.tasks'), icon: Home },
{ id: 'calendar', label: t('navigation.calendar'), icon: Calendar },
{ id: 'weeklist', label: t('navigation.weekList'), icon: List },
{ id: 'create', label: t('navigation.create'), icon: Plus, isCreate: true },
{ id: 'kanban', label: t('navigation.kanban'), icon: LayoutGrid },
{ id: 'settings', label: t('navigation.settings'), icon: Settings }
];
const handleTabClick = (tabId: string, isCreate?: boolean) => {
if (isCreate) {
onCreateTask?.();
console.log('Create task triggered');
return;
}
setCurrentTab(tabId);
onTabChange?.(tabId);
console.log('Tab changed to:', tabId);
};
return (
<div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border z-40">
<div className="flex items-center justify-around px-2 py-2 safe-area-inset-bottom">
{tabs.map((tab) => {
const isActive = currentTab === tab.id && !tab.isCreate;
const Icon = tab.icon;
return (
<Button
key={tab.id}
variant={tab.isCreate ? "default" : isActive ? "secondary" : "ghost"}
size={tab.isCreate ? "icon" : "sm"}
onClick={() => handleTabClick(tab.id, tab.isCreate)}
className={`relative flex flex-col gap-1 h-auto py-2 px-3 ${
tab.isCreate
? 'w-12 h-12 rounded-full shadow-lg'
: 'flex-1 max-w-[80px]'
}`}
data-testid={`nav-${tab.id}`}
>
<Icon className={`${
tab.isCreate ? 'w-6 h-6' : 'w-5 h-5'
}`} />
{!tab.isCreate && (
<span className="text-xs font-medium">
{tab.label}
</span>
)}
{tab.badge && !tab.isCreate && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center text-xs p-0 min-w-[20px]"
data-testid={`badge-${tab.id}`}
>
{tab.badge}
</Badge>
)}
</Button>
);
})}
</div>
</div>
);
}
+76 -70
View File
@@ -60,9 +60,11 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
const dates = Array.from({ length: 35 }, (_, i) => addDays(startDate, i));
const getTasksForDate = (date: Date) => {
return tasks.filter(task =>
task.dueDate && isSameDay(task.dueDate, date)
);
return tasks.filter(task => {
if (!task.dueDate) return false;
const taskDate = new Date(task.dueDate);
return isSameDay(taskDate, date);
});
};
const handleDragStart = (taskId: string) => {
@@ -98,15 +100,15 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
longPressTriggered.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggered.current = true;
setLongPressTask(task);
setDropdownOpen(true);
console.log('Long press detected for task:', task.title);
// Reset after a delay to allow next click
setTimeout(() => {
longPressTriggered.current = false;
@@ -124,13 +126,13 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
const handleTaskClick = (task: Task, e: React.MouseEvent | React.TouchEvent) => {
// Prevent click when dragging
if (draggedTask === task.id) return;
// Skip click if long press was just triggered
if (longPressTriggered.current) {
e.preventDefault();
return;
}
e.stopPropagation();
onTaskClick?.(task);
console.log('Calendar task clicked:', task.title);
@@ -139,7 +141,7 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
// Menu content component to avoid duplication
const TaskMenuContent = ({ task, closeMenu }: { task: Task; closeMenu?: () => void }) => (
<>
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
@@ -150,8 +152,8 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
if (task.isTracking) {
@@ -166,11 +168,11 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</ContextMenuItem>
<ContextMenuSeparator />
{task.status !== 'done' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'done' });
@@ -182,9 +184,9 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{t('taskCard.markAsDone')}
</ContextMenuItem>
)}
{task.status === 'todo' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'inProgress' });
@@ -196,9 +198,9 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{t('taskCard.startWorking')}
</ContextMenuItem>
)}
{task.status === 'inProgress' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'todo' });
@@ -210,9 +212,9 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{t('taskCard.moveToTodo')}
</ContextMenuItem>
)}
{task.status === 'done' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'inProgress' });
@@ -224,11 +226,11 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onTaskDelete && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskDelete(task.id);
@@ -247,18 +249,21 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
return (
<div className="space-y-4">
{/* Header */}
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-2 sm:pb-3 mb-3 sm:mb-4">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-2 sm:pb-3 mb-3 sm:mb-4 pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 text-primary" />
<h2 className="text-base sm:text-lg font-semibold" data-testid="text-calendar-title">
{t('calendar.title')}
</h2>
<Badge variant="outline" className="ml-2 text-xs font-normal text-muted-foreground inline-flex">
{t('analytics.cw')} {format(currentDate, 'w', { locale: dateLocale })}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigateWeek('prev')}
data-testid="button-prev-week"
@@ -266,9 +271,9 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigateWeek('next')}
data-testid="button-next-week"
@@ -286,18 +291,16 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
const dayTasks = getTasksForDate(date);
const isToday = isSameDay(date, new Date());
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const isMonday = date.getDay() === 1;
const isPreviousWeek = index < 7; // First 7 days are the previous week
return (
<Card
<Card
key={index}
className={`p-2 sm:p-3 min-h-[100px] sm:min-h-[120px] hover-elevate active-elevate-2 transition-all ${
isToday ? 'ring-2 ring-primary' : ''
} ${
isWeekend ? 'bg-muted/30' : ''
} ${
isPreviousWeek ? 'opacity-50 bg-muted/60' : ''
}`}
className={`p-2 sm:p-3 min-h-[100px] sm:min-h-[120px] hover-elevate active-elevate-2 transition-all ${isToday ? 'ring-2 ring-primary' : ''
} ${isWeekend ? 'bg-muted/30' : ''
} ${isPreviousWeek ? 'opacity-50 bg-muted/60' : ''
}`}
onDragOver={handleDragOver}
onDrop={() => handleDrop(date)}
onClick={() => {
@@ -306,24 +309,27 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
}}
data-testid={`calendar-date-${format(date, 'yyyy-MM-dd')}`}
>
<div className="text-center mb-2">
<div className={`text-xs font-medium ${
isPreviousWeek ? 'text-muted-foreground/60' : 'text-muted-foreground'
}`}>
<div className="text-center mb-2 relative">
{isMonday && (
<span className="absolute left-0 top-0 text-[10px] text-muted-foreground/70 font-mono">
{t('analytics.cw')} {format(date, 'w', { locale: dateLocale })}
</span>
)}
<div className={`text-xs font-medium ${isPreviousWeek ? 'text-muted-foreground/60' : 'text-muted-foreground'
}`}>
{format(date, 'EEE', { locale: dateLocale })}
</div>
<div className={`text-sm font-semibold ${
isToday ? 'text-primary' : isPreviousWeek ? 'text-muted-foreground/60' : ''
}`}>
<div className={`text-sm font-semibold ${isToday ? 'text-primary' : isPreviousWeek ? 'text-muted-foreground/60' : ''
}`}>
{format(date, 'd', { locale: dateLocale })}
</div>
</div>
<div className="space-y-1">
{dayTasks.slice(0, 3).map((task, taskIndex) => {
// Find the label for this task
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
return (
<ContextMenu key={task.id}>
<ContextMenuTrigger asChild>
@@ -338,10 +344,10 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
data-testid={`calendar-task-${task.id}`}
>
<Badge
variant="outline"
<Badge
variant="outline"
className="w-full justify-start text-xs p-1 h-auto"
style={taskLabel ? {
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
@@ -359,7 +365,7 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
</ContextMenu>
);
})}
{dayTasks.length > 3 && (
<Badge variant="secondary" className="w-full justify-center text-xs">
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
@@ -370,7 +376,7 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
);
})}
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
@@ -389,8 +395,8 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{/* Mobile long-press fallback menu */}
{longPressTask && (
<DropdownMenu
open={dropdownOpen}
<DropdownMenu
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
if (!open) {
@@ -400,7 +406,7 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
>
<DropdownMenuTrigger className="hidden" />
<DropdownMenuContent>
<DropdownMenuItem
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(longPressTask);
@@ -411,8 +417,8 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</DropdownMenuItem>
<DropdownMenuItem
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (longPressTask.isTracking) {
@@ -427,11 +433,11 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
<Timer className="w-4 h-4 mr-2" />
{longPressTask.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{longPressTask.status !== 'done' && (
<DropdownMenuItem
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(longPressTask.id, { status: 'done' });
@@ -443,9 +449,9 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{t('taskCard.markAsDone')}
</DropdownMenuItem>
)}
{longPressTask.status === 'todo' && (
<DropdownMenuItem
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(longPressTask.id, { status: 'inProgress' });
@@ -457,9 +463,9 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{t('taskCard.startWorking')}
</DropdownMenuItem>
)}
{longPressTask.status === 'inProgress' && (
<DropdownMenuItem
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(longPressTask.id, { status: 'todo' });
@@ -471,9 +477,9 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{t('taskCard.moveToTodo')}
</DropdownMenuItem>
)}
{longPressTask.status === 'done' && (
<DropdownMenuItem
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(longPressTask.id, { status: 'inProgress' });
@@ -485,11 +491,11 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
{t('taskCard.reopenTask')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{onTaskDelete && (
<DropdownMenuItem
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskDelete(longPressTask.id);
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { useTranslation } from "react-i18next";
import {
Calculator,
Calendar,
CreditCard,
Settings,
Smile,
User,
LayoutGrid,
List,
CheckSquare,
Plus,
Moon,
Sun,
Search
} from "lucide-react";
import { useTheme } from "next-themes";
import { Dialog, DialogContent } from "@/components/ui/dialog";
interface CommandPaletteProps {
onNavigate: (tab: string) => void;
onCreateTask: () => void;
}
export function CommandPalette({ onNavigate, onCreateTask }: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const { setTheme, theme } = useTheme();
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const runCommand = (command: () => void) => {
setOpen(false);
command();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0 overflow-hidden shadow-2xl max-w-2xl">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Command.Input
placeholder={t('search.placeholder') || "Type a command or search..."}
className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<Command.List className="max-h-[300px] overflow-y-auto overflow-x-hidden p-2">
<Command.Empty>{t('search.noResults') || "No results found."}</Command.Empty>
<Command.Group heading="Actions">
<Command.Item
onSelect={() => runCommand(onCreateTask)}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<Plus className="mr-2 h-4 w-4" />
<span>{t('taskCreation.title') || "Create New Task"}</span>
<span className="ml-auto text-xs text-muted-foreground">Cmd+N</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => setTheme(theme === 'dark' ? 'light' : 'dark'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
{theme === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
<span>{t('theme.toggle') || "Toggle Theme"}</span>
</Command.Item>
</Command.Group>
<Command.Group heading="Navigation">
<Command.Item
onSelect={() => runCommand(() => onNavigate('tasks'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<CheckSquare className="mr-2 h-4 w-4" />
<span>Tasks</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => onNavigate('calendar'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<Calendar className="mr-2 h-4 w-4" />
<span>Calendar</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => onNavigate('weeklist'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<List className="mr-2 h-4 w-4" />
<span>Week List</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => onNavigate('kanban'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<LayoutGrid className="mr-2 h-4 w-4" />
<span>Kanban Board</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => onNavigate('settings'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</Command.Item>
</Command.Group>
</Command.List>
</Command>
</DialogContent>
</Dialog>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Task } from '@shared/schema';
import TaskCard from './TaskCard';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Trophy, Target, Calendar as CalendarIcon, ArrowRight, GripVertical } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface FocusModeProps {
tasks: Task[];
onTaskUpdate: (taskId: string, updates: Partial<Task>) => void;
onTaskEdit: (task: Task) => void;
onTaskDelete: (taskId: string) => void;
onStartTimer: (taskId: string) => void;
onStopTimer: (taskId: string) => void;
onNavigateToTasks: () => void;
}
export default function FocusMode({
tasks,
onTaskUpdate,
onTaskEdit,
onTaskDelete,
onStartTimer,
onStopTimer,
onNavigateToTasks
}: FocusModeProps) {
const { t, i18n } = useTranslation();
const greeting = useMemo(() => {
const hour = new Date().getHours();
if (hour < 12) return t('focus.greeting.morning');
if (hour < 18) return t('focus.greeting.afternoon');
return t('focus.greeting.evening');
}, [t, i18n.language]);
const stats = useMemo(() => {
const today = new Date();
const completedToday = tasks.filter(t => {
// This is a mock check for 'completed today' since we don't track completion date in this schema
// We'll just use 'done' tasks for the prototype visual
return t.status === 'done';
}).length;
const totalActive = tasks.filter(t => t.status !== 'done').length;
return { completedToday, totalActive };
}, [tasks]);
// Sortable item component
const SortableTaskItem = ({ task }: { task: Task }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className="group relative touch-none">
<div {...attributes} {...listeners} className="absolute left-2 top-1/2 -translate-y-1/2 p-2 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
<div className="pl-8">
<TaskCard
task={task}
onStatusChange={(status) => onTaskUpdate(task.id, { status })}
onUpdate={(updates) => onTaskUpdate(task.id, updates)}
onEdit={() => onTaskEdit(task)}
onDelete={() => onTaskDelete(task.id)}
onStartTimer={() => onStartTimer(task.id)}
onStopTimer={() => onStopTimer(task.id)}
/>
</div>
</div>
);
};
const focusTasks = useMemo(() => {
// Return top 3 tasks for focus list
return tasks.filter(t => t.status !== 'done')
.sort((a, b) => {
if (a.priority === 'high' && b.priority !== 'high') return -1;
if (b.priority === 'high' && a.priority !== 'high') return 1;
return 0;
})
.slice(0, 3);
}, [tasks]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: any) => {
// In a real app we'd persist the new order
// For now we just animate it visually
const { active, over } = event;
if (active.id !== over.id) {
// logic to reorder would go here
console.log('Reordered', active.id, over.id);
}
};
return (
<div className="max-w-4xl mx-auto space-y-8 p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent">
{greeting}, Paul
</h1>
<p className="text-muted-foreground text-lg">
{t('focus.stats.activeTasksMessage', { count: stats.totalActive })}
</p>
</motion.div>
<div className="grid gap-6 md:grid-cols-2">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
>
<Card className="p-6 h-full bg-gradient-to-br from-violet-50 to-indigo-50 dark:from-violet-950/20 dark:to-indigo-950/20 border-violet-100 dark:border-violet-900/50">
<div className="flex items-center gap-4 mb-4">
<div className="p-3 bg-white dark:bg-violet-900/50 rounded-xl shadow-sm">
<Trophy className="w-6 h-6 text-violet-600 dark:text-violet-400" />
</div>
<div>
<h3 className="font-semibold text-lg">{t('focus.stats.dailyProgress')}</h3>
<p className="text-sm text-muted-foreground">{t('focus.stats.momentum')}</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-end gap-2">
<span className="text-4xl font-bold text-violet-700 dark:text-violet-300">{stats.completedToday}</span>
<span className="text-muted-foreground mb-1">{t('focus.stats.tasksCompleted')}</span>
</div>
<div className="h-2 bg-white/50 dark:bg-black/20 rounded-full overflow-hidden">
<div
className="h-full bg-violet-500 rounded-full transition-all duration-1000"
style={{ width: `${Math.min((stats.completedToday / (stats.completedToday + stats.totalActive || 1)) * 100, 100)}%` }}
/>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
>
<Card className="p-6 h-full flex flex-col justify-center items-center text-center bg-card hover:shadow-md transition-all cursor-pointer" onClick={onNavigateToTasks}>
<Target className="w-12 h-12 text-muted-foreground/20 mb-4" />
<h3 className="font-semibold text-lg">{t('focus.viewAll.title')}</h3>
<p className="text-sm text-muted-foreground mb-4">{t('focus.viewAll.description')}</p>
<Button variant="outline" className="gap-2 group">
{t('focus.viewAll.button')} <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Card>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Target className="w-5 h-5 text-red-500" />
{t('focus.list.title')}
</h2>
<span className="text-xs text-muted-foreground">{t('focus.list.subtitle')}</span>
</div>
{focusTasks.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={focusTasks.map(t => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{focusTasks.map(task => (
<SortableTaskItem key={task.id} task={task} />
))}
</div>
</SortableContext>
</DndContext>
) : (
<Card className="p-12 text-center text-muted-foreground bg-muted/50 border-dashed">
<p>{t('focus.list.empty')}</p>
</Card>
)}
</motion.div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { Trophy, Flame } from 'lucide-react';
import { Progress } from "@/components/ui/progress";
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import {
getLevelFromXP,
getNextLevelXP,
getLevelProgress,
getRankKey
} from "@/lib/gamification";
import { LevelDetailsModal } from './LevelDetailsModal';
interface GamificationBarProps {
xp: number;
level: number; // Keeping prop for backwards compat, but should calculate from XP usually
streak: number;
}
export function GamificationBar({ xp, streak }: GamificationBarProps) {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
// Use centralized logic
const computedLevel = getLevelFromXP(xp);
const nextLevelXP = getNextLevelXP(computedLevel);
const progress = getLevelProgress(xp);
const rankKey = getRankKey(computedLevel);
return (
<>
<div
className="p-4 border-b bg-sidebar-accent/10 cursor-pointer hover:bg-sidebar-accent/20 transition-colors"
onClick={() => setIsModalOpen(true)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="bg-yellow-500/20 p-1 rounded-md text-yellow-600">
<Trophy className="size-4" />
</div>
<div>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('gamification.level', { level: computedLevel })}
</span>
<div className="text-sm font-bold">
{t(`ranks.${rankKey}`)}
</div>
</div>
</div>
<div className="flex items-center gap-1 text-orange-600 bg-orange-500/10 px-2 py-1 rounded-full">
<Flame className="size-3 fill-orange-600" />
<span className="text-xs font-bold">
{t('gamification.streak', { count: streak })}
</span>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t('gamification.xp', { count: xp })}</span>
<span>{t('gamification.nextLevel', { count: nextLevelXP })}</span>
</div>
<Progress value={progress} className="h-2 bg-slate-200" />
</div>
</div>
<LevelDetailsModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
xp={xp}
/>
</>
);
}
+200 -200
View File
@@ -42,7 +42,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
const [viewMode, setViewMode] = useState<ViewMode>('traditional');
const [currentDate, setCurrentDate] = useState(new Date());
const [draggedTask, setDraggedTask] = useState<string | null>(null);
// Filter state
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
const [selectedPriorities, setSelectedPriorities] = useState<string[]>([]);
@@ -65,36 +65,36 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
const getTasksForColumn = (status: Task['status']) => {
let filteredTasks = tasks.filter(task => task.status === status);
// Apply label filtering
if (selectedLabels.length > 0) {
filteredTasks = filteredTasks.filter(task =>
filteredTasks = filteredTasks.filter(task =>
task.labelId && selectedLabels.includes(task.labelId)
);
}
// Apply priority filtering
if (selectedPriorities.length > 0) {
filteredTasks = filteredTasks.filter(task =>
filteredTasks = filteredTasks.filter(task =>
selectedPriorities.includes(task.priority)
);
}
// Apply date filtering for weekly/monthly views
if (viewMode === 'weekly') {
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 }); // Monday = 1
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 });
filteredTasks = filteredTasks.filter(task =>
filteredTasks = filteredTasks.filter(task =>
task.dueDate && isWithinInterval(task.dueDate, { start: weekStart, end: weekEnd })
);
} else if (viewMode === 'monthly') {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
filteredTasks = filteredTasks.filter(task =>
filteredTasks = filteredTasks.filter(task =>
task.dueDate && isWithinInterval(task.dueDate, { start: monthStart, end: monthEnd })
);
}
return filteredTasks;
};
@@ -139,7 +139,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
// Filter management functions
const toggleLabel = (labelId: string) => {
setSelectedLabels(prev =>
setSelectedLabels(prev =>
prev.includes(labelId)
? prev.filter(id => id !== labelId)
: [...prev, labelId]
@@ -147,7 +147,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
};
const togglePriority = (priority: string) => {
setSelectedPriorities(prev =>
setSelectedPriorities(prev =>
prev.includes(priority)
? prev.filter(p => p !== priority)
: [...prev, priority]
@@ -175,7 +175,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
return (
<div className="space-y-4">
{/* Header */}
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-2 sm:pb-3 mb-3 sm:mb-4">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-2 sm:pb-3 mb-3 sm:mb-4 pt-4">
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 sm:w-5 sm:h-5 text-primary" />
@@ -183,11 +183,11 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{t('kanban.title')}
</h2>
</div>
{viewMode !== 'traditional' && (
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigatePeriod('prev')}
data-testid="button-prev-period"
@@ -195,13 +195,13 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium min-w-[120px] text-center">
{getPeriodTitle()}
</span>
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigatePeriod('next')}
data-testid="button-next-period"
@@ -215,192 +215,192 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{/* Filter Bar */}
<Card className="p-3 sm:p-4">
<div className="flex items-center justify-between flex-wrap gap-3 sm:gap-4">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{t('kanban.filters')}</span>
{hasActiveFilters && (
<Badge variant="secondary" className="text-xs">
{selectedLabels.length + selectedPriorities.length}
</Badge>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Labels Filter */}
<div className="flex items-center justify-between flex-wrap gap-3 sm:gap-4">
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="justify-start"
data-testid="button-filter-labels"
>
<Tag className="w-4 h-4 mr-2" />
{t('kanban.labels')}
{selectedLabels.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedLabels.length}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-56" align="start">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t('kanban.filterByLabels')}</h4>
<Separator />
<div className="space-y-2 max-h-48 overflow-y-auto">
{labels.length > 0 ? (
labels.map((label) => (
<div
key={label.id}
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{t('kanban.filters')}</span>
{hasActiveFilters && (
<Badge variant="secondary" className="text-xs">
{selectedLabels.length + selectedPriorities.length}
</Badge>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Labels Filter */}
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="justify-start"
data-testid="button-filter-labels"
>
<Tag className="w-4 h-4 mr-2" />
{t('kanban.labels')}
{selectedLabels.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedLabels.length}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-56" align="start">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t('kanban.filterByLabels')}</h4>
<Separator />
<div className="space-y-2 max-h-48 overflow-y-auto">
{labels.length > 0 ? (
labels.map((label) => (
<div
key={label.id}
className="flex items-center space-x-2"
>
<Checkbox
id={`label-${label.id}`}
checked={selectedLabels.includes(label.id)}
onCheckedChange={() => toggleLabel(label.id)}
data-testid={`checkbox-label-${label.id}`}
/>
<label
htmlFor={`label-${label.id}`}
className="flex items-center space-x-2 text-sm cursor-pointer flex-1"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span>{label.name}</span>
</label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">{t('kanban.noLabelsAvailable')}</p>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* Priorities Filter */}
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="justify-start"
data-testid="button-filter-priorities"
>
<AlertTriangle className="w-4 h-4 mr-2" />
{t('kanban.priorities')}
{selectedPriorities.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedPriorities.length}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48" align="start">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t('kanban.filterByPriority')}</h4>
<Separator />
<div className="space-y-2">
{priorities.map((priority) => (
<div
key={priority}
className="flex items-center space-x-2"
>
<Checkbox
id={`label-${label.id}`}
checked={selectedLabels.includes(label.id)}
onCheckedChange={() => toggleLabel(label.id)}
data-testid={`checkbox-label-${label.id}`}
id={`priority-${priority}`}
checked={selectedPriorities.includes(priority)}
onCheckedChange={() => togglePriority(priority)}
data-testid={`checkbox-priority-${priority}`}
/>
<label
htmlFor={`label-${label.id}`}
htmlFor={`priority-${priority}`}
className="flex items-center space-x-2 text-sm cursor-pointer flex-1"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span>{label.name}</span>
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(priority)}`}
>
{t(`priority.${priority}`)}
</Badge>
</label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">{t('kanban.noLabelsAvailable')}</p>
)}
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</PopoverContent>
</Popover>
</div>
{/* Priorities Filter */}
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="justify-start"
data-testid="button-filter-priorities"
{/* Clear Filters */}
{hasActiveFilters && (
<>
<Separator orientation="vertical" className="h-6" />
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
data-testid="button-clear-filters"
className="text-muted-foreground hover:text-foreground"
>
<AlertTriangle className="w-4 h-4 mr-2" />
{t('kanban.priorities')}
{selectedPriorities.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedPriorities.length}
</Badge>
)}
<X className="w-4 h-4 mr-2" />
{t('kanban.clearAll')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48" align="start">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t('kanban.filterByPriority')}</h4>
<Separator />
<div className="space-y-2">
{priorities.map((priority) => (
<div
key={priority}
className="flex items-center space-x-2"
>
<Checkbox
id={`priority-${priority}`}
checked={selectedPriorities.includes(priority)}
onCheckedChange={() => togglePriority(priority)}
data-testid={`checkbox-priority-${priority}`}
/>
<label
htmlFor={`priority-${priority}`}
className="flex items-center space-x-2 text-sm cursor-pointer flex-1"
>
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(priority)}`}
>
{t(`priority.${priority}`)}
</Badge>
</label>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
{/* Clear Filters */}
{hasActiveFilters && (
<>
<Separator orientation="vertical" className="h-6" />
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
data-testid="button-clear-filters"
className="text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4 mr-2" />
{t('kanban.clearAll')}
</Button>
</>
)}
</div>
</div>
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="mt-3 pt-3 border-t">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('kanban.activeFilters')}</span>
{selectedLabels.map((labelId) => {
const label = labels.find(l => l.id === labelId);
return label ? (
<Badge
key={labelId}
variant="outline"
className="text-xs"
data-testid={`active-filter-label-${labelId}`}
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="mt-3 pt-3 border-t">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('kanban.activeFilters')}</span>
{selectedLabels.map((labelId) => {
const label = labels.find(l => l.id === labelId);
return label ? (
<Badge
key={labelId}
variant="outline"
className="text-xs"
data-testid={`active-filter-label-${labelId}`}
>
<div
className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: label.color }}
/>
{label.name}
<X
className="w-3 h-3 ml-1 cursor-pointer"
onClick={() => toggleLabel(labelId)}
/>
</Badge>
) : null;
})}
{selectedPriorities.map((priority) => (
<Badge
key={priority}
variant="outline"
className={`text-xs ${getPriorityColor(priority)}`}
data-testid={`active-filter-priority-${priority}`}
>
<div
className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: label.color }}
/>
{label.name}
<X
className="w-3 h-3 ml-1 cursor-pointer"
onClick={() => toggleLabel(labelId)}
{t(`priority.${priority}`)}
<X
className="w-3 h-3 ml-1 cursor-pointer"
onClick={() => togglePriority(priority)}
/>
</Badge>
) : null;
})}
{selectedPriorities.map((priority) => (
<Badge
key={priority}
variant="outline"
className={`text-xs ${getPriorityColor(priority)}`}
data-testid={`active-filter-priority-${priority}`}
>
{t(`priority.${priority}`)}
<X
className="w-3 h-3 ml-1 cursor-pointer"
onClick={() => togglePriority(priority)}
/>
</Badge>
))}
))}
</div>
</div>
</div>
)}
</Card>
)}
</Card>
</div>
{/* View Mode Tabs */}
@@ -425,9 +425,9 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
{columns.map((column) => {
const columnTasks = getTasksForColumn(column.status);
return (
<Card
<Card
key={column.id}
className="p-3 sm:p-4"
onDragOver={handleDragOver}
@@ -442,7 +442,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{columnTasks.length}
</Badge>
</div>
<div className="space-y-2 sm:space-y-3 min-h-[150px] sm:min-h-[200px]">
{columnTasks.map((task) => (
<div
@@ -452,7 +452,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
onDragEnd={handleDragEnd}
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
>
<TaskCard
<TaskCard
task={task}
onStartTimer={() => onStartTimer?.(task.id)}
onStopTimer={() => onStopTimer?.(task.id)}
@@ -472,7 +472,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
/>
</div>
))}
{columnTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
{t('kanban.noTasksInColumn', { column: column.title.toLowerCase() })}
@@ -489,9 +489,9 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
{columns.map((column) => {
const columnTasks = getTasksForColumn(column.status);
return (
<Card
<Card
key={column.id}
className="p-3 sm:p-4"
onDragOver={handleDragOver}
@@ -506,7 +506,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{columnTasks.length}
</Badge>
</div>
<div className="space-y-2 sm:space-y-3 min-h-[150px] sm:min-h-[200px]">
{columnTasks.map((task) => (
<div
@@ -516,7 +516,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
onDragEnd={handleDragEnd}
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
>
<TaskCard
<TaskCard
task={task}
onStartTimer={() => onStartTimer?.(task.id)}
onStopTimer={() => onStopTimer?.(task.id)}
@@ -536,7 +536,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
/>
</div>
))}
{columnTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
{t('kanban.noTasksInColumnForWeek', { column: column.title.toLowerCase() })}
@@ -553,9 +553,9 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
{columns.map((column) => {
const columnTasks = getTasksForColumn(column.status);
return (
<Card
<Card
key={column.id}
className="p-3 sm:p-4"
onDragOver={handleDragOver}
@@ -570,7 +570,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{columnTasks.length}
</Badge>
</div>
<div className="space-y-2 sm:space-y-3 min-h-[150px] sm:min-h-[200px]">
{columnTasks.map((task) => (
<div
@@ -580,7 +580,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
onDragEnd={handleDragEnd}
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
>
<TaskCard
<TaskCard
task={task}
onStartTimer={() => onStartTimer?.(task.id)}
onStopTimer={() => onStopTimer?.(task.id)}
@@ -600,7 +600,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
/>
</div>
))}
{columnTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
{t('kanban.noTasksInColumnForMonth', { column: column.title.toLowerCase() })}
@@ -0,0 +1,96 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { Trophy, Star, TrendingUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
getLevelFromXP,
getNextLevelXP,
getLevelProgress,
getRankKey
} from "@/lib/gamification";
interface LevelDetailsModalProps {
isOpen: boolean;
onClose: () => void;
xp: number;
}
export function LevelDetailsModal({ isOpen, onClose, xp }: LevelDetailsModalProps) {
const { t } = useTranslation();
const level = getLevelFromXP(xp);
const nextLevelXP = getNextLevelXP(level);
const progress = getLevelProgress(xp);
const rankKey = getRankKey(level);
// Calculate XP needed for next level relative to current level start
// Note: For display, we usually show Total XP / Threshold or Current Level XP / needed
// Let's show Total XP / Next Threshold for clarity as typical in RPGs
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
{t("achievements.levelDetails")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center py-6 space-y-6">
{/* Level Badge */}
<div className="relative">
<div className="h-24 w-24 rounded-full bg-gradient-to-br from-yellow-100 to-yellow-50 dark:from-yellow-900/20 dark:to-yellow-800/20 flex items-center justify-center border-4 border-yellow-200 dark:border-yellow-700">
<span className="text-4xl font-bold text-yellow-600 dark:text-yellow-400">
{level}
</span>
</div>
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-yellow-500 text-white text-xs font-bold rounded-full shadow-md whitespace-nowrap">
{t(`gamification.level`, { level })}
</div>
</div>
{/* Rank Title */}
<div className="text-center">
<h3 className="text-2xl font-bold text-foreground">
{t(`ranks.${rankKey}`)}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t("achievements.xpDescription")}
</p>
</div>
{/* Progress Section */}
<div className="w-full space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium text-muted-foreground">
{t("gamification.currentXP", { current: xp, next: nextLevelXP })}
</span>
<span className="font-bold text-primary">
{Math.round(progress)}%
</span>
</div>
<Progress value={progress} className="h-4" />
</div>
{/* Benefits / Rewards Placeholder */}
<div className="w-full bg-muted/50 rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm flex items-center gap-2">
<Star className="h-4 w-4 text-primary" />
{t("achievements.nextReward")}
</h4>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<TrendingUp className="h-8 w-8 text-green-500 p-1.5 bg-green-100 dark:bg-green-900/30 rounded-md" />
<span>{t('achievements.unlockReward', { level: level + 1 })}</span>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Play, Pause, Volume2, VolumeX, SkipForward, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ambientPlayer } from '@/lib/ambient-sounds';
import { useTranslation } from 'react-i18next';
import { triggerConfetti } from '@/lib/confetti';
import { playSuccessSound } from '@/lib/sounds';
interface PomodoroOverlayProps {
taskId: string | null;
taskTitle: string;
isOpen: boolean;
onClose: () => void;
onCompleteTask: (taskId: string) => void;
}
export default function PomodoroOverlay({ taskId, taskTitle, isOpen, onClose, onCompleteTask }: PomodoroOverlayProps) {
const { t } = useTranslation();
const [timeLeft, setTimeLeft] = useState(25 * 60);
const [isActive, setIsActive] = useState(false);
const [isSoundPlaying, setIsSoundPlaying] = useState(false);
const [mode, setMode] = useState<'focus' | 'break'>('focus');
const workerRef = useRef<Worker | null>(null);
// Initialize Worker
useEffect(() => {
if (typeof Worker !== 'undefined') {
workerRef.current = new Worker(new URL('../workers/timer.worker.ts', import.meta.url));
workerRef.current.onmessage = (e) => {
const { type, remaining } = e.data;
if (type === 'TICK') {
setTimeLeft(remaining);
} else if (type === 'COMPLETE') {
setIsActive(false);
playSuccessSound();
triggerConfetti(0.5, 0.5);
// If focus finished, propose break?
}
};
}
return () => {
workerRef.current?.terminate();
ambientPlayer.stop();
};
}, []);
// Timer Control
useEffect(() => {
if (isActive) {
workerRef.current?.postMessage({
command: 'START',
payload: { durationSeconds: timeLeft }
});
} else {
workerRef.current?.postMessage({ command: 'STOP' });
}
}, [isActive]);
const toggleTimer = () => setIsActive(!isActive);
const toggleSound = () => {
const playing = ambientPlayer.toggle();
setIsSoundPlaying(playing);
};
const skipTimer = () => {
setIsActive(false);
// Switch modes
if (mode === 'focus') {
setMode('break');
setTimeLeft(5 * 60);
} else {
setMode('focus');
setTimeLeft(25 * 60);
}
};
const handleComplete = () => {
if (taskId) {
onCompleteTask(taskId);
onClose();
setIsActive(false);
ambientPlayer.stop();
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// Circular Progress Calculation
const totalTime = mode === 'focus' ? 25 * 60 : 5 * 60;
const progress = ((totalTime - timeLeft) / totalTime) * 100;
const circleSize = 280;
const radius = 120;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center"
>
<Button
variant="ghost"
size="icon"
className="absolute top-6 right-6 h-12 w-12 rounded-full hover:bg-muted"
onClick={() => {
setIsActive(false);
ambientPlayer.stop();
onClose();
}}
>
<X className="w-6 h-6" />
</Button>
<div className="text-center space-y-8 max-w-md w-full px-4">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tight">{mode === 'focus' ? 'Focus Time' : 'Break Time'}</h2>
<p className="text-xl text-muted-foreground truncate">{taskTitle}</p>
</div>
<div className="relative flex items-center justify-center">
{/* SVG Ring */}
<svg width={circleSize} height={circleSize} className="transform -rotate-90">
{/* Background Circle */}
<circle
cx={circleSize / 2}
cy={circleSize / 2}
r={radius}
stroke="currentColor"
strokeWidth="12"
fill="transparent"
className="text-muted/20"
/>
{/* Progress Circle */}
<circle
cx={circleSize / 2}
cy={circleSize / 2}
r={radius}
stroke="currentColor"
strokeWidth="12"
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className={`transition-all duration-1000 ease-linear ${mode === 'focus' ? 'text-violet-600' : 'text-green-500'}`}
/>
</svg>
{/* Time Display */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-6xl font-mono font-bold tracking-tighter tabular-nums">
{formatTime(timeLeft)}
</span>
</div>
</div>
<div className="flex items-center justify-center gap-6">
<Button
size="icon"
variant="outline"
className="h-14 w-14 rounded-full border-2"
onClick={toggleSound}
>
{isSoundPlaying ? <Volume2 className="w-6 h-6" /> : <VolumeX className="w-6 h-6" />}
</Button>
<Button
size="icon"
className={`h-20 w-20 rounded-full shadow-lg transition-transform hover:scale-105 active:scale-95 ${isActive ? 'bg-orange-500 hover:bg-orange-600' : 'bg-primary hover:bg-primary/90'}`}
onClick={toggleTimer}
>
{isActive ? <Pause className="w-8 h-8 fill-current" /> : <Play className="w-8 h-8 fill-current ml-1" />}
</Button>
<Button
size="icon"
variant="outline"
className="h-14 w-14 rounded-full border-2"
onClick={skipTimer}
>
<SkipForward className="w-6 h-6" />
</Button>
</div>
{taskId && mode === 'focus' && (
<Button
variant="ghost"
className="mt-8 text-muted-foreground hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={handleComplete}
>
<CheckCircle className="w-5 h-5 mr-2" />
Mark Task Complete
</Button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+373 -307
View File
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -20,11 +21,16 @@ import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, Chec
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { Checkbox } from "@/components/ui/checkbox";
import { motion, PanInfo, useAnimation } from 'framer-motion';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { triggerConfetti } from "@/lib/confetti";
import { playSuccessSound } from "@/lib/sounds";
import { simulateAIDecomposition } from "@/lib/ai-simulator";
import { Wand2, Loader2 } from "lucide-react";
interface TaskCardProps {
task: Task;
@@ -33,11 +39,32 @@ interface TaskCardProps {
onEdit?: () => void;
onDelete?: () => void;
onStatusChange?: (status: Task['status']) => void;
onUpdate?: (updates: Partial<Task>) => void;
isDragging?: boolean;
}
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, isDragging }: TaskCardProps) {
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, isDragging }: TaskCardProps) {
const { t } = useTranslation();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const handleAIMagic = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isAnalyzing) return;
setIsAnalyzing(true);
try {
const aiContent = await simulateAIDecomposition(task.title);
const newDescription = (task.description || '') + aiContent;
onUpdate?.({ description: newDescription });
triggerConfetti(0.5, 0.5); // Small burst for "Magic"
playSuccessSound();
} catch (err) {
console.error(err);
} finally {
setIsAnalyzing(false);
}
};
// Fetch labels to get the label color
const { data: labels = [] } = useQuery<Label[]>({
@@ -92,330 +119,369 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
if (task.status === 'done') {
onStatusChange?.('todo');
} else {
triggerConfetti(e.clientX / window.innerWidth, e.clientY / window.innerHeight);
playSuccessSound();
onStatusChange?.('done');
}
};
const controls = useAnimation();
const handleDragEnd = async (event: any, info: PanInfo) => {
if (info.offset.x > 100) {
// Swiped right -> Complete
triggerConfetti(0.5, 0.5);
playSuccessSound();
onStatusChange?.('done');
await controls.start({ x: 500, opacity: 0 });
} else if (info.offset.x < -100 && onDelete) {
// Swiped left -> Delete (optional, maybe just shake for now)
onDelete();
} else {
controls.start({ x: 0 });
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Card
className={`p-4 hover-elevate active-elevate-2 transition-all duration-200 relative ${
isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
} ${task.status === 'done' ? 'opacity-60' : ''}`}
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
} : {}}
data-testid={`card-task-${task.id}`}
>
<div className="flex items-start gap-3">
{/* Checkbox for quick completion */}
<Tooltip>
<TooltipTrigger asChild>
<Checkbox
checked={task.status === 'done'}
onClick={handleToggleComplete}
data-testid={`checkbox-complete-${task.id}`}
className="mt-0.5"
/>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.toggleComplete')}</p>
</TooltipContent>
</Tooltip>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
onEdit?.();
console.log(`Task card clicked: ${task.title}`);
}}
>
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
{task.title}
</h3>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-3 flex-wrap">
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(task.priority)}`}
data-testid={`badge-priority-${task.id}`}
>
{t(`priority.${task.priority}`)}
</Badge>
<Badge
variant="outline"
className={`text-xs ${getStatusColor(task.status)}`}
data-testid={`badge-status-${task.id}`}
>
{t(`status.${task.status}`)}
</Badge>
<motion.div
drag="x"
dragConstraints={{ left: -50, right: 150 }} // Limit swipe distance
dragElastic={0.1}
onDragEnd={handleDragEnd}
animate={controls}
whileDrag={{ scale: 1.02, cursor: 'grabbing' }}
className="touch-pan-y" // Allow vertical scroll, horizontal swipe
>
<ContextMenu>
<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' : ''}`}
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
} : {}}
data-testid={`card-task-${task.id}`}
>
<div className="flex items-start gap-3">
{/* Checkbox for quick completion */}
<Tooltip>
<TooltipTrigger asChild>
<Checkbox
checked={task.status === 'done'}
onClick={handleToggleComplete}
data-testid={`checkbox-complete-${task.id}`}
className="mt-0.5"
/>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.toggleComplete')}</p>
</TooltipContent>
</Tooltip>
{task.dueDate && (
<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()}
</span>
</div>
)}
</div>
{(task.timeTracked > 0 || task.isTracking) && (
<div className="flex items-center gap-1 mt-2 text-xs">
<Clock className={`w-3 h-3 ${task.isTracking ? 'text-primary animate-pulse' : 'text-muted-foreground'}`} />
<span
data-testid={`text-time-tracked-${task.id}`}
className={task.isTracking ? 'text-primary font-medium' : 'text-muted-foreground'}
>
{task.timeTracked > 0 ? formatTime(task.timeTracked) : '0m'}
{task.isTracking && ` (${t('taskCard.running')})`}
</span>
</div>
)}
</div>
<div className="flex items-start gap-1">
{/* Quick delete button */}
{onDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
data-testid={`button-delete-${task.id}`}
className="w-6 h-6 text-muted-foreground hover:text-destructive"
>
<X className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.quickDelete')}</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => e.stopPropagation()}
data-testid={`button-menu-${task.id}`}
className="w-6 h-6"
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
onEdit?.();
console.log(`Edit task: ${task.title}`);
console.log(`Task card clicked: ${task.title}`);
}}
data-testid={`menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{task.status !== 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
data-testid={`menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</DropdownMenuItem>
)}
{task.status === 'todo' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Start working on: ${task.title}`);
}}
data-testid={`menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</DropdownMenuItem>
)}
{task.status === 'inProgress' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
console.log(`Move back to todo: ${task.title}`);
}}
data-testid={`menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</DropdownMenuItem>
)}
{task.status === 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Reopen task: ${task.title}`);
}}
data-testid={`menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
console.log(`Delete task: ${task.title}`);
}}
className="text-destructive"
data-testid={`menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
data-testid={`context-menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`context-menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</ContextMenuItem>
<ContextMenuSeparator />
{task.status !== 'done' && (
<ContextMenuItem
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
{task.title}
</h3>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-3 flex-wrap">
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(task.priority)}`}
data-testid={`badge-priority-${task.id}`}
>
{t(`priority.${task.priority}`)}
</Badge>
<Badge
variant="outline"
className={`text-xs ${getStatusColor(task.status)}`}
data-testid={`badge-status-${task.id}`}
>
{t(`status.${task.status}`)}
</Badge>
{task.dueDate && (
<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()}
</span>
</div>
)}
{task.energyLevel && task.energyLevel !== 'medium' && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{t(`energy.${task.energyLevel}`)}
</Badge>
)}
{task.estimatedDuration && (
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
{task.estimatedDuration}m
</Badge>
)}
</div>
{(task.timeTracked > 0 || task.isTracking) && (
<div className="flex items-center gap-1 mt-2 text-xs">
<Clock className={`w-3 h-3 ${task.isTracking ? 'text-primary animate-pulse' : 'text-muted-foreground'}`} />
<span
data-testid={`text-time-tracked-${task.id}`}
className={task.isTracking ? 'text-primary font-medium' : 'text-muted-foreground'}
>
{task.timeTracked > 0 ? formatTime(task.timeTracked) : '0m'}
{task.isTracking && ` (${t('taskCard.running')})`}
</span>
</div>
)}
</div>
<div className="flex items-start gap-1">
{/* Quick delete button */}
{onDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
data-testid={`button-delete-${task.id}`}
className="w-6 h-6 text-muted-foreground hover:text-destructive"
>
<X className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.quickDelete')}</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => e.stopPropagation()}
data-testid={`button-menu-${task.id}`}
className="w-6 h-6"
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit?.();
console.log(`Edit task: ${task.title}`);
}}
data-testid={`menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{task.status !== 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
data-testid={`menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</DropdownMenuItem>
)}
{task.status === 'todo' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Start working on: ${task.title}`);
}}
data-testid={`menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</DropdownMenuItem>
)}
{task.status === 'inProgress' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
console.log(`Move back to todo: ${task.title}`);
}}
data-testid={`menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</DropdownMenuItem>
)}
{task.status === 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Reopen task: ${task.title}`);
}}
data-testid={`menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
console.log(`Delete task: ${task.title}`);
}}
className="text-destructive"
data-testid={`menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
onEdit?.();
}}
data-testid={`context-menu-complete-${task.id}`}
data-testid={`context-menu-edit-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
)}
{task.status === 'todo' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
handleToggleTimer();
}}
data-testid={`context-menu-start-${task.id}`}
data-testid={`context-menu-timer-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</ContextMenuItem>
)}
{task.status === 'inProgress' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
}}
data-testid={`context-menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</ContextMenuItem>
)}
{task.status === 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
}}
data-testid={`context-menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive"
data-testid={`context-menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
<ContextMenuSeparator />
{task.status !== 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
}}
data-testid={`context-menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</ContextMenuItem>
)}
{task.status === 'todo' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
}}
data-testid={`context-menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</ContextMenuItem>
)}
{task.status === 'inProgress' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
}}
data-testid={`context-menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</ContextMenuItem>
)}
{task.status === 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
}}
data-testid={`context-menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive"
data-testid={`context-menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
</motion.div>
);
}
+74 -20
View File
@@ -10,6 +10,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { CalendarIcon, Plus, Tag } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { parseTaskInput } from '../lib/nlp';
import { Sparkles } from 'lucide-react';
interface TaskCreationModalProps {
isOpen: boolean;
@@ -22,9 +24,13 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [energyLevel, setEnergyLevel] = useState<'low' | 'medium' | 'high'>('medium');
const [estimatedDuration, setEstimatedDuration] = useState<number | undefined>();
const [dueDate, setDueDate] = useState<Date | undefined>();
const [labelId, setLabelId] = useState<string | undefined>();
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch labels
const { data: labels = [] } = useQuery<Label[]>({
@@ -32,28 +38,34 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
});
const handleSave = () => {
if (!title.trim()) return;
if (!title.trim()) {
setError(t('taskCreation.titleRequired') || 'Title is required');
return;
}
const newTask: Partial<Task> = {
title: title.trim(),
description: description.trim() || undefined,
priority,
energyLevel,
estimatedDuration,
dueDate,
labelId,
status: 'todo',
timeTracked: 0,
isTracking: false
};
onSave(newTask);
console.log('New task created:', newTask);
// Reset form
setTitle('');
setDescription('');
setPriority('medium');
setDueDate(undefined);
setLabelId(undefined);
setError(null);
onClose();
};
@@ -70,6 +82,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setPriority('medium');
setDueDate(undefined);
setLabelId(undefined);
setError(null);
onClose();
};
@@ -82,20 +95,39 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
{t('taskCreation.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder={t('taskCreation.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(e) => {
setTitle(e.target.value);
const parsed = parseTaskInput(e.target.value);
if (parsed.priority) setPriority(parsed.priority);
if (parsed.dueDate) setDueDate(parsed.dueDate);
if (parsed.labelName) {
const foundLabel = labels.find(l => l.name.toLowerCase() === parsed.labelName?.toLowerCase());
if (foundLabel) setLabelId(foundLabel.id);
}
if (error) setError(null);
}}
onKeyDown={handleKeyDown}
className="text-base"
data-testid="input-task-title"
autoFocus
/>
<div className="flex items-center gap-1 mt-1">
{title.includes('!') || title.includes('#') || title.toLowerCase().includes('tomorrow') ? (
<div className="bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-300 text-xs px-2 py-0.5 rounded-full flex items-center gap-1 animate-pulse">
<Sparkles className="w-3 h-3" />
Smart Input Active
</div>
) : null}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<div>
<Textarea
placeholder={t('taskCreation.descriptionPlaceholder')}
@@ -106,8 +138,8 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
data-testid="input-task-description"
/>
</div>
<div className="flex gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
<SelectTrigger data-testid="select-task-priority">
@@ -120,7 +152,30 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
<SelectTrigger>
<SelectValue placeholder="Energy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low"> Low Energy</SelectItem>
<SelectItem value="medium"> Medium Energy</SelectItem>
<SelectItem value="high"> High Energy</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-24">
<Input
type="number"
placeholder="Min"
value={estimatedDuration || ''}
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
className="text-sm"
/>
</div>
<div className="flex-1">
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
<SelectTrigger data-testid="select-task-label">
@@ -131,8 +186,8 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
@@ -142,11 +197,11 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
</SelectContent>
</Select>
</div>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
<Button
variant="outline"
className="flex items-center gap-2"
data-testid="button-due-date"
>
@@ -168,19 +223,18 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
</PopoverContent>
</Popover>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
data-testid="button-cancel"
>
{t('taskCreation.cancel')}
</Button>
<Button
<Button
onClick={handleSave}
disabled={!title.trim()}
className="flex-1"
data-testid="button-save-task"
>
+69 -70
View File
@@ -18,6 +18,7 @@ import { useQuery } from '@tanstack/react-query';
import TimeCompletionModal from './TimeCompletionModal';
import { addDays, format, isSameDay, startOfToday } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useDateLocale } from '../hooks/use-date-locale';
interface TasksWithCalendarProps {
tasks: Task[];
@@ -33,6 +34,7 @@ type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: TasksWithCalendarProps) {
const { t } = useTranslation();
const dateLocale = useDateLocale();
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
@@ -61,7 +63,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
// Minimum width per day card (including gaps): ~120px
const minDayWidth = 120;
const maxDays = Math.floor(availableWidth / minDayWidth);
// Clamp between 2 and 7 days
const calculated = Math.max(2, Math.min(7, maxDays));
setVisibleDays(calculated);
@@ -69,7 +71,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
calculateVisibleDays();
window.addEventListener('resize', calculateVisibleDays);
return () => window.removeEventListener('resize', calculateVisibleDays);
}, []);
@@ -85,10 +87,10 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
.filter(task => {
// Search filter
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchesSearch) return false;
// Status filter
switch (filterBy) {
case 'overdue':
@@ -106,18 +108,18 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority as keyof typeof priorityOrder] - priorityOrder[a.priority as keyof typeof priorityOrder];
case 'title':
return a.title.localeCompare(b.title);
case 'status':
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
return statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder];
default:
return 0;
}
@@ -125,9 +127,9 @@ 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);
const getTasksForDate = (date: Date) => {
return filteredAndSortedTasks.filter(task =>
return filteredAndSortedTasks.filter(task =>
task.dueDate && isSameDay(task.dueDate, date)
);
};
@@ -178,7 +180,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setHoveredDate(null);
}
@@ -202,7 +204,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
const handleTimeLogged = (timeSpent: number) => {
if (completionModal.task) {
onTaskUpdate?.(completionModal.task.id, {
onTaskUpdate?.(completionModal.task.id, {
status: 'done',
timeTracked: (completionModal.task.timeTracked || 0) + timeSpent,
isTracking: false
@@ -238,7 +240,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
data-testid="input-search-tasks"
/>
</div>
<div className="flex gap-3">
<Select value={filterBy} onValueChange={(value: FilterOption) => {
setFilterBy(value);
@@ -258,7 +260,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value: SortOption) => {
setSortBy(value);
console.log('Sort changed to:', value);
@@ -285,7 +287,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
{unscheduledTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
{searchQuery || filterBy !== 'all'
{searchQuery || filterBy !== 'all'
? t('taskList.noMatchingTasks')
: t('taskList.noUnscheduledTasks')
}
@@ -302,9 +304,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
draggable
onDragStart={(e) => handleDragStart(task.id, e)}
onDragEnd={handleDragEnd}
className={`cursor-move transition-transform hover-elevate ${
draggedTask === task.id ? 'opacity-50 scale-95' : ''
}`}
className={`cursor-move transition-transform hover-elevate ${draggedTask === task.id ? 'opacity-50 scale-95' : ''
}`}
>
<TaskCard
task={task}
@@ -326,6 +327,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
}
console.log(`Task ${task.title} status changed to ${newStatus}`);
}}
onUpdate={(updates) => onTaskUpdate?.(task.id, updates)}
isDragging={draggedTask === task.id}
/>
</div>
@@ -334,8 +336,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
)}
</div>
{/* Calendar Section - Fixed at bottom with full width, above bottom nav */}
<div className="fixed bottom-24 left-0 right-0 bg-background z-[5]" style={{ borderTop: '1px solid rgb(229, 231, 235)' }}>
{/* Calendar Section - Sticky at bottom */}
<div className="sticky bottom-0 -mx-4 sm:-mx-6 bg-background/95 backdrop-blur z-30 border-t">
<div className="p-3 max-h-[32vh] overflow-y-auto">
{/* Calendar Header */}
<div className="flex items-center justify-between mb-3">
@@ -345,19 +347,19 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
{t('taskList.calendarTitle', { count: visibleDays })}
</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigateCalendar('prev')}
data-testid="button-prev-week"
>
<ChevronLeft className="w-3 h-3" />
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigateCalendar('next')}
data-testid="button-next-week"
@@ -374,18 +376,18 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
const isToday = isSameDay(date, new Date());
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const isHovered = hoveredDate && isSameDay(date, hoveredDate);
// Use important modifier to ensure weekend styling overrides any conflicting CSS
const weekendClasses = isWeekend ? '!bg-muted/80 border-muted-foreground/30 shadow-sm' : '';
const todayClasses = isToday ? 'border-primary ring-1 ring-primary ring-offset-1 ring-offset-background' : 'border-border';
const hoverClasses = isHovered && draggedTask ? 'ring-2 ring-primary bg-primary/15 border-primary shadow-lg' : '';
const dragClasses = draggedTask && !isHovered ? 'border-dashed border-primary/30' : '';
const finalClassName = `p-2 sm:p-2 min-h-[140px] sm:min-h-[100px] transition-all border-2 ${todayClasses} ${weekendClasses} ${hoverClasses} ${dragClasses}`;
return (
<Card
<Card
key={index}
className={finalClassName}
onDragOver={handleDragOver}
@@ -399,23 +401,21 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
data-testid={`calendar-date-${format(date, 'yyyy-MM-dd')}`}
>
<div className="text-center mb-2">
<div className={`text-xs sm:text-xs font-medium ${
isWeekend ? 'text-muted-foreground font-semibold' : 'text-muted-foreground'
}`}>
{format(date, 'EEE')}
<div className={`text-xs sm:text-xs font-medium ${isWeekend ? 'text-muted-foreground font-semibold' : 'text-muted-foreground'
}`}>
{format(date, 'EEE', { locale: dateLocale })}
</div>
<div className={`text-sm sm:text-xs font-semibold ${
isToday ? 'text-primary' : isWeekend ? 'text-primary/80' : ''
}`}>
{format(date, 'MMM d')}
<div className={`text-sm sm:text-xs font-semibold ${isToday ? 'text-primary' : isWeekend ? 'text-primary/80' : ''
}`}>
{format(date, 'd', { locale: dateLocale })}
</div>
</div>
<div className="space-y-1">
{dayTasks.slice(0, 2).map((task) => {
// Find the label for this task
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
return (
<ContextMenu key={task.id}>
<ContextMenuTrigger asChild>
@@ -433,10 +433,10 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
data-testid={`calendar-task-${task.id}`}
>
<Badge
variant="outline"
<Badge
variant="outline"
className="w-full justify-start text-xs p-1 sm:p-1 py-2 sm:py-1 h-auto text-left"
style={taskLabel ? {
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
@@ -449,7 +449,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
@@ -459,8 +459,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
if (task.isTracking) {
@@ -474,11 +474,11 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</ContextMenuItem>
<ContextMenuSeparator />
{task.status !== 'done' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
const taskToComplete = tasks.find(t => t.id === task.id);
@@ -492,9 +492,9 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
{t('taskCard.markAsDone')}
</ContextMenuItem>
)}
{task.status === 'todo' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'inProgress' });
@@ -505,9 +505,9 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
{t('taskCard.startWorking')}
</ContextMenuItem>
)}
{task.status === 'inProgress' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'todo' });
@@ -518,9 +518,9 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
{t('taskCard.moveToTodo')}
</ContextMenuItem>
)}
{task.status === 'done' && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'inProgress' });
@@ -531,11 +531,11 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onTaskDelete && (
<ContextMenuItem
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskDelete(task.id);
@@ -551,21 +551,20 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</ContextMenu>
);
})}
{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>
)}
{dayTasks.length === 0 && (
<div className={`text-center text-xs py-2 rounded border border-dashed transition-all ${
isHovered && draggedTask
? 'border-primary text-primary bg-primary/5'
: draggedTask
? 'border-primary/50 text-primary/70'
<div className={`text-center text-xs py-2 rounded border border-dashed transition-all ${isHovered && draggedTask
? 'border-primary text-primary bg-primary/5'
: draggedTask
? 'border-primary/50 text-primary/70'
: 'border-muted-foreground/30 text-muted-foreground'
}`}>
}`}>
{isHovered && draggedTask ? '✓ Drop here' : draggedTask ? 'Drop' : ''}
</div>
)}
@@ -577,8 +576,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
</div>
{/* Add bottom padding to prevent content overlap */}
<div className="h-[37vh]" />
{/* Spacer */}
<div className="h-4" />
{/* Time Completion Modal */}
<TimeCompletionModal
+12 -11
View File
@@ -1,18 +1,20 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function ThemeToggle() {
const { t } = useTranslation();
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Check for saved theme preference or default to light mode
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = savedTheme === 'dark' || (!savedTheme && prefersDark);
setIsDark(shouldBeDark);
// Apply theme to document
if (shouldBeDark) {
document.documentElement.classList.add('dark');
@@ -24,7 +26,7 @@ export default function ThemeToggle() {
const toggleTheme = () => {
const newTheme = !isDark;
setIsDark(newTheme);
if (newTheme) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
@@ -32,23 +34,22 @@ export default function ThemeToggle() {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
console.log('Theme changed to:', newTheme ? 'dark' : 'light');
};
return (
<Button
variant="ghost"
<Button
variant="outline"
size="icon"
onClick={toggleTheme}
data-testid="button-theme-toggle"
className="w-9 h-9"
className="w-10 h-10 rounded-full border-2 border-primary/20 hover:border-primary hover:bg-primary/10 transition-all duration-300"
title={t('app.toggleTheme')}
>
{isDark ? (
<Sun className="w-4 h-4" />
<Moon className="h-5 w-5 text-violet-500 transition-all" />
) : (
<Moon className="w-4 h-4" />
<Sun className="h-5 w-5 text-orange-500 transition-all" />
)}
<span className="sr-only">{isDark ? t('theme.dark') : t('theme.light')}</span>
</Button>
);
}
+32 -30
View File
@@ -12,6 +12,8 @@ import TimeCompletionModal from './TimeCompletionModal';
import { addDays, format, isSameDay, startOfWeek, endOfWeek, startOfDay, endOfDay, addWeeks } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useDateLocale } from '../hooks/use-date-locale';
interface WeekListViewProps {
tasks: Task[];
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
@@ -26,6 +28,7 @@ type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: WeekListViewProps) {
const { t } = useTranslation();
const dateLocale = useDateLocale();
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
@@ -64,10 +67,10 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
// Search filter
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
(task.description?.toLowerCase()?.includes(searchQuery.toLowerCase()) ?? false);
(task.description?.toLowerCase()?.includes(searchQuery.toLowerCase()) ?? false);
if (!matchesSearch) return false;
// Status filter
switch (filterBy) {
case 'overdue':
@@ -85,18 +88,18 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority as keyof typeof priorityOrder] - priorityOrder[a.priority as keyof typeof priorityOrder];
case 'title':
return a.title.localeCompare(b.title);
case 'status':
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
return statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder];
default:
return 0;
}
@@ -115,7 +118,7 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
};
const getTasksForDate = (date: Date) => {
return filteredAndSortedTasks.filter(task =>
return filteredAndSortedTasks.filter(task =>
task.dueDate && isSameDay(task.dueDate, date)
);
};
@@ -136,7 +139,7 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
const handleTimeLogged = (timeSpent: number) => {
if (completionModal.task) {
onTaskUpdate?.(completionModal.task.id, {
onTaskUpdate?.(completionModal.task.id, {
status: 'done',
timeTracked: (completionModal.task.timeTracked || 0) + timeSpent,
isTracking: false
@@ -148,7 +151,7 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
return (
<div className="space-y-4 pb-4">
{/* Header and Search Filters */}
<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="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-3 mb-4 pt-4">
<div className="space-y-3">
{/* Title Bar */}
<div className="flex items-center justify-between">
@@ -175,7 +178,7 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
data-testid="input-search-week-tasks"
/>
</div>
<div className="flex gap-3">
<Select value={filterBy} onValueChange={(value: FilterOption) => {
setFilterBy(value);
@@ -195,7 +198,7 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
<SelectItem value="overdue">{t('weekList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value: SortOption) => {
setSortBy(value);
console.log('Sort changed to:', value);
@@ -223,13 +226,13 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold" data-testid="text-week-range">
{format(weekStart, 'MMM d')} - {format(weekEnd, 'MMM d, yyyy')}
{format(weekStart, 'MMM d', { locale: dateLocale })} - {format(weekEnd, 'MMM d, yyyy', { locale: dateLocale })}
</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => navigateCalendar('prev')}
data-testid="button-prev-week-list"
@@ -237,18 +240,18 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
<ChevronLeft className="w-4 h-4" />
{t('weekList.previous')}
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={goToCurrentWeek}
data-testid="button-today-week-list"
>
{t('weekList.today')}
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => navigateCalendar('next')}
data-testid="button-next-week-list"
@@ -265,20 +268,19 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
const dayTasks = getTasksForDate(date);
const isToday = isSameDay(date, new Date());
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return (
<div
key={index}
className={`p-2 rounded border-2 text-center ${
isToday ? 'border-primary bg-primary/5' : isWeekend ? 'bg-muted/50' : 'border-border'
}`}
className={`p-2 rounded border-2 text-center ${isToday ? 'border-primary bg-primary/5' : isWeekend ? 'bg-muted/50' : 'border-border'
}`}
data-testid={`week-calendar-date-${format(date, 'yyyy-MM-dd')}`}
>
<div className={`text-xs font-medium ${isWeekend ? 'text-muted-foreground' : 'text-muted-foreground'}`}>
{format(date, 'EEE')}
{format(date, 'EEE', { locale: dateLocale })}
</div>
<div className={`text-sm font-semibold ${isToday ? 'text-primary' : ''}`}>
{format(date, 'd')}
{format(date, 'd', { locale: dateLocale })}
</div>
{dayTasks.length > 0 && (
<Badge variant="secondary" className="mt-1 text-xs">
@@ -296,7 +298,7 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
{filteredAndSortedTasks.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground" data-testid="text-no-week-tasks">
{searchQuery || filterBy !== 'all'
{searchQuery || filterBy !== 'all'
? t('weekList.noMatchingTasks')
: t('weekList.noTasks')
}
@@ -305,7 +307,7 @@ export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDe
) : (
<>
<div className="text-sm font-medium text-muted-foreground mb-2">
{t('weekList.tasksFor', { start: format(weekStart, 'MMM d'), end: format(weekEnd, 'MMM d') })}
{t('weekList.tasksFor', { start: format(weekStart, 'MMM d', { locale: dateLocale }), end: format(weekEnd, 'MMM d', { locale: dateLocale }) })}
</div>
{filteredAndSortedTasks.map((task) => (
<div key={task.id}>
@@ -0,0 +1,63 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { icons } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Reward } from "@shared/schema";
interface RewardCardProps {
reward: Reward & { owned?: boolean };
userXp: number;
onBuy: (rewardId: string) => void;
isBuying?: boolean;
}
export function RewardCard({ reward, userXp, onBuy, isBuying }: RewardCardProps) {
const { t } = useTranslation();
// Dynamic icon
const Icon = (icons as any)[reward.icon] || (icons as any).Gift;
const canAfford = userXp >= reward.cost;
const isOwned = reward.owned;
return (
<Card className={`flex flex-col h-full ${isOwned ? 'opacity-80' : ''}`}>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<div className="p-2 rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
</div>
{isOwned && <Badge variant="secondary">{t('rewards.owned')}</Badge>}
</div>
<CardTitle className="mt-4 text-lg">
{reward.isSystem ? t(reward.title) : reward.title}
</CardTitle>
</CardHeader>
<CardContent className="flex-1">
<p className="text-sm text-muted-foreground">
{reward.isSystem ? t(reward.description) : reward.description}
</p>
</CardContent>
<CardFooter className="flex justify-between items-center border-t pt-4">
<div className="font-bold text-lg">
{reward.cost} <span className="text-xs font-normal text-muted-foreground">XP</span>
</div>
{isOwned ? (
<Button disabled variant="outline" size="sm">
{t('rewards.owned')}
</Button>
) : (
<Button
onClick={() => onBuy(reward.id)}
disabled={!canAfford || isBuying}
size="sm"
variant={canAfford ? "default" : "secondary"}
>
{isBuying ? t('rewards.processing') : t('rewards.buy')}
</Button>
)}
</CardFooter>
</Card>
);
}
+3 -3
View File
@@ -6,12 +6,12 @@ import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0" +
" hover-elevate active-elevate-2",
" hover-elevate active-elevate-2 transition-all duration-200 active:scale-95 hover:scale-105",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground border border-primary-border",
"bg-gradient-to-r from-violet-600 to-indigo-600 text-primary-foreground border border-primary-border shadow-md hover:shadow-lg hover:from-violet-500 hover:to-indigo-500",
destructive:
"bg-destructive text-destructive-foreground border border-destructive-border",
outline:
@@ -41,7 +41,7 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
+1
View File
@@ -16,6 +16,7 @@ function Calendar({
return (
<DayPicker
showOutsideDays={showOutsideDays}
showWeekNumber={true}
weekStartsOn={1}
className={cn("p-3", className)}
classNames={{
+8 -8
View File
@@ -3,7 +3,7 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { PanelLeftIcon, Menu } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
@@ -29,7 +29,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_WIDTH_ICON = "3.75rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
@@ -170,7 +170,7 @@ function Sidebar({
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
"bg-sidebar/80 backdrop-blur-xl border-r border-sidebar-border/50 text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
className
)}
{...props}
@@ -229,7 +229,7 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-50 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -244,7 +244,7 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
className="bg-sidebar/80 backdrop-blur-xl group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
@@ -273,7 +273,7 @@ function SidebarTrigger({
}}
{...props}
>
<PanelLeftIcon />
<Menu className="size-5" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
@@ -475,7 +475,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:w-8! group-data-[collapsible=icon]:h-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:w-12! group-data-[collapsible=icon]:h-12! group-data-[collapsible=icon]:p-2! group-data-[collapsible=icon]:justify-center [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -570,7 +570,7 @@ function SidebarMenuAction({
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
+15
View File
@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next';
import { enUS, de } from 'date-fns/locale';
export const useDateLocale = () => {
const { i18n } = useTranslation();
const getDateLocale = () => {
if (i18n.language && i18n.language.startsWith('de')) {
return de;
}
return enUS;
};
return getDateLocale();
};
+123 -1
View File
@@ -5,10 +5,12 @@
},
"navigation": {
"tasks": "Aufgaben",
"focus": "Fokus",
"calendar": "Kalender",
"weekList": "Woche",
"create": "Erstellen",
"kanban": "Kanban",
"achievements": "Erfolge",
"settings": "Einstellungen"
},
"taskList": {
@@ -298,5 +300,125 @@
"description": "Möchten Sie diese Aufgabe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"cancel": "Abbrechen",
"confirm": "Löschen"
},
"focus": {
"greeting": {
"morning": "Guten Morgen",
"afternoon": "Guten Tag",
"evening": "Guten Abend"
},
"stats": {
"dailyProgress": "Tagesfortschritt",
"momentum": "Bleib dran!",
"tasksCompleted": "Aufgaben erledigt",
"activeTasksMessage": "Du hast {{count}} aktive Aufgaben. Lass uns produktiv sein."
},
"viewAll": {
"title": "Alle Aufgaben",
"description": "Verwalte deinen Backlog und Projekte",
"button": "Zur Liste"
},
"list": {
"title": "Deine Fokus-Liste",
"subtitle": "Zum Priorisieren ziehen",
"empty": "Keine aktiven Aufgaben. Genieß den Tag!"
}
},
"gamification": {
"level": "Level {{level}}",
"streak": "{{count}}",
"xp": "{{count}} EP",
"nextLevel": "{{count}} EP",
"currentXP": "{{current}} / {{next}} EP",
"viewDetails": "Details anzeigen"
},
"ranks": {
"novice": "Einsteiger",
"builder": "Baumeister",
"planner": "Planer",
"architect": "Architekt",
"master": "Meister"
},
"achievements": {
"title": "Erfolge",
"subtitle": "Verfolge deinen Fortschritt und deine Ziele",
"currentStreak": "Aktuelle Serie",
"days": "{{count}} Tage",
"bestStreak": "Rekord: {{count}} Tage",
"xpActivity": "EP Aktivität",
"xpDescription": "Deine Produktivität im Zeitverlauf",
"goals": "Ziele",
"goalsDescription": "Persönliche Ziele",
"addGoal": "Ziel hinzufügen",
"createGoal": "Ziel erstellen",
"noGoals": "Noch keine Ziele gesetzt",
"goalTitle": "Bezeichnung",
"targetValue": "Zielwert",
"type": "Typ",
"weekly": "Wöchentlich",
"monthly": "Monatlich",
"yearly": "Jährlich",
"types": {
"weekly_tasks": "Wochenaufgaben",
"total_xp": "Gesamt EP",
"streak": "Serien-Tage"
},
"levelDetails": "Level Details",
"nextReward": "Nächste Belohnung",
"unlockReward": "Erweiterte Analysen auf Level {{level}} freischalten",
"goalTitlePlaceholder": "z.B. 50 Aufgaben erledigen",
"fromLastWeek": "seit letzter Woche"
},
"analytics": {
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa",
"sun": "So",
"jan": "Jan",
"feb": "Feb",
"mar": "Mär",
"apr": "Apr",
"may": "Mai",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Okt",
"nov": "Nov",
"dec": "Dez",
"week_1": "Woche 1",
"week_2": "Woche 2",
"week_3": "Woche 3",
"week_4": "Woche 4",
"cw": "KW"
},
"energy": {
"low": "⚡ Wenig Energie",
"medium": "⚡⚡ Mittlere Energie",
"high": "⚡⚡⚡ Viel Energie"
},
"rewards": {
"shopTitle": "Belohnungen",
"buy": "Kaufen",
"insufficientFunds": "Nicht genug EP",
"owned": "Im Besitz",
"processing": "Verarbeite...",
"defaults": {
"coffee": {
"title": "Kaffeepause",
"description": "Mach eine 15 Min Pause"
},
"gaming": {
"title": "Gaming Session",
"description": "1 Stunde zocken ohne schlechtes Gewissen"
},
"theme": {
"title": "Goldenes Design",
"description": "Schalte das goldene Design frei"
}
}
}
}
}
+123 -1
View File
@@ -5,10 +5,12 @@
},
"navigation": {
"tasks": "Tasks",
"focus": "Focus",
"calendar": "Calendar",
"weekList": "Week",
"create": "Create",
"kanban": "Kanban",
"achievements": "Achievements",
"settings": "Settings"
},
"taskList": {
@@ -298,5 +300,125 @@
"description": "Are you sure you want to delete this task? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete"
},
"focus": {
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening"
},
"stats": {
"dailyProgress": "Daily Progress",
"momentum": "Keep up the momentum",
"tasksCompleted": "tasks completed",
"activeTasksMessage": "You have {{count}} active tasks. Let's make today productive."
},
"viewAll": {
"title": "View All Tasks",
"description": "Manage your full backlog and projects",
"button": "Go to List"
},
"list": {
"title": "Your Focus List",
"subtitle": "Drag to prioritize",
"empty": "No active tasks. Enjoy your day!"
}
},
"gamification": {
"level": "Level {{level}}",
"streak": "{{count}}",
"xp": "{{count}} XP",
"nextLevel": "{{count}} XP",
"currentXP": "{{current}} / {{next}} XP",
"viewDetails": "View Details"
},
"ranks": {
"novice": "Novice",
"builder": "Builder",
"planner": "Planner",
"architect": "Architect",
"master": "Master"
},
"achievements": {
"title": "Achievements",
"subtitle": "Track your progress and goals",
"currentStreak": "Current Streak",
"days": "{{count}} Days",
"bestStreak": "Best: {{count}} Days",
"xpActivity": "XP Activity",
"xpDescription": "Your productivity over time",
"goals": "Goals",
"goalsDescription": "Personal targets",
"addGoal": "Add Goal",
"createGoal": "Create Goal",
"noGoals": "No goals set yet",
"goalTitle": "Goal Title",
"targetValue": "Target Value",
"type": "Type",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly",
"types": {
"weekly_tasks": "Weekly Tasks",
"total_xp": "Total XP",
"streak": "Streak Days"
},
"levelDetails": "Level Details",
"nextReward": "Next Level Reward",
"unlockReward": "Unlock advanced analytics at Level {{level}}",
"goalTitlePlaceholder": "e.g., Complete 50 Tasks",
"fromLastWeek": "from last week"
},
"analytics": {
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun",
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec",
"week_1": "Week 1",
"week_2": "Week 2",
"week_3": "Week 3",
"week_4": "Week 4",
"cw": "CW"
},
"energy": {
"low": "⚡ Low Energy",
"medium": "⚡⚡ Medium Energy",
"high": "⚡⚡⚡ High Energy"
},
"rewards": {
"shopTitle": "Reward Shop",
"buy": "Buy",
"insufficientFunds": "Not enough XP",
"owned": "Owned",
"processing": "Processing...",
"defaults": {
"coffee": {
"title": "Coffee Break",
"description": "Take a 15 min coffee break"
},
"gaming": {
"title": "Gaming Session",
"description": "1 hour of guilt-free gaming"
},
"theme": {
"title": "Golden Theme",
"description": "Unlock the golden theme"
}
}
}
}
}
+5 -3
View File
@@ -48,7 +48,8 @@
--popover-border: 220 13% 85%;
--primary: 210 100% 56%;
/* Premium Violet/Indigo Base */
--primary: 250 95% 64%;
--primary-foreground: 210 17% 98%;
@@ -76,10 +77,11 @@
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--font-sans: Inter, Open Sans, sans-serif;
--font-sans: "Inter", sans-serif;
--font-heading: "Outfit", sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: .5rem; /* 8px */
--radius: 1rem; /* 16px - Softer look */
--shadow-2xs: 0px 2px 0px 0px hsl(220 13% 91% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(220 13% 91% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(220 13% 91% / 0.00), 0px 1px 2px -1px hsl(220 13% 91% / 0.00);
+82
View File
@@ -0,0 +1,82 @@
interface SubtaskTemplate {
keywords: string[];
subtasks: string[];
}
const TEMPLATES: SubtaskTemplate[] = [
{
keywords: ['vacation', 'trip', 'travel', 'holiday', 'flight'],
subtasks: [
'- [ ] Research destination and dates',
'- [ ] Book flights',
'- [ ] Reserve accommodation',
'- [ ] Check passport validity',
'- [ ] Create packing list',
'- [ ] Arrange transport to airport'
]
},
{
keywords: ['feature', 'coding', 'code', 'bug', 'fix', 'implement', 'dev'],
subtasks: [
'- [ ] Analyze requirements',
'- [ ] Design technical approach',
'- [ ] Write implementation code',
'- [ ] Write unit tests',
'- [ ] Perform manual verification',
'- [ ] Open Pull Request'
]
},
{
keywords: ['groceries', 'shop', 'buy', 'store', 'market'],
subtasks: [
'- [ ] Check fridge/pantry inventory',
'- [ ] Plan meals for the week',
'- [ ] Write shopping list',
'- [ ] Bring reusable bags',
'- [ ] Go to store'
]
},
{
keywords: ['meeting', 'call', 'sync', 'discussion', 'interview'],
subtasks: [
'- [ ] Prepare agenda',
'- [ ] Review background materials',
'- [ ] Set up video conference link',
'- [ ] Take notes during meeting',
'- [ ] Send follow-up action items'
]
},
{
keywords: ['clean', 'tidy', 'organize', 'house', 'chore'],
subtasks: [
'- [ ] Gather cleaning supplies',
'- [ ] Declutter surface areas',
'- [ ] Dust and wipe down',
'- [ ] Vacuum/Sweep floors',
'- [ ] Take out trash'
]
}
];
const GENERIC_SUBTASKS = [
'- [ ] Define success criteria',
'- [ ] Break down into smaller steps',
'- [ ] Execute first step',
'- [ ] Review progress',
'- [ ] Mark as complete'
];
export async function simulateAIDecomposition(taskTitle: string): Promise<string> {
// Simulate network latency for "AI" feel
await new Promise(resolve => setTimeout(resolve, 1500));
const lowerTitle = taskTitle.toLowerCase();
const match = TEMPLATES.find(t =>
t.keywords.some(k => lowerTitle.includes(k))
);
const steps = match ? match.subtasks : GENERIC_SUBTASKS;
return `\n\n### 🪄 AI Suggested Steps:\n${steps.join('\n')}`;
}
+86
View File
@@ -0,0 +1,86 @@
export class AmbientSoundPlayer {
private audioCtx: AudioContext | null = null;
private noiseSource: AudioBufferSourceNode | null = null;
private gainNode: GainNode | null = null;
private isPlaying = false;
constructor() {
// Initialize audio context only on user interaction to comply with browser policies
}
private initContext() {
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
}
private createBrownNoiseBuffer(): AudioBuffer {
if (!this.audioCtx) throw new Error("No Audio Context");
const bufferSize = this.audioCtx.sampleRate * 2; // 2 seconds buffer
const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate);
const data = buffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < bufferSize; i++) {
const white = Math.random() * 2 - 1;
data[i] = (lastOut + (0.02 * white)) / 1.02;
lastOut = data[i];
data[i] *= 3.5; // Compensate for gain loss
}
return buffer;
}
public toggle(type: 'brown' | 'white' = 'brown'): boolean {
if (this.isPlaying) {
this.stop();
return false;
} else {
this.play(type);
return true;
}
}
public play(type: 'brown' | 'white' = 'brown') {
this.initContext();
if (!this.audioCtx) return;
// Resume if suspended (browser autoplay policy)
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume();
}
this.stop(); // Stop any current sound
this.noiseSource = this.audioCtx.createBufferSource();
this.noiseSource.buffer = this.createBrownNoiseBuffer(); // Currently only brown noise implemented efficiently
this.noiseSource.loop = true;
this.gainNode = this.audioCtx.createGain();
this.gainNode.gain.value = 0.5; // Default volume
this.noiseSource.connect(this.gainNode);
this.gainNode.connect(this.audioCtx.destination);
// Fade in
this.gainNode.gain.setValueAtTime(0, this.audioCtx.currentTime);
this.gainNode.gain.linearRampToValueAtTime(0.5, this.audioCtx.currentTime + 1);
this.noiseSource.start();
this.isPlaying = true;
}
public stop() {
if (this.noiseSource && this.gainNode && this.audioCtx) {
// Fade out
this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + 0.5);
setTimeout(() => {
this.noiseSource?.stop();
this.noiseSource = null;
}, 500);
}
this.isPlaying = false;
}
}
export const ambientPlayer = new AmbientSoundPlayer();
+37
View File
@@ -0,0 +1,37 @@
import confetti from 'canvas-confetti';
export const triggerConfetti = (x: number, y: number) => {
const defaults = { origin: { x, y } };
const count = 200;
function fire(particleRatio: number, opts: confetti.Options) {
confetti({
...defaults,
...opts,
particleCount: Math.floor(count * particleRatio),
});
}
fire(0.25, {
spread: 26,
startVelocity: 55,
});
fire(0.2, {
spread: 60,
});
fire(0.35, {
spread: 100,
decay: 0.91,
scalar: 0.8,
});
fire(0.1, {
spread: 120,
startVelocity: 25,
decay: 0.92,
scalar: 1.2,
});
fire(0.1, {
spread: 120,
startVelocity: 45,
});
};
+47
View File
@@ -0,0 +1,47 @@
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';
}
+53
View File
@@ -0,0 +1,53 @@
export interface ParsedTask {
title: string;
priority?: 'low' | 'medium' | 'high';
dueDate?: Date;
labelName?: string;
}
export const parseTaskInput = (input: string): ParsedTask => {
let title = input;
let priority: ParsedTask['priority'] | undefined;
let dueDate: Date | undefined;
let labelName: string | undefined;
// Parse Priority (!high, !medium, !low)
const priorityMatch = title.match(/!(high|medium|low)/i);
if (priorityMatch) {
priority = priorityMatch[1].toLowerCase() as ParsedTask['priority'];
title = title.replace(priorityMatch[0], '').trim();
}
// Parse Label (#work, #personal)
const labelMatch = title.match(/#(\w+)/);
if (labelMatch) {
labelName = labelMatch[1];
title = title.replace(labelMatch[0], '').trim();
}
// Parse Date (tomorrow, today, next friday) - Simple heuristic
// Note: For production capability, use 'chrono-node'
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
if (title.match(/\btomorrow\b/i)) {
dueDate = tomorrow;
title = title.replace(/\btomorrow\b/i, '').trim();
} else if (title.match(/\btoday\b/i)) {
dueDate = today;
title = title.replace(/\btoday\b/i, '').trim();
} else if (title.match(/\bnext week\b/i)) {
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
dueDate = nextWeek;
title = title.replace(/\bnext week\b/i, '').trim();
}
return {
title,
priority,
dueDate,
labelName
};
};
+31
View File
@@ -0,0 +1,31 @@
// Simple sound effects using base64 or public URLs to avoid asset management issues for this demo
// Using a short "pop" sound
const POP_SOUND = "data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU"; // Placeholder, real sound below
export const playSuccessSound = () => {
// A simple pleasant "pop" sound frequency sequence using Web Audio API for zero-dependency
try {
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
if (!AudioContext) return;
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(800, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.1);
} catch (e) {
console.error("Audio play failed", e);
}
};
+8 -1
View File
@@ -3,4 +3,11 @@ import App from "./App";
import "./index.css";
import "./i18n/config";
createRoot(document.getElementById("root")!).render(<App />);
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./lib/queryClient";
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
+469
View File
@@ -0,0 +1,469 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
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 { useState } 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 { RewardCard } from '@/components/gamification/RewardCard';
import { useToast } from "@/hooks/use-toast";
export default function AchievementsPage({ user }: { user: User }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
const [isGoalModalOpen, setIsGoalModalOpen] = useState(false);
// Mock User Data (Replace with context later)
// const user = { xp: 350, streak: 5, bestStreak: 12 };
const level = getLevelFromXP(user.xp);
const rankKey = getRankKey(level);
// Fetch Analytics Data
const { data: weeklyData } = useQuery({
queryKey: ['/api/analytics/weekly'],
queryFn: async () => {
const res = await fetch('/api/analytics/weekly');
return res.json();
}
});
const { data: yearlyData } = useQuery({
queryKey: ['/api/analytics/yearly'],
queryFn: async () => {
const res = await fetch('/api/analytics/yearly');
return res.json();
}
});
const { data: monthlyData } = useQuery({
queryKey: ['/api/analytics/monthly'],
queryFn: async () => {
const res = await fetch('/api/analytics/monthly');
return res.json();
}
});
// Fetch Goals
const { data: goals = [] } = useQuery<Goal[]>({
queryKey: ['/api/goals'],
});
// Create Goal Mutation
const createGoalMutation = useMutation({
mutationFn: async (newGoal: any) => {
const res = await fetch('/api/goals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newGoal)
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/goals'] });
setIsGoalModalOpen(false);
}
});
// Rewards
const { data: rewards = [] } = useQuery<Reward[]>({
queryKey: ['/api/rewards', user.id],
queryFn: async () => {
const res = await fetch(`/api/rewards?userId=${user.id}`);
return res.json();
}
});
const buyRewardMutation = useMutation({
mutationFn: async (rewardId: string) => {
const res = await fetch('/api/rewards/buy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rewardId, userId: user.id })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to buy");
}
return res.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
// Invalidate user query if we had one for XP, assuming manual update for now or refetch
// user.xp -= data.cost... (but user object is static const in this file currently)
toast({
title: t('rewards.processing'), // Should contain success message really
description: "Purchase successful!",
});
},
onError: (error: Error) => {
toast({
title: t('common.error'),
description: t('rewards.insufficientFunds') === error.message ? t('rewards.insufficientFunds') : error.message,
variant: "destructive"
});
}
});
const handleBuyReward = (rewardId: string) => {
buyRewardMutation.mutate(rewardId);
};
const handleCreateGoal = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
createGoalMutation.mutate({
title: formData.get('title'),
target: parseInt(formData.get('target') as string),
type: formData.get('type'),
userId: user.id
});
};
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background/95 p-3 shadow-xl backdrop-blur-sm">
<div className="mb-1 text-xs font-medium text-muted-foreground">
{t(`analytics.${label}`) === `analytics.${label}` ?
(label && !isNaN(label) ? `${t('analytics.cw')} ${label}` : label)
: t(`analytics.${label}`)}
</div>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="text-sm font-bold">
{payload[0].value} XP
</span>
</div>
</div>
);
}
return null;
};
// Create Reward Mutation
const createRewardMutation = useMutation({
mutationFn: async (newReward: { title: string, description: string, cost: number }) => {
const res = await fetch('/api/rewards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...newReward,
icon: "gift", // Default icon for now
type: "virtual"
})
});
if (!res.ok) throw new Error("Failed to create reward");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
toast({ title: "Reward created!" });
},
onError: () => {
toast({ title: "Failed to create reward", variant: "destructive" });
}
});
const handleCreateReward = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
createRewardMutation.mutate({
title: formData.get('title') as string,
description: formData.get('description') as string,
cost: parseInt(formData.get('cost') as string)
});
};
return (
<div className="space-y-6 pb-20 md:pb-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-1">{t('achievements.title')}</h1>
<p className="text-muted-foreground">{t('achievements.subtitle')}</p>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList>
<TabsTrigger value="overview">{t('achievements.title')}</TabsTrigger>
<TabsTrigger value="rewards">{t('rewards.shopTitle')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Level Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('gamification.level', { level })}</CardTitle>
<Trophy className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">{t(`ranks.${rankKey}`)}</div>
<p className="text-xs text-muted-foreground flex items-center mt-1">
<span className="text-green-500 flex items-center mr-1">
<TrendingUp className="h-3 w-3 mr-1" /> +15%
</span>
{t('achievements.fromLastWeek')}
</p>
</CardContent>
</Card>
{/* Streak Card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{t('achievements.currentStreak')}</CardTitle>
<Flame className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{t('achievements.days', { count: user.currentStreak })}</div>
<p className="text-xs text-muted-foreground mt-1">
{t('achievements.bestStreak', { count: user.currentStreak })}
</p>
</CardContent>
</Card>
{/* More stats placeholder */}
</div>
<div className="grid gap-4 md:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle>{t('achievements.xpActivity')}</CardTitle>
<CardDescription>{t('achievements.xpDescription')}</CardDescription>
</CardHeader>
<CardContent className="pl-2">
<Tabs defaultValue="weekly" className="space-y-4">
<div className="flex items-center justify-end px-4">
<TabsList>
<TabsTrigger value="weekly">{t('achievements.weekly')}</TabsTrigger>
<TabsTrigger value="monthly">{t('achievements.monthly')}</TabsTrigger>
<TabsTrigger value="yearly">{t('achievements.yearly')}</TabsTrigger>
</TabsList>
</div>
<TabsContent value="monthly" className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyData}>
<XAxis
dataKey="labelKey"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(val) => {
const translationKey = `analytics.${val}`;
const translated = t(translationKey);
return translated !== translationKey ? translated : `${t('analytics.cw')} ${val}`;
}}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
<Bar dataKey="xp" radius={[4, 4, 0, 0]}>
{monthlyData?.map((entry: any, index: number) => (
<Cell key={`cell-${index}`} fill={entry.xp > 300 ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'} opacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</TabsContent>
<TabsContent value="weekly" className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyData}>
<XAxis
dataKey="labelKey"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(val) => t(`analytics.${val}`)}
/>
<YAxis
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
<Bar dataKey="xp" radius={[4, 4, 0, 0]}>
{weeklyData?.map((entry: any, index: number) => (
<Cell key={`cell-${index}`} fill={entry.xp > 300 ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'} opacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</TabsContent>
<TabsContent value="yearly" className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={yearlyData}>
<XAxis
dataKey="labelKey"
stroke="#888888"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(val) => t(`analytics.${val}`)}
/>
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
<Bar dataKey="xp" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} opacity={0.8} />
</BarChart>
</ResponsiveContainer>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t('achievements.goals')}</CardTitle>
<CardDescription>{t('achievements.goalsDescription')}</CardDescription>
</div>
<Dialog open={isGoalModalOpen} onOpenChange={setIsGoalModalOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-2" />
{t('achievements.addGoal')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('achievements.createGoal')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreateGoal} className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('achievements.goalTitle')}</label>
<Input name="title" placeholder={t('achievements.goalTitlePlaceholder')} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('achievements.targetValue')}</label>
<Input name="target" type="number" placeholder="50" required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('achievements.type')}</label>
<Select name="type" defaultValue="weekly_tasks">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="weekly_tasks">{t('achievements.types.weekly_tasks')}</SelectItem>
<SelectItem value="total_xp">{t('achievements.types.total_xp')}</SelectItem>
<SelectItem value="streak">{t('achievements.types.streak')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" className="w-full">{t('achievements.createGoal')}</Button>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="space-y-6">
{goals.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Target className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p className="text-sm">{t('achievements.noGoals')}</p>
</div>
) : (
goals.map((goal) => (
<div key={goal.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{goal.completed ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
<span className={`text-sm font-medium ${goal.completed ? 'line-through text-muted-foreground' : ''}`}>
{goal.title}
</span>
</div>
<span className="text-xs text-muted-foreground">
{goal.current} / {goal.target}
</span>
</div>
<Progress value={(goal.current / goal.target) * 100} className="h-2" />
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="rewards">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="col-span-full mb-4 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-primary/20">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-primary" />
<CardTitle>{t('rewards.shopTitle')}</CardTitle>
</div>
<CardDescription>Spend your {user.xp} XP on exclusive rewards!</CardDescription>
</div>
<Dialog>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Reward
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Custom Reward</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreateReward} className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<Input name="title" required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Input name="description" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Cost (XP)</label>
<Input name="cost" type="number" required />
</div>
<Button type="submit" className="w-full">Create Reward</Button>
</form>
</DialogContent>
</Dialog>
</CardHeader>
</Card>
{rewards.map(reward => (
<div key={reward.id} className="h-full">
<RewardCard
reward={reward}
userXp={user.xp}
onBuy={handleBuyReward}
isBuying={buyRewardMutation.isPending}
/>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { User } from "@shared/schema";
import { apiRequest, queryClient } from "@/lib/queryClient";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Shield, ShieldAlert, User as UserIcon } from "lucide-react";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function AdminUserManagement() {
const { toast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newUser, setNewUser] = useState({ username: '', email: '', password: '', role: 'user' });
// Queries
const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ["/api/admin/users"],
});
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
queryKey: ["/api/admin/settings"],
});
// Mutations
const toggleActiveMutation = useMutation({
mutationFn: (userId: string) => apiRequest("POST", `/api/admin/users/${userId}/toggle-active`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/users"] });
toast({ title: "User status updated" });
},
onError: (e: Error) => toast({ title: "Failed to update", description: e.message, variant: "destructive" }),
});
const toggleRegistrationMutation = useMutation({
mutationFn: (enabled: boolean) => apiRequest("POST", "/api/admin/settings", { registration_enabled: enabled }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
toast({ title: "Settings updated" });
},
});
const createUserMutation = useMutation({
mutationFn: (data: typeof newUser) => apiRequest("POST", "/api/admin/users", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/users"] });
setIsCreateOpen(false);
setNewUser({ username: '', email: '', password: '', role: 'user' });
toast({ title: "User created" });
},
onError: (e: Error) => toast({ title: "Failed to create", description: e.message, variant: "destructive" }),
});
return (
<div className="space-y-6 container mx-auto p-4 max-w-5xl">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold tracking-tight">User Management</h1>
</div>
{/* Global Settings */}
<Card>
<CardHeader>
<CardTitle>System Settings</CardTitle>
<CardDescription>Control global access and registration</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div className="space-y-1">
<p className="font-medium">Public Registration</p>
<p className="text-sm text-muted-foreground">Allow new users to sign up</p>
</div>
<Switch
checked={settings?.registration_enabled}
onCheckedChange={(checked) => toggleRegistrationMutation.mutate(checked)}
/>
</CardContent>
</Card>
{/* User Table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Registered Users</CardTitle>
<CardDescription>Manage user accounts and roles</CardDescription>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> Create User</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Username</Label>
<Input value={newUser.username} onChange={e => setNewUser({ ...newUser, username: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={newUser.email} onChange={e => setNewUser({ ...newUser, email: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Password</Label>
<Input type="password" value={newUser.password} onChange={e => setNewUser({ ...newUser, password: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Role</Label>
<select
className="w-full p-2 border rounded-md bg-background"
value={newUser.role}
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Administrator</option>
</select>
</div>
<Button
onClick={() => createUserMutation.mutate(newUser)}
disabled={createUserMutation.isPending}
className="w-full"
>
{createUserMutation.isPending ? 'Creating...' : 'Create User'}
</Button>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>XP / Level</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center h-24">Loading users...</TableCell>
</TableRow>
) : users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{user.username}</span>
<span className="text-xs text-muted-foreground">{user.email}</span>
</div>
</TableCell>
<TableCell>
{user.role === 'admin' ? (
<Badge variant="default" className="bg-primary/20 text-primary hover:bg-primary/30">
<Shield className="w-3 h-3 mr-1" /> Admin
</Badge>
) : (
<Badge variant="outline">
<UserIcon className="w-3 h-3 mr-1" /> User
</Badge>
)}
</TableCell>
<TableCell>
{user.isActive ? (
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">Active</Badge>
) : (
<Badge variant="destructive">Inactive</Badge>
)}
</TableCell>
<TableCell>
{user.xp} XP (Lvl {user.level})
</TableCell>
<TableCell className="text-right">
<Button
variant={user.isActive ? "destructive" : "outline"}
size="sm"
onClick={() => toggleActiveMutation.mutate(user.id)}
disabled={user.role === 'admin' && user.username === 'admin'} // Protect super admin heuristic
>
{user.isActive ? 'Deactivate' : 'Activate'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useLocation } from "wouter";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertUserSchema, InsertUser, loginSchema, registerSchema, LoginUser } from "@shared/schema";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BrainCircuit } from "lucide-react";
export default function AuthPage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
queryKey: ["/api/settings/public"],
queryFn: async () => {
const res = await fetch("/api/settings/public");
if (!res.ok) {
throw new Error("Failed to fetch settings");
}
return res.json();
},
});
const loginMutation = useMutation({
mutationFn: async (data: LoginUser) => {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
throw new Error("Invalid username or password");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "Welcome back!" });
},
onError: (error: Error) => {
toast({
title: "Login failed",
description: error.message,
variant: "destructive",
});
},
});
const registerMutation = useMutation({
mutationFn: async (data: InsertUser) => {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Registration failed");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "Account created!" });
},
// Error handling is done in the form submission handler to set field errors
});
return (
<div className="min-h-screen grid lg:grid-cols-2">
<div className="hidden lg:flex flex-col justify-center items-center bg-zinc-900 p-12 text-white">
<div className="max-w-md space-y-4 text-center">
<div className="bg-white/10 p-4 rounded-2xl inline-block mb-4 backdrop-blur-sm">
<BrainCircuit className="w-16 h-16 text-primary-foreground" />
</div>
<h1 className="text-4xl font-bold tracking-tight">TaskFlow</h1>
<p className="text-lg text-zinc-400">
Master your productivity with AI-driven task management, gamified
achievements, and intelligent focus modes.
</p>
</div>
</div>
<div className="flex items-center justify-center p-4 bg-background">
<Card className="w-full max-w-md shadow-xl border-border/50">
<CardHeader className="text-center space-y-2">
<div className="lg:hidden mx-auto bg-primary/10 p-3 rounded-xl w-fit mb-2">
<BrainCircuit className="w-8 h-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
<CardDescription>
Sign in to your account
{settings?.registration_enabled && " or create a new one"} to get started
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="login" className="space-y-6">
<TabsList className={`grid w-full ${settings?.registration_enabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
<TabsTrigger value="login">Login</TabsTrigger>
{settings?.registration_enabled && (
<TabsTrigger value="register">Register</TabsTrigger>
)}
</TabsList>
<TabsContent value="login">
<AuthForm
mode="login"
onSubmit={(data) => loginMutation.mutate(data)}
isLoading={loginMutation.isPending}
/>
</TabsContent>
{settings?.registration_enabled && (
<TabsContent value="register">
<AuthForm
mode="register"
onSubmit={(data) => {
registerMutation.mutate(data as InsertUser, {
onError: (error) => {
// We can't access form here directly easily without refactoring,
// but we can pass a callback or handle it in AuthForm if we passed mutation there.
// However, simpler is to catch it here if we want global toast.
// The Requirements say "indicate failure".
// To set FIELD errors, we must be inside the form submit context or have access to form methods.
// Let's refactor AuthForm to handle the mutation itself or return the error?
// Actually, simpler: pass the mutation TO AuthForm so it can handle onError.
}
})
}}
isLoading={registerMutation.isPending}
registerMutation={registerMutation} // Pass mutation to handle errors inside
/>
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
</div>
</div>
);
}
function AuthForm({
mode,
onSubmit,
isLoading,
registerMutation,
}: {
mode: "login" | "register";
onSubmit: (data: InsertUser | LoginUser) => void;
isLoading: boolean;
registerMutation?: any; // Type accurately if possible, but 'any' for quick fix avoids generic complexities
}) {
const { toast } = useToast();
const form = useForm<InsertUser>({
resolver: zodResolver(mode === "login" ? loginSchema : registerSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
});
const handleSubmit = (data: InsertUser | LoginUser) => {
if (mode === 'register' && registerMutation) {
registerMutation.mutate(data, {
onError: (error: Error) => {
const msg = error.message.toLowerCase();
if (msg.includes("username")) {
form.setError("username", { type: "manual", message: "Username already exists" });
} else if (msg.includes("email")) {
form.setError("email", { type: "manual", message: "Email already exists" });
} else {
toast({
title: "Registration failed",
description: error.message,
variant: "destructive",
})
}
}
});
} else {
onSubmit(data);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{mode === 'login' ? 'Username or Email' : 'Username'}</FormLabel>
<FormControl>
<Input placeholder={mode === 'login' ? "Enter username or email" : "Choose a username"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{mode === "register" && (
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading
? mode === "login"
? "Logging in..."
: "Creating account..."
: mode === "login"
? "Sign In"
: "Create Account"}
</Button>
</form>
</Form>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Loader2, Trophy, Medal } from "lucide-react";
interface LeaderboardUser {
id: string;
username: string;
xp: number;
level: number;
}
export default function LeaderboardPage() {
const { data: leaderboard, isLoading } = useQuery<LeaderboardUser[]>({
queryKey: ["/api/leaderboard"],
});
if (isLoading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6 max-w-4xl mx-auto p-4 md:p-8">
<div className="flex items-center gap-3">
<div className="bg-yellow-500/20 p-3 rounded-xl">
<Trophy className="h-8 w-8 text-yellow-500" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Leaderboard</h1>
<p className="text-muted-foreground">Top performers in the community</p>
</div>
</div>
<Card className="border-border/50 shadow-sm">
<CardHeader>
<CardTitle>Global Rankings</CardTitle>
<CardDescription>Users ranked by total XP (opt-in only)</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{leaderboard?.map((user, index) => (
<div
key={user.id}
className={`flex items-center justify-between p-4 rounded-lg border ${index === 0 ? 'bg-yellow-500/10 border-yellow-500/50' :
index === 1 ? 'bg-slate-400/10 border-slate-400/50' :
index === 2 ? 'bg-amber-700/10 border-amber-700/50' :
'bg-card hover:bg-accent/50 transition-colors'
}`}
>
<div className="flex items-center gap-4">
<div className={`flex items-center justify-center w-8 h-8 rounded-full font-bold ${index < 3 ? 'text-foreground' : 'text-muted-foreground'
}`}>
{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}
</div>
<div className="flex flex-col">
<span className="font-semibold text-lg">{user.username}</span>
<span className="text-xs text-muted-foreground">Level {user.level}</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-lg text-primary">{user.xp}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wider">XP</span>
</div>
</div>
))}
{leaderboard?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No users on the leaderboard yet. Be the first to join in Settings!
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertUserSchema } from "@shared/schema";
import { useLocation } from "wouter";
import { useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { ShieldCheck } from "lucide-react";
export default function SetupWizard() {
const [, setLocation] = useLocation();
const { toast } = useToast();
const form = useForm({
resolver: zodResolver(insertUserSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
});
const setupMutation = useMutation({
mutationFn: async (data: any) => {
const res = await apiRequest("POST", "/api/setup", data);
return res.json();
},
onSuccess: (user) => {
queryClient.setQueryData(["/api/user"], user);
setLocation("/");
toast({
title: "Setup Complete",
description: "Super Administrator created successfully.",
});
},
onError: (error: Error) => {
toast({
title: "Setup Failed",
description: error.message,
variant: "destructive"
});
}
});
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
<Card className="w-full max-w-md border-2 border-primary/20 shadow-xl">
<CardHeader className="text-center">
<div className="mx-auto bg-primary/10 p-3 rounded-full w-fit mb-4">
<ShieldCheck className="w-10 h-10 text-primary" />
</div>
<CardTitle className="text-2xl">First-Time Setup</CardTitle>
<CardDescription>
Create your Super Administrator account to get started.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => setupMutation.mutate(data))} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Admin Username</FormLabel>
<FormControl>
<Input placeholder="admin" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Admin Email</FormLabel>
<FormControl>
<Input type="email" placeholder="admin@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={setupMutation.isPending}>
{setupMutation.isPending ? "Creating Admin..." : "Create Administrator"}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}
+80 -14
View File
@@ -6,10 +6,12 @@ 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 } from 'lucide-react';
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck } from 'lucide-react'; // Added ShieldCheck
import { Label } from '@shared/schema';
import { User } from '@shared/schema';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useLocation } from "wouter";
interface SettingsProps {
onNavigateToTemplates: () => void;
@@ -18,16 +20,37 @@ interface SettingsProps {
export default function Settings({ onNavigateToTemplates }: SettingsProps) {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const [, setLocation] = useLocation();
// Fetch user
const { data: user } = useQuery<User>({
queryKey: ['/api/user']
});
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [labelName, setLabelName] = useState('');
const [labelColor, setLabelColor] = useState('#3B82F6');
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
localStorage.setItem('taskflow-language', lng);
console.log('Language changed to:', lng);
const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
localStorage.setItem('taskflow-language', value);
console.log('Language changed to:', value);
};
const privacyMutation = useMutation({
mutationFn: async (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean }) => {
const res = await apiRequest("PATCH", "/api/user/privacy", updates);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "Privacy settings updated" });
},
});
const handlePrivacyUpdate = (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean }) => {
privacyMutation.mutate(updates);
};
// Fetch labels
@@ -115,6 +138,49 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</h1>
</div>
{/* Account Settings */}
<Card data-testid="card-account-settings">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserIcon className="w-5 h-5" />
Account
</CardTitle>
<CardDescription>
Manage your account settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">Username</p>
<p className="text-sm text-muted-foreground">{user?.username || 'Loading...'}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">User ID</p>
<p className="text-sm text-muted-foreground font-mono">{user?.id || '...'}</p>
</div>
</CardContent>
</Card>
{/* Admin Settings */}
{user?.role === 'admin' && (
<Card className="border-primary/50 bg-primary/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-primary" />
Administration
</CardTitle>
<CardDescription>
System-wide settings and user management
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setLocation("/admin/users")} className="w-full sm:w-auto">
Manage Users & Registration
</Button>
</CardContent>
</Card>
)}
{/* Language Settings */}
<Card data-testid="card-language-settings">
<CardHeader>
@@ -197,8 +263,8 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
<Button
variant="outline"
onClick={() => {
setIsLabelDialogOpen(false);
setEditingLabel(null);
@@ -210,7 +276,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
>
{t('settings.labels.cancel')}
</Button>
<Button
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
@@ -232,15 +298,15 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{labels.map((label) => (
<Card
key={label.id}
className="p-4 hover-elevate active-elevate-2"
<Card
key={label.id}
className="p-4 hover-elevate active-elevate-2"
style={{ borderLeft: `4px solid ${label.color}` }}
data-testid={`label-${label.id}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: label.color }}
/>
@@ -305,7 +371,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</CardDescription>
</CardHeader>
<CardContent>
<Button
<Button
onClick={onNavigateToTemplates}
data-testid="button-manage-templates"
>
+30
View File
@@ -0,0 +1,30 @@
/* eslint-disable no-restricted-globals */
self.onmessage = (e: MessageEvent) => {
const { command, payload } = e.data;
if (command === 'START') {
const { durationSeconds } = payload;
let remaining = durationSeconds;
// Clear any existing interval
if ((self as any).timerInterval) {
clearInterval((self as any).timerInterval);
}
(self as any).timerInterval = setInterval(() => {
remaining--;
self.postMessage({ type: 'TICK', remaining });
if (remaining <= 0) {
clearInterval((self as any).timerInterval);
self.postMessage({ type: 'COMPLETE' });
}
}, 1000);
} else if (command === 'STOP') {
if ((self as any).timerInterval) {
clearInterval((self as any).timerInterval);
}
}
};
export { };