Add counter animations

This commit is contained in:
2025-10-15 18:21:24 +02:00
parent cf9730086f
commit d273c976e8
7 changed files with 120 additions and 29 deletions

View File

@@ -0,0 +1,34 @@
import React, { useEffect, useRef, useState } from 'react';
/**
* AnimatedCounter
* Animates a number from 0 (or start) to the target value progressively.
* Usage: <AnimatedCounter value={targetNumber} duration={1000} />
*/
function AnimatedCounter({ value, duration = 1000, start = 0, format = v => v }) {
const [displayValue, setDisplayValue] = useState(start);
const rafRef = useRef();
const startRef = useRef(start);
const valueRef = useRef(value);
useEffect(() => {
startRef.current = displayValue;
valueRef.current = value;
let startTime;
function animate(ts) {
if (!startTime) startTime = ts;
const progress = Math.min((ts - startTime) / duration, 1);
const current = Math.round(startRef.current + (valueRef.current - startRef.current) * progress);
setDisplayValue(current);
if (progress < 1) {
rafRef.current = requestAnimationFrame(animate);
}
}
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, [value, duration]);
return <span>{format(displayValue)}</span>;
}
export default AnimatedCounter;

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { GitBranch } from 'lucide-react';
import { getCachedGitActivity } from '../lib/git';
import { formatDate, isToday, getWeekdayLabel } from '../lib/utils-habit';
import AnimatedCounter from './AnimatedCounter';
const GitActivityGrid = () => {
const [{ dailyCounts }, setData] = useState(() => getCachedGitActivity());
@@ -73,8 +74,13 @@ const GitActivityGrid = () => {
pointerEvents: 'none',
visibility: isFuture ? 'hidden' : 'visible',
}}
title={`${dateStr}${count} commits`}
/>
title={`${dateStr}`}
>
{/* Animated commit count for tooltip */}
<span style={{ display: 'none' }}>
<AnimatedCounter value={count} duration={600} /> commits
</span>
</div>
);
})}
</div>

View File

