From c518ad9f346d8bbe5f2aae8677d31b750495cf33 Mon Sep 17 00:00:00 2001 From: Avery Felts Date: Tue, 27 Jan 2026 17:59:05 -0700 Subject: [PATCH] Fix mood tracker affirmation bug and add dynamic background --- migrations/0001_initial.sql | 81 +++++++++ migrations/0006_add_mood_entries.sql | 14 ++ prisma/schema.prisma | 15 ++ src/app/api/mood/route.ts | 75 ++++++++ src/components/Dashboard.tsx | 4 + src/components/MoodTracker.tsx | 248 +++++++++++++++++++++++++++ src/lib/d1.ts | 52 ++++++ src/lib/storage.ts | 46 +++++ 8 files changed, 535 insertions(+) create mode 100644 migrations/0001_initial.sql create mode 100644 migrations/0006_add_mood_entries.sql create mode 100644 src/app/api/mood/route.ts create mode 100644 src/components/MoodTracker.tsx diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql new file mode 100644 index 0000000..f43d38c --- /dev/null +++ b/migrations/0001_initial.sql @@ -0,0 +1,81 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "UserPreferences" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "substance" TEXT NOT NULL DEFAULT 'nicotine', + "trackingStartDate" TEXT, + "hasCompletedSetup" BOOLEAN NOT NULL DEFAULT false, + "dailyGoal" INTEGER, + "userName" TEXT, + "userAge" INTEGER, + "religion" TEXT, + "lastNicotineUsageTime" TEXT, + "lastWeedUsageTime" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "quitPlanJson" TEXT +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "UsageEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "date" TEXT NOT NULL, + "count" INTEGER NOT NULL, + "substance" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "UsageEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "Achievement" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "badgeId" TEXT NOT NULL, + "unlockedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "substance" TEXT NOT NULL, + CONSTRAINT "Achievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "ReminderSettings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "reminderTime" TEXT NOT NULL DEFAULT '09:00', + "lastNotifiedDate" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "ReminderSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "SavingsConfig" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "costPerUnit" REAL NOT NULL, + "unitsPerDay" REAL NOT NULL, + "savingsGoal" REAL, + "goalName" TEXT, + "currency" TEXT NOT NULL DEFAULT 'USD', + "substance" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SavingsConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "UserPreferences_userId_key" ON "UserPreferences"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "UsageEntry_userId_date_substance_key" ON "UsageEntry"("userId", "date", "substance"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "Achievement_userId_badgeId_substance_key" ON "Achievement"("userId", "badgeId", "substance"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "ReminderSettings_userId_key" ON "ReminderSettings"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "SavingsConfig_userId_key" ON "SavingsConfig"("userId"); diff --git a/migrations/0006_add_mood_entries.sql b/migrations/0006_add_mood_entries.sql new file mode 100644 index 0000000..0e52cdd --- /dev/null +++ b/migrations/0006_add_mood_entries.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "MoodEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "mood" TEXT NOT NULL, + "date" TEXT NOT NULL, + "comment" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "MoodEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "UserPreferences" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "MoodEntry_userId_date_idx" ON "MoodEntry"("userId", "date"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82608ef..c42f191 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,6 +34,7 @@ model UserPreferences { achievements Achievement[] reminderSettings ReminderSettings? savingsConfig SavingsConfig? + moodEntries MoodEntry[] } model UsageEntry { @@ -88,3 +89,17 @@ model SavingsConfig { userPreferences UserPreferences? @relation(fields: [userId], references: [userId]) } + +model MoodEntry { + id String @id @default(cuid()) + userId String + mood String // "good", "neutral", "bad" + date String // YYYY-MM-DD + comment String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userPreferences UserPreferences? @relation(fields: [userId], references: [userId]) + + @@index([userId, date]) +} diff --git a/src/app/api/mood/route.ts b/src/app/api/mood/route.ts new file mode 100644 index 0000000..71616f8 --- /dev/null +++ b/src/app/api/mood/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/session'; +import { getMoodEntriesD1, saveMoodEntryD1 } from '@/lib/d1'; +import { getTodayString } from '@/lib/date-utils'; + +const AFFIRMATIONS = { + good: [ + "That's wonderful! Keep riding this positive wave.", + "Your strength is inspiring. Keep going!", + "Happiness is a great companion on this journey.", + "So glad you're feeling good! You've got this.", + "Keep that momentum! You're doing amazing." + ], + neutral: [ + "Steady as she goes. Every day is progress.", + "It's okay to just 'be' sometimes. Stay the course.", + "Focus on your 'why' today. You're doing great.", + "One step at a time. You're still moving forward.", + "Balance is key. Keep your goals in sight." + ], + bad: [ + "I'm sorry things are tough. This feeling is temporary.", + "Be kind to yourself today. You're still stronger than you think.", + "Tough times don't last, but tough people do. Hang in there.", + "Take a deep breath. Tomorrow is a fresh start.", + "It's okay to struggle. What matters is that you keep trying." + ] +}; + +export async function GET() { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const entries = await getMoodEntriesD1(session.user.id); + return NextResponse.json(entries); + } catch (error) { + console.error('Error fetching mood entries:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json() as { mood: 'good' | 'neutral' | 'bad'; comment?: string }; + const { mood, comment } = body; + + if (!mood || !['good', 'neutral', 'bad'].includes(mood)) { + return NextResponse.json({ error: 'Invalid mood' }, { status: 400 }); + } + + const today = getTodayString(); + const entry = await saveMoodEntryD1(session.user.id, mood, today, comment); + + if (!entry) { + return NextResponse.json({ error: 'Failed to save mood entry' }, { status: 500 }); + } + + // Pick a random affirmation + const moodAffirmations = AFFIRMATIONS[mood]; + const affirmation = moodAffirmations[Math.floor(Math.random() * moodAffirmations.length)]; + + return NextResponse.json({ entry, affirmation }); + } catch (error) { + console.error('Error saving mood entry:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 6eb54a5..0c4af80 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -32,6 +32,7 @@ import { AchievementsCard } from './AchievementsCard'; import { CelebrationAnimation } from './CelebrationAnimation'; import { HealthTimelineCard } from './HealthTimelineCard'; import { SavingsTrackerCard } from './SavingsTrackerCard'; +import { MoodTracker } from './MoodTracker'; import { Button } from '@/components/ui/button'; import { PlusCircle } from 'lucide-react'; import { useTheme } from '@/lib/theme-context'; @@ -254,6 +255,9 @@ export function Dashboard({ user }: DashboardProps) { }} /> +
+ +
([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [affirmation, setAffirmation] = useState(null); + const [weekOffset, setWeekOffset] = useState(0); + const [activeMood, setActiveMood] = useState<'good' | 'neutral' | 'bad' | null>(null); + const [currentTimeout, setCurrentTimeout] = useState(null); + + useEffect(() => { + const loadMoods = async () => { + const data = await fetchMoodEntries(); + setEntries(data); + if (data.length > 0) { + setActiveMood(data[0].mood as any); + } + setIsLoading(false); + }; + loadMoods(); + }, []); + + const handleMoodSelect = async (mood: 'good' | 'neutral' | 'bad') => { + setIsSaving(true); + setAffirmation(null); + setActiveMood(mood); + + // Clear previous timeout if it exists + if (currentTimeout) { + clearTimeout(currentTimeout); + setCurrentTimeout(null); + } + + try { + const result = await saveMoodEntry(mood); + if (result) { + setEntries(prev => [result.entry, ...prev]); + setAffirmation(result.affirmation); + + // Clear affirmation after 8 seconds + const timeout = setTimeout(() => { + setAffirmation(null); + setCurrentTimeout(null); + }, 8000); + setCurrentTimeout(timeout); + } + } finally { + setIsSaving(false); + } + }; + + const weeklyData = useMemo(() => { + const today = new Date(); + const start = startOfWeek(subDays(today, weekOffset * 7), { weekStartsOn: 1 }); + const end = endOfWeek(start, { weekStartsOn: 1 }); + const days = eachDayOfInterval({ start, end }); + + return days.map(day => { + const dateStr = format(day, 'yyyy-MM-dd'); + const dayEntries = entries.filter(e => e.date === dateStr); + + // Map mood to numeric values for graphing: bad=1, neutral=2, good=3 + let value = 0; + if (dayEntries.length > 0) { + const total = dayEntries.reduce((sum, e) => { + if (e.mood === 'good') return sum + 3; + if (e.mood === 'neutral') return sum + 2; + if (e.mood === 'bad') return sum + 1; + return sum; + }, 0); + value = total / dayEntries.length; + } + + return { + name: format(day, 'EEE'), + fullDate: dateStr, + value: value === 0 ? null : Number(value.toFixed(1)), + count: dayEntries.length + }; + }); + }, [entries, weekOffset]); + + const weekLabel = useMemo(() => { + const start = startOfWeek(subDays(new Date(), weekOffset * 7), { weekStartsOn: 1 }); + const end = endOfWeek(start, { weekStartsOn: 1 }); + if (weekOffset === 0) return 'This Week'; + if (weekOffset === 1) return 'Last Week'; + return `${format(start, 'MMM d')} - ${format(end, 'MMM d')}`; + }, [weekOffset]); + + // Dynamic styles based on active mood + const getMoodStyles = () => { + switch (activeMood) { + case 'good': + return 'from-emerald-600/20 via-teal-500/10 to-indigo-500/10 border-emerald-500/30 shadow-emerald-500/10'; + case 'neutral': + return 'from-amber-600/20 via-orange-500/10 to-purple-500/10 border-amber-500/30 shadow-amber-500/10'; + case 'bad': + return 'from-rose-600/20 via-pink-500/10 to-blue-500/10 border-rose-500/30 shadow-rose-500/10'; + default: + return 'from-indigo-600/20 via-purple-500/10 to-pink-500/10 border-white/10 shadow-indigo-500/10'; + } + }; + + return ( + + +
+ + + How do you feel? + +
+ + {weekLabel} + +
+
+
+ + + {/* Mood Buttons */} +
+ + + + + +
+ + {/* Affirmation Message */} + {affirmation && ( +
+ +

{affirmation}

+
+ )} + + {/* Week Graph */} +
+
+ + Mood Trend +
+
+ + + + + { + if (value === undefined) return ['', '']; + if (value >= 2.5) return ['Good', 'Mood']; + if (value >= 1.5) return ['Neutral', 'Mood']; + if (value > 0) return ['Bad', 'Mood']; + return ['No Record', 'Mood']; + }} + /> + + {weeklyData.map((entry, index) => { + let color = 'rgba(255,255,255,0.1)'; + if (entry.value) { + if (entry.value >= 2.5) color = '#10b981'; // emerald-500 + else if (entry.value >= 1.5) color = '#f59e0b'; // amber-500 + else color = '#f43f5e'; // rose-500 + } + return ; + })} + + + +
+
+
+
+ ); +} diff --git a/src/lib/d1.ts b/src/lib/d1.ts index 3ccc45e..92774ec 100644 --- a/src/lib/d1.ts +++ b/src/lib/d1.ts @@ -426,3 +426,55 @@ export async function upsertPushSubscriptionD1( return getPushSubscriptionD1(userId); } + +// ============ MOOD TRACKER ============ + +export interface MoodEntryRow { + id: string; + userId: string; + mood: string; + date: string; + comment: string | null; + createdAt: string; + updatedAt: string; +} + +export async function getMoodEntriesD1(userId: string, limit: number = 50): Promise { + const db = getD1(); + if (!db) return []; + + const result = await db.prepare( + 'SELECT * FROM MoodEntry WHERE userId = ? ORDER BY date DESC, createdAt DESC LIMIT ?' + ).bind(userId, limit).all(); + + return result.results || []; +} + +export async function saveMoodEntryD1( + userId: string, + mood: string, + date: string, + comment?: string | null +): Promise { + const db = getD1(); + if (!db) return null; + + const now = new Date().toISOString(); + const id = crypto.randomUUID(); + + // Mood tracking is flexible, multiple entries per day are allowed + await db.prepare( + `INSERT INTO MoodEntry (id, userId, mood, date, comment, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).bind(id, userId, mood, date, comment ?? null, now, now).run(); + + return { + id, + userId, + mood, + date, + comment: comment ?? null, + createdAt: now, + updatedAt: now + }; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 76f6fbd..a6ac60a 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -69,6 +69,15 @@ export interface HealthMilestone { icon: string; } +export interface MoodEntry { + id: string; + userId: string; + mood: 'good' | 'neutral' | 'bad'; + date: string; + comment: string | null; + createdAt: string; +} + // ============ BADGE DEFINITIONS ============ export const BADGE_DEFINITIONS: BadgeDefinition[] = [ @@ -111,6 +120,7 @@ let usageDataCache: UsageEntry[] | null = null; let achievementsCache: Achievement[] | null = null; let reminderSettingsCache: ReminderSettings | null = null; let savingsConfigCache: SavingsConfig | null = null; +let moodEntriesCache: MoodEntry[] | null = null; export function clearCache(): void { preferencesCache = null; @@ -118,6 +128,7 @@ export function clearCache(): void { achievementsCache = null; reminderSettingsCache = null; savingsConfigCache = null; + moodEntriesCache = null; } // These functions are kept for backwards compatibility but no longer used @@ -342,6 +353,41 @@ export function getSavingsConfig(): SavingsConfig | null { return savingsConfigCache; } +// ============ MOOD FUNCTIONS ============ + +export async function fetchMoodEntries(): Promise { + if (moodEntriesCache) return moodEntriesCache; + try { + const response = await fetch('/api/mood'); + if (!response.ok) return []; + const data = await response.json() as MoodEntry[]; + moodEntriesCache = data; + return data; + } catch (error) { + console.error('Error fetching mood entries:', error); + return []; + } +} + +export async function saveMoodEntry(mood: 'good' | 'neutral' | 'bad', comment?: string): Promise<{ entry: MoodEntry; affirmation: string } | null> { + try { + const response = await fetch('/api/mood', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mood, comment }), + }); + if (response.ok) { + const data = await response.json() as { entry: MoodEntry; affirmation: string }; + moodEntriesCache = null; // Invalidate cache + return data; + } + return null; + } catch (error) { + console.error('Error saving mood entry:', error); + return null; + } +} + // ============ CALCULATION HELPERS ============ export function calculateStreak(