From 75a75fd4999b91c898d66d2a2e7242960106a936 Mon Sep 17 00:00:00 2001
From: Avery Felts
Date: Sat, 31 Jan 2026 17:12:01 -0700
Subject: [PATCH] feat: Implement independent nicotine/weed quit plans with
refined UI and auto-unlock logic
---
src/app/api/preferences/route.ts | 19 +++++-
src/components/Dashboard.tsx | 105 +++++++++++++++++++++++++-----
src/components/QuitPlanCard.tsx | 108 ++++++++++++++++++++++++-------
src/lib/storage.ts | 61 ++++++++++++-----
4 files changed, 238 insertions(+), 55 deletions(-)
diff --git a/src/app/api/preferences/route.ts b/src/app/api/preferences/route.ts
index d83388f..5108ec5 100644
--- a/src/app/api/preferences/route.ts
+++ b/src/app/api/preferences/route.ts
@@ -54,6 +54,7 @@ export async function POST(request: NextRequest) {
hasCompletedSetup?: boolean;
dailyGoal?: number;
quitPlan?: unknown;
+ quitState?: unknown;
userName?: string;
userAge?: number;
religion?: string;
@@ -61,12 +62,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 +84,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,
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx
index 713624e..2102c1e 100644
--- a/src/components/Dashboard.tsx
+++ b/src/components/Dashboard.tsx
@@ -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,12 +358,41 @@ export function Dashboard({ user }: DashboardProps) {
-
+ {/* Nicotine Plan */}
+ {(preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine')) && (
+ 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')) && (
+ 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"
+ />
+ )}
diff --git a/src/components/QuitPlanCard.tsx b/src/components/QuitPlanCard.tsx
index 2a1008b..6b639e2 100644
--- a/src/components/QuitPlanCard.tsx
+++ b/src/components/QuitPlanCard.tsx
@@ -6,27 +6,59 @@ import { Button } from '@/components/ui/button';
import { QuitPlan, UsageEntry } from '@/lib/storage';
import { Target, TrendingDown } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
+import { getTodayString } from '@/lib/date-utils';
interface QuitPlanCardProps {
plan: QuitPlan | null;
onGeneratePlan: () => void;
usageData: UsageEntry[];
+ trackingStartDate: string | null;
+ substance: 'nicotine' | 'weed';
}
function QuitPlanCardComponent({
plan,
onGeneratePlan,
usageData,
+ trackingStartDate,
+ substance,
}: QuitPlanCardProps) {
const { theme } = useTheme();
// 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 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
- 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;
// Yellow gradient for tracking phase (darker in light mode)
@@ -48,7 +80,7 @@ function QuitPlanCardComponent({
- Your Personalized Plan
+ Your {substance === 'nicotine' ? 'Nicotine' : 'Weed'} Quit Plan
We're tracking your usage to build your custom quit plan
@@ -73,7 +105,7 @@ function QuitPlanCardComponent({
- {hasEnoughData ? (
+ {isUnlocked ? (
Great work! Your average daily usage is{' '}
@@ -107,6 +139,19 @@ function QuitPlanCardComponent({
const totalWeeks = plan.weeklyTargets.length;
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 (
- Your Quit Plan
+ Your {substance === 'nicotine' ? 'Nicotine' : 'Weed'} Plan
Week {Math.min(weekNumber, totalWeeks)} of {totalWeeks} - 25% weekly reduction
@@ -123,30 +168,49 @@ function QuitPlanCardComponent({
-
This week's daily target
+
{substance === 'nicotine' ? 'Nicotine' : 'Weed'} Max Puffs Target
{currentTarget !== null && currentTarget > 0 ? currentTarget : '0'}
-
per day
+
per day
+
+ {/* Daily Progress Bar */}
+
+
+ {todayUsage} used / {currentTarget} allowed
+
Weekly targets:
- {plan.weeklyTargets.map((target, index) => (
-
-
Week {index + 1}
-
{target}
-
- ))}
+ {plan.weeklyTargets.map((target, index) => {
+ const weekNum = index + 1;
+ const isFuture = weekNum > weekNumber;
+ const isCurrent = weekNum === weekNumber;
+
+ return (
+
+
Week {weekNum}
+
+ {isFuture ? '?' : target}
+
+
+ )
+ })}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index d631747..a90c365 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -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 {
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 {
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 {
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 {
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 {
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);