import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; import { formatDistanceToNowStrict, parseISO } from "date-fns"; import { afterPosition, beforePosition, betweenPosition, comparePositions, positionForSlotNeighbors, type Position, } from "@factory/shared/positions"; /** Merge Tailwind class lists, resolving conflicts (shadcn/ui convention). */ export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } /** "2h ago" style relative timestamp; degrades gracefully on bad input. */ export function formatRelativeTime(iso: string | Date): string { try { const date = typeof iso === "string" ? parseISO(iso) : iso; if (Number.isNaN(date.getTime())) return ""; // date-fns v3 (the pinned major) has no `now` option — "now" is the // process clock. (The v4-style `now` param was dead: no caller passed // it, and it type-checked against a type that doesn't exist here.) const dist = formatDistanceToNowStrict(date, { addSuffix: true }); const [head, ...rest] = dist.split(" "); if (!head || head.length === 0) return dist; return `${head.charAt(0).toUpperCase()}${head.slice(1)} ${rest.join(" ")}`.trim(); } catch { return ""; } } /** Truncate to `max` chars, appending an ellipsis when text was cut. */ export function truncate(text: string, max = 80): string { if (max <= 0) return ""; if (text.length <= max) return text; return `${text.slice(0, max - 1).trimEnd()}…`; } /** * Comparator for fractional (LexoRank-style) positions with a null tolerance: * the shared `comparePositions` owns the lexicographic rule, and a * `null`/`undefined` position sorts LAST so a partial position update never * jumps a row to the top of the list. */ export function comparePositionStrings( a: string | undefined | null, b: string | undefined | null, ): number { if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; return comparePositions(a, b); } /** * Sort a list of items by fractional `position` (stable for equal keys). * Accepts the raw string form so callers don't import the shared type. */ export function sortByPosition(items: readonly T[]): T[] { return [...items].sort((a, b) => comparePositionStrings(a.position ?? null, b.position ?? null)); } /* --------------------------------------------------------------------- * * Fractional-position helpers — thin, null-tolerant wrappers over the * canonical engine in `@factory/shared/positions` (single source of truth, * property-tested there). The UI layer never re-implements the algorithm. * --------------------------------------------------------------------- */ /** * Midpoint position strictly between two positions (`a < b`). Delegates to * the shared `betweenPosition`. Throws when the two positions are adjacent * with no room (empty gap) — the sibling list needs a server-side rebalance. */ export function between(a: Position, b: Position): Position { return betweenPosition(a, b); } /** * Insert a new row's position between its neighbors (or append/prepend). * `before` is the preceding sibling, `after` the following sibling. Delegates * to the shared `positionForSlotNeighbors`; throws on an empty gap (rebalance * required) rather than emitting a mis-ordered position. */ export function positionForInsert( before: string | null | undefined, after: string | null | undefined, ): Position { if (!before && !after) return "a0"; // first sibling of a new list return positionForSlotNeighbors(before ?? null, after ?? null); } /** * Position strictly smaller than `p` (for "insert above"). Delegates to the * shared `beforePosition`. At the very bottom of the range (`"0"`) there is no * smaller position — fall back to `p` itself so callers never crash. */ export function positionBefore(p: Position): Position { try { return beforePosition(p); } catch { return p; } } /** * Position strictly greater than `p` (for "append after"). Delegates to the * shared `afterPosition` (appends the alphabet midpoint `'i'`, keeping maximal * headroom below). */ export function positionAfter(p: Position): Position { return afterPosition(p); } /** Fuzzy-ish subsequence match used by the command palette & slash menu. */ export function fuzzyMatch(needle: string, haystack: string): boolean { const n = needle.trim().toLowerCase(); const h = haystack.toLowerCase(); if (!n) return true; if (h.includes(n)) return true; let i = 0; for (const ch of h) { if (ch === n[i]) i += 1; if (i === n.length) return true; } return false; } /** Clamp helper for presence/pagination math. */ export function clamp(n: number, min: number, max: number): number { return Math.min(max, Math.max(min, n)); }