mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-04-19 15:23:16 +00:00
Compare commits
3 Commits
b91f94a388
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 839252e3ef | |||
| a902062726 | |||
| e330346d86 |
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays } from '../lib/utils-habit';
|
||||
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays, calculateStreaks } from '../lib/utils-habit';
|
||||
import { toggleCompletion, getAuthUser } from '../lib/datastore';
|
||||
|
||||
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
@@ -50,6 +50,11 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
const cidx = completions.indexOf(dateStr);
|
||||
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
|
||||
habits[idx].completions = completions;
|
||||
// Recalculate streaks so counters reflect immediately
|
||||
const { currentStreak, longestStreak } = calculateStreaks(completions);
|
||||
const prevRecord = habits[idx].longestStreak || 0;
|
||||
habits[idx].currentStreak = currentStreak;
|
||||
habits[idx].longestStreak = Math.max(longestStreak, prevRecord);
|
||||
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||
}
|
||||
onUpdate();
|
||||
|
||||
@@ -38,7 +38,7 @@ function getFreezeIcon() {
|
||||
return icon || '❄️';
|
||||
}
|
||||
import { motion } from 'framer-motion';
|
||||
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
|
||||
import { getColorIntensity, isToday, formatDate, calculateStreaks } from '../lib/utils-habit';
|
||||
import { getFrozenDays } from '../lib/utils-habit';
|
||||
import { toggleCompletion, getAuthUser } from '../lib/datastore';
|
||||
import { toast } from './ui/use-toast';
|
||||
@@ -79,6 +79,11 @@ const MiniGrid = ({ habit, onUpdate }) => {
|
||||
const cidx = completions.indexOf(dateStr);
|
||||
if (cidx > -1) completions.splice(cidx, 1); else completions.push(dateStr);
|
||||
habits[idx].completions = completions;
|
||||
// Recalculate streaks locally so counters update immediately
|
||||
const { currentStreak, longestStreak } = calculateStreaks(completions);
|
||||
const prevRecord = habits[idx].longestStreak || 0;
|
||||
habits[idx].currentStreak = currentStreak;
|
||||
habits[idx].longestStreak = Math.max(longestStreak, prevRecord);
|
||||
localStorage.setItem('habitgrid_data', JSON.stringify(habits));
|
||||
}
|
||||
onUpdate();
|
||||
|
||||
@@ -23,6 +23,7 @@ function ensureUUIDs(habits) {
|
||||
});
|
||||
}
|
||||
import { supabase, isSupabaseConfigured } from './supabase';
|
||||
import { calculateStreaks } from './utils-habit';
|
||||
import * as local from './storage';
|
||||
|
||||
const SYNC_FLAG = 'habitgrid_remote_synced_at';
|
||||
@@ -30,7 +31,23 @@ 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;
|
||||
const user = data?.user ?? null;
|
||||
// Mark that the user has logged in at least once so we can prompt later if they're logged out
|
||||
try {
|
||||
if (user) localStorage.setItem('habitgrid_ever_logged_in', '1');
|
||||
} catch (e) {
|
||||
// ignore localStorage errors in restrictive environments
|
||||
}
|
||||
return user;
|
||||
};
|
||||
|
||||
// Helper to check whether the user has ever logged in from this browser
|
||||
export const hasEverLoggedIn = () => {
|
||||
try {
|
||||
return localStorage.getItem('habitgrid_ever_logged_in') === '1';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isLoggedIn = async () => Boolean(await getAuthUser());
|
||||
@@ -156,7 +173,10 @@ export async function toggleCompletion(habitId, dateStr) {
|
||||
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 });
|
||||
// Calculate streaks and preserve personal record (do not decrease longest)
|
||||
const { currentStreak, longestStreak } = calculateStreaks(completions);
|
||||
const nextLongest = Math.max(longestStreak, target.longestStreak || 0);
|
||||
return updateHabit(habitId, { completions, currentStreak, longestStreak: nextLongest });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -126,65 +126,7 @@ export const toggleCompletion = (habitId, dateStr) => {
|
||||
});
|
||||
};
|
||||
|
||||
import { getFrozenDays } from './utils-habit.js';
|
||||
const calculateStreaks = (completions) => {
|
||||
if (completions.length === 0) {
|
||||
return { currentStreak: 0, longestStreak: 0 };
|
||||
}
|
||||
// Only use frozen days for streak calculation
|
||||
const frozenDays = getFrozenDays(completions);
|
||||
const allValid = Array.from(new Set([...completions, ...frozenDays]));
|
||||
const sortedDates = allValid
|
||||
.map(d => new Date(d))
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
let currentStreak = 0;
|
||||
let longestStreak = 0;
|
||||
let tempStreak = 1;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const mostRecent = sortedDates[0];
|
||||
mostRecent.setHours(0, 0, 0, 0);
|
||||
|
||||
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
|
||||
currentStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const current = new Date(sortedDates[i]);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const previous = new Date(sortedDates[i - 1]);
|
||||
previous.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 1) {
|
||||
currentStreak++;
|
||||
tempStreak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tempStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const current = new Date(sortedDates[i]);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const previous = new Date(sortedDates[i - 1]);
|
||||
previous.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 1) {
|
||||
tempStreak++;
|
||||
longestStreak = Math.max(longestStreak, tempStreak);
|
||||
} else {
|
||||
tempStreak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
longestStreak = Math.max(longestStreak, currentStreak, 1);
|
||||
return { currentStreak, longestStreak };
|
||||
}
|
||||
import { calculateStreaks } from './utils-habit.js';
|
||||
|
||||
export const exportData = () => {
|
||||
const habits = getHabits();
|
||||
|
||||
@@ -40,6 +40,71 @@ export const getWeekdayLabel = (dayIndex) => {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return labels[dayIndex];
|
||||
};
|
||||
|
||||
// Calculate current and longest streaks from a list of completion date strings (YYYY-MM-DD)
|
||||
// Rules:
|
||||
// - Streaks count consecutive days
|
||||
// - Today or yesterday must be present to have a non-zero current streak
|
||||
// - We also include "frozen" days (one missed day per month sandwiched by completions)
|
||||
// - Longest streak is at least 1 if there is at least one completion
|
||||
export function calculateStreaks(completions) {
|
||||
if (!Array.isArray(completions) || completions.length === 0) {
|
||||
return { currentStreak: 0, longestStreak: 0 };
|
||||
}
|
||||
// Only use frozen days for streak calculation
|
||||
const frozenDays = getFrozenDays(completions);
|
||||
const allValid = Array.from(new Set([...(completions || []), ...frozenDays]));
|
||||
const sortedDates = allValid
|
||||
.map(d => new Date(d))
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
let currentStreak = 0;
|
||||
let longestStreak = 0;
|
||||
let tempStreak = 1;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const mostRecent = sortedDates[0];
|
||||
mostRecent.setHours(0, 0, 0, 0);
|
||||
|
||||
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
|
||||
currentStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const current = new Date(sortedDates[i]);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const previous = new Date(sortedDates[i - 1]);
|
||||
previous.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 1) {
|
||||
currentStreak++;
|
||||
tempStreak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tempStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const current = new Date(sortedDates[i]);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const previous = new Date(sortedDates[i - 1]);
|
||||
previous.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 1) {
|
||||
tempStreak++;
|
||||
longestStreak = Math.max(longestStreak, tempStreak);
|
||||
} else {
|
||||
tempStreak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
longestStreak = Math.max(longestStreak, currentStreak, 1);
|
||||
return { currentStreak, longestStreak };
|
||||
}
|
||||
// Returns array of frozen days (date strings) for a given completions array
|
||||
export function getFrozenDays(completions) {
|
||||
// Map: month string -> frozen day string
|
||||
|
||||
@@ -9,7 +9,8 @@ import HabitCard from '../components/HabitCard';
|
||||
import AnimatedCounter from '../components/AnimatedCounter';
|
||||
import GitActivityGrid from '../components/GitActivityGrid';
|
||||
import { getGitEnabled } from '../lib/git';
|
||||
import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser } from '../lib/datastore';
|
||||
import { calculateStreaks } from '../lib/utils-habit';
|
||||
import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser, hasEverLoggedIn } from '../lib/datastore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const HomePage = () => {
|
||||
@@ -19,6 +20,7 @@ const HomePage = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||||
const [everLoggedIn, setEverLoggedIn] = useState(false);
|
||||
const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
return localStorage.getItem('theme') === 'dark';
|
||||
@@ -30,6 +32,12 @@ const HomePage = () => {
|
||||
// On login, pull remote habits into localStorage
|
||||
const user = await getAuthUser();
|
||||
setLoggedIn(!!user);
|
||||
// Mark whether this browser has seen a login before
|
||||
try {
|
||||
setEverLoggedIn(hasEverLoggedIn());
|
||||
} catch (e) {
|
||||
setEverLoggedIn(false);
|
||||
}
|
||||
if (user) {
|
||||
await syncRemoteToLocal();
|
||||
}
|
||||
@@ -63,13 +71,27 @@ const HomePage = () => {
|
||||
const loadHabits = async () => {
|
||||
// Always read from local for instant UI
|
||||
const loadedHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
||||
loadedHabits.sort((a, b) => {
|
||||
// One-time consistency pass: recompute streaks from completions if missing or outdated
|
||||
let changed = false;
|
||||
const updated = loadedHabits.map(h => {
|
||||
const { currentStreak, longestStreak } = calculateStreaks(h.completions || []);
|
||||
const nextLongest = Math.max(longestStreak, h.longestStreak || 0);
|
||||
if ((h.currentStreak ?? 0) !== currentStreak || (h.longestStreak ?? 0) !== nextLongest) {
|
||||
changed = true;
|
||||
return { ...h, currentStreak, longestStreak: nextLongest };
|
||||
}
|
||||
return h;
|
||||
});
|
||||
if (changed) {
|
||||
localStorage.setItem('habitgrid_data', JSON.stringify(updated));
|
||||
}
|
||||
updated.sort((a, b) => {
|
||||
if (a.sortOrder !== undefined && b.sortOrder !== undefined) return a.sortOrder - b.sortOrder;
|
||||
if (a.sortOrder !== undefined) return -1;
|
||||
if (b.sortOrder !== undefined) return 1;
|
||||
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
|
||||
});
|
||||
setHabits(loadedHabits);
|
||||
setHabits(updated);
|
||||
// Initialize collapsed state for new categories
|
||||
const categories = Array.from(new Set(loadedHabits.map(h => h.category || 'Uncategorized')));
|
||||
setCollapsedGroups(prev => {
|
||||
@@ -86,7 +108,7 @@ const HomePage = () => {
|
||||
};
|
||||
|
||||
const handleLoginSync = () => {
|
||||
navigate('/login');
|
||||
navigate('/login-providers');
|
||||
};
|
||||
|
||||
const handleManualSync = async () => {
|
||||
@@ -133,7 +155,21 @@ const HomePage = () => {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
{/* Prompt previously-signed-in users to re-authenticate if currently logged out */}
|
||||
{!loggedIn && everLoggedIn && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="max-w-6xl mx-auto mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/40 rounded-lg border border-yellow-100 dark:border-yellow-800 flex items-center justify-between"
|
||||
>
|
||||
<div className="text-sm text-yellow-800 dark:text-yellow-200">Looks like you were previously signed in. Sign in again to keep your habits synced across devices.</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => navigate('/login-providers')}>Sign in</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { localStorage.removeItem('habitgrid_ever_logged_in'); setEverLoggedIn(false); }}>Dismiss</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
@@ -168,8 +204,6 @@ const HomePage = () => {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Habits List */}
|
||||
{/* Grouped Habits by Category, collapsible, and uncategorized habits outside */}
|
||||
<DragDropContext
|
||||
@@ -422,8 +456,6 @@ const HomePage = () => {
|
||||
)
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Add Button */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
|
||||
Reference in New Issue
Block a user