Update for Sally

Added Freeze Day
This commit is contained in:
2025-10-15 13:50:20 +02:00
parent 6dbb690e3d
commit f830e4fccf
4 changed files with 106 additions and 43 deletions

View File

@@ -1,9 +1,10 @@
import React, { useMemo, useEffect } from 'react'; import React, { useMemo, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { getColorIntensity, isToday, formatDate, getWeekdayLabel } from '../lib/utils-habit'; import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays } from '../lib/utils-habit';
import { toggleCompletion } from '../lib/storage'; import { toggleCompletion } from '../lib/storage';
const HabitGrid = ({ habit, onUpdate, fullView = false }) => { const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
const frozenDays = getFrozenDays(habit.completions);
const weeks = useMemo(() => { const weeks = useMemo(() => {
const today = new Date(); const today = new Date();
// Find the Monday of the current week // Find the Monday of the current week
@@ -67,13 +68,14 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0; const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
const isTodayCell = isToday(date); const isTodayCell = isToday(date);
const isFuture = date > new Date(); const isFuture = date > new Date();
const isFrozen = frozenDays.includes(dateStr);
return ( return (
<motion.button <motion.button
key={dayIndex} key={dayIndex}
whileHover={{ scale: 1.15 }} whileHover={{ scale: 1.15 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
onClick={() => handleCellClick(date)} onClick={() => handleCellClick(date)}
className="habit-cell w-3 h-3 rounded-sm" className="habit-cell w-3 h-3 rounded-sm flex items-center justify-center"
style={{ style={{
backgroundColor: isCompleted ? habit.color : 'transparent', backgroundColor: isCompleted ? habit.color : 'transparent',
opacity: isFuture ? 0 : (isCompleted ? 0.3 + (intensity * 0.7) : 1), opacity: isFuture ? 0 : (isCompleted ? 0.3 + (intensity * 0.7) : 1),
@@ -81,8 +83,12 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
pointerEvents: isFuture ? 'none' : 'auto', pointerEvents: isFuture ? 'none' : 'auto',
visibility: isFuture ? 'hidden' : 'visible', visibility: isFuture ? 'hidden' : 'visible',
}} }}
title={`${dateStr}${isCompleted ? ' ✓' : ''}`} title={`${dateStr}${isCompleted ? ' ✓' : ''}${isFrozen ? ' (Frozen)' : ''}`}
/> >
{isFrozen && (
<span role="img" aria-label="Frozen" style={{ fontSize: '0.7em' }}></span>
)}
</motion.button>
); );
})} })}
</div> </div>

View File

@@ -1,6 +1,21 @@
import React from 'react'; import React from 'react';
// Utility to lighten a hex color
function lightenColor(hex, percent) {
hex = hex.replace(/^#/, '');
if (hex.length === 3) hex = hex.split('').map(x => x + x).join('');
const num = parseInt(hex, 16);
let r = (num >> 16) + Math.round(255 * percent);
let g = ((num >> 8) & 0x00FF) + Math.round(255 * percent);
let b = (num & 0x0000FF) + Math.round(255 * percent);
r = Math.min(255, r);
g = Math.min(255, g);
b = Math.min(255, b);
return `rgb(${r},${g},${b})`;
}
import { Flame } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit'; import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
import { getFrozenDays } from '../lib/utils-habit';
import { toggleCompletion } from '../lib/storage'; import { toggleCompletion } from '../lib/storage';
const MiniGrid = ({ habit, onUpdate }) => { const MiniGrid = ({ habit, onUpdate }) => {
@@ -31,35 +46,46 @@ const MiniGrid = ({ habit, onUpdate }) => {
return ( return (
<div ref={scrollRef} className="flex gap-1 overflow-x-auto grid-scroll pb-2"> <div ref={scrollRef} className="flex gap-1 overflow-x-auto grid-scroll pb-2">
{days.map((date, index) => { {(() => {
const dateStr = formatDate(date); const frozenDays = getFrozenDays(habit.completions);
const isCompleted = habit.completions.includes(dateStr); return days.map((date, index) => {
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0; const dateStr = formatDate(date);
const isTodayCell = isToday(date); const isCompleted = habit.completions.includes(dateStr);
const dayLetter = date.toLocaleDateString('en-US', { weekday: 'short' })[0]; const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
const isTodayCell = isToday(date);
return ( const dayLetter = date.toLocaleDateString('en-US', { weekday: 'short' })[0];
<div key={index} className="flex flex-col items-center"> const isFrozen = frozenDays.includes(dateStr);
<motion.button return (
whileHover={{ scale: 0.9 }} <div key={index} className="flex flex-col items-center">
whileTap={{ scale: 0.5 }} <motion.button
onClick={(e) => handleCellClick(e, date)} whileHover={{ scale: 0.9 }}
className={`habit-cell flex w-8 h-8 transition-all ${isTodayCell ? 'rounded-md' : 'rounded-2xl'}`} whileTap={{ scale: 0.5 }}
style={{ onClick={(e) => handleCellClick(e, date)}
backgroundColor: isCompleted className={`habit-cell flex w-8 h-8 transition-all items-center justify-center ${isTodayCell ? 'rounded-md' : 'rounded-2xl'}`}
? habit.color style={{
: 'transparent', backgroundColor: isCompleted
opacity: isCompleted ? 0.3 + (intensity * 0.7) : 1, ? habit.color
border: isTodayCell : 'transparent',
? `2px solid ${habit.color}` opacity: isCompleted ? 0.3 + (intensity * 0.7) : 1,
: `1px solid ${habit.color}20`, border: isTodayCell
}} ? `2px solid ${habit.color}`
title={dateStr} : `1px solid ${habit.color}20`,
/> }}
<span className="text-[10px] text-muted-foreground mt-1">{dayLetter}</span> title={dateStr}
</div> >
); {isFrozen && (
})} <span role="img" aria-label="Frozen" style={{ fontSize: '1.2em' }}></span>
)}
{/* Flame icon for full streak days */}
{isCompleted && intensity >= 1 && (
<Flame className="w-5 h-5 absolute" style={{ color: lightenColor(habit.color, 0.4) }} />
)}
</motion.button>
<span className="text-[10px] text-muted-foreground mt-1">{dayLetter}</span>
</div>
);
});
})()}
</div> </div>
); );
}; };

View File

@@ -65,12 +65,15 @@ export const toggleCompletion = (habitId, dateStr) => {
}); });
}; };
import { getFrozenDays } from './utils-habit.js';
const calculateStreaks = (completions) => { const calculateStreaks = (completions) => {
if (completions.length === 0) { if (completions.length === 0) {
return { currentStreak: 0, longestStreak: 0 }; return { currentStreak: 0, longestStreak: 0 };
} }
// Only use frozen days for streak calculation
const sortedDates = completions const frozenDays = getFrozenDays(completions);
const allValid = Array.from(new Set([...completions, ...frozenDays]));
const sortedDates = allValid
.map(d => new Date(d)) .map(d => new Date(d))
.sort((a, b) => b - a); .sort((a, b) => b - a);
@@ -88,15 +91,12 @@ const calculateStreaks = (completions) => {
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) { if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
currentStreak = 1; currentStreak = 1;
for (let i = 1; i < sortedDates.length; i++) { for (let i = 1; i < sortedDates.length; i++) {
const current = new Date(sortedDates[i]); const current = new Date(sortedDates[i]);
current.setHours(0, 0, 0, 0); current.setHours(0, 0, 0, 0);
const previous = new Date(sortedDates[i - 1]); const previous = new Date(sortedDates[i - 1]);
previous.setHours(0, 0, 0, 0); previous.setHours(0, 0, 0, 0);
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24)); const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
if (diffDays === 1) { if (diffDays === 1) {
currentStreak++; currentStreak++;
tempStreak++; tempStreak++;
@@ -112,9 +112,7 @@ const calculateStreaks = (completions) => {
current.setHours(0, 0, 0, 0); current.setHours(0, 0, 0, 0);
const previous = new Date(sortedDates[i - 1]); const previous = new Date(sortedDates[i - 1]);
previous.setHours(0, 0, 0, 0); previous.setHours(0, 0, 0, 0);
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24)); const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
if (diffDays === 1) { if (diffDays === 1) {
tempStreak++; tempStreak++;
longestStreak = Math.max(longestStreak, tempStreak); longestStreak = Math.max(longestStreak, tempStreak);
@@ -124,9 +122,8 @@ const calculateStreaks = (completions) => {
} }
longestStreak = Math.max(longestStreak, currentStreak, 1); longestStreak = Math.max(longestStreak, currentStreak, 1);
return { currentStreak, longestStreak }; return { currentStreak, longestStreak };
}; }
export const exportData = () => { export const exportData = () => {
const habits = getHabits(); const habits = getHabits();

View File

@@ -39,4 +39,38 @@ export const getColorIntensity = (completions, dateStr) => {
export const getWeekdayLabel = (dayIndex) => { export const getWeekdayLabel = (dayIndex) => {
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return labels[dayIndex]; return labels[dayIndex];
}; };
// Returns array of frozen days (date strings) for a given completions array
export function getFrozenDays(completions) {
// Map: month string -> frozen day string
const frozenDays = [];
const completedSet = new Set(completions);
// Sort completions for easier lookup
const sorted = [...completions].sort();
// Track frozen per month
const frozenPerMonth = {};
// To find missed days, scan a range of dates
if (completions.length === 0) return [];
const minDate = new Date(sorted[0]);
const maxDate = new Date(sorted[sorted.length - 1]);
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
const dateStr = formatDate(d);
if (completedSet.has(dateStr)) continue; // skip completed days
// Check neighbors
const prevDate = new Date(d); prevDate.setDate(prevDate.getDate() - 1);
const nextDate = new Date(d); nextDate.setDate(nextDate.getDate() + 1);
const prevDateStr = formatDate(prevDate);
const nextDateStr = formatDate(nextDate);
// Only freeze if both neighbors are completed
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (
completedSet.has(prevDateStr) &&
completedSet.has(nextDateStr) &&
!frozenPerMonth[monthKey]
) {
frozenDays.push(dateStr);
frozenPerMonth[monthKey] = true;
}
}
return frozenDays;
}