Fix mood tracker affirmation bug and add dynamic background
This commit is contained in:
parent
39c8e92f92
commit
c518ad9f34
81
migrations/0001_initial.sql
Normal file
81
migrations/0001_initial.sql
Normal 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");
|
||||||
14
migrations/0006_add_mood_entries.sql
Normal file
14
migrations/0006_add_mood_entries.sql
Normal 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");
|
||||||
@ -34,6 +34,7 @@ model UserPreferences {
|
|||||||
achievements Achievement[]
|
achievements Achievement[]
|
||||||
reminderSettings ReminderSettings?
|
reminderSettings ReminderSettings?
|
||||||
savingsConfig SavingsConfig?
|
savingsConfig SavingsConfig?
|
||||||
|
moodEntries MoodEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model UsageEntry {
|
model UsageEntry {
|
||||||
@ -88,3 +89,17 @@ model SavingsConfig {
|
|||||||
|
|
||||||
userPreferences UserPreferences? @relation(fields: [userId], references: [userId])
|
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
75
src/app/api/mood/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ import { AchievementsCard } from './AchievementsCard';
|
|||||||
import { CelebrationAnimation } from './CelebrationAnimation';
|
import { CelebrationAnimation } from './CelebrationAnimation';
|
||||||
import { HealthTimelineCard } from './HealthTimelineCard';
|
import { HealthTimelineCard } from './HealthTimelineCard';
|
||||||
import { SavingsTrackerCard } from './SavingsTrackerCard';
|
import { SavingsTrackerCard } from './SavingsTrackerCard';
|
||||||
|
import { MoodTracker } from './MoodTracker';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PlusCircle } from 'lucide-react';
|
import { PlusCircle } from 'lucide-react';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
@ -254,6 +255,9 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="opacity-0 animate-fade-in-up delay-100">
|
||||||
|
<MoodTracker />
|
||||||
|
</div>
|
||||||
<div className="opacity-0 animate-fade-in-up delay-200">
|
<div className="opacity-0 animate-fade-in-up delay-200">
|
||||||
<QuitPlanCard
|
<QuitPlanCard
|
||||||
key={`quit-plan-${refreshKey}`}
|
key={`quit-plan-${refreshKey}`}
|
||||||
|
|||||||
248
src/components/MoodTracker.tsx
Normal file
248
src/components/MoodTracker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -426,3 +426,55 @@ export async function upsertPushSubscriptionD1(
|
|||||||
|
|
||||||
return getPushSubscriptionD1(userId);
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -69,6 +69,15 @@ export interface HealthMilestone {
|
|||||||
icon: string;
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MoodEntry {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
mood: 'good' | 'neutral' | 'bad';
|
||||||
|
date: string;
|
||||||
|
comment: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ BADGE DEFINITIONS ============
|
// ============ BADGE DEFINITIONS ============
|
||||||
|
|
||||||
export const BADGE_DEFINITIONS: BadgeDefinition[] = [
|
export const BADGE_DEFINITIONS: BadgeDefinition[] = [
|
||||||
@ -111,6 +120,7 @@ let usageDataCache: UsageEntry[] | null = null;
|
|||||||
let achievementsCache: Achievement[] | null = null;
|
let achievementsCache: Achievement[] | null = null;
|
||||||
let reminderSettingsCache: ReminderSettings | null = null;
|
let reminderSettingsCache: ReminderSettings | null = null;
|
||||||
let savingsConfigCache: SavingsConfig | null = null;
|
let savingsConfigCache: SavingsConfig | null = null;
|
||||||
|
let moodEntriesCache: MoodEntry[] | null = null;
|
||||||
|
|
||||||
export function clearCache(): void {
|
export function clearCache(): void {
|
||||||
preferencesCache = null;
|
preferencesCache = null;
|
||||||
@ -118,6 +128,7 @@ export function clearCache(): void {
|
|||||||
achievementsCache = null;
|
achievementsCache = null;
|
||||||
reminderSettingsCache = null;
|
reminderSettingsCache = null;
|
||||||
savingsConfigCache = null;
|
savingsConfigCache = null;
|
||||||
|
moodEntriesCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// These functions are kept for backwards compatibility but no longer used
|
// These functions are kept for backwards compatibility but no longer used
|
||||||
@ -342,6 +353,41 @@ export function getSavingsConfig(): SavingsConfig | null {
|
|||||||
return savingsConfigCache;
|
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 ============
|
// ============ CALCULATION HELPERS ============
|
||||||
|
|
||||||
export function calculateStreak(
|
export function calculateStreak(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user