From 2b6b515d471b0fe6bf1c83763e51559571597a24 Mon Sep 17 00:00:00 2001 From: count0 Date: Fri, 17 Oct 2025 22:10:57 +0200 Subject: [PATCH] Add supabase setup --- .env.example | 2 + package-lock.json | 135 ++++++++++++++++++++++- package.json | 1 + src/App.jsx | 2 + src/components/HabitGrid.jsx | 15 ++- src/components/MiniGrid.jsx | 14 ++- src/lib/datastore.js | 184 +++++++++++++++++++++++++++++++ src/lib/storage.js | 52 ++++++++- src/lib/supabase.js | 11 ++ src/pages/AddEditHabitPage.jsx | 24 +++- src/pages/HabitDetailPage.jsx | 14 ++- src/pages/HomePage.jsx | 32 ++++-- src/pages/LoginProvidersPage.jsx | 40 +++++++ src/pages/SettingsPage.jsx | 37 ++++++- supabase/habits_table.csv | 2 + supabase/habits_table.sql | 57 ++++++++++ 16 files changed, 599 insertions(+), 23 deletions(-) create mode 100644 .env.example create mode 100644 src/lib/datastore.js create mode 100644 src/lib/supabase.js create mode 100644 src/pages/LoginProvidersPage.jsx create mode 100644 supabase/habits_table.csv create mode 100644 supabase/habits_table.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..abfdc28 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= diff --git a/package-lock.json b/package-lock.json index 9935b69..6f35d61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", + "@supabase/supabase-js": "^2.75.1", "class-variance-authority": "^0.7.1", "clsx": "^2.0.0", "framer-motion": "^10.16.4", @@ -4060,6 +4061,80 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.75.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.75.1.tgz", + "integrity": "sha512-zktlxtXstQuVys/egDpVsargD9hQtG20CMdtn+mMn7d2Ulkzy2tgUT5FUtpppvCJtd9CkhPHO/73rvi5W6Am5A==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.75.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.75.1.tgz", + "integrity": "sha512-xO+01SUcwVmmo67J7Htxq8FmhkYLFdWkxfR/taxBOI36wACEUNQZmroXGPl4PkpYxBO7TaDsRHYGxUpv9zTKkg==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.75.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.75.1.tgz", + "integrity": "sha512-FiYBD0MaKqGW8eo4Xqu7/100Xm3ddgh+3qHtqS18yQRoglJTFRQCJzY1xkrGS0JFHE2YnbjL6XCiOBXiG8DK4Q==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.75.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.75.1.tgz", + "integrity": "sha512-lBIJ855bUsBFScHA/AY+lxIFkubduUvmwbagbP1hq0wDBNAsYdg3ql80w8YmtXCDjkCwlE96SZqcFn7BGKKJKQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.75.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.75.1.tgz", + "integrity": "sha512-WdGEhroflt5O398Yg3dpf1uKZZ6N3CGloY9iGsdT873uWbkQKoP0wG8mtx98dh0fhj6dAlzBqOAvnlV12cJfzA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "2.6.15" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.75.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.75.1.tgz", + "integrity": "sha512-GEPVBvjQimcMd9z5K1eTKTixTRb6oVbudoLQ9JKqTUJnR6GQdBU4OifFZean1AnHfsQwtri1fop2OWwsMv019w==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.75.1", + "@supabase/functions-js": "2.75.1", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "2.75.1", + "@supabase/realtime-js": "2.75.1", + "@supabase/storage-js": "2.75.1" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4130,7 +4205,6 @@ "version": "20.19.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz", "integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4143,6 +4217,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4184,6 +4264,15 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -9551,6 +9640,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -9754,7 +9849,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -10015,6 +10109,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10230,6 +10340,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 420f2be..10befcf 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", + "@supabase/supabase-js": "^2.75.1", "class-variance-authority": "^0.7.1", "clsx": "^2.0.0", "framer-motion": "^10.16.4", diff --git a/src/App.jsx b/src/App.jsx index da17d4b..35cd2f0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + } /> diff --git a/src/components/HabitGrid.jsx b/src/components/HabitGrid.jsx index 7d33588..db1480c 100644 --- a/src/components/HabitGrid.jsx +++ b/src/components/HabitGrid.jsx @@ -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(); }; diff --git a/src/components/MiniGrid.jsx b/src/components/MiniGrid.jsx index 42d5aae..2e06923 100644 --- a/src/components/MiniGrid.jsx +++ b/src/components/MiniGrid.jsx @@ -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) { diff --git a/src/lib/datastore.js b/src/lib/datastore.js new file mode 100644 index 0000000..51e7553 --- /dev/null +++ b/src/lib/datastore.js @@ -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')); +} diff --git a/src/lib/storage.js b/src/lib/storage.js index 9f54cc3..5d9b4ed 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -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); -}; \ No newline at end of file +}; + +// 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'; \ No newline at end of file diff --git a/src/lib/supabase.js b/src/lib/supabase.js new file mode 100644 index 0000000..d8b6604 --- /dev/null +++ b/src/lib/supabase.js @@ -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); diff --git a/src/pages/AddEditHabitPage.jsx b/src/pages/AddEditHabitPage.jsx index 51643c9..73d6e26 100644 --- a/src/pages/AddEditHabitPage.jsx +++ b/src/pages/AddEditHabitPage.jsx @@ -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!", diff --git a/src/pages/HabitDetailPage.jsx b/src/pages/HabitDetailPage.jsx index c05820c..976c685 100644 --- a/src/pages/HabitDetailPage.jsx +++ b/src/pages/HabitDetailPage.jsx @@ -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.", diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index f81cf05..ea1d7d7 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -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 }} >
diff --git a/src/pages/LoginProvidersPage.jsx b/src/pages/LoginProvidersPage.jsx new file mode 100644 index 0000000..ceab54e --- /dev/null +++ b/src/pages/LoginProvidersPage.jsx @@ -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 ( +
+
+

Choose Login Provider

+
+ {PROVIDERS.map(p => ( + + ))} +
+ +
+
+ ); +}; + +export default LoginProvidersPage; diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 50d091c..0a9cb35 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -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 = () => { > -
+

Settings

Customize your experience

+ {isSupabaseConfigured() && ( + userEmail ? ( +
+ {userEmail} + +
+ ) : ( + + ) + )}
diff --git a/supabase/habits_table.csv b/supabase/habits_table.csv new file mode 100644 index 0000000..cc7e025 --- /dev/null +++ b/supabase/habits_table.csv @@ -0,0 +1,2 @@ +id,user_id,name,color,category,completions,current_streak,longest_streak,sort_order,created_at,updated_at +3fa85f64-5717-4562-b3fc-2c963f66afa6,11111111-1111-1111-1111-111111111111,Sample Habit,#22c55e,Health,"[""2025-01-01"",""2025-01-02""]",2,5,0,2025-01-01T00:00:00Z,2025-01-02T00:00:00Z diff --git a/supabase/habits_table.sql b/supabase/habits_table.sql new file mode 100644 index 0000000..019fe06 --- /dev/null +++ b/supabase/habits_table.sql @@ -0,0 +1,57 @@ +-- Create habits table with proper types, defaults, constraints, and RLS +-- Run this in Supabase SQL editor + +-- Enable gen_random_uuid() +create extension if not exists pgcrypto; + +create table if not exists public.habits ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + name text not null, + color text, + category text, + completions jsonb not null default '[]'::jsonb, + current_streak integer not null default 0, + longest_streak integer not null default 0, + sort_order integer not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Useful indexes +create index if not exists idx_habits_user_id on public.habits(user_id); +create index if not exists idx_habits_user_sort on public.habits(user_id, sort_order); + +-- Automatically update updated_at +create or replace function public.set_updated_at() +returns trigger language plpgsql as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists trg_habits_set_updated_at on public.habits; +create trigger trg_habits_set_updated_at +before update on public.habits +for each row execute function public.set_updated_at(); + +-- Row Level Security +alter table public.habits enable row level security; + +-- Policies: each user can only access their own rows +drop policy if exists habits_select on public.habits; +create policy habits_select on public.habits +for select using (auth.uid() = user_id); + +drop policy if exists habits_insert on public.habits; +create policy habits_insert on public.habits +for insert with check (auth.uid() = user_id); + +drop policy if exists habits_update on public.habits; +create policy habits_update on public.habits +for update using (auth.uid() = user_id) with check (auth.uid() = user_id); + +drop policy if exists habits_delete on public.habits; +create policy habits_delete on public.habits +for delete using (auth.uid() = user_id);