Compare commits

...

36 Commits

Author SHA1 Message Date
paul 53bcdce6c2 ci: allow manual pipeline triggers
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-29 21:57:42 +02:00
paul cb4cf87ed4 Migrate CI from Drone to Woodpecker
continuous-integration/drone/push Build is pending
2001-01-01 00:00:00 +00:00
NotiBot 04402c6b65 fix: right-side content cutoff on tasks page
continuous-integration/drone/push Build is passing
- SidebarInset: use min-w-0 + overflow-x-hidden instead of w-full
- Calendar: measure container width instead of viewport width
- Remove unnecessary max-w-screen-2xl from main content area
2026-02-03 23:25:57 +01:00
NotiBot 08a9e349f2 Fix: overflow-x-hidden von Wrapper/Main entfernt — schnitt rechte Seite ab
continuous-integration/drone/push Build is passing
Nur noch overflow-x:clip auf #root + overflowX:clip auf TaskCard-Wrapper.
Die gestapelten overflow-hidden auf 3 Ebenen haben Content rechts abgeschnitten.
2026-02-03 17:00:19 +01:00
NotiBot 1f314b60aa Fix: overflow-hidden aus TasksWithCalendar entfernt — schnitt Elemente rechts ab
continuous-integration/drone/push Build is passing
2026-02-03 16:44:44 +01:00
NotiBot 200659ee96 Fix: overflow-x:clip statt overflow:hidden — verhindert Scroll ohne Content abzuschneiden
continuous-integration/drone/push Build is passing
2026-02-03 16:34:05 +01:00
NotiBot 90eb8a0465 Fix: Sauberer horizontal-scroll Fix ohne destruktive CSS-Regeln
continuous-integration/drone/push Build is passing
- Entfernt max-width:100vw (inkludiert Scrollbar-Breite = Bug)
- Entfernt * {max-width:100%} (bricht Flexbox/Grid Layouts)
- Stattdessen: width:100% + overflow-x:hidden auf body/#root
2026-02-03 16:24:40 +01:00
NotiBot 68777ca051 Fix: Horizontales Scrollen auf Mobilgeräten endgültig behoben
continuous-integration/drone/push Build is passing
- TaskCard: motion.div in overflow-hidden Wrapper gepackt
- TaskCard: overscrollBehaviorX auf motion.div gesetzt
- TaskCard: Titel und Badge-Zeile mit max-w-full/overflow-hidden abgesichert
- TasksWithCalendar: Negative Margins (-mx-4/-mx-6) am Calendar entfernt
- TasksWithCalendar: overflow-x-hidden auf Scrollable Task List
- index.css: overscroll-behavior-x: none global auf html/body/#root
- index.css: max-width: 100% als globale Absicherung gegen Overflow
2026-02-03 15:22:22 +01:00
NotiBot 8e8b96fcde Horizontalen Scroll verhindern: Lange Task-Titel werden überall abgeschnitten
continuous-integration/drone/push Build is passing
- Globales overflow-x: hidden auf html/body/#root in index.css
- App.tsx: overflow-x-hidden und min-w-0 auf Hauptcontainer
- TaskCard: Eltern-Task-Titel mit truncate/min-w-0 im Flex-Layout
- KanbanBoard: min-w-0 overflow-hidden auf Spalten-Cards
- FiveMinuteStarter: truncate auf Task-Titel
- HyperfocusGuard: truncate auf Task-Buttons
- TaskBreakdownModal: truncate auf Task- und Subtask-Titel
- NotificationsPage: truncate + min-w-0 auf Benachrichtigungs-Titel
2026-02-03 14:59:40 +01:00
NotiBot 79d5fae97c Label-Filter hinzufügen und Responsive-Bug auf der Tasks-Seite beheben
continuous-integration/drone/push Build is passing
- Label-Filter-Dropdown auf der Tasks-Übersichtsseite hinzugefügt (Tag-Icon, zeigt alle Labels mit Farbpunkt)
- Responsive-Bug behoben: Breakpoint-Mismatch bei negativen Margins (sm:-mx-6 → md:-mx-6) verursachte horizontales Scrollen zwischen 640-768px
- overflow-x-hidden auf den Hauptcontainer gesetzt
- Alle Select-Trigger mit min-w-0, truncate und shrink-0 Icons versehen für bessere Responsivität
2026-02-03 14:10:29 +01:00
NotiBot c867c5ca71 Fix: Search-Route vor :id-Route registriert, damit /api/tasks/search nicht als Task-ID interpretiert wird
continuous-integration/drone/push Build is passing
2026-02-03 12:57:45 +01:00
NotiBot 064e978fc5 fix: API key generation button - URL mismatch (apikey → api-key)
continuous-integration/drone/push Build is passing
Frontend was calling /api/user/apikey but backend route is /api/user/api-key.
Button did nothing because requests returned 404.
2026-02-03 11:39:58 +01:00
NotiBot 6a10f4c47b fix: resolve all client-side TypeScript compilation errors
continuous-integration/drone/push Build is passing
2026-02-03 10:54:20 +01:00
NotiBot 637bfb95f9 fix: TS-Fehler in routes.ts (awardXP Signatur) und storage.ts (fehlende User-Felder in MemStorage)
continuous-integration/drone/push Build is passing
2026-02-03 10:48:39 +01:00
NotiBot 70de82e88f fix: Security Issues + Bug Fixes
continuous-integration/drone/push Build is failing
SECURITY FIXES:
- SEC-1: Debug-Endpoints (/api/debug/*) entfernt - waren ohne Auth zugänglich
- SEC-2: debugCode aus 2FA API-Responses entfernt (nur noch console.log in dev)
- SEC-3: Session Secret Validierung - Server startet nicht ohne SECRET in production
- SEC-4: 2FA if(true) bypass entfernt - 2FA-Flow funktioniert jetzt korrekt
- SEC-5: Auth-Checks auf 10 Endpoints hinzugefügt (labels, notes, goals, rewards)

BUG FIXES:
- DUP-1: Doppelte DELETE /api/tasks/:id Route entfernt
- DUP-2: Doppelter deleteLabel() Aufruf entfernt
- DEAD-4: PATCH /api/goals/:id nutzt jetzt updateGoal() statt updateTask()
- MISC-3: XP Double-Counting in logXpEvent() behoben
- TYPE-2: awardXP() Parameter-Reihenfolge korrigiert (break, energy, focus)
- DEAD-1: Ungenutzter calculateXP() Dead Code entfernt
2026-02-03 10:39:32 +01:00
NotiBot 4f6aff32ab feat: API Key Auth + erweiterte MCP Tools + Dokumentation
continuous-integration/drone/push Build is passing
- API Key Middleware für alle /api/ Endpoints (X-API-Key, Bearer, Query)
- API Key Management Routes (generate, revoke, status)
- MCP Tools erweitert: 12 Tools (war 3) inkl. bulk ops, dashboard, search
- Audit Logging für alle API/MCP Aktionen
- docs/API-REFERENCE.md - vollständige API Dokumentation
- docs/ARCHITECTURE.md - Architektur-Übersicht
- Vorbereitung für NotiBot-Integration
2026-02-03 10:23:18 +01:00
Paul Nothaft c1f7a13c0e chore: Update logo and icon assets
continuous-integration/drone/push Build is passing
Updated favicon, app icons, and PWA icons with refreshed designs.
Added new icon-circle-tight.png variant.
2026-01-20 08:12:19 +01:00
Paul Nothaft ad50b97f44 feat: Add Mark as Done button to task details modal
continuous-integration/drone/push Build is passing
Add a "Mark as Done" button next to the Save button in the task details
modal. When clicked, if no time has been tracked for the task, a dialog
prompts the user to enter time spent before marking the task as done.
The dialog supports both clock-style (hours/minutes pickers) and manual
(decimal hours) time entry.
2026-01-19 22:12:23 +01:00
Paul Nothaft 88cd2a5c88 feat: Implement TaskFlow logos and icons throughout the application
continuous-integration/drone/push Build is passing
- Add favicon files (16x16 to 256x256, .ico)
- Add PWA icons (128, 256, 512, 1024)
- Add apple-touch-icon for iOS
- Add transparent icons for loading screens
- Add horizontal logo with tagline for auth page
- Update HTML head with proper favicon links
- Update PWA manifest with correct icon references
- Update sidebar header with branded icon
- Update mobile header with branded icon
- Update auth/login pages with logo display
- Update loading state with branded splash screen
- Update 404 page with logo header
- Update email template to use proper logo URL
- Update service worker and push notifications to use correct icons
2026-01-19 21:40:09 +01:00
Paul Nothaft 4b51e9f05e chore: Regenerate package-lock.json for Docker build compatibility
continuous-integration/drone/push Build is passing
2026-01-19 20:55:48 +01:00
Paul Nothaft f91978c078 feat: Add Web Push notifications and multiple UX improvements
continuous-integration/drone/push Build is failing
- Add PWA manifest and service worker for push notifications
- Implement VAPID key generation and push subscription management
- Add push notification API endpoints (/api/push/*)
- Add push_subscriptions table to database schema
- Update notification settings UI with push support and iOS hints
- Fix translation issue showing "{ task } created" - add missing keys
- Fix mobile sidebar visibility for iOS home screen app
- Change default task filter from "all" to "open" (excludes done tasks)
- Add "Open" filter option to show only todo + inProgress tasks
2026-01-19 20:53:13 +01:00
Paul Nothaft 9bfc9e2f96 fix: AI chat loading indicator persists until response received
continuous-integration/drone/push Build is passing
- Add separate isWaitingForResponse state to track loading
- Set to true before mutation, false only in onSuccess/onError
- Combine with mutation.isPending for reliable isLoading state
- Typing indicator now stays visible throughout entire AI request
2026-01-17 10:15:58 +01:00
Paul Nothaft 2a98ebdf9f fix: Multiple ADHD/Focus Tools fixes
continuous-integration/drone/push Build is passing
Switch/Checkbox Styling:
- Exclude switches and checkboxes from larger-targets CSS
- Fix checkbox square aspect ratio when ADHD mode enabled
- Prevent padding from being applied to form controls

Quick Wins Count:
- Use settings.quickWinThreshold instead of hardcoded 15
- Match same filtering logic as QuickWinList component

Back Button Navigation:
- Use window.history.back() instead of setLocation('/')
- Works correctly when navigating from ADHD dashboard to sub-pages

Time Tracking (Break Reminder):
- Track only active browser time using Page Visibility API
- Reset accumulated time after 8 hours of inactivity
- Store/restore active time in localStorage properly
- No more 1500+ hour counts from leaving browser open

Sources:
- https://ui.shadcn.com/docs/theming (shadcn theming)
- https://ui.shadcn.com/docs/tailwind-v4 (Tailwind v4 updates)
2026-01-16 23:01:26 +01:00
Paul Nothaft b4e7cce651 fix: Add cache control headers and improve typing indicator
continuous-integration/drone/push Build is passing
- Add proper cache control headers in static.ts
- HTML files: no-cache to always get fresh version
- Assets: long cache with immutable (content-hashed filenames)
- Improve typing indicator animation (larger dots, faster animation)
2026-01-16 16:53:34 +01:00
Paul Nothaft 5ac64b437b fix: AI chat loading indicator and auto-refresh tasks
continuous-integration/drone/push Build is passing
- Add animated typing indicator (bouncing dots) when AI is processing
- Show "Thinking..." status in chat header while processing
- Add translations for AI chat (en/de): title, welcome, placeholder, thinking, error
- Auto-refresh tasks, labels, and user data after AI response
- Disable input field while AI is processing
- Fix translation keys to use ai.chat.* namespace
2026-01-16 16:15:02 +01:00
Paul Nothaft f165ae52e6 fix: Mobile responsiveness for Focus Tools & AI chat improvements
continuous-integration/drone/push Build is passing
Mobile Responsiveness:
- Fix headers on all Focus Tools pages (responsive text sizes, proper spacing)
- Fix BodyDoublingLobby form grid (stacks on mobile)
- Add shrink-0 and min-w-0 to prevent flex overflow issues

AI Chat Improvements:
- Add labelName parameter to createTask tool for easier label assignment
- Auto-match or create labels when labelName is provided
- Improve system prompt to act immediately without confirmation
- Clearer instructions about using tools and handling labels
- Better support for Ollama models with function calling
2026-01-16 15:58:00 +01:00
Paul Nothaft 7ce4f7efdc feat: Add Focus Tools (ADHD-friendly productivity features)
continuous-integration/drone/push Build is passing
- Add Focus Tools dashboard with collapsible help section
- Implement Quick Wins page for tasks under 15 minutes
- Add Single Task Focus mode to reduce overwhelm
- Create Body Doubling page for virtual co-working
- Add visual timer, break reminders, and energy tracking
- Implement hyperfocus protection alerts
- Add ADHD settings panel with customizable options
- Include full English and German translations
- Fix larger touch targets CSS to not break button layouts
- Add Playwright tests for Focus Tools features
2026-01-15 21:20:04 +01:00
paul 74ffef48d3 feat: Add Time Tracking page with analytics and time distribution charts
continuous-integration/drone/push Build is passing
- Add TimeTrackingPage with pie/bar charts for time spent per label
- Add task_time_logs schema for tracking time entries
- Add /api/analytics/time-distribution endpoint
- Update sidebar navigation with time tracking link
- Update App routing for new pages
- Fix routine blocker and settings card issues
- Add German translations for analytics

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 23:50:10 +01:00
paul 877d0c897e Fix: Mobile responsiveness audit (modals, views, sidebar) & Critical bug fixes (registration, orphan tasks)
continuous-integration/drone/push Build is passing
2025-12-17 15:06:13 +01:00
paul 2579df0b89 feat: Enhance task filtering, smart scheduling, audit logs and translations
continuous-integration/drone/push Build is passing
2025-12-17 14:26:54 +01:00
paul 9819d8db0b feat: Notifications page, Sidebar layout fixes, and Achievements enhancements
continuous-integration/drone/push Build is failing
- implemented /notifications page with overdue/upcoming alerts
- Fixed sidebar scrolling in collapsed mode
- Moved notification button to sidebar footer
- Enhanced Achievements page with streak stats and tooltips
- Improved XP history to show task titles
- Added missing translations (en/de)
- Removed top bar header
- Fixed Docker environment routing
2025-12-17 10:06:36 +01:00
paul 7b79015ac2 fix(routine): resolove routine blocker logic bug and white screen
continuous-integration/drone/push Build is passing
feat(gamification): add streak bonuses, tooltip, and improved history details

fix(ep): resolve double counting ep bug

ui: update app icon and translations
2025-12-15 23:17:59 +01:00
paul 9736864425 feat: Complete AI Chat Agent, Admin Settings (MCP/AI), and Translations
continuous-integration/drone/push Build is passing
2025-12-15 21:52:36 +01:00
paul 71d5c825c5 refactor(ui): move MCP settings to dedicated admin tab and increase prod db timeout
continuous-integration/drone/push Build is passing
2025-12-15 16:28:47 +01:00
paul e8d9d0c686 feat: Add AI subtask reproduction script, refactor Admin MCP settings UI, update Vite config, and add i18n gamification rules.
continuous-integration/drone/push Build is passing
2025-12-15 16:20:47 +01:00
paul bc40969ad7 feat: implement automated versioning and update docs
continuous-integration/drone/push Build is passing
2025-12-15 16:06:38 +01:00
182 changed files with 19467 additions and 4025 deletions
+6
View File
@@ -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
+11
View File
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /app
# Dependencies stage
FROM base AS dependencies
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
RUN npm ci --omit=dev && \
cp -R node_modules /tmp/prod_node_modules && \
npm ci
+52
View File
@@ -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) {
+19 -2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

+41
View File
@@ -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
}
+140
View File
@@ -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()
})
});
})
);
});
+226 -117
View File
@@ -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;
+41 -12
View File
@@ -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>
+25 -8
View File
@@ -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>
-1
View File
@@ -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>
+49
View File
@@ -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;
}
}
+29 -7
View File
@@ -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>
+13 -6
View File
@@ -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">
+4 -3
View File
@@ -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}`}
+1 -1
View File
@@ -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;
+8 -1
View File
@@ -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
};
};
+137
View File
@@ -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();
}
+1 -1
View File
@@ -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" />
+84 -12
View File
@@ -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>
);
}
+104 -26
View File
@@ -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">
+261 -26
View File
@@ -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>
</>
);
}
+70 -21
View File
@@ -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>
+11 -11
View File
@@ -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}
/>
</>
);
}
+29
View File
@@ -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>
);
}
+27 -8
View File
@@ -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}>
+14 -12
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
);
}
+257 -87
View File
@@ -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,
};
}
+622 -76
View File
@@ -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!"
]
}
}
}
+573 -40
View File
@@ -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"
}
}
}
+275
View File
@@ -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%
);
}
+11 -11
View File
@@ -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,
+7 -3
View File
@@ -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>
);
+399
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+35 -6
View File
@@ -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>
+50
View File
@@ -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>
);
}
+240 -151
View File
@@ -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"
+157 -63
View File
@@ -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>
);
+56
View File
@@ -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>
);
}
+242
View File
@@ -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>
);
}
+134
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+1
View File
@@ -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",
+119
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+35 -1
View File
@@ -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>
+8 -1
View File
@@ -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">
+400 -82
View File
@@ -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' && (
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
+4 -4
View File
@@ -15,9 +15,10 @@ services:
- "${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
@@ -30,8 +31,7 @@ services:
PORT: 5000
SESSION_SECRET: ${SESSION_SECRET}
depends_on:
postgres:
condition: service_healthy
- postgres
healthcheck:
test: [ "CMD", "node", "-e", "require('http').get('http://localhost:5000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" ]
interval: 30s
+4 -4
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More