diff --git a/Dockerfile b/Dockerfile index 6ddd9a3..4e80c1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app # Dependencies stage FROM base AS dependencies COPY package.json package-lock.json ./ -RUN npm ci --only=production && \ +RUN npm ci --omit=dev && \ cp -R node_modules /tmp/prod_node_modules && \ npm ci diff --git a/client/src/App.tsx b/client/src/App.tsx index 1e6ac8b..79d75ea 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -16,6 +16,17 @@ import { import { Switch, Route, useLocation } from "wouter"; +// ADHD Mode Components +import { + ADHDModeProvider, + useADHDMode, + BreakReminderProvider, + BreakReminderOverlay, + EnergyCheckIn, + HyperfocusGuard, + EncouragementToast +} from '@/components/adhd'; + // Components import TaskCreationModal from './components/TaskCreationModal'; import TaskDetailsModal from './components/TaskDetailsModal'; @@ -61,11 +72,19 @@ import { Loader2 } from "lucide-react"; import TimeTrackingPage from './pages/TimeTrackingPage'; -function App() { +// ADHD Pages +import QuickWinsPage from './pages/QuickWinsPage'; +import SingleTaskPage from './pages/SingleTaskPage'; +import BodyDoublingPage from './pages/BodyDoublingPage'; +import ADHDDashboardPage from './pages/ADHDDashboardPage'; + +// Inner App component that uses ADHD hooks +function AppContent() { const { t } = useTranslation(); const { toast } = useToast(); const [, setLocation] = useLocation(); const isBlocked = useRoutineBlocker(); + const { isEnabled: adhdEnabled, settings: adhdSettings } = useADHDMode(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false); @@ -317,7 +336,7 @@ function App() { -
+
@@ -407,6 +426,9 @@ function App() { + + + setLocation('/templates')} /> @@ -420,6 +442,13 @@ function App() { )} + + {/* ADHD Mode Routes */} + + + + +
@@ -450,6 +479,11 @@ function App() { + {/* ADHD Mode Overlays */} + {adhdEnabled && } + {adhdEnabled && adhdSettings.hyperfocusProtection && } + {adhdEnabled && adhdSettings.positiveMessagingLevel !== 'off' && } + setIsCreateModalOpen(false)} @@ -499,5 +533,16 @@ function App() { } +// Main App wrapper with ADHD providers +function App() { + return ( + + + + + + ); +} + export default App; diff --git a/client/src/components/AppSidebar.tsx b/client/src/components/AppSidebar.tsx index ca0e2ce..e922d50 100644 --- a/client/src/components/AppSidebar.tsx +++ b/client/src/components/AppSidebar.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; -import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff, Bell, Clock } from 'lucide-react'; +import { Home, Calendar, List, LayoutGrid, Settings, CheckSquare, Target, Trophy, LogOut, Award, Bot, CalendarOff, Bell, Clock, Brain } from 'lucide-react'; import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'; import { useToast } from "@/hooks/use-toast"; import { Button } from "@/components/ui/button"; @@ -58,6 +58,7 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) { { title: t('navigation.weekList'), id: 'weeklist', path: '/weeklist', icon: List, color: 'text-pink-500' }, { title: t('unscheduled.title', 'Unscheduled'), id: 'unscheduled', path: '/unscheduled', icon: CalendarOff, color: 'text-slate-500' }, { title: t('navigation.kanban'), id: 'kanban', path: '/kanban', icon: LayoutGrid, color: 'text-orange-500' }, + { title: t('navigation.adhd', 'ADHD Mode'), id: 'adhd', path: '/adhd', icon: Brain, color: 'text-purple-500' }, { title: t('navigation.achievements'), id: 'achievements', path: '/achievements', icon: Trophy, color: 'text-yellow-500' }, { title: t('navigation.leaderboard'), id: 'leaderboard', path: '/leaderboard', icon: Award, color: 'text-yellow-500' }, { title: t('analytics.timeTracking'), id: 'time-tracking', path: '/time-tracking', icon: Clock, color: 'text-teal-500' }, diff --git a/client/src/components/adhd/ADHDModeToggle.tsx b/client/src/components/adhd/ADHDModeToggle.tsx new file mode 100644 index 0000000..f518d9d --- /dev/null +++ b/client/src/components/adhd/ADHDModeToggle.tsx @@ -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 ( + + + + + + +

{isEnabled ? t('adhd.modeEnabled') : t('adhd.modeDisabled')}

