Improve achievements, move reminders to header, and reset health timeline on usage

This commit is contained in:
Avery Felts 2026-01-24 12:00:22 -07:00
parent 54b7a294f5
commit 2491c79b0a
4 changed files with 244 additions and 54 deletions

View File

@ -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<string, React.ElementType> = {
export function AchievementsCard({ achievements, substance }: AchievementsCardProps) {
const { theme } = useTheme();
const [hoveredBadge, setHoveredBadge] = useState<string | null>(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
<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>
<span>Achievements</span>
</CardTitle>
</CardHeader>
@ -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 (
<div
key={badge.id}
className={`relative p-3 rounded-xl text-center transition-all duration-300 ${
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${
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'
: 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20'
}`}
title={
isUnlocked
? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}`
: badge.description
}
onMouseEnter={() => setHoveredBadge(badge.id)}
onMouseLeave={() => setHoveredBadge(null)}
>
{/* Hover tooltip */}
{isHovered && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 w-48 p-2 bg-gray-900/95 border border-white/20 rounded-lg shadow-xl backdrop-blur-sm">
<p className="text-xs text-white font-medium mb-1">{badge.name}</p>
<p className="text-[10px] text-white/70">
{isUnlocked
? `Unlocked: ${new Date(unlockedAchievement!.unlockedAt).toLocaleDateString()}`
: badge.howToUnlock}
</p>
<div className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900/95" />
</div>
)}
{!isUnlocked && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-xl">
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded-xl pointer-events-none">
<Lock className="h-4 w-4 text-white/40" />
</div>
)}

View File

@ -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<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);
@ -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}
/>
</div>
<div className="opacity-0 animate-slide-in-right delay-600">
<ReminderSettingsCard
settings={reminderSettings}
onSettingsChange={handleReminderSettingsChange}
/>
</div>
</div>
</div>
</>

View File

@ -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<string | null>(null);
const [reminderSettings, setReminderSettings] = useState<ReminderSettings>({ 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) {
</div>
<div className="flex items-center gap-2 sm:gap-3">
<button
onClick={() => setShowReminderDialog(true)}
className={`p-2.5 sm:p-2 rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-white/30 hover:scale-110 active:scale-95 ${
reminderSettings.enabled
? 'bg-indigo-500/30 hover:bg-indigo-500/40'
: 'bg-white/10 hover:bg-white/20'
}`}
aria-label="Reminder settings"
title={reminderSettings.enabled ? `Reminders on at ${reminderSettings.reminderTime}` : 'Reminders off'}
>
{reminderSettings.enabled ? (
<BellRing className="h-5 w-5 text-indigo-300 transition-transform duration-300" />
) : (
<Bell className="h-5 w-5 text-white/70 transition-transform duration-300" />
)}
</button>
<button
onClick={toggleTheme}
className="p-2.5 sm:p-2 rounded-full bg-white/10 hover:bg-white/20 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-white/30 hover:scale-110 active:scale-95"
@ -123,6 +175,100 @@ export function UserHeader({ user }: UserHeaderProps) {
</p>
</div>
)}
{/* Reminder Settings Dialog */}
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-indigo-400" />
Daily Reminders
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Permission Status */}
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<span className="text-sm text-muted-foreground">Notifications</span>
<span className={`text-sm font-medium ${
!isSupported ? 'text-red-400' :
permission === 'granted' ? 'text-green-400' :
permission === 'denied' ? 'text-red-400' : 'text-yellow-400'
}`}>
{!isSupported ? 'Not supported' :
permission === 'granted' ? 'Enabled' :
permission === 'denied' ? 'Blocked' : 'Not set'}
</span>
</div>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-2">
{reminderSettings.enabled ? (
<BellRing className="h-4 w-4 text-indigo-400" />
) : (
<BellOff className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm">
{reminderSettings.enabled ? 'Reminders On' : 'Reminders Off'}
</span>
</div>
<button
onClick={handleToggleReminders}
disabled={!isSupported || permission === 'denied'}
className={`relative w-12 h-6 rounded-full transition-all duration-300 ${
reminderSettings.enabled ? 'bg-indigo-500' : 'bg-muted-foreground/30'
} ${!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 ${
reminderSettings.enabled ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
{/* Time Picker */}
{reminderSettings.enabled && (
<div className="space-y-2">
<Label htmlFor="reminderTime" className="text-sm">
Reminder Time
</Label>
<Input
id="reminderTime"
type="time"
value={localTime}
onChange={(e) => handleTimeChange(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
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"
>
<Bell className="mr-2 h-4 w-4" />
Enable Notifications
</Button>
)}
{/* Denied Message */}
{permission === 'denied' && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-xs text-red-400">
Notifications are blocked. Please enable them in your browser settings to receive reminders.
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</header>
);
}

View File

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