mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-04-19 15:23:16 +00:00
Compare commits
14 Commits
1.0.0
...
b02c9c5c41
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
### Installation
|
||||||
```powershell
|
```powershell
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone https://github.com/yourusername/habitgrid.git
|
git clone https://github.com/nagaoo0/habitgrid.git
|
||||||
cd habitgrid
|
cd habitgrid
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
|
|||||||
70
package-lock.json
generated
70
package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^10.16.4",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"lucide-react": "^0.285.0",
|
"lucide-react": "^0.285.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -39,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"
|
||||||
}
|
}
|
||||||
@@ -4943,6 +4944,15 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.16",
|
"version": "2.8.16",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
|
||||||
@@ -5296,6 +5306,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -6762,6 +6781,19 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -9349,6 +9381,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/text-table": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||||
@@ -9786,6 +9827,15 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.9",
|
"version": "7.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
|
||||||
@@ -10114,20 +10164,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",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^10.16.4",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"lucide-react": "^0.285.0",
|
"lucide-react": "^0.285.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -40,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"
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getColorIntensity, isToday, formatDate, getWeekdayLabel } from '../lib/utils-habit';
|
import { getColorIntensity, isToday, formatDate, getWeekdayLabel, getFrozenDays } from '../lib/utils-habit';
|
||||||
import { toggleCompletion } from '../lib/storage';
|
import { toggleCompletion } from '../lib/storage';
|
||||||
|
|
||||||
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||||
|
const frozenDays = getFrozenDays(habit.completions);
|
||||||
const weeks = useMemo(() => {
|
const weeks = useMemo(() => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
// Find the Monday of the current week
|
// Find the Monday of the current week
|
||||||
@@ -29,35 +30,30 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
|||||||
return weeksArray;
|
return weeksArray;
|
||||||
}, [fullView]);
|
}, [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) => {
|
const handleCellClick = (date) => {
|
||||||
toggleCompletion(habit.id, formatDate(date));
|
toggleCompletion(habit.id, formatDate(date));
|
||||||
onUpdate();
|
onUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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-4">
|
<div className="mb-2 text-center w-full">
|
||||||
<h2 className="text-lg font-semibold mb-1">Activity Calendar</h2>
|
<h2 className="text-lg font-semibold mb-1 mt-4">Activity Calendar</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Tap any day to mark it as complete
|
Tap any day to mark it as complete
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto grid-scroll">
|
<div className="overflow-x-auto grid-scroll mt-2 w-full flex justify-center">
|
||||||
<div className="inline-flex gap-1">
|
<div className="inline-flex gap-1 mb-4">
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* Grid: Monday (top) to Sunday (bottom) */}
|
{/* Grid: Monday (top) to Sunday (bottom) */}
|
||||||
{weeks.map((week, weekIndex) => (
|
{weeks.map((week, weekIndex) => (
|
||||||
<div key={weekIndex} className="flex flex-col gap-1">
|
<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 intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
|
||||||
const isTodayCell = isToday(date);
|
const isTodayCell = isToday(date);
|
||||||
const isFuture = date > new Date();
|
const isFuture = date > new Date();
|
||||||
|
const isFrozen = frozenDays.includes(dateStr);
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={dayIndex}
|
key={dayIndex}
|
||||||
whileHover={{ scale: 1.15 }}
|
whileHover={{ scale: 1.15 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
onClick={() => handleCellClick(date)}
|
onClick={() => handleCellClick(date)}
|
||||||
className="habit-cell w-3 h-3 rounded-sm"
|
className="habit-cell w-3 h-3 rounded-sm flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isCompleted ? habit.color : 'transparent',
|
backgroundColor: isCompleted ? habit.color : 'transparent',
|
||||||
opacity: isFuture ? 0 : (isCompleted ? 0.3 + (intensity * 0.7) : 1),
|
opacity: isFuture ? 0 : (isCompleted ? 0.3 + (intensity * 0.7) : 1),
|
||||||
@@ -86,12 +83,29 @@ const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
|||||||
pointerEvents: isFuture ? 'none' : 'auto',
|
pointerEvents: isFuture ? 'none' : 'auto',
|
||||||
visibility: isFuture ? 'hidden' : 'visible',
|
visibility: isFuture ? 'hidden' : 'visible',
|
||||||
}}
|
}}
|
||||||
title={`${dateStr}${isCompleted ? ' ✓' : ''}`}
|
title={`${dateStr}${isCompleted ? ' ✓' : ''}${isFrozen ? ' (Frozen)' : ''}`}
|
||||||
/>
|
>
|
||||||
|
{isFrozen && (
|
||||||
|
<span role="img" aria-label="Frozen" style={{ fontSize: '0.7em' }}>❄️</span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
// Utility to lighten a hex color
|
||||||
|
function lightenColor(hex, percent) {
|
||||||
|
hex = hex.replace(/^#/, '');
|
||||||
|
if (hex.length === 3) hex = hex.split('').map(x => x + x).join('');
|
||||||
|
const num = parseInt(hex, 16);
|
||||||
|
let r = (num >> 16) + Math.round(255 * percent);
|
||||||
|
let g = ((num >> 8) & 0x00FF) + Math.round(255 * percent);
|
||||||
|
let b = (num & 0x0000FF) + Math.round(255 * percent);
|
||||||
|
r = Math.min(255, r);
|
||||||
|
g = Math.min(255, g);
|
||||||
|
b = Math.min(255, b);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
import { Flame } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
|
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
|
||||||
|
import { getFrozenDays } from '../lib/utils-habit';
|
||||||
import { toggleCompletion } from '../lib/storage';
|
import { toggleCompletion } from '../lib/storage';
|
||||||
|
import { toast } from './ui/use-toast';
|
||||||
|
|
||||||
const MiniGrid = ({ habit, onUpdate }) => {
|
const MiniGrid = ({ habit, onUpdate }) => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
// Show fewer days on mobile for better aspect ratio
|
// Dynamically calculate number of days that fit based on window width and cell size, max 28
|
||||||
const isMobile = window.innerWidth < 640; // Tailwind 'sm' breakpoint
|
const CELL_SIZE = 42; // px, matches w-8 h-8
|
||||||
const numDays = isMobile ? 14 : 28;
|
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 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--) {
|
for (let i = numDays - 1; i >= 0; i--) {
|
||||||
const date = new Date(today);
|
const date = new Date(today);
|
||||||
@@ -16,40 +40,135 @@ const MiniGrid = ({ habit, onUpdate }) => {
|
|||||||
days.push(date);
|
days.push(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCellClick = (e, date) => {
|
const handleCellClick = async (e, date) => {
|
||||||
e.stopPropagation();
|
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();
|
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 (
|
return (
|
||||||
<div className="flex gap-1 overflow-x-auto grid-scroll pb-2">
|
<div ref={scrollRef} className="flex gap-1 overflow-x-auto grid-scroll pt-4 pb-2">
|
||||||
{days.map((date, index) => {
|
{(() => {
|
||||||
const dateStr = formatDate(date);
|
const frozenDays = getFrozenDays(habit.completions);
|
||||||
const isCompleted = habit.completions.includes(dateStr);
|
return days.map((date, index) => {
|
||||||
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
|
const dateStr = formatDate(date);
|
||||||
const isTodayCell = isToday(date);
|
const isCompleted = habit.completions.includes(dateStr);
|
||||||
|
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
|
||||||
return (
|
const isTodayCell = isToday(date);
|
||||||
<motion.button
|
const dayLetter = date.toLocaleDateString('en-US', { weekday: 'short' })[0];
|
||||||
key={index}
|
// Check if previous day was completed and next day is today
|
||||||
whileHover={{ scale: 0.9 }}
|
let isFrozen = frozenDays.includes(dateStr);
|
||||||
whileTap={{ scale: 0.5 }}
|
if (!isCompleted && !isTodayCell && index < days.length - 1 && index > 0) {
|
||||||
onClick={(e) => handleCellClick(e, date)}
|
const prevDateStr = formatDate(days[index - 1]);
|
||||||
className="habit-cell flex w-8 h-8 rounded-2xl transition-all"
|
const nextDateStr = formatDate(days[index + 1]);
|
||||||
style={{
|
const prevCompleted = habit.completions.includes(prevDateStr);
|
||||||
backgroundColor: isCompleted
|
const nextIsToday = isToday(days[index + 1]);
|
||||||
? habit.color
|
if (prevCompleted && nextIsToday) {
|
||||||
: 'transparent',
|
isFrozen = true;
|
||||||
opacity: isCompleted ? 0.3 + (intensity * 0.7) : 1,
|
}
|
||||||
border: isTodayCell
|
}
|
||||||
? `2px solid ${habit.color}`
|
return (
|
||||||
: `1px solid ${habit.color}20`,
|
<div key={index} className="flex flex-col items-center">
|
||||||
}}
|
<motion.button
|
||||||
title={dateStr}
|
whileHover={{ scale: 0.9 }}
|
||||||
/>
|
whileTap={{ scale: 0.5 }}
|
||||||
);
|
onClick={(e) => handleCellClick(e, date)}
|
||||||
})}
|
className={`habit-cell flex w-8 h-8 transition-all items-center justify-center ${isTodayCell ? 'rounded-md' : 'rounded-2xl'}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isCompleted
|
||||||
|
? habit.color
|
||||||
|
: 'transparent',
|
||||||
|
opacity: isCompleted ? 0.3 + (intensity * 0.7) : 1,
|
||||||
|
border: isTodayCell
|
||||||
|
? `2px solid ${habit.color}`
|
||||||
|
: `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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,19 +7,21 @@ import React from 'react';
|
|||||||
const ToastProvider = ToastPrimitives.Provider;
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
|
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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',
|
||||||
className,
|
'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',
|
||||||
{...props}
|
className,
|
||||||
/>
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
const toastVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -73,20 +75,24 @@ const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Title
|
<ToastPrimitives.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('text-sm font-semibold', className)}
|
className={cn('text-lg font-bold flex items-center gap-2', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
<span className="animate-float inline-block">🎊</span> {props.children}
|
||||||
|
</ToastPrimitives.Title>
|
||||||
));
|
));
|
||||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Description
|
<ToastPrimitives.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('text-sm opacity-90', className)}
|
className={cn('text-base opacity-95 font-medium flex items-center gap-2', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
<span className="animate-float inline-block">✨</span> {props.children}
|
||||||
|
</ToastPrimitives.Description>
|
||||||
));
|
));
|
||||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -94,4 +94,21 @@
|
|||||||
|
|
||||||
.dark .grid-scroll::-webkit-scrollbar-thumb {
|
.dark .grid-scroll::-webkit-scrollbar-thumb {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
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;
|
||||||
|
}
|
||||||
@@ -65,12 +65,15 @@ export const toggleCompletion = (habitId, dateStr) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import { getFrozenDays } from './utils-habit.js';
|
||||||
const calculateStreaks = (completions) => {
|
const calculateStreaks = (completions) => {
|
||||||
if (completions.length === 0) {
|
if (completions.length === 0) {
|
||||||
return { currentStreak: 0, longestStreak: 0 };
|
return { currentStreak: 0, longestStreak: 0 };
|
||||||
}
|
}
|
||||||
|
// Only use frozen days for streak calculation
|
||||||
const sortedDates = completions
|
const frozenDays = getFrozenDays(completions);
|
||||||
|
const allValid = Array.from(new Set([...completions, ...frozenDays]));
|
||||||
|
const sortedDates = allValid
|
||||||
.map(d => new Date(d))
|
.map(d => new Date(d))
|
||||||
.sort((a, b) => b - a);
|
.sort((a, b) => b - a);
|
||||||
|
|
||||||
@@ -88,15 +91,12 @@ const calculateStreaks = (completions) => {
|
|||||||
|
|
||||||
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
|
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
|
||||||
currentStreak = 1;
|
currentStreak = 1;
|
||||||
|
|
||||||
for (let i = 1; i < sortedDates.length; i++) {
|
for (let i = 1; i < sortedDates.length; i++) {
|
||||||
const current = new Date(sortedDates[i]);
|
const current = new Date(sortedDates[i]);
|
||||||
current.setHours(0, 0, 0, 0);
|
current.setHours(0, 0, 0, 0);
|
||||||
const previous = new Date(sortedDates[i - 1]);
|
const previous = new Date(sortedDates[i - 1]);
|
||||||
previous.setHours(0, 0, 0, 0);
|
previous.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays === 1) {
|
if (diffDays === 1) {
|
||||||
currentStreak++;
|
currentStreak++;
|
||||||
tempStreak++;
|
tempStreak++;
|
||||||
@@ -112,9 +112,7 @@ const calculateStreaks = (completions) => {
|
|||||||
current.setHours(0, 0, 0, 0);
|
current.setHours(0, 0, 0, 0);
|
||||||
const previous = new Date(sortedDates[i - 1]);
|
const previous = new Date(sortedDates[i - 1]);
|
||||||
previous.setHours(0, 0, 0, 0);
|
previous.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays === 1) {
|
if (diffDays === 1) {
|
||||||
tempStreak++;
|
tempStreak++;
|
||||||
longestStreak = Math.max(longestStreak, tempStreak);
|
longestStreak = Math.max(longestStreak, tempStreak);
|
||||||
@@ -124,9 +122,8 @@ const calculateStreaks = (completions) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
longestStreak = Math.max(longestStreak, currentStreak, 1);
|
longestStreak = Math.max(longestStreak, currentStreak, 1);
|
||||||
|
|
||||||
return { currentStreak, longestStreak };
|
return { currentStreak, longestStreak };
|
||||||
};
|
}
|
||||||
|
|
||||||
export const exportData = () => {
|
export const exportData = () => {
|
||||||
const habits = getHabits();
|
const habits = getHabits();
|
||||||
|
|||||||
@@ -39,4 +39,38 @@ export const getColorIntensity = (completions, dateStr) => {
|
|||||||
export const getWeekdayLabel = (dayIndex) => {
|
export const getWeekdayLabel = (dayIndex) => {
|
||||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
return labels[dayIndex];
|
return labels[dayIndex];
|
||||||
};
|
};
|
||||||
|
// Returns array of frozen days (date strings) for a given completions array
|
||||||
|
export function getFrozenDays(completions) {
|
||||||
|
// Map: month string -> frozen day string
|
||||||
|
const frozenDays = [];
|
||||||
|
const completedSet = new Set(completions);
|
||||||
|
// Sort completions for easier lookup
|
||||||
|
const sorted = [...completions].sort();
|
||||||
|
// Track frozen per month
|
||||||
|
const frozenPerMonth = {};
|
||||||
|
// To find missed days, scan a range of dates
|
||||||
|
if (completions.length === 0) return [];
|
||||||
|
const minDate = new Date(sorted[0]);
|
||||||
|
const maxDate = new Date(sorted[sorted.length - 1]);
|
||||||
|
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
|
||||||
|
const dateStr = formatDate(d);
|
||||||
|
if (completedSet.has(dateStr)) continue; // skip completed days
|
||||||
|
// Check neighbors
|
||||||
|
const prevDate = new Date(d); prevDate.setDate(prevDate.getDate() - 1);
|
||||||
|
const nextDate = new Date(d); nextDate.setDate(nextDate.getDate() + 1);
|
||||||
|
const prevDateStr = formatDate(prevDate);
|
||||||
|
const nextDateStr = formatDate(nextDate);
|
||||||
|
// Only freeze if both neighbors are completed
|
||||||
|
const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
if (
|
||||||
|
completedSet.has(prevDateStr) &&
|
||||||
|
completedSet.has(nextDateStr) &&
|
||||||
|
!frozenPerMonth[monthKey]
|
||||||
|
) {
|
||||||
|
frozenDays.push(dateStr);
|
||||||
|
frozenPerMonth[monthKey] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frozenDays;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -15,6 +16,15 @@ const HabitDetailPage = () => {
|
|||||||
const [habit, setHabit] = useState(null);
|
const [habit, setHabit] = useState(null);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
loadHabit();
|
loadHabit();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
@@ -56,8 +66,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 +171,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 +182,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 +193,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,9 @@ 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 { getGitEnabled } from '../lib/git';
|
||||||
import { getHabits } from '../lib/storage';
|
import { getHabits } from '../lib/storage';
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
@@ -12,12 +15,14 @@ const HomePage = () => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [habits, setHabits] = useState([]);
|
const [habits, setHabits] = useState([]);
|
||||||
const [isPremium] = useState(false);
|
const [isPremium] = useState(false);
|
||||||
|
const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
return localStorage.getItem('theme') === 'dark';
|
return localStorage.getItem('theme') === 'dark';
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHabits();
|
loadHabits();
|
||||||
|
setGitEnabled(getGitEnabled());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -107,12 +112,19 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Habits List */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
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 { Button } from '../components/ui/button';
|
||||||
import { Switch } from '../components/ui/switch';
|
import { Switch } from '../components/ui/switch';
|
||||||
import { Label } from '../components/ui/label';
|
import { Label } from '../components/ui/label';
|
||||||
import { useToast } from '../components/ui/use-toast';
|
import { useToast } from '../components/ui/use-toast';
|
||||||
import { exportData, importData, clearAllData } from '../lib/storage';
|
import { exportData, importData, clearAllData } from '../lib/storage';
|
||||||
|
import { addIntegration, getIntegrations, removeIntegration, getGitEnabled, setGitEnabled, fetchAllGitActivity, getCachedGitActivity } from '../lib/git';
|
||||||
|
|
||||||
const SettingsPage = () => {
|
const SettingsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -15,6 +16,11 @@ const SettingsPage = () => {
|
|||||||
return localStorage.getItem('theme') === 'dark';
|
return localStorage.getItem('theme') === 'dark';
|
||||||
});
|
});
|
||||||
const [notifications, setNotifications] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (darkMode) {
|
if (darkMode) {
|
||||||
@@ -30,6 +36,31 @@ const SettingsPage = () => {
|
|||||||
setDarkMode(enabled);
|
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 handleExport = () => {
|
||||||
const data = exportData();
|
const data = exportData();
|
||||||
const blob = new Blob([data], { type: 'application/json' });
|
const blob = new Blob([data], { type: 'application/json' });
|
||||||
@@ -118,6 +149,7 @@ const SettingsPage = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -203,6 +235,72 @@ const SettingsPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</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 */}
|
{/* About */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user