PWA optimizations, bug fixes, time adjustment, and reduced loading / typescript conversion
This commit is contained in:
parent
4687958125
commit
3cf2e805f2
@ -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}`;
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 >
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user