Add supabase setup

This commit is contained in:
2025-10-17 22:10:57 +02:00
parent 237052ce35
commit 2b6b515d47
16 changed files with 599 additions and 23 deletions

View File

@@ -5,6 +5,7 @@ import HomePage from './pages/HomePage';
import HabitDetailPage from './pages/HabitDetailPage';
import AddEditHabitPage from './pages/AddEditHabitPage';
import SettingsPage from './pages/SettingsPage';
import LoginProvidersPage from './pages/LoginProvidersPage';
import { Toaster } from './components/ui/toaster';
function App() {
@@ -21,6 +22,7 @@ function App() {
<Route path="/add" element={<AddEditHabitPage />} />
<Route path="/edit/:id" element={<AddEditHabitPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/login-providers" element={<LoginProvidersPage />} />
</Routes>
<Toaster />
</div>

View File

@@ -1,7 +1,7 @@
import React, { useMemo, useEffect } from 'react';
import { motion } from 'framer-motion';
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays } from '../lib/utils-habit';
import { toggleCompletion } from '../lib/storage';
import { toggleCompletion } from '../lib/datastore';
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
const frozenDays = getFrozenDays(habit.completions);
@@ -39,7 +39,18 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
}, []);
const handleCellClick = (date) => {
toggleCompletion(habit.id, formatDate(date));
const dateStr = formatDate(date);
// Optimistic local update
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
const idx = habits.findIndex(h => h.id === habit.id);
if (idx !== -1) {
const completions = Array.isArray(habits[idx].completions) ? [...habits[idx].completions] : [];
const cidx = completions.indexOf(dateStr);
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
habits[idx].completions = completions;
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
}
toggleCompletion(habit.id, dateStr); // background sync
onUpdate();
};

View File

