From 54b7a294f5f0307ef6d3cda23b8443339b22b60f Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 24 Jan 2026 11:38:46 -0700 Subject: [PATCH] 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 --- prisma/schema.prisma | 44 +++- src/app/api/achievements/route.ts | 82 +++++++ src/app/api/reminders/route.ts | 64 +++++ src/app/api/savings/route.ts | 81 +++++++ src/app/globals.css | 16 ++ src/components/AchievementsCard.tsx | 123 ++++++++++ src/components/CelebrationAnimation.tsx | 118 ++++++++++ src/components/Dashboard.tsx | 124 +++++++++- src/components/HealthTimelineCard.tsx | 194 ++++++++++++++++ src/components/ReminderSettingsCard.tsx | 152 ++++++++++++ src/components/SavingsSetupDialog.tsx | 240 +++++++++++++++++++ src/components/SavingsTrackerCard.tsx | 202 ++++++++++++++++ src/hooks/useNotifications.ts | 106 +++++++++ src/lib/storage.ts | 295 ++++++++++++++++++++++++ 14 files changed, 1833 insertions(+), 8 deletions(-) create mode 100644 src/app/api/achievements/route.ts create mode 100644 src/app/api/reminders/route.ts create mode 100644 src/app/api/savings/route.ts create mode 100644 src/components/AchievementsCard.tsx create mode 100644 src/components/CelebrationAnimation.tsx create mode 100644 src/components/HealthTimelineCard.tsx create mode 100644 src/components/ReminderSettingsCard.tsx create mode 100644 src/components/SavingsSetupDialog.tsx create mode 100644 src/components/SavingsTrackerCard.tsx create mode 100644 src/hooks/useNotifications.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fc3e593..3160a02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,7 +25,10 @@ model UserPreferences { // Quit plan fields stored as JSON string quitPlanJson String? - usageEntries UsageEntry[] + usageEntries UsageEntry[] + achievements Achievement[] + reminderSettings ReminderSettings? + savingsConfig SavingsConfig? } model UsageEntry { @@ -41,3 +44,42 @@ model UsageEntry { @@unique([userId, date, substance]) } + +model Achievement { + id String @id @default(cuid()) + userId String + badgeId String + unlockedAt DateTime @default(now()) + substance String + + userPreferences UserPreferences? @relation(fields: [userId], references: [userId]) + + @@unique([userId, badgeId, substance]) +} + +model ReminderSettings { + id String @id @default(cuid()) + userId String @unique + enabled Boolean @default(false) + reminderTime String @default("09:00") + lastNotifiedDate String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userPreferences UserPreferences? @relation(fields: [userId], references: [userId]) +} + +model SavingsConfig { + id String @id @default(cuid()) + userId String @unique + costPerUnit Float + unitsPerDay Float + savingsGoal Float? + goalName String? + currency String @default("USD") + substance String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userPreferences UserPreferences? @relation(fields: [userId], references: [userId]) +} diff --git a/src/app/api/achievements/route.ts b/src/app/api/achievements/route.ts new file mode 100644 index 0000000..05fd4d8 --- /dev/null +++ b/src/app/api/achievements/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getSession } from '@/lib/session'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const achievements = await prisma.achievement.findMany({ + where: { userId: session.user.id }, + orderBy: { unlockedAt: 'desc' }, + }); + + return NextResponse.json( + achievements.map((a) => ({ + badgeId: a.badgeId, + unlockedAt: a.unlockedAt.toISOString(), + substance: a.substance, + })) + ); + } catch (error) { + console.error('Error fetching achievements:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { badgeId, substance } = body; + + if (!badgeId || !substance) { + return NextResponse.json({ error: 'Missing badgeId or substance' }, { status: 400 }); + } + + // Check if achievement already exists + const existing = await prisma.achievement.findUnique({ + where: { + userId_badgeId_substance: { + userId: session.user.id, + badgeId, + substance, + }, + }, + }); + + if (existing) { + return NextResponse.json({ + badgeId: existing.badgeId, + unlockedAt: existing.unlockedAt.toISOString(), + substance: existing.substance, + alreadyUnlocked: true, + }); + } + + const achievement = await prisma.achievement.create({ + data: { + userId: session.user.id, + badgeId, + substance, + }, + }); + + return NextResponse.json({ + badgeId: achievement.badgeId, + unlockedAt: achievement.unlockedAt.toISOString(), + substance: achievement.substance, + alreadyUnlocked: false, + }); + } catch (error) { + console.error('Error unlocking achievement:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/reminders/route.ts b/src/app/api/reminders/route.ts new file mode 100644 index 0000000..cf43ac9 --- /dev/null +++ b/src/app/api/reminders/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getSession } from '@/lib/session'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const settings = await prisma.reminderSettings.findUnique({ + where: { userId: session.user.id }, + }); + + if (!settings) { + return NextResponse.json({ + enabled: false, + reminderTime: '09:00', + }); + } + + return NextResponse.json({ + enabled: settings.enabled, + reminderTime: settings.reminderTime, + }); + } catch (error) { + console.error('Error fetching reminder settings:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { enabled, reminderTime } = body; + + const settings = await prisma.reminderSettings.upsert({ + where: { userId: session.user.id }, + update: { + enabled: enabled ?? false, + reminderTime: reminderTime ?? '09:00', + }, + create: { + userId: session.user.id, + enabled: enabled ?? false, + reminderTime: reminderTime ?? '09:00', + }, + }); + + return NextResponse.json({ + enabled: settings.enabled, + reminderTime: settings.reminderTime, + }); + } catch (error) { + console.error('Error saving reminder settings:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/savings/route.ts b/src/app/api/savings/route.ts new file mode 100644 index 0000000..da1831a --- /dev/null +++ b/src/app/api/savings/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getSession } from '@/lib/session'; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const config = await prisma.savingsConfig.findUnique({ + where: { userId: session.user.id }, + }); + + if (!config) { + return NextResponse.json(null); + } + + return NextResponse.json({ + costPerUnit: config.costPerUnit, + unitsPerDay: config.unitsPerDay, + savingsGoal: config.savingsGoal, + goalName: config.goalName, + currency: config.currency, + substance: config.substance, + }); + } catch (error) { + console.error('Error fetching savings config:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { costPerUnit, unitsPerDay, savingsGoal, goalName, currency, substance } = body; + + if (costPerUnit === undefined || unitsPerDay === undefined || !substance) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const config = await prisma.savingsConfig.upsert({ + where: { userId: session.user.id }, + update: { + costPerUnit, + unitsPerDay, + savingsGoal: savingsGoal ?? null, + goalName: goalName ?? null, + currency: currency ?? 'USD', + substance, + }, + create: { + userId: session.user.id, + costPerUnit, + unitsPerDay, + savingsGoal: savingsGoal ?? null, + goalName: goalName ?? null, + currency: currency ?? 'USD', + substance, + }, + }); + + return NextResponse.json({ + costPerUnit: config.costPerUnit, + unitsPerDay: config.unitsPerDay, + savingsGoal: config.savingsGoal, + goalName: config.goalName, + currency: config.currency, + substance: config.substance, + }); + } catch (error) { + console.error('Error saving savings config:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 3ba615b..f5576e2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -296,6 +296,21 @@ 50% { box-shadow: 0 0 20px rgba(99, 102, 241, 0.8); } } +@keyframes confetti { + 0% { + transform: translateY(0) rotate(0deg); + opacity: 1; + } + 100% { + transform: translateY(100vh) rotate(720deg); + opacity: 0; + } +} + +.animate-confetti { + animation: confetti 3s ease-out forwards; +} + /* Animation utility classes */ .animate-fade-in { animation: fade-in 0.5s ease-out forwards; @@ -339,6 +354,7 @@ .delay-300 { animation-delay: 300ms; } .delay-400 { animation-delay: 400ms; } .delay-500 { animation-delay: 500ms; } +.delay-600 { animation-delay: 600ms; } /* Start hidden for animations */ .opacity-0 { opacity: 0; } diff --git a/src/components/AchievementsCard.tsx b/src/components/AchievementsCard.tsx new file mode 100644 index 0000000..c04485b --- /dev/null +++ b/src/components/AchievementsCard.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useMemo } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Achievement, BADGE_DEFINITIONS } from '@/lib/storage'; +import { useTheme } from '@/lib/theme-context'; +import { + Trophy, + Lock, + Footprints, + Flame, + Shield, + Swords, + Crown, +} from 'lucide-react'; + +interface AchievementsCardProps { + achievements: Achievement[]; + substance: 'nicotine' | 'weed'; +} + +const iconMap: Record = { + Footprints, + Flame, + Shield, + Swords, + Crown, + Trophy, +}; + +export function AchievementsCard({ achievements, substance }: AchievementsCardProps) { + const { theme } = useTheme(); + + const unlockedBadgeIds = useMemo(() => { + return new Set( + achievements + .filter((a) => a.substance === substance || a.substance === 'both') + .map((a) => a.badgeId) + ); + }, [achievements, substance]); + + const cardBackground = + theme === 'light' + ? 'linear-gradient(135deg, rgba(124, 58, 237, 0.85) 0%, rgba(109, 40, 217, 0.9) 100%)' + : 'linear-gradient(135deg, rgba(168, 85, 247, 0.2) 0%, rgba(139, 92, 246, 0.15) 100%)'; + + const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana'; + const borderColor = 'border-purple-500/40'; + + return ( + +
+ + + + + {substanceLabel} Achievements + + + + +
+ {BADGE_DEFINITIONS.map((badge) => { + const isUnlocked = unlockedBadgeIds.has(badge.id); + const Icon = iconMap[badge.icon] || Trophy; + const unlockedAchievement = achievements.find( + (a) => + a.badgeId === badge.id && + (a.substance === substance || a.substance === 'both') + ); + + return ( +
+ {!isUnlocked && ( +
+ +
+ )} +
+ +
+

+ {badge.name} +

+
+ ); + })} +
+ +
+

+ {unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked +

+
+
+ + ); +} diff --git a/src/components/CelebrationAnimation.tsx b/src/components/CelebrationAnimation.tsx new file mode 100644 index 0000000..a918165 --- /dev/null +++ b/src/components/CelebrationAnimation.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { BadgeDefinition } from '@/lib/storage'; +import { + Trophy, + Footprints, + Flame, + Shield, + Swords, + Crown, + Sparkles, +} from 'lucide-react'; + +interface CelebrationAnimationProps { + badge: BadgeDefinition; + onComplete: () => void; +} + +const iconMap: Record = { + Footprints, + Flame, + Shield, + Swords, + Crown, + Trophy, +}; + +export function CelebrationAnimation({ + badge, + onComplete, +}: CelebrationAnimationProps) { + const [particles, setParticles] = useState< + Array<{ id: number; x: number; y: number; color: string; delay: number }> + >([]); + + useEffect(() => { + // Generate confetti particles + const newParticles = Array.from({ length: 50 }, (_, i) => ({ + id: i, + x: Math.random() * 100, + y: Math.random() * 100, + color: ['#fbbf24', '#a855f7', '#22c55e', '#3b82f6', '#ef4444'][ + Math.floor(Math.random() * 5) + ], + delay: Math.random() * 0.5, + })); + setParticles(newParticles); + + // Auto dismiss after 3 seconds + const timer = setTimeout(() => { + onComplete(); + }, 3000); + + return () => clearTimeout(timer); + }, [onComplete]); + + const Icon = iconMap[badge.icon] || Trophy; + + return ( +
+ {/* Confetti particles */} + {particles.map((particle) => ( +
+ ))} + + {/* Badge reveal */} +
+ {/* Glow effect */} +
+ + {/* Main content */} +
+
+ {/* Sparkles */} +
+ +
+
+ +
+ + {/* Badge icon */} +
+ +
+ + {/* Text */} +
+

+ Achievement Unlocked! +

+

{badge.name}

+

{badge.description}

+
+
+
+
+ + {/* Tap to dismiss hint */} +

+ Tap anywhere to dismiss +

+
+ ); +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 5406971..c315fbe 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -10,8 +10,20 @@ import { shouldShowUsagePrompt, markPromptShown, generateQuitPlan, + fetchAchievements, + fetchReminderSettings, + fetchSavingsConfig, + saveReminderSettings, + saveSavingsConfig, + unlockAchievement, + checkBadgeEligibility, UserPreferences, UsageEntry, + Achievement, + ReminderSettings, + SavingsConfig, + BADGE_DEFINITIONS, + BadgeDefinition, } from '@/lib/storage'; import { UserHeader } from './UserHeader'; import { SetupWizard } from './SetupWizard'; @@ -19,6 +31,11 @@ import { UsagePromptDialog } from './UsagePromptDialog'; import { UsageCalendar } from './UsageCalendar'; import { StatsCard } from './StatsCard'; import { QuitPlanCard } from './QuitPlanCard'; +import { AchievementsCard } from './AchievementsCard'; +import { CelebrationAnimation } from './CelebrationAnimation'; +import { HealthTimelineCard } from './HealthTimelineCard'; +import { SavingsTrackerCard } from './SavingsTrackerCard'; +import { ReminderSettingsCard } from './ReminderSettingsCard'; import { Button } from '@/components/ui/button'; import { PlusCircle } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; @@ -30,39 +47,81 @@ interface DashboardProps { export function Dashboard({ user }: DashboardProps) { const [preferences, setPreferences] = useState(null); const [usageData, setUsageData] = useState([]); + const [achievements, setAchievements] = useState([]); + const [reminderSettings, setReminderSettings] = useState({ enabled: false, reminderTime: '09:00' }); + const [savingsConfig, setSavingsConfig] = useState(null); const [showSetup, setShowSetup] = useState(false); const [showUsagePrompt, setShowUsagePrompt] = useState(false); + const [showCelebration, setShowCelebration] = useState(false); + const [newBadge, setNewBadge] = useState(null); const [isLoading, setIsLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); const { theme } = useTheme(); const loadData = useCallback(async () => { - const [prefs, usage] = await Promise.all([ + const [prefs, usage, achvs, reminders, savings] = await Promise.all([ fetchPreferences(), fetchUsageData(), + fetchAchievements(), + fetchReminderSettings(), + fetchSavingsConfig(), ]); setPreferences(prefs); setUsageData(usage); + setAchievements(achvs); + setReminderSettings(reminders); + setSavingsConfig(savings); setRefreshKey(prev => prev + 1); - return prefs; + return { prefs, usage, achvs }; + }, []); + + const checkAndUnlockAchievements = useCallback(async ( + usage: UsageEntry[], + prefs: UserPreferences, + currentAchievements: Achievement[] + ) => { + const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`)); + + for (const badge of BADGE_DEFINITIONS) { + for (const substance of ['nicotine', 'weed'] as const) { + const key = `${badge.id}-${substance}`; + if (unlockedIds.has(key)) continue; + + const isEligible = checkBadgeEligibility(badge.id, usage, prefs, substance); + if (isEligible) { + const result = await unlockAchievement(badge.id, substance); + if (result.isNew && result.achievement) { + setNewBadge(badge); + setShowCelebration(true); + setAchievements(prev => [...prev, result.achievement!]); + return; // Only show one celebration at a time + } + } + } + } }, []); useEffect(() => { const init = async () => { - const prefs = await loadData(); + const { prefs, usage, achvs } = await loadData(); if (!prefs.hasCompletedSetup) { setShowSetup(true); - } else if (shouldShowUsagePrompt()) { - setShowUsagePrompt(true); - markPromptShown(); + } else { + // Check for achievements + await checkAndUnlockAchievements(usage, prefs, achvs); + + if (shouldShowUsagePrompt()) { + setShowUsagePrompt(true); + markPromptShown(); + } } setIsLoading(false); }; init(); - }, [loadData]); + }, [loadData, checkAndUnlockAchievements]); const handleSetupComplete = async (data: { substance: 'nicotine' | 'weed'; name: string; age: number }) => { const today = new Date().toISOString().split('T')[0]; @@ -117,6 +176,21 @@ export function Dashboard({ user }: DashboardProps) { setRefreshKey(prev => prev + 1); }; + const handleReminderSettingsChange = async (settings: ReminderSettings) => { + setReminderSettings(settings); + await saveReminderSettings(settings); + }; + + const handleSavingsConfigChange = async (config: SavingsConfig) => { + setSavingsConfig(config); + await saveSavingsConfig(config); + }; + + const handleCelebrationComplete = () => { + setShowCelebration(false); + setNewBadge(null); + }; + if (isLoading) { return (
@@ -166,6 +240,13 @@ export function Dashboard({ user }: DashboardProps) { usageData={usageData} />
+
+ +
@@ -174,6 +255,28 @@ export function Dashboard({ user }: DashboardProps) {
+
+ +
+
+ +
+
+ +
@@ -190,6 +293,13 @@ export function Dashboard({ user }: DashboardProps) { userId={user.id} /> )} + + {showCelebration && newBadge && ( + + )}
); } diff --git a/src/components/HealthTimelineCard.tsx b/src/components/HealthTimelineCard.tsx new file mode 100644 index 0000000..f9f1af3 --- /dev/null +++ b/src/components/HealthTimelineCard.tsx @@ -0,0 +1,194 @@ +'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 = { + 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 ( + +
+ + + + + Health Recovery + +

+ {substanceLabel}-free for {formatDuration(minutesSinceQuit)} +

+
+ + + {/* Progress to next milestone */} + {nextMilestone && ( +
+
+ Next milestone + + {formatTimeRemaining(minutesSinceQuit, nextMilestone.timeMinutes)} + +
+
+
+
+

{nextMilestone.title}

+
+ )} + + {/* Timeline */} +
+ {HEALTH_MILESTONES.map((milestone, index) => { + const isAchieved = minutesSinceQuit >= milestone.timeMinutes; + const isCurrent = index === currentMilestoneIndex; + const Icon = iconMap[milestone.icon] || Heart; + + return ( +
+ {/* Icon */} +
+ {isAchieved ? ( + + ) : ( + + )} +
+ + {/* Content */} +
+
+

+ {milestone.title} +

+ {isCurrent && ( + + Current + + )} +
+

+ {milestone.description} +

+
+ + + {formatDuration(milestone.timeMinutes)} + +
+
+
+ ); + })} +
+ + + ); +} diff --git a/src/components/ReminderSettingsCard.tsx b/src/components/ReminderSettingsCard.tsx new file mode 100644 index 0000000..1024fb6 --- /dev/null +++ b/src/components/ReminderSettingsCard.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ReminderSettings } from '@/lib/storage'; +import { useNotifications } from '@/hooks/useNotifications'; +import { useTheme } from '@/lib/theme-context'; +import { Bell, BellOff, BellRing, Check, X } from 'lucide-react'; + +interface ReminderSettingsCardProps { + settings: ReminderSettings; + onSettingsChange: (settings: ReminderSettings) => void; +} + +export function ReminderSettingsCard({ + settings, + onSettingsChange, +}: ReminderSettingsCardProps) { + const { theme } = useTheme(); + const { isSupported, permission, requestPermission } = useNotifications(settings); + const [localTime, setLocalTime] = useState(settings.reminderTime); + + const handleToggle = async () => { + if (!settings.enabled && permission !== 'granted') { + const result = await requestPermission(); + if (result !== 'granted') return; + } + + onSettingsChange({ + ...settings, + enabled: !settings.enabled, + }); + }; + + const handleTimeChange = (newTime: string) => { + setLocalTime(newTime); + onSettingsChange({ + ...settings, + reminderTime: newTime, + }); + }; + + const cardBackground = + theme === 'light' + ? 'linear-gradient(135deg, rgba(79, 70, 229, 0.85) 0%, rgba(67, 56, 202, 0.9) 100%)' + : 'linear-gradient(135deg, rgba(99, 102, 241, 0.2) 0%, rgba(79, 70, 229, 0.15) 100%)'; + + const getPermissionStatus = () => { + if (!isSupported) return { text: 'Not supported', color: 'text-red-400' }; + if (permission === 'granted') return { text: 'Enabled', color: 'text-green-400' }; + if (permission === 'denied') return { text: 'Blocked', color: 'text-red-400' }; + return { text: 'Not set', color: 'text-yellow-400' }; + }; + + const permissionStatus = getPermissionStatus(); + + return ( + +
+ + + + + Daily Reminders + + + + + {/* Permission Status */} +
+ Notifications + + {permissionStatus.text} + +
+ + {/* Enable/Disable Toggle */} +
+
+ {settings.enabled ? ( + + ) : ( + + )} + + {settings.enabled ? 'Reminders On' : 'Reminders Off'} + +
+ +
+ + {/* Time Picker */} + {settings.enabled && ( +
+ + handleTimeChange(e.target.value)} + className="bg-white/10 border-white/20 text-white" + /> +

+ You'll receive a reminder at this time each day +

+
+ )} + + {/* Request Permission Button */} + {isSupported && permission === 'default' && ( + + )} + + {/* Denied Message */} + {permission === 'denied' && ( +
+

+ Notifications are blocked. Please enable them in your browser settings to receive reminders. +

+
+ )} +
+ + ); +} diff --git a/src/components/SavingsSetupDialog.tsx b/src/components/SavingsSetupDialog.tsx new file mode 100644 index 0000000..dfbeaac --- /dev/null +++ b/src/components/SavingsSetupDialog.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { SavingsConfig } from '@/lib/storage'; +import { DollarSign, Target } from 'lucide-react'; + +interface SavingsSetupDialogProps { + open: boolean; + onClose: () => void; + onSave: (config: SavingsConfig) => void; + existingConfig: SavingsConfig | null; +} + +const CURRENCIES = [ + { code: 'USD', symbol: '$', name: 'US Dollar' }, + { code: 'EUR', symbol: '€', name: 'Euro' }, + { code: 'GBP', symbol: '£', name: 'British Pound' }, + { code: 'CAD', symbol: 'C$', name: 'Canadian Dollar' }, + { code: 'AUD', symbol: 'A$', name: 'Australian Dollar' }, +]; + +export function SavingsSetupDialog({ + open, + onClose, + onSave, + existingConfig, +}: SavingsSetupDialogProps) { + const [costPerUnit, setCostPerUnit] = useState(''); + const [unitsPerDay, setUnitsPerDay] = useState(''); + const [currency, setCurrency] = useState('USD'); + const [substance, setSubstance] = useState<'nicotine' | 'weed'>('nicotine'); + const [savingsGoal, setSavingsGoal] = useState(''); + const [goalName, setGoalName] = useState(''); + + useEffect(() => { + if (existingConfig) { + setCostPerUnit(existingConfig.costPerUnit.toString()); + setUnitsPerDay(existingConfig.unitsPerDay.toString()); + setCurrency(existingConfig.currency); + setSubstance(existingConfig.substance); + setSavingsGoal(existingConfig.savingsGoal?.toString() || ''); + setGoalName(existingConfig.goalName || ''); + } else { + setCostPerUnit(''); + setUnitsPerDay(''); + setCurrency('USD'); + setSubstance('nicotine'); + setSavingsGoal(''); + setGoalName(''); + } + }, [existingConfig, open]); + + const handleSave = () => { + const cost = parseFloat(costPerUnit); + const units = parseFloat(unitsPerDay); + + if (isNaN(cost) || isNaN(units) || cost <= 0 || units <= 0) { + return; + } + + const config: SavingsConfig = { + costPerUnit: cost, + unitsPerDay: units, + currency, + substance, + savingsGoal: savingsGoal ? parseFloat(savingsGoal) : null, + goalName: goalName.trim() || null, + }; + + onSave(config); + }; + + const isValid = + costPerUnit && + unitsPerDay && + parseFloat(costPerUnit) > 0 && + parseFloat(unitsPerDay) > 0; + + return ( + !isOpen && onClose()}> + + + + + {existingConfig ? 'Edit Savings Tracker' : 'Set Up Savings Tracker'} + + + Enter your usage costs to track how much you're saving + + + +
+ {/* Substance Selection */} +
+ + +
+ + {/* Currency Selection */} +
+ + +
+ + {/* Cost Per Unit */} +
+ +
+ + {CURRENCIES.find((c) => c.code === currency)?.symbol || '$'} + + setCostPerUnit(e.target.value)} + className="pl-8" + placeholder="10.00" + /> +
+

+ How much does one pack or cartridge cost? +

+
+ + {/* Units Per Day */} +
+ + setUnitsPerDay(e.target.value)} + placeholder="1" + /> +

+ How many packs/units did you typically use per day? +

+
+ + {/* Optional: Savings Goal */} +
+
+ + Optional: Set a savings goal +
+ +
+ +
+ + {CURRENCIES.find((c) => c.code === currency)?.symbol || '$'} + + setSavingsGoal(e.target.value)} + className="pl-8" + placeholder="500" + /> +
+
+ +
+ + setGoalName(e.target.value)} + placeholder="e.g., New Phone, Vacation" + /> +
+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/SavingsTrackerCard.tsx b/src/components/SavingsTrackerCard.tsx new file mode 100644 index 0000000..92f7564 --- /dev/null +++ b/src/components/SavingsTrackerCard.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + SavingsConfig, + UsageEntry, + calculateTotalSaved, +} from '@/lib/storage'; +import { useTheme } from '@/lib/theme-context'; +import { DollarSign, Target, TrendingUp, Settings, Sparkles } from 'lucide-react'; +import { SavingsSetupDialog } from './SavingsSetupDialog'; + +interface SavingsTrackerCardProps { + savingsConfig: SavingsConfig | null; + usageData: UsageEntry[]; + trackingStartDate: string | null; + onSavingsConfigChange: (config: SavingsConfig) => void; +} + +export function SavingsTrackerCard({ + savingsConfig, + usageData, + trackingStartDate, + onSavingsConfigChange, +}: SavingsTrackerCardProps) { + const { theme } = useTheme(); + const [showSetup, setShowSetup] = useState(false); + + const totalSaved = useMemo(() => { + return calculateTotalSaved(savingsConfig, usageData, trackingStartDate); + }, [savingsConfig, usageData, trackingStartDate]); + + const projections = useMemo(() => { + if (!savingsConfig) return { daily: 0, weekly: 0, monthly: 0, yearly: 0 }; + const dailySavings = savingsConfig.costPerUnit * savingsConfig.unitsPerDay; + return { + daily: dailySavings, + weekly: dailySavings * 7, + monthly: dailySavings * 30, + yearly: dailySavings * 365, + }; + }, [savingsConfig]); + + const goalProgress = useMemo(() => { + if (!savingsConfig?.savingsGoal) return null; + return Math.min(100, (totalSaved / savingsConfig.savingsGoal) * 100); + }, [totalSaved, savingsConfig]); + + const cardBackground = + theme === 'light' + ? 'linear-gradient(135deg, rgba(5, 150, 105, 0.85) 0%, rgba(4, 120, 87, 0.9) 100%)' + : 'linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(5, 150, 105, 0.15) 100%)'; + + const formatCurrency = (amount: number) => { + const currency = savingsConfig?.currency || 'USD'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(amount); + }; + + // Setup mode - no config yet + if (!savingsConfig) { + return ( + <> + +
+ + + + + Money Savings + + + + +
+ +

+ Track how much money you're saving by reducing your usage +

+
+ +
+ + + setShowSetup(false)} + onSave={(config) => { + onSavingsConfigChange(config); + setShowSetup(false); + }} + existingConfig={null} + /> + + ); + } + + // Tracking mode - config exists + return ( + <> + +
+ + +
+ + + Money Saved + + +
+
+ + + {/* Total Saved */} +
+

Total Saved

+

+ {formatCurrency(totalSaved)} +

+
+ + {/* Goal Progress */} + {savingsConfig.savingsGoal && goalProgress !== null && ( +
+
+
+ + + {savingsConfig.goalName || 'Savings Goal'} + +
+ + {goalProgress.toFixed(0)}% + +
+
+
+
+

+ {formatCurrency(totalSaved)} of {formatCurrency(savingsConfig.savingsGoal)} +

+
+ )} + + {/* Projections */} +
+
+

{formatCurrency(projections.weekly)}

+

Weekly

+
+
+

{formatCurrency(projections.monthly)}

+

Monthly

+
+
+ +
+ + Based on {formatCurrency(projections.daily)}/day potential savings +
+ + + + setShowSetup(false)} + onSave={(config) => { + onSavingsConfigChange(config); + setShowSetup(false); + }} + existingConfig={savingsConfig} + /> + + ); +} diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..dc45a6d --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,106 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { ReminderSettings } from '@/lib/storage'; + +const LAST_NOTIFICATION_KEY = 'quittraq_last_notification_date'; + +export type NotificationPermission = 'default' | 'granted' | 'denied'; + +export function useNotifications(reminderSettings: ReminderSettings) { + const [permission, setPermission] = useState('default'); + const [isSupported, setIsSupported] = useState(false); + + // Check if notifications are supported and get current permission + useEffect(() => { + if (typeof window !== 'undefined' && 'Notification' in window) { + setIsSupported(true); + setPermission(Notification.permission as NotificationPermission); + } + }, []); + + // Request notification permission + const requestPermission = useCallback(async () => { + if (!isSupported) return 'denied'; + + try { + const result = await Notification.requestPermission(); + setPermission(result as NotificationPermission); + return result; + } catch (error) { + console.error('Error requesting notification permission:', error); + return 'denied'; + } + }, [isSupported]); + + // Send a notification + const sendNotification = useCallback( + (title: string, options?: NotificationOptions) => { + if (!isSupported || permission !== 'granted') return; + + try { + const notification = new Notification(title, { + icon: '/icon-192.png', + badge: '/icon-192.png', + ...options, + }); + + notification.onclick = () => { + window.focus(); + notification.close(); + }; + + return notification; + } catch (error) { + console.error('Error sending notification:', error); + } + }, + [isSupported, permission] + ); + + // Check and send daily reminder + const checkAndSendReminder = useCallback(() => { + if (!reminderSettings.enabled || permission !== 'granted') return; + + const today = new Date().toISOString().split('T')[0]; + const lastNotified = localStorage.getItem(LAST_NOTIFICATION_KEY); + + if (lastNotified === today) return; // Already notified today + + const now = new Date(); + const [hours, minutes] = reminderSettings.reminderTime.split(':').map(Number); + const reminderTime = new Date(); + reminderTime.setHours(hours, minutes, 0, 0); + + // Check if it's time for the reminder (within 1 minute window) + const timeDiff = Math.abs(now.getTime() - reminderTime.getTime()); + if (timeDiff <= 60000) { + sendNotification('QuitTraq Reminder', { + body: "Time to log your daily usage! Every day counts on your journey.", + tag: 'daily-reminder', + requireInteraction: false, + }); + localStorage.setItem(LAST_NOTIFICATION_KEY, today); + } + }, [reminderSettings, permission, sendNotification]); + + // Set up interval to check for reminder time + useEffect(() => { + if (!reminderSettings.enabled || permission !== 'granted') return; + + // Check immediately + checkAndSendReminder(); + + // Check every minute + const interval = setInterval(checkAndSendReminder, 60000); + + return () => clearInterval(interval); + }, [reminderSettings, permission, checkAndSendReminder]); + + return { + isSupported, + permission, + requestPermission, + sendNotification, + }; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index cae1f78..ea9158e 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -24,6 +24,68 @@ export interface QuitPlan { baselineAverage: number; } +// ============ NEW FEATURE INTERFACES ============ + +export interface Achievement { + badgeId: string; + unlockedAt: string; + substance: 'nicotine' | 'weed' | 'both'; +} + +export interface ReminderSettings { + enabled: boolean; + reminderTime: string; // HH:MM format +} + +export interface SavingsConfig { + costPerUnit: number; + unitsPerDay: number; + savingsGoal: number | null; + goalName: string | null; + currency: string; + substance: 'nicotine' | 'weed'; +} + +export interface BadgeDefinition { + id: string; + name: string; + description: string; + icon: string; +} + +export interface HealthMilestone { + id: string; + timeMinutes: number; + title: string; + description: string; + icon: string; +} + +// ============ BADGE DEFINITIONS ============ + +export const BADGE_DEFINITIONS: BadgeDefinition[] = [ + { id: 'first_day', name: 'First Step', description: 'Tracked your first day', icon: 'Footprints' }, + { id: 'streak_3', name: 'Hat Trick', description: '3-day streak substance-free', icon: 'Flame' }, + { id: 'streak_7', name: 'Week Warrior', description: '7-day streak substance-free', icon: 'Shield' }, + { id: 'two_weeks', name: 'Fortnight Fighter', description: '14-day streak substance-free', icon: 'Swords' }, + { id: 'one_month', name: 'Monthly Master', description: '30-day streak substance-free', icon: 'Crown' }, + { id: 'plan_completed', name: 'Goal Crusher', description: 'Completed your quit plan', icon: 'Trophy' }, +]; + +// ============ HEALTH MILESTONES ============ + +export const HEALTH_MILESTONES: HealthMilestone[] = [ + { id: '20min', timeMinutes: 20, title: 'Blood Pressure Normalizes', description: 'Your heart rate and blood pressure begin to drop', icon: 'Heart' }, + { id: '8hr', timeMinutes: 480, title: 'Oxygen Levels Rise', description: 'Carbon monoxide levels drop, oxygen levels increase', icon: 'Wind' }, + { id: '24hr', timeMinutes: 1440, title: 'Heart Attack Risk Drops', description: 'Your risk of heart attack begins to decrease', icon: 'HeartPulse' }, + { id: '48hr', timeMinutes: 2880, title: 'Senses Sharpen', description: 'Taste and smell begin to improve', icon: 'Eye' }, + { id: '72hr', timeMinutes: 4320, title: 'Breathing Easier', description: 'Bronchial tubes relax, energy levels increase', icon: 'Wind' }, + { id: '2wk', timeMinutes: 20160, title: 'Circulation Improves', description: 'Blood circulation significantly improves', icon: 'Activity' }, + { id: '1mo', timeMinutes: 43200, title: 'Lung Function Improves', description: 'Lung capacity increases up to 30%', icon: 'TrendingUp' }, + { id: '3mo', timeMinutes: 129600, title: 'Cilia Regenerate', description: 'Lungs begin to heal, coughing decreases', icon: 'Sparkles' }, + { id: '1yr', timeMinutes: 525600, title: 'Heart Disease Risk Halved', description: 'Risk of coronary heart disease cut in half', icon: 'HeartHandshake' }, +]; + const defaultPreferences: UserPreferences = { substance: 'nicotine', trackingStartDate: null, @@ -37,10 +99,16 @@ const defaultPreferences: UserPreferences = { // Cache for preferences and usage data to avoid excessive API calls let preferencesCache: UserPreferences | null = null; let usageDataCache: UsageEntry[] | null = null; +let achievementsCache: Achievement[] | null = null; +let reminderSettingsCache: ReminderSettings | null = null; +let savingsConfigCache: SavingsConfig | null = null; export function clearCache(): void { preferencesCache = null; usageDataCache = null; + achievementsCache = null; + reminderSettingsCache = null; + savingsConfigCache = null; } // These functions are kept for backwards compatibility but no longer used @@ -144,6 +212,233 @@ export async function clearDayDataAsync( } } +// ============ ACHIEVEMENTS FUNCTIONS ============ + +export async function fetchAchievements(): Promise { + try { + const response = await fetch('/api/achievements'); + if (!response.ok) return []; + const data = await response.json(); + achievementsCache = data; + return data; + } catch (error) { + console.error('Error fetching achievements:', error); + return []; + } +} + +export async function unlockAchievement( + badgeId: string, + substance: 'nicotine' | 'weed' | 'both' +): Promise<{ achievement: Achievement | null; isNew: boolean }> { + try { + const response = await fetch('/api/achievements', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ badgeId, substance }), + }); + if (response.ok) { + const data = await response.json(); + achievementsCache = null; // Invalidate cache + return { + achievement: { + badgeId: data.badgeId, + unlockedAt: data.unlockedAt, + substance: data.substance, + }, + isNew: !data.alreadyUnlocked, + }; + } + return { achievement: null, isNew: false }; + } catch (error) { + console.error('Error unlocking achievement:', error); + return { achievement: null, isNew: false }; + } +} + +export function getAchievements(): Achievement[] { + return achievementsCache || []; +} + +// ============ REMINDERS FUNCTIONS ============ + +export async function fetchReminderSettings(): Promise { + try { + const response = await fetch('/api/reminders'); + if (!response.ok) return { enabled: false, reminderTime: '09:00' }; + const data = await response.json(); + reminderSettingsCache = data; + return data; + } catch (error) { + console.error('Error fetching reminder settings:', error); + return { enabled: false, reminderTime: '09:00' }; + } +} + +export async function saveReminderSettings(settings: ReminderSettings): Promise { + try { + const response = await fetch('/api/reminders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + if (response.ok) { + reminderSettingsCache = settings; + } + } catch (error) { + console.error('Error saving reminder settings:', error); + } +} + +export function getReminderSettings(): ReminderSettings { + return reminderSettingsCache || { enabled: false, reminderTime: '09:00' }; +} + +// ============ SAVINGS FUNCTIONS ============ + +export async function fetchSavingsConfig(): Promise { + try { + const response = await fetch('/api/savings'); + if (!response.ok) return null; + const data = await response.json(); + savingsConfigCache = data; + return data; + } catch (error) { + console.error('Error fetching savings config:', error); + return null; + } +} + +export async function saveSavingsConfig(config: SavingsConfig): Promise { + try { + const response = await fetch('/api/savings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + if (response.ok) { + savingsConfigCache = config; + } + } catch (error) { + console.error('Error saving savings config:', error); + } +} + +export function getSavingsConfig(): SavingsConfig | null { + return savingsConfigCache; +} + +// ============ CALCULATION HELPERS ============ + +export function calculateStreak( + usageData: UsageEntry[], + substance: 'nicotine' | 'weed' +): number { + let streak = 0; + const today = new Date(); + const substanceData = usageData.filter((e) => e.substance === substance); + + for (let i = 0; i <= 365; i++) { + const checkDate = new Date(today); + checkDate.setDate(checkDate.getDate() - i); + const dateStr = checkDate.toISOString().split('T')[0]; + const dayUsage = substanceData.find((e) => e.date === dateStr)?.count ?? -1; + + if (dayUsage === 0) { + streak++; + } else if (dayUsage > 0) { + break; + } + // If dayUsage === -1 (no entry), we continue but don't count it as a streak day + } + return streak; +} + +export function calculateTotalSaved( + savingsConfig: SavingsConfig | null, + usageData: UsageEntry[], + startDate: string | null +): number { + if (!savingsConfig || !startDate) return 0; + + const start = new Date(startDate); + const today = new Date(); + const daysSinceStart = Math.floor( + (today.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + ); + + if (daysSinceStart <= 0) return 0; + + // Expected spending if they continued at baseline + const expectedSpend = + daysSinceStart * savingsConfig.costPerUnit * savingsConfig.unitsPerDay; + + // Actual usage converted to cost (assuming ~20 puffs/hits per unit) + const relevantUsage = usageData.filter( + (e) => e.substance === savingsConfig.substance && new Date(e.date) >= start + ); + const actualUnits = relevantUsage.reduce((sum, e) => sum + e.count, 0); + const unitsPerPack = 20; // Average puffs per pack/unit + const actualSpend = (actualUnits / unitsPerPack) * savingsConfig.costPerUnit; + + return Math.max(0, expectedSpend - actualSpend); +} + +export function getMinutesSinceQuit( + usageData: UsageEntry[], + substance: 'nicotine' | 'weed' +): number { + // 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 lastUsageDate = new Date(substanceData[0].date); + // Set to end of that day + lastUsageDate.setHours(23, 59, 59, 999); + + const now = new Date(); + const diffMs = now.getTime() - lastUsageDate.getTime(); + return Math.max(0, Math.floor(diffMs / (1000 * 60))); +} + +export function checkBadgeEligibility( + badgeId: string, + usageData: UsageEntry[], + preferences: UserPreferences, + substance: 'nicotine' | 'weed' +): boolean { + const streak = calculateStreak(usageData, substance); + const totalDays = new Set( + usageData.filter((e) => e.substance === substance).map((e) => e.date) + ).size; + const planCompleted = + preferences.quitPlan !== null && + new Date() > new Date(preferences.quitPlan.endDate); + + switch (badgeId) { + case 'first_day': + return totalDays >= 1; + case 'streak_3': + return streak >= 3; + case 'streak_7': + return streak >= 7; + case 'two_weeks': + return streak >= 14; + case 'one_month': + return streak >= 30; + case 'plan_completed': + return planCompleted; + default: + return false; + } +} + // Synchronous functions that use cache (for backwards compatibility) // These should be replaced with async versions in components