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-day-picker": "^9.13.0",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"styled-jsx": "^5.1.6",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"web-push": "^3.6.7", "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", "react-dom": "19.2.3",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"web-push": "^3.6.7" "web-push": "^3.6.7",
"styled-jsx": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250121.0", "@cloudflare/workers-types": "^4.20250121.0",

View File

@ -93,7 +93,8 @@ model SavingsConfig {
model MoodEntry { model MoodEntry {
id String @id @default(cuid()) id String @id @default(cuid())
userId String 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 date String // YYYY-MM-DD
comment String? comment String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -3,27 +3,76 @@ import { getSession } from '@/lib/session';
import { getMoodEntriesD1, saveMoodEntryD1 } from '@/lib/d1'; import { getMoodEntriesD1, saveMoodEntryD1 } from '@/lib/d1';
import { getTodayString } from '@/lib/date-utils'; 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: [ good: [
"That's wonderful! Keep riding this positive wave.", "That's wonderful! Keep riding this positive wave.",
"Your strength is inspiring. Keep going!", "Your strength is inspiring. Keep going!",
"Happiness is a great companion on this journey.", "Happiness is a great companion on this journey.",
"So glad you're feeling good! You've got this.", "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: [ neutral: [
"Steady as she goes. Every day is progress.", "Steady as she goes. Every day is progress.",
"It's okay to just 'be' sometimes. Stay the course.", "It's okay to just 'be' sometimes. Stay the course.",
"Focus on your 'why' today. You're doing great.", "Focus on your 'why' today. You're doing great.",
"One step at a time. You're still moving forward.", "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: [ bad: [
"I'm sorry things are tough. This feeling is temporary.", "I'm sorry things are tough. This feeling is temporary.",
"Be kind to yourself today. You're still stronger than you think.", "Be kind to yourself today. You're still stronger than you think.",
"Tough times don't last, but tough people do. Hang in there.", "Tough times don't last, but tough people do. Hang in there.",
"Take a deep breath. Tomorrow is a fresh start.", "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 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const body = await request.json() as { mood: 'good' | 'neutral' | 'bad'; comment?: string }; const body = await request.json() as { mood: 'amazing' | 'good' | 'neutral' | 'bad' | 'terrible'; score: number; comment?: string; date?: string };
const { mood, comment } = body; const { mood, score, comment, date } = body;
if (!mood || !['good', 'neutral', 'bad'].includes(mood)) { if (!mood) {
return NextResponse.json({ error: 'Invalid mood' }, { status: 400 }); return NextResponse.json({ error: 'Invalid mood' }, { status: 400 });
} }
const today = getTodayString(); // Validate score is between 0 and 100
const entry = await saveMoodEntryD1(session.user.id, mood, today, comment); 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) { if (!entry) {
return NextResponse.json({ error: 'Failed to save mood entry' }, { status: 500 }); return NextResponse.json({ error: 'Failed to save mood entry' }, { status: 500 });
} }
// Pick a random affirmation // Pick a random affirmation
const moodAffirmations = AFFIRMATIONS[mood]; let affirmation = "You're doing great! Keep it up.";
const affirmation = moodAffirmations[Math.floor(Math.random() * moodAffirmations.length)]; 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 }); return NextResponse.json({ entry, affirmation });
} catch (error) { } catch (error) {

View File

@ -14,22 +14,55 @@ export default function LoginPage() {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <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">
<Card className="w-full max-w-md bg-card/80 backdrop-blur-sm border-white/10"> {/* Background Orbs */}
<CardHeader className="text-center"> <div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/10 rounded-full blur-[120px] pointer-events-none" />
<CardTitle className="text-3xl font-bold">QuitTraq</CardTitle> <div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-500/10 rounded-full blur-[120px] pointer-events-none" />
<CardDescription className="text-lg">
Track your journey to a smoke-free life <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6 pb-8">
<div className="space-y-4"> <div className="space-y-3">
<Button <Button
variant="outline" 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')} 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 <path
fill="currentColor" 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" 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 <Button
variant="outline" 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')} 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" /> <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> </svg>
Continue with Apple Continue with Apple
</Button> </Button>
</div> </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 <Checkbox
id="stayLoggedIn" id="stayLoggedIn"
checked={stayLoggedIn} checked={stayLoggedIn}
onCheckedChange={(checked) => setStayLoggedIn(checked === true)} 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 Keep me logged in on this device
</Label> </Label>
</div> </div>
<p className="text-center text-sm text-muted-foreground"> <div className="pt-2">
By continuing, you agree to our Terms of Service and Privacy Policy <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> </p>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
</div>
); );
} }

