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
This commit is contained in:
Paul Nothaft
2026-01-15 21:20:04 +01:00
parent 74ffef48d3
commit 7ce4f7efdc
31 changed files with 5651 additions and 11 deletions
+2 -1
View File
@@ -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' },
@@ -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">{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">
<p className="font-medium text-sm">{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">
{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,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<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
`}
>
{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,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<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);
// 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 (
<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 items-center justify-between">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Users className="h-6 w-6 text-primary" />
{t('adhd.bodyDoubling.title')}
</h2>
<p className="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-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>
);
}