402 lines
12 KiB
TypeScript
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'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;
|