Skip to content

Commit 6554678

Browse files
committed
Add routine reordering persistence and API sync
1 parent e927bab commit 6554678

5 files changed

Lines changed: 253 additions & 4 deletions

File tree

client/src/lib/storage.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { routinesApi } from "./storage";
3+
import type { Routine } from "./schema";
4+
5+
const ROUTINES_KEY = "rm_routines";
6+
7+
function seedRoutines(routines: Routine[]) {
8+
localStorage.setItem(ROUTINES_KEY, JSON.stringify(routines));
9+
}
10+
11+
describe("routinesApi.reorder", () => {
12+
beforeEach(() => {
13+
localStorage.clear();
14+
vi.restoreAllMocks();
15+
vi.stubGlobal(
16+
"fetch",
17+
vi.fn(async () => ({
18+
ok: true,
19+
json: async () => ({ success: true }),
20+
}))
21+
);
22+
});
23+
24+
it("rewrites local routines in ordered id sequence with sequential sortOrder", async () => {
25+
seedRoutines([
26+
{ id: "a", name: "A", icon: "🅰️", timeCategories: ["AM"], isActive: true, sortOrder: 0 },
27+
{ id: "b", name: "B", icon: "🅱️", timeCategories: ["PM"], isActive: true, sortOrder: 1 },
28+
{ id: "c", name: "C", icon: "©️", timeCategories: ["ALL"], isActive: true, sortOrder: 2 },
29+
]);
30+
31+
await routinesApi.reorder(["c", "a", "b"]);
32+
33+
const routines = JSON.parse(localStorage.getItem(ROUTINES_KEY) || "[]") as Routine[];
34+
expect(routines.map((routine) => routine.id)).toEqual(["c", "a", "b"]);
35+
expect(routines.map((routine) => routine.sortOrder)).toEqual([0, 1, 2]);
36+
});
37+
38+
it("does nothing for a single-routine order", async () => {
39+
seedRoutines([
40+
{ id: "only", name: "Only", icon: "✅", timeCategories: ["ALL"], isActive: true, sortOrder: 0 },
41+
]);
42+
const fetchMock = vi.mocked(fetch);
43+
44+
await routinesApi.reorder(["only"]);
45+
46+
const routines = JSON.parse(localStorage.getItem(ROUTINES_KEY) || "[]") as Routine[];
47+
expect(routines).toHaveLength(1);
48+
expect(routines[0].id).toBe("only");
49+
expect(fetchMock).not.toHaveBeenCalled();
50+
});
51+
52+
it("preserves routine metadata after reorder", async () => {
53+
seedRoutines([
54+
{
55+
id: "r1",
56+
name: "Hydrate",
57+
icon: "💧",
58+
timeCategories: ["AM", "NOON"],
59+
isActive: true,
60+
sortOrder: 0,
61+
},
62+
{
63+
id: "r2",
64+
name: "Walk",
65+
icon: "🚶",
66+
timeCategories: ["PM"],
67+
isActive: true,
68+
sortOrder: 1,
69+
},
70+
]);
71+
72+
await routinesApi.reorder(["r2", "r1"]);
73+
74+
const routines = JSON.parse(localStorage.getItem(ROUTINES_KEY) || "[]") as Routine[];
75+
expect(routines[0]).toMatchObject({
76+
id: "r2",
77+
name: "Walk",
78+
icon: "🚶",
79+
timeCategories: ["PM"],
80+
});
81+
expect(routines[1]).toMatchObject({
82+
id: "r1",
83+
name: "Hydrate",
84+
icon: "💧",
85+
timeCategories: ["AM", "NOON"],
86+
});
87+
});
88+
89+
it("fires sync API call with ordered ids", async () => {
90+
seedRoutines([
91+
{ id: "one", name: "One", icon: "1️⃣", timeCategories: ["ALL"], isActive: true, sortOrder: 0 },
92+
{ id: "two", name: "Two", icon: "2️⃣", timeCategories: ["ALL"], isActive: true, sortOrder: 1 },
93+
]);
94+
const fetchMock = vi.mocked(fetch);
95+
96+
await routinesApi.reorder(["two", "one"]);
97+
98+
expect(fetchMock).toHaveBeenCalledWith(
99+
expect.stringContaining("/api/routines/reorder"),
100+
expect.objectContaining({
101+
method: "PUT",
102+
body: JSON.stringify({ orderedIds: ["two", "one"] }),
103+
})
104+
);
105+
});
106+
});

