// UUID v4 generator (browser safe) function generateUUID() { if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); // Fallback for older browsers return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } // UUID v4 validator function isValidUUID(id) { return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id); } // Ensure all habits in an array have valid UUIDs (returns new array) function ensureUUIDs(habits) { return habits.map(h => { if (!h.id || !isValidUUID(h.id)) { return { ...h, id: generateUUID() }; } return h; }); } 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(); // Ensure UUID for new habit const id = habit.id && isValidUUID(habit.id) ? habit.id : generateUUID(); const insert = { id, 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') .upsert(insert, { onConflict: 'id' }) .select('*') .single(); if (error) { console.warn('Supabase saveHabit error, writing local:', error.message); return local.saveHabit({ ...habit, id }); } 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); } // After any update, trigger a sync to ensure all local changes (including categories) are pushed to remote await syncLocalToRemoteIfNeeded(); } 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 let habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]'); habits = ensureUUIDs(habits); // If logged in, merge with remote and upsert remote if (await isLoggedIn()) { const remote = await getHabits(); let merged = mergeHabits(habits, remote); merged = ensureUUIDs(merged); await supabase.from('habits').upsert(merged, { onConflict: 'id' }); return JSON.stringify(merged, null, 2); } return JSON.stringify(habits, null, 2); } export async function importData(jsonString) { // Import to local let imported = local.importData(jsonString); // Always ensure UUIDs for imported data let importedArr = Array.isArray(imported) ? imported : JSON.parse(jsonString); importedArr = ensureUUIDs(importedArr); // If logged in, merge with remote and upsert if (await isLoggedIn()) { const user = await getAuthUser(); const remote = await getHabits(); let merged = mergeHabits(importedArr, remote); merged = ensureUUIDs(merged); localStorage.setItem('habitgrid_data', JSON.stringify(merged)); await supabase.from('habits').upsert(merged, { onConflict: 'id' }); return merged; } else { localStorage.setItem('habitgrid_data', JSON.stringify(importedArr)); return importedArr; } } 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; // Always upsert all local habits to Supabase after login let habits = local.getHabits(); if (habits.length === 0) return localStorage.setItem(SYNC_FLAG, new Date().toISOString()); habits = ensureUUIDs(habits); localStorage.setItem('habitgrid_data', JSON.stringify(habits)); const rows = habits.map(h => ({ id: h.id, 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()); } // Helper: Download JSON backup of local habits function backupLocalHabits() { const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]'); if (!habits.length) return; const blob = new Blob([JSON.stringify(habits, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `habitgrid-backup-${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); } // Helper: Merge two habit arrays by id, prefer latest updatedAt function mergeHabits(localHabits, remoteHabits) { const map = new Map(); [...localHabits, ...remoteHabits].forEach(h => { if (!map.has(h.id)) { map.set(h.id, h); } else { // Prefer latest updatedAt const existing = map.get(h.id); map.set(h.id, (new Date(h.updatedAt || 0) > new Date(existing.updatedAt || 0)) ? h : existing); } }); return Array.from(map.values()); } export async function syncRemoteToLocal() { const user = await getAuthUser(); if (!user) return; const remote = await getHabits(); const localHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]'); // Only backup on first login sync (not every refresh) const backupFlag = 'habitgrid_backup_done'; if (!localStorage.getItem(backupFlag)) { backupLocalHabits(); localStorage.setItem(backupFlag, '1'); } // If both local and remote have data, merge and update both if (localHabits.length && remote.length) { let merged = mergeHabits(localHabits, remote); merged = ensureUUIDs(merged); localStorage.setItem('habitgrid_data', JSON.stringify(merged)); await supabase.from('habits').upsert(merged, { onConflict: 'id' }); } else if (!remote.length && localHabits.length) { let ensured = ensureUUIDs(localHabits); await supabase.from('habits').upsert(ensured, { onConflict: 'id' }); localStorage.setItem('habitgrid_data', JSON.stringify(ensured)); } else if (remote.length && !localHabits.length) { let ensured = ensureUUIDs(remote); localStorage.setItem('habitgrid_data', JSON.stringify(ensured)); } window.dispatchEvent(new CustomEvent('habitgrid-sync-updated')); }