feat: add social features, leaderboard, auth enhancements, and admin fixes
continuous-integration/drone/push Build is passing
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:
+21
-17
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ function Calendar({
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
showWeekNumber={true}
|
||||
weekStartsOn={1}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 { };
|
||||
Reference in New Issue
Block a user