feat: Add Focus Tools (ADHD-friendly productivity features)
continuous-integration/drone/push Build is passing
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:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user