Compare commits

...

9 Commits

Author SHA1 Message Date
39f7bbd96f bug fixing 2025-10-18 14:24:45 +02:00
3dd34f4f17 bug fixing 2025-10-18 14:22:56 +02:00
caf31bd391 Merge branch 'main' of https://github.com/nagaoo0/HabitGrid 2025-10-18 14:20:53 +02:00
158e3ae342 update toggleColpletion 2025-10-18 14:20:23 +02:00
Mihajlo Ciric
9a216a658a Update README.md
Removed a dash from the sentence for clarity.
2025-10-18 14:09:02 +02:00
85db9f5efa update readme 2025-10-18 14:08:11 +02:00
08b04b8399 add login button on homepage 2025-10-18 14:03:58 +02:00
9675f42ffc Percist local habits to remote db 2025-10-18 13:56:01 +02:00
4d82d4c4b7 Update drag and drop update and sync to db 2025-10-18 13:50:44 +02:00
5 changed files with 116 additions and 39 deletions

View File

@@ -19,6 +19,7 @@ A modern, grid-based habit tracker app inspired by GitHub contribution graphs. T
- [Self-hosted Mirror (Gitea)](https://git.mihajlociric.com/count0/HabitGrid) - [Self-hosted Mirror (Gitea)](https://git.mihajlociric.com/count0/HabitGrid)
## Features ## Features
- GitHub-style habit grid (calendar view) - GitHub-style habit grid (calendar view)
- Streak tracking and personal bests - Streak tracking and personal bests
@@ -26,10 +27,19 @@ A modern, grid-based habit tracker app inspired by GitHub contribution graphs. T
- Dark mode and light mode - Dark mode and light mode
- Data export/import (JSON backup) - Data export/import (JSON backup)
- Responsive design (desktop & mobile) - Responsive design (desktop & mobile)
- **Cross-device sync with Supabase (cloud save)**
- **Offline-first:** works fully without login, syncs local habits to cloud on login
- Built with React, Vite, Tailwind CSS, Radix UI, and Framer Motion - Built with React, Vite, Tailwind CSS, Radix UI, and Framer Motion
--- ---
## How Sync Works
- By default, all habits are stored locally and work offline.
- When you log in (via the call to action button), your local habits are synced to Supabase and available on all devices.
- Any changes (including categories, order, completions) are automatically pushed to the cloud when logged in.
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
@@ -69,7 +79,29 @@ src/
pages/ pages/
``` ```
## Deployment Tip
## Cloud Sync Setup
To enable cross-device sync, you need a free [Supabase](https://supabase.com/) account:
1. Create a Supabase project and set up a `habits` table with the following schema:
```sql
create 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()
);
```
2. Add your Supabase project URL and anon key to the app's environment/config.
3. Deploy as usual (see below).
You can easily deploy your own instance of HabitGrid using [Cloudflare Pages](https://pages.cloudflare.com/): You can easily deploy your own instance of HabitGrid using [Cloudflare Pages](https://pages.cloudflare.com/):
@@ -86,9 +118,11 @@ You can easily deploy your own instance of HabitGrid using [Cloudflare Pages](ht
``` ```
5. Deploy and enjoy your own habit tracker online! 5. Deploy and enjoy your own habit tracker online!
## License
MIT ## Offline-First Guarantee
- You can use HabitGrid without ever logging in everything works locally.
- If you decide to log in later, all your local habits (including categories and order) will be synced to the cloud and available on all devices.
--- ---
@@ -100,6 +134,4 @@ MIT
--- ---
---
*Built with ❤️ by [Mihajlo Ciric](https://mihajlociric.com/)* *Built with ❤️ by [Mihajlo Ciric](https://mihajlociric.com/)*

View File

@@ -40,9 +40,9 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
const handleCellClick = async (date) => { const handleCellClick = async (date) => {
const dateStr = formatDate(date); const dateStr = formatDate(date);
// Only do optimistic localStorage write if logged in (remote-first). In local-only mode, let datastore handle it to avoid double-toggle.
const user = await getAuthUser(); const user = await getAuthUser();
if (user) { if (user) {
// Optimistically update completions for instant UI
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]'); const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
const idx = habits.findIndex(h => h.id === habit.id); const idx = habits.findIndex(h => h.id === habit.id);
if (idx !== -1) { if (idx !== -1) {
@@ -52,9 +52,14 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
habits[idx].completions = completions; habits[idx].completions = completions;
localStorage.setItem('habitgrid_data', JSON.stringify(habits)); localStorage.setItem('habitgrid_data', JSON.stringify(habits));
} }
} onUpdate();
// Sync in background
toggleCompletion(habit.id, dateStr);
} else {
// Local-only: just call toggleCompletion, then update UI
await toggleCompletion(habit.id, dateStr); await toggleCompletion(habit.id, dateStr);
onUpdate(); onUpdate();
}
}; };
return ( return (

View File

@@ -69,9 +69,9 @@ const MiniGrid = ({ habit, onUpdate }) => {
const dateStr = formatDate(date); const dateStr = formatDate(date);
const isTodayCell = isToday(date); const isTodayCell = isToday(date);
const wasCompleted = habit.completions.includes(dateStr); const wasCompleted = habit.completions.includes(dateStr);
// Only optimistic write if logged in; in local-only mode, datastore handles it to avoid double-toggle
const user = await getAuthUser(); const user = await getAuthUser();
if (user) { if (user) {
// Optimistically update completions for instant UI
const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]'); const habits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
const idx = habits.findIndex(h => h.id === habit.id); const idx = habits.findIndex(h => h.id === habit.id);
if (idx !== -1) { if (idx !== -1) {
@@ -81,9 +81,14 @@ const MiniGrid = ({ habit, onUpdate }) => {
habits[idx].completions = completions; habits[idx].completions = completions;
localStorage.setItem('habitgrid_data', JSON.stringify(habits)); localStorage.setItem('habitgrid_data', JSON.stringify(habits));
} }
} onUpdate();
// Sync in background
toggleCompletion(habit.id, dateStr);
} else {
// Local-only: just call toggleCompletion, then update UI
await toggleCompletion(habit.id, dateStr); await toggleCompletion(habit.id, dateStr);
onUpdate(); onUpdate();
}
// Only show encouragement toast if validating (adding) today's dot // Only show encouragement toast if validating (adding) today's dot
if (isTodayCell && !wasCompleted) { if (isTodayCell && !wasCompleted) {
try { try {

View File

@@ -134,6 +134,8 @@ export async function updateHabit(id, updates) {
console.warn('Supabase updateHabit error, writing local:', error.message); console.warn('Supabase updateHabit error, writing local:', error.message);
return local.updateHabit(id, updates); 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) { export async function deleteHabit(id) {
@@ -206,15 +208,10 @@ export async function syncLocalToRemoteIfNeeded() {
const user = await getAuthUser(); const user = await getAuthUser();
if (!user) return; if (!user) return;
const already = localStorage.getItem(SYNC_FLAG); // Always upsert all local habits to Supabase after login
const { data: remote, error } = await supabase.from('habits').select('id').limit(1);
if (error) return;
if (!already || (remote || []).length === 0) {
let habits = local.getHabits(); let habits = local.getHabits();
if (habits.length === 0) return localStorage.setItem(SYNC_FLAG, new Date().toISOString()); if (habits.length === 0) return localStorage.setItem(SYNC_FLAG, new Date().toISOString());
habits = ensureUUIDs(habits); habits = ensureUUIDs(habits);
// Persist back to local so IDs match remote after upsert
localStorage.setItem('habitgrid_data', JSON.stringify(habits)); localStorage.setItem('habitgrid_data', JSON.stringify(habits));
const rows = habits.map(h => ({ const rows = habits.map(h => ({
id: h.id, id: h.id,
@@ -232,7 +229,6 @@ export async function syncLocalToRemoteIfNeeded() {
await supabase.from('habits').upsert(rows, { onConflict: 'id' }); await supabase.from('habits').upsert(rows, { onConflict: 'id' });
localStorage.setItem(SYNC_FLAG, new Date().toISOString()); localStorage.setItem(SYNC_FLAG, new Date().toISOString());
} }
}
// Helper: Download JSON backup of local habits // Helper: Download JSON backup of local habits

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { useNavigate } from 'react-router-dom'; // ...existing code...
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun } from 'lucide-react'; import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun } from 'lucide-react';
import { Button } from '../components/ui/button'; import { Button } from '../components/ui/button';
@@ -10,6 +10,7 @@ import AnimatedCounter from '../components/AnimatedCounter';
import GitActivityGrid from '../components/GitActivityGrid'; import GitActivityGrid from '../components/GitActivityGrid';
import { getGitEnabled } from '../lib/git'; import { getGitEnabled } from '../lib/git';
import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser } from '../lib/datastore'; import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser } from '../lib/datastore';
import { useNavigate } from 'react-router-dom';
const HomePage = () => { const HomePage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -84,6 +85,15 @@ const HomePage = () => {
navigate('/add'); navigate('/add');
}; };
const handleLoginSync = () => {
navigate('/login');
};
const handleManualSync = async () => {
await syncLocalToRemoteIfNeeded();
toast({ title: 'Synced!', description: 'Your habits have been synced to the cloud.' });
};
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950"> <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
<div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8"> <div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
@@ -123,6 +133,7 @@ const HomePage = () => {
</div> </div>
</motion.div> </motion.div>
{/* Stats Overview */} {/* Stats Overview */}
{habits.length > 0 && ( {habits.length > 0 && (
<motion.div <motion.div
@@ -157,6 +168,8 @@ const HomePage = () => {
</motion.div> </motion.div>
)} )}
{/* Habits List */} {/* Habits List */}
{/* Grouped Habits by Category, collapsible, and uncategorized habits outside */} {/* Grouped Habits by Category, collapsible, and uncategorized habits outside */}
<DragDropContext <DragDropContext
@@ -234,7 +247,8 @@ const HomePage = () => {
...Object.values(grouped).flat() ...Object.values(grouped).flat()
]; ];
} }
setTimeout(loadHabits, 0); // reload instantly after update // Force immediate UI update after all updates
loadHabits();
}} }}
> >
<div className="space-y-6"> <div className="space-y-6">
@@ -365,10 +379,35 @@ const HomePage = () => {
<Plus className="w-5 h-5 mr-2" /> <Plus className="w-5 h-5 mr-2" />
Create First Habit Create First Habit
</Button> </Button>
{/* Call to Action for Login/Sync */}
<div className="mb-6 flex flex-col items-center mt-8">
{!loggedIn ? (
<Button
onClick={handleLoginSync}
size="lg"
className="rounded-full shadow-lg bg-emerald-600 text-white hover:bg-emerald-700 transition"
>
<Flame className="w-5 h-5 mr-2" />
Login & Sync My Habits
</Button>
) : (
<Button
onClick={handleManualSync}
size="lg"
className="rounded-full shadow-lg bg-blue-600 text-white hover:bg-blue-700 transition"
>
<Plus className="w-5 h-5 mr-2" />
Sync My Habits Now
</Button>
)}
</div>
</motion.div> </motion.div>
) )
)} )}
{/* Add Button */} {/* Add Button */}
{habits.length > 0 && ( {habits.length > 0 && (
<motion.div <motion.div