diff --git a/src/components/HabitGrid.jsx b/src/components/HabitGrid.jsx index 3b77170..8df5333 100644 --- a/src/components/HabitGrid.jsx +++ b/src/components/HabitGrid.jsx @@ -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(); diff --git a/src/components/MiniGrid.jsx b/src/components/MiniGrid.jsx index 37c59e4..c5a2795 100644 --- a/src/components/MiniGrid.jsx +++ b/src/components/MiniGrid.jsx @@ -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(); diff --git a/src/lib/datastore.js b/src/lib/datastore.js index f8a7ca7..ec72948 100644 --- a/src/lib/datastore.js +++ b/src/lib/datastore.js @@ -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'; @@ -172,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 }); } diff --git a/src/lib/storage.js b/src/lib/storage.js index 33710e5..67cef8a 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -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(); diff --git a/src/lib/utils-habit.js b/src/lib/utils-habit.js index db48561..e24469e 100644 --- a/src/lib/utils-habit.js +++ b/src/lib/utils-habit.js @@ -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 diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 75ccdc1..b2aad7f 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -9,6 +9,7 @@ import HabitCard from '../components/HabitCard'; import AnimatedCounter from '../components/AnimatedCounter'; import GitActivityGrid from '../components/GitActivityGrid'; import { getGitEnabled } from '../lib/git'; +import { calculateStreaks } from '../lib/utils-habit'; import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser, hasEverLoggedIn } from '../lib/datastore'; import { useNavigate } from 'react-router-dom'; @@ -70,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 => {