2f398711c8
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>
109 lines
3.4 KiB
TypeScript
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;
|
|
}
|