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>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user