feat(board): drop position arithmetic with same-column handling

This commit is contained in:
Dave 2026-09-16 10:00:57 +02:00
parent 193fe5bd86
commit 5d3323e765
3 changed files with 124 additions and 3 deletions

View file

@ -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
# 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

View file

@ -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);
});
});

39
src/lib/board/move.ts Normal file
View file

@ -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);
}