Compare commits
2 Commits
7046febd00
...
3a31c8a956
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a31c8a956 | ||
|
|
75a75fd499 |
@ -23,12 +23,21 @@ export async function GET() {
|
||||
});
|
||||
}
|
||||
|
||||
// Parse JSON to construct quitState
|
||||
const rawJson = preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null;
|
||||
const isNewFormat = rawJson && 'nicotine' in rawJson;
|
||||
const quitState = isNewFormat ? rawJson : {
|
||||
nicotine: preferences.substance === 'nicotine' ? { plan: rawJson, startDate: preferences.trackingStartDate } : { plan: null, startDate: null },
|
||||
weed: preferences.substance === 'weed' ? { plan: rawJson, startDate: preferences.trackingStartDate } : { plan: null, startDate: null }
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
substance: preferences.substance,
|
||||
trackingStartDate: preferences.trackingStartDate,
|
||||
hasCompletedSetup: !!preferences.hasCompletedSetup,
|
||||
dailyGoal: preferences.dailyGoal,
|
||||
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
|
||||
quitPlan: null,
|
||||
quitState,
|
||||
userName: preferences.userName,
|
||||
userAge: preferences.userAge,
|
||||
religion: preferences.religion,
|
||||
@ -54,6 +63,7 @@ export async function POST(request: NextRequest) {
|
||||
hasCompletedSetup?: boolean;
|
||||
dailyGoal?: number;
|
||||
quitPlan?: unknown;
|
||||
quitState?: unknown;
|
||||
userName?: string;
|
||||
userAge?: number;
|
||||
religion?: string;
|
||||
@ -61,12 +71,17 @@ export async function POST(request: NextRequest) {
|
||||
lastWeedUsageTime?: string;
|
||||
};
|
||||
|
||||
// If quitState is provided in body, save it to quitPlanJson
|
||||
const quitPlanJson = body.quitState
|
||||
? JSON.stringify(body.quitState)
|
||||
: (body.quitPlan ? JSON.stringify(body.quitPlan) : undefined);
|
||||
|
||||
const preferences = await upsertPreferencesD1(session.user.id, {
|
||||
substance: body.substance,
|
||||
trackingStartDate: body.trackingStartDate,
|
||||
hasCompletedSetup: body.hasCompletedSetup ? 1 : 0,
|
||||
dailyGoal: body.dailyGoal,
|
||||
quitPlanJson: body.quitPlan ? JSON.stringify(body.quitPlan) : undefined,
|
||||
quitPlanJson: quitPlanJson,
|
||||
userName: body.userName,
|
||||
userAge: body.userAge,
|
||||
religion: body.religion,
|
||||
@ -78,12 +93,21 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Failed to save preferences' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Parse returned JSON to construct state again
|
||||
const rawJson = preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null;
|
||||
const isNewFormat = rawJson && 'nicotine' in rawJson;
|
||||
const quitState = isNewFormat ? rawJson : {
|
||||
nicotine: preferences.substance === 'nicotine' ? { plan: rawJson, startDate: preferences.trackingStartDate } : { plan: null, startDate: null },
|
||||
weed: preferences.substance === 'weed' ? { plan: rawJson, startDate: preferences.trackingStartDate } : { plan: null, startDate: null }
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
substance: preferences.substance,
|
||||
trackingStartDate: preferences.trackingStartDate,
|
||||
hasCompletedSetup: !!preferences.hasCompletedSetup,
|
||||
dailyGoal: preferences.dailyGoal,
|
||||
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
|
||||
quitPlan: null,
|
||||
quitState,
|
||||
userName: preferences.userName,
|
||||
userAge: preferences.userAge,
|
||||
religion: preferences.religion,
|
||||
|
||||
@ -28,7 +28,7 @@ import { SetupWizard } from './SetupWizard';
|
||||
import { UsagePromptDialog } from './UsagePromptDialog';
|
||||
import { UsageCalendar } from './UsageCalendar';
|
||||
import { StatsCard } from './StatsCard';
|
||||
import { QuitPlanCard } from './QuitPlanCard';
|
||||
import { UnifiedQuitPlanCard } from './UnifiedQuitPlanCard';
|
||||
import { AchievementsCard } from './AchievementsCard';
|
||||
import { CelebrationAnimation } from './CelebrationAnimation';
|
||||
import { HealthTimelineCard } from './HealthTimelineCard';
|
||||
@ -106,7 +106,10 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
prefs: UserPreferences,
|
||||
currentAchievements: Achievement[]
|
||||
) => {
|
||||
// Current unlocked set (local + server)
|
||||
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
|
||||
const newUnlocked: Achievement[] = [];
|
||||
let badgeToCelebrate: BadgeDefinition | null = null;
|
||||
|
||||
for (const badge of BADGE_DEFINITIONS) {
|
||||
for (const substance of ['nicotine', 'weed'] as const) {
|
||||
@ -115,16 +118,34 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
|
||||
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, substance);
|
||||
if (isEligible) {
|
||||
const result = await unlockAchievement(badge.id, substance);
|
||||
if (result.isNew && result.achievement) {
|
||||
setNewBadge(badge);
|
||||
setShowCelebration(true);
|
||||
setAchievements(prev => [...prev, result.achievement!]);
|
||||
return; // Only show one celebration at a time
|
||||
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(() => {
|
||||
@ -208,18 +229,41 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
setUsageData(usage);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
|
||||
// Check for new achievements immediately
|
||||
// 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 () => {
|
||||
const handleGeneratePlan = async (targetSubstance: 'nicotine' | 'weed') => {
|
||||
if (!preferences) return;
|
||||
|
||||
const plan = generateQuitPlan(preferences.substance);
|
||||
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,
|
||||
quitPlan: plan,
|
||||
quitState: updatedQuitState
|
||||
};
|
||||
|
||||
await savePreferencesAsync(updatedPrefs);
|
||||
setPreferences(updatedPrefs);
|
||||
setRefreshKey(prev => prev + 1);
|
||||
@ -314,11 +358,12 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
</div>
|
||||
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
|
||||
<MoodTracker />
|
||||
<QuitPlanCard
|
||||
key={`quit-plan-${refreshKey}`}
|
||||
plan={preferences.quitPlan}
|
||||
onGeneratePlan={handleGeneratePlan}
|
||||
{/* Unified Quit Plan Placard */}
|
||||
<UnifiedQuitPlanCard
|
||||
preferences={preferences}
|
||||
usageData={usageData}
|
||||
onGeneratePlan={handleGeneratePlan}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,165 +0,0 @@
|
||||
'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';
|
||||
import { Target, TrendingDown } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
|
||||
interface QuitPlanCardProps {
|
||||
plan: QuitPlan | null;
|
||||
onGeneratePlan: () => void;
|
||||
usageData: UsageEntry[];
|
||||
}
|
||||
|
||||
function QuitPlanCardComponent({
|
||||
plan,
|
||||
onGeneratePlan,
|
||||
usageData,
|
||||
}: QuitPlanCardProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Count unique days with any logged data
|
||||
const uniqueDaysWithData = new Set(usageData.map(e => e.date)).size;
|
||||
const daysRemaining = Math.max(0, 7 - uniqueDaysWithData);
|
||||
const hasEnoughData = uniqueDaysWithData >= 7;
|
||||
|
||||
// Calculate current average
|
||||
const totalUsage = usageData.reduce((sum, e) => sum + e.count, 0);
|
||||
const currentAverage = uniqueDaysWithData > 0 ? Math.round(totalUsage / uniqueDaysWithData) : 0;
|
||||
|
||||
// Yellow gradient for tracking phase (darker in light mode)
|
||||
const yellowBackground = theme === 'light'
|
||||
? 'linear-gradient(135deg, rgba(161, 98, 7, 0.85) 0%, rgba(133, 77, 14, 0.9) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(234, 179, 8, 0.2) 0%, rgba(202, 138, 4, 0.15) 100%)';
|
||||
|
||||
// Pink gradient for active plan (darker in light mode)
|
||||
const pinkBackground = theme === 'light'
|
||||
? 'linear-gradient(135deg, rgba(157, 23, 77, 0.85) 0%, rgba(131, 24, 67, 0.9) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(236, 72, 153, 0.2) 0%, rgba(219, 39, 119, 0.15) 100%)';
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<Card className="backdrop-blur-xl shadow-xl drop-shadow-lg border-yellow-500/40 hover-lift transition-all duration-300 overflow-hidden relative" style={{
|
||||
background: yellowBackground
|
||||
}}>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-yellow-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
<CardHeader className="relative z-10">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<Target className="h-5 w-5 text-yellow-400" />
|
||||
Your Personalized Plan
|
||||
</CardTitle>
|
||||
<CardDescription className="text-white/70">
|
||||
We're tracking your usage to build your custom quit plan
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 relative z-10">
|
||||
<div className="bg-yellow-500/20 border border-yellow-500/30 p-4 rounded-xl backdrop-blur-sm">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium text-white">Tracking Progress</span>
|
||||
<span className="text-sm text-yellow-300 font-bold">
|
||||
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-white/10 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-yellow-400 to-yellow-500 h-3 rounded-full transition-all duration-700 ease-out"
|
||||
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-white/60 mt-2 text-center">
|
||||
{uniqueDaysWithData} of 7 days tracked
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasEnoughData ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-white text-center">
|
||||
Great work! Your average daily usage is{' '}
|
||||
<strong className="text-yellow-300">{currentAverage}</strong> per day.
|
||||
</p>
|
||||
<Button onClick={onGeneratePlan} className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-semibold transition-all duration-300 hover:scale-[1.02] active:scale-[0.98]">
|
||||
<TrendingDown className="mr-2 h-4 w-4" />
|
||||
Generate My Quit Plan
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-white">
|
||||
Log your usage each day. After 7 days, we'll create a personalized plan to help you reduce by <strong className="text-yellow-300">25% each week</strong>.
|
||||
</p>
|
||||
<p className="text-xs text-white/60">
|
||||
Your plan will be tailored to your habits
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const startDate = new Date(plan.startDate);
|
||||
const today = new Date();
|
||||
const weekNumber = Math.floor(
|
||||
(today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 7)
|
||||
) + 1;
|
||||
const totalWeeks = plan.weeklyTargets.length;
|
||||
const currentTarget = weekNumber <= totalWeeks ? plan.weeklyTargets[weekNumber - 1] : 0;
|
||||
|
||||
return (
|
||||
<Card className="backdrop-blur-xl shadow-xl drop-shadow-lg border-pink-500/40 hover-lift transition-all duration-300 overflow-hidden relative" style={{
|
||||
background: pinkBackground
|
||||
}}>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-pink-500/10 to-transparent rounded-full -translate-y-1/2 translate-x-1/2 pointer-events-none" />
|
||||
<CardHeader className="relative z-10">
|
||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||
<TrendingDown className="h-5 w-5 text-pink-400" />
|
||||
Your Quit Plan
|
||||
</CardTitle>
|
||||
<CardDescription className="text-white/70">
|
||||
Week {Math.min(weekNumber, totalWeeks)} of {totalWeeks} - 25% weekly reduction
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 relative z-10">
|
||||
<div className="bg-gradient-to-br from-pink-500/25 to-pink-600/20 border border-pink-500/40 p-5 rounded-xl text-center backdrop-blur-sm">
|
||||
<p className="text-sm text-white/70 mb-1">This week's daily target</p>
|
||||
<p className="text-5xl font-bold text-pink-300 text-shadow">
|
||||
{currentTarget !== null && currentTarget > 0 ? currentTarget : '0'}
|
||||
</p>
|
||||
<p className="text-sm text-white/60">per day</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 relative z-10">
|
||||
<p className="text-sm font-medium text-white">Weekly targets:</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{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
|
||||
? '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'
|
||||
: 'bg-white/10 text-white/60'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs">Week {index + 1}</p>
|
||||
<p className="font-bold">{target}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-white/70 bg-white/5 p-3 rounded-lg">
|
||||
<p>
|
||||
<strong className="text-white">Started at:</strong> {plan.baselineAverage}/day
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-white">Goal:</strong> Quit by {new Date(plan.endDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
export const QuitPlanCard = React.memo(QuitPlanCardComponent);
|
||||
294
src/components/UnifiedQuitPlanCard.tsx
Normal file
294
src/components/UnifiedQuitPlanCard.tsx
Normal file
@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QuitPlan, UsageEntry, UserPreferences } from '@/lib/storage';
|
||||
import { Target, TrendingDown, ChevronDown, ChevronUp, Cigarette, Leaf } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/theme-context';
|
||||
import { getTodayString } from '@/lib/date-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SubstancePlanSectionProps {
|
||||
substance: 'nicotine' | 'weed';
|
||||
plan: QuitPlan | null;
|
||||
usageData: UsageEntry[];
|
||||
trackingStartDate: string | null;
|
||||
onGeneratePlan: () => void;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function SubstancePlanSection({
|
||||
substance,
|
||||
plan,
|
||||
usageData,
|
||||
trackingStartDate,
|
||||
onGeneratePlan,
|
||||
isExpanded,
|
||||
onToggle
|
||||
}: SubstancePlanSectionProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// 1. Data Processing
|
||||
const substanceUsage = useMemo(() => usageData.filter(e => e.substance === substance), [usageData, substance]);
|
||||
const uniqueDaysWithData = useMemo(() => new Set(substanceUsage.map(e => e.date)).size, [substanceUsage]);
|
||||
const daysRemaining = Math.max(0, 7 - uniqueDaysWithData);
|
||||
|
||||
const totalUsage = substanceUsage.reduce((sum, e) => sum + e.count, 0);
|
||||
const currentAverage = uniqueDaysWithData > 0 ? Math.round(totalUsage / uniqueDaysWithData) : 0;
|
||||
|
||||
// 2. Unlock Logic
|
||||
const isUnlocked = useMemo(() => {
|
||||
if (!trackingStartDate || uniqueDaysWithData < 7) return false;
|
||||
const [y, m, d] = trackingStartDate.split('-').map(Number);
|
||||
const startObj = new Date(y, m - 1, d);
|
||||
const now = new Date();
|
||||
const todayObj = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const daysPassed = Math.floor((todayObj.getTime() - startObj.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return daysPassed >= 7 || uniqueDaysWithData > 7;
|
||||
}, [uniqueDaysWithData, trackingStartDate]);
|
||||
|
||||
// 3. Plan Validation & Calculations
|
||||
const isValidPlan = plan && plan.startDate && plan.weeklyTargets && Array.isArray(plan.weeklyTargets);
|
||||
const activePlan = isValidPlan ? plan : null;
|
||||
|
||||
const todayStr = getTodayString();
|
||||
const todayUsage = substanceUsage
|
||||
.filter(e => e.date === todayStr)
|
||||
.reduce((sum, e) => sum + e.count, 0);
|
||||
|
||||
let weekNumber = 0;
|
||||
let currentTarget = 0;
|
||||
let totalWeeks = 0;
|
||||
let usagePercent = 0;
|
||||
|
||||
if (activePlan) {
|
||||
const startDate = new Date(activePlan.startDate);
|
||||
const today = new Date();
|
||||
weekNumber = Math.floor((today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 7)) + 1;
|
||||
totalWeeks = activePlan.weeklyTargets.length;
|
||||
currentTarget = weekNumber <= totalWeeks ? activePlan.weeklyTargets[weekNumber - 1] : 0;
|
||||
usagePercent = currentTarget > 0 ? (todayUsage / currentTarget) * 100 : 0;
|
||||
}
|
||||
|
||||
// 4. Styling
|
||||
const isNicotine = substance === 'nicotine';
|
||||
const Icon = isNicotine ? Cigarette : Leaf;
|
||||
const label = isNicotine ? 'Nicotine' : 'Weed';
|
||||
|
||||
// Base Colors
|
||||
const bgColor = isNicotine
|
||||
? (theme === 'light' ? 'bg-yellow-500/10' : 'bg-yellow-500/5')
|
||||
: (theme === 'light' ? 'bg-emerald-500/10' : 'bg-emerald-500/5');
|
||||
|
||||
const borderColor = isNicotine
|
||||
? (theme === 'light' ? 'border-yellow-500/20' : 'border-yellow-500/10')
|
||||
: (theme === 'light' ? 'border-emerald-500/20' : 'border-emerald-500/10');
|
||||
|
||||
const accentColor = isNicotine ? 'text-yellow-500' : 'text-emerald-500';
|
||||
const progressFill = isNicotine ? 'bg-yellow-500' : 'bg-emerald-500';
|
||||
|
||||
// Specific plan color for the progress bar alert states
|
||||
let progressColor = progressFill;
|
||||
if (activePlan) {
|
||||
if (usagePercent >= 100) progressColor = 'bg-red-500';
|
||||
else if (usagePercent >= 80) progressColor = isNicotine ? 'bg-orange-400' : 'bg-yellow-400';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-xl border transition-all duration-300 overflow-hidden mb-3", bgColor, borderColor)}>
|
||||
{/* HEADER / SUMMARY ROW */}
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-black/5 active:bg-black/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg", isNicotine ? "bg-yellow-500/20" : "bg-emerald-500/20")}>
|
||||
<Icon className={cn("h-5 w-5", accentColor)} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm sm:text-base">{label} Plan</h3>
|
||||
<p className="text-[10px] opacity-50 uppercase tracking-wider font-bold">
|
||||
{activePlan ? `Week ${Math.min(weekNumber, totalWeeks)} of ${totalWeeks}` : `Tracking: Day ${uniqueDaysWithData}/7`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:flex flex-col items-end">
|
||||
<span className="text-xs font-bold opacity-60 uppercase">Today</span>
|
||||
<span className={cn("text-sm font-black", activePlan && usagePercent >= 100 ? "text-red-500" : accentColor)}>
|
||||
{todayUsage}{activePlan ? ` / ${currentTarget}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="h-5 w-5 opacity-30" /> : <ChevronDown className="h-5 w-5 opacity-30" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EXPANDED CONTENT */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="h-px w-full bg-border mb-4 opacity-30" />
|
||||
|
||||
{!activePlan ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-black/5 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-medium">Weekly Baseline Progress</span>
|
||||
<span className={cn("text-xs font-bold", accentColor)}>
|
||||
{daysRemaining > 0 ? `${daysRemaining} days left` : 'Ready!'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-black/10 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-700", progressFill)}
|
||||
style={{ width: `${Math.min(100, (uniqueDaysWithData / 7) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUnlocked ? (
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm">
|
||||
Baseline established: <strong className={accentColor}>{currentAverage} puffs/day</strong>
|
||||
</p>
|
||||
<Button onClick={(e) => { e.stopPropagation(); onGeneratePlan(); }} size="sm" className={cn("w-full h-10 font-bold", progressFill, "text-white hover:opacity-90")}>
|
||||
Generate Plan
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-center opacity-70 italic">
|
||||
Keep logging for {daysRemaining} more days to calculate your personalized reduction plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Active Plan Detail */}
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] font-bold uppercase opacity-50 mb-1">Current Daily Limit</p>
|
||||
<p className={cn("text-4xl font-black", accentColor)}>{currentTarget}</p>
|
||||
<p className="text-xs opacity-50 mt-1">puffs allowed today</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar Detail */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-[10px] uppercase font-bold opacity-60">
|
||||
<span>Usage Progress</span>
|
||||
<span>{Math.round(usagePercent)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-black/10 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-500", progressColor)}
|
||||
style={{ width: `${Math.min(100, usagePercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Matrix */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{activePlan.weeklyTargets.map((target, idx) => {
|
||||
const wNum = idx + 1;
|
||||
const isFuture = wNum > weekNumber;
|
||||
const isCurrent = wNum === weekNumber;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"text-center p-2 rounded-lg border transition-all",
|
||||
isCurrent
|
||||
? `${progressFill} border-transparent text-white shadow-lg scale-105`
|
||||
: isFuture
|
||||
? "bg-black/5 opacity-40 border-transparent"
|
||||
: "opacity-60 border-current"
|
||||
)}
|
||||
>
|
||||
<p className="text-[9px] uppercase font-black">Wk {wNum}</p>
|
||||
<p className="text-sm font-bold">{isFuture ? '?' : target}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-black/5 p-3 rounded-lg flex justify-between items-center text-[10px] uppercase font-bold opacity-50">
|
||||
<span>Start: {activePlan.baselineAverage}/day</span>
|
||||
<span>End: {new Date(activePlan.endDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UnifiedQuitPlanCardProps {
|
||||
preferences: UserPreferences | null;
|
||||
usageData: UsageEntry[];
|
||||
onGeneratePlan: (substance: 'nicotine' | 'weed') => void;
|
||||
refreshKey: number;
|
||||
}
|
||||
|
||||
export function UnifiedQuitPlanCard({
|
||||
preferences,
|
||||
usageData,
|
||||
onGeneratePlan,
|
||||
refreshKey
|
||||
}: UnifiedQuitPlanCardProps) {
|
||||
const [expandedSubstance, setExpandedSubstance] = useState<'nicotine' | 'weed' | 'none'>('nicotine');
|
||||
|
||||
if (!preferences) return null;
|
||||
|
||||
// Determine which substances to show
|
||||
const showNicotine = preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine');
|
||||
const showWeed = preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed');
|
||||
|
||||
if (!showNicotine && !showWeed) return null;
|
||||
|
||||
return (
|
||||
<Card className="backdrop-blur-xl shadow-xl border-white/10 overflow-hidden">
|
||||
<CardHeader className="pb-3 border-b border-white/5 bg-white/5">
|
||||
<CardTitle className="flex items-center gap-2 text-sm sm:text-base font-black uppercase tracking-widest opacity-80">
|
||||
<TrendingDown className="h-5 w-5 text-primary" />
|
||||
Quit Journey Plan
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 p-3 sm:p-6">
|
||||
{showNicotine && (
|
||||
<SubstancePlanSection
|
||||
substance="nicotine"
|
||||
isExpanded={expandedSubstance === 'nicotine'}
|
||||
onToggle={() => setExpandedSubstance(expandedSubstance === 'nicotine' ? 'none' : 'nicotine')}
|
||||
plan={preferences.quitState?.nicotine?.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
|
||||
usageData={usageData}
|
||||
trackingStartDate={
|
||||
preferences.quitState?.nicotine?.startDate ||
|
||||
(preferences.substance === 'nicotine' ? preferences.trackingStartDate : null) ||
|
||||
usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||
null
|
||||
}
|
||||
onGeneratePlan={() => onGeneratePlan('nicotine')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showWeed && (
|
||||
<SubstancePlanSection
|
||||
substance="weed"
|
||||
isExpanded={expandedSubstance === 'weed'}
|
||||
onToggle={() => setExpandedSubstance(expandedSubstance === 'weed' ? 'none' : 'weed')}
|
||||
plan={preferences.quitState?.weed?.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
|
||||
usageData={usageData}
|
||||
trackingStartDate={
|
||||
preferences.quitState?.weed?.startDate ||
|
||||
(preferences.substance === 'weed' ? preferences.trackingStartDate : null) ||
|
||||
usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||
null
|
||||
}
|
||||
onGeneratePlan={() => onGeneratePlan('weed')}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -7,17 +7,26 @@ export interface UsageEntry {
|
||||
substance: 'nicotine' | 'weed';
|
||||
}
|
||||
|
||||
export interface SubstanceState {
|
||||
plan: QuitPlan | null;
|
||||
startDate: string | null;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
substance: 'nicotine' | 'weed';
|
||||
trackingStartDate: string | null;
|
||||
hasCompletedSetup: boolean;
|
||||
dailyGoal: number | null;
|
||||
quitPlan: QuitPlan | null;
|
||||
quitState?: { // NEW: Flexible container for dual state
|
||||
nicotine: SubstanceState;
|
||||
weed: SubstanceState;
|
||||
};
|
||||
userName: string | null;
|
||||
userAge: number | null;
|
||||
religion: 'christian' | 'secular' | null;
|
||||
lastNicotineUsageTime?: string | null; // ISO timestamp of last usage
|
||||
lastWeedUsageTime?: string | null; // ISO timestamp of last usage
|
||||
lastNicotineUsageTime?: string | null;
|
||||
lastWeedUsageTime?: string | null;
|
||||
}
|
||||
|
||||
export interface QuitPlan {
|
||||
@ -110,6 +119,10 @@ const defaultPreferences: UserPreferences = {
|
||||
hasCompletedSetup: false,
|
||||
dailyGoal: null,
|
||||
quitPlan: null,
|
||||
quitState: {
|
||||
nicotine: { plan: null, startDate: null },
|
||||
weed: { plan: null, startDate: null }
|
||||
},
|
||||
userName: null,
|
||||
userAge: null,
|
||||
religion: null,
|
||||
@ -145,7 +158,7 @@ export function getCurrentUserId(): string | null {
|
||||
export async function fetchPreferences(): Promise<UserPreferences> {
|
||||
if (preferencesCache) return preferencesCache;
|
||||
try {
|
||||
const response = await fetch('/api/preferences');
|
||||
const response = await fetch('/api/preferences', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch preferences');
|
||||
return defaultPreferences;
|
||||
@ -177,7 +190,7 @@ export async function savePreferencesAsync(preferences: UserPreferences): Promis
|
||||
export async function fetchUsageData(): Promise<UsageEntry[]> {
|
||||
if (usageDataCache) return usageDataCache;
|
||||
try {
|
||||
const response = await fetch('/api/usage');
|
||||
const response = await fetch('/api/usage', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch usage data');
|
||||
return [];
|
||||
@ -240,7 +253,7 @@ export async function clearDayDataAsync(
|
||||
export async function fetchAchievements(): Promise<Achievement[]> {
|
||||
if (achievementsCache) return achievementsCache;
|
||||
try {
|
||||
const response = await fetch('/api/achievements');
|
||||
const response = await fetch('/api/achievements', { cache: 'no-store' });
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json() as Achievement[];
|
||||
achievementsCache = data;
|
||||
@ -324,7 +337,7 @@ export function getReminderSettings(): ReminderSettings {
|
||||
export async function fetchSavingsConfig(): Promise<SavingsConfig | null> {
|
||||
if (savingsConfigCache) return savingsConfigCache;
|
||||
try {
|
||||
const response = await fetch('/api/savings');
|
||||
const response = await fetch('/api/savings', { cache: 'no-store' });
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as SavingsConfig | null;
|
||||
savingsConfigCache = data;
|
||||
@ -359,7 +372,7 @@ export function getSavingsConfig(): SavingsConfig | null {
|
||||
export async function fetchMoodEntries(): Promise<MoodEntry[]> {
|
||||
if (moodEntriesCache) return moodEntriesCache;
|
||||
try {
|
||||
const response = await fetch('/api/mood');
|
||||
const response = await fetch('/api/mood', { cache: 'no-store' });
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json() as MoodEntry[];
|
||||
moodEntriesCache = data;
|
||||
@ -412,7 +425,10 @@ export function calculateStreak(
|
||||
for (let i = 0; i <= 365; i++) {
|
||||
const checkDate = new Date(today);
|
||||
checkDate.setDate(checkDate.getDate() - i);
|
||||
const dateStr = checkDate.toISOString().split('T')[0];
|
||||
// Use local date string to match storage format
|
||||
const offset = checkDate.getTimezoneOffset();
|
||||
const localDate = new Date(checkDate.getTime() - (offset * 60 * 1000));
|
||||
const dateStr = localDate.toISOString().split('T')[0];
|
||||
|
||||
// O(1) lookup
|
||||
const dayUsage = substanceMap.get(dateStr) ?? -1;
|
||||
@ -496,7 +512,10 @@ export function checkBadgeEligibility(
|
||||
for (let i = 0; i <= 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const ds = d.toISOString().split('T')[0];
|
||||
// Use local date string to match storage format
|
||||
const offset = d.getTimezoneOffset();
|
||||
const localDate = new Date(d.getTime() - (offset * 60 * 1000));
|
||||
const ds = localDate.toISOString().split('T')[0];
|
||||
const val = map.get(ds) ?? -1;
|
||||
if (val === 0) streak++;
|
||||
else if (val > 0) break;
|
||||
@ -507,23 +526,35 @@ export function checkBadgeEligibility(
|
||||
const streak = getStreakFromMap(substance === 'nicotine' ? stats.nicotineMap : stats.weedMap);
|
||||
|
||||
const checkMonthlyReduction = (): boolean => {
|
||||
const checkDate = new Date();
|
||||
// Use local dates to avoid UTC offset issues
|
||||
const offset = checkDate.getTimezoneOffset();
|
||||
const todayLocal = new Date(checkDate.getTime() - (offset * 60 * 1000));
|
||||
|
||||
if (!preferences.trackingStartDate) return false;
|
||||
const start = new Date(preferences.trackingStartDate);
|
||||
const today = new Date();
|
||||
const daysSinceStart = Math.floor((today.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Parse start date as local
|
||||
const [y, m, d] = preferences.trackingStartDate.split('-').map(Number);
|
||||
const startLocal = new Date(y, m - 1, d); // Month is 0-indexed in Date constructor
|
||||
|
||||
const daysSinceStart = Math.floor((todayLocal.getTime() - startLocal.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysSinceStart < 30) return false;
|
||||
|
||||
// Use current Map for O(1) lookups in week buckets
|
||||
let firstWeekTotal = 0;
|
||||
let lastWeekTotal = 0;
|
||||
|
||||
const startTime = start.getTime();
|
||||
const todayTime = today.getTime();
|
||||
const startTime = startLocal.getTime();
|
||||
const todayTime = todayLocal.getTime();
|
||||
const msInDay = 1000 * 60 * 60 * 24;
|
||||
|
||||
for (const entry of usageData) {
|
||||
if (entry.substance !== substance) continue;
|
||||
const entryTime = new Date(entry.date).getTime();
|
||||
|
||||
// Parse entry date as local
|
||||
const [ey, em, ed] = entry.date.split('-').map(Number);
|
||||
const entryTime = new Date(ey, em - 1, ed).getTime();
|
||||
|
||||
const daysSinceEntryStart = Math.floor((entryTime - startTime) / msInDay);
|
||||
const daysAgo = Math.floor((todayTime - entryTime) / msInDay);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user