From 3cf2e805f23b39ac7a081d7dd22df1d0765868cf Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Wed, 28 Jan 2026 10:11:06 -0700 Subject: [PATCH] PWA optimizations, bug fixes, time adjustment, and reduced loading / typescript conversion --- cron-worker/src/{index.js => index.ts} | 8 +- cron-worker/wrangler.toml | 2 +- src/app/globals.css | 11 +- src/components/AchievementsCard.tsx | 20 ++- src/components/Dashboard.tsx | 66 +++++----- src/components/HealthTimelineCard.tsx | 68 +++++++--- src/components/MoodTracker.tsx | 5 +- src/components/QuitPlanCard.tsx | 13 +- src/components/SavingsTrackerCard.tsx | 13 +- src/components/StatsCard.tsx | 93 ++++++++------ src/components/UsageCalendar.tsx | 161 ++++++++++++++---------- src/components/UserHeader.tsx | 21 ++-- src/lib/storage.ts | 164 +++++++++++++++---------- 13 files changed, 397 insertions(+), 248 deletions(-) rename cron-worker/src/{index.js => index.ts} (79%) diff --git a/cron-worker/src/index.js b/cron-worker/src/index.ts similarity index 79% rename from cron-worker/src/index.js rename to cron-worker/src/index.ts index 89687ec..b60314b 100644 --- a/cron-worker/src/index.js +++ b/cron-worker/src/index.ts @@ -1,8 +1,12 @@ +export interface Env { + CRON_SECRET?: string; +} + export default { - async scheduled(event, env, ctx) { + async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) { console.log('Cron triggered: Pinging /api/cron/reminders'); - const headers = {}; + const headers: Record = {}; if (env.CRON_SECRET) { headers['Authorization'] = `Bearer ${env.CRON_SECRET}`; } diff --git a/cron-worker/wrangler.toml b/cron-worker/wrangler.toml index da1c5a3..0ed1c91 100644 --- a/cron-worker/wrangler.toml +++ b/cron-worker/wrangler.toml @@ -1,5 +1,5 @@ name = "quittraq-cron-trigger" -main = "src/index.js" +main = "src/index.ts" compatibility_date = "2024-09-23" # Run every minute diff --git a/src/app/globals.css b/src/app/globals.css index 73d8210..8cf0a09 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -626,8 +626,9 @@ background-repeat: repeat; background-size: 600px 600px; animation: fog-drift-1 60s linear infinite; - opacity: 0.8; - filter: blur(6px); + opacity: 0.6; + filter: blur(12px); + will-change: background-position; } .fog-layer-2 { @@ -635,8 +636,9 @@ background-repeat: repeat; background-size: 500px 500px; animation: fog-drift-2 45s linear infinite; - opacity: 0.6; - filter: blur(4px); + opacity: 0.4; + filter: blur(8px); + will-change: background-position; } /* Swipe ecosystem for mobile placards */ @@ -656,6 +658,7 @@ padding-left: 1.5rem; padding-right: 1.5rem; overscroll-behavior-x: contain; + will-change: transform, scroll-position; } .swipe-container::-webkit-scrollbar { diff --git a/src/components/AchievementsCard.tsx b/src/components/AchievementsCard.tsx index f4232a6..91dfee5 100644 --- a/src/components/AchievementsCard.tsx +++ b/src/components/AchievementsCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage'; import { useTheme } from '@/lib/theme-context'; @@ -28,7 +28,7 @@ const iconMap: Record = { Trophy, }; -export function AchievementsCard({ achievements, substance }: AchievementsCardProps) { +function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) { const { theme } = useTheme(); const [hoveredBadge, setHoveredBadge] = useState(null); @@ -76,11 +76,10 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr return (
setHoveredBadge(badge.id)} onMouseLeave={() => setHoveredBadge(null)} > @@ -103,18 +102,16 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr
)}

{badge.name}

