mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-04-19 15:23:16 +00:00
Compare commits
16 Commits
1.0.0
...
28cedf9421
| Author | SHA1 | Date | |
|---|---|---|---|
| 28cedf9421 | |||
| f298eb4573 | |||
| b02c9c5c41 | |||
| 445f27a939 | |||
| 76111ecd2d | |||
| d273c976e8 | |||
| cf9730086f | |||
| 14ac268165 | |||
| 173c63d907 | |||
| 9041c7db94 | |||
| f830e4fccf | |||
|
|
6dbb690e3d | ||
| af1f8a8ac0 | |||
| b6a277cabf | |||
| bb64bacd1e | |||
| 7b513bca28 |
@@ -20,7 +20,7 @@ A modern, grid-based habit tracker app inspired by GitHub contribution graphs. T
|
||||
### Installation
|
||||
```powershell
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/habitgrid.git
|
||||
git clone https://github.com/nagaoo0/habitgrid.git
|
||||
cd habitgrid
|
||||
|
||||
# Install dependencies
|
||||
|
||||
164
package-lock.json
generated
164
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "habitgrid",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.3",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
@@ -22,6 +23,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.0.0",
|
||||
"framer-motion": "^10.16.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lucide-react": "^0.285.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -39,11 +41,11 @@
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.39.0",
|
||||
"vite": "^7.1.9"
|
||||
}
|
||||
@@ -91,6 +93,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -688,6 +691,7 @@
|
||||
"integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
@@ -1532,6 +1536,7 @@
|
||||
"integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.27.1",
|
||||
"@babel/helper-module-imports": "^7.27.1",
|
||||
@@ -2007,7 +2012,6 @@
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2621,6 +2625,23 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hello-pangea/dnd": {
|
||||
"version": "18.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz",
|
||||
"integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.7",
|
||||
"css-box-model": "^1.2.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -4114,6 +4135,7 @@
|
||||
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4138,6 +4160,7 @@
|
||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -4149,6 +4172,7 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -4160,6 +4184,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "5.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
|
||||
@@ -4234,6 +4264,7 @@
|
||||
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "5.62.0",
|
||||
"@typescript-eslint/types": "5.62.0",
|
||||
@@ -4473,6 +4504,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4943,6 +4975,15 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.16",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
|
||||
@@ -5008,6 +5049,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -5296,6 +5338,24 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-box-model": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tiny-invariant": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -5774,6 +5834,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -6762,6 +6823,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -7310,6 +7384,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -8052,6 +8127,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8198,11 +8274,18 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -8215,6 +8298,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -8250,6 +8334,29 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -8391,6 +8498,13 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -9240,6 +9354,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -9349,6 +9464,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
@@ -9377,6 +9501,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -9418,6 +9548,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9786,12 +9917,22 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
|
||||
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -9885,6 +10026,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10114,20 +10256,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview --host :: --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.3",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
@@ -23,6 +24,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.0.0",
|
||||
"framer-motion": "^10.16.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lucide-react": "^0.285.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -40,11 +42,11 @@
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.39.0",
|
||||
"vite": "^7.1.9"
|
||||
}
|
||||
|
||||
102
public/encouragements.json
Normal file
102
public/encouragements.json
Normal file
@@ -0,0 +1,102 @@
|
||||
[
|
||||
"Great job! Keep going!",
|
||||
"You're on fire! 🔥",
|
||||
"Consistency is key!",
|
||||
"Amazing streak!",
|
||||
"You crushed it today!",
|
||||
"Small steps, big results!",
|
||||
"Habit hero!",
|
||||
"Progress, not perfection!",
|
||||
"Every dot counts!",
|
||||
"Keep up the momentum!",
|
||||
"You’re building something awesome!",
|
||||
"One step closer to your goal!",
|
||||
"You’re unstoppable!",
|
||||
"Keep the streak alive!",
|
||||
"You’re making it happen!",
|
||||
"Your effort is inspiring!",
|
||||
"You’re a streak superstar!",
|
||||
"Every day matters!",
|
||||
"You’re a habit legend!",
|
||||
"You’re doing fantastic!",
|
||||
"Keep shining!",
|
||||
"You’re a role model!",
|
||||
"You’re a champion!",
|
||||
"You’re making progress!",
|
||||
"You’re a winner!",
|
||||
"You’re a streak master!",
|
||||
"You’re a habit machine!",
|
||||
"You’re a streak builder!",
|
||||
"You’re a streak star!",
|
||||
"You’re a streak hero!",
|
||||
"You’re a streak ninja!",
|
||||
"You’re a streak wizard!",
|
||||
"You’re a streak warrior!",
|
||||
"You’re a streak explorer!",
|
||||
"You’re a streak adventurer!",
|
||||
"You’re a streak conqueror!",
|
||||
"You’re a streak champion!",
|
||||
"You’re a streak genius!",
|
||||
"You’re a streak guru!",
|
||||
"You’re a streak expert!",
|
||||
"You’re a streak pro!",
|
||||
"You’re a streak veteran!",
|
||||
"You’re a streak rookie!",
|
||||
"You’re a streak all-star!",
|
||||
"You’re a streak MVP!",
|
||||
"You’re a streak superstar!",
|
||||
"You’re a streak rockstar!",
|
||||
"You’re a streak dynamo!",
|
||||
"You’re a streak powerhouse!",
|
||||
"You’re a streak inspiration!",
|
||||
"You’re a streak motivator!",
|
||||
"You’re a streak leader!",
|
||||
"You’re a streak innovator!",
|
||||
"You’re a streak creator!",
|
||||
"You’re a streak builder!",
|
||||
"You’re a streak achiever!",
|
||||
"You’re a streak doer!",
|
||||
"You’re a streak finisher!",
|
||||
"You’re a streak starter!",
|
||||
"You’re a streak closer!",
|
||||
"You’re a streak winner!",
|
||||
"You’re a streak believer!",
|
||||
"You’re a streak dreamer!",
|
||||
"You’re a streak thinker!",
|
||||
"You’re a streak planner!",
|
||||
"You’re a streak organizer!",
|
||||
"You’re a streak strategist!",
|
||||
"You’re a streak tactician!",
|
||||
"You’re a streak visionary!",
|
||||
"You’re a streak optimist!",
|
||||
"You’re a streak realist!",
|
||||
"You’re a streak enthusiast!",
|
||||
"You’re a streak supporter!",
|
||||
"You’re a streak encourager!",
|
||||
"You’re a streak helper!",
|
||||
"You’re a streak friend!",
|
||||
"You’re a streak teammate!",
|
||||
"You’re a streak partner!",
|
||||
"You’re a streak ally!",
|
||||
"You’re a streak companion!",
|
||||
"You’re a streak buddy!",
|
||||
"You’re a streak pal!",
|
||||
"You’re a streak mate!",
|
||||
"You’re a streak peer!",
|
||||
"You’re a streak colleague!",
|
||||
"You’re a streak associate!",
|
||||
"You’re a streak collaborator!",
|
||||
"You’re a streak contributor!",
|
||||
"You’re a streak participant!",
|
||||
"You’re a streak member!",
|
||||
"You’re a streak player!",
|
||||
"You’re a streak contender!",
|
||||
"You’re a streak competitor!",
|
||||
"You’re a streak challenger!",
|
||||
"You’re a streak rival!",
|
||||
"You’re a streak victor!",
|
||||
"You’re a streak survivor!",
|
||||
"You’re a streak thriver!",
|
||||
"You’re a streak overcomer!",
|
||||
"You’re a streak achiever!"
|
||||
]
|
||||
54
src/components/AnimatedCounter.jsx
Normal file
54
src/components/AnimatedCounter.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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 [animating, setAnimating] = useState(false);
|
||||
const [direction, setDirection] = useState('up');
|
||||
const rafRef = useRef();
|
||||
const startRef = useRef(start);
|
||||
const valueRef = useRef(value);
|
||||
const prevValueRef = useRef(start);
|
||||
|
||||
useEffect(() => {
|
||||
startRef.current = displayValue;
|
||||
valueRef.current = value;
|
||||
let startTime;
|
||||
setAnimating(true);
|
||||
setDirection(value > prevValueRef.current ? 'up' : value < prevValueRef.current ? 'down' : direction);
|
||||
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);
|
||||
} else {
|
||||
setAnimating(false);
|
||||
prevValueRef.current = current;
|
||||
}
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [value, duration]);
|
||||
|
||||
// Animation styles
|
||||
const styles = {
|
||||
display: 'inline-block',
|
||||
transition: 'transform 0.4s cubic-bezier(.68,-0.55,.27,1.55), color 0.4s',
|
||||
transform: animating ? 'scale(1.25) rotate(-5deg)' : 'scale(1)',
|
||||
color: animating ? (direction === 'up' ? '#22c55e' : direction === 'down' ? '#ef4444' : undefined) : undefined,
|
||||
fontWeight: animating ? 700 : undefined,
|
||||
filter: animating ? (direction === 'up' ? 'drop-shadow(0 0 8px #22c55e88)' : direction === 'down' ? 'drop-shadow(0 0 8px #ef444488)' : undefined) : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<span style={styles}>{format(displayValue)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnimatedCounter;
|
||||
102
src/components/GitActivityGrid.jsx
Normal file
102
src/components/GitActivityGrid.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
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());
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const today = new Date();
|
||||
const todayDay = today.getDay();
|
||||
const daysSinceMonday = (todayDay + 6) % 7;
|
||||
const mondayThisWeek = new Date(today);
|
||||
mondayThisWeek.setDate(today.getDate() - daysSinceMonday);
|
||||
const weeksArray = [];
|
||||
const totalWeeks = 52;
|
||||
for (let week = totalWeeks - 1; week >= 0; week--) {
|
||||
const weekDays = [];
|
||||
const monday = new Date(mondayThisWeek);
|
||||
monday.setDate(mondayThisWeek.getDate() - week * 7);
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const date = new Date(monday);
|
||||
date.setDate(monday.getDate() + day);
|
||||
weekDays.push(date);
|
||||
}
|
||||
weeksArray.push(weekDays);
|
||||
}
|
||||
return weeksArray;
|
||||
}, []);
|
||||
|
||||
const getOpacity = (count) => {
|
||||
if (!count) return 0.15;
|
||||
if (count < 2) return 0.35;
|
||||
if (count < 5) return 0.6;
|
||||
if (count < 10) return 0.8;
|
||||
return 1;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Display current cache only; syncing is done from Settings
|
||||
setData(getCachedGitActivity());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-4 pt-0 shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col items-center">
|
||||
<div className="mb-2 text-center w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<GitBranch className="w-5 h-5" />
|
||||
<h2 className="text-lg font-semibold">Git Activity</h2>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="overflow-x-auto grid-scroll mt-2 w-full flex justify-center">
|
||||
<div className="inline-flex gap-1 mb-4">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-1">
|
||||
<div className="h-3 text-xs text-muted-foreground text-center">
|
||||
{weekIndex % 4 === 0 && week[0].toLocaleDateString('en-US', { month: 'short' })}
|
||||
</div>
|
||||
{week.map((date, dayIndex) => {
|
||||
const dateStr = formatDate(date);
|
||||
const count = dailyCounts?.[dateStr] || 0;
|
||||
const isTodayCell = isToday(date);
|
||||
const isFuture = date > new Date();
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className="habit-cell w-3 h-3 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: '#3fb950',
|
||||
opacity: isFuture ? 0 : getOpacity(count),
|
||||
border: isTodayCell ? `2px solid #3fb950` : `1px solid #3fb95020`,
|
||||
pointerEvents: 'none',
|
||||
visibility: isFuture ? 'hidden' : 'visible',
|
||||
}}
|
||||
title={`${dateStr} • `}
|
||||
>
|
||||
{/* Animated commit count for tooltip */}
|
||||
<span style={{ display: 'none' }}>
|
||||
<AnimatedCounter value={count} duration={600} /> commits
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-1 ml-2">
|
||||
<div className="h-3" />
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<div key={day} className="h-3 flex items-center justify-end text-xs text-muted-foreground pr-0">
|
||||
{getWeekdayLabel(day)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitActivityGrid;
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
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';
|
||||
|
||||
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
const frozenDays = getFrozenDays(habit.completions);
|
||||
const weeks = useMemo(() => {
|
||||
const today = new Date();
|
||||
// Find the Monday of the current week
|
||||
@@ -29,35 +30,30 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
return weeksArray;
|
||||
}, [fullView]);
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to the rightmost (most recent) week on mount
|
||||
const gridScroll = document.querySelector('.grid-scroll');
|
||||
if (gridScroll) {
|
||||
gridScroll.scrollLeft = gridScroll.scrollWidth;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCellClick = (date) => {
|
||||
toggleCompletion(habit.id, formatDate(date));
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold mb-1">Activity Calendar</h2>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-4 pt-0 shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col items-center">
|
||||
<div className="mb-2 text-center w-full">
|
||||
<h2 className="text-lg font-semibold mb-1 mt-4">Activity Calendar</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tap any day to mark it as complete
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto grid-scroll">
|
||||
<div className="inline-flex gap-1">
|
||||
{/* Weekday labels: Monday (top) to Sunday (bottom) */}
|
||||
<div className="flex flex-col gap-1 mr-2">
|
||||
<div className="h-3" />
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="h-3 flex items-center justify-end text-xs text-muted-foreground pr-1"
|
||||
>
|
||||
{getWeekdayLabel(day)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto grid-scroll mt-2 w-full flex justify-center">
|
||||
<div className="inline-flex gap-1 mb-4">
|
||||
{/* Grid: Monday (top) to Sunday (bottom) */}
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-1">
|
||||
@@ -72,13 +68,14 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
|
||||
const isTodayCell = isToday(date);
|
||||
const isFuture = date > new Date();
|
||||
const isFrozen = frozenDays.includes(dateStr);
|
||||
return (
|
||||
<motion.button
|
||||
key={dayIndex}
|
||||
whileHover={{ scale: 1.15 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
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={{
|
||||
backgroundColor: isCompleted ? habit.color : 'transparent',
|
||||
opacity: isFuture ? 0 : (isCompleted ? 0.3 + (intensity * 0.7) : 1),
|
||||
@@ -86,12 +83,29 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
pointerEvents: isFuture ? 'none' : 'auto',
|
||||
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>
|
||||
))}
|
||||
{/* Weekday labels: Monday (top) to Sunday (bottom) */}
|
||||
<div className="flex flex-col gap-1 ml-2">
|
||||
{/* Spacer matches month label height to align rows */}
|
||||
<div className="h-3" />
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="h-3 flex items-center justify-end text-xs text-muted-foreground pr-0"
|
||||
>
|
||||
{getWeekdayLabel(day)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
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 { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
|
||||
import { getFrozenDays } from '../lib/utils-habit';
|
||||
import { toggleCompletion } from '../lib/storage';
|
||||
import { toast } from './ui/use-toast';
|
||||
|
||||
const MiniGrid = ({ habit, onUpdate }) => {
|
||||
const today = new Date();
|
||||
// Show fewer days on mobile for better aspect ratio
|
||||
const isMobile = window.innerWidth < 640; // Tailwind 'sm' breakpoint
|
||||
const numDays = isMobile ? 14 : 28;
|
||||
// Dynamically calculate number of days that fit based on window width and cell size, max 28
|
||||
const CELL_SIZE = 42; // px, matches w-8 h-8
|
||||
const PADDING = 16; // px, for grid padding/margin
|
||||
const numDays = Math.min(28, Math.max(5, Math.floor((window.innerWidth - PADDING) / CELL_SIZE)));
|
||||
const days = [];
|
||||
const scrollRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
|
||||
}
|
||||
}, [numDays, habit.completions]);
|
||||
|
||||
for (let i = numDays - 1; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
@@ -16,27 +40,63 @@ const MiniGrid = ({ habit, onUpdate }) => {
|
||||
days.push(date);
|
||||
}
|
||||
|
||||
const handleCellClick = (e, date) => {
|
||||
const handleCellClick = async (e, date) => {
|
||||
e.stopPropagation();
|
||||
toggleCompletion(habit.id, formatDate(date));
|
||||
const dateStr = formatDate(date);
|
||||
const isTodayCell = isToday(date);
|
||||
const wasCompleted = habit.completions.includes(dateStr);
|
||||
toggleCompletion(habit.id, dateStr);
|
||||
onUpdate();
|
||||
// Only show encouragement toast if validating (adding) today's dot
|
||||
if (isTodayCell && !wasCompleted) {
|
||||
try {
|
||||
const res = await fetch('/encouragements.json');
|
||||
const messages = await res.json();
|
||||
const msg = messages[Math.floor(Math.random() * messages.length)];
|
||||
toast({
|
||||
title: '🎉 Keep Going!',
|
||||
description: msg,
|
||||
duration: 2500,
|
||||
});
|
||||
} catch (err) {
|
||||
// fallback message
|
||||
toast({
|
||||
title: '🎉 Keep Going!',
|
||||
description: 'Great job! Keep up the streak!',
|
||||
duration: 2500,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 overflow-x-auto grid-scroll pb-2">
|
||||
{days.map((date, index) => {
|
||||
<div ref={scrollRef} className="flex gap-1 overflow-x-auto grid-scroll pt-4 pb-2">
|
||||
{(() => {
|
||||
const frozenDays = getFrozenDays(habit.completions);
|
||||
return days.map((date, index) => {
|
||||
const dateStr = formatDate(date);
|
||||
const isCompleted = habit.completions.includes(dateStr);
|
||||
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
|
||||
const isTodayCell = isToday(date);
|
||||
|
||||
const dayLetter = date.toLocaleDateString('en-US', { weekday: 'short' })[0];
|
||||
// Check if previous day was completed and next day is today
|
||||
let isFrozen = frozenDays.includes(dateStr);
|
||||
if (!isCompleted && !isTodayCell && index < days.length - 1 && index > 0) {
|
||||
const prevDateStr = formatDate(days[index - 1]);
|
||||
const nextDateStr = formatDate(days[index + 1]);
|
||||
const prevCompleted = habit.completions.includes(prevDateStr);
|
||||
const nextIsToday = isToday(days[index + 1]);
|
||||
if (prevCompleted && nextIsToday) {
|
||||
isFrozen = true;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div key={index} className="flex flex-col items-center">
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ scale: 0.9 }}
|
||||
whileTap={{ scale: 0.5 }}
|
||||
onClick={(e) => handleCellClick(e, date)}
|
||||
className="habit-cell flex w-8 h-8 rounded-2xl transition-all"
|
||||
className={`habit-cell flex w-8 h-8 transition-all items-center justify-center ${isTodayCell ? 'rounded-md' : 'rounded-2xl'}`}
|
||||
style={{
|
||||
backgroundColor: isCompleted
|
||||
? habit.color
|
||||
@@ -47,9 +107,68 @@ const MiniGrid = ({ habit, onUpdate }) => {
|
||||
: `1px solid ${habit.color}20`,
|
||||
}}
|
||||
title={dateStr}
|
||||
>
|
||||
{isFrozen && (
|
||||
<motion.span
|
||||
role="img"
|
||||
aria-label="Frozen"
|
||||
style={{ fontSize: '1.2em', filter: 'drop-shadow(0 0 8px #3b82f6)' }}
|
||||
initial={{ opacity: 0, y: -40, scale: 1.2 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: [ -40, 8, -4, 0 ],
|
||||
scale: [ 1.2, 0.9, 1.05, 1 ],
|
||||
rotate: [ 0, -10, 10, -5, 0 ]
|
||||
}}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
>
|
||||
❄️
|
||||
</motion.span>
|
||||
)}
|
||||
{/* Flame icon for full streak days */}
|
||||
{isCompleted && intensity >= 1 && (
|
||||
<motion.span
|
||||
className="relative flex items-center justify-center w-full h-full"
|
||||
initial={{ opacity: 0, scale: 0.2, rotate: -45 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1.3,
|
||||
rotate: [0, 10, -10, 0],
|
||||
transition: {
|
||||
duration: 0.7,
|
||||
delay: (index / numDays) * 0.7,
|
||||
type: 'spring',
|
||||
bounce: 0.7,
|
||||
stiffness: 180,
|
||||
onComplete: () => {},
|
||||
}
|
||||
}}
|
||||
whileHover={{ scale: 1.5, rotate: 10 }}
|
||||
whileTap={{ scale: 1.2, rotate: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 12, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
duration: 2,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}
|
||||
>
|
||||
<Flame
|
||||
className="w-3 h-3 sm:w-4 sm:h-4 md:w-5 md:h-5 drop-shadow-lg"
|
||||
style={{ color: lightenColor(habit.color, 0.4), filter: 'brightness(1.3) drop-shadow(0 0 6px white)' }}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.button>
|
||||
<span className="text-[10px] text-muted-foreground mt-1">{dayLetter}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,9 @@ const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
'fixed z-[100] flex max-h-screen w-full flex-col-reverse p-4',
|
||||
'sm:bottom-4 sm:right-4 sm:top-auto sm:left-auto sm:flex-col md:max-w-[420px]',
|
||||
'bottom-4 left-1/2 transform -translate-x-1/2 sm:transform-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -19,7 +21,7 @@ const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full',
|
||||
'data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-2xl border-0 p-6 pr-8 shadow-2xl transition-all bg-white/80 backdrop-blur-lg ring-2 ring-green-300/40 drop-shadow-xl scale-95 animate-toast-in data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -75,18 +77,22 @@ ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
className={cn('text-lg font-bold flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<span className="animate-float inline-block">🎊</span> {props.children}
|
||||
</ToastPrimitives.Title>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
className={cn('text-base opacity-95 font-medium flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<span className="animate-float inline-block">✨</span> {props.children}
|
||||
</ToastPrimitives.Description>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
|
||||
@@ -95,3 +95,20 @@
|
||||
.dark .grid-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Toast custom animations */
|
||||
@keyframes toast-in {
|
||||
0% { transform: scale(0.7) translateY(40px); opacity: 0; }
|
||||
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
.animate-toast-in {
|
||||
animation: toast-in 0.5s cubic-bezier(.68,-0.55,.27,1.55);
|
||||
}
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
.animate-float {
|
||||
animation: float 2s infinite ease-in-out;
|
||||
}
|
||||
289
src/lib/git.js
Normal file
289
src/lib/git.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// Git integrations library: manages sources, token encryption, fetching events, and caching
|
||||
import { formatDate } from './utils-habit';
|
||||
|
||||
const GIT_INT_KEY = 'habitgrid_git_integrations';
|
||||
const GIT_CACHE_KEY = 'habitgrid_git_cache';
|
||||
const GIT_ENABLED_KEY = 'habitgrid_git_enabled';
|
||||
const GIT_KEY_MATERIAL = 'habitgrid_git_k';
|
||||
|
||||
// --- Minimal AES-GCM encryption helpers using Web Crypto ---
|
||||
async function getCryptoKey() {
|
||||
try {
|
||||
let raw = localStorage.getItem(GIT_KEY_MATERIAL);
|
||||
if (!raw) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
raw = btoa(String.fromCharCode(...bytes));
|
||||
localStorage.setItem(GIT_KEY_MATERIAL, raw);
|
||||
}
|
||||
const buf = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
|
||||
return await crypto.subtle.importKey('raw', buf, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptToken(token) {
|
||||
const key = await getCryptoKey();
|
||||
if (!key) return token; // fallback
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = new TextEncoder().encode(token);
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc));
|
||||
return `${btoa(String.fromCharCode(...iv))}:${btoa(String.fromCharCode(...ct))}`;
|
||||
}
|
||||
|
||||
export async function decryptToken(tokenEnc) {
|
||||
const key = await getCryptoKey();
|
||||
if (!key || !tokenEnc.includes(':')) return tokenEnc || '';
|
||||
const [ivB64, ctB64] = tokenEnc.split(':');
|
||||
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
|
||||
const ct = Uint8Array.from(atob(ctB64), c => c.charCodeAt(0));
|
||||
try {
|
||||
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
||||
return new TextDecoder().decode(pt);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integrations CRUD ---
|
||||
export function getGitEnabled() {
|
||||
return localStorage.getItem(GIT_ENABLED_KEY) === 'true';
|
||||
}
|
||||
export function setGitEnabled(enabled) {
|
||||
localStorage.setItem(GIT_ENABLED_KEY, enabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
export function getIntegrations() {
|
||||
try {
|
||||
const raw = localStorage.getItem(GIT_INT_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addIntegration({ provider, baseUrl, username, token }) {
|
||||
const integrations = getIntegrations();
|
||||
const tokenEnc = await encryptToken(token);
|
||||
const id = Date.now().toString();
|
||||
integrations.push({ id, provider, baseUrl, username, tokenEnc, createdAt: new Date().toISOString() });
|
||||
localStorage.setItem(GIT_INT_KEY, JSON.stringify(integrations));
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeIntegration(id) {
|
||||
const integrations = getIntegrations().filter(x => x.id !== id);
|
||||
localStorage.setItem(GIT_INT_KEY, JSON.stringify(integrations));
|
||||
}
|
||||
|
||||
// --- Caching ---
|
||||
function getCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(GIT_CACHE_KEY);
|
||||
return raw ? JSON.parse(raw) : { lastSync: null, dailyCounts: {} };
|
||||
} catch {
|
||||
return { lastSync: null, dailyCounts: {} };
|
||||
}
|
||||
}
|
||||
function setCache(cache) {
|
||||
localStorage.setItem(GIT_CACHE_KEY, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
export function getCachedGitActivity() {
|
||||
return getCache();
|
||||
}
|
||||
|
||||
// --- Fetch events per provider ---
|
||||
function isOlderThan(dateStr, days) {
|
||||
const d = new Date(dateStr);
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return d < cutoff;
|
||||
}
|
||||
|
||||
function deriveGitHubGraphQLEndpoint(baseUrl) {
|
||||
try {
|
||||
const u = new URL(baseUrl || 'https://api.github.com');
|
||||
// If /api/v3 -> likely /api/graphql
|
||||
if (u.pathname.includes('/api/v3')) {
|
||||
return `${u.origin}/api/graphql`;
|
||||
}
|
||||
// If ends with /api -> /graphql under same base
|
||||
if (u.pathname.endsWith('/api')) {
|
||||
return `${u.origin}/graphql`;
|
||||
}
|
||||
// Default: append /graphql
|
||||
return `${baseUrl.replace(/\/$/, '')}/graphql`;
|
||||
} catch {
|
||||
return 'https://api.github.com/graphql';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitHubGraphQL({ baseUrl = 'https://api.github.com', username, token }, days = 365) {
|
||||
if (!token) return null; // require token for GraphQL
|
||||
const endpoint = deriveGitHubGraphQLEndpoint(baseUrl);
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - days + 1);
|
||||
const query = `query($login:String!, $from:DateTime!, $to:DateTime!) {
|
||||
user(login:$login) {
|
||||
contributionsCollection(from:$from, to:$to) {
|
||||
contributionCalendar { weeks { contributionDays { date contributionCount } } }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables: { login: username, from: from.toISOString(), to: to.toISOString() } }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
const daysArr = json?.data?.user?.contributionsCollection?.contributionCalendar?.weeks?.flatMap(w => w.contributionDays) || [];
|
||||
const counts = {};
|
||||
for (const d of daysArr) {
|
||||
if (d?.date) counts[d.date] = (counts[d.date] || 0) + (d.contributionCount || 0);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function fetchGitHubEvents({ baseUrl = 'https://api.github.com', username, token }, days = 365) {
|
||||
// Prefer GraphQL for full-year coverage if token present
|
||||
try {
|
||||
if (token) {
|
||||
const graphCounts = await fetchGitHubGraphQL({ baseUrl, username, token }, days);
|
||||
if (graphCounts) return graphCounts;
|
||||
}
|
||||
} catch {}
|
||||
const headers = { 'Accept': 'application/vnd.github+json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const counts = {};
|
||||
for (let page = 1; page <= 3; page++) {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/users/${encodeURIComponent(username)}/events?per_page=100&page=${page}`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) break;
|
||||
const events = await res.json();
|
||||
if (!Array.isArray(events) || events.length === 0) break;
|
||||
for (const ev of events) {
|
||||
if (!ev || !ev.created_at) continue;
|
||||
if (isOlderThan(ev.created_at, days)) { page = 999; break; }
|
||||
if (ev.type === 'PushEvent') {
|
||||
const c = ev.payload?.size || 1;
|
||||
const day = formatDate(new Date(ev.created_at));
|
||||
counts[day] = (counts[day] || 0) + c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function fetchGiteaLike({ baseUrl, username, token }, days = 365) {
|
||||
let authMode = token ? 'token' : null; // 'token' | 'bearer' | null
|
||||
const counts = {};
|
||||
for (let page = 1; page <= 8; page++) {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/api/v1/users/${encodeURIComponent(username)}/events?limit=50&page=${page}`;
|
||||
let res;
|
||||
try {
|
||||
const headers = { 'Accept': 'application/json' };
|
||||
if (token) headers['Authorization'] = authMode === 'bearer' ? `Bearer ${token}` : `token ${token}`;
|
||||
res = await fetch(url, { headers });
|
||||
} catch (e) {
|
||||
break; // likely CORS/network
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Retry once with alternate auth scheme if unauthorized/forbidden
|
||||
if ((res.status === 401 || res.status === 403) && token && authMode === 'token') {
|
||||
authMode = 'bearer';
|
||||
page--; // retry same page with Bearer
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
let events;
|
||||
try {
|
||||
events = await res.json();
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (!Array.isArray(events) || events.length === 0) break;
|
||||
for (const ev of events) {
|
||||
const created = ev?.created || ev?.created_at || ev?.timestamp;
|
||||
if (!created) continue;
|
||||
if (isOlderThan(created, days)) { page = 999; break; }
|
||||
const actionRaw = (ev?.op_type || ev?.action || ev?.type || '').toString().toLowerCase();
|
||||
const isPushLike = actionRaw.includes('push') || actionRaw.includes('commit');
|
||||
if (!isPushLike) continue;
|
||||
const day = formatDate(new Date(created));
|
||||
// Try to take number of commits if provided
|
||||
let inc = 1;
|
||||
if (typeof ev?.commits_count === 'number') inc = ev.commits_count;
|
||||
else if (typeof ev?.payload?.num_commits === 'number') inc = ev.payload.num_commits;
|
||||
else if (Array.isArray(ev?.payload?.commits)) inc = ev.payload.commits.length || 1;
|
||||
counts[day] = (counts[day] || 0) + (inc || 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function fetchGitLabEvents({ baseUrl = 'https://gitlab.com', token }, days = 365) {
|
||||
const headers = { 'Accept': 'application/json', 'PRIVATE-TOKEN': token };
|
||||
const counts = {};
|
||||
for (let page = 1; page <= 5; page++) {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/api/v4/events?per_page=100&page=${page}&action=push`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) break;
|
||||
const events = await res.json();
|
||||
if (!Array.isArray(events) || events.length === 0) break;
|
||||
for (const ev of events) {
|
||||
const created = ev?.created_at;
|
||||
if (!created) continue;
|
||||
if (isOlderThan(created, days)) { page = 999; break; }
|
||||
const day = formatDate(new Date(created));
|
||||
counts[day] = (counts[day] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export async function fetchAllGitActivity({ force = false, days = 365 } = {}) {
|
||||
const { lastSync, dailyCounts } = getCache();
|
||||
const last = lastSync ? new Date(lastSync) : null;
|
||||
const now = new Date();
|
||||
const withinDay = last && (now - last) < 24 * 60 * 60 * 1000;
|
||||
if (!force && withinDay && dailyCounts) {
|
||||
return { dailyCounts, lastSync };
|
||||
}
|
||||
|
||||
const integrations = getIntegrations();
|
||||
const perSource = [];
|
||||
for (const src of integrations) {
|
||||
const token = await decryptToken(src.tokenEnc);
|
||||
const baseUrl = src.baseUrl || (src.provider === 'gitea' || src.provider === 'forgejo' ? 'https://gitea.com' : undefined);
|
||||
const info = { baseUrl, username: src.username, token };
|
||||
try {
|
||||
if (src.provider === 'github') {
|
||||
perSource.push(await fetchGitHubEvents(info, days));
|
||||
} else if (src.provider === 'gitlab') {
|
||||
perSource.push(await fetchGitLabEvents(info, days));
|
||||
} else if (src.provider === 'gitea' || src.provider === 'forgejo' || src.provider === 'custom') {
|
||||
perSource.push(await fetchGiteaLike(info, days));
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue other sources
|
||||
console.warn('Git fetch failed for', src.provider, e);
|
||||
}
|
||||
}
|
||||
// Merge
|
||||
const merged = {};
|
||||
for (const m of perSource) {
|
||||
for (const [day, cnt] of Object.entries(m)) {
|
||||
merged[day] = (merged[day] || 0) + cnt;
|
||||
}
|
||||
}
|
||||
const updated = { lastSync: new Date().toISOString(), dailyCounts: merged };
|
||||
setCache(updated);
|
||||
return updated;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const saveHabit = (habit) => {
|
||||
const newHabit = {
|
||||
...habit,
|
||||
id: Date.now().toString(),
|
||||
sortOrder: habits.length,
|
||||
};
|
||||
habits.push(newHabit);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||
@@ -65,12 +66,15 @@ export const toggleCompletion = (habitId, dateStr) => {
|
||||
});
|
||||
};
|
||||
|
||||
import { getFrozenDays } from './utils-habit.js';
|
||||
const calculateStreaks = (completions) => {
|
||||
if (completions.length === 0) {
|
||||
return { currentStreak: 0, longestStreak: 0 };
|
||||
}
|
||||
|
||||
const sortedDates = completions
|
||||
// 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);
|
||||
|
||||
@@ -88,15 +92,12 @@ const calculateStreaks = (completions) => {
|
||||
|
||||
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++;
|
||||
@@ -112,9 +113,7 @@ const calculateStreaks = (completions) => {
|
||||
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);
|
||||
@@ -124,9 +123,8 @@ const calculateStreaks = (completions) => {
|
||||
}
|
||||
|
||||
longestStreak = Math.max(longestStreak, currentStreak, 1);
|
||||
|
||||
return { currentStreak, longestStreak };
|
||||
};
|
||||
}
|
||||
|
||||
export const exportData = () => {
|
||||
const habits = getHabits();
|
||||
|
||||
@@ -40,3 +40,37 @@ export const getWeekdayLabel = (dayIndex) => {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
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;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const AddEditHabitPage = () => {
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#22c55e');
|
||||
const [category, setCategory] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
@@ -24,6 +25,7 @@ const AddEditHabitPage = () => {
|
||||
if (habit) {
|
||||
setName(habit.name);
|
||||
setColor(habit.color);
|
||||
if (habit.category) setCategory(habit.category);
|
||||
} else {
|
||||
toast({
|
||||
title: "Habit not found",
|
||||
@@ -48,7 +50,7 @@ const AddEditHabitPage = () => {
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
updateHabit(id, { name: name.trim(), color });
|
||||
updateHabit(id, { name: name.trim(), color, category: category.trim() });
|
||||
toast({
|
||||
title: "✅ Habit updated",
|
||||
description: "Your habit has been updated successfully.",
|
||||
@@ -57,6 +59,7 @@ const AddEditHabitPage = () => {
|
||||
saveHabit({
|
||||
name: name.trim(),
|
||||
color,
|
||||
category: category.trim(),
|
||||
completions: [],
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
@@ -121,6 +124,19 @@ const AddEditHabitPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category <span className="text-xs text-muted-foreground">(optional)</span></Label>
|
||||
<Input
|
||||
id="category"
|
||||
placeholder="e.g., Health, Reading, Mindfulness"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="text-lg"
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<Label>Habit Color</Label>
|
||||
@@ -137,6 +153,9 @@ const AddEditHabitPage = () => {
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="font-medium">{name || 'Your Habit Name'}</span>
|
||||
{category && (
|
||||
<span className="ml-2 px-2 py-0.5 rounded bg-slate-200 dark:bg-slate-700 text-xs text-slate-700 dark:text-slate-200">{category}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[...Array(14)].map((_, i) => (
|
||||
|
||||
@@ -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();
|
||||
@@ -15,6 +16,15 @@ const HabitDetailPage = () => {
|
||||
const [habit, setHabit] = useState(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Load and apply saved theme on mount
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadHabit();
|
||||
}, [id]);
|
||||
@@ -56,8 +66,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 +171,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 +182,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 +193,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>
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { useToast } from '../components/ui/use-toast';
|
||||
import HabitCard from '../components/HabitCard';
|
||||
import { getHabits } from '../lib/storage';
|
||||
import AnimatedCounter from '../components/AnimatedCounter';
|
||||
import GitActivityGrid from '../components/GitActivityGrid';
|
||||
import { getGitEnabled } from '../lib/git';
|
||||
import { getHabits, updateHabit } from '../lib/storage';
|
||||
|
||||
const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [habits, setHabits] = useState([]);
|
||||
const [isPremium] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||||
const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
return localStorage.getItem('theme') === 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadHabits();
|
||||
setGitEnabled(getGitEnabled());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,18 +38,26 @@ const HomePage = () => {
|
||||
|
||||
const loadHabits = () => {
|
||||
const loadedHabits = getHabits();
|
||||
// Sort by sortOrder if present, then fallback to createdAt
|
||||
loadedHabits.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);
|
||||
// Initialize collapsed state for new categories
|
||||
const categories = Array.from(new Set(loadedHabits.map(h => h.category || 'Uncategorized')));
|
||||
setCollapsedGroups(prev => {
|
||||
const next = { ...prev };
|
||||
categories.forEach(cat => {
|
||||
if (!(cat in next)) next[cat] = false;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddHabit = () => {
|
||||
if (!isPremium && habits.length >= 1000) {
|
||||
toast({
|
||||
title: "🔒 Premium Feature",
|
||||
description: "Free tier limited to 1000 habits. Upgrade to unlock unlimited habits!",
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate('/add');
|
||||
};
|
||||
|
||||
@@ -107,18 +121,114 @@ 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>
|
||||
)}
|
||||
|
||||
{/* Git Activity */}
|
||||
{gitEnabled && (
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }} className="mb-8">
|
||||
<GitActivityGrid />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Habits List */}
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{habits.map((habit, index) => (
|
||||
{/* Grouped Habits by Category, collapsible, and uncategorized habits outside */}
|
||||
<DragDropContext
|
||||
onDragEnd={result => {
|
||||
if (!result.destination) return;
|
||||
const { source, destination } = result;
|
||||
// Get all habits grouped by category
|
||||
const uncategorized = habits.filter(h => !h.category);
|
||||
const categorized = habits.filter(h => h.category);
|
||||
const grouped = categorized.reduce((acc, habit) => {
|
||||
const cat = habit.category;
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(habit);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let newHabits = [...habits];
|
||||
|
||||
// If dropping into uncategorized, always unset category
|
||||
if (destination.droppableId === 'uncategorized') {
|
||||
let items, removed;
|
||||
if (source.droppableId === 'uncategorized') {
|
||||
// Reorder within uncategorized
|
||||
items = Array.from(uncategorized);
|
||||
[removed] = items.splice(source.index, 1);
|
||||
} else {
|
||||
// Move from category to uncategorized
|
||||
items = Array.from(uncategorized);
|
||||
const sourceItems = Array.from(grouped[source.droppableId]);
|
||||
[removed] = sourceItems.splice(source.index, 1);
|
||||
removed.category = '';
|
||||
grouped[source.droppableId] = sourceItems;
|
||||
}
|
||||
// Always set category to ''
|
||||
removed.category = '';
|
||||
items.splice(destination.index, 0, removed);
|
||||
items.forEach((h, i) => updateHabit(h.id, { sortOrder: i, category: '' }));
|
||||
newHabits = [
|
||||
...items,
|
||||
...Object.values(grouped).flat()
|
||||
];
|
||||
} else if (source.droppableId === 'uncategorized' && grouped[destination.droppableId]) {
|
||||
// Move from uncategorized to category
|
||||
const items = Array.from(uncategorized);
|
||||
const [removed] = items.splice(source.index, 1);
|
||||
removed.category = destination.droppableId;
|
||||
const destItems = Array.from(grouped[destination.droppableId] || []);
|
||||
destItems.splice(destination.index, 0, removed);
|
||||
destItems.forEach((h, i) => updateHabit(h.id, { sortOrder: i, category: h.category }));
|
||||
newHabits = [
|
||||
...items,
|
||||
...Object.values({ ...grouped, [destination.droppableId]: destItems }).flat()
|
||||
];
|
||||
} else if (grouped[source.droppableId] && grouped[destination.droppableId]) {
|
||||
// Move within or between categories
|
||||
const sourceItems = Array.from(grouped[source.droppableId]);
|
||||
const [removed] = sourceItems.splice(source.index, 1);
|
||||
if (source.droppableId === destination.droppableId) {
|
||||
// Reorder within same category
|
||||
sourceItems.splice(destination.index, 0, removed);
|
||||
sourceItems.forEach((h, i) => updateHabit(h.id, { sortOrder: i, category: h.category }));
|
||||
grouped[source.droppableId] = sourceItems;
|
||||
} else {
|
||||
// Move to another category
|
||||
const destItems = Array.from(grouped[destination.droppableId] || []);
|
||||
removed.category = destination.droppableId;
|
||||
destItems.splice(destination.index, 0, removed);
|
||||
destItems.forEach((h, i) => updateHabit(h.id, { sortOrder: i, category: h.category }));
|
||||
grouped[source.droppableId] = sourceItems;
|
||||
grouped[destination.droppableId] = destItems;
|
||||
}
|
||||
// Flatten
|
||||
newHabits = [
|
||||
...uncategorized,
|
||||
...Object.values(grouped).flat()
|
||||
];
|
||||
}
|
||||
setTimeout(loadHabits, 100); // reload after update
|
||||
}}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Uncategorized habits (no group panel) */}
|
||||
<Droppable droppableId="uncategorized" type="HABIT">
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-4">
|
||||
{habits.filter(h => !h.category).map((habit, index) => (
|
||||
<Draggable key={habit.id} draggableId={habit.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{ ...provided.draggableProps.style, zIndex: snapshot.isDragging ? 10 : undefined }}
|
||||
>
|
||||
<motion.div
|
||||
key={habit.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
@@ -126,9 +236,77 @@ const HomePage = () => {
|
||||
>
|
||||
<HabitCard habit={habit} onUpdate={loadHabits} />
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
{/* Group panels for named categories */}
|
||||
{Object.entries(
|
||||
habits.filter(h => h.category).reduce((acc, habit) => {
|
||||
const cat = habit.category;
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(habit);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([category, groupHabits], groupIdx) => (
|
||||
<div key={category} className="bg-white/60 dark:bg-slate-800/60 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
className="w-full flex items-center justify-between px-6 py-3 text-lg font-semibold focus:outline-none select-none hover:bg-slate-100 dark:hover:bg-slate-900 rounded-2xl transition"
|
||||
onClick={() => setCollapsedGroups(prev => ({ ...prev, [category]: !prev[category] }))}
|
||||
aria-expanded={!collapsedGroups[category]}
|
||||
>
|
||||
<span>{category}</span>
|
||||
<span className={`transition-transform ${collapsedGroups[category] ? 'rotate-90' : ''}`}>▶</span>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{!collapsedGroups[category] && (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Droppable droppableId={category} type="HABIT">
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-4 px-4 pb-4">
|
||||
{groupHabits.map((habit, index) => (
|
||||
<Draggable key={habit.id} draggableId={habit.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{ ...provided.draggableProps.style, zIndex: snapshot.isDragging ? 10 : undefined }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<HabitCard habit={habit} onUpdate={loadHabits} />
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
|
||||
{/* Empty State */}
|
||||
{habits.length === 0 && (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2 } from 'lucide-react';
|
||||
import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2, Plus, Trash, GitBranch } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { useToast } from '../components/ui/use-toast';
|
||||
import { exportData, importData, clearAllData } from '../lib/storage';
|
||||
import { addIntegration, getIntegrations, removeIntegration, getGitEnabled, setGitEnabled, fetchAllGitActivity, getCachedGitActivity } from '../lib/git';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -15,6 +16,11 @@ const SettingsPage = () => {
|
||||
return localStorage.getItem('theme') === 'dark';
|
||||
});
|
||||
const [notifications, setNotifications] = useState(false);
|
||||
const [gitEnabled, setGitEnabledState] = useState(getGitEnabled());
|
||||
const [sources, setSources] = useState(() => getIntegrations());
|
||||
const [form, setForm] = useState({ provider: 'github', baseUrl: '', username: '', token: '' });
|
||||
const [{ lastSync }, setCacheInfo] = useState(getCachedGitActivity());
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
@@ -30,6 +36,31 @@ const SettingsPage = () => {
|
||||
setDarkMode(enabled);
|
||||
};
|
||||
|
||||
const toggleGitEnabled = (enabled) => {
|
||||
setGitEnabledState(enabled);
|
||||
setGitEnabled(enabled);
|
||||
};
|
||||
|
||||
const handleAddSource = async () => {
|
||||
if (!form.username) return;
|
||||
const baseUrl = form.baseUrl || (form.provider === 'github' ? 'https://api.github.com' : form.provider === 'gitlab' ? 'https://gitlab.com' : '');
|
||||
await addIntegration({ provider: form.provider, baseUrl, username: form.username, token: form.token });
|
||||
setSources(getIntegrations());
|
||||
setForm({ provider: 'github', baseUrl: '', username: '', token: '' });
|
||||
};
|
||||
|
||||
const handleRemoveSource = (id) => {
|
||||
removeIntegration(id);
|
||||
setSources(getIntegrations());
|
||||
};
|
||||
|
||||
const handleSyncGit = async () => {
|
||||
setSyncing(true);
|
||||
const data = await fetchAllGitActivity({ force: true });
|
||||
setCacheInfo(data);
|
||||
setSyncing(false);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const data = exportData();
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
@@ -118,6 +149,7 @@ const SettingsPage = () => {
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Appearance */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -203,6 +235,72 @@ const SettingsPage = () => {
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Integrations */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"><GitBranch className="w-4 h-4" /> Integrations</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<Label htmlFor="git-enabled" className="text-base">Show Git Activity</Label>
|
||||
<p className="text-sm text-muted-foreground">Display a unified Git activity grid</p>
|
||||
</div>
|
||||
<Switch id="git-enabled" checked={gitEnabled} onCheckedChange={toggleGitEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-4 gap-2 mb-3">
|
||||
<div>
|
||||
<Label className="text-xs">Provider</Label>
|
||||
<select className="w-full bg-transparent border rounded-md p-2" value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="gitlab">GitLab</option>
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="forgejo">Forgejo</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Base URL</Label>
|
||||
<input className="w-full bg-transparent border rounded-md p-2" placeholder="GitHub: https://api.github.com • GitLab: https://gitlab.com • Gitea/Forgejo: https://your.instance" value={form.baseUrl} onChange={e => setForm({ ...form, baseUrl: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Username</Label>
|
||||
<input className="w-full bg-transparent border rounded-md p-2" placeholder="your-username" value={form.username} onChange={e => setForm({ ...form, username: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Token</Label>
|
||||
<input className="w-full bg-transparent border rounded-md p-2" placeholder="personal access token" value={form.token} onChange={e => setForm({ ...form, token: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleAddSource} className="mb-4 rounded-full"><Plus className="w-4 h-4 mr-1" /> Add Source</Button>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<Button variant="outline" onClick={handleSyncGit} disabled={syncing} className="rounded-full">
|
||||
{syncing ? 'Syncing…' : 'Sync Git Data'}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{lastSync ? `Last sync: ${new Date(lastSync).toLocaleString()}` : ''}</span>
|
||||
</div>
|
||||
|
||||
{sources.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sources.map(src => (
|
||||
<div key={src.id} className="flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-md p-2">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{src.provider} • {src.username}</div>
|
||||
<div className="text-xs text-muted-foreground">{src.baseUrl}</div>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" onClick={() => handleRemoveSource(src.id)} className="rounded-full" aria-label="Remove Source">
|
||||
<Trash className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* About */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
Reference in New Issue
Block a user