Enhance Mood Tracker: Optimize performance, smooth animations, add affirmations, and persist score
This commit is contained in:
parent
e532fe52d2
commit
36a3deddc7
1
bun.lock
1
bun.lock
@ -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",
|
||||
},
|
||||
|
||||
2
migrations/0007_add_mood_score.sql
Normal file
2
migrations/0007_add_mood_score.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- Migration number: 0007 2024-01-30T23:33:00.000Z
|
||||
ALTER TABLE MoodEntry ADD COLUMN score INTEGER DEFAULT 50;
|
||||
@ -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",
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -14,70 +14,108 @@ 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
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 text-base"
|
||||
onClick={() => handleLogin('GoogleOAuth')}
|
||||
<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"
|
||||
>
|
||||
<svg className="mr-2 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"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-12 text-base"
|
||||
onClick={() => handleLogin('AppleOAuth')}
|
||||
>
|
||||
<svg className="mr-2 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>
|
||||
<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>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="stayLoggedIn"
|
||||
checked={stayLoggedIn}
|
||||
onCheckedChange={(checked) => setStayLoggedIn(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="stayLoggedIn" className="text-sm cursor-pointer">
|
||||
Keep me logged in on this device
|
||||
</Label>
|
||||
</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 pb-8">
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
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-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"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
By continuing, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button
|
||||
variant="outline"
|
||||
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-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-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 font-medium cursor-pointer select-none opacity-80">
|
||||
Keep me logged in on this device
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,10 +211,11 @@ function MoodTrackerComponent() {
|
||||
theme === 'light' ? "text-slate-700" : "text-white/90"
|
||||
)}>
|
||||
<div className={cn("p-1.5 rounded-lg transition-colors duration-500",
|
||||
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") :
|
||||
(theme === 'light' ? "bg-indigo-100 text-indigo-500" : "bg-white/10 text-white/70")
|
||||
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 === '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" />
|
||||
</div>
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user