mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-04-19 15:23:16 +00:00
Add counter animations
This commit is contained in:
38
package-lock.json
generated
38
package-lock.json
generated
@@ -40,11 +40,11 @@
|
|||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@vitejs/plugin-react": "^4.0.3",
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-react-app": "^7.0.1",
|
"eslint-config-react-app": "^7.0.1",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.4.18",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
"vite": "^7.1.9"
|
"vite": "^7.1.9"
|
||||||
}
|
}
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
@@ -689,6 +690,7 @@
|
|||||||
"integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
|
"integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.27.1"
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
},
|
},
|
||||||
@@ -1533,6 +1535,7 @@
|
|||||||
"integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
|
"integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-annotate-as-pure": "^7.27.1",
|
"@babel/helper-annotate-as-pure": "^7.27.1",
|
||||||
"@babel/helper-module-imports": "^7.27.1",
|
"@babel/helper-module-imports": "^7.27.1",
|
||||||
@@ -4115,6 +4118,7 @@
|
|||||||
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
|
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -4139,6 +4143,7 @@
|
|||||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -4150,6 +4155,7 @@
|
|||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
@@ -4235,6 +4241,7 @@
|
|||||||
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
|
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "5.62.0",
|
"@typescript-eslint/scope-manager": "5.62.0",
|
||||||
"@typescript-eslint/types": "5.62.0",
|
"@typescript-eslint/types": "5.62.0",
|
||||||
@@ -4474,6 +4481,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5018,6 +5026,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.9",
|
"baseline-browser-mapping": "^2.8.9",
|
||||||
"caniuse-lite": "^1.0.30001746",
|
"caniuse-lite": "^1.0.30001746",
|
||||||
@@ -5793,6 +5802,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -7342,6 +7352,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -8084,6 +8095,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -8235,6 +8247,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -8247,6 +8260,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -9272,6 +9286,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -9459,6 +9474,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -9842,6 +9858,7 @@
|
|||||||
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
|
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -9935,6 +9952,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -10164,20 +10182,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -41,11 +41,11 @@
|
|||||||
"@types/react": "^18.2.15",
|
"@types/react": "^18.2.15",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@vitejs/plugin-react": "^4.0.3",
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-react-app": "^7.0.1",
|
"eslint-config-react-app": "^7.0.1",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.4.18",
|
||||||
"terser": "^5.39.0",
|
"terser": "^5.39.0",
|
||||||
"vite": "^7.1.9"
|
"vite": "^7.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/components/AnimatedCounter.jsx
Normal file
34
src/components/AnimatedCounter.jsx
Normal 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;
|
||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { GitBranch } from 'lucide-react';
|
import { GitBranch } from 'lucide-react';
|
||||||
import { getCachedGitActivity } from '../lib/git';
|
import { getCachedGitActivity } from '../lib/git';
|
||||||
import { formatDate, isToday, getWeekdayLabel } from '../lib/utils-habit';
|
import { formatDate, isToday, getWeekdayLabel } from '../lib/utils-habit';
|
||||||
|
import AnimatedCounter from './AnimatedCounter';
|
||||||
|
|
||||||
const GitActivityGrid = () => {
|
const GitActivityGrid = () => {
|
||||||
const [{ dailyCounts }, setData] = useState(() => getCachedGitActivity());
|
const [{ dailyCounts }, setData] = useState(() => getCachedGitActivity());
|
||||||
@@ -73,8 +74,13 @@ const GitActivityGrid = () => {
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
visibility: isFuture ? 'hidden' : 'visible',
|
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>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { ChevronRight, Flame } from 'lucide-react';
|
import { ChevronRight, Flame } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import MiniGrid from './MiniGrid';
|
import MiniGrid from './MiniGrid';
|
||||||
|
import AnimatedCounter from './AnimatedCounter';
|
||||||
|
|
||||||
const HabitCard = ({ habit, onUpdate }) => {
|
const HabitCard = ({ habit, onUpdate }) => {
|
||||||
const navigate = useNavigate();
|
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-4 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Flame className="w-4 h-4 text-orange-500" />
|
<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>
|
</div>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>Personal Record: {habit.longestStreak || 0} days</span>
|
<span>Personal Record: <AnimatedCounter value={habit.longestStreak || 0} duration={800} /> days</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useToast } from '../components/ui/use-toast';
|
|||||||
import HabitGrid from '../components/HabitGrid';
|
import HabitGrid from '../components/HabitGrid';
|
||||||
import DeleteHabitDialog from '../components/DeleteHabitDialog';
|
import DeleteHabitDialog from '../components/DeleteHabitDialog';
|
||||||
import { getHabit, deleteHabit } from '../lib/storage';
|
import { getHabit, deleteHabit } from '../lib/storage';
|
||||||
|
import AnimatedCounter from '../components/AnimatedCounter';
|
||||||
|
|
||||||
const HabitDetailPage = () => {
|
const HabitDetailPage = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -56,8 +57,52 @@ const HabitDetailPage = () => {
|
|||||||
oldestDate = new Date(habit.completions.reduce((min, d) => d < min ? d : min, habit.completions[0]));
|
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
|
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;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,7 +162,7 @@ const HabitDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-muted-foreground">Current Streak</span>
|
<span className="text-sm font-medium text-muted-foreground">Current Streak</span>
|
||||||
</div>
|
</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>
|
<p className="text-xs text-muted-foreground mt-1">days in a row</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,7 +173,7 @@ const HabitDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-muted-foreground">Longest Streak</span>
|
<span className="text-sm font-medium text-muted-foreground">Longest Streak</span>
|
||||||
</div>
|
</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>
|
<p className="text-xs text-muted-foreground mt-1">personal best</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,7 +184,7 @@ const HabitDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-muted-foreground">Consistency Score!</span>
|
<span className="text-sm font-medium text-muted-foreground">Consistency Score!</span>
|
||||||
</div>
|
</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>
|
<p className="text-xs text-muted-foreground mt-1">overall progress</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun } from 'lucide-r
|
|||||||
import { Button } from '../components/ui/button';
|
import { Button } from '../components/ui/button';
|
||||||
import { useToast } from '../components/ui/use-toast';
|
import { useToast } from '../components/ui/use-toast';
|
||||||
import HabitCard from '../components/HabitCard';
|
import HabitCard from '../components/HabitCard';
|
||||||
|
import AnimatedCounter from '../components/AnimatedCounter';
|
||||||
import GitActivityGrid from '../components/GitActivityGrid';
|
import GitActivityGrid from '../components/GitActivityGrid';
|
||||||
import { getGitEnabled } from '../lib/git';
|
import { getGitEnabled } from '../lib/git';
|
||||||
import { getHabits } from '../lib/storage';
|
import { getHabits } from '../lib/storage';
|
||||||
@@ -111,7 +112,7 @@ const HomePage = () => {
|
|||||||
<span className="text-xs font-medium text-muted-foreground">Total Streaks</span>
|
<span className="text-xs font-medium text-muted-foreground">Total Streaks</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user