Stop_smoking_website_ver2/src/components/HealthTimelineCard.tsx
Avery Felts 54b7a294f5 Add achievements, health timeline, savings tracker, and reminders features
- 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>
2026-01-24 11:38:46 -07:00

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>
);
}