1import { clsx, type ClassValue } from "clsx";
2import { twMerge } from "tailwind-merge";
3import { formatDistanceToNowStrict, parseISO } from "date-fns";
4import {
5 afterPosition,
6 beforePosition,
7 betweenPosition,
8 comparePositions,
9 positionForSlotNeighbors,
10 type Position,
11} from "@factory/shared/positions";
12
13
14export function cn(...inputs: ClassValue[]): string {
15 return twMerge(clsx(inputs));
16}
17
18
19export function formatRelativeTime(iso: string | Date): string {
20 try {
21 const date = typeof iso === "string" ? parseISO(iso) : iso;
22 if (Number.isNaN(date.getTime())) return "";
23
24
25
26 const dist = formatDistanceToNowStrict(date, { addSuffix: true });
27 const [head, ...rest] = dist.split(" ");
28 if (!head || head.length === 0) return dist;
29 return `${head.charAt(0).toUpperCase()}${head.slice(1)} ${rest.join(" ")}`.trim();
30 } catch {
31 return "";
32 }
33}
34
35
36export function truncate(text: string, max = 80): string {
37 if (max <= 0) return "";
38 if (text.length <= max) return text;
39 return `${text.slice(0, max - 1).trimEnd()}…`;
40}
41
42
48export function comparePositionStrings(
49 a: string | undefined | null,
50 b: string | undefined | null,
51): number {
52 if (a == null && b == null) return 0;
53 if (a == null) return 1;
54 if (b == null) return -1;
55 return comparePositions(a, b);
56}
57
58
62export function sortByPosition<T extends { position?: string | null }>(items: readonly T[]): T[] {
63 return [...items].sort((a, b) => comparePositionStrings(a.position ?? null, b.position ?? null));
64}
65
66
71
72
77export function between(a: Position, b: Position): Position {
78 return betweenPosition(a, b);
79}
80
81
87export function positionForInsert(
88 before: string | null | undefined,
89 after: string | null | undefined,
90): Position {
91 if (!before && !after) return "a0";
92 return positionForSlotNeighbors(before ?? null, after ?? null);
93}
94
95
100export function positionBefore(p: Position): Position {
101 try {
102 return beforePosition(p);
103 } catch {
104 return p;
105 }
106}
107
108
113export function positionAfter(p: Position): Position {
114 return afterPosition(p);
115}
116
117
118export function fuzzyMatch(needle: string, haystack: string): boolean {
119 const n = needle.trim().toLowerCase();
120 const h = haystack.toLowerCase();
121 if (!n) return true;
122 if (h.includes(n)) return true;
123 let i = 0;
124 for (const ch of h) {
125 if (ch === n[i]) i += 1;
126 if (i === n.length) return true;
127 }
128 return false;
129}
130
131
132export function clamp(n: number, min: number, max: number): number {
133 return Math.min(max, Math.max(min, n));
134}
135