Compare commits
38 Commits
d1736c5991
...
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 |
@@ -1 +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
|
||||
@@ -48,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"]
|
||||
|
||||
@@ -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) {
|
||||
@@ -7,8 +7,25 @@
|
||||
<meta name="description"
|
||||
content="Personal task management app with calendar scheduling, kanban boards, and time tracking" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
|
||||
<link rel="icon" type="image/png" href="/attached_assets/generated_images/TaskFlow_app_logo_icon_3a0e77e9.png" />
|
||||
<link rel="apple-touch-icon" href="/attached_assets/generated_images/TaskFlow_app_logo_icon_3a0e77e9.png" />
|
||||
|
||||
<!-- 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
|
||||
|
||||
|
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()
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -16,6 +16,18 @@ import {
|
||||
|
||||
import { Switch, Route, useLocation } from "wouter";
|
||||
|
||||
// ADHD Mode Components
|
||||
import {
|
||||
ADHDModeProvider,
|
||||
useADHDMode,
|
||||
BreakReminderProvider,
|
||||
BreakReminderOverlay,
|
||||
EnergyCheckIn,
|
||||
HyperfocusGuard,
|
||||
EncouragementToast,
|
||||
useEncouragement
|
||||
} from '@/components/adhd';
|
||||
|
||||
// Components
|
||||
import TaskCreationModal from './components/TaskCreationModal';
|
||||
import TaskDetailsModal from './components/TaskDetailsModal';
|
||||
@@ -33,6 +45,8 @@ 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";
|
||||
@@ -42,19 +56,42 @@ 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 { AiChat } from "@/components/AiChat";
|
||||
import ForgotPasswordPage from "@/pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "@/pages/ResetPasswordPage";
|
||||
import AiChatPage from "@/pages/AiChatPage";
|
||||
import NotificationsPage from "@/pages/NotificationsPage";
|
||||
import FocusRoutinePage from "@/pages/FocusRoutinePage";
|
||||
|
||||
|
||||
import { useRoutineBlocker } from './components/RoutineBlocker';
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { User } from "@shared/schema";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
|
||||
function App() {
|
||||
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 { toast } = useToast();
|
||||
const [, setLocation] = useLocation();
|
||||
const isBlocked = useRoutineBlocker();
|
||||
const { isEnabled: adhdEnabled, settings: adhdSettings } = useADHDMode();
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
|
||||
@@ -63,6 +100,9 @@ function App() {
|
||||
const [showPomodoro, setShowPomodoro] = useState(false);
|
||||
const [activePomodoroTaskId, setActivePomodoroTaskId] = useState<string | null>(null);
|
||||
|
||||
// Enable global notifications polling
|
||||
useNotifications({ poll: true });
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: user, isLoading: isLoadingUser } = useQuery<User>({
|
||||
@@ -75,19 +115,21 @@ function App() {
|
||||
});
|
||||
|
||||
// Fetch tasks and labels using React Query
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
const { data: tasksData } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
enabled: !!user,
|
||||
select: (data) => data.map(task => ({
|
||||
...task,
|
||||
dueDate: task.dueDate ? new Date(task.dueDate) : null
|
||||
}))
|
||||
});
|
||||
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
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 {
|
||||
@@ -96,17 +138,17 @@ 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 || null
|
||||
estimatedDuration: newTask.estimatedDuration || undefined
|
||||
})
|
||||
});
|
||||
|
||||
@@ -125,8 +167,18 @@ function App() {
|
||||
// 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"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -240,8 +292,14 @@ function App() {
|
||||
|
||||
if (isLoadingUser) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
@@ -256,13 +314,33 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (isBlocked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background w-full">
|
||||
<Switch>
|
||||
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
|
||||
{/* Catch all redirects to nothing, the hook handles the push to routine page */}
|
||||
<Route component={() => <div />} />
|
||||
</Switch>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/forgot-password" component={ForgotPasswordPage} />
|
||||
<Route path="/reset-password" component={ResetPasswordPage} />
|
||||
<Route component={AuthPage} />
|
||||
</Switch>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,15 +349,18 @@ function App() {
|
||||
<SidebarProvider>
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset>
|
||||
<div className="flex flex-col min-h-screen bg-background">
|
||||
{/* Mobile Header trigger */}
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 md:hidden">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="font-semibold">{t('app.title')}</div>
|
||||
<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 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>
|
||||
<span className="font-bold text-lg">{t('app.title')}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-4 md:p-6 max-w-screen-2xl mx-auto w-full relative">
|
||||
<main className="flex-1 p-4 md:p-6 w-full relative">
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<FocusMode
|
||||
@@ -292,42 +373,39 @@ function App() {
|
||||
onNavigateToTasks={() => setLocation('/tasks')}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/tasks">
|
||||
<TasksWithCalendar
|
||||
<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}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
onTaskClick={handleTaskClick}
|
||||
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
<CalendarView tasks={tasks} />
|
||||
</Route>
|
||||
<Route path="/weeklist">
|
||||
<WeekListView tasks={tasks} onTaskUpdate={handleTaskUpdate} />
|
||||
</Route>
|
||||
<Route path="/kanban">
|
||||
<KanbanBoard
|
||||
tasks={tasks}
|
||||
onTaskStatusChange={handleTaskStatusChange}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskClick={handleTaskClick}
|
||||
onTaskEdit={handleTaskClick}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
onStartTimer={startTimer}
|
||||
onStopTimer={stopTimer}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/weeklist">
|
||||
<WeekListView
|
||||
<Route path="/auth">
|
||||
{() => {
|
||||
setLocation('/');
|
||||
return null;
|
||||
}}
|
||||
</Route>
|
||||
<Route path="/tasks">
|
||||
<TasksWithCalendar
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskEdit={handleTaskClick}
|
||||
@@ -347,21 +425,20 @@ function App() {
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/achievements">
|
||||
{user ? <AchievementsPage user={user} /> : <AuthPage />}
|
||||
{() => <AchievementsPage user={user} />}
|
||||
</Route>
|
||||
|
||||
<Route path="/unscheduled">
|
||||
{user ? (
|
||||
<UnscheduledTasksPage
|
||||
user={user}
|
||||
onToggleCompletion={(taskId, currentStatus) => handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')}
|
||||
onDelete={(id) => setDeleteTaskId(id)}
|
||||
onUpdate={handleTaskUpdate}
|
||||
onSelect={setSelectedTask}
|
||||
/>
|
||||
) : (
|
||||
<AuthPage />
|
||||
)}
|
||||
<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 />
|
||||
@@ -369,18 +446,32 @@ function App() {
|
||||
<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' && (
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<Route path="/admin/users" component={AdminUserManagement} />
|
||||
<Route path="/admin/settings" component={AdminSettings} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
|
||||
|
||||
{/* ADHD Mode Routes */}
|
||||
<Route path="/adhd" component={ADHDDashboardPage} />
|
||||
<Route path="/adhd/quick-wins" component={QuickWinsPage} />
|
||||
<Route path="/adhd/single-task" component={SingleTaskPage} />
|
||||
<Route path="/adhd/body-doubling" component={BodyDoublingPage} />
|
||||
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
@@ -396,67 +487,85 @@ function App() {
|
||||
<Plus className="h-6 w-6 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
<CommandPalette
|
||||
onNavigate={(path) => setLocation(path.startsWith('/') ? path : `/${path}`)}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
|
||||
<PomodoroOverlay
|
||||
isOpen={showPomodoro}
|
||||
onClose={() => setShowPomodoro(false)}
|
||||
taskId={activePomodoroTaskId}
|
||||
taskTitle={tasks.find(t => t.id === activePomodoroTaskId)?.title || 'Quick Focus'}
|
||||
onCompleteTask={(id) => handleTaskUpdate(id, { status: 'done', isTracking: false })}
|
||||
/>
|
||||
|
||||
<Toaster />
|
||||
|
||||
{/* ADHD Mode Overlays */}
|
||||
{adhdEnabled && <BreakReminderOverlay />}
|
||||
{adhdEnabled && adhdSettings.hyperfocusProtection && <HyperfocusGuard />}
|
||||
{adhdEnabled && adhdSettings.positiveMessagingLevel !== 'minimal' && <EncouragementToastWrapper />}
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSave={handleCreateTask}
|
||||
/>
|
||||
|
||||
<TaskDetailsModal
|
||||
isOpen={isTaskDetailsOpen}
|
||||
onClose={handleTaskDetailsClose}
|
||||
task={selectedTask}
|
||||
onSave={handleTaskDetailsSave}
|
||||
labels={labels}
|
||||
onNavigate={(id) => {
|
||||
const t = tasks.find(x => x.id === id);
|
||||
if (t) setSelectedTask(t);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteTaskId !== null} onOpenChange={(open) => !open && setDeleteTaskId(null)}>
|
||||
<AlertDialogContent data-testid="dialog-delete-confirmation">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteConfirmation.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteConfirmation.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-testid="button-cancel-delete">
|
||||
{t('deleteConfirmation.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmTaskDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
data-testid="button-confirm-delete"
|
||||
>
|
||||
{t('deleteConfirmation.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
<CommandPalette
|
||||
onNavigate={(path) => setLocation(path.startsWith('/') ? path : `/${path}`)}
|
||||
onCreateTask={() => setIsCreateModalOpen(true)}
|
||||
/>
|
||||
|
||||
<PomodoroOverlay
|
||||
isOpen={showPomodoro}
|
||||
onClose={() => setShowPomodoro(false)}
|
||||
taskId={activePomodoroTaskId}
|
||||
taskTitle={tasks.find(t => t.id === activePomodoroTaskId)?.title || 'Quick Focus'}
|
||||
onCompleteTask={(id) => handleTaskUpdate(id, { status: 'done', isTracking: false })}
|
||||
/>
|
||||
|
||||
<Toaster />
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onSave={handleCreateTask}
|
||||
/>
|
||||
|
||||
<TaskDetailsModal
|
||||
isOpen={isTaskDetailsOpen}
|
||||
onClose={handleTaskDetailsClose}
|
||||
task={selectedTask}
|
||||
onSave={handleTaskDetailsSave}
|
||||
labels={labels}
|
||||
onNavigate={(id) => {
|
||||
const t = tasks.find(x => x.id === id);
|
||||
if (t) setSelectedTask(t);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteTaskId !== null} onOpenChange={(open) => !open && setDeleteTaskId(null)}>
|
||||
<AlertDialogContent data-testid="dialog-delete-confirmation">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('deleteConfirmation.title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteConfirmation.description')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-testid="button-cancel-delete">
|
||||
{t('deleteConfirmation.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmTaskDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
data-testid="button-confirm-delete"
|
||||
>
|
||||
{t('deleteConfirmation.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Main App wrapper with ADHD providers
|
||||
function App() {
|
||||
return (
|
||||
<ADHDModeProvider>
|
||||
<BreakReminderProvider>
|
||||
<AppContent />
|
||||
</BreakReminderProvider>
|
||||
</ADHDModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, Loader2 } from 'lucide-react';
|
||||
import { Bot, X, Send, Sparkles } from 'lucide-react';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -13,11 +13,24 @@ interface Message {
|
||||
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({
|
||||
@@ -31,25 +44,35 @@ export function AiChat() {
|
||||
},
|
||||
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.error') + ": " + err.message }]);
|
||||
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, mutation.isPending]);
|
||||
}, [messages, isOpen, isLoading]);
|
||||
|
||||
const handleSubmit = (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!input.trim() || mutation.isPending) return;
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const newMsgs: Message[] = [...messages, { role: 'user', content: input }];
|
||||
setMessages(newMsgs);
|
||||
setInput('');
|
||||
setIsWaitingForResponse(true);
|
||||
mutation.mutate(newMsgs);
|
||||
};
|
||||
|
||||
@@ -70,7 +93,12 @@ export function AiChat() {
|
||||
<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.title')}
|
||||
{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" />
|
||||
@@ -81,7 +109,7 @@ export function AiChat() {
|
||||
{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">{t('ai.welcome')}</p>
|
||||
<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) => (
|
||||
@@ -96,11 +124,11 @@ export function AiChat() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mutation.isPending && (
|
||||
{isLoading && (
|
||||
<div className="flex w-full justify-start">
|
||||
<div className="bg-muted rounded-2xl rounded-bl-none px-4 py-2 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-xs text-muted-foreground">{t('ai.thinking')}</span>
|
||||
<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>
|
||||
)}
|
||||
@@ -111,10 +139,11 @@ export function AiChat() {
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t('ai.placeholder')}
|
||||
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={mutation.isPending || !input.trim()}>
|
||||
<Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff } from 'lucide-react';
|
||||
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";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
SidebarTrigger,
|
||||
SidebarSeparator,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Task, User } from '@shared/schema';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
@@ -44,8 +45,9 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(["/api/user"], null);
|
||||
toast({ title: "Logged out successfully" });
|
||||
queryClient.clear();
|
||||
// toast({ title: "Logged out successfully" });
|
||||
window.location.href = "/auth?mode=login";
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,8 +58,10 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
||||
{ 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' },
|
||||
]
|
||||
@@ -68,13 +72,13 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground h-14">
|
||||
<div className="flex aspect-square size-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-600 to-indigo-600 text-white shadow-lg">
|
||||
<CheckSquare className="size-5" />
|
||||
<div 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">Personal</span>
|
||||
<span className="truncate text-xs opacity-70">Focus. Flow. AI.</span>
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
@@ -103,18 +107,31 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
||||
))}
|
||||
</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-3 items-center'
|
||||
: '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"
|
||||
@@ -122,7 +139,7 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
||||
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="Logout"
|
||||
title={t('auth.logout', 'Logout')}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
HoverCard,
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalP
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="w-[95vw] max-w-lg sm:max-w-md p-4 sm:p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('auth.changePassword')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Task } from '@shared/schema';
|
||||
import TaskCard from './TaskCard';
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trophy, Target, Calendar as CalendarIcon, ArrowRight, GripVertical } from 'lucide-react';
|
||||
import { Trophy, Target, Calendar as CalendarIcon, ArrowRight, GripVertical, Sun, Moon } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -190,13 +190,35 @@ export default function FocusMode({
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="grid grid-rows-2 gap-4 h-full"
|
||||
>
|
||||
<Card className="p-6 h-full flex flex-col justify-center items-center text-center bg-card hover:shadow-md transition-all cursor-pointer" onClick={onNavigateToTasks}>
|
||||
<Target className="w-12 h-12 text-muted-foreground/20 mb-4" />
|
||||
<h3 className="font-semibold text-lg">{t('focus.viewAll.title')}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{t('focus.viewAll.description')}</p>
|
||||
<Button variant="outline" className="gap-2 group">
|
||||
{t('focus.viewAll.button')} <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
{/* 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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -46,12 +47,18 @@ export function GamificationBar({ xp, streak }: GamificationBarProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-orange-600 bg-orange-500/10 px-2 py-1 rounded-full">
|
||||
<Flame className="size-3 fill-orange-600" />
|
||||
<span className="text-xs font-bold">
|
||||
{t('gamification.streak', { count: streak })}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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}`}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function PomodoroOverlay({ taskId, taskTitle, isOpen, onClose, on
|
||||
// Initialize Worker
|
||||
useEffect(() => {
|
||||
if (typeof Worker !== 'undefined') {
|
||||
workerRef.current = new Worker(new URL('../workers/timer.worker.ts', import.meta.url));
|
||||
workerRef.current = new Worker(new URL('../workers/timer.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
workerRef.current.onmessage = (e) => {
|
||||
const { type, remaining } = e.data;
|
||||
|
||||
@@ -38,7 +38,14 @@ const convertProjectTaskToTask = (projectTask: ProjectTask, projectId: string):
|
||||
energyLevel: 'medium',
|
||||
estimatedDuration: (projectTask.estimatedHours || 0) * 60,
|
||||
dependencies: [],
|
||||
userId: null
|
||||
userId: null,
|
||||
parentTaskId: null,
|
||||
startDate: null,
|
||||
isRecurring: false,
|
||||
recurrenceInterval: null,
|
||||
recurrenceIntervalValue: null,
|
||||
recurrenceDays: null,
|
||||
recurrenceEnd: null
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function ShareAccessModal({ open, onOpenChange }: ShareAccessModalProps)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<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" />
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock, CornerDownRight } from "lucide-react";
|
||||
import { Task, Label } from '@shared/schema';
|
||||
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';
|
||||
@@ -31,6 +32,10 @@ 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;
|
||||
@@ -40,13 +45,10 @@ interface TaskCardProps {
|
||||
onDelete?: () => void;
|
||||
onStatusChange?: (status: Task['status']) => void;
|
||||
onUpdate?: (updates: Partial<Task>) => void;
|
||||
onAutoSchedule?: () => void;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
import { ShareTaskModal } from './ShareTaskModal';
|
||||
import { Share2, Users } from "lucide-react";
|
||||
import { User } from "@shared/schema";
|
||||
|
||||
function SharedTaskIcon({ task }: { task: Task }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
|
||||
@@ -118,8 +120,9 @@ function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void })
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, isDragging }: TaskCardProps) {
|
||||
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);
|
||||
|
||||
@@ -201,6 +204,56 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
return `${hours}h ${mins}m`;
|
||||
};
|
||||
|
||||
/* 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
|
||||
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" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.create', 'Create')}
|
||||
</Button>
|
||||
),
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Follow-up check failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleComplete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isBlocked) return;
|
||||
@@ -210,6 +263,8 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
triggerConfetti(e.clientX / window.innerWidth, e.clientY / window.innerHeight);
|
||||
playSuccessSound();
|
||||
onStatusChange?.('done');
|
||||
// Trigger AI check
|
||||
checkFollowUp();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -240,6 +295,7 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'clip' }}>
|
||||
<motion.div
|
||||
drag="x"
|
||||
dragConstraints={{ left: -50, right: 150 }} // Limit swipe distance
|
||||
@@ -248,6 +304,7 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
animate={controls}
|
||||
whileDrag={{ scale: 1.02, cursor: 'grabbing' }}
|
||||
className="touch-pan-y" // Allow vertical scroll, horizontal swipe
|
||||
style={{ overscrollBehaviorX: 'none' }}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -278,7 +335,7 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
<TooltipContent>
|
||||
{isBlocked ? (
|
||||
<p className="text-destructive font-medium">
|
||||
Blocked by: {blockingTasks.map(t => t?.title).join(", ")}
|
||||
{t('taskCard.blockedBy', { tasks: blockingTasks.map(t => t?.title).join(", ") })}
|
||||
</p>
|
||||
) : (
|
||||
<p>{t('taskCard.toggleComplete')}</p>
|
||||
@@ -296,10 +353,10 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
{/* 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">
|
||||
<span className="text-muted-foreground font-normal">{parentTask.title}</span>
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<span>{task.title}</span>
|
||||
<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
|
||||
@@ -436,6 +493,20 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
{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();
|
||||
@@ -632,5 +703,6 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
|
||||
onOpenChange={setIsShareModalOpen}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,12 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
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 } from 'lucide-react';
|
||||
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;
|
||||
@@ -38,16 +39,24 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
|
||||
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: tasks = [] } = useQuery<Task[]>({
|
||||
const { data: tasksData } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
const tasks = tasksData ?? [];
|
||||
|
||||
const handleSave = () => {
|
||||
if (!title.trim()) {
|
||||
@@ -62,6 +71,11 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
energyLevel,
|
||||
estimatedDuration,
|
||||
|
||||
isRecurring,
|
||||
recurrenceInterval,
|
||||
recurrenceIntervalValue,
|
||||
recurrenceEnd,
|
||||
|
||||
startDate,
|
||||
dueDate,
|
||||
labelId,
|
||||
@@ -80,6 +94,10 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setPriority('medium');
|
||||
setDueDate(undefined);
|
||||
setStartDate(undefined);
|
||||
setIsRecurring(false);
|
||||
setRecurrenceInterval(undefined);
|
||||
setRecurrenceIntervalValue(1);
|
||||
setRecurrenceEnd(undefined);
|
||||
setLabelId(undefined);
|
||||
setDependencies([]);
|
||||
setError(null);
|
||||
@@ -99,6 +117,10 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
setPriority('medium');
|
||||
setDueDate(undefined);
|
||||
setStartDate(undefined);
|
||||
setIsRecurring(false);
|
||||
setRecurrenceInterval(undefined);
|
||||
setRecurrenceIntervalValue(1);
|
||||
setRecurrenceEnd(undefined);
|
||||
setLabelId(undefined);
|
||||
setDependencies([]);
|
||||
setError(null);
|
||||
@@ -107,7 +129,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose} >
|
||||
<DialogContent className="sm:max-w-md mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<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" />
|
||||
@@ -160,7 +182,7 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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')} />
|
||||
@@ -174,7 +196,21 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
|
||||
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('taskCreation.energy')} />
|
||||
<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>
|
||||
@@ -183,26 +219,32 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Select
|
||||
value={estimatedDuration ? estimatedDuration.toString() : "0"}
|
||||
onValueChange={(val) => setEstimatedDuration(val === "0" ? undefined : parseInt(val))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('taskCreation.duration')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
|
||||
<SelectItem value="15">15m</SelectItem>
|
||||
<SelectItem value="30">30m</SelectItem>
|
||||
<SelectItem value="45">45m</SelectItem>
|
||||
<SelectItem value="60">1h</SelectItem>
|
||||
<SelectItem value="90">1.5h</SelectItem>
|
||||
<SelectItem value="120">2h</SelectItem>
|
||||
<SelectItem value="240">4h</SelectItem>
|
||||
<SelectItem value="480">8h (1 Day)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div 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>
|
||||
|
||||
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
|
||||
@@ -283,6 +325,42 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
|
||||
</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">
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 } from 'lucide-react';
|
||||
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';
|
||||
@@ -66,15 +66,16 @@ export default function TaskDetailsModal({
|
||||
const [isCreatingSubtask, setIsCreatingSubtask] = useState(false);
|
||||
const [isScheduling, setIsScheduling] = useState(false);
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
const { data: tasksData } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
const tasks = tasksData ?? [];
|
||||
|
||||
// Notes state
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||
|
||||
// Time reporting state
|
||||
// Time reporting state
|
||||
const [isAddingTime, setIsAddingTime] = useState(false);
|
||||
const [hours, setHours] = useState(0);
|
||||
const [minutes, setMinutes] = useState(0);
|
||||
@@ -82,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[]>([]);
|
||||
|
||||
@@ -129,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;
|
||||
|
||||
@@ -321,8 +406,9 @@ export default function TaskDetailsModal({
|
||||
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" />
|
||||
@@ -610,26 +696,30 @@ export default function TaskDetailsModal({
|
||||
|
||||
{/* Estimated Duration */}
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')}</label>
|
||||
<Select
|
||||
value={editedEstimatedDuration ? editedEstimatedDuration.toString() : "0"}
|
||||
onValueChange={(val) => setEditedEstimatedDuration(val === "0" ? undefined : parseInt(val))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('taskCreation.duration')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">{t('taskCreation.durationNone')}</SelectItem>
|
||||
<SelectItem value="15">15m</SelectItem>
|
||||
<SelectItem value="30">30m</SelectItem>
|
||||
<SelectItem value="45">45m</SelectItem>
|
||||
<SelectItem value="60">1h</SelectItem>
|
||||
<SelectItem value="90">1.5h</SelectItem>
|
||||
<SelectItem value="120">2h</SelectItem>
|
||||
<SelectItem value="240">4h</SelectItem>
|
||||
<SelectItem value="480">8h (1 Day)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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 */}
|
||||
@@ -712,7 +802,7 @@ export default function TaskDetailsModal({
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
{/* Save and Mark as Done Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleSaveTaskEdit}
|
||||
@@ -722,6 +812,18 @@ export default function TaskDetailsModal({
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{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>
|
||||
@@ -1008,6 +1110,139 @@ export default function TaskDetailsModal({
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight, Edit, Timer, CheckCircle, Play, Clock, Trash2 } from 'lucide-react';
|
||||
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';
|
||||
@@ -35,14 +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);
|
||||
@@ -59,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);
|
||||
@@ -96,12 +99,23 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -163,8 +177,14 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
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;
|
||||
}
|
||||
@@ -239,7 +259,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-6rem)] relative">
|
||||
<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">
|
||||
@@ -266,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="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>
|
||||
|
||||
@@ -290,9 +339,9 @@ 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>
|
||||
@@ -312,7 +361,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
{unscheduledTasks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground" data-testid="text-no-tasks">
|
||||
{searchQuery || filterBy !== 'all'
|
||||
{searchQuery || (filterBy !== 'all' && filterBy !== 'open')
|
||||
? t('taskList.noMatchingTasks')
|
||||
: t('taskList.noUnscheduledTasks')
|
||||
}
|
||||
@@ -321,7 +370,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-2 mt-2">
|
||||
Task List
|
||||
{t('taskList.listHeader')}
|
||||
</div>
|
||||
{unscheduledTasks.map((task) => (
|
||||
<div
|
||||
@@ -362,7 +411,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
</div>
|
||||
|
||||
{/* Calendar Section - Fixed at bottom */}
|
||||
<div className="flex-none -mx-4 sm:-mx-6 bg-background/95 backdrop-blur z-30 border-t mt-auto shadow-up-lg">
|
||||
<div className="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">
|
||||
@@ -592,7 +641,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
<div className="p-3 border-b bg-muted/30">
|
||||
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>{t('calendar.moreItems', { count: dayTasks.length - 2 })}</span>
|
||||
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
|
||||
<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">
|
||||
@@ -637,7 +686,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
|
||||
? '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>
|
||||
|
||||
@@ -20,14 +20,14 @@ export default function TimeCompletionModal({ isOpen, onClose, onSave, taskTitle
|
||||
|
||||
const handleSave = () => {
|
||||
let totalMinutes = 0;
|
||||
|
||||
|
||||
if (activeTab === 'clock') {
|
||||
totalMinutes = hours * 60 + minutes;
|
||||
} else {
|
||||
const parsedHours = parseFloat(manualHours) || 0;
|
||||
totalMinutes = Math.round(parsedHours * 60);
|
||||
}
|
||||
|
||||
|
||||
onSave(totalMinutes);
|
||||
console.log(`Time logged: ${totalMinutes} minutes for task: ${taskTitle}`);
|
||||
handleClose();
|
||||
@@ -58,14 +58,14 @@ 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" />
|
||||
Task Completed!
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
@@ -96,7 +96,7 @@ export default function TimeCompletionModal({ isOpen, onClose, onSave, taskTitle
|
||||
{formatTime(hours, minutes)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Hours Picker */}
|
||||
<div className="space-y-2">
|
||||
@@ -116,7 +116,7 @@ export default function TimeCompletionModal({ isOpen, onClose, onSave, taskTitle
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Minutes Picker */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-center block">Minutes</label>
|
||||
@@ -147,7 +147,7 @@ export default function TimeCompletionModal({ isOpen, onClose, onSave, taskTitle
|
||||
{getCurrentTimeDisplay()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Hours (decimal)</label>
|
||||
<Input
|
||||
@@ -167,17 +167,17 @@ export default function TimeCompletionModal({ isOpen, onClose, onSave, taskTitle
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="flex-1"
|
||||
data-testid="button-cancel-time"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={activeTab === 'clock' ? hours === 0 && minutes === 0 : !manualHours.trim()}
|
||||
className="flex-1"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -25,6 +26,7 @@ export function AiSettingsCard() {
|
||||
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[]>([]);
|
||||
@@ -36,6 +38,7 @@ export function AiSettingsCard() {
|
||||
setApiKey(settings.ai_api_key || "");
|
||||
setModel(settings.ai_model || "gpt-4o");
|
||||
setBaseUrl(settings.ai_base_url || "");
|
||||
setSystemPrompt(settings.ai_system_prompt || "");
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
@@ -46,10 +49,10 @@ export function AiSettingsCard() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
|
||||
toast({ title: "Settings saved" });
|
||||
toast({ title: t('settings.ai.saved', "Settings saved") });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to save settings", variant: "destructive" });
|
||||
toast({ title: t('settings.ai.error', "Failed to save settings"), variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -62,10 +65,10 @@ export function AiSettingsCard() {
|
||||
onSuccess: (data: any) => {
|
||||
const models = data.models?.map((m: any) => m.name) || [];
|
||||
setOllamaModels(models);
|
||||
toast({ title: `Found ${models.length} models` });
|
||||
toast({ title: t('settings.ai.ollama.found', { count: models.length }) });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Could not connect to Ollama", description: err.message, variant: "destructive" });
|
||||
toast({ title: t('settings.ai.ollama.connectError', "Could not connect to Ollama"), description: err.message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,12 +79,12 @@ export function AiSettingsCard() {
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Model pulled successfully" });
|
||||
toast({ title: t('settings.ai.ollama.pullSuccess', "Model pulled successfully") });
|
||||
setPullModelName("");
|
||||
fetchOllamaModelsMutation.mutate(); // Refresh list
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Failed to pull model", description: err.message, variant: "destructive" });
|
||||
toast({ title: t('settings.ai.ollama.pullError', "Failed to pull model"), description: err.message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -91,7 +94,8 @@ export function AiSettingsCard() {
|
||||
ai_provider: provider,
|
||||
ai_api_key: apiKey,
|
||||
ai_model: model,
|
||||
ai_base_url: baseUrl
|
||||
ai_base_url: baseUrl,
|
||||
ai_system_prompt: systemPrompt
|
||||
});
|
||||
};
|
||||
|
||||
@@ -136,7 +140,7 @@ export function AiSettingsCard() {
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={provider === 'ollama' ? 'Optional for Ollama' : 'sk-...'}
|
||||
placeholder={provider === 'ollama' ? t('settings.ai.ollama.placeholder', 'Optional for Ollama') : 'sk-...'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -217,6 +221,21 @@ export function AiSettingsCard() {
|
||||
</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}>
|
||||
|
||||
@@ -42,34 +42,36 @@ export function AuditLogsTable() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit Logs</CardTitle>
|
||||
<CardDescription>Track all system changes and AI actions.</CardDescription>
|
||||
<CardTitle>{t('settings.auditLogs.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.auditLogs.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Entity</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
<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">
|
||||
{format(new Date(log.createdAt), "MMM d, HH:mm:ss")}
|
||||
{new Date(log.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.source === 'AI' ? 'secondary' : 'outline'}>
|
||||
{log.source}
|
||||
{t(`audit.source.${log.source}`, { defaultValue: log.source })}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{log.action}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{t(`audit.action.${log.action}`, { defaultValue: log.action })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.entityType}
|
||||
{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">
|
||||
@@ -82,7 +84,7 @@ export function AuditLogsTable() {
|
||||
{(!logs || logs.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No logs found.
|
||||
{t('settings.auditLogs.table.empty')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2, Server, Save } from "lucide-react";
|
||||
import { Loader2, Server, Save, Copy } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
|
||||
export function McpSettingsCard() {
|
||||
const { t } = useTranslation();
|
||||
@@ -18,6 +19,38 @@ export function McpSettingsCard() {
|
||||
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'],
|
||||
});
|
||||
@@ -64,19 +97,19 @@ export function McpSettingsCard() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
<CardTitle>MCP Server Configuration</CardTitle>
|
||||
<CardTitle>{t('settings.mcp.title')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure the Model Context Protocol (MCP) server settings.
|
||||
The MCP server runs on the same port as the application (/api/mcp).
|
||||
{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>Enable MCP Server</span>
|
||||
<span>{t('settings.mcp.enableLabel')}</span>
|
||||
<span className="font-normal text-xs text-muted-foreground">
|
||||
Allow external AI tools to connect via MCP protocol.
|
||||
{t('settings.mcp.enableDesc')}
|
||||
</span>
|
||||
</Label>
|
||||
<Switch
|
||||
@@ -86,8 +119,57 @@ export function McpSettingsCard() {
|
||||
/>
|
||||
</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">Port (Informational)</Label>
|
||||
<Label htmlFor="mcp_port">{t('settings.mcp.portLabel')}</Label>
|
||||
<Input
|
||||
id="mcp_port"
|
||||
value={formData.mcp_port}
|
||||
@@ -97,7 +179,7 @@ export function McpSettingsCard() {
|
||||
className="bg-muted"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Currently runs on the main application port. Separate port configuration coming soon.
|
||||
{t('settings.mcp.portDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function SMTPSettingsCard() {
|
||||
mutationFn: (data: Partial<typeof settings>) => apiRequest("POST", "/api/admin/settings", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
|
||||
toast({ title: t('smtp.saveSuccess', "Settings updated") });
|
||||
toast({ title: t('settings.smtp.saveSuccess', "Settings updated") });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +1,286 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 [enabled, setEnabled] = useState(false);
|
||||
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());
|
||||
// Track notified tasks to prevent duplicate alerts in same session
|
||||
const notifiedTasksRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// 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-notifications-enabled') === 'true';
|
||||
setEnabled(isEnabled && Notification.permission === 'granted');
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
if (!('Notification' in window)) return false;
|
||||
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;
|
||||
}
|
||||
|
||||
if (result === 'granted') {
|
||||
setEnabled(true);
|
||||
localStorage.setItem('taskflow-notifications-enabled', 'true');
|
||||
new Notification(t('notifications.enabledTitle'), {
|
||||
body: t('notifications.enabledBody'),
|
||||
// icon: '/favicon.ico' // Chrome sometimes blocks if icon 404
|
||||
});
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// Request notification permission
|
||||
const permissionResult = await Notification.requestPermission();
|
||||
setPermission(permissionResult);
|
||||
|
||||
if (permissionResult !== 'granted') {
|
||||
console.log('[Notifications] Permission denied');
|
||||
return false;
|
||||
}, [t]);
|
||||
}
|
||||
|
||||
const toggleEnabled = useCallback((value: boolean) => {
|
||||
if (value && permission !== 'granted') {
|
||||
requestPermission();
|
||||
} else {
|
||||
setEnabled(value);
|
||||
localStorage.setItem('taskflow-notifications-enabled', String(value));
|
||||
// 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);
|
||||
}
|
||||
}, [permission, requestPermission]);
|
||||
}
|
||||
setEnabled(false);
|
||||
localStorage.setItem('taskflow-push-enabled', 'false');
|
||||
}
|
||||
}, [permission, pushSubscription, requestPermission, unsubscribeMutation]);
|
||||
|
||||
// Query tasks for polling
|
||||
const { data: tasks } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
enabled: enabled && poll // Only poll if enabled AND polling is active
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Polling Logic
|
||||
useEffect(() => {
|
||||
if (!poll || !enabled || !tasks || permission !== 'granted') return;
|
||||
// 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
|
||||
});
|
||||
|
||||
const checkTasks = () => {
|
||||
const now = new Date();
|
||||
// Fallback Polling Logic (for when push isn't available)
|
||||
useEffect(() => {
|
||||
if (!poll || !enabled || !tasks || permission !== 'granted' || pushSupported) return;
|
||||
|
||||
tasks.forEach(task => {
|
||||
if (!task.dueDate || task.status === 'done' || notifiedTasksRef.current.has(task.id)) return;
|
||||
const checkTasks = () => {
|
||||
const now = new Date();
|
||||
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const diffMs = dueDate.getTime() - now.getTime();
|
||||
const diffMinutes = diffMs / (1000 * 60);
|
||||
tasks.forEach(task => {
|
||||
if (!task.dueDate || task.status === 'done' || notifiedTasksRef.current.has(task.id)) return;
|
||||
|
||||
// Alert: Upcoming (15 min before)
|
||||
if (diffMinutes > 0 && diffMinutes <= 15) {
|
||||
sendNotification(task, 'upcoming');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const diffMs = dueDate.getTime() - now.getTime();
|
||||
const diffMinutes = diffMs / (1000 * 60);
|
||||
|
||||
// Alert: Just Overdue (within last 1 min to catch it once)
|
||||
// or purely check if overdue and not notified?
|
||||
// Let's stick to "Just became overdue" or "Is overdue" but protect with Set
|
||||
if (diffMinutes < 0) {
|
||||
sendNotification(task, 'overdue');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Alert: Upcoming (15 min before)
|
||||
if (diffMinutes > 0 && diffMinutes <= 15) {
|
||||
sendNotification(task, 'upcoming');
|
||||
notifiedTasksRef.current.add(task.id);
|
||||
}
|
||||
|
||||
const sendNotification = (task: Task, type: 'upcoming' | 'overdue') => {
|
||||
const title = type === 'upcoming'
|
||||
? t('notifications.upcomingTitle', { task: task.title })
|
||||
: t('notifications.overdueTitle', { task: task.title });
|
||||
|
||||
const body = type === 'upcoming'
|
||||
? t('notifications.upcomingBody', { time: formatDistanceToNow(new Date(task.dueDate!), { locale: i18n.language === 'de' ? de : enUS }) })
|
||||
: t('notifications.overdueBody');
|
||||
|
||||
new Notification(title, {
|
||||
body,
|
||||
// icon: '/favicon.ico',
|
||||
tag: `task-${task.id}-${type}` // prevent duplicate native notifications
|
||||
});
|
||||
};
|
||||
|
||||
// Check immediately and then interval
|
||||
checkTasks();
|
||||
const interval = setInterval(checkTasks, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [enabled, tasks, permission, t, i18n.language]);
|
||||
|
||||
return {
|
||||
permission,
|
||||
enabled,
|
||||
requestPermission,
|
||||
toggleEnabled
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,13 +6,32 @@
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"enableBrowser": "Browser-Benachrichtigungen aktivieren",
|
||||
"enablePush": "Push-Benachrichtigungen",
|
||||
"description": "Erhalten Sie Warnungen für bevorstehende und überfällige Aufgaben.",
|
||||
"pushDescription": "Erhalten Sie Benachrichtigungen auch wenn die App geschlossen ist.",
|
||||
"enabledTitle": "Benachrichtigungen aktiviert",
|
||||
"enabledBody": "Sie erhalten nun Warnungen für Ihre Aufgaben.",
|
||||
"upcomingTitle": "Demnächst: {{task}}",
|
||||
"upcomingBody": "Fällig in {{time}}",
|
||||
"overdueTitle": "Überfällig: {{task}}",
|
||||
"overdueBody": "Diese Aufgabe ist jetzt überfällig!"
|
||||
"overdueBody": "Diese Aufgabe ist jetzt überfällig!",
|
||||
"settings": {
|
||||
"title": "Browser-Benachrichtigungen",
|
||||
"description": "Erhalten Sie Warnungen für bevorstehende und überfällige Aufgaben.",
|
||||
"denied": "Zugriff verweigert. Bitte in den Browsereinstellungen aktivieren.",
|
||||
"request": "Zugriff anfordern"
|
||||
},
|
||||
"recent": "Aktuelle Warnungen",
|
||||
"empty": "Alles erledigt! Keine urgierten Warnungen.",
|
||||
"pushSupported": "Push unterstützt",
|
||||
"installedApp": "Installierte App",
|
||||
"permissionDenied": "Berechtigung vom Browser verweigert. Bitte setzen Sie die Website-Berechtigungen zurück.",
|
||||
"iosHintTitle": "Zum Home-Bildschirm hinzufügen",
|
||||
"iosHintDesc": "Um Push-Benachrichtigungen auf iOS zu erhalten, tippen Sie auf die Teilen-Schaltfläche und wählen Sie \"Zum Home-Bildschirm\", dann öffnen Sie die App von dort.",
|
||||
"sendTest": "Test-Benachrichtigung senden",
|
||||
"testSent": "Test gesendet",
|
||||
"testSentDesc": "Überprüfen Sie Ihr Gerät auf eine Benachrichtigung.",
|
||||
"testFailed": "Test-Benachrichtigung konnte nicht gesendet werden."
|
||||
},
|
||||
"unscheduled": {
|
||||
"title": "Ungeplante Aufgaben",
|
||||
@@ -27,6 +46,7 @@
|
||||
"weekList": "Woche",
|
||||
"create": "Erstellen",
|
||||
"kanban": "Kanban",
|
||||
"adhd": "Fokus-Tools",
|
||||
"achievements": "Erfolge",
|
||||
"leaderboard": "Bestenliste",
|
||||
"settings": "Einstellungen"
|
||||
@@ -39,13 +59,17 @@
|
||||
"noMatchingTasks": "Keine Aufgaben entsprechen deinen Filtern",
|
||||
"noUnscheduledTasks": "Keine ungeplanten Aufgaben. Ziehe Aufgaben in den Kalender unten, um sie zu planen!",
|
||||
"unscheduledTasksLabel": "Ungeplante Aufgaben - In den Kalender ziehen zum Planen",
|
||||
"listHeader": "Aufgabenliste",
|
||||
"calendarTitle": "Nächste {{count}} Tage",
|
||||
"filter": {
|
||||
"open": "Offen",
|
||||
"all": "Alle",
|
||||
"todo": "Zu erledigen",
|
||||
"inProgress": "In Bearbeitung",
|
||||
"done": "Erledigt",
|
||||
"overdue": "Überfällig"
|
||||
"overdue": "Überfällig",
|
||||
"planned": "Geplant",
|
||||
"unplanned": "Ungeplant"
|
||||
},
|
||||
"sort": {
|
||||
"dueDate": "Fälligkeitsdatum",
|
||||
@@ -65,7 +89,9 @@
|
||||
"todo": "Zu erledigen",
|
||||
"inProgress": "In Bearbeitung",
|
||||
"done": "Erledigt",
|
||||
"overdue": "Überfällig"
|
||||
"overdue": "Überfällig",
|
||||
"planned": "Geplant",
|
||||
"unplanned": "Ungeplant"
|
||||
},
|
||||
"sort": {
|
||||
"dueDate": "Fälligkeitsdatum",
|
||||
@@ -93,7 +119,22 @@
|
||||
"minutesPlaceholder": "Minuten (optional)",
|
||||
"duration": "Dauer",
|
||||
"durationNone": "Keine",
|
||||
"startDate": "Startdatum"
|
||||
"startDate": "Startdatum",
|
||||
"recurrence": {
|
||||
"label": "Wiederholen:",
|
||||
"none": "Nie",
|
||||
"daily": "Täglich",
|
||||
"weekly": "Wöchentlich",
|
||||
"monthly": "Monatlich",
|
||||
"yearly": "Jährlich",
|
||||
"every": "Alle",
|
||||
"units": {
|
||||
"daily": "Tage",
|
||||
"weekly": "Wochen",
|
||||
"monthly": "Monate",
|
||||
"yearly": "Jahre"
|
||||
}
|
||||
}
|
||||
},
|
||||
"taskDetails": {
|
||||
"title": "Aufgabendetails",
|
||||
@@ -156,7 +197,15 @@
|
||||
"durationUnits": {
|
||||
"m": "Min",
|
||||
"h": "Std"
|
||||
}
|
||||
},
|
||||
"markAsDone": "Als erledigt markieren",
|
||||
"timeRequired": "Zeitangabe erforderlich",
|
||||
"timeRequiredDesc": "Bitte geben Sie die für diese Aufgabe aufgewendete Zeit ein, bevor Sie sie als erledigt markieren.",
|
||||
"hours": "Stunden",
|
||||
"minutes": "Minuten",
|
||||
"hoursDecimal": "Stunden (dezimal)",
|
||||
"timeExamples": "Beispiele: 0.5 = 30 Min, 1.25 = 1 Std 15 Min",
|
||||
"saveAndMarkDone": "Speichern & Als erledigt markieren"
|
||||
},
|
||||
"taskCard": {
|
||||
"play": "Timer starten",
|
||||
@@ -173,6 +222,7 @@
|
||||
"quickDelete": "Schnell löschen",
|
||||
"toggleComplete": "Aufgabe als erledigt/unerledigt markieren",
|
||||
"running": "läuft",
|
||||
"blockedBy": "Blockiert durch: {{tasks}}",
|
||||
"overdue": "Überfällig",
|
||||
"timeTracked": "{{hours}}Std {{minutes}}Min erfasst"
|
||||
},
|
||||
@@ -182,6 +232,9 @@
|
||||
"weekend": "Wochenende",
|
||||
"previousWeek": "Vorherige Woche",
|
||||
"moreItems": "+{{count}} weitere",
|
||||
"clickToEdit": "Klicken zum Bearbeiten",
|
||||
"dropHere": "✓ Hier ablegen",
|
||||
"drop": "Ablegen",
|
||||
"tasksDue": "{{count}} Aufgabe fällig",
|
||||
"tasksDue_plural": "{{count}} Aufgaben fällig"
|
||||
},
|
||||
@@ -211,6 +264,28 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"export": {
|
||||
"title": "Datenexport",
|
||||
"description": "Laden Sie Ihre Daten als JSON-Datei herunter.",
|
||||
"tasks": "Aufgaben",
|
||||
"labels": "Labels",
|
||||
"systemSettings": "Systemeinstellungen (Admin)",
|
||||
"button": "Daten exportieren",
|
||||
"success": "Daten erfolgreich exportiert",
|
||||
"error": "Fehler beim Exportieren der Daten"
|
||||
},
|
||||
"auditLogs": {
|
||||
"title": "Audit-Protokolle",
|
||||
"description": "Verfolgen Sie alle Systemänderungen und KI-Aktionen.",
|
||||
"table": {
|
||||
"time": "Zeit",
|
||||
"source": "Quelle",
|
||||
"action": "Aktion",
|
||||
"entity": "Entität",
|
||||
"details": "Details",
|
||||
"empty": "Keine Protokolle gefunden."
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"title": "Sprache",
|
||||
"description": "Wähle deine bevorzugte Sprache",
|
||||
@@ -230,14 +305,34 @@
|
||||
"publicLeaderboardDesc": "Mein Profil auf der globalen Bestenliste anzeigen",
|
||||
"searchable": "Auffindbarkeit erlauben",
|
||||
"searchableDesc": "Anderen Benutzern erlauben, mich zum Teilen von Aufgaben zu finden",
|
||||
"shareAccess": "Zugriff auf alle Aufgaben teilen..."
|
||||
"shareAccess": "Zugriff auf alle Aufgaben teilen...",
|
||||
"2fa": "Zwei-Faktor-Authentifizierung",
|
||||
"2faDesc": "Ihr Konto mit E-Mail-basierter 2FA sichern"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Zeitplan-Einstellungen",
|
||||
"description": "Definiere deine Verfügbarkeit für intelligente Planung.",
|
||||
"start": "Startzeit",
|
||||
"end": "Endzeit",
|
||||
"days": "Aktive Tage",
|
||||
"saved": "Zeitplan gespeichert",
|
||||
"work": "Arbeitszeit",
|
||||
"personal": "Persönliche Zeit",
|
||||
"workDesc": "Aufgaben mit 'Arbeit'-Labels werden während dieser Zeiten geplant.",
|
||||
"personalDesc": "Aufgaben mit 'Persönlich'-Labels werden während dieser Zeiten geplant. 'Neutrale' Aufgaben können beide nutzen."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"description": "Systemweite Einstellungen und Benutzerverwaltung",
|
||||
"manageUsers": "Benutzer & Registrierung verwalten",
|
||||
"smtpSettings": "SMTP Einstellungen",
|
||||
"systemSettings": "Systemeinstellungen (KI/SMTP)"
|
||||
"systemSettings": "Systemeinstellungen (KI/SMTP)",
|
||||
"tabs": {
|
||||
"general": "Allgemein",
|
||||
"ai": "KI-System",
|
||||
"mcp": "MCP-Integration",
|
||||
"audit": "Audit-Logs"
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"title": "E-Mail Einstellungen (SMTP)",
|
||||
@@ -252,6 +347,38 @@
|
||||
"saving": "Speichere...",
|
||||
"saveSuccess": "Einstellungen erfolgreich gespeichert"
|
||||
},
|
||||
"audit": {
|
||||
"action": {
|
||||
"CREATE": "Erstellt",
|
||||
"UPDATE": "Aktualisiert",
|
||||
"DELETE": "Gelöscht",
|
||||
"LOGIN": "Angemeldet",
|
||||
"LOGOUT": "Abmeldung",
|
||||
"Generate API Key": "API-Schlüssel generieren",
|
||||
"Revoke API Key": "API-Schlüssel widerrufen",
|
||||
"EXPORT_DATA": "Datenexport",
|
||||
"PURCHASE": "Kauf",
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"entity": {
|
||||
"USER": "Benutzer",
|
||||
"TASK": "Aufgabe",
|
||||
"LABEL": "Label",
|
||||
"SYSTEM_SETTINGS": "Systemeinst.",
|
||||
"AI_CONFIG": "KI-Konfig",
|
||||
"MCP": "MCP",
|
||||
"CONVERSATION": "Konversation",
|
||||
"REWARD": "Belohnung",
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"source": {
|
||||
"USER": "Benutzer",
|
||||
"ADMIN": "Admin",
|
||||
"AI": "KI",
|
||||
"SYSTEM": "System",
|
||||
"WEB": "Web"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"title": "Aufgabenlabels",
|
||||
"description": "Erstellen und verwalten Sie Labels, um Ihre Aufgaben zu organisieren",
|
||||
@@ -274,6 +401,10 @@
|
||||
"deletedDescription": "Ihr Label wurde erfolgreich gelöscht.",
|
||||
"share": "Label teilen"
|
||||
},
|
||||
"security": {
|
||||
"2fa": "Zwei-Faktor-Authentifizierung",
|
||||
"2faDesc": "Sichern Sie Ihr Konto mit E-Mail-basierter 2FA"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Projektvorlagen",
|
||||
"description": "Verwende Vorlagen zum schnellen Erstellen von Projekten",
|
||||
@@ -281,40 +412,100 @@
|
||||
"copy": "In die Zwischenablage kopieren",
|
||||
"instructions": "Konfigurieren Sie Ihren MCP-Client (z. B. Claude Desktop) mit dieser URL und diesem Token."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Server Konfiguration",
|
||||
"description": "Konfigurieren Sie die Model Context Protocol (MCP) Server-Einstellungen.",
|
||||
"descriptionDetail": "Der MCP-Server läuft auf demselben Port wie die Anwendung (/api/mcp).",
|
||||
"enableLabel": "MCP-Server aktivieren",
|
||||
"enableDesc": "Erlauben Sie externen KI-Tools über das MCP-Protokoll eine Verbindung.",
|
||||
"portLabel": "Port (Info)",
|
||||
"portDesc": "Läuft derzeit auf dem Hauptanwendungsport. Separate Konfiguration folgt.",
|
||||
"status": "Server Status",
|
||||
"running": "Aktiv",
|
||||
"url": "Endpunkt-URL (SSE)",
|
||||
"apiKey": "Zugriffstoken",
|
||||
"generate": "Token generieren",
|
||||
"revoke": "Token widerrufen",
|
||||
"generated": "Zugriffstoken generiert",
|
||||
"revoked": "Zugriffstoken widerrufen",
|
||||
"noKey": "Kein aktives Token. Generieren Sie eines, um sich zu verbinden.",
|
||||
"copy": "In Zwischenablage kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"instructions": "Konfigurieren Sie Ihren MCP-Client (z. B. Claude Desktop) mit dieser URL und diesem Token."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Konfiguration",
|
||||
"description": "Konfigurieren Sie den globalen KI-Anbieter für den Assistenten.",
|
||||
"provider": "KI-Anbieter",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"model": "Modellname",
|
||||
"baseUrl": "Basis-URL (Optional, z. B. für Ollama)",
|
||||
"enableUser": "KI-Assistent aktivieren",
|
||||
"enableUserDesc": "Zeige das KI-Chat-Widget an."
|
||||
"baseUrl": "Basis-URL",
|
||||
"systemPrompt": "System-Prompt",
|
||||
"systemPromptPlaceholder": "Definieren Sie die Persona und Regeln der KI...",
|
||||
"enableUser": "KI-Chat aktivieren",
|
||||
"enableUserDesc": "Zugriff auf den KI-Chat-Assistenten erlauben",
|
||||
"save": "KI-Einstellungen speichern",
|
||||
"saved": "Einstellungen gespeichert",
|
||||
"error": "Fehler beim Speichern",
|
||||
"ollama": {
|
||||
"placeholder": "Optional für Ollama",
|
||||
"pull": "Modell laden",
|
||||
"pullPlaceholder": "z.B. llama3",
|
||||
"fetch": "Modelle abrufen",
|
||||
"found": "{{count}} Modelle gefunden",
|
||||
"pullSuccess": "Modell erfolgreich geladen",
|
||||
"pullError": "Fehler beim Laden des Modells",
|
||||
"connectError": "Verbindung zu Ollama fehlgeschlagen"
|
||||
},
|
||||
"chat": {
|
||||
"title": "KI-Assistent",
|
||||
"welcome": "Hallo! Ich kann dir bei Aufgaben helfen, Erinnerungen erstellen und deinen Tag organisieren. Probiere: \"Erstelle eine Aufgabe zum Einkaufen für morgen\"",
|
||||
"placeholder": "Frag mich etwas...",
|
||||
"thinking": "Denke nach",
|
||||
"error": "Etwas ist schiefgelaufen"
|
||||
}
|
||||
},
|
||||
"routines": {
|
||||
"title": "Routine-Konfiguration",
|
||||
"description": "Konfigurieren Sie globale Zeitpläne für Morgen- und Abendroutinen.",
|
||||
"morningLabel": "Morgenroutine",
|
||||
"eveningLabel": "Abendroutine",
|
||||
"time": "Startzeit",
|
||||
"saved": "Routine-Einstellungen gespeichert",
|
||||
"error": "Fehler beim Speichern",
|
||||
"save": "Routinen speichern",
|
||||
"goodMorning": "Guten Morgen",
|
||||
"goodEvening": "Guten Abend",
|
||||
"morningSubtitle": "Lass uns deinen Tag planen.",
|
||||
"eveningSubtitle": "Zeit zum Reflektieren und Entspannen.",
|
||||
"dayComplete": "Tag abgeschlossen! Gute Arbeit."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Zeitplan-Einstellungen",
|
||||
"description": "Definieren Sie Ihre Verfügbarkeit für die automatische Planung.",
|
||||
"start": "Startzeit",
|
||||
"end": "Endzeit",
|
||||
"days": "Aktive Tage",
|
||||
"saved": "Zeitplan gespeichert",
|
||||
"work": "Arbeitszeitplan",
|
||||
"personal": "Persönlicher Zeitplan",
|
||||
"workDesc": "Aufgaben mit dem Label 'Arbeit' werden in diesen Stunden geplant.",
|
||||
"personalDesc": "Aufgaben mit dem Label 'Persönlich' werden in diesen Stunden geplant. 'Neutrale' Aufgaben können beides nutzen."
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Assistent",
|
||||
"assistant": "KI Assistent",
|
||||
"welcome": "Wie kann ich Ihnen heute bei Ihren Aufgaben helfen?",
|
||||
"welcomeTitle": "Wie kann ich Ihnen helfen?",
|
||||
"welcomeDesc": "Ich kann Sie bei Ihren Aufgaben, Ihrer Planung und mehr unterstützen.",
|
||||
"thinking": "Denke nach...",
|
||||
"placeholder": "Stellen Sie eine Frage...",
|
||||
"newChat": "Neuer Chat",
|
||||
"newChatDefault": "Neuer Chat",
|
||||
"startChat": "Neuen Chat starten",
|
||||
"selectConversation": "Chat wechseln",
|
||||
"recentChats": "Letzte Chats",
|
||||
"noChats": "Keine letzten Chats",
|
||||
"chatRenamed": "Chat umbenannt",
|
||||
"chatDeleted": "Chat gelöscht",
|
||||
"deleteConfirm": "Diesen Chat wirklich löschen?",
|
||||
"noConversation": "Kein Chat ausgewählt",
|
||||
"sendFailed": "Senden fehlgeschlagen",
|
||||
"errorSending": "Fehler beim Senden",
|
||||
"disclaimer": "KI kann Fehler machen. Überprüfen Sie wichtige Informationen.",
|
||||
"error": "Ich bin auf einen Fehler gestoßen"
|
||||
},
|
||||
"newChatDefault": "Neuer Chat",
|
||||
"startChat": "Neuen Chat starten",
|
||||
"selectConversation": "Chat wechseln",
|
||||
"recentChats": "Letzte Chats",
|
||||
"noChats": "Keine letzten Chats",
|
||||
"chatRenamed": "Chat umbenannt",
|
||||
"chatDeleted": "Chat gelöscht",
|
||||
"deleteConfirm": "Diesen Chat wirklich löschen?",
|
||||
"noConversation": "Kein Chat ausgewählt",
|
||||
"sendFailed": "Senden fehlgeschlagen",
|
||||
"errorSending": "Fehler beim Senden",
|
||||
"disclaimer": "KI kann Fehler machen. Überprüfen Sie wichtige Informationen.",
|
||||
"error": "Ich bin auf einen Fehler gestoßen",
|
||||
"userManagement": {
|
||||
"title": "Benutzerverwaltung",
|
||||
"registration": "Registrierung",
|
||||
@@ -328,6 +519,24 @@
|
||||
"admin": "Administrator",
|
||||
"user": "Benutzer"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analysen",
|
||||
"tasksCompleted": "Erledigte Aufgaben",
|
||||
"completionRate": "Abschlussrate",
|
||||
"onTime": "Pünktlich",
|
||||
"late": "Verspätet",
|
||||
"productivityScore": "Produktivitätswert",
|
||||
"timeDistribution": "Zeitverteilung",
|
||||
"timeTracking": "Zeiterfassung",
|
||||
"totalTime": "Gesamtzeit",
|
||||
"noData": "Keine Zeit für diesen Zeitraum erfasst",
|
||||
"period": {
|
||||
"day": "Tag",
|
||||
"week": "Woche",
|
||||
"month": "Monat",
|
||||
"year": "Jahr"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"user": "Benutzer",
|
||||
"role": "Rolle",
|
||||
@@ -439,6 +648,14 @@
|
||||
"loading": "Laden...",
|
||||
"error": "Ein Fehler ist aufgetreten"
|
||||
},
|
||||
"task": {
|
||||
"created": "Aufgabe erstellt",
|
||||
"createdDescription": "\"{{title}}\" wurde hinzugefügt."
|
||||
},
|
||||
"error": {
|
||||
"createTask": "Fehler",
|
||||
"createTaskDescription": "Aufgabe konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"title": "Aufgabe löschen",
|
||||
"description": "Möchten Sie diese Aufgabe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
@@ -474,31 +691,72 @@
|
||||
"xp": "{{count}} EP",
|
||||
"nextLevel": "{{count}} EP",
|
||||
"currentXP": "{{current}} / {{next}} EP",
|
||||
"viewDetails": "Details anzeigen",
|
||||
"source": {
|
||||
"task_completion": "Aufgabe erledigt",
|
||||
"daily_streak": "Täglicher Serien-Bonus",
|
||||
"daily_clear_bonus": "Tagesziel-Bonus",
|
||||
"goal_completed": "Ziel erreicht"
|
||||
"create_task": "Aufgabe erstellt",
|
||||
"create_subtask": "Teilaufgabe erstellt",
|
||||
"update_task": "Aufgabe aktualisiert",
|
||||
"complete_task": "Aufgabe erledigt",
|
||||
"complete_task_late": "Aufgabe erledigt",
|
||||
"ai_action": "AI Aktion",
|
||||
"daily_streak": "Täglicher Streak"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Gamification Regeln",
|
||||
"xpSystem": "XP System",
|
||||
"title": "Regeln & Ränge",
|
||||
"levelRequirements": "Level Anforderungen",
|
||||
"actions": "Aktionen & Belohnungen",
|
||||
"xpSystem": "Wie man XP verdient",
|
||||
"level": "Level {{level}}",
|
||||
"xp": "{{xp}} XP",
|
||||
"action": "Aktion",
|
||||
"points": "Punkte",
|
||||
"actions": "XP Aktionen",
|
||||
"createTask": "Aufgabe erstellen",
|
||||
"createSubtask": "Unteraufgabe erstellen",
|
||||
"updateTask": "Aufgabe aktualisieren",
|
||||
"completeTask": "Aufgabe erledigen (Pünktlich)",
|
||||
"completeTaskLate": "Aufgabe erledigen (Verspätet)",
|
||||
"aiAction": "AI Funktion nutzen",
|
||||
"dailyStreak": "Täglicher Serienbonus"
|
||||
"updateTask": "Aufgabe aktualisiert",
|
||||
"completeTask": "Aufgabe erledigen",
|
||||
"completeTaskLate": "Verspätet erledigen",
|
||||
"aiAction": "AI Nutzung",
|
||||
"dailyStreak": "Tägliche Strähne",
|
||||
"weeklyStreak": "Wochensträhne (7 Tage)",
|
||||
"monthlyStreak": "Monatssträhne (30 Tage)",
|
||||
"streakTooltip": "Melde dich täglich an, um deine Strähne zu erhöhen!",
|
||||
"streakBonus": "Wöchentliche & Monatliche Boni verfügbar: 7 Tage (+300 XP), 30 Tage (+1000 XP)."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analysen",
|
||||
"timeTracking": "Zeiterfassung",
|
||||
"timeDistribution": "Zeitverteilung",
|
||||
"totalTime": "Gesamtzeit",
|
||||
"noData": "Keine Daten für diesen Zeitraum",
|
||||
"period": {
|
||||
"day": "Tag",
|
||||
"week": "Woche",
|
||||
"month": "Monat",
|
||||
"year": "Jahr"
|
||||
},
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa",
|
||||
"sun": "So",
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mär",
|
||||
"apr": "Apr",
|
||||
"may": "Mai",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Okt",
|
||||
"nov": "Nov",
|
||||
"dec": "Dez",
|
||||
"week_1": "Woche 1",
|
||||
"week_2": "Woche 2",
|
||||
"week_3": "Woche 3",
|
||||
"week_4": "Woche 4",
|
||||
"cw": "KW"
|
||||
},
|
||||
"ranks": {
|
||||
"novice": "Neuling",
|
||||
"apprentice": "Lehrling",
|
||||
@@ -509,7 +767,17 @@
|
||||
"grandmaster": "Großmeister",
|
||||
"virtuoso": "Virtuose",
|
||||
"legend": "Legende",
|
||||
"mythic": "Mythisch"
|
||||
"mythic": "Mythisch",
|
||||
"titan": "Titan",
|
||||
"sentinel": "Wächter",
|
||||
"vanguard": "Vorhut",
|
||||
"oracle": "Orakel",
|
||||
"ascendant": "Aufgestiegener",
|
||||
"ethereal": "Ätherisch",
|
||||
"celestial": "Himmlisch",
|
||||
"divine": "Göttlich",
|
||||
"omnipotent": "Allmächtig",
|
||||
"eternal": "Ewiger"
|
||||
},
|
||||
"leaderboardPage": {
|
||||
"title": "Bestenliste",
|
||||
@@ -533,6 +801,7 @@
|
||||
"successAccessDesc": "Benutzer kann nun alle deine Aufgaben sehen.",
|
||||
"errorAccess": "Fehler beim Gewähren des Zugriffs",
|
||||
"sharedWith": "Geteilt mit",
|
||||
"sharedWithMe": "Mit mir geteilt",
|
||||
"unshare": "Entfernen",
|
||||
"confirmShareTitle": "Aufgabe teilen",
|
||||
"confirmShareDesc": "Möchtest du diese Aufgabe wirklich mit {{username}} teilen?",
|
||||
@@ -543,6 +812,7 @@
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
"subtitle": "Verfolge deinen Fortschritt und deine Ziele",
|
||||
"overview": "Übersicht",
|
||||
"rewardsDescription": "Gib deine {{xp}} EP für exklusive Belohnungen aus!",
|
||||
"addReward": "Belohnung hinzufügen",
|
||||
"createCustomReward": "Eigene Belohnung erstellen",
|
||||
@@ -551,6 +821,8 @@
|
||||
"rewardCost": "Kosten (EP)",
|
||||
"createRewardBtn": "Belohnung erstellen",
|
||||
"currentStreak": "Aktuelle Serie",
|
||||
"weeklyStreak": "Wochensträhne",
|
||||
"monthlyStreak": "Monatssträhne",
|
||||
"days": "{{count}} Tage",
|
||||
"bestStreak": "Rekord: {{count}} Tage",
|
||||
"xpActivity": "EP Aktivität",
|
||||
@@ -610,32 +882,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa",
|
||||
"sun": "So",
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mär",
|
||||
"apr": "Apr",
|
||||
"may": "Mai",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Okt",
|
||||
"nov": "Nov",
|
||||
"dec": "Dez",
|
||||
"week_1": "Woche 1",
|
||||
"week_2": "Woche 2",
|
||||
"week_3": "Woche 3",
|
||||
"week_4": "Woche 4",
|
||||
"cw": "KW"
|
||||
},
|
||||
"energy": {
|
||||
"low": "⚡ Wenig Energie",
|
||||
"medium": "⚡⚡ Mittlere Energie",
|
||||
@@ -705,6 +951,306 @@
|
||||
"createAccount": "Konto erstellen",
|
||||
"registrationDisabled": "Registrierung ist derzeit deaktiviert.",
|
||||
"heroTitle": "TaskFlow",
|
||||
"heroSubtitle": "Meistern Sie Ihre Produktivität mit KI-gesteuertem Aufgabenmanagement, gamifizierten Erfolgen und intelligenten Fokusmodi."
|
||||
"heroSubtitle": "Meistern Sie Ihre Produktivität mit KI-gesteuertem Aufgabenmanagement, gamifizierten Erfolgen und intelligenten Fokusmodi.",
|
||||
"2faVerification": "2FA Verifizierung",
|
||||
"2faCodeSent": "2FA Code gesendet",
|
||||
"checkEmail": "Bitte überprüfen Sie Ihre E-Mail auf den Bestätigungscode.",
|
||||
"enterCodeSentTo": "Geben Sie den 6-stelligen Code ein, der an ... gesendet wurde",
|
||||
"codeExpiresIn10": "Der Code läuft in 10 Minuten ab",
|
||||
"verify": "Verifizieren"
|
||||
},
|
||||
"planning": {
|
||||
"minutes": "Minuten",
|
||||
"durationPlaceholder": "z.B. 30",
|
||||
"startDate": "Startdatum",
|
||||
"estimatedDuration": "Geschätzte Dauer",
|
||||
"subtasks": "Teilaufgaben",
|
||||
"timeEstimate": "Zeitschätzung"
|
||||
},
|
||||
"common": {
|
||||
"optional": "Optional",
|
||||
"saving": "Speichern...",
|
||||
"creating": "Erstellen..."
|
||||
},
|
||||
"adhd": {
|
||||
"mode": "Fokus-Tools",
|
||||
"modeEnabled": "Fokus-Tools aktiviert",
|
||||
"modeDisabled": "Fokus-Tools deaktiviert",
|
||||
"modeDescription": "Optimiert die App für Fokus und Produktivität",
|
||||
"toggleMode": "Fokus-Tools umschalten",
|
||||
"active": "Aktiv",
|
||||
"dashboard": {
|
||||
"title": "Fokus-Dashboard",
|
||||
"subtitle": "Werkzeuge, die zu deinem Gehirn passen",
|
||||
"available": "verfügbar",
|
||||
"completedToday": "Heute erledigt",
|
||||
"coworking": "Zusammenarbeit",
|
||||
"energyLevel": "Energie",
|
||||
"focusMode": "Fokus-Modus",
|
||||
"helpTitle": "Wie die Fokus-Tools dir helfen",
|
||||
"helpIntro": "Diese Werkzeuge sind für dein Gehirn entwickelt - sie adressieren häufige Herausforderungen wie Zeitblindheit, Aufgabenlähmung und das Bedürfnis nach schnellen Erfolgen.",
|
||||
"help": {
|
||||
"quickWins": {
|
||||
"title": "Schnelle Erfolge",
|
||||
"why": "Das Starten von Aufgaben fühlt sich überwältigend an, und dein Gehirn sehnt sich nach schnellen Dopamin-Kicks.",
|
||||
"how": "Zeigt nur Aufgaben unter 15 Minuten, sortiert nach Einfachheit. Kleine Erfolge bauen Momentum auf und geben deinem Gehirn die Belohnung, die es braucht.",
|
||||
"usage": "Öffne Schnelle Erfolge, wenn du feststeckst. Wähle die erste Aufgabe und erledige sie. Der Dopamin-Boost hilft dir, mehr zu schaffen."
|
||||
},
|
||||
"singleTask": {
|
||||
"title": "Einzelaufgaben-Fokus",
|
||||
"why": "Eine lange Aufgabenliste zu sehen verursacht Überforderung und Lähmung.",
|
||||
"how": "Versteckt alles außer EINER Aufgabe. Keine Ablenkungen, keine Listen-Angst - nur die Aufgabe vor dir.",
|
||||
"usage": "Wähle eine Aufgabe und gehe in den Fokus-Modus. Arbeite nur an dieser Aufgabe, bis sie fertig ist oder du eine Pause brauchst."
|
||||
},
|
||||
"fiveMinute": {
|
||||
"title": "5-Minuten-Starter",
|
||||
"why": "Das Schwierigste ist der Anfang. Dein Gehirn wehrt sich gegen große Aufgaben.",
|
||||
"how": "Verpflichte dich zu nur 5 Minuten. Dein Gehirn denkt: 'Ich kann alles 5 Minuten lang machen.' Einmal angefangen, machst du oft weiter.",
|
||||
"usage": "Klicke '5 Min Start' bei einer Aufgabe. Arbeite 5 Minuten. Danach kannst du ohne schlechtes Gewissen aufhören ODER weitermachen."
|
||||
},
|
||||
"visualTimer": {
|
||||
"title": "Visueller Timer",
|
||||
"why": "Zeitblindheit - du kannst Zeit nicht natürlich wahrnehmen.",
|
||||
"how": "Ein schrumpfender Kreis zeigt Zeit visuell an. Farben ändern sich (grün → gelb → rot) wenn die Zeit abläuft.",
|
||||
"usage": "Nutze den Timer während Fokus-Sessions. Das visuelle Feedback hilft dir, Zeit zu spüren."
|
||||
},
|
||||
"breaks": {
|
||||
"title": "Pausen-Erinnerungen",
|
||||
"why": "Du vergisst Pausen zu machen, was zu Burnout oder Hyperfokus-Abstürzen führt.",
|
||||
"how": "Sanfte Erinnerungen erscheinen nach deinem eingestellten Intervall mit Vorschlägen wie Dehnen oder Wasser trinken.",
|
||||
"usage": "Wenn die Erinnerung erscheint, mach eine echte Pause. Auch 2 Minuten helfen. Du kannst bei Bedarf snoozen."
|
||||
},
|
||||
"hyperfocus": {
|
||||
"title": "Hyperfokus-Schutz",
|
||||
"why": "Du bleibst stundenlang an einer Aufgabe hängen und vernachlässigst alles andere.",
|
||||
"how": "Warnt dich, wenn du zu lange an einer Aufgabe arbeitest. Zeigt andere wartende Aufgaben.",
|
||||
"usage": "Bei einer Warnung überlege, ob du weitermachen oder wechseln solltest. Es ist okay weiterzumachen, aber mach es bewusst."
|
||||
},
|
||||
"energy": {
|
||||
"title": "Energie-Check-ins",
|
||||
"why": "Dich zu schweren Aufgaben zu zwingen, wenn du erschöpft bist, führt zum Scheitern.",
|
||||
"how": "Logge täglich dein Energielevel. Die App schlägt Aufgaben vor, die zu deiner aktuellen Energie passen.",
|
||||
"usage": "Checke jeden Morgen ein. Wenig Energie? Mach einfache Aufgaben. Viel Energie? Tackle Herausforderungen."
|
||||
},
|
||||
"bodyDoubling": {
|
||||
"title": "Body Doubling",
|
||||
"why": "Alleine zu arbeiten macht es schwer, am Ball zu bleiben.",
|
||||
"how": "Virtuelle Co-Working-Sessions, bei denen du neben anderen arbeitest. Soziale Verantwortlichkeit hält dich fokussiert.",
|
||||
"usage": "Tritt einer Session bei oder erstelle eine. Allein zu wissen, dass andere arbeiten, hilft dir dranzubleiben."
|
||||
},
|
||||
"taskBreakdown": {
|
||||
"title": "KI-Aufgabenzerlegung",
|
||||
"why": "Große Aufgaben fühlen sich unmöglich an zu starten.",
|
||||
"how": "KI zerlegt überwältigende Aufgaben in kleine, konkrete Schritte mit Zeitschätzungen.",
|
||||
"usage": "Klicke 'Zerlegen' bei einer großen Aufgabe. Überprüfe die Schritte und erstelle sie als Unteraufgaben."
|
||||
}
|
||||
},
|
||||
"quickWins": "Schnelle Erfolge",
|
||||
"streak": "Tagessträhne"
|
||||
},
|
||||
"timer": {
|
||||
"remaining": "verbleibend"
|
||||
},
|
||||
"breakReminder": {
|
||||
"title": "Zeit für eine Pause!",
|
||||
"workingFor": "Du arbeitest seit {{minutes}} Minuten",
|
||||
"message": "Dein Gehirn braucht regelmäßige Pausen. Wähle eine kurze Pause:",
|
||||
"snooze5": "5 Min später",
|
||||
"snooze15": "15 Min später",
|
||||
"gentle": "Es ist okay, Pausen zu machen. Du machst das großartig!",
|
||||
"types": {
|
||||
"stretch": "Dehnen",
|
||||
"water": "Wasser",
|
||||
"walk": "Spazieren",
|
||||
"eyes": "Augen",
|
||||
"snack": "Snack",
|
||||
"breathe": "Atmen"
|
||||
}
|
||||
},
|
||||
"taskBreakdown": {
|
||||
"title": "Aufgabe aufteilen",
|
||||
"description": "Große Aufgaben können überwältigend sein. Lass uns sie in kleine Schritte zerlegen.",
|
||||
"prompt": "Klicke unten, um diese Aufgabe in machbare Schritte zu zerlegen.",
|
||||
"analyzing": "Analysiere...",
|
||||
"breakItDown": "Aufgabe aufteilen",
|
||||
"estimated": "geschätzt",
|
||||
"totalTime": "Gesamtzeit",
|
||||
"regenerate": "Neu generieren",
|
||||
"createSubtasks": "{{count}} Schritte erstellen",
|
||||
"error": "Aufgabe konnte nicht zerlegt werden. Bitte versuche es erneut."
|
||||
},
|
||||
"quickWins": {
|
||||
"title": "Schnelle Erfolge",
|
||||
"description": "Kleine Aufgaben für sofortige Dopamin-Boosts",
|
||||
"tip": "Kleine Erfolge schaffen Momentum! Erledige diese schnellen Aufgaben für einen Dopamin-Boost.",
|
||||
"noTasks": "Keine schnellen Aufgaben verfügbar"
|
||||
},
|
||||
"singleTask": {
|
||||
"title": "Einzelaufgaben-Fokus",
|
||||
"description": "Konzentriere dich auf eine Aufgabe ohne Ablenkungen",
|
||||
"selectTask": "Wähle eine Aufgabe zum Fokussieren",
|
||||
"choosePlaceholder": "Aufgabe auswählen...",
|
||||
"task": "Aufgabe",
|
||||
"complete": "erledigt",
|
||||
"allDone": "Alles geschafft!",
|
||||
"allDoneDesc": "Du hast alle Aufgaben erledigt. Nimm dir eine wohlverdiente Pause!",
|
||||
"exit": "Ansicht verlassen",
|
||||
"markComplete": "Als erledigt markieren",
|
||||
"fiveMin": "5 Min starten",
|
||||
"startTimer": "Timer starten",
|
||||
"skip": "Überspringen"
|
||||
},
|
||||
"fiveMin": {
|
||||
"title": "Die 5-Minuten-Regel",
|
||||
"prompt": "Verpflichte dich nur zu 5 Minuten. Danach kannst du aufhören.",
|
||||
"start": "5 Minuten starten",
|
||||
"noObligation": "Keine Verpflichtung weiterzumachen!",
|
||||
"remaining": "verbleibend",
|
||||
"pause": "Pause",
|
||||
"resume": "Fortsetzen",
|
||||
"done": "Fertig!",
|
||||
"encouragement": "Du machst das großartig! Jede Sekunde zählt.",
|
||||
"congrats": "Super gemacht!",
|
||||
"fiveMinDone": "Du hast 5 Minuten geschafft!",
|
||||
"continueWorking": "Weitermachen",
|
||||
"finished": "Aufgabe fertig!",
|
||||
"takeBreak": "Pause machen",
|
||||
"earned": "verdient"
|
||||
},
|
||||
"energy": {
|
||||
"title": "Wie ist dein Energielevel?",
|
||||
"description": "Hilf uns, dir passende Aufgaben vorzuschlagen.",
|
||||
"checkIn": "Einchecken",
|
||||
"checkInReminder": "Wie fühlst du dich heute?",
|
||||
"checkInBenefit": "Tracke deine Energie für bessere Aufgabenvorschläge",
|
||||
"notesLabel": "Notizen",
|
||||
"notesPlaceholder": "Wie fühlst du dich heute?",
|
||||
"submit": "Energie erfassen",
|
||||
"levels": {
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch"
|
||||
},
|
||||
"suggestions": {
|
||||
"low": {
|
||||
"title": "Empfehlung für niedrige Energie",
|
||||
"description": "Konzentriere dich auf einfache, administrative Aufgaben oder kurze Quick Wins."
|
||||
},
|
||||
"medium": {
|
||||
"title": "Empfehlung für mittlere Energie",
|
||||
"description": "Gut für Routineaufgaben und Kommunikation."
|
||||
},
|
||||
"high": {
|
||||
"title": "Empfehlung für hohe Energie",
|
||||
"description": "Perfekt für kreative oder herausfordernde Aufgaben!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hyperfocus": {
|
||||
"title": "Hyperfokus-Warnung",
|
||||
"message": "Du arbeitest seit {{minutes}} Minuten an dieser Aufgabe.",
|
||||
"minutes": "Min",
|
||||
"urgentTasks": "dringende Aufgaben",
|
||||
"waitingTasks": "Wartende Aufgaben",
|
||||
"takeBreak": "Pause machen",
|
||||
"continue": "Weitermachen",
|
||||
"gentle": "Es ist okay, Pausen zu machen oder zu wechseln."
|
||||
},
|
||||
"bodyDoubling": {
|
||||
"title": "Body Doubling",
|
||||
"description": "Arbeite gemeinsam mit anderen für mehr Fokus und Accountability.",
|
||||
"tip": "Gemeinsames Arbeiten hilft beim Fokussieren. Tritt einer Session bei oder erstelle eine.",
|
||||
"createSession": "Session erstellen",
|
||||
"newSession": "Neue Session",
|
||||
"newSessionDesc": "Erstelle eine Session, zu der andere beitreten können.",
|
||||
"sessionTitle": "Titel",
|
||||
"titlePlaceholder": "z.B. Fokus-Session Vormittag",
|
||||
"sessionType": "Typ",
|
||||
"types": {
|
||||
"focus": "Fokus",
|
||||
"brainstorm": "Brainstorm",
|
||||
"admin": "Admin",
|
||||
"creative": "Kreativ"
|
||||
},
|
||||
"startTime": "Startzeit",
|
||||
"duration": "Dauer",
|
||||
"create": "Session erstellen",
|
||||
"activeSessions": "Aktive Sessions",
|
||||
"upcomingSessions": "Geplante Sessions",
|
||||
"noSessions": "Keine Sessions geplant. Erstelle die erste!",
|
||||
"live": "Live",
|
||||
"join": "Beitreten",
|
||||
"leave": "Verlassen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Fokus-Einstellungen",
|
||||
"description": "Passe deine fokusfreundliche Erfahrung an",
|
||||
"visualTitle": "Darstellung & Interaktion",
|
||||
"visualDesc": "Passe das Interface für deine Bedürfnisse an.",
|
||||
"reducedAnimations": "Reduzierte Animationen",
|
||||
"reducedAnimationsDesc": "Weniger visuelle Bewegung für bessere Konzentration.",
|
||||
"largerTargets": "Größere Buttons",
|
||||
"largerTargetsDesc": "Größere Berührungsflächen für einfacheres Klicken.",
|
||||
"singleTaskDefault": "Single-Task-Modus Standard",
|
||||
"singleTaskDefaultDesc": "Zeige nur eine Aufgabe zur Zeit an.",
|
||||
"timerTitle": "Timer & Fokus",
|
||||
"timerDesc": "Einstellungen für Zeitmanagement.",
|
||||
"visualTimer": "Visueller Timer",
|
||||
"visualTimerDesc": "Zeige die Zeit als schrumpfenden Kreis.",
|
||||
"hyperfocusProtection": "Hyperfokus-Schutz",
|
||||
"hyperfocusProtectionDesc": "Warnungen bei zu langem Arbeiten an einer Aufgabe.",
|
||||
"hyperfocusMaxMinutes": "Maximale Fokuszeit",
|
||||
"breakTitle": "Pausen",
|
||||
"breakDesc": "Erinnerungen für regelmäßige Pausen.",
|
||||
"breakInterval": "Pausen-Intervall (Minuten)",
|
||||
"motivationTitle": "Motivation",
|
||||
"motivationDesc": "Positive Verstärkung und Belohnungen.",
|
||||
"messagingLevel": "Ermutigungs-Level",
|
||||
"messagingMinimal": "Minimal",
|
||||
"messagingNormal": "Normal",
|
||||
"messagingHigh": "Hoch",
|
||||
"messagingLevelDesc": "Wie viel positive Ermutigung möchtest du?",
|
||||
"quickWinThreshold": "Quick-Win Schwellwert",
|
||||
"quickWinThresholdDesc": "Aufgaben unter dieser Dauer gelten als Quick Wins.",
|
||||
"energyTitle": "Energie",
|
||||
"energyDesc": "Tägliche Energie-Check-ins.",
|
||||
"energyCheckIns": "Energie-Check-ins aktivieren",
|
||||
"energyCheckInsDesc": "Täglich nach deinem Energielevel fragen.",
|
||||
"reset": "Auf Standard zurücksetzen"
|
||||
},
|
||||
"encouragements": {
|
||||
"taskComplete": [
|
||||
"Großartig! Jeder Schritt zählt.",
|
||||
"Du machst das super! Weiter so!",
|
||||
"Ein Task weniger - du rockst das!",
|
||||
"Siehst du? Du kannst das!",
|
||||
"Fantastisch! Bleib dran!"
|
||||
],
|
||||
"returning": [
|
||||
"Schön, dass du wieder da bist!",
|
||||
"Jeder Neustart ist ein Gewinn.",
|
||||
"Willkommen zurück! Lass uns loslegen."
|
||||
],
|
||||
"struggling": [
|
||||
"Es ist okay, kleine Schritte zu machen.",
|
||||
"Du musst nicht alles auf einmal schaffen.",
|
||||
"Atme tief durch. Du schaffst das."
|
||||
],
|
||||
"streakBroken": [
|
||||
"Streaks sind nur Zahlen. Du bist mehr als das.",
|
||||
"Morgen ist ein neuer Tag!",
|
||||
"Jeder Tag ist eine neue Chance."
|
||||
],
|
||||
"milestone": [
|
||||
"Meilenstein erreicht! Unglaublich!",
|
||||
"Du hast es geschafft! Feier diesen Moment!",
|
||||
"Was für ein Erfolg!"
|
||||
],
|
||||
"quickWin": [
|
||||
"Schneller Erfolg! Das Dopamin fließt!",
|
||||
"Boom! Quick Win erledigt!",
|
||||
"So schnell kann es gehen!"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,32 @@
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"enableBrowser": "Enable Browser Notifications",
|
||||
"enablePush": "Push Notifications",
|
||||
"description": "Get alerted about upcoming and overdue tasks.",
|
||||
"pushDescription": "Receive notifications even when the app is closed.",
|
||||
"enabledTitle": "Notifications Enabled",
|
||||
"enabledBody": "You will now receive alerts for your tasks.",
|
||||
"upcomingTitle": "Upcoming: {{task}}",
|
||||
"upcomingBody": "Due in {{time}}",
|
||||
"overdueTitle": "Overdue: {{task}}",
|
||||
"overdueBody": "This task is now overdue!"
|
||||
"overdueBody": "This task is now overdue!",
|
||||
"settings": {
|
||||
"title": "Browser Notifications",
|
||||
"description": "Receive alerts for upcoming and overdue tasks.",
|
||||
"denied": "Permission denied. Please enable in browser settings.",
|
||||
"request": "Request Permission"
|
||||
},
|
||||
"recent": "Recent Alerts",
|
||||
"empty": "All caught up! No urgent alerts.",
|
||||
"pushSupported": "Push Supported",
|
||||
"installedApp": "Installed App",
|
||||
"permissionDenied": "Permission denied by browser. Please reset site permissions.",
|
||||
"iosHintTitle": "Add to Home Screen",
|
||||
"iosHintDesc": "To receive push notifications on iOS, tap the Share button and select \"Add to Home Screen\", then open the app from there.",
|
||||
"sendTest": "Send Test Notification",
|
||||
"testSent": "Test Sent",
|
||||
"testSentDesc": "Check for a notification on your device.",
|
||||
"testFailed": "Failed to send test notification."
|
||||
},
|
||||
"unscheduled": {
|
||||
"title": "Unscheduled Tasks",
|
||||
@@ -27,6 +46,7 @@
|
||||
"weekList": "Week",
|
||||
"create": "Create",
|
||||
"kanban": "Kanban",
|
||||
"adhd": "Focus Tools",
|
||||
"achievements": "Achievements",
|
||||
"leaderboard": "Leaderboard",
|
||||
"settings": "Settings"
|
||||
@@ -39,13 +59,17 @@
|
||||
"noMatchingTasks": "No tasks match your filters",
|
||||
"noUnscheduledTasks": "No unscheduled tasks. Drag tasks to the calendar below to schedule them!",
|
||||
"unscheduledTasksLabel": "Unscheduled Tasks - Drag to calendar below to schedule",
|
||||
"listHeader": "Task List",
|
||||
"calendarTitle": "Next {{count}} Days",
|
||||
"filter": {
|
||||
"open": "Open",
|
||||
"all": "All",
|
||||
"todo": "To Do",
|
||||
"inProgress": "In Progress",
|
||||
"done": "Done",
|
||||
"overdue": "Overdue"
|
||||
"overdue": "Overdue",
|
||||
"planned": "Planned",
|
||||
"unplanned": "Unplanned"
|
||||
},
|
||||
"sort": {
|
||||
"dueDate": "Due Date",
|
||||
@@ -93,7 +117,22 @@
|
||||
"minutesPlaceholder": "Minutes (optional)",
|
||||
"duration": "Duration",
|
||||
"durationNone": "None",
|
||||
"startDate": "Start Date"
|
||||
"startDate": "Start Date",
|
||||
"recurrence": {
|
||||
"label": "Repeat:",
|
||||
"none": "None",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"every": "Every",
|
||||
"units": {
|
||||
"daily": "days",
|
||||
"weekly": "weeks",
|
||||
"monthly": "months",
|
||||
"yearly": "years"
|
||||
}
|
||||
}
|
||||
},
|
||||
"taskDetails": {
|
||||
"title": "Task Details",
|
||||
@@ -156,7 +195,15 @@
|
||||
"durationUnits": {
|
||||
"m": "m",
|
||||
"h": "h"
|
||||
}
|
||||
},
|
||||
"markAsDone": "Mark as Done",
|
||||
"timeRequired": "Time Required",
|
||||
"timeRequiredDesc": "Please enter the time spent on this task before marking it as done.",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes",
|
||||
"hoursDecimal": "Hours (decimal)",
|
||||
"timeExamples": "Examples: 0.5 = 30min, 1.25 = 1h 15min",
|
||||
"saveAndMarkDone": "Save & Mark as Done"
|
||||
},
|
||||
"taskCard": {
|
||||
"play": "Start timer",
|
||||
@@ -173,6 +220,7 @@
|
||||
"quickDelete": "Quick delete",
|
||||
"toggleComplete": "Mark task as complete/incomplete",
|
||||
"running": "running",
|
||||
"blockedBy": "Blocked by: {{tasks}}",
|
||||
"overdue": "Overdue",
|
||||
"timeTracked": "{{hours}}h {{minutes}}m tracked"
|
||||
},
|
||||
@@ -182,6 +230,9 @@
|
||||
"weekend": "Weekend",
|
||||
"previousWeek": "Previous Week",
|
||||
"moreItems": "+{{count}} more",
|
||||
"clickToEdit": "Click to edit",
|
||||
"dropHere": "✓ Drop here",
|
||||
"drop": "Drop",
|
||||
"tasksDue": "{{count}} task due",
|
||||
"tasksDue_plural": "{{count}} tasks due"
|
||||
},
|
||||
@@ -211,6 +262,28 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"export": {
|
||||
"title": "Data Export",
|
||||
"description": "Download your data as a JSON file.",
|
||||
"tasks": "Tasks",
|
||||
"labels": "Labels",
|
||||
"systemSettings": "System Settings (Admin)",
|
||||
"button": "Export Data",
|
||||
"success": "Data exported successfully",
|
||||
"error": "Failed to export data"
|
||||
},
|
||||
"auditLogs": {
|
||||
"title": "Audit Logs",
|
||||
"description": "Track all system changes and AI actions.",
|
||||
"table": {
|
||||
"time": "Time",
|
||||
"source": "Source",
|
||||
"action": "Action",
|
||||
"entity": "Entity",
|
||||
"details": "Details",
|
||||
"empty": "No logs found."
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "Choose your preferred language",
|
||||
@@ -230,14 +303,34 @@
|
||||
"publicLeaderboardDesc": "Show my profile on the global leaderboard",
|
||||
"searchable": "Allow others to find me",
|
||||
"searchableDesc": "Allow users to search for me to share tasks",
|
||||
"shareAccess": "Share access to all tasks..."
|
||||
"shareAccess": "Share access to all tasks...",
|
||||
"2fa": "Two-Factor Authentication",
|
||||
"2faDesc": "Secure your account with email-based 2FA"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule Settings",
|
||||
"description": "Define your availability for smart scheduling.",
|
||||
"start": "Start Time",
|
||||
"end": "End Time",
|
||||
"days": "Active Days",
|
||||
"saved": "Schedule saved",
|
||||
"work": "Work Schedule",
|
||||
"personal": "Personal Schedule",
|
||||
"workDesc": "Tasks with 'Work' labels will be scheduled during these hours.",
|
||||
"personalDesc": "Tasks with 'Personal' labels will be scheduled during these hours. 'Neutral' tasks can use either."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Settings",
|
||||
"description": "System administration and configuration",
|
||||
"manageUsers": "Manage Users",
|
||||
"smtpSettings": "SMTP / Email Settings",
|
||||
"systemSettings": "System Settings (AI/SMTP)"
|
||||
"systemSettings": "System Settings (AI/SMTP)",
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"ai": "AI System",
|
||||
"mcp": "MCP Integration",
|
||||
"audit": "Audit Logs"
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"title": "Email Settings (SMTP)",
|
||||
@@ -252,6 +345,38 @@
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Settings saved successfully"
|
||||
},
|
||||
"audit": {
|
||||
"action": {
|
||||
"CREATE": "Created",
|
||||
"UPDATE": "Updated",
|
||||
"DELETE": "Deleted",
|
||||
"LOGIN": "Logged In",
|
||||
"LOGOUT": "Logout",
|
||||
"Generate API Key": "Generate API Key",
|
||||
"Revoke API Key": "Revoke API Key",
|
||||
"EXPORT_DATA": "Data Export",
|
||||
"PURCHASE": "Purchase",
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"entity": {
|
||||
"USER": "User",
|
||||
"TASK": "Task",
|
||||
"LABEL": "Label",
|
||||
"SYSTEM_SETTINGS": "System Settings",
|
||||
"AI_CONFIG": "AI Config",
|
||||
"MCP": "MCP",
|
||||
"CONVERSATION": "Conversation",
|
||||
"REWARD": "Reward",
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"source": {
|
||||
"USER": "User",
|
||||
"ADMIN": "Admin",
|
||||
"AI": "AI",
|
||||
"SYSTEM": "System",
|
||||
"WEB": "Web"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"title": "Task Labels",
|
||||
"description": "Create and manage labels to organize your tasks",
|
||||
@@ -274,38 +399,104 @@
|
||||
"deletedDescription": "Your label has been deleted successfully.",
|
||||
"share": "Share Label"
|
||||
},
|
||||
"security": {
|
||||
"2fa": "Two-Factor Authentication",
|
||||
"2faDesc": "Secure your account with email-based 2FA"
|
||||
},
|
||||
"ai": {
|
||||
"enableUser": "Enable AI Assistant",
|
||||
"enableUserDesc": "Allow the AI assistant to help you with tasks and organization."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Server",
|
||||
"description": "Configure the Model Context Protocol server.",
|
||||
"enableLabel": "Enable MCP Server",
|
||||
"enableDesc": "Turn on the MCP server to expose task data to AI agents.",
|
||||
"status": "Status",
|
||||
"running": "Running",
|
||||
"url": "Server URL",
|
||||
"apiKey": "API Key",
|
||||
"noKey": "No API Key configured",
|
||||
"generate": "Generate New Key",
|
||||
"instructions": "The MCP server runs on the same port as the application (/api/mcp).",
|
||||
"portLabel": "Port",
|
||||
"portDesc": "The port is determined by the main application."
|
||||
},
|
||||
"templates": {
|
||||
"title": "Project Templates",
|
||||
"description": "Use templates to quickly create projects",
|
||||
"manageTemplates": "Manage Templates"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Server Integration",
|
||||
"description": "Connect AI assistants to TaskFlow via Model Context Protocol.",
|
||||
"status": "Server Status",
|
||||
"running": "Active",
|
||||
"url": "Endpoint URL (SSE)",
|
||||
"apiKey": "Access Token",
|
||||
"generate": "Generate Token",
|
||||
"revoke": "Revoke Token",
|
||||
"generated": "Access Token generated",
|
||||
"revoked": "Access Token revoked",
|
||||
"noKey": "No token active. Generate one to connect.",
|
||||
"copy": "Copy to Clipboard",
|
||||
"instructions": "Configure your MCP client (e.g. Claude Desktop) with this URL and Token."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Configuration",
|
||||
"description": "Configure the global AI provider for the assistant.",
|
||||
"provider": "AI Provider",
|
||||
"apiKey": "API Key",
|
||||
"model": "Model Name",
|
||||
"baseUrl": "Base URL (Optional, e.g. for Ollama)",
|
||||
"enableUser": "Enable AI Assistant",
|
||||
"enableUserDesc": "Show the AI chat widget."
|
||||
}
|
||||
"apiKey": "Access Token",
|
||||
"generate": "Generate Token",
|
||||
"revoke": "Revoke Token",
|
||||
"generated": "Access Token generated",
|
||||
"revoked": "Access Token revoked",
|
||||
"noKey": "No token active. Generate one to connect.",
|
||||
"copy": "Copy to Clipboard",
|
||||
"copied": "Copied!",
|
||||
"instructions": "Configure your MCP client (e.g. Claude Desktop) with this URL and Token."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Configuration",
|
||||
"description": "Configure the global AI provider for the assistant.",
|
||||
"provider": "AI Provider",
|
||||
"apiKeyLabel": "API Key",
|
||||
"model": "Model Name",
|
||||
"baseUrl": "Base URL",
|
||||
"systemPrompt": "System Prompt",
|
||||
"systemPromptPlaceholder": "Define the AI's persona and rules...",
|
||||
"enableUser": "Enable AI Chat",
|
||||
"enableUserDesc": "Allow access to the AI chat assistant",
|
||||
"save": "Save AI Settings",
|
||||
"saved": "Settings saved",
|
||||
"error": "Failed to save settings",
|
||||
"ollama": {
|
||||
"placeholder": "Optional for Ollama",
|
||||
"pull": "Pull Model",
|
||||
"pullPlaceholder": "e.g. llama3",
|
||||
"fetch": "Fetch Models",
|
||||
"found": "Found {{count}} models",
|
||||
"pullSuccess": "Model pulled successfully",
|
||||
"pullError": "Failed to pull model",
|
||||
"connectError": "Could not connect to Ollama"
|
||||
},
|
||||
"chat": {
|
||||
"title": "AI Assistant",
|
||||
"welcome": "Hi! I can help you manage tasks, create reminders, and organize your day. Try saying: \"Create a task to buy groceries tomorrow\"",
|
||||
"placeholder": "Ask me anything...",
|
||||
"thinking": "Thinking",
|
||||
"error": "Something went wrong"
|
||||
}
|
||||
},
|
||||
"routines": {
|
||||
"title": "Routine Configuration",
|
||||
"description": "Configure global morning and evening routine schedules.",
|
||||
"morningLabel": "Morning Routine",
|
||||
"eveningLabel": "Evening Routine",
|
||||
"time": "Start Time",
|
||||
"saved": "Routine settings saved",
|
||||
"error": "Failed to save settings",
|
||||
"save": "Save Routines",
|
||||
"goodMorning": "Good Morning",
|
||||
"goodEvening": "Good Evening",
|
||||
"morningSubtitle": "Let's plan your day for success.",
|
||||
"eveningSubtitle": "Time to reflect and unwind.",
|
||||
"dayComplete": "Day Complete! Great job."
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule Settings",
|
||||
"description": "Define your availability for smart scheduling.",
|
||||
"start": "Start Time",
|
||||
"end": "End Time",
|
||||
"days": "Active Days",
|
||||
"saved": "Schedule saved",
|
||||
"work": "Work Schedule",
|
||||
"personal": "Personal Schedule",
|
||||
"workDesc": "Tasks with 'Work' labels will be scheduled during these hours.",
|
||||
"personalDesc": "Tasks with 'Personal' labels will be scheduled during these hours. 'Neutral' tasks can use either."
|
||||
},
|
||||
"aiChat": {
|
||||
"title": "AI Assistant",
|
||||
"welcome": "How can I help you manage your tasks today?",
|
||||
"thinking": "Thinking...",
|
||||
@@ -436,6 +627,14 @@
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred"
|
||||
},
|
||||
"task": {
|
||||
"created": "Task Created",
|
||||
"createdDescription": "\"{{title}}\" has been added."
|
||||
},
|
||||
"error": {
|
||||
"createTask": "Error",
|
||||
"createTaskDescription": "Failed to create task. Please try again."
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"title": "Delete Task",
|
||||
"description": "Are you sure you want to delete this task? This action cannot be undone.",
|
||||
@@ -475,7 +674,17 @@
|
||||
"grandmaster": "Grandmaster",
|
||||
"virtuoso": "Virtuoso",
|
||||
"legend": "Legend",
|
||||
"mythic": "Mythic"
|
||||
"mythic": "Mythic",
|
||||
"titan": "Titan",
|
||||
"sentinel": "Sentinel",
|
||||
"vanguard": "Vanguard",
|
||||
"oracle": "Oracle",
|
||||
"ascendant": "Ascendant",
|
||||
"ethereal": "Ethereal",
|
||||
"celestial": "Celestial",
|
||||
"divine": "Divine",
|
||||
"omnipotent": "Omnipotent",
|
||||
"eternal": "Eternal"
|
||||
},
|
||||
"leaderboardPage": {
|
||||
"title": "Leaderboard",
|
||||
@@ -499,6 +708,7 @@
|
||||
"successAccessDesc": "User can now view all your tasks.",
|
||||
"errorAccess": "Failed to grant access",
|
||||
"sharedWith": "Shared with",
|
||||
"sharedWithMe": "Shared with me",
|
||||
"unshare": "Unshare",
|
||||
"confirmShareTitle": "Share Task",
|
||||
"confirmShareDesc": "Are you sure you want to share this task with {{username}}?",
|
||||
@@ -509,6 +719,7 @@
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
"subtitle": "Track your progress and goals",
|
||||
"overview": "Overview",
|
||||
"rewardsDescription": "Spend your {{xp}} XP on exclusive rewards!",
|
||||
"addReward": "Add Reward",
|
||||
"createCustomReward": "Create Custom Reward",
|
||||
@@ -517,6 +728,8 @@
|
||||
"rewardCost": "Cost (XP)",
|
||||
"createRewardBtn": "Create Reward",
|
||||
"currentStreak": "Current Streak",
|
||||
"weeklyStreak": "Weekly Streak",
|
||||
"monthlyStreak": "Monthly Streak",
|
||||
"days": "{{count}} Days",
|
||||
"bestStreak": "Best: {{count}} Days",
|
||||
"xpActivity": "XP Activity",
|
||||
@@ -559,16 +772,21 @@
|
||||
"currentXP": "{{current}} / {{next}} XP",
|
||||
"viewDetails": "View Details",
|
||||
"source": {
|
||||
"task_completion": "Task Completed",
|
||||
"create_task": "Task Created",
|
||||
"create_subtask": "Subtask Created",
|
||||
"update_task": "Task Updated",
|
||||
"complete_task": "Task Completed",
|
||||
"complete_task_late": "Task Completed",
|
||||
"ai_action": "AI Assistant Used",
|
||||
"daily_streak": "Daily Streak Bonus",
|
||||
"daily_clear_bonus": "Daily Clear Bonus",
|
||||
"goal_completed": "Goal Completed"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Gamification Rules",
|
||||
"xpSystem": "XP System",
|
||||
"title": "Rules & Ranks",
|
||||
"xpSystem": "How to earn XP",
|
||||
"levelRequirements": "Level Requirements",
|
||||
"actions": "Actions & Rewards",
|
||||
"actions": "XP Actions",
|
||||
"level": "Level {{level}}",
|
||||
"xp": "{{xp}} XP",
|
||||
"action": "Action",
|
||||
@@ -576,10 +794,14 @@
|
||||
"createTask": "Create Task",
|
||||
"createSubtask": "Create Subtask",
|
||||
"updateTask": "Update Task",
|
||||
"completeTask": "Complete Task (On Time)",
|
||||
"completeTask": "Complete Task",
|
||||
"completeTaskLate": "Complete Task (Late)",
|
||||
"aiAction": "Use AI Feature",
|
||||
"dailyStreak": "Daily Streak Bonus"
|
||||
"aiAction": "AI Action",
|
||||
"dailyStreak": "Daily Streak",
|
||||
"weeklyStreak": "Weekly Streak (7 Days)",
|
||||
"monthlyStreak": "Monthly Streak (30 Days)",
|
||||
"streakTooltip": "Log in daily to increase your streak!",
|
||||
"streakBonus": "Weekly & Monthly bonuses available: 7 days (+300 XP), 30 days (+1000 XP)."
|
||||
}
|
||||
},
|
||||
"rewards": {
|
||||
@@ -609,6 +831,17 @@
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"timeTracking": "Time Tracking",
|
||||
"timeDistribution": "Time Distribution",
|
||||
"totalTime": "Total Time",
|
||||
"noData": "No data recorded for this period",
|
||||
"period": {
|
||||
"day": "Day",
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"year": "Year"
|
||||
},
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
@@ -703,6 +936,306 @@
|
||||
"createAccount": "Create Account",
|
||||
"registrationDisabled": "Registration is currently disabled.",
|
||||
"heroTitle": "TaskFlow",
|
||||
"heroSubtitle": "Master your productivity with AI-driven task management, gamified achievements, and intelligent focus modes."
|
||||
"heroSubtitle": "Boost productivity with gamified task management and AI assistance.",
|
||||
"2faVerification": "2FA Verification",
|
||||
"2faCodeSent": "2FA Code Sent",
|
||||
"checkEmail": "Please check your email for the verification code.",
|
||||
"enterCodeSentTo": "Enter the 6-digit code sent to",
|
||||
"codeExpiresIn10": "Code expires in 10 minutes",
|
||||
"verify": "Verify"
|
||||
},
|
||||
"planning": {
|
||||
"minutes": "Minutes",
|
||||
"durationPlaceholder": "e.g. 30",
|
||||
"startDate": "Start Date",
|
||||
"estimatedDuration": "Estimated Duration",
|
||||
"subtasks": "Subtasks",
|
||||
"timeEstimate": "Time Estimate"
|
||||
},
|
||||
"common": {
|
||||
"optional": "Optional",
|
||||
"saving": "Saving...",
|
||||
"creating": "Creating..."
|
||||
},
|
||||
"adhd": {
|
||||
"mode": "Focus Tools",
|
||||
"modeEnabled": "Focus Tools enabled",
|
||||
"modeDisabled": "Focus Tools disabled",
|
||||
"modeDescription": "Optimizes the app for focus and productivity",
|
||||
"toggleMode": "Toggle Focus Tools",
|
||||
"active": "Active",
|
||||
"dashboard": {
|
||||
"title": "Focus Dashboard",
|
||||
"subtitle": "Tools designed for how your brain works",
|
||||
"available": "available",
|
||||
"completedToday": "Done today",
|
||||
"coworking": "Co-working",
|
||||
"energyLevel": "Energy",
|
||||
"focusMode": "Focus mode",
|
||||
"helpTitle": "How Focus Tools Help You",
|
||||
"helpIntro": "These tools are designed around how your brain works - addressing common challenges like time blindness, task paralysis, and the need for quick wins.",
|
||||
"help": {
|
||||
"quickWins": {
|
||||
"title": "Quick Wins",
|
||||
"why": "Starting tasks feels overwhelming, and your brain craves quick dopamine hits.",
|
||||
"how": "Shows only tasks under 15 minutes, sorted by easiest to complete. Completing small tasks builds momentum and gives your brain the reward it needs.",
|
||||
"usage": "Open Quick Wins when you feel stuck. Pick the first task and complete it. The dopamine boost will help you tackle more."
|
||||
},
|
||||
"singleTask": {
|
||||
"title": "Single Task Focus",
|
||||
"why": "Seeing a long task list causes overwhelm and paralysis.",
|
||||
"how": "Hides everything except ONE task. No distractions, no list anxiety - just the task in front of you.",
|
||||
"usage": "Select a task and enter focus mode. Work only on that task until done or you need a break."
|
||||
},
|
||||
"fiveMinute": {
|
||||
"title": "5-Minute Starter",
|
||||
"why": "The hardest part is beginning. Your brain resists starting big tasks.",
|
||||
"how": "Commit to just 5 minutes. Your brain thinks 'I can do anything for 5 minutes.' Once started, you often keep going.",
|
||||
"usage": "Click '5 Min Start' on any task. Work for 5 minutes. After that, you can stop guilt-free OR continue."
|
||||
},
|
||||
"visualTimer": {
|
||||
"title": "Visual Timer",
|
||||
"why": "Time blindness - you can't feel time passing naturally.",
|
||||
"how": "A shrinking circle shows time visually. Colors change (green → yellow → red) as time runs out.",
|
||||
"usage": "Use the timer during focus sessions. The visual feedback helps you feel time passing."
|
||||
},
|
||||
"breaks": {
|
||||
"title": "Break Reminders",
|
||||
"why": "You forget to take breaks, leading to burnout or hyperfocus crashes.",
|
||||
"how": "Gentle reminders appear after your set interval suggesting quick breaks like stretching or water.",
|
||||
"usage": "When the reminder appears, take a real break. Even 2 minutes helps. You can snooze if needed."
|
||||
},
|
||||
"hyperfocus": {
|
||||
"title": "Hyperfocus Protection",
|
||||
"why": "You get stuck on one task for hours, neglecting everything else.",
|
||||
"how": "Warns you when you've been on a single task too long. Shows other waiting tasks.",
|
||||
"usage": "When warned, assess if you should continue or switch. It's okay to keep going, but make it a conscious choice."
|
||||
},
|
||||
"energy": {
|
||||
"title": "Energy Check-ins",
|
||||
"why": "Forcing yourself to do hard tasks when exhausted leads to failure.",
|
||||
"how": "Log your energy level daily. The app suggests tasks that match your current energy.",
|
||||
"usage": "Check in each morning. Low energy? Do simple tasks. High energy? Tackle challenges."
|
||||
},
|
||||
"bodyDoubling": {
|
||||
"title": "Body Doubling",
|
||||
"why": "Working alone makes it hard to stay accountable.",
|
||||
"how": "Virtual co-working sessions where you work alongside others. Social accountability keeps you focused.",
|
||||
"usage": "Join or create a session. Just knowing others are working helps you stay on track."
|
||||
},
|
||||
"taskBreakdown": {
|
||||
"title": "AI Task Breakdown",
|
||||
"why": "Big tasks feel impossible to start.",
|
||||
"how": "AI breaks down overwhelming tasks into small, concrete steps with time estimates.",
|
||||
"usage": "Click 'Break It Down' on any large task. Review the steps and create them as subtasks."
|
||||
}
|
||||
},
|
||||
"quickWins": "Quick wins",
|
||||
"streak": "Day streak"
|
||||
},
|
||||
"timer": {
|
||||
"remaining": "remaining"
|
||||
},
|
||||
"breakReminder": {
|
||||
"title": "Time for a break!",
|
||||
"workingFor": "You've been working for {{minutes}} minutes",
|
||||
"message": "Your brain needs regular breaks. Choose a quick break:",
|
||||
"snooze5": "5 min later",
|
||||
"snooze15": "15 min later",
|
||||
"gentle": "It's okay to take breaks. You're doing great!",
|
||||
"types": {
|
||||
"stretch": "Stretch",
|
||||
"water": "Water",
|
||||
"walk": "Walk",
|
||||
"eyes": "Eyes",
|
||||
"snack": "Snack",
|
||||
"breathe": "Breathe"
|
||||
}
|
||||
},
|
||||
"taskBreakdown": {
|
||||
"title": "Break Down Task",
|
||||
"description": "Large tasks can be overwhelming. Let's break them into smaller steps.",
|
||||
"prompt": "Click below to break this task into manageable steps.",
|
||||
"analyzing": "Analyzing...",
|
||||
"breakItDown": "Break It Down",
|
||||
"estimated": "estimated",
|
||||
"totalTime": "Total time",
|
||||
"regenerate": "Regenerate",
|
||||
"createSubtasks": "Create {{count}} steps",
|
||||
"error": "Failed to break down task. Please try again."
|
||||
},
|
||||
"quickWins": {
|
||||
"title": "Quick Wins",
|
||||
"description": "Small tasks for instant dopamine boosts",
|
||||
"tip": "Small wins build momentum! Complete these quick tasks to boost your dopamine and build confidence.",
|
||||
"noTasks": "No quick tasks available"
|
||||
},
|
||||
"singleTask": {
|
||||
"title": "Single Task Focus",
|
||||
"description": "Focus on one task at a time without distractions",
|
||||
"selectTask": "Select a task to focus on",
|
||||
"choosePlaceholder": "Choose a task...",
|
||||
"task": "Task",
|
||||
"complete": "complete",
|
||||
"allDone": "All Done!",
|
||||
"allDoneDesc": "You've completed all tasks. Take a well-deserved break!",
|
||||
"exit": "Exit view",
|
||||
"markComplete": "Mark Complete",
|
||||
"fiveMin": "Start 5 Min",
|
||||
"startTimer": "Start Timer",
|
||||
"skip": "Skip"
|
||||
},
|
||||
"fiveMin": {
|
||||
"title": "The 5-Minute Rule",
|
||||
"prompt": "Commit to just 5 minutes. You can stop after that.",
|
||||
"start": "Start 5 Minutes",
|
||||
"noObligation": "No obligation to continue!",
|
||||
"remaining": "remaining",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"done": "Done!",
|
||||
"encouragement": "You're doing great! Every second counts.",
|
||||
"congrats": "Well done!",
|
||||
"fiveMinDone": "You made it through 5 minutes!",
|
||||
"continueWorking": "Keep Going",
|
||||
"finished": "Task finished!",
|
||||
"takeBreak": "Take a break",
|
||||
"earned": "earned"
|
||||
},
|
||||
"energy": {
|
||||
"title": "How's your energy level?",
|
||||
"description": "Help us suggest the right tasks for you.",
|
||||
"checkIn": "Check In",
|
||||
"checkInReminder": "How are you feeling today?",
|
||||
"checkInBenefit": "Track your energy to get better task suggestions",
|
||||
"notesLabel": "Notes",
|
||||
"notesPlaceholder": "How are you feeling today?",
|
||||
"submit": "Log Energy",
|
||||
"levels": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"suggestions": {
|
||||
"low": {
|
||||
"title": "Low energy recommendation",
|
||||
"description": "Focus on simple, administrative tasks or quick wins."
|
||||
},
|
||||
"medium": {
|
||||
"title": "Medium energy recommendation",
|
||||
"description": "Good for routine tasks and communication."
|
||||
},
|
||||
"high": {
|
||||
"title": "High energy recommendation",
|
||||
"description": "Perfect for creative or challenging tasks!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hyperfocus": {
|
||||
"title": "Hyperfocus Warning",
|
||||
"message": "You've been working on this task for {{minutes}} minutes.",
|
||||
"minutes": "min",
|
||||
"urgentTasks": "urgent tasks",
|
||||
"waitingTasks": "Waiting tasks",
|
||||
"takeBreak": "Take a break",
|
||||
"continue": "Continue",
|
||||
"gentle": "It's okay to take breaks or switch tasks."
|
||||
},
|
||||
"bodyDoubling": {
|
||||
"title": "Body Doubling",
|
||||
"description": "Work alongside others for more focus and accountability.",
|
||||
"tip": "Working alongside others helps maintain focus. Join or create a session to stay accountable.",
|
||||
"createSession": "Create Session",
|
||||
"newSession": "New Session",
|
||||
"newSessionDesc": "Create a session others can join.",
|
||||
"sessionTitle": "Title",
|
||||
"titlePlaceholder": "e.g. Morning Focus Session",
|
||||
"sessionType": "Type",
|
||||
"types": {
|
||||
"focus": "Focus",
|
||||
"brainstorm": "Brainstorm",
|
||||
"admin": "Admin",
|
||||
"creative": "Creative"
|
||||
},
|
||||
"startTime": "Start Time",
|
||||
"duration": "Duration",
|
||||
"create": "Create Session",
|
||||
"activeSessions": "Active Sessions",
|
||||
"upcomingSessions": "Upcoming Sessions",
|
||||
"noSessions": "No sessions scheduled. Create the first one!",
|
||||
"live": "Live",
|
||||
"join": "Join",
|
||||
"leave": "Leave"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Focus Settings",
|
||||
"description": "Customize your focus-friendly experience",
|
||||
"visualTitle": "Display & Interaction",
|
||||
"visualDesc": "Customize the interface for your needs.",
|
||||
"reducedAnimations": "Reduced Animations",
|
||||
"reducedAnimationsDesc": "Less visual motion for better focus.",
|
||||
"largerTargets": "Larger Buttons",
|
||||
"largerTargetsDesc": "Bigger touch targets for easier clicking.",
|
||||
"singleTaskDefault": "Single Task Mode Default",
|
||||
"singleTaskDefaultDesc": "Show only one task at a time.",
|
||||
"timerTitle": "Timer & Focus",
|
||||
"timerDesc": "Settings for time management.",
|
||||
"visualTimer": "Visual Timer",
|
||||
"visualTimerDesc": "Show time as a shrinking circle.",
|
||||
"hyperfocusProtection": "Hyperfocus Protection",
|
||||
"hyperfocusProtectionDesc": "Warnings when working too long on one task.",
|
||||
"hyperfocusMaxMinutes": "Maximum Focus Time",
|
||||
"breakTitle": "Breaks",
|
||||
"breakDesc": "Reminders for regular breaks.",
|
||||
"breakInterval": "Break Interval (minutes)",
|
||||
"motivationTitle": "Motivation",
|
||||
"motivationDesc": "Positive reinforcement and rewards.",
|
||||
"messagingLevel": "Encouragement Level",
|
||||
"messagingMinimal": "Minimal",
|
||||
"messagingNormal": "Normal",
|
||||
"messagingHigh": "High",
|
||||
"messagingLevelDesc": "How much positive encouragement do you want?",
|
||||
"quickWinThreshold": "Quick Win Threshold",
|
||||
"quickWinThresholdDesc": "Tasks under this duration are considered Quick Wins.",
|
||||
"energyTitle": "Energy",
|
||||
"energyDesc": "Daily energy check-ins.",
|
||||
"energyCheckIns": "Enable Energy Check-ins",
|
||||
"energyCheckInsDesc": "Ask about your energy level daily.",
|
||||
"reset": "Reset to Defaults"
|
||||
},
|
||||
"encouragements": {
|
||||
"taskComplete": [
|
||||
"Amazing! Every step counts.",
|
||||
"You're doing great! Keep it up!",
|
||||
"One less task - you rock!",
|
||||
"See? You can do it!",
|
||||
"Fantastic! Stay on track!"
|
||||
],
|
||||
"returning": [
|
||||
"Good to see you back!",
|
||||
"Every fresh start is a win.",
|
||||
"Welcome back! Let's get started."
|
||||
],
|
||||
"struggling": [
|
||||
"It's okay to take small steps.",
|
||||
"You don't have to do everything at once.",
|
||||
"Take a deep breath. You've got this."
|
||||
],
|
||||
"streakBroken": [
|
||||
"Streaks are just numbers. You're more than that.",
|
||||
"Tomorrow is a new day!",
|
||||
"Every day is a fresh chance."
|
||||
],
|
||||
"milestone": [
|
||||
"Milestone reached! Incredible!",
|
||||
"You made it! Celebrate this moment!",
|
||||
"What an achievement!"
|
||||
],
|
||||
"quickWin": [
|
||||
"Quick win! The dopamine is flowing!",
|
||||
"Boom! Quick win complete!",
|
||||
"That was fast!"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -327,4 +333,273 @@
|
||||
.border.hover-elevate:not(.no-hover-interaction-elevate)::after {
|
||||
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%
|
||||
);
|
||||
}
|
||||
@@ -28,23 +28,23 @@ export const getQueryFn: <T>(options: {
|
||||
on401: UnauthorizedBehavior;
|
||||
}) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey.join("/") as string, {
|
||||
credentials: "include",
|
||||
});
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey.join("/") as string, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return await res.json();
|
||||
};
|
||||
await throwIfResNotOk(res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: getQueryFn({ on401: "throw" }),
|
||||
queryFn: getQueryFn({ on401: "returnNull" }),
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
|
||||
@@ -6,8 +6,12 @@ import "./i18n/config";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useADHDMode, ADHDSettingsPanel, EnergyCheckIn } from '@/components/adhd';
|
||||
import { User, Task } from '@shared/schema';
|
||||
import { Brain, Zap, Focus, Users, Battery, TrendingUp, ArrowLeft, HelpCircle, Clock, Coffee, Sparkles, Timer, Split } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { useLocation } from 'wouter';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ADHDDashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const { isEnabled, settings } = useADHDMode();
|
||||
const [showEnergyCheckIn, setShowEnergyCheckIn] = useState(false);
|
||||
|
||||
const { data: user } = useQuery<User>({
|
||||
queryKey: ['/api/user'],
|
||||
});
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
const { data: breakStats } = useQuery({
|
||||
queryKey: ['/api/user/break-stats'],
|
||||
enabled: isEnabled,
|
||||
});
|
||||
|
||||
const { data: energyHistory } = useQuery({
|
||||
queryKey: ['/api/energy/history'],
|
||||
enabled: isEnabled,
|
||||
});
|
||||
|
||||
const completedToday = tasks.filter(t => {
|
||||
if (t.status !== 'done') return false;
|
||||
// No updatedAt on Task; use dueDate as approximate "completed today" proxy
|
||||
if (!t.dueDate) return false;
|
||||
const today = new Date();
|
||||
const due = new Date(t.dueDate);
|
||||
return due.toDateString() === today.toDateString();
|
||||
}).length;
|
||||
|
||||
// Use same logic as QuickWinList for consistent count
|
||||
const threshold = settings.quickWinThreshold || 15;
|
||||
const quickWins = tasks.filter(t => {
|
||||
if (t.status === 'done') return false;
|
||||
// Only tasks without subtasks (leaf tasks)
|
||||
if (tasks.some(parent => parent.parentTaskId === t.id)) return false;
|
||||
// Filter by duration threshold (same as QuickWinList: threshold * 1.5)
|
||||
if (t.estimatedDuration && t.estimatedDuration > threshold * 1.5) return false;
|
||||
return true;
|
||||
}).length;
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: t('adhd.quickWins.title'),
|
||||
description: t('adhd.quickWins.description'),
|
||||
path: '/adhd/quick-wins',
|
||||
color: 'from-yellow-400 to-orange-500',
|
||||
stat: `${quickWins} ${t('adhd.dashboard.available', 'available')}`,
|
||||
},
|
||||
{
|
||||
icon: Focus,
|
||||
title: t('adhd.singleTask.title'),
|
||||
description: t('adhd.singleTask.description'),
|
||||
path: '/adhd/single-task',
|
||||
color: 'from-blue-400 to-indigo-500',
|
||||
stat: t('adhd.dashboard.focusMode', 'Focus mode'),
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: t('adhd.bodyDoubling.title'),
|
||||
description: t('adhd.bodyDoubling.description'),
|
||||
path: '/adhd/body-doubling',
|
||||
color: 'from-green-400 to-teal-500',
|
||||
stat: t('adhd.dashboard.coworking', 'Co-working'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3 sm:gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.history.back()}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<div className="p-2 sm:p-3 rounded-xl bg-gradient-to-br from-purple-400 to-pink-500 text-white shrink-0">
|
||||
<Brain className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold truncate">{t('adhd.dashboard.title', 'ADHD Dashboard')}</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground truncate">{t('adhd.dashboard.subtitle', 'Tools designed for how your brain works')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{completedToday}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adhd.dashboard.completedToday', 'Done today')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<Battery className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold capitalize">{user?.currentEnergyLevel || '-'}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adhd.dashboard.energyLevel', 'Energy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-900/30">
|
||||
<Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{quickWins}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adhd.dashboard.quickWins', 'Quick wins')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<Brain className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{user?.currentStreak || 0}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adhd.dashboard.streak', 'Day streak')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Energy Check-in Button */}
|
||||
{isEnabled && settings.energyCheckInsEnabled && !user?.todayEnergyCheckedIn && (
|
||||
<Card className="border-2 border-dashed border-primary/50 bg-primary/5">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{t('adhd.energy.checkInReminder', 'How are you feeling today?')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('adhd.energy.checkInBenefit', 'Track your energy to get better task suggestions')}</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowEnergyCheckIn(true)}>
|
||||
{t('adhd.energy.checkIn', 'Check In')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{features.map((feature) => (
|
||||
<Card
|
||||
key={feature.path}
|
||||
className="cursor-pointer hover:shadow-lg transition-all hover:-translate-y-1"
|
||||
onClick={() => setLocation(feature.path)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${feature.color} flex items-center justify-center mb-2`}>
|
||||
<feature.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{feature.title}</CardTitle>
|
||||
<CardDescription>{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm font-medium text-primary">{feature.stat}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('adhd.settings.title', 'ADHD Settings')}</CardTitle>
|
||||
<CardDescription>{t('adhd.settings.description', 'Customize your ADHD-friendly experience')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ADHDSettingsPanel />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Collapsible Help Section */}
|
||||
<Card>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="help" className="border-none">
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<HelpCircle className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">{t('adhd.dashboard.helpTitle', 'How Focus Tools Help You')}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6">
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t('adhd.dashboard.helpIntro', 'These tools are designed around how your brain works, not against it. Each feature addresses a specific challenge and provides practical strategies to help you get things done.')}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Quick Wins */}
|
||||
<div className="p-4 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.quickWins.title', 'Quick Wins')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.quickWins.why', 'Starting tasks feels overwhelming when everything seems big.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.quickWins.how', 'Shows only tasks under 15 minutes. Small wins build momentum and dopamine.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.quickWins.usage', 'Open Quick Wins when you feel stuck or need to build momentum.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Single Task Focus */}
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Focus className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.singleTask.title', 'Single Task Focus')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.singleTask.why', 'Task lists are overwhelming. Seeing everything makes it hard to start anything.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.singleTask.how', 'Shows only ONE task at a time. No distractions, no choices, just focus.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.singleTask.usage', 'When you have many tasks and feel paralyzed by choice.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 5-Minute Starter */}
|
||||
<div className="p-4 rounded-lg bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.fiveMinute.title', '5-Minute Starter')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.fiveMinute.why', 'Getting started is the hardest part. Commitment feels impossible.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.fiveMinute.how', 'Commit to just 5 minutes. Often, starting leads to continuing naturally.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.fiveMinute.usage', 'For tasks you keep avoiding. Just 5 minutes, then decide if you continue.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Visual Timer */}
|
||||
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Timer className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.visualTimer.title', 'Visual Timer')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.visualTimer.why', 'Time blindness makes it hard to feel time passing.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.visualTimer.how', 'A visual countdown shows time disappearing, making it tangible.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.visualTimer.usage', 'Set timers for work sessions. The visual helps maintain awareness.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Break Reminders */}
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Coffee className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.breaks.title', 'Break Reminders')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.breaks.why', 'Hyperfocus makes you forget basic needs like rest, food, water.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.breaks.how', 'Gentle reminders interrupt hyperfocus before burnout.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.breaks.usage', 'Enable in settings. Take the breaks even when you feel productive.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Energy Tracking */}
|
||||
<div className="p-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Battery className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.energy.title', 'Energy Tracking')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.energy.why', 'Energy fluctuates unpredictably. Fighting low energy wastes time.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.energy.how', 'Track your energy to match tasks to your current state.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.energy.usage', 'Check in daily. Do hard tasks when high, easy tasks when low.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body Doubling */}
|
||||
<div className="p-4 rounded-lg bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="h-4 w-4 text-teal-600 dark:text-teal-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.bodyDoubling.title', 'Body Doubling')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.bodyDoubling.why', 'Working alone feels impossible. Presence of others helps focus.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.bodyDoubling.how', 'Virtual co-working sessions simulate having someone nearby.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.bodyDoubling.usage', 'Join a session when working from home feels isolating.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Task Breakdown */}
|
||||
<div className="p-4 rounded-lg bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Split className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.taskBreakdown.title', 'AI Task Breakdown')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.taskBreakdown.why', 'Big tasks feel impossible to start when you cannot see the steps.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.taskBreakdown.how', 'AI breaks large tasks into small, actionable subtasks automatically.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.taskBreakdown.usage', 'Click breakdown on any overwhelming task to get clear next steps.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hyperfocus Protection */}
|
||||
<div className="p-4 rounded-lg bg-pink-50 dark:bg-pink-900/20 border border-pink-200 dark:border-pink-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="h-4 w-4 text-pink-600 dark:text-pink-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.hyperfocus.title', 'Hyperfocus Protection')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.hyperfocus.why', 'Hyperfocus can make you lose hours on one thing while neglecting others.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.hyperfocus.how', 'Alerts when you spend too long on a single task.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.hyperfocus.usage', 'Enable in settings. When alerted, save your progress and decide consciously.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Card>
|
||||
|
||||
{/* Energy Check-in Modal */}
|
||||
<EnergyCheckIn
|
||||
open={showEnergyCheckIn}
|
||||
onOpenChange={setShowEnergyCheckIn}
|
||||
onComplete={() => setShowEnergyCheckIn(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { SMTPSettingsCard } from "@/components/admin/SMTPSettingsCard";
|
||||
import { AiSettingsCard } from "@/components/admin/AiSettingsCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Shield, Bot, FileText, Settings } from "lucide-react";
|
||||
import { ArrowLeft, Shield, Bot, FileText, Settings, Sparkles } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AuditLogsTable } from "@/components/admin/AuditLogsTable";
|
||||
import { McpSettingsCard } from "@/components/admin/McpSettingsCard";
|
||||
import { RoutineSettingsCard } from "@/components/admin/RoutineSettingsCard";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
export default function AdminSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,7 +22,12 @@ export default function AdminSettings() {
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('settings.admin.title')}</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('settings.admin.title')}</h1>
|
||||
<span className="text-sm font-mono text-muted-foreground border px-2 py-0.5 rounded-md bg-muted/50">
|
||||
v{__APP_VERSION__}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{t('settings.admin.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,20 +37,42 @@ export default function AdminSettings() {
|
||||
<TabsList>
|
||||
<TabsTrigger value="settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
General & AI
|
||||
{t('settings.admin.tabs.general')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ai" className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t('settings.admin.tabs.ai')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routines" className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t('settings.admin.tabs.routines', 'Routines')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
{t('settings.admin.tabs.mcp')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audit" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Audit Logs
|
||||
{t('settings.admin.tabs.audit')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<AiSettingsCard />
|
||||
<McpSettingsCard />
|
||||
<SMTPSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ai">
|
||||
<AiSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routines">
|
||||
<RoutineSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp">
|
||||
<McpSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit">
|
||||
<AuditLogsTable />
|
||||
</TabsContent>
|
||||
|
||||
@@ -20,6 +20,16 @@ import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export default function AdminUserManagement() {
|
||||
const { t } = useTranslation();
|
||||
@@ -80,6 +90,18 @@ export default function AdminUserManagement() {
|
||||
onError: (e: Error) => toast({ title: "Failed to create", description: e.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const [resetXpUser, setResetXpUser] = useState<User | null>(null);
|
||||
|
||||
const resetXpMutation = useMutation({
|
||||
mutationFn: (userId: string) => apiRequest("POST", `/api/admin/users/${userId}/reset-xp`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/admin/users"] });
|
||||
setResetXpUser(null);
|
||||
toast({ title: t('userManagement.resetXpSuccess', 'User XP reset successfully') });
|
||||
},
|
||||
onError: (e: Error) => toast({ title: "Failed to reset XP", description: e.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 container mx-auto p-4 max-w-5xl">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -210,6 +232,14 @@ export default function AdminUserManagement() {
|
||||
>
|
||||
{user.isActive ? t('userManagement.table.deactivate') : t('userManagement.table.activate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setResetXpUser(user)}
|
||||
disabled={user.role === 'admin' && user.username === 'admin'}
|
||||
>
|
||||
{t('userManagement.resetXp', 'Reset XP')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -255,6 +285,26 @@ export default function AdminUserManagement() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={!!resetXpUser} onOpenChange={(open) => !open && setResetXpUser(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('userManagement.resetXpTitle', 'Reset User XP')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('userManagement.resetXpConfirm', 'Are you sure you want to reset XP and Level for user "{username}"? This action cannot be undone.', { username: resetXpUser?.username })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setResetXpUser(null)}>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => resetXpUser && resetXpMutation.mutate(resetXpUser.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{resetXpMutation.isPending ? t('common.loading') : t('userManagement.resetXp', 'Reset XP')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -22,7 +23,24 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Plus, MessageSquare, Trash2, Send, Bot, User as UserIcon, Loader2, Sparkles, AlertCircle, Pencil, Check, ChevronDown, History, X } from "lucide-react";
|
||||
import {
|
||||
Send,
|
||||
Bot,
|
||||
User as UserIcon,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
X,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Download,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -45,6 +63,22 @@ type Message = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const preprocessMarkdown = (text: string) => {
|
||||
if (!text) return "";
|
||||
|
||||
// 1. Ensure newlines before headers (###), horizontal rules (---), and list items
|
||||
let processed = text
|
||||
.replace(/([^\n])\n(#{1,6}\s)/g, '$1\n\n$2') // Header
|
||||
.replace(/([^\n])\n(\*{3,}|-{3,}|_{3,})/g, '$1\n\n$2') // HR
|
||||
.replace(/([^\n])\n(- |\* |\d+\. )/g, '$1\n\n$2'); // Lists
|
||||
|
||||
// 2. Ensure code blocks have newlines before/after
|
||||
processed = processed.replace(/([^\n])```/g, '$1\n```');
|
||||
processed = processed.replace(/```([^\n])/g, '```\n$1');
|
||||
|
||||
return processed;
|
||||
};
|
||||
|
||||
const TypewriterMessage = ({ content, onComplete }: { content: string, onComplete?: () => void }) => {
|
||||
const [displayedContent, setDisplayedContent] = useState("");
|
||||
const indexRef = useRef(0);
|
||||
@@ -75,7 +109,8 @@ const TypewriterMessage = ({ content, onComplete }: { content: string, onComplet
|
||||
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayedContent}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{preprocessMarkdown(displayedContent)}</ReactMarkdown>
|
||||
{/* {displayedContent} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -99,9 +134,15 @@ export default function AiChatPage() {
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [editMessageContent, setEditMessageContent] = useState("");
|
||||
|
||||
// Check AI Configuration Status
|
||||
const { data: aiStatus, isLoading: isLoadingStatus } = useQuery<{ configured: boolean }>({
|
||||
queryKey: ['/api/ai/status'],
|
||||
});
|
||||
|
||||
// Fetch Conversations
|
||||
const { data: conversations, isLoading: isLoadingConvs } = useQuery<Conversation[]>({
|
||||
queryKey: ["/api/ai/conversations"],
|
||||
enabled: aiStatus?.configured
|
||||
});
|
||||
|
||||
// Select latest conversation on load if none selected
|
||||
@@ -111,10 +152,15 @@ export default function AiChatPage() {
|
||||
}
|
||||
}, [conversations, selectedConversationId]);
|
||||
|
||||
// Fetch Messages for selected conversation (disabled if 'new')
|
||||
// Fetch Messages for selected conversation
|
||||
const { data: messages, isLoading: isLoadingMessages } = useQuery<Message[]>({
|
||||
queryKey: ["/api/ai/conversations", selectedConversationId, "messages"],
|
||||
enabled: !!selectedConversationId && selectedConversationId !== 'new',
|
||||
refetchInterval: (query) => {
|
||||
// Poll if the last message is from the user (waiting for AI response)
|
||||
const lastMsg = query.state.data?.slice(-1)[0];
|
||||
return lastMsg?.role === 'user' ? 2000 : false;
|
||||
}
|
||||
});
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@@ -136,55 +182,51 @@ export default function AiChatPage() {
|
||||
},
|
||||
onSuccess: (newConv) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
|
||||
// Do NOT set selected ID here, handled in handleSend to avoid race conditions or double sets
|
||||
},
|
||||
});
|
||||
|
||||
// Delete Conversation
|
||||
const deleteConversationMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiRequest("DELETE", `/api/ai/conversations/${id}`);
|
||||
const res = await apiRequest("DELETE", `/api/ai/conversations/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to delete conversation");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
|
||||
if (selectedConversationId !== 'new') {
|
||||
if (selectedConversationId === deleteId) {
|
||||
setSelectedConversationId(null);
|
||||
}
|
||||
toast({ title: t('ai.chatDeleted', 'Chat deleted') });
|
||||
toast({ title: t('common.deleted', 'Deleted') });
|
||||
},
|
||||
});
|
||||
|
||||
// Rename Conversation
|
||||
const renameConversationMutation = useMutation({
|
||||
mutationFn: async ({ id, title }: { id: string; title: string }) => {
|
||||
await apiRequest("PATCH", `/api/ai/conversations/${id}`, { title });
|
||||
mutationFn: async ({ id, title }: { id: string, title: string }) => {
|
||||
const res = await apiRequest("PATCH", `/api/ai/conversations/${id}`, { title });
|
||||
if (!res.ok) throw new Error("Failed to rename conversation");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
|
||||
setEditingId(null);
|
||||
toast({ title: t('ai.chatRenamed', 'Chat renamed') });
|
||||
},
|
||||
});
|
||||
|
||||
const generateTitleMutation = useMutation({
|
||||
mutationFn: async ({ messages }: { conversationId: string, messages: any[] }) => {
|
||||
const res = await apiRequest("POST", "/api/ai/generate-title", { messages });
|
||||
if (!res.ok) throw new Error("Failed to generate title");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
renameConversationMutation.mutate({ id: vars.conversationId, title: data.title });
|
||||
}
|
||||
});
|
||||
|
||||
const sendMessageMutation = useMutation({
|
||||
mutationFn: async ({ conversationId, content }: { conversationId: string, content: string }) => {
|
||||
// ALWAYS use the main chat endpoint which is now async-friendly
|
||||
const res = await apiRequest("POST", "/api/ai/chat", {
|
||||
conversationId,
|
||||
content,
|
||||
clientTime: new Date().toLocaleString()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || t('ai.sendFailed', 'Failed to send message'));
|
||||
try {
|
||||
const errData = await res.json();
|
||||
throw new Error(errData.error || t('ai.sendFailed', 'Failed to send message'));
|
||||
} catch (e) {
|
||||
throw new Error(t('ai.sendFailed', 'Failed to send message'));
|
||||
}
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
@@ -192,6 +234,7 @@ export default function AiChatPage() {
|
||||
await queryClient.cancelQueries({ queryKey: ["/api/ai/conversations", conversationId, "messages"] });
|
||||
const previousMessages = queryClient.getQueryData<Message[]>(["/api/ai/conversations", conversationId, "messages"]);
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueryData(["/api/ai/conversations", conversationId, "messages"], (old: Message[] = []) => [
|
||||
...old,
|
||||
{ id: 'temp-' + Date.now(), role: 'user', content: content, createdAt: new Date().toISOString() }
|
||||
@@ -214,21 +257,13 @@ export default function AiChatPage() {
|
||||
variant: "destructive"
|
||||
});
|
||||
},
|
||||
onSuccess: (botMessage, vars, context) => {
|
||||
onSuccess: (userMessage, vars, context) => { // Returns USER message now
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", vars.conversationId, "messages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/goals"] });
|
||||
setLatestMessageId(botMessage.id);
|
||||
|
||||
// Auto-title if this was the first exchange (previous messages empty or undefined)
|
||||
if (!context?.previousMessages || context.previousMessages.length === 0) {
|
||||
const messagesForTitle = [
|
||||
{ role: 'user', content: vars.content },
|
||||
{ role: 'assistant', content: botMessage.content }
|
||||
];
|
||||
generateTitleMutation.mutate({ conversationId: vars.conversationId, messages: messagesForTitle });
|
||||
}
|
||||
// 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'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -335,7 +370,7 @@ export default function AiChatPage() {
|
||||
editMessageMutation.mutate({ id: editingMessageId, content: editMessageContent });
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === 'ArrowUp' && !input && !editingMessageId && messages) {
|
||||
e.preventDefault();
|
||||
// Find last user message
|
||||
@@ -356,6 +391,7 @@ export default function AiChatPage() {
|
||||
{/* Header / Top Navigation Bar */}
|
||||
<div className="h-16 border-b flex items-center px-4 md:px-6 justify-between shrink-0 bg-background/80 backdrop-blur-sm z-30 sticky top-0 shadow-sm">
|
||||
|
||||
{/* Left: Branding, History & Current Title */}
|
||||
{/* Left: Branding, History & Current Title */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
@@ -363,120 +399,164 @@ export default function AiChatPage() {
|
||||
</div>
|
||||
|
||||
{/* Logic: If editing current title, show input. Else show Dropdown. */}
|
||||
{editingId === selectedConversationId ? (
|
||||
<div className="flex items-center gap-2 flex-1 max-w-[300px]">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={saveName}
|
||||
autoFocus
|
||||
className="h-9"
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={() => setEditingId(null)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto py-2 px-3 font-semibold text-lg flex gap-2 items-center hover:bg-muted/50 transition-colors rounded-lg max-w-full">
|
||||
<div className="flex flex-col items-start leading-none min-w-0">
|
||||
<span className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent text-lg truncate max-w-[200px] md:max-w-[400px]">
|
||||
{currentTitle}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-normal flex items-center gap-1">
|
||||
{t('ai.selectConversation', 'Switch Chat')} <ChevronDown className="w-3 h-3" />
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[300px] max-h-[500px] overflow-y-auto">
|
||||
<DropdownMenuLabel>{t('ai.recentChats', 'Recent Chats')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{conversations?.map(conv => (
|
||||
<DropdownMenuItem
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConversationId(conv.id)}
|
||||
className={cn("flex justify-between items-center cursor-pointer py-3 group", selectedConversationId === conv.id ? "bg-muted" : "")}
|
||||
>
|
||||
{editingId === conv.id ? (
|
||||
<div className="flex items-center gap-2 flex-1 onClick-stop">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={saveName}
|
||||
autoFocus
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 overflow-hidden flex-1">
|
||||
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate font-medium">{conv.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
onClick={(e) => startEditing(conv, e)}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => handleDelete(conv.id, e)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{(!conversations || conversations.length === 0) && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t('ai.noChats', 'No recent chats')}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Button: Only show if we have a REAL selected conversation (not 'new') */}
|
||||
{selectedConversationId && selectedConversationId !== 'new' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
const conv = conversations?.find(c => c.id === selectedConversationId);
|
||||
if (conv) startEditing(conv, e);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
{aiStatus?.configured && (
|
||||
editingId === selectedConversationId ? (
|
||||
<div className="flex items-center gap-2 flex-1 max-w-[300px]">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={saveName}
|
||||
autoFocus
|
||||
className="h-9"
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={() => setEditingId(null)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto py-2 px-3 font-semibold text-lg flex gap-2 items-center hover:bg-muted/50 transition-colors rounded-lg max-w-full">
|
||||
<div className="flex flex-col items-start leading-none min-w-0">
|
||||
<span className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent text-lg truncate max-w-[200px] md:max-w-[400px]">
|
||||
{currentTitle}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-normal flex items-center gap-1">
|
||||
{t('ai.selectConversation', 'Switch Chat')} <ChevronDown className="w-3 h-3" />
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[300px] max-h-[500px] overflow-y-auto">
|
||||
<DropdownMenuLabel>{t('ai.recentChats', 'Recent Chats')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{conversations?.map(conv => (
|
||||
<DropdownMenuItem
|
||||
key={conv.id}
|
||||
onClick={() => setSelectedConversationId(conv.id)}
|
||||
className={cn("flex justify-between items-center cursor-pointer py-3 group", selectedConversationId === conv.id ? "bg-muted" : "")}
|
||||
>
|
||||
{editingId === conv.id ? (
|
||||
<div className="flex items-center gap-2 flex-1 onClick-stop">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={saveName}
|
||||
autoFocus
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 overflow-hidden flex-1">
|
||||
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate font-medium">{conv.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground"
|
||||
onClick={(e) => startEditing(conv, e)}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => handleDelete(conv.id, e)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{(!conversations || conversations.length === 0) && (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t('ai.noChats', 'No recent chats')}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Button: Only show if we have a REAL selected conversation (not 'new') */}
|
||||
{selectedConversationId && selectedConversationId !== 'new' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
const conv = conversations?.find(c => c.id === selectedConversationId);
|
||||
if (conv) startEditing(conv, e);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: New Chat Action */}
|
||||
<Button onClick={handleCreateNew} size="sm" className="gap-2 shadow-sm rounded-full shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('ai.newChat', 'New Chat')}</span>
|
||||
</Button>
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{aiStatus?.configured && (
|
||||
<>
|
||||
{/* Export Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
if (!conversations || conversations.length === 0) return;
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify({
|
||||
exportDate: new Date().toISOString(),
|
||||
conversations: conversations,
|
||||
activeMessages: messages
|
||||
}, null, 2));
|
||||
const downloadAnchorNode = document.createElement('a');
|
||||
downloadAnchorNode.setAttribute("href", dataStr);
|
||||
downloadAnchorNode.setAttribute("download", `ai_chat_export_${new Date().toISOString()}.json`);
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
}}
|
||||
title={t('ai.export', 'Export Chat')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleCreateNew} size="sm" className="gap-2 shadow-sm rounded-full shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('ai.newChat', 'New Chat')}</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 relative overflow-hidden">
|
||||
{!selectedConversationId ? (
|
||||
{!aiStatus?.configured ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8 text-center animate-in fade-in zoom-in duration-300">
|
||||
<div className="w-24 h-24 bg-destructive/10 rounded-full flex items-center justify-center mb-6">
|
||||
<Bot className="w-12 h-12 text-destructive" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-bold mb-3 tracking-tight text-foreground">
|
||||
{t('ai.notConfiguredTitle', 'AI Not Configured')}
|
||||
</h3>
|
||||
<p className="max-w-md mb-8 text-lg opacity-80 leading-relaxed">
|
||||
{t('ai.notConfiguredDesc', 'Please configure an AI provider in the admin settings to start using the assistant.')}
|
||||
</p>
|
||||
</div>
|
||||
) : !selectedConversationId ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8 text-center animate-in fade-in zoom-in duration-300">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-violet-500/10 to-indigo-500/10 rounded-full flex items-center justify-center mb-6 animate-pulse">
|
||||
<Sparkles className="w-12 h-12 text-violet-500" />
|
||||
@@ -550,7 +630,8 @@ export default function AiChatPage() {
|
||||
<TypewriterMessage content={msg.content} onComplete={() => scrollToBottom()} />
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{preprocessMarkdown(msg.content)}</ReactMarkdown>
|
||||
{/* {msg.content} */}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -591,16 +672,24 @@ export default function AiChatPage() {
|
||||
<div className="pt-2 px-4 md:px-6 md:pt-2 pb-6 bg-background/50 backdrop-blur-sm shrink-0">
|
||||
<div className="max-w-3xl mx-auto space-y-3">
|
||||
<form
|
||||
className="relative flex items-center gap-2 bg-muted/40 border rounded-2xl px-4 py-2.5 shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all hover:bg-muted/60"
|
||||
className="relative flex items-start gap-2 bg-muted/40 border rounded-2xl px-4 py-1.5 shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all hover:bg-muted/60"
|
||||
onSubmit={handleSend}
|
||||
>
|
||||
<Sparkles className="w-5 h-5 text-muted-foreground/70" />
|
||||
<Input
|
||||
<Sparkles className="w-5 h-5 text-muted-foreground/70 self-start mt-2" />
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyDown={(e) => {
|
||||
// Handle Shift+Enter for newline
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
// Let Shift+Enter pass through
|
||||
handleInputKeyDown(e);
|
||||
}}
|
||||
placeholder={t('ai.placeholder', 'Ask me anything about your tasks...')}
|
||||
className="flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0 px-3 h-11 text-base placeholder:text-muted-foreground/60"
|
||||
className="flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0 px-3 min-h-[44px] max-h-[200px] text-base placeholder:text-muted-foreground/60 resize-none py-2.5"
|
||||
autoFocus
|
||||
disabled={sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
|
||||
/>
|
||||
@@ -609,7 +698,7 @@ export default function AiChatPage() {
|
||||
disabled={!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-xl shrink-0 transition-all duration-300",
|
||||
"h-9 w-9 rounded-xl shrink-0 transition-all duration-300 self-end mb-1",
|
||||
input.trim()
|
||||
? "opacity-100 scale-100 bg-primary text-primary-foreground shadow-md hover:scale-105"
|
||||
: "opacity-0 scale-75"
|
||||
|
||||
@@ -31,9 +31,16 @@ import { BrainCircuit } from "lucide-react";
|
||||
export default function AuthPage() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [, setLocation] = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState("login");
|
||||
|
||||
// 2FA State
|
||||
const [is2FARequired, setIs2FARequired] = useState(false);
|
||||
const [twoFAUserId, setTwoFAUserId] = useState<string | null>(null);
|
||||
const [twoFAEmail, setTwoFAEmail] = useState<string | null>(null);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
|
||||
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
|
||||
queryKey: ["/api/settings/public"],
|
||||
queryFn: async () => {
|
||||
@@ -52,14 +59,26 @@ export default function AuthPage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
// Handle 200 OK could be user or 2fa_required
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Invalid username or password");
|
||||
throw new Error(json.message || "Invalid username or password");
|
||||
}
|
||||
return res.json();
|
||||
return json;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "Welcome back!" });
|
||||
onSuccess: (data) => {
|
||||
if (data.message === "2fa_required") {
|
||||
setIs2FARequired(true);
|
||||
setTwoFAUserId(data.userId);
|
||||
setTwoFAEmail(data.email);
|
||||
toast({ title: t('auth.2faCodeSent'), description: t('auth.checkEmail') });
|
||||
} else {
|
||||
queryClient.setQueryData(["/api/user"], data);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "Welcome back!" });
|
||||
setLocation('/');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
@@ -70,6 +89,38 @@ export default function AuthPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const verify2FAMutation = useMutation({
|
||||
mutationFn: async (data: { userId: string, code: string }) => {
|
||||
const res = await fetch("/api/auth/verify-2fa", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(["/api/user"], user);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "Verification Successful", description: "Welcome back!" });
|
||||
setLocation('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Verification Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleVerify2FA = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (twoFAUserId && otpCode) {
|
||||
verify2FAMutation.mutate({ userId: twoFAUserId, code: otpCode });
|
||||
}
|
||||
};
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (data: InsertUser) => {
|
||||
const res = await fetch("/api/register", {
|
||||
@@ -83,19 +134,24 @@ export default function AuthPage() {
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
queryClient.setQueryData(["/api/user"], data);
|
||||
toast({ title: "Account created!" });
|
||||
setLocation('/');
|
||||
},
|
||||
// Error handling is done in the form submission handler to set field errors
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen grid lg:grid-cols-2">
|
||||
<div className="hidden lg:flex flex-col justify-center items-center bg-zinc-900 p-12 text-white">
|
||||
<div className="max-w-md space-y-4 text-center">
|
||||
<div className="bg-white/10 p-4 rounded-2xl inline-block mb-4 backdrop-blur-sm">
|
||||
<BrainCircuit className="w-16 h-16 text-primary-foreground" />
|
||||
<div className="min-h-screen grid lg:grid-cols-2 dark">
|
||||
<div className="hidden lg:flex flex-col justify-center items-center bg-gradient-to-br from-zinc-900 via-zinc-900 to-violet-950 p-12 text-white">
|
||||
<div className="max-w-md space-y-6 text-center">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className="w-28 h-28 rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center shadow-2xl mb-6">
|
||||
<img src="/icon-transparent-256.png" alt="TaskFlow" className="w-20 h-20 object-contain" />
|
||||
</div>
|
||||
<img src="/logo-horizontal-tagline-transparent.png" alt="TaskFlow - Focus. Flow. AI." className="h-12 object-contain" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">{t('auth.heroTitle')}</h1>
|
||||
<p className="text-lg text-zinc-400">
|
||||
@@ -105,68 +161,106 @@ export default function AuthPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-4 bg-background">
|
||||
<Card className="w-full max-w-md shadow-xl border-border/50">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="lg:hidden mx-auto bg-primary/10 p-3 rounded-xl w-fit mb-2">
|
||||
<BrainCircuit className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('auth.welcomeBack')}</CardTitle>
|
||||
<CardDescription>
|
||||
{settings?.registration_enabled
|
||||
? t('auth.signInDesc')
|
||||
: t('auth.signInDescNoReg')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex w-full mb-6 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab("login")}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "login"
|
||||
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
{settings?.registration_enabled && (
|
||||
{is2FARequired ? (
|
||||
<Card className="w-full max-w-md shadow-xl border-border/50">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="lg:hidden mx-auto mb-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center shadow-lg mx-auto">
|
||||
<img src="/icon-transparent-128.png" alt="TaskFlow" className="w-10 h-10 object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('auth.2faVerification')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('auth.enterCodeSentTo')} <span className="font-medium text-foreground">{twoFAUserId && twoFAEmail ? twoFAEmail : 'your email'}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleVerify2FA} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="123456"
|
||||
className="text-center text-2xl tracking-widest"
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">{t('auth.codeExpiresIn10')}</p>
|
||||
</div>
|
||||
<Button className="w-full" type="submit" disabled={verify2FAMutation.isPending || otpCode.length !== 6}>
|
||||
{verify2FAMutation.isPending ? t('common.verifying') : t('auth.verify')}
|
||||
</Button>
|
||||
<Button variant="ghost" className="w-full" type="button" onClick={() => setIs2FARequired(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="w-full max-w-md shadow-xl border-border/50">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="lg:hidden mx-auto mb-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center shadow-lg mx-auto">
|
||||
<img src="/icon-transparent-128.png" alt="TaskFlow" className="w-10 h-10 object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('auth.welcomeBack')}</CardTitle>
|
||||
<CardDescription>
|
||||
{settings?.registration_enabled
|
||||
? t('auth.signInDesc')
|
||||
: t('auth.signInDescNoReg')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex w-full mb-6 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab("register")}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "register"
|
||||
onClick={() => setActiveTab("login")}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "login"
|
||||
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t('auth.register')}
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{settings?.registration_enabled && (
|
||||
<button
|
||||
onClick={() => setActiveTab("register")}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "register"
|
||||
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t('auth.register')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === "login" ? (
|
||||
<AuthForm
|
||||
mode="login"
|
||||
onSubmit={(data) => loginMutation.mutate(data)}
|
||||
isLoading={loginMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
settings?.registration_enabled ? (
|
||||
{activeTab === "login" ? (
|
||||
<AuthForm
|
||||
mode="register"
|
||||
onSubmit={(data) => {
|
||||
registerMutation.mutate(data as InsertUser, {
|
||||
onError: (error) => {
|
||||
// Handled in form
|
||||
}
|
||||
})
|
||||
}}
|
||||
isLoading={registerMutation.isPending}
|
||||
registerMutation={registerMutation}
|
||||
mode="login"
|
||||
onSubmit={(data) => loginMutation.mutate(data)}
|
||||
isLoading={loginMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">{t('auth.registrationDisabled')}</div>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
settings?.registration_enabled ? (
|
||||
<AuthForm
|
||||
mode="register"
|
||||
onSubmit={(data) => {
|
||||
registerMutation.mutate(data as InsertUser, {
|
||||
onError: (error) => {
|
||||
// Handled in form
|
||||
}
|
||||
})
|
||||
}}
|
||||
isLoading={registerMutation.isPending}
|
||||
registerMutation={registerMutation}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">{t('auth.registrationDisabled')}</div>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BodyDoublingLobby } from '@/components/adhd';
|
||||
import { User } from '@shared/schema';
|
||||
import { Users, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocation } from 'wouter';
|
||||
|
||||
export default function BodyDoublingPage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const { data: user } = useQuery<User>({
|
||||
queryKey: ['/api/user'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">{t('common.loading', 'Loading...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3 sm:gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.history.back()}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<div className="p-2 sm:p-3 rounded-xl bg-gradient-to-br from-green-400 to-teal-500 text-white shrink-0">
|
||||
<Users className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">{t('adhd.bodyDoubling.title')}</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">{t('adhd.bodyDoubling.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-teal-50 dark:from-green-950/20 dark:to-teal-950/20 rounded-2xl p-6 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('adhd.bodyDoubling.tip', 'Working alongside others helps maintain focus. Join or create a session to stay accountable.')}
|
||||
</p>
|
||||
|
||||
<BodyDoublingLobby />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRoute, useLocation } from "wouter";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { User, Task } from "@shared/schema";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sun, Moon, CheckCircle2, ArrowRight, ListTodo, Calendar as CalendarIcon, NotebookPen, BrainCircuit } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { triggerConfetti } from "@/lib/confetti";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
// Steps Configuration
|
||||
const MORNING_STEPS = [
|
||||
{ key: "review_yesterday", title: "Review Yesterday", description: "Did you complete everything?", icon: ListTodo },
|
||||
{ key: "plan_today", title: "Plan Today", description: "What are your top 3 priorities?", icon: CalendarIcon },
|
||||
{ key: "check_schedule", title: "Visualize Success", description: "Take a moment to visualize your day.", icon: BrainCircuit },
|
||||
];
|
||||
|
||||
const EVENING_STEPS = [
|
||||
{ key: "review_today", title: "Review Today", description: "Celebrate your wins!", icon: CheckCircle2 },
|
||||
{ key: "plan_tomorrow", title: "Plan Tomorrow", description: "Set yourself up for success.", icon: CalendarIcon },
|
||||
{ key: "clear_mind", title: "Clear Mind", description: "Jot down any lingering thoughts.", icon: NotebookPen },
|
||||
];
|
||||
|
||||
export default function FocusRoutinePage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const [match, params] = useRoute("/focus/routine/:type");
|
||||
const type = params?.type as "morning" | "evening";
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Prevent hydration mismatch or early render
|
||||
if (!match || !['morning', 'evening'].includes(type)) {
|
||||
return <div className="p-8 text-center">Invalid routine type</div>;
|
||||
}
|
||||
|
||||
const steps = type === "morning" ? MORNING_STEPS : EVENING_STEPS;
|
||||
const currentStep = steps[stepIndex];
|
||||
|
||||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"] });
|
||||
const { data: tasks } = useQuery<Task[]>({ queryKey: ["/api/tasks"] });
|
||||
|
||||
// Task Context
|
||||
const overdueTasks = tasks?.filter(t => t.status !== 'done' && t.dueDate && new Date(t.dueDate) < new Date()) || [];
|
||||
const todayTasks = tasks?.filter(t => t.status !== 'done' && ((t.dueDate && new Date(t.dueDate) <= new Date()) || !t.dueDate)) || [];
|
||||
const completedToday = tasks?.filter(t => {
|
||||
if (t.status !== 'done') return false;
|
||||
// Check if completed today (approximate based on status update or we need 'completedAt' field which we don't strictly preserve in schema except via AuditLog, but let's assume 'done' tasks are relevant)
|
||||
// Ideally we filter by 'last updated' or audit log, but for now just showing 'Done' tasks is okay as visual reinforcement.
|
||||
return true;
|
||||
}) || [];
|
||||
|
||||
const completeRoutineMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiRequest("POST", `/api/user/routine/${type}/complete`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
if (type === 'evening') {
|
||||
triggerConfetti(0.5, 0.5);
|
||||
toast({ title: t('routine.dayComplete', "Day Complete! Sleep well.") });
|
||||
setLocation('/achievements'); // Or dashboard
|
||||
} else {
|
||||
toast({ title: t('routine.dayStarted', "Have a great day!") });
|
||||
setLocation('/');
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to complete routine", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
if (stepIndex < steps.length - 1) {
|
||||
setStepIndex(stepIndex + 1);
|
||||
} else {
|
||||
completeRoutineMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const Icon = currentStep.icon;
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen w-full flex flex-col justify-center items-center p-6 transition-colors duration-1000 ${type === 'morning' ? 'bg-orange-50/50 dark:bg-orange-950/20' : 'bg-indigo-50/50 dark:bg-indigo-950/20'
|
||||
}`}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-2xl"
|
||||
>
|
||||
<div className="mb-8 text-center space-y-2">
|
||||
<div className="inline-flex items-center justify-center p-4 rounded-full bg-background shadow-sm mb-4">
|
||||
{type === "morning" ? <Sun className="w-8 h-8 text-orange-500" /> : <Moon className="w-8 h-8 text-indigo-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={stepIndex}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card className="border-none shadow-2xl bg-background/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Step {stepIndex + 1} of {steps.length}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLocation('/')} className="h-6 text-xs text-muted-foreground">
|
||||
Skip
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${type === 'morning' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-600' : 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-600'}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{currentStep.title}</CardTitle>
|
||||
<CardDescription>{currentStep.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-[300px] flex flex-col gap-4">
|
||||
|
||||
{/* DYNAMIC CONTENT BASED ON STEP */}
|
||||
{currentStep.key === "review_yesterday" && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
|
||||
{overdueTasks.length > 0 ? (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-100 dark:border-red-900">
|
||||
<h4 className="font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">
|
||||
<ListTodo className="w-4 h-4" />
|
||||
Overdue Tasks
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{overdueTasks.map(t => (
|
||||
<li key={t.id} className="text-sm flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||||
{t.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center space-y-3">
|
||||
<div className="inline-flex p-3 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600">
|
||||
<CheckCircle2 className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="font-medium">No overdue tasks from yesterday!</p>
|
||||
<p className="text-sm text-muted-foreground">Great job staying on track.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.key === "plan_today" && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
|
||||
<h4 className="font-medium">Your schedule for specific tasks:</h4>
|
||||
{todayTasks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{todayTasks.slice(0, 5).map(task => (
|
||||
<div key={task.id} className="flex items-center gap-3 p-3 border rounded-lg bg-card/50">
|
||||
<div className={`w-3 h-3 rounded-full ${task.priority === 'high' ? 'bg-red-500' : 'bg-blue-500'}`} />
|
||||
<span className="font-medium">{task.title}</span>
|
||||
{task.estimatedDuration && <span className="ml-auto text-xs text-muted-foreground">{task.estimatedDuration}m</span>}
|
||||
</div>
|
||||
))}
|
||||
{todayTasks.length > 5 && <p className="text-center text-xs text-muted-foreground">and {todayTasks.length - 5} more...</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 border-2 border-dashed rounded-lg">
|
||||
<p className="text-muted-foreground">No tasks specifically scheduled for today.</p>
|
||||
<Button variant="link" onClick={() => window.open('/', '_blank')}>Add Tasks</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/10 rounded-lg text-sm text-blue-700 dark:text-blue-300">
|
||||
💡 Tip: Pick just 3 absolute "Must Do" tasks for today.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.key === "review_today" && (
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-green-500 to-emerald-600 mb-2">
|
||||
{completedToday.length}
|
||||
</div>
|
||||
<p className="text-muted-foreground font-medium">Tasks Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<p className="text-sm text-center italic">"Small progress is still progress."</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default / Text Input Steps */}
|
||||
{["clear_mind", "check_schedule", "plan_tomorrow"].includes(currentStep.key) && (
|
||||
<div className="flex-1 flex flex-col justify-center animate-in fade-in slide-in-from-bottom-4">
|
||||
{currentStep.key === "clear_mind" && (
|
||||
<div className="space-y-4">
|
||||
<Input placeholder="Note down any loose thoughts..." className="h-12 text-lg" />
|
||||
<Button variant="outline" className="w-full">Save to Inbox</Button>
|
||||
</div>
|
||||
)}
|
||||
{currentStep.key !== "clear_mind" && (
|
||||
<div className="text-center py-12 text-muted-foreground italic">
|
||||
Take 2 minutes to {currentStep.title.toLowerCase()}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between border-t pt-6">
|
||||
<Button variant="ghost" disabled={stepIndex === 0} onClick={() => setStepIndex(stepIndex - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="gap-2 px-8" size="lg">
|
||||
{stepIndex === steps.length - 1 ? (
|
||||
<>Finish and Start <CheckCircle2 className="w-4 h-4" /></>
|
||||
) : (
|
||||
<>Next Step <ArrowRight className="w-4 h-4" /></>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Task } from '@shared/schema';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bell, BellOff, Calendar, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { useNotifications } from '@/hooks/use-notifications';
|
||||
import { formatDistanceToNow, isPast, isToday, addDays, isBefore } from 'date-fns';
|
||||
import { de, enUS } from 'date-fns/locale';
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { enabled, toggleEnabled, permission, requestPermission } = useNotifications({ poll: false }); // Don't double poll here
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Derive notifications from tasks
|
||||
const notifications = tasks.flatMap(task => {
|
||||
if (!task.dueDate || task.status === 'done') return [];
|
||||
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const isOverdue = isBefore(dueDate, now);
|
||||
// Upcoming: due within next 24 hours
|
||||
const isUpcoming = !isOverdue && isBefore(dueDate, addDays(now, 1));
|
||||
|
||||
if (!isOverdue && !isUpcoming) return [];
|
||||
|
||||
return [{
|
||||
id: task.id,
|
||||
type: isOverdue ? 'overdue' : 'upcoming',
|
||||
task,
|
||||
timestamp: dueDate
|
||||
}];
|
||||
}).sort((a, b) => {
|
||||
// Sort: Overdue first, then by time
|
||||
if (a.type !== b.type) return a.type === 'overdue' ? -1 : 1;
|
||||
return a.timestamp.getTime() - b.timestamp.getTime();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('notifications.title', 'Notifications')}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('notifications.description', 'Manage your alerts and view important updates.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
<CardTitle>{t('notifications.settings.title', 'Browser Notifications')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifications-toggle" className="text-base">
|
||||
{t('notifications.enableBrowser', 'Enable Push Notifications')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{permission === 'denied'
|
||||
? <span className="text-red-500">{t('notifications.settings.denied', 'Permission denied. Please enable in browser settings.')}</span>
|
||||
: t('notifications.settings.description', 'Receive alerts for upcoming and overdue tasks.')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifications-toggle"
|
||||
checked={enabled}
|
||||
onCheckedChange={toggleEnabled}
|
||||
disabled={permission === 'denied'}
|
||||
/>
|
||||
</div>
|
||||
{permission === 'default' && (
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" size="sm" onClick={requestPermission}>
|
||||
{t('notifications.settings.request', 'Request Permission')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification List */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
{t('notifications.recent', 'Recent Alerts')}
|
||||
</h2>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/10 border-dashed">
|
||||
<CheckCircle2 className="h-10 w-10 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground font-medium">{t('notifications.empty', 'All caught up! No urgent alerts.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notif) => (
|
||||
<Card key={notif.id} className={`border-l-4 ${notif.type === 'overdue' ? 'border-l-red-500 bg-red-50/10 dark:bg-red-900/10' : 'border-l-blue-500'}`}>
|
||||
<CardContent className="p-4 flex items-start gap-4">
|
||||
<div className={`p-2 rounded-full shrink-0 ${notif.type === 'overdue' ? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'}`}>
|
||||
{notif.type === 'overdue' ? <AlertCircle className="h-5 w-5" /> : <Calendar className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-semibold truncate">{notif.task.title}</h3>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(notif.timestamp, { addSuffix: true, locale: i18n.language === 'de' ? de : enUS })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{notif.type === 'overdue'
|
||||
? t('notifications.overdueBody', 'This task is overdue!')
|
||||
: t('notifications.upcomingBody', { time: formatDistanceToNow(notif.timestamp, { locale: i18n.language === 'de' ? de : enUS }) })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { QuickWinList } from '@/components/adhd';
|
||||
import { Task } from '@shared/schema';
|
||||
import { Zap, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocation } from 'wouter';
|
||||
|
||||
export default function QuickWinsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
const handleTaskSelect = (taskId: string) => {
|
||||
setLocation(`/tasks?selected=${taskId}`);
|
||||
};
|
||||
|
||||
const handleTaskComplete = async (taskId: string) => {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'done' }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3 sm:gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.history.back()}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<div className="p-2 sm:p-3 rounded-xl bg-gradient-to-br from-yellow-400 to-orange-500 text-white shrink-0">
|
||||
<Zap className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">{t('adhd.quickWins.title')}</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">{t('adhd.quickWins.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-yellow-50 to-orange-50 dark:from-yellow-950/20 dark:to-orange-950/20 rounded-2xl p-6 border border-yellow-200 dark:border-yellow-800">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('adhd.quickWins.tip', 'Small wins build momentum! Complete these quick tasks to boost your dopamine and build confidence.')}
|
||||
</p>
|
||||
|
||||
<QuickWinList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export default function SetupWizard() {
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(["/api/user"], user);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/setup/status"] });
|
||||
setLocation("/");
|
||||
toast({
|
||||
title: "Setup Complete",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { SingleTaskView, FiveMinuteStarter } from '@/components/adhd';
|
||||
import { Task } from '@shared/schema';
|
||||
import { Focus, ArrowLeft, Timer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocation } from 'wouter';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export default function SingleTaskPage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [showFiveMinStarter, setShowFiveMinStarter] = useState(false);
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
const activeTasks = tasks.filter(t => t.status !== 'done');
|
||||
const selectedTask = selectedTaskId ? tasks.find(t => t.id === selectedTaskId) : null;
|
||||
|
||||
const handleTaskComplete = async (taskId: string) => {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'done' }),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
setSelectedTaskId(null);
|
||||
};
|
||||
|
||||
const handleTaskUpdate = async (taskId: string, updates: Partial<Task>) => {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
};
|
||||
|
||||
const handleFiveMinComplete = () => {
|
||||
setShowFiveMinStarter(false);
|
||||
// Continue with the task
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3 sm:gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => window.history.back()}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<div className="p-2 sm:p-3 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 text-white shrink-0">
|
||||
<Focus className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">{t('adhd.singleTask.title')}</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">{t('adhd.singleTask.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedTask ? (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800">
|
||||
<h2 className="text-lg font-semibold mb-4">{t('adhd.singleTask.selectTask', 'Select a task to focus on')}</h2>
|
||||
<Select value={selectedTaskId || ''} onValueChange={setSelectedTaskId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('adhd.singleTask.choosePlaceholder', 'Choose a task...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeTasks.map(task => (
|
||||
<SelectItem key={task.id} value={task.id}>
|
||||
{task.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFiveMinStarter(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Timer className="h-4 w-4" />
|
||||
{t('adhd.fiveMin.title')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SingleTaskView
|
||||
onExit={() => setSelectedTaskId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFiveMinStarter && selectedTask && (
|
||||
<FiveMinuteStarter
|
||||
task={selectedTask}
|
||||
onComplete={handleFiveMinComplete}
|
||||
onClose={() => setShowFiveMinStarter(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||
import { Loader2, Clock, CalendarDays } from 'lucide-react';
|
||||
|
||||
interface TimeDistribution {
|
||||
labelId: string | null;
|
||||
labelName: string | null;
|
||||
labelColor: string | null;
|
||||
timeSpent: number; // minutes
|
||||
}
|
||||
|
||||
export default function TimeTrackingPage() {
|
||||
const { t } = useTranslation();
|
||||
const [period, setPeriod] = useState<'day' | 'week' | 'month' | 'year'>('day');
|
||||
|
||||
const { data: distribution, isLoading } = useQuery<TimeDistribution[]>({
|
||||
queryKey: ['/api/analytics/time-distribution', period],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/analytics/time-distribution?period=${period}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch data');
|
||||
return res.json();
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate total time
|
||||
const totalMinutes = distribution?.reduce((acc, curr) => acc + curr.timeSpent, 0) || 0;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
const formatTime = (totalMins: number) => {
|
||||
const h = Math.floor(totalMins / 60);
|
||||
const m = totalMins % 60;
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
const hasData = totalMinutes > 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 md:p-8 space-y-8 max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
{t('analytics.timeTracking')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t('analytics.timeDistribution')}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs value={period} onValueChange={(v) => setPeriod(v as any)} className="w-[400px]">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="day">{t('analytics.period.day')}</TabsTrigger>
|
||||
<TabsTrigger value="week">{t('analytics.period.week')}</TabsTrigger>
|
||||
<TabsTrigger value="month">{t('analytics.period.month')}</TabsTrigger>
|
||||
<TabsTrigger value="year">{t('analytics.period.year')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
{/* Main Stats Card */}
|
||||
<Card className="col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{t('analytics.totalTime')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-4xl font-bold flex items-center gap-2">
|
||||
<Clock className="w-8 h-8 text-indigo-500" />
|
||||
{hours}h <span className="text-xl text-muted-foreground">{minutes}m</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{period === 'day' && "Today's activity"}
|
||||
{period === 'week' && "This week's activity"}
|
||||
{period === 'month' && "This month's activity"}
|
||||
{period === 'year' && "This year's activity"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chart Card */}
|
||||
<Card className="col-span-2 lg:col-span-5">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('analytics.timeDistribution')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[350px]">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !hasData ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<CalendarDays className="w-12 h-12 mb-2 opacity-20" />
|
||||
<p>{t('analytics.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={distribution}
|
||||
dataKey="timeSpent"
|
||||
nameKey="labelName"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={5}
|
||||
>
|
||||
{distribution?.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.labelColor || '#808080'} stroke="none" />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number) => formatTime(value)}
|
||||
contentStyle={{ backgroundColor: 'rgba(255, 255, 255, 0.95)', borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
||||
/>
|
||||
<Legend verticalAlign="middle" align="right" layout="vertical" iconType="circle" />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('analytics.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{distribution?.sort((a, b) => b.timeSpent - a.timeSpent).map((item) => (
|
||||
<div key={item.labelId || 'unknown'} className="flex items-center justify-between p-3 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{ backgroundColor: item.labelColor || '#808080' }}
|
||||
/>
|
||||
<span className="font-medium">{item.labelName || 'No Label'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${(item.timeSpent / totalMinutes) * 100}%`,
|
||||
backgroundColor: item.labelColor || '#808080'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-bold w-16 text-right">
|
||||
{formatTime(item.timeSpent)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Task, Label, User } from '@shared/schema';
|
||||
import TaskCard from '@/components/TaskCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { CalendarOff } from 'lucide-react';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
interface UnscheduledTasksProps {
|
||||
user: User;
|
||||
@@ -15,6 +17,7 @@ interface UnscheduledTasksProps {
|
||||
|
||||
export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelete, onUpdate, onSelect }: UnscheduledTasksProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
@@ -40,6 +43,36 @@ export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelet
|
||||
// if I want it to be fully functional without duplicating handler logic.
|
||||
// Alternatively, I can implement the handlers here using mutations.
|
||||
|
||||
// Mutation for Auto Schedule
|
||||
const autoScheduleMutation = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
const res = await apiRequest("POST", "/api/ai/schedule", { taskId });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.success && data.scheduledDate) {
|
||||
toast({
|
||||
title: t('schedule.saved', 'Schedule saved'),
|
||||
description: t('taskDetails.scheduledFor', { date: new Date(data.scheduledDate).toLocaleString() })
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
} else {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: data.error || data.message || "Failed to schedule",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: err.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 overflow-y-auto h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -74,6 +107,7 @@ export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelet
|
||||
onDelete={() => onDelete(task.id)}
|
||||
onUpdate={(updates) => onUpdate(task.id, updates)}
|
||||
onEdit={() => onSelect(task)}
|
||||
onAutoSchedule={() => autoScheduleMutation.mutate(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,14 @@ import { Link } from "wouter";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-background p-4">
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-background p-4">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center shadow-lg">
|
||||
<img src="/icon-transparent-128.png" alt="TaskFlow" className="w-7 h-7 object-contain" />
|
||||
</div>
|
||||
<span className="text-xl font-bold">TaskFlow</span>
|
||||
</div>
|
||||
<Card className="w-full max-w-2xl mx-auto shadow-xl border-dashed border-2 overflow-hidden bg-card text-card-foreground">
|
||||
<div className="md:flex">
|
||||
<div className="md:w-1/2 bg-muted/30 flex items-center justify-center p-8">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell } from 'lucide-react';
|
||||
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell, Loader2, Clock } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ShareAccessModal } from '@/components/ShareAccessModal';
|
||||
import { ChangePasswordModal } from '@/components/ChangePasswordModal';
|
||||
@@ -18,35 +18,248 @@ import { queryClient, apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLocation } from "wouter";
|
||||
import { useNotifications } from '@/hooks/use-notifications';
|
||||
import { DataExportCard } from '@/components/user/DataExportCard';
|
||||
|
||||
const NotificationSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const { enabled, toggleEnabled, permission, requestPermission } = useNotifications({ poll: false });
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
enabled,
|
||||
toggleEnabled,
|
||||
permission,
|
||||
requestPermission,
|
||||
pushSupported,
|
||||
isStandalone,
|
||||
sendTestNotification,
|
||||
isLoading
|
||||
} = useNotifications({ poll: false });
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
if (checked && permission !== 'granted') {
|
||||
requestPermission();
|
||||
const success = await requestPermission();
|
||||
if (success) {
|
||||
toast({
|
||||
title: t('notifications.enabledTitle'),
|
||||
description: t('notifications.enabledBody'),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toggleEnabled(checked);
|
||||
await toggleEnabled(checked);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNotification = async () => {
|
||||
const success = await sendTestNotification();
|
||||
if (success) {
|
||||
toast({
|
||||
title: t('notifications.testSent', 'Test Sent'),
|
||||
description: t('notifications.testSentDesc', 'Check for a notification on your device.'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('notifications.testFailed', 'Failed to send test notification.'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium">{t('notifications.enableBrowser')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{permission === 'denied' ?
|
||||
<span className="text-destructive">Permission denied by browser. Please reset site permissions.</span> :
|
||||
t('notifications.description')
|
||||
}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Push Support Status */}
|
||||
{pushSupported && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
{t('notifications.pushSupported', 'Push Supported')}
|
||||
</span>
|
||||
{isStandalone && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-400">
|
||||
{t('notifications.installedApp', 'Installed App')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* iOS PWA Hint */}
|
||||
{!pushSupported && /iPhone|iPad|iPod/.test(navigator.userAgent) && !isStandalone && (
|
||||
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>{t('notifications.iosHintTitle', 'Add to Home Screen')}</strong><br />
|
||||
{t('notifications.iosHintDesc', 'To receive push notifications on iOS, tap the Share button and select "Add to Home Screen", then open the app from there.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium">
|
||||
{pushSupported
|
||||
? t('notifications.enablePush', 'Push Notifications')
|
||||
: t('notifications.enableBrowser')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{permission === 'denied' ? (
|
||||
<span className="text-destructive">
|
||||
{t('notifications.permissionDenied', 'Permission denied by browser. Please reset site permissions.')}
|
||||
</span>
|
||||
) : pushSupported ? (
|
||||
t('notifications.pushDescription', 'Receive notifications even when the app is closed.')
|
||||
) : (
|
||||
t('notifications.description')
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={permission === 'denied' || isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={permission === 'denied'}
|
||||
/>
|
||||
|
||||
{/* Test Button */}
|
||||
{enabled && pushSupported && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestNotification}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t('notifications.sendTest', 'Send Test Notification')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ScheduleSettings = ({ user }: { user: User }) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState<'work' | 'personal'>('work');
|
||||
|
||||
// Helper to safely get availability data
|
||||
const getAvailability = (type: 'work' | 'personal') => {
|
||||
// Cast to any because TS might not know about the JSON structure fully yet if types aren't perfectly synced in IDE
|
||||
const avail = user.availability as any;
|
||||
if (avail && avail[type]) {
|
||||
return avail[type];
|
||||
}
|
||||
// Fallback defaults
|
||||
if (type === 'work') return user.workHours || { start: "09:00", end: "17:00", days: [1, 2, 3, 4, 5] };
|
||||
return { start: "18:00", end: "22:00", days: [1, 2, 3, 4, 5, 0, 6] };
|
||||
};
|
||||
|
||||
// State for both schedules
|
||||
const [workSchedule, setWorkSchedule] = useState(getAvailability('work'));
|
||||
const [personalSchedule, setPersonalSchedule] = useState(getAvailability('personal'));
|
||||
|
||||
const currentSchedule = activeTab === 'work' ? workSchedule : personalSchedule;
|
||||
const setCurrentSchedule = (newSched: any) => {
|
||||
if (activeTab === 'work') setWorkSchedule(newSched);
|
||||
else setPersonalSchedule(newSched);
|
||||
};
|
||||
|
||||
const updateScheduleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
availability: {
|
||||
work: workSchedule,
|
||||
personal: personalSchedule
|
||||
}
|
||||
};
|
||||
const res = await apiRequest("PATCH", "/api/user/schedule", payload);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: t('settings.schedule.saved') });
|
||||
}
|
||||
});
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
const currentDays = currentSchedule.days || [];
|
||||
let newDays;
|
||||
if (currentDays.includes(day)) {
|
||||
newDays = currentDays.filter((d: number) => d !== day);
|
||||
} else {
|
||||
newDays = [...currentDays, day].sort();
|
||||
}
|
||||
setCurrentSchedule({ ...currentSchedule, days: newDays });
|
||||
};
|
||||
|
||||
const handleChange = (field: 'start' | 'end', value: string) => {
|
||||
setCurrentSchedule({ ...currentSchedule, [field]: value });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateScheduleMutation.mutate();
|
||||
};
|
||||
|
||||
const days = [
|
||||
{ id: 1, label: t('analytics.mon') },
|
||||
{ id: 2, label: t('analytics.tue') },
|
||||
{ id: 3, label: t('analytics.wed') },
|
||||
{ id: 4, label: t('analytics.thu') },
|
||||
{ id: 5, label: t('analytics.fri') },
|
||||
{ id: 6, label: t('analytics.sat') },
|
||||
{ id: 0, label: t('analytics.sun') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex space-x-4 border-b">
|
||||
<button
|
||||
className={`py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'work' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => setActiveTab('work')}
|
||||
>
|
||||
{t('settings.schedule.work', 'Work Schedule')}
|
||||
</button>
|
||||
<button
|
||||
className={`py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'personal' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => setActiveTab('personal')}
|
||||
>
|
||||
{t('settings.schedule.personal', 'Personal Schedule')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('settings.schedule.start')}</label>
|
||||
<Input type="time" value={currentSchedule.start} onChange={(e) => handleChange('start', e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('settings.schedule.end')}</label>
|
||||
<Input type="time" value={currentSchedule.end} onChange={(e) => handleChange('end', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('settings.schedule.days')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{days.map(day => (
|
||||
<Button
|
||||
key={day.id}
|
||||
variant={currentSchedule.days?.includes(day.id) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => toggleDay(day.id)}
|
||||
className="w-12 h-12 rounded-full p-0"
|
||||
>
|
||||
{day.label.slice(0, 2)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 p-4 rounded-md text-sm text-muted-foreground">
|
||||
{activeTab === 'work'
|
||||
? t('settings.schedule.workDesc', "Tasks with 'Work' labels will be scheduled during these hours.")
|
||||
: t('settings.schedule.personalDesc', "Tasks with 'Personal' labels will be scheduled during these hours. 'Neutral' tasks can use either.")}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={updateScheduleMutation.isPending}>
|
||||
{updateScheduleMutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -61,14 +274,34 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// Fetch user
|
||||
const { data: user } = useQuery<User>({
|
||||
const { data: user, isLoading: isLoadingUser } = useQuery<User>({
|
||||
queryKey: ['/api/user']
|
||||
});
|
||||
|
||||
if (isLoadingUser) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] space-y-4">
|
||||
<h2 className="text-2xl font-bold">{t('common.loginRequired')}</h2>
|
||||
<Button onClick={() => setLocation('/')}>
|
||||
{t('auth.login')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
|
||||
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [labelColor, setLabelColor] = useState('#3B82F6');
|
||||
const [labelDomain, setLabelDomain] = useState('neutral');
|
||||
const [isShareAccessOpen, setIsShareAccessOpen] = useState(false);
|
||||
const [isShareLabelOpen, setIsShareLabelOpen] = useState(false);
|
||||
const [sharingLabel, setSharingLabel] = useState<Label | null>(null);
|
||||
@@ -78,6 +311,8 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
const handleLanguageChange = (value: string) => {
|
||||
i18n.changeLanguage(value);
|
||||
localStorage.setItem('taskflow-language', value);
|
||||
// Persist to DB
|
||||
privacyMutation.mutate({ language: value } as any);
|
||||
console.log('Language changed to:', value);
|
||||
};
|
||||
|
||||
@@ -96,20 +331,79 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
privacyMutation.mutate(updates);
|
||||
};
|
||||
|
||||
// 2FA Logic
|
||||
const [is2FADialogOpen, setIs2FADialogOpen] = useState(false);
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [debugCode, setDebugCode] = useState<string | null>(null);
|
||||
|
||||
const generate2FAMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/api/auth/2fa/generate");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setDebugCode(data.debugCode); // For dev convenience
|
||||
setIs2FADialogOpen(true);
|
||||
toast({ title: t('auth.2faCodeSent'), description: t('auth.checkEmail') });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Failed to start 2FA setup", description: err.message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const verify2FAMutation = useMutation({
|
||||
mutationFn: async (code: string) => {
|
||||
const res = await apiRequest("POST", "/api/auth/verify-2fa", { userId: user.id, code });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIs2FADialogOpen(false);
|
||||
setOtpCode("");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "2FA Enabled Successfully" });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({ title: "Verification failed", description: err.message, variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const disable2FAMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiRequest("POST", "/api/auth/2fa/disable");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "2FA Disabled" });
|
||||
}
|
||||
});
|
||||
|
||||
const handle2FAToggle = (checked: boolean) => {
|
||||
if (checked) {
|
||||
generate2FAMutation.mutate(); // Starts flow, opens dialog on success
|
||||
} else {
|
||||
if (confirm("Are you sure you want to disable 2FA? This will reduce your account security.")) {
|
||||
disable2FAMutation.mutate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Fetch labels
|
||||
const { data: labels = [], isLoading: labelsLoading } = useQuery<Label[]>({
|
||||
const { data: labelsData, isLoading: labelsLoading } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels']
|
||||
});
|
||||
const labels = labelsData ?? [];
|
||||
|
||||
// Create label mutation
|
||||
const createLabelMutation = useMutation({
|
||||
mutationFn: (data: { name: string; color: string }) =>
|
||||
mutationFn: (data: { name: string; color: string; domain: string }) =>
|
||||
apiRequest('POST', '/api/labels', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
|
||||
setIsLabelDialogOpen(false);
|
||||
setLabelName('');
|
||||
setLabelColor('#3B82F6');
|
||||
setLabelDomain('neutral');
|
||||
toast({
|
||||
title: t('settings.labels.created'),
|
||||
description: t('settings.labels.createdDescription'),
|
||||
@@ -119,7 +413,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
|
||||
// Update label mutation
|
||||
const updateLabelMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string; name: string; color: string }) =>
|
||||
mutationFn: ({ id, ...data }: { id: string; name: string; color: string; domain: string }) =>
|
||||
apiRequest('PATCH', `/api/labels/${id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
|
||||
@@ -127,6 +421,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
setEditingLabel(null);
|
||||
setLabelName('');
|
||||
setLabelColor('#3B82F6');
|
||||
setLabelDomain('neutral');
|
||||
toast({
|
||||
title: t('settings.labels.updated'),
|
||||
description: t('settings.labels.updatedDescription'),
|
||||
@@ -154,9 +449,10 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
id: editingLabel.id,
|
||||
name: labelName,
|
||||
color: labelColor,
|
||||
domain: labelDomain,
|
||||
});
|
||||
} else {
|
||||
createLabelMutation.mutate({ name: labelName, color: labelColor });
|
||||
createLabelMutation.mutate({ name: labelName, color: labelColor, domain: labelDomain });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,6 +460,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
setEditingLabel(label);
|
||||
setLabelName(label.name);
|
||||
setLabelColor(label.color);
|
||||
setLabelDomain(label.domain || 'neutral');
|
||||
setIsLabelDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -295,7 +592,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
<p className="text-sm text-muted-foreground">{t('settings.social.publicLeaderboardDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={user?.showOnLeaderboard}
|
||||
checked={!!user?.showOnLeaderboard}
|
||||
onCheckedChange={(checked) => handlePrivacyUpdate({ showOnLeaderboard: checked })}
|
||||
/>
|
||||
</div>
|
||||
@@ -305,7 +602,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
<p className="text-sm text-muted-foreground">{t('settings.social.searchableDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={user?.isSearchable}
|
||||
checked={!!user?.isSearchable}
|
||||
onCheckedChange={(checked) => handlePrivacyUpdate({ isSearchable: checked })}
|
||||
/>
|
||||
</div>
|
||||
@@ -315,10 +612,50 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
<p className="text-sm text-muted-foreground">{t('settings.ai.enableUserDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={user?.aiEnabled}
|
||||
checked={!!user?.aiEnabled}
|
||||
onCheckedChange={(checked) => handlePrivacyUpdate({ aiEnabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium">{t('settings.social.2fa')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.social.2faDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!user?.is2faEnabled}
|
||||
onCheckedChange={(checked) => handle2FAToggle(checked)}
|
||||
disabled={generate2FAMutation.isPending || disable2FAMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={is2FADialogOpen} onOpenChange={setIs2FADialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('auth.verify2FATitle', 'Verify 2FA')}</DialogTitle>
|
||||
<CardDescription>
|
||||
Enter the code sent to your email to enable 2FA.
|
||||
{debugCode && <div className="mt-2 p-2 bg-muted rounded text-xs font-mono">Debug Code: {debugCode}</div>}
|
||||
</CardDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Input
|
||||
placeholder="123456"
|
||||
className="text-center text-2xl tracking-widest"
|
||||
maxLength={6}
|
||||
value={otpCode}
|
||||
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => verify2FAMutation.mutate(otpCode)}
|
||||
disabled={verify2FAMutation.isPending || otpCode.length !== 6}
|
||||
>
|
||||
{verify2FAMutation.isPending ? "Verifying..." : "Verify & Enable"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button variant="outline" onClick={() => setIsShareAccessOpen(true)}>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
@@ -330,67 +667,26 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
|
||||
<ShareAccessModal open={isShareAccessOpen} onOpenChange={setIsShareAccessOpen} />
|
||||
|
||||
{/* MCP Integration */}
|
||||
<Card>
|
||||
{/* Schedule Settings */}
|
||||
<Card data-testid="card-schedule-settings">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5" />
|
||||
{t('settings.mcp.title')}
|
||||
<Clock className="w-5 h-5" />
|
||||
{t('settings.schedule.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.mcp.description')}</CardDescription>
|
||||
<CardDescription>
|
||||
{t('settings.schedule.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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} />
|
||||
{/* Show full key on click/copy only, usually concealed */}
|
||||
<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>
|
||||
<CardContent>
|
||||
{user && <ScheduleSettings user={user} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Language Settings */}
|
||||
<Card data-testid="card-language-settings">
|
||||
<CardHeader>
|
||||
@@ -472,6 +768,19 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Select value={labelDomain} onValueChange={setLabelDomain}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Context (Domain)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="work">Work</SelectItem>
|
||||
<SelectItem value="personal">Personal</SelectItem>
|
||||
<SelectItem value="neutral">Neutral</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">Used for smart scheduling.</p>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -480,6 +789,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
setEditingLabel(null);
|
||||
setLabelName('');
|
||||
setLabelColor('#3B82F6');
|
||||
setLabelDomain('neutral');
|
||||
}}
|
||||
className="flex-1"
|
||||
data-testid="button-cancel-label"
|
||||
@@ -520,9 +830,14 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
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 className="flex flex-col">
|
||||
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
|
||||
{label.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{label.domain || 'neutral'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{label.creatorId === user?.id && (
|
||||
@@ -609,6 +924,9 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Export */}
|
||||
<DataExportCard user={user} />
|
||||
|
||||
{/* Admin Section */}
|
||||
{
|
||||
user?.role === 'admin' && (
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -10,14 +10,15 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- /mnt/DockerMount/task-manager/data:/var/lib/postgresql/data
|
||||
- /mnt/DockerMount/taskmanager/data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER}" ]
|
||||
interval: 60s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
retries: 60
|
||||
start_period: 60s
|
||||
networks:
|
||||
- taskflow-network
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
- "${POSTGRES_PORT:-2101}:5432"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-taskflow}" ]
|
||||
interval: 10s
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
# volumes:
|
||||
# - ./dist/public:/app/server/public
|
||||
ports:
|
||||
- "5001:5000"
|
||||
- "2100:5000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -62,8 +62,8 @@ services:
|
||||
image: mailhog/mailhog
|
||||
container_name: taskflow-mailhog
|
||||
ports:
|
||||
- "1025:1025" # SMTP
|
||||
- "8025:8025" # Web UI
|
||||
- "2102:1025" # SMTP
|
||||
- "2103:8025" # Web UI
|
||||
networks:
|
||||
- taskflow-network
|
||||
|
||||
|
||||