Private
Public Access
1
0
Files
Kanban/src/lib/utils/fractional-index.ts
T
Catherine Renelle 2f398711c8 Initial implementation: multi-tenant Kanban board (Phase 1)
Complete Phase 1 foundation with working board, column, and card CRUD:
- SvelteKit + TypeScript + Tailwind CSS v4 + adapter-node
- Full Prisma schema with 18 tables (tenants, users, boards, columns, cards, etc.)
- Multi-tenant architecture with hostname-based tenant resolution
- Email/password auth with bcrypt + session cookies
- Board/Column/Card API routes with full CRUD
- Fractional indexing for drag-and-drop ordering
- Board view with svelte-dnd-action for column and card drag-and-drop
- Card modal with inline editing
- Auth pages (login, register)
- Board listing with create form
- RLS policies SQL script for tenant isolation
- Prisma RLS client extensions (forTenant/bypassRLS)
- Permission helpers (canEditBoard, canManageMembers, etc.)
- Socket.IO server setup (dev Vite plugin + production Express server)
- Client-side socket store for real-time updates
- Docker Compose (app + PostgreSQL 16) + multi-stage Dockerfile
- Database seed script (default tenant + admin user)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:58:49 -05:00

109 lines
3.4 KiB
TypeScript

/**
* Lexicographic fractional indexing for ordering items.
*
* Generates string keys that sort lexicographically between any two existing keys,
* avoiding float precision issues and the need for rebalancing.
*
* Based on the approach described in:
* https://observablehq.com/@dgreensp/implementing-fractional-indexing
*/
const BASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const MIDPOINT = 'N'; // roughly middle of uppercase range
/**
* Generate a position string between `before` and `after`.
* - If both are null/undefined, returns a midpoint.
* - If only `before` is provided, returns something after it.
* - If only `after` is provided, returns something before it.
* - If both are provided, returns something between them.
*/
export function generatePosition(before?: string | null, after?: string | null): string {
if (!before && !after) {
return MIDPOINT;
}
if (!before) {
// Generate position before `after`
return midpointBefore(after!);
}
if (!after) {
// Generate position after `before`
return midpointAfter(before);
}
// Generate position between `before` and `after`
return midpointBetween(before, after);
}
function midpointBefore(pos: string): string {
// Find a string that sorts before `pos`
for (let i = 0; i < pos.length; i++) {
const charIndex = BASE_CHARS.indexOf(pos[i]);
if (charIndex > 0) {
const midCharIndex = Math.floor(charIndex / 2);
return pos.slice(0, i) + BASE_CHARS[midCharIndex];
}
}
// All chars are 'A' (first char), prepend 'A' and append midpoint
return pos + MIDPOINT;
}
function midpointAfter(pos: string): string {
// Find a string that sorts after `pos`
for (let i = pos.length - 1; i >= 0; i--) {
const charIndex = BASE_CHARS.indexOf(pos[i]);
if (charIndex < BASE_CHARS.length - 1) {
const midCharIndex = Math.ceil((charIndex + BASE_CHARS.length - 1) / 2);
return pos.slice(0, i) + BASE_CHARS[midCharIndex];
}
}
// All chars are last char, append midpoint
return pos + MIDPOINT;
}
function midpointBetween(before: string, after: string): string {
// Pad the shorter string with the first BASE_CHARS character conceptually
const maxLen = Math.max(before.length, after.length);
for (let i = 0; i < maxLen + 1; i++) {
const beforeChar = i < before.length ? BASE_CHARS.indexOf(before[i]) : 0;
const afterChar = i < after.length ? BASE_CHARS.indexOf(after[i]) : BASE_CHARS.length - 1;
if (beforeChar < afterChar) {
if (afterChar - beforeChar > 1) {
// There's room between these two characters
const midCharIndex = Math.floor((beforeChar + afterChar) / 2);
return before.slice(0, i) + BASE_CHARS[midCharIndex];
} else {
// Adjacent characters — go one level deeper
// Take the before character and find a midpoint in the next level
return before.slice(0, i + 1) + midpointAfter(before.slice(i + 1) || 'A');
}
}
// Characters are equal, continue to next position
}
// Shouldn't reach here if before < after, but safety net
return before + MIDPOINT;
}
/**
* Generate N evenly spaced positions (for initial bulk ordering).
*/
export function generatePositions(count: number): string[] {
if (count === 0) return [];
if (count === 1) return [MIDPOINT];
const positions: string[] = [];
const step = Math.floor(BASE_CHARS.length / (count + 1));
for (let i = 1; i <= count; i++) {
const charIndex = Math.min(step * i, BASE_CHARS.length - 1);
positions.push(BASE_CHARS[charIndex]);
}
return positions;
}