import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { IonAlert, IonButton, IonButtons, IonContent, IonHeader, IonIcon, IonPage, IonSkeletonText, IonTitle, IonToolbar, useIonViewWillEnter, } from '@ionic/react'; import { IonBackButton } from '@ionic/react'; import { checkmarkOutline, createOutline, ellipseOutline, flameOutline, sparklesOutline, trashOutline, trendingUpOutline, } from 'ionicons/icons'; import { Redirect, useHistory, useParams } from 'react-router-dom'; import type { Tables } from '../database.types'; import { supabase } from '../supabase'; import HabitFormModal from '../components/HabitFormModal'; import { Style, setStatusBarBackground, setStatusBarStyle, } from '../utils/statusBar'; interface HabitWithCompletions extends Tables<'habits'> { completions: Tables<'habit_completions'>[]; } const formatDate = (date: Date) => date.toISOString().slice(0, 10); const calculateStreak = (dates: string[]) => { const unique = Array.from(new Set(dates)).sort((a, b) => b.localeCompare(a)); let streak = 0; let cursor = new Date(); if (!unique.includes(formatDate(cursor))) { cursor.setDate(cursor.getDate() - 1); } while (unique.includes(formatDate(cursor))) { streak += 1; cursor.setDate(cursor.getDate() - 1); } return streak; }; const HabitDetail: React.FC = () => { const { id } = useParams<{ id: string }>(); const history = useHistory(); const [sessionChecked, setSessionChecked] = useState(false); const [userId, setUserId] = useState(null); const [habit, setHabit] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showDeleteAlert, setShowDeleteAlert] = useState(false); const loadHabit = useCallback(async () => { setError(''); const { data: { session }, error: sessionError, } = await supabase.auth.getSession(); if (sessionError) { setError(sessionError.message); setLoading(false); return; } if (!session?.user) { setUserId(null); setSessionChecked(true); setLoading(false); return; } setUserId(session.user.id); setSessionChecked(true); const { data, error: habitError } = await supabase .from('habits') .select('*, completions:habit_completions(*)') .eq('id', id) .single(); if (habitError) { if (habitError.code === 'PGRST116') { setHabit(null); } else { setError(habitError.message); } setLoading(false); return; } setHabit(data as HabitWithCompletions); setLoading(false); }, [id]); useEffect(() => { loadHabit(); }, [loadHabit]); const completionDates = useMemo( () => habit?.completions.map((c) => c.completed_on) ?? [], [habit], ); const today = formatDate(new Date()); const doneToday = completionDates.includes(today); const currentStreak = useMemo( () => calculateStreak(completionDates), [completionDates], ); const totalCompletions = completionDates.length; // Calculate longest streak const longestStreak = useMemo(() => { const unique = Array.from(new Set(completionDates)).sort((a, b) => a.localeCompare(b), ); if (unique.length === 0) return 0; let maxStreak = 1; let current = 1; for (let i = 1; i < unique.length; i++) { const prev = new Date(unique[i - 1]); const curr = new Date(unique[i]); const diffDays = (curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24); if (diffDays === 1) { current += 1; maxStreak = Math.max(maxStreak, current); } else { current = 1; } } return maxStreak; }, [completionDates]); // Days since habit creation (or first completion, whichever is earlier) const daysSinceStart = useMemo(() => { const startDate = habit ? new Date( Math.min( new Date(habit.created_at).getTime(), ...completionDates.map((d) => new Date(d).getTime()), Date.now(), ), ) : new Date(); const diff = Math.ceil( (Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24), ); return Math.max(diff, 1); }, [habit, completionDates]); const consistencyRate = Math.round((totalCompletions / daysSinceStart) * 100); // 30-day heatmap data const heatmapDays = useMemo(() => { return Array.from({ length: 30 }, (_, i) => { const date = new Date(); date.setDate(date.getDate() - (29 - i)); const key = formatDate(date); return { key, completed: completionDates.includes(key), isToday: key === today, }; }); }, [completionDates, today]); const toggleToday = async () => { if (!userId || !habit) return; setBusy(true); if (doneToday) { const existing = habit.completions.find((c) => c.completed_on === today); if (existing) { const { error: deleteError } = await supabase .from('habit_completions') .delete() .eq('id', existing.id); if (deleteError) setError(deleteError.message); } } else { const { error: insertError } = await supabase .from('habit_completions') .insert({ habit_id: habit.id, user_id: userId, completed_on: today, }); if (insertError) setError(insertError.message); } await loadHabit(); setBusy(false); }; const updateHabit = async (values: { name: string; targetDescription: string; color: string; }) => { if (!userId || !habit) throw new Error('Habit not found.'); const { error: updateError } = await supabase .from('habits') .update({ name: values.name, target_description: values.targetDescription || null, color: values.color, }) .eq('id', habit.id); if (updateError) throw updateError; await loadHabit(); }; const deleteHabit = async () => { if (!habit) return; setBusy(true); const { error: deleteError } = await supabase .from('habits') .delete() .eq('id', habit.id); if (deleteError) { setError(deleteError.message); setBusy(false); return; } history.push('/home'); }; if (sessionChecked && !userId) { return ; } return ( {habit?.name ?? 'Habit'} {habit && ( <> setShowEditModal(true)}> setShowDeleteAlert(true)} > )}
{/* Loading state */} {loading ? ( <> ) : error ? ( /* Error state */

Something went wrong

{error}

Try Again
) : !habit ? ( /* Not found state */

Habit not found

This habit may have been deleted or you don't have access to it.

Back to habits
) : ( <> {/* Habit info card */}
Your routine

{habit.name}

{habit.target_description || 'Show up today and keep the chain alive.'}

{/* Today toggle */}
{doneToday ? 'Completed today' : "Mark today's habit done"}
{/* All-time stats */}

All-time stats

{/* Stats row */}
{totalCompletions}
total done
{longestStreak}
best streak
{consistencyRate}%
consistency
{/* Current streak highlight */}

{currentStreak} day{currentStreak === 1 ? '' : 's'} and counting

{currentStreak > 0 ? `You've shown up every day since ${(() => { const d = new Date(); d.setDate(d.getDate() - currentStreak + 1); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); })()}.` : "Start today and build a streak you're proud of."}

{/* 30-day heatmap */}

Last 30 days

{heatmapDays.map((day) => (
))}
30 days ago {heatmapDays.filter((d) => d.completed).length} of 30 days Today
{/* Edit modal */} {showEditModal && ( setShowEditModal(false)} onSubmit={updateHabit} initialValues={{ name: habit.name, targetDescription: habit.target_description ?? '', color: habit.color, }} /> )} )}
{/* Delete confirmation */} setShowDeleteAlert(false)} header="Delete habit?" message={`Are you sure you want to delete "${habit?.name ?? ''}"? This will also remove all its completion history. This action cannot be undone.`} buttons={[ { text: 'Cancel', role: 'cancel', }, { text: 'Delete', role: 'destructive', handler: deleteHabit, }, ]} />
); }; export default HabitDetail;