- Achievements system with 6 badges and confetti celebration animation - Health recovery timeline showing 9 milestones from 20min to 1 year - Money savings tracker with cost configuration and goal progress - Daily reminder notifications with browser permission handling - New Prisma models: Achievement, ReminderSettings, SavingsConfig - API routes for all new features - Full dashboard integration with staggered animations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
195 lines
6.9 KiB
TypeScript
195 lines
6.9 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { HEALTH_MILESTONES, getMinutesSinceQuit, UsageEntry } from '@/lib/storage';
|
|
import { useTheme } from '@/lib/theme-context';
|
|
import {
|
|
Heart,
|
|
Wind,
|
|
HeartPulse,
|
|
Eye,
|
|
Activity,
|
|
TrendingUp,
|
|
Sparkles,
|
|
HeartHandshake,
|
|
CheckCircle2,
|
|
Clock,
|
|
} from 'lucide-react';
|
|
|
|
interface HealthTimelineCardProps {
|
|
usageData: UsageEntry[];
|
|
substance: 'nicotine' | 'weed';
|
|
}
|
|
|
|
const iconMap: Record<string, React.ElementType> = {
|
|
Heart,
|
|
Wind,
|
|
HeartPulse,
|
|
Eye,
|
|
Activity,
|
|
TrendingUp,
|
|
Sparkles,
|
|
HeartHandshake,
|
|
};
|
|
|
|
function formatDuration(minutes: number): string {
|
|
if (minutes < 60) return `${minutes} min`;
|
|
if (minutes < 1440) return `${Math.floor(minutes / 60)} hrs`;
|
|
if (minutes < 10080) return `${Math.floor(minutes / 1440)} days`;
|
|
if (minutes < 43200) return `${Math.floor(minutes / 10080)} weeks`;
|
|
if (minutes < 525600) return `${Math.floor(minutes / 43200)} months`;
|
|
return `${Math.floor(minutes / 525600)} year${minutes >= 1051200 ? 's' : ''}`;
|
|
}
|
|
|
|
function formatTimeRemaining(currentMinutes: number, targetMinutes: number): string {
|
|
const remaining = targetMinutes - currentMinutes;
|
|
if (remaining <= 0) return 'Achieved!';
|
|
return `${formatDuration(remaining)} to go`;
|
|
}
|
|
|
|
export function HealthTimelineCard({ usageData, substance }: HealthTimelineCardProps) {
|
|
const { theme } = useTheme();
|
|
|
|
const minutesSinceQuit = useMemo(() => {
|
|
return getMinutesSinceQuit(usageData, substance);
|
|
}, [usageData, substance]);
|
|
|
|
const currentMilestoneIndex = useMemo(() => {
|
|
for (let i = HEALTH_MILESTONES.length - 1; i >= 0; i--) {
|
|
if (minutesSinceQuit >= HEALTH_MILESTONES[i].timeMinutes) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}, [minutesSinceQuit]);
|
|
|
|
const nextMilestone = useMemo(() => {
|
|
const nextIndex = currentMilestoneIndex + 1;
|
|
if (nextIndex < HEALTH_MILESTONES.length) {
|
|
return HEALTH_MILESTONES[nextIndex];
|
|
}
|
|
return null;
|
|
}, [currentMilestoneIndex]);
|
|
|
|
const progressToNext = useMemo(() => {
|
|
if (!nextMilestone) return 100;
|
|
const prevMinutes =
|
|
currentMilestoneIndex >= 0
|
|
? HEALTH_MILESTONES[currentMilestoneIndex].timeMinutes
|
|
: 0;
|
|
const range = nextMilestone.timeMinutes - prevMinutes;
|
|
const progress = minutesSinceQuit - prevMinutes;
|
|
return Math.min(100, Math.max(0, (progress / range) * 100));
|
|
}, [minutesSinceQuit, nextMilestone, currentMilestoneIndex]);
|
|
|
|
const cardBackground =
|
|
theme === 'light'
|
|
? 'linear-gradient(135deg, rgba(6, 95, 70, 0.85) 0%, rgba(4, 120, 87, 0.9) 100%)'
|
|
: 'linear-gradient(135deg, rgba(20, 184, 166, 0.2) 0%, rgba(6, 182, 212, 0.15) 100%)';
|
|
|
|
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
|
|
|
|
return (
|
|
<Card
|
|
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
|
|
style={{ background: cardBackground }}
|
|
>
|
|
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-teal-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
|
|
|
<CardHeader className="relative z-10 pb-2">
|
|
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
|
<Heart className="h-5 w-5 text-teal-400" />
|
|
<span>Health Recovery</span>
|
|
</CardTitle>
|
|
<p className="text-sm text-white/70">
|
|
{substanceLabel}-free for {formatDuration(minutesSinceQuit)}
|
|
</p>
|
|
</CardHeader>
|
|
|
|
<CardContent className="relative z-10">
|
|
{/* Progress to next milestone */}
|
|
{nextMilestone && (
|
|
<div className="mb-4 p-3 bg-teal-500/20 rounded-xl border border-teal-500/30">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm text-white/80">Next milestone</span>
|
|
<span className="text-sm text-teal-300 font-medium">
|
|
{formatTimeRemaining(minutesSinceQuit, nextMilestone.timeMinutes)}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
|
|
<div
|
|
className="bg-gradient-to-r from-teal-400 to-cyan-400 h-2 rounded-full transition-all duration-700"
|
|
style={{ width: `${progressToNext}%` }}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-white/60 mt-2">{nextMilestone.title}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timeline */}
|
|
<div className="space-y-3 max-h-[300px] overflow-y-auto pr-2">
|
|
{HEALTH_MILESTONES.map((milestone, index) => {
|
|
const isAchieved = minutesSinceQuit >= milestone.timeMinutes;
|
|
const isCurrent = index === currentMilestoneIndex;
|
|
const Icon = iconMap[milestone.icon] || Heart;
|
|
|
|
return (
|
|
<div
|
|
key={milestone.id}
|
|
className={`flex items-start gap-3 p-2 rounded-lg transition-all ${
|
|
isAchieved
|
|
? 'bg-teal-500/20'
|
|
: 'bg-white/5 opacity-60'
|
|
} ${isCurrent ? 'ring-2 ring-teal-400/50' : ''}`}
|
|
>
|
|
{/* Icon */}
|
|
<div
|
|
className={`p-2 rounded-full shrink-0 ${
|
|
isAchieved
|
|
? 'bg-teal-500/30 text-teal-300'
|
|
: 'bg-white/10 text-white/40'
|
|
}`}
|
|
>
|
|
{isAchieved ? (
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
) : (
|
|
<Icon className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p
|
|
className={`text-sm font-medium ${
|
|
isAchieved ? 'text-white' : 'text-white/60'
|
|
}`}
|
|
>
|
|
{milestone.title}
|
|
</p>
|
|
{isCurrent && (
|
|
<span className="text-[10px] bg-teal-500 text-white px-1.5 py-0.5 rounded-full">
|
|
Current
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-white/50 mt-0.5">
|
|
{milestone.description}
|
|
</p>
|
|
<div className="flex items-center gap-1 mt-1">
|
|
<Clock className="h-3 w-3 text-white/40" />
|
|
<span className="text-[10px] text-white/40">
|
|
{formatDuration(milestone.timeMinutes)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|