View File

@ -1,16 +1,24 @@
'use client'; '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 { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; 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 { MoodEntry, fetchMoodEntries, saveMoodEntry } from '@/lib/storage';
import { ResponsiveContainer, BarChart, Bar, XAxis, Tooltip, Cell } from 'recharts'; import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Cell } from 'recharts';
import { format, subDays, startOfWeek, endOfWeek, eachDayOfInterval } from 'date-fns'; import { format, subDays, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay } from 'date-fns';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTheme } from '@/lib/theme-context'; 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() { function MoodTrackerComponent() {
const { theme } = useTheme(); const { theme } = useTheme();
const [entries, setEntries] = useState<MoodEntry[]>([]); const [entries, setEntries] = useState<MoodEntry[]>([]);
@ -18,7 +26,7 @@ function MoodTrackerComponent() {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [affirmation, setAffirmation] = useState<string | null>(null); const [affirmation, setAffirmation] = useState<string | null>(null);
const [weekOffset, setWeekOffset] = useState(0); 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); const [currentTimeout, setCurrentTimeout] = useState<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
@ -27,18 +35,25 @@ function MoodTrackerComponent() {
setEntries(data); setEntries(data);
if (data.length > 0) { if (data.length > 0) {
// If the most recent entry is today, set it as active // If the most recent entry is today, set it as active
const today = format(new Date(), 'yyyy-MM-dd'); // REMOVED: Auto-selection of mood on load per user request
const lastEntry = data[0]; // const today = format(new Date(), 'yyyy-MM-dd');
if (lastEntry.date === today) { // const lastEntry = data[0];
setActiveMood(lastEntry.mood as any); // 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); setIsLoading(false);
}; };
loadMoods(); loadMoods();
}, []); }, []);
const handleMoodSelect = async (mood: 'good' | 'neutral' | 'bad') => { const handleMoodSelect = useCallback(async (mood: MoodType) => {
setIsSaving(true); setIsSaving(true);
setAffirmation(null); setAffirmation(null);
setActiveMood(mood); setActiveMood(mood);
@ -48,14 +63,32 @@ function MoodTrackerComponent() {
setCurrentTimeout(null); 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 { try {
const result = await saveMoodEntry(mood); const result = await saveMoodEntry(mood, score, undefined, todayStr);
if (result) { if (result) {
setEntries(prev => { // Replace temp entry with real one
// Remove existing entry for today if it exists to avoid duplicates in state setEntries(prev => prev.map(e => e.id === tempId ? result.entry : e));
const filtered = prev.filter(e => e.date !== result.entry.date);
return [result.entry, ...filtered];
});
setAffirmation(result.affirmation); setAffirmation(result.affirmation);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -63,11 +96,17 @@ function MoodTrackerComponent() {
setCurrentTimeout(null); setCurrentTimeout(null);
}, 8000); }, 8000);
setCurrentTimeout(timeout); 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 { } finally {
setIsSaving(false); setIsSaving(false);
} }
}; }, [currentTimeout]);
const weeklyData = useMemo(() => { const weeklyData = useMemo(() => {
const today = new Date(); const today = new Date();
@ -77,23 +116,39 @@ function MoodTrackerComponent() {
return days.map(day => { return days.map(day => {
const dateStr = format(day, 'yyyy-MM-dd'); const dateStr = format(day, 'yyyy-MM-dd');
// Find the *latest* entry for this day // Find ALL entries for this day
const dayEntry = entries.find(e => e.date === dateStr); const dayEntries = entries.filter(e => e.date === dateStr);
// Map mood to numeric values: bad=1, neutral=2, good=3 let averageScore = 0;
let value = 0; let hasData = false;
if (dayEntry) {
if (dayEntry.mood === 'good') value = 3; if (dayEntries.length > 0) {
else if (dayEntry.mood === 'neutral') value = 2; hasData = true;
else if (dayEntry.mood === 'bad') value = 1; 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 { return {
name: format(day, 'EEE'), name: format(day, 'EEE'),
fullDate: dateStr, fullDate: dateStr,
value: value === 0 ? 0.2 : value, // 0.2 provides a small placeholder bar // Make sure even 0 score has significant visual height (e.g. 15% of height)
isPlaceholder: value === 0, // But keep originalScore accurate for tooltip colors
mood: dayEntry?.mood value: hasData ? Math.max(15, averageScore) : 2,
originalScore: hasData ? averageScore : 0,
isPlaceholder: !hasData,
count: dayEntries.length
}; };
}); });
}, [entries, weekOffset]); }, [entries, weekOffset]);
@ -106,37 +161,48 @@ function MoodTrackerComponent() {
return `${format(start, 'MMM d')} - ${format(end, 'MMM d')}`; return `${format(start, 'MMM d')} - ${format(end, 'MMM d')}`;
}, [weekOffset]); }, [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') { if (theme === 'light') {
switch (activeMood) { switch (activeMood) {
case 'amazing':
return 'from-fuchsia-100 via-pink-50 to-fuchsia-100 border-fuchsia-200 shadow-fuchsia-500/5';
case 'good': case 'good':
return 'from-emerald-100 via-teal-50 to-emerald-100 border-emerald-200 shadow-emerald-500/5'; return 'from-emerald-100 via-teal-50 to-emerald-100 border-emerald-200 shadow-emerald-500/5';
case 'neutral': case 'neutral':
return 'from-amber-100 via-orange-50 to-amber-100 border-amber-200 shadow-amber-500/5'; return 'from-amber-100 via-orange-50 to-amber-100 border-amber-200 shadow-amber-500/5';
case 'bad': case 'terrible':
return 'from-rose-100 via-red-50 to-rose-100 border-rose-200 shadow-rose-500/5'; return 'from-slate-200 via-gray-100 to-slate-200 border-slate-300 shadow-slate-500/5';
default: default:
return 'from-indigo-50 via-white to-indigo-50 border-indigo-100 shadow-indigo-500/5'; return 'from-indigo-50 via-white to-indigo-50 border-indigo-100 shadow-indigo-500/5';
} }
} }
switch (activeMood) { 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': case 'good':
return 'from-emerald-500/10 via-teal-500/5 to-emerald-500/10 border-emerald-500/20 shadow-emerald-500/10'; return 'from-emerald-500/10 via-teal-500/5 to-emerald-500/10 border-emerald-500/20 shadow-emerald-500/10';
case 'neutral': case 'neutral':
return 'from-amber-500/10 via-orange-500/5 to-amber-500/10 border-amber-500/20 shadow-amber-500/10'; return 'from-amber-500/10 via-orange-500/5 to-amber-500/10 border-amber-500/20 shadow-amber-500/10';
case 'bad': case 'terrible':
return 'from-rose-500/10 via-red-500/5 to-rose-500/10 border-rose-500/20 shadow-rose-500/10'; return 'from-red-900/20 via-red-900/10 to-red-900/20 border-red-500/20 shadow-red-500/10';
default: default:
return 'from-violet-500/10 via-indigo-500/5 to-violet-500/10 border-white/10 shadow-indigo-500/5'; return 'from-violet-500/10 via-indigo-500/5 to-violet-500/10 border-white/10 shadow-indigo-500/5';
} }
}; }, [activeMood, theme]);
return ( return (
<Card className={cn( <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", "bg-gradient-to-br",
getGradient() gradientClass
)}> )}>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -145,9 +211,10 @@ function MoodTrackerComponent() {
theme === 'light' ? "text-slate-700" : "text-white/90" theme === 'light' ? "text-slate-700" : "text-white/90"
)}> )}>
<div className={cn("p-1.5 rounded-lg transition-colors duration-500", <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 === '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 === '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") (theme === 'light' ? "bg-indigo-100 text-indigo-500" : "bg-white/10 text-white/70")
)}> )}>
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
@ -196,16 +263,31 @@ function MoodTrackerComponent() {
</Button> </Button>
</div> </div>
</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> </CardHeader>
<CardContent className="pt-2 space-y-4"> <CardContent className="pt-2 space-y-4">
{/* Mood Selection */} {/* Mood Selection */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-4 gap-2">
{[ {moodConfig.map((item) => {
{ 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) => {
const isSelected = activeMood === item.id; const isSelected = activeMood === item.id;
const Icon = item.icon; const Icon = item.icon;
@ -215,7 +297,7 @@ function MoodTrackerComponent() {
onClick={() => handleMoodSelect(item.id as any)} onClick={() => handleMoodSelect(item.id as any)}
disabled={isSaving} disabled={isSaving}
className={cn( 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", "border",
isSelected isSelected
? (theme === 'light' ? (theme === 'light'
@ -227,17 +309,17 @@ function MoodTrackerComponent() {
)} )}
> >
<div className={cn( <div className={cn(
"p-2 rounded-xl transition-all duration-300", "p-1.5 rounded-xl transition-all duration-300",
isSelected isSelected
? `bg-${item.color}-500 text-white shadow-lg scale-110` ? `bg-${item.color}-500 text-white shadow-lg scale-110`
: (theme === 'light' : (theme === 'light'
? `bg-slate-100 text-slate-400 group-hover:text-${item.color}-500 group-hover:scale-110` ? `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`) : `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> </div>
<span className={cn( <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 isSelected
? (theme === 'light' ? `text-${item.color}-700` : "text-white") ? (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") : (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" theme === 'light' ? "text-slate-400" : "text-white/40"
)}> )}>
<TrendingUp className="w-3 h-3" /> <TrendingUp className="w-3 h-3" />
<span>Mood Tracking</span> <span>Daily Average Mood</span>
</div> </div>
<div className={cn( <div className={cn(
@ -308,6 +390,15 @@ function MoodTrackerComponent() {
const data = payload[0].payload; const data = payload[0].payload;
if (data.isPlaceholder) return null; 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 ( return (
<div className={cn( <div className={cn(
"p-2 rounded-lg shadow-xl backdrop-blur-md border", "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" : "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-medium mb-1", theme === 'light' ? "text-slate-600" : "text-white")}>{data.fullDate}</p>
<p className={cn( <div className="flex items-center gap-2">
"text-xs font-bold capitalize", <p className={cn("text-xs font-bold", colorClass)}>
data.mood === 'good' ? (theme === 'light' ? "text-emerald-600" : "text-emerald-400") : {moodLabel} ({score}%)
data.mood === 'neutral' ? (theme === 'light' ? "text-amber-600" : "text-amber-400") : </p>
(theme === 'light' ? "text-rose-600" : "text-rose-400") </div>
)}> <p className={cn("text-[10px] mt-1 opacity-70", theme === 'light' ? "text-slate-500" : "text-white/60")}>
{data.mood} {data.count} {data.count === 1 ? 'entry' : 'entries'}
</p> </p>
</div> </div>
); );
@ -336,22 +427,23 @@ function MoodTrackerComponent() {
tickLine={false} tickLine={false}
tick={{ fill: theme === 'light' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.4)', fontSize: 10, dy: 10 }} 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]}> <Bar dataKey="value" radius={[4, 4, 4, 4]}>
{weeklyData.map((entry, index) => { {weeklyData.map((entry, index) => {
const score = entry.originalScore;
let fillColor; let fillColor;
if (entry.isPlaceholder) { if (entry.isPlaceholder) {
fillColor = theme === 'light' ? 'rgba(0,0,0,0.05)' : 'rgba(255,255,255,0.05)'; 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 { } 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 ( return (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}-${fillColor}-${score}`}
fill={fillColor} fill={fillColor}
className="transition-all duration-300 hover:opacity-80" className="transition-all duration-300 hover:opacity-80"
/> />

View File

@ -433,6 +433,7 @@ export interface MoodEntryRow {
id: string; id: string;
userId: string; userId: string;
mood: string; mood: string;
score: number;
date: string; date: string;
comment: string | null; comment: string | null;
createdAt: string; createdAt: string;
@ -453,6 +454,7 @@ export async function getMoodEntriesD1(userId: string, limit: number = 50): Prom
export async function saveMoodEntryD1( export async function saveMoodEntryD1(
userId: string, userId: string,
mood: string, mood: string,
score: number,
date: string, date: string,
comment?: string | null comment?: string | null
): Promise<MoodEntryRow | null> { ): Promise<MoodEntryRow | null> {
@ -464,14 +466,15 @@ export async function saveMoodEntryD1(
// Mood tracking is flexible, multiple entries per day are allowed // Mood tracking is flexible, multiple entries per day are allowed
await db.prepare( await db.prepare(
`INSERT INTO MoodEntry (id, userId, mood, date, comment, createdAt, updatedAt) `INSERT INTO MoodEntry (id, userId, mood, score, date, comment, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).bind(id, userId, mood, date, comment ?? null, now, now).run(); ).bind(id, userId, mood, score, date, comment ?? null, now, now).run();
return { return {
id, id,
userId, userId,
mood, mood,
score,
date, date,
comment: comment ?? null, comment: comment ?? null,
createdAt: now, createdAt: now,

View File

@ -72,7 +72,8 @@ export interface HealthMilestone {
export interface MoodEntry { export interface MoodEntry {
id: string; id: string;
userId: string; userId: string;
mood: 'good' | 'neutral' | 'bad'; mood: 'good' | 'neutral' | 'bad' | string;
score: number;
date: string; date: string;
comment: string | null; comment: string | null;
createdAt: string; 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 { try {
const response = await fetch('/api/mood', { const response = await fetch('/api/mood', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mood, comment }), body: JSON.stringify({ mood, score, comment, date }),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json() as { entry: MoodEntry; affirmation: string }; const data = await response.json() as { entry: MoodEntry; affirmation: string };