Polish MoodTracker component with premium glassmorphism and UI improvements

This commit is contained in:
Avery Felts 2026-01-27 20:17:33 -07:00
parent c518ad9f34
commit ce9cd0cce7

View File

@ -3,10 +3,11 @@
import { useState, useEffect, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Smile, Meh, Frown, TrendingUp, ChevronLeft, ChevronRight, MessageSquare, Quote } from 'lucide-react';
import { Smile, Meh, Frown, TrendingUp, ChevronLeft, ChevronRight, MessageSquare, Quote, Sparkles } from 'lucide-react';
import { MoodEntry, fetchMoodEntries, saveMoodEntry } from '@/lib/storage';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Cell, ReferenceLine } from 'recharts';
import { format, subDays, startOfWeek, endOfWeek, isWithinInterval, eachDayOfInterval, parseISO } from 'date-fns';
import { ResponsiveContainer, BarChart, Bar, XAxis, Tooltip, Cell } from 'recharts';
import { format, subDays, startOfWeek, endOfWeek, eachDayOfInterval } from 'date-fns';
import { cn } from '@/lib/utils';
export function MoodTracker() {
const [entries, setEntries] = useState<MoodEntry[]>([]);
@ -22,7 +23,12 @@ export function MoodTracker() {
const data = await fetchMoodEntries();
setEntries(data);
if (data.length > 0) {
setActiveMood(data[0].mood as any);
// 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);
}
}
setIsLoading(false);
};
@ -34,7 +40,6 @@ export function MoodTracker() {
setAffirmation(null);
setActiveMood(mood);
// Clear previous timeout if it exists
if (currentTimeout) {
clearTimeout(currentTimeout);
setCurrentTimeout(null);
@ -43,10 +48,13 @@ export function MoodTracker() {
try {
const result = await saveMoodEntry(mood);
if (result) {
setEntries(prev => [result.entry, ...prev]);
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];
});
setAffirmation(result.affirmation);
// Clear affirmation after 8 seconds
const timeout = setTimeout(() => {
setAffirmation(null);
setCurrentTimeout(null);
@ -66,25 +74,23 @@ export function MoodTracker() {
return days.map(day => {
const dateStr = format(day, 'yyyy-MM-dd');
const dayEntries = entries.filter(e => e.date === dateStr);
// Find the *latest* entry for this day
const dayEntry = entries.find(e => e.date === dateStr);
// Map mood to numeric values for graphing: bad=1, neutral=2, good=3
// Map mood to numeric values: bad=1, neutral=2, good=3
let value = 0;
if (dayEntries.length > 0) {
const total = dayEntries.reduce((sum, e) => {
if (e.mood === 'good') return sum + 3;
if (e.mood === 'neutral') return sum + 2;
if (e.mood === 'bad') return sum + 1;
return sum;
}, 0);
value = total / dayEntries.length;
if (dayEntry) {
if (dayEntry.mood === 'good') value = 3;
else if (dayEntry.mood === 'neutral') value = 2;
else if (dayEntry.mood === 'bad') value = 1;
}
return {
name: format(day, 'EEE'),
fullDate: dateStr,
value: value === 0 ? null : Number(value.toFixed(1)),
count: dayEntries.length
value: value === 0 ? 0.2 : value, // 0.2 provides a small placeholder bar
isPlaceholder: value === 0,
mood: dayEntry?.mood
};
});
}, [entries, weekOffset]);
@ -97,145 +103,180 @@ export function MoodTracker() {
return `${format(start, 'MMM d')} - ${format(end, 'MMM d')}`;
}, [weekOffset]);
// Dynamic styles based on active mood
const getMoodStyles = () => {
const getGradient = () => {
switch (activeMood) {
case 'good':
return 'from-emerald-600/20 via-teal-500/10 to-indigo-500/10 border-emerald-500/30 shadow-emerald-500/10';
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-600/20 via-orange-500/10 to-purple-500/10 border-amber-500/30 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':
return 'from-rose-600/20 via-pink-500/10 to-blue-500/10 border-rose-500/30 shadow-rose-500/10';
return 'from-rose-500/10 via-red-500/5 to-rose-500/10 border-rose-500/20 shadow-rose-500/10';
default:
return 'from-indigo-600/20 via-purple-500/10 to-pink-500/10 border-white/10 shadow-indigo-500/10';
return 'from-violet-500/10 via-indigo-500/5 to-violet-500/10 border-white/10 shadow-indigo-500/5';
}
};
return (
<Card className={`overflow-hidden border transition-all duration-1000 backdrop-blur-md shadow-2xl hover-lift bg-gradient-to-br ${getMoodStyles()}`}>
<CardHeader className="pb-2 border-b border-white/5 bg-black/20">
<Card className={cn(
"overflow-hidden transition-all duration-700 backdrop-blur-xl border shadow-xl",
"bg-gradient-to-br",
getGradient()
)}>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-bold flex items-center gap-2 text-white">
<MessageSquare className="w-5 h-5 text-indigo-400" />
How do you feel?
<CardTitle className="text-base sm:text-lg font-medium flex items-center gap-2 text-white/90">
<div className={cn("p-1.5 rounded-lg transition-colors duration-500",
activeMood === 'good' ? "bg-emerald-500/20 text-emerald-400" :
activeMood === 'neutral' ? "bg-amber-500/20 text-amber-400" :
activeMood === 'bad' ? "bg-rose-500/20 text-rose-400" :
"bg-white/10 text-white/70"
)}>
<Sparkles className="w-4 h-4" />
</div>
How are you feeling?
</CardTitle>
<div className="flex items-center gap-1 bg-white/5 rounded-full p-1 border border-white/10">
<div className="flex items-center gap-1 bg-black/20 rounded-full p-0.5 border border-white/5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full text-white/70 hover:text-white"
className="h-6 w-6 rounded-full text-white/50 hover:text-white hover:bg-white/10"
onClick={() => setWeekOffset(prev => prev + 1)}
>
<ChevronLeft className="w-4 h-4" />
<ChevronLeft className="w-3 h-3" />
</Button>
<span className="text-[10px] font-medium px-2 text-white/90 uppercase tracking-wider">{weekLabel}</span>
<span className="text-[10px] font-medium px-2 text-white/60 uppercase tracking-wider min-w-[60px] text-center">
{weekLabel}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full text-white/70 hover:text-white disabled:opacity-30"
className="h-6 w-6 rounded-full text-white/50 hover:text-white hover:bg-white/10 disabled:opacity-20"
disabled={weekOffset === 0}
onClick={() => setWeekOffset(prev => prev - 1)}
>
<ChevronRight className="w-4 h-4" />
<ChevronRight className="w-3 h-3" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Mood Buttons */}
<div className="grid grid-cols-3 gap-4">
<button
onClick={() => handleMoodSelect('good')}
disabled={isSaving}
className="group flex flex-col items-center gap-2 p-4 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 hover:bg-emerald-500/20 transition-all active:scale-95 disabled:opacity-50"
>
<div className="p-3 rounded-full bg-emerald-500/20 group-hover:bg-emerald-500/40 transition-colors">
<Smile className="w-8 h-8 text-emerald-400 group-hover:scale-110 transition-transform" />
</div>
<span className="text-xs font-semibold text-emerald-400 uppercase tracking-tighter">Good</span>
</button>
<CardContent className="pt-6 space-y-8">
{/* Mood Selection */}
<div className="grid grid-cols-3 gap-3 sm:gap-6">
{[
{ 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 Icon = item.icon;
<button
onClick={() => handleMoodSelect('neutral')}
disabled={isSaving}
className="group flex flex-col items-center gap-2 p-4 rounded-2xl bg-amber-500/10 border border-amber-500/20 hover:bg-amber-500/20 transition-all active:scale-95 disabled:opacity-50"
>
<div className="p-3 rounded-full bg-amber-500/20 group-hover:bg-amber-500/40 transition-colors">
<Meh className="w-8 h-8 text-amber-400 group-hover:rotate-12 transition-transform" />
</div>
<span className="text-xs font-semibold text-amber-400 uppercase tracking-tighter">Neutral</span>
</button>
<button
onClick={() => handleMoodSelect('bad')}
disabled={isSaving}
className="group flex flex-col items-center gap-2 p-4 rounded-2xl bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 transition-all active:scale-95 disabled:opacity-50"
>
<div className="p-3 rounded-full bg-rose-500/20 group-hover:bg-rose-500/40 transition-colors">
<Frown className="w-8 h-8 text-rose-400 group-hover:-translate-y-1 transition-transform" />
</div>
<span className="text-xs font-semibold text-rose-400 uppercase tracking-tighter">Bad</span>
</button>
return (
<button
key={item.id}
onClick={() => handleMoodSelect(item.id as any)}
disabled={isSaving}
className={cn(
"group relative flex flex-col items-center justify-center gap-3 p-4 rounded-2xl transition-all duration-300",
"border",
isSelected
? `bg-${item.color}-500/20 border-${item.color}-500/50 shadow-[0_0_20px_-5px_var(--color-${item.color}-500)]`
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 hover:-translate-y-1"
)}
>
<div className={cn(
"p-3 rounded-xl transition-all duration-300",
isSelected ? `bg-${item.color}-500 text-white shadow-lg scale-110` : `bg-white/5 text-white/60 group-hover:text-${item.color}-400 group-hover:scale-110`
)}>
<Icon className={cn("w-6 h-6", isSelected && "animate-pulse-subtle")} />
</div>
<span className={cn(
"text-xs font-semibold tracking-wide uppercase transition-colors",
isSelected ? "text-white" : "text-white/40 group-hover:text-white/80"
)}>
{item.label}
</span>
</button>
);
})}
</div>
{/* Affirmation Message */}
{affirmation && (
<div className="animate-fade-in-up bg-white/5 border border-white/10 rounded-xl p-4 flex gap-3 items-start">
<Quote className="w-4 h-4 text-indigo-400 flex-shrink-0 mt-1" />
<p className="text-sm italic text-white/90 leading-relaxed">{affirmation}</p>
{/* Dynamic Affirmation */}
<div className={cn(
"relative overflow-hidden transition-all duration-500 ease-out",
affirmation ? "opacity-100 max-h-32 translate-y-0" : "opacity-0 max-h-0 translate-y-4"
)}>
<div className="bg-gradient-to-r from-indigo-500/10 to-purple-500/10 border border-indigo-500/20 rounded-xl p-4 flex gap-4 items-center">
<div className="p-2 bg-indigo-500/20 rounded-full shrink-0">
<Quote className="w-4 h-4 text-indigo-400" />
</div>
<p className="text-sm font-medium text-indigo-100/90 leading-relaxed">
{affirmation}
</p>
</div>
)}
</div>
{/* Week Graph */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-[10px] font-bold text-white/40 uppercase tracking-widest pl-1">
{/* Mini Graph */}
<div className="space-y-4 pt-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/40 uppercase tracking-widest px-1">
<TrendingUp className="w-3 h-3" />
Mood Trend
<span>Mood Tracking</span>
</div>
<div className="h-32 w-full">
<div className="h-32 w-full bg-white/5 rounded-xl border border-white/5 p-4 relative group">
{/* Grid lines background effect */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:14px_14px] mask-image-linear-gradient(to_bottom,transparent,black)] pointer-events-none" />
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyData} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
<BarChart data={weeklyData} barSize={36}>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.05)', radius: 4 }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
if (data.isPlaceholder) return null;
return (
<div className="bg-slate-900/90 border border-white/10 p-2 rounded-lg shadow-xl backdrop-blur-md">
<p className="text-xs font-medium text-white mb-1">{data.fullDate}</p>
<p className={cn(
"text-xs font-bold capitalize",
data.mood === 'good' ? "text-emerald-400" :
data.mood === 'neutral' ? "text-amber-400" :
"text-rose-400"
)}>
{data.mood}
</p>
</div>
);
}
return null;
}}
/>
<XAxis
dataKey="name"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 10 }}
axisLine={false}
tickLine={false}
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 10, dy: 10 }}
/>
<YAxis
domain={[0, 3]}
ticks={[1, 2, 3]}
axisLine={false}
tickLine={false}
tick={false}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
contentStyle={{
backgroundColor: 'rgba(15, 23, 42, 0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
fontSize: '12px',
color: '#fff'
}}
labelStyle={{ display: 'none' }}
formatter={(value: number | undefined) => {
if (value === undefined) return ['', ''];
if (value >= 2.5) return ['Good', 'Mood'];
if (value >= 1.5) return ['Neutral', 'Mood'];
if (value > 0) return ['Bad', 'Mood'];
return ['No Record', 'Mood'];
}}
/>
<Bar dataKey="value" radius={[4, 4, 4, 4]} barSize={24}>
<Bar dataKey="value" radius={[4, 4, 4, 4]}>
{weeklyData.map((entry, index) => {
let color = 'rgba(255,255,255,0.1)';
if (entry.value) {
if (entry.value >= 2.5) color = '#10b981'; // emerald-500
else if (entry.value >= 1.5) color = '#f59e0b'; // amber-500
else color = '#f43f5e'; // rose-500
let color = 'rgba(255,255,255,0.05)'; // Placeholder color
if (!entry.isPlaceholder) {
if (entry.mood === 'good') color = '#10b981'; // emerald-500
else if (entry.mood === 'neutral') color = '#f59e0b'; // amber-500
else if (entry.mood === 'bad') color = '#f43f5e'; // rose-500
}
return <Cell key={`cell-${index}`} fill={color} />;
return (
<Cell
key={`cell-${index}`}
fill={color}
className="transition-all duration-300 hover:opacity-80"
/>
);
})}
</Bar>
</BarChart>