From 20a3eb86b5190de91240ebda3d6a6ad58192347d Mon Sep 17 00:00:00 2001 From: SankeerthNara Date: Mon, 20 Jul 2026 10:38:08 +0530 Subject: [PATCH 1/5] Add animated dashboard counters (closes #88) --- frontend/package-lock.json | 25 ++++ frontend/package.json | 1 + frontend/src/components/AnimatedCounter.tsx | 109 +++++++++++++++ frontend/src/components/StatisticCard.tsx | 85 +++++++++++ frontend/src/components/animated-counter.css | 23 +++ frontend/src/hooks/useAnimatedCounter.ts | 140 +++++++++++++++++++ frontend/src/main.tsx | 1 + frontend/src/pages/Dashboard.tsx | 24 ++-- frontend/src/pages/LandingPage.tsx | 1 - frontend/src/styles/index.css | 3 +- frontend/src/utils/numberFormatter.ts | 68 +++++++++ 11 files changed, 469 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/AnimatedCounter.tsx create mode 100644 frontend/src/components/StatisticCard.tsx create mode 100644 frontend/src/components/animated-counter.css create mode 100644 frontend/src/hooks/useAnimatedCounter.ts create mode 100644 frontend/src/utils/numberFormatter.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e5547a..6ca5148 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "framer-motion": "^12.40.0", "gsap": "^3.15.0", "lucide-react": "^1.17.0", + "motion": "^12.42.2", "react": "^19.2.6", "react-dom": "^19.2.6", "react-hook-form": "^7.77.0", @@ -4515,6 +4516,30 @@ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index bc48f63..c976000 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "framer-motion": "^12.40.0", "gsap": "^3.15.0", "lucide-react": "^1.17.0", + "motion": "^12.42.2", "react": "^19.2.6", "react-dom": "^19.2.6", "react-hook-form": "^7.77.0", diff --git a/frontend/src/components/AnimatedCounter.tsx b/frontend/src/components/AnimatedCounter.tsx new file mode 100644 index 0000000..7e0daed --- /dev/null +++ b/frontend/src/components/AnimatedCounter.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { useAnimatedCounter } from '../hooks/useAnimatedCounter'; +import { formatNumber, formatForScreenReader } from '../utils/numberFormatter'; +import type { FormatOptions } from '../utils/numberFormatter'; + +export interface AnimatedCounterProps extends FormatOptions { + /** The target numeric value to display. */ + value: number; + /** Animation duration in ms (800โ€“1500 recommended). Default: 1200. */ + duration?: number; + /** Show a skeleton placeholder instead of the number/animation. */ + loading?: boolean; + /** Extra class name for the wrapping . */ + className?: string; + /** Briefly highlight the counter when the value increases. Default: false. */ + highlightOnIncrease?: boolean; +} + +/** + * AnimatedCounter + * + * Lightweight, reusable counter that animates from 0 (first load) or the + * previous value (on updates) to a new target. Formatting and animation + * logic are delegated to numberFormatter.ts and useAnimatedCounter.ts โ€” + * this component only handles rendering and accessibility. + */ +export function AnimatedCounter({ + value, + duration = 1200, + loading = false, + className = '', + highlightOnIncrease = false, + ...formatOptions +}: AnimatedCounterProps) { + const { displayValue, isAnimating } = useAnimatedCounter(value, { + duration, + disableAnimation: loading, + }); + + const [highlight, setHighlight] = React.useState(false); + const prevValueRef = React.useRef(value); + + React.useEffect(() => { + if (highlightOnIncrease && value > prevValueRef.current) { + setHighlight(true); + const t = setTimeout(() => setHighlight(false), 600); + prevValueRef.current = value; + return () => clearTimeout(t); + } + prevValueRef.current = value; + }, [value, highlightOnIncrease]); + + if (loading) { + return ( +