Add achievements, health timeline, savings tracker, and reminders features

- Achievements system with 6 badges and confetti celebration animation
- Health recovery timeline showing 9 milestones from 20min to 1 year
- Money savings tracker with cost configuration and goal progress
- Daily reminder notifications with browser permission handling
- New Prisma models: Achievement, ReminderSettings, SavingsConfig
- API routes for all new features
- Full dashboard integration with staggered animations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Avery Felts 2026-01-24 11:38:46 -07:00
parent 45bcad9788
commit 54b7a294f5
14 changed files with 1833 additions and 8 deletions

View File

@ -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])
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, React.ElementType> = {
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 (
<Card
className={`backdrop-blur-xl border ${borderColor} shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative`}
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-yellow-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<Trophy className="h-5 w-5 text-yellow-400" />
<span>{substanceLabel} Achievements</span>
</CardTitle>
</CardHeader>
<CardContent className="relative z-10">
<div className="grid grid-cols-3 gap-3">
{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 (
<div
key={badge.id}
className={`relative p-3 rounded-xl text-center transition-all duration-300 ${
isUnlocked
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50 hover:scale-105'
: 'bg-white/5 border border-white/10 opacity-50'
}`}
title={
isUnlocked
? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}`
: badge.description
}
>
{!isUnlocked && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-xl">
<Lock className="h-4 w-4 text-white/40" />
</div>
)}
<div
className={`mx-auto mb-1 p-2 rounded-full w-fit ${
isUnlocked
? 'bg-yellow-500/30 text-yellow-300'
: 'bg-white/10 text-white/30'
}`}
>
<Icon className="h-5 w-5" />
</div>
<p
className={`text-xs font-medium ${
isUnlocked ? 'text-white' : 'text-white/40'
}`}
>
{badge.name}
</p>
</div>
);
})}
</div>
<div className="mt-4 text-center">
<p className="text-sm text-white/70">
{unlockedBadgeIds.size} of {BADGE_DEFINITIONS.length} badges unlocked
</p>
</div>
</CardContent>
</Card>
);
}

View File

@ -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<string, React.ElementType> = {
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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onComplete}
>
{/* Confetti particles */}
{particles.map((particle) => (
<div
key={particle.id}
className="absolute w-2 h-2 rounded-full animate-confetti"
style={{
left: `${particle.x}%`,
top: '-10px',
backgroundColor: particle.color,
animationDelay: `${particle.delay}s`,
}}
/>
))}
{/* Badge reveal */}
<div className="relative animate-scale-in">
{/* Glow effect */}
<div className="absolute inset-0 bg-yellow-500/30 rounded-full blur-3xl scale-150 animate-pulse-subtle" />
{/* Main content */}
<div className="relative bg-gradient-to-br from-purple-600 to-indigo-700 p-8 rounded-2xl border border-yellow-500/50 shadow-2xl">
<div className="flex flex-col items-center gap-4">
{/* Sparkles */}
<div className="absolute -top-4 -right-4">
<Sparkles className="h-8 w-8 text-yellow-400 animate-float" />
</div>
<div className="absolute -bottom-4 -left-4">
<Sparkles className="h-6 w-6 text-yellow-400 animate-float delay-300" />
</div>
{/* Badge icon */}
<div className="p-4 bg-gradient-to-br from-yellow-400 to-amber-500 rounded-full shadow-lg">
<Icon className="h-12 w-12 text-white" />
</div>
{/* Text */}
<div className="text-center">
<p className="text-yellow-400 text-sm font-medium uppercase tracking-wider mb-1">
Achievement Unlocked!
</p>
<h2 className="text-2xl font-bold text-white mb-1">{badge.name}</h2>
<p className="text-white/70 text-sm">{badge.description}</p>
</div>
</div>
</div>
</div>
{/* Tap to dismiss hint */}
<p className="absolute bottom-8 text-white/50 text-sm">
Tap anywhere to dismiss
</p>
</div>
);
}

View File

@ -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<UserPreferences | null>(null);
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
const [achievements, setAchievements] = useState<Achievement[]>([]);
const [reminderSettings, setReminderSettings] = useState<ReminderSettings>({ enabled: false, reminderTime: '09:00' });
const [savingsConfig, setSavingsConfig] = useState<SavingsConfig | null>(null);
const [showSetup, setShowSetup] = useState(false);
const [showUsagePrompt, setShowUsagePrompt] = useState(false);
const [showCelebration, setShowCelebration] = useState(false);
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(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 (
<div className="min-h-screen flex items-center justify-center">
@ -166,6 +240,13 @@ export function Dashboard({ user }: DashboardProps) {
usageData={usageData}
/>
</div>
<div className="opacity-0 animate-fade-in-up delay-400">
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
substance={preferences.substance}
/>
</div>
</div>
<div className="space-y-6">
<div className="opacity-0 animate-slide-in-right delay-100">
@ -174,6 +255,28 @@ export function Dashboard({ user }: DashboardProps) {
<div className="opacity-0 animate-slide-in-right delay-300">
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
</div>
<div className="opacity-0 animate-slide-in-right delay-400">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
</div>
<div className="opacity-0 animate-slide-in-right delay-500">
<SavingsTrackerCard
key={`savings-${refreshKey}`}
savingsConfig={savingsConfig}
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
/>
</div>
<div className="opacity-0 animate-slide-in-right delay-600">
<ReminderSettingsCard
settings={reminderSettings}
onSettingsChange={handleReminderSettingsChange}
/>
</div>
</div>
</div>
</>
@ -190,6 +293,13 @@ export function Dashboard({ user }: DashboardProps) {
userId={user.id}
/>
)}
{showCelebration && newBadge && (
<CelebrationAnimation
badge={newBadge}
onComplete={handleCelebrationComplete}
/>
)}
</div>
);
}

View File

@ -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<string, React.ElementType> = {
Heart,
Wind,
HeartPulse,
Eye,
Activity,
TrendingUp,
Sparkles,
HeartHandshake,
};
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
if (minutes < 1440) return `${Math.floor(minutes / 60)} hrs`;
if (minutes < 10080) return `${Math.floor(minutes / 1440)} days`;
if (minutes < 43200) return `${Math.floor(minutes / 10080)} weeks`;
if (minutes < 525600) return `${Math.floor(minutes / 43200)} months`;
return `${Math.floor(minutes / 525600)} year${minutes >= 1051200 ? 's' : ''}`;
}
function formatTimeRemaining(currentMinutes: number, targetMinutes: number): string {
const remaining = targetMinutes - currentMinutes;
if (remaining <= 0) return 'Achieved!';
return `${formatDuration(remaining)} to go`;
}
export function HealthTimelineCard({ usageData, substance }: HealthTimelineCardProps) {
const { theme } = useTheme();
const minutesSinceQuit = useMemo(() => {
return getMinutesSinceQuit(usageData, substance);
}, [usageData, substance]);
const currentMilestoneIndex = useMemo(() => {
for (let i = HEALTH_MILESTONES.length - 1; i >= 0; i--) {
if (minutesSinceQuit >= HEALTH_MILESTONES[i].timeMinutes) {
return i;
}
}
return -1;
}, [minutesSinceQuit]);
const nextMilestone = useMemo(() => {
const nextIndex = currentMilestoneIndex + 1;
if (nextIndex < HEALTH_MILESTONES.length) {
return HEALTH_MILESTONES[nextIndex];
}
return null;
}, [currentMilestoneIndex]);
const progressToNext = useMemo(() => {
if (!nextMilestone) return 100;
const prevMinutes =
currentMilestoneIndex >= 0
? HEALTH_MILESTONES[currentMilestoneIndex].timeMinutes
: 0;
const range = nextMilestone.timeMinutes - prevMinutes;
const progress = minutesSinceQuit - prevMinutes;
return Math.min(100, Math.max(0, (progress / range) * 100));
}, [minutesSinceQuit, nextMilestone, currentMilestoneIndex]);
const cardBackground =
theme === 'light'
? 'linear-gradient(135deg, rgba(6, 95, 70, 0.85) 0%, rgba(4, 120, 87, 0.9) 100%)'
: 'linear-gradient(135deg, rgba(20, 184, 166, 0.2) 0%, rgba(6, 182, 212, 0.15) 100%)';
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
return (
<Card
className="backdrop-blur-xl border border-teal-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-teal-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<Heart className="h-5 w-5 text-teal-400" />
<span>Health Recovery</span>
</CardTitle>
<p className="text-sm text-white/70">
{substanceLabel}-free for {formatDuration(minutesSinceQuit)}
</p>
</CardHeader>
<CardContent className="relative z-10">
{/* Progress to next milestone */}
{nextMilestone && (
<div className="mb-4 p-3 bg-teal-500/20 rounded-xl border border-teal-500/30">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-white/80">Next milestone</span>
<span className="text-sm text-teal-300 font-medium">
{formatTimeRemaining(minutesSinceQuit, nextMilestone.timeMinutes)}
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
<div
className="bg-gradient-to-r from-teal-400 to-cyan-400 h-2 rounded-full transition-all duration-700"
style={{ width: `${progressToNext}%` }}
/>
</div>
<p className="text-xs text-white/60 mt-2">{nextMilestone.title}</p>
</div>
)}
{/* Timeline */}
<div className="space-y-3 max-h-[300px] overflow-y-auto pr-2">
{HEALTH_MILESTONES.map((milestone, index) => {
const isAchieved = minutesSinceQuit >= milestone.timeMinutes;
const isCurrent = index === currentMilestoneIndex;
const Icon = iconMap[milestone.icon] || Heart;
return (
<div
key={milestone.id}
className={`flex items-start gap-3 p-2 rounded-lg transition-all ${
isAchieved
? 'bg-teal-500/20'
: 'bg-white/5 opacity-60'
} ${isCurrent ? 'ring-2 ring-teal-400/50' : ''}`}
>
{/* Icon */}
<div
className={`p-2 rounded-full shrink-0 ${
isAchieved
? 'bg-teal-500/30 text-teal-300'
: 'bg-white/10 text-white/40'
}`}
>
{isAchieved ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p
className={`text-sm font-medium ${
isAchieved ? 'text-white' : 'text-white/60'
}`}
>
{milestone.title}
</p>
{isCurrent && (
<span className="text-[10px] bg-teal-500 text-white px-1.5 py-0.5 rounded-full">
Current
</span>
)}
</div>
<p className="text-xs text-white/50 mt-0.5">
{milestone.description}
</p>
<div className="flex items-center gap-1 mt-1">
<Clock className="h-3 w-3 text-white/40" />
<span className="text-[10px] text-white/40">
{formatDuration(milestone.timeMinutes)}
</span>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@ -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 (
<Card
className="backdrop-blur-xl border border-indigo-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-indigo-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<Bell className="h-5 w-5 text-indigo-400" />
<span>Daily Reminders</span>
</CardTitle>
</CardHeader>
<CardContent className="relative z-10 space-y-4">
{/* Permission Status */}
<div className="flex items-center justify-between p-3 bg-white/10 rounded-lg">
<span className="text-sm text-white/70">Notifications</span>
<span className={`text-sm font-medium ${permissionStatus.color}`}>
{permissionStatus.text}
</span>
</div>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-3 bg-white/10 rounded-lg">
<div className="flex items-center gap-2">
{settings.enabled ? (
<BellRing className="h-4 w-4 text-indigo-300" />
) : (
<BellOff className="h-4 w-4 text-white/50" />
)}
<span className="text-sm text-white">
{settings.enabled ? 'Reminders On' : 'Reminders Off'}
</span>
</div>
<button
onClick={handleToggle}
disabled={!isSupported || permission === 'denied'}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${
settings.enabled ? 'bg-indigo-500' : 'bg-white/20'
} ${!isSupported || permission === 'denied' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<div
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all duration-300 ${
settings.enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{/* Time Picker */}
{settings.enabled && (
<div className="space-y-2">
<Label htmlFor="reminderTime" className="text-white/70 text-sm">
Reminder Time
</Label>
<Input
id="reminderTime"
type="time"
value={localTime}
onChange={(e) => handleTimeChange(e.target.value)}
className="bg-white/10 border-white/20 text-white"
/>
<p className="text-xs text-white/50">
You&apos;ll receive a reminder at this time each day
</p>
</div>
)}
{/* Request Permission Button */}
{isSupported && permission === 'default' && (
<Button
onClick={requestPermission}
variant="outline"
className="w-full border-indigo-400/50 text-white hover:bg-white/10"
>
<Bell className="mr-2 h-4 w-4" />
Enable Notifications
</Button>
)}
{/* Denied Message */}
{permission === 'denied' && (
<div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p className="text-xs text-red-300">
Notifications are blocked. Please enable them in your browser settings to receive reminders.
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-emerald-400" />
{existingConfig ? 'Edit Savings Tracker' : 'Set Up Savings Tracker'}
</DialogTitle>
<DialogDescription>
Enter your usage costs to track how much you&apos;re saving
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Substance Selection */}
<div className="space-y-2">
<Label>What are you tracking?</Label>
<Select value={substance} onValueChange={(v) => setSubstance(v as 'nicotine' | 'weed')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="nicotine">Nicotine (Vape/Cigarettes)</SelectItem>
<SelectItem value="weed">Marijuana</SelectItem>
</SelectContent>
</Select>
</div>
{/* Currency Selection */}
<div className="space-y-2">
<Label>Currency</Label>
<Select value={currency} onValueChange={setCurrency}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((curr) => (
<SelectItem key={curr.code} value={curr.code}>
{curr.symbol} {curr.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Cost Per Unit */}
<div className="space-y-2">
<Label htmlFor="costPerUnit">
Cost per pack/cartridge/unit
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{CURRENCIES.find((c) => c.code === currency)?.symbol || '$'}
</span>
<Input
id="costPerUnit"
type="number"
min="0"
step="0.01"
value={costPerUnit}
onChange={(e) => setCostPerUnit(e.target.value)}
className="pl-8"
placeholder="10.00"
/>
</div>
<p className="text-xs text-muted-foreground">
How much does one pack or cartridge cost?
</p>
</div>
{/* Units Per Day */}
<div className="space-y-2">
<Label htmlFor="unitsPerDay">
Packs/units per day (before quitting)
</Label>
<Input
id="unitsPerDay"
type="number"
min="0"
step="0.1"
value={unitsPerDay}
onChange={(e) => setUnitsPerDay(e.target.value)}
placeholder="1"
/>
<p className="text-xs text-muted-foreground">
How many packs/units did you typically use per day?
</p>
</div>
{/* Optional: Savings Goal */}
<div className="pt-4 border-t space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Target className="h-4 w-4" />
<span>Optional: Set a savings goal</span>
</div>
<div className="space-y-2">
<Label htmlFor="savingsGoal">Target amount</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
{CURRENCIES.find((c) => c.code === currency)?.symbol || '$'}
</span>
<Input
id="savingsGoal"
type="number"
min="0"
step="1"
value={savingsGoal}
onChange={(e) => setSavingsGoal(e.target.value)}
className="pl-8"
placeholder="500"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="goalName">What are you saving for?</Label>
<Input
id="goalName"
type="text"
value={goalName}
onChange={(e) => setGoalName(e.target.value)}
placeholder="e.g., New Phone, Vacation"
/>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-4">
<Button variant="outline" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!isValid}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{existingConfig ? 'Update' : 'Save'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<>
<Card
className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<DollarSign className="h-5 w-5 text-emerald-400" />
<span>Money Savings</span>
</CardTitle>
</CardHeader>
<CardContent className="relative z-10 text-center py-6">
<div className="mb-4">
<DollarSign className="h-12 w-12 text-emerald-400/50 mx-auto mb-3" />
<p className="text-white/70 text-sm mb-4">
Track how much money you&apos;re saving by reducing your usage
</p>
</div>
<Button
onClick={() => setShowSetup(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-white transition-all duration-300 hover:scale-105"
>
<Settings className="mr-2 h-4 w-4" />
Set Up Savings Tracker
</Button>
</CardContent>
</Card>
<SavingsSetupDialog
open={showSetup}
onClose={() => setShowSetup(false)}
onSave={(config) => {
onSavingsConfigChange(config);
setShowSetup(false);
}}
existingConfig={null}
/>
</>
);
}
// Tracking mode - config exists
return (
<>
<Card
className="backdrop-blur-xl border border-emerald-500/40 shadow-xl drop-shadow-lg hover-lift transition-all duration-300 overflow-hidden relative"
style={{ background: cardBackground }}
>
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<CardHeader className="relative z-10 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
<DollarSign className="h-5 w-5 text-emerald-400" />
<span>Money Saved</span>
</CardTitle>
<button
onClick={() => setShowSetup(true)}
className="p-1.5 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<Settings className="h-4 w-4 text-white/70" />
</button>
</div>
</CardHeader>
<CardContent className="relative z-10">
{/* Total Saved */}
<div className="text-center mb-4 p-4 bg-emerald-500/20 rounded-xl border border-emerald-500/30">
<p className="text-sm text-white/70 mb-1">Total Saved</p>
<p className="text-4xl font-bold text-emerald-300 text-shadow">
{formatCurrency(totalSaved)}
</p>
</div>
{/* Goal Progress */}
{savingsConfig.savingsGoal && goalProgress !== null && (
<div className="mb-4 p-3 bg-white/10 rounded-xl">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-emerald-400" />
<span className="text-sm text-white/80">
{savingsConfig.goalName || 'Savings Goal'}
</span>
</div>
<span className="text-sm text-emerald-300 font-medium">
{goalProgress.toFixed(0)}%
</span>
</div>
<div className="w-full bg-white/10 rounded-full h-2.5 overflow-hidden">
<div
className="bg-gradient-to-r from-emerald-400 to-green-400 h-2.5 rounded-full transition-all duration-700"
style={{ width: `${goalProgress}%` }}
/>
</div>
<p className="text-xs text-white/50 mt-2 text-right">
{formatCurrency(totalSaved)} of {formatCurrency(savingsConfig.savingsGoal)}
</p>
</div>
)}
{/* Projections */}
<div className="grid grid-cols-2 gap-2">
<div className="bg-white/10 p-3 rounded-lg text-center hover:bg-white/15 transition-all">
<p className="text-lg font-bold text-white">{formatCurrency(projections.weekly)}</p>
<p className="text-xs text-white/60">Weekly</p>
</div>
<div className="bg-white/10 p-3 rounded-lg text-center hover:bg-white/15 transition-all">
<p className="text-lg font-bold text-white">{formatCurrency(projections.monthly)}</p>
<p className="text-xs text-white/60">Monthly</p>
</div>
</div>
<div className="mt-3 flex items-center justify-center gap-1 text-xs text-white/50">
<Sparkles className="h-3 w-3" />
<span>Based on {formatCurrency(projections.daily)}/day potential savings</span>
</div>
</CardContent>
</Card>
<SavingsSetupDialog
open={showSetup}
onClose={() => setShowSetup(false)}
onSave={(config) => {
onSavingsConfigChange(config);
setShowSetup(false);
}}
existingConfig={savingsConfig}
/>
</>
);
}

View File

@ -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<NotificationPermission>('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,
};
}

View File

@ -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<Achievement[]> {
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<ReminderSettings> {
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<void> {
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<SavingsConfig | null> {
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<void> {
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