624 lines
19 KiB
TypeScript
624 lines
19 KiB
TypeScript
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<string | null>(null);
|
|
const [habit, setHabit] = useState<HabitWithCompletions | null>(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 <Redirect to="/auth" />;
|
|
}
|
|
|
|
return (
|
|
<IonPage>
|
|
<IonHeader className="ion-no-border">
|
|
<IonToolbar>
|
|
<IonButtons slot="start">
|
|
<IonBackButton defaultHref="/home" />
|
|
</IonButtons>
|
|
<IonTitle>{habit?.name ?? 'Habit'}</IonTitle>
|
|
<IonButtons slot="end">
|
|
{habit && (
|
|
<>
|
|
<IonButton fill="clear" onClick={() => setShowEditModal(true)}>
|
|
<IonIcon icon={createOutline} />
|
|
</IonButton>
|
|
<IonButton
|
|
fill="clear"
|
|
color="danger"
|
|
onClick={() => setShowDeleteAlert(true)}
|
|
>
|
|
<IonIcon icon={trashOutline} />
|
|
</IonButton>
|
|
</>
|
|
)}
|
|
</IonButtons>
|
|
</IonToolbar>
|
|
</IonHeader>
|
|
|
|
<IonContent fullscreen>
|
|
<div className="dashboard-shell ion-padding">
|
|
{/* Loading state */}
|
|
{loading ? (
|
|
<>
|
|
<IonSkeletonText
|
|
animated
|
|
style={{ width: '60%', height: '24px', borderRadius: '8px' }}
|
|
/>
|
|
<IonSkeletonText
|
|
animated
|
|
style={{
|
|
width: '100%',
|
|
height: '120px',
|
|
borderRadius: '24px',
|
|
}}
|
|
/>
|
|
<IonSkeletonText
|
|
animated
|
|
style={{
|
|
width: '100%',
|
|
height: '160px',
|
|
borderRadius: '24px',
|
|
}}
|
|
/>
|
|
<IonSkeletonText
|
|
animated
|
|
style={{
|
|
width: '100%',
|
|
height: '100px',
|
|
borderRadius: '24px',
|
|
}}
|
|
/>
|
|
</>
|
|
) : error ? (
|
|
/* Error state */
|
|
<div className="inline-message error-card">
|
|
<h3>Something went wrong</h3>
|
|
<p>{error}</p>
|
|
<IonButton onClick={loadHabit}>Try Again</IonButton>
|
|
</div>
|
|
) : !habit ? (
|
|
/* Not found state */
|
|
<div className="inline-message">
|
|
<h3>Habit not found</h3>
|
|
<p>
|
|
This habit may have been deleted or you don't have access
|
|
to it.
|
|
</p>
|
|
<IonButton routerLink="/home">Back to habits</IonButton>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Habit info card */}
|
|
<section className="hero-card compact">
|
|
<div className="habit-copy-topline">
|
|
<span
|
|
className="habit-color-pill"
|
|
style={{ background: habit.color }}
|
|
/>
|
|
<span className="eyebrow">Your routine</span>
|
|
</div>
|
|
<h1>{habit.name}</h1>
|
|
<p>
|
|
{habit.target_description ||
|
|
'Show up today and keep the chain alive.'}
|
|
</p>
|
|
|
|
{/* Today toggle */}
|
|
<div style={{ marginTop: '18px' }}>
|
|
<IonButton
|
|
expand="block"
|
|
fill={doneToday ? 'solid' : 'outline'}
|
|
onClick={toggleToday}
|
|
disabled={busy}
|
|
style={
|
|
doneToday
|
|
? ({
|
|
'--background': habit.color,
|
|
'--background-hover': habit.color,
|
|
'--background-activated': habit.color,
|
|
'--border-radius': '18px',
|
|
minHeight: '50px',
|
|
} as any)
|
|
: ({
|
|
'--border-color': habit.color,
|
|
'--color': habit.color,
|
|
'--border-radius': '18px',
|
|
minHeight: '50px',
|
|
} as any)
|
|
}
|
|
>
|
|
<IonIcon
|
|
icon={doneToday ? checkmarkOutline : ellipseOutline}
|
|
slot="start"
|
|
/>
|
|
{doneToday ? 'Completed today' : "Mark today's habit done"}
|
|
</IonButton>
|
|
</div>
|
|
</section>
|
|
|
|
{/* All-time stats */}
|
|
<section>
|
|
<p className="home-section-label">All-time stats</p>
|
|
<div className="stack-list">
|
|
{/* Stats row */}
|
|
<div
|
|
className="hero-card compact"
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '1fr 1fr 1fr',
|
|
gap: '12px',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: '1.8rem',
|
|
fontWeight: 800,
|
|
color: habit.color,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{totalCompletions}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '0.78rem',
|
|
color: 'var(--ion-color-medium)',
|
|
marginTop: '4px',
|
|
}}
|
|
>
|
|
total done
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: '1.8rem',
|
|
fontWeight: 800,
|
|
color: habit.color,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{longestStreak}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '0.78rem',
|
|
color: 'var(--ion-color-medium)',
|
|
marginTop: '4px',
|
|
}}
|
|
>
|
|
best streak
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: '1.8rem',
|
|
fontWeight: 800,
|
|
color: habit.color,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{consistencyRate}%
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: '0.78rem',
|
|
color: 'var(--ion-color-medium)',
|
|
marginTop: '4px',
|
|
}}
|
|
>
|
|
consistency
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Current streak highlight */}
|
|
<div
|
|
className="hero-card compact"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '14px',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: '48px',
|
|
height: '48px',
|
|
borderRadius: '16px',
|
|
background: `${habit.color}18`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<IonIcon
|
|
icon={flameOutline}
|
|
style={{ color: habit.color, fontSize: '1.3rem' }}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<h3
|
|
style={{
|
|
margin: 0,
|
|
fontSize: '1rem',
|
|
fontWeight: 700,
|
|
color: 'var(--ion-color-dark)',
|
|
}}
|
|
>
|
|
{currentStreak} day{currentStreak === 1 ? '' : 's'} and
|
|
counting
|
|
</h3>
|
|
<p
|
|
style={{
|
|
margin: '4px 0 0',
|
|
color: 'var(--ion-color-medium)',
|
|
fontSize: '0.84rem',
|
|
}}
|
|
>
|
|
{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."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 30-day heatmap */}
|
|
<section>
|
|
<p className="home-section-label">Last 30 days</p>
|
|
<div className="hero-card compact">
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
|
gap: '6px',
|
|
}}
|
|
>
|
|
{heatmapDays.map((day) => (
|
|
<div
|
|
key={day.key}
|
|
title={
|
|
day.isToday
|
|
? 'Today'
|
|
: new Date(day.key).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
style={{
|
|
aspectRatio: '1',
|
|
borderRadius: '6px',
|
|
background: day.completed
|
|
? habit.color
|
|
: `${habit.color}14`,
|
|
border: day.isToday
|
|
? `2px solid ${habit.color}`
|
|
: '2px solid transparent',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
marginTop: '12px',
|
|
fontSize: '0.74rem',
|
|
color: 'var(--ion-color-medium)',
|
|
}}
|
|
>
|
|
<span>30 days ago</span>
|
|
<span>
|
|
<IonIcon
|
|
icon={trendingUpOutline}
|
|
style={{ verticalAlign: 'middle', marginRight: '4px' }}
|
|
/>
|
|
{heatmapDays.filter((d) => d.completed).length} of 30 days
|
|
</span>
|
|
<span>Today</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Edit modal */}
|
|
{showEditModal && (
|
|
<HabitFormModal
|
|
isOpen={showEditModal}
|
|
onDismiss={() => setShowEditModal(false)}
|
|
onSubmit={updateHabit}
|
|
initialValues={{
|
|
name: habit.name,
|
|
targetDescription: habit.target_description ?? '',
|
|
color: habit.color,
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Delete confirmation */}
|
|
<IonAlert
|
|
isOpen={showDeleteAlert}
|
|
onDidDismiss={() => 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,
|
|
},
|
|
]}
|
|
/>
|
|
</IonContent>
|
|
</IonPage>
|
|
);
|
|
};
|
|
|
|
export default HabitDetail;
|