feat: Implement independent nicotine/weed quit plans with refined UI and auto-unlock logic

This commit is contained in:
Avery Felts 2026-01-31 17:12:01 -07:00
parent 7046febd00
commit 75a75fd499
4 changed files with 238 additions and 55 deletions

View File

@ -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,

View File

@ -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) {
</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}
usageData={usageData}
/>
{/* Nicotine Plan */}
{(preferences.substance === 'nicotine' || usageData.some(e => e.substance === 'nicotine')) && (
<QuitPlanCard
key={`quit-plan-nicotine-${refreshKey}`}
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>

View File

@ -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({
<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
Your {substance === 'nicotine' ? 'Nicotine' : 'Weed'} Quit Plan
</CardTitle>
<CardDescription className="text-white/70">
We&apos;re tracking your usage to build your custom quit plan
@ -73,7 +105,7 @@ function QuitPlanCardComponent({
</p>
</div>
{hasEnoughData ? (
{isUnlocked ? (
<div className="space-y-3">
<p className="text-sm text-white text-center">
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 (
<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
@ -115,7 +160,7 @@ function QuitPlanCardComponent({
<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
Your {substance === 'nicotine' ? 'Nicotine' : 'Weed'} Plan
</CardTitle>
<CardDescription className="text-white/70">
Week {Math.min(weekNumber, totalWeeks)} of {totalWeeks} - 25% weekly reduction
@ -123,30 +168,49 @@ function QuitPlanCardComponent({
</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&apos;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">
{currentTarget !== null && currentTarget > 0 ? currentTarget : '0'}
</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 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>
))}
{plan.weeklyTargets.map((target, index) => {
const weekNum = index + 1;
const isFuture = weekNum > weekNumber;
const isCurrent = weekNum === weekNumber;
return (
<div
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'
: isFuture
? 'bg-white/5 text-white/40'
: 'bg-pink-900/50 text-pink-200'
}`}
>
<p className="text-xs">Week {weekNum}</p>
<p className="font-bold">
{isFuture ? '?' : target}
</p>
</div>
)
})}
</div>
</div>

View File

@ -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);