Compare commits
68 Commits
b21d13d580
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53bcdce6c2 | |||
| cb4cf87ed4 | |||
| 04402c6b65 | |||
| 08a9e349f2 | |||
| 1f314b60aa | |||
| 200659ee96 | |||
| 90eb8a0465 | |||
| 68777ca051 | |||
| 8e8b96fcde | |||
| 79d5fae97c | |||
| c867c5ca71 | |||
| 064e978fc5 | |||
| 6a10f4c47b | |||
| 637bfb95f9 | |||
| 70de82e88f | |||
| 4f6aff32ab | |||
| c1f7a13c0e | |||
| ad50b97f44 | |||
| 88cd2a5c88 | |||
| 4b51e9f05e | |||
| f91978c078 | |||
| 9bfc9e2f96 | |||
| 2a98ebdf9f | |||
| b4e7cce651 | |||
| 5ac64b437b | |||
| f165ae52e6 | |||
| 7ce4f7efdc | |||
| 74ffef48d3 | |||
| 877d0c897e | |||
| 2579df0b89 | |||
| 9819d8db0b | |||
| 7b79015ac2 | |||
| 9736864425 | |||
| 71d5c825c5 | |||
| e8d9d0c686 | |||
| bc40969ad7 | |||
| cd7707a725 | |||
| aa5f2328f7 | |||
| d1736c5991 | |||
| fdf321cde9 | |||
| a184115d28 | |||
| 93063f772d | |||
| 5d8976b1cd | |||
| ccfb674318 | |||
| d5b045158a | |||
| 3c39346087 | |||
| f045e2dce9 | |||
| 102315fa7c | |||
| 57f09310ca | |||
| 5a72497acd | |||
| 284223bdcc | |||
| 16c097b3f0 | |||
| 56709ce8ca | |||
| b9b3572a02 | |||
| f6c4d1332c | |||
| 8248acceb9 | |||
| a9efeeafbf | |||
| f65eea2d25 | |||
| 9c8ec283ed | |||
| b69726b428 | |||
| c1fe158b3e | |||
| 037d3f4d73 | |||
| 94bb2b599d | |||
| 64114cfb9c | |||
| 4a5a59fb34 | |||
| 82ffb73d41 | |||
| 7870d0b31e | |||
| 037256401f |
@@ -0,0 +1,7 @@
|
||||
DATABASE_URL=postgresql://taskflow:taskflow_password@localhost:5432/taskflow
|
||||
|
||||
POSTGRES_USER=taskflow
|
||||
POSTGRES_PASSWORD=taskflow_password
|
||||
POSTGRES_DB=taskflow
|
||||
SESSION_SECRET=local_development_secret
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
when:
|
||||
event: [push, manual]
|
||||
|
||||
steps:
|
||||
- name: build taskflow
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/taskflow
|
||||
tag: latest
|
||||
dockerfile: Dockerfile
|
||||
registry: registry.local.nothaft.cloud
|
||||
@@ -5,9 +5,9 @@ WORKDIR /app
|
||||
# Dependencies stage
|
||||
FROM base AS dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --only=production && \
|
||||
cp -R node_modules /tmp/prod_node_modules && \
|
||||
npm ci
|
||||
RUN npm ci --omit=dev && \
|
||||
cp -R node_modules /tmp/prod_node_modules && \
|
||||
npm ci
|
||||
|
||||
# Build stage
|
||||
FROM base AS build
|
||||
@@ -33,6 +33,9 @@ COPY --from=build /app/tsconfig.json ./
|
||||
# Copy built frontend to the location static.ts expects
|
||||
COPY --from=build /app/dist/public ./server/public
|
||||
|
||||
# Copy attached assets (logo, favicon, etc.)
|
||||
COPY --from=build /app/attached_assets ./server/public/attached_assets
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5000
|
||||
@@ -45,4 +48,5 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:5000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Start the application using tsx to run TypeScript directly
|
||||
CMD ["npx", "tsx", "server/index.ts"]
|
||||
# Run DB migrations and start the application
|
||||
CMD ["sh", "-c", "npx drizzle-kit push && npx tsx server/index.ts"]
|
||||
|
||||
|
After Width: | Height: | Size: 240 KiB |
@@ -0,0 +1,52 @@
|
||||
64: } catch (e) {
|
||||
95: } catch (e) {
|
||||
127: } catch (e) {
|
||||
138: } catch (e) {
|
||||
164: } catch (e) {
|
||||
190: } catch (e) {
|
||||
216: } catch (e) {
|
||||
238: } catch (e) {
|
||||
298: } catch (e: any) {
|
||||
326: } catch (e: any) {
|
||||
340: } catch (e) {
|
||||
363: } catch (e) {
|
||||
392: } catch (e) {
|
||||
419: } catch (e) {
|
||||
432: } catch (e) {
|
||||
457: } catch (e) {
|
||||
472: } catch (e) {
|
||||
489: } catch (e: any) {
|
||||
556: } catch (e: any) {
|
||||
633: } catch (e: any) {
|
||||
665: } catch (error) {
|
||||
677: } catch (error) {
|
||||
704: } catch (error) {
|
||||
732: } catch (error) {
|
||||
755: } catch (error) {
|
||||
789: } catch (e) {
|
||||
813: } catch (e) {
|
||||
833: } catch (e) {
|
||||
844: } catch (error) {
|
||||
858: } catch (error) {
|
||||
892: } catch (e: any) {
|
||||
926: } catch (error) {
|
||||
968: } catch (error: any) {
|
||||
991: } catch (error) {
|
||||
1002: } catch (error) {
|
||||
1015: } catch (error) {
|
||||
1025: } catch (error) {
|
||||
1052: } catch (error) {
|
||||
1137: } catch (error) {
|
||||
1175: } catch (e) {
|
||||
1195: } catch (e) {
|
||||
1214: } catch (e) {
|
||||
1232: } catch (e) {
|
||||
1248: } catch (e) {
|
||||
1266: } catch (e) {
|
||||
1287: } catch (e) {
|
||||
1300: } catch (e) {
|
||||
1325: } catch (e) {
|
||||
1345: } catch (e) {
|
||||
1370: } catch (e) {
|
||||
1384: } catch (e) {
|
||||
1400: } catch (e) {
|
||||
@@ -0,0 +1 @@
|
||||
TRUNCATE TABLE "session";
|
||||
@@ -1,18 +1,41 @@
|
||||
<!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="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" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#7c3aed" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="TaskFlow" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/favicon-64x64.png" />
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-128x128.png" />
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="/favicon-256x256.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 742 B |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 764 B |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 388 KiB |
|
After Width: | Height: | Size: 6.8 MiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 7.6 MiB |
|
After Width: | Height: | Size: 6.3 MiB |
|
After Width: | Height: | Size: 6.7 MiB |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "TaskFlow",
|
||||
"short_name": "TaskFlow",
|
||||
"description": "Gamified task management with AI assistance. Focus. Flow. AI.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f0f23",
|
||||
"theme_color": "#7c3aed",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-1024.png",
|
||||
"sizes": "1024x1024",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "utilities"],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// TaskFlow Service Worker for Push Notifications
|
||||
const CACHE_NAME = 'taskflow-v1';
|
||||
|
||||
// Install event - cache essential assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...');
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...');
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Push event - handle incoming push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push received:', event);
|
||||
|
||||
let data = {
|
||||
title: 'TaskFlow',
|
||||
body: 'You have a new notification',
|
||||
icon: '/icon-256.png',
|
||||
badge: '/icon-128.png',
|
||||
tag: 'taskflow-notification',
|
||||
data: {}
|
||||
};
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
const payload = event.data.json();
|
||||
data = {
|
||||
title: payload.title || data.title,
|
||||
body: payload.body || data.body,
|
||||
icon: payload.icon || data.icon,
|
||||
badge: payload.badge || data.badge,
|
||||
tag: payload.tag || data.tag,
|
||||
data: payload.data || {}
|
||||
};
|
||||
} catch (e) {
|
||||
// If not JSON, use text
|
||||
data.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: data.icon,
|
||||
badge: data.badge,
|
||||
tag: data.tag,
|
||||
data: data.data,
|
||||
vibrate: [100, 50, 100],
|
||||
requireInteraction: false,
|
||||
actions: [
|
||||
{
|
||||
action: 'open',
|
||||
title: 'Open TaskFlow'
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
title: 'Dismiss'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click event - handle user interaction
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[SW] Notification clicked:', event);
|
||||
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'dismiss') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Default action or 'open' action - open the app
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
// Check if app is already open
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
client.focus();
|
||||
if (event.notification.data?.url) {
|
||||
client.navigate(event.notification.data.url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Open new window if not already open
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Notification close event
|
||||
self.addEventListener('notificationclose', (event) => {
|
||||
console.log('[SW] Notification closed:', event);
|
||||
});
|
||||
|
||||
// Handle push subscription change
|
||||
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
console.log('[SW] Push subscription changed:', event);
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: event.oldSubscription?.options?.applicationServerKey
|
||||
}).then((subscription) => {
|
||||
// Send new subscription to server
|
||||
return fetch('/api/push/resubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oldEndpoint: event.oldSubscription?.endpoint,
|
||||
newSubscription: subscription.toJSON()
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
@@ -17,131 +14,122 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import { Switch, Route, useLocation } from "wouter";
|
||||
|
||||
// ADHD Mode Components
|
||||
import {
|
||||
ADHDModeProvider,
|
||||
useADHDMode,
|
||||
BreakReminderProvider,
|
||||
BreakReminderOverlay,
|
||||
EnergyCheckIn,
|
||||
HyperfocusGuard,
|
||||
EncouragementToast,
|
||||
useEncouragement
|
||||
} from '@/components/adhd';
|
||||
|
||||
// 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 ThemeToggle from './components/ThemeToggle';
|
||||
import WeekListView from './components/WeekListView';
|
||||
import Settings from './pages/settings';
|
||||
import AchievementsPage from './pages/AchievementsPage';
|
||||
import UnscheduledTasksPage from './pages/UnscheduledTasksPage';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import { addDays, subDays } from 'date-fns';
|
||||
import { useTimer } from './hooks/useTimer';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { AppSidebar } from "./components/AppSidebar"
|
||||
import { useNotifications } from './hooks/use-notifications';
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import PomodoroOverlay from './components/PomodoroOverlay';
|
||||
import AuthPage from "@/pages/AuthPage";
|
||||
|
||||
// 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 LeaderboardPage from "@/pages/LeaderboardPage";
|
||||
import SetupWizard from "@/pages/SetupWizard";
|
||||
import AdminUserManagement from "@/pages/AdminUserManagement";
|
||||
import AdminSettings from "@/pages/AdminSettings";
|
||||
import NotFound from "@/pages/not-found";
|
||||
// import { AiChat } from "@/components/AiChat";
|
||||
import ForgotPasswordPage from "@/pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "@/pages/ResetPasswordPage";
|
||||
import AiChatPage from "@/pages/AiChatPage";
|
||||
import NotificationsPage from "@/pages/NotificationsPage";
|
||||
import FocusRoutinePage from "@/pages/FocusRoutinePage";
|
||||
|
||||
function App() {
|
||||
|
||||
import { useRoutineBlocker } from './components/RoutineBlocker';
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { User } from "@shared/schema";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
|
||||
import TimeTrackingPage from './pages/TimeTrackingPage';
|
||||
|
||||
// ADHD Pages
|
||||
import QuickWinsPage from './pages/QuickWinsPage';
|
||||
import SingleTaskPage from './pages/SingleTaskPage';
|
||||
import BodyDoublingPage from './pages/BodyDoublingPage';
|
||||
import ADHDDashboardPage from './pages/ADHDDashboardPage';
|
||||
|
||||
// Self-managing EncouragementToast wrapper
|
||||
function EncouragementToastWrapper() {
|
||||
const { visible, type, message, xp, hide } = useEncouragement();
|
||||
return <EncouragementToast type={type} visible={visible} onDismiss={hide} customMessage={message} xpEarned={xp} />;
|
||||
}
|
||||
|
||||
// Inner App component that uses ADHD hooks
|
||||
function AppContent() {
|
||||
const { t } = useTranslation();
|
||||
const [currentTab, setCurrentTab] = useState('tasks');
|
||||
const [tasks, setTasks] = useState<Task[]>(initialTasks);
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
const { toast } = useToast();
|
||||
const [, setLocation] = useLocation();
|
||||
const isBlocked = useRoutineBlocker();
|
||||
const { isEnabled: adhdEnabled, settings: adhdSettings } = useADHDMode();
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null);
|
||||
const [showPomodoro, setShowPomodoro] = useState(false);
|
||||
const [activePomodoroTaskId, setActivePomodoroTaskId] = useState<string | null>(null);
|
||||
|
||||
// Fetch tasks and labels from server on mount
|
||||
useEffect(() => {
|
||||
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]);
|
||||
}
|
||||
// Enable global notifications polling
|
||||
useNotifications({ poll: true });
|
||||
|
||||
// Fetch labels
|
||||
const labelsResponse = await fetch('/api/labels');
|
||||
if (labelsResponse.ok) {
|
||||
const serverLabels: Label[] = await labelsResponse.json();
|
||||
setLabels(serverLabels);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
// Keep initial data on error
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: user, isLoading: isLoadingUser } = useQuery<User>({
|
||||
queryKey: ["/api/user"],
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: setupStatus } = useQuery<{ isSetup: boolean }>({
|
||||
queryKey: ['/api/setup/status'],
|
||||
});
|
||||
|
||||
// Fetch tasks and labels using React Query
|
||||
const { data: tasksData } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const tasks = (tasksData ?? []).map(task => ({
|
||||
...task,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : null
|
||||
}));
|
||||
|
||||
const { data: labelsData } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels'],
|
||||
enabled: !!user,
|
||||
});
|
||||
const labels = labelsData ?? [];
|
||||
|
||||
const handleCreateTask = async (newTask: Partial<Task>) => {
|
||||
try {
|
||||
@@ -150,36 +138,58 @@ function App() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: newTask.title || '',
|
||||
description: newTask.description || null,
|
||||
description: newTask.description || undefined,
|
||||
status: 'todo',
|
||||
priority: newTask.priority || 'medium',
|
||||
dueDate: newTask.dueDate || null,
|
||||
dueDate: newTask.dueDate || null, // Explicitly handled as nullable in schema
|
||||
timeTracked: 0,
|
||||
isTracking: false,
|
||||
projectId: newTask.projectId || null,
|
||||
notes: newTask.notes || null,
|
||||
labelId: newTask.labelId || null
|
||||
projectId: newTask.projectId || undefined,
|
||||
notes: newTask.notes || undefined,
|
||||
labelId: newTask.labelId || undefined,
|
||||
energyLevel: newTask.energyLevel || 'medium',
|
||||
estimatedDuration: newTask.estimatedDuration || undefined
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create task');
|
||||
}
|
||||
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);
|
||||
|
||||
// Update cache optimistically or invalidation
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => {
|
||||
const normalizedTask = {
|
||||
...createdTask,
|
||||
dueDate: createdTask.dueDate ? new Date(createdTask.dueDate) : null
|
||||
};
|
||||
return [...old, normalizedTask];
|
||||
});
|
||||
// Invalidate to be sure
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
|
||||
toast({
|
||||
title: t('task.created', 'Task Created'),
|
||||
description: t('task.createdDescription', '"{title}" has been added.', { title: createdTask.title }),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
toast({
|
||||
title: t('error.createTask', 'Error'),
|
||||
description: t('error.createTaskDescription', 'Failed to create task. Please try again.'),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskUpdate = async (taskId: string, updates: Partial<Task>) => {
|
||||
// Optimistic Update
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
|
||||
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
|
||||
old.map(task => task.id === taskId ? { ...task, ...updates } : task)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -187,35 +197,44 @@ function App() {
|
||||
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);
|
||||
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => uniqueTasks(old, updatedTask));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating task:', error);
|
||||
// Optimistic update fallback
|
||||
setTasks(prev => prev.map(task =>
|
||||
task.id === taskId ? { ...task, ...updates } : task
|
||||
));
|
||||
// Rollback
|
||||
if (previousTasks) {
|
||||
queryClient.setQueryData(['/api/tasks'], previousTasks);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the timer hook after handleTaskUpdate is defined
|
||||
// Helper to merge updated task into array
|
||||
const uniqueTasks = (tasks: Task[], updated: Task) => {
|
||||
const normalized = { ...updated, dueDate: updated.dueDate ? new Date(updated.dueDate) : null };
|
||||
return tasks.map(t => t.id === updated.id ? normalized : t);
|
||||
};
|
||||
|
||||
const handleCreateFromTemplate = (templateId: string, startDate: Date, projectName: string) => {
|
||||
console.log('Creating project from template:', { templateId, startDate, projectName });
|
||||
// Assuming this might create tasks, we should invalidate
|
||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }), 1000);
|
||||
};
|
||||
|
||||
const { startTimer, stopTimer } = useTimer({
|
||||
tasks,
|
||||
onTaskUpdate: handleTaskUpdate
|
||||
});
|
||||
|
||||
const handleStartPomodoro = (taskId: string) => {
|
||||
setActivePomodoroTaskId(taskId);
|
||||
setShowPomodoro(true);
|
||||
startTimer(taskId);
|
||||
};
|
||||
|
||||
const handleTaskStatusChange = (taskId: string, newStatus: Task['status']) => {
|
||||
handleTaskUpdate(taskId, { status: newStatus });
|
||||
};
|
||||
@@ -227,7 +246,6 @@ function App() {
|
||||
const handleTaskClick = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setIsTaskDetailsOpen(true);
|
||||
console.log('Task clicked:', task.title);
|
||||
};
|
||||
|
||||
const handleTaskDetailsClose = () => {
|
||||
@@ -237,7 +255,6 @@ function App() {
|
||||
|
||||
const handleTaskDetailsSave = (updatedTask: Task) => {
|
||||
handleTaskUpdate(updatedTask.id, updatedTask);
|
||||
console.log('Task details saved:', updatedTask.title);
|
||||
};
|
||||
|
||||
const handleTaskDelete = async (taskId: string) => {
|
||||
@@ -247,194 +264,308 @@ function App() {
|
||||
const confirmTaskDelete = async () => {
|
||||
if (!deleteTaskId) return;
|
||||
|
||||
// Optimistic deletion - remove from UI immediately
|
||||
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
|
||||
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
|
||||
// Optimistic delete
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
|
||||
old.filter(task => task.id !== deleteTaskId)
|
||||
);
|
||||
|
||||
if (selectedTask?.id === deleteTaskId) {
|
||||
handleTaskDetailsClose();
|
||||
}
|
||||
|
||||
setDeleteTaskId(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${deleteTaskId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
// 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');
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
// Restore task on error
|
||||
if (taskToDelete) {
|
||||
setTasks(prev => [...prev, taskToDelete]);
|
||||
// Rollback
|
||||
if (previousTasks) {
|
||||
queryClient.setQueryData(['/api/tasks'], previousTasks);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 flex-col items-center justify-center min-h-screen bg-background gap-6">
|
||||
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center shadow-2xl animate-pulse">
|
||||
<img src="/icon-transparent-256.png" alt="TaskFlow" className="w-14 h-14 object-contain" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground text-sm">Loading TaskFlow...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentTab) {
|
||||
case 'tasks':
|
||||
return (
|
||||
<TasksWithCalendar
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
);
|
||||
// Setup Redirect
|
||||
if (setupStatus && !setupStatus.isSetup) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/setup" component={SetupWizard} />
|
||||
<Route component={() => <SetupWizard />} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
case 'calendar':
|
||||
return (
|
||||
<CalendarView
|
||||
tasks={tasks}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onTaskClick={handleTaskClick}
|
||||
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'kanban':
|
||||
return (
|
||||
<KanbanBoard
|
||||
tasks={tasks}
|
||||
onTaskStatusChange={handleTaskStatusChange}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskClick={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
);
|
||||
if (isBlocked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background w-full">
|
||||
<Switch>
|
||||
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
|
||||
{/* Catch all redirects to nothing, the hook handles the push to routine page */}
|
||||
<Route component={() => <div />} />
|
||||
</Switch>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'templates':
|
||||
return (
|
||||
<ProjectTemplate
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateTasks={(newTasks) => setTasks(prev => [...prev, ...newTasks])}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'settings':
|
||||
return <Settings />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">Page not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background w-full">
|
||||
<Switch>
|
||||
<Route path="/auth" component={AuthPage} />
|
||||
<Route path="/achievements">{() => <AuthPage />}</Route>
|
||||
<Route path="/leaderboard" component={LeaderboardPage} />
|
||||
<Route path="/time-tracking" component={TimeTrackingPage} />
|
||||
<Route path="/ai" component={AiChatPage} />
|
||||
<Route component={AuthPage} />
|
||||
</Switch>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<TooltipProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset>
|
||||
<div className={`min-h-screen bg-background flex flex-col min-w-0 ${adhdEnabled ? 'adhd-mode' : ''} ${adhdEnabled && adhdSettings.reducedAnimations ? 'reduced-motion' : ''} ${adhdEnabled && adhdSettings.largerTargets ? 'larger-targets' : ''}`}>
|
||||
{/* Mobile Header with Sidebar Toggle */}
|
||||
<header className="sticky top-0 z-50 flex items-center gap-3 px-4 py-3 bg-background/80 backdrop-blur-xl border-b border-border/50 md:hidden">
|
||||
<SidebarTrigger className="h-9 w-9" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-sm">T</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center shadow-md">
|
||||
<img src="/icon-transparent-128.png" alt="TaskFlow" className="w-6 h-6 object-contain" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold" data-testid="text-app-title">
|
||||
{t('app.title')}
|
||||
</h1>
|
||||
<span className="font-bold text-lg">{t('app.title')}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 p-4 md:p-6 w-full relative">
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<FocusMode
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
onNavigateToTasks={() => setLocation('/tasks')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/focus">
|
||||
<FocusMode
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
onNavigateToTasks={() => setLocation('/tasks')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/calendar">
|
||||
<CalendarView tasks={tasks} />
|
||||
</Route>
|
||||
<Route path="/weeklist">
|
||||
<WeekListView tasks={tasks} onTaskUpdate={handleTaskUpdate} />
|
||||
</Route>
|
||||
<Route path="/kanban">
|
||||
<KanbanBoard
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/auth">
|
||||
{() => {
|
||||
setLocation('/');
|
||||
return null;
|
||||
}}
|
||||
</Route>
|
||||
<Route path="/tasks">
|
||||
<TasksWithCalendar
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/templates">
|
||||
<ProjectTemplate
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateTasks={(newTasks) => {
|
||||
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => [...old, ...newTasks]);
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
}}
|
||||
onNavigateToSettings={() => setLocation('/settings')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/achievements">
|
||||
{() => <AchievementsPage user={user} />}
|
||||
</Route>
|
||||
|
||||
<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>
|
||||
<Route path="/unscheduled">
|
||||
<UnscheduledTasksPage
|
||||
user={user}
|
||||
onToggleCompletion={(taskId, currentStatus) => handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')}
|
||||
onDelete={(id) => setDeleteTaskId(id)}
|
||||
onUpdate={handleTaskUpdate}
|
||||
onSelect={setSelectedTask}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/leaderboard">
|
||||
<LeaderboardPage />
|
||||
</Route>
|
||||
<Route path="/leaderboard">
|
||||
<LeaderboardPage />
|
||||
</Route>
|
||||
<Route path="/ai">
|
||||
<AiChatPage />
|
||||
</Route>
|
||||
<Route path="/notifications">
|
||||
<NotificationsPage />
|
||||
</Route>
|
||||
<Route path="/time-tracking">
|
||||
<TimeTrackingPage />
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Route */}
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<Route path="/admin/users" component={AdminUserManagement} />
|
||||
<Route path="/admin/settings" component={AdminSettings} />
|
||||
</>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
|
||||
|
||||
{/* ADHD Mode Routes */}
|
||||
<Route path="/adhd" component={ADHDDashboardPage} />
|
||||
<Route path="/adhd/quick-wins" component={QuickWinsPage} />
|
||||
<Route path="/adhd/single-task" component={SingleTaskPage} />
|
||||
<Route path="/adhd/body-doubling" component={BodyDoublingPage} />
|
||||
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<div className="fixed bottom-8 right-8 z-[100]">
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-14 w-14 rounded-full shadow-2xl bg-gradient-to-r from-violet-600 to-indigo-600 hover:scale-110 transition-transform duration-200"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
data-testid="fab-create-task"
|
||||
>
|
||||
<Plus className="h-6 w-6 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<CommandPalette
|
||||
onNavigate={(path) => setLocation(path.startsWith('/') ? path : `/${path}`)}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="pb-20 px-3 py-4 sm:px-4 sm:py-6 max-w-screen-2xl mx-auto">
|
||||
{renderContent()}
|
||||
</main>
|
||||
<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 })}
|
||||
/>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<BottomNavigation
|
||||
activeTab={currentTab}
|
||||
onTabChange={(tab) => {
|
||||
if (tab === 'settings') {
|
||||
setCurrentTab('settings');
|
||||
} else {
|
||||
setCurrentTab(tab);
|
||||
}
|
||||
}}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
<Toaster />
|
||||
|
||||
{/* Task Creation Modal */}
|
||||
<TaskCreationModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSave={handleCreateTask}
|
||||
/>
|
||||
{/* ADHD Mode Overlays */}
|
||||
{adhdEnabled && <BreakReminderOverlay />}
|
||||
{adhdEnabled && adhdSettings.hyperfocusProtection && <HyperfocusGuard />}
|
||||
{adhdEnabled && adhdSettings.positiveMessagingLevel !== 'minimal' && <EncouragementToastWrapper />}
|
||||
|
||||
{/* 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>
|
||||
<TaskDetailsModal
|
||||
isOpen={isTaskDetailsOpen}
|
||||
onClose={handleTaskDetailsClose}
|
||||
task={selectedTask}
|
||||
onSave={handleTaskDetailsSave}
|
||||
labels={labels}
|
||||
onNavigate={(id) => {
|
||||
const t = tasks.find(x => x.id === id);
|
||||
if (t) setSelectedTask(t);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
<AlertDialog open={deleteTaskId !== null} onOpenChange={(open) => !open && setDeleteTaskId(null)}>
|
||||
<AlertDialogContent data-testid="dialog-delete-confirmation">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteConfirmation.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteConfirmation.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-testid="button-cancel-delete">
|
||||
{t('deleteConfirmation.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmTaskDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
data-testid="button-confirm-delete"
|
||||
>
|
||||
{t('deleteConfirmation.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Main App wrapper with ADHD providers
|
||||
function App() {
|
||||
return (
|
||||
<ADHDModeProvider>
|
||||
<BreakReminderProvider>
|
||||
<AppContent />
|
||||
</BreakReminderProvider>
|
||||
</ADHDModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardFooter, CardHeader } from '@/components/ui/card';
|
||||
import { Bot, X, Send, Sparkles } from 'lucide-react';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Typing indicator with animated dots - v2
|
||||
function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0ms', animationDuration: '0.6s' }} />
|
||||
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '150ms', animationDuration: '0.6s' }} />
|
||||
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '300ms', animationDuration: '0.6s' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiChat() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (msgs: Message[]) => {
|
||||
const res = await apiRequest("POST", "/api/ai/chat", { messages: msgs });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setMessages(prev => [...prev, data]);
|
||||
setIsWaitingForResponse(false);
|
||||
// Refresh tasks and labels in case AI created/modified them
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: t('ai.chat.error', 'Something went wrong') + ": " + err.message }]);
|
||||
setIsWaitingForResponse(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Use our own loading state OR mutation.isPending for the indicator
|
||||
const isLoading = isWaitingForResponse || mutation.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, isOpen, isLoading]);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const newMsgs: Message[] = [...messages, { role: 'user', content: input }];
|
||||
setMessages(newMsgs);
|
||||
setInput('');
|
||||
setIsWaitingForResponse(true);
|
||||
mutation.mutate(newMsgs);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-4 right-8 z-50 rounded-full h-14 w-14 shadow-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:scale-110 transition-transform duration-200"
|
||||
size="icon"
|
||||
>
|
||||
<Sparkles className="h-6 w-6 text-white animate-pulse" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="fixed bottom-4 right-8 z-50 w-80 md:w-96 h-[500px] flex flex-col shadow-2xl border-primary/20 animate-in slide-in-from-bottom-10 fade-in duration-200">
|
||||
<CardHeader className="p-4 border-b bg-primary/5 flex flex-row items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
{t('ai.chat.title', 'AI Assistant')}
|
||||
{isLoading && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground animate-pulse">
|
||||
{t('ai.chat.thinking', 'Thinking')}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsOpen(false)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4" ref={scrollRef}>
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground mt-10">
|
||||
<Bot className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm px-4">{t('ai.chat.welcome', 'Hi! I can help you manage tasks. Try: "Create a task to buy groceries tomorrow"')}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={cn("flex w-full", msg.role === 'user' ? "justify-end" : "justify-start")}>
|
||||
<div className={cn(
|
||||
"max-w-[80%] rounded-2xl px-4 py-2 text-sm shadow-sm whitespace-pre-wrap",
|
||||
msg.role === 'user'
|
||||
? "bg-primary text-primary-foreground rounded-br-none"
|
||||
: "bg-muted text-foreground rounded-bl-none"
|
||||
)}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex w-full justify-start">
|
||||
<div className="bg-muted rounded-2xl rounded-bl-none px-4 py-3 flex items-center gap-3">
|
||||
<TypingIndicator />
|
||||
<span className="text-sm text-muted-foreground">{t('ai.chat.thinking', 'Thinking')}...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardFooter className="p-3 border-t bg-background shrink-0">
|
||||
<form onSubmit={handleSubmit} className="flex w-full gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t('ai.chat.placeholder', 'Ask me anything...')}
|
||||
className="bg-muted/50 focus-visible:ring-primary/50"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff, Bell, Clock, Brain } from 'lucide-react';
|
||||
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
SidebarTrigger,
|
||||
SidebarSeparator,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Task, User } from '@shared/schema';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { useLocation } from "wouter";
|
||||
import { GamificationBar } from './GamificationBar';
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
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();
|
||||
|
||||
// Fetch unscheduled count
|
||||
// const { data: unscheduledCount = 0 } = useQuery({
|
||||
// queryKey: ['/api/tasks'],
|
||||
// select: (tasks: Task[]) => tasks.filter(t => !t.dueDate && t.status !== 'done').length,
|
||||
// enabled: !!user
|
||||
// });
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
// toast({ title: "Logged out successfully" });
|
||||
window.location.href = "/auth?mode=login";
|
||||
},
|
||||
});
|
||||
|
||||
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('unscheduled.title', 'Unscheduled'), id: 'unscheduled', path: '/unscheduled', icon: CalendarOff, color: 'text-slate-500' },
|
||||
{ title: t('navigation.kanban'), id: 'kanban', path: '/kanban', icon: LayoutGrid, color: 'text-orange-500' },
|
||||
{ title: t('navigation.adhd', 'ADHD Mode'), id: 'adhd', path: '/adhd', icon: Brain, color: 'text-purple-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('analytics.timeTracking'), id: 'time-tracking', path: '/time-tracking', icon: Clock, color: 'text-teal-500' },
|
||||
...(user?.aiEnabled ? [{ title: t('navigation.aiChat', 'AI Chat'), id: 'ai-chat', path: '/ai', icon: Bot, color: 'text-indigo-500' }] : []),
|
||||
{ title: t('navigation.settings'), id: 'settings', path: '/settings', icon: Settings, color: 'text-gray-500' },
|
||||
]
|
||||
|
||||
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 overflow-hidden shadow-lg bg-gradient-to-br from-violet-600 to-indigo-600">
|
||||
<img src="/icon-transparent-128.png" alt="TaskFlow" className="w-7 h-7 object-contain" />
|
||||
</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">Focus. Flow. AI.</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>
|
||||
<SidebarSeparator />
|
||||
<SidebarFooter>
|
||||
{state !== 'collapsed' && user && (
|
||||
<GamificationBar xp={user.xp} level={user.level} streak={user.currentStreak} />
|
||||
)}
|
||||
<div className={`p-4 ${state === 'collapsed'
|
||||
? 'flex flex-col items-center justify-center gap-4'
|
||||
: 'grid grid-cols-4 items-center gap-2'
|
||||
}`}>
|
||||
<div className={state === 'collapsed' ? '' : 'justify-self-start'}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className={state === 'collapsed' ? '' : 'justify-self-center'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation('/notifications')}
|
||||
className="bg-transparent hover:bg-sidebar-accent"
|
||||
title={t('navigation.notifications', 'Notifications')}
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={state === 'collapsed' ? '' : 'justify-self-center'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
disabled={logoutMutation.isPending}
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
title={t('auth.logout', 'Logout')}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={state === 'collapsed' ? '' : 'justify-self-end'}>
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Home, Calendar, 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: '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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Calendar, Edit, Timer, CheckCircle, Play, Clock, Trash2 } from 'lucide-react';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { addDays, format, isSameDay, startOfWeek, subWeeks } from 'date-fns';
|
||||
import { addDays, format, isSameDay, startOfWeek, subWeeks, startOfDay } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
|
||||
interface CalendarViewProps {
|
||||
tasks: Task[];
|
||||
onTaskDrop?: (taskId: string, newDate: Date) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
onDateSelect?: (date: Date) => void;
|
||||
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
|
||||
onTaskEdit?: (task: Task) => void;
|
||||
onTaskDelete?: (taskId: string) => void;
|
||||
onStartTimer?: (taskId: string) => void;
|
||||
onStopTimer?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSelect }: CalendarViewProps) {
|
||||
export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSelect, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: CalendarViewProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||
const [longPressTask, setLongPressTask] = useState<Task | null>(null);
|
||||
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const longPressTriggered = useRef(false);
|
||||
|
||||
// Get date-fns locale based on current language
|
||||
const dateLocale = i18n.language === 'de' ? de : enUS;
|
||||
@@ -37,9 +66,24 @@ 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 => {
|
||||
// Handle multi-day tasks
|
||||
if (task.startDate && task.dueDate) {
|
||||
const start = startOfDay(new Date(task.startDate));
|
||||
const end = startOfDay(new Date(task.dueDate));
|
||||
const current = startOfDay(date);
|
||||
return current >= start && current <= end;
|
||||
}
|
||||
|
||||
// Handle single date tasks (dueDate or startDate)
|
||||
if (task.dueDate) {
|
||||
return isSameDay(new Date(task.dueDate), date);
|
||||
}
|
||||
if (task.startDate) { // Fallback if only start date exists
|
||||
return isSameDay(new Date(task.startDate), date);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragStart = (taskId: string) => {
|
||||
@@ -69,16 +113,171 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
||||
console.log(`Navigating to week of ${newDate.toLocaleDateString()}`);
|
||||
};
|
||||
|
||||
// Long press handlers for mobile
|
||||
const handleTouchStart = (task: Task) => {
|
||||
// Clear any existing timeout
|
||||
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;
|
||||
}, 300);
|
||||
}, 500); // 500ms long press threshold
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (longPressTimerRef.current) {
|
||||
clearTimeout(longPressTimerRef.current);
|
||||
longPressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// Menu content component to avoid duplication
|
||||
const TaskMenuContent = ({ task, closeMenu }: { task: Task; closeMenu?: () => void }) => (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
closeMenu?.();
|
||||
}}
|
||||
data-testid={`context-menu-edit-${task.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.editTask')}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (task.isTracking) {
|
||||
onStopTimer?.(task.id);
|
||||
} else {
|
||||
onStartTimer?.(task.id);
|
||||
}
|
||||
closeMenu?.();
|
||||
}}
|
||||
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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskUpdate?.(task.id, { status: 'done' });
|
||||
closeMenu?.();
|
||||
}}
|
||||
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();
|
||||
onTaskUpdate?.(task.id, { status: 'inProgress' });
|
||||
closeMenu?.();
|
||||
}}
|
||||
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();
|
||||
onTaskUpdate?.(task.id, { status: 'todo' });
|
||||
closeMenu?.();
|
||||
}}
|
||||
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();
|
||||
onTaskUpdate?.(task.id, { status: 'inProgress' });
|
||||
closeMenu?.();
|
||||
}}
|
||||
data-testid={`context-menu-reopen-${task.id}`}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.reopenTask')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{onTaskDelete && (
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskDelete(task.id);
|
||||
closeMenu?.();
|
||||
}}
|
||||
className="text-destructive"
|
||||
data-testid={`context-menu-delete-${task.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.deleteTask')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
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">
|
||||
@@ -111,18 +310,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
|
||||
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={() => {
|
||||
@@ -131,15 +328,18 @@ 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>
|
||||
@@ -150,42 +350,88 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(task.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => {
|
||||
// Prevent click when dragging
|
||||
if (draggedTask === task.id) return;
|
||||
e.stopPropagation();
|
||||
onTaskClick?.(task);
|
||||
console.log('Calendar task clicked:', task.title);
|
||||
}}
|
||||
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs p-1 h-auto pointer-events-none"
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate flex-1 text-left">
|
||||
{task.title}
|
||||
<ContextMenu key={task.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(task.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onTouchStart={() => handleTouchStart(task)}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchEnd}
|
||||
onClick={(e) => handleTaskClick(task, e)}
|
||||
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs p-1 h-auto"
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate flex-1 text-left pointer-events-none">
|
||||
{task.title}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<TaskMenuContent task={task} />
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
|
||||
{dayTasks.length > 3 && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-xs">
|
||||
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
|
||||
</Badge>
|
||||
<HoverCard openDelay={0} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="secondary" className="w-full justify-center text-xs cursor-pointer hover:bg-secondary/80">
|
||||
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72 p-0">
|
||||
<div className="p-3 border-b bg-muted/30">
|
||||
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>{t('calendar.moreItems', { count: dayTasks.length - 3 })}</span>
|
||||
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
|
||||
{dayTasks.slice(3).map(task => {
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-2 border rounded hover:bg-accent cursor-pointer flex items-center justify-between group transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-2">
|
||||
<div className="text-xs font-medium truncate">{task.title}</div>
|
||||
{taskLabel && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: taskLabel.color }} />
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{taskLabel.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -208,6 +454,125 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
|
||||
<span>{t('calendar.previousWeek')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile long-press fallback menu */}
|
||||
{longPressTask && (
|
||||
<DropdownMenu
|
||||
open={dropdownOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDropdownOpen(open);
|
||||
if (!open) {
|
||||
setLongPressTask(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger className="hidden" />
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(longPressTask);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
data-testid={`dropdown-menu-edit-${longPressTask.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.editTask')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (longPressTask.isTracking) {
|
||||
onStopTimer?.(longPressTask.id);
|
||||
} else {
|
||||
onStartTimer?.(longPressTask.id);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
data-testid={`dropdown-menu-timer-${longPressTask.id}`}
|
||||
>
|
||||
<Timer className="w-4 h-4 mr-2" />
|
||||
{longPressTask.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{longPressTask.status !== 'done' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskUpdate?.(longPressTask.id, { status: 'done' });
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
data-testid={`dropdown-menu-complete-${longPressTask.id}`}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.markAsDone')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{longPressTask.status === 'todo' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskUpdate?.(longPressTask.id, { status: 'inProgress' });
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
data-testid={`dropdown-menu-start-${longPressTask.id}`}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.startWorking')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{longPressTask.status === 'inProgress' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskUpdate?.(longPressTask.id, { status: 'todo' });
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
data-testid={`dropdown-menu-todo-${longPressTask.id}`}
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.moveToTodo')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{longPressTask.status === 'done' && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskUpdate?.(longPressTask.id, { status: 'inProgress' });
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
data-testid={`dropdown-menu-reopen-${longPressTask.id}`}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.reopenTask')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{onTaskDelete && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskDelete(longPressTask.id);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
className="text-destructive"
|
||||
data-testid={`dropdown-menu-delete-${longPressTask.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.deleteTask')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "auth.currentPasswordRequired"),
|
||||
newPassword: z.string().min(6, "auth.passwordMinLength"),
|
||||
confirmNewPassword: z.string(),
|
||||
}).refine((data) => data.newPassword === data.confirmNewPassword, {
|
||||
message: "validation.passwordMatch",
|
||||
path: ["confirmNewPassword"],
|
||||
});
|
||||
|
||||
type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<ChangePasswordFormValues>({
|
||||
resolver: zodResolver(changePasswordSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmNewPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: ChangePasswordFormValues) => {
|
||||
const res = await apiRequest("PATCH", "/api/user/password", {
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || "Failed to change password");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: t('auth.passwordChangedSuccess') });
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
// If error mentions current password, set field error
|
||||
if (error.message.toLowerCase().includes("current password")) {
|
||||
form.setError("currentPassword", { message: t('auth.incorrectCurrentPassword') });
|
||||
} else {
|
||||
toast({
|
||||
title: t('auth.changePasswordFailed'),
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: ChangePasswordFormValues) => {
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-lg sm:max-w-md p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('auth.changePassword')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('auth.changePasswordDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('auth.currentPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage>
|
||||
{form.formState.errors.currentPassword?.message && t(form.formState.errors.currentPassword.message)}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('auth.newPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage>
|
||||
{form.formState.errors.newPassword?.message && t(form.formState.errors.newPassword.message)}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmNewPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('auth.confirmPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
<FormMessage>
|
||||
{form.formState.errors.confirmNewPassword?.message && t(form.formState.errors.confirmNewPassword.message)}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? t('auth.changing') : t('auth.changePassword')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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,49 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-red-50 text-red-900">
|
||||
<div className="max-w-xl p-8 bg-white rounded-lg shadow-xl border border-red-200">
|
||||
<h1 className="text-2xl font-bold mb-4">Something went wrong</h1>
|
||||
<p className="mb-4">The application crashed. Here is the error:</p>
|
||||
<pre className="bg-red-100 p-4 rounded overflow-auto text-sm font-mono">
|
||||
{this.state.error?.toString()}
|
||||
</pre>
|
||||
<button
|
||||
className="mt-6 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
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, Sun, Moon } 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 tasks for focus list
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
return tasks.filter(t => t.status !== 'done')
|
||||
.sort((a, b) => {
|
||||
// 1. Due Date (Overdue/Today first)
|
||||
if (a.dueDate && b.dueDate) {
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
}
|
||||
// Tasks with due dates come before those without
|
||||
if (a.dueDate && !b.dueDate) return -1;
|
||||
if (!a.dueDate && b.dueDate) return 1;
|
||||
|
||||
// 2. Priority
|
||||
if (a.priority === 'high' && b.priority !== 'high') return -1;
|
||||
if (b.priority === 'high' && a.priority !== 'high') return 1;
|
||||
|
||||
return 0;
|
||||
})
|
||||
// Take top 5 to include more subtasks if relevant
|
||||
.slice(0, 5);
|
||||
}, [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 }}
|
||||
className="grid grid-rows-2 gap-4 h-full"
|
||||
>
|
||||
{/* Routine Actions */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-full flex flex-col items-center justify-center gap-2 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-950/30 border-dashed"
|
||||
onClick={() => window.location.href = '/focus/routine/morning'}
|
||||
>
|
||||
<Sun className="w-5 h-5 text-orange-500" />
|
||||
<span className="text-xs font-semibold">{t('focus.routine.morning', 'Morning Plan')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-full flex flex-col items-center justify-center gap-2 hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-950/30 border-dashed"
|
||||
onClick={() => window.location.href = '/focus/routine/evening'}
|
||||
>
|
||||
<Moon className="w-5 h-5 text-indigo-500" />
|
||||
<span className="text-xs font-semibold">{t('focus.routine.evening', 'Evening Review')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-4 flex flex-col justify-center items-center text-center bg-card hover:shadow-md transition-all cursor-pointer" onClick={onNavigateToTasks}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="w-5 h-5 text-muted-foreground/50" />
|
||||
<h3 className="font-semibold text-sm">{t('focus.viewAll.title')}</h3>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="gap-1 text-xs text-muted-foreground group">
|
||||
{t('focus.viewAll.button')} <ArrowRight className="w-3 h-3 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,81 @@
|
||||
import { Trophy, Flame } from 'lucide-react';
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-orange-500/10 rounded-md border border-orange-500/20 cursor-help">
|
||||
<Flame className="w-4 h-4 text-orange-500 fill-orange-500" />
|
||||
<span className="text-sm font-bold text-orange-600 dark:text-orange-400">{streak}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('gamification.streakTooltip', 'Log in daily to increase your streak!')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('gamification.streakBonus', 'Weekly & Monthly bonuses available.')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface KanbanBoardProps {
|
||||
onTaskStatusChange?: (taskId: string, newStatus: Task['status']) => void;
|
||||
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
onTaskEdit?: (task: Task) => void;
|
||||
onTaskDelete?: (taskId: string) => void;
|
||||
onStartTimer?: (taskId: string) => void;
|
||||
onStopTimer?: (taskId: string) => void;
|
||||
@@ -175,7 +176,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" />
|
||||
@@ -215,192 +216,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) => (
|
||||
<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={label.id}
|
||||
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>
|
||||
{/* Clear Filters */}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
data-testid="button-filter-priorities"
|
||||
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 ? (
|
||||
{/* 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={labelId}
|
||||
key={priority}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
data-testid={`active-filter-label-${labelId}`}
|
||||
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}
|
||||
{t(`priority.${priority}`)}
|
||||
<X
|
||||
className="w-3 h-3 ml-1 cursor-pointer"
|
||||
onClick={() => toggleLabel(labelId)}
|
||||
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 */}
|
||||
@@ -429,7 +430,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
|
||||
return (
|
||||
<Card
|
||||
key={column.id}
|
||||
className="p-3 sm:p-4"
|
||||
className="p-3 sm:p-4 min-w-0 overflow-hidden"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(column.status)}
|
||||
data-testid={`column-${column.id}`}
|
||||
@@ -493,7 +494,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
|
||||
return (
|
||||
<Card
|
||||
key={column.id}
|
||||
className="p-3 sm:p-4"
|
||||
className="p-3 sm:p-4 min-w-0 overflow-hidden"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(column.status)}
|
||||
data-testid={`column-${column.id}`}
|
||||
@@ -557,7 +558,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
|
||||
return (
|
||||
<Card
|
||||
key={column.id}
|
||||
className="p-3 sm:p-4"
|
||||
className="p-3 sm:p-4 min-w-0 overflow-hidden"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(column.status)}
|
||||
data-testid={`column-${column.id}`}
|
||||
|
||||
@@ -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), { type: 'module' });
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -8,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Plus, Calendar as CalendarIcon, Clock, Copy, Play, Tag, Edit, Trash2, Palette, FileText } from 'lucide-react';
|
||||
import { Plus, Calendar as CalendarIcon, Clock, Copy, Play, Tag, Edit, Trash2, Palette, FileText, ArrowLeft } from 'lucide-react';
|
||||
import { Label, Task } from '@shared/schema';
|
||||
import TaskDetailsModal from '@/components/TaskDetailsModal';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -33,7 +34,18 @@ const convertProjectTaskToTask = (projectTask: ProjectTask, projectId: string):
|
||||
isTracking: false,
|
||||
projectId: projectId,
|
||||
notes: projectTask.notes || null,
|
||||
labelId: projectTask.labelId || null
|
||||
labelId: projectTask.labelId || null,
|
||||
energyLevel: 'medium',
|
||||
estimatedDuration: (projectTask.estimatedHours || 0) * 60,
|
||||
dependencies: [],
|
||||
userId: null,
|
||||
parentTaskId: null,
|
||||
startDate: null,
|
||||
isRecurring: false,
|
||||
recurrenceInterval: null,
|
||||
recurrenceIntervalValue: null,
|
||||
recurrenceDays: null,
|
||||
recurrenceEnd: null
|
||||
};
|
||||
};
|
||||
|
||||
@@ -73,6 +85,7 @@ interface ProjectTemplateProps {
|
||||
onCreateFromTemplate?: (templateId: string, startDate: Date, projectName: string) => void;
|
||||
onCreateTemplate?: (template: any) => void;
|
||||
onCreateTasks?: (tasks: Task[]) => void;
|
||||
onNavigateToSettings?: () => void;
|
||||
}
|
||||
|
||||
const defaultProjects: Project[] = [
|
||||
@@ -122,8 +135,10 @@ const defaultProjects: Project[] = [
|
||||
export default function ProjectTemplate({
|
||||
onCreateFromTemplate,
|
||||
onCreateTemplate,
|
||||
onCreateTasks
|
||||
onCreateTasks,
|
||||
onNavigateToSettings
|
||||
}: ProjectTemplateProps) {
|
||||
const { t } = useTranslation();
|
||||
const [projects, setProjects] = useState<Project[]>(defaultProjects);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [draggedProject, setDraggedProject] = useState<string | null>(null);
|
||||
@@ -282,10 +297,10 @@ export default function ProjectTemplate({
|
||||
const duplicatedTasks = clearHistory
|
||||
? []
|
||||
: restartingProject.tasks.map(task => ({
|
||||
...task,
|
||||
id: `${newProjectId}-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Generate new task ID
|
||||
status: 'todo' as const // Reset all tasks to todo status
|
||||
}));
|
||||
...task,
|
||||
id: `${newProjectId}-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Generate new task ID
|
||||
status: 'todo' as const // Reset all tasks to todo status
|
||||
}));
|
||||
|
||||
const duplicatedProject: Project = {
|
||||
...restartingProject,
|
||||
@@ -494,7 +509,7 @@ export default function ProjectTemplate({
|
||||
};
|
||||
|
||||
const handleDeleteLabel = (id: string) => {
|
||||
if (confirm('Are you sure you want to delete this label?')) {
|
||||
if (confirm(t('projectTemplate.deleteConfirm'))) {
|
||||
deleteLabelMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
@@ -514,18 +529,30 @@ export default function ProjectTemplate({
|
||||
};
|
||||
|
||||
const projectColumns = [
|
||||
{ id: 'planning', title: 'Planning', status: 'planning' as const, color: 'bg-slate-100' },
|
||||
{ id: 'active', title: 'Active', status: 'active' as const, color: 'bg-blue-100' },
|
||||
{ id: 'finished', title: 'Finished', status: 'finished' as const, color: 'bg-green-100' }
|
||||
{ id: 'planning', title: t('projectTemplate.planning'), status: 'planning' as const, color: 'bg-slate-100' },
|
||||
{ id: 'active', title: t('projectTemplate.active'), status: 'active' as const, color: 'bg-blue-100' },
|
||||
{ id: 'finished', title: t('projectTemplate.finished'), status: 'finished' as const, color: 'bg-green-100' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-base sm:text-lg font-semibold" data-testid="text-settings-title">
|
||||
Settings
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{onNavigateToSettings && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onNavigateToSettings}
|
||||
data-testid="button-back-to-settings"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="text-base sm:text-lg font-semibold" data-testid="text-settings-title">
|
||||
{t('projectTemplate.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsCreateProjectOpen(true)}
|
||||
data-testid="button-create-project"
|
||||
@@ -533,14 +560,14 @@ export default function ProjectTemplate({
|
||||
className="h-11"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
{t('projectTemplate.newProject')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="projects" className="space-y-3 sm:space-y-4">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="projects" data-testid="tab-projects" className="flex-1">Projects</TabsTrigger>
|
||||
<TabsTrigger value="labels" data-testid="tab-labels" className="flex-1">Labels</TabsTrigger>
|
||||
<TabsTrigger value="projects" data-testid="tab-projects" className="flex-1">{t('projectTemplate.projects')}</TabsTrigger>
|
||||
<TabsTrigger value="labels" data-testid="tab-labels" className="flex-1">{t('projectTemplate.labels')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="projects" className="space-y-4 sm:space-y-6">
|
||||
@@ -573,9 +600,8 @@ export default function ProjectTemplate({
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(project.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`p-3 sm:p-4 cursor-move hover-elevate transition-all ${
|
||||
draggedProject === project.id ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
className={`p-3 sm:p-4 cursor-move hover-elevate transition-all ${draggedProject === project.id ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
data-testid={`project-card-${project.id}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -661,7 +687,7 @@ export default function ProjectTemplate({
|
||||
{/* Closed Projects Section */}
|
||||
<div className="space-y-4 mt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Closed Projects</h3>
|
||||
<h3 className="text-lg font-semibold">{t('projectTemplate.closedProjects')}</h3>
|
||||
<Input
|
||||
placeholder="Filter closed projects..."
|
||||
value={closedProjectsFilter}
|
||||
@@ -828,29 +854,29 @@ export default function ProjectTemplate({
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
<DialogTitle>{t('projectTemplate.createProject')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new project with tasks, timeline, and organize your work efficiently.
|
||||
{t('projectTemplate.pageDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Project Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Project Name</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.projectName')}</label>
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Enter project name"
|
||||
placeholder={t('projectTemplate.projectNamePlaceholder')}
|
||||
data-testid="input-project-name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description (Optional)</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.projectDescription')}</label>
|
||||
<Input
|
||||
value={newProjectDescription}
|
||||
onChange={(e) => setNewProjectDescription(e.target.value)}
|
||||
placeholder="Enter project description"
|
||||
placeholder={t('projectTemplate.projectDescriptionPlaceholder')}
|
||||
data-testid="input-project-description"
|
||||
/>
|
||||
</div>
|
||||
@@ -858,7 +884,7 @@ export default function ProjectTemplate({
|
||||
|
||||
{/* Planned End Date */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Planned End Date (Optional)</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.plannedEndDate')}</label>
|
||||
<Popover open={isPlannedEndDateCalendarOpen} onOpenChange={setIsPlannedEndDateCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -867,7 +893,7 @@ export default function ProjectTemplate({
|
||||
data-testid="button-select-planned-end-date"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{newPlannedEndDate ? newPlannedEndDate.toLocaleDateString() : "Select planned end date"}
|
||||
{newPlannedEndDate ? newPlannedEndDate.toLocaleDateString() : t('projectTemplate.selectPlannedEndDate')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
@@ -891,7 +917,7 @@ export default function ProjectTemplate({
|
||||
className="text-xs text-muted-foreground hover:text-destructive"
|
||||
data-testid="button-clear-create-planned-end-date"
|
||||
>
|
||||
Clear date
|
||||
{t('projectTemplate.clearDate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -899,7 +925,7 @@ export default function ProjectTemplate({
|
||||
{/* Tasks Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Project Tasks</h3>
|
||||
<h3 className="text-lg font-semibold">{t('projectTemplate.projectTasks')}</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -907,7 +933,7 @@ export default function ProjectTemplate({
|
||||
data-testid="button-add-task"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Task
|
||||
{t('projectTemplate.addTask')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -959,7 +985,7 @@ export default function ProjectTemplate({
|
||||
|
||||
{projectTasks.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
|
||||
No tasks added yet. Click "Add Task" to get started.
|
||||
{t('projectTemplate.noLabelsDescription')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -969,74 +995,74 @@ export default function ProjectTemplate({
|
||||
<Card className="p-4 border-primary">
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-sm">
|
||||
{editingTaskIndex !== null ? 'Edit Task' : 'New Task'}
|
||||
{editingTaskIndex !== null ? t('projectTemplate.edit') : t('projectTemplate.addTask')}
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Task Title</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.taskTitle')}</label>
|
||||
<Input
|
||||
value={taskForm.title || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
|
||||
placeholder="Enter task title"
|
||||
onChange={(e) => setTaskForm({ ...taskForm, title: e.target.value })}
|
||||
placeholder={t('projectTemplate.enterTaskTitle')}
|
||||
data-testid="input-task-title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.description')}</label>
|
||||
<Input
|
||||
value={taskForm.description || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
|
||||
placeholder="Enter task description"
|
||||
onChange={(e) => setTaskForm({ ...taskForm, description: e.target.value })}
|
||||
placeholder={t('projectTemplate.enterTaskDescription')}
|
||||
data-testid="input-task-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Priority</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.priority')}</label>
|
||||
<Select
|
||||
value={taskForm.priority || 'medium'}
|
||||
onValueChange={(value: ProjectTask['priority']) =>
|
||||
setTaskForm({...taskForm, priority: value})
|
||||
setTaskForm({ ...taskForm, priority: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="select-task-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="low">{t('priority.low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('priority.high')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.status')}</label>
|
||||
<Select
|
||||
value={taskForm.status || 'todo'}
|
||||
onValueChange={(value: ProjectTask['status']) =>
|
||||
setTaskForm({...taskForm, status: value})
|
||||
setTaskForm({ ...taskForm, status: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="select-task-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inProgress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="todo">{t('status.todo')}</SelectItem>
|
||||
<SelectItem value="inProgress">{t('status.inProgress')}</SelectItem>
|
||||
<SelectItem value="done">{t('status.done')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Estimated Hours</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.estimatedHours')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={taskForm.estimatedHours || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, estimatedHours: Number(e.target.value) })}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
data-testid="input-task-hours"
|
||||
@@ -1050,7 +1076,7 @@ export default function ProjectTemplate({
|
||||
onClick={handleCancelTaskEdit}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t('projectTemplate.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={editingTaskIndex !== null ? handleSaveTask : handleAddTask}
|
||||
@@ -1058,7 +1084,7 @@ export default function ProjectTemplate({
|
||||
className="flex-1"
|
||||
data-testid="button-save-task"
|
||||
>
|
||||
{editingTaskIndex !== null ? 'Save Changes' : 'Add Task'}
|
||||
{editingTaskIndex !== null ? t('projectTemplate.save') : t('projectTemplate.addTask')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1077,7 +1103,7 @@ export default function ProjectTemplate({
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t('projectTemplate.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateProject}
|
||||
@@ -1085,7 +1111,7 @@ export default function ProjectTemplate({
|
||||
className="flex-1"
|
||||
data-testid="button-save-project"
|
||||
>
|
||||
Create Project ({projectTasks.length} tasks)
|
||||
{t('projectTemplate.createProject')} ({projectTasks.length} {t('projectTemplate.tasks', { count: projectTasks.length })})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -1101,32 +1127,32 @@ export default function ProjectTemplate({
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Project</DialogTitle>
|
||||
<DialogTitle>{t('projectTemplate.edit')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedProject && (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Project Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Project Name</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.projectName')}</label>
|
||||
<Input
|
||||
value={selectedProject.name}
|
||||
onChange={(e) => setSelectedProject({ ...selectedProject, name: e.target.value })}
|
||||
placeholder="Enter project name"
|
||||
placeholder={t('projectTemplate.projectNamePlaceholder')}
|
||||
data-testid="input-edit-project-name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description (Optional)</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.projectDescription')}</label>
|
||||
<Input
|
||||
value={selectedProject.description || ''}
|
||||
onChange={(e) => setSelectedProject({ ...selectedProject, description: e.target.value })}
|
||||
placeholder="Enter project description"
|
||||
placeholder={t('projectTemplate.projectDescriptionPlaceholder')}
|
||||
data-testid="input-edit-project-description"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.status')}</label>
|
||||
<Select
|
||||
value={selectedProject.status}
|
||||
onValueChange={(value: Project['status']) =>
|
||||
@@ -1137,9 +1163,9 @@ export default function ProjectTemplate({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="planning">Planning</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="finished">Finished</SelectItem>
|
||||
<SelectItem value="planning">{t('projectTemplate.planning')}</SelectItem>
|
||||
<SelectItem value="active">{t('projectTemplate.active')}</SelectItem>
|
||||
<SelectItem value="finished">{t('projectTemplate.finished')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1147,7 +1173,7 @@ export default function ProjectTemplate({
|
||||
|
||||
{/* Planned End Date */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Planned End Date (Optional)</label>
|
||||
<label className="text-sm font-medium">{t('projectTemplate.plannedEndDate')}</label>
|
||||
<Popover open={isPlannedEndDateCalendarOpen} onOpenChange={setIsPlannedEndDateCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -1158,7 +1184,7 @@ export default function ProjectTemplate({
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{selectedProject.plannedEndDate
|
||||
? selectedProject.plannedEndDate.toLocaleDateString()
|
||||
: "Select planned end date"}
|
||||
: t('projectTemplate.selectPlannedEndDate')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
@@ -1182,7 +1208,7 @@ export default function ProjectTemplate({
|
||||
className="text-xs text-muted-foreground hover:text-destructive"
|
||||
data-testid="button-clear-planned-end-date"
|
||||
>
|
||||
Clear date
|
||||
{t('projectTemplate.clearDate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1190,7 +1216,7 @@ export default function ProjectTemplate({
|
||||
{/* Tasks Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Project Tasks</h3>
|
||||
<h3 className="text-lg font-semibold">{t('projectTemplate.projectTasks')}</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1198,7 +1224,7 @@ export default function ProjectTemplate({
|
||||
data-testid="button-add-task-edit"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Task
|
||||
{t('projectTemplate.addTask')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1211,71 +1237,71 @@ export default function ProjectTemplate({
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Task Title</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.taskTitle')}</label>
|
||||
<Input
|
||||
value={taskForm.title || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
|
||||
placeholder="Enter task title"
|
||||
onChange={(e) => setTaskForm({ ...taskForm, title: e.target.value })}
|
||||
placeholder={t('projectTemplate.enterTaskTitle')}
|
||||
className="text-sm"
|
||||
data-testid={`input-inline-task-title-${index}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Description</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.description')}</label>
|
||||
<Input
|
||||
value={taskForm.description || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
|
||||
placeholder="Enter task description"
|
||||
onChange={(e) => setTaskForm({ ...taskForm, description: e.target.value })}
|
||||
placeholder={t('projectTemplate.enterTaskDescription')}
|
||||
className="text-sm"
|
||||
data-testid={`input-inline-task-description-${index}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Priority</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.priority')}</label>
|
||||
<Select
|
||||
value={taskForm.priority || 'medium'}
|
||||
onValueChange={(value: ProjectTask['priority']) =>
|
||||
setTaskForm({...taskForm, priority: value})
|
||||
setTaskForm({ ...taskForm, priority: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm" data-testid={`select-inline-task-priority-${index}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="low">{t('priority.low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('priority.high')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Status</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.status')}</label>
|
||||
<Select
|
||||
value={taskForm.status || 'todo'}
|
||||
onValueChange={(value: ProjectTask['status']) =>
|
||||
setTaskForm({...taskForm, status: value})
|
||||
setTaskForm({ ...taskForm, status: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm" data-testid={`select-inline-task-status-${index}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inProgress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="todo">{t('status.todo')}</SelectItem>
|
||||
<SelectItem value="inProgress">{t('status.inProgress')}</SelectItem>
|
||||
<SelectItem value="done">{t('status.done')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Estimated Hours</label>
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.estimatedHours')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={taskForm.estimatedHours || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, estimatedHours: Number(e.target.value) })}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="text-sm max-w-32"
|
||||
@@ -1291,7 +1317,7 @@ export default function ProjectTemplate({
|
||||
onClick={handleCancelTaskEdit}
|
||||
data-testid={`button-cancel-inline-edit-${index}`}
|
||||
>
|
||||
Cancel
|
||||
{t('projectTemplate.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1299,7 +1325,7 @@ export default function ProjectTemplate({
|
||||
disabled={!taskForm.title?.trim()}
|
||||
data-testid={`button-save-inline-edit-${index}`}
|
||||
>
|
||||
Save Changes
|
||||
{t('projectTemplate.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1329,13 +1355,13 @@ export default function ProjectTemplate({
|
||||
) : null}
|
||||
{task.timeTracked && task.timeTracked > 0 && (
|
||||
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
||||
{Math.floor(task.timeTracked / 60)}h {task.timeTracked % 60}m tracked
|
||||
{Math.floor(task.timeTracked / 60)}h {task.timeTracked % 60}m {t('projectTemplate.tracked')}
|
||||
</Badge>
|
||||
)}
|
||||
{task.notes && task.notes.trim() && (
|
||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Notes
|
||||
{t('projectTemplate.notesLabel')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -1349,7 +1375,7 @@ export default function ProjectTemplate({
|
||||
handleOpenTaskDetails(task);
|
||||
}}
|
||||
data-testid={`button-details-task-${index}`}
|
||||
title="View task details"
|
||||
title={t('projectTemplate.viewTaskDetails')}
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -1361,7 +1387,7 @@ export default function ProjectTemplate({
|
||||
handleEditTask(index);
|
||||
}}
|
||||
data-testid={`button-edit-task-edit-${index}`}
|
||||
title="Edit task"
|
||||
title={t('projectTemplate.editTaskTooltip')}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -1373,7 +1399,7 @@ export default function ProjectTemplate({
|
||||
handleRemoveTask(index);
|
||||
}}
|
||||
data-testid={`button-remove-task-edit-${index}`}
|
||||
title="Remove task"
|
||||
title={t('projectTemplate.removeTask')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -1385,7 +1411,7 @@ export default function ProjectTemplate({
|
||||
|
||||
{projectTasks.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
|
||||
No tasks in this project yet. Click "Add Task" to get started.
|
||||
{t('projectTemplate.noTasksInProject')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1405,7 +1431,7 @@ export default function ProjectTemplate({
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t('projectTemplate.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveEditedProject}
|
||||
@@ -1413,157 +1439,172 @@ export default function ProjectTemplate({
|
||||
className="flex-1"
|
||||
data-testid="button-update-project"
|
||||
>
|
||||
Update Project ({projectTasks.length} tasks)
|
||||
{t('projectTemplate.update')} ({projectTasks.length} {t('projectTemplate.tasks', { count: projectTasks.length })})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TabsContent value="labels" className="space-y-6">
|
||||
{/* Labels Section Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-md font-medium">Task Labels</h3>
|
||||
<Dialog open={isLabelDialogOpen} onOpenChange={setIsLabelDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" data-testid="button-create-label">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Label
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingLabel ? 'Edit Label' : 'Create New Label'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Label name"
|
||||
value={labelName}
|
||||
onChange={(e) => setLabelName(e.target.value)}
|
||||
data-testid="input-label-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={labelColor}
|
||||
onChange={(e) => setLabelColor(e.target.value)}
|
||||
className="w-12 h-8 rounded border cursor-pointer"
|
||||
data-testid="input-label-color"
|
||||
/>
|
||||
<Input
|
||||
value={labelColor}
|
||||
onChange={(e) => setLabelColor(e.target.value)}
|
||||
placeholder="#3B82F6"
|
||||
className="flex-1"
|
||||
data-testid="input-label-color-text"
|
||||
/>
|
||||
{/* Labels Section Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-md font-medium">{t('settings.labels.title')}</h3>
|
||||
<Dialog open={isLabelDialogOpen} onOpenChange={setIsLabelDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" data-testid="button-create-label">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('projectTemplate.createLabel')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingLabel ? t('projectTemplate.editLabel') : t('projectTemplate.createNewLabel')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
placeholder={t('projectTemplate.labelNamePlaceholder')}
|
||||
value={labelName}
|
||||
onChange={(e) => setLabelName(e.target.value)}
|
||||
data-testid="input-label-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={labelColor}
|
||||
onChange={(e) => setLabelColor(e.target.value)}
|
||||
className="w-12 h-8 rounded border cursor-pointer"
|
||||
data-testid="input-label-color"
|
||||
/>
|
||||
<Input
|
||||
value={labelColor}
|
||||
onChange={(e) => setLabelColor(e.target.value)}
|
||||
placeholder="#3B82F6"
|
||||
className="flex-1"
|
||||
data-testid="input-label-color-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsLabelDialogOpen(false);
|
||||
setEditingLabel(null);
|
||||
setLabelName('');
|
||||
setLabelColor('#3B82F6');
|
||||
}}
|
||||
className="flex-1"
|
||||
data-testid="button-cancel-label"
|
||||
>
|
||||
{t('projectTemplate.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveLabel}
|
||||
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
|
||||
className="flex-1"
|
||||
data-testid="button-save-label"
|
||||
>
|
||||
{editingLabel ? t('projectTemplate.update') : t('projectTemplate.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsLabelDialogOpen(false);
|
||||
setEditingLabel(null);
|
||||
setLabelName('');
|
||||
setLabelColor('#3B82F6');
|
||||
}}
|
||||
className="flex-1"
|
||||
data-testid="button-cancel-label"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveLabel}
|
||||
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
|
||||
className="flex-1"
|
||||
data-testid="button-save-label"
|
||||
>
|
||||
{editingLabel ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Labels List */}
|
||||
{labelsLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-muted-foreground">Loading labels...</div>
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
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
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
|
||||
{label.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Labels List */}
|
||||
{labelsLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-muted-foreground">{t('projectTemplate.loadingLabels')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<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"
|
||||
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
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
|
||||
{label.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditLabel(label)}
|
||||
className="w-6 h-6"
|
||||
data-testid={`button-edit-${label.id}`}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteLabel(label.id)}
|
||||
className="w-6 h-6 text-destructive hover:text-destructive"
|
||||
data-testid={`button-delete-${label.id}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{labels.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<Tag className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
<h3 className="font-medium text-muted-foreground mb-2">{t('projectTemplate.noLabelsYet')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('projectTemplate.noLabelsDescription')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditLabel(label)}
|
||||
className="w-6 h-6"
|
||||
data-testid={`button-edit-${label.id}`}
|
||||
variant="outline"
|
||||
onClick={() => setIsLabelDialogOpen(true)}
|
||||
data-testid="button-create-first-label"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteLabel(label.id)}
|
||||
className="w-6 h-6 text-destructive hover:text-destructive"
|
||||
data-testid={`button-delete-${label.id}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('projectTemplate.createLabel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{labels.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center p-8 text-center">
|
||||
<Tag className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
<h3 className="font-medium text-muted-foreground mb-2">No labels yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create your first label to organize your tasks by color and category.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsLabelDialogOpen(true)}
|
||||
data-testid="button-create-first-label"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Label
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Task Details Modal */}
|
||||
<TaskDetailsModal
|
||||
isOpen={isTaskDetailsOpen}
|
||||
onClose={handleCloseTaskDetails}
|
||||
task={selectedTaskForDetails}
|
||||
onSave={handleSaveTaskDetails}
|
||||
task={selectedTaskForDetails ? convertProjectTaskToTask(selectedTaskForDetails, selectedProject?.id || 'temp') : null}
|
||||
onSave={(updatedTask) => {
|
||||
// Convert back to ProjectTask
|
||||
const projectTask: ProjectTask = {
|
||||
id: selectedTaskForDetails?.id || updatedTask.id, // Keep original ID if possible
|
||||
title: updatedTask.title,
|
||||
description: updatedTask.description || undefined,
|
||||
status: updatedTask.status as any,
|
||||
priority: updatedTask.priority as any,
|
||||
estimatedHours: updatedTask.estimatedDuration ? Math.round(updatedTask.estimatedDuration / 60) : 0,
|
||||
labelId: updatedTask.labelId || undefined,
|
||||
notes: updatedTask.notes || undefined,
|
||||
timeTracked: updatedTask.timeTracked,
|
||||
timeEntries: [] // Simplify for now
|
||||
};
|
||||
handleSaveTaskDetails(projectTask);
|
||||
}}
|
||||
labels={labels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { User } from "@shared/schema";
|
||||
|
||||
export function useRoutineBlocker() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"] });
|
||||
const { data: settings } = useQuery<Record<string, string>>({ queryKey: ["/api/admin/settings"] });
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !settings) return;
|
||||
|
||||
const checkRoutine = () => {
|
||||
const now = new Date();
|
||||
const currentHours = now.getHours();
|
||||
const currentMinutes = now.getMinutes();
|
||||
const currentTimeVal = currentHours * 60 + currentMinutes;
|
||||
|
||||
// Helper to parse "HH:MM" to minutes
|
||||
const parseTime = (t: string) => {
|
||||
const [h, m] = t.split(':').map(Number);
|
||||
return h * 60 + (m || 0);
|
||||
};
|
||||
|
||||
// Helper to check if date is today
|
||||
const isToday = (dateStr?: Date | string | null) => {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
return d.getDate() === now.getDate() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getFullYear() === now.getFullYear();
|
||||
};
|
||||
|
||||
// Evening Routine Check
|
||||
const eveningEnabled = settings.evening_routine_enabled !== "false";
|
||||
const eveningStartTime = parseTime(settings.evening_routine_time || "22:00"); // 22:00 default
|
||||
|
||||
// If it is Evening time (>= start time), we primarily check Evening Routine.
|
||||
if (eveningEnabled) {
|
||||
if (currentTimeVal >= eveningStartTime) {
|
||||
// It is evening. Block if evening not done.
|
||||
// We do NOT block for Morning routine anymore if it's evening time (user missed it).
|
||||
if (!isToday(user.lastEveningRoutine)) {
|
||||
return '/focus/routine/evening';
|
||||
}
|
||||
// If evening done, we don't block for morning either.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Morning Routine Check (Only if < evening start time or evening disabled)
|
||||
const morningEnabled = settings.morning_routine_enabled !== "false";
|
||||
if (morningEnabled) {
|
||||
const morningStartTime = parseTime(settings.morning_routine_time || "09:00");
|
||||
|
||||
// Block if it's morning time (>= start) AND < evening start (if evening enabled).
|
||||
// FIX: Add a hard cutoff (e.g. 13:00 / 1 PM) so morning routine doesn't haunt users all day.
|
||||
const HARD_MORNING_CUTOFF = 13 * 60;
|
||||
|
||||
const cutoffTime = eveningEnabled
|
||||
? Math.min(eveningStartTime, HARD_MORNING_CUTOFF)
|
||||
: HARD_MORNING_CUTOFF;
|
||||
|
||||
if (currentTimeVal >= morningStartTime && currentTimeVal < cutoffTime && !isToday(user.lastMorningRoutine)) {
|
||||
return '/focus/routine/morning';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const target = checkRoutine();
|
||||
if (target) {
|
||||
// Only redirect if not already there
|
||||
if (!window.location.pathname.includes(target)) {
|
||||
setLocation(target);
|
||||
}
|
||||
}
|
||||
|
||||
}, [user, settings, setLocation]);
|
||||
|
||||
// Return a boolean telling if blocking is active, so App can hide Sidebar
|
||||
const isMorningBlocked = () => {
|
||||
if (!user || !settings) return false;
|
||||
|
||||
const now = new Date();
|
||||
const currentTimeVal = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const eveningEnabled = settings.evening_routine_enabled !== "false";
|
||||
// Safe parse
|
||||
const [eh, em] = (settings.evening_routine_time || "22:00").split(':').map(Number);
|
||||
const eveningStartVal = (eh || 0) * 60 + (em || 0);
|
||||
|
||||
const morningEnabled = settings.morning_routine_enabled !== "false";
|
||||
if (!morningEnabled) return false;
|
||||
|
||||
const [mh, mm] = (settings.morning_routine_time || "09:00").split(':').map(Number);
|
||||
const startVal = (mh || 0) * 60 + (mm || 0);
|
||||
|
||||
const HARD_MORNING_CUTOFF = 13 * 60; // 13:00
|
||||
|
||||
// Cutoff: Min of (End of day OR Evening Start) AND Hard Cutoff
|
||||
const baseCutoff = eveningEnabled ? eveningStartVal : 24 * 60;
|
||||
const cutoffVal = Math.min(baseCutoff, HARD_MORNING_CUTOFF);
|
||||
|
||||
if (currentTimeVal >= startVal && currentTimeVal < cutoffVal && !isSameDay(user.lastMorningRoutine, now)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isEveningBlocked = () => {
|
||||
if (!user || !settings) return false;
|
||||
|
||||
const now = new Date();
|
||||
const currentTimeVal = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const eveningEnabled = settings.evening_routine_enabled !== "false";
|
||||
if (!eveningEnabled) return false;
|
||||
|
||||
const rawTime = settings.evening_routine_time || "22:00";
|
||||
const [eh, em] = rawTime.split(':').map(Number);
|
||||
const startVal = (eh || 0) * 60 + (em || 0);
|
||||
|
||||
if (currentTimeVal >= startVal && !isSameDay(user.lastEveningRoutine, now)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return isMorningBlocked() || isEveningBlocked();
|
||||
}
|
||||
|
||||
function isSameDay(d1: any, d2: Date) {
|
||||
if (!d1) return false;
|
||||
const d = new Date(d1);
|
||||
return d.getDate() === d2.getDate() && d.getMonth() === d2.getMonth() && d.getFullYear() === d2.getFullYear();
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { User } from "@shared/schema";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Loader2, Share2, Search, Users } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ShareAccessModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ShareAccessModal({ open, onOpenChange }: ShareAccessModalProps) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
|
||||
const { data: users, isLoading } = useQuery<Pick<User, "id" | "username">[]>({
|
||||
queryKey: ["/api/users/search", debouncedQuery],
|
||||
queryFn: async () => {
|
||||
if (debouncedQuery.length < 2) return [];
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
|
||||
if (!res.ok) throw new Error("Failed to search users");
|
||||
return res.json();
|
||||
},
|
||||
enabled: debouncedQuery.length >= 2,
|
||||
});
|
||||
|
||||
const shareMutation = useMutation({
|
||||
mutationFn: async (targetUserId: string) => {
|
||||
const res = await apiRequest("POST", `/api/users/share-all`, { targetUserId });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: t('share.successAccess'), description: t('share.successAccessDesc') });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t('share.errorAccess'), variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-lg sm:max-w-md p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
{t('share.allTasksTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('share.allTasksDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('share.searchPlaceholder')}
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
// Simple manual debounce simulation for UI response, real query uses effect or direct set
|
||||
// For consistency with ShareTaskModal, we'll just set it
|
||||
}}
|
||||
onKeyUp={() => setDebouncedQuery(searchQuery)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[200px] rounded-md border p-2">
|
||||
{isLoading && debouncedQuery.length >= 2 ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : users && users.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between p-2 hover:bg-muted/50 rounded-lg transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{user.username}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => shareMutation.mutate(user.id)}
|
||||
disabled={shareMutation.isPending}
|
||||
>
|
||||
{shareMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{t('share.grantAccess')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : debouncedQuery.length >= 2 ? (
|
||||
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||
{t('share.noUsers')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||
{t('share.typeToSearch')}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@shared/schema';
|
||||
import { queryClient, apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { X, UserPlus, Users } from 'lucide-react';
|
||||
|
||||
interface ShareLabelModalProps {
|
||||
label: Label | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentUser: any;
|
||||
}
|
||||
|
||||
interface LabelShare {
|
||||
userId: string;
|
||||
username: string;
|
||||
permission: 'read' | 'write' | 'admin';
|
||||
}
|
||||
|
||||
export function ShareLabelModal({ label, open, onOpenChange, currentUser }: ShareLabelModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newPermission, setNewPermission] = useState<'read' | 'write'>('read');
|
||||
|
||||
const { data: shares = [], isLoading } = useQuery<LabelShare[]>({
|
||||
queryKey: [`/api/labels/${label?.id}/share`],
|
||||
enabled: !!label && open,
|
||||
});
|
||||
|
||||
const shareMutation = useMutation({
|
||||
mutationFn: (data: { username: string; permission: string }) =>
|
||||
apiRequest('POST', `/api/labels/${label?.id}/share`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/labels/${label?.id}/share`] });
|
||||
setNewUsername('');
|
||||
toast({ title: t('settings.labels.shareSuccess') });
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast({
|
||||
title: t('settings.labels.shareFailed'),
|
||||
description: e.message,
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const unshareMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
apiRequest('DELETE', `/api/labels/${label?.id}/share/${userId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/labels/${label?.id}/share`] });
|
||||
toast({ title: t('settings.labels.unshareSuccess') });
|
||||
},
|
||||
});
|
||||
|
||||
const handleShare = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newUsername.trim()) return;
|
||||
shareMutation.mutate({ username: newUsername, permission: newPermission });
|
||||
};
|
||||
|
||||
if (!label) return null;
|
||||
|
||||
// Only creator can share (enforced by backend, but UI check is good too)
|
||||
const isCreator = label.creatorId === currentUser?.id;
|
||||
const isSystem = label.creatorId === null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
{t('settings.labels.shareLabel')}: {label.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.labels.shareDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isSystem ? (
|
||||
<div className="p-4 bg-muted rounded-md text-center text-sm text-muted-foreground">
|
||||
{t('settings.labels.systemLabelNoShare')}
|
||||
</div>
|
||||
) : !isCreator ? (
|
||||
<div className="p-4 bg-yellow-500/10 text-yellow-500 border border-yellow-500/20 rounded-md text-center text-sm">
|
||||
{t('settings.labels.onlyOwnerCanShare')}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('settings.labels.owner')}: {t('common.unknown')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Add Collaborator Form */}
|
||||
<form onSubmit={handleShare} className="flex gap-2 items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">{t('settings.labels.addCollaborator')}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('settings.labels.usernamePlaceholder')}
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={newPermission}
|
||||
onValueChange={(v: 'read' | 'write') => setNewPermission(v)}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read">{t('settings.labels.permRead')}</SelectItem>
|
||||
<SelectItem value="write">{t('settings.labels.permWrite')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={!newUsername || shareMutation.isPending}>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{t('settings.labels.accessList')}</h4>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-center py-4">{t('common.loading')}</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="text-sm text-center py-4 text-muted-foreground bg-muted/30 rounded-md border border-dashed">
|
||||
{t('settings.labels.noCollaborators')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{shares.map((share) => (
|
||||
<div key={share.userId} className="flex items-center justify-between p-2 rounded-md bg-muted/50 border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
|
||||
{share.username.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{share.username}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{t(`settings.labels.perm${share.permission.charAt(0).toUpperCase() + share.permission.slice(1)}`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => unshareMutation.mutate(share.userId)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { User } from "@shared/schema";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Loader2, Share2, Check, Search } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ShareTaskModalProps {
|
||||
taskId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ShareTaskModal({ taskId, open, onOpenChange }: ShareTaskModalProps) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [userToConfirm, setUserToConfirm] = useState<{ id: string; username: string } | null>(null);
|
||||
|
||||
const { data: users, isLoading: searchLoading } = useQuery<Pick<User, "id" | "username">[]>({
|
||||
queryKey: ["/api/users/search", debouncedQuery],
|
||||
queryFn: async () => {
|
||||
if (debouncedQuery.length < 2) return [];
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
|
||||
if (!res.ok) throw new Error("Failed to search users");
|
||||
return res.json();
|
||||
},
|
||||
enabled: debouncedQuery.length >= 2,
|
||||
});
|
||||
|
||||
const { data: sharedUsers, refetch: refetchShared, isLoading: sharedLoading } = useQuery<Pick<User, "id" | "username">[]>({
|
||||
queryKey: [`/api/tasks/${taskId}/shared-users`], // Needs to be dependent on taskId
|
||||
enabled: open, // Only fetch when open
|
||||
});
|
||||
|
||||
const shareMutation = useMutation({
|
||||
mutationFn: async (targetUserId: string) => {
|
||||
const res = await apiRequest("POST", `/api/tasks/${taskId}/share`, { targetUserId });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: t('share.successTask') });
|
||||
setUserToConfirm(null);
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery(""); // Clear search
|
||||
refetchShared(); // Update list
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t('share.errorTask'), variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const unshareMutation = useMutation({
|
||||
mutationFn: async (targetUserId: string) => {
|
||||
const res = await apiRequest("DELETE", `/api/tasks/${taskId}/share/${targetUserId}`);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: t('share.removeSuccess') });
|
||||
refetchShared();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t('share.errorTask'), variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Confirmation State
|
||||
const confirmShare = () => {
|
||||
if (userToConfirm) {
|
||||
shareMutation.mutate(userToConfirm.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(val) => {
|
||||
if (!val) {
|
||||
setUserToConfirm(null); // Reset on close
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
}
|
||||
onOpenChange(val);
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Share2 className="w-5 h-5" />
|
||||
{t('share.taskTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{userToConfirm ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="bg-muted/50 p-4 rounded-lg text-center">
|
||||
<Avatar className="h-16 w-16 mx-auto mb-2">
|
||||
<AvatarFallback className="text-lg">{userToConfirm.username.substring(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<h3 className="font-semibold text-lg">{userToConfirm.username}</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('share.confirmShareDesc', { username: userToConfirm.username })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setUserToConfirm(null)}>
|
||||
{t('share.cancel')}
|
||||
</Button>
|
||||
<Button onClick={confirmShare} disabled={shareMutation.isPending}>
|
||||
{shareMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{t('share.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Current Shared Users */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">{t('share.sharedWith')}</h4>
|
||||
<div className="space-y-2">
|
||||
{sharedLoading ? (
|
||||
<div className="flex justify-center py-2"><Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /></div>
|
||||
) : sharedUsers && sharedUsers.length > 0 ? (
|
||||
<div className="max-h-[150px] overflow-y-auto space-y-2 pr-1">
|
||||
{sharedUsers.map(user => (
|
||||
<div key={user.id} className="flex items-center justify-between p-2 bg-muted/40 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{user.username}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => unshareMutation.mutate(user.id)}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
<span className="text-xs">{t('share.unshare')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic pl-1">Not shared with anyone yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-x-0 border-t my-2" />
|
||||
</div>
|
||||
|
||||
{/* Search to Add */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('share.searchPlaceholder')}
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
// Simple manual debounce for now
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
// Debounce could be improved, but sufficient
|
||||
setDebouncedQuery(searchQuery);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[180px] rounded-md border p-2">
|
||||
{searchLoading && debouncedQuery.length >= 2 ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : users && users.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-2 hover:bg-muted cursor-pointer rounded-lg transition-colors"
|
||||
onClick={() => setUserToConfirm(user)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{user.username}</span>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Share2 className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).length === 0 && (
|
||||
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||
No new users found to share with.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : debouncedQuery.length >= 2 ? (
|
||||
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||
{t('share.noUsers')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||
{t('share.typeToSearch')}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -9,9 +10,32 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle } from "lucide-react";
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock, CornerDownRight, Share2, Users } from "lucide-react";
|
||||
import { Task, Label, User } from '@shared/schema';
|
||||
import { ShareTaskModal } from './ShareTaskModal';
|
||||
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";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
@@ -20,11 +44,117 @@ interface TaskCardProps {
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onStatusChange?: (status: Task['status']) => void;
|
||||
onUpdate?: (updates: Partial<Task>) => void;
|
||||
onAutoSchedule?: () => void;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, isDragging }: TaskCardProps) {
|
||||
function SharedTaskIcon({ task }: { task: Task }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
|
||||
|
||||
const isOwner = user?.id === task.userId;
|
||||
|
||||
// If owner, fetch shared users
|
||||
const { data: sharedUsers } = useQuery<any[]>({
|
||||
queryKey: [`/api/tasks/${task.id}/shared-users`],
|
||||
enabled: !!isOwner && !!task.id,
|
||||
retry: false
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Case 1: Owner and task is shared
|
||||
if (isOwner && sharedUsers && sharedUsers.length > 0) {
|
||||
const names = sharedUsers.map(u => u.username).join(", ");
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-indigo-100 dark:bg-indigo-900 cursor-help">
|
||||
<Share2 className="w-3 h-3 text-indigo-600 dark:text-indigo-300" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('share.sharedWith')}: {names}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Case 2: Not owner (Shared WITH me)
|
||||
if (!isOwner && task.userId) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-orange-100 dark:bg-orange-900 cursor-help">
|
||||
<Users className="w-3 h-3 text-orange-600 dark:text-orange-300" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('share.sharedWithMe') || "Shared with me"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
|
||||
|
||||
// Only owner can share
|
||||
if (user?.id !== task.userId) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShare();
|
||||
}}
|
||||
>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
{t('share.taskTitle')}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, onAutoSchedule, isDragging }: TaskCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isShareModalOpen, setIsShareModalOpen] = 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);
|
||||
}
|
||||
};
|
||||
|
||||
const { data: allTasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const blockingTasks = task.dependencies?.map(dId => allTasks.find(t => t.id === dId)).filter(t => t && t.status !== 'done') || [];
|
||||
const isBlocked = blockingTasks.length > 0;
|
||||
|
||||
const subtasks = allTasks.filter(t => t.parentTaskId === task.id);
|
||||
const completedSubtasks = subtasks.filter(t => t.status === 'done');
|
||||
|
||||
// Fetch labels to get the label color
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
@@ -74,202 +204,505 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
return `${hours}h ${mins}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`p-4 hover-elevate active-elevate-2 transition-all duration-200 relative cursor-pointer ${
|
||||
isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
|
||||
}`}
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
data-testid={`card-task-${task.id}`}
|
||||
onClick={() => {
|
||||
onEdit?.();
|
||||
console.log(`Task card clicked: ${task.title}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm leading-tight truncate" 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>
|
||||
)}
|
||||
</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 flex-col gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
/* AI Follow-up Check */
|
||||
const checkFollowUp = async () => {
|
||||
// Fire and forget - don't block UI
|
||||
try {
|
||||
const res = await apiRequest("POST", "/api/ai/analyze-completion", { taskId: task.id });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.needed && data.title) {
|
||||
toast({
|
||||
title: t('ai.followUpSuggestion', 'Follow-up Suggested'),
|
||||
description: `${data.title} - ${data.description || ''}`,
|
||||
action: (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`button-menu-${task.id}`}
|
||||
className="w-6 h-6"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation(); // prevent toast click from doing generic things
|
||||
// Create the task immediately
|
||||
try {
|
||||
await apiRequest("POST", "/api/tasks", {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
labelId: task.labelId, // Inherit label
|
||||
dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // Tomorrow
|
||||
});
|
||||
toast({ title: t('ai.taskCreated', 'Follow-up task created!') });
|
||||
// Invalidate queries to show new task
|
||||
// queryClient.invalidateQueries... (Need queryClient access)
|
||||
// Since we don't have queryClient here easily without import useQueryClient
|
||||
// We can just reload or rely on auto-refetch
|
||||
window.location.reload(); // Crude but effective for now, or useQueryClient
|
||||
} catch (err) {
|
||||
toast({ title: t('ai.errorCreating', 'Failed to create task'), variant: "destructive" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="w-3 h-3" />
|
||||
{t('common.create', 'Create')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
),
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Follow-up check failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleComplete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isBlocked) return;
|
||||
if (task.status === 'done') {
|
||||
onStatusChange?.('todo');
|
||||
} else {
|
||||
triggerConfetti(e.clientX / window.innerWidth, e.clientY / window.innerHeight);
|
||||
playSuccessSound();
|
||||
onStatusChange?.('done');
|
||||
// Trigger AI check
|
||||
checkFollowUp();
|
||||
}
|
||||
};
|
||||
|
||||
const controls = useAnimation();
|
||||
|
||||
const handleDragEnd = async (event: any, info: PanInfo) => {
|
||||
if (info.offset.x > 100) {
|
||||
if (isBlocked) {
|
||||
controls.start({ x: 0 });
|
||||
return;
|
||||
}
|
||||
// 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 });
|
||||
}
|
||||
};
|
||||
|
||||
/* Retrieve parent task if this is a subtask */
|
||||
const parentTask = task.parentTaskId
|
||||
? allTasks.find(t => String(t.id) === String(task.parentTaskId))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'clip' }}>
|
||||
<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
|
||||
style={{ overscrollBehaviorX: 'none' }}
|
||||
>
|
||||
<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' : ''} ${parentTask ? 'border-l-4 border-l-indigo-400 bg-indigo-50/10' : ''}`}
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid',
|
||||
borderLeftWidth: parentTask ? '4px' : '2px', // Make left border thicker if subtask
|
||||
borderLeftColor: parentTask ? '#818cf8' : taskLabel.color // Indigo for subtask
|
||||
} : {}}
|
||||
data-testid={`card-task-${task.id}`}
|
||||
>
|
||||
<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"
|
||||
disabled={isBlocked}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isBlocked ? (
|
||||
<p className="text-destructive font-medium">
|
||||
{t('taskCard.blockedBy', { tasks: blockingTasks.map(t => t?.title).join(", ") })}
|
||||
</p>
|
||||
) : (
|
||||
<p>{t('taskCard.toggleComplete')}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<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>
|
||||
{/* Parent Task Link rendered in title now */}
|
||||
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
|
||||
{parentTask ? (
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-muted-foreground font-normal truncate shrink min-w-0">{parentTask.title}</span>
|
||||
<span className="text-muted-foreground shrink-0">›</span>
|
||||
<span className="truncate shrink min-w-0">{task.title}</span>
|
||||
</span>
|
||||
) : (
|
||||
task.title
|
||||
)}
|
||||
</h3>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${getStatusColor(task.status)}`}
|
||||
data-testid={`badge-status-${task.id}`}
|
||||
>
|
||||
{t(`status.${task.status}`)}
|
||||
</Badge>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{/* Shared Status Icon */}
|
||||
<SharedTaskIcon task={task} />
|
||||
|
||||
{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.dueDate || task.startDate) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span data-testid={`text-due-date-${task.id}`}>
|
||||
{task.startDate ? `${new Date(task.startDate).toLocaleDateString()} - ` : ''}
|
||||
{task.dueDate ? new Date(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.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.estimatedDuration && (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
|
||||
⏳ {formatTime(task.estimatedDuration)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{subtasks.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{completedSubtasks.length}/{subtasks.length} Subtasks
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{isBlocked && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="text-xs border-destructive text-destructive gap-1 animate-pulse">
|
||||
<Lock className="w-3 h-3" />
|
||||
Blocked
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Waiting for: {blockingTasks.map(t => t?.title).join(", ")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
{(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>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant={task.isTracking ? "default" : "outline"}
|
||||
<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>
|
||||
|
||||
{onAutoSchedule && !task.dueDate && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAutoSchedule();
|
||||
}}
|
||||
className="text-indigo-600 dark:text-indigo-400"
|
||||
data-testid={`menu-schedule-${task.id}`}
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.autoSchedule', 'Auto-Schedule')}
|
||||
</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>
|
||||
|
||||
{/* Share Option - Check if owner */}
|
||||
<SharedMenuItem task={task} onShare={() => setIsShareModalOpen(true)} />
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isBlocked) return;
|
||||
onStatusChange?.('done');
|
||||
console.log(`Mark task as done: ${task.title}`);
|
||||
}}
|
||||
disabled={isBlocked}
|
||||
data-testid={`menu-complete-${task.id}`}
|
||||
>
|
||||
{isBlocked ? <Lock className="w-4 h-4 mr-2" /> : <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={`button-timer-${task.id}`}
|
||||
className="w-6 h-6"
|
||||
data-testid={`context-menu-timer-${task.id}`}
|
||||
>
|
||||
{task.isTracking ? <Pause className="w-3 h-3" /> : <Play className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Timer className="w-4 h-4 mr-2" />
|
||||
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
|
||||
</ContextMenuItem>
|
||||
|
||||
<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>
|
||||
|
||||
<ShareTaskModal
|
||||
taskId={task.id}
|
||||
open={isShareModalOpen}
|
||||
onOpenChange={setIsShareModalOpen}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,15 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { CalendarIcon, Plus, Tag } from 'lucide-react';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarIcon, Plus, Tag, Check, Link2, Info } 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';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface TaskCreationModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -22,24 +28,58 @@ 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 [startDate, setStartDate] = useState<Date | undefined>();
|
||||
|
||||
const [labelId, setLabelId] = useState<string | undefined>();
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
|
||||
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
const [isStartDateOpen, setIsStartDateOpen] = useState(false);
|
||||
|
||||
const [isRecurring, setIsRecurring] = useState(false);
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState<string | undefined>();
|
||||
const [recurrenceIntervalValue, setRecurrenceIntervalValue] = useState(1);
|
||||
const [recurrenceEnd, setRecurrenceEnd] = useState<Date | undefined>();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch labels
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
const { data: labelsData } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels'],
|
||||
});
|
||||
const labels = labelsData ?? [];
|
||||
|
||||
const { data: tasksData } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
const tasks = tasksData ?? [];
|
||||
|
||||
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,
|
||||
|
||||
isRecurring,
|
||||
recurrenceInterval,
|
||||
recurrenceIntervalValue,
|
||||
recurrenceEnd,
|
||||
|
||||
startDate,
|
||||
dueDate,
|
||||
labelId,
|
||||
dependencies,
|
||||
status: 'todo',
|
||||
timeTracked: 0,
|
||||
isTracking: false
|
||||
@@ -53,7 +93,14 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setDescription('');
|
||||
setPriority('medium');
|
||||
setDueDate(undefined);
|
||||
setStartDate(undefined);
|
||||
setIsRecurring(false);
|
||||
setRecurrenceInterval(undefined);
|
||||
setRecurrenceIntervalValue(1);
|
||||
setRecurrenceEnd(undefined);
|
||||
setLabelId(undefined);
|
||||
setDependencies([]);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -69,13 +116,20 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setDescription('');
|
||||
setPriority('medium');
|
||||
setDueDate(undefined);
|
||||
setStartDate(undefined);
|
||||
setIsRecurring(false);
|
||||
setRecurrenceInterval(undefined);
|
||||
setRecurrenceIntervalValue(1);
|
||||
setRecurrenceEnd(undefined);
|
||||
setLabelId(undefined);
|
||||
setDependencies([]);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md mx-4">
|
||||
<Dialog open={isOpen} onOpenChange={handleClose} >
|
||||
<DialogContent className="w-[95vw] max-w-lg sm:max-w-md max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -86,14 +140,35 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Input
|
||||
placeholder={t('taskCreation.titlePlaceholder')}
|
||||
placeholder={t('smartTask.placeholder')}
|
||||
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') || title.toLowerCase().includes('morgen') ||
|
||||
title.toLowerCase().includes('today') || title.toLowerCase().includes('heute') ? (
|
||||
<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" />
|
||||
{t('taskCreation.smartInputActive')}
|
||||
</div>
|
||||
) : null}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -107,86 +182,283 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
|
||||
<SelectTrigger data-testid="select-task-priority">
|
||||
<SelectValue placeholder={t('taskCreation.priority')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{t('priority.low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('priority.high')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
|
||||
<SelectTrigger data-testid="select-task-priority">
|
||||
<SelectValue placeholder={t('taskCreation.priority')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{t('priority.low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('priority.high')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
|
||||
<SelectTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<SelectValue placeholder={t('taskCreation.energy')} />
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="sr-only">Help</p>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">{t('taskCreation.energyHelp', 'Match tasks to your energy levels. Low energy tasks are good for slumps, High energy tasks require focus.')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">{t('gamification.energy.low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('gamification.energy.medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('gamification.energy.high')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="col-span-2 sm:col-span-1 space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('taskCreation.duration')} ({t('planning.minutes')})</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="5"
|
||||
placeholder={t('planning.durationPlaceholder')}
|
||||
value={estimatedDuration || ''}
|
||||
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="w-full"
|
||||
data-testid="input-duration"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[15, 30, 45, 60, 90, 120].map((mins) => (
|
||||
<Badge
|
||||
key={mins}
|
||||
variant={estimatedDuration === mins ? "default" : "outline"}
|
||||
className="cursor-pointer text-[10px] px-1.5 py-0.5"
|
||||
onClick={() => setEstimatedDuration(mins)}
|
||||
>
|
||||
{mins}m
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
|
||||
<SelectTrigger data-testid="select-task-label">
|
||||
<SelectValue placeholder={t('taskCreation.label')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
|
||||
{labels.map((label) => (
|
||||
<SelectItem key={label.id} value={label.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
|
||||
<SelectTrigger data-testid="select-task-label">
|
||||
<SelectValue placeholder={t('taskCreation.label')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
|
||||
{labels.map((label) => (
|
||||
<SelectItem key={label.id} value={label.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Popover open={isStartDateOpen} onOpenChange={setIsStartDateOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!startDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{startDate ? startDate.toLocaleDateString() : t('taskCreation.startDate')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={(date) => {
|
||||
setStartDate(date);
|
||||
setIsStartDateOpen(false);
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||||
<div className="col-span-1">
|
||||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!dueDate && "text-muted-foreground"
|
||||
)}
|
||||
data-testid="button-due-date"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={(date) => {
|
||||
setDueDate(date);
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence Selection */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium flex-none">{t('taskCreation.recurrence.label')}</label>
|
||||
<Select value={recurrenceInterval || 'none'} onValueChange={(v) => {
|
||||
setRecurrenceInterval(v === 'none' ? undefined : v);
|
||||
setIsRecurring(v !== 'none');
|
||||
}}>
|
||||
<SelectTrigger className="h-8" data-testid="recurrence-trigger">
|
||||
<SelectValue placeholder={t('taskCreation.recurrence.none')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" data-testid="recurrence-option-none">{t('taskCreation.recurrence.none')}</SelectItem>
|
||||
<SelectItem value="daily" data-testid="recurrence-option-daily">{t('taskCreation.recurrence.daily')}</SelectItem>
|
||||
<SelectItem value="weekly" data-testid="recurrence-option-weekly">{t('taskCreation.recurrence.weekly')}</SelectItem>
|
||||
<SelectItem value="monthly" data-testid="recurrence-option-monthly">{t('taskCreation.recurrence.monthly')}</SelectItem>
|
||||
<SelectItem value="yearly" data-testid="recurrence-option-yearly">{t('taskCreation.recurrence.yearly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{isRecurring && (
|
||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-2">
|
||||
<span className="text-sm">{t('taskCreation.recurrence.every')}</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-16 h-8"
|
||||
min={1}
|
||||
value={recurrenceIntervalValue}
|
||||
onChange={(e) => setRecurrenceIntervalValue(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{recurrenceInterval && t(`taskCreation.recurrence.units.${recurrenceInterval}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dependencies Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4" />
|
||||
{t('dependencies.label')}
|
||||
</label>
|
||||
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
data-testid="button-due-date"
|
||||
role="combobox"
|
||||
className="w-full justify-between h-auto min-h-[40px]"
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4" />
|
||||
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{dependencies.length > 0 ? (
|
||||
dependencies.map((depId) => {
|
||||
const task = tasks.find((t) => t.id === depId);
|
||||
return (
|
||||
<Badge key={depId} variant="secondary" className="mr-1">
|
||||
{task?.title || 'Unknown Task'}
|
||||
<div
|
||||
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDependencies(dependencies.filter((id) => id !== depId));
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
|
||||
</div>
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={(date) => {
|
||||
setDueDate(date);
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
disabled={(date) => date < new Date()}
|
||||
initialFocus
|
||||
/>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search tasks..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No task found.</CommandEmpty>
|
||||
<CommandGroup heading="Available Tasks">
|
||||
{tasks
|
||||
.filter(t => t.status !== 'done') // Only active tasks
|
||||
.map((task) => (
|
||||
<CommandItem
|
||||
key={task.id}
|
||||
value={task.title}
|
||||
onSelect={() => {
|
||||
if (dependencies.includes(task.id)) {
|
||||
setDependencies(dependencies.filter((id) => id !== task.id));
|
||||
} else {
|
||||
setDependencies([...dependencies, task.id]);
|
||||
}
|
||||
// Keep the popover open for multiple selection
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
dependencies.includes(task.id)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Check className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
<span>{task.title}</span>
|
||||
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
|
||||
{task.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="flex-1"
|
||||
data-testid="button-cancel"
|
||||
>
|
||||
{t('taskCreation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!title.trim()}
|
||||
className="flex-1"
|
||||
data-testid="button-save-task"
|
||||
>
|
||||
{t('taskCreation.create')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="flex-1"
|
||||
data-testid="button-cancel"
|
||||
>
|
||||
{t('taskCreation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="flex-1"
|
||||
data-testid="button-save-task"
|
||||
>
|
||||
{t('taskCreation.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -8,8 +8,17 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar, Tag } from 'lucide-react';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar as CalendarIcon, Tag, Link2, Check, LayoutList, Trash2, ArrowRight, CheckCircle2 } from 'lucide-react';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { format } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TimeEntry {
|
||||
id: string;
|
||||
@@ -24,6 +33,7 @@ interface TaskDetailsModalProps {
|
||||
task: Task | null;
|
||||
onSave: (updatedTask: Task) => void;
|
||||
labels?: Label[];
|
||||
onNavigate?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export default function TaskDetailsModal({
|
||||
@@ -31,8 +41,13 @@ export default function TaskDetailsModal({
|
||||
onClose,
|
||||
task,
|
||||
onSave,
|
||||
labels = []
|
||||
labels = [],
|
||||
onNavigate
|
||||
}: TaskDetailsModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const currentLocale = i18n.language === 'de' ? de : enUS;
|
||||
|
||||
// Edit form state
|
||||
const [editedTitle, setEditedTitle] = useState('');
|
||||
const [editedDescription, setEditedDescription] = useState('');
|
||||
@@ -40,6 +55,21 @@ export default function TaskDetailsModal({
|
||||
const [editedPriority, setEditedPriority] = useState<'low' | 'medium' | 'high'>('medium');
|
||||
const [editedLabelId, setEditedLabelId] = useState<string | null>(null);
|
||||
const [editedDueDate, setEditedDueDate] = useState('');
|
||||
const [editedStartDate, setEditedStartDate] = useState('');
|
||||
const [editedEstimatedDuration, setEditedEstimatedDuration] = useState<number | undefined>(undefined);
|
||||
const [editedDependencies, setEditedDependencies] = useState<string[]>([]);
|
||||
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
|
||||
|
||||
// Subtasks state
|
||||
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
const [isCreatingSubtask, setIsCreatingSubtask] = useState(false);
|
||||
const [isScheduling, setIsScheduling] = useState(false);
|
||||
|
||||
const { data: tasksData } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
const tasks = tasksData ?? [];
|
||||
|
||||
// Notes state
|
||||
const [notes, setNotes] = useState('');
|
||||
@@ -53,6 +83,13 @@ export default function TaskDetailsModal({
|
||||
const [timeDescription, setTimeDescription] = useState('');
|
||||
const [activeTimeTab, setActiveTimeTab] = useState('clock');
|
||||
|
||||
// Mark as done flow state
|
||||
const [showTimeRequiredDialog, setShowTimeRequiredDialog] = useState(false);
|
||||
const [markDoneHours, setMarkDoneHours] = useState(0);
|
||||
const [markDoneMinutes, setMarkDoneMinutes] = useState(0);
|
||||
const [markDoneManualHours, setMarkDoneManualHours] = useState('');
|
||||
const [markDoneTimeTab, setMarkDoneTimeTab] = useState('clock');
|
||||
|
||||
// Time entries state - simulate from timeTracked for display
|
||||
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
||||
|
||||
@@ -65,6 +102,9 @@ export default function TaskDetailsModal({
|
||||
setEditedPriority(task.priority as 'low' | 'medium' | 'high');
|
||||
setEditedLabelId(task.labelId || null);
|
||||
setEditedDueDate(task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : '');
|
||||
setEditedStartDate(task.startDate ? new Date(task.startDate).toISOString().split('T')[0] : '');
|
||||
setEditedEstimatedDuration(task.estimatedDuration || undefined);
|
||||
setEditedDependencies(task.dependencies || []);
|
||||
|
||||
setNotes(task.notes || '');
|
||||
// Create a single time entry from timeTracked for display purposes
|
||||
@@ -73,7 +113,7 @@ export default function TaskDetailsModal({
|
||||
id: 'tracked-time',
|
||||
date: new Date(),
|
||||
timeSpent: task.timeTracked,
|
||||
description: 'Previously tracked time'
|
||||
description: t('taskDetails.previouslyTrackedTime')
|
||||
}]);
|
||||
} else {
|
||||
setTimeEntries([]);
|
||||
@@ -97,9 +137,86 @@ export default function TaskDetailsModal({
|
||||
setMinutes(0);
|
||||
setManualHours('');
|
||||
setTimeDescription('');
|
||||
setShowTimeRequiredDialog(false);
|
||||
setMarkDoneHours(0);
|
||||
setMarkDoneMinutes(0);
|
||||
setMarkDoneManualHours('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handle Mark as Done button click
|
||||
const handleMarkAsDone = () => {
|
||||
if (!task) return;
|
||||
|
||||
// Check if time has been tracked
|
||||
if (!task.timeTracked || task.timeTracked === 0) {
|
||||
// Show dialog to enter time first
|
||||
setShowTimeRequiredDialog(true);
|
||||
setMarkDoneHours(0);
|
||||
setMarkDoneMinutes(0);
|
||||
setMarkDoneManualHours('');
|
||||
setMarkDoneTimeTab('clock');
|
||||
} else {
|
||||
// Time already tracked, mark as done directly
|
||||
completeTaskAsDone();
|
||||
}
|
||||
};
|
||||
|
||||
// Complete the task as done
|
||||
const completeTaskAsDone = () => {
|
||||
if (!task) return;
|
||||
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
title: editedTitle,
|
||||
description: editedDescription || null,
|
||||
status: 'done',
|
||||
priority: editedPriority,
|
||||
labelId: editedLabelId,
|
||||
dueDate: editedDueDate ? new Date(editedDueDate) : null,
|
||||
startDate: editedStartDate ? new Date(editedStartDate) : null,
|
||||
estimatedDuration: editedEstimatedDuration ?? null,
|
||||
dependencies: editedDependencies
|
||||
};
|
||||
onSave(updatedTask);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// Handle saving time and then marking as done
|
||||
const handleSaveTimeAndMarkDone = () => {
|
||||
if (!task) return;
|
||||
|
||||
let totalMinutes = 0;
|
||||
|
||||
if (markDoneTimeTab === 'clock') {
|
||||
totalMinutes = markDoneHours * 60 + markDoneMinutes;
|
||||
} else {
|
||||
const parsedHours = parseFloat(markDoneManualHours) || 0;
|
||||
totalMinutes = Math.round(parsedHours * 60);
|
||||
}
|
||||
|
||||
if (totalMinutes <= 0) return;
|
||||
|
||||
// Create updated task with time and done status
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
title: editedTitle,
|
||||
description: editedDescription || null,
|
||||
status: 'done',
|
||||
priority: editedPriority,
|
||||
labelId: editedLabelId,
|
||||
dueDate: editedDueDate ? new Date(editedDueDate) : null,
|
||||
startDate: editedStartDate ? new Date(editedStartDate) : null,
|
||||
estimatedDuration: editedEstimatedDuration ?? null,
|
||||
dependencies: editedDependencies,
|
||||
timeTracked: (task.timeTracked || 0) + totalMinutes
|
||||
};
|
||||
|
||||
onSave(updatedTask);
|
||||
setShowTimeRequiredDialog(false);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleSaveTaskEdit = () => {
|
||||
if (!task) return;
|
||||
|
||||
@@ -109,8 +226,12 @@ export default function TaskDetailsModal({
|
||||
description: editedDescription || null,
|
||||
status: editedStatus,
|
||||
priority: editedPriority,
|
||||
|
||||
labelId: editedLabelId,
|
||||
dueDate: editedDueDate ? new Date(editedDueDate) : null
|
||||
dueDate: editedDueDate ? new Date(editedDueDate) : null,
|
||||
startDate: editedStartDate ? new Date(editedStartDate) : null,
|
||||
estimatedDuration: editedEstimatedDuration ?? null,
|
||||
dependencies: editedDependencies
|
||||
};
|
||||
onSave(updatedTask);
|
||||
};
|
||||
@@ -165,6 +286,55 @@ export default function TaskDetailsModal({
|
||||
console.log(`Time entry saved: ${totalMinutes} minutes for task: ${task.title}`);
|
||||
};
|
||||
|
||||
const handleCreateSubtask = async () => {
|
||||
if (!task || !newSubtaskTitle.trim()) return;
|
||||
|
||||
try {
|
||||
setIsCreatingSubtask(true);
|
||||
const subtaskData = {
|
||||
title: newSubtaskTitle,
|
||||
description: '',
|
||||
status: 'todo',
|
||||
priority: 'medium',
|
||||
parentTaskId: task.id,
|
||||
dueDate: null,
|
||||
estimatedDuration: null
|
||||
};
|
||||
|
||||
await apiRequest('POST', '/api/tasks', subtaskData);
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
setNewSubtaskTitle('');
|
||||
// Switch to the newly created task? Or just show it in the list.
|
||||
} catch (error) {
|
||||
console.error('Failed to create subtask:', error);
|
||||
} finally {
|
||||
setIsCreatingSubtask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoSchedule = async () => {
|
||||
if (!task) return;
|
||||
setIsScheduling(true);
|
||||
try {
|
||||
const res = await apiRequest('POST', `/api/tasks/${task.id}/schedule`, {});
|
||||
const data = await res.json();
|
||||
if (data.success && data.scheduledDate) {
|
||||
setEditedDueDate(new Date(data.scheduledDate).toISOString().split('T')[0]);
|
||||
// Also update the local task object immediately for smoother UX, or let queryClient invalidate
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
} else {
|
||||
console.error("Scheduling failed: ", data.error);
|
||||
// Could add a toast here ideally
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auto-schedule error", error);
|
||||
} finally {
|
||||
setIsScheduling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const subtasks = tasks.filter(t => t.parentTaskId === task?.id);
|
||||
|
||||
const handleDeleteTimeEntry = (entryId: string) => {
|
||||
if (!task || entryId === 'tracked-time') {
|
||||
// Don't allow deleting the main tracked time entry
|
||||
@@ -231,13 +401,18 @@ export default function TaskDetailsModal({
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
|
||||
const parentTask = task.parentTaskId ? tasks.find(t => String(t.id) === String(task.parentTaskId)) : null;
|
||||
const isSubtask = !!task.parentTaskId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="w-[95vw] max-w-lg sm:max-w-2xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Task Details
|
||||
{t('taskDetails.title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -245,6 +420,16 @@ export default function TaskDetailsModal({
|
||||
{/* Task Information */}
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
{parentTask && (
|
||||
<div
|
||||
className="flex items-center gap-1 text-sm text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 px-2 py-1 rounded w-fit mb-1 cursor-pointer hover:underline"
|
||||
onClick={() => onNavigate?.(String(parentTask.id))}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<span className="font-medium">{t('taskDetails.subtaskOf')}</span>
|
||||
<span>{parentTask.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="font-semibold text-lg" data-testid="text-task-details-title">
|
||||
{task.title}
|
||||
</h3>
|
||||
@@ -257,14 +442,14 @@ export default function TaskDetailsModal({
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className={`text-xs ${getPriorityColor(task.priority)}`}>
|
||||
{task.priority} priority
|
||||
{t(`taskDetails.priorityValue.${task.priority}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-xs ${getStatusColor(task.status)}`}>
|
||||
{task.status}
|
||||
{t(`taskDetails.statusValue.${task.status}`)}
|
||||
</Badge>
|
||||
{getTotalTrackedTime() > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMinutesToHours(getTotalTrackedTime())} tracked
|
||||
{formatMinutesToHours(getTotalTrackedTime())} {t('taskDetails.tracked')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -272,44 +457,106 @@ export default function TaskDetailsModal({
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="edit" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="edit" data-testid="tab-edit">
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
{t('taskDetails.editTab')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notes" data-testid="tab-notes">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Notes
|
||||
{t('taskDetails.notesTab')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subtasks" data-testid="tab-subtasks">
|
||||
<LayoutList className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.subtasksTab')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="time" data-testid="tab-time">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Time Tracking
|
||||
{t('taskDetails.timeTab')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Subtasks Tab */}
|
||||
<TabsContent value="subtasks" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-4">{t('taskDetails.subtasks')}</h4>
|
||||
|
||||
{!isSubtask ? (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder={t('taskDetails.newSubtaskPlaceholder')}
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateSubtask();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateSubtask}
|
||||
disabled={!newSubtaskTitle.trim() || isCreatingSubtask}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t('taskDetails.createSubtask')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 text-sm rounded-md border border-yellow-200 dark:border-yellow-900 flex items-center gap-2">
|
||||
<LayoutList className="w-4 h-4" />
|
||||
{t('taskDetails.hierarchyRestriction')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{subtasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
{t('taskDetails.noSubtasks')}
|
||||
</div>
|
||||
) : (
|
||||
subtasks.map(subtask => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="flex items-center justify-between p-3 border rounded-md hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
onClick={() => onNavigate?.(String(subtask.id))}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={subtask.status === 'done' ? 'secondary' : 'outline'}>
|
||||
{t(`taskDetails.statusValue.${subtask.status}`)}
|
||||
</Badge>
|
||||
<span className={subtask.status === 'done' ? 'line-through text-muted-foreground' : ''}>
|
||||
{subtask.title}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Edit Tab */}
|
||||
<TabsContent value="edit" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium mb-4">Edit Task</h4>
|
||||
<h4 className="font-medium mb-4">{t('taskDetails.editTask')}</h4>
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Title</label>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.titleLabel')}</label>
|
||||
<Input
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
placeholder="Task title"
|
||||
placeholder={t('taskDetails.titlePlaceholder')}
|
||||
data-testid="input-task-title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Description</label>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.description')}</label>
|
||||
<Textarea
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
placeholder="Task description"
|
||||
placeholder={t('taskDetails.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
data-testid="input-task-description"
|
||||
/>
|
||||
@@ -318,29 +565,29 @@ export default function TaskDetailsModal({
|
||||
{/* Status and Priority */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Status</label>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.status')}</label>
|
||||
<Select value={editedStatus} onValueChange={(value) => setEditedStatus(value as 'todo' | 'inProgress' | 'done')}>
|
||||
<SelectTrigger data-testid="select-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="inProgress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="todo">{t('taskDetails.statusValue.todo')}</SelectItem>
|
||||
<SelectItem value="inProgress">{t('taskDetails.statusValue.inProgress')}</SelectItem>
|
||||
<SelectItem value="done">{t('taskDetails.statusValue.done')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Priority</label>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.priority')}</label>
|
||||
<Select value={editedPriority} onValueChange={(value) => setEditedPriority(value as 'low' | 'medium' | 'high')}>
|
||||
<SelectTrigger data-testid="select-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="low">{t('taskDetails.priorityValue.low')}</SelectItem>
|
||||
<SelectItem value="medium">{t('taskDetails.priorityValue.medium')}</SelectItem>
|
||||
<SelectItem value="high">{t('taskDetails.priorityValue.high')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -348,13 +595,13 @@ export default function TaskDetailsModal({
|
||||
|
||||
{/* Label */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Label</label>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.label')}</label>
|
||||
<Select value={editedLabelId || 'none'} onValueChange={(value) => setEditedLabelId(value === 'none' ? null : value)}>
|
||||
<SelectTrigger data-testid="input-label-selector">
|
||||
<SelectValue placeholder="Select a label" />
|
||||
<SelectValue placeholder={t('taskDetails.selectLabel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No Label</SelectItem>
|
||||
<SelectItem value="none">{t('taskDetails.noLabel')}</SelectItem>
|
||||
{labels.map((label) => (
|
||||
<SelectItem key={label.id} value={label.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -371,17 +618,191 @@ export default function TaskDetailsModal({
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Due Date</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={editedDueDate}
|
||||
onChange={(e) => setEditedDueDate(e.target.value)}
|
||||
data-testid="input-due-date"
|
||||
/>
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Start Date */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 h-8">
|
||||
<label className="text-sm font-medium">{t('taskDetails.startDate')}</label>
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!editedStartDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{editedStartDate ? format(new Date(editedStartDate), "PPP", { locale: i18n.language === 'de' ? de : enUS }) : <span>{t('taskDetails.pickDate')}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={editedStartDate ? new Date(editedStartDate) : undefined}
|
||||
onSelect={(date) => setEditedStartDate(date ? date.toISOString().split('T')[0] : '')}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Due Date */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 h-8">
|
||||
<label className="text-sm font-medium">{t('taskDetails.dueDate')}</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-indigo-600 border-indigo-200 hover:text-indigo-700 hover:bg-indigo-50"
|
||||
onClick={handleAutoSchedule}
|
||||
disabled={isScheduling}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3 mr-1" />
|
||||
{isScheduling ? t('taskDetails.scheduling') : t('taskDetails.autoSchedule')}
|
||||
</Button>
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!editedDueDate && "text-muted-foreground"
|
||||
)}
|
||||
data-testid="input-due-date"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{editedDueDate ? format(new Date(editedDueDate), "PPP", { locale: i18n.language === 'de' ? de : enUS }) : <span>{t('taskDetails.pickDate')}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={editedDueDate ? new Date(editedDueDate) : undefined}
|
||||
onSelect={(date) => setEditedDueDate(date ? date.toISOString().split('T')[0] : '')}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
{/* Estimated Duration */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')} ({t('planning.minutes')})</label>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="5"
|
||||
placeholder={t('planning.durationPlaceholder')}
|
||||
value={editedEstimatedDuration || ''}
|
||||
onChange={(e) => setEditedEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
data-testid="input-edit-duration"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[15, 30, 45, 60, 90, 120].map((mins) => (
|
||||
<Badge
|
||||
key={mins}
|
||||
variant={editedEstimatedDuration === mins ? "default" : "outline"}
|
||||
className="cursor-pointer text-xs"
|
||||
onClick={() => setEditedEstimatedDuration(mins)}
|
||||
>
|
||||
{mins}m
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div>
|
||||
<label className="text-sm font-medium flex items-center gap-2 mb-2">
|
||||
<Link2 className="w-4 h-4" />
|
||||
{t('taskDetails.blockedBy')}
|
||||
</label>
|
||||
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between h-auto min-h-[40px]"
|
||||
>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{editedDependencies.length > 0 ? (
|
||||
editedDependencies.map((depId) => {
|
||||
const depTask = tasks.find((t) => t.id === depId);
|
||||
return (
|
||||
<Badge key={depId} variant="secondary" className="mr-1">
|
||||
{depTask?.title || 'Unknown Task'}
|
||||
<div
|
||||
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditedDependencies(editedDependencies.filter((id) => id !== depId));
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
|
||||
</div>
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search tasks..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No task found.</CommandEmpty>
|
||||
<CommandGroup heading="Available Tasks">
|
||||
{tasks
|
||||
.filter(t => t.id !== task.id && t.status !== 'done') // Cannot depend on self or done tasks (optional logic)
|
||||
.map((tItem) => (
|
||||
<CommandItem
|
||||
key={tItem.id}
|
||||
value={tItem.title}
|
||||
onSelect={() => {
|
||||
if (editedDependencies.includes(tItem.id)) {
|
||||
setEditedDependencies(editedDependencies.filter((id) => id !== tItem.id));
|
||||
} else {
|
||||
setEditedDependencies([...editedDependencies, tItem.id]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
editedDependencies.includes(tItem.id)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Check className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
<span>{tItem.title}</span>
|
||||
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
|
||||
{tItem.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Save and Mark as Done Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleSaveTaskEdit}
|
||||
@@ -389,8 +810,20 @@ export default function TaskDetailsModal({
|
||||
data-testid="button-save-task-edit"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save Changes
|
||||
{t('taskDetails.save')}
|
||||
</Button>
|
||||
{editedStatus !== 'done' && (
|
||||
<Button
|
||||
onClick={handleMarkAsDone}
|
||||
disabled={!editedTitle.trim()}
|
||||
variant="default"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
data-testid="button-mark-as-done"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
{t('taskDetails.markAsDone', 'Mark as Done')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -400,7 +833,7 @@ export default function TaskDetailsModal({
|
||||
<TabsContent value="notes" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium">Task Notes</h4>
|
||||
<h4 className="font-medium">{t('taskDetails.taskNotes')}</h4>
|
||||
{!isEditingNotes && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -409,7 +842,7 @@ export default function TaskDetailsModal({
|
||||
data-testid="button-edit-notes"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
{t('taskDetails.edit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -419,7 +852,7 @@ export default function TaskDetailsModal({
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add your notes for this task..."
|
||||
placeholder={t('taskDetails.notesPlaceholder')}
|
||||
rows={4}
|
||||
data-testid="textarea-task-notes"
|
||||
/>
|
||||
@@ -430,7 +863,7 @@ export default function TaskDetailsModal({
|
||||
data-testid="button-save-notes"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
{t('taskDetails.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -442,7 +875,7 @@ export default function TaskDetailsModal({
|
||||
data-testid="button-cancel-notes"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Cancel
|
||||
{t('taskDetails.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,7 +887,7 @@ export default function TaskDetailsModal({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No notes added yet. Click "Edit" to add notes.
|
||||
{t('taskDetails.noNotesYet')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -468,7 +901,7 @@ export default function TaskDetailsModal({
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">Total Time Tracked</h4>
|
||||
<h4 className="font-medium">{t('taskDetails.totalTimeTracked')}</h4>
|
||||
<p className="text-2xl font-mono text-primary" data-testid="text-total-time">
|
||||
{formatMinutesToHours(getTotalTrackedTime())}
|
||||
</p>
|
||||
@@ -479,7 +912,7 @@ export default function TaskDetailsModal({
|
||||
data-testid="button-add-time"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Add Time
|
||||
{t('taskDetails.addTime')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -489,7 +922,7 @@ export default function TaskDetailsModal({
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Add Time Entry</h4>
|
||||
<h4 className="font-medium">{t('taskDetails.addTimeEntry')}</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -504,11 +937,11 @@ export default function TaskDetailsModal({
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="clock" data-testid="tab-clock-picker">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Clock
|
||||
{t('taskDetails.clock')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manual" data-testid="tab-manual-input">
|
||||
<Timer className="w-4 h-4 mr-2" />
|
||||
Manual
|
||||
{t('taskDetails.manual')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -678,5 +1111,138 @@ export default function TaskDetailsModal({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Time Required Dialog - shown when marking as done without time */}
|
||||
<Dialog open={showTimeRequiredDialog} onOpenChange={setShowTimeRequiredDialog}>
|
||||
<DialogContent className="w-[95vw] max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
{t('taskDetails.timeRequired', 'Time Required')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('taskDetails.timeRequiredDesc', 'Please enter the time spent on this task before marking it as done.')}
|
||||
</p>
|
||||
|
||||
<Tabs value={markDoneTimeTab} onValueChange={setMarkDoneTimeTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="clock">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.clock', 'Clock')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manual">
|
||||
<Timer className="w-4 h-4 mr-2" />
|
||||
{t('taskDetails.manual', 'Manual')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="clock" className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-mono">
|
||||
{formatTime(markDoneHours, markDoneMinutes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Hours Picker */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-center block">{t('taskDetails.hours', 'Hours')}</label>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||
{Array.from({ length: 13 }, (_, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={markDoneHours === i ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setMarkDoneHours(i)}
|
||||
>
|
||||
{i}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Minutes Picker */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-center block">{t('taskDetails.minutes', 'Minutes')}</label>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => (
|
||||
<Button
|
||||
key={minute}
|
||||
variant={markDoneMinutes === minute ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setMarkDoneMinutes(minute)}
|
||||
>
|
||||
{minute}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-mono">
|
||||
{(() => {
|
||||
const parsedHours = parseFloat(markDoneManualHours) || 0;
|
||||
const h = Math.floor(parsedHours);
|
||||
const m = Math.round((parsedHours - h) * 60);
|
||||
return formatTime(h, m);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.hoursDecimal', 'Hours (decimal)')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
max="24"
|
||||
placeholder="e.g., 1.5 for 1 hour 30 minutes"
|
||||
value={markDoneManualHours}
|
||||
onChange={(e) => setMarkDoneManualHours(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('taskDetails.timeExamples', 'Examples: 0.5 = 30min, 1.25 = 1h 15min')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleSaveTimeAndMarkDone}
|
||||
disabled={
|
||||
markDoneTimeTab === 'clock'
|
||||
? markDoneHours === 0 && markDoneMinutes === 0
|
||||
: !markDoneManualHours.trim() || parseFloat(markDoneManualHours) <= 0
|
||||
}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700"
|
||||
data-testid="button-save-time-and-done"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
{t('taskDetails.saveAndMarkDone', 'Save & Mark as Done')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowTimeRequiredDialog(false)}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, Filter, SortAsc } from 'lucide-react';
|
||||
import { Task } from './TaskCard';
|
||||
import { Task } from '@shared/schema';
|
||||
import TaskCard from './TaskCard';
|
||||
|
||||
interface TaskListProps {
|
||||
@@ -32,7 +32,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
|
||||
.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;
|
||||
|
||||
@@ -55,15 +55,15 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
|
||||
return a.dueDate.getTime() - b.dueDate.getTime();
|
||||
|
||||
case 'priority':
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return priorityOrder[b.priority] - priorityOrder[a.priority];
|
||||
const priorityOrder: Record<string, number> = { high: 3, medium: 2, low: 1 };
|
||||
return (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0);
|
||||
|
||||
case 'title':
|
||||
return a.title.localeCompare(b.title);
|
||||
|
||||
case 'status':
|
||||
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status];
|
||||
const statusOrder: Record<string, number> = { todo: 1, inProgress: 2, done: 3 };
|
||||
return (statusOrder[a.status] || 0) - (statusOrder[b.status] || 0);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
@@ -165,11 +165,11 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onPlay={() => {
|
||||
onStartTimer={() => {
|
||||
onTaskUpdate?.(task.id, { isTracking: true, timeTracked: task.timeTracked });
|
||||
console.log(`Timer started for ${task.title}`);
|
||||
}}
|
||||
onPause={() => {
|
||||
onStopTimer={() => {
|
||||
onTaskUpdate?.(task.id, { isTracking: false });
|
||||
console.log(`Timer paused for ${task.title}`);
|
||||
}}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight, Edit, Timer, CheckCircle, Play, Clock, Trash2, Tag } from 'lucide-react';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import TaskCard from './TaskCard';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import TimeCompletionModal from './TimeCompletionModal';
|
||||
import { addDays, format, isSameDay, startOfToday } from 'date-fns';
|
||||
import { addDays, format, isSameDay, startOfToday, startOfDay, isWithinInterval } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDateLocale } from '../hooks/use-date-locale';
|
||||
|
||||
interface TasksWithCalendarProps {
|
||||
tasks: Task[];
|
||||
@@ -21,12 +35,15 @@ interface TasksWithCalendarProps {
|
||||
}
|
||||
|
||||
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
|
||||
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
|
||||
type FilterOption = 'all' | 'open' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
|
||||
|
||||
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');
|
||||
const [filterBy, setFilterBy] = useState<FilterOption>('open');
|
||||
const [filterByLabel, setFilterByLabel] = useState<string>('all');
|
||||
const [calendarStartDate, setCalendarStartDate] = useState(startOfToday());
|
||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||
const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
|
||||
@@ -43,12 +60,14 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Calculate responsive visible days based on screen width
|
||||
// Calculate responsive visible days based on available container width
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const calculateVisibleDays = () => {
|
||||
const screenWidth = window.innerWidth;
|
||||
// Subtract margins and padding (approximately 32px total)
|
||||
const availableWidth = screenWidth - 32;
|
||||
// Use actual container width (accounts for sidebar) instead of window.innerWidth
|
||||
const containerWidth = containerRef.current?.offsetWidth ?? window.innerWidth;
|
||||
// Subtract padding (approximately 32px total)
|
||||
const availableWidth = containerWidth - 32;
|
||||
// Minimum width per day card (including gaps): ~120px
|
||||
const minDayWidth = 120;
|
||||
const maxDays = Math.floor(availableWidth / minDayWidth);
|
||||
@@ -76,16 +95,27 @@ 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;
|
||||
|
||||
// Label filter
|
||||
if (filterByLabel !== 'all') {
|
||||
if (task.labelId !== filterByLabel) return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
switch (filterBy) {
|
||||
case 'overdue':
|
||||
return isOverdue(task);
|
||||
case 'planned':
|
||||
return !!task.dueDate || !!task.startDate;
|
||||
case 'unplanned':
|
||||
return !task.dueDate && !task.startDate;
|
||||
case 'all':
|
||||
return true;
|
||||
case 'open':
|
||||
return task.status !== 'done';
|
||||
default:
|
||||
return task.status === filterBy;
|
||||
}
|
||||
@@ -114,21 +144,47 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
}
|
||||
});
|
||||
|
||||
// Tasks that are NOT on the calendar (no due date or not scheduled)
|
||||
const unscheduledTasks = filteredAndSortedTasks.filter(task => !task.dueDate);
|
||||
// Tasks that are NOT on the calendar (no due date OR not in visible dates)
|
||||
const unscheduledTasks = filteredAndSortedTasks.filter(task => {
|
||||
if (!task.dueDate) return true;
|
||||
const isVisible = dates.some(date => isSameDay(date, task.dueDate!));
|
||||
return !isVisible;
|
||||
});
|
||||
|
||||
const getTasksForDate = (date: Date) => {
|
||||
return filteredAndSortedTasks.filter(task =>
|
||||
task.dueDate && isSameDay(task.dueDate, date)
|
||||
);
|
||||
return filteredAndSortedTasks.filter(task => {
|
||||
// Handle multi-day tasks
|
||||
if (task.startDate && task.dueDate) {
|
||||
const start = startOfDay(new Date(task.startDate));
|
||||
const end = startOfDay(new Date(task.dueDate));
|
||||
const current = startOfDay(date);
|
||||
return current >= start && current <= end;
|
||||
}
|
||||
|
||||
// Handle single date tasks (dueDate or startDate)
|
||||
if (task.dueDate) {
|
||||
return isSameDay(new Date(task.dueDate), date);
|
||||
}
|
||||
if (task.startDate) {
|
||||
return isSameDay(new Date(task.startDate), date);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const getFilterCount = (filter: FilterOption) => {
|
||||
switch (filter) {
|
||||
case 'overdue':
|
||||
return tasks.filter(isOverdue).length;
|
||||
case 'planned':
|
||||
return tasks.filter(t => !!t.dueDate || !!t.startDate).length;
|
||||
case 'unplanned':
|
||||
return tasks.filter(t => !t.dueDate && !t.startDate).length;
|
||||
case 'all':
|
||||
return tasks.length;
|
||||
case 'open':
|
||||
return tasks.filter(t => t.status !== 'done').length;
|
||||
default:
|
||||
return tasks.filter(task => task.status === filter).length;
|
||||
}
|
||||
@@ -203,23 +259,23 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header and Search Filters - Sticky */}
|
||||
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-3 mb-4">
|
||||
<div ref={containerRef} className="flex flex-col h-[calc(100vh-6rem)] relative">
|
||||
{/* Header and Search Filters - Fixed at Top */}
|
||||
<div className="flex-none pb-3 mb-2 bg-background/95 backdrop-blur z-40 border-b">
|
||||
<div className="space-y-3">
|
||||
{/* Title Bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold" data-testid="text-task-list-title">
|
||||
My Tasks
|
||||
{t('taskList.title')}
|
||||
</h2>
|
||||
<Badge variant="secondary" data-testid="badge-task-count">
|
||||
{filteredAndSortedTasks.length} tasks
|
||||
{t('taskList.taskCount', { count: filteredAndSortedTasks.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tasks..."
|
||||
placeholder={t('taskList.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -230,23 +286,52 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||
<Select value={filterBy} onValueChange={(value: FilterOption) => {
|
||||
setFilterBy(value);
|
||||
console.log('Filter changed to:', value);
|
||||
}}>
|
||||
<SelectTrigger className="flex-1" data-testid="select-filter">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
<SelectTrigger className="flex-1 min-w-0" data-testid="select-filter">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Filter className="w-4 h-4 shrink-0" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All ({getFilterCount('all')})</SelectItem>
|
||||
<SelectItem value="todo">To Do ({getFilterCount('todo')})</SelectItem>
|
||||
<SelectItem value="inProgress">In Progress ({getFilterCount('inProgress')})</SelectItem>
|
||||
<SelectItem value="done">Done ({getFilterCount('done')})</SelectItem>
|
||||
<SelectItem value="overdue">Overdue ({getFilterCount('overdue')})</SelectItem>
|
||||
<SelectItem value="open">{t('taskList.filter.open')} ({getFilterCount('open')})</SelectItem>
|
||||
<SelectItem value="all">{t('taskList.filter.all')} ({getFilterCount('all')})</SelectItem>
|
||||
<SelectItem value="todo">{t('taskList.filter.todo')} ({getFilterCount('todo')})</SelectItem>
|
||||
<SelectItem value="inProgress">{t('taskList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
|
||||
<SelectItem value="done">{t('taskList.filter.done')} ({getFilterCount('done')})</SelectItem>
|
||||
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
|
||||
<SelectItem value="planned">{t('taskList.filter.planned')} ({getFilterCount('planned')})</SelectItem>
|
||||
<SelectItem value="unplanned">{t('taskList.filter.unplanned')} ({getFilterCount('unplanned')})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterByLabel} onValueChange={(value: string) => {
|
||||
setFilterByLabel(value);
|
||||
console.log('Label filter changed to:', value);
|
||||
}}>
|
||||
<SelectTrigger className="flex-1 min-w-0" data-testid="select-label-filter">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Tag className="w-4 h-4 shrink-0" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('taskList.filter.allLabels', 'Alle Labels')}</SelectItem>
|
||||
{labels.map((label) => (
|
||||
<SelectItem key={label.id} value={label.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
<span>{label.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -254,38 +339,38 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
setSortBy(value);
|
||||
console.log('Sort changed to:', value);
|
||||
}}>
|
||||
<SelectTrigger className="flex-1" data-testid="select-sort">
|
||||
<div className="flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
<SelectTrigger className="flex-1 min-w-0" data-testid="select-sort">
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<SortAsc className="w-4 h-4 shrink-0" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dueDate">Due Date</SelectItem>
|
||||
<SelectItem value="priority">Priority</SelectItem>
|
||||
<SelectItem value="title">Title</SelectItem>
|
||||
<SelectItem value="status">Status</SelectItem>
|
||||
<SelectItem value="dueDate">{t('taskList.sort.dueDate')}</SelectItem>
|
||||
<SelectItem value="priority">{t('taskList.sort.priority')}</SelectItem>
|
||||
<SelectItem value="title">{t('taskList.sort.title')}</SelectItem>
|
||||
<SelectItem value="status">{t('taskList.sort.status')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unscheduled Task List */}
|
||||
<div className="space-y-3">
|
||||
{/* Scrollable Task List */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 px-1 pb-4">
|
||||
{unscheduledTasks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground" data-testid="text-no-tasks">
|
||||
{searchQuery || filterBy !== 'all'
|
||||
? 'No unscheduled tasks match your filters'
|
||||
: 'No unscheduled tasks. Drag tasks to the calendar below to schedule them!'
|
||||
{searchQuery || (filterBy !== 'all' && filterBy !== 'open')
|
||||
? t('taskList.noMatchingTasks')
|
||||
: t('taskList.noUnscheduledTasks')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
||||
Unscheduled Tasks - Drag to calendar below to schedule
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2 mt-2">
|
||||
{t('taskList.listHeader')}
|
||||
</div>
|
||||
{unscheduledTasks.map((task) => (
|
||||
<div
|
||||
@@ -293,9 +378,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}
|
||||
@@ -317,6 +401,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>
|
||||
@@ -325,15 +410,15 @@ 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)' }}>
|
||||
<div className="p-3 max-h-[32vh] overflow-y-auto">
|
||||
{/* Calendar Section - Fixed at bottom */}
|
||||
<div className="flex-none bg-background/95 backdrop-blur z-30 border-t mt-auto shadow-up-lg">
|
||||
<div className="p-3">
|
||||
{/* Calendar Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold" data-testid="text-calendar-title">
|
||||
Next {visibleDays} Days
|
||||
{t('taskList.calendarTitle', { count: visibleDays })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -361,7 +446,11 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
{/* Full Width Calendar Grid - Dynamic */}
|
||||
<div className={`grid gap-2`} style={{ gridTemplateColumns: `repeat(${visibleDays}, minmax(0, 1fr))` }}>
|
||||
{dates.map((date, index) => {
|
||||
const dayTasks = getTasksForDate(date);
|
||||
// Sort by priority to ensure high priority items are shown first
|
||||
const dayTasks = getTasksForDate(date).sort((a, b) => {
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 0) - (priorityOrder[a.priority as keyof typeof priorityOrder] || 0);
|
||||
});
|
||||
const isToday = isSameDay(date, new Date());
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||
const isHovered = hoveredDate && isSameDay(date, hoveredDate);
|
||||
@@ -390,15 +479,13 @@ 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>
|
||||
|
||||
@@ -408,53 +495,198 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(task.id, e)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => {
|
||||
// Prevent click when dragging
|
||||
if (draggedTask === task.id) return;
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
console.log('Calendar task clicked:', task.title);
|
||||
}}
|
||||
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<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 ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate text-sm sm:text-xs">
|
||||
{task.title}
|
||||
<ContextMenu key={task.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(task.id, e)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={(e) => {
|
||||
// Prevent click when dragging
|
||||
if (draggedTask === task.id) return;
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
console.log('Calendar task clicked:', task.title);
|
||||
}}
|
||||
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<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 ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate text-sm sm:text-xs pointer-events-none">
|
||||
{task.title}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}
|
||||
data-testid={`mini-context-menu-edit-${task.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.editTask')}
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (task.isTracking) {
|
||||
onStopTimer?.(task.id);
|
||||
} else {
|
||||
onStartTimer?.(task.id);
|
||||
}
|
||||
}}
|
||||
data-testid={`mini-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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const taskToComplete = tasks.find(t => t.id === task.id);
|
||||
if (taskToComplete) {
|
||||
handleTaskComplete(taskToComplete);
|
||||
}
|
||||
}}
|
||||
data-testid={`mini-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();
|
||||
onTaskUpdate?.(task.id, { status: 'inProgress' });
|
||||
}}
|
||||
data-testid={`mini-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();
|
||||
onTaskUpdate?.(task.id, { status: 'todo' });
|
||||
}}
|
||||
data-testid={`mini-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();
|
||||
onTaskUpdate?.(task.id, { status: 'inProgress' });
|
||||
}}
|
||||
data-testid={`mini-context-menu-reopen-${task.id}`}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.reopenTask')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
{onTaskDelete && (
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskDelete(task.id);
|
||||
}}
|
||||
className="text-destructive"
|
||||
data-testid={`mini-context-menu-delete-${task.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('taskCard.deleteTask')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</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>
|
||||
<HoverCard openDelay={0} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge variant="secondary" className="w-full justify-center text-sm sm:text-xs py-1 sm:py-0 cursor-pointer hover:bg-secondary/80">
|
||||
+{dayTasks.length - 2} more
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72 p-0" side="top">
|
||||
<div className="p-3 border-b bg-muted/30">
|
||||
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>{t('calendar.moreItems', { count: dayTasks.length - 2 })}</span>
|
||||
<span className="text-[10px] font-normal opacity-75">{t('calendar.clickToEdit')}</span>
|
||||
</h4>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
|
||||
{dayTasks.slice(2).map(task => {
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-2 border rounded hover:bg-accent cursor-pointer flex items-center justify-between group transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1 mr-2">
|
||||
<div className="text-xs font-medium truncate">{task.title}</div>
|
||||
{taskLabel && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: taskLabel.color }} />
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{taskLabel.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskEdit?.(task);
|
||||
}}>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
|
||||
{dayTasks.length === 0 && (
|
||||
<div className={`text-center text-xs py-2 rounded border border-dashed transition-all ${
|
||||
isHovered && draggedTask
|
||||
? 'border-primary text-primary bg-primary/5'
|
||||
: draggedTask
|
||||
<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' : ''}
|
||||
}`}>
|
||||
{isHovered && draggedTask ? t('calendar.dropHere') : draggedTask ? t('calendar.drop') : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -465,8 +697,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,8 +1,10 @@
|
||||
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(() => {
|
||||
@@ -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"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export default function TimeCompletionModal({ isOpen, onClose, onSave, taskTitle
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md mx-4">
|
||||
<DialogContent className="w-[95vw] max-w-lg sm:max-w-md p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Timer className="w-4 h-4" />
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { User } from '@shared/schema';
|
||||
|
||||
const profileSchema = z.object({
|
||||
email: z.string().email("Invalid email address"),
|
||||
});
|
||||
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
|
||||
interface UpdateProfileModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function UpdateProfileModal({ open, onOpenChange, user }: UpdateProfileModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: ProfileFormValues) => {
|
||||
const res = await apiRequest("PATCH", "/api/user/profile", data);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || "Failed to update profile");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (updatedUser) => {
|
||||
queryClient.setQueryData(['/api/user'], updatedUser);
|
||||
toast({ title: "Profile updated successfully" });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: ProfileFormValues) => {
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update your account information.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import TaskCard from './TaskCard';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import TimeCompletionModal from './TimeCompletionModal';
|
||||
import { addDays, format, isSameDay, 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;
|
||||
onTaskEdit?: (task: Task) => void;
|
||||
onTaskDelete?: (taskId: string) => void;
|
||||
onStartTimer?: (taskId: string) => void;
|
||||
onStopTimer?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
|
||||
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');
|
||||
const [selectedWeekStart, setSelectedWeekStart] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||
const [completionModal, setCompletionModal] = useState<{ isOpen: boolean; task: Task | null }>({
|
||||
isOpen: false,
|
||||
task: null
|
||||
});
|
||||
|
||||
// Fetch labels to get label colors
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels'],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Always show 7 days for the selected week (Monday to Sunday)
|
||||
const weekStart = startOfDay(selectedWeekStart);
|
||||
const weekEnd = endOfDay(endOfWeek(selectedWeekStart, { weekStartsOn: 1 }));
|
||||
const dates = Array.from({ length: 7 }, (_, i) => addDays(selectedWeekStart, i));
|
||||
|
||||
const isOverdue = (task: Task) => {
|
||||
if (!task.dueDate) return false;
|
||||
return task.dueDate < new Date() && task.status !== 'done';
|
||||
};
|
||||
|
||||
const isInCurrentWeek = (task: Task) => {
|
||||
if (!task.dueDate) return false;
|
||||
return task.dueDate >= weekStart && task.dueDate <= weekEnd;
|
||||
};
|
||||
|
||||
const filteredAndSortedTasks = tasks
|
||||
.filter(task => {
|
||||
// Only show tasks in the selected week
|
||||
if (!isInCurrentWeek(task)) return false;
|
||||
|
||||
// Search filter
|
||||
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(task.description?.toLowerCase()?.includes(searchQuery.toLowerCase()) ?? false);
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
// Status filter
|
||||
switch (filterBy) {
|
||||
case 'overdue':
|
||||
return isOverdue(task);
|
||||
case 'all':
|
||||
return true;
|
||||
default:
|
||||
return task.status === filterBy;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'dueDate':
|
||||
if (!a.dueDate && !b.dueDate) return 0;
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
const getFilterCount = (filter: FilterOption) => {
|
||||
const weekTasks = tasks.filter(isInCurrentWeek);
|
||||
switch (filter) {
|
||||
case 'overdue':
|
||||
return weekTasks.filter(isOverdue).length;
|
||||
case 'all':
|
||||
return weekTasks.length;
|
||||
default:
|
||||
return weekTasks.filter(task => task.status === filter).length;
|
||||
}
|
||||
};
|
||||
|
||||
const getTasksForDate = (date: Date) => {
|
||||
return filteredAndSortedTasks.filter(task =>
|
||||
task.dueDate && isSameDay(task.dueDate, date)
|
||||
);
|
||||
};
|
||||
|
||||
const navigateCalendar = (direction: 'prev' | 'next') => {
|
||||
const newWeekStart = addWeeks(selectedWeekStart, direction === 'next' ? 1 : -1);
|
||||
setSelectedWeekStart(newWeekStart);
|
||||
console.log(`Navigating to week starting ${newWeekStart.toLocaleDateString()}`);
|
||||
};
|
||||
|
||||
const goToCurrentWeek = () => {
|
||||
setSelectedWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||
};
|
||||
|
||||
const handleTaskComplete = (task: Task) => {
|
||||
setCompletionModal({ isOpen: true, task });
|
||||
};
|
||||
|
||||
const handleTimeLogged = (timeSpent: number) => {
|
||||
if (completionModal.task) {
|
||||
onTaskUpdate?.(completionModal.task.id, {
|
||||
status: 'done',
|
||||
timeTracked: (completionModal.task.timeTracked || 0) + timeSpent,
|
||||
isTracking: false
|
||||
});
|
||||
}
|
||||
setCompletionModal({ isOpen: false, task: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
{/* Header and Search Filters */}
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold" data-testid="text-week-list-title">
|
||||
{t('weekList.title')}
|
||||
</h2>
|
||||
<Badge variant="secondary" data-testid="badge-week-task-count">
|
||||
{t('weekList.taskCount', { count: filteredAndSortedTasks.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('weekList.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
console.log('Search query:', e.target.value);
|
||||
}}
|
||||
className="pl-10"
|
||||
data-testid="input-search-week-tasks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Select value={filterBy} onValueChange={(value: FilterOption) => {
|
||||
setFilterBy(value);
|
||||
console.log('Filter changed to:', value);
|
||||
}}>
|
||||
<SelectTrigger className="flex-1" data-testid="select-filter-week">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('weekList.filter.all')} ({getFilterCount('all')})</SelectItem>
|
||||
<SelectItem value="todo">{t('weekList.filter.todo')} ({getFilterCount('todo')})</SelectItem>
|
||||
<SelectItem value="inProgress">{t('weekList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
|
||||
<SelectItem value="done">{t('weekList.filter.done')} ({getFilterCount('done')})</SelectItem>
|
||||
<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);
|
||||
}}>
|
||||
<SelectTrigger className="flex-1" data-testid="select-sort-week">
|
||||
<div className="flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dueDate">{t('weekList.sort.dueDate')}</SelectItem>
|
||||
<SelectItem value="priority">{t('weekList.sort.priority')}</SelectItem>
|
||||
<SelectItem value="title">{t('weekList.sort.title')}</SelectItem>
|
||||
<SelectItem value="status">{t('weekList.sort.status')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Week Selector */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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', { locale: dateLocale })} - {format(weekEnd, 'MMM d, yyyy', { locale: dateLocale })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateCalendar('prev')}
|
||||
data-testid="button-prev-week-list"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t('weekList.previous')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToCurrentWeek}
|
||||
data-testid="button-today-week-list"
|
||||
>
|
||||
{t('weekList.today')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateCalendar('next')}
|
||||
data-testid="button-next-week-list"
|
||||
>
|
||||
{t('weekList.next')}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Calendar Bar */}
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(7, minmax(0, 1fr))' }}>
|
||||
{dates.map((date, index) => {
|
||||
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'
|
||||
}`}
|
||||
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', { locale: dateLocale })}
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${isToday ? 'text-primary' : ''}`}>
|
||||
{format(date, 'd', { locale: dateLocale })}
|
||||
</div>
|
||||
{dayTasks.length > 0 && (
|
||||
<Badge variant="secondary" className="mt-1 text-xs">
|
||||
{dayTasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="space-y-3">
|
||||
{filteredAndSortedTasks.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground" data-testid="text-no-week-tasks">
|
||||
{searchQuery || filterBy !== 'all'
|
||||
? t('weekList.noMatchingTasks')
|
||||
: t('weekList.noTasks')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{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}>
|
||||
<TaskCard
|
||||
task={task}
|
||||
onStartTimer={() => onStartTimer?.(task.id)}
|
||||
onStopTimer={() => onStopTimer?.(task.id)}
|
||||
onEdit={() => {
|
||||
onTaskEdit?.(task);
|
||||
console.log(`Edit task ${task.title}`);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onTaskDelete?.(task.id);
|
||||
console.log(`Delete task ${task.title}`);
|
||||
}}
|
||||
onStatusChange={(newStatus) => {
|
||||
if (newStatus === 'done') {
|
||||
handleTaskComplete(task);
|
||||
} else {
|
||||
onTaskUpdate?.(task.id, { status: newStatus });
|
||||
}
|
||||
console.log(`Task ${task.title} status changed to ${newStatus}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time Completion Modal */}
|
||||
<TimeCompletionModal
|
||||
isOpen={completionModal.isOpen}
|
||||
onClose={() => setCompletionModal({ isOpen: false, task: null })}
|
||||
onSave={handleTimeLogged}
|
||||
taskTitle={completionModal.task?.title || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useADHDMode } from './providers/ADHDModeProvider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Brain, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
interface ADHDModeToggleProps {
|
||||
showLabel?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ADHDModeToggle({ showLabel = true, compact = false }: ADHDModeToggleProps) {
|
||||
const { isEnabled, toggle, isLoading } = useADHDMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
relative flex items-center justify-center w-10 h-10 rounded-lg
|
||||
transition-all duration-200
|
||||
${isEnabled
|
||||
? 'bg-primary/20 text-primary border-2 border-primary'
|
||||
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
|
||||
}
|
||||
${isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<Brain className="h-5 w-5" />
|
||||
{isEnabled && (
|
||||
<Sparkles className="absolute -top-1 -right-1 h-3 w-3 text-primary animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{isEnabled ? t('adhd.modeEnabled') : t('adhd.modeDisabled')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-card border">
|
||||
<div className={`
|
||||
flex items-center justify-center w-10 h-10 rounded-full
|
||||
${isEnabled ? 'bg-primary/20' : 'bg-muted'}
|
||||
`}>
|
||||
<Brain className={`h-5 w-5 ${isEnabled ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{showLabel && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{t('adhd.mode')}</span>
|
||||
{isEnabled && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary">
|
||||
{t('adhd.active')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('adhd.modeDescription')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={toggle}
|
||||
disabled={isLoading}
|
||||
aria-label={t('adhd.toggleMode')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Loader2, Sparkles, Clock, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
import type { Task } from '@shared/schema';
|
||||
|
||||
interface SubtaskSuggestion {
|
||||
title: string;
|
||||
estimatedMinutes: number;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface BreakdownResponse {
|
||||
subtasks: SubtaskSuggestion[];
|
||||
totalEstimatedMinutes: number;
|
||||
encouragement: string;
|
||||
}
|
||||
|
||||
interface TaskBreakdownModalProps {
|
||||
task: Task;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TaskBreakdownModal({ task, open, onOpenChange }: TaskBreakdownModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useADHDMode();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [breakdown, setBreakdown] = useState<BreakdownResponse | null>(null);
|
||||
const [selectedSubtasks, setSelectedSubtasks] = useState<Set<number>>(new Set());
|
||||
|
||||
// Mutation to get AI breakdown
|
||||
const breakdownMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch('/api/ai/breakdown-task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
taskId: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
estimatedDuration: task.estimatedDuration,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to break down task');
|
||||
return res.json() as Promise<BreakdownResponse>;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setBreakdown(data);
|
||||
// Select all by default
|
||||
setSelectedSubtasks(new Set(data.subtasks.map((_, i) => i)));
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation to create subtasks
|
||||
const createSubtasksMutation = useMutation({
|
||||
mutationFn: async (subtasks: SubtaskSuggestion[]) => {
|
||||
const promises = subtasks.map((subtask, index) =>
|
||||
fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
title: subtask.title,
|
||||
parentTaskId: task.id,
|
||||
estimatedDuration: subtask.estimatedMinutes,
|
||||
priority: task.priority,
|
||||
status: 'todo',
|
||||
}),
|
||||
})
|
||||
);
|
||||
return Promise.all(promises);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleGenerate = () => {
|
||||
breakdownMutation.mutate();
|
||||
};
|
||||
|
||||
const handleToggleSubtask = (index: number) => {
|
||||
const newSet = new Set(selectedSubtasks);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
setSelectedSubtasks(newSet);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!breakdown) return;
|
||||
const selected = breakdown.subtasks.filter((_, i) => selectedSubtasks.has(i));
|
||||
createSubtasksMutation.mutate(selected);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
{t('adhd.taskBreakdown.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('adhd.taskBreakdown.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="bg-muted/50 rounded-lg p-4 mb-4">
|
||||
<h4 className="font-medium mb-1 truncate">{task.title}</h4>
|
||||
{task.description && (
|
||||
<p className="text-sm text-muted-foreground">{task.description}</p>
|
||||
)}
|
||||
{task.estimatedDuration && (
|
||||
<div className="flex items-center gap-1 mt-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{task.estimatedDuration} min {t('adhd.taskBreakdown.estimated')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate button or results */}
|
||||
{!breakdown ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{t('adhd.taskBreakdown.prompt')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={breakdownMutation.isPending}
|
||||
size="lg"
|
||||
className={settings.largerTargets ? 'h-14 px-8 text-lg' : ''}
|
||||
>
|
||||
{breakdownMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
{t('adhd.taskBreakdown.analyzing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-5 w-5 mr-2" />
|
||||
{t('adhd.taskBreakdown.breakItDown')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Encouragement */}
|
||||
{breakdown.encouragement && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-primary/10 text-primary rounded-lg p-3 text-sm"
|
||||
>
|
||||
{breakdown.encouragement}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Subtask list */}
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
<AnimatePresence>
|
||||
{breakdown.subtasks.map((subtask, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className={`
|
||||
flex items-start gap-3 p-3 rounded-lg border
|
||||
${selectedSubtasks.has(index) ? 'bg-primary/5 border-primary/30' : 'bg-muted/30 border-transparent'}
|
||||
transition-colors duration-200
|
||||
`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedSubtasks.has(index)}
|
||||
onCheckedChange={() => handleToggleSubtask(index)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{subtask.title}</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>~{subtask.estimatedMinutes} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Total time */}
|
||||
<div className="flex items-center justify-between text-sm bg-muted/50 rounded-lg p-3">
|
||||
<span className="text-muted-foreground">{t('adhd.taskBreakdown.totalTime')}</span>
|
||||
<span className="font-medium">
|
||||
{breakdown.subtasks
|
||||
.filter((_, i) => selectedSubtasks.has(i))
|
||||
.reduce((sum, s) => sum + s.estimatedMinutes, 0)} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGenerate}
|
||||
disabled={breakdownMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('adhd.taskBreakdown.regenerate')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={createSubtasksMutation.isPending || selectedSubtasks.size === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
{createSubtasksMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('adhd.taskBreakdown.createSubtasks', { count: selectedSubtasks.size })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{breakdownMutation.isError && (
|
||||
<div className="flex items-center gap-2 text-destructive text-sm mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{t('adhd.taskBreakdown.error')}</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useBreakReminder } from '../providers/BreakReminderProvider';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, Clock, Droplet, PersonStanding, Eye, Apple, Wind, Timer } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const breakSuggestions = [
|
||||
{ id: 'stretch', icon: PersonStanding, duration: 2 },
|
||||
{ id: 'water', icon: Droplet, duration: 1 },
|
||||
{ id: 'walk', icon: Timer, duration: 5 },
|
||||
{ id: 'eyes', icon: Eye, duration: 1 },
|
||||
{ id: 'snack', icon: Apple, duration: 3 },
|
||||
{ id: 'breathe', icon: Wind, duration: 2 },
|
||||
];
|
||||
|
||||
export function BreakReminderOverlay() {
|
||||
const { t } = useTranslation();
|
||||
const { isReminderVisible, minutesSinceBreak, logBreak, snooze, dismiss } = useBreakReminder();
|
||||
const { isEnabled, settings } = useADHDMode();
|
||||
|
||||
if (!isEnabled || !isReminderVisible) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ x: '100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: '100%', opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className={`
|
||||
fixed right-4 top-1/2 -translate-y-1/2 z-50
|
||||
w-80 max-w-[calc(100vw-2rem)]
|
||||
bg-gradient-to-br from-blue-50 to-purple-50
|
||||
dark:from-blue-950/90 dark:to-purple-950/90
|
||||
rounded-2xl shadow-2xl border border-border/50
|
||||
backdrop-blur-sm
|
||||
${settings.reducedAnimations ? '' : 'animate-pulse-soft'}
|
||||
`}
|
||||
>
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="absolute top-3 right-3 p-1 rounded-full hover:bg-white/50 dark:hover:bg-black/20 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-primary/20">
|
||||
<Clock className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{t('adhd.breakReminder.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adhd.breakReminder.workingFor', { minutes: minutesSinceBreak })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-sm text-foreground/80 mb-4">
|
||||
{t('adhd.breakReminder.message')}
|
||||
</p>
|
||||
|
||||
{/* Break suggestions */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
{breakSuggestions.map((suggestion) => {
|
||||
const Icon = suggestion.icon;
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
onClick={() => logBreak(suggestion.id, suggestion.duration)}
|
||||
className={`
|
||||
flex flex-col items-center gap-1 p-3 rounded-lg
|
||||
bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40
|
||||
transition-all duration-200
|
||||
${settings.largerTargets ? 'min-h-[80px]' : 'min-h-[60px]'}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
<span className="text-xs font-medium">
|
||||
{t(`adhd.breakReminder.types.${suggestion.id}`)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{suggestion.duration} min
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Snooze options */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => snooze(5)}
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{t('adhd.breakReminder.snooze5')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => snooze(15)}
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{t('adhd.breakReminder.snooze15')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Gentle message */}
|
||||
<p className="text-xs text-center text-muted-foreground mt-4 italic">
|
||||
{t('adhd.breakReminder.gentle')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
import { Sparkles, Heart, Star, Trophy, Sun } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type EncouragementType = 'taskComplete' | 'returning' | 'struggling' | 'streakBroken' | 'milestone' | 'quickWin';
|
||||
|
||||
interface EncouragementToastProps {
|
||||
type: EncouragementType;
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
customMessage?: string;
|
||||
xpEarned?: number;
|
||||
}
|
||||
|
||||
const icons: Record<EncouragementType, React.ComponentType<{ className?: string }>> = {
|
||||
taskComplete: Star,
|
||||
returning: Sun,
|
||||
struggling: Heart,
|
||||
streakBroken: Heart,
|
||||
milestone: Trophy,
|
||||
quickWin: Sparkles,
|
||||
};
|
||||
|
||||
const colors: Record<EncouragementType, string> = {
|
||||
taskComplete: 'from-green-500/20 to-emerald-500/20 border-green-500/30',
|
||||
returning: 'from-blue-500/20 to-cyan-500/20 border-blue-500/30',
|
||||
struggling: 'from-purple-500/20 to-pink-500/20 border-purple-500/30',
|
||||
streakBroken: 'from-orange-500/20 to-amber-500/20 border-orange-500/30',
|
||||
milestone: 'from-yellow-500/20 to-amber-500/20 border-yellow-500/30',
|
||||
quickWin: 'from-primary/20 to-primary/10 border-primary/30',
|
||||
};
|
||||
|
||||
export function EncouragementToast({
|
||||
type,
|
||||
visible,
|
||||
onDismiss,
|
||||
customMessage,
|
||||
xpEarned,
|
||||
}: EncouragementToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled, settings } = useADHDMode();
|
||||
|
||||
const Icon = icons[type];
|
||||
const colorClass = colors[type];
|
||||
|
||||
// Auto-dismiss after 4 seconds
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(onDismiss, 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visible, onDismiss]);
|
||||
|
||||
// Don't show if ADHD mode is disabled or messaging is minimal
|
||||
if (!isEnabled || settings.positiveMessagingLevel === 'minimal') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = t(`adhd.encouragements.${type}`, { returnObjects: true }) as string[];
|
||||
const message = customMessage || (Array.isArray(messages) ? messages[Math.floor(Math.random() * messages.length)] : messages);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
damping: 20,
|
||||
stiffness: 300,
|
||||
duration: settings.reducedAnimations ? 0 : undefined,
|
||||
}}
|
||||
onClick={onDismiss}
|
||||
className={`
|
||||
fixed bottom-6 left-1/2 -translate-x-1/2 z-50
|
||||
bg-gradient-to-r ${colorClass}
|
||||
backdrop-blur-md rounded-2xl border shadow-lg
|
||||
p-4 cursor-pointer
|
||||
max-w-md w-[calc(100vw-2rem)]
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-background/50">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">{message}</p>
|
||||
{xpEarned && xpEarned > 0 && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
+{xpEarned} XP
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative sparkles for high messaging level */}
|
||||
{settings.positiveMessagingLevel === 'high' && !settings.reducedAnimations && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="absolute -top-2 -right-2"
|
||||
>
|
||||
<Sparkles className="h-5 w-5 text-yellow-500" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="absolute top-1/2 -left-2"
|
||||
>
|
||||
<Star className="h-4 w-4 text-primary/50" />
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook to manage encouragement state
|
||||
export function useEncouragement() {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [type, setType] = React.useState<EncouragementType>('taskComplete');
|
||||
const [message, setMessage] = React.useState<string | undefined>();
|
||||
const [xp, setXp] = React.useState<number | undefined>();
|
||||
|
||||
const show = React.useCallback((
|
||||
encouragementType: EncouragementType,
|
||||
customMessage?: string,
|
||||
xpEarned?: number
|
||||
) => {
|
||||
setType(encouragementType);
|
||||
setMessage(customMessage);
|
||||
setXp(xpEarned);
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
const hide = React.useCallback(() => {
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
return { visible, type, message, xp, show, hide };
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Play, Pause, CheckCircle2, ArrowRight, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
import type { Task } from '@shared/schema';
|
||||
|
||||
interface FiveMinuteStarterProps {
|
||||
task: Task;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
type Phase = 'ready' | 'running' | 'done';
|
||||
|
||||
export function FiveMinuteStarter({
|
||||
task,
|
||||
onClose,
|
||||
onComplete,
|
||||
onContinue,
|
||||
}: FiveMinuteStarterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useADHDMode();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('ready');
|
||||
const [timeLeft, setTimeLeft] = useState(5 * 60); // 5 minutes in seconds
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const progress = ((5 * 60 - timeLeft) / (5 * 60)) * 100;
|
||||
|
||||
// Timer logic
|
||||
useEffect(() => {
|
||||
if (!isRunning || timeLeft <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
setIsRunning(false);
|
||||
setPhase('done');
|
||||
// Play completion sound
|
||||
const audio = new Audio('/sounds/complete.mp3');
|
||||
audio.volume = 0.3;
|
||||
audio.play().catch(() => {});
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning, timeLeft]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
setPhase('running');
|
||||
setIsRunning(true);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setIsRunning(!isRunning);
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
onComplete();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
// Reset for another 5 minutes
|
||||
setTimeLeft(5 * 60);
|
||||
setPhase('running');
|
||||
setIsRunning(true);
|
||||
onContinue?.();
|
||||
};
|
||||
|
||||
const handleTakeBreak = () => {
|
||||
// Just close without completing
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<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 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"
|
||||
>
|
||||
<div className="relative w-full max-w-md bg-card rounded-2xl border shadow-2xl p-6">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 rounded-full hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === 'ready' && (
|
||||
<motion.div
|
||||
key="ready"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Play className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold mb-2">
|
||||
{t('adhd.fiveMin.title')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-2 truncate max-w-full">
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{t('adhd.fiveMin.prompt')}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleStart}
|
||||
className={`w-full ${settings.largerTargets ? 'h-14 text-lg' : 'h-12'}`}
|
||||
>
|
||||
<Play className="h-5 w-5 mr-2" />
|
||||
{t('adhd.fiveMin.start')}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-4 italic">
|
||||
{t('adhd.fiveMin.noObligation')}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'running' && (
|
||||
<motion.div
|
||||
key="running"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-center"
|
||||
>
|
||||
{/* Visual progress ring */}
|
||||
<div className="relative w-48 h-48 mx-auto mb-6">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="96"
|
||||
cy="96"
|
||||
r="88"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-muted"
|
||||
/>
|
||||
<motion.circle
|
||||
cx="96"
|
||||
cy="96"
|
||||
r="88"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-primary"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={553}
|
||||
animate={{ strokeDashoffset: 553 - (553 * progress) / 100 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-4xl font-mono font-bold">
|
||||
{formatTime(timeLeft)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('adhd.fiveMin.remaining')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="font-medium mb-4 truncate px-4">
|
||||
{task.title}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePause}
|
||||
className="flex-1"
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
{t('adhd.fiveMin.pause')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{t('adhd.fiveMin.resume')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleDone} className="flex-1">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
{t('adhd.fiveMin.done')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
{t('adhd.fiveMin.encouragement')}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<motion.div
|
||||
key="done"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/20 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-500" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold mb-2">
|
||||
{t('adhd.fiveMin.congrats')}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t('adhd.fiveMin.fiveMinDone')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleContinue}
|
||||
className={`w-full ${settings.largerTargets ? 'h-14' : 'h-12'}`}
|
||||
>
|
||||
<ArrowRight className="h-5 w-5 mr-2" />
|
||||
{t('adhd.fiveMin.continueWorking')}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDone}
|
||||
className="flex-1"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
{t('adhd.fiveMin.finished')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleTakeBreak}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('adhd.fiveMin.takeBreak')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-primary mt-4 font-medium">
|
||||
+10 XP {t('adhd.fiveMin.earned')}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Clock, ListTodo, Coffee, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
import type { Task } from '@shared/schema';
|
||||
|
||||
interface HyperfocusGuardProps {
|
||||
activeTaskId?: string;
|
||||
onTaskStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function HyperfocusGuard({ activeTaskId, onTaskStart }: HyperfocusGuardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled, settings } = useADHDMode();
|
||||
|
||||
const [focusStartTime, setFocusStartTime] = useState<Date | null>(null);
|
||||
const [minutesOnTask, setMinutesOnTask] = useState(0);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
// Track when focus starts
|
||||
useEffect(() => {
|
||||
if (activeTaskId && !focusStartTime) {
|
||||
setFocusStartTime(new Date());
|
||||
setDismissed(false);
|
||||
} else if (!activeTaskId) {
|
||||
setFocusStartTime(null);
|
||||
setMinutesOnTask(0);
|
||||
setShowWarning(false);
|
||||
setDismissed(false);
|
||||
}
|
||||
}, [activeTaskId, focusStartTime]);
|
||||
|
||||
// Timer to track focus duration
|
||||
useEffect(() => {
|
||||
if (!isEnabled || !settings.hyperfocusProtection || !focusStartTime || dismissed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - focusStartTime.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
setMinutesOnTask(diffMins);
|
||||
|
||||
// Show warning at threshold
|
||||
if (diffMins >= settings.hyperfocusMaxMinutes && !showWarning) {
|
||||
setShowWarning(true);
|
||||
}
|
||||
}, 30000); // Check every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isEnabled, settings, focusStartTime, showWarning, dismissed]);
|
||||
|
||||
// Count pending tasks
|
||||
const pendingTasks = tasks.filter(
|
||||
(t) => t.status !== 'done' && t.id !== activeTaskId
|
||||
);
|
||||
const urgentTasks = pendingTasks.filter(
|
||||
(t) => t.priority === 'high' || (t.dueDate && new Date(t.dueDate) < new Date(Date.now() + 24 * 60 * 60 * 1000))
|
||||
);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowWarning(false);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const handleTakeBreak = () => {
|
||||
setShowWarning(false);
|
||||
// Could trigger break reminder or navigate to break
|
||||
};
|
||||
|
||||
const handleSwitchTask = (taskId: string) => {
|
||||
setShowWarning(false);
|
||||
setFocusStartTime(null);
|
||||
onTaskStart?.(taskId);
|
||||
};
|
||||
|
||||
if (!isEnabled || !settings.hyperfocusProtection || !showWarning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 50 }}
|
||||
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-full max-w-md px-4"
|
||||
>
|
||||
<div className="bg-amber-50 dark:bg-amber-950/90 border-2 border-amber-500/50 rounded-2xl shadow-xl p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-amber-500/20">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-amber-900 dark:text-amber-100">
|
||||
{t('adhd.hyperfocus.title')}
|
||||
</h4>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
{t('adhd.hyperfocus.message', { minutes: minutesOnTask })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-1 rounded-full hover:bg-amber-500/20 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex gap-4 mb-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{minutesOnTask} {t('adhd.hyperfocus.minutes')}</span>
|
||||
</div>
|
||||
{urgentTasks.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||
<ListTodo className="h-4 w-4" />
|
||||
<span>{urgentTasks.length} {t('adhd.hyperfocus.urgentTasks')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Urgent tasks preview */}
|
||||
{urgentTasks.length > 0 && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<p className="text-xs font-medium text-amber-800 dark:text-amber-200 mb-2">
|
||||
{t('adhd.hyperfocus.waitingTasks')}:
|
||||
</p>
|
||||
{urgentTasks.slice(0, 3).map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={() => handleSwitchTask(task.id)}
|
||||
className={`
|
||||
w-full text-left p-2 rounded-lg
|
||||
bg-amber-100 dark:bg-amber-900/50
|
||||
hover:bg-amber-200 dark:hover:bg-amber-900
|
||||
transition-colors text-sm
|
||||
text-amber-900 dark:text-amber-100
|
||||
truncate
|
||||
`}
|
||||
>
|
||||
{task.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTakeBreak}
|
||||
className="flex-1 border-amber-500/50 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50"
|
||||
>
|
||||
<Coffee className="h-4 w-4 mr-2" />
|
||||
{t('adhd.hyperfocus.takeBreak')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 bg-amber-500 hover:bg-amber-600 text-white"
|
||||
>
|
||||
{t('adhd.hyperfocus.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Gentle reminder */}
|
||||
<p className="text-xs text-center text-amber-600 dark:text-amber-400 mt-3 italic">
|
||||
{t('adhd.hyperfocus.gentle')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Zap, Clock, Star, CheckCircle2, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
import { useEncouragement, EncouragementToast } from '../feedback/EncouragementToast';
|
||||
import type { Task } from '@shared/schema';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
interface QuickWinListProps {
|
||||
maxItems?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function calculateQuickWinScore(task: Task, threshold: number): number {
|
||||
let score = 100;
|
||||
|
||||
// Shorter tasks = higher score
|
||||
if (task.estimatedDuration) {
|
||||
score += Math.max(0, (threshold - task.estimatedDuration) * 10);
|
||||
} else {
|
||||
// No estimate - assume quick
|
||||
score += 30;
|
||||
}
|
||||
|
||||
// Lower priority = quicker to complete (less mental load)
|
||||
if (task.priority === 'low') score += 20;
|
||||
if (task.priority === 'medium') score += 10;
|
||||
|
||||
// Today's due date = bonus
|
||||
if (task.dueDate) {
|
||||
const today = new Date();
|
||||
const dueDate = new Date(task.dueDate);
|
||||
if (
|
||||
dueDate.getDate() === today.getDate() &&
|
||||
dueDate.getMonth() === today.getMonth() &&
|
||||
dueDate.getFullYear() === today.getFullYear()
|
||||
) {
|
||||
score += 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Already in progress = bonus
|
||||
if (task.status === 'inProgress') score += 40;
|
||||
|
||||
// Low energy level tasks are good quick wins
|
||||
if (task.energyLevel === 'low') score += 25;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export function QuickWinList({ maxItems = 5, compact = false }: QuickWinListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useADHDMode();
|
||||
const queryClient = useQueryClient();
|
||||
const encouragement = useEncouragement();
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
const quickWins = useMemo(() => {
|
||||
const threshold = settings.quickWinThreshold || 10;
|
||||
|
||||
return tasks
|
||||
.filter((task) => {
|
||||
// Only pending or in-progress tasks
|
||||
if (task.status === 'done') return false;
|
||||
// Only tasks without subtasks (leaf tasks)
|
||||
if (tasks.some((t) => t.parentTaskId === task.id)) return false;
|
||||
// Filter by duration threshold
|
||||
if (task.estimatedDuration && task.estimatedDuration > threshold * 1.5) return false;
|
||||
return true;
|
||||
})
|
||||
.map((task) => ({
|
||||
...task,
|
||||
quickWinScore: calculateQuickWinScore(task, threshold),
|
||||
}))
|
||||
.sort((a, b) => b.quickWinScore - a.quickWinScore)
|
||||
.slice(0, maxItems);
|
||||
}, [tasks, maxItems, settings.quickWinThreshold]);
|
||||
|
||||
// Mutation to complete task
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status: 'done' }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to complete task');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
||||
|
||||
// Celebrate!
|
||||
confetti({
|
||||
particleCount: 50,
|
||||
spread: 60,
|
||||
origin: { y: 0.8 },
|
||||
colors: ['#10b981', '#3b82f6', '#8b5cf6'],
|
||||
});
|
||||
|
||||
encouragement.show('quickWin', undefined, 30);
|
||||
},
|
||||
});
|
||||
|
||||
const handleComplete = (taskId: string) => {
|
||||
completeMutation.mutate(taskId);
|
||||
};
|
||||
|
||||
if (quickWins.length === 0) {
|
||||
return (
|
||||
<Card className={compact ? 'border-0 shadow-none' : ''}>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Sparkles className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{t('adhd.quickWins.noTasks')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={compact ? 'border-0 shadow-none' : ''}>
|
||||
{!compact && (
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Zap className="h-5 w-5 text-yellow-500" />
|
||||
{t('adhd.quickWins.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('adhd.quickWins.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={compact ? 'p-0' : ''}>
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{quickWins.map((task, index) => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20, height: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className={`
|
||||
flex items-center gap-3 p-3 rounded-lg
|
||||
bg-gradient-to-r from-yellow-500/5 to-orange-500/5
|
||||
border border-yellow-500/20
|
||||
hover:border-yellow-500/40 transition-colors
|
||||
${settings.largerTargets ? 'min-h-[72px]' : ''}
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleComplete(task.id)}
|
||||
disabled={completeMutation.isPending}
|
||||
className={`
|
||||
shrink-0 rounded-full
|
||||
hover:bg-green-500/20 hover:text-green-500
|
||||
${settings.largerTargets ? 'h-12 w-12' : 'h-9 w-9'}
|
||||
`}
|
||||
>
|
||||
<CheckCircle2 className={settings.largerTargets ? 'h-6 w-6' : 'h-5 w-5'} />
|
||||
</Button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{task.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{task.estimatedDuration && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{task.estimatedDuration} min
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
+30 XP
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<EncouragementToast
|
||||
type={encouragement.type}
|
||||
visible={encouragement.visible}
|
||||
onDismiss={encouragement.hide}
|
||||
customMessage={encouragement.message}
|
||||
xpEarned={encouragement.xp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Play,
|
||||
SkipForward,
|
||||
Focus,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
import { VisualTimer } from '../timer/VisualTimer';
|
||||
import { FiveMinuteStarter } from './FiveMinuteStarter';
|
||||
import { useEncouragement, EncouragementToast } from '../feedback/EncouragementToast';
|
||||
import type { Task } from '@shared/schema';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
interface SingleTaskViewProps {
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export function SingleTaskView({ onExit }: SingleTaskViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useADHDMode();
|
||||
const queryClient = useQueryClient();
|
||||
const encouragement = useEncouragement();
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showTimer, setShowTimer] = useState(false);
|
||||
const [showFiveMin, setShowFiveMin] = useState(false);
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
// Filter to actionable tasks (not done, no subtasks)
|
||||
const actionableTasks = useMemo(() => {
|
||||
return tasks.filter((task) => {
|
||||
if (task.status === 'done') return false;
|
||||
// Exclude parent tasks (tasks with subtasks)
|
||||
if (tasks.some((t) => t.parentTaskId === task.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
const currentTask = actionableTasks[currentIndex];
|
||||
const totalTasks = actionableTasks.length;
|
||||
|
||||
// Complete task mutation
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
const res = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ status: 'done' }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to complete task');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
||||
|
||||
confetti({
|
||||
particleCount: 100,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
});
|
||||
|
||||
encouragement.show('taskComplete', undefined, 50);
|
||||
|
||||
// Move to next task if available
|
||||
if (currentIndex >= actionableTasks.length - 1) {
|
||||
setCurrentIndex(Math.max(0, actionableTasks.length - 2));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleComplete = () => {
|
||||
if (currentTask) {
|
||||
completeMutation.mutate(currentTask.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex < totalTasks - 1) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
handleNext();
|
||||
};
|
||||
|
||||
if (totalTasks === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8 text-center">
|
||||
<Sparkles className="h-16 w-16 text-primary mb-6" />
|
||||
<h2 className="text-2xl font-bold mb-2">{t('adhd.singleTask.allDone')}</h2>
|
||||
<p className="text-muted-foreground mb-6">{t('adhd.singleTask.allDoneDesc')}</p>
|
||||
{onExit && (
|
||||
<Button onClick={onExit} size="lg">
|
||||
{t('adhd.singleTask.exit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] p-6">
|
||||
{/* Progress indicator */}
|
||||
<div className="w-full max-w-md mb-8">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>{t('adhd.singleTask.task')} {currentIndex + 1} / {totalTasks}</span>
|
||||
<span>{Math.round(((totalTasks - actionableTasks.length + (tasks.length - actionableTasks.length)) / tasks.length) * 100)}% {t('adhd.singleTask.complete')}</span>
|
||||
</div>
|
||||
<Progress value={((currentIndex + 1) / totalTasks) * 100} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Timer (when active) */}
|
||||
<AnimatePresence>
|
||||
{showTimer && currentTask?.estimatedDuration && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<VisualTimer
|
||||
duration={currentTask.estimatedDuration * 60}
|
||||
onComplete={() => setShowTimer(false)}
|
||||
taskTitle={currentTask.title}
|
||||
size="medium"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 5-Minute Starter */}
|
||||
<AnimatePresence>
|
||||
{showFiveMin && currentTask && (
|
||||
<FiveMinuteStarter
|
||||
task={currentTask}
|
||||
onClose={() => setShowFiveMin(false)}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Task Card */}
|
||||
{!showTimer && !showFiveMin && (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentTask?.id}
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: settings.reducedAnimations ? 0 : 0.3 }}
|
||||
className="w-full max-w-lg"
|
||||
>
|
||||
<div className="bg-card rounded-2xl border shadow-lg p-8">
|
||||
{/* Priority badge */}
|
||||
{currentTask && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Badge
|
||||
variant={
|
||||
currentTask.priority === 'high'
|
||||
? 'destructive'
|
||||
: currentTask.priority === 'low'
|
||||
? 'secondary'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{t(`tasks.priority.${currentTask.priority}`)}
|
||||
</Badge>
|
||||
{currentTask.estimatedDuration && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{currentTask.estimatedDuration} min
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task title */}
|
||||
<h2 className="text-2xl md:text-3xl font-bold mb-4 text-center">
|
||||
{currentTask?.title}
|
||||
</h2>
|
||||
|
||||
{/* Task description */}
|
||||
{currentTask?.description && (
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
{currentTask.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleComplete}
|
||||
disabled={completeMutation.isPending}
|
||||
className={`w-full ${settings.largerTargets ? 'h-14 text-lg' : 'h-12'}`}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5 mr-2" />
|
||||
{t('adhd.singleTask.markComplete')}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFiveMin(true)}
|
||||
className={`flex-1 ${settings.largerTargets ? 'h-12' : 'h-10'}`}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{t('adhd.singleTask.fiveMin')}
|
||||
</Button>
|
||||
|
||||
{currentTask?.estimatedDuration && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowTimer(true)}
|
||||
className={`flex-1 ${settings.largerTargets ? 'h-12' : 'h-10'}`}
|
||||
>
|
||||
<Focus className="h-4 w-4 mr-2" />
|
||||
{t('adhd.singleTask.startTimer')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSkip}
|
||||
disabled={currentIndex >= totalTasks - 1}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<SkipForward className="h-4 w-4 mr-2" />
|
||||
{t('adhd.singleTask.skip')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{/* Navigation dots */}
|
||||
<div className="flex items-center gap-4 mt-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handlePrev}
|
||||
disabled={currentIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
{actionableTasks.slice(0, 7).map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
className={`
|
||||
w-2.5 h-2.5 rounded-full transition-all
|
||||
${index === currentIndex ? 'bg-primary w-6' : 'bg-muted hover:bg-muted-foreground/50'}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
{totalTasks > 7 && (
|
||||
<span className="text-xs text-muted-foreground ml-1">+{totalTasks - 7}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex >= totalTasks - 1}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Exit button */}
|
||||
{onExit && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onExit}
|
||||
className="mt-6 text-muted-foreground"
|
||||
>
|
||||
{t('adhd.singleTask.exit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EncouragementToast
|
||||
type={encouragement.type}
|
||||
visible={encouragement.visible}
|
||||
onDismiss={encouragement.hide}
|
||||
customMessage={encouragement.message}
|
||||
xpEarned={encouragement.xp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Providers
|
||||
export { ADHDModeProvider, useADHDMode, defaultADHDSettings } from './providers/ADHDModeProvider';
|
||||
export { BreakReminderProvider, useBreakReminder } from './providers/BreakReminderProvider';
|
||||
|
||||
// Toggle
|
||||
export { ADHDModeToggle } from './ADHDModeToggle';
|
||||
|
||||
// Timer Components
|
||||
export { VisualTimer } from './timer/VisualTimer';
|
||||
|
||||
// Focus Components
|
||||
export { QuickWinList } from './focus/QuickWinList';
|
||||
export { SingleTaskView } from './focus/SingleTaskView';
|
||||
export { FiveMinuteStarter } from './focus/FiveMinuteStarter';
|
||||
export { HyperfocusGuard } from './focus/HyperfocusGuard';
|
||||
|
||||
// AI Components
|
||||
export { TaskBreakdownModal } from './ai/TaskBreakdownModal';
|
||||
|
||||
// Feedback Components
|
||||
export { BreakReminderOverlay } from './feedback/BreakReminderOverlay';
|
||||
export { EncouragementToast, useEncouragement } from './feedback/EncouragementToast';
|
||||
|
||||
// Social Components
|
||||
export { BodyDoublingLobby } from './social/BodyDoublingLobby';
|
||||
|
||||
// Settings Components
|
||||
export { ADHDSettingsPanel } from './settings/ADHDSettingsPanel';
|
||||
export { EnergyCheckIn } from './settings/EnergyCheckIn';
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ADHDSettings } from '@shared/schema';
|
||||
|
||||
const defaultADHDSettings: ADHDSettings = {
|
||||
reducedAnimations: false,
|
||||
largerTargets: true,
|
||||
breakReminderInterval: 45,
|
||||
singleTaskModeDefault: false,
|
||||
positiveMessagingLevel: 'normal',
|
||||
hyperfocusProtection: true,
|
||||
hyperfocusMaxMinutes: 90,
|
||||
visualTimerEnabled: true,
|
||||
quickWinThreshold: 10,
|
||||
energyCheckInsEnabled: true,
|
||||
};
|
||||
|
||||
interface ADHDModeContextType {
|
||||
isEnabled: boolean;
|
||||
settings: ADHDSettings;
|
||||
toggle: () => void;
|
||||
updateSettings: (settings: Partial<ADHDSettings>) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ADHDModeContext = createContext<ADHDModeContextType | undefined>(undefined);
|
||||
|
||||
export function ADHDModeProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch user data to get ADHD settings
|
||||
const { data: user, isLoading } = useQuery<{
|
||||
adhdMode: boolean;
|
||||
adhdSettings: ADHDSettings;
|
||||
}>({
|
||||
queryKey: ['/api/user'],
|
||||
});
|
||||
|
||||
const [localEnabled, setLocalEnabled] = useState(false);
|
||||
const [localSettings, setLocalSettings] = useState<ADHDSettings>(defaultADHDSettings);
|
||||
|
||||
// Sync with server data
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setLocalEnabled(user.adhdMode ?? false);
|
||||
setLocalSettings(user.adhdSettings ?? defaultADHDSettings);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Apply ADHD mode class to document
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (localEnabled) {
|
||||
root.classList.add('adhd-mode');
|
||||
if (localSettings.reducedAnimations) {
|
||||
root.classList.add('reduced-motion');
|
||||
} else {
|
||||
root.classList.remove('reduced-motion');
|
||||
}
|
||||
if (localSettings.largerTargets) {
|
||||
root.classList.add('larger-targets');
|
||||
} else {
|
||||
root.classList.remove('larger-targets');
|
||||
}
|
||||
} else {
|
||||
root.classList.remove('adhd-mode', 'reduced-motion', 'larger-targets');
|
||||
}
|
||||
}, [localEnabled, localSettings]);
|
||||
|
||||
// Mutation to update ADHD settings
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: { adhdMode: boolean; adhdSettings: ADHDSettings }) => {
|
||||
const res = await fetch('/api/user/adhd-settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update ADHD settings');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const newEnabled = !localEnabled;
|
||||
setLocalEnabled(newEnabled);
|
||||
updateMutation.mutate({ adhdMode: newEnabled, adhdSettings: localSettings });
|
||||
}, [localEnabled, localSettings, updateMutation]);
|
||||
|
||||
const updateSettings = useCallback((newSettings: Partial<ADHDSettings>) => {
|
||||
const merged = { ...localSettings, ...newSettings };
|
||||
setLocalSettings(merged);
|
||||
updateMutation.mutate({ adhdMode: localEnabled, adhdSettings: merged });
|
||||
}, [localEnabled, localSettings, updateMutation]);
|
||||
|
||||
return (
|
||||
<ADHDModeContext.Provider
|
||||
value={{
|
||||
isEnabled: localEnabled,
|
||||
settings: localSettings,
|
||||
toggle,
|
||||
updateSettings,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ADHDModeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useADHDMode() {
|
||||
const context = useContext(ADHDModeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useADHDMode must be used within an ADHDModeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { defaultADHDSettings };
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
|
||||
import { useADHDMode } from './ADHDModeProvider';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface BreakReminderContextType {
|
||||
isReminderVisible: boolean;
|
||||
minutesSinceBreak: number;
|
||||
logBreak: (breakType: string, durationMinutes?: number) => void;
|
||||
snooze: (minutes: number) => void;
|
||||
dismiss: () => void;
|
||||
lastBreakAt: Date | null;
|
||||
}
|
||||
|
||||
const BreakReminderContext = createContext<BreakReminderContextType | undefined>(undefined);
|
||||
|
||||
export function BreakReminderProvider({ children }: { children: ReactNode }) {
|
||||
const { isEnabled, settings } = useADHDMode();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isReminderVisible, setIsReminderVisible] = useState(false);
|
||||
const [minutesSinceBreak, setMinutesSinceBreak] = useState(0);
|
||||
const [lastBreakAt, setLastBreakAt] = useState<Date | null>(null);
|
||||
const [snoozeUntil, setSnoozeUntil] = useState<Date | null>(null);
|
||||
|
||||
// Track accumulated active minutes instead of time since break
|
||||
const accumulatedMinutesRef = useRef(0);
|
||||
const lastTickRef = useRef<number>(Date.now());
|
||||
const isPageVisibleRef = useRef(true);
|
||||
|
||||
// Reset accumulated minutes when break is taken or on page load
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('accumulatedActiveMinutes');
|
||||
const storedTime = localStorage.getItem('lastActiveTime');
|
||||
|
||||
if (stored && storedTime) {
|
||||
// Only restore if less than 8 hours have passed (assume they took a real break)
|
||||
const lastActive = new Date(storedTime).getTime();
|
||||
const hoursSinceActive = (Date.now() - lastActive) / (1000 * 60 * 60);
|
||||
|
||||
if (hoursSinceActive < 8) {
|
||||
accumulatedMinutesRef.current = parseInt(stored, 10) || 0;
|
||||
} else {
|
||||
// Reset after long break
|
||||
accumulatedMinutesRef.current = 0;
|
||||
localStorage.setItem('accumulatedActiveMinutes', '0');
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy: load lastBreakAt for backward compatibility
|
||||
const storedBreak = localStorage.getItem('lastBreakAt');
|
||||
if (storedBreak) {
|
||||
setLastBreakAt(new Date(storedBreak));
|
||||
} else {
|
||||
const now = new Date();
|
||||
setLastBreakAt(now);
|
||||
localStorage.setItem('lastBreakAt', now.toISOString());
|
||||
}
|
||||
|
||||
setMinutesSinceBreak(accumulatedMinutesRef.current);
|
||||
}, []);
|
||||
|
||||
// Track page visibility
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const isVisible = document.visibilityState === 'visible';
|
||||
|
||||
if (!isVisible && isPageVisibleRef.current) {
|
||||
// Page becoming hidden - save current state
|
||||
localStorage.setItem('accumulatedActiveMinutes', String(accumulatedMinutesRef.current));
|
||||
localStorage.setItem('lastActiveTime', new Date().toISOString());
|
||||
} else if (isVisible && !isPageVisibleRef.current) {
|
||||
// Page becoming visible - update last tick to now to avoid counting hidden time
|
||||
lastTickRef.current = Date.now();
|
||||
}
|
||||
|
||||
isPageVisibleRef.current = isVisible;
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
// Log break mutation
|
||||
const logBreakMutation = useMutation({
|
||||
mutationFn: async ({ breakType, durationMinutes }: { breakType: string; durationMinutes: number }) => {
|
||||
const res = await fetch('/api/user/log-break', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ breakType, durationMinutes }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to log break');
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const logBreak = useCallback((breakType: string, durationMinutes = 5) => {
|
||||
const now = new Date();
|
||||
setLastBreakAt(now);
|
||||
localStorage.setItem('lastBreakAt', now.toISOString());
|
||||
setIsReminderVisible(false);
|
||||
setSnoozeUntil(null);
|
||||
|
||||
// Reset accumulated time
|
||||
accumulatedMinutesRef.current = 0;
|
||||
setMinutesSinceBreak(0);
|
||||
localStorage.setItem('accumulatedActiveMinutes', '0');
|
||||
localStorage.setItem('lastActiveTime', now.toISOString());
|
||||
lastTickRef.current = Date.now();
|
||||
|
||||
// Log to server
|
||||
logBreakMutation.mutate({ breakType, durationMinutes });
|
||||
}, [logBreakMutation]);
|
||||
|
||||
const snooze = useCallback((minutes: number) => {
|
||||
const snoozeTime = new Date(Date.now() + minutes * 60 * 1000);
|
||||
setSnoozeUntil(snoozeTime);
|
||||
setIsReminderVisible(false);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setIsReminderVisible(false);
|
||||
}, []);
|
||||
|
||||
// Timer to track ACTIVE minutes since break (only when page is visible)
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
// Only count time if page is visible
|
||||
if (isPageVisibleRef.current) {
|
||||
const elapsedMs = now - lastTickRef.current;
|
||||
const elapsedMins = elapsedMs / 60000;
|
||||
|
||||
// Add to accumulated time
|
||||
accumulatedMinutesRef.current += elapsedMins;
|
||||
setMinutesSinceBreak(Math.floor(accumulatedMinutesRef.current));
|
||||
|
||||
// Save periodically
|
||||
localStorage.setItem('accumulatedActiveMinutes', String(Math.floor(accumulatedMinutesRef.current)));
|
||||
localStorage.setItem('lastActiveTime', new Date().toISOString());
|
||||
}
|
||||
|
||||
lastTickRef.current = now;
|
||||
|
||||
// Check if reminder should be shown
|
||||
const reminderInterval = settings.breakReminderInterval || 45;
|
||||
|
||||
// Check snooze
|
||||
if (snoozeUntil && new Date() < snoozeUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (accumulatedMinutesRef.current >= reminderInterval && !isReminderVisible) {
|
||||
setIsReminderVisible(true);
|
||||
setSnoozeUntil(null);
|
||||
}
|
||||
}, 30000); // Check every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isEnabled, settings.breakReminderInterval, snoozeUntil, isReminderVisible]);
|
||||
|
||||
return (
|
||||
<BreakReminderContext.Provider
|
||||
value={{
|
||||
isReminderVisible,
|
||||
minutesSinceBreak,
|
||||
logBreak,
|
||||
snooze,
|
||||
dismiss,
|
||||
lastBreakAt,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BreakReminderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBreakReminder() {
|
||||
const context = useContext(BreakReminderContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useBreakReminder must be used within a BreakReminderProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import React from 'react';
|
||||
import { useADHDMode, defaultADHDSettings } from '../providers/ADHDModeProvider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Brain,
|
||||
Timer,
|
||||
Sparkles,
|
||||
Coffee,
|
||||
Target,
|
||||
Battery,
|
||||
Zap,
|
||||
Shield,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ADHDModeToggle } from '../ADHDModeToggle';
|
||||
|
||||
export function ADHDSettingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled, settings, updateSettings } = useADHDMode();
|
||||
|
||||
const handleReset = () => {
|
||||
updateSettings(defaultADHDSettings);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Main Toggle */}
|
||||
<ADHDModeToggle showLabel={true} />
|
||||
|
||||
{/* Settings (only shown when enabled) */}
|
||||
{isEnabled && (
|
||||
<>
|
||||
{/* Visual & Interaction Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Target className="h-5 w-5" />
|
||||
{t('adhd.settings.visualTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('adhd.settings.visualDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Reduced Animations */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">{t('adhd.settings.reducedAnimations')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adhd.settings.reducedAnimationsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.reducedAnimations}
|
||||
onCheckedChange={(checked) => updateSettings({ reducedAnimations: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Larger Touch Targets */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">{t('adhd.settings.largerTargets')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adhd.settings.largerTargetsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.largerTargets}
|
||||
onCheckedChange={(checked) => updateSettings({ largerTargets: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Single Task Mode Default */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">{t('adhd.settings.singleTaskDefault')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adhd.settings.singleTaskDefaultDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.singleTaskModeDefault}
|
||||
onCheckedChange={(checked) => updateSettings({ singleTaskModeDefault: checked })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Timer & Focus Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Timer className="h-5 w-5" />
|
||||
{t('adhd.settings.timerTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('adhd.settings.timerDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Visual Timer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">{t('adhd.settings.visualTimer')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adhd.settings.visualTimerDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.visualTimerEnabled}
|
||||
onCheckedChange={(checked) => updateSettings({ visualTimerEnabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hyperfocus Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t('adhd.settings.hyperfocusProtection')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adhd.settings.hyperfocusProtectionDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.hyperfocusProtection}
|
||||
onCheckedChange={(checked) => updateSettings({ hyperfocusProtection: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hyperfocus Max Minutes */}
|
||||
{settings.hyperfocusProtection && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('adhd.settings.hyperfocusMaxMinutes')}</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{settings.hyperfocusMaxMinutes} min
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[settings.hyperfocusMaxMinutes]}
|
||||
onValueChange={([value]) => updateSettings({ hyperfocusMaxMinutes: value })}
|
||||
min={30}
|
||||
max={180}
|
||||
step={15}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Break Reminder Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Coffee className="h-5 w-5" />
|
||||
{t('adhd.settings.breakTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('adhd.settings.breakDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Break Reminder Interval */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('adhd.settings.breakInterval')}</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{settings.breakReminderInterval} min
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[settings.breakReminderInterval]}
|
||||
onValueChange={([value]) => updateSettings({ breakReminderInterval: value })}
|
||||
min={15}
|
||||
max={90}
|
||||
step={5}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Motivation Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{t('adhd.settings.motivationTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('adhd.settings.motivationDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Positive Messaging Level */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('adhd.settings.messagingLevel')}</Label>
|
||||
<Select
|
||||
value={settings.positiveMessagingLevel}
|
||||
onValueChange={(value: 'minimal' | 'normal' | 'high') =>
|
||||
updateSettings({ positiveMessagingLevel: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="minimal">{t('adhd.settings.messagingMinimal')}</SelectItem>
|
||||
<SelectItem value="normal">{t('adhd.settings.messagingNormal')}</SelectItem>
|
||||
<SelectItem value="high">{t('adhd.settings.messagingHigh')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('adhd.settings.messagingLevelDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Win Threshold */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-yellow-500" />
|
||||
{t('adhd.settings.quickWinThreshold')}
|
||||
</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{settings.quickWinThreshold} min
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[settings.quickWinThreshold]}
|
||||
onValueChange={([value]) => updateSettings({ quickWinThreshold: value })}
|
||||
min={5}
|
||||
max={30}
|
||||
step={5}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('adhd.settings.quickWinThresholdDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Energy Check-ins */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Battery className="h-5 w-5" />
|
||||
{t('adhd.settings.energyTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('adhd.settings.energyDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">{t('adhd.settings.energyCheckIns')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adhd.settings.energyCheckInsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.energyCheckInsEnabled}
|
||||
onCheckedChange={(checked) => updateSettings({ energyCheckInsEnabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Reset Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
{t('adhd.settings.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Battery, BatteryLow, BatteryMedium, BatteryFull, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
|
||||
interface EnergyCheckInProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete?: (level: string) => void;
|
||||
}
|
||||
|
||||
type EnergyLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
const energyLevels: { id: EnergyLevel; icon: React.ComponentType<{ className?: string }>; color: string }[] = [
|
||||
{ id: 'low', icon: BatteryLow, color: 'text-red-500 bg-red-500/10 border-red-500/30 hover:bg-red-500/20' },
|
||||
{ id: 'medium', icon: BatteryMedium, color: 'text-yellow-500 bg-yellow-500/10 border-yellow-500/30 hover:bg-yellow-500/20' },
|
||||
{ id: 'high', icon: BatteryFull, color: 'text-green-500 bg-green-500/10 border-green-500/30 hover:bg-green-500/20' },
|
||||
];
|
||||
|
||||
export function EnergyCheckIn({ open, onOpenChange, onComplete }: EnergyCheckInProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useADHDMode();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedLevel, setSelectedLevel] = useState<EnergyLevel | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const logMutation = useMutation({
|
||||
mutationFn: async ({ energyLevel, notes }: { energyLevel: string; notes?: string }) => {
|
||||
const res = await fetch('/api/energy/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ energyLevel, notes }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to log energy');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
||||
onComplete?.(selectedLevel!);
|
||||
onOpenChange(false);
|
||||
setSelectedLevel(null);
|
||||
setNotes('');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedLevel) return;
|
||||
logMutation.mutate({ energyLevel: selectedLevel, notes: notes || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Battery className="h-5 w-5 text-primary" />
|
||||
{t('adhd.energy.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('adhd.energy.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{/* Energy level selection */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
{energyLevels.map((level) => {
|
||||
const Icon = level.icon;
|
||||
const isSelected = selectedLevel === level.id;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={level.id}
|
||||
whileHover={{ scale: settings.reducedAnimations ? 1 : 1.02 }}
|
||||
whileTap={{ scale: settings.reducedAnimations ? 1 : 0.98 }}
|
||||
onClick={() => setSelectedLevel(level.id)}
|
||||
className={`
|
||||
flex flex-col items-center gap-2 p-4 rounded-xl border-2
|
||||
transition-all duration-200
|
||||
${level.color}
|
||||
${isSelected ? 'ring-2 ring-offset-2 ring-primary' : ''}
|
||||
${settings.largerTargets ? 'min-h-[100px]' : 'min-h-[80px]'}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-8 w-8" />
|
||||
<span className="font-medium text-sm">
|
||||
{t(`adhd.energy.levels.${level.id}`)}
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Energy-based suggestions */}
|
||||
<AnimatePresence>
|
||||
{selectedLevel && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sm mb-1">
|
||||
{t(`adhd.energy.suggestions.${selectedLevel}.title`)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`adhd.energy.suggestions.${selectedLevel}.description`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Optional notes */}
|
||||
<div className="mb-4">
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
{t('adhd.energy.notesLabel')} <span className="text-muted-foreground">({t('common.optional')})</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder={t('adhd.energy.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedLevel || logMutation.isPending}
|
||||
className={`w-full ${settings.largerTargets ? 'h-12' : 'h-10'}`}
|
||||
>
|
||||
{logMutation.isPending ? t('common.saving') : t('adhd.energy.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
Calendar,
|
||||
Clock,
|
||||
Play,
|
||||
UserPlus,
|
||||
LogOut,
|
||||
Crown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
import { format } from 'date-fns';
|
||||
import type { BodyDoublingSession } from '@shared/schema';
|
||||
|
||||
interface SessionWithParticipants extends BodyDoublingSession {
|
||||
participantCount: number;
|
||||
isParticipant: boolean;
|
||||
host: { username: string };
|
||||
}
|
||||
|
||||
const sessionTypes = [
|
||||
{ id: 'focus', icon: '🎯' },
|
||||
{ id: 'brainstorm', icon: '💡' },
|
||||
{ id: 'admin', icon: '📋' },
|
||||
{ id: 'creative', icon: '🎨' },
|
||||
];
|
||||
|
||||
export function BodyDoublingLobby() {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useADHDMode();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newSession, setNewSession] = useState({
|
||||
title: '',
|
||||
sessionType: 'focus',
|
||||
durationMinutes: 50,
|
||||
startsAt: '',
|
||||
});
|
||||
|
||||
// Fetch sessions
|
||||
const { data: sessions = [], isLoading } = useQuery<SessionWithParticipants[]>({
|
||||
queryKey: ['/api/sessions'],
|
||||
});
|
||||
|
||||
// Create session mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: typeof newSession) => {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
startsAt: new Date(data.startsAt).toISOString(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create session');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/sessions'] });
|
||||
setCreateDialogOpen(false);
|
||||
setNewSession({
|
||||
title: '',
|
||||
sessionType: 'focus',
|
||||
durationMinutes: 50,
|
||||
startsAt: '',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Join session mutation
|
||||
const joinMutation = useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/join`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to join session');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/sessions'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Leave session mutation
|
||||
const leaveMutation = useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/leave`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to leave session');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/sessions'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
createMutation.mutate(newSession);
|
||||
};
|
||||
|
||||
const upcomingSessions = sessions.filter((s) => s.status === 'scheduled');
|
||||
const activeSessions = sessions.filter((s) => s.status === 'active');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold flex items-center gap-2">
|
||||
<Users className="h-5 w-5 sm:h-6 sm:w-6 text-primary" />
|
||||
{t('adhd.bodyDoubling.title')}
|
||||
</h2>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">
|
||||
{t('adhd.bodyDoubling.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className={settings.largerTargets ? 'h-12 px-6' : ''}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('adhd.bodyDoubling.createSession')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('adhd.bodyDoubling.newSession')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('adhd.bodyDoubling.newSessionDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="title">{t('adhd.bodyDoubling.sessionTitle')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newSession.title}
|
||||
onChange={(e) => setNewSession({ ...newSession, title: e.target.value })}
|
||||
placeholder={t('adhd.bodyDoubling.titlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t('adhd.bodyDoubling.sessionType')}</Label>
|
||||
<Select
|
||||
value={newSession.sessionType}
|
||||
onValueChange={(value) => setNewSession({ ...newSession, sessionType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sessionTypes.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
{type.icon} {t(`adhd.bodyDoubling.types.${type.id}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="startsAt">{t('adhd.bodyDoubling.startTime')}</Label>
|
||||
<Input
|
||||
id="startsAt"
|
||||
type="datetime-local"
|
||||
value={newSession.startsAt}
|
||||
onChange={(e) => setNewSession({ ...newSession, startsAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('adhd.bodyDoubling.duration')}</Label>
|
||||
<Select
|
||||
value={newSession.durationMinutes.toString()}
|
||||
onValueChange={(value) => setNewSession({ ...newSession, durationMinutes: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="25">25 min</SelectItem>
|
||||
<SelectItem value="50">50 min</SelectItem>
|
||||
<SelectItem value="90">90 min</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!newSession.title || !newSession.startsAt || createMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{createMutation.isPending ? t('common.creating') : t('adhd.bodyDoubling.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Active Sessions */}
|
||||
{activeSessions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Play className="h-5 w-5 text-green-500" />
|
||||
{t('adhd.bodyDoubling.activeSessions')}
|
||||
</h3>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{activeSessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onJoin={() => joinMutation.mutate(session.id)}
|
||||
onLeave={() => leaveMutation.mutate(session.id)}
|
||||
isJoining={joinMutation.isPending}
|
||||
isLeaving={leaveMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming Sessions */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
{t('adhd.bodyDoubling.upcomingSessions')}
|
||||
</h3>
|
||||
{upcomingSessions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground text-center">
|
||||
{t('adhd.bodyDoubling.noSessions')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<AnimatePresence>
|
||||
{upcomingSessions.map((session, index) => (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<SessionCard
|
||||
session={session}
|
||||
onJoin={() => joinMutation.mutate(session.id)}
|
||||
onLeave={() => leaveMutation.mutate(session.id)}
|
||||
isJoining={joinMutation.isPending}
|
||||
isLeaving={leaveMutation.isPending}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Session Card Component
|
||||
function SessionCard({
|
||||
session,
|
||||
onJoin,
|
||||
onLeave,
|
||||
isJoining,
|
||||
isLeaving,
|
||||
}: {
|
||||
session: SessionWithParticipants;
|
||||
onJoin: () => void;
|
||||
onLeave: () => void;
|
||||
isJoining: boolean;
|
||||
isLeaving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useADHDMode();
|
||||
|
||||
const sessionType = sessionTypes.find((t) => t.id === session.sessionType);
|
||||
const isActive = session.status === 'active';
|
||||
|
||||
return (
|
||||
<Card className={`
|
||||
${isActive ? 'border-green-500/50 bg-green-500/5' : ''}
|
||||
${session.isParticipant ? 'ring-2 ring-primary/50' : ''}
|
||||
`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{sessionType?.icon}</span>
|
||||
<div>
|
||||
<CardTitle className="text-base">{session.title}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1">
|
||||
<Crown className="h-3 w-3" />
|
||||
{session.host?.username}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<Badge variant="default" className="bg-green-500">
|
||||
{t('adhd.bodyDoubling.live')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{format(new Date(session.startsAt), 'MMM d, HH:mm')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{session.durationMinutes} min
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{session.participantCount}/{session.maxParticipants}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session.isParticipant ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onLeave}
|
||||
disabled={isLeaving}
|
||||
className={`w-full ${settings.largerTargets ? 'h-11' : ''}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{t('adhd.bodyDoubling.leave')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onJoin}
|
||||
disabled={isJoining || session.participantCount >= session.maxParticipants}
|
||||
className={`w-full ${settings.largerTargets ? 'h-11' : ''}`}
|
||||
>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
{t('adhd.bodyDoubling.join')}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Play, Pause, RotateCcw, Volume2, VolumeX, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useADHDMode } from '../providers/ADHDModeProvider';
|
||||
|
||||
interface VisualTimerProps {
|
||||
duration: number; // in seconds
|
||||
onComplete?: () => void;
|
||||
onMilestone?: (percent: number) => void;
|
||||
colorScheme?: 'default' | 'calm' | 'energetic';
|
||||
showNumbers?: boolean;
|
||||
size?: 'small' | 'medium' | 'large' | 'fullscreen';
|
||||
autoStart?: boolean;
|
||||
taskTitle?: string;
|
||||
}
|
||||
|
||||
const colorSchemes = {
|
||||
default: {
|
||||
start: 'hsl(142, 76%, 36%)',
|
||||
mid: 'hsl(45, 93%, 47%)',
|
||||
end: 'hsl(0, 84%, 60%)',
|
||||
bg: 'hsl(0, 0%, 90%)',
|
||||
},
|
||||
calm: {
|
||||
start: 'hsl(200, 70%, 50%)',
|
||||
mid: 'hsl(180, 60%, 45%)',
|
||||
end: 'hsl(280, 60%, 55%)',
|
||||
bg: 'hsl(220, 20%, 95%)',
|
||||
},
|
||||
energetic: {
|
||||
start: 'hsl(160, 84%, 39%)',
|
||||
mid: 'hsl(38, 92%, 50%)',
|
||||
end: 'hsl(350, 89%, 60%)',
|
||||
bg: 'hsl(0, 0%, 92%)',
|
||||
},
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
small: { ring: 120, stroke: 8, font: 'text-xl' },
|
||||
medium: { ring: 200, stroke: 12, font: 'text-3xl' },
|
||||
large: { ring: 300, stroke: 16, font: 'text-5xl' },
|
||||
fullscreen: { ring: 400, stroke: 20, font: 'text-7xl' },
|
||||
};
|
||||
|
||||
export function VisualTimer({
|
||||
duration,
|
||||
onComplete,
|
||||
onMilestone,
|
||||
colorScheme = 'default',
|
||||
showNumbers = true,
|
||||
size = 'medium',
|
||||
autoStart = false,
|
||||
taskTitle,
|
||||
}: VisualTimerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useADHDMode();
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState(duration);
|
||||
const [isRunning, setIsRunning] = useState(autoStart);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||
const [lastMilestone, setLastMilestone] = useState(100);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const colors = colorSchemes[colorScheme];
|
||||
const sizeConfig = isFullscreen ? sizes.fullscreen : sizes[size];
|
||||
|
||||
const progress = timeLeft / duration;
|
||||
const percent = Math.round(progress * 100);
|
||||
|
||||
// Calculate current color based on progress
|
||||
const getCurrentColor = useCallback(() => {
|
||||
if (progress > 0.5) {
|
||||
return colors.start;
|
||||
} else if (progress > 0.25) {
|
||||
return colors.mid;
|
||||
} else {
|
||||
return colors.end;
|
||||
}
|
||||
}, [progress, colors]);
|
||||
|
||||
// Format time display
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Play sound effect
|
||||
const playSound = useCallback((type: 'tick' | 'milestone' | 'complete') => {
|
||||
if (!soundEnabled) return;
|
||||
|
||||
// Simple beep sounds using Web Audio API
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
switch (type) {
|
||||
case 'tick':
|
||||
oscillator.frequency.value = 800;
|
||||
gainNode.gain.value = 0.1;
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.05);
|
||||
break;
|
||||
case 'milestone':
|
||||
oscillator.frequency.value = 600;
|
||||
gainNode.gain.value = 0.2;
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
break;
|
||||
case 'complete':
|
||||
oscillator.frequency.value = 440;
|
||||
gainNode.gain.value = 0.3;
|
||||
oscillator.start();
|
||||
setTimeout(() => {
|
||||
oscillator.frequency.value = 554;
|
||||
}, 200);
|
||||
setTimeout(() => {
|
||||
oscillator.frequency.value = 659;
|
||||
}, 400);
|
||||
oscillator.stop(audioContext.currentTime + 0.6);
|
||||
break;
|
||||
}
|
||||
}, [soundEnabled]);
|
||||
|
||||
// Timer logic
|
||||
useEffect(() => {
|
||||
if (!isRunning || timeLeft <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
const newTime = prev - 1;
|
||||
|
||||
if (newTime <= 0) {
|
||||
playSound('complete');
|
||||
onComplete?.();
|
||||
setIsRunning(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning, timeLeft, onComplete, playSound]);
|
||||
|
||||
// Milestone detection
|
||||
useEffect(() => {
|
||||
const milestones = [75, 50, 25, 10, 5];
|
||||
for (const milestone of milestones) {
|
||||
if (percent <= milestone && lastMilestone > milestone) {
|
||||
setLastMilestone(milestone);
|
||||
playSound('milestone');
|
||||
onMilestone?.(milestone);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [percent, lastMilestone, onMilestone, playSound]);
|
||||
|
||||
// Reset timer
|
||||
const reset = () => {
|
||||
setTimeLeft(duration);
|
||||
setIsRunning(false);
|
||||
setLastMilestone(100);
|
||||
};
|
||||
|
||||
// Toggle fullscreen
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement && containerRef.current) {
|
||||
containerRef.current.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// SVG calculations
|
||||
const radius = (sizeConfig.ring - sizeConfig.stroke) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const strokeDashoffset = circumference * (1 - progress);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`
|
||||
flex flex-col items-center justify-center gap-6 p-6
|
||||
${isFullscreen ? 'fixed inset-0 bg-background z-50' : ''}
|
||||
${settings.reducedAnimations ? '' : 'transition-all duration-300'}
|
||||
`}
|
||||
>
|
||||
{taskTitle && (
|
||||
<h3 className="text-lg font-medium text-center text-muted-foreground max-w-md">
|
||||
{taskTitle}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Visual Timer Ring */}
|
||||
<div className="relative" style={{ width: sizeConfig.ring, height: sizeConfig.ring }}>
|
||||
<svg
|
||||
className="transform -rotate-90"
|
||||
width={sizeConfig.ring}
|
||||
height={sizeConfig.ring}
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={sizeConfig.ring / 2}
|
||||
cy={sizeConfig.ring / 2}
|
||||
r={radius}
|
||||
stroke={colors.bg}
|
||||
strokeWidth={sizeConfig.stroke}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<motion.circle
|
||||
cx={sizeConfig.ring / 2}
|
||||
cy={sizeConfig.ring / 2}
|
||||
r={radius}
|
||||
stroke={getCurrentColor()}
|
||||
strokeWidth={sizeConfig.stroke}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: 0 }}
|
||||
animate={{ strokeDashoffset }}
|
||||
transition={{ duration: settings.reducedAnimations ? 0 : 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Time display in center */}
|
||||
{showNumbers && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={timeLeft}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: settings.reducedAnimations ? 0 : 0.2 }}
|
||||
className={`font-mono font-bold ${sizeConfig.font}`}
|
||||
style={{ color: getCurrentColor() }}
|
||||
>
|
||||
{formatTime(timeLeft)}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<span className="text-sm text-muted-foreground mt-1">
|
||||
{percent}% {t('adhd.timer.remaining')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsRunning(!isRunning)}
|
||||
className={settings.largerTargets ? 'h-12 w-12' : 'h-10 w-10'}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={reset}
|
||||
className={settings.largerTargets ? 'h-12 w-12' : 'h-10 w-10'}
|
||||
>
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSoundEnabled(!soundEnabled)}
|
||||
className={settings.largerTargets ? 'h-12 w-12' : 'h-10 w-10'}
|
||||
>
|
||||
{soundEnabled ? (
|
||||
<Volume2 className="h-5 w-5" />
|
||||
) : (
|
||||
<VolumeX className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleFullscreen}
|
||||
className={settings.largerTargets ? 'h-12 w-12' : 'h-10 w-10'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Maximize2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress milestones */}
|
||||
<div className="flex gap-2">
|
||||
{[75, 50, 25, 0].map((milestone) => (
|
||||
<div
|
||||
key={milestone}
|
||||
className={`
|
||||
w-3 h-3 rounded-full transition-all
|
||||
${percent <= milestone ? 'bg-primary' : 'bg-muted'}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import { Loader2, Bot, RefreshCw, Download, Server } from "lucide-react";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
|
||||
|
||||
export function AiSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch settings
|
||||
const { data: settings, isLoading } = useQuery<Record<string, string>>({
|
||||
queryKey: ['/api/admin/settings'],
|
||||
});
|
||||
|
||||
const [provider, setProvider] = useState("openai");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("gpt-4o");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
|
||||
// Ollama specific state
|
||||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||
const [pullModelName, setPullModelName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setProvider(settings.ai_provider || "openai");
|
||||
setApiKey(settings.ai_api_key || "");
|
||||
setModel(settings.ai_model || "gpt-4o");
|
||||
setBaseUrl(settings.ai_base_url || "");
|
||||
setSystemPrompt(settings.ai_system_prompt || "");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
const res = await apiRequest("POST", "/api/admin/settings", data);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
|
||||
toast({ title: t('settings.ai.saved', "Settings saved") });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t('settings.ai.error', "Failed to save settings"), variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const fetchOllamaModelsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/admin/ollama/tags", { baseUrl });
|
||||
if (!res.ok) throw new Error("Failed to fetch models");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
const models = data.models?.map((m: any) => m.name) || [];
|
||||
setOllamaModels(models);
|
||||
toast({ title: t('settings.ai.ollama.found', { count: models.length }) });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: t('settings.ai.ollama.connectError', "Could not connect to Ollama"), description: err.message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const pullOllamaModelMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/admin/ollama/pull", { baseUrl, model: pullModelName });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: t('settings.ai.ollama.pullSuccess', "Model pulled successfully") });
|
||||
setPullModelName("");
|
||||
fetchOllamaModelsMutation.mutate(); // Refresh list
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: t('settings.ai.ollama.pullError', "Failed to pull model"), description: err.message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const handleSave = () => {
|
||||
mutation.mutate({
|
||||
ai_provider: provider,
|
||||
ai_api_key: apiKey,
|
||||
ai_model: model,
|
||||
ai_base_url: baseUrl,
|
||||
ai_system_prompt: systemPrompt
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <Loader2 className="animate-spin" />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="w-5 h-5" />
|
||||
{t('settings.ai.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.ai.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('settings.ai.provider')}</Label>
|
||||
<Select value={provider} onValueChange={(v) => {
|
||||
setProvider(v);
|
||||
if (v === 'openai' && model === 'claude-3-5-sonnet') setModel('gpt-4o');
|
||||
if (v === 'anthropic' && model === 'gpt-4o') setModel('claude-3-5-sonnet');
|
||||
if (v === 'ollama') {
|
||||
setBaseUrl(baseUrl || 'http://localhost:11434');
|
||||
if (!ollamaModels.length) setModel('llama3'); // Default if unknown
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="google">Google Gemini</SelectItem>
|
||||
<SelectItem value="ollama">Ollama (Local)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('settings.ai.apiKey')}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={provider === 'ollama' ? t('settings.ai.ollama.placeholder', 'Optional for Ollama') : 'sk-...'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('settings.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder={provider === 'ollama' ? 'http://localhost:11434' : 'Optional override'}
|
||||
/>
|
||||
{provider === 'ollama' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchOllamaModelsMutation.mutate()}
|
||||
disabled={fetchOllamaModelsMutation.isPending}
|
||||
title="Fetch Models from Server"
|
||||
>
|
||||
{fetchOllamaModelsMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('settings.ai.model')}</Label>
|
||||
{provider === 'ollama' && ollamaModels.length > 0 ? (
|
||||
<div className="flex gap-2">
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ollamaModels.map(m => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="e.g. gpt-4, llama3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{provider === 'ollama' && (
|
||||
<div className="pt-4 border-t space-y-3">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Pull Model from Ollama Library
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Model name (e.g. llama3, mistral)"
|
||||
value={pullModelName}
|
||||
onChange={(e) => setPullModelName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => pullOllamaModelMutation.mutate()}
|
||||
disabled={!pullModelName || pullOllamaModelMutation.isPending}
|
||||
>
|
||||
{pullOllamaModelMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Pulling...
|
||||
</>
|
||||
) : "Pull"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Please check your server logs/Ollama console if the pull takes a long time.
|
||||
Ensure the container has internet access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 pt-4 border-t">
|
||||
<Label className="flex items-center justify-between">
|
||||
{t('settings.ai.systemPrompt', 'System Prompt')}
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
Available: {"${user.username}"}, {"${currentDate...}"}, {"${context}"}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
className="font-mono text-sm min-h-[300px]"
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
placeholder="You are TaskFlow AI..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Settings
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
source: string;
|
||||
details: any;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function AuditLogsTable() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: logs, isLoading, error } = useQuery<AuditLog[]>({
|
||||
queryKey: ['/api/admin/audit-logs'],
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin" /></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-8 text-center text-red-500">Failed to load audit logs. Please check server logs.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.auditLogs.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.auditLogs.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('settings.auditLogs.table.time')}</TableHead>
|
||||
<TableHead>{t('settings.auditLogs.table.source')}</TableHead>
|
||||
<TableHead>{t('settings.auditLogs.table.action')}</TableHead>
|
||||
<TableHead>{t('settings.auditLogs.table.entity')}</TableHead>
|
||||
<TableHead>{t('settings.auditLogs.table.details')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs?.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{new Date(log.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.source === 'AI' ? 'secondary' : 'outline'}>
|
||||
{t(`audit.source.${log.source}`, { defaultValue: log.source })}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{t(`audit.action.${log.action}`, { defaultValue: log.action })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(`audit.entity.${log.entityType}`, { defaultValue: log.entityType })}
|
||||
{log.entityId && <span className="text-xs text-muted-foreground block truncate max-w-[100px]">{log.entityId}</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground w-1/3">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs">
|
||||
{JSON.stringify(log.details, null, 2)}
|
||||
</pre>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(!logs || logs.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
{t('settings.auditLogs.table.empty')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2, Server, Save, Copy } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
|
||||
export function McpSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
mcp_enabled: false,
|
||||
mcp_port: "5001" // Default to main app port unless specific override logic is added
|
||||
});
|
||||
|
||||
// Feature 1: Get User for API Key
|
||||
const { data: user } = useQuery<any>({
|
||||
queryKey: ['/api/user']
|
||||
});
|
||||
|
||||
// Feature 2: API Key Mutations
|
||||
const generateApiKeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/user/api-key", {});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: t('settings.mcp.generated') });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeApiKeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiRequest("DELETE", "/api/user/api-key");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: t('settings.mcp.revoked') });
|
||||
},
|
||||
});
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast({ title: t('settings.mcp.copied', "Copied!") });
|
||||
}
|
||||
|
||||
const { data: settings, isLoading } = useQuery<any>({
|
||||
queryKey: ['/api/admin/settings'],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData({
|
||||
mcp_enabled: settings.mcp_enabled === true,
|
||||
mcp_port: settings.mcp_port || "5001"
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save settings");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
|
||||
toast({ title: t('settings.saved', 'Settings saved') });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t('settings.error', 'Failed to save settings'), variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
mutation.mutate({
|
||||
mcp_enabled: formData.mcp_enabled,
|
||||
mcp_port: formData.mcp_port
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="flex justify-center p-4"><Loader2 className="animate-spin" /></div>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
<CardTitle>{t('settings.mcp.title')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('settings.mcp.description')}
|
||||
<span className="block mt-1 text-xs">{t('settings.mcp.descriptionDetail', "The MCP server runs on the same port as the application (/api/mcp).")}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="mcp_enabled" className="flex flex-col gap-1">
|
||||
<span>{t('settings.mcp.enableLabel')}</span>
|
||||
<span className="font-normal text-xs text-muted-foreground">
|
||||
{t('settings.mcp.enableDesc')}
|
||||
</span>
|
||||
</Label>
|
||||
<Switch
|
||||
id="mcp_enabled"
|
||||
checked={formData.mcp_enabled}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, mcp_enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium">{t('settings.mcp.status')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="font-medium">{t('settings.mcp.running')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint URL */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t('settings.mcp.url')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={`${window.location.protocol}//${window.location.host}/api/mcp/sse`} />
|
||||
<Button variant="outline" size="icon" onClick={() => copyToClipboard(`${window.location.protocol}//${window.location.host}/api/mcp/sse`)}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Management */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t('settings.mcp.apiKey')}</p>
|
||||
{user?.apiKey ? (
|
||||
<div className="flex gap-2">
|
||||
<Input type="password" readOnly value={user.apiKey} />
|
||||
<Button variant="outline" size="icon" onClick={() => copyToClipboard(user.apiKey!)}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => revokeApiKeyMutation.mutate()} disabled={revokeApiKeyMutation.isPending}>
|
||||
{t('settings.mcp.revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{t('settings.mcp.noKey')}</p>
|
||||
<Button onClick={() => generateApiKeyMutation.mutate()} disabled={generateApiKeyMutation.isPending}>
|
||||
{t('settings.mcp.generate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 p-3 rounded-lg text-sm text-muted-foreground">
|
||||
{t('settings.mcp.instructions')}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp_port">{t('settings.mcp.portLabel')}</Label>
|
||||
<Input
|
||||
id="mcp_port"
|
||||
value={formData.mcp_port}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, mcp_port: e.target.value }))}
|
||||
placeholder="5001"
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.mcp.portDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t('settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Sun, Moon } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export function RoutineSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [morningEnabled, setMorningEnabled] = useState(true);
|
||||
const [morningTime, setMorningTime] = useState("09:00");
|
||||
const [eveningEnabled, setEveningEnabled] = useState(true);
|
||||
const [eveningTime, setEveningTime] = useState("22:00");
|
||||
|
||||
const { data: settings, isLoading } = useQuery<Record<string, string>>({
|
||||
queryKey: ['/api/admin/settings'],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/admin/settings");
|
||||
if (!res.ok) throw new Error("Failed to fetch settings");
|
||||
return res.json();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setMorningEnabled(settings.morning_routine_enabled !== "false");
|
||||
setMorningTime(settings.morning_routine_time || "09:00");
|
||||
setEveningEnabled(settings.evening_routine_enabled !== "false");
|
||||
setEveningTime(settings.evening_routine_time || "22:00");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = {
|
||||
morning_routine_enabled: String(morningEnabled),
|
||||
morning_routine_time: morningTime,
|
||||
evening_routine_enabled: String(eveningEnabled),
|
||||
evening_routine_time: eveningTime,
|
||||
};
|
||||
|
||||
// We send individual updates or a bulk update?
|
||||
// The backend /api/admin/settings usually accepts a map of KVs to update.
|
||||
// Checking AdminSettings.tsx might verify this, but typically we post to specific keys or bulk object.
|
||||
// Assuming GET returns object, POST probably takes object.
|
||||
// If server routes handle bulk update.
|
||||
// If not, we loop.
|
||||
// Checking AiSettingsCard logic: it calls `/api/admin/settings` with JSON body.
|
||||
// Assuming generic handler supports partial updates.
|
||||
|
||||
const res = await apiRequest("POST", "/api/admin/settings", updates);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
|
||||
toast({ title: t('settings.routines.saved', 'Routine settings saved') });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t('settings.routines.error', 'Failed to save settings'),
|
||||
description: err.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.routines.title', 'Routine Configuration')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settings.routines.description', 'Configure global morning and evening routine schedules.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Morning Routine */}
|
||||
<div className="flex flex-col gap-4 border-b pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-5 w-5 text-orange-500" />
|
||||
<Label htmlFor="morning_enabled" className="text-base font-medium">
|
||||
{t('settings.routines.morningLabel', 'Morning Routine')}
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="morning_enabled"
|
||||
checked={morningEnabled}
|
||||
onCheckedChange={setMorningEnabled}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{morningEnabled && (
|
||||
<div className="pl-7">
|
||||
<Label htmlFor="morning_time" className="mb-2 block text-sm text-muted-foreground">{t('settings.routines.time', 'Start Time')}</Label>
|
||||
<Input
|
||||
id="morning_time"
|
||||
type="time"
|
||||
value={morningTime}
|
||||
onChange={(e) => setMorningTime(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Evening Routine */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5 text-indigo-500" />
|
||||
<Label htmlFor="evening_enabled" className="text-base font-medium">
|
||||
{t('settings.routines.eveningLabel', 'Evening Routine')}
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="evening_enabled"
|
||||
checked={eveningEnabled}
|
||||
onCheckedChange={setEveningEnabled}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{eveningEnabled && (
|
||||
<div className="pl-7">
|
||||
<Label htmlFor="evening_time" className="mb-2 block text-sm text-muted-foreground">{t('settings.routines.time', 'Start Time')}</Label>
|
||||
<Input
|
||||
id="evening_time"
|
||||
type="time"
|
||||
value={eveningTime}
|
||||
onChange={(e) => setEveningTime(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end">
|
||||
<Button onClick={() => mutation.mutate()} disabled={isLoading || mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('settings.routines.save', 'Save Routines')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function SMTPSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: settings } = useQuery<{
|
||||
smtp_host: string;
|
||||
smtp_port: string;
|
||||
smtp_user: string;
|
||||
smtp_pass: string;
|
||||
smtp_from: string;
|
||||
smtp_secure: boolean;
|
||||
}>({
|
||||
queryKey: ["/api/admin/settings"],
|
||||
});
|
||||
|
||||
const [emailSettings, setEmailSettings] = useState({
|
||||
smtp_host: '',
|
||||
smtp_port: '',
|
||||
smtp_user: '',
|
||||
smtp_pass: '',
|
||||
smtp_from: '',
|
||||
smtp_secure: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setEmailSettings({
|
||||
smtp_host: settings.smtp_host || '',
|
||||
smtp_port: settings.smtp_port || '',
|
||||
smtp_user: settings.smtp_user || '',
|
||||
smtp_pass: settings.smtp_pass || '',
|
||||
smtp_from: settings.smtp_from || '',
|
||||
smtp_secure: settings.smtp_secure || false
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: (data: Partial<typeof settings>) => apiRequest("POST", "/api/admin/settings", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
|
||||
toast({ title: t('settings.smtp.saveSuccess', "Settings updated") });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.smtp.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.smtp.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('settings.smtp.host')}</Label>
|
||||
<Input placeholder="smtp.example.com" value={emailSettings.smtp_host} onChange={e => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('settings.smtp.port')}</Label>
|
||||
<Input placeholder="587" value={emailSettings.smtp_port} onChange={e => setEmailSettings({ ...emailSettings, smtp_port: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('settings.smtp.user')}</Label>
|
||||
<Input placeholder="user@example.com" value={emailSettings.smtp_user} onChange={e => setEmailSettings({ ...emailSettings, smtp_user: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('settings.smtp.password')}</Label>
|
||||
<Input type="password" placeholder="••••••" value={emailSettings.smtp_pass} onChange={e => setEmailSettings({ ...emailSettings, smtp_pass: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('settings.smtp.from')}</Label>
|
||||
<Input placeholder='"TaskFlow" <noreply@taskflow.local>' value={emailSettings.smtp_from} onChange={e => setEmailSettings({ ...emailSettings, smtp_from: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t('settings.smtp.secure')}</Label>
|
||||
<Switch checked={emailSettings.smtp_secure} onCheckedChange={(c) => setEmailSettings({ ...emailSettings, smtp_secure: c })} />
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => updateSettingsMutation.mutate(emailSettings)}
|
||||
disabled={updateSettingsMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{updateSettingsMutation.isPending ? t('settings.smtp.saving') : t('settings.smtp.save')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import BottomNavigation from '../BottomNavigation';
|
||||
|
||||
export default function BottomNavigationExample() {
|
||||
return (
|
||||
<div className="relative h-[400px] bg-muted/20">
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold mb-2">App Content Area</h3>
|
||||
<p className="text-muted-foreground">
|
||||
This is where your main app content would be displayed.
|
||||
The bottom navigation is fixed at the bottom of the screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BottomNavigation />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import CalendarView from '../CalendarView';
|
||||
import { Task } from '../TaskCard';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Team meeting',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
dueDate: new Date(),
|
||||
timeTracked: 0,
|
||||
isTracking: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Review design mockups',
|
||||
status: 'inProgress',
|
||||
priority: 'medium',
|
||||
dueDate: addDays(new Date(), 2),
|
||||
timeTracked: 30,
|
||||
isTracking: false
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Client presentation',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
dueDate: addDays(new Date(), 5),
|
||||
timeTracked: 0,
|
||||
isTracking: false
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Code review',
|
||||
status: 'todo',
|
||||
priority: 'low',
|
||||
dueDate: addDays(new Date(), 1),
|
||||
timeTracked: 0,
|
||||
isTracking: false
|
||||
}
|
||||
];
|
||||
|
||||
export default function CalendarViewExample() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<CalendarView tasks={mockTasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import KanbanBoard from '../KanbanBoard';
|
||||
import { Task } from '../TaskCard';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Design new landing page',
|
||||
description: 'Create wireframes and mockups for the new product landing page',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
dueDate: addDays(new Date(), 3),
|
||||
timeTracked: 0,
|
||||
isTracking: false,
|
||||
projectId: 'project-1'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Implement user authentication',
|
||||
description: 'Set up login, registration, and password reset functionality',
|
||||
status: 'inProgress',
|
||||
priority: 'high',
|
||||
dueDate: addDays(new Date(), 1),
|
||||
timeTracked: 120,
|
||||
isTracking: true,
|
||||
projectId: 'project-2'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Write API documentation',
|
||||
status: 'inProgress',
|
||||
priority: 'medium',
|
||||
dueDate: addDays(new Date(), 5),
|
||||
timeTracked: 45,
|
||||
isTracking: false,
|
||||
projectId: 'project-1'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Set up CI/CD pipeline',
|
||||
description: 'Configure automated testing and deployment',
|
||||
status: 'done',
|
||||
priority: 'medium',
|
||||
dueDate: addDays(new Date(), -2),
|
||||
timeTracked: 180,
|
||||
isTracking: false,
|
||||
projectId: 'project-2'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Review code changes',
|
||||
status: 'todo',
|
||||
priority: 'low',
|
||||
dueDate: new Date(),
|
||||
timeTracked: 0,
|
||||
isTracking: false
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Update dependencies',
|
||||
status: 'done',
|
||||
priority: 'low',
|
||||
dueDate: addDays(new Date(), -1),
|
||||
timeTracked: 30,
|
||||
isTracking: false
|
||||
}
|
||||
];
|
||||
|
||||
export default function KanbanBoardExample() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<KanbanBoard tasks={mockTasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import ProjectTemplate from '../ProjectTemplate';
|
||||
|
||||
export default function ProjectTemplateExample() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<ProjectTemplate />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import TaskCard from '../TaskCard';
|
||||
import { Task } from '@shared/schema';
|
||||
|
||||
const mockTask: Task = {
|
||||
id: '1',
|
||||
title: 'Design mobile task interface',
|
||||
description: 'Create wireframes and mockups for the mobile-first task management interface',
|
||||
status: 'inProgress',
|
||||
priority: 'high',
|
||||
dueDate: new Date('2024-09-20'),
|
||||
timeTracked: 45,
|
||||
isTracking: false,
|
||||
projectId: 'project-1',
|
||||
notes: 'Focus on mobile-first design principles'
|
||||
};
|
||||
|
||||
export default function TaskCardExample() {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<TaskCard task={mockTask} />
|
||||
<TaskCard
|
||||
task={{
|
||||
...mockTask,
|
||||
id: '2',
|
||||
title: 'Quick task without details',
|
||||
description: undefined,
|
||||
status: 'todo',
|
||||
priority: 'low',
|
||||
timeTracked: 0
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import TaskCreationModal from '../TaskCreationModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
export default function TaskCreationModalExample() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create New Task
|
||||
</Button>
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onSave={(task) => {
|
||||
console.log('Task saved:', task);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import TaskList from '../TaskList';
|
||||
import { Task } from '../TaskCard';
|
||||
import { addDays, subDays } from 'date-fns';
|
||||
|
||||
const mockTasks: 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'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Update website copy',
|
||||
description: 'Revise landing page content based on user feedback',
|
||||
status: 'inProgress',
|
||||
priority: 'medium',
|
||||
dueDate: addDays(new Date(), 5),
|
||||
timeTracked: 90,
|
||||
isTracking: true,
|
||||
projectId: 'project-2'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Team standup meeting',
|
||||
status: 'todo',
|
||||
priority: 'low',
|
||||
dueDate: new Date(),
|
||||
timeTracked: 0,
|
||||
isTracking: false
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Fix login bug',
|
||||
description: 'Users unable to login with special characters in password',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
dueDate: subDays(new Date(), 1), // Overdue
|
||||
timeTracked: 30,
|
||||
isTracking: false,
|
||||
projectId: 'project-2'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Deploy new features',
|
||||
status: 'done',
|
||||
priority: 'medium',
|
||||
dueDate: subDays(new Date(), 2),
|
||||
timeTracked: 120,
|
||||
isTracking: false,
|
||||
projectId: 'project-1'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Write documentation',
|
||||
description: 'Document new API endpoints for external developers',
|
||||
status: 'inProgress',
|
||||
priority: 'medium',
|
||||
dueDate: addDays(new Date(), 7),
|
||||
timeTracked: 60,
|
||||
isTracking: false,
|
||||
projectId: 'project-1'
|
||||
}
|
||||
];
|
||||
|
||||
export default function TaskListExample() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<TaskList tasks={mockTasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import ThemeToggle from '../ThemeToggle';
|
||||
|
||||
export default function ThemeToggleExample() {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-semibold">Dark Mode</h3>
|
||||
<p className="text-sm text-muted-foreground">Toggle between light and dark themes</p>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-card border rounded-lg">
|
||||
<p className="text-sm">This card will change appearance when you toggle the theme.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, Coffee, Gamepad2, Palette, Gift, type LucideIcon } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useState } from "react";
|
||||
// We don't have types shared for specific API responses yet, so using any for now or defining locally
|
||||
type Reward = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
cost: number;
|
||||
icon: string;
|
||||
type: string;
|
||||
owned?: boolean;
|
||||
};
|
||||
|
||||
type RewardCardProps = {
|
||||
reward: Reward;
|
||||
userXp: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
"coffee": Coffee,
|
||||
"gamepad-2": Gamepad2,
|
||||
"palette": Palette,
|
||||
"gift": Gift
|
||||
};
|
||||
|
||||
export const RewardCard = ({ reward, userXp, userId }: RewardCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const canAfford = userXp >= reward.cost;
|
||||
const isOwned = reward.owned;
|
||||
|
||||
const IconComponent = iconMap[reward.icon] || Gift; // Fallback
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!canAfford && !isOwned) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiRequest("POST", "/api/rewards/purchase", {
|
||||
rewardId: reward.id
|
||||
});
|
||||
await res.json();
|
||||
|
||||
toast({
|
||||
title: t("rewards.buySuccess"), // Need to add this key or use generic
|
||||
description: t("rewards.itemPurchased", { item: t(reward.title) }),
|
||||
});
|
||||
|
||||
// Refetch rewards and user (to update XP)
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/rewards"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] }); // Assuming user fetch uses this key
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users/current"] }); // Or this
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("rewards.buyFailed"),
|
||||
description: t("rewards.insufficientFunds"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Card className={`relative overflow-hidden p-6 transition-colors ${isOwned ? "bg-primary/10 border-primary/20" : "bg-card hover:border-primary/50"
|
||||
}`}>
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className={`p-4 rounded-full ${isOwned ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
<IconComponent className="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-bold text-lg">{t(reward.title)}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t(reward.description || "")}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-full">
|
||||
{isOwned ? (
|
||||
<Button variant="outline" className="w-full cursor-default disabled:opacity-100" disabled>
|
||||
{t("rewards.owned")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
disabled={!canAfford || isLoading}
|
||||
onClick={handleBuy}
|
||||
variant={canAfford ? "default" : "secondary"}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t("rewards.buy")}
|
||||
<span className="font-mono text-xs bg-black/20 px-2 py-0.5 rounded">
|
||||
{reward.cost} XP
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"
|
||||
const badgeVariants = cva(
|
||||
// Whitespace-nowrap: Badges should never wrap.
|
||||
"whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" +
|
||||
" hover-elevate " ,
|
||||
" hover-elevate ",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -27,12 +27,15 @@ const badgeVariants = cva(
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
VariantProps<typeof badgeVariants> { }
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
Badge.displayName = "Badge"
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -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:
|
||||
@@ -21,6 +21,7 @@ const buttonVariants = cva(
|
||||
secondary: "border bg-secondary text-secondary-foreground border border-secondary-border ",
|
||||
// Add a transparent border so that when someone toggles a border on later, it doesn't shift layout/size.
|
||||
ghost: "border border-transparent",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
// Heights are set as "min" heights, because sometimes Ai will place large amount of content
|
||||
// inside buttons. With a min-height they will look appropriate with small amounts of content,
|
||||
@@ -41,7 +42,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={{
|
||||
|
||||
@@ -13,16 +13,18 @@ const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -310,7 +310,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"bg-background relative flex min-w-0 flex-1 flex-col overflow-x-hidden",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
@@ -375,7 +375,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -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,117 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Loader2, FileJson } from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
|
||||
import { User } from "@shared/schema";
|
||||
|
||||
interface DataExportCardProps {
|
||||
user: User | undefined | null;
|
||||
}
|
||||
|
||||
export function DataExportCard({ user }: DataExportCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
|
||||
const [includeTasks, setIncludeTasks] = useState(true);
|
||||
const [includeLabels, setIncludeLabels] = useState(true);
|
||||
const [includeSettings, setIncludeSettings] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
const response = await apiRequest("POST", "/api/user/data-export", {
|
||||
includeTasks,
|
||||
includeLabels,
|
||||
includeSettings
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Export failed");
|
||||
|
||||
// Handle blob download
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `taskflow_export_${user?.username}_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast({ title: t('settings.export.success', "Data exported successfully") });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
title: t('settings.export.error', "Failed to export data"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileJson className="w-5 h-5" />
|
||||
{t('settings.export.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('settings.export.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="tasks"
|
||||
checked={includeTasks}
|
||||
onCheckedChange={(c) => setIncludeTasks(!!c)}
|
||||
/>
|
||||
<Label htmlFor="tasks">{t('settings.export.tasks')}</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="labels"
|
||||
checked={includeLabels}
|
||||
onCheckedChange={(c) => setIncludeLabels(!!c)}
|
||||
/>
|
||||
<Label htmlFor="labels">{t('settings.export.labels')}</Label>
|
||||
</div>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="settings"
|
||||
checked={includeSettings}
|
||||
onCheckedChange={(c) => setIncludeSettings(!!c)}
|
||||
/>
|
||||
<Label htmlFor="settings">
|
||||
{t('settings.export.systemSettings')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || (!includeTasks && !includeLabels && !includeSettings)}
|
||||
data-testid="button-export-data"
|
||||
>
|
||||
{isExporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{t('settings.export.button')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Task } from '@shared/schema';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
|
||||
// Convert VAPID public key from base64 to Uint8Array (required for subscription)
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// Check if push notifications are supported
|
||||
function isPushSupported(): boolean {
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window;
|
||||
}
|
||||
|
||||
// Check if running as installed PWA (standalone)
|
||||
function isStandalone(): boolean {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true;
|
||||
}
|
||||
|
||||
export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [permission, setPermission] = useState<NotificationPermission>('default');
|
||||
const [pushSupported, setPushSupported] = useState(false);
|
||||
const [pushSubscription, setPushSubscription] = useState<PushSubscription | null>(null);
|
||||
const [serviceWorkerReady, setServiceWorkerReady] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
// Track notified tasks to prevent duplicate alerts in same session
|
||||
const notifiedTasksRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Check support and register service worker on mount
|
||||
useEffect(() => {
|
||||
const initializePush = async () => {
|
||||
if (!isPushSupported()) {
|
||||
console.log('[Notifications] Push not supported');
|
||||
setPushSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPushSupported(true);
|
||||
|
||||
try {
|
||||
// Register service worker
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/'
|
||||
});
|
||||
console.log('[Notifications] Service worker registered:', registration.scope);
|
||||
|
||||
// Wait for service worker to be ready
|
||||
await navigator.serviceWorker.ready;
|
||||
setServiceWorkerReady(true);
|
||||
|
||||
// Check current subscription
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
setPushSubscription(subscription);
|
||||
|
||||
// Update permission state
|
||||
if ('Notification' in window) {
|
||||
setPermission(Notification.permission);
|
||||
const isEnabled = localStorage.getItem('taskflow-push-enabled') === 'true';
|
||||
setEnabled(isEnabled && Notification.permission === 'granted' && subscription !== null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Service worker registration failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializePush();
|
||||
}, []);
|
||||
|
||||
// Fetch VAPID public key
|
||||
const { data: vapidData } = useQuery<{ publicKey: string }>({
|
||||
queryKey: ['/api/push/vapid-public-key'],
|
||||
enabled: serviceWorkerReady && pushSupported,
|
||||
retry: false,
|
||||
staleTime: Infinity, // VAPID key doesn't change
|
||||
});
|
||||
|
||||
// Subscribe mutation
|
||||
const subscribeMutation = useMutation({
|
||||
mutationFn: async (subscription: PushSubscription) => {
|
||||
const response = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription: subscription.toJSON() }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to subscribe');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
localStorage.setItem('taskflow-push-enabled', 'true');
|
||||
setEnabled(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Unsubscribe mutation
|
||||
const unsubscribeMutation = useMutation({
|
||||
mutationFn: async (endpoint: string) => {
|
||||
const response = await fetch('/api/push/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to unsubscribe');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
localStorage.setItem('taskflow-push-enabled', 'false');
|
||||
setEnabled(false);
|
||||
setPushSubscription(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Request permission and subscribe to push
|
||||
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||
if (!pushSupported || !serviceWorkerReady || !vapidData?.publicKey) {
|
||||
console.log('[Notifications] Cannot request permission - prerequisites not met');
|
||||
|
||||
// Fallback to basic notification permission
|
||||
if ('Notification' in window) {
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
return result === 'granted';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Request notification permission
|
||||
const permissionResult = await Notification.requestPermission();
|
||||
setPermission(permissionResult);
|
||||
|
||||
if (permissionResult !== 'granted') {
|
||||
console.log('[Notifications] Permission denied');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get service worker registration
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
// Check for existing subscription
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
// Create new subscription
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidData.publicKey);
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
});
|
||||
console.log('[Notifications] New push subscription created');
|
||||
}
|
||||
|
||||
setPushSubscription(subscription);
|
||||
|
||||
// Send subscription to server
|
||||
await subscribeMutation.mutateAsync(subscription);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Failed to subscribe:', error);
|
||||
return false;
|
||||
}
|
||||
}, [pushSupported, serviceWorkerReady, vapidData?.publicKey, subscribeMutation]);
|
||||
|
||||
// Toggle notifications on/off
|
||||
const toggleEnabled = useCallback(async (value: boolean) => {
|
||||
if (value) {
|
||||
// Enable notifications
|
||||
if (permission !== 'granted' || !pushSubscription) {
|
||||
await requestPermission();
|
||||
} else {
|
||||
setEnabled(true);
|
||||
localStorage.setItem('taskflow-push-enabled', 'true');
|
||||
}
|
||||
} else {
|
||||
// Disable notifications
|
||||
if (pushSubscription) {
|
||||
try {
|
||||
await pushSubscription.unsubscribe();
|
||||
await unsubscribeMutation.mutateAsync(pushSubscription.endpoint);
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Failed to unsubscribe:', error);
|
||||
}
|
||||
}
|
||||
setEnabled(false);
|
||||
localStorage.setItem('taskflow-push-enabled', 'false');
|
||||
}
|
||||
}, [permission, pushSubscription, requestPermission, unsubscribeMutation]);
|
||||
|
||||
// Send test notification
|
||||
const sendTestNotification = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/push/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Query tasks for fallback polling (when push isn't available or app is open)
|
||||
const { data: tasks } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
enabled: enabled && poll && !pushSupported // Only poll if push isn't supported
|
||||
});
|
||||
|
||||
// Fallback Polling Logic (for when push isn't available)
|
||||
useEffect(() => {
|
||||
if (!poll || !enabled || !tasks || permission !== 'granted' || pushSupported) return;
|
||||
|
||||
const checkTasks = () => {
|
||||
const now = new Date();
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (!task.dueDate || task.status === 'done' || notifiedTasksRef.current.has(task.id)) return;
|
||||
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const diffMs = dueDate.getTime() - now.getTime();
|
||||
const diffMinutes = diffMs / (1000 * 60);
|
||||
|
||||
// Alert: Upcoming (15 min before)
|
||||
if (diffMinutes > 0 && diffMinutes <= 15) {
|
||||
sendNotification(task, 'upcoming');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
|
||||
// Alert: Just Overdue
|
||||
if (diffMinutes < 0) {
|
||||
sendNotification(task, 'overdue');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const sendNotification = (task: Task, type: 'upcoming' | 'overdue') => {
|
||||
const title = type === 'upcoming'
|
||||
? t('notifications.upcomingTitle', { task: task.title })
|
||||
: t('notifications.overdueTitle', { task: task.title });
|
||||
|
||||
const body = type === 'upcoming'
|
||||
? t('notifications.upcomingBody', { time: formatDistanceToNow(new Date(task.dueDate!), { locale: i18n.language === 'de' ? de : enUS }) })
|
||||
: t('notifications.overdueBody');
|
||||
|
||||
new Notification(title, {
|
||||
body,
|
||||
tag: `task-${task.id}-${type}`
|
||||
});
|
||||
};
|
||||
|
||||
checkTasks();
|
||||
const interval = setInterval(checkTasks, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enabled, tasks, permission, t, i18n.language, poll, pushSupported]);
|
||||
|
||||
return {
|
||||
permission,
|
||||
enabled,
|
||||
pushSupported,
|
||||
pushSubscription,
|
||||
isStandalone: isStandalone(),
|
||||
serviceWorkerReady,
|
||||
requestPermission,
|
||||
toggleEnabled,
|
||||
sendTestNotification,
|
||||
isLoading: subscribeMutation.isPending || unsubscribeMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"gamification": {
|
||||
"rules": {
|
||||
"title": "Gamification Rules",
|
||||
"xpSystem": "XP System",
|
||||
"levelRequirements": "Level Requirements",
|
||||
"actions": "Actions & Rewards",
|
||||
"level": "Level {{level}}",
|
||||
"xp": "{{xp}} XP",
|
||||
"action": "Action",
|
||||
"points": "Points",
|
||||
"createTask": "Create Task",
|
||||
"createSubtask": "Create Subtask",
|
||||
"updateTask": "Update Task",
|
||||
"completeTask": "Complete Task (On Time)",
|
||||
"completeTaskLate": "Complete Task (Late)",
|
||||
"aiAction": "Use AI Feature",
|
||||
"dailyStreak": "Daily Streak Bonus"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Prevent horizontal scroll globally — only on root, not nested containers */
|
||||
#root {
|
||||
overflow-x: clip;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
/* LIGHT MODE */
|
||||
:root {
|
||||
--button-outline: rgba(0,0,0, .10);
|
||||
@@ -48,7 +54,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 +83,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);
|
||||
@@ -326,3 +334,272 @@
|
||||
inset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ADHD MODE STYLES
|
||||
Calmer colors, reduced cognitive load,
|
||||
larger touch targets, and time-awareness
|
||||
============================================ */
|
||||
|
||||
/* ADHD Mode Variables */
|
||||
:root {
|
||||
--adhd-timer-green: 142 76% 36%;
|
||||
--adhd-timer-yellow: 45 93% 47%;
|
||||
--adhd-timer-red: 0 84% 60%;
|
||||
--adhd-calm-primary: 210 40% 52%;
|
||||
--adhd-calm-accent: 199 89% 48%;
|
||||
--adhd-success-green: 142 71% 45%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--adhd-timer-green: 142 76% 46%;
|
||||
--adhd-timer-yellow: 45 93% 57%;
|
||||
--adhd-timer-red: 0 84% 65%;
|
||||
--adhd-calm-primary: 210 50% 62%;
|
||||
--adhd-calm-accent: 199 89% 58%;
|
||||
--adhd-success-green: 142 71% 55%;
|
||||
}
|
||||
|
||||
/* ADHD Mode - Global adjustments when enabled */
|
||||
.adhd-mode {
|
||||
/* Calmer color palette */
|
||||
--primary: var(--adhd-calm-primary);
|
||||
--ring: var(--adhd-calm-accent);
|
||||
|
||||
/* Larger border radius for softer appearance */
|
||||
--radius: 1.25rem;
|
||||
}
|
||||
|
||||
.adhd-mode .card,
|
||||
.adhd-mode .dialog-content,
|
||||
.adhd-mode .popover-content {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Reduced Motion - Disable animations for users who need focus */
|
||||
.reduced-motion,
|
||||
.reduced-motion * {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
|
||||
/* Larger Touch Targets for ADHD Mode */
|
||||
/* Only increase height, not width - width breaks flex layouts and icon buttons */
|
||||
/* Exclude switches, checkboxes, and sliders from larger targets - they have specific sizing */
|
||||
.larger-targets button:not([data-size="icon"]):not([role="switch"]):not([role="checkbox"]),
|
||||
.larger-targets [role="button"]:not([data-size="icon"]):not([role="switch"]):not([role="checkbox"]) {
|
||||
min-height: 44px;
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
/* Icon buttons get increased size as square - but not switches */
|
||||
.larger-targets button[data-size="icon"]:not([role="switch"]),
|
||||
.larger-targets [role="button"][data-size="icon"]:not([role="switch"]) {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Form inputs and selects - exclude checkbox and switch */
|
||||
.larger-targets input:not([type="checkbox"]):not([role="switch"]),
|
||||
.larger-targets select,
|
||||
.larger-targets textarea {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Links only need height adjustment when they look like buttons */
|
||||
.larger-targets a[role="button"] {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.larger-targets .sidebar-menu-button {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* Checkboxes and switches should maintain their aspect ratio */
|
||||
.larger-targets [role="switch"],
|
||||
.larger-targets [role="checkbox"],
|
||||
.larger-targets input[type="checkbox"] {
|
||||
min-height: auto !important;
|
||||
min-width: auto !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* Specific fix for Radix checkbox buttons - ensure square aspect ratio */
|
||||
.larger-targets button[role="checkbox"] {
|
||||
height: 1rem !important;
|
||||
width: 1rem !important;
|
||||
min-height: 1rem !important;
|
||||
min-width: 1rem !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Visual Timer Color Classes */
|
||||
.timer-green {
|
||||
color: hsl(var(--adhd-timer-green));
|
||||
}
|
||||
|
||||
.timer-yellow {
|
||||
color: hsl(var(--adhd-timer-yellow));
|
||||
}
|
||||
|
||||
.timer-red {
|
||||
color: hsl(var(--adhd-timer-red));
|
||||
}
|
||||
|
||||
.timer-stroke-green {
|
||||
stroke: hsl(var(--adhd-timer-green));
|
||||
}
|
||||
|
||||
.timer-stroke-yellow {
|
||||
stroke: hsl(var(--adhd-timer-yellow));
|
||||
}
|
||||
|
||||
.timer-stroke-red {
|
||||
stroke: hsl(var(--adhd-timer-red));
|
||||
}
|
||||
|
||||
/* Break Reminder Pulse Animation */
|
||||
@keyframes break-reminder-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.break-reminder-pulse {
|
||||
animation: break-reminder-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Encouragement Toast Animation */
|
||||
@keyframes encouragement-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.encouragement-bounce {
|
||||
animation: encouragement-bounce 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Quick Win Card Highlight */
|
||||
@keyframes quick-win-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px 0 hsl(var(--adhd-success-green) / 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px 2px hsl(var(--adhd-success-green) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-win-highlight {
|
||||
animation: quick-win-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* XP Micro-Reward Animation */
|
||||
@keyframes xp-float {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.xp-float {
|
||||
animation: xp-float 1s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Focus Mode Overlay */
|
||||
.single-task-overlay {
|
||||
backdrop-filter: blur(4px);
|
||||
background: hsl(var(--background) / 0.95);
|
||||
}
|
||||
|
||||
/* Energy Level Indicators */
|
||||
.energy-low {
|
||||
border-left: 4px solid hsl(var(--adhd-timer-red));
|
||||
}
|
||||
|
||||
.energy-medium {
|
||||
border-left: 4px solid hsl(var(--adhd-timer-yellow));
|
||||
}
|
||||
|
||||
.energy-high {
|
||||
border-left: 4px solid hsl(var(--adhd-timer-green));
|
||||
}
|
||||
|
||||
/* Hyperfocus Warning Styles */
|
||||
@keyframes hyperfocus-warning {
|
||||
0%, 100% {
|
||||
border-color: hsl(var(--adhd-timer-yellow));
|
||||
}
|
||||
50% {
|
||||
border-color: hsl(var(--adhd-timer-red));
|
||||
}
|
||||
}
|
||||
|
||||
.hyperfocus-warning {
|
||||
animation: hyperfocus-warning 1.5s ease-in-out infinite;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* Body Doubling Session Active Indicator */
|
||||
@keyframes session-active-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--adhd-calm-accent) / 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px hsl(var(--adhd-calm-accent) / 0);
|
||||
}
|
||||
}
|
||||
|
||||
.session-active {
|
||||
animation: session-active-pulse 2s ease-out infinite;
|
||||
}
|
||||
|
||||
/* ADHD Settings Panel Styles */
|
||||
.adhd-settings-section {
|
||||
@apply p-4 rounded-xl bg-muted/50 border border-border;
|
||||
}
|
||||
|
||||
.adhd-settings-section h3 {
|
||||
@apply text-lg font-semibold mb-4 flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* Task Breakdown Subtask Styles */
|
||||
.subtask-item {
|
||||
@apply p-3 rounded-lg bg-background border border-border hover:border-primary/50 transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.subtask-item.completed {
|
||||
@apply bg-muted/50 opacity-70;
|
||||
}
|
||||
|
||||
/* Five Minute Starter Progress Ring */
|
||||
.five-min-progress {
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Calm Background for Focus Sessions */
|
||||
.calm-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--background)) 0%,
|
||||
hsl(var(--adhd-calm-primary) / 0.05) 50%,
|
||||
hsl(var(--adhd-calm-accent) / 0.05) 100%
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||