feat: Implement independent nicotine/weed quit plans with refined UI and auto-unlock logic
This commit is contained in:
parent
7046febd00
commit
75a75fd499
@ -54,6 +54,7 @@ export async function POST(request: NextRequest) {
|
|||||||
hasCompletedSetup?: boolean;
|
hasCompletedSetup?: boolean;
|
||||||
dailyGoal?: number;
|
dailyGoal?: number;
|
||||||
quitPlan?: unknown;
|
quitPlan?: unknown;
|
||||||
|
quitState?: unknown;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
userAge?: number;
|
userAge?: number;
|
||||||
religion?: string;
|
religion?: string;
|
||||||
@ -61,12 +62,17 @@ export async function POST(request: NextRequest) {
|
|||||||
lastWeedUsageTime?: string;
|
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, {
|
const preferences = await upsertPreferencesD1(session.user.id, {
|
||||||
substance: body.substance,
|
substance: body.substance,
|
||||||
trackingStartDate: body.trackingStartDate,
|
trackingStartDate: body.trackingStartDate,
|
||||||
hasCompletedSetup: body.hasCompletedSetup ? 1 : 0,
|
hasCompletedSetup: body.hasCompletedSetup ? 1 : 0,
|
||||||
dailyGoal: body.dailyGoal,
|
dailyGoal: body.dailyGoal,
|
||||||
quitPlanJson: body.quitPlan ? JSON.stringify(body.quitPlan) : undefined,
|
quitPlanJson: quitPlanJson,
|
||||||
userName: body.userName,
|
userName: body.userName,
|
||||||
userAge: body.userAge,
|
userAge: body.userAge,
|
||||||
religion: body.religion,
|
religion: body.religion,
|
||||||
@ -78,12 +84,21 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Failed to save preferences' }, { status: 500 });
|
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({
|
return NextResponse.json({
|
||||||
substance: preferences.substance,
|
substance: preferences.substance,
|
||||||
trackingStartDate: preferences.trackingStartDate,
|
trackingStartDate: preferences.trackingStartDate,
|
||||||
hasCompletedSetup: !!preferences.hasCompletedSetup,
|
hasCompletedSetup: !!preferences.hasCompletedSetup,
|
||||||
dailyGoal: preferences.dailyGoal,
|
dailyGoal: preferences.dailyGoal,
|
||||||
quitPlan: preferences.quitPlanJson ? JSON.parse(preferences.quitPlanJson) : null,
|
quitPlan: null,
|
||||||
|
quitState,
|
||||||
userName: preferences.userName,
|
userName: preferences.userName,
|
||||||
userAge: preferences.userAge,
|
userAge: preferences.userAge,
|
||||||
religion: preferences.religion,
|
religion: preferences.religion,
|
||||||
|
|||||||
@ -106,7 +106,10 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
prefs: UserPreferences,
|
prefs: UserPreferences,
|
||||||
currentAchievements: Achievement[]
|
currentAchievements: Achievement[]
|
||||||
) => {
|
) => {
|
||||||
|
// Current unlocked set (local + server)
|
||||||
const unlockedIds = new Set(currentAchievements.map(a => `${a.badgeId}-${a.substance}`));
|
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 badge of BADGE_DEFINITIONS) {
|
||||||
for (const substance of ['nicotine', 'weed'] as const) {
|
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);
|
const isEligible = checkBadgeEligibility(badge.id, usage, prefs, substance);
|
||||||
if (isEligible) {
|
if (isEligible) {
|
||||||
const result = await unlockAchievement(badge.id, substance);
|
try {
|
||||||
if (result.isNew && result.achievement) {
|
const result = await unlockAchievement(badge.id, substance);
|
||||||
setNewBadge(badge);
|
if (result.isNew && result.achievement) {
|
||||||
setShowCelebration(true);
|
newUnlocked.push(result.achievement);
|
||||||
setAchievements(prev => [...prev, result.achievement!]);
|
// Prioritize celebrating the first one found
|
||||||
return; // Only show one celebration at a time
|
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(() => {
|
useEffect(() => {
|
||||||
@ -208,18 +229,41 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
setUsageData(usage);
|
setUsageData(usage);
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
|
|
||||||
// Check for new achievements immediately
|
// Check for new achievements metrics FIRST
|
||||||
await checkAndUnlockAchievements(usage, latestPrefs, achievements);
|
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;
|
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 = {
|
const updatedPrefs = {
|
||||||
...preferences,
|
...preferences,
|
||||||
quitPlan: plan,
|
quitState: updatedQuitState
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePreferencesAsync(updatedPrefs);
|
await savePreferencesAsync(updatedPrefs);
|
||||||
setPreferences(updatedPrefs);
|
setPreferences(updatedPrefs);
|
||||||
setRefreshKey(prev => prev + 1);
|
setRefreshKey(prev => prev + 1);
|
||||||
@ -314,12 +358,41 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
|
<div className="space-y-4 sm:grid sm:grid-cols-2 sm:gap-6 sm:space-y-0">
|
||||||
<MoodTracker />
|
<MoodTracker />
|
||||||
<QuitPlanCard
|
{/* Nicotine Plan */}
|
||||||
key={`quit-plan-${refreshKey}`}
|
{(preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine')) && (
|
||||||
plan={preferences.quitPlan}
|
<QuitPlanCard
|
||||||
onGeneratePlan={handleGeneratePlan}
|
key={`quit-plan-nicotine-${refreshKey}`}
|
||||||
usageData={usageData}
|
plan={preferences.quitState?.nicotine.plan || (preferences.substance === 'nicotine' ? preferences.quitPlan : null)}
|
||||||
/>
|
onGeneratePlan={() => handleGeneratePlan('nicotine')}
|
||||||
|
usageData={usageData}
|
||||||
|
trackingStartDate={
|
||||||
|
preferences.quitState?.nicotine.startDate ||
|
||||||
|
(preferences.substance === 'nicotine' ? preferences.trackingStartDate : null) ||
|
||||||
|
// Fallback: Find earliest usage date
|
||||||
|
usageData.filter(e => e.substance === 'nicotine').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
substance="nicotine"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Weed Plan */}
|
||||||
|
{(preferences.substance === 'weed' || usageData.some(e => e.substance === 'weed')) && (
|
||||||
|
<QuitPlanCard
|
||||||
|
key={`quit-plan-weed-${refreshKey}`}
|
||||||
|
plan={preferences.quitState?.weed.plan || (preferences.substance === 'weed' ? preferences.quitPlan : null)}
|
||||||
|
onGeneratePlan={() => handleGeneratePlan('weed')}
|
||||||
|
usageData={usageData}
|
||||||
|
trackingStartDate={
|
||||||
|
preferences.quitState?.weed.startDate ||
|
||||||
|
(preferences.substance === 'weed' ? preferences.trackingStartDate : null) ||
|
||||||
|
// Fallback: Find earliest usage date
|
||||||
|
usageData.filter(e => e.substance === 'weed').sort((a, b) => a.date.localeCompare(b.date))[0]?.date ||
|
||||||
|
null
|
||||||
|
}
|
||||||
|
substance="weed"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -6,27 +6,59 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { QuitPlan, UsageEntry } from '@/lib/storage';
|
import { QuitPlan, UsageEntry } from '@/lib/storage';
|
||||||
import { Target, TrendingDown } from 'lucide-react';
|
import { Target, TrendingDown } from 'lucide-react';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
|
import { getTodayString } from '@/lib/date-utils';
|
||||||
|
|
||||||
interface QuitPlanCardProps {
|
interface QuitPlanCardProps {
|
||||||
plan: QuitPlan | null;
|
plan: QuitPlan | null;
|
||||||
onGeneratePlan: () => void;
|
onGeneratePlan: () => void;
|
||||||
usageData: UsageEntry[];
|
usageData: UsageEntry[];
|
||||||
|
trackingStartDate: string | null;
|
||||||
|
substance: 'nicotine' | 'weed';
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuitPlanCardComponent({
|
function QuitPlanCardComponent({
|
||||||
plan,
|
plan,
|
||||||
onGeneratePlan,
|
onGeneratePlan,
|
||||||
usageData,
|
usageData,
|
||||||
|
trackingStartDate,
|
||||||
|
substance,
|
||||||
}: QuitPlanCardProps) {
|
}: QuitPlanCardProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
// Count unique days with any logged data
|
// Count unique days with any logged data
|
||||||
const uniqueDaysWithData = new Set(usageData.map(e => e.date)).size;
|
const uniqueDaysWithData = new Set(usageData.filter(e => e.substance === substance).map(e => e.date)).size;
|
||||||
const daysRemaining = Math.max(0, 7 - uniqueDaysWithData);
|
const daysRemaining = Math.max(0, 7 - uniqueDaysWithData);
|
||||||
const hasEnoughData = uniqueDaysWithData >= 7;
|
|
||||||
|
// Logic: Unlocked if 7+ days tracked AND (It's Day 8+ OR usage exists for Day 8+)
|
||||||
|
// This effectively locks it until 12:01 AM next day after Day 7 is done
|
||||||
|
const isUnlocked = React.useMemo(() => {
|
||||||
|
// Determine the local start date cleanly (ignoring time)
|
||||||
|
if (!trackingStartDate || uniqueDaysWithData < 7) return false;
|
||||||
|
|
||||||
|
// Parse YYYY-MM-DD
|
||||||
|
const [y, m, d] = trackingStartDate.split('-').map(Number);
|
||||||
|
const startObj = new Date(y, m - 1, d); // Local midnight
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
// Get today's local midnight
|
||||||
|
const todayObj = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
// Calculate difference in full days
|
||||||
|
// Jan 1 to Jan 8: difference of 7 days.
|
||||||
|
const diffTime = todayObj.getTime() - startObj.getTime();
|
||||||
|
const daysPassed = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// If 7 days have passed (meaning we are on Day 8 or later), unlock.
|
||||||
|
if (daysPassed >= 7) return true;
|
||||||
|
|
||||||
|
// Also check if usage count is > 7, implying usage beyond the first week
|
||||||
|
if (uniqueDaysWithData > 7) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [uniqueDaysWithData, trackingStartDate]);
|
||||||
|
|
||||||
// Calculate current average
|
// Calculate current average
|
||||||
const totalUsage = usageData.reduce((sum, e) => sum + e.count, 0);
|
const totalUsage = usageData.filter(e => e.substance === substance).reduce((sum, e) => sum + e.count, 0);
|
||||||
const currentAverage = uniqueDaysWithData > 0 ? Math.round(totalUsage / uniqueDaysWithData) : 0;
|
const currentAverage = uniqueDaysWithData > 0 ? Math.round(totalUsage / uniqueDaysWithData) : 0;
|
||||||
|
|
||||||
// Yellow gradient for tracking phase (darker in light mode)
|
// Yellow gradient for tracking phase (darker in light mode)
|
||||||
@ -48,7 +80,7 @@ function QuitPlanCardComponent({
|
|||||||
<CardHeader className="relative z-10">
|
<CardHeader className="relative z-10">
|
||||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||||
<Target className="h-5 w-5 text-yellow-400" />
|
<Target className="h-5 w-5 text-yellow-400" />
|
||||||
Your Personalized Plan
|
Your {substance === 'nicotine' ? 'Nicotine' : 'Weed'} Quit Plan
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-white/70">
|
<CardDescription className="text-white/70">
|
||||||
We're tracking your usage to build your custom quit plan
|
We're tracking your usage to build your custom quit plan
|
||||||
@ -73,7 +105,7 @@ function QuitPlanCardComponent({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasEnoughData ? (
|
{isUnlocked ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-white text-center">
|
<p className="text-sm text-white text-center">
|
||||||
Great work! Your average daily usage is{' '}
|
Great work! Your average daily usage is{' '}
|
||||||
@ -107,6 +139,19 @@ function QuitPlanCardComponent({
|
|||||||
const totalWeeks = plan.weeklyTargets.length;
|
const totalWeeks = plan.weeklyTargets.length;
|
||||||
const currentTarget = weekNumber <= totalWeeks ? plan.weeklyTargets[weekNumber - 1] : 0;
|
const currentTarget = weekNumber <= totalWeeks ? plan.weeklyTargets[weekNumber - 1] : 0;
|
||||||
|
|
||||||
|
// Calculate today's usage for progress bar
|
||||||
|
const todayStr = getTodayString();
|
||||||
|
const todayUsage = usageData
|
||||||
|
.filter(e => e.date === todayStr && e.substance === substance)
|
||||||
|
.reduce((sum, e) => sum + e.count, 0);
|
||||||
|
|
||||||
|
const usagePercent = currentTarget > 0 ? (todayUsage / currentTarget) * 100 : 0;
|
||||||
|
|
||||||
|
// Progress bar color based on usage
|
||||||
|
let progressColor = 'bg-emerald-400'; // Good
|
||||||
|
if (usagePercent >= 100) progressColor = 'bg-red-500'; // Over limit
|
||||||
|
else if (usagePercent >= 80) progressColor = 'bg-yellow-400'; // Warning
|
||||||
|
|
||||||
return (
|
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={{
|
<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
|
background: pinkBackground
|
||||||
@ -115,7 +160,7 @@ function QuitPlanCardComponent({
|
|||||||
<CardHeader className="relative z-10">
|
<CardHeader className="relative z-10">
|
||||||
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
<CardTitle className="flex items-center gap-2 text-white text-shadow-sm">
|
||||||
<TrendingDown className="h-5 w-5 text-pink-400" />
|
<TrendingDown className="h-5 w-5 text-pink-400" />
|
||||||
Your Quit Plan
|
Your {substance === 'nicotine' ? 'Nicotine' : 'Weed'} Plan
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-white/70">
|
<CardDescription className="text-white/70">
|
||||||
Week {Math.min(weekNumber, totalWeeks)} of {totalWeeks} - 25% weekly reduction
|
Week {Math.min(weekNumber, totalWeeks)} of {totalWeeks} - 25% weekly reduction
|
||||||
@ -123,30 +168,49 @@ function QuitPlanCardComponent({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 relative z-10">
|
<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">
|
<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-sm text-white/70 mb-1">{substance === 'nicotine' ? 'Nicotine' : 'Weed'} Max Puffs Target</p>
|
||||||
<p className="text-5xl font-bold text-pink-300 text-shadow">
|
<p className="text-5xl font-bold text-pink-300 text-shadow">
|
||||||
{currentTarget !== null && currentTarget > 0 ? currentTarget : '0'}
|
{currentTarget !== null && currentTarget > 0 ? currentTarget : '0'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-white/60">per day</p>
|
<p className="text-sm text-white/60 mb-3">per day</p>
|
||||||
|
|
||||||
|
{/* Daily Progress Bar */}
|
||||||
|
<div className="w-full bg-black/20 rounded-full h-2 mb-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${progressColor}`}
|
||||||
|
style={{ width: `${Math.min(100, usagePercent)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/60">
|
||||||
|
{todayUsage} used / {currentTarget} allowed
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 relative z-10">
|
<div className="space-y-2 relative z-10">
|
||||||
<p className="text-sm font-medium text-white">Weekly targets:</p>
|
<p className="text-sm font-medium text-white">Weekly targets:</p>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{plan.weeklyTargets.map((target, index) => (
|
{plan.weeklyTargets.map((target, index) => {
|
||||||
<div
|
const weekNum = index + 1;
|
||||||
key={index}
|
const isFuture = weekNum > weekNumber;
|
||||||
className={`text-center p-2 rounded-lg transition-all duration-200 hover:scale-105 ${index + 1 === weekNumber
|
const isCurrent = weekNum === weekNumber;
|
||||||
? 'bg-gradient-to-br from-pink-500 to-pink-600 text-white shadow-lg shadow-pink-500/30'
|
|
||||||
: index + 1 < weekNumber
|
return (
|
||||||
? 'bg-pink-900/50 text-pink-200'
|
<div
|
||||||
: 'bg-white/10 text-white/60'
|
key={index}
|
||||||
}`}
|
className={`text-center p-2 rounded-lg transition-all duration-200 ${isCurrent
|
||||||
>
|
? 'bg-gradient-to-br from-pink-500 to-pink-600 text-white shadow-lg shadow-pink-500/30 scale-105'
|
||||||
<p className="text-xs">Week {index + 1}</p>
|
: isFuture
|
||||||
<p className="font-bold">{target}</p>
|
? 'bg-white/5 text-white/40'
|
||||||
</div>
|
: 'bg-pink-900/50 text-pink-200'
|
||||||
))}
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs">Week {weekNum}</p>
|
||||||
|
<p className="font-bold">
|
||||||
|
{isFuture ? '?' : target}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -7,17 +7,26 @@ export interface UsageEntry {
|
|||||||
substance: 'nicotine' | 'weed';
|
substance: 'nicotine' | 'weed';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubstanceState {
|
||||||
|
plan: QuitPlan | null;
|
||||||
|
startDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
substance: 'nicotine' | 'weed';
|
substance: 'nicotine' | 'weed';
|
||||||
trackingStartDate: string | null;
|
trackingStartDate: string | null;
|
||||||
hasCompletedSetup: boolean;
|
hasCompletedSetup: boolean;
|
||||||
dailyGoal: number | null;
|
dailyGoal: number | null;
|
||||||
quitPlan: QuitPlan | null;
|
quitPlan: QuitPlan | null;
|
||||||
|
quitState?: { // NEW: Flexible container for dual state
|
||||||
|
nicotine: SubstanceState;
|
||||||
|
weed: SubstanceState;
|
||||||
|
};
|
||||||
userName: string | null;
|
userName: string | null;
|
||||||
userAge: number | null;
|
userAge: number | null;
|
||||||
religion: 'christian' | 'secular' | null;
|
religion: 'christian' | 'secular' | null;
|
||||||
lastNicotineUsageTime?: string | null; // ISO timestamp of last usage
|
lastNicotineUsageTime?: string | null;
|
||||||
lastWeedUsageTime?: string | null; // ISO timestamp of last usage
|
lastWeedUsageTime?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuitPlan {
|
export interface QuitPlan {
|
||||||
@ -110,6 +119,10 @@ const defaultPreferences: UserPreferences = {
|
|||||||
hasCompletedSetup: false,
|
hasCompletedSetup: false,
|
||||||
dailyGoal: null,
|
dailyGoal: null,
|
||||||
quitPlan: null,
|
quitPlan: null,
|
||||||
|
quitState: {
|
||||||
|
nicotine: { plan: null, startDate: null },
|
||||||
|
weed: { plan: null, startDate: null }
|
||||||
|
},
|
||||||
userName: null,
|
userName: null,
|
||||||
userAge: null,
|
userAge: null,
|
||||||
religion: null,
|
religion: null,
|
||||||
@ -145,7 +158,7 @@ export function getCurrentUserId(): string | null {
|
|||||||
export async function fetchPreferences(): Promise<UserPreferences> {
|
export async function fetchPreferences(): Promise<UserPreferences> {
|
||||||
if (preferencesCache) return preferencesCache;
|
if (preferencesCache) return preferencesCache;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/preferences');
|
const response = await fetch('/api/preferences', { cache: 'no-store' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch preferences');
|
console.error('Failed to fetch preferences');
|
||||||
return defaultPreferences;
|
return defaultPreferences;
|
||||||
@ -177,7 +190,7 @@ export async function savePreferencesAsync(preferences: UserPreferences): Promis
|
|||||||
export async function fetchUsageData(): Promise<UsageEntry[]> {
|
export async function fetchUsageData(): Promise<UsageEntry[]> {
|
||||||
if (usageDataCache) return usageDataCache;
|
if (usageDataCache) return usageDataCache;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/usage');
|
const response = await fetch('/api/usage', { cache: 'no-store' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch usage data');
|
console.error('Failed to fetch usage data');
|
||||||
return [];
|
return [];
|
||||||
@ -240,7 +253,7 @@ export async function clearDayDataAsync(
|
|||||||
export async function fetchAchievements(): Promise<Achievement[]> {
|
export async function fetchAchievements(): Promise<Achievement[]> {
|
||||||
if (achievementsCache) return achievementsCache;
|
if (achievementsCache) return achievementsCache;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/achievements');
|
const response = await fetch('/api/achievements', { cache: 'no-store' });
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json() as Achievement[];
|
const data = await response.json() as Achievement[];
|
||||||
achievementsCache = data;
|
achievementsCache = data;
|
||||||
@ -324,7 +337,7 @@ export function getReminderSettings(): ReminderSettings {
|
|||||||
export async function fetchSavingsConfig(): Promise<SavingsConfig | null> {
|
export async function fetchSavingsConfig(): Promise<SavingsConfig | null> {
|
||||||
if (savingsConfigCache) return savingsConfigCache;
|
if (savingsConfigCache) return savingsConfigCache;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/savings');
|
const response = await fetch('/api/savings', { cache: 'no-store' });
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json() as SavingsConfig | null;
|
const data = await response.json() as SavingsConfig | null;
|
||||||
savingsConfigCache = data;
|
savingsConfigCache = data;
|
||||||
@ -359,7 +372,7 @@ export function getSavingsConfig(): SavingsConfig | null {
|
|||||||
export async function fetchMoodEntries(): Promise<MoodEntry[]> {
|
export async function fetchMoodEntries(): Promise<MoodEntry[]> {
|
||||||
if (moodEntriesCache) return moodEntriesCache;
|
if (moodEntriesCache) return moodEntriesCache;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/mood');
|
const response = await fetch('/api/mood', { cache: 'no-store' });
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
const data = await response.json() as MoodEntry[];
|
const data = await response.json() as MoodEntry[];
|
||||||
moodEntriesCache = data;
|
moodEntriesCache = data;
|
||||||
@ -412,7 +425,10 @@ export function calculateStreak(
|
|||||||
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];
|
// 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
|
// O(1) lookup
|
||||||
const dayUsage = substanceMap.get(dateStr) ?? -1;
|
const dayUsage = substanceMap.get(dateStr) ?? -1;
|
||||||
@ -496,7 +512,10 @@ export function checkBadgeEligibility(
|
|||||||
for (let i = 0; i <= 365; i++) {
|
for (let i = 0; i <= 365; i++) {
|
||||||
const d = new Date(today);
|
const d = new Date(today);
|
||||||
d.setDate(d.getDate() - i);
|
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;
|
const val = map.get(ds) ?? -1;
|
||||||
if (val === 0) streak++;
|
if (val === 0) streak++;
|
||||||
else if (val > 0) break;
|
else if (val > 0) break;
|
||||||
@ -507,23 +526,35 @@ export function checkBadgeEligibility(
|
|||||||
const streak = getStreakFromMap(substance === 'nicotine' ? stats.nicotineMap : stats.weedMap);
|
const streak = getStreakFromMap(substance === 'nicotine' ? stats.nicotineMap : stats.weedMap);
|
||||||
|
|
||||||
const checkMonthlyReduction = (): boolean => {
|
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;
|
if (!preferences.trackingStartDate) return false;
|
||||||
const start = new Date(preferences.trackingStartDate);
|
|
||||||
const today = new Date();
|
// Parse start date as local
|
||||||
const daysSinceStart = Math.floor((today.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
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;
|
if (daysSinceStart < 30) return false;
|
||||||
|
|
||||||
// Use current Map for O(1) lookups in week buckets
|
// Use current Map for O(1) lookups in week buckets
|
||||||
let firstWeekTotal = 0;
|
let firstWeekTotal = 0;
|
||||||
let lastWeekTotal = 0;
|
let lastWeekTotal = 0;
|
||||||
|
|
||||||
const startTime = start.getTime();
|
const startTime = startLocal.getTime();
|
||||||
const todayTime = today.getTime();
|
const todayTime = todayLocal.getTime();
|
||||||
const msInDay = 1000 * 60 * 60 * 24;
|
const msInDay = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
for (const entry of usageData) {
|
for (const entry of usageData) {
|
||||||
if (entry.substance !== substance) continue;
|
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 daysSinceEntryStart = Math.floor((entryTime - startTime) / msInDay);
|
||||||
const daysAgo = Math.floor((todayTime - entryTime) / msInDay);
|
const daysAgo = Math.floor((todayTime - entryTime) / msInDay);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user