From 75a75fd4999b91c898d66d2a2e7242960106a936 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Sat, 31 Jan 2026 17:12:01 -0700 Subject: [PATCH] feat: Implement independent nicotine/weed quit plans with refined UI and auto-unlock logic --- src/app/api/preferences/route.ts | 19 +++++- src/components/Dashboard.tsx | 105 +++++++++++++++++++++++++----- src/components/QuitPlanCard.tsx | 108 ++++++++++++++++++++++++------- src/lib/storage.ts | 61 ++++++++++++----- 4 files changed, 238 insertions(+), 55 deletions(-) diff --git a/src/app/api/preferences/route.ts b/src/app/api/preferences/route.ts index d83388f..5108ec5 100644 --- a/src/app/api/preferences/route.ts +++ b/src/app/api/preferences/route.ts @@ -54,6 +54,7 @@ export async function POST(request: NextRequest) { hasCompletedSetup?: boolean; dailyGoal?: number; quitPlan?: unknown; + quitState?: unknown; userName?: string; userAge?: number; religion?: string; @@ -61,12 +62,17 @@ export async function POST(request: NextRequest) { lastWeedUsageTime?: string; }; + // If quitState is provided in body, save it to quitPlanJson + const quitPlanJson = body.quitState + ? JSON.stringify(body.quitState) + : (body.quitPlan ? JSON.stringify(body.quitPlan) : undefined); + const preferences = await upsertPreferencesD1(session.user.id, { substance: body.substance, trackingStartDate: body.trackingStartDate, hasCompletedSetup: body.hasCompletedSetup ? 1 : 0, dailyGoal: body.dailyGoal, - quitPlanJson: body.quitPlan ? JSON.stringify(body.quitPlan) : undefined, + quitPlanJson: quitPlanJson, userName: body.userName, userAge: body.userAge, religion: body.religion, @@ -78,12 +84,21 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Failed to save preferences' }, { status: 500 }); } + // Parse returned JSON to construct state again + const rawJson = preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null; + const isNewFormat = rawJson && 'nicotine' in rawJson; + const quitState = isNewFormat ? rawJson : { + nicotine: preferences.substance === 'nicotine' ? { plan: rawJson, startDate: preferences.trackingStartDate } : { plan: null, startDate: null }, + weed: preferences.substance === 'weed' ? { plan: rawJson, startDate: preferences.trackingStartDate } : { plan: null, startDate: null } + }; + return NextResponse.json({ substance: preferences.substance, trackingStartDate: preferences.trackingStartDate, hasCompletedSetup: !!preferences.hasCompletedSetup, dailyGoal: preferences.dailyGoal, - quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null, + quitPlan: null, + quitState, userName: preferences.userName, userAge: preferences.userAge, religion: preferences.religion, diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 713624e..2102c1e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -106,7 +106,10 @@ export function Dashboard({ user }: DashboardProps) { prefs: UserPreferences, currentAchievements: Achievement[] ) => { + // Current unlocked set (local + server) const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`)); + const newUnlocked: Achievement[] = []; + let badgeToCelebrate: BadgeDefinition | null = null; for (const badge of BADGE_DEFINITIONS) { for (const substance of ['nicotine', 'weed'] as const) { @@ -115,16 +118,34 @@ export function Dashboard({ user }: DashboardProps) { 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 + try { + const result = await unlockAchievement(badge.id, substance); + if (result.isNew && result.achievement) { + newUnlocked.push(result.achievement); + // Prioritize celebrating the first one found + if (!badgeToCelebrate) { + badgeToCelebrate = badge; + } + } + } catch (e) { + console.error('Error unlocking achievement:', e); } } } } + + if (newUnlocked.length > 0) { + // Update local state with ALL new achievements + setAchievements(prev => [...prev, ...newUnlocked]); + + // Show celebration for determining badge + if (badgeToCelebrate) { + setNewBadge(badgeToCelebrate); + setShowCelebration(true); + } + } + + return newUnlocked.length > 0; }, []); useEffect(() => { @@ -208,18 +229,41 @@ export function Dashboard({ user }: DashboardProps) { setUsageData(usage); setRefreshKey(prev => prev + 1); - // Check for new achievements immediately + // Check for new achievements metrics FIRST await checkAndUnlockAchievements(usage, latestPrefs, achievements); + + // Force a fresh fetch of all data to ensure UI sync + const freshAchievements = await fetchAchievements(); + setAchievements(freshAchievements); + + // THEN refresh UI components + setRefreshKey(prev => prev + 1); }; - const handleGeneratePlan = async () => { + const handleGeneratePlan = async (targetSubstance: 'nicotine' | 'weed') => { if (!preferences) return; - const plan = generateQuitPlan(preferences.substance); + const plan = generateQuitPlan(targetSubstance); + + // Construct new state + const currentQuitState = preferences.quitState || { + nicotine: { plan: null, startDate: null }, + weed: { plan: null, startDate: null } + }; + + const updatedQuitState = { + ...currentQuitState, + [targetSubstance]: { + plan, + startDate: currentQuitState[targetSubstance].startDate || (preferences.substance === targetSubstance ? preferences.trackingStartDate : null) || getTodayString() + } + }; + const updatedPrefs = { ...preferences, - quitPlan: plan, + quitState: updatedQuitState }; + await savePreferencesAsync(updatedPrefs); setPreferences(updatedPrefs); setRefreshKey(prev => prev + 1); @@ -314,12 +358,41 @@ export function Dashboard({ user }: DashboardProps) {
- + {/* Nicotine Plan */} + {(preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine')) && ( + handleGeneratePlan('nicotine')} + usageData={usageData} + trackingStartDate={ + preferences.quitState?.nicotine.startDate || + (preferences.substance === 'nicotine' ? preferences.trackingStartDate : null) || + // Fallback: Find earliest usage date + usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date || + null + } + substance="nicotine" + /> + )} + + {/* Weed Plan */} + {(preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed')) && ( + handleGeneratePlan('weed')} + usageData={usageData} + trackingStartDate={ + preferences.quitState?.weed.startDate || + (preferences.substance === 'weed' ? preferences.trackingStartDate : null) || + // Fallback: Find earliest usage date + usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date || + null + } + substance="weed" + /> + )}
diff --git a/src/components/QuitPlanCard.tsx b/src/components/QuitPlanCard.tsx index 2a1008b..6b639e2 100644 --- a/src/components/QuitPlanCard.tsx +++ b/src/components/QuitPlanCard.tsx @@ -6,27 +6,59 @@ import { Button } from '@/components/ui/button'; import { QuitPlan, UsageEntry } from '@/lib/storage'; import { Target, TrendingDown } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; +import { getTodayString } from '@/lib/date-utils'; interface QuitPlanCardProps { plan: QuitPlan | null; onGeneratePlan: () => void; usageData: UsageEntry[]; + trackingStartDate: string | null; + substance: 'nicotine' | 'weed'; } function QuitPlanCardComponent({ plan, onGeneratePlan, usageData, + trackingStartDate, + substance, }: QuitPlanCardProps) { const { theme } = useTheme(); // Count unique days with any logged data - const uniqueDaysWithData = new Set(usageData.map(e => e.date)).size; + const uniqueDaysWithData = new Set(usageData.filter(e => e.substance === substance).map(e => e.date)).size; const daysRemaining = Math.max(0, 7 - uniqueDaysWithData); - const hasEnoughData = uniqueDaysWithData >= 7; + + // Logic: Unlocked if 7+ days tracked AND (It's Day 8+ OR usage exists for Day 8+) + // This effectively locks it until 12:01 AM next day after Day 7 is done + const isUnlocked = React.useMemo(() => { + // Determine the local start date cleanly (ignoring time) + if (!trackingStartDate || uniqueDaysWithData < 7) return false; + + // Parse YYYY-MM-DD + const [y, m, d] = trackingStartDate.split('-').map(Number); + const startObj = new Date(y, m - 1, d); // Local midnight + + const now = new Date(); + // Get today's local midnight + const todayObj = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + // Calculate difference in full days + // Jan 1 to Jan 8: difference of 7 days. + const diffTime = todayObj.getTime() - startObj.getTime(); + const daysPassed = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + // If 7 days have passed (meaning we are on Day 8 or later), unlock. + if (daysPassed >= 7) return true; + + // Also check if usage count is > 7, implying usage beyond the first week + if (uniqueDaysWithData > 7) return true; + + return false; + }, [uniqueDaysWithData, trackingStartDate]); // Calculate current average - const totalUsage = usageData.reduce((sum, e) => sum + e.count, 0); + const totalUsage = usageData.filter(e => e.substance === substance).reduce((sum, e) => sum + e.count, 0); const currentAverage = uniqueDaysWithData > 0 ? Math.round(totalUsage / uniqueDaysWithData) : 0; // Yellow gradient for tracking phase (darker in light mode) @@ -48,7 +80,7 @@ function QuitPlanCardComponent({ - Your Personalized Plan + Your {substance === 'nicotine' ? 'Nicotine' : 'Weed'} Quit Plan We're tracking your usage to build your custom quit plan @@ -73,7 +105,7 @@ function QuitPlanCardComponent({

- {hasEnoughData ? ( + {isUnlocked ? (

Great work! Your average daily usage is{' '} @@ -107,6 +139,19 @@ function QuitPlanCardComponent({ const totalWeeks = plan.weeklyTargets.length; const currentTarget = weekNumber <= totalWeeks ? plan.weeklyTargets[weekNumber - 1] : 0; + // Calculate today's usage for progress bar + const todayStr = getTodayString(); + const todayUsage = usageData + .filter(e => e.date === todayStr && e.substance === substance) + .reduce((sum, e) => sum + e.count, 0); + + const usagePercent = currentTarget > 0 ? (todayUsage / currentTarget) * 100 : 0; + + // Progress bar color based on usage + let progressColor = 'bg-emerald-400'; // Good + if (usagePercent >= 100) progressColor = 'bg-red-500'; // Over limit + else if (usagePercent >= 80) progressColor = 'bg-yellow-400'; // Warning + return ( - Your Quit Plan + Your {substance === 'nicotine' ? 'Nicotine' : 'Weed'} Plan Week {Math.min(weekNumber, totalWeeks)} of {totalWeeks} - 25% weekly reduction @@ -123,30 +168,49 @@ function QuitPlanCardComponent({

-

This week's daily target

+

{substance === 'nicotine' ? 'Nicotine' : 'Weed'} Max Puffs Target

{currentTarget !== null && currentTarget > 0 ? currentTarget : '0'}

-

per day

+

per day

+ + {/* Daily Progress Bar */} +
+
+
+

+ {todayUsage} used / {currentTarget} allowed +

Weekly targets:

- {plan.weeklyTargets.map((target, index) => ( -
-

Week {index + 1}

-

{target}

-
- ))} + {plan.weeklyTargets.map((target, index) => { + const weekNum = index + 1; + const isFuture = weekNum > weekNumber; + const isCurrent = weekNum === weekNumber; + + return ( +
+

Week {weekNum}

+

+ {isFuture ? '?' : target} +

+
+ ) + })}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts index d631747..a90c365 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -7,17 +7,26 @@ export interface UsageEntry { substance: 'nicotine' | 'weed'; } +export interface SubstanceState { + plan: QuitPlan | null; + startDate: string | null; +} + export interface UserPreferences { substance: 'nicotine' | 'weed'; trackingStartDate: string | null; hasCompletedSetup: boolean; dailyGoal: number | null; quitPlan: QuitPlan | null; + quitState?: { // NEW: Flexible container for dual state + nicotine: SubstanceState; + weed: SubstanceState; + }; userName: string | null; userAge: number | null; religion: 'christian' | 'secular' | null; - lastNicotineUsageTime?: string | null; // ISO timestamp of last usage - lastWeedUsageTime?: string | null; // ISO timestamp of last usage + lastNicotineUsageTime?: string | null; + lastWeedUsageTime?: string | null; } export interface QuitPlan { @@ -110,6 +119,10 @@ const defaultPreferences: UserPreferences = { hasCompletedSetup: false, dailyGoal: null, quitPlan: null, + quitState: { + nicotine: { plan: null, startDate: null }, + weed: { plan: null, startDate: null } + }, userName: null, userAge: null, religion: null, @@ -145,7 +158,7 @@ export function getCurrentUserId(): string | null { export async function fetchPreferences(): Promise { if (preferencesCache) return preferencesCache; try { - const response = await fetch('/api/preferences'); + const response = await fetch('/api/preferences', { cache: 'no-store' }); if (!response.ok) { console.error('Failed to fetch preferences'); return defaultPreferences; @@ -177,7 +190,7 @@ export async function savePreferencesAsync(preferences: UserPreferences): Promis export async function fetchUsageData(): Promise { if (usageDataCache) return usageDataCache; try { - const response = await fetch('/api/usage'); + const response = await fetch('/api/usage', { cache: 'no-store' }); if (!response.ok) { console.error('Failed to fetch usage data'); return []; @@ -240,7 +253,7 @@ export async function clearDayDataAsync( export async function fetchAchievements(): Promise { if (achievementsCache) return achievementsCache; try { - const response = await fetch('/api/achievements'); + const response = await fetch('/api/achievements', { cache: 'no-store' }); if (!response.ok) return []; const data = await response.json() as Achievement[]; achievementsCache = data; @@ -324,7 +337,7 @@ export function getReminderSettings(): ReminderSettings { export async function fetchSavingsConfig(): Promise { if (savingsConfigCache) return savingsConfigCache; try { - const response = await fetch('/api/savings'); + const response = await fetch('/api/savings', { cache: 'no-store' }); if (!response.ok) return null; const data = await response.json() as SavingsConfig | null; savingsConfigCache = data; @@ -359,7 +372,7 @@ export function getSavingsConfig(): SavingsConfig | null { export async function fetchMoodEntries(): Promise { if (moodEntriesCache) return moodEntriesCache; try { - const response = await fetch('/api/mood'); + const response = await fetch('/api/mood', { cache: 'no-store' }); if (!response.ok) return []; const data = await response.json() as MoodEntry[]; moodEntriesCache = data; @@ -412,7 +425,10 @@ export function calculateStreak( for (let i = 0; i <= 365; i++) { const checkDate = new Date(today); checkDate.setDate(checkDate.getDate() - i); - const dateStr = checkDate.toISOString().split('T')[0]; + // Use local date string to match storage format + const offset = checkDate.getTimezoneOffset(); + const localDate = new Date(checkDate.getTime() - (offset * 60 * 1000)); + const dateStr = localDate.toISOString().split('T')[0]; // O(1) lookup const dayUsage = substanceMap.get(dateStr) ?? -1; @@ -496,7 +512,10 @@ export function checkBadgeEligibility( for (let i = 0; i <= 365; i++) { const d = new Date(today); d.setDate(d.getDate() - i); - const ds = d.toISOString().split('T')[0]; + // Use local date string to match storage format + const offset = d.getTimezoneOffset(); + const localDate = new Date(d.getTime() - (offset * 60 * 1000)); + const ds = localDate.toISOString().split('T')[0]; const val = map.get(ds) ?? -1; if (val === 0) streak++; else if (val > 0) break; @@ -507,23 +526,35 @@ export function checkBadgeEligibility( const streak = getStreakFromMap(substance === 'nicotine' ? stats.nicotineMap : stats.weedMap); const checkMonthlyReduction = (): boolean => { + const checkDate = new Date(); + // Use local dates to avoid UTC offset issues + const offset = checkDate.getTimezoneOffset(); + const todayLocal = new Date(checkDate.getTime() - (offset * 60 * 1000)); + if (!preferences.trackingStartDate) return false; - const start = new Date(preferences.trackingStartDate); - const today = new Date(); - const daysSinceStart = Math.floor((today.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); + + // Parse start date as local + const [y, m, d] = preferences.trackingStartDate.split('-').map(Number); + const startLocal = new Date(y, m - 1, d); // Month is 0-indexed in Date constructor + + const daysSinceStart = Math.floor((todayLocal.getTime() - startLocal.getTime()) / (1000 * 60 * 60 * 24)); if (daysSinceStart < 30) return false; // Use current Map for O(1) lookups in week buckets let firstWeekTotal = 0; let lastWeekTotal = 0; - const startTime = start.getTime(); - const todayTime = today.getTime(); + const startTime = startLocal.getTime(); + const todayTime = todayLocal.getTime(); const msInDay = 1000 * 60 * 60 * 24; for (const entry of usageData) { if (entry.substance !== substance) continue; - const entryTime = new Date(entry.date).getTime(); + + // Parse entry date as local + const [ey, em, ed] = entry.date.split('-').map(Number); + const entryTime = new Date(ey, em - 1, ed).getTime(); + const daysSinceEntryStart = Math.floor((entryTime - startTime) / msInDay); const daysAgo = Math.floor((todayTime - entryTime) / msInDay);