@@ -132,3 +129,4 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr ); } +export const AchievementsCard = React.memo(AchievementsCardComponent); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index c9c07cc..713624e 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import React from 'react'; import { User } from '@/lib/session'; import { fetchPreferences, @@ -38,6 +39,7 @@ import { PlusCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; import { getTodayString } from '@/lib/date-utils'; + interface DashboardProps { user: User; } @@ -54,9 +56,16 @@ export function Dashboard({ user }: DashboardProps) { const [isLoading, setIsLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); const [currentPage, setCurrentPage] = useState(0); + const [modalOpenCount, setModalOpenCount] = useState(0); const swipeContainerRef = useRef(null); const { theme } = useTheme(); + const isAnyModalOpen = modalOpenCount > 0 || showUsagePrompt || showSetup || showCelebration; + + const handleModalStateChange = useCallback((isOpen: boolean) => { + setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1)); + }, []); + const handleScroll = useCallback(() => { if (!swipeContainerRef.current) return; const scrollLeft = swipeContainerRef.current.scrollLeft; @@ -238,13 +247,17 @@ export function Dashboard({ user }: DashboardProps) { return (
- +
{preferences && ( <> {/* Floating Log Button */} -
+
)} - {currentPage < 3 && ( + {currentPage < 3 && !isAnyModalOpen && (
@@ -360,13 +374,13 @@ export function Dashboard({ user }: DashboardProps) { onDataUpdate={loadData} userId={user.id} religion={preferences.religion} - onReligionUpdate={async (religion) => { + onReligionUpdate={async (religion: 'christian' | 'secular') => { const updatedPrefs = { ...preferences, religion }; setPreferences(updatedPrefs); await savePreferencesAsync(updatedPrefs); }} preferences={preferences} - onPreferencesUpdate={async (updatedPrefs) => { + onPreferencesUpdate={async (updatedPrefs: UserPreferences) => { await savePreferencesAsync(updatedPrefs); setPreferences(updatedPrefs); }} @@ -377,29 +391,23 @@ export function Dashboard({ user }: DashboardProps) {
)} - + - { - preferences && ( - setShowUsagePrompt(false)} - onSubmit={handleUsageSubmit} - userId={user.id} - /> - ) - } + setShowUsagePrompt(false)} + onSubmit={handleUsageSubmit} + userId={user.id} + /> - { - showCelebration && newBadge && ( - - ) - } - + {showCelebration && newBadge && ( + + )} + ); } diff --git a/src/components/HealthTimelineCard.tsx b/src/components/HealthTimelineCard.tsx index 3146ae7..e0d9647 100644 --- a/src/components/HealthTimelineCard.tsx +++ b/src/components/HealthTimelineCard.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import React from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { HEALTH_MILESTONES, UsageEntry, UserPreferences } from '@/lib/storage'; import { useTheme } from '@/lib/theme-context'; @@ -184,15 +185,15 @@ function TimelineColumn({ substance, minutesFree, theme }: TimelineColumnProps)
{/* Icon */}
{isAchieved ? : } @@ -215,28 +216,62 @@ function TimelineColumn({ substance, minutesFree, theme }: TimelineColumnProps) ); } -export function HealthTimelineCard({ usageData, preferences }: HealthTimelineCardProps) { +function HealthTimelineCardComponent({ + usageData, + preferences, +}: HealthTimelineCardProps) { const { theme } = useTheme(); + // Calculate last usage timestamps only when data changes + const lastUsageTimes = useMemo(() => { + const getTimestamp = (substance: 'nicotine' | 'weed') => { + // 1. Check for stored timestamp first + const stored = substance === 'nicotine' ? preferences?.lastNicotineUsageTime : preferences?.lastWeedUsageTime; + if (stored) return new Date(stored).getTime(); + + // 2. Fallback to usage data + const lastEntry = usageData + .filter(e => e.substance === substance && e.count > 0) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0]; + + if (lastEntry) { + const d = new Date(lastEntry.date); + d.setHours(23, 59, 59, 999); + return d.getTime(); + } + + // 3. Fallback to start date + if (preferences?.trackingStartDate) { + const d = new Date(preferences.trackingStartDate); + d.setHours(0, 0, 0, 0); + return d.getTime(); + } + + return null; + }; + + return { + nicotine: getTimestamp('nicotine'), + weed: getTimestamp('weed') + }; + }, [usageData, preferences]); + // State for live timer values const [nicotineMinutes, setNicotineMinutes] = useState(0); const [weedMinutes, setWeedMinutes] = useState(0); - // Function to recalculate both timers + // Update timers using O(1) math from memoized timestamps const updateTimers = useCallback(() => { - const prefs = preferences || null; - setNicotineMinutes(calculateMinutesFree('nicotine', usageData, prefs)); - setWeedMinutes(calculateMinutesFree('weed', usageData, prefs)); - }, [usageData, preferences]); + const now = Date.now(); + const msInMin = 1000 * 60; + + setNicotineMinutes(lastUsageTimes.nicotine ? Math.max(0, (now - lastUsageTimes.nicotine) / msInMin) : 0); + setWeedMinutes(lastUsageTimes.weed ? Math.max(0, (now - lastUsageTimes.weed) / msInMin) : 0); + }, [lastUsageTimes]); - // Initial calculation and start interval useEffect(() => { - // Calculate immediately updateTimers(); - - // Update every second const interval = setInterval(updateTimers, 1000); - return () => clearInterval(interval); }, [updateTimers]); @@ -271,3 +306,4 @@ export function HealthTimelineCard({ usageData, preferences }: HealthTimelineCar ); } +export const HealthTimelineCard = React.memo(HealthTimelineCardComponent); diff --git a/src/components/MoodTracker.tsx b/src/components/MoodTracker.tsx index fb32eba..3a217f0 100644 --- a/src/components/MoodTracker.tsx +++ b/src/components/MoodTracker.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Smile, Meh, Frown, TrendingUp, ChevronLeft, ChevronRight, MessageSquare, Quote, Sparkles } from 'lucide-react'; @@ -11,7 +11,7 @@ import { cn } from '@/lib/utils'; import { useTheme } from '@/lib/theme-context'; -export function MoodTracker() { +function MoodTrackerComponent() { const { theme } = useTheme(); const [entries, setEntries] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -366,3 +366,4 @@ export function MoodTracker() { ); } +export const MoodTracker = React.memo(MoodTrackerComponent); diff --git a/src/components/QuitPlanCard.tsx b/src/components/QuitPlanCard.tsx index a7e5e5a..2a1008b 100644 --- a/src/components/QuitPlanCard.tsx +++ b/src/components/QuitPlanCard.tsx @@ -1,5 +1,6 @@ 'use client'; +import React from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { QuitPlan, UsageEntry } from '@/lib/storage'; @@ -12,7 +13,7 @@ interface QuitPlanCardProps { usageData: UsageEntry[]; } -export function QuitPlanCard({ +function QuitPlanCardComponent({ plan, onGeneratePlan, usageData, @@ -135,13 +136,12 @@ export function QuitPlanCard({ {plan.weeklyTargets.map((target, index) => (

Week {index + 1}

{target}

@@ -162,3 +162,4 @@ export function QuitPlanCard({ ); } +export const QuitPlanCard = React.memo(QuitPlanCardComponent); diff --git a/src/components/SavingsTrackerCard.tsx b/src/components/SavingsTrackerCard.tsx index 83856d2..c10bf52 100644 --- a/src/components/SavingsTrackerCard.tsx +++ b/src/components/SavingsTrackerCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { @@ -17,17 +17,25 @@ interface SavingsTrackerCardProps { usageData: UsageEntry[]; trackingStartDate: string | null; onSavingsConfigChange: (config: SavingsConfig) => void; + onModalStateChange?: (isOpen: boolean) => void; } -export function SavingsTrackerCard({ +function SavingsTrackerCardComponent({ savingsConfig, usageData, trackingStartDate, onSavingsConfigChange, + onModalStateChange, }: SavingsTrackerCardProps) { const { theme } = useTheme(); const [showSetup, setShowSetup] = useState(false); + useEffect(() => { + if (onModalStateChange) { + onModalStateChange(showSetup); + } + }, [showSetup, onModalStateChange]); + const totalSaved = useMemo(() => { return calculateTotalSaved(savingsConfig, usageData, trackingStartDate); }, [savingsConfig, usageData, trackingStartDate]); @@ -202,3 +210,4 @@ export function SavingsTrackerCard({ ); } +export const SavingsTrackerCard = React.memo(SavingsTrackerCardComponent); diff --git a/src/components/StatsCard.tsx b/src/components/StatsCard.tsx index d904734..faea2ac 100644 --- a/src/components/StatsCard.tsx +++ b/src/components/StatsCard.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useState, useMemo } from 'react'; +import React from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { UsageEntry } from '@/lib/storage'; import { Cigarette, Leaf } from 'lucide-react'; @@ -11,46 +13,64 @@ interface StatsCardProps { substance: 'nicotine' | 'weed'; } -export function StatsCard({ usageData, substance }: StatsCardProps) { +function StatsCardComponent({ usageData, substance }: StatsCardProps) { const { theme } = useTheme(); - const substanceData = usageData.filter((e) => e.substance === substance); + // Calculate stats with useMemo for performance + const stats = useMemo(() => { + // 1. Build lookup map for O(1) day access + const substanceMap = new Map(); + let totalUsage = 0; - // Calculate stats - const today = new Date(); - const todayStr = getTodayString(); - const todayUsage = substanceData.find((e) => e.date === todayStr)?.count ?? 0; - - // Last 7 days - const last7Days = substanceData.filter((e) => { - const entryDate = new Date(e.date); - const diff = (today.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24); - return diff <= 7 && diff >= 0; - }); - const weekTotal = last7Days.reduce((sum, e) => sum + e.count, 0); - const weekAverage = last7Days.length > 0 ? Math.round(weekTotal / last7Days.length) : 0; - - // Streak (days with 0 usage) - let streak = 0; - const sortedDates = substanceData - .map((e) => e.date) - .sort((a, b) => new Date(b).getTime() - new Date(a).getTime()); - - for (let i = 0; i <= 30; i++) { - const checkDate = new Date(today); - checkDate.setDate(checkDate.getDate() - i); - const dateStr = getLocalDateString(checkDate); - const dayUsage = substanceData.find((e) => e.date === dateStr)?.count ?? -1; - - if (dayUsage === 0) { - streak++; - } else if (dayUsage > 0) { - break; + for (const e of usageData) { + if (e.substance === substance) { + substanceMap.set(e.date, e.count); + totalUsage += e.count; + } } - } - // Total tracked - const totalUsage = substanceData.reduce((sum, e) => sum + e.count, 0); - const totalDays = substanceData.length; + const today = new Date(); + const todayStr = getTodayString(); + const todayUsage = substanceMap.get(todayStr) ?? 0; + + // 2. Last 7 days stats + let weekTotal = 0; + let daysWithEntries = 0; + for (let i = 0; i < 7; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const ds = getLocalDateString(d); + const val = substanceMap.get(ds); + if (val !== undefined) { + weekTotal += val; + daysWithEntries++; + } + } + const weekAverage = daysWithEntries > 0 ? Math.round(weekTotal / 7) : 0; + + // 3. Streak calculation O(366) + let streak = 0; + for (let i = 0; i <= 365; i++) { + const checkDate = new Date(today); + checkDate.setDate(checkDate.getDate() - i); + const dateStr = getLocalDateString(checkDate); + const dayUsage = substanceMap.get(dateStr) ?? -1; + + if (dayUsage === 0) { + streak++; + } else if (dayUsage > 0) { + break; + } + } + + return { + todayUsage, + weekAverage, + streak, + totalDays: substanceMap.size + }; + }, [usageData, substance]); + + const { todayUsage, weekAverage, streak, totalDays } = stats; const SubstanceIcon = substance === 'nicotine' ? Cigarette : Leaf; const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana'; @@ -109,3 +129,4 @@ export function StatsCard({ usageData, substance }: StatsCardProps) { ); } +export const StatsCard = React.memo(StatsCardComponent); diff --git a/src/components/UsageCalendar.tsx b/src/components/UsageCalendar.tsx index 140a65f..bb60867 100644 --- a/src/components/UsageCalendar.tsx +++ b/src/components/UsageCalendar.tsx @@ -18,7 +18,7 @@ import { useTheme } from '@/lib/theme-context'; import { getLocalDateString, getTodayString } from '@/lib/date-utils'; import { DailyInspirationCard } from './DailyInspirationCard'; import { cn } from '@/lib/utils'; - +import React from 'react'; interface UsageCalendarProps { @@ -31,17 +31,32 @@ interface UsageCalendarProps { onPreferencesUpdate?: (prefs: UserPreferences) => Promise; } -export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) { +function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) { const [selectedDate, setSelectedDate] = useState(undefined); const [editNicotineCount, setEditNicotineCount] = useState(''); const [editWeedCount, setEditWeedCount] = useState(''); const [isEditing, setIsEditing] = useState(false); const { theme } = useTheme(); - const getUsageForDate = (date: Date, substance: 'nicotine' | 'weed'): number => { + // Pre-index usage data for O(1) lookups during calendar rendering + const indexedUsage = useMemo(() => { + const map = new Map(); + for (const entry of usageData) { + const current = map.get(entry.date) || { nicotine: 0, weed: 0 }; + if (entry.substance === 'nicotine') { + current.nicotine = entry.count; + } else if (entry.substance === 'weed') { + current.weed = entry.count; + } + map.set(entry.date, current); + } + return map; + }, [usageData]); + + const getUsageFromMap = (date: Date, substance: 'nicotine' | 'weed'): number => { const dateStr = getLocalDateString(date); - const entry = usageData.find((e) => e.date === dateStr && e.substance === substance); - return entry?.count ?? 0; + const counts = indexedUsage.get(dateStr); + return counts?.[substance] ?? 0; }; const handleDateSelect = (date: Date | undefined) => { @@ -53,8 +68,8 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd if (date > today) return; setSelectedDate(date); - const nicotineCount = getUsageForDate(date, 'nicotine'); - const weedCount = getUsageForDate(date, 'weed'); + const nicotineCount = getUsageFromMap(date, 'nicotine'); + const weedCount = getUsageFromMap(date, 'weed'); setEditNicotineCount(nicotineCount.toString()); setEditWeedCount(weedCount.toString()); setIsEditing(true); @@ -177,8 +192,8 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd const isFuture = dateToCheck > today; const isToday = dateToCheck.getTime() === today.getTime(); - const nicotineCount = isFuture ? 0 : getUsageForDate(date, 'nicotine'); - const weedCount = isFuture ? 0 : getUsageForDate(date, 'weed'); + const nicotineCount = isFuture ? 0 : getUsageFromMap(date, 'nicotine'); + const weedCount = isFuture ? 0 : getUsageFromMap(date, 'weed'); const colorStyle = !isFuture ? getColorStyle(nicotineCount, weedCount, isToday) : {}; return ( @@ -219,72 +234,85 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd <> - {/* Legend - moved to top */} -
-
-
- No usage + {/* Legend - Re-styled for better balance */} +
+
+
+ No usage
-
-
- Today +
+
+ Today
-
-
- Nicotine +
+
+ Nicotine
-
-
- Marijuana +
+
+ Marijuana
-
-
- Both +
+
+ Both
-
- {/* Calendar */} -
- ( - - ), - Chevron: ({ orientation }) => ( -
- {orientation === 'left' ? ( - - ) : ( - - )} -
- ), - }} - /> +
+ {/* Calendar - Focused Container */} +
+
+ ( + + ), + Chevron: ({ orientation }) => ( +
+ {orientation === 'left' ? ( + + ) : ( + + )} +
+ ), + }} + /> +
- {/* Daily Inspiration */} - + {/* Desktop Vertical Divider */} +
+ + {/* Daily Inspiration - Centered vertically on desktop */} +
+ +
@@ -355,3 +383,4 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd ); } +export const UsageCalendar = React.memo(UsageCalendarComponent); diff --git a/src/components/UserHeader.tsx b/src/components/UserHeader.tsx index eb4913d..20997e7 100644 --- a/src/components/UserHeader.tsx +++ b/src/components/UserHeader.tsx @@ -38,6 +38,7 @@ import { SideMenu } from './SideMenu'; interface UserHeaderProps { user: User; preferences?: UserPreferences | null; + onModalStateChange?: (isOpen: boolean) => void; } interface HourlyTimePickerProps { @@ -122,7 +123,7 @@ function HourlyTimePicker({ value, onChange }: HourlyTimePickerProps) { ); } -export function UserHeader({ user, preferences }: UserHeaderProps) { +export function UserHeader({ user, preferences, onModalStateChange }: UserHeaderProps) { const [userName, setUserName] = useState(null); const [reminderSettings, setReminderSettings] = useState({ enabled: false, reminderTime: '09:00', frequency: 'daily' }); const [showReminderDialog, setShowReminderDialog] = useState(false); @@ -133,6 +134,12 @@ export function UserHeader({ user, preferences }: UserHeaderProps) { const { theme, toggleTheme } = useTheme(); const { isSupported, permission, requestPermission } = useNotifications(reminderSettings); + useEffect(() => { + if (onModalStateChange) { + onModalStateChange(showReminderDialog || isSideMenuOpen); + } + }, [showReminderDialog, isSideMenuOpen, onModalStateChange]); + // Helper to parse time string const [parsedHours, parsedMinutes] = reminderSettings.reminderTime.split(':').map(Number); const currentAmpm = parsedHours >= 12 ? 'PM' : 'AM'; @@ -220,16 +227,16 @@ export function UserHeader({ user, preferences }: UserHeaderProps) { WebkitBackdropFilter: 'blur(20px)', borderBottom: theme === 'light' ? '1px solid rgba(0,0,0,0.08)' : '1px solid rgba(255,255,255,0.08)' }}> - {/* Cloudy/Foggy effect overlay with ultra-soft feathered edges for a blurred look */} + {/* Cloudy/Foggy effect overlay with ultra-wide, organic feathering */}
-
-
+
+
diff --git a/src/lib/storage.ts b/src/lib/storage.ts index a6ac60a..84b7255 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -394,22 +394,33 @@ export function calculateStreak( usageData: UsageEntry[], substance: 'nicotine' | 'weed' ): number { + if (usageData.length === 0) return 0; + + // O(n) to build lookup Map + const substanceMap = new Map(); + for (const entry of usageData) { + if (entry.substance === substance) { + substanceMap.set(entry.date, entry.count); + } + } + let streak = 0; const today = new Date(); - const substanceData = usageData.filter((e) => e.substance === substance); + // O(365) constant time relative to data size 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; + + // O(1) lookup + const dayUsage = substanceMap.get(dateStr) ?? -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; } @@ -423,101 +434,122 @@ export function calculateTotalSaved( const start = new Date(startDate); const today = new Date(); - const daysSinceStart = Math.floor( - (today.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) - ); + const diffTime = today.getTime() - start.getTime(); + const daysSinceStart = Math.floor(diffTime / (1000 * 60 * 60 * 24)); if (daysSinceStart <= 0) return 0; - // Expected spending if they continued at baseline (unitsPerDay is now unitsPerWeek) const weeksSinceStart = daysSinceStart / 7; const expectedSpend = weeksSinceStart * 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 + // Single pass O(n) calculation + let actualUnits = 0; + const substance = savingsConfig.substance; + const startTime = start.getTime(); + + for (const entry of usageData) { + if (entry.substance === substance) { + const entryTime = new Date(entry.date).getTime(); + if (entryTime >= startTime) { + actualUnits += entry.count; + } + } + } + + const unitsPerPack = 20; const actualSpend = (actualUnits / unitsPerPack) * savingsConfig.costPerUnit; return Math.max(0, expectedSpend - actualSpend); } - - export function checkBadgeEligibility( badgeId: string, usageData: UsageEntry[], preferences: UserPreferences, 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; + // Pre-calculate common stats once O(n) + const stats = (() => { + const nicotineMap = new Map(); + const weedMap = new Map(); + const trackedDays = new Set(); + + for (const entry of usageData) { + if (entry.substance === 'nicotine') { + nicotineMap.set(entry.date, entry.count); + } else { + weedMap.set(entry.date, entry.count); + } + if (entry.substance === substance) { + trackedDays.add(entry.date); + } + } + + return { nicotineMap, weedMap, totalDays: trackedDays.size }; + })(); + + const getStreakFromMap = (map: Map) => { + let streak = 0; + const today = new Date(); + for (let i = 0; i <= 365; i++) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const ds = d.toISOString().split('T')[0]; + const val = map.get(ds) ?? -1; + if (val === 0) streak++; + else if (val > 0) break; + } + return streak; + }; + + const streak = getStreakFromMap(substance === 'nicotine' ? stats.nicotineMap : stats.weedMap); - // 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 start = new Date(preferences.trackingStartDate); const today = new Date(); - const daysSinceStart = Math.floor( - (today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) - ); + const daysSinceStart = Math.floor((today.getTime() - start.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; + // Use current Map for O(1) lookups in week buckets + let firstWeekTotal = 0; + let lastWeekTotal = 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 startTime = start.getTime(); + const todayTime = today.getTime(); + const msInDay = 1000 * 60 * 60 * 24; + + for (const entry of usageData) { + if (entry.substance !== substance) continue; + const entryTime = new Date(entry.date).getTime(); + const daysSinceEntryStart = Math.floor((entryTime - startTime) / msInDay); + const daysAgo = Math.floor((todayTime - entryTime) / msInDay); + + if (daysSinceEntryStart >= 0 && daysSinceEntryStart < 7) { + firstWeekTotal += entry.count; + } + if (daysAgo >= 0 && daysAgo < 7) { + lastWeekTotal += entry.count; + } + } + + const firstWeekAvg = firstWeekTotal / 7; const lastWeekAvg = lastWeekTotal / 7; - // Check if reduced by at least 50% - if (firstWeekAvg <= 0) return lastWeekAvg === 0; - return lastWeekAvg <= firstWeekAvg * 0.5; + return firstWeekAvg <= 0 ? lastWeekAvg === 0 : 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': - // Track usage for one week (7 days of entries) - return totalDays >= 7; + case 'first_day': return stats.totalDays >= 1; + case 'streak_3': return streak >= 3; + case 'streak_7': return stats.totalDays >= 7; case 'fighter': - // 7 days off ANY substance (both nicotine AND weed) - return nicotineStreak >= 7 && weedStreak >= 7; - case 'one_month': - // Track one month and reduce usage by 50% - return checkMonthlyReduction(); + return getStreakFromMap(stats.nicotineMap) >= 7 && getStreakFromMap(stats.weedMap) >= 7; + case 'one_month': return checkMonthlyReduction(); case 'goal_crusher': - // One month substance free (both substances) - return nicotineStreak >= 30 && weedStreak >= 30; - default: - return false; + return getStreakFromMap(stats.nicotineMap) >= 30 && getStreakFromMap(stats.weedMap) >= 30; + default: return false; } }