diff --git a/bun.lock b/bun.lock index d49434b..0bbf64c 100644 --- a/bun.lock +++ b/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", }, diff --git a/migrations/0007_add_mood_score.sql b/migrations/0007_add_mood_score.sql new file mode 100644 index 0000000..0bc9758 --- /dev/null +++ b/migrations/0007_add_mood_score.sql @@ -0,0 +1,2 @@ +-- Migration number: 0007 2024-01-30T23:33:00.000Z +ALTER TABLE MoodEntry ADD COLUMN score INTEGER DEFAULT 50; diff --git a/package.json b/package.json index dd07803..3fb4244 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c42f191..2db568f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/src/app/api/mood/route.ts b/src/app/api/mood/route.ts index 71616f8..0034b39 100644 --- a/src/app/api/mood/route.ts +++ b/src/app/api/mood/route.ts @@ -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 = { + 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) { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 27469a3..189287d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -14,70 +14,108 @@ export default function LoginPage() { }; return ( -
- - - QuitTraq - - Track your journey to a smoke-free life - - - -
- - - + + +
+
+

Live Preview

+

Experience the journey

+
+
-
- setStayLoggedIn(checked === true)} - /> - -
+ {/* Right Side: Login Form */} +
+ + +
+ Q +
+ + QuitTraq + + + Your companion to a smoke-free life + +
+ +
+ -

- By continuing, you agree to our Terms of Service and Privacy Policy -

- - + +
+ +
+ setStayLoggedIn(checked === true)} + className="w-5 h-5 rounded-md border-slate-300 dark:border-slate-700" + /> + +
+ +
+

+ By continuing, you agree to our Terms & Privacy Policy +

+
+
+
+
+
); } diff --git a/src/components/MoodTracker.tsx b/src/components/MoodTracker.tsx index 3a217f0..0fc2f20 100644 --- a/src/components/MoodTracker.tsx +++ b/src/components/MoodTracker.tsx @@ -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([]); @@ -18,7 +26,7 @@ function MoodTrackerComponent() { const [isSaving, setIsSaving] = useState(false); const [affirmation, setAffirmation] = useState(null); const [weekOffset, setWeekOffset] = useState(0); - const [activeMood, setActiveMood] = useState<'good' | 'neutral' | 'bad' | null>(null); + const [activeMood, setActiveMood] = useState(null); const [currentTimeout, setCurrentTimeout] = useState(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 (
@@ -145,10 +211,11 @@ function MoodTrackerComponent() { theme === 'light' ? "text-slate-700" : "text-white/90" )}>
@@ -196,16 +263,31 @@ function MoodTrackerComponent() {
+ {weeklyAverage !== null && ( +
+ + Weekly Score: + = 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}% + + +
+ )}
{/* Mood Selection */} -
- {[ - { 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) => { +
+ {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() { )} >
- +
- Mood Tracking + Daily Average Mood
= 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 (

{data.fullDate}

-

- {data.mood} +

+

+ {moodLabel} ({score}%) +

+
+

+ {data.count} {data.count === 1 ? 'entry' : 'entries'}

); @@ -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 }} /> + {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 ( diff --git a/src/lib/d1.ts b/src/lib/d1.ts index 92774ec..67fb451 100644 --- a/src/lib/d1.ts +++ b/src/lib/d1.ts @@ -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 { @@ -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, diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 84b7255..6f707fd 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -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 { } } -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 };