PWA optimizations, bug fixes, time adjustment, and reduced loading / typescript conversion

This commit is contained in:
Avery Felts 2026-01-28 10:11:06 -07:00
parent 4687958125
commit 3cf2e805f2
13 changed files with 397 additions and 248 deletions

View File

@ -1,8 +1,12 @@
export interface Env {
CRON_SECRET?: string;
}
export default { export default {
async scheduled(event, env, ctx) { async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
console.log('Cron triggered: Pinging /api/cron/reminders'); console.log('Cron triggered: Pinging /api/cron/reminders');
const headers = {}; const headers: Record<string, string> = {};
if (env.CRON_SECRET) { if (env.CRON_SECRET) {
headers['Authorization'] = `Bearer ${env.CRON_SECRET}`; headers['Authorization'] = `Bearer ${env.CRON_SECRET}`;
} }

View File

@ -1,5 +1,5 @@
name = "quittraq-cron-trigger" name = "quittraq-cron-trigger"
main = "src/index.js" main = "src/index.ts"
compatibility_date = "2024-09-23" compatibility_date = "2024-09-23"
# Run every minute # Run every minute

View File

@ -626,8 +626,9 @@
background-repeat: repeat; background-repeat: repeat;
background-size: 600px 600px; background-size: 600px 600px;
animation: fog-drift-1 60s linear infinite; animation: fog-drift-1 60s linear infinite;
opacity: 0.8; opacity: 0.6;
filter: blur(6px); filter: blur(12px);
will-change: background-position;
} }
.fog-layer-2 { .fog-layer-2 {
@ -635,8 +636,9 @@
background-repeat: repeat; background-repeat: repeat;
background-size: 500px 500px; background-size: 500px 500px;
animation: fog-drift-2 45s linear infinite; animation: fog-drift-2 45s linear infinite;
opacity: 0.6; opacity: 0.4;
filter: blur(4px); filter: blur(8px);
will-change: background-position;
} }
/* Swipe ecosystem for mobile placards */ /* Swipe ecosystem for mobile placards */
@ -656,6 +658,7 @@
padding-left: 1.5rem; padding-left: 1.5rem;
padding-right: 1.5rem; padding-right: 1.5rem;
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
will-change: transform, scroll-position;
} }
.swipe-container::-webkit-scrollbar { .swipe-container::-webkit-scrollbar {

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage'; import { Achievement, BADGE_DEFINITIONS, BadgeDefinition } from '@/lib/storage';
import { useTheme } from '@/lib/theme-context'; import { useTheme } from '@/lib/theme-context';
@ -28,7 +28,7 @@ const iconMap: Record<string, React.ElementType> = {
Trophy, Trophy,
}; };
export function AchievementsCard({ achievements, substance }: AchievementsCardProps) { function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const [hoveredBadge, setHoveredBadge] = useState<string | null>(null); const [hoveredBadge, setHoveredBadge] = useState<string | null>(null);
@ -76,11 +76,10 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr
return ( return (
<div <div
key={badge.id} key={badge.id}
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${ className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${isUnlocked
isUnlocked
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50 hover:scale-105' ? '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 hover:bg-white/10 hover:border-white/20' : 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20'
}`} }`}
onMouseEnter={() => setHoveredBadge(badge.id)} onMouseEnter={() => setHoveredBadge(badge.id)}
onMouseLeave={() => setHoveredBadge(null)} onMouseLeave={() => setHoveredBadge(null)}
> >
@ -103,18 +102,16 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr
</div> </div>
)} )}
<div <div
className={`mx-auto mb-1 p-2 rounded-full w-fit ${ className={`mx-auto mb-1 p-2 rounded-full w-fit ${isUnlocked
isUnlocked
? 'bg-yellow-500/30 text-yellow-300' ? 'bg-yellow-500/30 text-yellow-300'
: 'bg-white/10 text-white/30' : 'bg-white/10 text-white/30'
}`} }`}
> >
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</div> </div>
<p <p
className={`text-xs font-medium ${ className={`text-xs font-medium ${isUnlocked ? 'text-white' : 'text-white/40'
isUnlocked ? 'text-white' : 'text-white/40' }`}
}`}
> >
{badge.name} {badge.name}
</p> </p>
@ -132,3 +129,4 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr
</Card> </Card>
); );
} }
export const AchievementsCard = React.memo(AchievementsCardComponent);

View File

