Compare commits

...

68 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
paul cd7707a725 fix: run db migrations on startup in production
continuous-integration/drone/push Build is passing
2025-12-15 16:05:53 +01:00
paul aa5f2328f7 chore: verify version bump automation 2025-12-15 16:02:53 +01:00
paul d1736c5991 feat: enhance audit logging, add MCP settings, and production docker setup
continuous-integration/drone/push Build is passing
- Implemented comprehensive audit logging for Tasks, Users, Settings, Goals, Labels, AI Chat, and Rewards.
- Added Admin UI for MCP Server settings and Audit Logs.
- Created docker-compose-production.yml with Traefik configuration.
- Fixed backend bugs (missing storage methods, route closure).
- Added Audit Logging Guidelines.
2025-12-15 15:53:31 +01:00
paul fdf321cde9 fix(client): Remove duplicate imports in AiSettingsCard
continuous-integration/drone/push Build is passing
2025-12-12 08:57:51 +01:00
paul a184115d28 feat: Enhanced Ollama Integration (Model Fetching/Pulling)
continuous-integration/drone/push Build is passing
2025-12-12 08:57:30 +01:00
paul 93063f772d fix: Consolidate Admin Settings UI
continuous-integration/drone/push Build is passing
2025-12-12 08:50:21 +01:00
paul 5d8976b1cd feat: Implement AI Chat Agent, Email Notifications, and UI enhancements
continuous-integration/drone/push Build is passing
2025-12-12 08:35:48 +01:00
paul ccfb674318 feat: add social features, leaderboard, auth enhancements, and admin fixes
continuous-integration/drone/push Build is passing
- Implement Social Features: Shared Tasks, Global Access, Privacy Settings (Leaderboard/Searchable).
- Add Leaderboard Page and API.
- Enhance Auth: Support Email/Username login, explicit duplicate registration errors.
- Fix: Admin login password hash regression.
- Refactor: Move to wouter for routing, add Admin Dashboard and User Management.
- Add Setup Wizard.
- Update UI with Sidebar and Gamification elements.
2025-12-10 14:04:26 +01:00
paul-nothaft d5b045158a Add missing translations for project templates and task details
continuous-integration/drone/push Build is passing
Adds new translation keys for project templates and task details in `en.json` and `de.json`, and updates `ProjectTemplate.tsx` to use these translations.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/iGgwDyZ
2025-10-24 11:26:17 +00:00
paul-nothaft 3c39346087 Improve error handling and user feedback for task creation
Update task creation form to include validation and display user-friendly error messages for invalid inputs.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/iGgwDyZ
2025-10-24 11:06:39 +00:00
paul-nothaft f045e2dce9 Translate project template and creation UI elements
Add translations for project template page, new project form, labels, and popups in the task management app.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/iGgwDyZ
2025-10-24 11:06:00 +00:00
paul-nothaft 102315fa7c Add translations for various project management features
Integrate i18n translation keys for project template page elements, including column titles, buttons, dialogs, and input fields, within the ProjectTemplate component.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/HdLXOOf
2025-10-24 11:00:26 +00:00
paul-nothaft 57f09310ca Improve navigation and translations for project templates
Add back button navigation from Project Templates to Settings, implement translations for Project Templates page in English and German, and update UI component translation list.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/HdLXOOf
2025-10-24 10:32:20 +00:00
paul-nothaft 5a72497acd Improve overall styling and layout across the application
Update CSS variables and component styles for improved visual consistency and responsiveness.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/HdLXOOf
2025-10-24 10:31:45 +00:00
paul-nothaft 284223bdcc Add translations for project templates and navigation to settings
Introduce new translations for project template creation and editing, and add a button to navigate back to the settings page from the ProjectTemplate component.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/LNMKOrA
2025-10-24 10:26:16 +00:00
paul-nothaft 16c097b3f0 Add ability to manage task labels and create project templates
Refactors the settings page to include CRUD operations for task labels, integrates template creation navigation, and removes the appearance section.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/LNMKOrA
2025-10-24 10:21:30 +00:00
paul-nothaft 56709ce8ca Add favicon and other assets to the deployed application
continuous-integration/drone/push Build is passing
Copy attached assets (logo, favicon) from the build stage to the server's public directory and configure express.static to serve them.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/LNMKOrA
2025-10-24 10:00:25 +00:00
paul-nothaft b9b3572a02 Make the app logo and favicon match smartphone app icon styles
continuous-integration/drone/push Build is passing
Update the logo image className in App.tsx to include rounded-lg for a softer, app-icon-like appearance.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/E3NLe8e
2025-10-24 09:41:44 +00:00
paul-nothaft f6c4d1332c Update application to use new custom logo and favicon
Replace placeholder logo in header with new custom image, update index.html with new favicon and apple-touch-icon, and update replit.md with logo details.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/E3NLe8e
2025-10-24 09:40:16 +00:00
paul-nothaft 8248acceb9 Add reusable components for displaying user avatars
Add a new Avatar component with props for image source, size, and fallback text.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/E3NLe8e
2025-10-24 09:33:55 +00:00
paul-nothaft a9efeeafbf Add missing translations for task management features
Integrates i18next for internationalization, adding translated strings for task details, search, filters, and calendar views in both English and German.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/E3NLe8e
2025-10-24 09:33:15 +00:00
paul-nothaft f65eea2d25 Improve the accuracy of task completion tracking
Fix an issue where task completion status was not being updated correctly.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/R3zkvYP
2025-10-24 09:13:29 +00:00
paul-nothaft 9c8ec283ed Add a weekly task view to help users organize their week
Introduces a new "Week List" view accessible via the bottom navigation. This view displays tasks scheduled for the current week, allowing users to filter, sort, and manage them. The implementation includes new components for the week list view and integrates internationalization support for English and German.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/R3zkvYP
2025-10-24 09:09:25 +00:00
paul-nothaft b69726b428 Add completion option to right-click menus on tasks and calendar
Implement right-click functionality on task and calendar views, enabling task completion.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/R3zkvYP
2025-10-24 08:57:10 +00:00
paul-nothaft c1fe158b3e Add right-click functionality to complete tasks on the task and calendar pages
Implement context menu for task completion on task and calendar views.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/R3zkvYP
2025-10-24 08:56:52 +00:00
paul-nothaft 037d3f4d73 Add right-click context menus for task and calendar interactions
Integrates context menus on the task card and calendar views, providing options to edit, start/stop timers, and change task status.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/R3zkvYP
2025-10-24 08:50:06 +00:00
paul-nothaft 94bb2b599d Enable right-click context menus for calendar tasks
Modify `CalendarView.tsx` to remove `pointer-events-none` from the parent Badge component, allowing right-click interactions on calendar task badges.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/7gWYrTe
2025-10-24 08:44:25 +00:00
paul-nothaft 64114cfb9c Add basic functionality for setting up a new project
continuous-integration/drone/push Build is passing
Introduce initial project setup features and configurations.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/7gWYrTe
2025-10-24 08:11:58 +00:00
paul-nothaft 4a5a59fb34 Add context menu to calendar view for task management
Integrates context menu functionality into the CalendarView component, allowing task management via right-click (desktop) or long-press (mobile), mirroring the existing task list menu options. Includes handlers for task update, edit, delete, and timer start/stop.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/7gWYrTe
2025-10-24 08:05:12 +00:00
paul-nothaft 82ffb73d41 Add and improve task management features for better user control
Implement functionality for marking tasks as done, deleting tasks, and viewing task history.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/7gWYrTe
2025-10-24 07:47:57 +00:00
paul-nothaft 7870d0b31e Improve task management with quick delete and complete options
Add tooltip functionality for quick delete and toggle complete actions on TaskCard component, and update translations.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/7gWYrTe
2025-10-24 07:47:41 +00:00
paul-nothaft 037256401f Add quick ways to complete and delete tasks
Implement a checkbox for marking tasks as done and a delete button directly on the TaskCard component, enhancing user interaction.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/nn5bWKE
2025-10-24 07:45:11 +00:00
227 changed files with 40106 additions and 3585 deletions
+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL=postgresql://taskflow:taskflow_password@localhost:5432/taskflow
POSTGRES_USER=taskflow
POSTGRES_PASSWORD=taskflow_password
POSTGRES_DB=taskflow
SESSION_SECRET=local_development_secret
+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
+8 -4
View File
@@ -5,9 +5,9 @@ WORKDIR /app
# Dependencies stage
FROM base AS dependencies
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
cp -R node_modules /tmp/prod_node_modules && \
npm ci
RUN npm ci --omit=dev && \
cp -R node_modules /tmp/prod_node_modules && \
npm ci
# Build stage
FROM base AS build
@@ -33,6 +33,9 @@ COPY --from=build /app/tsconfig.json ./
# Copy built frontend to the location static.ts expects
COPY --from=build /app/dist/public ./server/public
# Copy attached assets (logo, favicon, etc.)
COPY --from=build /app/attached_assets ./server/public/attached_assets
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5000
@@ -45,4 +48,5 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:5000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application using tsx to run TypeScript directly
CMD ["npx", "tsx", "server/index.ts"]
# Run DB migrations and start the application
CMD ["sh", "-c", "npx drizzle-kit push && npx tsx server/index.ts"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

+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) {
+1
View File
@@ -0,0 +1 @@
TRUNCATE TABLE "session";
+38 -15
View File
@@ -1,18 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>TaskFlow - Personal Task Management</title>
<meta name="description" content="Personal task management app with calendar scheduling, kanban boards, and time tracking" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..600;1,14..32,400..600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- This is a replit script which adds a banner on the top of the page when opened in development mode outside the replit environment -->
<script type="text/javascript" src="https://replit.com/public/js/replit-dev-banner.js"></script>
</body>
<head>
<meta charset="UTF-8" />
<title>TaskFlow - Personal Task Management</title>
<meta name="description"
content="Personal task management app with calendar scheduling, kanban boards, and time tracking" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#7c3aed" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="TaskFlow" />
<!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png" />
<link rel="icon" type="image/png" sizes="64x64" href="/favicon-64x64.png" />
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-128x128.png" />
<link rel="icon" type="image/png" sizes="256x256" href="/favicon-256x256.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400..600;1,14..32,400..600&family=Outfit:wght@400;500;600;700&display=swap"
rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

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()
})
});
})
);
});
+442 -311
View File
@@ -1,11 +1,8 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { queryClient } from "./lib/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
@@ -17,131 +14,122 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Switch, Route, useLocation } from "wouter";
// ADHD Mode Components
import {
ADHDModeProvider,
useADHDMode,
BreakReminderProvider,
BreakReminderOverlay,
EnergyCheckIn,
HyperfocusGuard,
EncouragementToast,
useEncouragement
} from '@/components/adhd';
// Components
import BottomNavigation from './components/BottomNavigation';
import TaskCreationModal from './components/TaskCreationModal';
import TaskDetailsModal from './components/TaskDetailsModal';
import TasksWithCalendar from './components/TasksWithCalendar';
import FocusMode from './components/FocusMode';
import CalendarView from './components/CalendarView';
import KanbanBoard from './components/KanbanBoard';
import ProjectTemplate from './components/ProjectTemplate';
import ThemeToggle from './components/ThemeToggle';
import WeekListView from './components/WeekListView';
import Settings from './pages/settings';
import AchievementsPage from './pages/AchievementsPage';
import UnscheduledTasksPage from './pages/UnscheduledTasksPage';
import { Task, Label } from '@shared/schema';
import { addDays, subDays } from 'date-fns';
import { useTimer } from './hooks/useTimer';
import { Plus } from 'lucide-react';
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "./components/AppSidebar"
import { useNotifications } from './hooks/use-notifications';
import { useToast } from "@/hooks/use-toast";
import { CommandPalette } from './components/CommandPalette';
import PomodoroOverlay from './components/PomodoroOverlay';
import AuthPage from "@/pages/AuthPage";
// Mock data for prototype
const initialTasks: Task[] = [
{
id: '1',
title: 'Review quarterly reports',
description: 'Analyze Q3 performance metrics and prepare summary',
status: 'todo',
priority: 'high',
dueDate: addDays(new Date(), 2),
timeTracked: 0,
isTracking: false,
projectId: 'project-1',
notes: null,
labelId: '2e8c3aa0-278f-4220-b2c5-aa109b4279b7' // Urgent label
},
{
id: '2',
title: 'Update mobile app interface',
description: 'Implement new design system and improve user experience',
status: 'inProgress',
priority: 'high',
dueDate: addDays(new Date(), 1),
timeTracked: 120,
isTracking: true,
projectId: 'project-2',
notes: null,
labelId: 'cb44bed1-8ba3-43fe-9498-bb28e483ed1f' // Work label
},
{
id: '3',
title: 'Team standup meeting',
description: null,
status: 'todo',
priority: 'low',
dueDate: null,
timeTracked: 0,
isTracking: false,
projectId: null,
notes: null,
labelId: '274f0ba4-a133-471a-bbe9-8189aa3b0106' // Personal label
},
{
id: '4',
title: 'Fix critical login bug',
description: 'Users unable to login with special characters in password',
status: 'todo',
priority: 'high',
dueDate: null,
timeTracked: 30,
isTracking: false,
projectId: 'project-2',
notes: null,
labelId: 'c5eccac9-7919-4eb1-bb50-badacad6b1c1' // Study label
},
{
id: '5',
title: 'Deploy new features',
description: null,
status: 'done',
priority: 'medium',
dueDate: subDays(new Date(), 2),
timeTracked: 120,
isTracking: false,
projectId: 'project-1',
notes: null,
labelId: null
}
];
import LeaderboardPage from "@/pages/LeaderboardPage";
import SetupWizard from "@/pages/SetupWizard";
import AdminUserManagement from "@/pages/AdminUserManagement";
import AdminSettings from "@/pages/AdminSettings";
import NotFound from "@/pages/not-found";
// import { AiChat } from "@/components/AiChat";
import ForgotPasswordPage from "@/pages/ForgotPasswordPage";
import ResetPasswordPage from "@/pages/ResetPasswordPage";
import AiChatPage from "@/pages/AiChatPage";
import NotificationsPage from "@/pages/NotificationsPage";
import FocusRoutinePage from "@/pages/FocusRoutinePage";
function App() {
import { useRoutineBlocker } from './components/RoutineBlocker';
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "@shared/schema";
import { Loader2 } from "lucide-react";
import TimeTrackingPage from './pages/TimeTrackingPage';
// ADHD Pages
import QuickWinsPage from './pages/QuickWinsPage';
import SingleTaskPage from './pages/SingleTaskPage';
import BodyDoublingPage from './pages/BodyDoublingPage';
import ADHDDashboardPage from './pages/ADHDDashboardPage';
// Self-managing EncouragementToast wrapper
function EncouragementToastWrapper() {
const { visible, type, message, xp, hide } = useEncouragement();
return <EncouragementToast type={type} visible={visible} onDismiss={hide} customMessage={message} xpEarned={xp} />;
}
// Inner App component that uses ADHD hooks
function AppContent() {
const { t } = useTranslation();
const [currentTab, setCurrentTab] = useState('tasks');
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const [labels, setLabels] = useState<Label[]>([]);
const { toast } = useToast();
const [, setLocation] = useLocation();
const isBlocked = useRoutineBlocker();
const { isEnabled: adhdEnabled, settings: adhdSettings } = useADHDMode();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null);
const [showPomodoro, setShowPomodoro] = useState(false);
const [activePomodoroTaskId, setActivePomodoroTaskId] = useState<string | null>(null);
// Fetch tasks and labels from server on mount
useEffect(() => {
const fetchData = async () => {
try {
// Fetch tasks
const tasksResponse = await fetch('/api/tasks');
if (tasksResponse.ok) {
const serverTasks: Task[] = await tasksResponse.json();
// Normalize server tasks - convert date strings to Date objects
const normalizedTasks = serverTasks.map(task => ({
...task,
dueDate: task.dueDate ? new Date(task.dueDate) : null
}));
// Merge server tasks with initial tasks (avoiding duplicates)
const existingIds = new Set(normalizedTasks.map(t => t.id));
const uniqueInitialTasks = initialTasks.filter(t => !existingIds.has(t.id));
setTasks([...normalizedTasks, ...uniqueInitialTasks]);
}
// Fetch labels
const labelsResponse = await fetch('/api/labels');
if (labelsResponse.ok) {
const serverLabels: Label[] = await labelsResponse.json();
setLabels(serverLabels);
}
} catch (error) {
console.error('Error fetching data:', error);
// Keep initial data on error
}
};
fetchData();
}, []);
// Enable global notifications polling
useNotifications({ poll: true });
const queryClient = useQueryClient();
const { data: user, isLoading: isLoadingUser } = useQuery<User>({
queryKey: ["/api/user"],
retry: false,
});
const { data: setupStatus } = useQuery<{ isSetup: boolean }>({
queryKey: ['/api/setup/status'],
});
// Fetch tasks and labels using React Query
const { data: tasksData } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
enabled: !!user,
});
const tasks = (tasksData ?? []).map(task => ({
...task,
dueDate: task.dueDate ? new Date(task.dueDate) : null
}));
const { data: labelsData } = useQuery<Label[]>({
queryKey: ['/api/labels'],
enabled: !!user,
});
const labels = labelsData ?? [];
const handleCreateTask = async (newTask: Partial<Task>) => {
try {
@@ -150,72 +138,103 @@ function App() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newTask.title || '',
description: newTask.description || null,
description: newTask.description || undefined,
status: 'todo',
priority: newTask.priority || 'medium',
dueDate: newTask.dueDate || null,
dueDate: newTask.dueDate || null, // Explicitly handled as nullable in schema
timeTracked: 0,
isTracking: false,
projectId: newTask.projectId || null,
notes: newTask.notes || null,
labelId: newTask.labelId || null
projectId: newTask.projectId || undefined,
notes: newTask.notes || undefined,
labelId: newTask.labelId || undefined,
energyLevel: newTask.energyLevel || 'medium',
estimatedDuration: newTask.estimatedDuration || undefined
})
});
if (!response.ok) {
throw new Error('Failed to create task');
}
if (!response.ok) throw new Error('Failed to create task');
const createdTask: Task = await response.json();
// Normalize the created task's date
const normalizedTask = {
...createdTask,
dueDate: createdTask.dueDate ? new Date(createdTask.dueDate) : null
};
setTasks(prev => [...prev, normalizedTask]);
console.log('Task created:', normalizedTask);
// Update cache optimistically or invalidation
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => {
const normalizedTask = {
...createdTask,
dueDate: createdTask.dueDate ? new Date(createdTask.dueDate) : null
};
return [...old, normalizedTask];
});
// Invalidate to be sure
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
toast({
title: t('task.created', 'Task Created'),
description: t('task.createdDescription', '"{title}" has been added.', { title: createdTask.title }),
});
} catch (error) {
console.error('Error creating task:', error);
toast({
title: t('error.createTask', 'Error'),
description: t('error.createTaskDescription', 'Failed to create task. Please try again.'),
variant: "destructive"
});
}
};
const handleTaskUpdate = async (taskId: string, updates: Partial<Task>) => {
// Optimistic Update
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
old.map(task => task.id === taskId ? { ...task, ...updates } : task)
);
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error('Failed to update task');
}
if (!response.ok) throw new Error('Failed to update task');
const updatedTask: Task = await response.json();
// Normalize the updated task's date
const normalizedTask = {
...updatedTask,
dueDate: updatedTask.dueDate ? new Date(updatedTask.dueDate) : null
};
setTasks(prev => prev.map(task =>
task.id === taskId ? normalizedTask : task
));
console.log('Task updated:', taskId, updates);
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => uniqueTasks(old, updatedTask));
} catch (error) {
console.error('Error updating task:', error);
// Optimistic update fallback
setTasks(prev => prev.map(task =>
task.id === taskId ? { ...task, ...updates } : task
));
// Rollback
if (previousTasks) {
queryClient.setQueryData(['/api/tasks'], previousTasks);
}
}
};
// Initialize the timer hook after handleTaskUpdate is defined
// Helper to merge updated task into array
const uniqueTasks = (tasks: Task[], updated: Task) => {
const normalized = { ...updated, dueDate: updated.dueDate ? new Date(updated.dueDate) : null };
return tasks.map(t => t.id === updated.id ? normalized : t);
};
const handleCreateFromTemplate = (templateId: string, startDate: Date, projectName: string) => {
console.log('Creating project from template:', { templateId, startDate, projectName });
// Assuming this might create tasks, we should invalidate
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }), 1000);
};
const { startTimer, stopTimer } = useTimer({
tasks,
onTaskUpdate: handleTaskUpdate
});
const handleStartPomodoro = (taskId: string) => {
setActivePomodoroTaskId(taskId);
setShowPomodoro(true);
startTimer(taskId);
};
const handleTaskStatusChange = (taskId: string, newStatus: Task['status']) => {
handleTaskUpdate(taskId, { status: newStatus });
};
@@ -227,7 +246,6 @@ function App() {
const handleTaskClick = (task: Task) => {
setSelectedTask(task);
setIsTaskDetailsOpen(true);
console.log('Task clicked:', task.title);
};
const handleTaskDetailsClose = () => {
@@ -237,7 +255,6 @@ function App() {
const handleTaskDetailsSave = (updatedTask: Task) => {
handleTaskUpdate(updatedTask.id, updatedTask);
console.log('Task details saved:', updatedTask.title);
};
const handleTaskDelete = async (taskId: string) => {
@@ -246,195 +263,309 @@ function App() {
const confirmTaskDelete = async () => {
if (!deleteTaskId) return;
// Optimistic deletion - remove from UI immediately
const previousTasks = queryClient.getQueryData<Task[]>(['/api/tasks']);
const taskToDelete = tasks.find(task => task.id === deleteTaskId);
setTasks(prev => prev.filter(task => task.id !== deleteTaskId));
// Close task details modal if the deleted task was open
// Optimistic delete
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) =>
old.filter(task => task.id !== deleteTaskId)
);
if (selectedTask?.id === deleteTaskId) {
handleTaskDetailsClose();
}
setDeleteTaskId(null);
try {
const response = await fetch(`/api/tasks/${deleteTaskId}`, {
method: 'DELETE'
});
// 404 is acceptable - task doesn't exist on backend (seeded task)
if (!response.ok && response.status !== 404) {
throw new Error('Failed to delete task');
}
console.log('Task deleted:', deleteTaskId);
const response = await fetch(`/api/tasks/${deleteTaskId}`, { method: 'DELETE' });
if (!response.ok && response.status !== 404) throw new Error('Failed to delete task');
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
} catch (error) {
console.error('Error deleting task:', error);
// Restore task on error
if (taskToDelete) {
setTasks(prev => [...prev, taskToDelete]);
// Rollback
if (previousTasks) {
queryClient.setQueryData(['/api/tasks'], previousTasks);
}
}
};
const handleCreateFromTemplate = (templateId: string, startDate: Date, projectName: string) => {
console.log('Creating project from template:', { templateId, startDate, projectName });
// In a real app, this would create multiple tasks based on the template
};
if (isLoadingUser) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background gap-6">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center shadow-2xl animate-pulse">
<img src="/icon-transparent-256.png" alt="TaskFlow" className="w-14 h-14 object-contain" />
</div>
<div className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<span className="text-muted-foreground text-sm">Loading TaskFlow...</span>
</div>
</div>
);
}
const renderContent = () => {
switch (currentTab) {
case 'tasks':
return (
<TasksWithCalendar
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
);
case 'calendar':
return (
<CalendarView
tasks={tasks}
onTaskDrop={handleTaskDrop}
onTaskClick={handleTaskClick}
onDateSelect={(date) => console.log('Date selected:', date.toLocaleDateString())}
/>
);
case 'kanban':
return (
<KanbanBoard
tasks={tasks}
onTaskStatusChange={handleTaskStatusChange}
onTaskUpdate={handleTaskUpdate}
onTaskClick={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
);
case 'templates':
return (
<ProjectTemplate
onCreateFromTemplate={handleCreateFromTemplate}
onCreateTasks={(newTasks) => setTasks(prev => [...prev, ...newTasks])}
/>
);
case 'settings':
return <Settings />;
default:
return (
<div className="text-center py-8">
<p className="text-muted-foreground">Page not found</p>
</div>
);
}
};
// Setup Redirect
if (setupStatus && !setupStatus.isSetup) {
return (
<Switch>
<Route path="/setup" component={SetupWizard} />
<Route component={() => <SetupWizard />} />
</Switch>
);
}
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 (
<div className="min-h-screen bg-background w-full">
<Switch>
<Route path="/auth" component={AuthPage} />
<Route path="/achievements">{() => <AuthPage />}</Route>
<Route path="/leaderboard" component={LeaderboardPage} />
<Route path="/time-tracking" component={TimeTrackingPage} />
<Route path="/ai" component={AiChatPage} />
<Route component={AuthPage} />
</Switch>
<Toaster />
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-16 items-center justify-between px-4">
<TooltipProvider>
<SidebarProvider>
<AppSidebar user={user} />
<SidebarInset>
<div className={`min-h-screen bg-background flex flex-col min-w-0 ${adhdEnabled ? 'adhd-mode' : ''} ${adhdEnabled && adhdSettings.reducedAnimations ? 'reduced-motion' : ''} ${adhdEnabled && adhdSettings.largerTargets ? 'larger-targets' : ''}`}>
{/* Mobile Header with Sidebar Toggle */}
<header className="sticky top-0 z-50 flex items-center gap-3 px-4 py-3 bg-background/80 backdrop-blur-xl border-b border-border/50 md:hidden">
<SidebarTrigger className="h-9 w-9" />
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<span className="text-primary-foreground font-bold text-sm">T</span>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center shadow-md">
<img src="/icon-transparent-128.png" alt="TaskFlow" className="w-6 h-6 object-contain" />
</div>
<h1 className="text-lg font-semibold" data-testid="text-app-title">
{t('app.title')}
</h1>
<span className="font-bold text-lg">{t('app.title')}</span>
</div>
<div className="flex items-center gap-2">
{(currentTab === 'settings' || currentTab === 'templates') && (
<Button
variant="outline"
size="sm"
onClick={() => setCurrentTab('tasks')}
data-testid="button-back-to-tasks"
>
{t('app.backToTasks')}
</Button>
</header>
<main className="flex-1 p-4 md:p-6 w-full relative">
<Switch>
<Route path="/">
<FocusMode
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
onNavigateToTasks={() => setLocation('/tasks')}
/>
</Route>
<Route path="/focus">
<FocusMode
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
onNavigateToTasks={() => setLocation('/tasks')}
/>
</Route>
<Route path="/calendar">
<CalendarView tasks={tasks} />
</Route>
<Route path="/weeklist">
<WeekListView tasks={tasks} onTaskUpdate={handleTaskUpdate} />
</Route>
<Route path="/kanban">
<KanbanBoard
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
/>
</Route>
<Route path="/auth">
{() => {
setLocation('/');
return null;
}}
</Route>
<Route path="/tasks">
<TasksWithCalendar
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskEdit={handleTaskClick}
onTaskDelete={handleTaskDelete}
onStartTimer={startTimer}
onStopTimer={stopTimer}
/>
</Route>
<Route path="/templates">
<ProjectTemplate
onCreateFromTemplate={handleCreateFromTemplate}
onCreateTasks={(newTasks) => {
queryClient.setQueryData(['/api/tasks'], (old: Task[] = []) => [...old, ...newTasks]);
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
}}
onNavigateToSettings={() => setLocation('/settings')}
/>
</Route>
<Route path="/achievements">
{() => <AchievementsPage user={user} />}
</Route>
<Route path="/unscheduled">
<UnscheduledTasksPage
user={user}
onToggleCompletion={(taskId, currentStatus) => handleTaskStatusChange(taskId, currentStatus === 'done' ? 'todo' : 'done')}
onDelete={(id) => setDeleteTaskId(id)}
onUpdate={handleTaskUpdate}
onSelect={setSelectedTask}
/>
</Route>
<Route path="/leaderboard">
<LeaderboardPage />
</Route>
<Route path="/leaderboard">
<LeaderboardPage />
</Route>
<Route path="/ai">
<AiChatPage />
</Route>
<Route path="/notifications">
<NotificationsPage />
</Route>
<Route path="/time-tracking">
<TimeTrackingPage />
</Route>
<Route path="/settings">
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
</Route>
{/* Admin Route */}
{user?.role === 'admin' && (
<>
<Route path="/admin/users" component={AdminUserManagement} />
<Route path="/admin/settings" component={AdminSettings} />
</>
)}
<ThemeToggle />
</div>
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
{/* ADHD Mode Routes */}
<Route path="/adhd" component={ADHDDashboardPage} />
<Route path="/adhd/quick-wins" component={QuickWinsPage} />
<Route path="/adhd/single-task" component={SingleTaskPage} />
<Route path="/adhd/body-doubling" component={BodyDoublingPage} />
<Route component={NotFound} />
</Switch>
</main>
{/* Floating Action Button */}
<div className="fixed bottom-8 right-8 z-[100]">
<Button
size="icon"
className="h-14 w-14 rounded-full shadow-2xl bg-gradient-to-r from-violet-600 to-indigo-600 hover:scale-110 transition-transform duration-200"
onClick={() => setIsCreateModalOpen(true)}
data-testid="fab-create-task"
>
<Plus className="h-6 w-6 text-white" />
</Button>
</div>
</header>
<CommandPalette
onNavigate={(path) => setLocation(path.startsWith('/') ? path : `/${path}`)}
onCreateTask={() => setIsCreateModalOpen(true)}
/>
{/* Main Content */}
<main className="pb-20 px-3 py-4 sm:px-4 sm:py-6 max-w-screen-2xl mx-auto">
{renderContent()}
</main>
<PomodoroOverlay
isOpen={showPomodoro}
onClose={() => setShowPomodoro(false)}
taskId={activePomodoroTaskId}
taskTitle={tasks.find(t => t.id === activePomodoroTaskId)?.title || 'Quick Focus'}
onCompleteTask={(id) => handleTaskUpdate(id, { status: 'done', isTracking: false })}
/>
{/* Bottom Navigation */}
<BottomNavigation
activeTab={currentTab}
onTabChange={(tab) => {
if (tab === 'settings') {
setCurrentTab('settings');
} else {
setCurrentTab(tab);
}
}}
onCreateTask={() => setIsCreateModalOpen(true)}
/>
<Toaster />
{/* Task Creation Modal */}
<TaskCreationModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSave={handleCreateTask}
/>
{/* ADHD Mode Overlays */}
{adhdEnabled && <BreakReminderOverlay />}
{adhdEnabled && adhdSettings.hyperfocusProtection && <HyperfocusGuard />}
{adhdEnabled && adhdSettings.positiveMessagingLevel !== 'minimal' && <EncouragementToastWrapper />}
{/* Task Details Modal */}
<TaskDetailsModal
isOpen={isTaskDetailsOpen}
onClose={handleTaskDetailsClose}
task={selectedTask}
onSave={handleTaskDetailsSave}
labels={labels}
/>
<TaskCreationModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSave={handleCreateTask}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteTaskId !== null} onOpenChange={(open) => !open && setDeleteTaskId(null)}>
<AlertDialogContent data-testid="dialog-delete-confirmation">
<AlertDialogHeader>
<AlertDialogTitle>{t('deleteConfirmation.title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('deleteConfirmation.description')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-cancel-delete">
{t('deleteConfirmation.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmTaskDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
data-testid="button-confirm-delete"
>
{t('deleteConfirmation.confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Toaster />
</TooltipProvider>
</QueryClientProvider>
<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>
</TooltipProvider>
);
}
// Main App wrapper with ADHD providers
function App() {
return (
<ADHDModeProvider>
<BreakReminderProvider>
<AppContent />
</BreakReminderProvider>
</ADHDModeProvider>
);
}
export default App;
+153
View File
@@ -0,0 +1,153 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardFooter, CardHeader } from '@/components/ui/card';
import { Bot, X, Send, Sparkles } from 'lucide-react';
import { apiRequest } from '@/lib/queryClient';
import { cn } from '@/lib/utils';
interface Message {
role: 'user' | 'assistant';
content: string;
}
// Typing indicator with animated dots - v2
function TypingIndicator() {
return (
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0ms', animationDuration: '0.6s' }} />
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '150ms', animationDuration: '0.6s' }} />
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '300ms', animationDuration: '0.6s' }} />
</div>
);
}
export function AiChat() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const mutation = useMutation({
mutationFn: async (msgs: Message[]) => {
const res = await apiRequest("POST", "/api/ai/chat", { messages: msgs });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed");
}
return res.json();
},
onSuccess: (data) => {
setMessages(prev => [...prev, data]);
setIsWaitingForResponse(false);
// Refresh tasks and labels in case AI created/modified them
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
},
onError: (err: Error) => {
setMessages(prev => [...prev, { role: 'assistant', content: t('ai.chat.error', 'Something went wrong') + ": " + err.message }]);
setIsWaitingForResponse(false);
}
});
// Use our own loading state OR mutation.isPending for the indicator
const isLoading = isWaitingForResponse || mutation.isPending;
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isOpen, isLoading]);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
if (!input.trim() || isLoading) return;
const newMsgs: Message[] = [...messages, { role: 'user', content: input }];
setMessages(newMsgs);
setInput('');
setIsWaitingForResponse(true);
mutation.mutate(newMsgs);
};
if (!isOpen) {
return (
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-4 right-8 z-50 rounded-full h-14 w-14 shadow-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:scale-110 transition-transform duration-200"
size="icon"
>
<Sparkles className="h-6 w-6 text-white animate-pulse" />
</Button>
);
}
return (
<Card className="fixed bottom-4 right-8 z-50 w-80 md:w-96 h-[500px] flex flex-col shadow-2xl border-primary/20 animate-in slide-in-from-bottom-10 fade-in duration-200">
<CardHeader className="p-4 border-b bg-primary/5 flex flex-row items-center justify-between shrink-0">
<div className="flex items-center gap-2 font-semibold">
<Bot className="w-5 h-5 text-primary" />
{t('ai.chat.title', 'AI Assistant')}
{isLoading && (
<span className="ml-2 text-xs font-normal text-muted-foreground animate-pulse">
{t('ai.chat.thinking', 'Thinking')}...
</span>
)}
</div>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsOpen(false)}>
<X className="w-4 h-4" />
</Button>
</CardHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4" ref={scrollRef}>
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-10">
<Bot className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm px-4">{t('ai.chat.welcome', 'Hi! I can help you manage tasks. Try: "Create a task to buy groceries tomorrow"')}</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={cn("flex w-full", msg.role === 'user' ? "justify-end" : "justify-start")}>
<div className={cn(
"max-w-[80%] rounded-2xl px-4 py-2 text-sm shadow-sm whitespace-pre-wrap",
msg.role === 'user'
? "bg-primary text-primary-foreground rounded-br-none"
: "bg-muted text-foreground rounded-bl-none"
)}>
{msg.content}
</div>
</div>
))}
{isLoading && (
<div className="flex w-full justify-start">
<div className="bg-muted rounded-2xl rounded-bl-none px-4 py-3 flex items-center gap-3">
<TypingIndicator />
<span className="text-sm text-muted-foreground">{t('ai.chat.thinking', 'Thinking')}...</span>
</div>
</div>
)}
</div>
<CardFooter className="p-3 border-t bg-background shrink-0">
<form onSubmit={handleSubmit} className="flex w-full gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t('ai.chat.placeholder', 'Ask me anything...')}
className="bg-muted/50 focus-visible:ring-primary/50"
disabled={isLoading}
/>
<Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
<Send className="w-4 h-4" />
</Button>
</form>
</CardFooter>
</Card>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useTranslation } from 'react-i18next';
import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff, Bell, Clock, Brain } from 'lucide-react';
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query';
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarRail,
useSidebar,
SidebarTrigger,
SidebarSeparator,
} from "@/components/ui/sidebar"
import { Task, User } from '@shared/schema';
import ThemeToggle from './ThemeToggle';
import { useLocation } from "wouter";
import { GamificationBar } from './GamificationBar';
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
user: User | undefined;
}
export function AppSidebar({ user, ...props }: AppSidebarProps) {
const { t } = useTranslation();
const { state } = useSidebar();
const { toast } = useToast();
const queryClient = useQueryClient();
const [location, setLocation] = useLocation();
// Fetch unscheduled count
// const { data: unscheduledCount = 0 } = useQuery({
// queryKey: ['/api/tasks'],
// select: (tasks: Task[]) => tasks.filter(t => !t.dueDate && t.status !== 'done').length,
// enabled: !!user
// });
const logoutMutation = useMutation({
mutationFn: async () => {
await fetch("/api/logout", { method: "POST" });
},
onSuccess: () => {
queryClient.clear();
// toast({ title: "Logged out successfully" });
window.location.href = "/auth?mode=login";
},
});
const items = [
{ title: t('navigation.focus'), id: 'focus', path: '/', icon: Target, color: 'text-red-500' },
{ title: t('navigation.tasks'), id: 'tasks', path: '/tasks', icon: Home, color: 'text-blue-500' },
{ title: t('navigation.calendar'), id: 'calendar', path: '/calendar', icon: Calendar, color: 'text-violet-500' },
{ title: t('navigation.weekList'), id: 'weeklist', path: '/weeklist', icon: List, color: 'text-pink-500' },
{ title: t('unscheduled.title', 'Unscheduled'), id: 'unscheduled', path: '/unscheduled', icon: CalendarOff, color: 'text-slate-500' },
{ title: t('navigation.kanban'), id: 'kanban', path: '/kanban', icon: LayoutGrid, color: 'text-orange-500' },
{ title: t('navigation.adhd', 'ADHD Mode'), id: 'adhd', path: '/adhd', icon: Brain, color: 'text-purple-500' },
{ title: t('navigation.achievements'), id: 'achievements', path: '/achievements', icon: Trophy, color: 'text-yellow-500' },
{ title: t('navigation.leaderboard'), id: 'leaderboard', path: '/leaderboard', icon: Award, color: 'text-yellow-500' },
{ title: t('analytics.timeTracking'), id: 'time-tracking', path: '/time-tracking', icon: Clock, color: 'text-teal-500' },
...(user?.aiEnabled ? [{ title: t('navigation.aiChat', 'AI Chat'), id: 'ai-chat', path: '/ai', icon: Bot, color: 'text-indigo-500' }] : []),
{ title: t('navigation.settings'), id: 'settings', path: '/settings', icon: Settings, color: 'text-gray-500' },
]
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground h-14">
<div className="flex aspect-square size-9 items-center justify-center rounded-xl overflow-hidden shadow-lg bg-gradient-to-br from-violet-600 to-indigo-600">
<img src="/icon-transparent-128.png" alt="TaskFlow" className="w-7 h-7 object-contain" />
</div>
{state !== 'collapsed' && (
<div className="grid flex-1 text-left text-sm leading-tight ml-2">
<span className="truncate font-bold text-base">{t('app.title')}</span>
<span className="truncate text-xs opacity-70">Focus. Flow. AI.</span>
</div>
)}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarMenu className="gap-2 px-2">
{items.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={location === item.path || (item.path !== '/' && location.startsWith(item.path))}
onClick={() => setLocation(item.path)}
tooltip={item.title}
className={`h-12 transition-all duration-200 ${location === item.path || (item.path !== '/' && location.startsWith(item.path))
? 'bg-gradient-to-r from-violet-600 to-indigo-600 text-white shadow-md hover:from-violet-500 hover:to-indigo-500 hover:text-white'
: 'hover:bg-sidebar-accent hover:pl-4'
}`}
>
<item.icon className={`transition-all duration-200 ${state === 'collapsed' ? 'size-7' : 'size-5'} ${location === item.path || (item.path !== '/' && location.startsWith(item.path)) ? 'text-white' : item.color}`} />
{state !== 'collapsed' && (
<span className="font-medium text-base ml-2">{item.title}</span>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarContent>
<SidebarSeparator />
<SidebarFooter>
{state !== 'collapsed' && user && (
<GamificationBar xp={user.xp} level={user.level} streak={user.currentStreak} />
)}
<div className={`p-4 ${state === 'collapsed'
? 'flex flex-col items-center justify-center gap-4'
: 'grid grid-cols-4 items-center gap-2'
}`}>
<div className={state === 'collapsed' ? '' : 'justify-self-start'}>
<ThemeToggle />
</div>
<div className={state === 'collapsed' ? '' : 'justify-self-center'}>
<Button
variant="ghost"
size="icon"
onClick={() => setLocation('/notifications')}
className="bg-transparent hover:bg-sidebar-accent"
title={t('navigation.notifications', 'Notifications')}
>
<Bell className="size-4" />
</Button>
</div>
<div className={state === 'collapsed' ? '' : 'justify-self-center'}>
<Button
variant="ghost"
size="icon"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
title={t('auth.logout', 'Logout')}
>
<LogOut className="size-4" />
</Button>
</div>
<div className={state === 'collapsed' ? '' : 'justify-self-end'}>
<SidebarTrigger />
</div>
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}
@@ -1,82 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Home, Calendar, LayoutGrid, Plus, Settings } from 'lucide-react';
import { useState } from 'react';
interface BottomNavigationProps {
onTabChange?: (tab: string) => void;
onCreateTask?: () => void;
activeTab?: string;
}
export default function BottomNavigation({ onTabChange, onCreateTask, activeTab = 'tasks' }: BottomNavigationProps) {
const { t } = useTranslation();
const [currentTab, setCurrentTab] = useState(activeTab);
const tabs = [
{ id: 'tasks', label: t('navigation.tasks'), icon: Home },
{ id: 'calendar', label: t('navigation.calendar'), icon: Calendar },
{ id: 'create', label: t('navigation.create'), icon: Plus, isCreate: true },
{ id: 'kanban', label: t('navigation.kanban'), icon: LayoutGrid },
{ id: 'settings', label: t('navigation.settings'), icon: Settings }
];
const handleTabClick = (tabId: string, isCreate?: boolean) => {
if (isCreate) {
onCreateTask?.();
console.log('Create task triggered');
return;
}
setCurrentTab(tabId);
onTabChange?.(tabId);
console.log('Tab changed to:', tabId);
};
return (
<div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border z-40">
<div className="flex items-center justify-around px-2 py-2 safe-area-inset-bottom">
{tabs.map((tab) => {
const isActive = currentTab === tab.id && !tab.isCreate;
const Icon = tab.icon;
return (
<Button
key={tab.id}
variant={tab.isCreate ? "default" : isActive ? "secondary" : "ghost"}
size={tab.isCreate ? "icon" : "sm"}
onClick={() => handleTabClick(tab.id, tab.isCreate)}
className={`relative flex flex-col gap-1 h-auto py-2 px-3 ${
tab.isCreate
? 'w-12 h-12 rounded-full shadow-lg'
: 'flex-1 max-w-[80px]'
}`}
data-testid={`nav-${tab.id}`}
>
<Icon className={`${
tab.isCreate ? 'w-6 h-6' : 'w-5 h-5'
}`} />
{!tab.isCreate && (
<span className="text-xs font-medium">
{tab.label}
</span>
)}
{tab.badge && !tab.isCreate && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 w-5 h-5 flex items-center justify-center text-xs p-0 min-w-[20px]"
data-testid={`badge-${tab.id}`}
>
{tab.badge}
</Badge>
)}
</Button>
);
})}
</div>
</div>
);
}
+430 -65
View File
@@ -1,25 +1,54 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
import { ChevronLeft, ChevronRight, Calendar, Edit, Timer, CheckCircle, Play, Clock, Trash2 } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { addDays, format, isSameDay, startOfWeek, subWeeks } from 'date-fns';
import { addDays, format, isSameDay, startOfWeek, subWeeks, startOfDay } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
interface CalendarViewProps {
tasks: Task[];
onTaskDrop?: (taskId: string, newDate: Date) => void;
onTaskClick?: (task: Task) => void;
onDateSelect?: (date: Date) => void;
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
onTaskEdit?: (task: Task) => void;
onTaskDelete?: (taskId: string) => void;
onStartTimer?: (taskId: string) => void;
onStopTimer?: (taskId: string) => void;
}
export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSelect }: CalendarViewProps) {
export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSelect, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: CalendarViewProps) {
const { t, i18n } = useTranslation();
const [currentDate, setCurrentDate] = useState(new Date());
const [draggedTask, setDraggedTask] = useState<string | null>(null);
const [longPressTask, setLongPressTask] = useState<Task | null>(null);
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const longPressTriggered = useRef(false);
// Get date-fns locale based on current language
const dateLocale = i18n.language === 'de' ? de : enUS;
@@ -37,9 +66,24 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
const dates = Array.from({ length: 35 }, (_, i) => addDays(startDate, i));
const getTasksForDate = (date: Date) => {
return tasks.filter(task =>
task.dueDate && isSameDay(task.dueDate, date)
);
return tasks.filter(task => {
// Handle multi-day tasks
if (task.startDate && task.dueDate) {
const start = startOfDay(new Date(task.startDate));
const end = startOfDay(new Date(task.dueDate));
const current = startOfDay(date);
return current >= start && current <= end;
}
// Handle single date tasks (dueDate or startDate)
if (task.dueDate) {
return isSameDay(new Date(task.dueDate), date);
}
if (task.startDate) { // Fallback if only start date exists
return isSameDay(new Date(task.startDate), date);
}
return false;
});
};
const handleDragStart = (taskId: string) => {
@@ -69,21 +113,176 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
console.log(`Navigating to week of ${newDate.toLocaleDateString()}`);
};
// Long press handlers for mobile
const handleTouchStart = (task: Task) => {
// Clear any existing timeout
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
}
longPressTriggered.current = false;
longPressTimerRef.current = setTimeout(() => {
longPressTriggered.current = true;
setLongPressTask(task);
setDropdownOpen(true);
console.log('Long press detected for task:', task.title);
// Reset after a delay to allow next click
setTimeout(() => {
longPressTriggered.current = false;
}, 300);
}, 500); // 500ms long press threshold
};
const handleTouchEnd = () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
};
const handleTaskClick = (task: Task, e: React.MouseEvent | React.TouchEvent) => {
// Prevent click when dragging
if (draggedTask === task.id) return;
// Skip click if long press was just triggered
if (longPressTriggered.current) {
e.preventDefault();
return;
}
e.stopPropagation();
onTaskClick?.(task);
console.log('Calendar task clicked:', task.title);
};
// Menu content component to avoid duplication
const TaskMenuContent = ({ task, closeMenu }: { task: Task; closeMenu?: () => void }) => (
<>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
closeMenu?.();
}}
data-testid={`context-menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
if (task.isTracking) {
onStopTimer?.(task.id);
} else {
onStartTimer?.(task.id);
}
closeMenu?.();
}}
data-testid={`context-menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</ContextMenuItem>
<ContextMenuSeparator />
{task.status !== 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'done' });
closeMenu?.();
}}
data-testid={`context-menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</ContextMenuItem>
)}
{task.status === 'todo' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'inProgress' });
closeMenu?.();
}}
data-testid={`context-menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</ContextMenuItem>
)}
{task.status === 'inProgress' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'todo' });
closeMenu?.();
}}
data-testid={`context-menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</ContextMenuItem>
)}
{task.status === 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'inProgress' });
closeMenu?.();
}}
data-testid={`context-menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onTaskDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskDelete(task.id);
closeMenu?.();
}}
className="text-destructive"
data-testid={`context-menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</ContextMenuItem>
)}
</>
);
return (
<div className="space-y-4">
{/* Header */}
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-2 sm:pb-3 mb-3 sm:mb-4">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-2 sm:pb-3 mb-3 sm:mb-4 pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 text-primary" />
<h2 className="text-base sm:text-lg font-semibold" data-testid="text-calendar-title">
{t('calendar.title')}
</h2>
<Badge variant="outline" className="ml-2 text-xs font-normal text-muted-foreground inline-flex">
{t('analytics.cw')} {format(currentDate, 'w', { locale: dateLocale })}
</Badge>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigateWeek('prev')}
data-testid="button-prev-week"
@@ -91,9 +290,9 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigateWeek('next')}
data-testid="button-next-week"
@@ -111,18 +310,16 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
const dayTasks = getTasksForDate(date);
const isToday = isSameDay(date, new Date());
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const isMonday = date.getDay() === 1;
const isPreviousWeek = index < 7; // First 7 days are the previous week
return (
<Card
<Card
key={index}
className={`p-2 sm:p-3 min-h-[100px] sm:min-h-[120px] hover-elevate active-elevate-2 transition-all ${
isToday ? 'ring-2 ring-primary' : ''
} ${
isWeekend ? 'bg-muted/30' : ''
} ${
isPreviousWeek ? 'opacity-50 bg-muted/60' : ''
}`}
className={`p-2 sm:p-3 min-h-[100px] sm:min-h-[120px] hover-elevate active-elevate-2 transition-all ${isToday ? 'ring-2 ring-primary' : ''
} ${isWeekend ? 'bg-muted/30' : ''
} ${isPreviousWeek ? 'opacity-50 bg-muted/60' : ''
}`}
onDragOver={handleDragOver}
onDrop={() => handleDrop(date)}
onClick={() => {
@@ -131,68 +328,117 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
}}
data-testid={`calendar-date-${format(date, 'yyyy-MM-dd')}`}
>
<div className="text-center mb-2">
<div className={`text-xs font-medium ${
isPreviousWeek ? 'text-muted-foreground/60' : 'text-muted-foreground'
}`}>
<div className="text-center mb-2 relative">
{isMonday && (
<span className="absolute left-0 top-0 text-[10px] text-muted-foreground/70 font-mono">
{t('analytics.cw')} {format(date, 'w', { locale: dateLocale })}
</span>
)}
<div className={`text-xs font-medium ${isPreviousWeek ? 'text-muted-foreground/60' : 'text-muted-foreground'
}`}>
{format(date, 'EEE', { locale: dateLocale })}
</div>
<div className={`text-sm font-semibold ${
isToday ? 'text-primary' : isPreviousWeek ? 'text-muted-foreground/60' : ''
}`}>
<div className={`text-sm font-semibold ${isToday ? 'text-primary' : isPreviousWeek ? 'text-muted-foreground/60' : ''
}`}>
{format(date, 'd', { locale: dateLocale })}
</div>
</div>
<div className="space-y-1">
{dayTasks.slice(0, 3).map((task, taskIndex) => {
// Find the label for this task
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
return (
<div
key={task.id}
draggable
onDragStart={() => handleDragStart(task.id)}
onDragEnd={handleDragEnd}
onClick={(e) => {
// Prevent click when dragging
if (draggedTask === task.id) return;
e.stopPropagation();
onTaskClick?.(task);
console.log('Calendar task clicked:', task.title);
}}
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
data-testid={`calendar-task-${task.id}`}
>
<Badge
variant="outline"
className="w-full justify-start text-xs p-1 h-auto pointer-events-none"
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
} : {}}
>
<div className="truncate flex-1 text-left">
{task.title}
<ContextMenu key={task.id}>
<ContextMenuTrigger asChild>
<div
draggable
onDragStart={() => handleDragStart(task.id)}
onDragEnd={handleDragEnd}
onTouchStart={() => handleTouchStart(task)}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchEnd}
onClick={(e) => handleTaskClick(task, e)}
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
data-testid={`calendar-task-${task.id}`}
>
<Badge
variant="outline"
className="w-full justify-start text-xs p-1 h-auto"
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
} : {}}
>
<div className="truncate flex-1 text-left pointer-events-none">
{task.title}
</div>
</Badge>
</div>
</Badge>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<TaskMenuContent task={task} />
</ContextMenuContent>
</ContextMenu>
);
})}
{dayTasks.length > 3 && (
<Badge variant="secondary" className="w-full justify-center text-xs">
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
</Badge>
<HoverCard openDelay={0} closeDelay={100}>
<HoverCardTrigger asChild>
<Badge variant="secondary" className="w-full justify-center text-xs cursor-pointer hover:bg-secondary/80">
{t('calendar.moreItems', { count: dayTasks.length - 3 })}
</Badge>
</HoverCardTrigger>
<HoverCardContent className="w-72 p-0">
<div className="p-3 border-b bg-muted/30">
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
<span>{t('calendar.moreItems', { count: dayTasks.length - 3 })}</span>
<span className="text-[10px] font-normal opacity-75">Click to edit</span>
</h4>
</div>
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
{dayTasks.slice(3).map(task => {
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
return (
<div
key={task.id}
className="p-2 border rounded hover:bg-accent cursor-pointer flex items-center justify-between group transition-colors"
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}
>
<div className="min-w-0 flex-1 mr-2">
<div className="text-xs font-medium truncate">{task.title}</div>
{taskLabel && (
<div className="flex items-center gap-1 mt-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: taskLabel.color }} />
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{taskLabel.name}</span>
</div>
)}
</div>
<Button size="icon" variant="ghost" className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}>
<Edit className="w-3 h-3" />
</Button>
</div>
)
})}
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
</Card>
);
})}
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
@@ -208,6 +454,125 @@ export default function CalendarView({ tasks, onTaskDrop, onTaskClick, onDateSel
<span>{t('calendar.previousWeek')}</span>
</div>
</div>
{/* Mobile long-press fallback menu */}
{longPressTask && (
<DropdownMenu
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
if (!open) {
setLongPressTask(null);
}
}}
>
<DropdownMenuTrigger className="hidden" />
<DropdownMenuContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(longPressTask);
setDropdownOpen(false);
}}
data-testid={`dropdown-menu-edit-${longPressTask.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (longPressTask.isTracking) {
onStopTimer?.(longPressTask.id);
} else {
onStartTimer?.(longPressTask.id);
}
setDropdownOpen(false);
}}
data-testid={`dropdown-menu-timer-${longPressTask.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{longPressTask.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{longPressTask.status !== 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(longPressTask.id, { status: 'done' });
setDropdownOpen(false);
}}
data-testid={`dropdown-menu-complete-${longPressTask.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</DropdownMenuItem>
)}
{longPressTask.status === 'todo' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(longPressTask.id, { status: 'inProgress' });
setDropdownOpen(false);
}}
data-testid={`dropdown-menu-start-${longPressTask.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</DropdownMenuItem>
)}
{longPressTask.status === 'inProgress' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(longPressTask.id, { status: 'todo' });
setDropdownOpen(false);
}}
data-testid={`dropdown-menu-todo-${longPressTask.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</DropdownMenuItem>
)}
{longPressTask.status === 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(longPressTask.id, { status: 'inProgress' });
setDropdownOpen(false);
}}
data-testid={`dropdown-menu-reopen-${longPressTask.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{onTaskDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskDelete(longPressTask.id);
setDropdownOpen(false);
}}
className="text-destructive"
data-testid={`dropdown-menu-delete-${longPressTask.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
@@ -0,0 +1,152 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "auth.currentPasswordRequired"),
newPassword: z.string().min(6, "auth.passwordMinLength"),
confirmNewPassword: z.string(),
}).refine((data) => data.newPassword === data.confirmNewPassword, {
message: "validation.passwordMatch",
path: ["confirmNewPassword"],
});
type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
interface ChangePasswordModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const form = useForm<ChangePasswordFormValues>({
resolver: zodResolver(changePasswordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
},
});
const mutation = useMutation({
mutationFn: async (data: ChangePasswordFormValues) => {
const res = await apiRequest("PATCH", "/api/user/password", {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to change password");
}
return res.json();
},
onSuccess: () => {
toast({ title: t('auth.passwordChangedSuccess') });
form.reset();
onOpenChange(false);
},
onError: (error: Error) => {
// If error mentions current password, set field error
if (error.message.toLowerCase().includes("current password")) {
form.setError("currentPassword", { message: t('auth.incorrectCurrentPassword') });
} else {
toast({
title: t('auth.changePasswordFailed'),
description: error.message,
variant: "destructive",
});
}
},
});
const onSubmit = (data: ChangePasswordFormValues) => {
mutation.mutate(data);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-lg sm:max-w-md p-4 sm:p-6">
<DialogHeader>
<DialogTitle>{t('auth.changePassword')}</DialogTitle>
<DialogDescription>
{t('auth.changePasswordDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.currentPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.currentPassword?.message && t(form.formState.errors.currentPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.newPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.newPassword?.message && t(form.formState.errors.newPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('auth.confirmPassword')}</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage>
{form.formState.errors.confirmNewPassword?.message && t(form.formState.errors.confirmNewPassword.message)}
</FormMessage>
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? t('auth.changing') : t('auth.changePassword')}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { Command } from "cmdk";
import { useTranslation } from "react-i18next";
import {
Calculator,
Calendar,
CreditCard,
Settings,
Smile,
User,
LayoutGrid,
List,
CheckSquare,
Plus,
Moon,
Sun,
Search
} from "lucide-react";
import { useTheme } from "next-themes";
import { Dialog, DialogContent } from "@/components/ui/dialog";
interface CommandPaletteProps {
onNavigate: (tab: string) => void;
onCreateTask: () => void;
}
export function CommandPalette({ onNavigate, onCreateTask }: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const { setTheme, theme } = useTheme();
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const runCommand = (command: () => void) => {
setOpen(false);
command();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0 overflow-hidden shadow-2xl max-w-2xl">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Command.Input
placeholder={t('search.placeholder') || "Type a command or search..."}
className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<Command.List className="max-h-[300px] overflow-y-auto overflow-x-hidden p-2">
<Command.Empty>{t('search.noResults') || "No results found."}</Command.Empty>
<Command.Group heading="Actions">
<Command.Item
onSelect={() => runCommand(onCreateTask)}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<Plus className="mr-2 h-4 w-4" />
<span>{t('taskCreation.title') || "Create New Task"}</span>
<span className="ml-auto text-xs text-muted-foreground">Cmd+N</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => setTheme(theme === 'dark' ? 'light' : 'dark'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
{theme === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
<span>{t('theme.toggle') || "Toggle Theme"}</span>
</Command.Item>
</Command.Group>
<Command.Group heading="Navigation">
<Command.Item
onSelect={() => runCommand(() => onNavigate('tasks'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<CheckSquare className="mr-2 h-4 w-4" />
<span>Tasks</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => onNavigate('calendar'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<Calendar className="mr-2 h-4 w-4" />
<span>Calendar</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => onNavigate('weeklist'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<List className="mr-2 h-4 w-4" />
<span>Week List</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => onNavigate('kanban'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<LayoutGrid className="mr-2 h-4 w-4" />
<span>Kanban Board</span>
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => onNavigate('settings'))}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer"
>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</Command.Item>
</Command.Group>
</Command.List>
</Command>
</DialogContent>
</Dialog>
);
}
+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;
}
}
+265
View File
@@ -0,0 +1,265 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Task } from '@shared/schema';
import TaskCard from './TaskCard';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Trophy, Target, Calendar as CalendarIcon, ArrowRight, GripVertical, Sun, Moon } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface FocusModeProps {
tasks: Task[];
onTaskUpdate: (taskId: string, updates: Partial<Task>) => void;
onTaskEdit: (task: Task) => void;
onTaskDelete: (taskId: string) => void;
onStartTimer: (taskId: string) => void;
onStopTimer: (taskId: string) => void;
onNavigateToTasks: () => void;
}
export default function FocusMode({
tasks,
onTaskUpdate,
onTaskEdit,
onTaskDelete,
onStartTimer,
onStopTimer,
onNavigateToTasks
}: FocusModeProps) {
const { t, i18n } = useTranslation();
const greeting = useMemo(() => {
const hour = new Date().getHours();
if (hour < 12) return t('focus.greeting.morning');
if (hour < 18) return t('focus.greeting.afternoon');
return t('focus.greeting.evening');
}, [t, i18n.language]);
const stats = useMemo(() => {
const today = new Date();
const completedToday = tasks.filter(t => {
// This is a mock check for 'completed today' since we don't track completion date in this schema
// We'll just use 'done' tasks for the prototype visual
return t.status === 'done';
}).length;
const totalActive = tasks.filter(t => t.status !== 'done').length;
return { completedToday, totalActive };
}, [tasks]);
// Sortable item component
const SortableTaskItem = ({ task }: { task: Task }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className="group relative touch-none">
<div {...attributes} {...listeners} className="absolute left-2 top-1/2 -translate-y-1/2 p-2 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity z-10">
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
<div className="pl-8">
<TaskCard
task={task}
onStatusChange={(status) => onTaskUpdate(task.id, { status })}
onUpdate={(updates) => onTaskUpdate(task.id, updates)}
onEdit={() => onTaskEdit(task)}
onDelete={() => onTaskDelete(task.id)}
onStartTimer={() => onStartTimer(task.id)}
onStopTimer={() => onStopTimer(task.id)}
/>
</div>
</div>
);
};
const focusTasks = useMemo(() => {
// Return tasks for focus list
const today = new Date();
today.setHours(0, 0, 0, 0);
return tasks.filter(t => t.status !== 'done')
.sort((a, b) => {
// 1. Due Date (Overdue/Today first)
if (a.dueDate && b.dueDate) {
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
}
// Tasks with due dates come before those without
if (a.dueDate && !b.dueDate) return -1;
if (!a.dueDate && b.dueDate) return 1;
// 2. Priority
if (a.priority === 'high' && b.priority !== 'high') return -1;
if (b.priority === 'high' && a.priority !== 'high') return 1;
return 0;
})
// Take top 5 to include more subtasks if relevant
.slice(0, 5);
}, [tasks]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = (event: any) => {
// In a real app we'd persist the new order
// For now we just animate it visually
const { active, over } = event;
if (active.id !== over.id) {
// logic to reorder would go here
console.log('Reordered', active.id, over.id);
}
};
return (
<div className="max-w-4xl mx-auto space-y-8 p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2"
>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent">
{greeting}, Paul
</h1>
<p className="text-muted-foreground text-lg">
{t('focus.stats.activeTasksMessage', { count: stats.totalActive })}
</p>
</motion.div>
<div className="grid gap-6 md:grid-cols-2">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
>
<Card className="p-6 h-full bg-gradient-to-br from-violet-50 to-indigo-50 dark:from-violet-950/20 dark:to-indigo-950/20 border-violet-100 dark:border-violet-900/50">
<div className="flex items-center gap-4 mb-4">
<div className="p-3 bg-white dark:bg-violet-900/50 rounded-xl shadow-sm">
<Trophy className="w-6 h-6 text-violet-600 dark:text-violet-400" />
</div>
<div>
<h3 className="font-semibold text-lg">{t('focus.stats.dailyProgress')}</h3>
<p className="text-sm text-muted-foreground">{t('focus.stats.momentum')}</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-end gap-2">
<span className="text-4xl font-bold text-violet-700 dark:text-violet-300">{stats.completedToday}</span>
<span className="text-muted-foreground mb-1">{t('focus.stats.tasksCompleted')}</span>
</div>
<div className="h-2 bg-white/50 dark:bg-black/20 rounded-full overflow-hidden">
<div
className="h-full bg-violet-500 rounded-full transition-all duration-1000"
style={{ width: `${Math.min((stats.completedToday / (stats.completedToday + stats.totalActive || 1)) * 100, 100)}%` }}
/>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
className="grid grid-rows-2 gap-4 h-full"
>
{/* Routine Actions */}
<div className="grid grid-cols-2 gap-4">
<Button
variant="outline"
className="h-full flex flex-col items-center justify-center gap-2 hover:bg-orange-50 hover:text-orange-600 dark:hover:bg-orange-950/30 border-dashed"
onClick={() => window.location.href = '/focus/routine/morning'}
>
<Sun className="w-5 h-5 text-orange-500" />
<span className="text-xs font-semibold">{t('focus.routine.morning', 'Morning Plan')}</span>
</Button>
<Button
variant="outline"
className="h-full flex flex-col items-center justify-center gap-2 hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-950/30 border-dashed"
onClick={() => window.location.href = '/focus/routine/evening'}
>
<Moon className="w-5 h-5 text-indigo-500" />
<span className="text-xs font-semibold">{t('focus.routine.evening', 'Evening Review')}</span>
</Button>
</div>
<Card className="p-4 flex flex-col justify-center items-center text-center bg-card hover:shadow-md transition-all cursor-pointer" onClick={onNavigateToTasks}>
<div className="flex items-center gap-2 mb-2">
<Target className="w-5 h-5 text-muted-foreground/50" />
<h3 className="font-semibold text-sm">{t('focus.viewAll.title')}</h3>
</div>
<Button variant="ghost" size="sm" className="gap-1 text-xs text-muted-foreground group">
{t('focus.viewAll.button')} <ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
</Button>
</Card>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Target className="w-5 h-5 text-red-500" />
{t('focus.list.title')}
</h2>
<span className="text-xs text-muted-foreground">{t('focus.list.subtitle')}</span>
</div>
{focusTasks.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={focusTasks.map(t => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{focusTasks.map(task => (
<SortableTaskItem key={task.id} task={task} />
))}
</div>
</SortableContext>
</DndContext>
) : (
<Card className="p-12 text-center text-muted-foreground bg-muted/50 border-dashed">
<p>{t('focus.list.empty')}</p>
</Card>
)}
</motion.div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { Trophy, Flame } from 'lucide-react';
import { Progress } from "@/components/ui/progress";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import {
getLevelFromXP,
getNextLevelXP,
getLevelProgress,
getRankKey
} from "@/lib/gamification";
import { LevelDetailsModal } from './LevelDetailsModal';
interface GamificationBarProps {
xp: number;
level: number; // Keeping prop for backwards compat, but should calculate from XP usually
streak: number;
}
export function GamificationBar({ xp, streak }: GamificationBarProps) {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
// Use centralized logic
const computedLevel = getLevelFromXP(xp);
const nextLevelXP = getNextLevelXP(computedLevel);
const progress = getLevelProgress(xp);
const rankKey = getRankKey(computedLevel);
return (
<>
<div
className="p-4 border-b bg-sidebar-accent/10 cursor-pointer hover:bg-sidebar-accent/20 transition-colors"
onClick={() => setIsModalOpen(true)}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="bg-yellow-500/20 p-1 rounded-md text-yellow-600">
<Trophy className="size-4" />
</div>
<div>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('gamification.level', { level: computedLevel })}
</span>
<div className="text-sm font-bold">
{t(`ranks.${rankKey}`)}
</div>
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 px-2 py-1 bg-orange-500/10 rounded-md border border-orange-500/20 cursor-help">
<Flame className="w-4 h-4 text-orange-500 fill-orange-500" />
<span className="text-sm font-bold text-orange-600 dark:text-orange-400">{streak}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('gamification.streakTooltip', 'Log in daily to increase your streak!')}</p>
<p className="text-xs text-muted-foreground mt-1">{t('gamification.streakBonus', 'Weekly & Monthly bonuses available.')}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t('gamification.xp', { count: xp })}</span>
<span>{t('gamification.nextLevel', { count: nextLevelXP })}</span>
</div>
<Progress value={progress} className="h-2 bg-slate-200" />
</div>
</div>
<LevelDetailsModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
xp={xp}
/>
</>
);
}
+204 -203
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;
@@ -42,7 +43,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
const [viewMode, setViewMode] = useState<ViewMode>('traditional');
const [currentDate, setCurrentDate] = useState(new Date());
const [draggedTask, setDraggedTask] = useState<string | null>(null);
// Filter state
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
const [selectedPriorities, setSelectedPriorities] = useState<string[]>([]);
@@ -65,36 +66,36 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
const getTasksForColumn = (status: Task['status']) => {
let filteredTasks = tasks.filter(task => task.status === status);
// Apply label filtering
if (selectedLabels.length > 0) {
filteredTasks = filteredTasks.filter(task =>
filteredTasks = filteredTasks.filter(task =>
task.labelId && selectedLabels.includes(task.labelId)
);
}
// Apply priority filtering
if (selectedPriorities.length > 0) {
filteredTasks = filteredTasks.filter(task =>
filteredTasks = filteredTasks.filter(task =>
selectedPriorities.includes(task.priority)
);
}
// Apply date filtering for weekly/monthly views
if (viewMode === 'weekly') {
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 }); // Monday = 1
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 });
filteredTasks = filteredTasks.filter(task =>
filteredTasks = filteredTasks.filter(task =>
task.dueDate && isWithinInterval(task.dueDate, { start: weekStart, end: weekEnd })
);
} else if (viewMode === 'monthly') {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
filteredTasks = filteredTasks.filter(task =>
filteredTasks = filteredTasks.filter(task =>
task.dueDate && isWithinInterval(task.dueDate, { start: monthStart, end: monthEnd })
);
}
return filteredTasks;
};
@@ -139,7 +140,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
// Filter management functions
const toggleLabel = (labelId: string) => {
setSelectedLabels(prev =>
setSelectedLabels(prev =>
prev.includes(labelId)
? prev.filter(id => id !== labelId)
: [...prev, labelId]
@@ -147,7 +148,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
};
const togglePriority = (priority: string) => {
setSelectedPriorities(prev =>
setSelectedPriorities(prev =>
prev.includes(priority)
? prev.filter(p => p !== priority)
: [...prev, priority]
@@ -175,7 +176,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
return (
<div className="space-y-4">
{/* Header */}
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-2 sm:pb-3 mb-3 sm:mb-4">
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-2 sm:pb-3 mb-3 sm:mb-4 pt-4">
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 sm:w-5 sm:h-5 text-primary" />
@@ -183,11 +184,11 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{t('kanban.title')}
</h2>
</div>
{viewMode !== 'traditional' && (
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigatePeriod('prev')}
data-testid="button-prev-period"
@@ -195,13 +196,13 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium min-w-[120px] text-center">
{getPeriodTitle()}
</span>
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigatePeriod('next')}
data-testid="button-next-period"
@@ -215,192 +216,192 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{/* Filter Bar */}
<Card className="p-3 sm:p-4">
<div className="flex items-center justify-between flex-wrap gap-3 sm:gap-4">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{t('kanban.filters')}</span>
{hasActiveFilters && (
<Badge variant="secondary" className="text-xs">
{selectedLabels.length + selectedPriorities.length}
</Badge>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Labels Filter */}
<div className="flex items-center justify-between flex-wrap gap-3 sm:gap-4">
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="justify-start"
data-testid="button-filter-labels"
>
<Tag className="w-4 h-4 mr-2" />
{t('kanban.labels')}
{selectedLabels.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedLabels.length}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-56" align="start">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t('kanban.filterByLabels')}</h4>
<Separator />
<div className="space-y-2 max-h-48 overflow-y-auto">
{labels.length > 0 ? (
labels.map((label) => (
<div
key={label.id}
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{t('kanban.filters')}</span>
{hasActiveFilters && (
<Badge variant="secondary" className="text-xs">
{selectedLabels.length + selectedPriorities.length}
</Badge>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Labels Filter */}
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="justify-start"
data-testid="button-filter-labels"
>
<Tag className="w-4 h-4 mr-2" />
{t('kanban.labels')}
{selectedLabels.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedLabels.length}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-56" align="start">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t('kanban.filterByLabels')}</h4>
<Separator />
<div className="space-y-2 max-h-48 overflow-y-auto">
{labels.length > 0 ? (
labels.map((label) => (
<div
key={label.id}
className="flex items-center space-x-2"
>
<Checkbox
id={`label-${label.id}`}
checked={selectedLabels.includes(label.id)}
onCheckedChange={() => toggleLabel(label.id)}
data-testid={`checkbox-label-${label.id}`}
/>
<label
htmlFor={`label-${label.id}`}
className="flex items-center space-x-2 text-sm cursor-pointer flex-1"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span>{label.name}</span>
</label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">{t('kanban.noLabelsAvailable')}</p>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* Priorities Filter */}
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="justify-start"
data-testid="button-filter-priorities"
>
<AlertTriangle className="w-4 h-4 mr-2" />
{t('kanban.priorities')}
{selectedPriorities.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedPriorities.length}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48" align="start">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t('kanban.filterByPriority')}</h4>
<Separator />
<div className="space-y-2">
{priorities.map((priority) => (
<div
key={priority}
className="flex items-center space-x-2"
>
<Checkbox
id={`label-${label.id}`}
checked={selectedLabels.includes(label.id)}
onCheckedChange={() => toggleLabel(label.id)}
data-testid={`checkbox-label-${label.id}`}
id={`priority-${priority}`}
checked={selectedPriorities.includes(priority)}
onCheckedChange={() => togglePriority(priority)}
data-testid={`checkbox-priority-${priority}`}
/>
<label
htmlFor={`label-${label.id}`}
htmlFor={`priority-${priority}`}
className="flex items-center space-x-2 text-sm cursor-pointer flex-1"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: label.color }}
/>
<span>{label.name}</span>
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(priority)}`}
>
{t(`priority.${priority}`)}
</Badge>
</label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">{t('kanban.noLabelsAvailable')}</p>
)}
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</PopoverContent>
</Popover>
</div>
{/* Priorities Filter */}
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="justify-start"
data-testid="button-filter-priorities"
{/* Clear Filters */}
{hasActiveFilters && (
<>
<Separator orientation="vertical" className="h-6" />
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
data-testid="button-clear-filters"
className="text-muted-foreground hover:text-foreground"
>
<AlertTriangle className="w-4 h-4 mr-2" />
{t('kanban.priorities')}
{selectedPriorities.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{selectedPriorities.length}
</Badge>
)}
<X className="w-4 h-4 mr-2" />
{t('kanban.clearAll')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-48" align="start">
<div className="space-y-2">
<h4 className="text-sm font-medium">{t('kanban.filterByPriority')}</h4>
<Separator />
<div className="space-y-2">
{priorities.map((priority) => (
<div
key={priority}
className="flex items-center space-x-2"
>
<Checkbox
id={`priority-${priority}`}
checked={selectedPriorities.includes(priority)}
onCheckedChange={() => togglePriority(priority)}
data-testid={`checkbox-priority-${priority}`}
/>
<label
htmlFor={`priority-${priority}`}
className="flex items-center space-x-2 text-sm cursor-pointer flex-1"
>
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(priority)}`}
>
{t(`priority.${priority}`)}
</Badge>
</label>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
{/* Clear Filters */}
{hasActiveFilters && (
<>
<Separator orientation="vertical" className="h-6" />
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
data-testid="button-clear-filters"
className="text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4 mr-2" />
{t('kanban.clearAll')}
</Button>
</>
)}
</div>
</div>
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="mt-3 pt-3 border-t">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('kanban.activeFilters')}</span>
{selectedLabels.map((labelId) => {
const label = labels.find(l => l.id === labelId);
return label ? (
<Badge
key={labelId}
variant="outline"
className="text-xs"
data-testid={`active-filter-label-${labelId}`}
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="mt-3 pt-3 border-t">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">{t('kanban.activeFilters')}</span>
{selectedLabels.map((labelId) => {
const label = labels.find(l => l.id === labelId);
return label ? (
<Badge
key={labelId}
variant="outline"
className="text-xs"
data-testid={`active-filter-label-${labelId}`}
>
<div
className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: label.color }}
/>
{label.name}
<X
className="w-3 h-3 ml-1 cursor-pointer"
onClick={() => toggleLabel(labelId)}
/>
</Badge>
) : null;
})}
{selectedPriorities.map((priority) => (
<Badge
key={priority}
variant="outline"
className={`text-xs ${getPriorityColor(priority)}`}
data-testid={`active-filter-priority-${priority}`}
>
<div
className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: label.color }}
/>
{label.name}
<X
className="w-3 h-3 ml-1 cursor-pointer"
onClick={() => toggleLabel(labelId)}
{t(`priority.${priority}`)}
<X
className="w-3 h-3 ml-1 cursor-pointer"
onClick={() => togglePriority(priority)}
/>
</Badge>
) : null;
})}
{selectedPriorities.map((priority) => (
<Badge
key={priority}
variant="outline"
className={`text-xs ${getPriorityColor(priority)}`}
data-testid={`active-filter-priority-${priority}`}
>
{t(`priority.${priority}`)}
<X
className="w-3 h-3 ml-1 cursor-pointer"
onClick={() => togglePriority(priority)}
/>
</Badge>
))}
))}
</div>
</div>
</div>
)}
</Card>
)}
</Card>
</div>
{/* View Mode Tabs */}
@@ -425,11 +426,11 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
{columns.map((column) => {
const columnTasks = getTasksForColumn(column.status);
return (
<Card
<Card
key={column.id}
className="p-3 sm:p-4"
className="p-3 sm:p-4 min-w-0 overflow-hidden"
onDragOver={handleDragOver}
onDrop={() => handleDrop(column.status)}
data-testid={`column-${column.id}`}
@@ -442,7 +443,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{columnTasks.length}
</Badge>
</div>
<div className="space-y-2 sm:space-y-3 min-h-[150px] sm:min-h-[200px]">
{columnTasks.map((task) => (
<div
@@ -452,7 +453,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
onDragEnd={handleDragEnd}
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
>
<TaskCard
<TaskCard
task={task}
onStartTimer={() => onStartTimer?.(task.id)}
onStopTimer={() => onStopTimer?.(task.id)}
@@ -472,7 +473,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
/>
</div>
))}
{columnTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
{t('kanban.noTasksInColumn', { column: column.title.toLowerCase() })}
@@ -489,11 +490,11 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
{columns.map((column) => {
const columnTasks = getTasksForColumn(column.status);
return (
<Card
<Card
key={column.id}
className="p-3 sm:p-4"
className="p-3 sm:p-4 min-w-0 overflow-hidden"
onDragOver={handleDragOver}
onDrop={() => handleDrop(column.status)}
data-testid={`column-${column.id}`}
@@ -506,7 +507,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{columnTasks.length}
</Badge>
</div>
<div className="space-y-2 sm:space-y-3 min-h-[150px] sm:min-h-[200px]">
{columnTasks.map((task) => (
<div
@@ -516,7 +517,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
onDragEnd={handleDragEnd}
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
>
<TaskCard
<TaskCard
task={task}
onStartTimer={() => onStartTimer?.(task.id)}
onStopTimer={() => onStopTimer?.(task.id)}
@@ -536,7 +537,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
/>
</div>
))}
{columnTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
{t('kanban.noTasksInColumnForWeek', { column: column.title.toLowerCase() })}
@@ -553,11 +554,11 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
{columns.map((column) => {
const columnTasks = getTasksForColumn(column.status);
return (
<Card
<Card
key={column.id}
className="p-3 sm:p-4"
className="p-3 sm:p-4 min-w-0 overflow-hidden"
onDragOver={handleDragOver}
onDrop={() => handleDrop(column.status)}
data-testid={`column-${column.id}`}
@@ -570,7 +571,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
{columnTasks.length}
</Badge>
</div>
<div className="space-y-2 sm:space-y-3 min-h-[150px] sm:min-h-[200px]">
{columnTasks.map((task) => (
<div
@@ -580,7 +581,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
onDragEnd={handleDragEnd}
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
>
<TaskCard
<TaskCard
task={task}
onStartTimer={() => onStartTimer?.(task.id)}
onStopTimer={() => onStopTimer?.(task.id)}
@@ -600,7 +601,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate, o
/>
</div>
))}
{columnTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
{t('kanban.noTasksInColumnForMonth', { column: column.title.toLowerCase() })}
@@ -0,0 +1,96 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { Trophy, Star, TrendingUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
getLevelFromXP,
getNextLevelXP,
getLevelProgress,
getRankKey
} from "@/lib/gamification";
interface LevelDetailsModalProps {
isOpen: boolean;
onClose: () => void;
xp: number;
}
export function LevelDetailsModal({ isOpen, onClose, xp }: LevelDetailsModalProps) {
const { t } = useTranslation();
const level = getLevelFromXP(xp);
const nextLevelXP = getNextLevelXP(level);
const progress = getLevelProgress(xp);
const rankKey = getRankKey(level);
// Calculate XP needed for next level relative to current level start
// Note: For display, we usually show Total XP / Threshold or Current Level XP / needed
// Let's show Total XP / Next Threshold for clarity as typical in RPGs
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
{t("achievements.levelDetails")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center py-6 space-y-6">
{/* Level Badge */}
<div className="relative">
<div className="h-24 w-24 rounded-full bg-gradient-to-br from-yellow-100 to-yellow-50 dark:from-yellow-900/20 dark:to-yellow-800/20 flex items-center justify-center border-4 border-yellow-200 dark:border-yellow-700">
<span className="text-4xl font-bold text-yellow-600 dark:text-yellow-400">
{level}
</span>
</div>
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 px-3 py-1 bg-yellow-500 text-white text-xs font-bold rounded-full shadow-md whitespace-nowrap">
{t(`gamification.level`, { level })}
</div>
</div>
{/* Rank Title */}
<div className="text-center">
<h3 className="text-2xl font-bold text-foreground">
{t(`ranks.${rankKey}`)}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t("achievements.xpDescription")}
</p>
</div>
{/* Progress Section */}
<div className="w-full space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium text-muted-foreground">
{t("gamification.currentXP", { current: xp, next: nextLevelXP })}
</span>
<span className="font-bold text-primary">
{Math.round(progress)}%
</span>
</div>
<Progress value={progress} className="h-4" />
</div>
{/* Benefits / Rewards Placeholder */}
<div className="w-full bg-muted/50 rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm flex items-center gap-2">
<Star className="h-4 w-4 text-primary" />
{t("achievements.nextReward")}
</h4>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<TrendingUp className="h-8 w-8 text-green-500 p-1.5 bg-green-100 dark:bg-green-900/30 rounded-md" />
<span>{t('achievements.unlockReward', { level: level + 1 })}</span>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Play, Pause, Volume2, VolumeX, SkipForward, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ambientPlayer } from '@/lib/ambient-sounds';
import { useTranslation } from 'react-i18next';
import { triggerConfetti } from '@/lib/confetti';
import { playSuccessSound } from '@/lib/sounds';
interface PomodoroOverlayProps {
taskId: string | null;
taskTitle: string;
isOpen: boolean;
onClose: () => void;
onCompleteTask: (taskId: string) => void;
}
export default function PomodoroOverlay({ taskId, taskTitle, isOpen, onClose, onCompleteTask }: PomodoroOverlayProps) {
const { t } = useTranslation();
const [timeLeft, setTimeLeft] = useState(25 * 60);
const [isActive, setIsActive] = useState(false);
const [isSoundPlaying, setIsSoundPlaying] = useState(false);
const [mode, setMode] = useState<'focus' | 'break'>('focus');
const workerRef = useRef<Worker | null>(null);
// Initialize Worker
useEffect(() => {
if (typeof Worker !== 'undefined') {
workerRef.current = new Worker(new URL('../workers/timer.worker.ts', import.meta.url), { type: 'module' });
workerRef.current.onmessage = (e) => {
const { type, remaining } = e.data;
if (type === 'TICK') {
setTimeLeft(remaining);
} else if (type === 'COMPLETE') {
setIsActive(false);
playSuccessSound();
triggerConfetti(0.5, 0.5);
// If focus finished, propose break?
}
};
}
return () => {
workerRef.current?.terminate();
ambientPlayer.stop();
};
}, []);
// Timer Control
useEffect(() => {
if (isActive) {
workerRef.current?.postMessage({
command: 'START',
payload: { durationSeconds: timeLeft }
});
} else {
workerRef.current?.postMessage({ command: 'STOP' });
}
}, [isActive]);
const toggleTimer = () => setIsActive(!isActive);
const toggleSound = () => {
const playing = ambientPlayer.toggle();
setIsSoundPlaying(playing);
};
const skipTimer = () => {
setIsActive(false);
// Switch modes
if (mode === 'focus') {
setMode('break');
setTimeLeft(5 * 60);
} else {
setMode('focus');
setTimeLeft(25 * 60);
}
};
const handleComplete = () => {
if (taskId) {
onCompleteTask(taskId);
onClose();
setIsActive(false);
ambientPlayer.stop();
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// Circular Progress Calculation
const totalTime = mode === 'focus' ? 25 * 60 : 5 * 60;
const progress = ((totalTime - timeLeft) / totalTime) * 100;
const circleSize = 280;
const radius = 120;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center"
>
<Button
variant="ghost"
size="icon"
className="absolute top-6 right-6 h-12 w-12 rounded-full hover:bg-muted"
onClick={() => {
setIsActive(false);
ambientPlayer.stop();
onClose();
}}
>
<X className="w-6 h-6" />
</Button>
<div className="text-center space-y-8 max-w-md w-full px-4">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tight">{mode === 'focus' ? 'Focus Time' : 'Break Time'}</h2>
<p className="text-xl text-muted-foreground truncate">{taskTitle}</p>
</div>
<div className="relative flex items-center justify-center">
{/* SVG Ring */}
<svg width={circleSize} height={circleSize} className="transform -rotate-90">
{/* Background Circle */}
<circle
cx={circleSize / 2}
cy={circleSize / 2}
r={radius}
stroke="currentColor"
strokeWidth="12"
fill="transparent"
className="text-muted/20"
/>
{/* Progress Circle */}
<circle
cx={circleSize / 2}
cy={circleSize / 2}
r={radius}
stroke="currentColor"
strokeWidth="12"
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
className={`transition-all duration-1000 ease-linear ${mode === 'focus' ? 'text-violet-600' : 'text-green-500'}`}
/>
</svg>
{/* Time Display */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-6xl font-mono font-bold tracking-tighter tabular-nums">
{formatTime(timeLeft)}
</span>
</div>
</div>
<div className="flex items-center justify-center gap-6">
<Button
size="icon"
variant="outline"
className="h-14 w-14 rounded-full border-2"
onClick={toggleSound}
>
{isSoundPlaying ? <Volume2 className="w-6 h-6" /> : <VolumeX className="w-6 h-6" />}
</Button>
<Button
size="icon"
className={`h-20 w-20 rounded-full shadow-lg transition-transform hover:scale-105 active:scale-95 ${isActive ? 'bg-orange-500 hover:bg-orange-600' : 'bg-primary hover:bg-primary/90'}`}
onClick={toggleTimer}
>
{isActive ? <Pause className="w-8 h-8 fill-current" /> : <Play className="w-8 h-8 fill-current ml-1" />}
</Button>
<Button
size="icon"
variant="outline"
className="h-14 w-14 rounded-full border-2"
onClick={skipTimer}
>
<SkipForward className="w-6 h-6" />
</Button>
</div>
{taskId && mode === 'focus' && (
<Button
variant="ghost"
className="mt-8 text-muted-foreground hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={handleComplete}
>
<CheckCircle className="w-5 h-5 mr-2" />
Mark Task Complete
</Button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
}
File diff suppressed because it is too large Load Diff
+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();
}
+123
View File
@@ -0,0 +1,123 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { User } from "@shared/schema";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Loader2, Share2, Search, Users } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTranslation } from "react-i18next";
interface ShareAccessModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ShareAccessModal({ open, onOpenChange }: ShareAccessModalProps) {
const { toast } = useToast();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const { data: users, isLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: ["/api/users/search", debouncedQuery],
queryFn: async () => {
if (debouncedQuery.length < 2) return [];
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
if (!res.ok) throw new Error("Failed to search users");
return res.json();
},
enabled: debouncedQuery.length >= 2,
});
const shareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("POST", `/api/users/share-all`, { targetUserId });
return res.json();
},
onSuccess: () => {
toast({ title: t('share.successAccess'), description: t('share.successAccessDesc') });
onOpenChange(false);
},
onError: () => {
toast({ title: t('share.errorAccess'), variant: "destructive" });
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-lg sm:max-w-md p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
{t('share.allTasksTitle')}
</DialogTitle>
<DialogDescription>
{t('share.allTasksDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('share.searchPlaceholder')}
className="pl-9"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
// Simple manual debounce simulation for UI response, real query uses effect or direct set
// For consistency with ShareTaskModal, we'll just set it
}}
onKeyUp={() => setDebouncedQuery(searchQuery)}
/>
</div>
<ScrollArea className="h-[200px] rounded-md border p-2">
{isLoading && debouncedQuery.length >= 2 ? (
<div className="flex justify-center p-4">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : users && users.length > 0 ? (
<div className="space-y-2">
{users.map((user) => (
<div key={user.id} className="flex items-center justify-between p-2 hover:bg-muted/50 rounded-lg transition-colors">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium">{user.username}</span>
</div>
<Button
size="sm"
onClick={() => shareMutation.mutate(user.id)}
disabled={shareMutation.isPending}
>
{shareMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Share2 className="w-4 h-4 mr-2" />
)}
{t('share.grantAccess')}
</Button>
</div>
))}
</div>
) : debouncedQuery.length >= 2 ? (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.noUsers')}
</div>
) : (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.typeToSearch')}
</div>
)}
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@shared/schema';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { X, UserPlus, Users } from 'lucide-react';
interface ShareLabelModalProps {
label: Label | null;
open: boolean;
onOpenChange: (open: boolean) => void;
currentUser: any;
}
interface LabelShare {
userId: string;
username: string;
permission: 'read' | 'write' | 'admin';
}
export function ShareLabelModal({ label, open, onOpenChange, currentUser }: ShareLabelModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [newUsername, setNewUsername] = useState('');
const [newPermission, setNewPermission] = useState<'read' | 'write'>('read');
const { data: shares = [], isLoading } = useQuery<LabelShare[]>({
queryKey: [`/api/labels/${label?.id}/share`],
enabled: !!label && open,
});
const shareMutation = useMutation({
mutationFn: (data: { username: string; permission: string }) =>
apiRequest('POST', `/api/labels/${label?.id}/share`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/labels/${label?.id}/share`] });
setNewUsername('');
toast({ title: t('settings.labels.shareSuccess') });
},
onError: (e: Error) => {
toast({
title: t('settings.labels.shareFailed'),
description: e.message,
variant: 'destructive'
});
}
});
const unshareMutation = useMutation({
mutationFn: (userId: string) =>
apiRequest('DELETE', `/api/labels/${label?.id}/share/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/labels/${label?.id}/share`] });
toast({ title: t('settings.labels.unshareSuccess') });
},
});
const handleShare = (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername.trim()) return;
shareMutation.mutate({ username: newUsername, permission: newPermission });
};
if (!label) return null;
// Only creator can share (enforced by backend, but UI check is good too)
const isCreator = label.creatorId === currentUser?.id;
const isSystem = label.creatorId === null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
{t('settings.labels.shareLabel')}: {label.name}
</DialogTitle>
<DialogDescription>
{t('settings.labels.shareDescription')}
</DialogDescription>
</DialogHeader>
{isSystem ? (
<div className="p-4 bg-muted rounded-md text-center text-sm text-muted-foreground">
{t('settings.labels.systemLabelNoShare')}
</div>
) : !isCreator ? (
<div className="p-4 bg-yellow-500/10 text-yellow-500 border border-yellow-500/20 rounded-md text-center text-sm">
{t('settings.labels.onlyOwnerCanShare')}
<p className="text-xs text-muted-foreground mt-1">
{t('settings.labels.owner')}: {t('common.unknown')}
</p>
</div>
) : (
<div className="space-y-6">
{/* Add Collaborator Form */}
<form onSubmit={handleShare} className="flex gap-2 items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">{t('settings.labels.addCollaborator')}</label>
<div className="flex gap-2">
<Input
placeholder={t('settings.labels.usernamePlaceholder')}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
/>
<Select
value={newPermission}
onValueChange={(v: 'read' | 'write') => setNewPermission(v)}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">{t('settings.labels.permRead')}</SelectItem>
<SelectItem value="write">{t('settings.labels.permWrite')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" disabled={!newUsername || shareMutation.isPending}>
<UserPlus className="w-4 h-4" />
</Button>
</form>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">{t('settings.labels.accessList')}</h4>
{isLoading ? (
<div className="text-sm text-center py-4">{t('common.loading')}</div>
) : shares.length === 0 ? (
<div className="text-sm text-center py-4 text-muted-foreground bg-muted/30 rounded-md border border-dashed">
{t('settings.labels.noCollaborators')}
</div>
) : (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{shares.map((share) => (
<div key={share.userId} className="flex items-center justify-between p-2 rounded-md bg-muted/50 border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
{share.username.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{share.username}</p>
<p className="text-xs text-muted-foreground capitalize">{t(`settings.labels.perm${share.permission.charAt(0).toUpperCase() + share.permission.slice(1)}`)}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => unshareMutation.mutate(share.userId)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+224
View File
@@ -0,0 +1,224 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { User } from "@shared/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Loader2, Share2, Check, Search } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTranslation } from "react-i18next";
interface ShareTaskModalProps {
taskId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ShareTaskModal({ taskId, open, onOpenChange }: ShareTaskModalProps) {
const { toast } = useToast();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [userToConfirm, setUserToConfirm] = useState<{ id: string; username: string } | null>(null);
const { data: users, isLoading: searchLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: ["/api/users/search", debouncedQuery],
queryFn: async () => {
if (debouncedQuery.length < 2) return [];
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
if (!res.ok) throw new Error("Failed to search users");
return res.json();
},
enabled: debouncedQuery.length >= 2,
});
const { data: sharedUsers, refetch: refetchShared, isLoading: sharedLoading } = useQuery<Pick<User, "id" | "username">[]>({
queryKey: [`/api/tasks/${taskId}/shared-users`], // Needs to be dependent on taskId
enabled: open, // Only fetch when open
});
const shareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("POST", `/api/tasks/${taskId}/share`, { targetUserId });
return res.json();
},
onSuccess: () => {
toast({ title: t('share.successTask') });
setUserToConfirm(null);
setSearchQuery("");
setDebouncedQuery(""); // Clear search
refetchShared(); // Update list
},
onError: () => {
toast({ title: t('share.errorTask'), variant: "destructive" });
},
});
const unshareMutation = useMutation({
mutationFn: async (targetUserId: string) => {
const res = await apiRequest("DELETE", `/api/tasks/${taskId}/share/${targetUserId}`);
return res.json();
},
onSuccess: () => {
toast({ title: t('share.removeSuccess') });
refetchShared();
},
onError: () => {
toast({ title: t('share.errorTask'), variant: "destructive" });
},
});
// Confirmation State
const confirmShare = () => {
if (userToConfirm) {
shareMutation.mutate(userToConfirm.id);
}
};
return (
<Dialog open={open} onOpenChange={(val) => {
if (!val) {
setUserToConfirm(null); // Reset on close
setSearchQuery("");
setDebouncedQuery("");
}
onOpenChange(val);
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="w-5 h-5" />
{t('share.taskTitle')}
</DialogTitle>
</DialogHeader>
{userToConfirm ? (
<div className="space-y-4 py-4">
<div className="bg-muted/50 p-4 rounded-lg text-center">
<Avatar className="h-16 w-16 mx-auto mb-2">
<AvatarFallback className="text-lg">{userToConfirm.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<h3 className="font-semibold text-lg">{userToConfirm.username}</h3>
<p className="text-muted-foreground mt-2">
{t('share.confirmShareDesc', { username: userToConfirm.username })}
</p>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setUserToConfirm(null)}>
{t('share.cancel')}
</Button>
<Button onClick={confirmShare} disabled={shareMutation.isPending}>
{shareMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{t('share.confirm')}
</Button>
</div>
</div>
) : (
<div className="space-y-6 py-4">
{/* Current Shared Users */}
<div>
<h4 className="text-sm font-medium mb-3">{t('share.sharedWith')}</h4>
<div className="space-y-2">
{sharedLoading ? (
<div className="flex justify-center py-2"><Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /></div>
) : sharedUsers && sharedUsers.length > 0 ? (
<div className="max-h-[150px] overflow-y-auto space-y-2 pr-1">
{sharedUsers.map(user => (
<div key={user.id} className="flex items-center justify-between p-2 bg-muted/40 rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-6 w-6">
<AvatarFallback className="text-xs">{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="text-sm">{user.username}</span>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => unshareMutation.mutate(user.id)}
disabled={unshareMutation.isPending}
>
<span className="text-xs">{t('share.unshare')}</span>
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground italic pl-1">Not shared with anyone yet.</p>
)}
</div>
</div>
<div className="relative">
<div className="absolute inset-x-0 border-t my-2" />
</div>
{/* Search to Add */}
<div className="space-y-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('share.searchPlaceholder')}
className="pl-9"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
// Simple manual debounce for now
}}
onKeyUp={(e) => {
// Debounce could be improved, but sufficient
setDebouncedQuery(searchQuery);
}}
/>
</div>
<ScrollArea className="h-[180px] rounded-md border p-2">
{searchLoading && debouncedQuery.length >= 2 ? (
<div className="flex justify-center p-4">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : users && users.length > 0 ? (
<div className="space-y-1">
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-2 hover:bg-muted cursor-pointer rounded-lg transition-colors"
onClick={() => setUserToConfirm(user)}
>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium">{user.username}</span>
</div>
<Button size="icon" variant="ghost" className="h-8 w-8">
<Share2 className="w-4 h-4 text-muted-foreground" />
</Button>
</div>
))}
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).length === 0 && (
<div className="text-center p-4 text-sm text-muted-foreground">
No new users found to share with.
</div>
)}
</div>
) : debouncedQuery.length >= 2 ? (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.noUsers')}
</div>
) : (
<div className="text-center p-4 text-sm text-muted-foreground">
{t('share.typeToSearch')}
</div>
)}
</ScrollArea>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
+622 -189
View File
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -9,9 +10,32 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle } from "lucide-react";
import { Task, Label } from '@shared/schema';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X, Lock, CornerDownRight, Share2, Users } from "lucide-react";
import { Task, Label, User } from '@shared/schema';
import { ShareTaskModal } from './ShareTaskModal';
import { useQuery } from '@tanstack/react-query';
import { Checkbox } from "@/components/ui/checkbox";
import { motion, PanInfo, useAnimation } from 'framer-motion';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { triggerConfetti } from "@/lib/confetti";
import { playSuccessSound } from "@/lib/sounds";
import { simulateAIDecomposition } from "@/lib/ai-simulator";
import { Wand2, Loader2 } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
interface TaskCardProps {
task: Task;
@@ -20,11 +44,117 @@ interface TaskCardProps {
onEdit?: () => void;
onDelete?: () => void;
onStatusChange?: (status: Task['status']) => void;
onUpdate?: (updates: Partial<Task>) => void;
onAutoSchedule?: () => void;
isDragging?: boolean;
}
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, isDragging }: TaskCardProps) {
function SharedTaskIcon({ task }: { task: Task }) {
const { t } = useTranslation();
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
const isOwner = user?.id === task.userId;
// If owner, fetch shared users
const { data: sharedUsers } = useQuery<any[]>({
queryKey: [`/api/tasks/${task.id}/shared-users`],
enabled: !!isOwner && !!task.id,
retry: false
});
if (!user) return null;
// Case 1: Owner and task is shared
if (isOwner && sharedUsers && sharedUsers.length > 0) {
const names = sharedUsers.map(u => u.username).join(", ");
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-indigo-100 dark:bg-indigo-900 cursor-help">
<Share2 className="w-3 h-3 text-indigo-600 dark:text-indigo-300" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('share.sharedWith')}: {names}</p>
</TooltipContent>
</Tooltip>
);
}
// Case 2: Not owner (Shared WITH me)
if (!isOwner && task.userId) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-orange-100 dark:bg-orange-900 cursor-help">
<Users className="w-3 h-3 text-orange-600 dark:text-orange-300" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('share.sharedWithMe') || "Shared with me"}</p>
</TooltipContent>
</Tooltip>
);
}
return null;
}
function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void }) {
const { t } = useTranslation();
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
// Only owner can share
if (user?.id !== task.userId) return null;
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onShare();
}}
>
<Share2 className="w-4 h-4 mr-2" />
{t('share.taskTitle')}
</DropdownMenuItem>
);
}
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, onAutoSchedule, isDragging }: TaskCardProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const handleAIMagic = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isAnalyzing) return;
setIsAnalyzing(true);
try {
const aiContent = await simulateAIDecomposition(task.title);
const newDescription = (task.description || '') + aiContent;
onUpdate?.({ description: newDescription });
triggerConfetti(0.5, 0.5); // Small burst for "Magic"
playSuccessSound();
} catch (err) {
console.error(err);
} finally {
setIsAnalyzing(false);
}
};
const { data: allTasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
staleTime: 5 * 60 * 1000,
});
const blockingTasks = task.dependencies?.map(dId => allTasks.find(t => t.id === dId)).filter(t => t && t.status !== 'done') || [];
const isBlocked = blockingTasks.length > 0;
const subtasks = allTasks.filter(t => t.parentTaskId === task.id);
const completedSubtasks = subtasks.filter(t => t.status === 'done');
// Fetch labels to get the label color
const { data: labels = [] } = useQuery<Label[]>({
@@ -74,202 +204,505 @@ export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDe
return `${hours}h ${mins}m`;
};
return (
<Card
className={`p-4 hover-elevate active-elevate-2 transition-all duration-200 relative cursor-pointer ${
isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
}`}
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
} : {}}
data-testid={`card-task-${task.id}`}
onClick={() => {
onEdit?.();
console.log(`Task card clicked: ${task.title}`);
}}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm leading-tight truncate" data-testid={`text-task-title-${task.id}`}>
{task.title}
</h3>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-3 flex-wrap">
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(task.priority)}`}
data-testid={`badge-priority-${task.id}`}
>
{t(`priority.${task.priority}`)}
</Badge>
<Badge
variant="outline"
className={`text-xs ${getStatusColor(task.status)}`}
data-testid={`badge-status-${task.id}`}
>
{t(`status.${task.status}`)}
</Badge>
{task.dueDate && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
<span data-testid={`text-due-date-${task.id}`}>
{task.dueDate.toLocaleDateString()}
</span>
</div>
)}
</div>
{(task.timeTracked > 0 || task.isTracking) && (
<div className="flex items-center gap-1 mt-2 text-xs">
<Clock className={`w-3 h-3 ${task.isTracking ? 'text-primary animate-pulse' : 'text-muted-foreground'}`} />
<span
data-testid={`text-time-tracked-${task.id}`}
className={task.isTracking ? 'text-primary font-medium' : 'text-muted-foreground'}
>
{task.timeTracked > 0 ? formatTime(task.timeTracked) : '0m'}
{task.isTracking && ` (${t('taskCard.running')})`}
</span>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
/* AI Follow-up Check */
const checkFollowUp = async () => {
// Fire and forget - don't block UI
try {
const res = await apiRequest("POST", "/api/ai/analyze-completion", { taskId: task.id });
if (res.ok) {
const data = await res.json();
if (data.needed && data.title) {
toast({
title: t('ai.followUpSuggestion', 'Follow-up Suggested'),
description: `${data.title} - ${data.description || ''}`,
action: (
<Button
size="icon"
variant="ghost"
onClick={(e) => e.stopPropagation()}
data-testid={`button-menu-${task.id}`}
className="w-6 h-6"
variant="outline"
size="sm"
onClick={async (e) => {
e.stopPropagation(); // prevent toast click from doing generic things
// Create the task immediately
try {
await apiRequest("POST", "/api/tasks", {
title: data.title,
description: data.description,
status: "todo",
priority: "medium",
labelId: task.labelId, // Inherit label
dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // Tomorrow
});
toast({ title: t('ai.taskCreated', 'Follow-up task created!') });
// Invalidate queries to show new task
// queryClient.invalidateQueries... (Need queryClient access)
// Since we don't have queryClient here easily without import useQueryClient
// We can just reload or rely on auto-refetch
window.location.reload(); // Crude but effective for now, or useQueryClient
} catch (err) {
toast({ title: t('ai.errorCreating', 'Failed to create task'), variant: "destructive" });
}
}}
>
<MoreHorizontal className="w-3 h-3" />
{t('common.create', 'Create')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
),
duration: 8000,
});
}
}
} catch (e) {
console.error("Follow-up check failed", e);
}
};
const handleToggleComplete = (e: React.MouseEvent) => {
e.stopPropagation();
if (isBlocked) return;
if (task.status === 'done') {
onStatusChange?.('todo');
} else {
triggerConfetti(e.clientX / window.innerWidth, e.clientY / window.innerHeight);
playSuccessSound();
onStatusChange?.('done');
// Trigger AI check
checkFollowUp();
}
};
const controls = useAnimation();
const handleDragEnd = async (event: any, info: PanInfo) => {
if (info.offset.x > 100) {
if (isBlocked) {
controls.start({ x: 0 });
return;
}
// Swiped right -> Complete
triggerConfetti(0.5, 0.5);
playSuccessSound();
onStatusChange?.('done');
await controls.start({ x: 500, opacity: 0 });
} else if (info.offset.x < -100 && onDelete) {
// Swiped left -> Delete (optional, maybe just shake for now)
onDelete();
} else {
controls.start({ x: 0 });
}
};
/* Retrieve parent task if this is a subtask */
const parentTask = task.parentTaskId
? allTasks.find(t => String(t.id) === String(task.parentTaskId))
: null;
return (
<div style={{ overflowX: 'clip' }}>
<motion.div
drag="x"
dragConstraints={{ left: -50, right: 150 }} // Limit swipe distance
dragElastic={0.1}
onDragEnd={handleDragEnd}
animate={controls}
whileDrag={{ scale: 1.02, cursor: 'grabbing' }}
className="touch-pan-y" // Allow vertical scroll, horizontal swipe
style={{ overscrollBehaviorX: 'none' }}
>
<ContextMenu>
<ContextMenuTrigger asChild>
<Card
className={`p-5 hover-elevate active-elevate-2 transition-all duration-300 relative hover:-translate-y-1 hover:shadow-xl ${isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
} ${task.status === 'done' ? 'opacity-60' : ''} ${parentTask ? 'border-l-4 border-l-indigo-400 bg-indigo-50/10' : ''}`}
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid',
borderLeftWidth: parentTask ? '4px' : '2px', // Make left border thicker if subtask
borderLeftColor: parentTask ? '#818cf8' : taskLabel.color // Indigo for subtask
} : {}}
data-testid={`card-task-${task.id}`}
>
<div className="flex items-start gap-3">
{/* Checkbox for quick completion */}
<Tooltip>
<TooltipTrigger asChild>
<Checkbox
checked={task.status === 'done'}
onClick={handleToggleComplete}
data-testid={`checkbox-complete-${task.id}`}
className="mt-0.5"
disabled={isBlocked}
/>
</TooltipTrigger>
<TooltipContent>
{isBlocked ? (
<p className="text-destructive font-medium">
{t('taskCard.blockedBy', { tasks: blockingTasks.map(t => t?.title).join(", ") })}
</p>
) : (
<p>{t('taskCard.toggleComplete')}</p>
)}
</TooltipContent>
</Tooltip>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
onEdit?.();
console.log(`Edit task: ${task.title}`);
console.log(`Task card clicked: ${task.title}`);
}}
data-testid={`menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{task.status !== 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
data-testid={`menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</DropdownMenuItem>
)}
{task.status === 'todo' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Start working on: ${task.title}`);
}}
data-testid={`menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</DropdownMenuItem>
)}
{task.status === 'inProgress' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
console.log(`Move back to todo: ${task.title}`);
}}
data-testid={`menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</DropdownMenuItem>
)}
{task.status === 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Reopen task: ${task.title}`);
}}
data-testid={`menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
console.log(`Delete task: ${task.title}`);
}}
className="text-destructive"
data-testid={`menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
size="icon"
variant={task.isTracking ? "default" : "outline"}
{/* Parent Task Link rendered in title now */}
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
{parentTask ? (
<span className="flex items-center gap-1 min-w-0">
<span className="text-muted-foreground font-normal truncate shrink min-w-0">{parentTask.title}</span>
<span className="text-muted-foreground shrink-0"></span>
<span className="truncate shrink min-w-0">{task.title}</span>
</span>
) : (
task.title
)}
</h3>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-3 flex-wrap">
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(task.priority)}`}
data-testid={`badge-priority-${task.id}`}
>
{t(`priority.${task.priority}`)}
</Badge>
<Badge
variant="outline"
className={`text-xs ${getStatusColor(task.status)}`}
data-testid={`badge-status-${task.id}`}
>
{t(`status.${task.status}`)}
</Badge>
{/* Shared Status Icon */}
<SharedTaskIcon task={task} />
{(task.dueDate || task.startDate) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
<span data-testid={`text-due-date-${task.id}`}>
{task.startDate ? `${new Date(task.startDate).toLocaleDateString()} - ` : ''}
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : ''}
</span>
</div>
)}
{task.energyLevel && task.energyLevel !== 'medium' && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
{t(`energy.${task.energyLevel}`)}
</Badge>
)}
{task.estimatedDuration && (
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
{formatTime(task.estimatedDuration)}
</Badge>
)}
{subtasks.length > 0 && (
<Badge variant="secondary" className="text-xs">
{completedSubtasks.length}/{subtasks.length} Subtasks
</Badge>
)}
{isBlocked && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs border-destructive text-destructive gap-1 animate-pulse">
<Lock className="w-3 h-3" />
Blocked
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Waiting for: {blockingTasks.map(t => t?.title).join(", ")}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{(task.timeTracked > 0 || task.isTracking) && (
<div className="flex items-center gap-1 mt-2 text-xs">
<Clock className={`w-3 h-3 ${task.isTracking ? 'text-primary animate-pulse' : 'text-muted-foreground'}`} />
<span
data-testid={`text-time-tracked-${task.id}`}
className={task.isTracking ? 'text-primary font-medium' : 'text-muted-foreground'}
>
{task.timeTracked > 0 ? formatTime(task.timeTracked) : '0m'}
{task.isTracking && ` (${t('taskCard.running')})`}
</span>
</div>
)}
</div>
<div className="flex items-start gap-1">
{/* Quick delete button */}
{onDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
data-testid={`button-delete-${task.id}`}
className="w-6 h-6 text-muted-foreground hover:text-destructive"
>
<X className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.quickDelete')}</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => e.stopPropagation()}
data-testid={`button-menu-${task.id}`}
className="w-6 h-6"
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit?.();
console.log(`Edit task: ${task.title}`);
}}
data-testid={`menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</DropdownMenuItem>
{onAutoSchedule && !task.dueDate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onAutoSchedule();
}}
className="text-indigo-600 dark:text-indigo-400"
data-testid={`menu-schedule-${task.id}`}
>
<Wand2 className="w-4 h-4 mr-2" />
{t('taskDetails.autoSchedule', 'Auto-Schedule')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
{/* Share Option - Check if owner */}
<SharedMenuItem task={task} onShare={() => setIsShareModalOpen(true)} />
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
if (isBlocked) return;
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
disabled={isBlocked}
data-testid={`menu-complete-${task.id}`}
>
{isBlocked ? <Lock className="w-4 h-4 mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
{t('taskCard.markAsDone')}
</DropdownMenuItem>
{task.status === 'todo' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Start working on: ${task.title}`);
}}
data-testid={`menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</DropdownMenuItem>
)}
{task.status === 'inProgress' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
console.log(`Move back to todo: ${task.title}`);
}}
data-testid={`menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</DropdownMenuItem>
)}
{task.status === 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Reopen task: ${task.title}`);
}}
data-testid={`menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
console.log(`Delete task: ${task.title}`);
}}
className="text-destructive"
data-testid={`menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
data-testid={`context-menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`button-timer-${task.id}`}
className="w-6 h-6"
data-testid={`context-menu-timer-${task.id}`}
>
{task.isTracking ? <Pause className="w-3 h-3" /> : <Play className="w-3 h-3" />}
</Button>
</div>
</div>
</Card>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</ContextMenuItem>
<ContextMenuSeparator />
{task.status !== 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
}}
data-testid={`context-menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</ContextMenuItem>
)}
{task.status === 'todo' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
}}
data-testid={`context-menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</ContextMenuItem>
)}
{task.status === 'inProgress' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
}}
data-testid={`context-menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</ContextMenuItem>
)}
{task.status === 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
}}
data-testid={`context-menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive"
data-testid={`context-menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
<ShareTaskModal
taskId={task.id}
open={isShareModalOpen}
onOpenChange={setIsShareModalOpen}
/>
</motion.div>
</div>
);
}
+356 -84
View File
@@ -7,9 +7,15 @@ import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { CalendarIcon, Plus, Tag } from 'lucide-react';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { CalendarIcon, Plus, Tag, Check, Link2, Info } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { parseTaskInput } from '../lib/nlp';
import { Sparkles } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface TaskCreationModalProps {
isOpen: boolean;
@@ -22,38 +28,79 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [energyLevel, setEnergyLevel] = useState<'low' | 'medium' | 'high'>('medium');
const [estimatedDuration, setEstimatedDuration] = useState<number | undefined>();
const [dueDate, setDueDate] = useState<Date | undefined>();
const [startDate, setStartDate] = useState<Date | undefined>();
const [labelId, setLabelId] = useState<string | undefined>();
const [dependencies, setDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [isStartDateOpen, setIsStartDateOpen] = useState(false);
const [isRecurring, setIsRecurring] = useState(false);
const [recurrenceInterval, setRecurrenceInterval] = useState<string | undefined>();
const [recurrenceIntervalValue, setRecurrenceIntervalValue] = useState(1);
const [recurrenceEnd, setRecurrenceEnd] = useState<Date | undefined>();
const [error, setError] = useState<string | null>(null);
// Fetch labels
const { data: labels = [] } = useQuery<Label[]>({
const { data: labelsData } = useQuery<Label[]>({
queryKey: ['/api/labels'],
});
const labels = labelsData ?? [];
const { data: tasksData } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
const tasks = tasksData ?? [];
const handleSave = () => {
if (!title.trim()) return;
if (!title.trim()) {
setError(t('taskCreation.titleRequired') || 'Title is required');
return;
}
const newTask: Partial<Task> = {
title: title.trim(),
description: description.trim() || undefined,
priority,
energyLevel,
estimatedDuration,
isRecurring,
recurrenceInterval,
recurrenceIntervalValue,
recurrenceEnd,
startDate,
dueDate,
labelId,
dependencies,
status: 'todo',
timeTracked: 0,
isTracking: false
};
onSave(newTask);
console.log('New task created:', newTask);
// Reset form
setTitle('');
setDescription('');
setPriority('medium');
setDueDate(undefined);
setStartDate(undefined);
setIsRecurring(false);
setRecurrenceInterval(undefined);
setRecurrenceIntervalValue(1);
setRecurrenceEnd(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
onClose();
};
@@ -69,33 +116,61 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
setDescription('');
setPriority('medium');
setDueDate(undefined);
setStartDate(undefined);
setIsRecurring(false);
setRecurrenceInterval(undefined);
setRecurrenceIntervalValue(1);
setRecurrenceEnd(undefined);
setLabelId(undefined);
setDependencies([]);
setError(null);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md mx-4">
<Dialog open={isOpen} onOpenChange={handleClose} >
<DialogContent className="w-[95vw] max-w-lg sm:max-w-md max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-4 h-4" />
{t('taskCreation.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder={t('taskCreation.titlePlaceholder')}
placeholder={t('smartTask.placeholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(e) => {
setTitle(e.target.value);
const parsed = parseTaskInput(e.target.value);
if (parsed.priority) setPriority(parsed.priority);
if (parsed.dueDate) setDueDate(parsed.dueDate);
if (parsed.labelName) {
const foundLabel = labels.find(l => l.name.toLowerCase() === parsed.labelName?.toLowerCase());
if (foundLabel) setLabelId(foundLabel.id);
}
if (error) setError(null);
}}
onKeyDown={handleKeyDown}
className="text-base"
data-testid="input-task-title"
autoFocus
/>
<div className="flex items-center gap-1 mt-1">
{title.includes('!') || title.includes('#') ||
title.toLowerCase().includes('tomorrow') || title.toLowerCase().includes('morgen') ||
title.toLowerCase().includes('today') || title.toLowerCase().includes('heute') ? (
<div className="bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-300 text-xs px-2 py-0.5 rounded-full flex items-center gap-1 animate-pulse">
<Sparkles className="w-3 h-3" />
{t('taskCreation.smartInputActive')}
</div>
) : null}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
</div>
<div>
<Textarea
placeholder={t('taskCreation.descriptionPlaceholder')}
@@ -106,87 +181,284 @@ export default function TaskCreationModal({ isOpen, onClose, onSave }: TaskCreat
data-testid="input-task-description"
/>
</div>
<div className="flex gap-3">
<div className="flex-1">
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
<SelectTrigger data-testid="select-task-priority">
<SelectValue placeholder={t('taskCreation.priority')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('priority.low')}</SelectItem>
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
<SelectItem value="high">{t('priority.high')}</SelectItem>
</SelectContent>
</Select>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Select value={priority} onValueChange={(value: 'low' | 'medium' | 'high') => setPriority(value)}>
<SelectTrigger data-testid="select-task-priority">
<SelectValue placeholder={t('taskCreation.priority')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('priority.low')}</SelectItem>
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
<SelectItem value="high">{t('priority.high')}</SelectItem>
</SelectContent>
</Select>
<Select value={energyLevel} onValueChange={(value: 'low' | 'medium' | 'high') => setEnergyLevel(value)}>
<SelectTrigger>
<div className="flex items-center gap-2">
<SelectValue placeholder={t('taskCreation.energy')} />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help" onClick={(e) => e.stopPropagation()}>
<p className="sr-only">Help</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">{t('taskCreation.energyHelp', 'Match tasks to your energy levels. Low energy tasks are good for slumps, High energy tasks require focus.')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('gamification.energy.low')}</SelectItem>
<SelectItem value="medium">{t('gamification.energy.medium')}</SelectItem>
<SelectItem value="high">{t('gamification.energy.high')}</SelectItem>
</SelectContent>
</Select>
<div className="col-span-2 sm:col-span-1 space-y-2">
<label className="text-xs font-medium text-muted-foreground">{t('taskCreation.duration')} ({t('planning.minutes')})</label>
<div className="flex items-center gap-2">
<Input
type="number"
min="0"
step="5"
placeholder={t('planning.durationPlaceholder')}
value={estimatedDuration || ''}
onChange={(e) => setEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
className="w-full"
data-testid="input-duration"
/>
</div>
<div className="flex flex-wrap gap-1">
{[15, 30, 45, 60, 90, 120].map((mins) => (
<Badge
key={mins}
variant={estimatedDuration === mins ? "default" : "outline"}
className="cursor-pointer text-[10px] px-1.5 py-0.5"
onClick={() => setEstimatedDuration(mins)}
>
{mins}m
</Badge>
))}
</div>
</div>
<div className="flex-1">
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
<SelectTrigger data-testid="select-task-label">
<SelectValue placeholder={t('taskCreation.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={labelId || 'none'} onValueChange={(value) => setLabelId(value === 'none' ? undefined : value)}>
<SelectTrigger data-testid="select-task-label">
<SelectValue placeholder={t('taskCreation.label')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskCreation.noLabel')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: label.color }}
/>
{label.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="col-span-1">
<Popover open={isStartDateOpen} onOpenChange={setIsStartDateOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!startDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? startDate.toLocaleDateString() : t('taskCreation.startDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={(date) => {
setStartDate(date);
setIsStartDateOpen(false);
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<div className="col-span-1">
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dueDate && "text-muted-foreground"
)}
data-testid="button-due-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={(date) => {
setDueDate(date);
setIsCalendarOpen(false);
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* Recurrence Selection */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium flex-none">{t('taskCreation.recurrence.label')}</label>
<Select value={recurrenceInterval || 'none'} onValueChange={(v) => {
setRecurrenceInterval(v === 'none' ? undefined : v);
setIsRecurring(v !== 'none');
}}>
<SelectTrigger className="h-8" data-testid="recurrence-trigger">
<SelectValue placeholder={t('taskCreation.recurrence.none')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" data-testid="recurrence-option-none">{t('taskCreation.recurrence.none')}</SelectItem>
<SelectItem value="daily" data-testid="recurrence-option-daily">{t('taskCreation.recurrence.daily')}</SelectItem>
<SelectItem value="weekly" data-testid="recurrence-option-weekly">{t('taskCreation.recurrence.weekly')}</SelectItem>
<SelectItem value="monthly" data-testid="recurrence-option-monthly">{t('taskCreation.recurrence.monthly')}</SelectItem>
<SelectItem value="yearly" data-testid="recurrence-option-yearly">{t('taskCreation.recurrence.yearly')}</SelectItem>
</SelectContent>
</Select>
{isRecurring && (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-2">
<span className="text-sm">{t('taskCreation.recurrence.every')}</span>
<Input
type="number"
className="w-16 h-8"
min={1}
value={recurrenceIntervalValue}
onChange={(e) => setRecurrenceIntervalValue(parseInt(e.target.value) || 1)}
/>
<span className="text-sm">
{recurrenceInterval && t(`taskCreation.recurrence.units.${recurrenceInterval}`)}
</span>
</div>
)}
</div>
{/* Dependencies Selector */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<Link2 className="w-4 h-4" />
{t('dependencies.label')}
</label>
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2"
data-testid="button-due-date"
<Button
variant="outline"
role="combobox"
className="w-full justify-between h-auto min-h-[40px]"
>
<CalendarIcon className="w-4 h-4" />
{dueDate ? dueDate.toLocaleDateString() : t('taskCreation.dueDate')}
<div className="flex flex-wrap gap-1">
{dependencies.length > 0 ? (
dependencies.map((depId) => {
const task = tasks.find((t) => t.id === depId);
return (
<Badge key={depId} variant="secondary" className="mr-1">
{task?.title || 'Unknown Task'}
<div
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setDependencies(dependencies.filter((id) => id !== depId));
}}
>
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
</div>
</Badge>
);
})
) : (
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={dueDate}
onSelect={(date) => {
setDueDate(date);
setIsCalendarOpen(false);
}}
disabled={(date) => date < new Date()}
initialFocus
/>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tasks..." />
<CommandList>
<CommandEmpty>No task found.</CommandEmpty>
<CommandGroup heading="Available Tasks">
{tasks
.filter(t => t.status !== 'done') // Only active tasks
.map((task) => (
<CommandItem
key={task.id}
value={task.title}
onSelect={() => {
if (dependencies.includes(task.id)) {
setDependencies(dependencies.filter((id) => id !== task.id));
} else {
setDependencies([...dependencies, task.id]);
}
// Keep the popover open for multiple selection
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
dependencies.includes(task.id)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-4 w-4")} />
</div>
<span>{task.title}</span>
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
{task.status.replace('_', ' ')}
</Badge>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
data-testid="button-cancel"
>
{t('taskCreation.cancel')}
</Button>
<Button
onClick={handleSave}
disabled={!title.trim()}
className="flex-1"
data-testid="button-save-task"
>
{t('taskCreation.create')}
</Button>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={handleClose}
className="flex-1"
data-testid="button-cancel"
>
{t('taskCreation.cancel')}
</Button>
<Button
onClick={handleSave}
className="flex-1"
data-testid="button-save-task"
>
{t('taskCreation.create')}
</Button>
</div>
</DialogContent>
</Dialog>
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Filter, SortAsc } from 'lucide-react';
import { Task } from './TaskCard';
import { Task } from '@shared/schema';
import TaskCard from './TaskCard';
interface TaskListProps {
@@ -32,10 +32,10 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
.filter(task => {
// Search filter
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchesSearch) return false;
// Status filter
switch (filterBy) {
case 'overdue':
@@ -53,18 +53,18 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
const priorityOrder: Record<string, number> = { high: 3, medium: 2, low: 1 };
return (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0);
case 'title':
return a.title.localeCompare(b.title);
case 'status':
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
return statusOrder[a.status] - statusOrder[b.status];
const statusOrder: Record<string, number> = { todo: 1, inProgress: 2, done: 3 };
return (statusOrder[a.status] || 0) - (statusOrder[b.status] || 0);
default:
return 0;
}
@@ -108,7 +108,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
data-testid="input-search-tasks"
/>
</div>
<div className="flex gap-3">
<Select value={filterBy} onValueChange={(value: FilterOption) => {
setFilterBy(value);
@@ -128,7 +128,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value: SortOption) => {
setSortBy(value);
console.log('Sort changed to:', value);
@@ -154,7 +154,7 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
{filteredAndSortedTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
{searchQuery || filterBy !== 'all'
{searchQuery || filterBy !== 'all'
? t('taskList.noMatchingTasks')
: t('taskList.noTasks')
}
@@ -165,11 +165,11 @@ export default function TaskList({ tasks, onTaskUpdate, onTaskEdit }: TaskListPr
<TaskCard
key={task.id}
task={task}
onPlay={() => {
onStartTimer={() => {
onTaskUpdate?.(task.id, { isTracking: true, timeTracked: task.timeTracked });
console.log(`Timer started for ${task.title}`);
}}
onPause={() => {
onStopTimer={() => {
onTaskUpdate?.(task.id, { isTracking: false });
console.log(`Timer paused for ${task.title}`);
}}
+360 -128
View File
@@ -1,15 +1,29 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight, Edit, Timer, CheckCircle, Play, Clock, Trash2, Tag } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import TaskCard from './TaskCard';
import { useQuery } from '@tanstack/react-query';
import TimeCompletionModal from './TimeCompletionModal';
import { addDays, format, isSameDay, startOfToday } from 'date-fns';
import { addDays, format, isSameDay, startOfToday, startOfDay, isWithinInterval } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useDateLocale } from '../hooks/use-date-locale';
interface TasksWithCalendarProps {
tasks: Task[];
@@ -21,12 +35,15 @@ interface TasksWithCalendarProps {
}
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
type FilterOption = 'all' | 'open' | 'todo' | 'inProgress' | 'done' | 'overdue' | 'planned' | 'unplanned';
export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: TasksWithCalendarProps) {
const { t } = useTranslation();
const dateLocale = useDateLocale();
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
const [filterBy, setFilterBy] = useState<FilterOption>('open');
const [filterByLabel, setFilterByLabel] = useState<string>('all');
const [calendarStartDate, setCalendarStartDate] = useState(startOfToday());
const [draggedTask, setDraggedTask] = useState<string | null>(null);
const [hoveredDate, setHoveredDate] = useState<Date | null>(null);
@@ -43,16 +60,18 @@ 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);
// Clamp between 2 and 7 days
const calculated = Math.max(2, Math.min(7, maxDays));
setVisibleDays(calculated);
@@ -60,7 +79,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
calculateVisibleDays();
window.addEventListener('resize', calculateVisibleDays);
return () => window.removeEventListener('resize', calculateVisibleDays);
}, []);
@@ -76,16 +95,27 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
.filter(task => {
// Search filter
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
task.description?.toLowerCase().includes(searchQuery.toLowerCase());
if (!matchesSearch) return false;
// Label filter
if (filterByLabel !== 'all') {
if (task.labelId !== filterByLabel) return false;
}
// Status filter
switch (filterBy) {
case 'overdue':
return isOverdue(task);
case 'planned':
return !!task.dueDate || !!task.startDate;
case 'unplanned':
return !task.dueDate && !task.startDate;
case 'all':
return true;
case 'open':
return task.status !== 'done';
default:
return task.status === filterBy;
}
@@ -97,38 +127,64 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority as keyof typeof priorityOrder] - priorityOrder[a.priority as keyof typeof priorityOrder];
case 'title':
return a.title.localeCompare(b.title);
case 'status':
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
return statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder];
default:
return 0;
}
});
// Tasks that are NOT on the calendar (no due date or not scheduled)
const unscheduledTasks = filteredAndSortedTasks.filter(task => !task.dueDate);
// Tasks that are NOT on the calendar (no due date OR not in visible dates)
const unscheduledTasks = filteredAndSortedTasks.filter(task => {
if (!task.dueDate) return true;
const isVisible = dates.some(date => isSameDay(date, task.dueDate!));
return !isVisible;
});
const getTasksForDate = (date: Date) => {
return filteredAndSortedTasks.filter(task =>
task.dueDate && isSameDay(task.dueDate, date)
);
return filteredAndSortedTasks.filter(task => {
// Handle multi-day tasks
if (task.startDate && task.dueDate) {
const start = startOfDay(new Date(task.startDate));
const end = startOfDay(new Date(task.dueDate));
const current = startOfDay(date);
return current >= start && current <= end;
}
// Handle single date tasks (dueDate or startDate)
if (task.dueDate) {
return isSameDay(new Date(task.dueDate), date);
}
if (task.startDate) {
return isSameDay(new Date(task.startDate), date);
}
return false;
});
};
const getFilterCount = (filter: FilterOption) => {
switch (filter) {
case 'overdue':
return tasks.filter(isOverdue).length;
case 'planned':
return tasks.filter(t => !!t.dueDate || !!t.startDate).length;
case 'unplanned':
return tasks.filter(t => !t.dueDate && !t.startDate).length;
case 'all':
return tasks.length;
case 'open':
return tasks.filter(t => t.status !== 'done').length;
default:
return tasks.filter(task => task.status === filter).length;
}
@@ -169,7 +225,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setHoveredDate(null);
}
@@ -193,7 +249,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
const handleTimeLogged = (timeSpent: number) => {
if (completionModal.task) {
onTaskUpdate?.(completionModal.task.id, {
onTaskUpdate?.(completionModal.task.id, {
status: 'done',
timeTracked: (completionModal.task.timeTracked || 0) + timeSpent,
isTracking: false
@@ -203,23 +259,23 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
};
return (
<div className="space-y-4">
{/* Header and Search Filters - Sticky */}
<div className="sticky top-16 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-3 mb-4">
<div ref={containerRef} className="flex flex-col h-[calc(100vh-6rem)] relative">
{/* Header and Search Filters - Fixed at Top */}
<div className="flex-none pb-3 mb-2 bg-background/95 backdrop-blur z-40 border-b">
<div className="space-y-3">
{/* Title Bar */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold" data-testid="text-task-list-title">
My Tasks
{t('taskList.title')}
</h2>
<Badge variant="secondary" data-testid="badge-task-count">
{filteredAndSortedTasks.length} tasks
{t('taskList.taskCount', { count: filteredAndSortedTasks.length })}
</Badge>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search tasks..."
placeholder={t('taskList.searchPlaceholder')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
@@ -229,63 +285,92 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
data-testid="input-search-tasks"
/>
</div>
<div className="flex gap-3">
<div className="flex flex-wrap gap-2 sm:gap-3">
<Select value={filterBy} onValueChange={(value: FilterOption) => {
setFilterBy(value);
console.log('Filter changed to:', value);
}}>
<SelectTrigger className="flex-1" data-testid="select-filter">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
<SelectTrigger className="flex-1 min-w-0" data-testid="select-filter">
<div className="flex items-center gap-2 truncate">
<Filter className="w-4 h-4 shrink-0" />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ({getFilterCount('all')})</SelectItem>
<SelectItem value="todo">To Do ({getFilterCount('todo')})</SelectItem>
<SelectItem value="inProgress">In Progress ({getFilterCount('inProgress')})</SelectItem>
<SelectItem value="done">Done ({getFilterCount('done')})</SelectItem>
<SelectItem value="overdue">Overdue ({getFilterCount('overdue')})</SelectItem>
<SelectItem value="open">{t('taskList.filter.open')} ({getFilterCount('open')})</SelectItem>
<SelectItem value="all">{t('taskList.filter.all')} ({getFilterCount('all')})</SelectItem>
<SelectItem value="todo">{t('taskList.filter.todo')} ({getFilterCount('todo')})</SelectItem>
<SelectItem value="inProgress">{t('taskList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
<SelectItem value="done">{t('taskList.filter.done')} ({getFilterCount('done')})</SelectItem>
<SelectItem value="overdue">{t('taskList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
<SelectItem value="planned">{t('taskList.filter.planned')} ({getFilterCount('planned')})</SelectItem>
<SelectItem value="unplanned">{t('taskList.filter.unplanned')} ({getFilterCount('unplanned')})</SelectItem>
</SelectContent>
</Select>
<Select value={filterByLabel} onValueChange={(value: string) => {
setFilterByLabel(value);
console.log('Label filter changed to:', value);
}}>
<SelectTrigger className="flex-1 min-w-0" data-testid="select-label-filter">
<div className="flex items-center gap-2 truncate">
<Tag className="w-4 h-4 shrink-0" />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('taskList.filter.allLabels', 'Alle Labels')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: label.color }}
/>
<span>{label.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value: SortOption) => {
setSortBy(value);
console.log('Sort changed to:', value);
}}>
<SelectTrigger className="flex-1" data-testid="select-sort">
<div className="flex items-center gap-2">
<SortAsc className="w-4 h-4" />
<SelectTrigger className="flex-1 min-w-0" data-testid="select-sort">
<div className="flex items-center gap-2 truncate">
<SortAsc className="w-4 h-4 shrink-0" />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="dueDate">Due Date</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="title">Title</SelectItem>
<SelectItem value="status">Status</SelectItem>
<SelectItem value="dueDate">{t('taskList.sort.dueDate')}</SelectItem>
<SelectItem value="priority">{t('taskList.sort.priority')}</SelectItem>
<SelectItem value="title">{t('taskList.sort.title')}</SelectItem>
<SelectItem value="status">{t('taskList.sort.status')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Unscheduled Task List */}
<div className="space-y-3">
{/* Scrollable Task List */}
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 px-1 pb-4">
{unscheduledTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground" data-testid="text-no-tasks">
{searchQuery || filterBy !== 'all'
? 'No unscheduled tasks match your filters'
: 'No unscheduled tasks. Drag tasks to the calendar below to schedule them!'
{searchQuery || (filterBy !== 'all' && filterBy !== 'open')
? t('taskList.noMatchingTasks')
: t('taskList.noUnscheduledTasks')
}
</p>
</div>
) : (
<>
<div className="text-sm font-medium text-muted-foreground mb-2">
Unscheduled Tasks - Drag to calendar below to schedule
<div className="text-sm font-medium text-muted-foreground mb-2 mt-2">
{t('taskList.listHeader')}
</div>
{unscheduledTasks.map((task) => (
<div
@@ -293,9 +378,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
draggable
onDragStart={(e) => handleDragStart(task.id, e)}
onDragEnd={handleDragEnd}
className={`cursor-move transition-transform hover-elevate ${
draggedTask === task.id ? 'opacity-50 scale-95' : ''
}`}
className={`cursor-move transition-transform hover-elevate ${draggedTask === task.id ? 'opacity-50 scale-95' : ''
}`}
>
<TaskCard
task={task}
@@ -317,6 +401,7 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
}
console.log(`Task ${task.title} status changed to ${newStatus}`);
}}
onUpdate={(updates) => onTaskUpdate?.(task.id, updates)}
isDragging={draggedTask === task.id}
/>
</div>
@@ -325,30 +410,30 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
)}
</div>
{/* Calendar Section - Fixed at bottom with full width, above bottom nav */}
<div className="fixed bottom-24 left-0 right-0 bg-background z-[5]" style={{ borderTop: '1px solid rgb(229, 231, 235)' }}>
<div className="p-3 max-h-[32vh] overflow-y-auto">
{/* Calendar Section - Fixed at bottom */}
<div className="flex-none bg-background/95 backdrop-blur z-30 border-t mt-auto shadow-up-lg">
<div className="p-3">
{/* Calendar Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold" data-testid="text-calendar-title">
Next {visibleDays} Days
{t('taskList.calendarTitle', { count: visibleDays })}
</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigateCalendar('prev')}
data-testid="button-prev-week"
>
<ChevronLeft className="w-3 h-3" />
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={() => navigateCalendar('next')}
data-testid="button-next-week"
@@ -361,22 +446,26 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
{/* Full Width Calendar Grid - Dynamic */}
<div className={`grid gap-2`} style={{ gridTemplateColumns: `repeat(${visibleDays}, minmax(0, 1fr))` }}>
{dates.map((date, index) => {
const dayTasks = getTasksForDate(date);
// Sort by priority to ensure high priority items are shown first
const dayTasks = getTasksForDate(date).sort((a, b) => {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return (priorityOrder[b.priority as keyof typeof priorityOrder] || 0) - (priorityOrder[a.priority as keyof typeof priorityOrder] || 0);
});
const isToday = isSameDay(date, new Date());
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
const isHovered = hoveredDate && isSameDay(date, hoveredDate);
// Use important modifier to ensure weekend styling overrides any conflicting CSS
const weekendClasses = isWeekend ? '!bg-muted/80 border-muted-foreground/30 shadow-sm' : '';
const todayClasses = isToday ? 'border-primary ring-1 ring-primary ring-offset-1 ring-offset-background' : 'border-border';
const hoverClasses = isHovered && draggedTask ? 'ring-2 ring-primary bg-primary/15 border-primary shadow-lg' : '';
const dragClasses = draggedTask && !isHovered ? 'border-dashed border-primary/30' : '';
const finalClassName = `p-2 sm:p-2 min-h-[140px] sm:min-h-[100px] transition-all border-2 ${todayClasses} ${weekendClasses} ${hoverClasses} ${dragClasses}`;
return (
<Card
<Card
key={index}
className={finalClassName}
onDragOver={handleDragOver}
@@ -390,71 +479,214 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
data-testid={`calendar-date-${format(date, 'yyyy-MM-dd')}`}
>
<div className="text-center mb-2">
<div className={`text-xs sm:text-xs font-medium ${
isWeekend ? 'text-muted-foreground font-semibold' : 'text-muted-foreground'
}`}>
{format(date, 'EEE')}
<div className={`text-xs sm:text-xs font-medium ${isWeekend ? 'text-muted-foreground font-semibold' : 'text-muted-foreground'
}`}>
{format(date, 'EEE', { locale: dateLocale })}
</div>
<div className={`text-sm sm:text-xs font-semibold ${
isToday ? 'text-primary' : isWeekend ? 'text-primary/80' : ''
}`}>
{format(date, 'MMM d')}
<div className={`text-sm sm:text-xs font-semibold ${isToday ? 'text-primary' : isWeekend ? 'text-primary/80' : ''
}`}>
{format(date, 'd', { locale: dateLocale })}
</div>
</div>
<div className="space-y-1">
{dayTasks.slice(0, 2).map((task) => {
// Find the label for this task
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
return (
<div
key={task.id}
draggable
onDragStart={(e) => handleDragStart(task.id, e)}
onDragEnd={handleDragEnd}
onClick={(e) => {
// Prevent click when dragging
if (draggedTask === task.id) return;
e.stopPropagation();
onTaskEdit?.(task);
console.log('Calendar task clicked:', task.title);
}}
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
data-testid={`calendar-task-${task.id}`}
>
<Badge
variant="outline"
className="w-full justify-start text-xs p-1 sm:p-1 py-2 sm:py-1 h-auto text-left"
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
} : {}}
>
<div className="truncate text-sm sm:text-xs">
{task.title}
<ContextMenu key={task.id}>
<ContextMenuTrigger asChild>
<div
draggable
onDragStart={(e) => handleDragStart(task.id, e)}
onDragEnd={handleDragEnd}
onClick={(e) => {
// Prevent click when dragging
if (draggedTask === task.id) return;
e.stopPropagation();
onTaskEdit?.(task);
console.log('Calendar task clicked:', task.title);
}}
className={`cursor-pointer hover-elevate ${draggedTask === task.id ? 'opacity-50' : ''}`}
data-testid={`calendar-task-${task.id}`}
>
<Badge
variant="outline"
className="w-full justify-start text-xs p-1 sm:p-1 py-2 sm:py-1 h-auto text-left"
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
} : {}}
>
<div className="truncate text-sm sm:text-xs pointer-events-none">
{task.title}
</div>
</Badge>
</div>
</Badge>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}
data-testid={`mini-context-menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
if (task.isTracking) {
onStopTimer?.(task.id);
} else {
onStartTimer?.(task.id);
}
}}
data-testid={`mini-context-menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</ContextMenuItem>
<ContextMenuSeparator />
{task.status !== 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
const taskToComplete = tasks.find(t => t.id === task.id);
if (taskToComplete) {
handleTaskComplete(taskToComplete);
}
}}
data-testid={`mini-context-menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</ContextMenuItem>
)}
{task.status === 'todo' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'inProgress' });
}}
data-testid={`mini-context-menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</ContextMenuItem>
)}
{task.status === 'inProgress' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'todo' });
}}
data-testid={`mini-context-menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</ContextMenuItem>
)}
{task.status === 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskUpdate?.(task.id, { status: 'inProgress' });
}}
data-testid={`mini-context-menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onTaskDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onTaskDelete(task.id);
}}
className="text-destructive"
data-testid={`mini-context-menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
})}
{dayTasks.length > 2 && (
<Badge variant="secondary" className="w-full justify-center text-sm sm:text-xs py-1 sm:py-0">
+{dayTasks.length - 2}
</Badge>
<HoverCard openDelay={0} closeDelay={100}>
<HoverCardTrigger asChild>
<Badge variant="secondary" className="w-full justify-center text-sm sm:text-xs py-1 sm:py-0 cursor-pointer hover:bg-secondary/80">
+{dayTasks.length - 2} more
</Badge>
</HoverCardTrigger>
<HoverCardContent className="w-72 p-0" side="top">
<div className="p-3 border-b bg-muted/30">
<h4 className="font-semibold text-xs text-muted-foreground flex items-center justify-between">
<span>{t('calendar.moreItems', { count: dayTasks.length - 2 })}</span>
<span className="text-[10px] font-normal opacity-75">{t('calendar.clickToEdit')}</span>
</h4>
</div>
<div className="max-h-[300px] overflow-y-auto p-2 space-y-1">
{dayTasks.slice(2).map(task => {
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
return (
<div
key={task.id}
className="p-2 border rounded hover:bg-accent cursor-pointer flex items-center justify-between group transition-colors"
onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}
>
<div className="min-w-0 flex-1 mr-2">
<div className="text-xs font-medium truncate">{task.title}</div>
{taskLabel && (
<div className="flex items-center gap-1 mt-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: taskLabel.color }} />
<span className="text-[10px] text-muted-foreground truncate max-w-[100px]">{taskLabel.name}</span>
</div>
)}
</div>
<Button size="icon" variant="ghost" className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0" onClick={(e) => {
e.stopPropagation();
onTaskEdit?.(task);
}}>
<Edit className="w-3 h-3" />
</Button>
</div>
)
})}
</div>
</HoverCardContent>
</HoverCard>
)}
{dayTasks.length === 0 && (
<div className={`text-center text-xs py-2 rounded border border-dashed transition-all ${
isHovered && draggedTask
? 'border-primary text-primary bg-primary/5'
: draggedTask
? 'border-primary/50 text-primary/70'
<div className={`text-center text-xs py-2 rounded border border-dashed transition-all ${isHovered && draggedTask
? 'border-primary text-primary bg-primary/5'
: draggedTask
? 'border-primary/50 text-primary/70'
: 'border-muted-foreground/30 text-muted-foreground'
}`}>
{isHovered && draggedTask ? '✓ Drop here' : draggedTask ? 'Drop' : ''}
}`}>
{isHovered && draggedTask ? t('calendar.dropHere') : draggedTask ? t('calendar.drop') : ''}
</div>
)}
</div>
@@ -465,8 +697,8 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit, onT
</div>
</div>
{/* Add bottom padding to prevent content overlap */}
<div className="h-[37vh]" />
{/* Spacer */}
<div className="h-4" />
{/* Time Completion Modal */}
<TimeCompletionModal
+12 -11
View File
@@ -1,18 +1,20 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function ThemeToggle() {
const { t } = useTranslation();
const [isDark, setIsDark] = useState(false);
useEffect(() => {
// Check for saved theme preference or default to light mode
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = savedTheme === 'dark' || (!savedTheme && prefersDark);
setIsDark(shouldBeDark);
// Apply theme to document
if (shouldBeDark) {
document.documentElement.classList.add('dark');
@@ -24,7 +26,7 @@ export default function ThemeToggle() {
const toggleTheme = () => {
const newTheme = !isDark;
setIsDark(newTheme);
if (newTheme) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
@@ -32,23 +34,22 @@ export default function ThemeToggle() {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
console.log('Theme changed to:', newTheme ? 'dark' : 'light');
};
return (
<Button
variant="ghost"
<Button
variant="outline"
size="icon"
onClick={toggleTheme}
data-testid="button-theme-toggle"
className="w-9 h-9"
className="w-10 h-10 rounded-full border-2 border-primary/20 hover:border-primary hover:bg-primary/10 transition-all duration-300"
title={t('app.toggleTheme')}
>
{isDark ? (
<Sun className="w-4 h-4" />
<Moon className="h-5 w-5 text-violet-500 transition-all" />
) : (
<Moon className="w-4 h-4" />
<Sun className="h-5 w-5 text-orange-500 transition-all" />
)}
<span className="sr-only">{isDark ? t('theme.dark') : t('theme.light')}</span>
</Button>
);
}
+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,106 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { User } from '@shared/schema';
const profileSchema = z.object({
email: z.string().email("Invalid email address"),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
interface UpdateProfileModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user: User;
}
export function UpdateProfileModal({ open, onOpenChange, user }: UpdateProfileModalProps) {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
email: user.email,
},
});
const mutation = useMutation({
mutationFn: async (data: ProfileFormValues) => {
const res = await apiRequest("PATCH", "/api/user/profile", data);
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to update profile");
}
return res.json();
},
onSuccess: (updatedUser) => {
queryClient.setQueryData(['/api/user'], updatedUser);
toast({ title: "Profile updated successfully" });
onOpenChange(false);
},
onError: (error: Error) => {
toast({
title: "Update failed",
description: error.message,
variant: "destructive",
});
},
});
const onSubmit = (data: ProfileFormValues) => {
mutation.mutate(data);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Update Profile</DialogTitle>
<DialogDescription>
Update your account information.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+350
View File
@@ -0,0 +1,350 @@
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
import { Task, Label } from '@shared/schema';
import TaskCard from './TaskCard';
import { useQuery } from '@tanstack/react-query';
import TimeCompletionModal from './TimeCompletionModal';
import { addDays, format, isSameDay, startOfWeek, endOfWeek, startOfDay, endOfDay, addWeeks } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useDateLocale } from '../hooks/use-date-locale';
interface WeekListViewProps {
tasks: Task[];
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
onTaskEdit?: (task: Task) => void;
onTaskDelete?: (taskId: string) => void;
onStartTimer?: (taskId: string) => void;
onStopTimer?: (taskId: string) => void;
}
type SortOption = 'dueDate' | 'priority' | 'title' | 'status';
type FilterOption = 'all' | 'todo' | 'inProgress' | 'done' | 'overdue';
export default function WeekListView({ tasks, onTaskUpdate, onTaskEdit, onTaskDelete, onStartTimer, onStopTimer }: WeekListViewProps) {
const { t } = useTranslation();
const dateLocale = useDateLocale();
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('dueDate');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
const [selectedWeekStart, setSelectedWeekStart] = useState(startOfWeek(new Date(), { weekStartsOn: 1 }));
const [completionModal, setCompletionModal] = useState<{ isOpen: boolean; task: Task | null }>({
isOpen: false,
task: null
});
// Fetch labels to get label colors
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels'],
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
// Always show 7 days for the selected week (Monday to Sunday)
const weekStart = startOfDay(selectedWeekStart);
const weekEnd = endOfDay(endOfWeek(selectedWeekStart, { weekStartsOn: 1 }));
const dates = Array.from({ length: 7 }, (_, i) => addDays(selectedWeekStart, i));
const isOverdue = (task: Task) => {
if (!task.dueDate) return false;
return task.dueDate < new Date() && task.status !== 'done';
};
const isInCurrentWeek = (task: Task) => {
if (!task.dueDate) return false;
return task.dueDate >= weekStart && task.dueDate <= weekEnd;
};
const filteredAndSortedTasks = tasks
.filter(task => {
// Only show tasks in the selected week
if (!isInCurrentWeek(task)) return false;
// Search filter
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
(task.description?.toLowerCase()?.includes(searchQuery.toLowerCase()) ?? false);
if (!matchesSearch) return false;
// Status filter
switch (filterBy) {
case 'overdue':
return isOverdue(task);
case 'all':
return true;
default:
return task.status === filterBy;
}
})
.sort((a, b) => {
switch (sortBy) {
case 'dueDate':
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority as keyof typeof priorityOrder] - priorityOrder[a.priority as keyof typeof priorityOrder];
case 'title':
return a.title.localeCompare(b.title);
case 'status':
const statusOrder = { todo: 1, inProgress: 2, done: 3 };
return statusOrder[a.status as keyof typeof statusOrder] - statusOrder[b.status as keyof typeof statusOrder];
default:
return 0;
}
});
const getFilterCount = (filter: FilterOption) => {
const weekTasks = tasks.filter(isInCurrentWeek);
switch (filter) {
case 'overdue':
return weekTasks.filter(isOverdue).length;
case 'all':
return weekTasks.length;
default:
return weekTasks.filter(task => task.status === filter).length;
}
};
const getTasksForDate = (date: Date) => {
return filteredAndSortedTasks.filter(task =>
task.dueDate && isSameDay(task.dueDate, date)
);
};
const navigateCalendar = (direction: 'prev' | 'next') => {
const newWeekStart = addWeeks(selectedWeekStart, direction === 'next' ? 1 : -1);
setSelectedWeekStart(newWeekStart);
console.log(`Navigating to week starting ${newWeekStart.toLocaleDateString()}`);
};
const goToCurrentWeek = () => {
setSelectedWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
};
const handleTaskComplete = (task: Task) => {
setCompletionModal({ isOpen: true, task });
};
const handleTimeLogged = (timeSpent: number) => {
if (completionModal.task) {
onTaskUpdate?.(completionModal.task.id, {
status: 'done',
timeTracked: (completionModal.task.timeTracked || 0) + timeSpent,
isTracking: false
});
}
setCompletionModal({ isOpen: false, task: null });
};
return (
<div className="space-y-4 pb-4">
{/* Header and Search Filters */}
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-3 mb-4 pt-4">
<div className="space-y-3">
{/* Title Bar */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold" data-testid="text-week-list-title">
{t('weekList.title')}
</h2>
<Badge variant="secondary" data-testid="badge-week-task-count">
{t('weekList.taskCount', { count: filteredAndSortedTasks.length })}
</Badge>
</div>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t('weekList.searchPlaceholder')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
console.log('Search query:', e.target.value);
}}
className="pl-10"
data-testid="input-search-week-tasks"
/>
</div>
<div className="flex gap-3">
<Select value={filterBy} onValueChange={(value: FilterOption) => {
setFilterBy(value);
console.log('Filter changed to:', value);
}}>
<SelectTrigger className="flex-1" data-testid="select-filter-week">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('weekList.filter.all')} ({getFilterCount('all')})</SelectItem>
<SelectItem value="todo">{t('weekList.filter.todo')} ({getFilterCount('todo')})</SelectItem>
<SelectItem value="inProgress">{t('weekList.filter.inProgress')} ({getFilterCount('inProgress')})</SelectItem>
<SelectItem value="done">{t('weekList.filter.done')} ({getFilterCount('done')})</SelectItem>
<SelectItem value="overdue">{t('weekList.filter.overdue')} ({getFilterCount('overdue')})</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value: SortOption) => {
setSortBy(value);
console.log('Sort changed to:', value);
}}>
<SelectTrigger className="flex-1" data-testid="select-sort-week">
<div className="flex items-center gap-2">
<SortAsc className="w-4 h-4" />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="dueDate">{t('weekList.sort.dueDate')}</SelectItem>
<SelectItem value="priority">{t('weekList.sort.priority')}</SelectItem>
<SelectItem value="title">{t('weekList.sort.title')}</SelectItem>
<SelectItem value="status">{t('weekList.sort.status')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Calendar Week Selector */}
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary" />
<h3 className="text-sm font-semibold" data-testid="text-week-range">
{format(weekStart, 'MMM d', { locale: dateLocale })} - {format(weekEnd, 'MMM d, yyyy', { locale: dateLocale })}
</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigateCalendar('prev')}
data-testid="button-prev-week-list"
>
<ChevronLeft className="w-4 h-4" />
{t('weekList.previous')}
</Button>
<Button
variant="outline"
size="sm"
onClick={goToCurrentWeek}
data-testid="button-today-week-list"
>
{t('weekList.today')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigateCalendar('next')}
data-testid="button-next-week-list"
>
{t('weekList.next')}
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* Mini Calendar Bar */}
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(7, minmax(0, 1fr))' }}>
{dates.map((date, index) => {
const dayTasks = getTasksForDate(date);
const isToday = isSameDay(date, new Date());
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
return (
<div
key={index}
className={`p-2 rounded border-2 text-center ${isToday ? 'border-primary bg-primary/5' : isWeekend ? 'bg-muted/50' : 'border-border'
}`}
data-testid={`week-calendar-date-${format(date, 'yyyy-MM-dd')}`}
>
<div className={`text-xs font-medium ${isWeekend ? 'text-muted-foreground' : 'text-muted-foreground'}`}>
{format(date, 'EEE', { locale: dateLocale })}
</div>
<div className={`text-sm font-semibold ${isToday ? 'text-primary' : ''}`}>
{format(date, 'd', { locale: dateLocale })}
</div>
{dayTasks.length > 0 && (
<Badge variant="secondary" className="mt-1 text-xs">
{dayTasks.length}
</Badge>
)}
</div>
);
})}
</div>
</Card>
{/* Task List */}
<div className="space-y-3">
{filteredAndSortedTasks.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground" data-testid="text-no-week-tasks">
{searchQuery || filterBy !== 'all'
? t('weekList.noMatchingTasks')
: t('weekList.noTasks')
}
</p>
</div>
) : (
<>
<div className="text-sm font-medium text-muted-foreground mb-2">
{t('weekList.tasksFor', { start: format(weekStart, 'MMM d', { locale: dateLocale }), end: format(weekEnd, 'MMM d', { locale: dateLocale }) })}
</div>
{filteredAndSortedTasks.map((task) => (
<div key={task.id}>
<TaskCard
task={task}
onStartTimer={() => onStartTimer?.(task.id)}
onStopTimer={() => onStopTimer?.(task.id)}
onEdit={() => {
onTaskEdit?.(task);
console.log(`Edit task ${task.title}`);
}}
onDelete={() => {
onTaskDelete?.(task.id);
console.log(`Delete task ${task.title}`);
}}
onStatusChange={(newStatus) => {
if (newStatus === 'done') {
handleTaskComplete(task);
} else {
onTaskUpdate?.(task.id, { status: newStatus });
}
console.log(`Task ${task.title} status changed to ${newStatus}`);
}}
/>
</div>
))}
</>
)}
</div>
{/* Time Completion Modal */}
<TimeCompletionModal
isOpen={completionModal.isOpen}
onClose={() => setCompletionModal({ isOpen: false, task: null })}
onSave={handleTimeLogged}
taskTitle={completionModal.task?.title || ''}
/>
</div>
);
}
@@ -0,0 +1,88 @@
import { useADHDMode } from './providers/ADHDModeProvider';
import { Switch } from '@/components/ui/switch';
import { Brain, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
interface ADHDModeToggleProps {
showLabel?: boolean;
compact?: boolean;
}
export function ADHDModeToggle({ showLabel = true, compact = false }: ADHDModeToggleProps) {
const { isEnabled, toggle, isLoading } = useADHDMode();
const { t } = useTranslation();
if (compact) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggle}
disabled={isLoading}
className={`
relative flex items-center justify-center w-10 h-10 rounded-lg
transition-all duration-200
${isEnabled
? 'bg-primary/20 text-primary border-2 border-primary'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
}
${isLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<Brain className="h-5 w-5" />
{isEnabled && (
<Sparkles className="absolute -top-1 -right-1 h-3 w-3 text-primary animate-pulse" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
<p>{isEnabled ? t('adhd.modeEnabled') : t('adhd.modeDisabled')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-card border">
<div className={`
flex items-center justify-center w-10 h-10 rounded-full
${isEnabled ? 'bg-primary/20' : 'bg-muted'}
`}>
<Brain className={`h-5 w-5 ${isEnabled ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<div className="flex-1">
{showLabel && (
<>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{t('adhd.mode')}</span>
{isEnabled && (
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/20 text-primary">
{t('adhd.active')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{t('adhd.modeDescription')}
</p>
</>
)}
</div>
<Switch
checked={isEnabled}
onCheckedChange={toggle}
disabled={isLoading}
aria-label={t('adhd.toggleMode')}
/>
</div>
);
}
@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2, Sparkles, Clock, CheckCircle2, AlertCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useADHDMode } from '../providers/ADHDModeProvider';
import type { Task } from '@shared/schema';
interface SubtaskSuggestion {
title: string;
estimatedMinutes: number;
order: number;
}
interface BreakdownResponse {
subtasks: SubtaskSuggestion[];
totalEstimatedMinutes: number;
encouragement: string;
}
interface TaskBreakdownModalProps {
task: Task;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function TaskBreakdownModal({ task, open, onOpenChange }: TaskBreakdownModalProps) {
const { t } = useTranslation();
const { settings } = useADHDMode();
const queryClient = useQueryClient();
const [breakdown, setBreakdown] = useState<BreakdownResponse | null>(null);
const [selectedSubtasks, setSelectedSubtasks] = useState<Set<number>>(new Set());
// Mutation to get AI breakdown
const breakdownMutation = useMutation({
mutationFn: async () => {
const res = await fetch('/api/ai/breakdown-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
taskId: task.id,
title: task.title,
description: task.description,
estimatedDuration: task.estimatedDuration,
}),
});
if (!res.ok) throw new Error('Failed to break down task');
return res.json() as Promise<BreakdownResponse>;
},
onSuccess: (data) => {
setBreakdown(data);
// Select all by default
setSelectedSubtasks(new Set(data.subtasks.map((_, i) => i)));
},
});
// Mutation to create subtasks
const createSubtasksMutation = useMutation({
mutationFn: async (subtasks: SubtaskSuggestion[]) => {
const promises = subtasks.map((subtask, index) =>
fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: subtask.title,
parentTaskId: task.id,
estimatedDuration: subtask.estimatedMinutes,
priority: task.priority,
status: 'todo',
}),
})
);
return Promise.all(promises);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
onOpenChange(false);
},
});
const handleGenerate = () => {
breakdownMutation.mutate();
};
const handleToggleSubtask = (index: number) => {
const newSet = new Set(selectedSubtasks);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
setSelectedSubtasks(newSet);
};
const handleCreate = () => {
if (!breakdown) return;
const selected = breakdown.subtasks.filter((_, i) => selectedSubtasks.has(i));
createSubtasksMutation.mutate(selected);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
{t('adhd.taskBreakdown.title')}
</DialogTitle>
<DialogDescription>
{t('adhd.taskBreakdown.description')}
</DialogDescription>
</DialogHeader>
{/* Task info */}
<div className="bg-muted/50 rounded-lg p-4 mb-4">
<h4 className="font-medium mb-1 truncate">{task.title}</h4>
{task.description && (
<p className="text-sm text-muted-foreground">{task.description}</p>
)}
{task.estimatedDuration && (
<div className="flex items-center gap-1 mt-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
<span>{task.estimatedDuration} min {t('adhd.taskBreakdown.estimated')}</span>
</div>
)}
</div>
{/* Generate button or results */}
{!breakdown ? (
<div className="flex flex-col items-center py-8">
<p className="text-sm text-muted-foreground mb-4 text-center">
{t('adhd.taskBreakdown.prompt')}
</p>
<Button
onClick={handleGenerate}
disabled={breakdownMutation.isPending}
size="lg"
className={settings.largerTargets ? 'h-14 px-8 text-lg' : ''}
>
{breakdownMutation.isPending ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
{t('adhd.taskBreakdown.analyzing')}
</>
) : (
<>
<Sparkles className="h-5 w-5 mr-2" />
{t('adhd.taskBreakdown.breakItDown')}
</>
)}
</Button>
</div>
) : (
<div className="space-y-4">
{/* Encouragement */}
{breakdown.encouragement && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-primary/10 text-primary rounded-lg p-3 text-sm"
>
{breakdown.encouragement}
</motion.div>
)}
{/* Subtask list */}
<div className="space-y-2 max-h-[300px] overflow-y-auto">
<AnimatePresence>
{breakdown.subtasks.map((subtask, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className={`
flex items-start gap-3 p-3 rounded-lg border
${selectedSubtasks.has(index) ? 'bg-primary/5 border-primary/30' : 'bg-muted/30 border-transparent'}
transition-colors duration-200
`}
>
<Checkbox
checked={selectedSubtasks.has(index)}
onCheckedChange={() => handleToggleSubtask(index)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{subtask.title}</p>
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>~{subtask.estimatedMinutes} min</span>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Total time */}
<div className="flex items-center justify-between text-sm bg-muted/50 rounded-lg p-3">
<span className="text-muted-foreground">{t('adhd.taskBreakdown.totalTime')}</span>
<span className="font-medium">
{breakdown.subtasks
.filter((_, i) => selectedSubtasks.has(i))
.reduce((sum, s) => sum + s.estimatedMinutes, 0)} min
</span>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={handleGenerate}
disabled={breakdownMutation.isPending}
className="flex-1"
>
{t('adhd.taskBreakdown.regenerate')}
</Button>
<Button
onClick={handleCreate}
disabled={createSubtasksMutation.isPending || selectedSubtasks.size === 0}
className="flex-1"
>
{createSubtasksMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4 mr-2" />
)}
{t('adhd.taskBreakdown.createSubtasks', { count: selectedSubtasks.size })}
</Button>
</div>
</div>
)}
{/* Error state */}
{breakdownMutation.isError && (
<div className="flex items-center gap-2 text-destructive text-sm mt-2">
<AlertCircle className="h-4 w-4" />
<span>{t('adhd.taskBreakdown.error')}</span>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,124 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useBreakReminder } from '../providers/BreakReminderProvider';
import { useADHDMode } from '../providers/ADHDModeProvider';
import { Button } from '@/components/ui/button';
import { X, Clock, Droplet, PersonStanding, Eye, Apple, Wind, Timer } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const breakSuggestions = [
{ id: 'stretch', icon: PersonStanding, duration: 2 },
{ id: 'water', icon: Droplet, duration: 1 },
{ id: 'walk', icon: Timer, duration: 5 },
{ id: 'eyes', icon: Eye, duration: 1 },
{ id: 'snack', icon: Apple, duration: 3 },
{ id: 'breathe', icon: Wind, duration: 2 },
];
export function BreakReminderOverlay() {
const { t } = useTranslation();
const { isReminderVisible, minutesSinceBreak, logBreak, snooze, dismiss } = useBreakReminder();
const { isEnabled, settings } = useADHDMode();
if (!isEnabled || !isReminderVisible) return null;
return (
<AnimatePresence>
<motion.div
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className={`
fixed right-4 top-1/2 -translate-y-1/2 z-50
w-80 max-w-[calc(100vw-2rem)]
bg-gradient-to-br from-blue-50 to-purple-50
dark:from-blue-950/90 dark:to-purple-950/90
rounded-2xl shadow-2xl border border-border/50
backdrop-blur-sm
${settings.reducedAnimations ? '' : 'animate-pulse-soft'}
`}
>
{/* Dismiss button */}
<button
onClick={dismiss}
className="absolute top-3 right-3 p-1 rounded-full hover:bg-white/50 dark:hover:bg-black/20 transition-colors"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
<div className="p-6">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-primary/20">
<Clock className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-lg">{t('adhd.breakReminder.title')}</h3>
<p className="text-sm text-muted-foreground">
{t('adhd.breakReminder.workingFor', { minutes: minutesSinceBreak })}
</p>
</div>
</div>
{/* Message */}
<p className="text-sm text-foreground/80 mb-4">
{t('adhd.breakReminder.message')}
</p>
{/* Break suggestions */}
<div className="grid grid-cols-3 gap-2 mb-4">
{breakSuggestions.map((suggestion) => {
const Icon = suggestion.icon;
return (
<button
key={suggestion.id}
onClick={() => logBreak(suggestion.id, suggestion.duration)}
className={`
flex flex-col items-center gap-1 p-3 rounded-lg
bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40
transition-all duration-200
${settings.largerTargets ? 'min-h-[80px]' : 'min-h-[60px]'}
`}
>
<Icon className="h-5 w-5 text-primary" />
<span className="text-xs font-medium">
{t(`adhd.breakReminder.types.${suggestion.id}`)}
</span>
<span className="text-[10px] text-muted-foreground">
{suggestion.duration} min
</span>
</button>
);
})}
</div>
{/* Snooze options */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => snooze(5)}
className="flex-1 text-xs"
>
{t('adhd.breakReminder.snooze5')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => snooze(15)}
className="flex-1 text-xs"
>
{t('adhd.breakReminder.snooze15')}
</Button>
</div>
{/* Gentle message */}
<p className="text-xs text-center text-muted-foreground mt-4 italic">
{t('adhd.breakReminder.gentle')}
</p>
</div>
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,150 @@
import React, { useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useADHDMode } from '../providers/ADHDModeProvider';
import { Sparkles, Heart, Star, Trophy, Sun } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type EncouragementType = 'taskComplete' | 'returning' | 'struggling' | 'streakBroken' | 'milestone' | 'quickWin';
interface EncouragementToastProps {
type: EncouragementType;
visible: boolean;
onDismiss: () => void;
customMessage?: string;
xpEarned?: number;
}
const icons: Record<EncouragementType, React.ComponentType<{ className?: string }>> = {
taskComplete: Star,
returning: Sun,
struggling: Heart,
streakBroken: Heart,
milestone: Trophy,
quickWin: Sparkles,
};
const colors: Record<EncouragementType, string> = {
taskComplete: 'from-green-500/20 to-emerald-500/20 border-green-500/30',
returning: 'from-blue-500/20 to-cyan-500/20 border-blue-500/30',
struggling: 'from-purple-500/20 to-pink-500/20 border-purple-500/30',
streakBroken: 'from-orange-500/20 to-amber-500/20 border-orange-500/30',
milestone: 'from-yellow-500/20 to-amber-500/20 border-yellow-500/30',
quickWin: 'from-primary/20 to-primary/10 border-primary/30',
};
export function EncouragementToast({
type,
visible,
onDismiss,
customMessage,
xpEarned,
}: EncouragementToastProps) {
const { t } = useTranslation();
const { isEnabled, settings } = useADHDMode();
const Icon = icons[type];
const colorClass = colors[type];
// Auto-dismiss after 4 seconds
useEffect(() => {
if (visible) {
const timer = setTimeout(onDismiss, 4000);
return () => clearTimeout(timer);
}
}, [visible, onDismiss]);
// Don't show if ADHD mode is disabled or messaging is minimal
if (!isEnabled || settings.positiveMessagingLevel === 'minimal') {
return null;
}
const messages = t(`adhd.encouragements.${type}`, { returnObjects: true }) as string[];
const message = customMessage || (Array.isArray(messages) ? messages[Math.floor(Math.random() * messages.length)] : messages);
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
transition={{
type: 'spring',
damping: 20,
stiffness: 300,
duration: settings.reducedAnimations ? 0 : undefined,
}}
onClick={onDismiss}
className={`
fixed bottom-6 left-1/2 -translate-x-1/2 z-50
bg-gradient-to-r ${colorClass}
backdrop-blur-md rounded-2xl border shadow-lg
p-4 cursor-pointer
max-w-md w-[calc(100vw-2rem)]
`}
>
<div className="flex items-center gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-background/50">
<Icon className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<p className="font-medium text-foreground">{message}</p>
{xpEarned && xpEarned > 0 && (
<p className="text-sm text-muted-foreground mt-1">
+{xpEarned} XP
</p>
)}
</div>
</div>
{/* Decorative sparkles for high messaging level */}
{settings.positiveMessagingLevel === 'high' && !settings.reducedAnimations && (
<>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
className="absolute -top-2 -right-2"
>
<Sparkles className="h-5 w-5 text-yellow-500" />
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="absolute top-1/2 -left-2"
>
<Star className="h-4 w-4 text-primary/50" />
</motion.div>
</>
)}
</motion.div>
)}
</AnimatePresence>
);
}
// Hook to manage encouragement state
export function useEncouragement() {
const [visible, setVisible] = React.useState(false);
const [type, setType] = React.useState<EncouragementType>('taskComplete');
const [message, setMessage] = React.useState<string | undefined>();
const [xp, setXp] = React.useState<number | undefined>();
const show = React.useCallback((
encouragementType: EncouragementType,
customMessage?: string,
xpEarned?: number
) => {
setType(encouragementType);
setMessage(customMessage);
setXp(xpEarned);
setVisible(true);
}, []);
const hide = React.useCallback(() => {
setVisible(false);
}, []);
return { visible, type, message, xp, show, hide };
}
@@ -0,0 +1,277 @@
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Play, Pause, CheckCircle2, ArrowRight, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { useADHDMode } from '../providers/ADHDModeProvider';
import type { Task } from '@shared/schema';
interface FiveMinuteStarterProps {
task: Task;
onClose: () => void;
onComplete: () => void;
onContinue?: () => void;
}
type Phase = 'ready' | 'running' | 'done';
export function FiveMinuteStarter({
task,
onClose,
onComplete,
onContinue,
}: FiveMinuteStarterProps) {
const { t } = useTranslation();
const { settings } = useADHDMode();
const [phase, setPhase] = useState<Phase>('ready');
const [timeLeft, setTimeLeft] = useState(5 * 60); // 5 minutes in seconds
const [isRunning, setIsRunning] = useState(false);
const progress = ((5 * 60 - timeLeft) / (5 * 60)) * 100;
// Timer logic
useEffect(() => {
if (!isRunning || timeLeft <= 0) return;
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
setIsRunning(false);
setPhase('done');
// Play completion sound
const audio = new Audio('/sounds/complete.mp3');
audio.volume = 0.3;
audio.play().catch(() => {});
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [isRunning, timeLeft]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleStart = () => {
setPhase('running');
setIsRunning(true);
};
const handlePause = () => {
setIsRunning(!isRunning);
};
const handleDone = () => {
onComplete();
onClose();
};
const handleContinue = () => {
// Reset for another 5 minutes
setTimeLeft(5 * 60);
setPhase('running');
setIsRunning(true);
onContinue?.();
};
const handleTakeBreak = () => {
// Just close without completing
onClose();
};
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"
>
<div className="relative w-full max-w-md bg-card rounded-2xl border shadow-2xl p-6">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full hover:bg-muted transition-colors"
>
<X className="h-5 w-5 text-muted-foreground" />
</button>
<AnimatePresence mode="wait">
{phase === 'ready' && (
<motion.div
key="ready"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-center"
>
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-primary/10 flex items-center justify-center">
<Play className="h-10 w-10 text-primary" />
</div>
<h3 className="text-xl font-bold mb-2">
{t('adhd.fiveMin.title')}
</h3>
<p className="text-muted-foreground mb-2 truncate max-w-full">
{task.title}
</p>
<p className="text-sm text-muted-foreground mb-6">
{t('adhd.fiveMin.prompt')}
</p>
<Button
size="lg"
onClick={handleStart}
className={`w-full ${settings.largerTargets ? 'h-14 text-lg' : 'h-12'}`}
>
<Play className="h-5 w-5 mr-2" />
{t('adhd.fiveMin.start')}
</Button>
<p className="text-xs text-muted-foreground mt-4 italic">
{t('adhd.fiveMin.noObligation')}
</p>
</motion.div>
)}
{phase === 'running' && (
<motion.div
key="running"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-center"
>
{/* Visual progress ring */}
<div className="relative w-48 h-48 mx-auto mb-6">
<svg className="w-full h-full transform -rotate-90">
<circle
cx="96"
cy="96"
r="88"
stroke="currentColor"
strokeWidth="8"
fill="none"
className="text-muted"
/>
<motion.circle
cx="96"
cy="96"
r="88"
stroke="currentColor"
strokeWidth="8"
fill="none"
className="text-primary"
strokeLinecap="round"
strokeDasharray={553}
animate={{ strokeDashoffset: 553 - (553 * progress) / 100 }}
transition={{ duration: 0.5 }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-mono font-bold">
{formatTime(timeLeft)}
</span>
<span className="text-sm text-muted-foreground">
{t('adhd.fiveMin.remaining')}
</span>
</div>
</div>
<p className="font-medium mb-4 truncate px-4">
{task.title}
</p>
<div className="flex gap-3">
<Button
variant="outline"
onClick={handlePause}
className="flex-1"
>
{isRunning ? (
<>
<Pause className="h-4 w-4 mr-2" />
{t('adhd.fiveMin.pause')}
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
{t('adhd.fiveMin.resume')}
</>
)}
</Button>
<Button onClick={handleDone} className="flex-1">
<CheckCircle2 className="h-4 w-4 mr-2" />
{t('adhd.fiveMin.done')}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-4">
{t('adhd.fiveMin.encouragement')}
</p>
</motion.div>
)}
{phase === 'done' && (
<motion.div
key="done"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="text-center"
>
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-green-500/20 flex items-center justify-center">
<CheckCircle2 className="h-10 w-10 text-green-500" />
</div>
<h3 className="text-xl font-bold mb-2">
{t('adhd.fiveMin.congrats')}
</h3>
<p className="text-muted-foreground mb-6">
{t('adhd.fiveMin.fiveMinDone')}
</p>
<div className="flex flex-col gap-3">
<Button
size="lg"
onClick={handleContinue}
className={`w-full ${settings.largerTargets ? 'h-14' : 'h-12'}`}
>
<ArrowRight className="h-5 w-5 mr-2" />
{t('adhd.fiveMin.continueWorking')}
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleDone}
className="flex-1"
>
<CheckCircle2 className="h-4 w-4 mr-2" />
{t('adhd.fiveMin.finished')}
</Button>
<Button
variant="ghost"
onClick={handleTakeBreak}
className="flex-1"
>
{t('adhd.fiveMin.takeBreak')}
</Button>
</div>
</div>
<p className="text-sm text-primary mt-4 font-medium">
+10 XP {t('adhd.fiveMin.earned')}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}
@@ -0,0 +1,185 @@
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, Clock, ListTodo, Coffee, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { useADHDMode } from '../providers/ADHDModeProvider';
import type { Task } from '@shared/schema';
interface HyperfocusGuardProps {
activeTaskId?: string;
onTaskStart?: (taskId: string) => void;
}
export function HyperfocusGuard({ activeTaskId, onTaskStart }: HyperfocusGuardProps) {
const { t } = useTranslation();
const { isEnabled, settings } = useADHDMode();
const [focusStartTime, setFocusStartTime] = useState<Date | null>(null);
const [minutesOnTask, setMinutesOnTask] = useState(0);
const [showWarning, setShowWarning] = useState(false);
const [dismissed, setDismissed] = useState(false);
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
// Track when focus starts
useEffect(() => {
if (activeTaskId && !focusStartTime) {
setFocusStartTime(new Date());
setDismissed(false);
} else if (!activeTaskId) {
setFocusStartTime(null);
setMinutesOnTask(0);
setShowWarning(false);
setDismissed(false);
}
}, [activeTaskId, focusStartTime]);
// Timer to track focus duration
useEffect(() => {
if (!isEnabled || !settings.hyperfocusProtection || !focusStartTime || dismissed) {
return;
}
const interval = setInterval(() => {
const now = new Date();
const diffMs = now.getTime() - focusStartTime.getTime();
const diffMins = Math.floor(diffMs / 60000);
setMinutesOnTask(diffMins);
// Show warning at threshold
if (diffMins >= settings.hyperfocusMaxMinutes && !showWarning) {
setShowWarning(true);
}
}, 30000); // Check every 30 seconds
return () => clearInterval(interval);
}, [isEnabled, settings, focusStartTime, showWarning, dismissed]);
// Count pending tasks
const pendingTasks = tasks.filter(
(t) => t.status !== 'done' && t.id !== activeTaskId
);
const urgentTasks = pendingTasks.filter(
(t) => t.priority === 'high' || (t.dueDate && new Date(t.dueDate) < new Date(Date.now() + 24 * 60 * 60 * 1000))
);
const handleDismiss = () => {
setShowWarning(false);
setDismissed(true);
};
const handleTakeBreak = () => {
setShowWarning(false);
// Could trigger break reminder or navigate to break
};
const handleSwitchTask = (taskId: string) => {
setShowWarning(false);
setFocusStartTime(null);
onTaskStart?.(taskId);
};
if (!isEnabled || !settings.hyperfocusProtection || !showWarning) {
return null;
}
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-full max-w-md px-4"
>
<div className="bg-amber-50 dark:bg-amber-950/90 border-2 border-amber-500/50 rounded-2xl shadow-xl p-5">
{/* Header */}
<div className="flex items-start gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-amber-500/20">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-amber-900 dark:text-amber-100">
{t('adhd.hyperfocus.title')}
</h4>
<p className="text-sm text-amber-700 dark:text-amber-300">
{t('adhd.hyperfocus.message', { minutes: minutesOnTask })}
</p>
</div>
<button
onClick={handleDismiss}
className="p-1 rounded-full hover:bg-amber-500/20 transition-colors"
>
<X className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</button>
</div>
{/* Stats */}
<div className="flex gap-4 mb-4 text-sm">
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
<Clock className="h-4 w-4" />
<span>{minutesOnTask} {t('adhd.hyperfocus.minutes')}</span>
</div>
{urgentTasks.length > 0 && (
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300">
<ListTodo className="h-4 w-4" />
<span>{urgentTasks.length} {t('adhd.hyperfocus.urgentTasks')}</span>
</div>
)}
</div>
{/* Urgent tasks preview */}
{urgentTasks.length > 0 && (
<div className="mb-4 space-y-2">
<p className="text-xs font-medium text-amber-800 dark:text-amber-200 mb-2">
{t('adhd.hyperfocus.waitingTasks')}:
</p>
{urgentTasks.slice(0, 3).map((task) => (
<button
key={task.id}
onClick={() => handleSwitchTask(task.id)}
className={`
w-full text-left p-2 rounded-lg
bg-amber-100 dark:bg-amber-900/50
hover:bg-amber-200 dark:hover:bg-amber-900
transition-colors text-sm
text-amber-900 dark:text-amber-100
truncate
`}
>
{task.title}
</button>
))}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleTakeBreak}
className="flex-1 border-amber-500/50 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/50"
>
<Coffee className="h-4 w-4 mr-2" />
{t('adhd.hyperfocus.takeBreak')}
</Button>
<Button
onClick={handleDismiss}
className="flex-1 bg-amber-500 hover:bg-amber-600 text-white"
>
{t('adhd.hyperfocus.continue')}
</Button>
</div>
{/* Gentle reminder */}
<p className="text-xs text-center text-amber-600 dark:text-amber-400 mt-3 italic">
{t('adhd.hyperfocus.gentle')}
</p>
</div>
</motion.div>
</AnimatePresence>
);
}
@@ -0,0 +1,213 @@
import React, { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { Zap, Clock, Star, CheckCircle2, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import { useADHDMode } from '../providers/ADHDModeProvider';
import { useEncouragement, EncouragementToast } from '../feedback/EncouragementToast';
import type { Task } from '@shared/schema';
import confetti from 'canvas-confetti';
interface QuickWinListProps {
maxItems?: number;
compact?: boolean;
}
function calculateQuickWinScore(task: Task, threshold: number): number {
let score = 100;
// Shorter tasks = higher score
if (task.estimatedDuration) {
score += Math.max(0, (threshold - task.estimatedDuration) * 10);
} else {
// No estimate - assume quick
score += 30;
}
// Lower priority = quicker to complete (less mental load)
if (task.priority === 'low') score += 20;
if (task.priority === 'medium') score += 10;
// Today's due date = bonus
if (task.dueDate) {
const today = new Date();
const dueDate = new Date(task.dueDate);
if (
dueDate.getDate() === today.getDate() &&
dueDate.getMonth() === today.getMonth() &&
dueDate.getFullYear() === today.getFullYear()
) {
score += 30;
}
}
// Already in progress = bonus
if (task.status === 'inProgress') score += 40;
// Low energy level tasks are good quick wins
if (task.energyLevel === 'low') score += 25;
return score;
}
export function QuickWinList({ maxItems = 5, compact = false }: QuickWinListProps) {
const { t } = useTranslation();
const { settings } = useADHDMode();
const queryClient = useQueryClient();
const encouragement = useEncouragement();
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
const quickWins = useMemo(() => {
const threshold = settings.quickWinThreshold || 10;
return tasks
.filter((task) => {
// Only pending or in-progress tasks
if (task.status === 'done') return false;
// Only tasks without subtasks (leaf tasks)
if (tasks.some((t) => t.parentTaskId === task.id)) return false;
// Filter by duration threshold
if (task.estimatedDuration && task.estimatedDuration > threshold * 1.5) return false;
return true;
})
.map((task) => ({
...task,
quickWinScore: calculateQuickWinScore(task, threshold),
}))
.sort((a, b) => b.quickWinScore - a.quickWinScore)
.slice(0, maxItems);
}, [tasks, maxItems, settings.quickWinThreshold]);
// Mutation to complete task
const completeMutation = useMutation({
mutationFn: async (taskId: string) => {
const res = await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ status: 'done' }),
});
if (!res.ok) throw new Error('Failed to complete task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
// Celebrate!
confetti({
particleCount: 50,
spread: 60,
origin: { y: 0.8 },
colors: ['#10b981', '#3b82f6', '#8b5cf6'],
});
encouragement.show('quickWin', undefined, 30);
},
});
const handleComplete = (taskId: string) => {
completeMutation.mutate(taskId);
};
if (quickWins.length === 0) {
return (
<Card className={compact ? 'border-0 shadow-none' : ''}>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Sparkles className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">
{t('adhd.quickWins.noTasks')}
</p>
</CardContent>
</Card>
);
}
return (
<>
<Card className={compact ? 'border-0 shadow-none' : ''}>
{!compact && (
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Zap className="h-5 w-5 text-yellow-500" />
{t('adhd.quickWins.title')}
</CardTitle>
<CardDescription>
{t('adhd.quickWins.description')}
</CardDescription>
</CardHeader>
)}
<CardContent className={compact ? 'p-0' : ''}>
<div className="space-y-2">
<AnimatePresence mode="popLayout">
{quickWins.map((task, index) => (
<motion.div
key={task.id}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20, height: 0 }}
transition={{ delay: index * 0.05 }}
className={`
flex items-center gap-3 p-3 rounded-lg
bg-gradient-to-r from-yellow-500/5 to-orange-500/5
border border-yellow-500/20
hover:border-yellow-500/40 transition-colors
${settings.largerTargets ? 'min-h-[72px]' : ''}
`}
>
<Button
variant="ghost"
size="icon"
onClick={() => handleComplete(task.id)}
disabled={completeMutation.isPending}
className={`
shrink-0 rounded-full
hover:bg-green-500/20 hover:text-green-500
${settings.largerTargets ? 'h-12 w-12' : 'h-9 w-9'}
`}
>
<CheckCircle2 className={settings.largerTargets ? 'h-6 w-6' : 'h-5 w-5'} />
</Button>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-1">
{task.estimatedDuration && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{task.estimatedDuration} min
</span>
)}
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
+30 XP
</Badge>
</div>
</div>
<div className="shrink-0">
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</CardContent>
</Card>
<EncouragementToast
type={encouragement.type}
visible={encouragement.visible}
onDismiss={encouragement.hide}
customMessage={encouragement.message}
xpEarned={encouragement.xp}
/>
</>
);
}
@@ -0,0 +1,320 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import {
ChevronLeft,
ChevronRight,
CheckCircle2,
Clock,
Play,
SkipForward,
Focus,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { useTranslation } from 'react-i18next';
import { useADHDMode } from '../providers/ADHDModeProvider';
import { VisualTimer } from '../timer/VisualTimer';
import { FiveMinuteStarter } from './FiveMinuteStarter';
import { useEncouragement, EncouragementToast } from '../feedback/EncouragementToast';
import type { Task } from '@shared/schema';
import confetti from 'canvas-confetti';
interface SingleTaskViewProps {
onExit?: () => void;
}
export function SingleTaskView({ onExit }: SingleTaskViewProps) {
const { t } = useTranslation();
const { settings } = useADHDMode();
const queryClient = useQueryClient();
const encouragement = useEncouragement();
const [currentIndex, setCurrentIndex] = useState(0);
const [showTimer, setShowTimer] = useState(false);
const [showFiveMin, setShowFiveMin] = useState(false);
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
// Filter to actionable tasks (not done, no subtasks)
const actionableTasks = useMemo(() => {
return tasks.filter((task) => {
if (task.status === 'done') return false;
// Exclude parent tasks (tasks with subtasks)
if (tasks.some((t) => t.parentTaskId === task.id)) return false;
return true;
});
}, [tasks]);
const currentTask = actionableTasks[currentIndex];
const totalTasks = actionableTasks.length;
// Complete task mutation
const completeMutation = useMutation({
mutationFn: async (taskId: string) => {
const res = await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ status: 'done' }),
});
if (!res.ok) throw new Error('Failed to complete task');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
});
encouragement.show('taskComplete', undefined, 50);
// Move to next task if available
if (currentIndex >= actionableTasks.length - 1) {
setCurrentIndex(Math.max(0, actionableTasks.length - 2));
}
},
});
const handleComplete = () => {
if (currentTask) {
completeMutation.mutate(currentTask.id);
}
};
const handleNext = () => {
if (currentIndex < totalTasks - 1) {
setCurrentIndex(currentIndex + 1);
}
};
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
const handleSkip = () => {
handleNext();
};
if (totalTasks === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8 text-center">
<Sparkles className="h-16 w-16 text-primary mb-6" />
<h2 className="text-2xl font-bold mb-2">{t('adhd.singleTask.allDone')}</h2>
<p className="text-muted-foreground mb-6">{t('adhd.singleTask.allDoneDesc')}</p>
{onExit && (
<Button onClick={onExit} size="lg">
{t('adhd.singleTask.exit')}
</Button>
)}
</div>
);
}
return (
<>
<div className="flex flex-col items-center justify-center min-h-[70vh] p-6">
{/* Progress indicator */}
<div className="w-full max-w-md mb-8">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>{t('adhd.singleTask.task')} {currentIndex + 1} / {totalTasks}</span>
<span>{Math.round(((totalTasks - actionableTasks.length + (tasks.length - actionableTasks.length)) / tasks.length) * 100)}% {t('adhd.singleTask.complete')}</span>
</div>
<Progress value={((currentIndex + 1) / totalTasks) * 100} className="h-2" />
</div>
{/* Timer (when active) */}
<AnimatePresence>
{showTimer && currentTask?.estimatedDuration && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="mb-8"
>
<VisualTimer
duration={currentTask.estimatedDuration * 60}
onComplete={() => setShowTimer(false)}
taskTitle={currentTask.title}
size="medium"
/>
</motion.div>
)}
</AnimatePresence>
{/* 5-Minute Starter */}
<AnimatePresence>
{showFiveMin && currentTask && (
<FiveMinuteStarter
task={currentTask}
onClose={() => setShowFiveMin(false)}
onComplete={handleComplete}
/>
)}
</AnimatePresence>
{/* Task Card */}
{!showTimer && !showFiveMin && (
<AnimatePresence mode="wait">
<motion.div
key={currentTask?.id}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: settings.reducedAnimations ? 0 : 0.3 }}
className="w-full max-w-lg"
>
<div className="bg-card rounded-2xl border shadow-lg p-8">
{/* Priority badge */}
{currentTask && (
<div className="flex items-center justify-between mb-4">
<Badge
variant={
currentTask.priority === 'high'
? 'destructive'
: currentTask.priority === 'low'
? 'secondary'
: 'default'
}
>
{t(`tasks.priority.${currentTask.priority}`)}
</Badge>
{currentTask.estimatedDuration && (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{currentTask.estimatedDuration} min
</span>
)}
</div>
)}
{/* Task title */}
<h2 className="text-2xl md:text-3xl font-bold mb-4 text-center">
{currentTask?.title}
</h2>
{/* Task description */}
{currentTask?.description && (
<p className="text-muted-foreground text-center mb-6">
{currentTask.description}
</p>
)}
{/* Action buttons */}
<div className="flex flex-col gap-3">
<Button
size="lg"
onClick={handleComplete}
disabled={completeMutation.isPending}
className={`w-full ${settings.largerTargets ? 'h-14 text-lg' : 'h-12'}`}
>
<CheckCircle2 className="h-5 w-5 mr-2" />
{t('adhd.singleTask.markComplete')}
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowFiveMin(true)}
className={`flex-1 ${settings.largerTargets ? 'h-12' : 'h-10'}`}
>
<Play className="h-4 w-4 mr-2" />
{t('adhd.singleTask.fiveMin')}
</Button>
{currentTask?.estimatedDuration && (
<Button
variant="outline"
onClick={() => setShowTimer(true)}
className={`flex-1 ${settings.largerTargets ? 'h-12' : 'h-10'}`}
>
<Focus className="h-4 w-4 mr-2" />
{t('adhd.singleTask.startTimer')}
</Button>
)}
</div>
<Button
variant="ghost"
onClick={handleSkip}
disabled={currentIndex >= totalTasks - 1}
className="text-muted-foreground"
>
<SkipForward className="h-4 w-4 mr-2" />
{t('adhd.singleTask.skip')}
</Button>
</div>
</div>
</motion.div>
</AnimatePresence>
)}
{/* Navigation dots */}
<div className="flex items-center gap-4 mt-8">
<Button
variant="ghost"
size="icon"
onClick={handlePrev}
disabled={currentIndex === 0}
>
<ChevronLeft className="h-5 w-5" />
</Button>
<div className="flex gap-1.5">
{actionableTasks.slice(0, 7).map((_, index) => (
<button
key={index}
onClick={() => setCurrentIndex(index)}
className={`
w-2.5 h-2.5 rounded-full transition-all
${index === currentIndex ? 'bg-primary w-6' : 'bg-muted hover:bg-muted-foreground/50'}
`}
/>
))}
{totalTasks > 7 && (
<span className="text-xs text-muted-foreground ml-1">+{totalTasks - 7}</span>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={handleNext}
disabled={currentIndex >= totalTasks - 1}
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
{/* Exit button */}
{onExit && (
<Button
variant="link"
onClick={onExit}
className="mt-6 text-muted-foreground"
>
{t('adhd.singleTask.exit')}
</Button>
)}
</div>
<EncouragementToast
type={encouragement.type}
visible={encouragement.visible}
onDismiss={encouragement.hide}
customMessage={encouragement.message}
xpEarned={encouragement.xp}
/>
</>
);
}
+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>
);
}
@@ -0,0 +1,248 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { Loader2, Bot, RefreshCw, Download, Server } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
export function AiSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
// Fetch settings
const { data: settings, isLoading } = useQuery<Record<string, string>>({
queryKey: ['/api/admin/settings'],
});
const [provider, setProvider] = useState("openai");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("gpt-4o");
const [baseUrl, setBaseUrl] = useState("");
const [systemPrompt, setSystemPrompt] = useState("");
// Ollama specific state
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [pullModelName, setPullModelName] = useState("");
useEffect(() => {
if (settings) {
setProvider(settings.ai_provider || "openai");
setApiKey(settings.ai_api_key || "");
setModel(settings.ai_model || "gpt-4o");
setBaseUrl(settings.ai_base_url || "");
setSystemPrompt(settings.ai_system_prompt || "");
}
}, [settings]);
const mutation = useMutation({
mutationFn: async (data: any) => {
const res = await apiRequest("POST", "/api/admin/settings", data);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
toast({ title: t('settings.ai.saved', "Settings saved") });
},
onError: () => {
toast({ title: t('settings.ai.error', "Failed to save settings"), variant: "destructive" });
}
});
const fetchOllamaModelsMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/admin/ollama/tags", { baseUrl });
if (!res.ok) throw new Error("Failed to fetch models");
return res.json();
},
onSuccess: (data: any) => {
const models = data.models?.map((m: any) => m.name) || [];
setOllamaModels(models);
toast({ title: t('settings.ai.ollama.found', { count: models.length }) });
},
onError: (err: Error) => {
toast({ title: t('settings.ai.ollama.connectError', "Could not connect to Ollama"), description: err.message, variant: "destructive" });
}
});
const pullOllamaModelMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/admin/ollama/pull", { baseUrl, model: pullModelName });
if (!res.ok) throw new Error(await res.text());
return res.json();
},
onSuccess: () => {
toast({ title: t('settings.ai.ollama.pullSuccess', "Model pulled successfully") });
setPullModelName("");
fetchOllamaModelsMutation.mutate(); // Refresh list
},
onError: (err: Error) => {
toast({ title: t('settings.ai.ollama.pullError', "Failed to pull model"), description: err.message, variant: "destructive" });
}
});
const handleSave = () => {
mutation.mutate({
ai_provider: provider,
ai_api_key: apiKey,
ai_model: model,
ai_base_url: baseUrl,
ai_system_prompt: systemPrompt
});
};
if (isLoading) return <Loader2 className="animate-spin" />;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="w-5 h-5" />
{t('settings.ai.title')}
</CardTitle>
<CardDescription>{t('settings.ai.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label>{t('settings.ai.provider')}</Label>
<Select value={provider} onValueChange={(v) => {
setProvider(v);
if (v === 'openai' && model === 'claude-3-5-sonnet') setModel('gpt-4o');
if (v === 'anthropic' && model === 'gpt-4o') setModel('claude-3-5-sonnet');
if (v === 'ollama') {
setBaseUrl(baseUrl || 'http://localhost:11434');
if (!ollamaModels.length) setModel('llama3'); // Default if unknown
}
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="google">Google Gemini</SelectItem>
<SelectItem value="ollama">Ollama (Local)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.apiKey')}</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === 'ollama' ? t('settings.ai.ollama.placeholder', 'Optional for Ollama') : 'sk-...'}
/>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={provider === 'ollama' ? 'http://localhost:11434' : 'Optional override'}
/>
{provider === 'ollama' && (
<Button
variant="outline"
size="icon"
onClick={() => fetchOllamaModelsMutation.mutate()}
disabled={fetchOllamaModelsMutation.isPending}
title="Fetch Models from Server"
>
{fetchOllamaModelsMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</Button>
)}
</div>
</div>
<div className="grid gap-2">
<Label>{t('settings.ai.model')}</Label>
{provider === 'ollama' && ollamaModels.length > 0 ? (
<div className="flex gap-2">
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{ollamaModels.map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="e.g. gpt-4, llama3"
/>
)}
</div>
{provider === 'ollama' && (
<div className="pt-4 border-t space-y-3">
<Label className="text-sm font-medium flex items-center gap-2">
<Download className="w-4 h-4" />
Pull Model from Ollama Library
</Label>
<div className="flex gap-2">
<Input
placeholder="Model name (e.g. llama3, mistral)"
value={pullModelName}
onChange={(e) => setPullModelName(e.target.value)}
/>
<Button
onClick={() => pullOllamaModelMutation.mutate()}
disabled={!pullModelName || pullOllamaModelMutation.isPending}
>
{pullOllamaModelMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Pulling...
</>
) : "Pull"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Please check your server logs/Ollama console if the pull takes a long time.
Ensure the container has internet access.
</p>
</div>
)}
<div className="grid gap-2 pt-4 border-t">
<Label className="flex items-center justify-between">
{t('settings.ai.systemPrompt', 'System Prompt')}
<span className="text-xs text-muted-foreground font-normal">
Available: {"${user.username}"}, {"${currentDate...}"}, {"${context}"}
</span>
</Label>
<Textarea
className="font-mono text-sm min-h-[300px]"
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="You are TaskFlow AI..."
/>
</div>
</CardContent>
<CardFooter>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Settings
</Button>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,96 @@
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
interface AuditLog {
id: string;
userId: string | null;
action: string;
entityType: string;
entityId: string | null;
source: string;
details: any;
createdAt: string;
}
export function AuditLogsTable() {
const { t } = useTranslation();
const { data: logs, isLoading, error } = useQuery<AuditLog[]>({
queryKey: ['/api/admin/audit-logs'],
});
if (isLoading) {
return <div className="flex justify-center p-8"><Loader2 className="h-8 w-8 animate-spin" /></div>;
}
if (error) {
return <div className="p-8 text-center text-red-500">Failed to load audit logs. Please check server logs.</div>;
}
return (
<Card>
<CardHeader>
<CardTitle>{t('settings.auditLogs.title')}</CardTitle>
<CardDescription>{t('settings.auditLogs.description')}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('settings.auditLogs.table.time')}</TableHead>
<TableHead>{t('settings.auditLogs.table.source')}</TableHead>
<TableHead>{t('settings.auditLogs.table.action')}</TableHead>
<TableHead>{t('settings.auditLogs.table.entity')}</TableHead>
<TableHead>{t('settings.auditLogs.table.details')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs?.map((log) => (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap">
{new Date(log.createdAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' })}
</TableCell>
<TableCell>
<Badge variant={log.source === 'AI' ? 'secondary' : 'outline'}>
{t(`audit.source.${log.source}`, { defaultValue: log.source })}
</Badge>
</TableCell>
<TableCell className="font-medium">
{t(`audit.action.${log.action}`, { defaultValue: log.action })}
</TableCell>
<TableCell>
{t(`audit.entity.${log.entityType}`, { defaultValue: log.entityType })}
{log.entityId && <span className="text-xs text-muted-foreground block truncate max-w-[100px]">{log.entityId}</span>}
</TableCell>
<TableCell className="text-sm text-muted-foreground w-1/3">
<pre className="whitespace-pre-wrap font-mono text-xs">
{JSON.stringify(log.details, null, 2)}
</pre>
</TableCell>
</TableRow>
))}
{(!logs || logs.length === 0) && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
{t('settings.auditLogs.table.empty')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,194 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import { Loader2, Server, Save, Copy } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
export function McpSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
mcp_enabled: false,
mcp_port: "5001" // Default to main app port unless specific override logic is added
});
// Feature 1: Get User for API Key
const { data: user } = useQuery<any>({
queryKey: ['/api/user']
});
// Feature 2: API Key Mutations
const generateApiKeyMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/user/api-key", {});
return res.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: t('settings.mcp.generated') });
},
});
const revokeApiKeyMutation = useMutation({
mutationFn: async () => {
await apiRequest("DELETE", "/api/user/api-key");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: t('settings.mcp.revoked') });
},
});
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast({ title: t('settings.mcp.copied', "Copied!") });
}
const { data: settings, isLoading } = useQuery<any>({
queryKey: ['/api/admin/settings'],
});
useEffect(() => {
if (settings) {
setFormData({
mcp_enabled: settings.mcp_enabled === true,
mcp_port: settings.mcp_port || "5001"
});
}
}, [settings]);
const mutation = useMutation({
mutationFn: async (data: any) => {
const res = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to save settings");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
toast({ title: t('settings.saved', 'Settings saved') });
},
onError: () => {
toast({ title: t('settings.error', 'Failed to save settings'), variant: "destructive" });
}
});
const handleSave = () => {
mutation.mutate({
mcp_enabled: formData.mcp_enabled,
mcp_port: formData.mcp_port
});
};
if (isLoading) return <div className="flex justify-center p-4"><Loader2 className="animate-spin" /></div>;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
<CardTitle>{t('settings.mcp.title')}</CardTitle>
</div>
<CardDescription>
{t('settings.mcp.description')}
<span className="block mt-1 text-xs">{t('settings.mcp.descriptionDetail', "The MCP server runs on the same port as the application (/api/mcp).")}</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="mcp_enabled" className="flex flex-col gap-1">
<span>{t('settings.mcp.enableLabel')}</span>
<span className="font-normal text-xs text-muted-foreground">
{t('settings.mcp.enableDesc')}
</span>
</Label>
<Switch
id="mcp_enabled"
checked={formData.mcp_enabled}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, mcp_enabled: checked }))}
/>
</div>
{/* Status Indicator */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="font-medium">{t('settings.mcp.status')}</p>
</div>
<div className="flex items-center gap-2 text-green-500">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="font-medium">{t('settings.mcp.running')}</span>
</div>
</div>
{/* Endpoint URL */}
<div className="space-y-2">
<p className="text-sm font-medium">{t('settings.mcp.url')}</p>
<div className="flex gap-2">
<Input readOnly value={`${window.location.protocol}//${window.location.host}/api/mcp/sse`} />
<Button variant="outline" size="icon" onClick={() => copyToClipboard(`${window.location.protocol}//${window.location.host}/api/mcp/sse`)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{/* API Key Management */}
<div className="space-y-2">
<p className="text-sm font-medium">{t('settings.mcp.apiKey')}</p>
{user?.apiKey ? (
<div className="flex gap-2">
<Input type="password" readOnly value={user.apiKey} />
<Button variant="outline" size="icon" onClick={() => copyToClipboard(user.apiKey!)}>
<Copy className="w-4 h-4" />
</Button>
<Button variant="destructive" onClick={() => revokeApiKeyMutation.mutate()} disabled={revokeApiKeyMutation.isPending}>
{t('settings.mcp.revoke')}
</Button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t('settings.mcp.noKey')}</p>
<Button onClick={() => generateApiKeyMutation.mutate()} disabled={generateApiKeyMutation.isPending}>
{t('settings.mcp.generate')}
</Button>
</div>
)}
</div>
<div className="bg-muted/50 p-3 rounded-lg text-sm text-muted-foreground">
{t('settings.mcp.instructions')}
</div>
<div className="grid gap-2">
<Label htmlFor="mcp_port">{t('settings.mcp.portLabel')}</Label>
<Input
id="mcp_port"
value={formData.mcp_port}
onChange={(e) => setFormData(prev => ({ ...prev, mcp_port: e.target.value }))}
placeholder="5001"
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
{t('settings.mcp.portDesc')}
</p>
</div>
<Button onClick={handleSave} disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
{t('settings.save', 'Save Changes')}
</Button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,156 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Loader2, Sun, Moon } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
export function RoutineSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [morningEnabled, setMorningEnabled] = useState(true);
const [morningTime, setMorningTime] = useState("09:00");
const [eveningEnabled, setEveningEnabled] = useState(true);
const [eveningTime, setEveningTime] = useState("22:00");
const { data: settings, isLoading } = useQuery<Record<string, string>>({
queryKey: ['/api/admin/settings'],
queryFn: async () => {
const res = await apiRequest("GET", "/api/admin/settings");
if (!res.ok) throw new Error("Failed to fetch settings");
return res.json();
}
});
useEffect(() => {
if (settings) {
setMorningEnabled(settings.morning_routine_enabled !== "false");
setMorningTime(settings.morning_routine_time || "09:00");
setEveningEnabled(settings.evening_routine_enabled !== "false");
setEveningTime(settings.evening_routine_time || "22:00");
}
}, [settings]);
const mutation = useMutation({
mutationFn: async () => {
const updates = {
morning_routine_enabled: String(morningEnabled),
morning_routine_time: morningTime,
evening_routine_enabled: String(eveningEnabled),
evening_routine_time: eveningTime,
};
// We send individual updates or a bulk update?
// The backend /api/admin/settings usually accepts a map of KVs to update.
// Checking AdminSettings.tsx might verify this, but typically we post to specific keys or bulk object.
// Assuming GET returns object, POST probably takes object.
// If server routes handle bulk update.
// If not, we loop.
// Checking AiSettingsCard logic: it calls `/api/admin/settings` with JSON body.
// Assuming generic handler supports partial updates.
const res = await apiRequest("POST", "/api/admin/settings", updates);
if (!res.ok) throw new Error(await res.text());
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/admin/settings'] });
toast({ title: t('settings.routines.saved', 'Routine settings saved') });
},
onError: (err: Error) => {
toast({
title: t('settings.routines.error', 'Failed to save settings'),
description: err.message,
variant: "destructive"
});
}
});
return (
<Card>
<CardHeader>
<CardTitle>{t('settings.routines.title', 'Routine Configuration')}</CardTitle>
<CardDescription>
{t('settings.routines.description', 'Configure global morning and evening routine schedules.')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Morning Routine */}
<div className="flex flex-col gap-4 border-b pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sun className="h-5 w-5 text-orange-500" />
<Label htmlFor="morning_enabled" className="text-base font-medium">
{t('settings.routines.morningLabel', 'Morning Routine')}
</Label>
</div>
<Switch
id="morning_enabled"
checked={morningEnabled}
onCheckedChange={setMorningEnabled}
disabled={isLoading}
/>
</div>
{morningEnabled && (
<div className="pl-7">
<Label htmlFor="morning_time" className="mb-2 block text-sm text-muted-foreground">{t('settings.routines.time', 'Start Time')}</Label>
<Input
id="morning_time"
type="time"
value={morningTime}
onChange={(e) => setMorningTime(e.target.value)}
className="w-32"
/>
</div>
)}
</div>
{/* Evening Routine */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Moon className="h-5 w-5 text-indigo-500" />
<Label htmlFor="evening_enabled" className="text-base font-medium">
{t('settings.routines.eveningLabel', 'Evening Routine')}
</Label>
</div>
<Switch
id="evening_enabled"
checked={eveningEnabled}
onCheckedChange={setEveningEnabled}
disabled={isLoading}
/>
</div>
{eveningEnabled && (
<div className="pl-7">
<Label htmlFor="evening_time" className="mb-2 block text-sm text-muted-foreground">{t('settings.routines.time', 'Start Time')}</Label>
<Input
id="evening_time"
type="time"
value={eveningTime}
onChange={(e) => setEveningTime(e.target.value)}
className="w-32"
/>
</div>
)}
</div>
<div className="pt-4 flex justify-end">
<Button onClick={() => mutation.mutate()} disabled={isLoading || mutation.isPending}>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('settings.routines.save', 'Save Routines')}
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,103 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
export function SMTPSettingsCard() {
const { t } = useTranslation();
const { toast } = useToast();
const { data: settings } = useQuery<{
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
smtp_secure: boolean;
}>({
queryKey: ["/api/admin/settings"],
});
const [emailSettings, setEmailSettings] = useState({
smtp_host: '',
smtp_port: '',
smtp_user: '',
smtp_pass: '',
smtp_from: '',
smtp_secure: false
});
useEffect(() => {
if (settings) {
setEmailSettings({
smtp_host: settings.smtp_host || '',
smtp_port: settings.smtp_port || '',
smtp_user: settings.smtp_user || '',
smtp_pass: settings.smtp_pass || '',
smtp_from: settings.smtp_from || '',
smtp_secure: settings.smtp_secure || false
});
}
}, [settings]);
const updateSettingsMutation = useMutation({
mutationFn: (data: Partial<typeof settings>) => apiRequest("POST", "/api/admin/settings", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
toast({ title: t('settings.smtp.saveSuccess', "Settings updated") });
},
});
return (
<Card>
<CardHeader>
<CardTitle>{t('settings.smtp.title')}</CardTitle>
<CardDescription>{t('settings.smtp.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('settings.smtp.host')}</Label>
<Input placeholder="smtp.example.com" value={emailSettings.smtp_host} onChange={e => setEmailSettings({ ...emailSettings, smtp_host: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.port')}</Label>
<Input placeholder="587" value={emailSettings.smtp_port} onChange={e => setEmailSettings({ ...emailSettings, smtp_port: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>{t('settings.smtp.user')}</Label>
<Input placeholder="user@example.com" value={emailSettings.smtp_user} onChange={e => setEmailSettings({ ...emailSettings, smtp_user: e.target.value })} />
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.password')}</Label>
<Input type="password" placeholder="••••••" value={emailSettings.smtp_pass} onChange={e => setEmailSettings({ ...emailSettings, smtp_pass: e.target.value })} />
</div>
</div>
<div className="space-y-2">
<Label>{t('settings.smtp.from')}</Label>
<Input placeholder='"TaskFlow" <noreply@taskflow.local>' value={emailSettings.smtp_from} onChange={e => setEmailSettings({ ...emailSettings, smtp_from: e.target.value })} />
</div>
<div className="flex items-center justify-between">
<Label>{t('settings.smtp.secure')}</Label>
<Switch checked={emailSettings.smtp_secure} onCheckedChange={(c) => setEmailSettings({ ...emailSettings, smtp_secure: c })} />
</div>
<Button
onClick={() => updateSettingsMutation.mutate(emailSettings)}
disabled={updateSettingsMutation.isPending}
className="w-full"
>
{updateSettingsMutation.isPending ? t('settings.smtp.saving') : t('settings.smtp.save')}
</Button>
</CardContent>
</Card>
);
}
@@ -1,17 +0,0 @@
import BottomNavigation from '../BottomNavigation';
export default function BottomNavigationExample() {
return (
<div className="relative h-[400px] bg-muted/20">
<div className="p-4">
<h3 className="text-lg font-semibold mb-2">App Content Area</h3>
<p className="text-muted-foreground">
This is where your main app content would be displayed.
The bottom navigation is fixed at the bottom of the screen.
</p>
</div>
<BottomNavigation />
</div>
);
}
@@ -1,50 +0,0 @@
import CalendarView from '../CalendarView';
import { Task } from '../TaskCard';
import { addDays } from 'date-fns';
const mockTasks: Task[] = [
{
id: '1',
title: 'Team meeting',
status: 'todo',
priority: 'high',
dueDate: new Date(),
timeTracked: 0,
isTracking: false
},
{
id: '2',
title: 'Review design mockups',
status: 'inProgress',
priority: 'medium',
dueDate: addDays(new Date(), 2),
timeTracked: 30,
isTracking: false
},
{
id: '3',
title: 'Client presentation',
status: 'todo',
priority: 'high',
dueDate: addDays(new Date(), 5),
timeTracked: 0,
isTracking: false
},
{
id: '4',
title: 'Code review',
status: 'todo',
priority: 'low',
dueDate: addDays(new Date(), 1),
timeTracked: 0,
isTracking: false
}
];
export default function CalendarViewExample() {
return (
<div className="p-4">
<CalendarView tasks={mockTasks} />
</div>
);
}
@@ -1,75 +0,0 @@
import KanbanBoard from '../KanbanBoard';
import { Task } from '../TaskCard';
import { addDays } from 'date-fns';
const mockTasks: Task[] = [
{
id: '1',
title: 'Design new landing page',
description: 'Create wireframes and mockups for the new product landing page',
status: 'todo',
priority: 'high',
dueDate: addDays(new Date(), 3),
timeTracked: 0,
isTracking: false,
projectId: 'project-1'
},
{
id: '2',
title: 'Implement user authentication',
description: 'Set up login, registration, and password reset functionality',
status: 'inProgress',
priority: 'high',
dueDate: addDays(new Date(), 1),
timeTracked: 120,
isTracking: true,
projectId: 'project-2'
},
{
id: '3',
title: 'Write API documentation',
status: 'inProgress',
priority: 'medium',
dueDate: addDays(new Date(), 5),
timeTracked: 45,
isTracking: false,
projectId: 'project-1'
},
{
id: '4',
title: 'Set up CI/CD pipeline',
description: 'Configure automated testing and deployment',
status: 'done',
priority: 'medium',
dueDate: addDays(new Date(), -2),
timeTracked: 180,
isTracking: false,
projectId: 'project-2'
},
{
id: '5',
title: 'Review code changes',
status: 'todo',
priority: 'low',
dueDate: new Date(),
timeTracked: 0,
isTracking: false
},
{
id: '6',
title: 'Update dependencies',
status: 'done',
priority: 'low',
dueDate: addDays(new Date(), -1),
timeTracked: 30,
isTracking: false
}
];
export default function KanbanBoardExample() {
return (
<div className="p-4">
<KanbanBoard tasks={mockTasks} />
</div>
);
}
@@ -1,9 +0,0 @@
import ProjectTemplate from '../ProjectTemplate';
export default function ProjectTemplateExample() {
return (
<div className="p-4">
<ProjectTemplate />
</div>
);
}
@@ -1,34 +0,0 @@
import TaskCard from '../TaskCard';
import { Task } from '@shared/schema';
const mockTask: Task = {
id: '1',
title: 'Design mobile task interface',
description: 'Create wireframes and mockups for the mobile-first task management interface',
status: 'inProgress',
priority: 'high',
dueDate: new Date('2024-09-20'),
timeTracked: 45,
isTracking: false,
projectId: 'project-1',
notes: 'Focus on mobile-first design principles'
};
export default function TaskCardExample() {
return (
<div className="p-4 space-y-4">
<TaskCard task={mockTask} />
<TaskCard
task={{
...mockTask,
id: '2',
title: 'Quick task without details',
description: undefined,
status: 'todo',
priority: 'low',
timeTracked: 0
}}
/>
</div>
);
}
@@ -1,29 +0,0 @@
import { useState } from 'react';
import TaskCreationModal from '../TaskCreationModal';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
export default function TaskCreationModalExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="p-4">
<Button
onClick={() => setIsOpen(true)}
className="w-full flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Create New Task
</Button>
<TaskCreationModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onSave={(task) => {
console.log('Task saved:', task);
setIsOpen(false);
}}
/>
</div>
);
}
@@ -1,77 +0,0 @@
import TaskList from '../TaskList';
import { Task } from '../TaskCard';
import { addDays, subDays } from 'date-fns';
const mockTasks: Task[] = [
{
id: '1',
title: 'Review quarterly reports',
description: 'Analyze Q3 performance metrics and prepare summary',
status: 'todo',
priority: 'high',
dueDate: addDays(new Date(), 2),
timeTracked: 0,
isTracking: false,
projectId: 'project-1'
},
{
id: '2',
title: 'Update website copy',
description: 'Revise landing page content based on user feedback',
status: 'inProgress',
priority: 'medium',
dueDate: addDays(new Date(), 5),
timeTracked: 90,
isTracking: true,
projectId: 'project-2'
},
{
id: '3',
title: 'Team standup meeting',
status: 'todo',
priority: 'low',
dueDate: new Date(),
timeTracked: 0,
isTracking: false
},
{
id: '4',
title: 'Fix login bug',
description: 'Users unable to login with special characters in password',
status: 'todo',
priority: 'high',
dueDate: subDays(new Date(), 1), // Overdue
timeTracked: 30,
isTracking: false,
projectId: 'project-2'
},
{
id: '5',
title: 'Deploy new features',
status: 'done',
priority: 'medium',
dueDate: subDays(new Date(), 2),
timeTracked: 120,
isTracking: false,
projectId: 'project-1'
},
{
id: '6',
title: 'Write documentation',
description: 'Document new API endpoints for external developers',
status: 'inProgress',
priority: 'medium',
dueDate: addDays(new Date(), 7),
timeTracked: 60,
isTracking: false,
projectId: 'project-1'
}
];
export default function TaskListExample() {
return (
<div className="p-4">
<TaskList tasks={mockTasks} />
</div>
);
}
@@ -1,19 +0,0 @@
import ThemeToggle from '../ThemeToggle';
export default function ThemeToggleExample() {
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-semibold">Dark Mode</h3>
<p className="text-sm text-muted-foreground">Toggle between light and dark themes</p>
</div>
<ThemeToggle />
</div>
<div className="p-4 bg-card border rounded-lg">
<p className="text-sm">This card will change appearance when you toggle the theme.</p>
</div>
</div>
);
}
@@ -0,0 +1,121 @@
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useTranslation } from "react-i18next";
import { Loader2, Coffee, Gamepad2, Palette, Gift, type LucideIcon } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { useState } from "react";
// We don't have types shared for specific API responses yet, so using any for now or defining locally
type Reward = {
id: string;
title: string;
description: string | null;
cost: number;
icon: string;
type: string;
owned?: boolean;
};
type RewardCardProps = {
reward: Reward;
userXp: number;
userId: string;
};
const iconMap: Record<string, LucideIcon> = {
"coffee": Coffee,
"gamepad-2": Gamepad2,
"palette": Palette,
"gift": Gift
};
export const RewardCard = ({ reward, userXp, userId }: RewardCardProps) => {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const canAfford = userXp >= reward.cost;
const isOwned = reward.owned;
const IconComponent = iconMap[reward.icon] || Gift; // Fallback
const handleBuy = async () => {
if (!canAfford && !isOwned) return;
setIsLoading(true);
try {
const res = await apiRequest("POST", "/api/rewards/purchase", {
rewardId: reward.id
});
await res.json();
toast({
title: t("rewards.buySuccess"), // Need to add this key or use generic
description: t("rewards.itemPurchased", { item: t(reward.title) }),
});
// Refetch rewards and user (to update XP)
queryClient.invalidateQueries({ queryKey: ["/api/rewards"] });
queryClient.invalidateQueries({ queryKey: ["/api/user"] }); // Assuming user fetch uses this key
queryClient.invalidateQueries({ queryKey: ["/api/users/current"] }); // Or this
} catch (error) {
toast({
title: t("rewards.buyFailed"),
description: t("rewards.insufficientFunds"),
variant: "destructive"
});
} finally {
setIsLoading(false);
}
};
return (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card className={`relative overflow-hidden p-6 transition-colors ${isOwned ? "bg-primary/10 border-primary/20" : "bg-card hover:border-primary/50"
}`}>
<div className="flex flex-col items-center gap-4 text-center">
<div className={`p-4 rounded-full ${isOwned ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
}`}>
<IconComponent className="h-8 w-8" />
</div>
<div className="space-y-1">
<h3 className="font-bold text-lg">{t(reward.title)}</h3>
<p className="text-sm text-muted-foreground">{t(reward.description || "")}</p>
</div>
<div className="mt-2 w-full">
{isOwned ? (
<Button variant="outline" className="w-full cursor-default disabled:opacity-100" disabled>
{t("rewards.owned")}
</Button>
) : (
<Button
className="w-full gap-2"
disabled={!canAfford || isLoading}
onClick={handleBuy}
variant={canAfford ? "default" : "secondary"}
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
{t("rewards.buy")}
<span className="font-mono text-xs bg-black/20 px-2 py-0.5 rounded">
{reward.cost} XP
</span>
</>
)}
</Button>
)}
</div>
</div>
</Card>
</motion.div>
);
};
+10 -7
View File
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"
const badgeVariants = cva(
// Whitespace-nowrap: Badges should never wrap.
"whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" +
" hover-elevate " ,
" hover-elevate ",
{
variants: {
variant: {
@@ -27,12 +27,15 @@ const badgeVariants = cva(
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => {
return (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
)
Badge.displayName = "Badge"
export { Badge, badgeVariants }
+4 -3
View File
@@ -6,12 +6,12 @@ import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0" +
" hover-elevate active-elevate-2",
" hover-elevate active-elevate-2 transition-all duration-200 active:scale-95 hover:scale-105",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground border border-primary-border",
"bg-gradient-to-r from-violet-600 to-indigo-600 text-primary-foreground border border-primary-border shadow-md hover:shadow-lg hover:from-violet-500 hover:to-indigo-500",
destructive:
"bg-destructive text-destructive-foreground border border-destructive-border",
outline:
@@ -21,6 +21,7 @@ const buttonVariants = cva(
secondary: "border bg-secondary text-secondary-foreground border border-secondary-border ",
// Add a transparent border so that when someone toggles a border on later, it doesn't shift layout/size.
ghost: "border border-transparent",
link: "text-primary underline-offset-4 hover:underline",
},
// Heights are set as "min" heights, because sometimes Ai will place large amount of content
// inside buttons. With a min-height they will look appropriate with small amounts of content,
@@ -41,7 +42,7 @@ const buttonVariants = cva(
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
+1
View File
@@ -16,6 +16,7 @@ function Calendar({
return (
<DayPicker
showOutsideDays={showOutsideDays}
showWeekNumber={true}
weekStartsOn={1}
className={cn("p-3", className)}
classNames={{
+12 -10
View File
@@ -13,16 +13,18 @@ const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
+10 -10
View File
@@ -3,7 +3,7 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { PanelLeftIcon, Menu } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
@@ -29,7 +29,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_WIDTH_ICON = "3.75rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
@@ -170,7 +170,7 @@ function Sidebar({
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
"bg-sidebar/80 backdrop-blur-xl border-r border-sidebar-border/50 text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
className
)}
{...props}
@@ -229,7 +229,7 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-50 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -244,7 +244,7 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
className="bg-sidebar/80 backdrop-blur-xl group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
@@ -273,7 +273,7 @@ function SidebarTrigger({
}}
{...props}
>
<PanelLeftIcon />
<Menu className="size-5" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
@@ -310,7 +310,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"bg-background relative flex min-w-0 flex-1 flex-col overflow-x-hidden",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
@@ -375,7 +375,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto",
className
)}
{...props}
@@ -475,7 +475,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:w-8! group-data-[collapsible=icon]:h-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:w-12! group-data-[collapsible=icon]:h-12! group-data-[collapsible=icon]:p-2! group-data-[collapsible=icon]:justify-center [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -570,7 +570,7 @@ function SidebarMenuAction({
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
@@ -0,0 +1,117 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Download, Loader2, FileJson } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { User } from "@shared/schema";
interface DataExportCardProps {
user: User | undefined | null;
}
export function DataExportCard({ user }: DataExportCardProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [includeTasks, setIncludeTasks] = useState(true);
const [includeLabels, setIncludeLabels] = useState(true);
const [includeSettings, setIncludeSettings] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const handleExport = async () => {
try {
setIsExporting(true);
const response = await apiRequest("POST", "/api/user/data-export", {
includeTasks,
includeLabels,
includeSettings
});
if (!response.ok) throw new Error("Export failed");
// Handle blob download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `taskflow_export_${user?.username}_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast({ title: t('settings.export.success', "Data exported successfully") });
} catch (error) {
console.error(error);
toast({
title: t('settings.export.error', "Failed to export data"),
variant: "destructive"
});
} finally {
setIsExporting(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileJson className="w-5 h-5" />
{t('settings.export.title')}
</CardTitle>
<CardDescription>
{t('settings.export.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="tasks"
checked={includeTasks}
onCheckedChange={(c) => setIncludeTasks(!!c)}
/>
<Label htmlFor="tasks">{t('settings.export.tasks')}</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="labels"
checked={includeLabels}
onCheckedChange={(c) => setIncludeLabels(!!c)}
/>
<Label htmlFor="labels">{t('settings.export.labels')}</Label>
</div>
{user?.role === 'admin' && (
<div className="flex items-center space-x-2">
<Checkbox
id="settings"
checked={includeSettings}
onCheckedChange={(c) => setIncludeSettings(!!c)}
/>
<Label htmlFor="settings">
{t('settings.export.systemSettings')}
</Label>
</div>
)}
</CardContent>
<CardFooter>
<Button
onClick={handleExport}
disabled={isExporting || (!includeTasks && !includeLabels && !includeSettings)}
data-testid="button-export-data"
>
{isExporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{t('settings.export.button')}
</Button>
</CardFooter>
</Card>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next';
import { enUS, de } from 'date-fns/locale';
export const useDateLocale = () => {
const { i18n } = useTranslation();
const getDateLocale = () => {
if (i18n.language && i18n.language.startsWith('de')) {
return de;
}
return enUS;
};
return getDateLocale();
};
+286
View File
@@ -0,0 +1,286 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Task } from '@shared/schema';
import { useTranslation } from 'react-i18next';
import { formatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
// Convert VAPID public key from base64 to Uint8Array (required for subscription)
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Check if push notifications are supported
function isPushSupported(): boolean {
return 'serviceWorker' in navigator && 'PushManager' in window;
}
// Check if running as installed PWA (standalone)
function isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}
export function useNotifications({ poll = true }: { poll?: boolean } = {}) {
const { t, i18n } = useTranslation();
const [permission, setPermission] = useState<NotificationPermission>('default');
const [pushSupported, setPushSupported] = useState(false);
const [pushSubscription, setPushSubscription] = useState<PushSubscription | null>(null);
const [serviceWorkerReady, setServiceWorkerReady] = useState(false);
const [enabled, setEnabled] = useState(false);
// Track notified tasks to prevent duplicate alerts in same session
const notifiedTasksRef = useRef<Set<string>>(new Set());
// Check support and register service worker on mount
useEffect(() => {
const initializePush = async () => {
if (!isPushSupported()) {
console.log('[Notifications] Push not supported');
setPushSupported(false);
return;
}
setPushSupported(true);
try {
// Register service worker
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('[Notifications] Service worker registered:', registration.scope);
// Wait for service worker to be ready
await navigator.serviceWorker.ready;
setServiceWorkerReady(true);
// Check current subscription
const subscription = await registration.pushManager.getSubscription();
setPushSubscription(subscription);
// Update permission state
if ('Notification' in window) {
setPermission(Notification.permission);
const isEnabled = localStorage.getItem('taskflow-push-enabled') === 'true';
setEnabled(isEnabled && Notification.permission === 'granted' && subscription !== null);
}
} catch (error) {
console.error('[Notifications] Service worker registration failed:', error);
}
};
initializePush();
}, []);
// Fetch VAPID public key
const { data: vapidData } = useQuery<{ publicKey: string }>({
queryKey: ['/api/push/vapid-public-key'],
enabled: serviceWorkerReady && pushSupported,
retry: false,
staleTime: Infinity, // VAPID key doesn't change
});
// Subscribe mutation
const subscribeMutation = useMutation({
mutationFn: async (subscription: PushSubscription) => {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription: subscription.toJSON() }),
});
if (!response.ok) throw new Error('Failed to subscribe');
return response.json();
},
onSuccess: () => {
localStorage.setItem('taskflow-push-enabled', 'true');
setEnabled(true);
},
});
// Unsubscribe mutation
const unsubscribeMutation = useMutation({
mutationFn: async (endpoint: string) => {
const response = await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
});
if (!response.ok) throw new Error('Failed to unsubscribe');
return response.json();
},
onSuccess: () => {
localStorage.setItem('taskflow-push-enabled', 'false');
setEnabled(false);
setPushSubscription(null);
},
});
// Request permission and subscribe to push
const requestPermission = useCallback(async (): Promise<boolean> => {
if (!pushSupported || !serviceWorkerReady || !vapidData?.publicKey) {
console.log('[Notifications] Cannot request permission - prerequisites not met');
// Fallback to basic notification permission
if ('Notification' in window) {
const result = await Notification.requestPermission();
setPermission(result);
return result === 'granted';
}
return false;
}
try {
// Request notification permission
const permissionResult = await Notification.requestPermission();
setPermission(permissionResult);
if (permissionResult !== 'granted') {
console.log('[Notifications] Permission denied');
return false;
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Check for existing subscription
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Create new subscription
const applicationServerKey = urlBase64ToUint8Array(vapidData.publicKey);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
console.log('[Notifications] New push subscription created');
}
setPushSubscription(subscription);
// Send subscription to server
await subscribeMutation.mutateAsync(subscription);
return true;
} catch (error) {
console.error('[Notifications] Failed to subscribe:', error);
return false;
}
}, [pushSupported, serviceWorkerReady, vapidData?.publicKey, subscribeMutation]);
// Toggle notifications on/off
const toggleEnabled = useCallback(async (value: boolean) => {
if (value) {
// Enable notifications
if (permission !== 'granted' || !pushSubscription) {
await requestPermission();
} else {
setEnabled(true);
localStorage.setItem('taskflow-push-enabled', 'true');
}
} else {
// Disable notifications
if (pushSubscription) {
try {
await pushSubscription.unsubscribe();
await unsubscribeMutation.mutateAsync(pushSubscription.endpoint);
} catch (error) {
console.error('[Notifications] Failed to unsubscribe:', error);
}
}
setEnabled(false);
localStorage.setItem('taskflow-push-enabled', 'false');
}
}, [permission, pushSubscription, requestPermission, unsubscribeMutation]);
// Send test notification
const sendTestNotification = useCallback(async () => {
try {
const response = await fetch('/api/push/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return response.ok;
} catch {
return false;
}
}, []);
// Query tasks for fallback polling (when push isn't available or app is open)
const { data: tasks } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
enabled: enabled && poll && !pushSupported // Only poll if push isn't supported
});
// Fallback Polling Logic (for when push isn't available)
useEffect(() => {
if (!poll || !enabled || !tasks || permission !== 'granted' || pushSupported) return;
const checkTasks = () => {
const now = new Date();
tasks.forEach(task => {
if (!task.dueDate || task.status === 'done' || notifiedTasksRef.current.has(task.id)) return;
const dueDate = new Date(task.dueDate);
const diffMs = dueDate.getTime() - now.getTime();
const diffMinutes = diffMs / (1000 * 60);
// Alert: Upcoming (15 min before)
if (diffMinutes > 0 && diffMinutes <= 15) {
sendNotification(task, 'upcoming');
notifiedTasksRef.current.add(task.id);
}
// Alert: Just Overdue
if (diffMinutes < 0) {
sendNotification(task, 'overdue');
notifiedTasksRef.current.add(task.id);
}
});
};
const sendNotification = (task: Task, type: 'upcoming' | 'overdue') => {
const title = type === 'upcoming'
? t('notifications.upcomingTitle', { task: task.title })
: t('notifications.overdueTitle', { task: task.title });
const body = type === 'upcoming'
? t('notifications.upcomingBody', { time: formatDistanceToNow(new Date(task.dueDate!), { locale: i18n.language === 'de' ? de : enUS }) })
: t('notifications.overdueBody');
new Notification(title, {
body,
tag: `task-${task.id}-${type}`
});
};
checkTasks();
const interval = setInterval(checkTasks, 60000);
return () => clearInterval(interval);
}, [enabled, tasks, permission, t, i18n.language, poll, pushSupported]);
return {
permission,
enabled,
pushSupported,
pushSubscription,
isStandalone: isStandalone(),
serviceWorkerReady,
requestPermission,
toggleEnabled,
sendTestNotification,
isLoading: subscribeMutation.isPending || unsubscribeMutation.isPending,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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"
}
}
}
+280 -3
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);
@@ -48,7 +54,8 @@
--popover-border: 220 13% 85%;
--primary: 210 100% 56%;
/* Premium Violet/Indigo Base */
--primary: 250 95% 64%;
--primary-foreground: 210 17% 98%;
@@ -76,10 +83,11 @@
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--font-sans: Inter, Open Sans, sans-serif;
--font-sans: "Inter", sans-serif;
--font-heading: "Outfit", sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: .5rem; /* 8px */
--radius: 1rem; /* 16px - Softer look */
--shadow-2xs: 0px 2px 0px 0px hsl(220 13% 91% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(220 13% 91% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(220 13% 91% / 0.00), 0px 1px 2px -1px hsl(220 13% 91% / 0.00);
@@ -325,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%
);
}
+82
View File
@@ -0,0 +1,82 @@
interface SubtaskTemplate {
keywords: string[];
subtasks: string[];
}
const TEMPLATES: SubtaskTemplate[] = [
{
keywords: ['vacation', 'trip', 'travel', 'holiday', 'flight'],
subtasks: [
'- [ ] Research destination and dates',
'- [ ] Book flights',
'- [ ] Reserve accommodation',
'- [ ] Check passport validity',
'- [ ] Create packing list',
'- [ ] Arrange transport to airport'
]
},
{
keywords: ['feature', 'coding', 'code', 'bug', 'fix', 'implement', 'dev'],
subtasks: [
'- [ ] Analyze requirements',
'- [ ] Design technical approach',
'- [ ] Write implementation code',
'- [ ] Write unit tests',
'- [ ] Perform manual verification',
'- [ ] Open Pull Request'
]
},
{
keywords: ['groceries', 'shop', 'buy', 'store', 'market'],
subtasks: [
'- [ ] Check fridge/pantry inventory',
'- [ ] Plan meals for the week',
'- [ ] Write shopping list',
'- [ ] Bring reusable bags',
'- [ ] Go to store'
]
},
{
keywords: ['meeting', 'call', 'sync', 'discussion', 'interview'],
subtasks: [
'- [ ] Prepare agenda',
'- [ ] Review background materials',
'- [ ] Set up video conference link',
'- [ ] Take notes during meeting',
'- [ ] Send follow-up action items'
]
},
{
keywords: ['clean', 'tidy', 'organize', 'house', 'chore'],
subtasks: [
'- [ ] Gather cleaning supplies',
'- [ ] Declutter surface areas',
'- [ ] Dust and wipe down',
'- [ ] Vacuum/Sweep floors',
'- [ ] Take out trash'
]
}
];
const GENERIC_SUBTASKS = [
'- [ ] Define success criteria',
'- [ ] Break down into smaller steps',
'- [ ] Execute first step',
'- [ ] Review progress',
'- [ ] Mark as complete'
];
export async function simulateAIDecomposition(taskTitle: string): Promise<string> {
// Simulate network latency for "AI" feel
await new Promise(resolve => setTimeout(resolve, 1500));
const lowerTitle = taskTitle.toLowerCase();
const match = TEMPLATES.find(t =>
t.keywords.some(k => lowerTitle.includes(k))
);
const steps = match ? match.subtasks : GENERIC_SUBTASKS;
return `\n\n### 🪄 AI Suggested Steps:\n${steps.join('\n')}`;
}
+86
View File
@@ -0,0 +1,86 @@
export class AmbientSoundPlayer {
private audioCtx: AudioContext | null = null;
private noiseSource: AudioBufferSourceNode | null = null;
private gainNode: GainNode | null = null;
private isPlaying = false;
constructor() {
// Initialize audio context only on user interaction to comply with browser policies
}
private initContext() {
if (!this.audioCtx) {
this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
}
}
private createBrownNoiseBuffer(): AudioBuffer {
if (!this.audioCtx) throw new Error("No Audio Context");
const bufferSize = this.audioCtx.sampleRate * 2; // 2 seconds buffer
const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate);
const data = buffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < bufferSize; i++) {
const white = Math.random() * 2 - 1;
data[i] = (lastOut + (0.02 * white)) / 1.02;
lastOut = data[i];
data[i] *= 3.5; // Compensate for gain loss
}
return buffer;
}
public toggle(type: 'brown' | 'white' = 'brown'): boolean {
if (this.isPlaying) {
this.stop();
return false;
} else {
this.play(type);
return true;
}
}
public play(type: 'brown' | 'white' = 'brown') {
this.initContext();
if (!this.audioCtx) return;
// Resume if suspended (browser autoplay policy)
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume();
}
this.stop(); // Stop any current sound
this.noiseSource = this.audioCtx.createBufferSource();
this.noiseSource.buffer = this.createBrownNoiseBuffer(); // Currently only brown noise implemented efficiently
this.noiseSource.loop = true;
this.gainNode = this.audioCtx.createGain();
this.gainNode.gain.value = 0.5; // Default volume
this.noiseSource.connect(this.gainNode);
this.gainNode.connect(this.audioCtx.destination);
// Fade in
this.gainNode.gain.setValueAtTime(0, this.audioCtx.currentTime);
this.gainNode.gain.linearRampToValueAtTime(0.5, this.audioCtx.currentTime + 1);
this.noiseSource.start();
this.isPlaying = true;
}
public stop() {
if (this.noiseSource && this.gainNode && this.audioCtx) {
// Fade out
this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + 0.5);
setTimeout(() => {
this.noiseSource?.stop();
this.noiseSource = null;
}, 500);
}
this.isPlaying = false;
}
}
export const ambientPlayer = new AmbientSoundPlayer();
+37
View File
@@ -0,0 +1,37 @@
import confetti from 'canvas-confetti';
export const triggerConfetti = (x: number, y: number) => {
const defaults = { origin: { x, y } };
const count = 200;
function fire(particleRatio: number, opts: confetti.Options) {
confetti({
...defaults,
...opts,
particleCount: Math.floor(count * particleRatio),
});
}
fire(0.25, {
spread: 26,
startVelocity: 55,
});
fire(0.2, {
spread: 60,
});
fire(0.35, {
spread: 100,
decay: 0.91,
scalar: 0.8,
});
fire(0.1, {
spread: 120,
startVelocity: 25,
decay: 0.92,
scalar: 1.2,
});
fire(0.1, {
spread: 120,
startVelocity: 45,
});
};

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