310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import React from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { HEALTH_MILESTONES, UsageEntry, UserPreferences } from '@/lib/storage';
|
|
import { useTheme } from '@/lib/theme-context';
|
|
import {
|
|
Heart,
|
|
Wind,
|
|
HeartPulse,
|
|
Eye,
|
|
Activity,
|
|
TrendingUp,
|
|
Sparkles,
|
|
HeartHandshake,
|
|
CheckCircle2,
|
|
Cigarette,
|
|
Leaf
|
|
} from 'lucide-react';
|
|
|
|
interface HealthTimelineCardProps {
|
|
usageData: UsageEntry[];
|
|
preferences?: UserPreferences | null;
|
|
}
|
|
|
|
const iconMap: Record<string, React.ElementType> = {
|
|
Heart,
|
|
Wind,
|
|
HeartPulse,
|
|
Eye,
|
|
Activity,
|
|
TrendingUp,
|
|
Sparkles,
|
|
HeartHandshake,
|
|
};
|
|
|
|
// Simple, direct calculation of minutes since last usage
|
|
function calculateMinutesFree(
|
|
substance: 'nicotine' | 'weed',
|
|
usageData: UsageEntry[],
|
|
preferences: UserPreferences | null
|
|
): number {
|
|
const now = new Date();
|
|
|
|
// 1. Check for stored timestamp first (most accurate)
|
|
const lastUsageTime = substance === 'nicotine'
|
|
? preferences?.lastNicotineUsageTime
|
|
: preferences?.lastWeedUsageTime;
|
|
|
|
if (lastUsageTime) {
|
|
const lastTime = new Date(lastUsageTime);
|
|
const diffMs = now.getTime() - lastTime.getTime();
|
|
return Math.max(0, diffMs / (1000 * 60));
|
|
}
|
|
|
|
// 2. Find last recorded usage from usage data
|
|
const substanceData = usageData
|
|
.filter(e => e.substance === substance && e.count > 0)
|
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
|
|
if (substanceData.length > 0) {
|
|
const lastDateStr = substanceData[0].date;
|
|
const todayStr = now.toISOString().split('T')[0];
|
|
|
|
// If last usage was today but no timestamp, count from now (0 minutes)
|
|
if (lastDateStr === todayStr) {
|
|
return 0;
|
|
}
|
|
|
|
// For past days, count from end of that day (23:59:59)
|
|
const lastDate = new Date(lastDateStr);
|
|
lastDate.setHours(23, 59, 59, 999);
|
|
const diffMs = now.getTime() - lastDate.getTime();
|
|
return Math.max(0, diffMs / (1000 * 60));
|
|
}
|
|
|
|
// 3. No usage ever - count from tracking start date
|
|
if (preferences?.trackingStartDate) {
|
|
const startDate = new Date(preferences.trackingStartDate);
|
|
startDate.setHours(0, 0, 0, 0);
|
|
const diffMs = now.getTime() - startDate.getTime();
|
|
return Math.max(0, diffMs / (1000 * 60));
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function formatDuration(minutes: number): string {
|
|
if (minutes < 1) return '< 1 min';
|
|
if (minutes < 60) return `${Math.floor(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`;
|
|
}
|
|
|
|
interface TimelineColumnProps {
|
|
substance: 'nicotine' | 'weed';
|
|
minutesFree: number;
|
|
theme: 'light' | 'dark';
|
|
}
|
|
|
|
function TimelineColumn({ substance, minutesFree, theme }: TimelineColumnProps) {
|
|
// Find current milestone
|
|
let currentMilestoneIndex = -1;
|
|
for (let i = HEALTH_MILESTONES.length - 1; i >= 0; i--) {
|
|
if (minutesFree >= HEALTH_MILESTONES[i].timeMinutes) {
|
|
currentMilestoneIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Find next milestone
|
|
const nextMilestoneIndex = currentMilestoneIndex + 1;
|
|
const nextMilestone = nextMilestoneIndex < HEALTH_MILESTONES.length
|
|
? HEALTH_MILESTONES[nextMilestoneIndex]
|
|
: null;
|
|
|
|
// Calculate progress to next milestone
|
|
let progressToNext = 100;
|
|
if (nextMilestone) {
|
|
const prevMinutes = currentMilestoneIndex >= 0
|
|
? HEALTH_MILESTONES[currentMilestoneIndex].timeMinutes
|
|
: 0;
|
|
const range = nextMilestone.timeMinutes - prevMinutes;
|
|
const progress = minutesFree - prevMinutes;
|
|
progressToNext = Math.min(100, Math.max(0, (progress / range) * 100));
|
|
}
|
|
|
|
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
|
|
const SubstanceIcon = substance === 'nicotine' ? Cigarette : Leaf;
|
|
const accentColorClass = substance === 'nicotine' ? 'text-red-500' : 'text-green-500';
|
|
const bgAccentClass = substance === 'nicotine' ? 'bg-red-500' : 'bg-green-500';
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-black/5 dark:bg-white/5 rounded-xl border border-white/5 overflow-hidden">
|
|
{/* Header with live timer */}
|
|
<div className={`p-3 border-b border-white/5 flex items-center gap-2 ${theme === 'light' ? 'bg-white/50' : 'bg-black/20'}`}>
|
|
<SubstanceIcon className={`h-4 w-4 ${accentColorClass}`} />
|
|
<span className={`text-sm font-semibold ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}>
|
|
{substanceLabel}
|
|
</span>
|
|
<span className="ml-auto text-xs opacity-70 font-medium tabular-nums">
|
|
{formatDuration(minutesFree)} free
|
|
</span>
|
|
</div>
|
|
|
|
<div className="p-3 flex-1 overflow-y-auto min-h-0 space-y-3 custom-scrollbar">
|
|
{/* Progress to next milestone */}
|
|
{nextMilestone && (
|
|
<div className={`p-3 rounded-lg border ${theme === 'light' ? 'bg-white border-slate-200 shadow-sm' : 'bg-white/5 border-white/10'}`}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className={`text-xs font-medium opacity-80 ${theme === 'light' ? 'text-slate-700' : 'text-white'}`}>Next Up</span>
|
|
<span className={`text-xs font-bold ${accentColorClass}`}>
|
|
{formatTimeRemaining(minutesFree, nextMilestone.timeMinutes)}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-slate-200 dark:bg-white/10 rounded-full h-1.5 overflow-hidden">
|
|
<div
|
|
className={`h-1.5 rounded-full transition-all duration-700 ${bgAccentClass}`}
|
|
style={{ width: `${progressToNext}%`, opacity: 0.8 }}
|
|
/>
|
|
</div>
|
|
<p className={`text-xs mt-1.5 opacity-70 truncate ${theme === 'light' ? 'text-slate-600' : 'text-white/70'}`}>
|
|
{nextMilestone.title}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timeline Items */}
|
|
{HEALTH_MILESTONES.map((milestone, index) => {
|
|
const isAchieved = minutesFree >= milestone.timeMinutes;
|
|
const isCurrent = index === currentMilestoneIndex;
|
|
const Icon = iconMap[milestone.icon] || Heart;
|
|
|
|
return (
|
|
<div
|
|
key={milestone.id}
|
|
className={`flex items-start gap-2.5 p-2 rounded-lg transition-all ${isAchieved
|
|
? (theme === 'light' ? 'bg-slate-100/50' : 'bg-white/5')
|
|
: 'opacity-50 grayscale'
|
|
} ${isCurrent ? 'ring-1 ring-offset-1 ring-offset-transparent ' + (substance === 'nicotine' ? 'ring-red-500/50' : 'ring-green-500/50') : ''}`}
|
|
>
|
|
{/* Icon */}
|
|
<div
|
|
className={`p-1.5 rounded-full shrink-0 mt-0.5 ${isAchieved
|
|
? (theme === 'light' ? 'bg-white text-slate-700 shadow-sm' : 'bg-white/10 text-white')
|
|
: 'bg-black/5 text-black/30 dark:bg-white/5 dark:text-white/30'
|
|
}`}
|
|
>
|
|
{isAchieved ? <CheckCircle2 className="h-3 w-3" /> : <Icon className="h-3 w-3" />}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className={`text-xs font-semibold ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}>
|
|
{milestone.title}
|
|
</p>
|
|
<p className={`text-[10px] mt-0.5 leading-tight ${theme === 'light' ? 'text-slate-600' : 'text-white/50'}`}>
|
|
{milestone.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HealthTimelineCardComponent({
|
|
usageData,
|
|
preferences,
|
|
}: HealthTimelineCardProps) {
|
|
const { theme } = useTheme();
|
|
|
|
// Calculate last usage timestamps only when data changes
|
|
const lastUsageTimes = useMemo(() => {
|
|
const getTimestamp = (substance: 'nicotine' | 'weed') => {
|
|
// 1. Check for stored timestamp first
|
|
const stored = substance === 'nicotine' ? preferences?.lastNicotineUsageTime : preferences?.lastWeedUsageTime;
|
|
if (stored) return new Date(stored).getTime();
|
|
|
|
// 2. Fallback to usage data
|
|
const lastEntry = usageData
|
|
.filter(e => e.substance === substance && e.count > 0)
|
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0];
|
|
|
|
if (lastEntry) {
|
|
const d = new Date(lastEntry.date);
|
|
d.setHours(23, 59, 59, 999);
|
|
return d.getTime();
|
|
}
|
|
|
|
// 3. Fallback to start date
|
|
if (preferences?.trackingStartDate) {
|
|
const d = new Date(preferences.trackingStartDate);
|
|
d.setHours(0, 0, 0, 0);
|
|
return d.getTime();
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
return {
|
|
nicotine: getTimestamp('nicotine'),
|
|
weed: getTimestamp('weed')
|
|
};
|
|
}, [usageData, preferences]);
|
|
|
|
// State for live timer values
|
|
const [nicotineMinutes, setNicotineMinutes] = useState(0);
|
|
const [weedMinutes, setWeedMinutes] = useState(0);
|
|
|
|
// Update timers using O(1) math from memoized timestamps
|
|
const updateTimers = useCallback(() => {
|
|
const now = Date.now();
|
|
const msInMin = 1000 * 60;
|
|
|
|
setNicotineMinutes(lastUsageTimes.nicotine ? Math.max(0, (now - lastUsageTimes.nicotine) / msInMin) : 0);
|
|
setWeedMinutes(lastUsageTimes.weed ? Math.max(0, (now - lastUsageTimes.weed) / msInMin) : 0);
|
|
}, [lastUsageTimes]);
|
|
|
|
useEffect(() => {
|
|
updateTimers();
|
|
const interval = setInterval(updateTimers, 1000);
|
|
return () => clearInterval(interval);
|
|
}, [updateTimers]);
|
|
|
|
const cardBackground =
|
|
theme === 'light'
|
|
? 'linear-gradient(135deg, rgba(236, 253, 245, 0.9) 0%, rgba(209, 250, 229, 0.8) 100%)'
|
|
: 'linear-gradient(135deg, rgba(20, 184, 166, 0.2) 0%, rgba(6, 182, 212, 0.15) 100%)';
|
|
|
|
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 h-[500px] flex flex-col"
|
|
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-4 shrink-0">
|
|
<CardTitle className={`flex items-center gap-2 ${theme === 'light' ? 'text-teal-900' : 'text-white'} text-shadow-sm`}>
|
|
<Heart className="h-5 w-5 text-teal-500" />
|
|
<span>Health Recovery</span>
|
|
</CardTitle>
|
|
<p className={`text-sm ${theme === 'light' ? 'text-teal-700' : 'text-white/70'}`}>
|
|
Track your body's healing process for each substance independently.
|
|
</p>
|
|
</CardHeader>
|
|
|
|
<CardContent className="relative z-10 flex-1 min-h-0 pb-6 pt-0">
|
|
<div className="grid grid-cols-2 gap-4 h-full">
|
|
<TimelineColumn substance="nicotine" minutesFree={nicotineMinutes} theme={theme} />
|
|
<TimelineColumn substance="weed" minutesFree={weedMinutes} theme={theme} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
export const HealthTimelineCard = React.memo(HealthTimelineCardComponent);
|