@ -1,6 +1,7 @@
'use client'; '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 { User } from '@/lib/session';
import { import {
fetchPreferences, fetchPreferences,
@ -38,6 +39,7 @@ import { PlusCircle, ChevronLeft, ChevronRight } from 'lucide-react';
import { useTheme } from '@/lib/theme-context'; import { useTheme } from '@/lib/theme-context';
import { getTodayString } from '@/lib/date-utils'; import { getTodayString } from '@/lib/date-utils';
interface DashboardProps { interface DashboardProps {
user: User; user: User;
} }
@ -54,9 +56,16 @@ export function Dashboard({ user }: DashboardProps) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [modalOpenCount, setModalOpenCount] = useState(0);
const swipeContainerRef = useRef<HTMLDivElement>(null); const swipeContainerRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme(); 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(() => { const handleScroll = useCallback(() => {
if (!swipeContainerRef.current) return; if (!swipeContainerRef.current) return;
const scrollLeft = swipeContainerRef.current.scrollLeft; const scrollLeft = swipeContainerRef.current.scrollLeft;
@ -238,13 +247,17 @@ export function Dashboard({ user }: DashboardProps) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<UserHeader user={user} preferences={preferences} /> <UserHeader
user={user}
preferences={preferences}
onModalStateChange={handleModalStateChange}
/>
<main className="container mx-auto px-4 py-4 sm:py-8 pb-4 sm:pb-8 max-w-full"> <main className="container mx-auto px-4 py-4 sm:py-8 pb-4 sm:pb-8 max-w-full">
{preferences && ( {preferences && (
<> <>
{/* Floating Log Button */} {/* Floating Log Button */}
<div className="fixed bottom-6 right-6 z-50 opacity-0 animate-scale-in delay-500 sm:block"> <div className={`fixed bottom-6 right-6 z-40 transition-all duration-300 ${isAnyModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
<Button <Button
size="lg" size="lg"
onClick={() => setShowUsagePrompt(true)} onClick={() => setShowUsagePrompt(true)}
@ -259,10 +272,10 @@ export function Dashboard({ user }: DashboardProps) {
<div className="space-y-6 sm:space-y-12 relative overflow-hidden"> <div className="space-y-6 sm:space-y-12 relative overflow-hidden">
{/* Mobile Navigation Buttons - LARGE */} {/* Mobile Navigation Buttons - LARGE */}
<div className="sm:hidden"> <div className="sm:hidden">
{currentPage > 0 && ( {currentPage > 0 && !isAnyModalOpen && (
<button <button
onClick={() => scrollToPage(currentPage - 1)} onClick={() => scrollToPage(currentPage - 1)}
className="fixed left-3 top-[55%] -translate-y-1/2 z-[60] p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group" className="fixed left-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
style={{ style={{
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)', background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(16px)', backdropFilter: 'blur(16px)',
@ -272,10 +285,10 @@ export function Dashboard({ user }: DashboardProps) {
<ChevronLeft className="h-8 w-8 text-primary group-hover:scale-110" /> <ChevronLeft className="h-8 w-8 text-primary group-hover:scale-110" />
</button> </button>
)} )}
{currentPage < 3 && ( {currentPage < 3 && !isAnyModalOpen && (
<button <button
onClick={() => scrollToPage(currentPage + 1)} onClick={() => scrollToPage(currentPage + 1)}
className="fixed right-3 top-[55%] -translate-y-1/2 z-[60] p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group" className="fixed right-3 top-[55%] -translate-y-1/2 z-40 p-5 rounded-full glass border border-white/20 shadow-2xl active:scale-90 transition-all duration-300 group"
style={{ style={{
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)', background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(16px)', backdropFilter: 'blur(16px)',
@ -345,6 +358,7 @@ export function Dashboard({ user }: DashboardProps) {
usageData={usageData} usageData={usageData}
trackingStartDate={preferences.trackingStartDate} trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange} onSavingsConfigChange={handleSavingsConfigChange}
onModalStateChange={handleModalStateChange}
/> />
</div> </div>
</div> </div>
@ -360,13 +374,13 @@ export function Dashboard({ user }: DashboardProps) {
onDataUpdate={loadData} onDataUpdate={loadData}
userId={user.id} userId={user.id}
religion={preferences.religion} religion={preferences.religion}
onReligionUpdate={async (religion) => { onReligionUpdate={async (religion: 'christian' | 'secular') => {
const updatedPrefs = { ...preferences, religion }; const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs); setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs); await savePreferencesAsync(updatedPrefs);
}} }}
preferences={preferences} preferences={preferences}
onPreferencesUpdate={async (updatedPrefs) => { onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs); await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs); setPreferences(updatedPrefs);
}} }}
@ -377,29 +391,23 @@ export function Dashboard({ user }: DashboardProps) {
</div> </div>
</> </>
)} )}
</main > </main>
<SetupWizard open={showSetup} onComplete={handleSetupComplete} /> <SetupWizard open={showSetup} onComplete={handleSetupComplete} />
{ <UsagePromptDialog
preferences && ( open={showUsagePrompt}
<UsagePromptDialog onClose={() => setShowUsagePrompt(false)}
open={showUsagePrompt} onSubmit={handleUsageSubmit}
onClose={() => setShowUsagePrompt(false)} userId={user.id}
onSubmit={handleUsageSubmit} />
userId={user.id}
/>
)
}
{ {showCelebration && newBadge && (
showCelebration && newBadge && ( <CelebrationAnimation
<CelebrationAnimation badge={newBadge}
badge={newBadge} onComplete={handleCelebrationComplete}
onComplete={handleCelebrationComplete} />
/> )}
) </div>
}
</div >
); );
} }

View File

@ -1,6 +1,7 @@
'use client'; '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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { HEALTH_MILESTONES, UsageEntry, UserPreferences } from '@/lib/storage'; import { HEALTH_MILESTONES, UsageEntry, UserPreferences } from '@/lib/storage';
import { useTheme } from '@/lib/theme-context'; import { useTheme } from '@/lib/theme-context';
@ -184,15 +185,15 @@ function TimelineColumn({ substance, minutesFree, theme }: TimelineColumnProps)
<div <div
key={milestone.id} key={milestone.id}
className={`flex items-start gap-2.5 p-2 rounded-lg transition-all ${isAchieved className={`flex items-start gap-2.5 p-2 rounded-lg transition-all ${isAchieved
? (theme === 'light' ? 'bg-slate-100/50' : 'bg-white/5') ? (theme === 'light' ? 'bg-slate-100/50' : 'bg-white/5')
: 'opacity-50 grayscale' : 'opacity-50 grayscale'
} ${isCurrent ? 'ring-1 ring-offset-1 ring-offset-transparent ' + (substance === 'nicotine' ? 'ring-red-500/50' : 'ring-green-500/50') : ''}`} } ${isCurrent ? 'ring-1 ring-offset-1 ring-offset-transparent ' + (substance === 'nicotine' ? 'ring-red-500/50' : 'ring-green-500/50') : ''}`}
> >
{/* Icon */} {/* Icon */}
<div <div
className={`p-1.5 rounded-full shrink-0 mt-0.5 ${isAchieved className={`p-1.5 rounded-full shrink-0 mt-0.5 ${isAchieved
? (theme === 'light' ? 'bg-white text-slate-700 shadow-sm' : 'bg-white/10 text-white') ? (theme === 'light' ? 'bg-white text-slate-700 shadow-sm' : 'bg-white/10 text-white')
: 'bg-black/5 text-black/30 dark:bg-white/5 dark:text-white/30' : 'bg-black/5 text-black/30 dark:bg-white/5 dark:text-white/30'
}`} }`}
> >
{isAchieved ? <CheckCircle2 className="h-3 w-3" /> : <Icon className="h-3 w-3" />} {isAchieved ? <CheckCircle2 className="h-3 w-3" /> : <Icon className="h-3 w-3" />}
@ -215,28 +216,62 @@ function TimelineColumn({ substance, minutesFree, theme }: TimelineColumnProps)
); );
} }
export function HealthTimelineCard({ usageData, preferences }: HealthTimelineCardProps) { function HealthTimelineCardComponent({
usageData,
preferences,
}: HealthTimelineCardProps) {
const { theme } = useTheme(); 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 // State for live timer values
const [nicotineMinutes, setNicotineMinutes] = useState(0); const [nicotineMinutes, setNicotineMinutes] = useState(0);
const [weedMinutes, setWeedMinutes] = 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 updateTimers = useCallback(() => {
const prefs = preferences || null; const now = Date.now();
setNicotineMinutes(calculateMinutesFree('nicotine', usageData, prefs)); const msInMin = 1000 * 60;
setWeedMinutes(calculateMinutesFree('weed', usageData, prefs));
}, [usageData, preferences]); 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(() => { useEffect(() => {
// Calculate immediately
updateTimers(); updateTimers();
// Update every second
const interval = setInterval(updateTimers, 1000); const interval = setInterval(updateTimers, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [updateTimers]); }, [updateTimers]);
@ -271,3 +306,4 @@ export function HealthTimelineCard({ usageData, preferences }: HealthTimelineCar
</Card> </Card>
); );
} }
export const HealthTimelineCard = React.memo(HealthTimelineCardComponent);

View File

@ -1,6 +1,6 @@
'use client'; '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 { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Smile, Meh, Frown, TrendingUp, ChevronLeft, ChevronRight, MessageSquare, Quote, Sparkles } from 'lucide-react'; 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'; import { useTheme } from '@/lib/theme-context';
export function MoodTracker() { function MoodTrackerComponent() {
const { theme } = useTheme(); const { theme } = useTheme();
const [entries, setEntries] = useState<MoodEntry[]>([]); const [entries, setEntries] = useState<MoodEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -366,3 +366,4 @@ export function MoodTracker() {
</Card> </Card>
); );
} }
export const MoodTracker = React.memo(MoodTrackerComponent);

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import React from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { QuitPlan, UsageEntry } from '@/lib/storage'; import { QuitPlan, UsageEntry } from '@/lib/storage';
@ -12,7 +13,7 @@ interface QuitPlanCardProps {
usageData: UsageEntry[]; usageData: UsageEntry[];
} }
export function QuitPlanCard({ function QuitPlanCardComponent({
plan, plan,
onGeneratePlan, onGeneratePlan,
usageData, usageData,
@ -135,13 +136,12 @@ export function QuitPlanCard({
{plan.weeklyTargets.map((target, index) => ( {plan.weeklyTargets.map((target, index) => (
<div <div
key={index} key={index}
className={`text-center p-2 rounded-lg transition-all duration-200 hover:scale-105 ${ className={`text-center p-2 rounded-lg transition-all duration-200 hover:scale-105 ${index + 1 === weekNumber
index + 1 === weekNumber
? 'bg-gradient-to-br from-pink-500 to-pink-600 text-white shadow-lg shadow-pink-500/30' ? 'bg-gradient-to-br from-pink-500 to-pink-600 text-white shadow-lg shadow-pink-500/30'
: index + 1 < weekNumber : index + 1 < weekNumber
? 'bg-pink-900/50 text-pink-200' ? 'bg-pink-900/50 text-pink-200'
: 'bg-white/10 text-white/60' : 'bg-white/10 text-white/60'
}`} }`}
> >
<p className="text-xs">Week {index + 1}</p> <p className="text-xs">Week {index + 1}</p>
<p className="font-bold">{target}</p> <p className="font-bold">{target}</p>
@ -162,3 +162,4 @@ export function QuitPlanCard({
</Card> </Card>
); );
} }
export const QuitPlanCard = React.memo(QuitPlanCardComponent);

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -17,17 +17,25 @@ interface SavingsTrackerCardProps {
usageData: UsageEntry[]; usageData: UsageEntry[];
trackingStartDate: string | null; trackingStartDate: string | null;
onSavingsConfigChange: (config: SavingsConfig) => void; onSavingsConfigChange: (config: SavingsConfig) => void;
onModalStateChange?: (isOpen: boolean) => void;
} }
export function SavingsTrackerCard({ function SavingsTrackerCardComponent({
savingsConfig, savingsConfig,
usageData, usageData,
trackingStartDate, trackingStartDate,
onSavingsConfigChange, onSavingsConfigChange,
onModalStateChange,
}: SavingsTrackerCardProps) { }: SavingsTrackerCardProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const [showSetup, setShowSetup] = useState(false); const [showSetup, setShowSetup] = useState(false);
useEffect(() => {
if (onModalStateChange) {
onModalStateChange(showSetup);
}
}, [showSetup, onModalStateChange]);
const totalSaved = useMemo(() => { const totalSaved = useMemo(() => {
return calculateTotalSaved(savingsConfig, usageData, trackingStartDate); return calculateTotalSaved(savingsConfig, usageData, trackingStartDate);
}, [savingsConfig, usageData, trackingStartDate]); }, [savingsConfig, usageData, trackingStartDate]);
@ -202,3 +210,4 @@ export function SavingsTrackerCard({
</> </>
); );
} }
export const SavingsTrackerCard = React.memo(SavingsTrackerCardComponent);

View File

@ -1,5 +1,7 @@
'use client'; 'use client';
import { useState, useMemo } from 'react';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageEntry } from '@/lib/storage'; import { UsageEntry } from '@/lib/storage';
import { Cigarette, Leaf } from 'lucide-react'; import { Cigarette, Leaf } from 'lucide-react';
@ -11,46 +13,64 @@ interface StatsCardProps {
substance: 'nicotine' | 'weed'; substance: 'nicotine' | 'weed';
} }
export function StatsCard({ usageData, substance }: StatsCardProps) { function StatsCardComponent({ usageData, substance }: StatsCardProps) {
const { theme } = useTheme(); 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<string, number>();
let totalUsage = 0;
// Calculate stats for (const e of usageData) {
const today = new Date(); if (e.substance === substance) {
const todayStr = getTodayString(); substanceMap.set(e.date, e.count);
const todayUsage = substanceData.find((e) => e.date === todayStr)?.count ?? 0; totalUsage += e.count;
}
// 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;
} }
}
// Total tracked const today = new Date();
const totalUsage = substanceData.reduce((sum, e) => sum + e.count, 0); const todayStr = getTodayString();
const totalDays = substanceData.length; 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 SubstanceIcon = substance === 'nicotine' ? Cigarette : Leaf;
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana'; const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
@ -109,3 +129,4 @@ export function StatsCard({ usageData, substance }: StatsCardProps) {
</Card> </Card>
); );
} }
export const StatsCard = React.memo(StatsCardComponent);

View File

@ -18,7 +18,7 @@ import { useTheme } from '@/lib/theme-context';
import { getLocalDateString, getTodayString } from '@/lib/date-utils'; import { getLocalDateString, getTodayString } from '@/lib/date-utils';
import { DailyInspirationCard } from './DailyInspirationCard'; import { DailyInspirationCard } from './DailyInspirationCard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import React from 'react';
interface UsageCalendarProps { interface UsageCalendarProps {
@ -31,17 +31,32 @@ interface UsageCalendarProps {
onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>; onPreferencesUpdate?: (prefs: UserPreferences) => Promise<void>;
} }
export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) { function UsageCalendarComponent({ usageData, onDataUpdate, religion, onReligionUpdate, preferences, onPreferencesUpdate }: UsageCalendarProps) {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined); const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [editNicotineCount, setEditNicotineCount] = useState(''); const [editNicotineCount, setEditNicotineCount] = useState('');
const [editWeedCount, setEditWeedCount] = useState(''); const [editWeedCount, setEditWeedCount] = useState('');
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const { theme } = useTheme(); 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<string, { nicotine: number; weed: number }>();
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 dateStr = getLocalDateString(date);
const entry = usageData.find((e) => e.date === dateStr && e.substance === substance); const counts = indexedUsage.get(dateStr);
return entry?.count ?? 0; return counts?.[substance] ?? 0;
}; };
const handleDateSelect = (date: Date | undefined) => { const handleDateSelect = (date: Date | undefined) => {
@ -53,8 +68,8 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd
if (date > today) return; if (date > today) return;
setSelectedDate(date); setSelectedDate(date);
const nicotineCount = getUsageForDate(date, 'nicotine'); const nicotineCount = getUsageFromMap(date, 'nicotine');
const weedCount = getUsageForDate(date, 'weed'); const weedCount = getUsageFromMap(date, 'weed');
setEditNicotineCount(nicotineCount.toString()); setEditNicotineCount(nicotineCount.toString());
setEditWeedCount(weedCount.toString()); setEditWeedCount(weedCount.toString());
setIsEditing(true); setIsEditing(true);
@ -177,8 +192,8 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd
const isFuture = dateToCheck > today; const isFuture = dateToCheck > today;
const isToday = dateToCheck.getTime() === today.getTime(); const isToday = dateToCheck.getTime() === today.getTime();
const nicotineCount = isFuture ? 0 : getUsageForDate(date, 'nicotine'); const nicotineCount = isFuture ? 0 : getUsageFromMap(date, 'nicotine');
const weedCount = isFuture ? 0 : getUsageForDate(date, 'weed'); const weedCount = isFuture ? 0 : getUsageFromMap(date, 'weed');
const colorStyle = !isFuture ? getColorStyle(nicotineCount, weedCount, isToday) : {}; const colorStyle = !isFuture ? getColorStyle(nicotineCount, weedCount, isToday) : {};
return ( return (
@ -219,72 +234,85 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd
<> <>
<Card className="bg-card/80 backdrop-blur-xl shadow-xl drop-shadow-lg transition-all duration-300 border-white/10 overflow-hidden"> <Card className="bg-card/80 backdrop-blur-xl shadow-xl drop-shadow-lg transition-all duration-300 border-white/10 overflow-hidden">
<CardContent className="p-2 sm:p-6"> <CardContent className="p-2 sm:p-6">
{/* Legend - moved to top */} {/* Legend - Re-styled for better balance */}
<div className="mb-4 grid grid-cols-3 sm:flex sm:flex-wrap gap-x-3 gap-y-2 text-[10px] sm:text-xs font-medium opacity-80"> <div className="mb-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-3 px-2 sm:px-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full shrink-0" style={{ background: 'linear-gradient(135deg, rgba(96,165,250,0.5), rgba(59,130,246,0.6))' }} /> <div className="w-2.5 h-2.5 rounded-full shadow-sm" style={{ background: 'linear-gradient(135deg, rgba(96,165,250,0.8), rgba(59,130,246,1))' }} />
<span>No usage</span> <span className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">No usage</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full shrink-0" style={{ background: 'linear-gradient(135deg, rgba(251,191,36,0.6), rgba(245,158,11,0.7))' }} /> <div className="w-2.5 h-2.5 rounded-full shadow-sm" style={{ background: 'linear-gradient(135deg, rgba(251,191,36,0.8), rgba(245,158,11,1))' }} />
<span>Today</span> <span className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">Today</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full shrink-0" style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.7), rgba(185,28,28,0.8))' }} /> <div className="w-2.5 h-2.5 rounded-full shadow-sm" style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.8), rgba(185,28,28,1))' }} />
<span>Nicotine</span> <span className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">Nicotine</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full shrink-0" style={{ background: 'linear-gradient(135deg, rgba(34,197,94,0.7), rgba(22,163,74,0.8))' }} /> <div className="w-2.5 h-2.5 rounded-full shadow-sm" style={{ background: 'linear-gradient(135deg, rgba(34,197,94,0.8), rgba(22,163,74,1))' }} />
<span>Marijuana</span> <span className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">Marijuana</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full shrink-0" style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.8) 50%, rgba(34,197,94,0.8) 50%)' }} /> <div className="w-2.5 h-2.5 rounded-full shadow-sm" style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.8) 50%, rgba(34,197,94,0.8) 50%)' }} />
<span>Both</span> <span className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">Both</span>
</div> </div>
</div> </div>
<div className="flex flex-col lg:flex-row gap-4"> <div className="flex flex-col lg:flex-row gap-8 lg:gap-16 items-center lg:items-stretch justify-center max-w-6xl mx-auto">
{/* Calendar */} {/* Calendar - Focused Container */}
<div className="w-full lg:w-auto block"> <div className="w-full lg:w-auto flex flex-col items-center">
<DayPicker <div className={cn(
mode="single" "rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500",
selected={selectedDate} theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
onSelect={handleDateSelect} )}>
className={`rounded-xl p-0 sm:p-3 w-full ${theme === 'light' ? 'text-slate-900' : 'text-white'}`} <DayPicker
showOutsideDays={false} mode="single"
components={{ selected={selectedDate}
DayButton: (props) => ( onSelect={handleDateSelect}
<CustomDayButton className={cn(
{...props} "p-0 sm:p-2",
className={cn( theme === 'light' ? "text-slate-900" : "text-white"
props.className, )}
"aspect-square rounded-full flex items-center justify-center p-0" showOutsideDays={false}
)} components={{
/> DayButton: (props) => (
), <CustomDayButton
Chevron: ({ orientation }) => ( {...props}
<div className={cn( className={cn(
"p-1 rounded-full border transition-colors", props.className,
theme === 'light' "aspect-square rounded-full flex items-center justify-center p-0"
? "bg-slate-100/50 border-slate-200 text-slate-600 hover:bg-slate-200" )}
: "bg-white/5 border-white/10 text-white/70 hover:bg-white/10" />
)}> ),
{orientation === 'left' ? ( Chevron: ({ orientation }) => (
<ChevronLeftIcon className="h-3.5 w-3.5" /> <div className={cn(
) : ( "p-1.5 rounded-full border transition-all duration-200",
<ChevronRightIcon className="h-3.5 w-3.5" /> theme === 'light'
)} ? "bg-white border-slate-200 text-slate-600 hover:bg-slate-100 hover:scale-110 shadow-sm"
</div> : "bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:scale-110"
), )}>
}} {orientation === 'left' ? (
/> <ChevronLeftIcon className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</div>
),
}}
/>
</div>
</div> </div>
{/* Daily Inspiration */} {/* Desktop Vertical Divider */}
<DailyInspirationCard <div className="hidden lg:block w-px self-stretch bg-gradient-to-b from-transparent via-white/10 to-transparent" />
initialReligion={religion}
onReligionChange={onReligionUpdate} {/* Daily Inspiration - Centered vertically on desktop */}
/> <div className="flex-1 w-full max-w-2xl flex flex-col justify-center">
<DailyInspirationCard
initialReligion={religion}
onReligionChange={onReligionUpdate}
/>
</div>
</div> </div>
</CardContent> </CardContent>
@ -355,3 +383,4 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd
</> </>
); );
} }
export const UsageCalendar = React.memo(UsageCalendarComponent);

View File

@ -38,6 +38,7 @@ import { SideMenu } from './SideMenu';
interface UserHeaderProps { interface UserHeaderProps {
user: User; user: User;
preferences?: UserPreferences | null; preferences?: UserPreferences | null;
onModalStateChange?: (isOpen: boolean) => void;
} }
interface HourlyTimePickerProps { 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<string | null>(null); const [userName, setUserName] = useState<string | null>(null);
const [reminderSettings, setReminderSettings] = useState<ReminderSettings>({ enabled: false, reminderTime: '09:00', frequency: 'daily' }); const [reminderSettings, setReminderSettings] = useState<ReminderSettings>({ enabled: false, reminderTime: '09:00', frequency: 'daily' });
const [showReminderDialog, setShowReminderDialog] = useState(false); const [showReminderDialog, setShowReminderDialog] = useState(false);
@ -133,6 +134,12 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { isSupported, permission, requestPermission } = useNotifications(reminderSettings); const { isSupported, permission, requestPermission } = useNotifications(reminderSettings);
useEffect(() => {
if (onModalStateChange) {
onModalStateChange(showReminderDialog || isSideMenuOpen);
}
}, [showReminderDialog, isSideMenuOpen, onModalStateChange]);
// Helper to parse time string // Helper to parse time string
const [parsedHours, parsedMinutes] = reminderSettings.reminderTime.split(':').map(Number); const [parsedHours, parsedMinutes] = reminderSettings.reminderTime.split(':').map(Number);
const currentAmpm = parsedHours >= 12 ? 'PM' : 'AM'; const currentAmpm = parsedHours >= 12 ? 'PM' : 'AM';
@ -220,16 +227,16 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
WebkitBackdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderBottom: theme === 'light' ? '1px solid rgba(0,0,0,0.08)' : '1px solid rgba(255,255,255,0.08)' 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 */}
<div <div
className="absolute inset-0 pointer-events-none select-none overflow-hidden invert dark:invert-0 transition-opacity duration-500" className="absolute inset-0 pointer-events-none select-none overflow-hidden invert dark:invert-0 transition-opacity duration-700"
style={{ style={{
maskImage: 'linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.4) 12%, black 25%, black 75%, rgba(0,0,0,0.4) 88%, transparent 100%)', maskImage: 'radial-gradient(ellipse 95% 140% at 50% 50%, black 0%, rgba(0,0,0,0.4) 60%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.4) 12%, black 25%, black 75%, rgba(0,0,0,0.4) 88%, transparent 100%)' WebkitMaskImage: 'radial-gradient(ellipse 95% 140% at 50% 50%, black 0%, rgba(0,0,0,0.4) 60%, transparent 100%)',
}} }}
> >
<div className="absolute inset-0 fog-layer-1 opacity-[0.18] dark:opacity-[0.15]" /> <div className="absolute inset-0 fog-layer-1 opacity-[0.25] dark:opacity-[0.2]" />
<div className="absolute inset-0 fog-layer-2 opacity-[0.12] dark:opacity-[0.1]" /> <div className="absolute inset-0 fog-layer-2 opacity-[0.18] dark:opacity-[0.14]" />
</div> </div>
<div className="container mx-auto px-4 h-16 sm:h-20 flex items-center justify-between relative z-50"> <div className="container mx-auto px-4 h-16 sm:h-20 flex items-center justify-between relative z-50">

View File

@ -394,22 +394,33 @@ export function calculateStreak(
usageData: UsageEntry[], usageData: UsageEntry[],
substance: 'nicotine' | 'weed' substance: 'nicotine' | 'weed'
): number { ): number {
if (usageData.length === 0) return 0;
// O(n) to build lookup Map
const substanceMap = new Map<string, number>();
for (const entry of usageData) {
if (entry.substance === substance) {
substanceMap.set(entry.date, entry.count);
}
}
let streak = 0; let streak = 0;
const today = new Date(); 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++) { for (let i = 0; i <= 365; i++) {
const checkDate = new Date(today); const checkDate = new Date(today);
checkDate.setDate(checkDate.getDate() - i); checkDate.setDate(checkDate.getDate() - i);
const dateStr = checkDate.toISOString().split('T')[0]; 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) { if (dayUsage === 0) {
streak++; streak++;
} else if (dayUsage > 0) { } else if (dayUsage > 0) {
break; break;
} }
// If dayUsage === -1 (no entry), we continue but don't count it as a streak day
} }
return streak; return streak;
} }
@ -423,101 +434,122 @@ export function calculateTotalSaved(
const start = new Date(startDate); const start = new Date(startDate);
const today = new Date(); const today = new Date();
const daysSinceStart = Math.floor( const diffTime = today.getTime() - start.getTime();
(today.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) const daysSinceStart = Math.floor(diffTime / (1000 * 60 * 60 * 24));
);
if (daysSinceStart <= 0) return 0; if (daysSinceStart <= 0) return 0;
// Expected spending if they continued at baseline (unitsPerDay is now unitsPerWeek)
const weeksSinceStart = daysSinceStart / 7; const weeksSinceStart = daysSinceStart / 7;
const expectedSpend = const expectedSpend =
weeksSinceStart * savingsConfig.costPerUnit * savingsConfig.unitsPerDay; weeksSinceStart * savingsConfig.costPerUnit * savingsConfig.unitsPerDay;
// Actual usage converted to cost (assuming ~20 puffs/hits per unit) // Single pass O(n) calculation
const relevantUsage = usageData.filter( let actualUnits = 0;
(e) => e.substance === savingsConfig.substance && new Date(e.date) >= start const substance = savingsConfig.substance;
); const startTime = start.getTime();
const actualUnits = relevantUsage.reduce((sum, e) => sum + e.count, 0);
const unitsPerPack = 20; // Average puffs per pack/unit 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; const actualSpend = (actualUnits / unitsPerPack) * savingsConfig.costPerUnit;
return Math.max(0, expectedSpend - actualSpend); return Math.max(0, expectedSpend - actualSpend);
} }
export function checkBadgeEligibility( export function checkBadgeEligibility(
badgeId: string, badgeId: string,
usageData: UsageEntry[], usageData: UsageEntry[],
preferences: UserPreferences, preferences: UserPreferences,
substance: 'nicotine' | 'weed' substance: 'nicotine' | 'weed'
): boolean { ): boolean {
const streak = calculateStreak(usageData, substance); // Pre-calculate common stats once O(n)
const nicotineStreak = calculateStreak(usageData, 'nicotine'); const stats = (() => {
const weedStreak = calculateStreak(usageData, 'weed'); const nicotineMap = new Map<string, number>();
const totalDays = new Set( const weedMap = new Map<string, number>();
usageData.filter((e) => e.substance === substance).map((e) => e.date) const trackedDays = new Set<string>();
).size;
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<string, number>) => {
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 => { const checkMonthlyReduction = (): boolean => {
if (!preferences.trackingStartDate) return false; if (!preferences.trackingStartDate) return false;
const startDate = new Date(preferences.trackingStartDate); const start = new Date(preferences.trackingStartDate);
const today = new Date(); const today = new Date();
const daysSinceStart = Math.floor( const daysSinceStart = Math.floor((today.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
(today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceStart < 30) return false; if (daysSinceStart < 30) return false;
// Get first week's average // Use current Map for O(1) lookups in week buckets
const firstWeekData = usageData.filter((e) => { let firstWeekTotal = 0;
const entryDate = new Date(e.date); let lastWeekTotal = 0;
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 startTime = start.getTime();
const lastWeekData = usageData.filter((e) => { const todayTime = today.getTime();
const entryDate = new Date(e.date); const msInDay = 1000 * 60 * 60 * 24;
const daysAgo = Math.floor(
(today.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24) for (const entry of usageData) {
); if (entry.substance !== substance) continue;
return e.substance === substance && daysAgo >= 0 && daysAgo < 7; const entryTime = new Date(entry.date).getTime();
}); const daysSinceEntryStart = Math.floor((entryTime - startTime) / msInDay);
const lastWeekTotal = lastWeekData.reduce((sum, e) => sum + e.count, 0); 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; const lastWeekAvg = lastWeekTotal / 7;
// Check if reduced by at least 50% return firstWeekAvg <= 0 ? lastWeekAvg === 0 : lastWeekAvg <= firstWeekAvg * 0.5;
if (firstWeekAvg <= 0) return lastWeekAvg === 0;
return lastWeekAvg <= firstWeekAvg * 0.5;
}; };
switch (badgeId) { switch (badgeId) {
case 'first_day': case 'first_day': return stats.totalDays >= 1;
// Log usage for the first time case 'streak_3': return streak >= 3;
return totalDays >= 1; case 'streak_7': return stats.totalDays >= 7;
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 'fighter': case 'fighter':
// 7 days off ANY substance (both nicotine AND weed) return getStreakFromMap(stats.nicotineMap) >= 7 && getStreakFromMap(stats.weedMap) >= 7;
return nicotineStreak >= 7 && weedStreak >= 7; case 'one_month': return checkMonthlyReduction();
case 'one_month':
// Track one month and reduce usage by 50%
return checkMonthlyReduction();
case 'goal_crusher': case 'goal_crusher':
// One month substance free (both substances) return getStreakFromMap(stats.nicotineMap) >= 30 && getStreakFromMap(stats.weedMap) >= 30;
return nicotineStreak >= 30 && weedStreak >= 30; default: return false;
default:
return false;
} }
} }