client/src/lib/storage.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,10 @@ export const routinesApi = {
220220
const seenIds = new Set<string>();
221221
const seenNames = new Set<string>();
222222

223-
// Sort to put active routines first
223+
// Sort by user-defined order, then by active state
224224
const sorted = [...routines].sort((a, b) => {
225+
const orderDiff = (a.sortOrder ?? Number.MAX_SAFE_INTEGER) - (b.sortOrder ?? Number.MAX_SAFE_INTEGER);
226+
if (orderDiff !== 0) return orderDiff;
225227
if (a.isActive && !b.isActive) return -1;
226228
if (!a.isActive && b.isActive) return 1;
227229
return 0;
@@ -237,6 +239,41 @@ export const routinesApi = {
237239
});
238240
},
239241

242+
reorder: async (orderedIds: string[]): Promise<void> => {
243+
if (orderedIds.length <= 1) return;
244+
245+
const routines = getFromStorage<Routine[]>(KEYS.routines, []);
246+
const routineById = new Map(routines.map((routine) => [routine.id, routine]));
247+
const reordered: Routine[] = [];
248+
249+
orderedIds.forEach((id, index) => {
250+
const routine = routineById.get(id);
251+
if (routine) {
252+
reordered.push({
253+
...routine,
254+
sortOrder: index,
255+
});
256+
routineById.delete(id);
257+
}
258+
});
259+
260+
// Keep any routines not included in orderedIds at the end
261+
routineById.forEach((routine) => {
262+
reordered.push({
263+
...routine,
264+
sortOrder: reordered.length,
265+
});
266+
});
267+
268+
saveToStorage(KEYS.routines, reordered);
269+
270+
// Fire-and-forget sync; local order remains authoritative
271+
api("/api/routines/reorder", {
272+
method: "PUT",
273+
body: JSON.stringify({ orderedIds }),
274+
});
275+
},
276+
240277
getDaily: async (date: string): Promise<(Routine & {
241278
completedCategories: string[];
242279
isFullyCompleted: boolean;

client/src/pages/routines.tsx

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useQuery, useMutation } from "@tanstack/react-query";
33
import { useForm } from "react-hook-form";
44
import { zodResolver } from "@hookform/resolvers/zod";
55
import { 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";
77
import { Button } from "@/components/ui/button";
88
import { Card } from "@/components/ui/card";
99
import { 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}

client/src/pages/today.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ export default function TodayPage() {
9898
});
9999
});
100100

101+
Object.keys(groupedRoutines).forEach((category) => {
102+
groupedRoutines[category].sort(
103+
(a, b) => (a.routine.sortOrder ?? Number.MAX_SAFE_INTEGER) - (b.routine.sortOrder ?? Number.MAX_SAFE_INTEGER)
104+
);
105+
});
106+
101107
// Count total tasks (routine × category pairs) and completed ones
102108
const totalTasks = routines?.reduce((sum, r) => sum + r.timeCategories.length, 0) || 0;
103109
const completedTasks = routines?.reduce((sum, r) => sum + r.completedCategories.length, 0) || 0;

worker/src/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,29 @@ app.post('/api/routines', async (c) => {
255255
return c.json({ id, name, icon: icon || '✅', timeCategories, isActive: true, sortOrder, createdAt });
256256
});
257257

258+
// Update routine
259+
app.put('/api/routines/reorder', async (c) => {
260+
const userId = c.req.header('X-User-Id');
261+
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
262+
263+
const body = await c.req.json();
264+
const orderedIds = Array.isArray(body?.orderedIds) ? body.orderedIds : [];
265+
266+
if (orderedIds.length === 0) {
267+
return c.json({ error: 'orderedIds must be a non-empty array' }, 400);
268+
}
269+
270+
const statements = orderedIds.map((id: string, index: number) =>
271+
c.env.DB
272+
.prepare('UPDATE routines SET sort_order = ? WHERE id = ? AND user_id = ?')
273+
.bind(index, id, userId)
274+
);
275+
276+
await c.env.DB.batch(statements);
277+
278+
return c.json({ success: true });
279+
});
280+
258281
// Update routine
259282
app.put('/api/routines/:id', async (c) => {
260283
const userId = c.req.header('X-User-Id');

0 commit comments

Comments
 (0)