Files
appcakes-builds/src/pages/Home.tsx
T
2026-05-28 13:17:34 +00:00

402 lines
12 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonRefresher,
IonRefresherContent,
IonSkeletonText,
IonToolbar,
useIonViewWillEnter,
} from '@ionic/react';
import {
addOutline,
analyticsOutline,
checkmarkCircle,
personCircleOutline,
sparklesOutline,
} from 'ionicons/icons';
import { Redirect, useHistory } from 'react-router-dom';
import type { Tables } from '../database.types';
import { supabase } from '../supabase';
import EmptyState from '../components/EmptyState';
import HabitCard from '../components/HabitCard';
import HabitFormModal from '../components/HabitFormModal';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
interface HabitWithMeta extends Tables<'habits'> {
completions: Tables<'habit_completions'>[];
}
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
const calculateCurrentStreak = (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 Home: React.FC = () => {
const history = useHistory();
const [sessionChecked, setSessionChecked] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [busyHabitId, setBusyHabitId] = useState<string | null>(null);
const loadDashboard = useCallback(async () => {
setError('');
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError) {
setError(sessionError.message);
setSessionChecked(true);
setLoading(false);
return;
}
if (!session?.user) {
setUserId(null);
setSessionChecked(true);
setLoading(false);
return;
}
setUserId(session.user.id);
setSessionChecked(true);
const { data, error: habitsError } = await supabase
.from('habits')
.select('*, completions:habit_completions(*)')
.order('created_at', { ascending: true });
if (habitsError) {
setError(habitsError.message);
setLoading(false);
return;
}
setHabits((data ?? []) as HabitWithMeta[]);
setLoading(false);
}, []);
useEffect(() => {
loadDashboard();
}, [loadDashboard]);
const today = formatDate(new Date());
const habitStats = useMemo(() => {
const last7Days = Array.from({ length: 7 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - index);
return formatDate(date);
});
return habits.reduce<
Record<string, { streak: number; weekRate: number; doneToday: boolean }>
>((acc, habit) => {
const completionDates = habit.completions.map(
(entry) => entry.completed_on,
);
const completedInWeek = completionDates.filter((date) =>
last7Days.includes(date),
).length;
acc[habit.id] = {
streak: calculateCurrentStreak(completionDates),
weekRate: Math.round((completedInWeek / last7Days.length) * 100),
doneToday: completionDates.includes(today),
};
return acc;
}, {});
}, [habits, today]);
const totalCompletedToday = habits.filter(
(habit) => habitStats[habit.id]?.doneToday,
).length;
const longestStreak = habits.reduce(
(max, habit) => Math.max(max, habitStats[habit.id]?.streak ?? 0),
0,
);
const completionRatio = habits.length
? totalCompletedToday / habits.length
: 0;
const pendingCount = Math.max(habits.length - totalCompletedToday, 0);
const createHabit = async (values: {
name: string;
targetDescription: string;
color: string;
}) => {
if (!userId) throw new Error('You need to be logged in.');
const { error: insertError } = await supabase.from('habits').insert({
user_id: userId,
name: values.name,
target_description: values.targetDescription || null,
color: values.color,
});
if (insertError) throw insertError;
await loadDashboard();
};
const toggleHabit = async (habit: HabitWithMeta) => {
if (!userId) return;
setBusyHabitId(habit.id);
const existing = habit.completions.find(
(entry) => entry.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 loadDashboard();
setBusyHabitId(null);
};
const deleteHabit = async (habitId: string) => {
setBusyHabitId(habitId);
const { error: deleteError } = await supabase
.from('habits')
.delete()
.eq('id', habitId);
if (deleteError) {
setError(deleteError.message);
}
await loadDashboard();
setBusyHabitId(null);
};
if (sessionChecked && !userId) {
return <Redirect to="/auth" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border home-header">
<IonToolbar>
<IonButtons slot="end">
<IonButton
className="soft-icon-button"
onClick={() => history.push('/stats')}
>
<IonIcon icon={analyticsOutline} />
</IonButton>
<IonButton
className="soft-icon-button"
onClick={() => history.push('/account')}
>
<IonIcon icon={personCircleOutline} />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent fullscreen className="home-content">
<IonRefresher
slot="fixed"
onIonRefresh={async (e) => {
await loadDashboard();
e.detail.complete();
}}
>
<IonRefresherContent />
</IonRefresher>
<div className="dashboard-shell home-shell ion-padding">
<section className="habits-hero">
<div className="habits-hero-top">
<div>
<p className="hero-kicker">Habits ({habits.length})</p>
<div className="hero-title-wrap">
<h1>Daily</h1>
<span>Weekly</span>
</div>
</div>
<IonButton
className="hero-add-button"
fill="clear"
onClick={() => setShowCreateModal(true)}
>
<IonIcon icon={addOutline} />
</IonButton>
</div>
<div className="hero-progress-track" aria-hidden="true">
<div
className="hero-progress-fill"
style={{ width: `${Math.max(completionRatio * 100, 6)}%` }}
>
<span className="hero-progress-dot" />
</div>
</div>
<div className="hero-progress-meta">
<span>{totalCompletedToday} completed today</span>
<span>{Math.round(completionRatio * 100)}% completed</span>
</div>
</section>
{loading ? (
<div className="home-routine-panel">
<div className="stack-list home-habit-stack">
<IonSkeletonText
animated
style={{
width: '100%',
height: '82px',
borderRadius: '24px',
}}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '82px',
borderRadius: '24px',
}}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '82px',
borderRadius: '24px',
}}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '82px',
borderRadius: '24px',
}}
/>
</div>
</div>
) : error ? (
<div className="inline-message error-card home-inline-card">
<h3>Couldn&apos;t load your habits</h3>
<p>{error}</p>
<IonButton onClick={loadDashboard}>Try Again</IonButton>
</div>
) : habits.length === 0 ? (
<div className="home-routine-panel">
<EmptyState
title="Start your first routine"
message="Create one small daily habit and begin building your streak story today."
actionLabel="Create First Habit"
onAction={() => setShowCreateModal(true)}
/>
</div>
) : (
<>
<section className="home-routine-panel">
<div className="stack-list home-habit-stack">
{habits.map((habit) => (
<HabitCard
key={habit.id}
habit={habit}
completedToday={habitStats[habit.id]?.doneToday ?? false}
currentStreak={habitStats[habit.id]?.streak ?? 0}
completionRate={habitStats[habit.id]?.weekRate ?? 0}
onToggle={() => toggleHabit(habit)}
onDelete={() => deleteHabit(habit.id)}
busy={busyHabitId === habit.id}
/>
))}
</div>
</section>
<section className="home-complete-section">
<p className="home-section-label">Momentum</p>
<div className="home-summary-card">
<div
className="summary-ring"
style={{
background: `conic-gradient(#7c3aed 0deg, #a855f7 ${completionRatio * 360}deg, rgba(109, 40, 217, 0.08) ${completionRatio * 360}deg 360deg)`,
}}
>
<div className="summary-ring-inner">
{Math.round(completionRatio * 100)}%
</div>
</div>
<div className="summary-copy">
<h3>Keep your rhythm</h3>
<p>
{pendingCount === 0
? 'Everything is complete today. Enjoy the streak.'
: `${pendingCount} habit${pendingCount === 1 ? '' : 's'} left to protect today's routine.`}
</p>
<div className="summary-metrics">
<span>
<IonIcon icon={sparklesOutline} /> {totalCompletedToday}{' '}
done
</span>
<span>
<IonIcon icon={checkmarkCircle} /> {longestStreak} day
best streak
</span>
</div>
</div>
</div>
</section>
</>
)}
</div>
<HabitFormModal
isOpen={showCreateModal}
onDismiss={() => setShowCreateModal(false)}
onSubmit={createHabit}
/>
</IonContent>
</IonPage>
);
};
export default Home;