From 5d3323e765c55c706e3ad362de85b466214f32d5 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 10:00:57 +0200 Subject: [PATCH] feat(board): drop position arithmetic with same-column handling --- .env.example | 16 +++++++-- src/lib/board/move.test.ts | 72 ++++++++++++++++++++++++++++++++++++++ src/lib/board/move.ts | 39 +++++++++++++++++++++ 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/lib/board/move.test.ts create mode 100644 src/lib/board/move.ts diff --git a/.env.example b/.env.example index 02a6a2a..c77dab3 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,13 @@ -NEXT_PUBLIC_PB_URL=http://pocketbase-roo2fon0xs50369mmf04udx1.169.239.180.130.sslip.io/ -PB_SUPERUSER_EMAIL=kanban@apphub.co.za -PB_SUPERUSER_PASSWORD=apphub12345678 \ No newline at end of file +# Copy to .env.local and fill in real values. .env.local is gitignored. +# +# NEVER put real credentials in this file. It is tracked by git, so anything +# written here is published to every clone and lives in history forever. + +# The PocketBase instance. This one is public by design — the browser connects +# to it directly — so the URL itself is not a secret. +NEXT_PUBLIC_PB_URL=https://pocketbase.example.com + +# Superuser credentials, used server-side only by scripts/setup-remote.ts, +# scripts/seed-demo.ts, and the /api/client/* route handlers. +PB_SUPERUSER_EMAIL=you@example.com +PB_SUPERUSER_PASSWORD=replace-me diff --git a/src/lib/board/move.test.ts b/src/lib/board/move.test.ts new file mode 100644 index 0000000..df55d65 --- /dev/null +++ b/src/lib/board/move.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { computeDropPosition, type MovableCard } from "@/lib/board/move"; + +const column: MovableCard[] = [ + { id: "a", position: 1000 }, + { id: "b", position: 2000 }, + { id: "c", position: 3000 }, +]; + +describe("computeDropPosition — moving into a different column", () => { + it("drops at the top", () => { + expect(computeDropPosition(column, 0, null)).toBe(0); + }); + + it("drops in the middle", () => { + expect(computeDropPosition(column, 1, null)).toBe(1500); + }); + + it("drops at the bottom", () => { + expect(computeDropPosition(column, 3, null)).toBe(4000); + }); + + it("drops into an empty column", () => { + expect(computeDropPosition([], 0, null)).toBe(1000); + }); +}); + +describe("computeDropPosition — reordering within the same column", () => { + it("excludes the dragged card from its own neighbours", () => { + // Moving "a" to the end. If "a" were still counted, the last neighbour + // would be "c" at 3000 and the answer 4000 — but "a" leaving the top does + // not change that. The real trap is the middle case below. + expect(computeDropPosition(column, 3, "a")).toBe(4000); + }); + + it("computes the midpoint of the remaining cards, not including itself", () => { + // Moving "c" between "a" and "b". With "c" removed the neighbours are + // 1000 and 2000, so the answer is 1500. If "c" were left in the list the + // index would point at the wrong pair and the card would land back where + // it started. + expect(computeDropPosition(column, 1, "c")).toBe(1500); + }); + + it("moves the first card to the second slot", () => { + // Remaining after removing "a": [b=2000, c=3000]. Index 1 sits between + // them, so 2500. + expect(computeDropPosition(column, 1, "a")).toBe(2500); + }); + + it("returns the card's own position when dropped where it already is", () => { + expect(computeDropPosition(column, 1, "b")).toBe(2000); + }); +}); + +describe("computeDropPosition — guards", () => { + it("clamps an index beyond the end", () => { + expect(computeDropPosition(column, 99, null)).toBe(4000); + }); + + it("clamps a negative index", () => { + expect(computeDropPosition(column, -5, null)).toBe(0); + }); + + it("tolerates an unsorted input list", () => { + const shuffled: MovableCard[] = [ + { id: "c", position: 3000 }, + { id: "a", position: 1000 }, + { id: "b", position: 2000 }, + ]; + expect(computeDropPosition(shuffled, 1, null)).toBe(1500); + }); +}); \ No newline at end of file diff --git a/src/lib/board/move.ts b/src/lib/board/move.ts new file mode 100644 index 0000000..1b5f20a --- /dev/null +++ b/src/lib/board/move.ts @@ -0,0 +1,39 @@ +/** + * Where a dragged card lands. + * + * The one thing worth being careful about: when a card is reordered inside + * its own column, it must be removed from the list before its neighbours are + * read. Leave it in and the index points at the wrong pair, and the card + * snaps back to roughly where it started — a bug that looks like the drag + * "not working" rather than like arithmetic. + */ + +import { positionBetween } from "@/lib/ordering/fractional"; + +export interface MovableCard { + id: string; + position: number; +} + +/** + * @param destinationCards every card currently in the destination column + * @param dropIndex the slot the card is being dropped into, 0-based + * @param draggedCardId the dragged card's id when reordering within the + * same column; null when it comes from another column + */ +export function computeDropPosition( + destinationCards: MovableCard[], + dropIndex: number, + draggedCardId: string | null +): number { + const ordered = [...destinationCards] + .filter((card) => card.id !== draggedCardId) + .sort((a, b) => a.position - b.position); + + const index = Math.max(0, Math.min(dropIndex, ordered.length)); + + const before = index > 0 ? ordered[index - 1].position : null; + const after = index < ordered.length ? ordered[index].position : null; + + return positionBetween(before, after); +} \ No newline at end of file