build: 55712324-398e-4334-a254-fc30c84b2e47
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'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;
|
||||
@@ -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'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;
|
||||
@@ -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'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;
|
||||
Reference in New Issue
Block a user