Update quit smoking website

This commit is contained in:
Avery Felts 2026-01-25 12:30:09 -07:00
parent 380f1af1da
commit 7847b71c4d
9 changed files with 349 additions and 133 deletions

View File

@ -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

View File

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

View File

@ -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">

View File

@ -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>

View File

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

View File

@ -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,

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

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

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