feat(ordering): fractional card positions with rebalance guard

This commit is contained in:
Dave 2026-09-16 09:40:09 +02:00
parent d55103ae2d
commit 88a3761857
3 changed files with 151 additions and 3 deletions

View file

@ -1,3 +1,3 @@
NEXT_PUBLIC_PB_URL=http://127.0.0.1:8090
PB_SUPERUSER_EMAIL=admin@example.com
PB_SUPERUSER_PASSWORD=change-me-please
NEXT_PUBLIC_PB_URL=http://pocketbase-roo2fon0xs50369mmf04udx1.169.239.180.130.sslip.io/
PB_SUPERUSER_EMAIL=kanban@apphub.co.za
PB_SUPERUSER_PASSWORD=apphub12345678

View file

@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import {
SPACING,
MIN_GAP,
positionBetween,
needsRebalance,
rebalance,
} from "@/lib/ordering/fractional";
describe("positionBetween", () => {
it("returns SPACING for the first card in an empty column", () => {
expect(positionBetween(null, null)).toBe(SPACING);
});
it("appends one SPACING beyond the last card", () => {
expect(positionBetween(3000, null)).toBe(3000 + SPACING);
});
it("prepends one SPACING before the first card", () => {
expect(positionBetween(null, 1000)).toBe(1000 - SPACING);
});
it("returns the midpoint between two neighbours", () => {
expect(positionBetween(1000, 2000)).toBe(1500);
});
it("throws when the neighbours are out of order", () => {
expect(() => positionBetween(2000, 1000)).toThrow(/out of order/i);
});
});
describe("needsRebalance", () => {
it("is false for a well-spaced column", () => {
expect(needsRebalance([1000, 2000, 3000])).toBe(false);
});
it("is false for zero or one card", () => {
expect(needsRebalance([])).toBe(false);
expect(needsRebalance([1000])).toBe(false);
});
it("is true once an adjacent gap falls below MIN_GAP", () => {
expect(needsRebalance([1000, 1000 + MIN_GAP / 2, 2000])).toBe(true);
});
it("becomes true after repeated midpoint insertion into one slot", () => {
let lo = 1000;
const hi = 2000;
// 30 iterations is enough to drop the gap below MIN_GAP; more would
// exceed float64 precision and cause (lo + hi) / 2 to equal hi exactly,
// at which point positionBetween rightly refuses.
for (let i = 0; i < 30; i++) {
lo = positionBetween(lo, hi);
}
expect(needsRebalance([1000, lo, hi])).toBe(true);
});
});
describe("rebalance", () => {
it("spaces ids evenly starting at SPACING and preserves order", () => {
expect(rebalance(["a", "b", "c"])).toEqual([
{ id: "a", position: 1000 },
{ id: "b", position: 2000 },
{ id: "c", position: 3000 },
]);
});
it("returns an empty list for an empty column", () => {
expect(rebalance([])).toEqual([]);
});
it("produces positions that no longer need rebalancing", () => {
let lo = 1000;
const hi = 2000;
for (let i = 0; i < 30; i++) lo = positionBetween(lo, hi);
const ordered = [
{ id: "a", position: 1000 },
{ id: "b", position: lo },
{ id: "c", position: hi },
].sort((x, y) => x.position - y.position);
const result = rebalance(ordered.map((r) => r.id));
expect(needsRebalance(result.map((r) => r.position))).toBe(false);
expect(result.map((r) => r.id)).toEqual(["a", "b", "c"]);
});
});

View file

@ -0,0 +1,61 @@
/**
* Fractional index arithmetic for card ordering.
*
* A card's `position` is a float. Inserting between two neighbours takes
* their midpoint, so a drag writes a single row instead of renumbering the
* whole column. The tradeoff is finite precision: each midpoint insertion
* into the same slot halves the gap, and around the 50th the gap reaches
* the limit of a float64 and two cards silently collide. `needsRebalance`
* detects that approach; `rebalance` renumbers the column before it happens.
*/
/** Distance between consecutive cards in a freshly numbered column. */
export const SPACING = 1000;
/**
* Smallest adjacent gap tolerated before a column is renumbered. Well above
* float64 epsilon at these magnitudes, so rebalancing happens with room to
* spare rather than at the moment of failure.
*/
export const MIN_GAP = 1e-6;
/**
* The position for a card dropped between `before` and `after`.
* Pass null for either side to mean "no neighbour there".
*/
export function positionBetween(
before: number | null,
after: number | null
): number {
if (before === null && after === null) return SPACING;
if (before === null) return after! - SPACING;
if (after === null) return before + SPACING;
if (before >= after) {
throw new Error(
`positionBetween: neighbours out of order (before=${before}, after=${after})`
);
}
return (before + after) / 2;
}
/** True when any adjacent pair has drifted too close to stay distinguishable. */
export function needsRebalance(positions: number[]): boolean {
const sorted = [...positions].sort((a, b) => a - b);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] - sorted[i - 1] < MIN_GAP) return true;
}
return false;
}
/**
* Evenly spaced positions for a column, given its card ids in display order.
* The caller writes these back; order is preserved exactly.
*/
export function rebalance(
orderedIds: string[]
): Array<{ id: string; position: number }> {
return orderedIds.map((id, index) => ({
id,
position: (index + 1) * SPACING,
}));
}