build: 55712324-398e-4334-a254-fc30c84b2e47

This commit is contained in:
AppCakes
2026-05-29 12:47:56 +00:00
commit 0d900dc510
110 changed files with 11252 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
import React, { useEffect, useState } from 'react';
import {
IonButton,
IonContent,
IonHeader,
IonIcon,
IonItem,
IonLabel,
IonPage,
IonTitle,
IonToolbar,
IonText,
} from '@ionic/react';
import {
waterOutline,
fitnessOutline,
shieldCheckmarkOutline,
} from 'ionicons/icons';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
import { Capacitor } from '@capacitor/core';
import { Redirect as RedirectRR, useHistory } from 'react-router-dom';
import { supabase } from '../supabase';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
import { useIonViewWillEnter } from '@ionic/react';
import runnerDrinkingWaterImage from '../assets/runner-drinking-water-photorealistic.png';
const RedirectAny = RedirectRR as any;
const Account: React.FC = () => {
const history = useHistory();
const [sessionChecked, setSessionChecked] = useState(false);
const [email, setEmail] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false);
const [signOutError, setSignOutError] = useState('');
useEffect(() => {
let mounted = true;
supabase.auth.getSession().then(({ data }) => {
if (!mounted) return;
setEmail(data.session?.user.email ?? null);
setSessionChecked(true);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
setEmail(session?.user.email ?? null);
setSessionChecked(true);
if (!session) {
history.replace('/auth');
}
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, [history]);
const handleSignOut = async () => {
setSignOutError('');
setSigningOut(true);
try {
const { error } = await supabase.auth.signOut();
if (error) {
setSignOutError(error.message);
return;
}
if (Capacitor.isNativePlatform()) {
try {
await FirebaseAuthentication.signOut();
} catch {
// Ignore if no native Google session exists.
}
}
setEmail(null);
history.replace('/auth');
} catch (error) {
setSignOutError(
error instanceof Error
? error.message
: 'Unable to sign out right now.',
);
} finally {
setSigningOut(false);
}
};
if (sessionChecked && !email) {
return <RedirectAny to="/auth" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonTitle>Account</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="dashboard-shell ion-padding account-shell">
<section className="account-hero-card">
<div className="account-hero-image-wrap">
<img
src={runnerDrinkingWaterImage}
alt="Runner drinking water after a run"
className="account-hero-image"
/>
<div className="account-hero-overlay" />
</div>
<div className="account-hero-content">
<span className="eyebrow">Recovery & account</span>
<h1>{email ?? 'Loading...'}</h1>
<p>
Stay hydrated, stay consistent, and keep your habit history
safely synced across sessions.
</p>
<div className="account-highlight-row">
<div className="account-highlight-chip">
<IonIcon icon={waterOutline} />
<span>Recovery mindset</span>
</div>
<div className="account-highlight-chip subtle">
<IonIcon icon={fitnessOutline} />
<span>Built for routines</span>
</div>
</div>
</div>
</section>
<div className="stats-card account-details-card">
<IonItem lines="none" className="account-info-item">
<IonLabel>
<h2>Email</h2>
<p>{email ?? '—'}</p>
</IonLabel>
</IonItem>
<div className="account-trust-row">
<div className="account-trust-icon">
<IonIcon icon={shieldCheckmarkOutline} />
</div>
<div>
<h3>Your data is synced</h3>
<p>
Sign in to keep your streaks and progress available whenever
you come back.
</p>
</div>
</div>
<IonButton
expand="block"
className="account-primary-button"
routerLink="/home"
>
Back to Habits
</IonButton>
{signOutError ? (
<IonText color="danger">
<p className="auth-feedback">{signOutError}</p>
</IonText>
) : null}
<div></div>
<IonButton
expand="block"
fill="outline"
color="danger"
className="account-secondary-button"
onClick={handleSignOut}
disabled={signingOut}
>
{signingOut ? 'Signing Out…' : 'Sign Out'}
</IonButton>
</div>
</div>
</IonContent>
</IonPage>
);
};
export default Account;
+398
View File
@@ -0,0 +1,398 @@
import React, { useMemo, useState } from 'react';
import {
IonButton,
IonCard,
IonCardContent,
IonContent,
IonIcon,
IonInput,
IonItem,
IonLabel,
IonPage,
IonSegment,
IonSegmentButton,
IonText,
useIonViewWillEnter,
} from '@ionic/react';
import { FirebaseAuthentication } from '@capacitor-firebase/authentication';
import { Capacitor } from '@capacitor/core';
import { Redirect, useHistory } from 'react-router-dom';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
import { supabase } from '../supabase';
const Auth: React.FC = () => {
const history = useHistory();
const [mode, setMode] = useState<'login' | 'signup'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [appleLoading, setAppleLoading] = useState(false);
const [error, setError] = useState('');
const [info, setInfo] = useState('');
const [verificationMessage, setVerificationMessage] = useState('');
const [sessionChecked, setSessionChecked] = useState(false);
const [hasSession, setHasSession] = useState(false);
const showError = (msg: string) => {
setError(msg);
setTimeout(() => {
setError((current) => (current === msg ? '' : current));
}, 4000);
};
useIonViewWillEnter(() => {
setStatusBarStyle(Style.Dark);
setStatusBarBackground('#f6f7fb');
});
React.useEffect(() => {
let mounted = true;
supabase.auth.getSession().then(({ data }) => {
if (!mounted) return;
setHasSession(Boolean(data.session));
setSessionChecked(true);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
setHasSession(Boolean(session));
setSessionChecked(true);
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, []);
const formValid = useMemo(() => {
return email.trim().length > 3 && password.length >= 6;
}, [email, password]);
const handleSubmit = async () => {
setError('');
setInfo('');
setVerificationMessage('');
setLoading(true);
try {
if (mode === 'login') {
const { error: signInError } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
if (signInError) {
showError(signInError.message);
return;
}
history.replace('/home');
return;
}
const { data, error: signUpError } = await supabase.auth.signUp({
email: email.trim(),
password,
});
if (signUpError) {
showError(signUpError.message);
return;
}
if (data.session) {
history.replace('/home');
return;
}
setVerificationMessage(
'Account created! Check your email for a verification link.'
);
setInfo('Once verified, come back and sign in.');
} finally {
setLoading(false);
}
};
const handleGoogleLogin = async () => {
setError('');
setInfo('');
setVerificationMessage('');
setGoogleLoading(true);
if (!Capacitor.isNativePlatform()) {
showError(
'Google Sign-In is only available in the native app. Use email to sign in here.'
);
setGoogleLoading(false);
return;
}
try {
const result = await FirebaseAuthentication.signInWithGoogle();
const idToken = result.credential?.idToken;
if (!idToken) {
showError('Google sign-in did not return an ID token.');
return;
}
const { error: googleAuthError } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: idToken,
});
if (googleAuthError) {
showError(googleAuthError.message);
return;
}
history.replace('/home');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Google sign-in failed.';
showError(message);
} finally {
setGoogleLoading(false);
}
};
const handleAppleLogin = async () => {
setError('');
setInfo('');
setVerificationMessage('');
setAppleLoading(true);
if (!Capacitor.isNativePlatform()) {
showError(
'Apple Sign-In is only available in the native app. Use email to sign in here.'
);
setAppleLoading(false);
return;
}
try {
const result = await FirebaseAuthentication.signInWithApple({
skipNativeAuth: true,
});
const idToken = result.credential?.idToken;
const rawNonce = result.credential?.nonce;
if (!idToken) {
showError('Apple sign-in did not return an ID token.');
return;
}
const { error: appleAuthError } = await supabase.auth.signInWithIdToken({
provider: 'apple',
token: idToken,
nonce: rawNonce || undefined,
});
if (appleAuthError) {
showError(appleAuthError.message);
return;
}
history.replace('/home');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Apple sign-in failed.';
showError(message);
} finally {
setAppleLoading(false);
}
};
if (sessionChecked && hasSession) {
return <Redirect to="/home" />;
}
return (
<IonPage>
<IonContent fullscreen style={{ '--background': '#f6f7fb' }}>
<div className="auth-shell">
<div className="auth-hero">
<span className="eyebrow">Protect your streaks</span>
<h1>Build habits that actually stick.</h1>
<p>
Track daily wins, recover faster from misses, and see which habits
are getting real momentum.
</p>
</div>
<IonCard className="auth-card">
<IonCardContent>
<IonSegment
value={mode}
onIonChange={(e) =>
setMode(e.detail.value as 'login' | 'signup')
}
>
<IonSegmentButton value="login">
<IonLabel>Login</IonLabel>
</IonSegmentButton>
<IonSegmentButton value="signup">
<IonLabel>Sign Up</IonLabel>
</IonSegmentButton>
</IonSegment>
{verificationMessage ? (
<div className="auth-message-block">
<h2>Check your inbox</h2>
<p>{verificationMessage}</p>
<IonText color="medium">
<p>{info}</p>
</IonText>
<IonButton fill="clear" onClick={() => setMode('login')}>
Back to Login
</IonButton>
</div>
) : (
<div className="auth-form">
<IonItem lines="none" className="auth-input-item">
<IonInput
type="email"
label="Email"
labelPlacement="stacked"
value={email}
placeholder="you@example.com"
onIonInput={(e) => setEmail(e.detail.value ?? '')}
/>
</IonItem>
<IonItem lines="none" className="auth-input-item">
<IonInput
type="password"
label="Password"
labelPlacement="stacked"
value={password}
placeholder="At least 6 characters"
onIonInput={(e) => setPassword(e.detail.value ?? '')}
/>
</IonItem>
{error ? (
<IonText color="danger">
<p className="auth-feedback">{error}</p>
</IonText>
) : null}
{info ? (
<IonText color="medium">
<p className="auth-feedback">{info}</p>
</IonText>
) : null}
<IonButton
expand="block"
disabled={
!formValid || loading || googleLoading || appleLoading
}
onClick={handleSubmit}
>
{loading
? 'Please wait…'
: mode === 'login'
? 'Log In'
: 'Create Account'}
</IonButton>
{mode === 'login' ? (
<>
<div className="auth-divider">
<span>or</span>
</div>
<IonButton
expand="block"
fill="outline"
className="google-auth-button"
disabled={loading || googleLoading || appleLoading}
onClick={handleGoogleLogin}
>
<span
slot="start"
className="google-svg-wrap"
style={{
display: 'inline-flex',
alignItems: 'center',
marginRight: '8px',
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
style={{ display: 'block' }}
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
</span>
{googleLoading ? 'Connecting…' : 'Continue with Google'}
</IonButton>
<IonButton
expand="block"
fill="outline"
className="apple-auth-button"
disabled={loading || googleLoading || appleLoading}
onClick={handleAppleLogin}
>
<span
slot="start"
style={{
display: 'inline-flex',
alignItems: 'center',
marginRight: '8px',
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
style={{ display: 'block' }}
>
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
</span>
{appleLoading ? 'Connecting…' : 'Continue with Apple'}
</IonButton>
</>
) : null}
</div>
)}
</IonCardContent>
</IonCard>
</div>
</IonContent>
</IonPage>
);
};
export default Auth;
+623
View File
@@ -0,0 +1,623 @@
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&apos;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;
+479
View File
@@ -0,0 +1,479 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonRefresher,
IonRefresherContent,
IonSkeletonText,
IonToolbar,
} from '@ionic/react';
import {
addOutline,
analyticsOutline,
checkmarkCircle,
personCircleOutline,
sparklesOutline,
} from 'ionicons/icons';
import { PushNotifications } from '@capacitor/push-notifications';
import { Capacitor } from '@capacitor/core';
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';
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 pushTokenRef = useRef<string | null>(null);
const pushListenersReadyRef = useRef(false);
const showError = (message: string) => {
setError(message);
window.setTimeout(() => setError(''), 4000);
};
const registerPushNotifications = useCallback(
async (currentUserId: string) => {
if (!Capacitor.isNativePlatform()) {
return;
}
if (!pushListenersReadyRef.current) {
pushListenersReadyRef.current = true;
await PushNotifications.removeAllListeners();
PushNotifications.addListener('registration', async (token) => {
if (pushTokenRef.current === token.value) {
return;
}
pushTokenRef.current = token.value;
const { error: updateError } = await supabase
.from('habits')
.update({ fcm_token: token.value })
.eq('user_id', currentUserId);
if (updateError) {
showError(updateError.message);
}
});
PushNotifications.addListener(
'registrationError',
(registrationError) => {
showError(registrationError.error ?? 'Push registration failed.');
}
);
}
const permissionStatus = await PushNotifications.checkPermissions();
if (permissionStatus.receive === 'prompt') {
const requested = await PushNotifications.requestPermissions();
if (requested.receive !== 'granted') {
return;
}
} else if (permissionStatus.receive !== 'granted') {
return;
}
await PushNotifications.register();
},
[showError]
);
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);
await registerPushNotifications(session.user.id);
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);
}, [registerPushNotifications]);
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) showError(deleteError.message);
} else {
const { error: insertError } = await supabase
.from('habit_completions')
.insert({
habit_id: habit.id,
user_id: userId,
completed_on: today,
});
if (insertError) {
showError(insertError.message);
} else {
const { error: pushError } = await supabase.functions.invoke(
'send-habit-completion-push',
{
body: { habitId: habit.id },
}
);
if (pushError) {
showError(pushError.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) {
showError(deleteError.message);
}
await loadDashboard();
setBusyHabitId(null);
};
useEffect(() => {
return () => {
void PushNotifications.removeAllListeners();
};
}, []);
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;
+174
View File
@@ -0,0 +1,174 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonPage,
IonSegment,
IonSegmentButton,
IonTitle,
IonToolbar,
} from '@ionic/react';
import { IonBackButton } from '@ionic/react';
import { calendarOutline } from 'ionicons/icons';
import { Redirect } from 'react-router-dom';
import type { Tables } from '../database.types';
import StatsHabitCard from '../components/StatsHabitCard';
import { supabase } from '../supabase';
import {
Style,
setStatusBarBackground,
setStatusBarStyle,
} from '../utils/statusBar';
import { useIonViewWillEnter } from '@ionic/react';
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 Stats: React.FC = () => {
const [period, setPeriod] = useState<'7' | '30'>('7');
const [sessionChecked, setSessionChecked] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
useEffect(() => {
const load = async () => {
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.user) {
setSessionChecked(true);
setUserId(null);
return;
}
setUserId(session.user.id);
setSessionChecked(true);
const { data } = await supabase
.from('habits')
.select('*, completions:habit_completions(*)')
.order('created_at', { ascending: true });
setHabits((data ?? []) as HabitWithMeta[]);
};
load();
}, []);
const days = Number(period);
const visibleDates = useMemo(() => {
return Array.from({ length: days }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - index);
return formatDate(date);
});
}, [days]);
const summary = useMemo(() => {
return habits.map((habit) => {
const completionDates = habit.completions.map(
(entry) => entry.completed_on,
);
const completedCount = completionDates.filter((date) =>
visibleDates.includes(date),
).length;
return {
id: habit.id,
name: habit.name,
color: habit.color,
completedCount,
streak: calculateCurrentStreak(completionDates),
};
});
}, [habits, visibleDates]);
if (sessionChecked && !userId) {
return <Redirect to="/auth" />;
}
return (
<IonPage>
<IonHeader className="ion-no-border">
<IonToolbar>
<IonButtons slot="start">
<IonBackButton defaultHref="/home" />
</IonButtons>
<IonTitle>Stats</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div className="dashboard-shell ion-padding">
<section className="hero-card compact">
<span className="eyebrow">Consistency view</span>
<h1>See which habits you&apos;re actually keeping.</h1>
<p>
Flip between the last week and month to spot patterns before a
streak disappears.
</p>
</section>
<div className="stats-filter-row">
<IonIcon icon={calendarOutline} />
<IonSegment
value={period}
onIonChange={(e) => setPeriod(e.detail.value as '7' | '30')}
>
<IonSegmentButton value="7">Last 7 days</IonSegmentButton>
<IonSegmentButton value="30">Last 30 days</IonSegmentButton>
</IonSegment>
</div>
{summary.length === 0 ? (
<div className="inline-message">
<h3>No stats yet</h3>
<p>
Create a habit and start checking it off daily to unlock
consistency insights.
</p>
<IonButton routerLink="/home">Go to dashboard</IonButton>
</div>
) : (
<div className="stack-list">
{summary.map((habit) => (
<StatsHabitCard
key={habit.id}
name={habit.name}
color={habit.color}
completedCount={habit.completedCount}
totalDays={days}
streak={habit.streak}
/>
))}
</div>
)}
</div>
</IonContent>
</IonPage>
);
};
export default Stats;