diff --git a/.env.example b/.env.example index 2e4a6b7..ba01b67 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,9 @@ # See OPENMINDWELL_PROJECT_GUIDE.md for instructions on obtaining these # Supabase (get from https://app.supabase.com) -SUPABASE_URL=https://your-project.supabase.co -SUPABASE_ANON_KEY=your-anon-key-here -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here +SUPABASE_URL=your-supabase-url-here +SUPABASE_ANON_KEY=your-supabase-anon-key-here +SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key-here # HuggingFace (optional - get from https://huggingface.co/settings/tokens) HUGGINGFACE_API_TOKEN=your-token-here diff --git a/backend/.env.example b/backend/.env.example index 0b99aae..dbcd8fa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,10 +1,14 @@ -# Supabase Configuration -SUPABASE_URL=https://your-project-id.supabase.co -SUPABASE_ANON_KEY=your-anon-key -SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Backend Environment Variables +# Copy this to .env and fill in your actual values -# HuggingFace API Token (Optional) -HUGGINGFACE_API_TOKEN=hf_YourTokenHere +# Supabase Configuration (get from https://app.supabase.com → Project Settings → API) +SUPABASE_URL=your-supabase-url-here +SUPABASE_ANON_KEY=your-supabase-anon-key-here +SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key-here + +# HuggingFace API Token (optional - get from https://huggingface.co/settings/tokens) +# If not provided, crisis detection will use keyword-based fallback +HUGGINGFACE_API_TOKEN=your-huggingface-token-here # Server Configuration PORT=3001 diff --git a/backend/src/routes/habits.ts b/backend/src/routes/habits.ts index f434c34..a513410 100644 --- a/backend/src/routes/habits.ts +++ b/backend/src/routes/habits.ts @@ -9,7 +9,7 @@ router.get('/', authenticate, async (req: AuthRequest, res) => { try { const userId = req.user!.id; - const { data, error } = await supabase + const { data: habits, error } = await supabase .from('habits') .select('*') .eq('user_id', userId) @@ -20,7 +20,68 @@ router.get('/', authenticate, async (req: AuthRequest, res) => { return res.status(500).json({ error: 'Failed to fetch habits' }); } - res.json(data || []); + if (!habits) return res.json([]); + + // Fetch all logs for the user to calculate streaks efficiently + const { data: allLogs, error: logsError } = await supabase + .from('habit_logs') + .select('habit_id, completed_at') + .eq('user_id', userId) + .order('completed_at', { ascending: false }); + + if (logsError) { + console.error('Error fetching logs for streaks:', logsError); + return res.json(habits.map(h => ({ ...h, current_streak: 0 }))); + } + + const logsByHabit: Record = {}; + (allLogs || []).forEach(log => { + if (!logsByHabit[log.habit_id]) logsByHabit[log.habit_id] = []; + logsByHabit[log.habit_id].push(log.completed_at); + }); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const habitsWithStreaks = habits.map(habit => { + const logs = logsByHabit[habit.id] || []; + const streak = calculateStreakFromLogs(logs); + return { ...habit, current_streak: streak }; + }); + + res.json(habitsWithStreaks); + } catch (error) { + console.error('Error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Get today's logs for all habits +router.get('/today', authenticate, async (req: AuthRequest, res) => { + try { + const userId = req.user!.id; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const { data, error } = await supabase + .from('habit_logs') + .select('*') + .eq('user_id', userId) + .gte('completed_at', today.toISOString()); + + if (error) { + console.error('Error fetching today\'s logs:', error); + return res.status(500).json({ error: 'Failed to fetch logs' }); + } + + const logsByHabit = (data || []).reduce((acc: any, log: any) => { + acc[log.habit_id] = log; + return acc; + }, {}); + + res.json(logsByHabit); } catch (error) { console.error('Error:', error); res.status(500).json({ error: 'Internal server error' }); @@ -83,7 +144,10 @@ router.post('/:id/log', authenticate, async (req: AuthRequest, res) => { return res.status(500).json({ error: 'Failed to log habit' }); } - res.status(201).json(data); + // Calculate new streak + const newStreak = await calculateHabitStreak(id, userId); + + res.status(201).json({ ...data, new_streak: newStreak }); } catch (error) { console.error('Error:', error); res.status(500).json({ error: 'Internal server error' }); @@ -175,3 +239,54 @@ router.delete('/:id', authenticate, async (req: AuthRequest, res) => { }); export default router; + +// Helper to calculate streak from a list of completion timestamps (ISO strings) +function calculateStreakFromLogs(logs: string[]): number { + if (!logs || logs.length === 0) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const lastLogDate = new Date(logs[0]); + lastLogDate.setHours(0, 0, 0, 0); + + if (lastLogDate.getTime() < yesterday.getTime()) return 0; + + let streak = 0; + let expectedDate = new Date(today); + if (lastLogDate.getTime() === yesterday.getTime()) { + expectedDate = new Date(yesterday); + } + + let lastDayProcessed = -1; + for (const logAt of logs) { + const logDate = new Date(logAt); + logDate.setHours(0, 0, 0, 0); + + if (logDate.getTime() === lastDayProcessed) continue; + + if (logDate.getTime() === expectedDate.getTime()) { + streak++; + expectedDate.setDate(expectedDate.getDate() - 1); + lastDayProcessed = logDate.getTime(); + } else if (logDate.getTime() < expectedDate.getTime()) { + break; + } + } + return streak; +} + +// Helper to calculate streak for a single habit from DB +async function calculateHabitStreak(habitId: string, userId: string): Promise { + const { data: logs, error } = await supabase + .from('habit_logs') + .select('completed_at') + .eq('habit_id', habitId) + .eq('user_id', userId) + .order('completed_at', { ascending: false }); + + if (error || !logs) return 0; + return calculateStreakFromLogs(logs.map(l => l.completed_at)); +} diff --git a/frontend/.env.example b/frontend/.env.example index eedc8d4..c5d12d0 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -8,8 +8,8 @@ VITE_API_BASE_URL=http://localhost:3001 VITE_WS_URL=ws://localhost:3001 # Supabase Configuration (same as backend) -VITE_SUPABASE_URL=https://your-project-id.supabase.co -VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +VITE_SUPABASE_URL=your-supabase-url-here +VITE_SUPABASE_ANON_KEY=your-supabase-anon-key-here # Production values: # VITE_API_BASE_URL=https://your-domain.com diff --git a/frontend/src/components/CheckInModal.tsx b/frontend/src/components/CheckInModal.tsx new file mode 100644 index 0000000..827854f --- /dev/null +++ b/frontend/src/components/CheckInModal.tsx @@ -0,0 +1,168 @@ +import { useState, useEffect } from 'react'; + +interface CheckInModalProps { + isOpen: boolean; + habit: { + id: string; + name: string; + } | null; + currentStreak: number; + onClose: () => void; + onCheckIn: (habitId: string, notes?: string) => Promise; +} + +const MILESTONES = [7, 30, 100]; + +export default function CheckInModal({ + isOpen, + habit, + currentStreak, + onClose, + onCheckIn +}: CheckInModalProps) { + const [notes, setNotes] = useState(''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [showConfetti, setShowConfetti] = useState(false); + const [milestone, setMilestone] = useState(null); + + useEffect(() => { + if (!isOpen) { + // Reset state when modal closes + setNotes(''); + setSuccess(false); + setShowConfetti(false); + setMilestone(null); + } + }, [isOpen]); + + if (!isOpen || !habit) return null; + + async function handleCheckIn() { + if (!habit) return; // TypeScript null guard + setLoading(true); + + try { + await onCheckIn(habit.id, notes.trim() || undefined); + setSuccess(true); + + // Check for milestone + const newStreak = currentStreak + 1; + if (MILESTONES.includes(newStreak)) { + setMilestone(newStreak); + setShowConfetti(true); + } + + // Auto-close after delay + setTimeout(() => { + onClose(); + }, showConfetti ? 3000 : 1500); + } catch (err) { + console.error('Check-in failed:', err); + } finally { + setLoading(false); + } + } + + return ( +
+
e.stopPropagation()}> + {success ? ( +
+ {showConfetti && ( +
+ {[...Array(50)].map((_, i) => ( +
+ ))} +
+ )} + +
🎉
+ + {milestone ? ( + <> +

+ 🏆 {milestone} Day Streak! +

+

+ Amazing! You've completed {habit.name} for {milestone} days in a row! +

+ + ) : ( + <> +

+ Great job! +

+

+ You completed {habit.name} today! +

+
+ 🔥 + {currentStreak + 1} + day streak +
+ + )} +
+ ) : ( + <> +
+

Check In

+ +
+ +
+
+

{habit.name}

+

Current streak: {currentStreak} days

+
+ +
+ +