742 lines
27 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import React from 'react';
import { User } from '@/lib/session';
import {
fetchPreferences,
fetchUsageData,
savePreferencesAsync,
saveUsageEntryAsync,
shouldShowUsagePrompt,
markPromptShown,
generateQuitPlan,
fetchAchievements,
fetchSavingsConfig,
saveSavingsConfig,
unlockAchievement,
checkBadgeEligibility,
UserPreferences,
UsageEntry,
Achievement,
SavingsConfig,
BADGE_DEFINITIONS,
BadgeDefinition,
StorageRequestError,
} from '@/lib/storage';
import { UserHeader } from './UserHeader';
import { SetupWizard } from './SetupWizard';
import { UsageCalendar } from './UsageCalendar';
import { StatsCard } from './StatsCard';
import { UnifiedQuitPlanCard } from './UnifiedQuitPlanCard';
import { AchievementsCard } from './AchievementsCard';
import { CelebrationAnimation } from './CelebrationAnimation';
import { HealthTimelineCard } from './HealthTimelineCard';
import { SavingsTrackerCard } from './SavingsTrackerCard';
import { MoodTracker } from './MoodTracker';
import { DailyInspirationCard } from './DailyInspirationCard';
import { ScrollWheelLogger } from './ScrollWheelLogger';
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
import { VersionUpdateModal } from './VersionUpdateModal';
import { Button } from '@/components/ui/button';
import { PlusCircle, X } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
import { getTodayString } from '@/lib/date-utils';
interface DashboardProps {
user: User;
}
const MOBILE_SLIDES = [
{ id: 'mood', label: 'How Are You Feeling' },
{ id: 'plan', label: 'Quit Journey Plan' },
{ id: 'stats', label: 'Usage Stats' },
{ id: 'recovery', label: 'Health Recovery' },
{ id: 'achievements', label: 'Achievements' },
{ id: 'savings', label: 'Savings' },
{ id: 'calendar', label: 'Usage Calendar' },
];
const GLOBAL_BADGE_IDS = new Set(['first_day']);
export function Dashboard({ user }: DashboardProps) {
const [preferences, setPreferences] = useState<UserPreferences | null>(null);
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
const [achievements, setAchievements] = useState<Achievement[]>([]);
const [savingsConfig, setSavingsConfig] = useState<SavingsConfig | null>(null);
const [showSetup, setShowSetup] = useState(false);
const [showCelebration, setShowCelebration] = useState(false);
const [isSubstancePickerOpen, setIsSubstancePickerOpen] = useState(false);
const [activeLoggingSubstance, setActiveLoggingSubstance] = useState<'nicotine' | 'weed' | null>(null);
const [newBadge, setNewBadge] = useState<BadgeDefinition | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [currentPage, setCurrentPage] = useState(0);
const [modalOpenCount, setModalOpenCount] = useState(0);
const swipeContainerRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme();
const isModalOpen = modalOpenCount > 0 || showSetup || showCelebration;
const totalPages = MOBILE_SLIDES.length;
const handleModalStateChange = useCallback((isOpen: boolean) => {
setModalOpenCount(prev => isOpen ? prev + 1 : Math.max(0, prev - 1));
}, []);
const handleScroll = useCallback(() => {
const container = swipeContainerRef.current;
if (!container) return;
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
if (slides.length === 0) return;
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
slides.forEach((slide, index) => {
const distance = Math.abs(container.scrollLeft - slide.offsetLeft);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
if (nearestIndex !== currentPage) {
setCurrentPage(nearestIndex);
}
}, [currentPage]);
const scrollToPage = (pageIndex: number) => {
const container = swipeContainerRef.current;
if (!container) return;
const slides = Array.from(container.querySelectorAll<HTMLElement>('.swipe-item'));
if (slides.length === 0) return;
const boundedPage = Math.min(Math.max(pageIndex, 0), totalPages - 1);
const targetSlide = slides[boundedPage];
container.scrollTo({
left: targetSlide?.offsetLeft ?? 0,
behavior: 'smooth'
});
};
useEffect(() => {
if (typeof window === 'undefined') return;
const savedPage = Number(localStorage.getItem('quittraq_mobile_dashboard_page'));
if (Number.isNaN(savedPage) || savedPage < 0 || savedPage >= totalPages) return;
setCurrentPage(savedPage);
const timeout = window.setTimeout(() => {
scrollToPage(savedPage);
}, 0);
return () => window.clearTimeout(timeout);
}, [totalPages]);
useEffect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem('quittraq_mobile_dashboard_page', String(currentPage));
}, [currentPage]);
const loadData = useCallback(async () => {
try {
const [prefs, usage, achvs, savings] = await Promise.all([
fetchPreferences(),
fetchUsageData(),
fetchAchievements(),
fetchSavingsConfig(),
]);
setPreferences(prefs);
setUsageData(usage);
setAchievements(achvs);
setSavingsConfig(savings);
setLoadError(null);
console.log('[Dashboard] Loaded prefs:', prefs);
setRefreshKey(prev => prev + 1);
return { prefs, usage, achvs };
} catch (error) {
const message = error instanceof StorageRequestError
? error.message
: 'Unable to sync your dashboard right now. Please try again.';
setLoadError(message);
throw error;
}
}, []);
const checkAndUnlockAchievements = useCallback(async (
usage: UsageEntry[],
prefs: UserPreferences,
currentAchievements: Achievement[]
) => {
// Current unlocked set (local + server)
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
const unlockedGlobalBadges = new Set(
currentAchievements
.filter((a) => GLOBAL_BADGE_IDS.has(a.badgeId))
.map((a) => a.badgeId)
);
const newUnlocked: Achievement[] = [];
let badgeToCelebrate: BadgeDefinition | null = null;
const hasUsageBySubstance = {
nicotine: usage.some((entry) => entry.substance === 'nicotine' && entry.count > 0),
weed: usage.some((entry) => entry.substance === 'weed' && entry.count > 0),
};
for (const badge of BADGE_DEFINITIONS) {
if (GLOBAL_BADGE_IDS.has(badge.id)) {
if (unlockedGlobalBadges.has(badge.id)) continue;
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, 'nicotine')
|| checkBadgeEligibility(badge.id, usage, prefs, 'weed');
if (!isEligible) continue;
try {
const result = await unlockAchievement(badge.id, 'both');
if (result.isNew && result.achievement) {
newUnlocked.push(result.achievement);
unlockedGlobalBadges.add(badge.id);
if (!badgeToCelebrate) {
badgeToCelebrate = badge;
}
}
} catch (e) {
console.error('Error unlocking global achievement:', e);
}
continue;
}
for (const substance of ['nicotine', 'weed'] as const) {
if (!hasUsageBySubstance[substance]) continue;
const key = `${badge.id}-${substance}`;
if (unlockedIds.has(key)) continue;
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, substance);
if (isEligible) {
try {
const result = await unlockAchievement(badge.id, substance);
if (result.isNew && result.achievement) {
newUnlocked.push(result.achievement);
// Prioritize celebrating the first one found
if (!badgeToCelebrate) {
badgeToCelebrate = badge;
}
}
} catch (e) {
console.error('Error unlocking achievement:', e);
}
}
}
}
if (newUnlocked.length > 0) {
// Update local state with ALL new achievements
setAchievements(prev => [...prev, ...newUnlocked]);
// Show celebration for determining badge
if (badgeToCelebrate) {
setNewBadge(badgeToCelebrate);
setShowCelebration(true);
}
}
return newUnlocked.length > 0;
}, []);
useEffect(() => {
const init = async () => {
try {
const { prefs, usage, achvs } = await loadData();
if (!prefs.hasCompletedSetup) {
setShowSetup(true);
} else {
// Check for achievements
await checkAndUnlockAchievements(usage, prefs, achvs);
// Check if running as PWA (home screen shortcut)
// No longer automatically showing substance picker
if (shouldShowUsagePrompt()) {
markPromptShown();
}
}
} catch (error) {
console.error('Dashboard init error:', error);
} finally {
setIsLoading(false);
}
};
init();
}, [loadData, checkAndUnlockAchievements]);
const handleSetupComplete = async (data: { substance: 'nicotine' | 'weed'; name: string; age: number; religion: 'christian' | 'secular' }) => {
const today = getTodayString();
const newPrefs: UserPreferences = {
substance: data.substance,
trackingStartDate: today,
hasCompletedSetup: true,
dailyGoal: null,
quitPlan: null,
userName: data.name,
userAge: data.age,
religion: data.religion,
};
await savePreferencesAsync(newPrefs);
setPreferences(newPrefs);
setShowSetup(false);
setIsSubstancePickerOpen(true);
setRefreshKey(prev => prev + 1);
};
const handleUsageSubmit = async (count: number, substance: 'nicotine' | 'weed') => {
if (!preferences) {
setIsSubstancePickerOpen(false);
setActiveLoggingSubstance(null);
return;
}
let latestPrefs = preferences;
if (count > 0) {
const today = getTodayString();
const now = new Date().toISOString();
await saveUsageEntryAsync({
date: today,
count,
substance,
});
// Update preferences with last usage time
latestPrefs = {
...preferences,
[substance === 'nicotine' ? 'lastNicotineUsageTime' : 'lastWeedUsageTime']: now,
};
// Force specific fields to be present to avoid partial update issues
// This ensures that even if preferences is stale, we explicitly set the usage time
const payload: UserPreferences = {
...latestPrefs,
lastNicotineUsageTime: substance === 'nicotine' ? now : (latestPrefs.lastNicotineUsageTime ?? null),
lastWeedUsageTime: substance === 'weed' ? now : (latestPrefs.lastWeedUsageTime ?? null),
};
await savePreferencesAsync(payload);
setPreferences(payload);
}
setActiveLoggingSubstance(null);
setIsSubstancePickerOpen(false);
// Reload data and force calendar refresh
const usage = await fetchUsageData();
setUsageData(usage);
setRefreshKey(prev => prev + 1);
// Check for new achievements metrics FIRST
await checkAndUnlockAchievements(usage, latestPrefs, achievements);
// Force a fresh fetch of all data to ensure UI sync
const freshAchievements = await fetchAchievements();
setAchievements(freshAchievements);
// THEN refresh UI components
setRefreshKey(prev => prev + 1);
};
const handleGeneratePlan = async (targetSubstance: 'nicotine' | 'weed') => {
if (!preferences) return;
const plan = generateQuitPlan(targetSubstance);
// Construct new state
const currentQuitState = preferences.quitState || {
nicotine: { plan: null, startDate: null },
weed: { plan: null, startDate: null }
};
const updatedQuitState = {
...currentQuitState,
[targetSubstance]: {
plan,
startDate: currentQuitState[targetSubstance].startDate || (preferences.substance === targetSubstance ? preferences.trackingStartDate : null) || getTodayString()
}
};
const updatedPrefs = {
...preferences,
quitState: updatedQuitState
};
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
setRefreshKey(prev => prev + 1);
};
const handleSavingsConfigChange = async (config: SavingsConfig) => {
setSavingsConfig(config);
await saveSavingsConfig(config);
};
const handleCelebrationComplete = () => {
setShowCelebration(false);
setNewBadge(null);
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse text-lg text-white">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen">
<UserHeader
user={user}
preferences={preferences}
onModalStateChange={handleModalStateChange}
/>
<main className="container mx-auto px-4 py-4 sm:py-8 pb-28 sm:pb-8 max-w-full">
{loadError && (
<div className={`mb-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm ${theme === 'light' ? 'text-amber-800' : 'text-amber-100'}`}>
<div className="flex items-center justify-between gap-3">
<span>{loadError}</span>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={async () => {
setIsLoading(true);
try {
await loadData();
} finally {
setIsLoading(false);
}
}}
>
Retry
</Button>
</div>
</div>
)}
{!preferences && !isLoading && (
<div className="rounded-2xl border border-border bg-card/70 p-6 text-center">
<p className="text-sm text-muted-foreground">Your dashboard data is unavailable right now.</p>
</div>
)}
{preferences && (
<>
{/* Floating Log Button - Simplified to toggle Picker */}
<div className={`fixed bottom-[6.5rem] sm:bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-6 z-40 transition-all duration-300 ${isModalOpen ? 'opacity-0 scale-90 pointer-events-none' : 'opacity-100 scale-100'} sm:block`}>
<Button
size="lg"
onClick={() => setIsSubstancePickerOpen(!isSubstancePickerOpen)}
className={`h-14 px-6 sm:h-16 sm:px-8 text-base sm:text-lg rounded-full shadow-xl transition-all duration-300 hover:scale-105 active:scale-95 flex items-center gap-2 ${isSubstancePickerOpen
? 'bg-white/10 text-white backdrop-blur-xl border border-white/20'
: 'bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70 text-white border-none'
}`}
>
{isSubstancePickerOpen ? (
<>
<X className="h-5 w-5 sm:h-6 sm:w-6" />
Cancel
</>
) : (
<>
<PlusCircle className="h-5 w-5 sm:h-6 sm:w-6" />
Log Usage
</>
)}
</Button>
</div>
{/* Dashboard Sections */}
<div className="space-y-6 sm:space-y-12 relative overflow-hidden">
{/* DESKTOP LAYOUT - Hidden on mobile */}
<div className="hidden sm:block space-y-8">
{/* Row 1: Mood + Quit Plan */}
<div className="grid grid-cols-2 gap-6">
<MoodTracker />
<UnifiedQuitPlanCard
preferences={preferences}
usageData={usageData}
onGeneratePlan={handleGeneratePlan}
refreshKey={refreshKey}
variant="desktop"
/>
</div>
{/* Row 2: Calendar/Quote */}
<div id="calendar-section">
<UsageCalendar
key={refreshKey}
usageData={usageData}
onDataUpdate={loadData}
userId={user.id}
religion={preferences.religion}
onReligionUpdate={async (religion: 'christian' | 'secular') => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
showInspirationPanel
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
}}
/>
</div>
{/* Row 3: Achievements + Health Recovery */}
<div className="grid grid-cols-2 gap-6">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
preferences={preferences}
/>
</div>
{/* Row 4: Savings + Stats */}
<div className="grid grid-cols-2 gap-6">
<SavingsTrackerCard
key={`savings-${refreshKey}`}
savingsConfig={savingsConfig}
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
onModalStateChange={handleModalStateChange}
/>
<div className="grid grid-cols-2 gap-4">
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
</div>
</div>
</div>
{/* MOBILE SWIPE LAYOUT - Hidden on desktop */}
<div
ref={swipeContainerRef}
id="mobile-dashboard-slides"
onScroll={handleScroll}
onKeyDown={(event) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
scrollToPage(currentPage + 1);
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollToPage(currentPage - 1);
}
}}
className="swipe-container sm:hidden"
tabIndex={0}
role="region"
aria-label="Mobile dashboard sections"
>
{/* SLIDE 1: Mood */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">How Are You Feeling</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto space-y-3">
<MoodTracker />
<DailyInspirationCard
initialReligion={preferences.religion}
onReligionChange={async (religion) => {
const updatedPrefs = { ...preferences, religion };
setPreferences(updatedPrefs);
await savePreferencesAsync(updatedPrefs);
}}
/>
</div>
</div>
{/* SLIDE 2: Quit Plan */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Quit Journey Plan</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<UnifiedQuitPlanCard
preferences={preferences}
usageData={usageData}
onGeneratePlan={handleGeneratePlan}
refreshKey={refreshKey}
variant="mobile"
/>
</div>
</div>
{/* SLIDE 3: Stats */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Stats</h2>
</div>
<div className="grid grid-cols-1 gap-3 w-full max-w-[30rem] mx-auto">
<StatsCard
key={`stats-nicotine-${refreshKey}`}
usageData={usageData}
substance="nicotine"
/>
<StatsCard
key={`stats-weed-${refreshKey}`}
usageData={usageData}
substance="weed"
/>
</div>
</div>
{/* SLIDE 4: Recovery */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Health Recovery</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<HealthTimelineCard
key={`health-${refreshKey}`}
usageData={usageData}
preferences={preferences}
/>
</div>
</div>
{/* SLIDE 5: Achievements */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Achievements</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<AchievementsCard
key={`achievements-${refreshKey}`}
achievements={achievements}
substance={preferences.substance}
/>
</div>
</div>
{/* SLIDE 6: Savings */}
<div className="swipe-item space-y-3">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Savings</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<SavingsTrackerCard
key={`savings-${refreshKey}`}
savingsConfig={savingsConfig}
usageData={usageData}
trackingStartDate={preferences.trackingStartDate}
onSavingsConfigChange={handleSavingsConfigChange}
onModalStateChange={handleModalStateChange}
/>
</div>
</div>
{/* SLIDE 7: Calendar */}
<div id="calendar-section-mobile" className="swipe-item">
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">Usage Calendar</h2>
</div>
<div className="w-full max-w-[30rem] mx-auto">
<UsageCalendar
key={refreshKey}
usageData={usageData}
onDataUpdate={loadData}
userId={user.id}
preferences={preferences}
onPreferencesUpdate={async (updatedPrefs: UserPreferences) => {
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
}}
/>
</div>
</div>
</div>
<div className="sm:hidden fixed bottom-[calc(env(safe-area-inset-bottom)+0.75rem)] left-1/2 -translate-x-1/2 z-30 w-full px-3 pointer-events-none">
<div className="mx-auto max-w-sm rounded-xl border border-white/10 bg-black/25 backdrop-blur-xl px-3 py-2 pointer-events-auto">
<div className="text-[10px] uppercase tracking-[0.2em] opacity-60 text-center mb-2">
{MOBILE_SLIDES[currentPage]?.label}
</div>
<div className="flex items-center justify-center gap-2">
{MOBILE_SLIDES.map((slide, index) => (
<button
key={slide.id}
type="button"
onClick={() => scrollToPage(index)}
aria-label={`Go to ${slide.label}`}
aria-current={currentPage === index ? 'page' : undefined}
className={`h-2 rounded-full transition-all ${currentPage === index ? 'w-8 bg-primary' : 'w-2 bg-white/30'}`}
/>
))}
</div>
</div>
</div>
</div>
</>
)}
</main>
<SetupWizard open={showSetup} onComplete={handleSetupComplete} />
<UsageLoggerDropUp
isOpen={isSubstancePickerOpen}
onSelect={(substance) => {
setActiveLoggingSubstance(substance);
setIsSubstancePickerOpen(false);
}}
onClose={() => setIsSubstancePickerOpen(false)}
/>
{activeLoggingSubstance && (
<ScrollWheelLogger
substance={activeLoggingSubstance}
onSubmit={handleUsageSubmit}
onCancel={() => setActiveLoggingSubstance(null)}
/>
)}
<VersionUpdateModal
preferences={preferences}
onAcknowledge={async (version) => {
if (!preferences) return;
const nextPreferences = {
...preferences,
lastSeenReleaseNotesVersion: version,
};
setPreferences(nextPreferences);
await savePreferencesAsync(nextPreferences);
}}
/>
{showCelebration && newBadge && (
<CelebrationAnimation
badge={newBadge}
onComplete={handleCelebrationComplete}
/>
)}
</div>
);
}