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 {
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<string, string> = {};
if (env.CRON_SECRET) {
headers['Authorization'] = `Bearer ${env.CRON_SECRET}`;
}

View File

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

View File

@ -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 {

View File

@ -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<string, React.ElementType> = {
Trophy,
};
export function AchievementsCard({ achievements, substance }: AchievementsCardProps) {
function AchievementsCardComponent({ achievements, substance }: AchievementsCardProps) {
const { theme } = useTheme();
const [hoveredBadge, setHoveredBadge] = useState<string | null>(null);
@ -76,8 +76,7 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr
return (
<div
key={badge.id}
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${
isUnlocked
className={`relative p-3 rounded-xl text-center transition-all duration-300 cursor-pointer ${isUnlocked
? 'bg-gradient-to-br from-yellow-500/30 to-amber-600/20 border border-yellow-500/50 hover:scale-105'
: 'bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20'
}`}
@ -103,8 +102,7 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr
</div>
)}
<div
className={`mx-auto mb-1 p-2 rounded-full w-fit ${
isUnlocked
className={`mx-auto mb-1 p-2 rounded-full w-fit ${isUnlocked
? 'bg-yellow-500/30 text-yellow-300'
: 'bg-white/10 text-white/30'
}`}
@ -112,8 +110,7 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr
<Icon className="h-5 w-5" />
</div>
<p
className={`text-xs font-medium ${
isUnlocked ? 'text-white' : 'text-white/40'
className={`text-xs font-medium ${isUnlocked ? 'text-white' : 'text-white/40'
}`}
>
{badge.name}
@ -132,3 +129,4 @@ export function AchievementsCard({ achievements, substance }: AchievementsCardPr
</Card>
);
}
export const AchievementsCard = React.memo(AchievementsCardComponent);

View File

@ -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<HTMLDivElement>(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 (
<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">
{preferences && (
<>
{/* 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
size="lg"
onClick={() => setShowUsagePrompt(true)}
@ -259,10 +272,10 @@ export function Dashboard({ user }: DashboardProps) {
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
{/* Mobile Navigation Buttons - LARGE */}
<div className="sm:hidden">
{currentPage > 0 && (
{currentPage > 0 && !isAnyModalOpen && (
<button
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={{
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(16px)',
@ -272,10 +285,10 @@ export function Dashboard({ user }: DashboardProps) {
<ChevronLeft className="h-8 w-8 text-primary group-hover:scale-110" />
</button>
)}
{currentPage < 3 && (
{currentPage < 3 && !isAnyModalOpen && (
<button
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={{
background: theme === 'light' ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.6)',
backdropFilter: 'blur(16px)',
@ -345,6 +358,7 @@ export function Dashboard({ user }: DashboardProps) {
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
onModalStateChange={handleModalStateChange}
/>
</div>
</div>
@ -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) {
</div>
</>
)}
</main >
</main>
<SetupWizard open={showSetup} onComplete={handleSetupComplete} />
{
preferences && (
<UsagePromptDialog
open={showUsagePrompt}
onClose={() => setShowUsagePrompt(false)}
onSubmit={handleUsageSubmit}
userId={user.id}
/>
)
}
{
showCelebration && newBadge && (
{showCelebration && newBadge && (
<CelebrationAnimation
badge={newBadge}
onComplete={handleCelebrationComplete}
/>
)
}
</div >
)}
</div>
);
}

View File

@ -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';
@ -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
</Card>
);
}
export const HealthTimelineCard = React.memo(HealthTimelineCardComponent);

View File

@ -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<MoodEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -366,3 +366,4 @@ export function MoodTracker() {
</Card>
);
}
export const MoodTracker = React.memo(MoodTrackerComponent);

View File

@ -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,8 +136,7 @@ export function QuitPlanCard({
{plan.weeklyTargets.map((target, index) => (
<div
key={index}
className={`text-center p-2 rounded-lg transition-all duration-200 hover:scale-105 ${
index + 1 === weekNumber
className={`text-center p-2 rounded-lg transition-all duration-200 hover:scale-105 ${index + 1 === weekNumber
? 'bg-gradient-to-br from-pink-500 to-pink-600 text-white shadow-lg shadow-pink-500/30'
: index + 1 < weekNumber
? 'bg-pink-900/50 text-pink-200'
@ -162,3 +162,4 @@ export function QuitPlanCard({
</Card>
);
}
export const QuitPlanCard = React.memo(QuitPlanCardComponent);

View File

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

View File

@ -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,35 +13,47 @@ 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<string, number>();
let totalUsage = 0;
for (const e of usageData) {
if (e.substance === substance) {
substanceMap.set(e.date, e.count);
totalUsage += e.count;
}
}
// Calculate stats
const today = new Date();
const todayStr = getTodayString();
const todayUsage = substanceData.find((e) => e.date === todayStr)?.count ?? 0;
const todayUsage = substanceMap.get(todayStr) ?? 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;
// 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;
// Streak (days with 0 usage)
// 3. Streak calculation O(366)
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++) {
for (let i = 0; i <= 365; 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;
const dayUsage = substanceMap.get(dateStr) ?? -1;
if (dayUsage === 0) {
streak++;
@ -48,9 +62,15 @@ export function StatsCard({ usageData, substance }: StatsCardProps) {
}
}
// Total tracked
const totalUsage = substanceData.reduce((sum, e) => sum + e.count, 0);
const totalDays = substanceData.length;
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) {
</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 { DailyInspirationCard } from './DailyInspirationCard';
import { cn } from '@/lib/utils';
import React from 'react';
interface UsageCalendarProps {
@ -31,17 +31,32 @@ interface UsageCalendarProps {
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 [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<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 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,38 +234,45 @@ 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">
<CardContent className="p-2 sm:p-6">
{/* Legend - moved to top */}
<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="flex items-center gap-1.5">
<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))' }} />
<span>No usage</span>
{/* Legend - Re-styled for better balance */}
<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-2">
<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 className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">No usage</span>
</div>
<div className="flex items-center gap-1.5">
<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))' }} />
<span>Today</span>
<div className="flex items-center gap-2">
<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 className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">Today</span>
</div>
<div className="flex items-center gap-1.5">
<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))' }} />
<span>Nicotine</span>
<div className="flex items-center gap-2">
<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 className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">Nicotine</span>
</div>
<div className="flex items-center gap-1.5">
<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))' }} />
<span>Marijuana</span>
<div className="flex items-center gap-2">
<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 className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">Marijuana</span>
</div>
<div className="flex items-center gap-1.5">
<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%)' }} />
<span>Both</span>
<div className="flex items-center gap-2">
<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 className="text-[10px] sm:text-xs font-semibold opacity-60 uppercase tracking-wider">Both</span>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-4">
{/* Calendar */}
<div className="w-full lg:w-auto block">
<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 - Focused Container */}
<div className="w-full lg:w-auto flex flex-col items-center">
<div className={cn(
"rounded-2xl p-2 sm:p-4 border shadow-inner transition-all duration-500",
theme === 'light' ? "bg-slate-50/50 border-slate-200/60" : "bg-black/20 border-white/5"
)}>
<DayPicker
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
className={`rounded-xl p-0 sm:p-3 w-full ${theme === 'light' ? 'text-slate-900' : 'text-white'}`}
className={cn(
"p-0 sm:p-2",
theme === 'light' ? "text-slate-900" : "text-white"
)}
showOutsideDays={false}
components={{
DayButton: (props) => (
@ -264,28 +286,34 @@ export function UsageCalendar({ usageData, onDataUpdate, religion, onReligionUpd
),
Chevron: ({ orientation }) => (
<div className={cn(
"p-1 rounded-full border transition-colors",
"p-1.5 rounded-full border transition-all duration-200",
theme === 'light'
? "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"
? "bg-white border-slate-200 text-slate-600 hover:bg-slate-100 hover:scale-110 shadow-sm"
: "bg-white/5 border-white/10 text-white/70 hover:bg-white/10 hover:scale-110"
)}>
{orientation === 'left' ? (
<ChevronLeftIcon className="h-3.5 w-3.5" />
<ChevronLeftIcon className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-3.5 w-3.5" />
<ChevronRightIcon className="h-4 w-4" />
)}
</div>
),
}}
/>
</div>
</div>
{/* Daily Inspiration */}
{/* Desktop Vertical Divider */}
<div className="hidden lg:block w-px self-stretch bg-gradient-to-b from-transparent via-white/10 to-transparent" />
{/* 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>
</CardContent>
</Card>
@ -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 {
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<string | null>(null);
const [reminderSettings, setReminderSettings] = useState<ReminderSettings>({ 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 */}
<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={{
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%)',
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%)'
maskImage: 'radial-gradient(ellipse 95% 140% at 50% 50%, black 0%, rgba(0,0,0,0.4) 60%, 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-2 opacity-[0.12] dark:opacity-[0.1]" />
<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.18] dark:opacity-[0.14]" />
</div>
<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[],
substance: 'nicotine' | 'weed'
): 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;
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<string, number>();
const weedMap = new Map<string, number>();
const trackedDays = new Set<string>();
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 => {
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;
}
}