@@ -40,7 +40,7 @@ function getFreezeIcon() {
import { motion } from 'framer-motion';
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
import { getFrozenDays } from '../lib/utils-habit';
import { toggleCompletion } from '../lib/storage';
import { toggleCompletion } from '../lib/datastore';
import { toast } from './ui/use-toast';
const MiniGrid = ({ habit, onUpdate }) => {
@@ -69,7 +69,17 @@ const MiniGrid = ({ habit, onUpdate }) => {
const dateStr = formatDate(date);
const isTodayCell = isToday(date);
const wasCompleted = habit.completions.includes(dateStr);
toggleCompletion(habit.id, dateStr);
// Optimistic local update
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
const idx = habits.findIndex(h => h.id === habit.id);
if (idx !== -1) {
const completions = Array.isArray(habits[idx].completions) ? [...habits[idx].completions] : [];
const cidx = completions.indexOf(dateStr);
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
habits[idx].completions = completions;
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
}
toggleCompletion(habit.id, dateStr); // background sync
onUpdate();
// Only show encouragement toast if validating (adding) today's dot
if (isTodayCell && !wasCompleted) {

184
src/lib/datastore.js Normal file
View File

@@ -0,0 +1,184 @@
import { supabase, isSupabaseConfigured } from './supabase';
import * as local from './storage';
const SYNC_FLAG = 'habitgrid_remote_synced_at';
export const getAuthUser = async () => {
if (!isSupabaseConfigured()) return null;
const { data } = await supabase.auth.getUser();
return data?.user ?? null;
};
export const isLoggedIn = async () => Boolean(await getAuthUser());
// Remote schema suggestion:
// table habits {
// id uuid primary key default gen_random_uuid(),
// user_id uuid not null,
// name text not null,
// color text,
// category text,
// completions jsonb default '[]'::jsonb,
// current_streak int default 0,
// longest_streak int default 0,
// sort_order int default 0,
// created_at timestamptz default now(),
// updated_at timestamptz default now()
// };
export async function getHabits() {
if (!(await isLoggedIn())) return local.getHabits();
const { data: user } = await supabase.auth.getUser();
const userId = user?.user?.id;
if (!userId) return local.getHabits();
const { data, error } = await supabase
.from('habits')
.select('id,user_id,name,color,category,completions,current_streak,longest_streak,sort_order,updated_at,created_at')
.eq('user_id', userId)
.order('sort_order');
if (error) {
console.warn('Supabase getHabits error, falling back to local:', error.message);
return local.getHabits();
}
// Map to local shape
return (data || []).map(row => ({
id: row.id,
name: row.name,
color: row.color,
category: row.category || '',
completions: row.completions || [],
currentStreak: row.current_streak ?? 0,
longestStreak: row.longest_streak ?? 0,
sortOrder: row.sort_order ?? 0,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export async function saveHabit(habit) {
if (!(await isLoggedIn())) return local.saveHabit(habit);
const now = new Date().toISOString();
const { data: auth } = await supabase.auth.getUser();
const insert = {
user_id: auth?.user?.id,
name: habit.name,
color: habit.color,
category: habit.category || '',
completions: habit.completions || [],
current_streak: habit.currentStreak ?? 0,
longest_streak: habit.longestStreak ?? 0,
sort_order: habit.sortOrder ?? 0,
created_at: now,
updated_at: now,
};
const { data, error } = await supabase.from('habits').insert(insert).select('*').single();
if (error) {
console.warn('Supabase saveHabit error, writing local:', error.message);
return local.saveHabit(habit);
}
return {
id: data.id,
sortOrder: data.sort_order ?? 0,
...habit,
};
}
export async function updateHabit(id, updates) {
if (!(await isLoggedIn())) return local.updateHabit(id, updates);
const now = new Date().toISOString();
const patch = {
...(updates.name !== undefined ? { name: updates.name } : {}),
...(updates.color !== undefined ? { color: updates.color } : {}),
...(updates.category !== undefined ? { category: updates.category } : {}),
...(updates.completions !== undefined ? { completions: updates.completions } : {}),
...(updates.currentStreak !== undefined ? { current_streak: updates.currentStreak } : {}),
...(updates.longestStreak !== undefined ? { longest_streak: updates.longestStreak } : {}),
...(updates.sortOrder !== undefined ? { sort_order: updates.sortOrder } : {}),
updated_at: now,
};
const { error } = await supabase.from('habits').update(patch).eq('id', id);
if (error) {
console.warn('Supabase updateHabit error, writing local:', error.message);
return local.updateHabit(id, updates);
}
}
export async function deleteHabit(id) {
if (!(await isLoggedIn())) return local.deleteHabit(id);
const { error } = await supabase.from('habits').delete().eq('id', id);
if (error) {
console.warn('Supabase deleteHabit error, writing local:', error.message);
return local.deleteHabit(id);
}
}
export async function toggleCompletion(habitId, dateStr) {
if (!(await isLoggedIn())) return local.toggleCompletion(habitId, dateStr);
// Fetch current then delegate to local logic for streak calc
const habits = await getHabits();
const target = habits.find(h => h.id === habitId);
if (!target) return;
const completions = Array.isArray(target.completions) ? [...target.completions] : [];
const idx = completions.indexOf(dateStr);
if (idx > -1) completions.splice(idx, 1); else completions.push(dateStr);
return updateHabit(habitId, { completions });
}
export async function exportData() {
// Always export from local snapshot for portability
const habits = await getHabits();
return JSON.stringify(habits, null, 2);
}
export async function importData(jsonString) {
// Always import to local; remote sync will push on login
return local.importData(jsonString);
}
export async function clearAllData() {
// Clear local only; remote data persists per account
return local.clearAllData();
}
// Sync: push local data to remote when user first logs in or when no remote data exists
export async function syncLocalToRemoteIfNeeded() {
if (!isSupabaseConfigured()) return;
const user = await getAuthUser();
if (!user) return;
const already = localStorage.getItem(SYNC_FLAG);
const { data: remote, error } = await supabase.from('habits').select('id').limit(1);
if (error) return;
if (!already || (remote || []).length === 0) {
const habits = local.getHabits();
if (habits.length === 0) return localStorage.setItem(SYNC_FLAG, new Date().toISOString());
const rows = habits.map(h => ({
id: h.id && h.id.length > 0 ? h.id : undefined,
user_id: user.id,
name: h.name,
color: h.color,
category: h.category || '',
completions: h.completions || [],
current_streak: h.currentStreak ?? 0,
longest_streak: h.longestStreak ?? 0,
sort_order: h.sortOrder ?? 0,
created_at: h.createdAt || new Date().toISOString(),
updated_at: h.updatedAt || new Date().toISOString(),
}));
await supabase.from('habits').upsert(rows, { onConflict: 'id' });
localStorage.setItem(SYNC_FLAG, new Date().toISOString());
}
}
export async function syncRemoteToLocal() {
const user = await getAuthUser();
if (!user) return;
const remote = await getHabits();
// write to local in the app's expected format
localStorage.setItem('habitgrid_data', JSON.stringify(remote));
// Notify UI to reload if listening
window.dispatchEvent(new CustomEvent('habitgrid-sync-updated'));
}

View File

@@ -1,3 +1,5 @@
// Local storage remains the primary source. If Supabase auth is active, we mirror writes to the remote DB.
import { supabase } from './supabase';
const STORAGE_KEY = 'habitgrid_data';
export const getHabits = () => {
@@ -15,15 +17,55 @@ export const getHabit = (id) => {
return habits.find(h => h.id === id);
};
const nowIso = () => new Date().toISOString();
const remoteMirrorUpsert = async (habit) => {
try {
if (!supabase) return;
const { data: auth } = await supabase.auth.getUser();
if (!auth?.user) return;
const row = {
id: habit.id,
name: habit.name ?? habit.title ?? habit?.name,
color: habit.color,
category: habit.category || '',
completions: habit.completions || [],
current_streak: habit.currentStreak ?? 0,
longest_streak: habit.longestStreak ?? 0,
sort_order: habit.sortOrder ?? 0,
created_at: habit.createdAt || nowIso(),
updated_at: habit.updatedAt || nowIso(),
user_id: auth.user.id,
};
await supabase.from('habits').upsert(row, { onConflict: 'id' });
} catch (e) {
console.warn('Remote mirror upsert failed:', e?.message || e);
}
};
const remoteMirrorDelete = async (id) => {
try {
if (!supabase) return;
const { data: auth } = await supabase.auth.getUser();
if (!auth?.user) return;
await supabase.from('habits').delete().eq('id', id).eq('user_id', auth.user.id);
} catch (e) {
console.warn('Remote mirror delete failed:', e?.message || e);
}
};
export const saveHabit = (habit) => {
const habits = getHabits();
const newHabit = {
...habit,
id: Date.now().toString(),
sortOrder: habits.length,
createdAt: nowIso(),
updatedAt: nowIso(),
};
habits.push(newHabit);
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
remoteMirrorUpsert(newHabit);
return newHabit;
};
@@ -31,8 +73,9 @@ export const updateHabit = (id, updates) => {
const habits = getHabits();
const index = habits.findIndex(h => h.id === id);
if (index !== -1) {
habits[index] = { ...habits[index], ...updates };
habits[index] = { ...habits[index], ...updates, updatedAt: nowIso() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
remoteMirrorUpsert(habits[index]);
}
};
@@ -40,6 +83,7 @@ export const deleteHabit = (id) => {
const habits = getHabits();
const filtered = habits.filter(h => h.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
remoteMirrorDelete(id);
};
export const toggleCompletion = (habitId, dateStr) => {
@@ -138,4 +182,8 @@ export const importData = (jsonString) => {
export const clearAllData = () => {
localStorage.removeItem(STORAGE_KEY);
};
};
// Re-export a thin Supabase-aware facade so the rest of the app can import from 'lib/storage'
// without refactors. We keep original names but allow higher-level modules to import the remote-aware versions.
export * as remote from './datastore';

11
src/lib/supabase.js Normal file
View File

@@ -0,0 +1,11 @@
import { createClient } from '@supabase/supabase-js';
// Expect env vars provided by Vite
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = (supabaseUrl && supabaseAnonKey)
? createClient(supabaseUrl, supabaseAnonKey)
: null;
export const isSupabaseConfigured = () => Boolean(supabase);

View File

@@ -7,7 +7,7 @@ import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { useToast } from '../components/ui/use-toast';
import ColorPicker from '../components/ColorPicker';
import { getHabit, saveHabit, updateHabit } from '../lib/storage';
import { getHabits, saveHabit, updateHabit } from '../lib/datastore';
const AddEditHabitPage = () => {
const { id } = useParams();
@@ -49,14 +49,25 @@ const AddEditHabitPage = () => {
return;
}
// Optimistic local update
if (isEdit) {
updateHabit(id, { name: name.trim(), color, category: category.trim() });
// Update localStorage directly for instant UI
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
const idx = habits.findIndex(h => h.id === id);
if (idx !== -1) {
habits[idx] = { ...habits[idx], name: name.trim(), color, category: category.trim() };
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
}
updateHabit(id, { name: name.trim(), color, category: category.trim() }); // background sync
toast({
title: "✅ Habit updated",
description: "Your habit has been updated successfully.",
});
} else {
saveHabit({
// Add to localStorage for instant UI
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
const newHabit = {
id: Date.now().toString(),
name: name.trim(),
color,
category: category.trim(),
@@ -64,7 +75,12 @@ const AddEditHabitPage = () => {
currentStreak: 0,
longestStreak: 0,
createdAt: new Date().toISOString(),
});
updatedAt: new Date().toISOString(),
sortOrder: habits.length,
};
habits.push(newHabit);
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
saveHabit(newHabit); // background sync
toast({
title: "✅ Habit created",
description: "Your new habit is ready to track!",

View File

@@ -6,7 +6,13 @@ import { Button } from '../components/ui/button';
import { useToast } from '../components/ui/use-toast';
import HabitGrid from '../components/HabitGrid';
import DeleteHabitDialog from '../components/DeleteHabitDialog';
import { getHabit, deleteHabit } from '../lib/storage';
import { getHabits, deleteHabit } from '../lib/datastore';
// Local helper to get habit by id from localStorage
function getHabit(id) {
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
return habits.find(h => h.id === id);
}
import AnimatedCounter from '../components/AnimatedCounter';
const HabitDetailPage = () => {
@@ -44,7 +50,11 @@ const HabitDetailPage = () => {
};
const handleDelete = () => {
deleteHabit(id);
// Optimistic local delete
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
const filtered = habits.filter(h => h.id !== id);
localStorage.setItem('habitgrid_data', JSON.stringify(filtered));
deleteHabit(id); // background sync
toast({
title: "✅ Habit deleted",
description: "Your habit has been removed successfully.",

View File

@@ -9,7 +9,7 @@ import HabitCard from '../components/HabitCard';
import AnimatedCounter from '../components/AnimatedCounter';
import GitActivityGrid from '../components/GitActivityGrid';
import { getGitEnabled } from '../lib/git';
import { getHabits, updateHabit } from '../lib/storage';
import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser } from '../lib/datastore';
const HomePage = () => {
const navigate = useNavigate();
@@ -22,8 +22,26 @@ const HomePage = () => {
});
useEffect(() => {
loadHabits();
setGitEnabled(getGitEnabled());
(async () => {
// On login, pull remote habits into localStorage
const user = await getAuthUser();
if (user) {
await syncRemoteToLocal();
}
await loadHabits();
setGitEnabled(getGitEnabled());
})();
// Background sync every 10s if logged in
const interval = setInterval(() => {
syncLocalToRemoteIfNeeded();
}, 10000);
// Listen for remote sync event to reload habits
const syncListener = () => loadHabits();
window.addEventListener('habitgrid-sync-updated', syncListener);
return () => {
clearInterval(interval);
window.removeEventListener('habitgrid-sync-updated', syncListener);
};
}, []);
useEffect(() => {
@@ -36,9 +54,9 @@ const HomePage = () => {
}
}, [darkMode]);
const loadHabits = () => {
const loadedHabits = getHabits();
// Sort by sortOrder if present, then fallback to createdAt
const loadHabits = async () => {
// Always read from local for instant UI
const loadedHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
loadedHabits.sort((a, b) => {
if (a.sortOrder !== undefined && b.sortOrder !== undefined) return a.sortOrder - b.sortOrder;
if (a.sortOrder !== undefined) return -1;
@@ -211,7 +229,7 @@ const HomePage = () => {
...Object.values(grouped).flat()
];
}
setTimeout(loadHabits, 100); // reload after update
setTimeout(loadHabits, 0); // reload instantly after update
}}
>
<div className="space-y-6">

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase, isSupabaseConfigured } from '../lib/supabase';
import { Button } from '../components/ui/button';
const PROVIDERS = [
{ id: 'github', label: 'GitHub' },
{ id: 'discord', label: 'Discord' },
// Add more providers here if needed
];
const LoginProvidersPage = () => {
const navigate = useNavigate();
const handleLogin = async (provider) => {
if (!isSupabaseConfigured()) return alert('Supabase not configured. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY');
const { error } = await supabase.auth.signInWithOAuth({ provider });
if (error) alert(error.message);
};
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div className="max-w-md w-full p-8 bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700">
<h1 className="text-2xl font-bold mb-6 text-center">Choose Login Provider</h1>
<div className="flex flex-col gap-4">
{PROVIDERS.map(p => (
<Button key={p.id} onClick={() => handleLogin(p.id)} className="w-full">
{`Login with ${p.label}`}
</Button>
))}
</div>
<Button variant="ghost" className="mt-8 w-full" onClick={() => navigate(-1)}>
Cancel
</Button>
</div>
</div>
);
};
export default LoginProvidersPage;

View File

@@ -7,7 +7,9 @@ import { Separator } from '../components/ui/separator';
import { Switch } from '../components/ui/switch';
import { Label } from '../components/ui/label';
import { useToast } from '../components/ui/use-toast';
import { exportData, importData, clearAllData } from '../lib/storage';
import { exportData, importData, clearAllData } from '../lib/datastore';
import { supabase, isSupabaseConfigured } from '../lib/supabase';
import { syncLocalToRemoteIfNeeded } from '../lib/datastore';
import { addIntegration, getIntegrations, removeIntegration, getGitEnabled, setGitEnabled, fetchAllGitActivity, getCachedGitActivity } from '../lib/git';
const DEFAULT_STREAK_ICON = 'flame';
@@ -57,6 +59,27 @@ const SettingsPage = () => {
const [form, setForm] = useState({ provider: 'github', baseUrl: '', username: '', token: '' });
const [{ lastSync }, setCacheInfo] = useState(getCachedGitActivity());
const [syncing, setSyncing] = useState(false);
const [userEmail, setUserEmail] = useState('');
useEffect(() => {
if (!isSupabaseConfigured()) return;
supabase.auth.getUser().then(({ data }) => setUserEmail(data?.user?.email || ''));
const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
setUserEmail(session?.user?.email || '');
if (session?.user) syncLocalToRemoteIfNeeded();
});
return () => sub?.subscription?.unsubscribe();
}, []);
const handleLogin = async (provider) => {
if (!isSupabaseConfigured()) return alert('Supabase not configured. Set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY');
const { error } = await supabase.auth.signInWithOAuth({ provider });
if (error) alert(error.message);
};
const handleLogout = async () => {
await supabase?.auth?.signOut();
};
useEffect(() => {
if (darkMode) {
@@ -178,10 +201,20 @@ const SettingsPage = () => {
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<div className="flex-1">
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-sm text-muted-foreground">Customize your experience</p>
</div>
{isSupabaseConfigured() && (
userEmail ? (
<div className="flex items-center gap-2 ml-auto">
<span className="text-sm text-muted-foreground">{userEmail}</span>
<Button variant="outline" size="sm" onClick={handleLogout} className="rounded-full">Logout</Button>
</div>
) : (
<Button onClick={() => navigate('/login-providers')} variant="outline" size="sm" className="rounded-full ml-auto">Login to Sync</Button>
)
)}
</motion.div>
<div className="space-y-4">