feat(ordering): fractional card positions with rebalance guard
This commit is contained in:
parent
d55103ae2d
commit
88a3761857
3 changed files with 151 additions and 3 deletions
|
|
@ -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
|
||||
87
src/lib/ordering/fractional.test.ts
Normal file
87
src/lib/ordering/fractional.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
61
src/lib/ordering/fractional.ts
Normal file
61
src/lib/ordering/fractional.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Loading…
Reference in a new issue