Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
121 changes: 118 additions & 3 deletions backend/src/routes/habits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<string, string[]> = {};
(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' });
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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<number> {
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));
}
4 changes: 2 additions & 2 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions frontend/src/components/CheckInModal.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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<number | null>(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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{success ? (
<div className="text-center py-6">
{showConfetti && (
<div className="confetti-container">
{[...Array(50)].map((_, i) => (
<div
key={i}
className="confetti-piece"
style={{
left: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 0.5}s`,
backgroundColor: ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#9b59b6'][Math.floor(Math.random() * 5)],
}}
/>
))}
</div>
)}

<div className="text-6xl mb-4 animate-bounce">🎉</div>

{milestone ? (
<>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
🏆 {milestone} Day Streak!
</h2>
<p className="text-gray-600">
Amazing! You've completed {habit.name} for {milestone} days in a row!
</p>
</>
) : (
<>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Great job!
</h2>
<p className="text-gray-600">
You completed {habit.name} today!
</p>
<div className="mt-4 streak-badge inline-flex">
<span className="text-2xl">🔥</span>
<span className="streak-count">{currentStreak + 1}</span>
<span className="streak-label">day streak</span>
</div>
</>
)}
</div>
) : (
<>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">Check In</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
>
×
</button>
</div>

<div className="text-center mb-6">
<div className="text-4xl mb-2">✓</div>
<p className="text-lg font-medium text-gray-900">{habit.name}</p>
<p className="text-sm text-gray-500">Current streak: {currentStreak} days</p>
</div>

<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Add a note (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="How did it go? Any reflections?"
className="input-field resize-none"
rows={3}
/>
</div>

<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="btn-secondary flex-1"
disabled={loading}
>
Cancel
</button>
<button
onClick={handleCheckIn}
className="btn-primary flex-1"
disabled={loading}
>
{loading ? 'Saving...' : 'Complete ✓'}
</button>
</div>
</>
)}
</div>
</div>
);
}
Loading