+
+
+
+ ); + } + + return ( +
+
+ +
+ +
+ {showLabel && ( + <> +
+ {t('adhd.mode')} + {isEnabled && ( + + {t('adhd.active')} + + )} +
+

+ {t('adhd.modeDescription')} +

+ + )} +
+ + +
+ ); +} diff --git a/client/src/components/adhd/ai/TaskBreakdownModal.tsx b/client/src/components/adhd/ai/TaskBreakdownModal.tsx new file mode 100644 index 0000000..94f11b9 --- /dev/null +++ b/client/src/components/adhd/ai/TaskBreakdownModal.tsx @@ -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(null); + const [selectedSubtasks, setSelectedSubtasks] = useState>(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; + }, + 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 ( + + + + + + {t('adhd.taskBreakdown.title')} + + + {t('adhd.taskBreakdown.description')} + + + + {/* Task info */} +
+

{task.title}

+ {task.description && ( +

{task.description}

+ )} + {task.estimatedDuration && ( +
+ + {task.estimatedDuration} min {t('adhd.taskBreakdown.estimated')} +
+ )} +
+ + {/* Generate button or results */} + {!breakdown ? ( +
+

+ {t('adhd.taskBreakdown.prompt')} +

+ +
+ ) : ( +
+ {/* Encouragement */} + {breakdown.encouragement && ( + + {breakdown.encouragement} + + )} + + {/* Subtask list */} +
+ + {breakdown.subtasks.map((subtask, index) => ( + + handleToggleSubtask(index)} + className="mt-0.5" + /> +
+

{subtask.title}

+
+ + ~{subtask.estimatedMinutes} min +
+
+
+ ))} +
+
+ + {/* Total time */} +
+ {t('adhd.taskBreakdown.totalTime')} + + {breakdown.subtasks + .filter((_, i) => selectedSubtasks.has(i)) + .reduce((sum, s) => sum + s.estimatedMinutes, 0)} min + +
+ + {/* Actions */} +
+ + +
+
+ )} + + {/* Error state */} + {breakdownMutation.isError && ( +
+ + {t('adhd.taskBreakdown.error')} +
+ )} +
+
+ ); +} diff --git a/client/src/components/adhd/feedback/BreakReminderOverlay.tsx b/client/src/components/adhd/feedback/BreakReminderOverlay.tsx new file mode 100644 index 0000000..d51d758 --- /dev/null +++ b/client/src/components/adhd/feedback/BreakReminderOverlay.tsx @@ -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 ( + + + {/* Dismiss button */} + + +
+ {/* Header */} +
+
+ +
+
+

{t('adhd.breakReminder.title')}

+

+ {t('adhd.breakReminder.workingFor', { minutes: minutesSinceBreak })} +

+
+
+ + {/* Message */} +

+ {t('adhd.breakReminder.message')} +

+ + {/* Break suggestions */} +
+ {breakSuggestions.map((suggestion) => { + const Icon = suggestion.icon; + return ( + + ); + })} +
+ + {/* Snooze options */} +
+ + +
+ + {/* Gentle message */} +

+ {t('adhd.breakReminder.gentle')} +

+
+
+
+ ); +} diff --git a/client/src/components/adhd/feedback/EncouragementToast.tsx b/client/src/components/adhd/feedback/EncouragementToast.tsx new file mode 100644 index 0000000..ddde556 --- /dev/null +++ b/client/src/components/adhd/feedback/EncouragementToast.tsx @@ -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> = { + taskComplete: Star, + returning: Sun, + struggling: Heart, + streakBroken: Heart, + milestone: Trophy, + quickWin: Sparkles, +}; + +const colors: Record = { + 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 ( + + {visible && ( + +
+
+ +
+
+

{message}

+ {xpEarned && xpEarned > 0 && ( +

+ +{xpEarned} XP +

+ )} +
+
+ + {/* Decorative sparkles for high messaging level */} + {settings.positiveMessagingLevel === 'high' && !settings.reducedAnimations && ( + <> + + + + + + + + )} +
+ )} +
+ ); +} + +// Hook to manage encouragement state +export function useEncouragement() { + const [visible, setVisible] = React.useState(false); + const [type, setType] = React.useState('taskComplete'); + const [message, setMessage] = React.useState(); + const [xp, setXp] = React.useState(); + + 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 }; +} diff --git a/client/src/components/adhd/focus/FiveMinuteStarter.tsx b/client/src/components/adhd/focus/FiveMinuteStarter.tsx new file mode 100644 index 0000000..b4148ac --- /dev/null +++ b/client/src/components/adhd/focus/FiveMinuteStarter.tsx @@ -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('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 ( + +
+ {/* Close button */} + + + + {phase === 'ready' && ( + +
+ +
+ +

+ {t('adhd.fiveMin.title')} +

+

+ {task.title} +

+

+ {t('adhd.fiveMin.prompt')} +

+ + + +

+ {t('adhd.fiveMin.noObligation')} +

+
+ )} + + {phase === 'running' && ( + + {/* Visual progress ring */} +
+ + + + +
+ + {formatTime(timeLeft)} + + + {t('adhd.fiveMin.remaining')} + +
+
+ +

+ {task.title} +

+ +
+ + +
+ +

+ {t('adhd.fiveMin.encouragement')} +

+
+ )} + + {phase === 'done' && ( + +
+ +
+ +

+ {t('adhd.fiveMin.congrats')} +

+

+ {t('adhd.fiveMin.fiveMinDone')} +

+ +
+ + +
+ + +
+
+ +

+ +10 XP {t('adhd.fiveMin.earned')} +

+
+ )} +
+
+
+ ); +} diff --git a/client/src/components/adhd/focus/HyperfocusGuard.tsx b/client/src/components/adhd/focus/HyperfocusGuard.tsx new file mode 100644 index 0000000..4edbfc6 --- /dev/null +++ b/client/src/components/adhd/focus/HyperfocusGuard.tsx @@ -0,0 +1,184 @@ +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(null); + const [minutesOnTask, setMinutesOnTask] = useState(0); + const [showWarning, setShowWarning] = useState(false); + const [dismissed, setDismissed] = useState(false); + + const { data: tasks = [] } = useQuery({ + 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 ( + + +
+ {/* Header */} +
+
+ +
+
+

+ {t('adhd.hyperfocus.title')} +

+

+ {t('adhd.hyperfocus.message', { minutes: minutesOnTask })} +

+
+ +
+ + {/* Stats */} +
+
+ + {minutesOnTask} {t('adhd.hyperfocus.minutes')} +
+ {urgentTasks.length > 0 && ( +
+ + {urgentTasks.length} {t('adhd.hyperfocus.urgentTasks')} +
+ )} +
+ + {/* Urgent tasks preview */} + {urgentTasks.length > 0 && ( +
+

+ {t('adhd.hyperfocus.waitingTasks')}: +

+ {urgentTasks.slice(0, 3).map((task) => ( + + ))} +
+ )} + + {/* Actions */} +
+ + +
+ + {/* Gentle reminder */} +

+ {t('adhd.hyperfocus.gentle')} +

+
+
+
+ ); +} diff --git a/client/src/components/adhd/focus/QuickWinList.tsx b/client/src/components/adhd/focus/QuickWinList.tsx new file mode 100644 index 0000000..0a9daf0 --- /dev/null +++ b/client/src/components/adhd/focus/QuickWinList.tsx @@ -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({ + 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 ( + + + +

+ {t('adhd.quickWins.noTasks')} +

+
+
+ ); + } + + return ( + <> + + {!compact && ( + + + + {t('adhd.quickWins.title')} + + + {t('adhd.quickWins.description')} + + + )} + +
+ + {quickWins.map((task, index) => ( + + + +
+

{task.title}

+
+ {task.estimatedDuration && ( + + + {task.estimatedDuration} min + + )} + + +30 XP + +
+
+ +
+ +
+
+ ))} +
+
+
+
+ + + + ); +} diff --git a/client/src/components/adhd/focus/SingleTaskView.tsx b/client/src/components/adhd/focus/SingleTaskView.tsx new file mode 100644 index 0000000..1ec57e2 --- /dev/null +++ b/client/src/components/adhd/focus/SingleTaskView.tsx @@ -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({ + 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 ( +
+ +

{t('adhd.singleTask.allDone')}

+

{t('adhd.singleTask.allDoneDesc')}

+ {onExit && ( + + )} +
+ ); + } + + return ( + <> +
+ {/* Progress indicator */} +
+
+ {t('adhd.singleTask.task')} {currentIndex + 1} / {totalTasks} + {Math.round(((totalTasks - actionableTasks.length + (tasks.length - actionableTasks.length)) / tasks.length) * 100)}% {t('adhd.singleTask.complete')} +
+ +
+ + {/* Timer (when active) */} + + {showTimer && currentTask?.estimatedDuration && ( + + setShowTimer(false)} + taskTitle={currentTask.title} + size="medium" + /> + + )} + + + {/* 5-Minute Starter */} + + {showFiveMin && currentTask && ( + setShowFiveMin(false)} + onComplete={handleComplete} + /> + )} + + + {/* Task Card */} + {!showTimer && !showFiveMin && ( + + +
+ {/* Priority badge */} + {currentTask && ( +
+ + {t(`tasks.priority.${currentTask.priority}`)} + + {currentTask.estimatedDuration && ( + + + {currentTask.estimatedDuration} min + + )} +
+ )} + + {/* Task title */} +

+ {currentTask?.title} +

+ + {/* Task description */} + {currentTask?.description && ( +

+ {currentTask.description} +

+ )} + + {/* Action buttons */} +
+ + +
+ + + {currentTask?.estimatedDuration && ( + + )} +
+ + +
+
+
+
+ )} + + {/* Navigation dots */} +
+ + +
+ {actionableTasks.slice(0, 7).map((_, index) => ( +
+ + +
+ + {/* Exit button */} + {onExit && ( + + )} +
+ + + + ); +} diff --git a/client/src/components/adhd/index.ts b/client/src/components/adhd/index.ts new file mode 100644 index 0000000..523bf57 --- /dev/null +++ b/client/src/components/adhd/index.ts @@ -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'; diff --git a/client/src/components/adhd/providers/ADHDModeProvider.tsx b/client/src/components/adhd/providers/ADHDModeProvider.tsx new file mode 100644 index 0000000..7e47d23 --- /dev/null +++ b/client/src/components/adhd/providers/ADHDModeProvider.tsx @@ -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) => void; + isLoading: boolean; +} + +const ADHDModeContext = createContext(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(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) => { + const merged = { ...localSettings, ...newSettings }; + setLocalSettings(merged); + updateMutation.mutate({ adhdMode: localEnabled, adhdSettings: merged }); + }, [localEnabled, localSettings, updateMutation]); + + return ( + + {children} + + ); +} + +export function useADHDMode() { + const context = useContext(ADHDModeContext); + if (context === undefined) { + throw new Error('useADHDMode must be used within an ADHDModeProvider'); + } + return context; +} + +export { defaultADHDSettings }; diff --git a/client/src/components/adhd/providers/BreakReminderProvider.tsx b/client/src/components/adhd/providers/BreakReminderProvider.tsx new file mode 100644 index 0000000..7d9ff21 --- /dev/null +++ b/client/src/components/adhd/providers/BreakReminderProvider.tsx @@ -0,0 +1,129 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, 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(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(null); + const [snoozeUntil, setSnoozeUntil] = useState(null); + + // Load last break from localStorage on mount + useEffect(() => { + const stored = localStorage.getItem('lastBreakAt'); + if (stored) { + setLastBreakAt(new Date(stored)); + } else { + // Default to current time on first load + const now = new Date(); + setLastBreakAt(now); + localStorage.setItem('lastBreakAt', now.toISOString()); + } + }, []); + + // 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); + setMinutesSinceBreak(0); + setSnoozeUntil(null); + + // 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 minutes since break and show reminder + useEffect(() => { + if (!isEnabled || !lastBreakAt) return; + + const interval = setInterval(() => { + const now = new Date(); + const diffMs = now.getTime() - lastBreakAt.getTime(); + const diffMins = Math.floor(diffMs / 60000); + setMinutesSinceBreak(diffMins); + + // Check if reminder should be shown + const reminderInterval = settings.breakReminderInterval || 45; + + // Check snooze + if (snoozeUntil && now < snoozeUntil) { + return; + } + + if (diffMins >= reminderInterval && !isReminderVisible) { + setIsReminderVisible(true); + setSnoozeUntil(null); + } + }, 30000); // Check every 30 seconds + + // Initial check + const now = new Date(); + const diffMs = now.getTime() - lastBreakAt.getTime(); + const diffMins = Math.floor(diffMs / 60000); + setMinutesSinceBreak(diffMins); + + return () => clearInterval(interval); + }, [isEnabled, lastBreakAt, settings.breakReminderInterval, snoozeUntil, isReminderVisible]); + + return ( + + {children} + + ); +} + +export function useBreakReminder() { + const context = useContext(BreakReminderContext); + if (context === undefined) { + throw new Error('useBreakReminder must be used within a BreakReminderProvider'); + } + return context; +} diff --git a/client/src/components/adhd/settings/ADHDSettingsPanel.tsx b/client/src/components/adhd/settings/ADHDSettingsPanel.tsx new file mode 100644 index 0000000..324287d --- /dev/null +++ b/client/src/components/adhd/settings/ADHDSettingsPanel.tsx @@ -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 ( +
+ {/* Main Toggle */} + + + {/* Settings (only shown when enabled) */} + {isEnabled && ( + <> + {/* Visual & Interaction Settings */} + + + + + {t('adhd.settings.visualTitle')} + + + {t('adhd.settings.visualDesc')} + + + + {/* Reduced Animations */} +
+
+ +

+ {t('adhd.settings.reducedAnimationsDesc')} +

+
+ updateSettings({ reducedAnimations: checked })} + /> +
+ + {/* Larger Touch Targets */} +
+
+ +

+ {t('adhd.settings.largerTargetsDesc')} +

+
+ updateSettings({ largerTargets: checked })} + /> +
+ + {/* Single Task Mode Default */} +
+
+ +

+ {t('adhd.settings.singleTaskDefaultDesc')} +

+
+ updateSettings({ singleTaskModeDefault: checked })} + /> +
+
+
+ + {/* Timer & Focus Settings */} + + + + + {t('adhd.settings.timerTitle')} + + + {t('adhd.settings.timerDesc')} + + + + {/* Visual Timer */} +
+
+ +

+ {t('adhd.settings.visualTimerDesc')} +

+
+ updateSettings({ visualTimerEnabled: checked })} + /> +
+ + {/* Hyperfocus Protection */} +
+
+ +

