diff --git a/package-lock.json b/package-lock.json index d47862c..02af06e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -92,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", @@ -689,6 +691,7 @@ "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1533,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", @@ -2008,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" @@ -2622,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", @@ -4115,6 +4135,7 @@ "integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4139,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" @@ -4150,6 +4172,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4161,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", @@ -4235,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", @@ -4474,6 +4504,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5018,6 +5049,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5306,6 +5338,15 @@ "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", @@ -5793,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", @@ -7342,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" } @@ -8084,6 +8127,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8230,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" }, @@ -8247,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" @@ -8282,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", @@ -8423,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", @@ -9272,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", @@ -9418,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", @@ -9459,6 +9548,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9842,6 +9932,7 @@ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -9935,6 +10026,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index d556397..420f2be 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/storage.js b/src/lib/storage.js index 2c5889c..9f54cc3 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -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)); diff --git a/src/pages/AddEditHabitPage.jsx b/src/pages/AddEditHabitPage.jsx index 7f9415c..51643c9 100644 --- a/src/pages/AddEditHabitPage.jsx +++ b/src/pages/AddEditHabitPage.jsx @@ -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 = () => {

+ {/* Category Input */} +
+ + setCategory(e.target.value)} + className="text-lg" + maxLength={30} + /> +
+ {/* Color Picker */}
@@ -137,6 +153,9 @@ const AddEditHabitPage = () => { style={{ backgroundColor: color }} /> {name || 'Your Habit Name'} + {category && ( + {category} + )}
{[...Array(14)].map((_, i) => ( diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 2517793..313bb0a 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,4 +1,5 @@ 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'; @@ -8,12 +9,13 @@ 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, updateHabit } from '../lib/storage'; const HomePage = () => { const navigate = useNavigate(); const { toast } = useToast(); const [habits, setHabits] = useState([]); + const [collapsedGroups, setCollapsedGroups] = useState({}); const [isPremium] = useState(false); const [gitEnabled, setGitEnabled] = useState(getGitEnabled()); const [darkMode, setDarkMode] = useState(() => { @@ -37,7 +39,23 @@ 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 = () => { @@ -126,21 +144,178 @@ const HomePage = () => { )} {/* Habits List */} -
- - {habits.map((habit, index) => ( - - - + {/* Grouped Habits by Category, collapsible, and uncategorized habits outside */} + { + 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 + }} + > +
+ {/* Uncategorized habits (no group panel) */} + + {(provided) => ( +
+ {habits.filter(h => !h.category).map((habit, index) => ( + + {(provided, snapshot) => ( +
+ + + +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+ {/* 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) => ( +
+ + + {!collapsedGroups[category] && ( + + + {(provided) => ( +
+ {groupHabits.map((habit, index) => ( + + {(provided, snapshot) => ( +
+ + + +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ )} +
+
))} - -
+
+ {/* Empty State */} {habits.length === 0 && (