diff --git a/.env.example b/.env.example index a38fdea..02a6a2a 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file +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 diff --git a/src/lib/ordering/fractional.test.ts b/src/lib/ordering/fractional.test.ts new file mode 100644 index 0000000..acf97dd --- /dev/null +++ b/src/lib/ordering/fractional.test.ts @@ -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"]); + }); +}); \ No newline at end of file diff --git a/src/lib/ordering/fractional.ts b/src/lib/ordering/fractional.ts new file mode 100644 index 0000000..395b389 --- /dev/null +++ b/src/lib/ordering/fractional.ts @@ -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, + })); +} \ No newline at end of file