+ {t('adhd.settings.hyperfocusProtectionDesc')} +

+
+ updateSettings({ hyperfocusProtection: checked })} + /> +
+ + {/* Hyperfocus Max Minutes */} + {settings.hyperfocusProtection && ( +
+
+ + + {settings.hyperfocusMaxMinutes} min + +
+ updateSettings({ hyperfocusMaxMinutes: value })} + min={30} + max={180} + step={15} + className="w-full" + /> +
+ )} +
+
+ + {/* Break Reminder Settings */} + + + + + {t('adhd.settings.breakTitle')} + + + {t('adhd.settings.breakDesc')} + + + + {/* Break Reminder Interval */} +
+
+ + + {settings.breakReminderInterval} min + +
+ updateSettings({ breakReminderInterval: value })} + min={15} + max={90} + step={5} + className="w-full" + /> +
+
+
+ + {/* Motivation Settings */} + + + + + {t('adhd.settings.motivationTitle')} + + + {t('adhd.settings.motivationDesc')} + + + + {/* Positive Messaging Level */} +
+ + +

+ {t('adhd.settings.messagingLevelDesc')} +

+
+ + {/* Quick Win Threshold */} +
+
+ + + {settings.quickWinThreshold} min + +
+ updateSettings({ quickWinThreshold: value })} + min={5} + max={30} + step={5} + className="w-full" + /> +

