@@ -3,7 +3,7 @@ import { useQuery, useMutation } from "@tanstack/react-query";
33import { useForm } from "react-hook-form" ;
44import { zodResolver } from "@hookform/resolvers/zod" ;
55import { z } from "zod" ;
6- import { Plus , Pencil , Trash2 , Bell , BellOff , Sun , Moon } from "lucide-react" ;
6+ import { Plus , Pencil , Trash2 , Bell , BellOff , Sun , Moon , GripVertical } from "lucide-react" ;
77import { Button } from "@/components/ui/button" ;
88import { Card } from "@/components/ui/card" ;
99import { Input } from "@/components/ui/input" ;
@@ -220,6 +220,8 @@ export default function RoutinesPage() {
220220 const [ editingRoutine , setEditingRoutine ] = useState < Routine | null > ( null ) ;
221221 const { theme, toggleTheme } = useTheme ( ) ;
222222 const { toast } = useToast ( ) ;
223+ const [ draggedId , setDraggedId ] = useState < string | null > ( null ) ;
224+ const [ dragOverId , setDragOverId ] = useState < string | null > ( null ) ;
223225
224226 const { data : routines , isLoading } = useQuery < Routine [ ] > ( {
225227 queryKey : [ "routines" ] ,
@@ -256,6 +258,35 @@ export default function RoutinesPage() {
256258 } ,
257259 } ) ;
258260
261+ const reorderMutation = useMutation ( {
262+ mutationFn : ( orderedIds : string [ ] ) => routinesApi . reorder ( orderedIds ) ,
263+ onSuccess : ( ) => {
264+ queryClient . invalidateQueries ( { queryKey : [ "routines" ] } ) ;
265+ queryClient . invalidateQueries ( { queryKey : [ "routines" , "daily" ] } ) ;
266+ toast ( { title : "Routine order updated" } ) ;
267+ } ,
268+ } ) ;
269+
270+ const reorderRoutines = ( fromId : string , toId : string ) => {
271+ if ( ! routines || fromId === toId ) return ;
272+ const fromIndex = routines . findIndex ( ( routine ) => routine . id === fromId ) ;
273+ const toIndex = routines . findIndex ( ( routine ) => routine . id === toId ) ;
274+ if ( fromIndex === - 1 || toIndex === - 1 || fromIndex === toIndex ) return ;
275+
276+ const reordered = [ ...routines ] ;
277+ const [ moved ] = reordered . splice ( fromIndex , 1 ) ;
278+ reordered . splice ( toIndex , 0 , moved ) ;
279+ reorderMutation . mutate ( reordered . map ( ( routine ) => routine . id ) ) ;
280+ } ;
281+
282+ const moveByOffset = ( routineId : string , offset : - 1 | 1 ) => {
283+ if ( ! routines ) return ;
284+ const currentIndex = routines . findIndex ( ( routine ) => routine . id === routineId ) ;
285+ const targetIndex = currentIndex + offset ;
286+ if ( currentIndex === - 1 || targetIndex < 0 || targetIndex >= routines . length ) return ;
287+ reorderRoutines ( routineId , routines [ targetIndex ] . id ) ;
288+ } ;
289+
259290 return (
260291 < div className = "flex flex-col min-h-screen pb-20" >
261292 < header className = "sticky top-0 bg-background/95 backdrop-blur-sm z-40 border-b border-border px-4 py-4" >
@@ -321,9 +352,54 @@ export default function RoutinesPage() {
321352 </ div >
322353 ) : (
323354 routines ?. map ( ( routine ) => (
324- < Card key = { routine . id } className = "p-4" data-testid = { `card-routine-${ routine . id } ` } >
355+ < Card
356+ key = { routine . id }
357+ className = { `p-4 transition-all ${ draggedId === routine . id ? "opacity-70 ring-2 ring-primary shadow-lg scale-[1.01]" : "" } ${ dragOverId === routine . id ? "ring-2 ring-primary/40" : "" } ` }
358+ data-testid = { `card-routine-${ routine . id } ` }
359+ onDragOver = { ( e ) => {
360+ if ( ! draggedId || draggedId === routine . id ) return ;
361+ e . preventDefault ( ) ;
362+ setDragOverId ( routine . id ) ;
363+ } }
364+ onDrop = { ( e ) => {
365+ e . preventDefault ( ) ;
366+ if ( ! draggedId || draggedId === routine . id ) return ;
367+ reorderRoutines ( draggedId , routine . id ) ;
368+ setDraggedId ( null ) ;
369+ setDragOverId ( null ) ;
370+ } }
371+ onDragEnd = { ( ) => {
372+ setDraggedId ( null ) ;
373+ setDragOverId ( null ) ;
374+ } }
375+ >
325376 < div className = "flex items-start justify-between gap-3" >
326- < div className = "flex-1 min-w-0" >
377+ < div className = "flex items-start gap-2 flex-1 min-w-0" >
378+ { routines . length > 1 && (
379+ < button
380+ type = "button"
381+ draggable
382+ aria-label = { `Reorder ${ routine . name } ` }
383+ className = "mt-0.5 text-muted-foreground hover:text-foreground transition-colors cursor-grab active:cursor-grabbing touch-none"
384+ data-testid = { `button-reorder-${ routine . id } ` }
385+ onDragStart = { ( e ) => {
386+ e . dataTransfer . effectAllowed = "move" ;
387+ setDraggedId ( routine . id ) ;
388+ } }
389+ onKeyDown = { ( e ) => {
390+ if ( e . key === "ArrowUp" ) {
391+ e . preventDefault ( ) ;
392+ moveByOffset ( routine . id , - 1 ) ;
393+ } else if ( e . key === "ArrowDown" ) {
394+ e . preventDefault ( ) ;
395+ moveByOffset ( routine . id , 1 ) ;
396+ }
397+ } }
398+ >
399+ < GripVertical className = "w-4 h-4" />
400+ </ button >
401+ ) }
402+ < div className = "flex-1 min-w-0" >
327403 < div className = "flex items-center gap-2 mb-2" >
328404 < span className = "text-xl" > { routine . icon || "✅" } </ span >
329405 < h3 className = "font-semibold truncate" data-testid = { `text-routine-name-${ routine . id } ` } >
@@ -342,6 +418,7 @@ export default function RoutinesPage() {
342418 ) ) }
343419 </ div >
344420 </ div >
421+ </ div >
345422 < div className = "flex items-center gap-1" >
346423 < Dialog
347424 open = { editingRoutine ?. id === routine . id }
0 commit comments