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 && (
+
+ )}
+
+ {/* 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(