From f298eb4573bfc2926ea1e16d666d6c472d07a9d1 Mon Sep 17 00:00:00 2001
From: count0
Date: Thu, 16 Oct 2025 17:40:33 +0200
Subject: [PATCH] Drag and drop
---
package-lock.json | 94 ++++++++++++++-
package.json | 1 +
src/lib/storage.js | 1 +
src/pages/AddEditHabitPage.jsx | 21 +++-
src/pages/HomePage.jsx | 205 ++++++++++++++++++++++++++++++---
5 files changed, 305 insertions(+), 17 deletions(-)
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 && (