Fix mood tracker affirmation bug and add dynamic background

This commit is contained in:
Avery Felts 2026-01-27 17:59:05 -07:00
parent 39c8e92f92
commit c518ad9f34
8 changed files with 535 additions and 0 deletions

View File

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

View File

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

View File

@ -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])
}

75
src/app/api/mood/route.ts Normal file
View File

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

View File

@ -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) {
}}
/>
</div>
<div className="opacity-0 animate-fade-in-up delay-100">
<MoodTracker />
</div>
<div className="opacity-0 animate-fade-in-up delay-200">
<QuitPlanCard
key={`quit-plan-${refreshKey}`}

View File

@ -0,0 +1,248 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Smile, Meh, Frown, TrendingUp, ChevronLeft, ChevronRight, MessageSquare, Quote } from 'lucide-react';
import { MoodEntry, fetchMoodEntries, saveMoodEntry } from '@/lib/storage';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Cell, ReferenceLine } from 'recharts';
import { format, subDays, startOfWeek, endOfWeek, isWithinInterval, eachDayOfInterval, parseISO } from 'date-fns';
export function MoodTracker() {
const [entries, setEntries] = useState<MoodEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [affirmation, setAffirmation] = useState<string | null>(null);
const [weekOffset, setWeekOffset] = useState(0);
const [activeMood, setActiveMood] = useState<'good' | 'neutral' | 'bad' | null>(null);
const [currentTimeout, setCurrentTimeout] = useState<NodeJS.Timeout | null>(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 (
<Card className={`overflow-hidden border transition-all duration-1000 backdrop-blur-md shadow-2xl hover-lift bg-gradient-to-br ${getMoodStyles()}`}>
<CardHeader className="pb-2 border-b border-white/5 bg-black/20">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold flex items-center gap-2 text-white">
<MessageSquare className="w-5 h-5 text-indigo-400" />
How do you feel?
</CardTitle>
<div className="flex items-center gap-1 bg-white/5 rounded-full p-1 border border-white/10">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full text-white/70 hover:text-white"
onClick={() => setWeekOffset(prev => prev + 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-[10px] font-medium px-2 text-white/90 uppercase tracking-wider">{weekLabel}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full text-white/70 hover:text-white disabled:opacity-30"
disabled={weekOffset === 0}
onClick={() => setWeekOffset(prev => prev - 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Mood Buttons */}
<div className="grid grid-cols-3 gap-4">
<button
onClick={() => handleMoodSelect('good')}
disabled={isSaving}
className="group flex flex-col items-center gap-2 p-4 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 hover:bg-emerald-500/20 transition-all active:scale-95 disabled:opacity-50"
>
<div className="p-3 rounded-full bg-emerald-500/20 group-hover:bg-emerald-500/40 transition-colors">
<Smile className="w-8 h-8 text-emerald-400 group-hover:scale-110 transition-transform" />
</div>
<span className="text-xs font-semibold text-emerald-400 uppercase tracking-tighter">Good</span>
</button>
<button
onClick={() => handleMoodSelect('neutral')}
disabled={isSaving}
className="group flex flex-col items-center gap-2 p-4 rounded-2xl bg-amber-500/10 border border-amber-500/20 hover:bg-amber-500/20 transition-all active:scale-95 disabled:opacity-50"
>
<div className="p-3 rounded-full bg-amber-500/20 group-hover:bg-amber-500/40 transition-colors">
<Meh className="w-8 h-8 text-amber-400 group-hover:rotate-12 transition-transform" />
</div>
<span className="text-xs font-semibold text-amber-400 uppercase tracking-tighter">Neutral</span>
</button>
<button
onClick={() => handleMoodSelect('bad')}
disabled={isSaving}
className="group flex flex-col items-center gap-2 p-4 rounded-2xl bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 transition-all active:scale-95 disabled:opacity-50"
>
<div className="p-3 rounded-full bg-rose-500/20 group-hover:bg-rose-500/40 transition-colors">
<Frown className="w-8 h-8 text-rose-400 group-hover:-translate-y-1 transition-transform" />
</div>
<span className="text-xs font-semibold text-rose-400 uppercase tracking-tighter">Bad</span>
</button>
</div>
{/* Affirmation Message */}
{affirmation && (
<div className="animate-fade-in-up bg-white/5 border border-white/10 rounded-xl p-4 flex gap-3 items-start">
<Quote className="w-4 h-4 text-indigo-400 flex-shrink-0 mt-1" />
<p className="text-sm italic text-white/90 leading-relaxed">{affirmation}</p>
</div>
)}
{/* Week Graph */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] font-bold text-white/40 uppercase tracking-widest pl-1">
<TrendingUp className="w-3 h-3" />
Mood Trend
</div>
<div className="h-32 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyData} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
<XAxis
dataKey="name"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
domain={[0, 3]}
ticks={[1, 2, 3]}
axisLine={false}
tickLine={false}
tick={false}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
contentStyle={{
backgroundColor: 'rgba(15, 23, 42, 0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
fontSize: '12px',
color: '#fff'
}}
labelStyle={{ display: 'none' }}
formatter={(value: number | undefined) => {
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'];
}}
/>
<Bar dataKey="value" radius={[4, 4, 4, 4]} barSize={24}>
{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 <Cell key={`cell-${index}`} fill={color} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -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<MoodEntryRow[]> {
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<MoodEntryRow>();
return result.results || [];
}
export async function saveMoodEntryD1(
userId: string,
mood: string,
date: string,
comment?: string | null
): Promise<MoodEntryRow | null> {
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
};
}

View File

@ -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<MoodEntry[]> {
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(