Enhance Mood Tracker: Optimize performance, smooth animations, add affirmations, and persist score

This commit is contained in:
Avery Felts 2026-01-31 10:22:37 -07:00
parent e532fe52d2
commit 36a3deddc7
9 changed files with 344 additions and 145 deletions

View File

@ -28,6 +28,7 @@
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"styled-jsx": "^5.1.6",
"tailwind-merge": "^3.4.0",
"web-push": "^3.6.7",
},

View File

@ -0,0 +1,2 @@
-- Migration number: 0007 2024-01-30T23:33:00.000Z
ALTER TABLE MoodEntry ADD COLUMN score INTEGER DEFAULT 50;

View File

@ -41,7 +41,8 @@
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7",
"styled-jsx": "^5.1.6"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250121.0",

View File

@ -93,7 +93,8 @@ model SavingsConfig {
model MoodEntry {
id String @id @default(cuid())
userId String
mood String // "good", "neutral", "bad"
mood String // "good", "neutral", "bad" (kept for backward compat or categorical)
score Int @default(50) // 0-100
date String // YYYY-MM-DD
comment String?
createdAt DateTime @default(now())

View File

@ -3,27 +3,76 @@ import { getSession } from '@/lib/session';
import { getMoodEntriesD1, saveMoodEntryD1 } from '@/lib/d1';
import { getTodayString } from '@/lib/date-utils';
const AFFIRMATIONS = {
const AFFIRMATIONS: Record<string, string[]> = {
amazing: [
"Incredible! Use this energy to stay smoke-free!",
"You're on fire! Keep living your best life.",
"So happy for you! This is what freedom feels like.",
"Outstanding! Bottle this feeling for later.",
"You're unstoppable today! Keep it up.",
"Radiating positivity! Your journey is inspiring.",
"Top of the world! Enjoy every moment of this success.",
"Absolutely brilliant! You're glowing with health.",
"Peak performance! This is the real you shining through.",
"Marvelous! Your commitment is paying off in spades.",
"What a feeling! You're crushing your goals.",
"Sky high! nothing can bring you down today."
],
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."
"Keep that momentum! You're doing amazing.",
"Great vibes! A healthy life looks good on you.",
"Feeling good is just the beginning. It gets better.",
"Solid progress! You're building a beautiful future.",
"Nice! Enjoy this clarity and energy.",
"You're doing it! Every good day is a victory.",
"Beautiful! Your body thanks you for your choices.",
"Keep smiling! You're on the right path."
],
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."
"Balance is key. Keep your goals in sight.",
"Calm waters run deep. You're staying strong.",
"Consistency is your superpower. Keep showing up.",
"A quiet day is a good day. Stay committed.",
"Just keep swimming. You're doing just fine.",
"Neutral ground is safe ground. protecting your progress.",
"Peaceful and present. That's a win in itself.",
"Center yourself. You are in control."
],
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."
"It's okay to struggle. What matters is that you keep trying.",
"Storms run out of rain. This too shall pass.",
"Your journey is worth it, even on hard days.",
"Don't lose hope. You've come so far already.",
"Courage is keeping on when it's hard. You valid.",
"Treat yourself with extra care today. You deserve it.",
"One breath at a time. You can get through this.",
"Sending you strength. You are not alone."
],
terrible: [
"It's okay to not be okay. Just breathe.",
"This moment will pass. You are stronger than this usage.",
"Reaching out for support is a sign of strength.",
"Be gentle with yourself. You're fighting a hard battle.",
"Don't give up. The sun will rise again.",
"You are resilient. You can weather this storm.",
"Hold on. Better days are coming.",
"Your worth is not defined by this moment.",
"Scream if you need to, but don't give up.",
"It's darkest before dawn. Keep fighting.",
"You are loved and you are capable. Stay with us.",
"This pain is temporary, your freedom is forever."
]
};
@ -49,23 +98,34 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json() as { mood: 'good' | 'neutral' | 'bad'; comment?: string };
const { mood, comment } = body;
const body = await request.json() as { mood: 'amazing' | 'good' | 'neutral' | 'bad' | 'terrible'; score: number; comment?: string; date?: string };
const { mood, score, comment, date } = body;
if (!mood || !['good', 'neutral', 'bad'].includes(mood)) {
if (!mood) {
return NextResponse.json({ error: 'Invalid mood' }, { status: 400 });
}
const today = getTodayString();
const entry = await saveMoodEntryD1(session.user.id, mood, today, comment);
// Validate score is between 0 and 100
const finalScore = Math.max(0, Math.min(100, score ?? 50));
const entryDate = date || getTodayString();
const entry = await saveMoodEntryD1(session.user.id, mood, finalScore, entryDate, 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)];
let affirmation = "You're doing great! Keep it up.";
try {
const moodAffirmations = AFFIRMATIONS[mood] || AFFIRMATIONS['neutral'];
if (moodAffirmations && moodAffirmations.length > 0) {
affirmation = moodAffirmations[Math.floor(Math.random() * moodAffirmations.length)];
}
} catch (affError) {
console.error('Error selecting affirmation:', affError);
// Fallback is already set
}
return NextResponse.json({ entry, affirmation });
} catch (error) {

View File

@ -14,22 +14,55 @@ export default function LoginPage() {
};
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-card/80 backdrop-blur-sm border-white/10">
<CardHeader className="text-center">
<CardTitle className="text-3xl font-bold">QuitTraq</CardTitle>
<CardDescription className="text-lg">
Track your journey to a smoke-free life
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 overflow-hidden relative">
{/* Background Orbs */}
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="w-full max-w-5xl grid lg:grid-cols-2 gap-8 items-center relative z-10">
{/* Left Side: Video Demo */}
<div className="hidden lg:block relative group">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-purple-500/20 rounded-[2.5rem] blur-2xl group-hover:blur-3xl transition-all duration-500 opacity-50" />
<div className="relative aspect-[9/16] max-h-[80vh] mx-auto rounded-[2rem] overflow-hidden border border-white/10 shadow-2xl shadow-black/20">
<video
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover"
>
<source src="/demo-video.mp4" type="video/mp4" />
</video>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
<div className="absolute bottom-8 left-8 right-8 text-white">
<p className="text-sm font-bold uppercase tracking-widest opacity-70 mb-2">Live Preview</p>
<h3 className="text-xl font-bold">Experience the journey</h3>
</div>
</div>
</div>
{/* Right Side: Login Form */}
<div className="w-full max-w-md mx-auto">
<Card className="bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-white/20 dark:border-white/10 shadow-2xl rounded-[2rem] overflow-hidden">
<CardHeader className="text-center pt-8 pb-4">
<div className="w-16 h-16 bg-gradient-to-br from-primary to-purple-600 rounded-2xl mx-auto mb-6 flex items-center justify-center shadow-lg shadow-primary/20 rotate-3 group-hover:rotate-0 transition-transform">
<span className="text-2xl font-black text-white">Q</span>
</div>
<CardTitle className="text-4xl font-black tracking-tight bg-gradient-to-br from-slate-900 to-slate-700 dark:from-white dark:to-slate-400 bg-clip-text text-transparent">
QuitTraq
</CardTitle>
<CardDescription className="text-base font-medium mt-2">
Your companion to a smoke-free life
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<CardContent className="space-y-6 pb-8">
<div className="space-y-3">
<Button
variant="outline"
className="w-full h-12 text-base"
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
onClick={() => handleLogin('GoogleOAuth')}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
@ -52,32 +85,37 @@ export default function LoginPage() {
<Button
variant="outline"
className="w-full h-12 text-base"
className="w-full h-14 text-base font-bold rounded-xl border-slate-200 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 hover:scale-[1.02] transition-all shadow-sm"
onClick={() => handleLogin('AppleOAuth')}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
Continue with Apple
</Button>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-3 bg-slate-50 dark:bg-slate-800/50 p-3 rounded-xl border border-slate-100 dark:border-slate-700/50">
<Checkbox
id="stayLoggedIn"
checked={stayLoggedIn}
onCheckedChange={(checked) => setStayLoggedIn(checked === true)}
className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700"
/>
<Label htmlFor="stayLoggedIn" className="text-sm cursor-pointer">
<Label htmlFor="stayLoggedIn" className="text-sm font-medium cursor-pointer select-none opacity-80">
Keep me logged in on this device
</Label>
</div>
<p className="text-center text-sm text-muted-foreground">
By continuing, you agree to our Terms of Service and Privacy Policy
<div className="pt-2">
<p className="text-center text-[10px] uppercase font-bold tracking-widest text-slate-400 dark:text-slate-500 leading-relaxed max-w-[200px] mx-auto">
By continuing, you agree to our Terms & Privacy Policy
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -1,16 +1,24 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } 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, Sparkles } from 'lucide-react';
import { Smile, Meh, Frown, TrendingUp, ChevronLeft, ChevronRight, Quote, Sparkles, Heart, AlertCircle } from 'lucide-react';
import { MoodEntry, fetchMoodEntries, saveMoodEntry } from '@/lib/storage';
import { ResponsiveContainer, BarChart, Bar, XAxis, Tooltip, Cell } from 'recharts';
import { format, subDays, startOfWeek, endOfWeek, eachDayOfInterval } from 'date-fns';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Cell } from 'recharts';
import { format, subDays, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay } from 'date-fns';
import { cn } from '@/lib/utils';
import { useTheme } from '@/lib/theme-context';
type MoodType = 'amazing' | 'good' | 'neutral' | 'terrible';
const moodConfig = [
{ id: 'amazing', icon: Heart, label: 'Amazing!', color: 'fuchsia', score: 100 },
{ id: 'good', icon: Smile, label: 'Good', color: 'emerald', score: 75 },
{ id: 'neutral', icon: Meh, label: 'Okay', color: 'amber', score: 50 },
{ id: 'terrible', icon: AlertCircle, label: 'Terrible', color: 'red', score: 0 }
];
function MoodTrackerComponent() {
const { theme } = useTheme();
const [entries, setEntries] = useState<MoodEntry[]>([]);
@ -18,7 +26,7 @@ function MoodTrackerComponent() {
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 [activeMood, setActiveMood] = useState<MoodType | null>(null);
const [currentTimeout, setCurrentTimeout] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
@ -27,18 +35,25 @@ function MoodTrackerComponent() {
setEntries(data);
if (data.length > 0) {
// If the most recent entry is today, set it as active
const today = format(new Date(), 'yyyy-MM-dd');
const lastEntry = data[0];
if (lastEntry.date === today) {
setActiveMood(lastEntry.mood as any);
}
// REMOVED: Auto-selection of mood on load per user request
// const today = format(new Date(), 'yyyy-MM-dd');
// const lastEntry = data[0];
// if (lastEntry.date === today) {
// if (['amazing', 'good', 'neutral', 'terrible'].includes(lastEntry.mood)) {
// setActiveMood(lastEntry.mood as MoodType);
// } else if (lastEntry.mood === 'bad') {
// // Map legacy 'bad' to 'terrible' or 'neutral' for UI state?
// // Let's map legacy 'bad' to 'terrible' for now or just generic fallback
// setActiveMood('terrible');
// }
// }
}
setIsLoading(false);
};
loadMoods();
}, []);
const handleMoodSelect = async (mood: 'good' | 'neutral' | 'bad') => {
const handleMoodSelect = useCallback(async (mood: MoodType) => {
setIsSaving(true);
setAffirmation(null);
setActiveMood(mood);
@ -48,14 +63,32 @@ function MoodTrackerComponent() {
setCurrentTimeout(null);
}
let score = 50;
if (mood === 'amazing') score = 100;
else if (mood === 'good') score = 75;
else if (mood === 'neutral') score = 50;
else if (mood === 'terrible') score = 0;
// Optimistic Update
const tempId = `temp-${Date.now()}`;
const todayStr = format(new Date(), 'yyyy-MM-dd');
const tempEntry: MoodEntry = {
id: tempId,
userId: 'current',
mood,
score,
date: todayStr,
comment: null,
createdAt: new Date().toISOString()
};
setEntries(prev => [tempEntry, ...prev]);
try {
const result = await saveMoodEntry(mood);
const result = await saveMoodEntry(mood, score, undefined, todayStr);
if (result) {
setEntries(prev => {
// Remove existing entry for today if it exists to avoid duplicates in state
const filtered = prev.filter(e => e.date !== result.entry.date);
return [result.entry, ...filtered];
});
// Replace temp entry with real one
setEntries(prev => prev.map(e => e.id === tempId ? result.entry : e));
setAffirmation(result.affirmation);
const timeout = setTimeout(() => {
@ -63,11 +96,17 @@ function MoodTrackerComponent() {
setCurrentTimeout(null);
}, 8000);
setCurrentTimeout(timeout);
} else {
// Revert if failed
setEntries(prev => prev.filter(e => e.id !== tempId));
}
} catch (error) {
// Revert if error
setEntries(prev => prev.filter(e => e.id !== tempId));
} finally {
setIsSaving(false);
}
};
}, [currentTimeout]);
const weeklyData = useMemo(() => {
const today = new Date();
@ -77,23 +116,39 @@ function MoodTrackerComponent() {
return days.map(day => {
const dateStr = format(day, 'yyyy-MM-dd');
// Find the *latest* entry for this day
const dayEntry = entries.find(e => e.date === dateStr);
// Find ALL entries for this day
const dayEntries = entries.filter(e => e.date === dateStr);
// Map mood to numeric values: bad=1, neutral=2, good=3
let value = 0;
if (dayEntry) {
if (dayEntry.mood === 'good') value = 3;
else if (dayEntry.mood === 'neutral') value = 2;
else if (dayEntry.mood === 'bad') value = 1;
let averageScore = 0;
let hasData = false;
if (dayEntries.length > 0) {
hasData = true;
const totalScore = dayEntries.reduce((sum, entry) => {
// Use entry.score if available, otherwise fallback to mapping mood
let s = entry.score;
if (typeof s !== 'number') {
if (entry.mood === 'amazing') s = 100;
else if (entry.mood === 'good') s = 75;
else if (entry.mood === 'neutral') s = 50;
else if (entry.mood === 'bad') s = 25; // Legacy bad
else if (entry.mood === 'terrible') s = 0;
else s = 50;
}
return sum + s;
}, 0);
averageScore = totalScore / dayEntries.length;
}
return {
name: format(day, 'EEE'),
fullDate: dateStr,
value: value === 0 ? 0.2 : value, // 0.2 provides a small placeholder bar
isPlaceholder: value === 0,
mood: dayEntry?.mood
// Make sure even 0 score has significant visual height (e.g. 15% of height)
// But keep originalScore accurate for tooltip colors
value: hasData ? Math.max(15, averageScore) : 2,
originalScore: hasData ? averageScore : 0,
isPlaceholder: !hasData,
count: dayEntries.length
};
});
}, [entries, weekOffset]);
@ -106,37 +161,48 @@ function MoodTrackerComponent() {
return `${format(start, 'MMM d')} - ${format(end, 'MMM d')}`;
}, [weekOffset]);
const getGradient = () => {
const weeklyAverage = useMemo(() => {
const daysWithData = weeklyData.filter(d => !d.isPlaceholder && d.count > 0);
if (daysWithData.length === 0) return null;
const totalScore = daysWithData.reduce((sum, d) => sum + d.originalScore, 0);
return Math.round(totalScore / daysWithData.length);
}, [weeklyData]);
const gradientClass = useMemo(() => {
if (theme === 'light') {
switch (activeMood) {
case 'amazing':
return 'from-fuchsia-100 via-pink-50 to-fuchsia-100 border-fuchsia-200 shadow-fuchsia-500/5';
case 'good':
return 'from-emerald-100 via-teal-50 to-emerald-100 border-emerald-200 shadow-emerald-500/5';
case 'neutral':
return 'from-amber-100 via-orange-50 to-amber-100 border-amber-200 shadow-amber-500/5';
case 'bad':
return 'from-rose-100 via-red-50 to-rose-100 border-rose-200 shadow-rose-500/5';
case 'terrible':
return 'from-slate-200 via-gray-100 to-slate-200 border-slate-300 shadow-slate-500/5';
default:
return 'from-indigo-50 via-white to-indigo-50 border-indigo-100 shadow-indigo-500/5';
}
}
switch (activeMood) {
case 'amazing':
return 'from-fuchsia-500/10 via-pink-500/5 to-fuchsia-500/10 border-fuchsia-500/20 shadow-fuchsia-500/10';
case 'good':
return 'from-emerald-500/10 via-teal-500/5 to-emerald-500/10 border-emerald-500/20 shadow-emerald-500/10';
case 'neutral':
return 'from-amber-500/10 via-orange-500/5 to-amber-500/10 border-amber-500/20 shadow-amber-500/10';
case 'bad':
return 'from-rose-500/10 via-red-500/5 to-rose-500/10 border-rose-500/20 shadow-rose-500/10';
case 'terrible':
return 'from-red-900/20 via-red-900/10 to-red-900/20 border-red-500/20 shadow-red-500/10';
default:
return 'from-violet-500/10 via-indigo-500/5 to-violet-500/10 border-white/10 shadow-indigo-500/5';
}
};
}, [activeMood, theme]);
return (
<Card className={cn(
"overflow-hidden transition-all duration-700 backdrop-blur-xl border shadow-xl",
"overflow-hidden transition-all duration-700 ease-in-out backdrop-blur-xl border shadow-xl",
"bg-gradient-to-br",
getGradient()
gradientClass
)}>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
@ -145,9 +211,10 @@ function MoodTrackerComponent() {
theme === 'light' ? "text-slate-700" : "text-white/90"
)}>
<div className={cn("p-1.5 rounded-lg transition-colors duration-500",
activeMood === 'amazing' ? (theme === 'light' ? "bg-fuchsia-100 text-fuchsia-600" : "bg-fuchsia-500/20 text-fuchsia-400") :
activeMood === 'good' ? (theme === 'light' ? "bg-emerald-100 text-emerald-600" : "bg-emerald-500/20 text-emerald-400") :
activeMood === 'neutral' ? (theme === 'light' ? "bg-amber-100 text-amber-600" : "bg-amber-500/20 text-amber-400") :
activeMood === 'bad' ? (theme === 'light' ? "bg-rose-100 text-rose-600" : "bg-rose-500/20 text-rose-400") :
activeMood === 'terrible' ? (theme === 'light' ? "bg-red-100 text-red-600" : "bg-red-500/20 text-red-400") :
(theme === 'light' ? "bg-indigo-100 text-indigo-500" : "bg-white/10 text-white/70")
)}>
<Sparkles className="w-4 h-4" />
@ -196,16 +263,31 @@ function MoodTrackerComponent() {
</Button>
</div>
</div>
{weeklyAverage !== null && (
<div className={cn(
"mt-1 flex justify-end",
theme === 'light' ? "text-slate-500" : "text-white/60"
)}>
<span className="text-[10px] font-medium uppercase tracking-wider flex items-center gap-1.5">
Weekly Score:
<span className={cn(
"font-bold text-xs px-1.5 py-0.5 rounded-md",
weeklyAverage >= 85 ? (theme === 'light' ? "bg-fuchsia-100 text-fuchsia-600" : "bg-fuchsia-500/20 text-fuchsia-300") :
weeklyAverage >= 65 ? (theme === 'light' ? "bg-emerald-100 text-emerald-600" : "bg-emerald-500/20 text-emerald-300") :
weeklyAverage >= 35 ? (theme === 'light' ? "bg-amber-100 text-amber-600" : "bg-amber-500/20 text-amber-300") :
(theme === 'light' ? "bg-red-100 text-red-600" : "bg-red-500/20 text-red-300")
)}>
{weeklyAverage}%
</span>
</span>
</div>
)}
</CardHeader>
<CardContent className="pt-2 space-y-4">
{/* Mood Selection */}
<div className="grid grid-cols-3 gap-3">
{[
{ id: 'good', icon: Smile, label: 'Good', color: 'emerald' },
{ id: 'neutral', icon: Meh, label: 'Okay', color: 'amber' },
{ id: 'bad', icon: Frown, label: 'Bad', color: 'rose' }
].map((item) => {
<div className="grid grid-cols-4 gap-2">
{moodConfig.map((item) => {
const isSelected = activeMood === item.id;
const Icon = item.icon;
@ -215,7 +297,7 @@ function MoodTrackerComponent() {
onClick={() => handleMoodSelect(item.id as any)}
disabled={isSaving}
className={cn(
"group relative flex flex-col items-center justify-center gap-2 p-3 rounded-2xl transition-all duration-300",
"group relative flex flex-col items-center justify-center gap-2 p-2 rounded-2xl transition-all duration-300 hover:scale-105 active:scale-95",
"border",
isSelected
? (theme === 'light'
@ -227,17 +309,17 @@ function MoodTrackerComponent() {
)}
>
<div className={cn(
"p-2 rounded-xl transition-all duration-300",
"p-1.5 rounded-xl transition-all duration-300",
isSelected
? `bg-${item.color}-500 text-white shadow-lg scale-110`
: (theme === 'light'
? `bg-slate-100 text-slate-400 group-hover:text-${item.color}-500 group-hover:scale-110`
: `bg-white/5 text-white/60 group-hover:text-${item.color}-400 group-hover:scale-110`)
)}>
<Icon className={cn("w-5 h-5", isSelected && "animate-pulse-subtle")} />
<Icon className={cn("w-4 h-4", isSelected && "animate-pulse-subtle")} />
</div>
<span className={cn(
"text-[10px] font-semibold tracking-wide uppercase transition-colors",
"text-[9px] font-semibold tracking-wide uppercase transition-colors truncate w-full text-center",
isSelected
? (theme === 'light' ? `text-${item.color}-700` : "text-white")
: (theme === 'light' ? "text-slate-400 group-hover:text-slate-600" : "text-white/40 group-hover:text-white/80")
@ -282,7 +364,7 @@ function MoodTrackerComponent() {
theme === 'light' ? "text-slate-400" : "text-white/40"
)}>
<TrendingUp className="w-3 h-3" />
<span>Mood Tracking</span>
<span>Daily Average Mood</span>
</div>
<div className={cn(
@ -308,6 +390,15 @@ function MoodTrackerComponent() {
const data = payload[0].payload;
if (data.isPlaceholder) return null;
const score = Math.round(data.originalScore);
let moodLabel = 'Neutral';
let colorClass = theme === 'light' ? "text-amber-600" : "text-amber-400";
if (score >= 85) { moodLabel = 'Amazing'; colorClass = theme === 'light' ? "text-fuchsia-600" : "text-fuchsia-400"; }
else if (score >= 65) { moodLabel = 'Good'; colorClass = theme === 'light' ? "text-emerald-600" : "text-emerald-400"; }
else if (score >= 35) { moodLabel = 'Okay'; colorClass = theme === 'light' ? "text-amber-600" : "text-amber-400"; }
else { moodLabel = 'Terrible'; colorClass = theme === 'light' ? "text-red-600" : "text-red-400"; }
return (
<div className={cn(
"p-2 rounded-lg shadow-xl backdrop-blur-md border",
@ -316,13 +407,13 @@ function MoodTrackerComponent() {
: "bg-slate-900/90 border-white/10 text-white"
)}>
<p className={cn("text-xs font-medium mb-1", theme === 'light' ? "text-slate-600" : "text-white")}>{data.fullDate}</p>
<p className={cn(
"text-xs font-bold capitalize",
data.mood === 'good' ? (theme === 'light' ? "text-emerald-600" : "text-emerald-400") :
data.mood === 'neutral' ? (theme === 'light' ? "text-amber-600" : "text-amber-400") :
(theme === 'light' ? "text-rose-600" : "text-rose-400")
)}>
{data.mood}
<div className="flex items-center gap-2">
<p className={cn("text-xs font-bold", colorClass)}>
{moodLabel} ({score}%)
</p>
</div>
<p className={cn("text-[10px] mt-1 opacity-70", theme === 'light' ? "text-slate-500" : "text-white/60")}>
{data.count} {data.count === 1 ? 'entry' : 'entries'}
</p>
</div>
);
@ -336,22 +427,23 @@ function MoodTrackerComponent() {
tickLine={false}
tick={{ fill: theme === 'light' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.4)', fontSize: 10, dy: 10 }}
/>
<YAxis hide domain={[0, 100]} />
<Bar dataKey="value" radius={[4, 4, 4, 4]}>
{weeklyData.map((entry, index) => {
const score = entry.originalScore;
let fillColor;
if (entry.isPlaceholder) {
fillColor = theme === 'light' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.05)';
} else if (entry.mood === 'good') {
fillColor = theme === 'light' ? '#10b981' : '#34d399'; // emerald-500 : emerald-400
} else if (entry.mood === 'neutral') {
fillColor = theme === 'light' ? '#f59e0b' : '#fbbf24'; // amber-500 : amber-400
} else {
fillColor = theme === 'light' ? '#f43f5e' : '#fb7185'; // rose-500 : rose-400
if (score >= 85) fillColor = theme === 'light' ? '#d946ef' : '#e879f9'; // Amazing (100) -> Purple
else if (score >= 60) fillColor = theme === 'light' ? '#10b981' : '#34d399'; // Good (75) -> Green
else if (score >= 50) fillColor = theme === 'light' ? '#f59e0b' : '#fbbf24'; // Neutral (50) -> Yellow
else fillColor = theme === 'light' ? '#ef4444' : '#f87171'; // Terrible (0) -> Red
}
return (
<Cell
key={`cell-${index}`}
key={`cell-${index}-${fillColor}-${score}`}
fill={fillColor}
className="transition-all duration-300 hover:opacity-80"
/>

View File

@ -433,6 +433,7 @@ export interface MoodEntryRow {
id: string;
userId: string;
mood: string;
score: number;
date: string;
comment: string | null;
createdAt: string;
@ -453,6 +454,7 @@ export async function getMoodEntriesD1(userId: string, limit: number = 50): Prom
export async function saveMoodEntryD1(
userId: string,
mood: string,
score: number,
date: string,
comment?: string | null
): Promise<MoodEntryRow | null> {
@ -464,14 +466,15 @@ export async function saveMoodEntryD1(
// 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();
`INSERT INTO MoodEntry (id, userId, mood, score, date, comment, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).bind(id, userId, mood, score, date, comment ?? null, now, now).run();
return {
id,
userId,
mood,
score,
date,
comment: comment ?? null,
createdAt: now,

View File

@ -72,7 +72,8 @@ export interface HealthMilestone {
export interface MoodEntry {
id: string;
userId: string;
mood: 'good' | 'neutral' | 'bad';
mood: 'good' | 'neutral' | 'bad' | string;
score: number;
date: string;
comment: string | null;
createdAt: string;
@ -369,12 +370,12 @@ export async function fetchMoodEntries(): Promise<MoodEntry[]> {
}
}
export async function saveMoodEntry(mood: 'good' | 'neutral' | 'bad', comment?: string): Promise<{ entry: MoodEntry; affirmation: string } | null> {
export async function saveMoodEntry(mood: 'good' | 'neutral' | 'bad' | string, score: number, comment?: string, date?: 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 }),
body: JSON.stringify({ mood, score, comment, date }),
});
if (response.ok) {
const data = await response.json() as { entry: MoodEntry; affirmation: string };