@@ -4,6 +4,7 @@ import { motion } from 'framer-motion';
import { ChevronRight, Flame } from 'lucide-react';
import { Button } from './ui/button';
import MiniGrid from './MiniGrid';
import AnimatedCounter from './AnimatedCounter';
const HabitCard = ({ habit, onUpdate }) => {
const navigate = useNavigate();
@@ -27,10 +28,10 @@ const HabitCard = ({ habit, onUpdate }) => {
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Flame className="w-4 h-4 text-orange-500" />
<span>{habit.currentStreak || 0} day streak</span>
<span><AnimatedCounter value={habit.currentStreak || 0} duration={800} /> day streak</span>
</div>
<span></span>
<span>Personal Record: {habit.longestStreak || 0} days</span>
<span>Personal Record: <AnimatedCounter value={habit.longestStreak || 0} duration={800} /> days</span>
</div>
</div>
<Button

View File

@@ -7,6 +7,7 @@ import { useToast } from '../components/ui/use-toast';
import HabitGrid from '../components/HabitGrid';
import DeleteHabitDialog from '../components/DeleteHabitDialog';
import { getHabit, deleteHabit } from '../lib/storage';
import AnimatedCounter from '../components/AnimatedCounter';
const HabitDetailPage = () => {
const { id } = useParams();
@@ -56,8 +57,52 @@ const HabitDetailPage = () => {
oldestDate = new Date(habit.completions.reduce((min, d) => d < min ? d : min, habit.completions[0]));
}
// Calculate streaks of consecutive days
function getFullOpacityStreaks(completions) {
if (!completions || completions.length === 0) return [];
const sorted = [...completions].sort();
let streaks = [];
let currentStreak = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const prev = new Date(sorted[i - 1]);
const curr = new Date(sorted[i]);
const diff = (curr - prev) / (1000 * 60 * 60 * 24);
if (diff === 1) {
currentStreak.push(sorted[i]);
} else {
if (currentStreak.length > 1) streaks.push([...currentStreak]);
currentStreak = [sorted[i]];
}
}
if (currentStreak.length > 1) streaks.push([...currentStreak]);
return streaks;
}
// Bonus: +2% per streak of 3+ full opacity days (capped at +10%)
const streaks = getFullOpacityStreaks(habit.completions);
const bonus = Math.min(streaks.filter(s => s.length >= 3).length * 2, 10);
const completionRate = habit.completions.length > 0
? Math.round((habit.completions.length / Math.max(1, Math.ceil((Date.now() - oldestDate.getTime()) / (1000 * 60 * 60 * 24)))) * 100)
? (() => {
// Overall rate
const totalDays = Math.max(1, Math.ceil((Date.now() - oldestDate.getTime()) / (1000 * 60 * 60 * 24)));
const overallRate = habit.completions.length / totalDays;
// Last 30 days rate
const today = new Date();
const lastMonthStart = new Date(today);
lastMonthStart.setDate(today.getDate() - 29);
const lastMonthDates = [];
for (let d = new Date(lastMonthStart); d <= today; d.setDate(d.getDate() + 1)) {
lastMonthDates.push(d.toISOString().slice(0, 10));
}
const lastMonthCompletions = habit.completions.filter(dateStr => lastMonthDates.includes(dateStr));
const lastMonthRate = lastMonthCompletions.length / 30;
// Weighted blend: 70% last month, 30% overall
const blendedRate = (lastMonthRate * 0.7) + (overallRate * 0.3);
return Math.round(blendedRate * 100 + bonus);
})()
: 0;
return (
@@ -117,7 +162,7 @@ const HabitDetailPage = () => {
</div>
<span className="text-sm font-medium text-muted-foreground">Current Streak</span>
</div>
<p className="text-3xl font-bold">{habit.currentStreak || 0}</p>
<p className="text-3xl font-bold"><AnimatedCounter value={habit.currentStreak || 0} duration={900} /></p>
<p className="text-xs text-muted-foreground mt-1">days in a row</p>
</div>
@@ -128,7 +173,7 @@ const HabitDetailPage = () => {
</div>
<span className="text-sm font-medium text-muted-foreground">Longest Streak</span>
</div>
<p className="text-3xl font-bold">{habit.longestStreak || 0}</p>
<p className="text-3xl font-bold"><AnimatedCounter value={habit.longestStreak || 0} duration={900} /></p>
<p className="text-xs text-muted-foreground mt-1">personal best</p>
</div>
@@ -139,7 +184,7 @@ const HabitDetailPage = () => {
</div>
<span className="text-sm font-medium text-muted-foreground">Consistency Score!</span>
</div>
<p className="text-3xl font-bold">{completionRate}%</p>
<p className="text-3xl font-bold"><AnimatedCounter value={completionRate} duration={900} format={v => `${v}%`} /></p>
<p className="text-xs text-muted-foreground mt-1">overall progress</p>
</div>
</motion.div>

View File

@@ -5,6 +5,7 @@ import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun } from 'lucide-r
import { Button } from '../components/ui/button';
import { useToast } from '../components/ui/use-toast';
import HabitCard from '../components/HabitCard';
import AnimatedCounter from '../components/AnimatedCounter';
import GitActivityGrid from '../components/GitActivityGrid';
import { getGitEnabled } from '../lib/git';
import { getHabits } from '../lib/storage';
@@ -111,7 +112,7 @@ const HomePage = () => {
<span className="text-xs font-medium text-muted-foreground">Total Streaks</span>
</div>
<p className="text-2xl font-bold">
{habits.reduce((sum, h) => sum + (h.currentStreak || 0), 0)}
<AnimatedCounter value={habits.reduce((sum, h) => sum + (h.currentStreak || 0), 0)} duration={900} />
</p>
</div>
</motion.div>