Update quit smoking website
This commit is contained in:
parent
380f1af1da
commit
7847b71c4d
@ -19,6 +19,9 @@ model UserPreferences {
|
|||||||
dailyGoal Int?
|
dailyGoal Int?
|
||||||
userName String?
|
userName String?
|
||||||
userAge Int?
|
userAge Int?
|
||||||
|
religion String?
|
||||||
|
lastNicotineUsageTime String?
|
||||||
|
lastWeedUsageTime String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,9 @@ export async function GET() {
|
|||||||
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
|
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
|
||||||
userName: preferences.userName,
|
userName: preferences.userName,
|
||||||
userAge: preferences.userAge,
|
userAge: preferences.userAge,
|
||||||
|
religion: preferences.religion,
|
||||||
|
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
|
||||||
|
lastWeedUsageTime: preferences.lastWeedUsageTime,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching preferences:', error);
|
console.error('Error fetching preferences:', error);
|
||||||
@ -48,7 +51,18 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { substance, trackingStartDate, hasCompletedSetup, dailyGoal, quitPlan, userName, userAge } = body;
|
const {
|
||||||
|
substance,
|
||||||
|
trackingStartDate,
|
||||||
|
hasCompletedSetup,
|
||||||
|
dailyGoal,
|
||||||
|
quitPlan,
|
||||||
|
userName,
|
||||||
|
userAge,
|
||||||
|
religion,
|
||||||
|
lastNicotineUsageTime,
|
||||||
|
lastWeedUsageTime
|
||||||
|
} = body;
|
||||||
|
|
||||||
const preferences = await prisma.userPreferences.upsert({
|
const preferences = await prisma.userPreferences.upsert({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
@ -60,6 +74,9 @@ export async function POST(request: NextRequest) {
|
|||||||
quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null,
|
quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null,
|
||||||
userName,
|
userName,
|
||||||
userAge,
|
userAge,
|
||||||
|
religion,
|
||||||
|
lastNicotineUsageTime,
|
||||||
|
lastWeedUsageTime,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@ -70,6 +87,9 @@ export async function POST(request: NextRequest) {
|
|||||||
quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null,
|
quitPlanJson: quitPlan ? JSON.stringify(quitPlan) : null,
|
||||||
userName,
|
userName,
|
||||||
userAge,
|
userAge,
|
||||||
|
religion,
|
||||||
|
lastNicotineUsageTime,
|
||||||
|
lastWeedUsageTime,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -81,6 +101,9 @@ export async function POST(request: NextRequest) {
|
|||||||
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
|
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
|
||||||
userName: preferences.userName,
|
userName: preferences.userName,
|
||||||
userAge: preferences.userAge,
|
userAge: preferences.userAge,
|
||||||
|
religion: preferences.religion,
|
||||||
|
lastNicotineUsageTime: preferences.lastNicotineUsageTime,
|
||||||
|
lastWeedUsageTime: preferences.lastWeedUsageTime,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving preferences:', error);
|
console.error('Error saving preferences:', error);
|
||||||
|
|||||||
@ -64,6 +64,7 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
setUsageData(usage);
|
setUsageData(usage);
|
||||||
setAchievements(achvs);
|
setAchievements(achvs);
|
||||||
setSavingsConfig(savings);
|
setSavingsConfig(savings);
|
||||||
|
console.log('[Dashboard] Loaded prefs:', prefs);
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
return { prefs, usage, achvs };
|
return { prefs, usage, achvs };
|
||||||
}, []);
|
}, []);
|
||||||
@ -233,6 +234,11 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
setPreferences(updatedPrefs);
|
setPreferences(updatedPrefs);
|
||||||
await savePreferencesAsync(updatedPrefs);
|
await savePreferencesAsync(updatedPrefs);
|
||||||
}}
|
}}
|
||||||
|
preferences={preferences}
|
||||||
|
onPreferencesUpdate={async (updatedPrefs) => {
|
||||||
|
await savePreferencesAsync(updatedPrefs);
|
||||||
|
setPreferences(updatedPrefs);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="opacity-0 animate-fade-in-up delay-200">
|
<div className="opacity-0 animate-fade-in-up delay-200">
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { HEALTH_MILESTONES, getMinutesSinceQuit, UsageEntry, UserPreferences } from '@/lib/storage';
|
import { HEALTH_MILESTONES, UsageEntry, UserPreferences } from '@/lib/storage';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
import {
|
import {
|
||||||
Heart,
|
Heart,
|
||||||
@ -14,13 +14,13 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
HeartHandshake,
|
HeartHandshake,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
|
||||||
Cigarette,
|
Cigarette,
|
||||||
Leaf
|
Leaf
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface HealthTimelineCardProps {
|
interface HealthTimelineCardProps {
|
||||||
usageData: UsageEntry[];
|
usageData: UsageEntry[];
|
||||||
|
preferences?: UserPreferences | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = {
|
const iconMap: Record<string, React.ElementType> = {
|
||||||
@ -34,8 +34,60 @@ const iconMap: Record<string, React.ElementType> = {
|
|||||||
HeartHandshake,
|
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 {
|
function formatDuration(minutes: number): string {
|
||||||
if (minutes < 60) return `${minutes} min`;
|
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 < 1440) return `${Math.floor(minutes / 60)} hrs`;
|
||||||
if (minutes < 10080) return `${Math.floor(minutes / 1440)} days`;
|
if (minutes < 10080) return `${Math.floor(minutes / 1440)} days`;
|
||||||
if (minutes < 43200) return `${Math.floor(minutes / 10080)} weeks`;
|
if (minutes < 43200) return `${Math.floor(minutes / 10080)} weeks`;
|
||||||
@ -51,55 +103,52 @@ function formatTimeRemaining(currentMinutes: number, targetMinutes: number): str
|
|||||||
|
|
||||||
interface TimelineColumnProps {
|
interface TimelineColumnProps {
|
||||||
substance: 'nicotine' | 'weed';
|
substance: 'nicotine' | 'weed';
|
||||||
minutesSinceQuit: number;
|
minutesFree: number;
|
||||||
theme: 'light' | 'dark';
|
theme: 'light' | 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineColumn({ substance, minutesSinceQuit, theme }: TimelineColumnProps) {
|
function TimelineColumn({ substance, minutesFree, theme }: TimelineColumnProps) {
|
||||||
const currentMilestoneIndex = useMemo(() => {
|
// Find current milestone
|
||||||
for (let i = HEALTH_MILESTONES.length - 1; i >= 0; i--) {
|
let currentMilestoneIndex = -1;
|
||||||
if (minutesSinceQuit >= HEALTH_MILESTONES[i].timeMinutes) {
|
for (let i = HEALTH_MILESTONES.length - 1; i >= 0; i--) {
|
||||||
return i;
|
if (minutesFree >= HEALTH_MILESTONES[i].timeMinutes) {
|
||||||
}
|
currentMilestoneIndex = i;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return -1;
|
}
|
||||||
}, [minutesSinceQuit]);
|
|
||||||
|
|
||||||
const nextMilestone = useMemo(() => {
|
// Find next milestone
|
||||||
const nextIndex = currentMilestoneIndex + 1;
|
const nextMilestoneIndex = currentMilestoneIndex + 1;
|
||||||
if (nextIndex < HEALTH_MILESTONES.length) {
|
const nextMilestone = nextMilestoneIndex < HEALTH_MILESTONES.length
|
||||||
return HEALTH_MILESTONES[nextIndex];
|
? HEALTH_MILESTONES[nextMilestoneIndex]
|
||||||
}
|
: null;
|
||||||
return null;
|
|
||||||
}, [currentMilestoneIndex]);
|
|
||||||
|
|
||||||
const progressToNext = useMemo(() => {
|
// Calculate progress to next milestone
|
||||||
if (!nextMilestone) return 100;
|
let progressToNext = 100;
|
||||||
const prevMinutes =
|
if (nextMilestone) {
|
||||||
currentMilestoneIndex >= 0
|
const prevMinutes = currentMilestoneIndex >= 0
|
||||||
? HEALTH_MILESTONES[currentMilestoneIndex].timeMinutes
|
? HEALTH_MILESTONES[currentMilestoneIndex].timeMinutes
|
||||||
: 0;
|
: 0;
|
||||||
const range = nextMilestone.timeMinutes - prevMinutes;
|
const range = nextMilestone.timeMinutes - prevMinutes;
|
||||||
const progress = minutesSinceQuit - prevMinutes;
|
const progress = minutesFree - prevMinutes;
|
||||||
return Math.min(100, Math.max(0, (progress / range) * 100));
|
progressToNext = Math.min(100, Math.max(0, (progress / range) * 100));
|
||||||
}, [minutesSinceQuit, nextMilestone, currentMilestoneIndex]);
|
}
|
||||||
|
|
||||||
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
|
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
|
||||||
const SubstanceIcon = substance === 'nicotine' ? Cigarette : Leaf;
|
const SubstanceIcon = substance === 'nicotine' ? Cigarette : Leaf;
|
||||||
const accentColor = substance === 'nicotine' ? 'red' : 'green';
|
|
||||||
const accentColorClass = substance === 'nicotine' ? 'text-red-500' : 'text-green-500';
|
const accentColorClass = substance === 'nicotine' ? 'text-red-500' : 'text-green-500';
|
||||||
const bgAccentClass = substance === 'nicotine' ? 'bg-red-500' : 'bg-green-500';
|
const bgAccentClass = substance === 'nicotine' ? 'bg-red-500' : 'bg-green-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-black/5 dark:bg-white/5 rounded-xl border border-white/5 overflow-hidden">
|
<div className="flex flex-col h-full bg-black/5 dark:bg-white/5 rounded-xl border border-white/5 overflow-hidden">
|
||||||
{/* Header */}
|
{/* 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'}`}>
|
<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}`} />
|
<SubstanceIcon className={`h-4 w-4 ${accentColorClass}`} />
|
||||||
<span className={`text-sm font-semibold ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}>
|
<span className={`text-sm font-semibold ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}>
|
||||||
{substanceLabel}
|
{substanceLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto text-xs opacity-70 font-medium">
|
<span className="ml-auto text-xs opacity-70 font-medium tabular-nums">
|
||||||
{formatDuration(Math.floor(minutesSinceQuit))} free
|
{formatDuration(minutesFree)} free
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -110,7 +159,7 @@ function TimelineColumn({ substance, minutesSinceQuit, theme }: TimelineColumnPr
|
|||||||
<div className="flex items-center justify-between mb-2">
|
<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-medium opacity-80 ${theme === 'light' ? 'text-slate-700' : 'text-white'}`}>Next Up</span>
|
||||||
<span className={`text-xs font-bold ${accentColorClass}`}>
|
<span className={`text-xs font-bold ${accentColorClass}`}>
|
||||||
{formatTimeRemaining(Math.floor(minutesSinceQuit), nextMilestone.timeMinutes)}
|
{formatTimeRemaining(minutesFree, nextMilestone.timeMinutes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-slate-200 dark:bg-white/10 rounded-full h-1.5 overflow-hidden">
|
<div className="w-full bg-slate-200 dark:bg-white/10 rounded-full h-1.5 overflow-hidden">
|
||||||
@ -127,7 +176,7 @@ function TimelineColumn({ substance, minutesSinceQuit, theme }: TimelineColumnPr
|
|||||||
|
|
||||||
{/* Timeline Items */}
|
{/* Timeline Items */}
|
||||||
{HEALTH_MILESTONES.map((milestone, index) => {
|
{HEALTH_MILESTONES.map((milestone, index) => {
|
||||||
const isAchieved = minutesSinceQuit >= milestone.timeMinutes;
|
const isAchieved = minutesFree >= milestone.timeMinutes;
|
||||||
const isCurrent = index === currentMilestoneIndex;
|
const isCurrent = index === currentMilestoneIndex;
|
||||||
const Icon = iconMap[milestone.icon] || Heart;
|
const Icon = iconMap[milestone.icon] || Heart;
|
||||||
|
|
||||||
@ -135,15 +184,15 @@ function TimelineColumn({ substance, minutesSinceQuit, theme }: TimelineColumnPr
|
|||||||
<div
|
<div
|
||||||
key={milestone.id}
|
key={milestone.id}
|
||||||
className={`flex items-start gap-2.5 p-2 rounded-lg transition-all ${isAchieved
|
className={`flex items-start gap-2.5 p-2 rounded-lg transition-all ${isAchieved
|
||||||
? (theme === 'light' ? 'bg-slate-100/50' : 'bg-white/5')
|
? (theme === 'light' ? 'bg-slate-100/50' : 'bg-white/5')
|
||||||
: 'opacity-50 grayscale'
|
: 'opacity-50 grayscale'
|
||||||
} ${isCurrent ? `ring-1 ring-${accentColor}-500/50 bg-${accentColor}-500/5` : ''}`}
|
} ${isCurrent ? 'ring-1 ring-offset-1 ring-offset-transparent ' + (substance === 'nicotine' ? 'ring-red-500/50' : 'ring-green-500/50') : ''}`}
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className={`p-1.5 rounded-full shrink-0 mt-0.5 ${isAchieved
|
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')
|
? (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'
|
: '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" />}
|
{isAchieved ? <CheckCircle2 className="h-3 w-3" /> : <Icon className="h-3 w-3" />}
|
||||||
@ -151,11 +200,9 @@ function TimelineColumn({ substance, minutesSinceQuit, theme }: TimelineColumnPr
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-1">
|
<p className={`text-xs font-semibold ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}>
|
||||||
<p className={`text-xs font-semibold ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}>
|
{milestone.title}
|
||||||
{milestone.title}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className={`text-[10px] mt-0.5 leading-tight ${theme === 'light' ? 'text-slate-600' : 'text-white/50'}`}>
|
<p className={`text-[10px] mt-0.5 leading-tight ${theme === 'light' ? 'text-slate-600' : 'text-white/50'}`}>
|
||||||
{milestone.description}
|
{milestone.description}
|
||||||
</p>
|
</p>
|
||||||
@ -168,20 +215,30 @@ function TimelineColumn({ substance, minutesSinceQuit, theme }: TimelineColumnPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HealthTimelineCard({ usageData, preferences }: HealthTimelineCardProps & { preferences?: UserPreferences | null }) {
|
export function HealthTimelineCard({ usageData, preferences }: HealthTimelineCardProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [now, setNow] = useState(Date.now());
|
|
||||||
|
|
||||||
|
// State for live timer values
|
||||||
|
const [nicotineMinutes, setNicotineMinutes] = useState(0);
|
||||||
|
const [weedMinutes, setWeedMinutes] = useState(0);
|
||||||
|
|
||||||
|
// Function to recalculate both timers
|
||||||
|
const updateTimers = useCallback(() => {
|
||||||
|
const prefs = preferences || null;
|
||||||
|
setNicotineMinutes(calculateMinutesFree('nicotine', usageData, prefs));
|
||||||
|
setWeedMinutes(calculateMinutesFree('weed', usageData, prefs));
|
||||||
|
}, [usageData, preferences]);
|
||||||
|
|
||||||
|
// Initial calculation and start interval
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
// Calculate immediately
|
||||||
setNow(Date.now());
|
updateTimers();
|
||||||
}, 1000); // Update every second
|
|
||||||
|
// Update every second
|
||||||
|
const interval = setInterval(updateTimers, 1000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [updateTimers]);
|
||||||
|
|
||||||
const nicotineMinutes = useMemo(() => getMinutesSinceQuit(usageData, 'nicotine', true, preferences), [usageData, preferences, now]);
|
|
||||||
const weedMinutes = useMemo(() => getMinutesSinceQuit(usageData, 'weed', true, preferences), [usageData, preferences, now]);
|
|
||||||
|
|
||||||
const cardBackground =
|
const cardBackground =
|
||||||
theme === 'light'
|
theme === 'light'
|
||||||
@ -207,8 +264,8 @@ export function HealthTimelineCard({ usageData, preferences }: HealthTimelineCar
|
|||||||
|
|
||||||
<CardContent className="relative z-10 flex-1 min-h-0 pb-6 pt-0">
|
<CardContent className="relative z-10 flex-1 min-h-0 pb-6 pt-0">
|
||||||
<div className="grid grid-cols-2 gap-4 h-full">
|
<div className="grid grid-cols-2 gap-4 h-full">
|
||||||
<TimelineColumn substance="nicotine" minutesSinceQuit={nicotineMinutes} theme={theme} />
|
<TimelineColumn substance="nicotine" minutesFree={nicotineMinutes} theme={theme} />
|
||||||
<TimelineColumn substance="weed" minutesSinceQuit={weedMinutes} theme={theme} />
|
<TimelineColumn substance="weed" minutesFree={weedMinutes} theme={theme} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { UsageEntry, setUsageForDateAsync, clearDayDataAsync } from '@/lib/storage';
|
import { UsageEntry, UserPreferences, setUsageForDateAsync, clearDayDataAsync } from '@/lib/storage';
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react';
|
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf, Sparkles } from 'lucide-react';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
import { DailyInspirationCard } from './DailyInspirationCard';
|
import { DailyInspirationCard } from './DailyInspirationCard';
|
||||||
@ -25,9 +25,11 @@ interface UsageCalendarProps {
|
|||||||
userId: string;
|
userId: string;
|
||||||
religion?: 'christian' | 'muslim' | 'jewish' | 'secular' | null;
|
religion?: 'christian' | 'muslim' | 'jewish' | 'secular' | null;
|
||||||
onReligionUpdate?: (religion: 'christian' | 'muslim' | 'jewish' | 'secular') => void;
|
onReligionUpdate?: (religion: 'christian' | 'muslim' | 'jewish' | 'secular') => void;
|
||||||
|
preferences?: UserPreferences | null;
|
||||||
|
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpdate }: UsageCalendarProps) {
|
export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
||||||
const [editNicotineCount, setEditNicotineCount] = useState('');
|
const [editNicotineCount, setEditNicotineCount] = useState('');
|
||||||
const [editWeedCount, setEditWeedCount] = useState('');
|
const [editWeedCount, setEditWeedCount] = useState('');
|
||||||
@ -59,6 +61,7 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
const dateStr = selectedDate.toISOString().split('T')[0];
|
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||||
|
const todayStr = new Date().toISOString().split('T')[0];
|
||||||
const newNicotineCount = parseInt(editNicotineCount, 10) || 0;
|
const newNicotineCount = parseInt(editNicotineCount, 10) || 0;
|
||||||
const newWeedCount = parseInt(editWeedCount, 10) || 0;
|
const newWeedCount = parseInt(editWeedCount, 10) || 0;
|
||||||
|
|
||||||
@ -66,6 +69,25 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd
|
|||||||
setUsageForDateAsync(dateStr, newNicotineCount, 'nicotine'),
|
setUsageForDateAsync(dateStr, newNicotineCount, 'nicotine'),
|
||||||
setUsageForDateAsync(dateStr, newWeedCount, 'weed'),
|
setUsageForDateAsync(dateStr, newWeedCount, 'weed'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Update last usage time preferences if editing today's usage and count > 0
|
||||||
|
if (dateStr === todayStr && preferences && onPreferencesUpdate) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updatedPrefs = { ...preferences };
|
||||||
|
|
||||||
|
if (newNicotineCount > 0) {
|
||||||
|
updatedPrefs.lastNicotineUsageTime = now;
|
||||||
|
}
|
||||||
|
if (newWeedCount > 0) {
|
||||||
|
updatedPrefs.lastWeedUsageTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if we changed something
|
||||||
|
if (newNicotineCount > 0 || newWeedCount > 0) {
|
||||||
|
await onPreferencesUpdate(updatedPrefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onDataUpdate();
|
onDataUpdate();
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|||||||
@ -395,81 +395,7 @@ export function calculateTotalSaved(
|
|||||||
return Math.max(0, expectedSpend - actualSpend);
|
return Math.max(0, expectedSpend - actualSpend);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMinutesSinceQuit(
|
|
||||||
usageData: UsageEntry[],
|
|
||||||
substance: 'nicotine' | 'weed',
|
|
||||||
precise: boolean = false,
|
|
||||||
preferences?: UserPreferences | null
|
|
||||||
): number {
|
|
||||||
// Try to use precise timestamp from preferences first
|
|
||||||
if (preferences) {
|
|
||||||
const lastUsageTimeStr = substance === 'nicotine'
|
|
||||||
? preferences.lastNicotineUsageTime
|
|
||||||
: preferences.lastWeedUsageTime;
|
|
||||||
|
|
||||||
if (lastUsageTimeStr) {
|
|
||||||
const now = new Date();
|
|
||||||
const lastUsageTime = new Date(lastUsageTimeStr);
|
|
||||||
const diffMs = now.getTime() - lastUsageTime.getTime();
|
|
||||||
const minutes = Math.max(0, diffMs / (1000 * 60));
|
|
||||||
|
|
||||||
// Sanity check: if the timestamp is OLDER than the last recorded date in usageData,
|
|
||||||
// it might mean the user manually added a later date in the calendar without a timestamp.
|
|
||||||
// In that case, we should fall back to the date-based logic.
|
|
||||||
|
|
||||||
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 lastDate = new Date(lastDateStr);
|
|
||||||
// Set lastDate to end of day to compare with timestamp
|
|
||||||
lastDate.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
// If the timestamp is essentially on the same day or later than the last recorded date, rely on the timestamp
|
|
||||||
// (We allow the timestamp to be earlier in the same day, that's the whole point)
|
|
||||||
const lastDateStart = new Date(lastDateStr);
|
|
||||||
lastDateStart.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (lastUsageTime >= lastDateStart) {
|
|
||||||
return precise ? minutes : Math.floor(minutes);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No usage data but we have a timestamp? Trust the timestamp.
|
|
||||||
return precise ? minutes : Math.floor(minutes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the last usage date for this substance
|
|
||||||
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) {
|
|
||||||
// No usage recorded, assume they just started
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const todayStr = now.toISOString().split('T')[0];
|
|
||||||
const lastUsageDateStr = substanceData[0].date;
|
|
||||||
|
|
||||||
// If the last usage was today, reset to 0 (just used, unknown time)
|
|
||||||
if (lastUsageDateStr === todayStr) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For past days, count from the end of that day
|
|
||||||
const lastUsageDate = new Date(lastUsageDateStr);
|
|
||||||
lastUsageDate.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const diffMs = now.getTime() - lastUsageDate.getTime();
|
|
||||||
const minutes = Math.max(0, diffMs / (1000 * 60));
|
|
||||||
|
|
||||||
return precise ? minutes : Math.floor(minutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkBadgeEligibility(
|
export function checkBadgeEligibility(
|
||||||
badgeId: string,
|
badgeId: string,
|
||||||
|
|||||||
77
src/lib/tracker/NicotineTracker.ts
Normal file
77
src/lib/tracker/NicotineTracker.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { RecoveryTracker } from './RecoveryTracker';
|
||||||
|
|
||||||
|
export class NicotineTracker extends RecoveryTracker {
|
||||||
|
calculateMinutesFree(precise: boolean = false): number {
|
||||||
|
// 1. Try to use precise timestamp from preferences first
|
||||||
|
if (this.preferences?.lastNicotineUsageTime) {
|
||||||
|
const now = new Date();
|
||||||
|
const lastUsageTime = new Date(this.preferences.lastNicotineUsageTime);
|
||||||
|
console.log('[NicotineTracker] Found timestamp:', this.preferences.lastNicotineUsageTime);
|
||||||
|
|
||||||
|
const diffMs = now.getTime() - lastUsageTime.getTime();
|
||||||
|
const minutes = this.msToMinutes(diffMs, precise);
|
||||||
|
|
||||||
|
// Validation: Ensure the timestamp aligns with the last recorded usage date.
|
||||||
|
// If the user manually edited usage for a LATER date, the timestamp might be stale.
|
||||||
|
const lastRecordedDateStr = this.getLastRecordedDate();
|
||||||
|
console.log('[NicotineTracker] Last recorded date:', lastRecordedDateStr);
|
||||||
|
|
||||||
|
if (lastRecordedDateStr) {
|
||||||
|
const lastRecordedDate = new Date(lastRecordedDateStr);
|
||||||
|
lastRecordedDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// If the timestamp is older than the start of the last recorded day,
|
||||||
|
// it means we have a newer manual entry without a timestamp.
|
||||||
|
// In this case, fall back to date-based logic.
|
||||||
|
if (lastUsageTime < lastRecordedDate) {
|
||||||
|
console.log('[NicotineTracker] Timestamp is stale, falling back to date logic');
|
||||||
|
return this.calculateDateBasedMinutes(lastRecordedDateStr, precise);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to date-based logic if no timestamp exists
|
||||||
|
const lastDateStr = this.getLastRecordedDate();
|
||||||
|
|
||||||
|
// 3. If no nicotine usage ever recorded, use tracking start date
|
||||||
|
if (!lastDateStr) {
|
||||||
|
if (this.preferences?.trackingStartDate) {
|
||||||
|
const startDate = new Date(this.preferences.trackingStartDate);
|
||||||
|
startDate.setHours(0, 0, 0, 0); // Count from start of tracking day
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - startDate.getTime();
|
||||||
|
return this.msToMinutes(diffMs, precise);
|
||||||
|
}
|
||||||
|
return 0; // No usage and no tracking start date
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.calculateDateBasedMinutes(lastDateStr, precise);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLastRecordedDate(): string | null {
|
||||||
|
const nicotineData = this.usageData
|
||||||
|
.filter((e) => e.substance === 'nicotine' && e.count > 0)
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
return nicotineData.length > 0 ? nicotineData[0].date : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateDateBasedMinutes(dateStr: string, precise: boolean): number {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// If the last usage was today but we have no timestamp, reset to 0
|
||||||
|
if (dateStr === todayStr) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For past days, count from the END of that day (23:59:59)
|
||||||
|
const lastUsageDate = new Date(dateStr);
|
||||||
|
lastUsageDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const diffMs = now.getTime() - lastUsageDate.getTime();
|
||||||
|
return this.msToMinutes(diffMs, precise);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/lib/tracker/RecoveryTracker.ts
Normal file
33
src/lib/tracker/RecoveryTracker.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { UsageEntry, UserPreferences } from '../storage';
|
||||||
|
|
||||||
|
export abstract class RecoveryTracker {
|
||||||
|
protected usageData: UsageEntry[];
|
||||||
|
protected preferences: UserPreferences | null;
|
||||||
|
|
||||||
|
constructor(usageData: UsageEntry[], preferences: UserPreferences | null) {
|
||||||
|
this.usageData = usageData;
|
||||||
|
this.preferences = preferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the number of minutes elapsed since the last usage.
|
||||||
|
* This is the core logic that subclasses must support, but the implementation
|
||||||
|
* heavily depends on the specific substance's data source (preferences timestamp vs usage logs).
|
||||||
|
*/
|
||||||
|
abstract calculateMinutesFree(precise?: boolean): number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to convert milliseconds to minutes with optional precision.
|
||||||
|
*/
|
||||||
|
protected msToMinutes(ms: number, precise: boolean = false): number {
|
||||||
|
const minutes = Math.max(0, ms / (1000 * 60));
|
||||||
|
return precise ? minutes : Math.floor(minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to check if a timestamp is valid and recent enough to rely on.
|
||||||
|
*/
|
||||||
|
protected isValidTimestamp(timestamp: string | null | undefined): boolean {
|
||||||
|
return !!timestamp && !isNaN(new Date(timestamp).getTime());
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/lib/tracker/WeedTracker.ts
Normal file
69
src/lib/tracker/WeedTracker.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { RecoveryTracker } from './RecoveryTracker';
|
||||||
|
|
||||||
|
export class WeedTracker extends RecoveryTracker {
|
||||||
|
calculateMinutesFree(precise: boolean = false): number {
|
||||||
|
// 1. Try to use precise timestamp from preferences first
|
||||||
|
if (this.preferences?.lastWeedUsageTime) {
|
||||||
|
const now = new Date();
|
||||||
|
const lastUsageTime = new Date(this.preferences.lastWeedUsageTime);
|
||||||
|
const diffMs = now.getTime() - lastUsageTime.getTime();
|
||||||
|
const minutes = this.msToMinutes(diffMs, precise);
|
||||||
|
|
||||||
|
// Validation: Ensure the timestamp aligns with the last recorded usage date.
|
||||||
|
const lastRecordedDateStr = this.getLastRecordedDate();
|
||||||
|
|
||||||
|
if (lastRecordedDateStr) {
|
||||||
|
const lastRecordedDate = new Date(lastRecordedDateStr);
|
||||||
|
lastRecordedDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (lastUsageTime < lastRecordedDate) {
|
||||||
|
return this.calculateDateBasedMinutes(lastRecordedDateStr, precise);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to date-based logic if no timestamp exists
|
||||||
|
const lastDateStr = this.getLastRecordedDate();
|
||||||
|
|
||||||
|
// 3. If no weed usage ever recorded, use tracking start date
|
||||||
|
if (!lastDateStr) {
|
||||||
|
if (this.preferences?.trackingStartDate) {
|
||||||
|
const startDate = new Date(this.preferences.trackingStartDate);
|
||||||
|
startDate.setHours(0, 0, 0, 0); // Count from start of tracking day
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - startDate.getTime();
|
||||||
|
return this.msToMinutes(diffMs, precise);
|
||||||
|
}
|
||||||
|
return 0; // No usage and no tracking start date
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.calculateDateBasedMinutes(lastDateStr, precise);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLastRecordedDate(): string | null {
|
||||||
|
const weedData = this.usageData
|
||||||
|
.filter((e) => e.substance === 'weed' && e.count > 0)
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
return weedData.length > 0 ? weedData[0].date : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateDateBasedMinutes(dateStr: string, precise: boolean): number {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// If the last usage was today but we have no timestamp, reset to 0
|
||||||
|
if (dateStr === todayStr) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For past days, count from the END of that day (23:59:59)
|
||||||
|
const lastUsageDate = new Date(dateStr);
|
||||||
|
lastUsageDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const diffMs = now.getTime() - lastUsageDate.getTime();
|
||||||
|
return this.msToMinutes(diffMs, precise);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user