+ {t('adhd.settings.quickWinThresholdDesc')} +

+
+
+
+ + {/* Energy Check-ins */} + + + + + {t('adhd.settings.energyTitle')} + + + {t('adhd.settings.energyDesc')} + + + +
+
+ +

+ {t('adhd.settings.energyCheckInsDesc')} +

+
+ updateSettings({ energyCheckInsEnabled: checked })} + /> +
+
+
+ + {/* Reset Button */} +
+ +
+ + )} +
+ ); +} diff --git a/client/src/components/adhd/settings/EnergyCheckIn.tsx b/client/src/components/adhd/settings/EnergyCheckIn.tsx new file mode 100644 index 0000000..e3896ad --- /dev/null +++ b/client/src/components/adhd/settings/EnergyCheckIn.tsx @@ -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(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 ( + + + + + + {t('adhd.energy.title')} + + + {t('adhd.energy.description')} + + + +
+ {/* Energy level selection */} +
+ {energyLevels.map((level) => { + const Icon = level.icon; + const isSelected = selectedLevel === level.id; + + return ( + 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]'} + `} + > + + + {t(`adhd.energy.levels.${level.id}`)} + + + ); + })} +
+ + {/* Energy-based suggestions */} + + {selectedLevel && ( + +
+
+ +
+

+ {t(`adhd.energy.suggestions.${selectedLevel}.title`)} +

+

+ {t(`adhd.energy.suggestions.${selectedLevel}.description`)} +

+
+
+
+
+ )} +
+ + {/* Optional notes */} +
+ +