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:
parent
45bcad9788
commit
54b7a294f5
@ -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])
|
||||
}
|
||||
|
||||
82
src/app/api/achievements/route.ts
Normal file
82
src/app/api/achievements/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
src/app/api/reminders/route.ts
Normal file
64
src/app/api/reminders/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
81
src/app/api/savings/route.ts
Normal file
81
src/app/api/savings/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
123
src/components/AchievementsCard.tsx
Normal file
123
src/components/AchievementsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/CelebrationAnimation.tsx
Normal file
118
src/components/CelebrationAnimation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
194
src/components/HealthTimelineCard.tsx
Normal file
194
src/components/HealthTimelineCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
src/components/ReminderSettingsCard.tsx
Normal file
152
src/components/ReminderSettingsCard.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
240
src/components/SavingsSetupDialog.tsx
Normal file
240
src/components/SavingsSetupDialog.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
202
src/components/SavingsTrackerCard.tsx
Normal file
202
src/components/SavingsTrackerCard.tsx
Normal 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'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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
src/hooks/useNotifications.ts
Normal file
106
src/hooks/useNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user