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
-1
@@ -5,7 +5,7 @@ WORKDIR /app
|
||||
# Dependencies stage
|
||||
FROM base AS dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --only=production && \
|
||||
RUN npm ci --omit=dev && \
|
||||
cp -R node_modules /tmp/prod_node_modules && \
|
||||
npm ci
|
||||
|
||||
|
||||
+47
-2
@@ -16,6 +16,17 @@ import {
|
||||
|
||||
import { Switch, Route, useLocation } from "wouter";
|
||||
|
||||
// ADHD Mode Components
|
||||
import {
|
||||
ADHDModeProvider,
|
||||
useADHDMode,
|
||||
BreakReminderProvider,
|
||||
BreakReminderOverlay,
|
||||
EnergyCheckIn,
|
||||
HyperfocusGuard,
|
||||
EncouragementToast
|
||||
} from '@/components/adhd';
|
||||
|
||||
// Components
|
||||
import TaskCreationModal from './components/TaskCreationModal';
|
||||
import TaskDetailsModal from './components/TaskDetailsModal';
|
||||
@@ -61,11 +72,19 @@ import { Loader2 } from "lucide-react";
|
||||
|
||||
import TimeTrackingPage from './pages/TimeTrackingPage';
|
||||
|
||||
function App() {
|
||||
// ADHD Pages
|
||||
import QuickWinsPage from './pages/QuickWinsPage';
|
||||
import SingleTaskPage from './pages/SingleTaskPage';
|
||||
import BodyDoublingPage from './pages/BodyDoublingPage';
|
||||
import ADHDDashboardPage from './pages/ADHDDashboardPage';
|
||||
|
||||
// Inner App component that uses ADHD hooks
|
||||
function AppContent() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [, setLocation] = useLocation();
|
||||
const isBlocked = useRoutineBlocker();
|
||||
const { isEnabled: adhdEnabled, settings: adhdSettings } = useADHDMode();
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
|
||||
@@ -317,7 +336,7 @@ function App() {
|
||||
<SidebarProvider>
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset>
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<div className={`min-h-screen bg-background flex flex-col ${adhdEnabled ? 'adhd-mode' : ''} ${adhdEnabled && adhdSettings.reducedAnimations ? 'reduced-motion' : ''} ${adhdEnabled && adhdSettings.largerTargets ? 'larger-targets' : ''}`}>
|
||||
<main className="flex-1 p-4 md:p-6 max-w-screen-2xl mx-auto w-full relative">
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
@@ -407,6 +426,9 @@ function App() {
|
||||
<Route path="/notifications">
|
||||
<NotificationsPage />
|
||||
</Route>
|
||||
<Route path="/time-tracking">
|
||||
<TimeTrackingPage />
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
<Settings onNavigateToTemplates={() => setLocation('/templates')} />
|
||||
</Route>
|
||||
@@ -420,6 +442,13 @@ function App() {
|
||||
)}
|
||||
|
||||
<Route path="/focus/routine/:type" component={FocusRoutinePage} />
|
||||
|
||||
{/* ADHD Mode Routes */}
|
||||
<Route path="/adhd" component={ADHDDashboardPage} />
|
||||
<Route path="/adhd/quick-wins" component={QuickWinsPage} />
|
||||
<Route path="/adhd/single-task" component={SingleTaskPage} />
|
||||
<Route path="/adhd/body-doubling" component={BodyDoublingPage} />
|
||||
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
@@ -450,6 +479,11 @@ function App() {
|
||||
|
||||
<Toaster />
|
||||
|
||||
{/* ADHD Mode Overlays */}
|
||||
{adhdEnabled && <BreakReminderOverlay />}
|
||||
{adhdEnabled && adhdSettings.hyperfocusProtection && <HyperfocusGuard maxMinutes={adhdSettings.hyperfocusMaxMinutes} />}
|
||||
{adhdEnabled && adhdSettings.positiveMessagingLevel !== 'off' && <EncouragementToast />}
|
||||
|
||||
<TaskCreationModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
@@ -499,5 +533,16 @@ function App() {
|
||||
|
||||
}
|
||||
|
||||
// Main App wrapper with ADHD providers
|
||||
function App() {
|
||||
return (
|
||||
<ADHDModeProvider>
|
||||
<BreakReminderProvider>
|
||||
<AppContent />
|
||||
</BreakReminderProvider>
|
||||
</ADHDModeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"weekList": "Woche",
|
||||
"create": "Erstellen",
|
||||
"kanban": "Kanban",
|
||||
"adhd": "Fokus-Tools",
|
||||
"achievements": "Erfolge",
|
||||
"leaderboard": "Bestenliste",
|
||||
"settings": "Einstellungen"
|
||||
@@ -286,6 +287,18 @@
|
||||
"2fa": "Zwei-Faktor-Authentifizierung",
|
||||
"2faDesc": "Ihr Konto mit E-Mail-basierter 2FA sichern"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Zeitplan-Einstellungen",
|
||||
"description": "Definiere deine Verfügbarkeit für intelligente Planung.",
|
||||
"start": "Startzeit",
|
||||
"end": "Endzeit",
|
||||
"days": "Aktive Tage",
|
||||
"saved": "Zeitplan gespeichert",
|
||||
"work": "Arbeitszeit",
|
||||
"personal": "Persönliche Zeit",
|
||||
"workDesc": "Aufgaben mit 'Arbeit'-Labels werden während dieser Zeiten geplant.",
|
||||
"personalDesc": "Aufgaben mit 'Persönlich'-Labels werden während dieser Zeiten geplant. 'Neutrale' Aufgaben können beide nutzen."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"description": "Systemweite Einstellungen und Benutzerverwaltung",
|
||||
@@ -671,6 +684,17 @@
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analysen",
|
||||
"timeTracking": "Zeiterfassung",
|
||||
"timeDistribution": "Zeitverteilung",
|
||||
"totalTime": "Gesamtzeit",
|
||||
"noData": "Keine Daten für diesen Zeitraum",
|
||||
"period": {
|
||||
"day": "Tag",
|
||||
"week": "Woche",
|
||||
"month": "Monat",
|
||||
"year": "Jahr"
|
||||
},
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
@@ -905,5 +929,291 @@
|
||||
"estimatedDuration": "Geschätzte Dauer",
|
||||
"subtasks": "Teilaufgaben",
|
||||
"timeEstimate": "Zeitschätzung"
|
||||
},
|
||||
"common": {
|
||||
"optional": "Optional",
|
||||
"saving": "Speichern...",
|
||||
"creating": "Erstellen..."
|
||||
},
|
||||
"adhd": {
|
||||
"mode": "Fokus-Tools",
|
||||
"modeEnabled": "Fokus-Tools aktiviert",
|
||||
"modeDisabled": "Fokus-Tools deaktiviert",
|
||||
"modeDescription": "Optimiert die App für Fokus und Produktivität",
|
||||
"toggleMode": "Fokus-Tools umschalten",
|
||||
"active": "Aktiv",
|
||||
"dashboard": {
|
||||
"title": "Fokus-Dashboard",
|
||||
"subtitle": "Werkzeuge, die zu deinem Gehirn passen",
|
||||
"available": "verfügbar",
|
||||
"completedToday": "Heute erledigt",
|
||||
"coworking": "Zusammenarbeit",
|
||||
"energyLevel": "Energie",
|
||||
"focusMode": "Fokus-Modus",
|
||||
"helpTitle": "Wie die Fokus-Tools dir helfen",
|
||||
"helpIntro": "Diese Werkzeuge sind für dein Gehirn entwickelt - sie adressieren häufige Herausforderungen wie Zeitblindheit, Aufgabenlähmung und das Bedürfnis nach schnellen Erfolgen.",
|
||||
"help": {
|
||||
"quickWins": {
|
||||
"title": "Schnelle Erfolge",
|
||||
"why": "Das Starten von Aufgaben fühlt sich überwältigend an, und dein Gehirn sehnt sich nach schnellen Dopamin-Kicks.",
|
||||
"how": "Zeigt nur Aufgaben unter 15 Minuten, sortiert nach Einfachheit. Kleine Erfolge bauen Momentum auf und geben deinem Gehirn die Belohnung, die es braucht.",
|
||||
"usage": "Öffne Schnelle Erfolge, wenn du feststeckst. Wähle die erste Aufgabe und erledige sie. Der Dopamin-Boost hilft dir, mehr zu schaffen."
|
||||
},
|
||||
"singleTask": {
|
||||
"title": "Einzelaufgaben-Fokus",
|
||||
"why": "Eine lange Aufgabenliste zu sehen verursacht Überforderung und Lähmung.",
|
||||
"how": "Versteckt alles außer EINER Aufgabe. Keine Ablenkungen, keine Listen-Angst - nur die Aufgabe vor dir.",
|
||||
"usage": "Wähle eine Aufgabe und gehe in den Fokus-Modus. Arbeite nur an dieser Aufgabe, bis sie fertig ist oder du eine Pause brauchst."
|
||||
},
|
||||
"fiveMinute": {
|
||||
"title": "5-Minuten-Starter",
|
||||
"why": "Das Schwierigste ist der Anfang. Dein Gehirn wehrt sich gegen große Aufgaben.",
|
||||
"how": "Verpflichte dich zu nur 5 Minuten. Dein Gehirn denkt: 'Ich kann alles 5 Minuten lang machen.' Einmal angefangen, machst du oft weiter.",
|
||||
"usage": "Klicke '5 Min Start' bei einer Aufgabe. Arbeite 5 Minuten. Danach kannst du ohne schlechtes Gewissen aufhören ODER weitermachen."
|
||||
},
|
||||
"visualTimer": {
|
||||
"title": "Visueller Timer",
|
||||
"why": "Zeitblindheit - du kannst Zeit nicht natürlich wahrnehmen.",
|
||||
"how": "Ein schrumpfender Kreis zeigt Zeit visuell an. Farben ändern sich (grün → gelb → rot) wenn die Zeit abläuft.",
|
||||
"usage": "Nutze den Timer während Fokus-Sessions. Das visuelle Feedback hilft dir, Zeit zu spüren."
|
||||
},
|
||||
"breaks": {
|
||||
"title": "Pausen-Erinnerungen",
|
||||
"why": "Du vergisst Pausen zu machen, was zu Burnout oder Hyperfokus-Abstürzen führt.",
|
||||
"how": "Sanfte Erinnerungen erscheinen nach deinem eingestellten Intervall mit Vorschlägen wie Dehnen oder Wasser trinken.",
|
||||
"usage": "Wenn die Erinnerung erscheint, mach eine echte Pause. Auch 2 Minuten helfen. Du kannst bei Bedarf snoozen."
|
||||
},
|
||||
"hyperfocus": {
|
||||
"title": "Hyperfokus-Schutz",
|
||||
"why": "Du bleibst stundenlang an einer Aufgabe hängen und vernachlässigst alles andere.",
|
||||
"how": "Warnt dich, wenn du zu lange an einer Aufgabe arbeitest. Zeigt andere wartende Aufgaben.",
|
||||
"usage": "Bei einer Warnung überlege, ob du weitermachen oder wechseln solltest. Es ist okay weiterzumachen, aber mach es bewusst."
|
||||
},
|
||||
"energy": {
|
||||
"title": "Energie-Check-ins",
|
||||
"why": "Dich zu schweren Aufgaben zu zwingen, wenn du erschöpft bist, führt zum Scheitern.",
|
||||
"how": "Logge täglich dein Energielevel. Die App schlägt Aufgaben vor, die zu deiner aktuellen Energie passen.",
|
||||
"usage": "Checke jeden Morgen ein. Wenig Energie? Mach einfache Aufgaben. Viel Energie? Tackle Herausforderungen."
|
||||
},
|
||||
"bodyDoubling": {
|
||||
"title": "Body Doubling",
|
||||
"why": "Alleine zu arbeiten macht es schwer, am Ball zu bleiben.",
|
||||
"how": "Virtuelle Co-Working-Sessions, bei denen du neben anderen arbeitest. Soziale Verantwortlichkeit hält dich fokussiert.",
|
||||
"usage": "Tritt einer Session bei oder erstelle eine. Allein zu wissen, dass andere arbeiten, hilft dir dranzubleiben."
|
||||
},
|
||||
"taskBreakdown": {
|
||||
"title": "KI-Aufgabenzerlegung",
|
||||
"why": "Große Aufgaben fühlen sich unmöglich an zu starten.",
|
||||
"how": "KI zerlegt überwältigende Aufgaben in kleine, konkrete Schritte mit Zeitschätzungen.",
|
||||
"usage": "Klicke 'Zerlegen' bei einer großen Aufgabe. Überprüfe die Schritte und erstelle sie als Unteraufgaben."
|
||||
}
|
||||
},
|
||||
"quickWins": "Schnelle Erfolge",
|
||||
"streak": "Tagessträhne"
|
||||
},
|
||||
"timer": {
|
||||
"remaining": "verbleibend"
|
||||
},
|
||||
"breakReminder": {
|
||||
"title": "Zeit für eine Pause!",
|
||||
"workingFor": "Du arbeitest seit {{minutes}} Minuten",
|
||||
"message": "Dein Gehirn braucht regelmäßige Pausen. Wähle eine kurze Pause:",
|
||||
"snooze5": "5 Min später",
|
||||
"snooze15": "15 Min später",
|
||||
"gentle": "Es ist okay, Pausen zu machen. Du machst das großartig!",
|
||||
"types": {
|
||||
"stretch": "Dehnen",
|
||||
"water": "Wasser",
|
||||
"walk": "Spazieren",
|
||||
"eyes": "Augen",
|
||||
"snack": "Snack",
|
||||
"breathe": "Atmen"
|
||||
}
|
||||
},
|
||||
"taskBreakdown": {
|
||||
"title": "Aufgabe aufteilen",
|
||||
"description": "Große Aufgaben können überwältigend sein. Lass uns sie in kleine Schritte zerlegen.",
|
||||
"prompt": "Klicke unten, um diese Aufgabe in machbare Schritte zu zerlegen.",
|
||||
"analyzing": "Analysiere...",
|
||||
"breakItDown": "Aufgabe aufteilen",
|
||||
"estimated": "geschätzt",
|
||||
"totalTime": "Gesamtzeit",
|
||||
"regenerate": "Neu generieren",
|
||||
"createSubtasks": "{{count}} Schritte erstellen",
|
||||
"error": "Aufgabe konnte nicht zerlegt werden. Bitte versuche es erneut."
|
||||
},
|
||||
"quickWins": {
|
||||
"title": "Schnelle Erfolge",
|
||||
"description": "Kleine Aufgaben für sofortige Dopamin-Boosts",
|
||||
"tip": "Kleine Erfolge schaffen Momentum! Erledige diese schnellen Aufgaben für einen Dopamin-Boost.",
|
||||
"noTasks": "Keine schnellen Aufgaben verfügbar"
|
||||
},
|
||||
"singleTask": {
|
||||
"title": "Einzelaufgaben-Fokus",
|
||||
"description": "Konzentriere dich auf eine Aufgabe ohne Ablenkungen",
|
||||
"selectTask": "Wähle eine Aufgabe zum Fokussieren",
|
||||
"choosePlaceholder": "Aufgabe auswählen...",
|
||||
"task": "Aufgabe",
|
||||
"complete": "erledigt",
|
||||
"allDone": "Alles geschafft!",
|
||||
"allDoneDesc": "Du hast alle Aufgaben erledigt. Nimm dir eine wohlverdiente Pause!",
|
||||
"exit": "Ansicht verlassen",
|
||||
"markComplete": "Als erledigt markieren",
|
||||
"fiveMin": "5 Min starten",
|
||||
"startTimer": "Timer starten",
|
||||
"skip": "Überspringen"
|
||||
},
|
||||
"fiveMin": {
|
||||
"title": "Die 5-Minuten-Regel",
|
||||
"prompt": "Verpflichte dich nur zu 5 Minuten. Danach kannst du aufhören.",
|
||||
"start": "5 Minuten starten",
|
||||
"noObligation": "Keine Verpflichtung weiterzumachen!",
|
||||
"remaining": "verbleibend",
|
||||
"pause": "Pause",
|
||||
"resume": "Fortsetzen",
|
||||
"done": "Fertig!",
|
||||
"encouragement": "Du machst das großartig! Jede Sekunde zählt.",
|
||||
"congrats": "Super gemacht!",
|
||||
"fiveMinDone": "Du hast 5 Minuten geschafft!",
|
||||
"continueWorking": "Weitermachen",
|
||||
"finished": "Aufgabe fertig!",
|
||||
"takeBreak": "Pause machen",
|
||||
"earned": "verdient"
|
||||
},
|
||||
"energy": {
|
||||
"title": "Wie ist dein Energielevel?",
|
||||
"description": "Hilf uns, dir passende Aufgaben vorzuschlagen.",
|
||||
"checkIn": "Einchecken",
|
||||
"checkInReminder": "Wie fühlst du dich heute?",
|
||||
"checkInBenefit": "Tracke deine Energie für bessere Aufgabenvorschläge",
|
||||
"notesLabel": "Notizen",
|
||||
"notesPlaceholder": "Wie fühlst du dich heute?",
|
||||
"submit": "Energie erfassen",
|
||||
"levels": {
|
||||
"low": "Niedrig",
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch"
|
||||
},
|
||||
"suggestions": {
|
||||
"low": {
|
||||
"title": "Empfehlung für niedrige Energie",
|
||||
"description": "Konzentriere dich auf einfache, administrative Aufgaben oder kurze Quick Wins."
|
||||
},
|
||||
"medium": {
|
||||
"title": "Empfehlung für mittlere Energie",
|
||||
"description": "Gut für Routineaufgaben und Kommunikation."
|
||||
},
|
||||
"high": {
|
||||
"title": "Empfehlung für hohe Energie",
|
||||
"description": "Perfekt für kreative oder herausfordernde Aufgaben!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hyperfocus": {
|
||||
"title": "Hyperfokus-Warnung",
|
||||
"message": "Du arbeitest seit {{minutes}} Minuten an dieser Aufgabe.",
|
||||
"minutes": "Min",
|
||||
"urgentTasks": "dringende Aufgaben",
|
||||
"waitingTasks": "Wartende Aufgaben",
|
||||
"takeBreak": "Pause machen",
|
||||
"continue": "Weitermachen",
|
||||
"gentle": "Es ist okay, Pausen zu machen oder zu wechseln."
|
||||
},
|
||||
"bodyDoubling": {
|
||||
"title": "Body Doubling",
|
||||
"description": "Arbeite gemeinsam mit anderen für mehr Fokus und Accountability.",
|
||||
"tip": "Gemeinsames Arbeiten hilft beim Fokussieren. Tritt einer Session bei oder erstelle eine.",
|
||||
"createSession": "Session erstellen",
|
||||
"newSession": "Neue Session",
|
||||
"newSessionDesc": "Erstelle eine Session, zu der andere beitreten können.",
|
||||
"sessionTitle": "Titel",
|
||||
"titlePlaceholder": "z.B. Fokus-Session Vormittag",
|
||||
"sessionType": "Typ",
|
||||
"types": {
|
||||
"focus": "Fokus",
|
||||
"brainstorm": "Brainstorm",
|
||||
"admin": "Admin",
|
||||
"creative": "Kreativ"
|
||||
},
|
||||
"startTime": "Startzeit",
|
||||
"duration": "Dauer",
|
||||
"create": "Session erstellen",
|
||||
"activeSessions": "Aktive Sessions",
|
||||
"upcomingSessions": "Geplante Sessions",
|
||||
"noSessions": "Keine Sessions geplant. Erstelle die erste!",
|
||||
"live": "Live",
|
||||
"join": "Beitreten",
|
||||
"leave": "Verlassen"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Fokus-Einstellungen",
|
||||
"description": "Passe deine fokusfreundliche Erfahrung an",
|
||||
"visualTitle": "Darstellung & Interaktion",
|
||||
"visualDesc": "Passe das Interface für deine Bedürfnisse an.",
|
||||
"reducedAnimations": "Reduzierte Animationen",
|
||||
"reducedAnimationsDesc": "Weniger visuelle Bewegung für bessere Konzentration.",
|
||||
"largerTargets": "Größere Buttons",
|
||||
"largerTargetsDesc": "Größere Berührungsflächen für einfacheres Klicken.",
|
||||
"singleTaskDefault": "Single-Task-Modus Standard",
|
||||
"singleTaskDefaultDesc": "Zeige nur eine Aufgabe zur Zeit an.",
|
||||
"timerTitle": "Timer & Fokus",
|
||||
"timerDesc": "Einstellungen für Zeitmanagement.",
|
||||
"visualTimer": "Visueller Timer",
|
||||
"visualTimerDesc": "Zeige die Zeit als schrumpfenden Kreis.",
|
||||
"hyperfocusProtection": "Hyperfokus-Schutz",
|
||||
"hyperfocusProtectionDesc": "Warnungen bei zu langem Arbeiten an einer Aufgabe.",
|
||||
"hyperfocusMaxMinutes": "Maximale Fokuszeit",
|
||||
"breakTitle": "Pausen",
|
||||
"breakDesc": "Erinnerungen für regelmäßige Pausen.",
|
||||
"breakInterval": "Pausen-Intervall (Minuten)",
|
||||
"motivationTitle": "Motivation",
|
||||
"motivationDesc": "Positive Verstärkung und Belohnungen.",
|
||||
"messagingLevel": "Ermutigungs-Level",
|
||||
"messagingMinimal": "Minimal",
|
||||
"messagingNormal": "Normal",
|
||||
"messagingHigh": "Hoch",
|
||||
"messagingLevelDesc": "Wie viel positive Ermutigung möchtest du?",
|
||||
"quickWinThreshold": "Quick-Win Schwellwert",
|
||||
"quickWinThresholdDesc": "Aufgaben unter dieser Dauer gelten als Quick Wins.",
|
||||
"energyTitle": "Energie",
|
||||
"energyDesc": "Tägliche Energie-Check-ins.",
|
||||
"energyCheckIns": "Energie-Check-ins aktivieren",
|
||||
"energyCheckInsDesc": "Täglich nach deinem Energielevel fragen.",
|
||||
"reset": "Auf Standard zurücksetzen"
|
||||
},
|
||||
"encouragements": {
|
||||
"taskComplete": [
|
||||
"Großartig! Jeder Schritt zählt.",
|
||||
"Du machst das super! Weiter so!",
|
||||
"Ein Task weniger - du rockst das!",
|
||||
"Siehst du? Du kannst das!",
|
||||
"Fantastisch! Bleib dran!"
|
||||
],
|
||||
"returning": [
|
||||
"Schön, dass du wieder da bist!",
|
||||
"Jeder Neustart ist ein Gewinn.",
|
||||
"Willkommen zurück! Lass uns loslegen."
|
||||
],
|
||||
"struggling": [
|
||||
"Es ist okay, kleine Schritte zu machen.",
|
||||
"Du musst nicht alles auf einmal schaffen.",
|
||||
"Atme tief durch. Du schaffst das."
|
||||
],
|
||||
"streakBroken": [
|
||||
"Streaks sind nur Zahlen. Du bist mehr als das.",
|
||||
"Morgen ist ein neuer Tag!",
|
||||
"Jeder Tag ist eine neue Chance."
|
||||
],
|
||||
"milestone": [
|
||||
"Meilenstein erreicht! Unglaublich!",
|
||||
"Du hast es geschafft! Feier diesen Moment!",
|
||||
"Was für ein Erfolg!"
|
||||
],
|
||||
"quickWin": [
|
||||
"Schneller Erfolg! Das Dopamin fließt!",
|
||||
"Boom! Quick Win erledigt!",
|
||||
"So schnell kann es gehen!"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"weekList": "Week",
|
||||
"create": "Create",
|
||||
"kanban": "Kanban",
|
||||
"adhd": "Focus Tools",
|
||||
"achievements": "Achievements",
|
||||
"leaderboard": "Leaderboard",
|
||||
"settings": "Settings"
|
||||
@@ -286,6 +287,18 @@
|
||||
"2fa": "Two-Factor Authentication",
|
||||
"2faDesc": "Secure your account with email-based 2FA"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule Settings",
|
||||
"description": "Define your availability for smart scheduling.",
|
||||
"start": "Start Time",
|
||||
"end": "End Time",
|
||||
"days": "Active Days",
|
||||
"saved": "Schedule saved",
|
||||
"work": "Work Schedule",
|
||||
"personal": "Personal Schedule",
|
||||
"workDesc": "Tasks with 'Work' labels will be scheduled during these hours.",
|
||||
"personalDesc": "Tasks with 'Personal' labels will be scheduled during these hours. 'Neutral' tasks can use either."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Settings",
|
||||
"description": "System administration and configuration",
|
||||
@@ -783,6 +796,17 @@
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"timeTracking": "Time Tracking",
|
||||
"timeDistribution": "Time Distribution",
|
||||
"totalTime": "Total Time",
|
||||
"noData": "No data recorded for this period",
|
||||
"period": {
|
||||
"day": "Day",
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"year": "Year"
|
||||
},
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
@@ -892,5 +916,291 @@
|
||||
"estimatedDuration": "Estimated Duration",
|
||||
"subtasks": "Subtasks",
|
||||
"timeEstimate": "Time Estimate"
|
||||
},
|
||||
"common": {
|
||||
"optional": "Optional",
|
||||
"saving": "Saving...",
|
||||
"creating": "Creating..."
|
||||
},
|
||||
"adhd": {
|
||||
"mode": "Focus Tools",
|
||||
"modeEnabled": "Focus Tools enabled",
|
||||
"modeDisabled": "Focus Tools disabled",
|
||||
"modeDescription": "Optimizes the app for focus and productivity",
|
||||
"toggleMode": "Toggle Focus Tools",
|
||||
"active": "Active",
|
||||
"dashboard": {
|
||||
"title": "Focus Dashboard",
|
||||
"subtitle": "Tools designed for how your brain works",
|
||||
"available": "available",
|
||||
"completedToday": "Done today",
|
||||
"coworking": "Co-working",
|
||||
"energyLevel": "Energy",
|
||||
"focusMode": "Focus mode",
|
||||
"helpTitle": "How Focus Tools Help You",
|
||||
"helpIntro": "These tools are designed around how your brain works - addressing common challenges like time blindness, task paralysis, and the need for quick wins.",
|
||||
"help": {
|
||||
"quickWins": {
|
||||
"title": "Quick Wins",
|
||||
"why": "Starting tasks feels overwhelming, and your brain craves quick dopamine hits.",
|
||||
"how": "Shows only tasks under 15 minutes, sorted by easiest to complete. Completing small tasks builds momentum and gives your brain the reward it needs.",
|
||||
"usage": "Open Quick Wins when you feel stuck. Pick the first task and complete it. The dopamine boost will help you tackle more."
|
||||
},
|
||||
"singleTask": {
|
||||
"title": "Single Task Focus",
|
||||
"why": "Seeing a long task list causes overwhelm and paralysis.",
|
||||
"how": "Hides everything except ONE task. No distractions, no list anxiety - just the task in front of you.",
|
||||
"usage": "Select a task and enter focus mode. Work only on that task until done or you need a break."
|
||||
},
|
||||
"fiveMinute": {
|
||||
"title": "5-Minute Starter",
|
||||
"why": "The hardest part is beginning. Your brain resists starting big tasks.",
|
||||
"how": "Commit to just 5 minutes. Your brain thinks 'I can do anything for 5 minutes.' Once started, you often keep going.",
|
||||
"usage": "Click '5 Min Start' on any task. Work for 5 minutes. After that, you can stop guilt-free OR continue."
|
||||
},
|
||||
"visualTimer": {
|
||||
"title": "Visual Timer",
|
||||
"why": "Time blindness - you can't feel time passing naturally.",
|
||||
"how": "A shrinking circle shows time visually. Colors change (green → yellow → red) as time runs out.",
|
||||
"usage": "Use the timer during focus sessions. The visual feedback helps you feel time passing."
|
||||
},
|
||||
"breaks": {
|
||||
"title": "Break Reminders",
|
||||
"why": "You forget to take breaks, leading to burnout or hyperfocus crashes.",
|
||||
"how": "Gentle reminders appear after your set interval suggesting quick breaks like stretching or water.",
|
||||
"usage": "When the reminder appears, take a real break. Even 2 minutes helps. You can snooze if needed."
|
||||
},
|
||||
"hyperfocus": {
|
||||
"title": "Hyperfocus Protection",
|
||||
"why": "You get stuck on one task for hours, neglecting everything else.",
|
||||
"how": "Warns you when you've been on a single task too long. Shows other waiting tasks.",
|
||||
"usage": "When warned, assess if you should continue or switch. It's okay to keep going, but make it a conscious choice."
|
||||
},
|
||||
"energy": {
|
||||
"title": "Energy Check-ins",
|
||||
"why": "Forcing yourself to do hard tasks when exhausted leads to failure.",
|
||||
"how": "Log your energy level daily. The app suggests tasks that match your current energy.",
|
||||
"usage": "Check in each morning. Low energy? Do simple tasks. High energy? Tackle challenges."
|
||||
},
|
||||
"bodyDoubling": {
|
||||
"title": "Body Doubling",
|
||||
"why": "Working alone makes it hard to stay accountable.",
|
||||
"how": "Virtual co-working sessions where you work alongside others. Social accountability keeps you focused.",
|
||||
"usage": "Join or create a session. Just knowing others are working helps you stay on track."
|
||||
},
|
||||
"taskBreakdown": {
|
||||
"title": "AI Task Breakdown",
|
||||
"why": "Big tasks feel impossible to start.",
|
||||
"how": "AI breaks down overwhelming tasks into small, concrete steps with time estimates.",
|
||||
"usage": "Click 'Break It Down' on any large task. Review the steps and create them as subtasks."
|
||||
}
|
||||
},
|
||||
"quickWins": "Quick wins",
|
||||
"streak": "Day streak"
|
||||
},
|
||||
"timer": {
|
||||
"remaining": "remaining"
|
||||
},
|
||||
"breakReminder": {
|
||||
"title": "Time for a break!",
|
||||
"workingFor": "You've been working for {{minutes}} minutes",
|
||||
"message": "Your brain needs regular breaks. Choose a quick break:",
|
||||
"snooze5": "5 min later",
|
||||
"snooze15": "15 min later",
|
||||
"gentle": "It's okay to take breaks. You're doing great!",
|
||||
"types": {
|
||||
"stretch": "Stretch",
|
||||
"water": "Water",
|
||||
"walk": "Walk",
|
||||
"eyes": "Eyes",
|
||||
"snack": "Snack",
|
||||
"breathe": "Breathe"
|
||||
}
|
||||
},
|
||||
"taskBreakdown": {
|
||||
"title": "Break Down Task",
|
||||
"description": "Large tasks can be overwhelming. Let's break them into smaller steps.",
|
||||
"prompt": "Click below to break this task into manageable steps.",
|
||||
"analyzing": "Analyzing...",
|
||||
"breakItDown": "Break It Down",
|
||||
"estimated": "estimated",
|
||||
"totalTime": "Total time",
|
||||
"regenerate": "Regenerate",
|
||||
"createSubtasks": "Create {{count}} steps",
|
||||
"error": "Failed to break down task. Please try again."
|
||||
},
|
||||
"quickWins": {
|
||||
"title": "Quick Wins",
|
||||
"description": "Small tasks for instant dopamine boosts",
|
||||
"tip": "Small wins build momentum! Complete these quick tasks to boost your dopamine and build confidence.",
|
||||
"noTasks": "No quick tasks available"
|
||||
},
|
||||
"singleTask": {
|
||||
"title": "Single Task Focus",
|
||||
"description": "Focus on one task at a time without distractions",
|
||||
"selectTask": "Select a task to focus on",
|
||||
"choosePlaceholder": "Choose a task...",
|
||||
"task": "Task",
|
||||
"complete": "complete",
|
||||
"allDone": "All Done!",
|
||||
"allDoneDesc": "You've completed all tasks. Take a well-deserved break!",
|
||||
"exit": "Exit view",
|
||||
"markComplete": "Mark Complete",
|
||||
"fiveMin": "Start 5 Min",
|
||||
"startTimer": "Start Timer",
|
||||
"skip": "Skip"
|
||||
},
|
||||
"fiveMin": {
|
||||
"title": "The 5-Minute Rule",
|
||||
"prompt": "Commit to just 5 minutes. You can stop after that.",
|
||||
"start": "Start 5 Minutes",
|
||||
"noObligation": "No obligation to continue!",
|
||||
"remaining": "remaining",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"done": "Done!",
|
||||
"encouragement": "You're doing great! Every second counts.",
|
||||
"congrats": "Well done!",
|
||||
"fiveMinDone": "You made it through 5 minutes!",
|
||||
"continueWorking": "Keep Going",
|
||||
"finished": "Task finished!",
|
||||
"takeBreak": "Take a break",
|
||||
"earned": "earned"
|
||||
},
|
||||
"energy": {
|
||||
"title": "How's your energy level?",
|
||||
"description": "Help us suggest the right tasks for you.",
|
||||
"checkIn": "Check In",
|
||||
"checkInReminder": "How are you feeling today?",
|
||||
"checkInBenefit": "Track your energy to get better task suggestions",
|
||||
"notesLabel": "Notes",
|
||||
"notesPlaceholder": "How are you feeling today?",
|
||||
"submit": "Log Energy",
|
||||
"levels": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"suggestions": {
|
||||
"low": {
|
||||
"title": "Low energy recommendation",
|
||||
"description": "Focus on simple, administrative tasks or quick wins."
|
||||
},
|
||||
"medium": {
|
||||
"title": "Medium energy recommendation",
|
||||
"description": "Good for routine tasks and communication."
|
||||
},
|
||||
"high": {
|
||||
"title": "High energy recommendation",
|
||||
"description": "Perfect for creative or challenging tasks!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hyperfocus": {
|
||||
"title": "Hyperfocus Warning",
|
||||
"message": "You've been working on this task for {{minutes}} minutes.",
|
||||
"minutes": "min",
|
||||
"urgentTasks": "urgent tasks",
|
||||
"waitingTasks": "Waiting tasks",
|
||||
"takeBreak": "Take a break",
|
||||
"continue": "Continue",
|
||||
"gentle": "It's okay to take breaks or switch tasks."
|
||||
},
|
||||
"bodyDoubling": {
|
||||
"title": "Body Doubling",
|
||||
"description": "Work alongside others for more focus and accountability.",
|
||||
"tip": "Working alongside others helps maintain focus. Join or create a session to stay accountable.",
|
||||
"createSession": "Create Session",
|
||||
"newSession": "New Session",
|
||||
"newSessionDesc": "Create a session others can join.",
|
||||
"sessionTitle": "Title",
|
||||
"titlePlaceholder": "e.g. Morning Focus Session",
|
||||
"sessionType": "Type",
|
||||
"types": {
|
||||
"focus": "Focus",
|
||||
"brainstorm": "Brainstorm",
|
||||
"admin": "Admin",
|
||||
"creative": "Creative"
|
||||
},
|
||||
"startTime": "Start Time",
|
||||
"duration": "Duration",
|
||||
"create": "Create Session",
|
||||
"activeSessions": "Active Sessions",
|
||||
"upcomingSessions": "Upcoming Sessions",
|
||||
"noSessions": "No sessions scheduled. Create the first one!",
|
||||
"live": "Live",
|
||||
"join": "Join",
|
||||
"leave": "Leave"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Focus Settings",
|
||||
"description": "Customize your focus-friendly experience",
|
||||
"visualTitle": "Display & Interaction",
|
||||
"visualDesc": "Customize the interface for your needs.",
|
||||
"reducedAnimations": "Reduced Animations",
|
||||
"reducedAnimationsDesc": "Less visual motion for better focus.",
|
||||
"largerTargets": "Larger Buttons",
|
||||
"largerTargetsDesc": "Bigger touch targets for easier clicking.",
|
||||
"singleTaskDefault": "Single Task Mode Default",
|
||||
"singleTaskDefaultDesc": "Show only one task at a time.",
|
||||
"timerTitle": "Timer & Focus",
|
||||
"timerDesc": "Settings for time management.",
|
||||
"visualTimer": "Visual Timer",
|
||||
"visualTimerDesc": "Show time as a shrinking circle.",
|
||||
"hyperfocusProtection": "Hyperfocus Protection",
|
||||
"hyperfocusProtectionDesc": "Warnings when working too long on one task.",
|
||||
"hyperfocusMaxMinutes": "Maximum Focus Time",
|
||||
"breakTitle": "Breaks",
|
||||
"breakDesc": "Reminders for regular breaks.",
|
||||
"breakInterval": "Break Interval (minutes)",
|
||||
"motivationTitle": "Motivation",
|
||||
"motivationDesc": "Positive reinforcement and rewards.",
|
||||
"messagingLevel": "Encouragement Level",
|
||||
"messagingMinimal": "Minimal",
|
||||
"messagingNormal": "Normal",
|
||||
"messagingHigh": "High",
|
||||
"messagingLevelDesc": "How much positive encouragement do you want?",
|
||||
"quickWinThreshold": "Quick Win Threshold",
|
||||
"quickWinThresholdDesc": "Tasks under this duration are considered Quick Wins.",
|
||||
"energyTitle": "Energy",
|
||||
"energyDesc": "Daily energy check-ins.",
|
||||
"energyCheckIns": "Enable Energy Check-ins",
|
||||
"energyCheckInsDesc": "Ask about your energy level daily.",
|
||||
"reset": "Reset to Defaults"
|
||||
},
|
||||
"encouragements": {
|
||||
"taskComplete": [
|
||||
"Amazing! Every step counts.",
|
||||
"You're doing great! Keep it up!",
|
||||
"One less task - you rock!",
|
||||
"See? You can do it!",
|
||||
"Fantastic! Stay on track!"
|
||||
],
|
||||
"returning": [
|
||||
"Good to see you back!",
|
||||
"Every fresh start is a win.",
|
||||
"Welcome back! Let's get started."
|
||||
],
|
||||
"struggling": [
|
||||
"It's okay to take small steps.",
|
||||
"You don't have to do everything at once.",
|
||||
"Take a deep breath. You've got this."
|
||||
],
|
||||
"streakBroken": [
|
||||
"Streaks are just numbers. You're more than that.",
|
||||
"Tomorrow is a new day!",
|
||||
"Every day is a fresh chance."
|
||||
],
|
||||
"milestone": [
|
||||
"Milestone reached! Incredible!",
|
||||
"You made it! Celebrate this moment!",
|
||||
"What an achievement!"
|
||||
],
|
||||
"quickWin": [
|
||||
"Quick win! The dopamine is flowing!",
|
||||
"Boom! Quick win complete!",
|
||||
"That was fast!"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,4 +327,253 @@
|
||||
.border.hover-elevate:not(.no-hover-interaction-elevate)::after {
|
||||
inset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ADHD MODE STYLES
|
||||
Calmer colors, reduced cognitive load,
|
||||
larger touch targets, and time-awareness
|
||||
============================================ */
|
||||
|
||||
/* ADHD Mode Variables */
|
||||
:root {
|
||||
--adhd-timer-green: 142 76% 36%;
|
||||
--adhd-timer-yellow: 45 93% 47%;
|
||||
--adhd-timer-red: 0 84% 60%;
|
||||
--adhd-calm-primary: 210 40% 52%;
|
||||
--adhd-calm-accent: 199 89% 48%;
|
||||
--adhd-success-green: 142 71% 45%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--adhd-timer-green: 142 76% 46%;
|
||||
--adhd-timer-yellow: 45 93% 57%;
|
||||
--adhd-timer-red: 0 84% 65%;
|
||||
--adhd-calm-primary: 210 50% 62%;
|
||||
--adhd-calm-accent: 199 89% 58%;
|
||||
--adhd-success-green: 142 71% 55%;
|
||||
}
|
||||
|
||||
/* ADHD Mode - Global adjustments when enabled */
|
||||
.adhd-mode {
|
||||
/* Calmer color palette */
|
||||
--primary: var(--adhd-calm-primary);
|
||||
--ring: var(--adhd-calm-accent);
|
||||
|
||||
/* Larger border radius for softer appearance */
|
||||
--radius: 1.25rem;
|
||||
}
|
||||
|
||||
.adhd-mode .card,
|
||||
.adhd-mode .dialog-content,
|
||||
.adhd-mode .popover-content {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Reduced Motion - Disable animations for users who need focus */
|
||||
.reduced-motion,
|
||||
.reduced-motion * {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
|
||||
/* Larger Touch Targets for ADHD Mode */
|
||||
/* Only increase height, not width - width breaks flex layouts and icon buttons */
|
||||
.larger-targets button:not([data-size="icon"]),
|
||||
.larger-targets [role="button"]:not([data-size="icon"]) {
|
||||
min-height: 44px;
|
||||
padding-top: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
/* Icon buttons get increased size as square */
|
||||
.larger-targets button[data-size="icon"],
|
||||
.larger-targets [role="button"][data-size="icon"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Form inputs and selects */
|
||||
.larger-targets input,
|
||||
.larger-targets select,
|
||||
.larger-targets textarea {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Links only need height adjustment when they look like buttons */
|
||||
.larger-targets a[role="button"] {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.larger-targets .sidebar-menu-button {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* Visual Timer Color Classes */
|
||||
.timer-green {
|
||||
color: hsl(var(--adhd-timer-green));
|
||||
}
|
||||
|
||||
.timer-yellow {
|
||||
color: hsl(var(--adhd-timer-yellow));
|
||||
}
|
||||
|
||||
.timer-red {
|
||||
color: hsl(var(--adhd-timer-red));
|
||||
}
|
||||
|
||||
.timer-stroke-green {
|
||||
stroke: hsl(var(--adhd-timer-green));
|
||||
}
|
||||
|
||||
.timer-stroke-yellow {
|
||||
stroke: hsl(var(--adhd-timer-yellow));
|
||||
}
|
||||
|
||||
.timer-stroke-red {
|
||||
stroke: hsl(var(--adhd-timer-red));
|
||||
}
|
||||
|
||||
/* Break Reminder Pulse Animation */
|
||||
@keyframes break-reminder-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.break-reminder-pulse {
|
||||
animation: break-reminder-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Encouragement Toast Animation */
|
||||
@keyframes encouragement-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.encouragement-bounce {
|
||||
animation: encouragement-bounce 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Quick Win Card Highlight */
|
||||
@keyframes quick-win-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px 0 hsl(var(--adhd-success-green) / 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px 2px hsl(var(--adhd-success-green) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-win-highlight {
|
||||
animation: quick-win-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* XP Micro-Reward Animation */
|
||||
@keyframes xp-float {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.xp-float {
|
||||
animation: xp-float 1s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Focus Mode Overlay */
|
||||
.single-task-overlay {
|
||||
backdrop-filter: blur(4px);
|
||||
background: hsl(var(--background) / 0.95);
|
||||
}
|
||||
|
||||
/* Energy Level Indicators */
|
||||
.energy-low {
|
||||
border-left: 4px solid hsl(var(--adhd-timer-red));
|
||||
}
|
||||
|
||||
.energy-medium {
|
||||
border-left: 4px solid hsl(var(--adhd-timer-yellow));
|
||||
}
|
||||
|
||||
.energy-high {
|
||||
border-left: 4px solid hsl(var(--adhd-timer-green));
|
||||
}
|
||||
|
||||
/* Hyperfocus Warning Styles */
|
||||
@keyframes hyperfocus-warning {
|
||||
0%, 100% {
|
||||
border-color: hsl(var(--adhd-timer-yellow));
|
||||
}
|
||||
50% {
|
||||
border-color: hsl(var(--adhd-timer-red));
|
||||
}
|
||||
}
|
||||
|
||||
.hyperfocus-warning {
|
||||
animation: hyperfocus-warning 1.5s ease-in-out infinite;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* Body Doubling Session Active Indicator */
|
||||
@keyframes session-active-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 hsl(var(--adhd-calm-accent) / 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px hsl(var(--adhd-calm-accent) / 0);
|
||||
}
|
||||
}
|
||||
|
||||
.session-active {
|
||||
animation: session-active-pulse 2s ease-out infinite;
|
||||
}
|
||||
|
||||
/* ADHD Settings Panel Styles */
|
||||
.adhd-settings-section {
|
||||
@apply p-4 rounded-xl bg-muted/50 border border-border;
|
||||
}
|
||||
|
||||
.adhd-settings-section h3 {
|
||||
@apply text-lg font-semibold mb-4 flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* Task Breakdown Subtask Styles */
|
||||
.subtask-item {
|
||||
@apply p-3 rounded-lg bg-background border border-border hover:border-primary/50 transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.subtask-item.completed {
|
||||
@apply bg-muted/50 opacity-70;
|
||||
}
|
||||
|
||||
/* Five Minute Starter Progress Ring */
|
||||
.five-min-progress {
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Calm Background for Focus Sessions */
|
||||
.calm-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--background)) 0%,
|
||||
hsl(var(--adhd-calm-primary) / 0.05) 50%,
|
||||
hsl(var(--adhd-calm-accent) / 0.05) 100%
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useADHDMode, ADHDSettingsPanel, EnergyCheckIn } from '@/components/adhd';
|
||||
import { User, Task } from '@shared/schema';
|
||||
import { Brain, Zap, Focus, Users, Battery, TrendingUp, ArrowLeft, HelpCircle, Clock, Coffee, Sparkles, Timer, Split } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { useLocation } from 'wouter';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ADHDDashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const { isEnabled, settings } = useADHDMode();
|
||||
const [showEnergyCheckIn, setShowEnergyCheckIn] = useState(false);
|
||||
|
||||
const { data: user } = useQuery<User>({
|
||||
queryKey: ['/api/user'],
|
||||
});
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
const { data: breakStats } = useQuery({
|
||||
queryKey: ['/api/user/break-stats'],
|
||||
enabled: isEnabled,
|
||||
});
|
||||
|
||||
const { data: energyHistory } = useQuery({
|
||||
queryKey: ['/api/energy/history'],
|
||||
enabled: isEnabled,
|
||||
});
|
||||
|
||||
const completedToday = tasks.filter(t => {
|
||||
if (t.status !== 'done' || !t.updatedAt) return false;
|
||||
const today = new Date();
|
||||
const updated = new Date(t.updatedAt);
|
||||
return updated.toDateString() === today.toDateString();
|
||||
}).length;
|
||||
|
||||
const quickWins = tasks.filter(t =>
|
||||
t.status !== 'done' &&
|
||||
t.estimatedDuration &&
|
||||
t.estimatedDuration <= 15
|
||||
).length;
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: t('adhd.quickWins.title'),
|
||||
description: t('adhd.quickWins.description'),
|
||||
path: '/adhd/quick-wins',
|
||||
color: 'from-yellow-400 to-orange-500',
|
||||
stat: `${quickWins} ${t('adhd.dashboard.available', 'available')}`,
|
||||
},
|
||||
{
|
||||
icon: Focus,
|
||||
title: t('adhd.singleTask.title'),
|
||||
description: t('adhd.singleTask.description'),
|
||||
path: '/adhd/single-task',
|
||||
color: 'from-blue-400 to-indigo-500',
|
||||
stat: t('adhd.dashboard.focusMode', 'Focus mode'),
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: t('adhd.bodyDoubling.title'),
|
||||
description: t('adhd.bodyDoubling.description'),
|
||||
path: '/adhd/body-doubling',
|
||||
color: 'from-green-400 to-teal-500',
|
||||
stat: t('adhd.dashboard.coworking', 'Co-working'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation('/')}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-purple-400 to-pink-500 text-white">
|
||||
<Brain className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('adhd.dashboard.title', 'ADHD Dashboard')}</h1>
|
||||
<p className="text-muted-foreground">{t('adhd.dashboard.subtitle', 'Tools designed for how your brain works')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{completedToday}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adhd.dashboard.completedToday', 'Done today')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<Battery className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold capitalize">{user?.currentEnergyLevel || '-'}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adhd.dashboard.energyLevel', 'Energy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-900/30">
|
||||
<Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{quickWins}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adhd.dashboard.quickWins', 'Quick wins')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<Brain className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{user?.currentStreak || 0}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adhd.dashboard.streak', 'Day streak')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Energy Check-in Button */}
|
||||
{isEnabled && settings.energyCheckInsEnabled && !user?.todayEnergyCheckedIn && (
|
||||
<Card className="border-2 border-dashed border-primary/50 bg-primary/5">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{t('adhd.energy.checkInReminder', 'How are you feeling today?')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('adhd.energy.checkInBenefit', 'Track your energy to get better task suggestions')}</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowEnergyCheckIn(true)}>
|
||||
{t('adhd.energy.checkIn', 'Check In')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
{features.map((feature) => (
|
||||
<Card
|
||||
key={feature.path}
|
||||
className="cursor-pointer hover:shadow-lg transition-all hover:-translate-y-1"
|
||||
onClick={() => setLocation(feature.path)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${feature.color} flex items-center justify-center mb-2`}>
|
||||
<feature.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{feature.title}</CardTitle>
|
||||
<CardDescription>{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm font-medium text-primary">{feature.stat}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('adhd.settings.title', 'ADHD Settings')}</CardTitle>
|
||||
<CardDescription>{t('adhd.settings.description', 'Customize your ADHD-friendly experience')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ADHDSettingsPanel />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Collapsible Help Section */}
|
||||
<Card>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="help" className="border-none">
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<HelpCircle className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">{t('adhd.dashboard.helpTitle', 'How Focus Tools Help You')}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6">
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t('adhd.dashboard.helpIntro', 'These tools are designed around how your brain works, not against it. Each feature addresses a specific challenge and provides practical strategies to help you get things done.')}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Quick Wins */}
|
||||
<div className="p-4 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.quickWins.title', 'Quick Wins')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.quickWins.why', 'Starting tasks feels overwhelming when everything seems big.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.quickWins.how', 'Shows only tasks under 15 minutes. Small wins build momentum and dopamine.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.quickWins.usage', 'Open Quick Wins when you feel stuck or need to build momentum.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Single Task Focus */}
|
||||
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Focus className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.singleTask.title', 'Single Task Focus')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.singleTask.why', 'Task lists are overwhelming. Seeing everything makes it hard to start anything.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.singleTask.how', 'Shows only ONE task at a time. No distractions, no choices, just focus.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.singleTask.usage', 'When you have many tasks and feel paralyzed by choice.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 5-Minute Starter */}
|
||||
<div className="p-4 rounded-lg bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.fiveMinute.title', '5-Minute Starter')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.fiveMinute.why', 'Getting started is the hardest part. Commitment feels impossible.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.fiveMinute.how', 'Commit to just 5 minutes. Often, starting leads to continuing naturally.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.fiveMinute.usage', 'For tasks you keep avoiding. Just 5 minutes, then decide if you continue.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Visual Timer */}
|
||||
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Timer className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.visualTimer.title', 'Visual Timer')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.visualTimer.why', 'Time blindness makes it hard to feel time passing.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.visualTimer.how', 'A visual countdown shows time disappearing, making it tangible.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.visualTimer.usage', 'Set timers for work sessions. The visual helps maintain awareness.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Break Reminders */}
|
||||
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Coffee className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.breaks.title', 'Break Reminders')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.breaks.why', 'Hyperfocus makes you forget basic needs like rest, food, water.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.breaks.how', 'Gentle reminders interrupt hyperfocus before burnout.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.breaks.usage', 'Enable in settings. Take the breaks even when you feel productive.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Energy Tracking */}
|
||||
<div className="p-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Battery className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.energy.title', 'Energy Tracking')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.energy.why', 'Energy fluctuates unpredictably. Fighting low energy wastes time.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.energy.how', 'Track your energy to match tasks to your current state.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.energy.usage', 'Check in daily. Do hard tasks when high, easy tasks when low.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body Doubling */}
|
||||
<div className="p-4 rounded-lg bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="h-4 w-4 text-teal-600 dark:text-teal-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.bodyDoubling.title', 'Body Doubling')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.bodyDoubling.why', 'Working alone feels impossible. Presence of others helps focus.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.bodyDoubling.how', 'Virtual co-working sessions simulate having someone nearby.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.bodyDoubling.usage', 'Join a session when working from home feels isolating.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Task Breakdown */}
|
||||
<div className="p-4 rounded-lg bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Split className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.taskBreakdown.title', 'AI Task Breakdown')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.taskBreakdown.why', 'Big tasks feel impossible to start when you cannot see the steps.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.taskBreakdown.how', 'AI breaks large tasks into small, actionable subtasks automatically.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.taskBreakdown.usage', 'Click breakdown on any overwhelming task to get clear next steps.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hyperfocus Protection */}
|
||||
<div className="p-4 rounded-lg bg-pink-50 dark:bg-pink-900/20 border border-pink-200 dark:border-pink-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="h-4 w-4 text-pink-600 dark:text-pink-400" />
|
||||
<h4 className="font-medium">{t('adhd.dashboard.help.hyperfocus.title', 'Hyperfocus Protection')}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.why', 'Why')}:</strong> {t('adhd.dashboard.help.hyperfocus.why', 'Hyperfocus can make you lose hours on one thing while neglecting others.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
<strong>{t('adhd.dashboard.help.how', 'How')}:</strong> {t('adhd.dashboard.help.hyperfocus.how', 'Alerts when you spend too long on a single task.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>{t('adhd.dashboard.help.usage', 'Use it')}:</strong> {t('adhd.dashboard.help.hyperfocus.usage', 'Enable in settings. When alerted, save your progress and decide consciously.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Card>
|
||||
|
||||
{/* Energy Check-in Modal */}
|
||||
{showEnergyCheckIn && (
|
||||
<EnergyCheckIn
|
||||
onClose={() => setShowEnergyCheckIn(false)}
|
||||
onSubmit={() => setShowEnergyCheckIn(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BodyDoublingLobby } from '@/components/adhd';
|
||||
import { User } from '@shared/schema';
|
||||
import { Users, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocation } from 'wouter';
|
||||
|
||||
export default function BodyDoublingPage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const { data: user } = useQuery<User>({
|
||||
queryKey: ['/api/user'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">{t('common.loading', 'Loading...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation('/')}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-green-400 to-teal-500 text-white">
|
||||
<Users className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('adhd.bodyDoubling.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('adhd.bodyDoubling.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-teal-50 dark:from-green-950/20 dark:to-teal-950/20 rounded-2xl p-6 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('adhd.bodyDoubling.tip', 'Working alongside others helps maintain focus. Join or create a session to stay accountable.')}
|
||||
</p>
|
||||
|
||||
<BodyDoublingLobby currentUserId={user.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { QuickWinList } from '@/components/adhd';
|
||||
import { Task } from '@shared/schema';
|
||||
import { Zap, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocation } from 'wouter';
|
||||
|
||||
export default function QuickWinsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
const handleTaskSelect = (taskId: string) => {
|
||||
setLocation(`/tasks?selected=${taskId}`);
|
||||
};
|
||||
|
||||
const handleTaskComplete = async (taskId: string) => {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'done' }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation('/')}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-yellow-400 to-orange-500 text-white">
|
||||
<Zap className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('adhd.quickWins.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('adhd.quickWins.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-yellow-50 to-orange-50 dark:from-yellow-950/20 dark:to-orange-950/20 rounded-2xl p-6 border border-yellow-200 dark:border-yellow-800">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('adhd.quickWins.tip', 'Small wins build momentum! Complete these quick tasks to boost your dopamine and build confidence.')}
|
||||
</p>
|
||||
|
||||
<QuickWinList
|
||||
tasks={tasks.filter(t => t.status !== 'done')}
|
||||
maxDuration={15}
|
||||
onTaskSelect={handleTaskSelect}
|
||||
onTaskComplete={handleTaskComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { SingleTaskView, FiveMinuteStarter } from '@/components/adhd';
|
||||
import { Task } from '@shared/schema';
|
||||
import { Focus, ArrowLeft, Timer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocation } from 'wouter';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export default function SingleTaskPage() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [showFiveMinStarter, setShowFiveMinStarter] = useState(false);
|
||||
|
||||
const { data: tasks = [] } = useQuery<Task[]>({
|
||||
queryKey: ['/api/tasks'],
|
||||
});
|
||||
|
||||
const activeTasks = tasks.filter(t => t.status !== 'done');
|
||||
const selectedTask = selectedTaskId ? tasks.find(t => t.id === selectedTaskId) : null;
|
||||
|
||||
const handleTaskComplete = async (taskId: string) => {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'done' }),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
setSelectedTaskId(null);
|
||||
};
|
||||
|
||||
const handleTaskUpdate = async (taskId: string, updates: Partial<Task>) => {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
||||
};
|
||||
|
||||
const handleFiveMinComplete = () => {
|
||||
setShowFiveMinStarter(false);
|
||||
// Continue with the task
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLocation('/')}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 text-white">
|
||||
<Focus className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('adhd.singleTask.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('adhd.singleTask.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedTask ? (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 rounded-2xl p-8 border border-blue-200 dark:border-blue-800">
|
||||
<h2 className="text-lg font-semibold mb-4">{t('adhd.singleTask.selectTask', 'Select a task to focus on')}</h2>
|
||||
<Select value={selectedTaskId || ''} onValueChange={setSelectedTaskId}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('adhd.singleTask.choosePlaceholder', 'Choose a task...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeTasks.map(task => (
|
||||
<SelectItem key={task.id} value={task.id}>
|
||||
{task.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFiveMinStarter(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Timer className="h-4 w-4" />
|
||||
{t('adhd.fiveMin.title')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SingleTaskView
|
||||
task={selectedTask}
|
||||
onComplete={handleTaskComplete}
|
||||
onExit={() => setSelectedTaskId(null)}
|
||||
onUpdateTime={(taskId, seconds) => handleTaskUpdate(taskId, { timeTracked: seconds })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFiveMinStarter && selectedTask && (
|
||||
<FiveMinuteStarter
|
||||
task={selectedTask}
|
||||
onComplete={handleFiveMinComplete}
|
||||
onCancel={() => setShowFiveMinStarter(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -13,7 +13,7 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
- "${POSTGRES_PORT:-2101}:5432"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-taskflow}" ]
|
||||
interval: 10s
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
# volumes:
|
||||
# - ./dist/public:/app/server/public
|
||||
ports:
|
||||
- "5001:5000"
|
||||
- "2100:5000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -62,8 +62,8 @@ services:
|
||||
image: mailhog/mailhog
|
||||
container_name: taskflow-mailhog
|
||||
ports:
|
||||
- "1025:1025" # SMTP
|
||||
- "8025:8025" # Web UI
|
||||
- "2102:1025" # SMTP
|
||||
- "2103:8025" # Web UI
|
||||
networks:
|
||||
- taskflow-network
|
||||
|
||||
|
||||
@@ -738,6 +738,88 @@ Format:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Break down a large task into smaller, manageable subtasks for ADHD users.
|
||||
*/
|
||||
async breakdownTask(task: { title: string; description?: string; estimatedDuration?: number }): Promise<{
|
||||
subtasks: { title: string; estimatedMinutes: number; order: number }[];
|
||||
totalEstimatedMinutes: number;
|
||||
encouragement: string;
|
||||
}> {
|
||||
const provider = await this.storage.getSystemSettings("ai_provider") || "openai";
|
||||
const apiKey = await this.storage.getSystemSettings("ai_api_key");
|
||||
const model = await this.storage.getSystemSettings("ai_model") || "gpt-4o";
|
||||
const baseUrl = await this.storage.getSystemSettings("ai_base_url");
|
||||
|
||||
// Default fallback if AI is not configured
|
||||
if (!apiKey && provider !== "ollama") {
|
||||
return {
|
||||
subtasks: [
|
||||
{ title: `Start: ${task.title}`, estimatedMinutes: 5, order: 1 },
|
||||
{ title: `Continue: ${task.title}`, estimatedMinutes: 10, order: 2 },
|
||||
{ title: `Finish: ${task.title}`, estimatedMinutes: 5, order: 3 },
|
||||
],
|
||||
totalEstimatedMinutes: 20,
|
||||
encouragement: "Du schaffst das! Nimm dir einen Schritt nach dem anderen vor.",
|
||||
};
|
||||
}
|
||||
|
||||
const prompt = `Du bist ein ADHS-Coach. Der Nutzer hat eine überwältigende Aufgabe, die in kleinere Schritte zerlegt werden muss.
|
||||
|
||||
Aufgabe: "${task.title}"
|
||||
${task.description ? `Beschreibung: "${task.description}"` : ''}
|
||||
${task.estimatedDuration ? `Geschätzte Dauer: ${task.estimatedDuration} Minuten` : ''}
|
||||
|
||||
Zerlege diese Aufgabe in 3-7 kleine, konkrete Schritte.
|
||||
Jeder Schritt sollte:
|
||||
- In 5-15 Minuten erledigt werden können
|
||||
- Eine klare, aktionsorientierte Beschreibung haben (auf Deutsch)
|
||||
- Einen konkreten Endpunkt haben
|
||||
|
||||
Antworte NUR mit einem JSON-Objekt. Keine Markdown-Formatierung.
|
||||
Format:
|
||||
{
|
||||
"subtasks": [
|
||||
{ "title": "Schritt beschreibung", "estimatedMinutes": 5, "order": 1 },
|
||||
...
|
||||
],
|
||||
"totalEstimatedMinutes": number,
|
||||
"encouragement": "Eine ermutigende Nachricht auf Deutsch"
|
||||
}`;
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: "Du bist ein hilfreicher ADHS-Coach. Antworte nur mit validem JSON." },
|
||||
{ role: "user", content: prompt }
|
||||
];
|
||||
|
||||
try {
|
||||
let responseText = "";
|
||||
if (provider === "openai" || provider === "ollama") {
|
||||
responseText = await this.chatOpenAI(provider, apiKey || "", model, baseUrl, messages, undefined, []);
|
||||
} else if (provider === "anthropic") {
|
||||
responseText = await this.chatAnthropic(apiKey || "", model, messages);
|
||||
} else if (provider === "google") {
|
||||
responseText = await this.chatGemini(apiKey || "", model, messages);
|
||||
}
|
||||
|
||||
// Clean response
|
||||
const cleanJson = responseText.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
return JSON.parse(cleanJson);
|
||||
} catch (error) {
|
||||
console.error("AI Breakdown Error:", error);
|
||||
// Fallback
|
||||
return {
|
||||
subtasks: [
|
||||
{ title: `Schritt 1: ${task.title} vorbereiten`, estimatedMinutes: 5, order: 1 },
|
||||
{ title: `Schritt 2: ${task.title} durchführen`, estimatedMinutes: 10, order: 2 },
|
||||
{ title: `Schritt 3: ${task.title} abschließen`, estimatedMinutes: 5, order: 3 },
|
||||
],
|
||||
totalEstimatedMinutes: 20,
|
||||
encouragement: "Jeder kleine Schritt zählt! Du machst das großartig.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async chatAnthropic(apiKey: string, model: string, messages: any[]): Promise<string> {
|
||||
const systemMessage = messages.find(m => m.role === "system")?.content || "";
|
||||
const userAssistantMessages = messages.filter(m => m.role !== "system");
|
||||
|
||||
+349
-1
@@ -1871,6 +1871,354 @@ ${activeTasksWithLabels.slice(0, 5).map(t => `- [${t.priority}] [${t.label}] ${t
|
||||
console.error("Export Failed:", e);
|
||||
res.status(500).json({ error: "Failed to export data" });
|
||||
}
|
||||
}); // End of routes
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ADHD MODE ROUTES
|
||||
// ============================================
|
||||
|
||||
// Update ADHD settings
|
||||
app.patch("/api/user/adhd-settings", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const { adhdMode, adhdSettings } = req.body;
|
||||
const updated = await storage.updateUser(userId, {
|
||||
adhdMode,
|
||||
adhdSettings,
|
||||
});
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId,
|
||||
action: "UPDATE",
|
||||
entityType: "USER",
|
||||
entityId: userId,
|
||||
details: { action: "UPDATE_ADHD_SETTINGS", adhdMode },
|
||||
source: "USER"
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
console.error("Update ADHD settings failed:", e);
|
||||
res.status(500).json({ error: "Failed to update ADHD settings" });
|
||||
}
|
||||
});
|
||||
|
||||
// Log a break
|
||||
app.post("/api/user/log-break", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const { breakType, durationMinutes } = req.body;
|
||||
|
||||
// Log the break
|
||||
await storage.createBreakLog({
|
||||
userId,
|
||||
breakType,
|
||||
durationMinutes: durationMinutes || 5,
|
||||
});
|
||||
|
||||
// Update user's lastBreakAt
|
||||
await storage.updateUser(userId, {
|
||||
lastBreakAt: new Date(),
|
||||
});
|
||||
|
||||
// Award XP for taking a break (micro-XP)
|
||||
await gamificationService.awardXP(userId, 10, 'break_taken', undefined, { breakType });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Log break failed:", e);
|
||||
res.status(500).json({ error: "Failed to log break" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get break stats
|
||||
app.get("/api/user/break-stats", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const stats = await storage.getBreakStats(userId);
|
||||
res.json(stats);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to get break stats" });
|
||||
}
|
||||
});
|
||||
|
||||
// Log energy level
|
||||
app.post("/api/energy/log", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const { energyLevel, notes } = req.body;
|
||||
|
||||
await storage.createEnergyLog({
|
||||
userId,
|
||||
energyLevel,
|
||||
notes,
|
||||
});
|
||||
|
||||
// Update user's current energy level
|
||||
await storage.updateUser(userId, {
|
||||
currentEnergyLevel: energyLevel,
|
||||
todayEnergyCheckedIn: true,
|
||||
});
|
||||
|
||||
// Award XP for checking in
|
||||
await gamificationService.awardXP(userId, 5, 'energy_checkin', undefined, { energyLevel });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Log energy failed:", e);
|
||||
res.status(500).json({ error: "Failed to log energy" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get energy history
|
||||
app.get("/api/energy/history", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const history = await storage.getEnergyHistory(userId);
|
||||
res.json(history);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to get energy history" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get energy-based task suggestions
|
||||
app.get("/api/tasks/energy-based", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const tasks = await storage.getTasksForUser(userId);
|
||||
const energyLevel = user.currentEnergyLevel || 'medium';
|
||||
|
||||
// Filter tasks based on energy level
|
||||
let recommended = tasks.filter(t => t.status !== 'done');
|
||||
|
||||
if (energyLevel === 'low') {
|
||||
// Prefer low-energy tasks for low energy
|
||||
recommended = recommended.filter(t =>
|
||||
t.energyLevel === 'low' ||
|
||||
(t.estimatedDuration && t.estimatedDuration <= 15) ||
|
||||
t.priority === 'low'
|
||||
);
|
||||
} else if (energyLevel === 'high') {
|
||||
// Prefer high-energy/challenging tasks
|
||||
recommended = recommended.filter(t =>
|
||||
t.energyLevel === 'high' ||
|
||||
t.priority === 'high' ||
|
||||
(t.estimatedDuration && t.estimatedDuration > 30)
|
||||
);
|
||||
}
|
||||
|
||||
res.json(recommended.slice(0, 10));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to get energy-based tasks" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get quick wins
|
||||
app.get("/api/tasks/quick-wins", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const tasks = await storage.getTasksForUser(userId);
|
||||
const threshold = (user.adhdSettings as any)?.quickWinThreshold || 10;
|
||||
|
||||
const quickWins = tasks
|
||||
.filter(t => {
|
||||
if (t.status === 'done') return false;
|
||||
if (t.estimatedDuration && t.estimatedDuration > threshold * 1.5) return false;
|
||||
return true;
|
||||
})
|
||||
.slice(0, 10);
|
||||
|
||||
res.json(quickWins);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to get quick wins" });
|
||||
}
|
||||
});
|
||||
|
||||
// AI Task Breakdown
|
||||
app.post("/api/ai/breakdown-task", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const { taskId, title, description, estimatedDuration } = req.body;
|
||||
|
||||
const breakdown = await aiService.breakdownTask({
|
||||
title,
|
||||
description,
|
||||
estimatedDuration,
|
||||
});
|
||||
|
||||
await storage.createAuditLog({
|
||||
userId,
|
||||
action: "AI_BREAKDOWN",
|
||||
entityType: "TASK",
|
||||
entityId: taskId,
|
||||
details: { title, subtaskCount: breakdown.subtasks?.length },
|
||||
source: "AI"
|
||||
});
|
||||
|
||||
res.json(breakdown);
|
||||
} catch (e) {
|
||||
console.error("AI breakdown failed:", e);
|
||||
res.status(500).json({ error: "Failed to break down task" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// BODY DOUBLING SESSION ROUTES
|
||||
// ============================================
|
||||
|
||||
// Get all sessions
|
||||
app.get("/api/sessions", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const sessions = await storage.getBodyDoublingSessions(userId);
|
||||
res.json(sessions);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to get sessions" });
|
||||
}
|
||||
});
|
||||
|
||||
// Create session
|
||||
app.post("/api/sessions", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const session = await storage.createBodyDoublingSession({
|
||||
hostId: userId,
|
||||
...req.body,
|
||||
});
|
||||
|
||||
// Auto-join as participant
|
||||
await storage.joinSession(session.id, userId);
|
||||
|
||||
res.json(session);
|
||||
} catch (e) {
|
||||
console.error("Create session failed:", e);
|
||||
res.status(500).json({ error: "Failed to create session" });
|
||||
}
|
||||
});
|
||||
|
||||
// Join session
|
||||
app.post("/api/sessions/:id/join", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
const sessionId = req.params.id;
|
||||
try {
|
||||
await storage.joinSession(sessionId, userId);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Join session failed:", e);
|
||||
res.status(500).json({ error: "Failed to join session" });
|
||||
}
|
||||
});
|
||||
|
||||
// Leave session
|
||||
app.post("/api/sessions/:id/leave", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
const sessionId = req.params.id;
|
||||
try {
|
||||
await storage.leaveSession(sessionId, userId);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
console.error("Leave session failed:", e);
|
||||
res.status(500).json({ error: "Failed to leave session" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// FOCUS SESSION ROUTES
|
||||
// ============================================
|
||||
|
||||
// Start focus session
|
||||
app.post("/api/focus-sessions", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const session = await storage.createFocusSession({
|
||||
userId,
|
||||
...req.body,
|
||||
});
|
||||
res.json(session);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to start focus session" });
|
||||
}
|
||||
});
|
||||
|
||||
// Complete focus session
|
||||
app.patch("/api/focus-sessions/:id/complete", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const { actualMinutes, wasCompleted } = req.body;
|
||||
const session = await storage.completeFocusSession(req.params.id, actualMinutes, wasCompleted);
|
||||
|
||||
// Award XP based on completion
|
||||
const xpAmount = wasCompleted ? 15 : 5;
|
||||
await gamificationService.awardXP(userId, xpAmount, 'focus_session', undefined, {
|
||||
sessionType: session?.sessionType,
|
||||
actualMinutes,
|
||||
});
|
||||
|
||||
res.json(session);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to complete focus session" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// DAILY CHALLENGES ROUTES
|
||||
// ============================================
|
||||
|
||||
// Get today's challenges
|
||||
app.get("/api/challenges/today", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
let challenges = await storage.getTodaysChallenges(userId);
|
||||
|
||||
// Generate new challenges if none exist for today
|
||||
if (challenges.length === 0) {
|
||||
challenges = await storage.generateDailyChallenges(userId);
|
||||
}
|
||||
|
||||
res.json(challenges);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to get challenges" });
|
||||
}
|
||||
});
|
||||
|
||||
// Update challenge progress
|
||||
app.post("/api/challenges/:id/progress", async (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
const userId = (req.user as User).id;
|
||||
try {
|
||||
const { increment } = req.body;
|
||||
const challenge = await storage.updateChallengeProgress(req.params.id, increment || 1);
|
||||
|
||||
// Award XP if completed
|
||||
if (challenge?.completedAt && challenge.xpReward) {
|
||||
await gamificationService.awardXP(userId, challenge.xpReward, 'daily_challenge', undefined, {
|
||||
challengeType: challenge.challengeType,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(challenge);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: "Failed to update challenge progress" });
|
||||
}
|
||||
});
|
||||
|
||||
// End of routes
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
+256
-1
@@ -1,4 +1,4 @@
|
||||
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, type Reward, type InsertReward, type UserReward, type InsertUserReward, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, type InsertPasswordResetToken, type PasswordResetToken, type Conversation, type InsertConversation, type Message, type InsertMessage, type AuditLog, type InsertAuditLog, type TaskTimeLog, type InsertTaskTimeLog } from "@shared/schema";
|
||||
import { type User, type InsertUser, type Label, type InsertLabel, type SharedLabel, type Task, type InsertTask, type XpEvent, type InsertXpEvent, type Goal, type InsertGoal, type Reward, type InsertReward, type UserReward, type InsertUserReward, type SystemSettings, type InsertSystemSettings, type SharedTask, type InsertSharedTask, type UserTaskAccess, type InsertUserTaskAccess, type InsertPasswordResetToken, type PasswordResetToken, type Conversation, type InsertConversation, type Message, type InsertMessage, type AuditLog, type InsertAuditLog, type TaskTimeLog, type InsertTaskTimeLog, type BreakLog, type EnergyLog, type BodyDoublingSession, type FocusSession, type DailyChallenge } from "@shared/schema";
|
||||
import * as schema from "@shared/schema";
|
||||
import { getDatabase, pool } from "./db";
|
||||
import { eq, sql, and, desc, asc, gt, ne, or, isNull } from "drizzle-orm";
|
||||
@@ -109,6 +109,27 @@ export interface IStorage {
|
||||
// Time Tracking
|
||||
logTaskTime(log: InsertTaskTimeLog): Promise<TaskTimeLog>;
|
||||
getAnalyticsTimeDistribution(userId: string, period: 'day' | 'week' | 'month' | 'year'): Promise<{ labelId: string | null, labelName: string | null, labelColor: string | null, timeSpent: number }[]>;
|
||||
|
||||
// ADHD Features
|
||||
createBreakLog(log: { userId: string; breakType: string; durationMinutes: number }): Promise<any>;
|
||||
getBreakStats(userId: string): Promise<{ todayBreaks: number; totalMinutes: number; lastBreak: Date | null }>;
|
||||
createEnergyLog(log: { userId: string; energyLevel: string; notes?: string }): Promise<any>;
|
||||
getEnergyHistory(userId: string): Promise<any[]>;
|
||||
|
||||
// Body Doubling Sessions
|
||||
getBodyDoublingSessions(userId: string): Promise<any[]>;
|
||||
createBodyDoublingSession(session: any): Promise<any>;
|
||||
joinSession(sessionId: string, userId: string): Promise<void>;
|
||||
leaveSession(sessionId: string, userId: string): Promise<void>;
|
||||
|
||||
// Focus Sessions
|
||||
createFocusSession(session: any): Promise<any>;
|
||||
completeFocusSession(sessionId: string, actualMinutes: number, wasCompleted: boolean): Promise<any>;
|
||||
|
||||
// Daily Challenges
|
||||
getTodaysChallenges(userId: string): Promise<any[]>;
|
||||
generateDailyChallenges(userId: string): Promise<any[]>;
|
||||
updateChallengeProgress(challengeId: string, increment: number): Promise<any>;
|
||||
}
|
||||
|
||||
export class MemStorage implements IStorage {
|
||||
@@ -787,6 +808,21 @@ export class MemStorage implements IStorage {
|
||||
|
||||
return Array.from(distribution.values());
|
||||
}
|
||||
|
||||
// ADHD Feature stubs for MemStorage (minimal implementation)
|
||||
async createBreakLog(_log: any): Promise<any> { return {}; }
|
||||
async getBreakStats(_userId: string): Promise<any> { return { todayBreaks: 0, totalMinutes: 0, lastBreak: null }; }
|
||||
async createEnergyLog(_log: any): Promise<any> { return {}; }
|
||||
async getEnergyHistory(_userId: string): Promise<any[]> { return []; }
|
||||
async getBodyDoublingSessions(_userId: string): Promise<any[]> { return []; }
|
||||
async createBodyDoublingSession(_session: any): Promise<any> { return {}; }
|
||||
async joinSession(_sessionId: string, _userId: string): Promise<void> {}
|
||||
async leaveSession(_sessionId: string, _userId: string): Promise<void> {}
|
||||
async createFocusSession(_session: any): Promise<any> { return {}; }
|
||||
async completeFocusSession(_sessionId: string, _actualMinutes: number, _wasCompleted: boolean): Promise<any> { return undefined; }
|
||||
async getTodaysChallenges(_userId: string): Promise<any[]> { return []; }
|
||||
async generateDailyChallenges(_userId: string): Promise<any[]> { return []; }
|
||||
async updateChallengeProgress(_challengeId: string, _increment: number): Promise<any> { return undefined; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1369,6 +1405,225 @@ export class DbStorage implements IStorage {
|
||||
timeSpent: log.timeSpent || 0
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ADHD FEATURE IMPLEMENTATIONS
|
||||
// ============================================
|
||||
|
||||
async createBreakLog(log: { userId: string; breakType: string; durationMinutes: number }): Promise<BreakLog> {
|
||||
const [result] = await this.db.insert(schema.breakLogs).values({
|
||||
userId: log.userId,
|
||||
breakType: log.breakType,
|
||||
durationMinutes: log.durationMinutes,
|
||||
}).returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
async getBreakStats(userId: string): Promise<{ todayBreaks: number; totalMinutes: number; lastBreak: Date | null }> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayLogs = await this.db.select()
|
||||
.from(schema.breakLogs)
|
||||
.where(and(
|
||||
eq(schema.breakLogs.userId, userId),
|
||||
gt(schema.breakLogs.createdAt, today)
|
||||
));
|
||||
|
||||
const totalMinutes = todayLogs.reduce((sum, log) => sum + log.durationMinutes, 0);
|
||||
const lastLog = await this.db.select()
|
||||
.from(schema.breakLogs)
|
||||
.where(eq(schema.breakLogs.userId, userId))
|
||||
.orderBy(desc(schema.breakLogs.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
todayBreaks: todayLogs.length,
|
||||
totalMinutes,
|
||||
lastBreak: lastLog[0]?.createdAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
async createEnergyLog(log: { userId: string; energyLevel: string; notes?: string }): Promise<EnergyLog> {
|
||||
const [result] = await this.db.insert(schema.energyLogs).values({
|
||||
userId: log.userId,
|
||||
energyLevel: log.energyLevel,
|
||||
notes: log.notes,
|
||||
}).returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
async getEnergyHistory(userId: string): Promise<EnergyLog[]> {
|
||||
return await this.db.select()
|
||||
.from(schema.energyLogs)
|
||||
.where(eq(schema.energyLogs.userId, userId))
|
||||
.orderBy(desc(schema.energyLogs.loggedAt))
|
||||
.limit(30);
|
||||
}
|
||||
|
||||
async getBodyDoublingSessions(userId: string): Promise<any[]> {
|
||||
const sessions = await this.db.select({
|
||||
id: schema.bodyDoublingSessions.id,
|
||||
hostId: schema.bodyDoublingSessions.hostId,
|
||||
title: schema.bodyDoublingSessions.title,
|
||||
sessionType: schema.bodyDoublingSessions.sessionType,
|
||||
startsAt: schema.bodyDoublingSessions.startsAt,
|
||||
durationMinutes: schema.bodyDoublingSessions.durationMinutes,
|
||||
maxParticipants: schema.bodyDoublingSessions.maxParticipants,
|
||||
isPublic: schema.bodyDoublingSessions.isPublic,
|
||||
status: schema.bodyDoublingSessions.status,
|
||||
createdAt: schema.bodyDoublingSessions.createdAt,
|
||||
hostUsername: schema.users.username,
|
||||
})
|
||||
.from(schema.bodyDoublingSessions)
|
||||
.innerJoin(schema.users, eq(schema.bodyDoublingSessions.hostId, schema.users.id))
|
||||
.where(or(
|
||||
eq(schema.bodyDoublingSessions.isPublic, true),
|
||||
eq(schema.bodyDoublingSessions.hostId, userId)
|
||||
))
|
||||
.orderBy(asc(schema.bodyDoublingSessions.startsAt));
|
||||
|
||||
// Get participant counts
|
||||
const result = await Promise.all(sessions.map(async (session) => {
|
||||
const participants = await this.db.select()
|
||||
.from(schema.sessionParticipants)
|
||||
.where(eq(schema.sessionParticipants.sessionId, session.id));
|
||||
|
||||
const isParticipant = participants.some(p => p.userId === userId);
|
||||
|
||||
return {
|
||||
...session,
|
||||
host: { username: session.hostUsername },
|
||||
participantCount: participants.length,
|
||||
isParticipant,
|
||||
};
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async createBodyDoublingSession(sessionData: any): Promise<BodyDoublingSession> {
|
||||
const [result] = await this.db.insert(schema.bodyDoublingSessions).values({
|
||||
hostId: sessionData.hostId,
|
||||
title: sessionData.title,
|
||||
sessionType: sessionData.sessionType || 'focus',
|
||||
startsAt: new Date(sessionData.startsAt),
|
||||
durationMinutes: sessionData.durationMinutes || 50,
|
||||
maxParticipants: sessionData.maxParticipants || 5,
|
||||
isPublic: sessionData.isPublic ?? true,
|
||||
}).returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
async joinSession(sessionId: string, userId: string): Promise<void> {
|
||||
// Check if already a participant
|
||||
const existing = await this.db.select()
|
||||
.from(schema.sessionParticipants)
|
||||
.where(and(
|
||||
eq(schema.sessionParticipants.sessionId, sessionId),
|
||||
eq(schema.sessionParticipants.userId, userId)
|
||||
));
|
||||
|
||||
if (existing.length === 0) {
|
||||
await this.db.insert(schema.sessionParticipants).values({
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async leaveSession(sessionId: string, userId: string): Promise<void> {
|
||||
await this.db.update(schema.sessionParticipants)
|
||||
.set({ leftAt: new Date() })
|
||||
.where(and(
|
||||
eq(schema.sessionParticipants.sessionId, sessionId),
|
||||
eq(schema.sessionParticipants.userId, userId)
|
||||
));
|
||||
}
|
||||
|
||||
async createFocusSession(sessionData: any): Promise<FocusSession> {
|
||||
const [result] = await this.db.insert(schema.focusSessions).values({
|
||||
userId: sessionData.userId,
|
||||
taskId: sessionData.taskId,
|
||||
sessionType: sessionData.sessionType || 'pomodoro',
|
||||
plannedMinutes: sessionData.plannedMinutes,
|
||||
}).returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
async completeFocusSession(sessionId: string, actualMinutes: number, wasCompleted: boolean): Promise<FocusSession | undefined> {
|
||||
const [result] = await this.db.update(schema.focusSessions)
|
||||
.set({
|
||||
actualMinutes,
|
||||
wasCompleted,
|
||||
endedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.focusSessions.id, sessionId))
|
||||
.returning();
|
||||
return result;
|
||||
}
|
||||
|
||||
async getTodaysChallenges(userId: string): Promise<DailyChallenge[]> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return await this.db.select()
|
||||
.from(schema.dailyChallenges)
|
||||
.where(and(
|
||||
eq(schema.dailyChallenges.userId, userId),
|
||||
eq(schema.dailyChallenges.challengeDate, today)
|
||||
));
|
||||
}
|
||||
|
||||
async generateDailyChallenges(userId: string): Promise<DailyChallenge[]> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const challengeTemplates = [
|
||||
{ type: 'complete_tasks', title: 'Complete 3 tasks', target: 3, xp: 50 },
|
||||
{ type: 'take_breaks', title: 'Take 2 breaks', target: 2, xp: 30 },
|
||||
{ type: 'quick_wins', title: 'Complete 2 quick wins', target: 2, xp: 40 },
|
||||
{ type: 'focus_time', title: 'Focus for 25 minutes', target: 25, xp: 35 },
|
||||
{ type: 'use_timer', title: 'Use the visual timer', target: 1, xp: 20 },
|
||||
];
|
||||
|
||||
// Pick 3 random challenges
|
||||
const shuffled = challengeTemplates.sort(() => Math.random() - 0.5);
|
||||
const selected = shuffled.slice(0, 3);
|
||||
|
||||
const challenges = await Promise.all(selected.map(async (template) => {
|
||||
const [result] = await this.db.insert(schema.dailyChallenges).values({
|
||||
userId,
|
||||
challengeType: template.type,
|
||||
title: template.title,
|
||||
target: template.target,
|
||||
xpReward: template.xp,
|
||||
challengeDate: today,
|
||||
}).returning();
|
||||
return result;
|
||||
}));
|
||||
|
||||
return challenges;
|
||||
}
|
||||
|
||||
async updateChallengeProgress(challengeId: string, increment: number): Promise<DailyChallenge | undefined> {
|
||||
const challenge = await this.db.select()
|
||||
.from(schema.dailyChallenges)
|
||||
.where(eq(schema.dailyChallenges.id, challengeId))
|
||||
.limit(1);
|
||||
|
||||
if (!challenge[0]) return undefined;
|
||||
|
||||
const newProgress = Math.min(challenge[0].progress + increment, challenge[0].target);
|
||||
const isCompleted = newProgress >= challenge[0].target;
|
||||
|
||||
const [result] = await this.db.update(schema.dailyChallenges)
|
||||
.set({
|
||||
progress: newProgress,
|
||||
completedAt: isCompleted && !challenge[0].completedAt ? new Date() : challenge[0].completedAt,
|
||||
})
|
||||
.where(eq(schema.dailyChallenges.id, challengeId))
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Export storage based on environment
|
||||
|
||||
+174
-1
@@ -1,8 +1,22 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { pgTable, text, varchar, timestamp, integer, boolean, json } from "drizzle-orm/pg-core";
|
||||
import { pgTable, text, varchar, timestamp, integer, boolean, json, date } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
|
||||
// ADHD Settings Type
|
||||
export interface ADHDSettings {
|
||||
reducedAnimations: boolean;
|
||||
largerTargets: boolean;
|
||||
breakReminderInterval: number; // minutes
|
||||
singleTaskModeDefault: boolean;
|
||||
positiveMessagingLevel: 'minimal' | 'normal' | 'high';
|
||||
hyperfocusProtection: boolean;
|
||||
hyperfocusMaxMinutes: number;
|
||||
visualTimerEnabled: boolean;
|
||||
quickWinThreshold: number; // minutes - tasks under this are "quick wins"
|
||||
energyCheckInsEnabled: boolean;
|
||||
}
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
username: text("username").notNull().unique(),
|
||||
@@ -36,6 +50,24 @@ export const users = pgTable("users", {
|
||||
work: { start: "09:00", end: "17:00", days: [1, 2, 3, 4, 5] },
|
||||
personal: { start: "18:00", end: "22:00", days: [1, 2, 3, 4, 5, 0, 6] } // Mon-Fri Evening + Weekend
|
||||
}),
|
||||
|
||||
// ADHD Mode Fields
|
||||
adhdMode: boolean("adhd_mode").notNull().default(false),
|
||||
adhdSettings: json("adhd_settings").$type<ADHDSettings>().default({
|
||||
reducedAnimations: false,
|
||||
largerTargets: true,
|
||||
breakReminderInterval: 45,
|
||||
singleTaskModeDefault: false,
|
||||
positiveMessagingLevel: 'normal',
|
||||
hyperfocusProtection: true,
|
||||
hyperfocusMaxMinutes: 90,
|
||||
visualTimerEnabled: true,
|
||||
quickWinThreshold: 10,
|
||||
energyCheckInsEnabled: true,
|
||||
}),
|
||||
lastBreakAt: timestamp("last_break_at"),
|
||||
currentEnergyLevel: text("current_energy_level").default("medium"), // 'low' | 'medium' | 'high'
|
||||
todayEnergyCheckedIn: boolean("today_energy_checked_in").default(false),
|
||||
});
|
||||
|
||||
export const systemSettings = pgTable("system_settings", {
|
||||
@@ -116,6 +148,8 @@ export const insertUserSchema = createInsertSchema(users).pick({
|
||||
language: true,
|
||||
workHours: true,
|
||||
availability: true,
|
||||
adhdMode: true,
|
||||
adhdSettings: true,
|
||||
});
|
||||
|
||||
export const registerSchema = insertUserSchema;
|
||||
@@ -351,3 +385,142 @@ export const insertTaskTimeLogSchema = createInsertSchema(taskTimeLogs).omit({
|
||||
|
||||
export type InsertTaskTimeLog = z.infer<typeof insertTaskTimeLogSchema>;
|
||||
export type TaskTimeLog = typeof taskTimeLogs.$inferSelect;
|
||||
|
||||
// ============================================
|
||||
// ADHD FEATURE TABLES
|
||||
// ============================================
|
||||
|
||||
// Energy Logs - Track daily energy levels
|
||||
export const energyLogs = pgTable("energy_logs", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id").references(() => users.id).notNull(),
|
||||
energyLevel: text("energy_level").notNull(), // 'low' | 'medium' | 'high'
|
||||
notes: text("notes"),
|
||||
loggedAt: timestamp("logged_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const insertEnergyLogSchema = createInsertSchema(energyLogs).omit({
|
||||
id: true,
|
||||
loggedAt: true,
|
||||
});
|
||||
|
||||
export type InsertEnergyLog = z.infer<typeof insertEnergyLogSchema>;
|
||||
export type EnergyLog = typeof energyLogs.$inferSelect;
|
||||
|
||||
// Break Logs - Track when users take breaks
|
||||
export const breakLogs = pgTable("break_logs", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id").references(() => users.id).notNull(),
|
||||
breakType: text("break_type").notNull(), // 'stretch' | 'water' | 'walk' | 'eyes' | 'snack' | 'breathe' | 'custom'
|
||||
durationMinutes: integer("duration_minutes").notNull().default(5),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const insertBreakLogSchema = createInsertSchema(breakLogs).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
export type InsertBreakLog = z.infer<typeof insertBreakLogSchema>;
|
||||
export type BreakLog = typeof breakLogs.$inferSelect;
|
||||
|
||||
// Body Doubling Sessions
|
||||
export const bodyDoublingSessions = pgTable("body_doubling_sessions", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
hostId: varchar("host_id").references(() => users.id).notNull(),
|
||||
title: text("title").notNull(),
|
||||
sessionType: text("session_type").notNull().default("focus"), // 'focus' | 'brainstorm' | 'admin' | 'creative'
|
||||
startsAt: timestamp("starts_at").notNull(),
|
||||
durationMinutes: integer("duration_minutes").notNull().default(50),
|
||||
maxParticipants: integer("max_participants").notNull().default(5),
|
||||
isPublic: boolean("is_public").notNull().default(true),
|
||||
status: text("status").notNull().default("scheduled"), // 'scheduled' | 'active' | 'completed' | 'cancelled'
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const insertBodyDoublingSessionSchema = createInsertSchema(bodyDoublingSessions).omit({
|
||||
id: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
export type InsertBodyDoublingSession = z.infer<typeof insertBodyDoublingSessionSchema>;
|
||||
export type BodyDoublingSession = typeof bodyDoublingSessions.$inferSelect;
|
||||
|
||||
// Session Participants
|
||||
export const sessionParticipants = pgTable("session_participants", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
sessionId: varchar("session_id").references(() => bodyDoublingSessions.id).notNull(),
|
||||
userId: varchar("user_id").references(() => users.id).notNull(),
|
||||
joinedAt: timestamp("joined_at").defaultNow(),
|
||||
leftAt: timestamp("left_at"),
|
||||
});
|
||||
|
||||
export const insertSessionParticipantSchema = createInsertSchema(sessionParticipants).omit({
|
||||
id: true,
|
||||
joinedAt: true,
|
||||
leftAt: true,
|
||||
});
|
||||
|
||||
export type InsertSessionParticipant = z.infer<typeof insertSessionParticipantSchema>;
|
||||
export type SessionParticipant = typeof sessionParticipants.$inferSelect;
|
||||
|
||||
// Daily Challenges for ADHD motivation
|
||||
export const dailyChallenges = pgTable("daily_challenges", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id").references(() => users.id).notNull(),
|
||||
challengeType: text("challenge_type").notNull(), // 'complete_tasks' | 'take_breaks' | 'quick_wins' | 'focus_time' | 'use_timer'
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
target: integer("target").notNull(),
|
||||
progress: integer("progress").notNull().default(0),
|
||||
xpReward: integer("xp_reward").notNull(),
|
||||
challengeDate: date("challenge_date").notNull(),
|
||||
completedAt: timestamp("completed_at"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const insertDailyChallengeSchema = createInsertSchema(dailyChallenges).omit({
|
||||
id: true,
|
||||
progress: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
export type InsertDailyChallenge = z.infer<typeof insertDailyChallengeSchema>;
|
||||
export type DailyChallenge = typeof dailyChallenges.$inferSelect;
|
||||
|
||||
// Focus Sessions - Track focused work periods
|
||||
export const focusSessions = pgTable("focus_sessions", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id").references(() => users.id).notNull(),
|
||||
taskId: varchar("task_id").references(() => tasks.id),
|
||||
sessionType: text("session_type").notNull().default("pomodoro"), // 'pomodoro' | 'five_minute' | 'deep_focus' | 'visual_timer'
|
||||
plannedMinutes: integer("planned_minutes").notNull(),
|
||||
actualMinutes: integer("actual_minutes"),
|
||||
wasCompleted: boolean("was_completed").default(false),
|
||||
startedAt: timestamp("started_at").defaultNow(),
|
||||
endedAt: timestamp("ended_at"),
|
||||
});
|
||||
|
||||
export const insertFocusSessionSchema = createInsertSchema(focusSessions).omit({
|
||||
id: true,
|
||||
actualMinutes: true,
|
||||
wasCompleted: true,
|
||||
startedAt: true,
|
||||
endedAt: true,
|
||||
});
|
||||
|
||||
export type InsertFocusSession = z.infer<typeof insertFocusSessionSchema>;
|
||||
export type FocusSession = typeof focusSessions.$inferSelect;
|
||||
|
||||
// Encouragements/Affirmations shown to users
|
||||
export const encouragementLogs = pgTable("encouragement_logs", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id").references(() => users.id).notNull(),
|
||||
encouragementType: text("encouragement_type").notNull(), // 'task_complete' | 'returning' | 'struggling' | 'streak_broken' | 'milestone'
|
||||
message: text("message").notNull(),
|
||||
shownAt: timestamp("shown_at").defaultNow(),
|
||||
});
|
||||
|
||||
export type EncouragementLog = typeof encouragementLogs.$inferSelect;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('ADHD Mode Features', () => {
|
||||
const username = 'adhd_test_' + Date.now();
|
||||
const email = username + '@example.com';
|
||||
const password = 'testpass123';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Listen for console logs and errors
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
console.log(`BROWSER ERROR: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', exception => console.log(`PAGE ERROR: ${exception}`));
|
||||
});
|
||||
|
||||
test('ADHD Dashboard is accessible and displays features', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
// Go to app
|
||||
await page.goto('http://localhost:2100');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Register if needed
|
||||
const registerBtn = page.getByRole('button', { name: 'Register' });
|
||||
if (await registerBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await registerBtn.click();
|
||||
await page.fill('input[name="username"]', username);
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
} else {
|
||||
// Try login
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Navigate to ADHD Dashboard
|
||||
await page.goto('http://localhost:2100/adhd');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check if ADHD Dashboard loaded (look for any ADHD-related content)
|
||||
const pageContent = await page.content();
|
||||
console.log('Page loaded, checking for ADHD content...');
|
||||
|
||||
// Should see ADHD-related content
|
||||
const hasADHDContent = pageContent.includes('ADHD') ||
|
||||
pageContent.includes('ADHS') ||
|
||||
pageContent.includes('Quick Win') ||
|
||||
pageContent.includes('Schnelle') ||
|
||||
pageContent.includes('Focus') ||
|
||||
pageContent.includes('Fokus');
|
||||
|
||||
expect(hasADHDContent).toBeTruthy();
|
||||
console.log('ADHD Dashboard test passed!');
|
||||
});
|
||||
|
||||
test('ADHD navigation item exists in sidebar', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto('http://localhost:2100');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Try to login first
|
||||
const loginInput = page.locator('input[name="username"]');
|
||||
if (await loginInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Check for ADHD navigation item (Brain icon or ADHD text)
|
||||
const pageContent = await page.content();
|
||||
const hasADHDNav = pageContent.includes('/adhd') ||
|
||||
pageContent.includes('ADHD Mode') ||
|
||||
pageContent.includes('ADHS-Modus');
|
||||
|
||||
console.log('Checking for ADHD navigation...');
|
||||
expect(hasADHDNav).toBeTruthy();
|
||||
console.log('ADHD navigation test passed!');
|
||||
});
|
||||
|
||||
test('Quick Wins page loads and shows tasks', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto('http://localhost:2100');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Login
|
||||
const loginInput = page.locator('input[name="username"]');
|
||||
if (await loginInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Navigate to Quick Wins page
|
||||
await page.goto('http://localhost:2100/adhd/quick-wins');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const pageContent = await page.content();
|
||||
const hasQuickWinsContent = pageContent.includes('Quick Win') ||
|
||||
pageContent.includes('Schnelle') ||
|
||||
pageContent.includes('dopamine') ||
|
||||
pageContent.includes('Dopamin');
|
||||
|
||||
console.log('Checking Quick Wins page...');
|
||||
expect(hasQuickWinsContent).toBeTruthy();
|
||||
console.log('Quick Wins page test passed!');
|
||||
});
|
||||
|
||||
test('Single Task Focus page loads', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto('http://localhost:2100');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Login
|
||||
const loginInput = page.locator('input[name="username"]');
|
||||
if (await loginInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Navigate to Single Task page
|
||||
await page.goto('http://localhost:2100/adhd/single-task');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const pageContent = await page.content();
|
||||
const hasSingleTaskContent = pageContent.includes('Single Task') ||
|
||||
pageContent.includes('Einzelaufgabe') ||
|
||||
pageContent.includes('focus') ||
|
||||
pageContent.includes('Fokus') ||
|
||||
pageContent.includes('select') ||
|
||||
pageContent.includes('wählen');
|
||||
|
||||
console.log('Checking Single Task page...');
|
||||
expect(hasSingleTaskContent).toBeTruthy();
|
||||
console.log('Single Task page test passed!');
|
||||
});
|
||||
|
||||
test('Body Doubling page loads', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
await page.goto('http://localhost:2100');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Login
|
||||
const loginInput = page.locator('input[name="username"]');
|
||||
if (await loginInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await page.fill('input[name="username"]', 'admin');
|
||||
await page.fill('input[name="password"]', 'admin');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Navigate to Body Doubling page
|
||||
await page.goto('http://localhost:2100/adhd/body-doubling');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const pageContent = await page.content();
|
||||
const hasBodyDoublingContent = pageContent.includes('Body Doubling') ||
|
||||
pageContent.includes('Virtuelles') ||
|
||||
pageContent.includes('session') ||
|
||||
pageContent.includes('Sitzung') ||
|
||||
pageContent.includes('together') ||
|
||||
pageContent.includes('gemeinsam');
|
||||
|
||||
console.log('Checking Body Doubling page...');
|
||||
expect(hasBodyDoublingContent).toBeTruthy();
|
||||
console.log('Body Doubling page test passed!');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user