From 2491c79b0a61e4e8338e4bc6b709c911ceac9130 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 24 Jan 2026 12:00:22 -0700 Subject: [PATCH] Improve achievements, move reminders to header, and reset health timeline on usage --- src/components/AchievementsCard.tsx | 35 ++++--- src/components/Dashboard.tsx | 20 +--- src/components/UserHeader.tsx | 156 +++++++++++++++++++++++++++- src/lib/storage.ts | 87 ++++++++++++---- 4 files changed, 244 insertions(+), 54 deletions(-) diff --git a/src/components/AchievementsCard.tsx b/src/components/AchievementsCard.tsx index c04485b..f4232a6 100644 --- a/src/components/AchievementsCard.tsx +++ b/src/components/AchievementsCard.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Achievement, BADGE_DEFINITIONS } from '@/lib/storage'; +import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage'; import { useTheme } from '@/lib/theme-context'; import { Trophy, @@ -30,6 +30,7 @@ const iconMap: Record = { export function AchievementsCard({ achievements, substance }: AchievementsCardProps) { const { theme } = useTheme(); + const [hoveredBadge, setHoveredBadge] = useState(null); const unlockedBadgeIds = useMemo(() => { return new Set( @@ -44,7 +45,6 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr ? '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 ( @@ -57,7 +57,7 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr - {substanceLabel} Achievements + Achievements @@ -71,23 +71,34 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr a.badgeId === badge.id && (a.substance === substance || a.substance === 'both') ); + const isHovered = hoveredBadge === badge.id; return (
setHoveredBadge(badge.id)} + onMouseLeave={() => setHoveredBadge(null)} > + {/* Hover tooltip */} + {isHovered && ( +
+

{badge.name}

+

+ {isUnlocked + ? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}` + : badge.howToUnlock} +

+
+
+ )} + {!isUnlocked && ( -
+
)} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index c315fbe..f385d5c 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -11,16 +11,13 @@ import { markPromptShown, generateQuitPlan, fetchAchievements, - fetchReminderSettings, fetchSavingsConfig, - saveReminderSettings, saveSavingsConfig, unlockAchievement, checkBadgeEligibility, UserPreferences, UsageEntry, Achievement, - ReminderSettings, SavingsConfig, BADGE_DEFINITIONS, BadgeDefinition, @@ -35,7 +32,6 @@ 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'; @@ -48,7 +44,6 @@ export function Dashboard({ user }: DashboardProps) { const [preferences, setPreferences] = useState(null); const [usageData, setUsageData] = useState([]); const [achievements, setAchievements] = useState([]); - const [reminderSettings, setReminderSettings] = useState({ enabled: false, reminderTime: '09:00' }); const [savingsConfig, setSavingsConfig] = useState(null); const [showSetup, setShowSetup] = useState(false); const [showUsagePrompt, setShowUsagePrompt] = useState(false); @@ -59,17 +54,15 @@ export function Dashboard({ user }: DashboardProps) { const { theme } = useTheme(); const loadData = useCallback(async () => { - const [prefs, usage, achvs, reminders, savings] = await Promise.all([ + const [prefs, usage, achvs, 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, usage, achvs }; @@ -176,11 +169,6 @@ 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); @@ -271,12 +259,6 @@ export function Dashboard({ user }: DashboardProps) { onSavingsConfigChange={handleSavingsConfigChange} />
-
- -
diff --git a/src/components/UserHeader.tsx b/src/components/UserHeader.tsx index 1520236..e92f217 100644 --- a/src/components/UserHeader.tsx +++ b/src/components/UserHeader.tsx @@ -8,11 +8,21 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; import { User } from '@/lib/session'; -import { fetchPreferences } from '@/lib/storage'; +import { fetchPreferences, fetchReminderSettings, saveReminderSettings, ReminderSettings } from '@/lib/storage'; +import { useNotifications } from '@/hooks/useNotifications'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon } from 'lucide-react'; +import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; interface UserHeaderProps { @@ -21,17 +31,43 @@ interface UserHeaderProps { export function UserHeader({ user }: UserHeaderProps) { const [userName, setUserName] = useState(null); + const [reminderSettings, setReminderSettings] = useState({ enabled: false, reminderTime: '09:00' }); + const [showReminderDialog, setShowReminderDialog] = useState(false); + const [localTime, setLocalTime] = useState('09:00'); const router = useRouter(); const { theme, toggleTheme } = useTheme(); + const { isSupported, permission, requestPermission } = useNotifications(reminderSettings); useEffect(() => { - const loadUserName = async () => { - const prefs = await fetchPreferences(); + const loadData = async () => { + const [prefs, reminders] = await Promise.all([ + fetchPreferences(), + fetchReminderSettings(), + ]); setUserName(prefs.userName); + setReminderSettings(reminders); + setLocalTime(reminders.reminderTime); }; - loadUserName(); + loadData(); }, []); + const handleToggleReminders = async () => { + if (!reminderSettings.enabled && permission !== 'granted') { + const result = await requestPermission(); + if (result !== 'granted') return; + } + const newSettings = { ...reminderSettings, enabled: !reminderSettings.enabled }; + setReminderSettings(newSettings); + await saveReminderSettings(newSettings); + }; + + const handleTimeChange = async (newTime: string) => { + setLocalTime(newTime); + const newSettings = { ...reminderSettings, reminderTime: newTime }; + setReminderSettings(newSettings); + await saveReminderSettings(newSettings); + }; + const initials = [user.firstName?.[0], user.lastName?.[0]] .filter(Boolean) .join('') @@ -72,6 +108,22 @@ export function UserHeader({ user }: UserHeaderProps) {
+
)} + + {/* Reminder Settings Dialog */} + + + + + + Daily Reminders + + + +
+ {/* Permission Status */} +
+ Notifications + + {!isSupported ? 'Not supported' : + permission === 'granted' ? 'Enabled' : + permission === 'denied' ? 'Blocked' : 'Not set'} + +
+ + {/* Enable/Disable Toggle */} +
+
+ {reminderSettings.enabled ? ( + + ) : ( + + )} + + {reminderSettings.enabled ? 'Reminders On' : 'Reminders Off'} + +
+ +
+ + {/* Time Picker */} + {reminderSettings.enabled && ( +
+ + handleTimeChange(e.target.value)} + /> +

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

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

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

+
+ )} +
+
+
); } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index ea9158e..7dca2b6 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -50,6 +50,7 @@ export interface BadgeDefinition { id: string; name: string; description: string; + howToUnlock: string; icon: string; } @@ -64,12 +65,12 @@ export interface HealthMilestone { // ============ 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' }, + { id: 'first_day', name: 'First Step', description: 'Logged your first usage', howToUnlock: 'Log your usage for the first time', icon: 'Footprints' }, + { id: 'streak_3', name: 'Hat Trick', description: '3 days substance-free', howToUnlock: 'Go 3 consecutive days without using a tracked substance', icon: 'Flame' }, + { id: 'streak_7', name: 'Week Warrior', description: 'Tracked for one week', howToUnlock: 'Track your usage for 7 days', icon: 'Shield' }, + { id: 'fighter', name: 'Fighter', description: '7 days substance-free', howToUnlock: 'Go 7 consecutive days without using any substance', icon: 'Swords' }, + { id: 'one_month', name: 'Monthly Master', description: 'One month tracked with 50% reduction', howToUnlock: 'Track for 30 days and reduce your usage by at least 50%', icon: 'Crown' }, + { id: 'goal_crusher', name: 'Goal Crusher', description: 'One month substance-free', howToUnlock: 'Go 30 consecutive days without using any substance', icon: 'Trophy' }, ]; // ============ HEALTH MILESTONES ============ @@ -398,11 +399,19 @@ export function getMinutesSinceQuit( return 0; } - const lastUsageDate = new Date(substanceData[0].date); - // Set to end of that day + const now = new Date(); + const todayStr = now.toISOString().split('T')[0]; + const lastUsageDateStr = substanceData[0].date; + + // If the last usage was today, reset to 0 (just used) + if (lastUsageDateStr === todayStr) { + return 0; + } + + // For past days, count from the end of that day + const lastUsageDate = new Date(lastUsageDateStr); lastUsageDate.setHours(23, 59, 59, 999); - const now = new Date(); const diffMs = now.getTime() - lastUsageDate.getTime(); return Math.max(0, Math.floor(diffMs / (1000 * 60))); } @@ -414,26 +423,68 @@ export function checkBadgeEligibility( substance: 'nicotine' | 'weed' ): boolean { const streak = calculateStreak(usageData, substance); + const nicotineStreak = calculateStreak(usageData, 'nicotine'); + const weedStreak = calculateStreak(usageData, 'weed'); 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); + + // Check if user has tracked for at least 30 days and reduced usage by 50% + const checkMonthlyReduction = (): boolean => { + if (!preferences.trackingStartDate) return false; + const startDate = new Date(preferences.trackingStartDate); + const today = new Date(); + const daysSinceStart = Math.floor( + (today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) + ); + if (daysSinceStart < 30) return false; + + // Get first week's average + const firstWeekData = usageData.filter((e) => { + const entryDate = new Date(e.date); + const daysSinceEntry = Math.floor( + (entryDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) + ); + return e.substance === substance && daysSinceEntry >= 0 && daysSinceEntry < 7; + }); + const firstWeekTotal = firstWeekData.reduce((sum, e) => sum + e.count, 0); + const firstWeekAvg = firstWeekData.length > 0 ? firstWeekTotal / 7 : 0; + + // Get last week's average + const lastWeekData = usageData.filter((e) => { + const entryDate = new Date(e.date); + const daysAgo = Math.floor( + (today.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24) + ); + return e.substance === substance && daysAgo >= 0 && daysAgo < 7; + }); + const lastWeekTotal = lastWeekData.reduce((sum, e) => sum + e.count, 0); + const lastWeekAvg = lastWeekTotal / 7; + + // Check if reduced by at least 50% + if (firstWeekAvg <= 0) return lastWeekAvg === 0; + return lastWeekAvg <= firstWeekAvg * 0.5; + }; switch (badgeId) { case 'first_day': + // Log usage for the first time return totalDays >= 1; case 'streak_3': + // 3 days off a tracked substance return streak >= 3; case 'streak_7': - return streak >= 7; - case 'two_weeks': - return streak >= 14; + // Track usage for one week (7 days of entries) + return totalDays >= 7; + case 'fighter': + // 7 days off ANY substance (both nicotine AND weed) + return nicotineStreak >= 7 && weedStreak >= 7; case 'one_month': - return streak >= 30; - case 'plan_completed': - return planCompleted; + // Track one month and reduce usage by 50% + return checkMonthlyReduction(); + case 'goal_crusher': + // One month substance free (both substances) + return nicotineStreak >= 30 && weedStreak >= 30; default: return false; }