1
11
12import type { PropertyType } from "@factory/shared/dto";
13
14import type { ValueCodec, PropertyValue, DateRangeValue, FileValue } from "./types.js";
15import { normalizeDate, dateToMs, dateBucket } from "./dates.js";
16
17const optionId = (v: unknown): v is string => typeof v === "string" && v.length > 0;
18
19function optionIds(config: Record<string, unknown>): Set<string> {
20 const opts = Array.isArray(config["options"])
21 ? (config["options"] as Array<Record<string, unknown>>)
22 : [];
23 return new Set(opts.filter((o) => typeof o["id"] === "string").map((o) => o["id"] as string));
24}
25
26function optionName(config: Record<string, unknown>, id: string): string {
27 const opts = Array.isArray(config["options"])
28 ? (config["options"] as Array<Record<string, unknown>>)
29 : [];
30 const hit = opts.find((o) => o["id"] === id);
31 return typeof hit?.["name"] === "string" ? hit["name"] : id;
32}
33
34function optionIndex(config: Record<string, unknown>, id: string): number {
35 const opts = Array.isArray(config["options"])
36 ? (config["options"] as Array<Record<string, unknown>>)
37 : [];
38 return opts.findIndex((o) => o["id"] === id);
39}
40
41export class ValueCodecError extends Error {
42 readonly type: PropertyType;
43
44 constructor(type: PropertyType, message: string) {
45 super(message);
46 this.type = type;
47 this.name = "ValueCodecError";
48 }
49}
50
51const fail = (type: PropertyType, msg: string): never => {
52 throw new ValueCodecError(type, msg);
53};
54
55
56interface NumberConfig {
57 format?: "number" | "integer" | "percent" | "currency";
58 precision?: number;
59 currency?: string;
60}
61
62const numFmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 });
63const intFmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
64
65function displayNumber(v: number, config: Record<string, unknown>, locale: string): string {
66 const cfg = config as NumberConfig;
67 const format = cfg.format ?? "number";
68 const precision = Math.min(Math.max(cfg.precision ?? (format === "integer" ? 0 : 8), 0), 8);
69 if (format === "integer")
70 return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(v);
71 if (format === "percent") return `${numFmt.format(v * 100)} %`;
72 if (format === "currency")
73 return new Intl.NumberFormat(locale, {
74 style: "currency",
75 currency: cfg.currency ?? "USD",
76 maximumFractionDigits: precision,
77 }).format(v);
78 return new Intl.NumberFormat(locale, { maximumFractionDigits: precision }).format(v);
79}
80
81const ISO_DAY = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/;
82
83const isDateStr = (v: unknown): v is string => typeof v === "string" && ISO_DAY.test(v);
84const isDayStr = (v: unknown): v is string =>
85 typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
86
87const codecs: Record<PropertyType, ValueCodec> = {
88
89 title: {
90 type: "title",
91 fromWire: (j) =>
92 j === null || j === undefined
93 ? ""
94 : typeof j === "string"
95 ? j
96 : fail("title", "title value must be a string"),
97 toWire: (v) => (typeof v === "string" ? v : ""),
98 display: (v) => (typeof v === "string" ? v : ""),
99 compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")),
100 },
101 text: {
102 type: "text",
103 fromWire: (j) =>
104 j === null || j === undefined
105 ? ""
106 : typeof j === "string"
107 ? j
108 : fail("text", "text value must be a string"),
109 toWire: (v) => (typeof v === "string" ? v : ""),
110 display: (v) => (typeof v === "string" ? v : ""),
111 compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")),
112 },
113 rich_text: {
114 type: "rich_text",
115
116 fromWire: (j) =>
117 j === null || j === undefined
118 ? ""
119 : typeof j === "string"
120 ? j
121 : fail("rich_text", "rich_text value must be a markdown string"),
122 toWire: (v) => (typeof v === "string" ? v : ""),
123 display: (v) => (typeof v === "string" ? v.replace(/[*_`>#[\]()!-]/g, "").trim() : ""),
124 compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")),
125 },
126 // --- number ----------------------------------------------------------------------
127 number: {
128 type: "number",
129 fromWire: (j) =>
130 j === null || j === undefined
131 ? null
132 : typeof j === "number" && Number.isFinite(j)
133 ? j
134 : typeof j === "string" && j.trim() !== "" && Number.isFinite(Number(j))
135 ? Number(j)
136 : fail("number", "number value must be a finite number"),
137 toWire: (v) => (typeof v === "number" ? v : null),
138 display: (v, config, locale = "en-US") =>
139 typeof v === "number" ? displayNumber(v, config, locale) : "",
140 compare: (a, b) => {
141 const x = typeof a === "number" ? a : NaN;
142 const y = typeof b === "number" ? b : NaN;
143 const xN = Number.isNaN(x),
144 yN = Number.isNaN(y);
145 if (xN && yN) return 0;
146 if (xN) return -1;
147 if (yN) return 1;
148 return x - y;
149 },
150 },
151 // --- select family -----------------------------------------------------------------
152 select: {
153 type: "select",
154 fromWire: (j) => (j === null || j === undefined || !optionId(j) ? null : j),
155 toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null),
156 display: (v, config) => {
157 if (typeof v !== "string" || !optionId(v)) return "";
158 if (!optionIds(config).has(v)) return ""; // orphan option → empty
159 return optionName(config, v);
160 },
161 compare: (a, b, config) => {
162 const ia = typeof a === "string" ? optionIndex(config, a) : -1;
163 const ib = typeof b === "string" ? optionIndex(config, b) : -1;
164 // missing/unknown option sorts last
165 const xa = ia < 0 ? Number.MAX_SAFE_INTEGER : ia;
166 const xb = ib < 0 ? Number.MAX_SAFE_INTEGER : ib;
167 return xa - xb;
168 },
169 },
170 status: {
171 type: "status",
172 fromWire: (j) => (j === null || j === undefined || !optionId(j) ? null : j),
173 toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null),
174 display: (v, config) => {
175 if (typeof v !== "string" || !optionId(v)) return "";
176 if (!optionIds(config).has(v)) return "";
177 return optionName(config, v);
178 },
179 compare: (a, b, config) => {
180 const ia = typeof a === "string" ? optionIndex(config, a) : -1;
181 const ib = typeof b === "string" ? optionIndex(config, b) : -1;
182 const xa = ia < 0 ? Number.MAX_SAFE_INTEGER : ia;
183 const xb = ib < 0 ? Number.MAX_SAFE_INTEGER : ib;
184 return xa - xb;
185 },
186 },
187 multi_select: {
188 type: "multi_select",
189 fromWire: (j) => {
190 if (j === null || j === undefined) return [];
191 if (Array.isArray(j)) return j.filter((x) => optionId(x));
192 if (optionId(j)) return [j];
193 fail("multi_select", "multi_select value must be an array of option ids");
194 },
195 toWire: (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []),
196 display: (v, config) =>
197 Array.isArray(v)
198 ? v
199 .filter((x): x is string => typeof x === "string" && optionIds(config).has(x))
200 .map((x) => optionName(config, x))
201 .join(", ")
202 : "",
203 compare: (a, b, config) => {
204 const rank = (v: PropertyValue) =>
205 Array.isArray(v)
206 ? v
207 .filter((x) => typeof x === "string")
208 .map((x) => {
209 const i = optionIndex(config, x);
210 return i < 0 ? Number.MAX_SAFE_INTEGER : i;
211 })
212 .sort((x, y) => x - y)
213 : [];
214 const ra = rank(a),
215 rb = rank(b);
216 for (let i = 0; i < Math.max(ra.length, rb.length); i++) {
217 const x = ra[i] ?? Number.MAX_SAFE_INTEGER + 1;
218 const y = rb[i] ?? Number.MAX_SAFE_INTEGER + 1;
219 if (x !== y) return x - y;
220 }
221 return 0;
222 },
223 },
224 // --- dates ---------------------------------------------------------------------
225 date: {
226 type: "date",
227 fromWire: (j) =>
228 j === null || j === undefined
229 ? null
230 : isDayStr(j)
231 ? j
232 : fail("date", "date value must be ISO YYYY-MM-DD"),
233 toWire: (v) => (typeof v === "string" && isDayStr(v) ? v : null),
234 display: (v, config, locale = "en-US") => {
235 if (typeof v !== "string" || !isDayStr(v)) return "";
236 const g =
237 typeof config["groupGranularity"] === "string" ? config["groupGranularity"] : undefined;
238 return g === "month"
239 ? dateBucket(v, "month")
240 : g === "week"
241 ? dateBucket(v, "week")
242 : new Date(v + "T00:00:00Z").toLocaleDateString(locale, { timeZone: "UTC" });
243 },
244 compare: (a, b) => {
245 const x = typeof a === "string" ? dateToMs(a) : NaN;
246 const y = typeof b === "string" ? dateToMs(b) : NaN;
247 const xN = Number.isNaN(x),
248 yN = Number.isNaN(y);
249 if (xN && yN) return 0;
250 if (xN) return -1;
251 if (yN) return 1;
252 return x - y;
253 },
254 },
255 date_range: {
256 type: "date_range",
257 fromWire: (j) => {
258 if (j === null || j === undefined) return null;
259 if (typeof j === "object" && !Array.isArray(j)) {
260 const o = j as Record<string, unknown>;
261 const s = o["start"];
262 const e = o["end"];
263 if (isDayStr(s)) {
264 const end = isDayStr(e) ? e : s;
265 if (dateToMs(end) < dateToMs(s)) return { start: end, end: s };
266 return { start: s, end };
267 }
268 }
269 if (isDayStr(j)) return { start: j, end: j };
270 fail("date_range", "date_range value must be {start, end} or a single ISO day");
271 },
272 toWire: (v) => {
273 if (v && typeof v === "object" && !Array.isArray(v)) {
274 const o = v as Record<string, unknown>;
275 if (typeof o["start"] === "string" && typeof o["end"] === "string") return o;
276 }
277 return null;
278 },
279 display: (v, _config, locale = "en-US") => {
280 const vr = v as DateRangeValue | null;
281 if (!vr || typeof vr !== "object") return "";
282 const d = (s: string) =>
283 new Date(s + "T00:00:00Z").toLocaleDateString(locale, {
284 timeZone: "UTC",
285 month: "short",
286 day: "numeric",
287 });
288 return vr.start === vr.end ? d(vr.start) : `${d(vr.start)} – ${d(vr.end)}`;
289 },
290 compare: (a, b) => {
291 const s = (v: PropertyValue) =>
292 v && typeof v === "object" && typeof (v as DateRangeValue).start === "string"
293 ? dateToMs((v as DateRangeValue).start)
294 : NaN;
295 const x = s(a),
296 y = s(b);
297 if (Number.isNaN(x) && Number.isNaN(y)) return 0;
298 if (Number.isNaN(x)) return -1;
299 if (Number.isNaN(y)) return 1;
300 return x - y;
301 },
302 },
303 // --- people / contact -----------------------------------------------------------
304 person: {
305 type: "person",
306 fromWire: (j) => {
307 if (j === null || j === undefined) return [];
308 if (Array.isArray(j)) return j.filter((x) => typeof x === "string" && x.length > 0);
309 if (typeof j === "string" && j.length > 0) return [j];
310 fail("person", "person value must be an array of user ids");
311 },
312 toWire: (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []),
313 display: (v, config) =>
314 Array.isArray(v)
315 ? v
316 .map((x) => {
317 const users = Array.isArray(config["users"])
318 ? (config["users"] as Array<Record<string, unknown>>)
319 : [];
320 const hit = users.find((u) => u["id"] === x);
321 return typeof hit?.["name"] === "string"
322 ? hit["name"]
323 : typeof x === "string"
324 ? x
325 : "";
326 })
327 .filter(Boolean)
328 .join(", ")
329 : "",
330 compare: (a, b) => {
331 const ra = Array.isArray(a) ? a.filter((x) => typeof x === "string") : [];
332 const rb = Array.isArray(b) ? b.filter((x) => typeof x === "string") : [];
333 for (let i = 0; i < Math.max(ra.length, rb.length); i++) {
334 const x = ra[i] ?? "\uffff";
335 const y = rb[i] ?? "\uffff";
336 if (x !== y) return String(x).localeCompare(String(y));
337 }
338 return 0;
339 },
340 },
341 email: {
342 type: "email",
343 fromWire: (j) =>
344 j === null || j === undefined
345 ? null
346 : typeof j === "string"
347 ? j
348 : fail("email", "email must be a string"),
349 toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null),
350 display: (v) => (typeof v === "string" ? v : ""),
351 compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")),
352 },
353 phone: {
354 type: "phone",
355 fromWire: (j) =>
356 j === null || j === undefined
357 ? null
358 : typeof j === "string"
359 ? j
360 : fail("phone", "phone must be a string"),
361 toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null),
362 display: (v) => (typeof v === "string" ? v : ""),
363 compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")),
364 },
365 url: {
366 type: "url",
367 fromWire: (j) =>
368 j === null || j === undefined
369 ? null
370 : typeof j === "string"
371 ? j
372 : fail("url", "url must be a string"),
373 toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null),
374 display: (v) => (typeof v === "string" ? v : ""),
375 compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")),
376 },
377 // --- checkbox --------------------------------------------------------------------
378 checkbox: {
379 type: "checkbox",
380 // missing EAV row = false (TASK-0067 R1).
381 fromWire: (j) =>
382 j === null || j === undefined
383 ? false
384 : typeof j === "boolean"
385 ? j
386 : fail("checkbox", "checkbox must be a boolean"),
387 toWire: (v) => (typeof v === "boolean" ? v : false),
388 display: (v) => (v === true ? "✓" : ""),
389 compare: (a, b) => Number(a === true) - Number(b === true),
390 },
391 // --- files ------------------------------------------------------------------------
392 files: {
393 type: "files",
394 fromWire: (j) => {
395 if (j === null || j === undefined) return [];
396 // Positive guard (not `if (!Array.isArray(j)) fail(…)`): TS 5.6 doesn't
397 // narrow `unknown` through a `never` statement in the negative branch,
398 // so the array path must be the guarded block.
399 if (Array.isArray(j)) {
400 return j
401 .filter(
402 (x): x is FileValue =>
403 x !== null &&
404 typeof x === "object" &&
405 typeof (x as Record<string, unknown>)["id"] === "string",
406 )
407 .map(
408 (x): FileValue => ({
409 id: x.id,
410 name: typeof x.name === "string" ? x.name : x.id,
411 ...(x.url !== undefined ? { url: x.url } : {}),
412 ...(x.size !== undefined ? { size: x.size } : {}),
413 }),
414 );
415 }
416 return fail("files", "files value must be an array");
417 },
418 toWire: (v) => (Array.isArray(v) ? v : []),
419 // files values are FileValue[] (fromWire guarantees the shape); the array
420 // constituents of PropertyValue narrow to the generic `PropertyValue[] |
421
422 display: (v) => (Array.isArray(v) ? (v as FileValue[]).map((f) => f.name).join(", ") : ""),
423 compare: (a, b) => (Array.isArray(a) ? a.length : 0) - (Array.isArray(b) ? b.length : 0),
424 },
425
426 created_time: {
427 type: "created_time",
428 fromWire: (j) =>
429 j === null || j === undefined
430 ? null
431 : typeof j === "number"
432 ? j
433 : typeof j === "string" && !Number.isNaN(Date.parse(j))
434 ? Date.parse(j)
435 : fail("created_time", "created_time must be epoch ms"),
436 toWire: (v) => (typeof v === "number" ? v : null),
437 display: (v, _c, locale = "en-US") =>
438 typeof v === "number" ? new Date(v).toLocaleDateString(locale) : "",
439 compare: (a, b) => (typeof a === "number" ? a : NaN) - (typeof b === "number" ? b : NaN) || 0,
440 },
441 created_by: {
442 type: "created_by",
443 fromWire: (j) =>
444 j === null || j === undefined
445 ? null
446 : typeof j === "string"
447 ? j
448 : fail("created_by", "created_by must be a user id"),
449 toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null),
450 display: (v) => (typeof v === "string" ? v : ""),
451 compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")),
452 },
453 last_edited_time: {
454 type: "last_edited_time",
455 fromWire: (j) =>
456 j === null || j === undefined
457 ? null
458 : typeof j === "number"
459 ? j
460 : typeof j === "string" && !Number.isNaN(Date.parse(j))
461 ? Date.parse(j)
462 : fail("last_edited_time", "last_edited_time must be epoch ms"),
463 toWire: (v) => (typeof v === "number" ? v : null),
464 display: (v, _c, locale = "en-US") =>
465 typeof v === "number" ? new Date(v).toLocaleDateString(locale) : "",
466 compare: (a, b) => (typeof a === "number" ? a : NaN) - (typeof b === "number" ? b : NaN) || 0,
467 },
468 last_edited_by: {
469 type: "last_edited_by",
470 fromWire: (j) =>
471 j === null || j === undefined
472 ? null
473 : typeof j === "string"
474 ? j
475 : fail("last_edited_by", "last_edited_by must be a user id"),
476 toWire: (v) => (typeof v === "string" && v.length > 0 ? v : null),
477 display: (v) => (typeof v === "string" ? v : ""),
478 compare: (a, b) => String(a ?? "").localeCompare(String(b ?? "")),
479 },
480
481 relation: {
482 type: "relation",
483
484 fromWire: (j) =>
485 j === null || j === undefined
486 ? []
487 : Array.isArray(j)
488 ? j.filter((x) => typeof x === "string")
489 : fail("relation", "relation value must be an array of record ids"),
490 toWire: (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : []),
491 display: (v) => (Array.isArray(v) ? String(v.length) : ""),
492 compare: (a, b) => (Array.isArray(a) ? a.length : 0) - (Array.isArray(b) ? b.length : 0),
493 },
494 rollup: {
495 type: "rollup",
496 fromWire: (j) =>
497 j === null || j === undefined
498 ? null
499 : typeof j === "number" || typeof j === "string" || Array.isArray(j)
500 ? j
501 : fail("rollup", "rollup value must be number|string|array"),
502 toWire: (v) => v,
503 display: (v, _c, locale = "en-US") =>
504 Array.isArray(v)
505 ? v.length.toString()
506 : typeof v === "number"
507 ? new Intl.NumberFormat(locale).format(v)
508 : typeof v === "string"
509 ? v
510 : "",
511 compare: (a, b) => {
512 if (Array.isArray(a) && Array.isArray(b)) return a.length - b.length;
513 if (typeof a === "number" && typeof b === "number") return a - b;
514 return String(a ?? "").localeCompare(String(b ?? ""));
515 },
516 },
517 formula: {
518 type: "formula",
519 fromWire: (j) =>
520 j === null ||
521 j === undefined ||
522 typeof j === "number" ||
523 typeof j === "string" ||
524 typeof j === "boolean"
525 ? j
526 : fail("formula", "formula value must be a scalar"),
527 toWire: (v) => v,
528 display: (v, _c, locale = "en-US") =>
529 typeof v === "number"
530 ? new Intl.NumberFormat(locale).format(v)
531 : typeof v === "string"
532 ? v
533 : v === true
534 ? "true"
535 : v === false
536 ? "false"
537 : "",
538 compare: (a, b) => {
539 if (typeof a === "number" && typeof b === "number") return a - b;
540 return String(a ?? "").localeCompare(String(b ?? ""));
541 },
542 },
543 button: {
544 type: "button",
545
546 fromWire: () => null,
547 toWire: () => null,
548 display: () => "",
549 compare: () => 0,
550 },
551
552 unique_id: {
553 type: "unique_id",
554 fromWire: (j) =>
555 j === null || j === undefined
556 ? null
557 : typeof j === "number"
558 ? j
559 : fail("unique_id", "unique_id must be a number"),
560 toWire: (v) => (typeof v === "number" ? v : null),
561 display: (v, config) => {
562 if (typeof v !== "number") return "";
563 const prefix = typeof config["prefix"] === "string" ? config["prefix"] : "";
564 const pad = typeof config["padding"] === "number" ? config["padding"] : 0;
565 const digits = Math.min(Math.max(pad, 0), 12);
566 return `${prefix}${String(v).padStart(digits, "0")}`;
567 },
568 compare: (a, b) => (typeof a === "number" ? a : NaN) - (typeof b === "number" ? b : NaN) || 0,
569 },
570};
571
572export const CODECS = codecs;
573
574
575export function codec(type: PropertyType): ValueCodec {
576 return codecs[type] ?? fail("title", `unknown property type: ${type}`);
577}
578
579
580export const PROPERTY_TYPES: readonly PropertyType[] = Object.keys(codecs) as PropertyType[];
581
582
588const CONVERTIBLE: ReadonlySet<string> = new Set([
589 "text:rich_text",
590 "rich_text:text",
591 "number:text",
592]);
593export function canConvert(from: PropertyType, to: PropertyType): boolean {
594 return CONVERTIBLE.has(`${from}:${to}`);
595}
596
597export function convertValue(
598 from: PropertyType,
599 to: PropertyType,
600 value: PropertyValue,
601): PropertyValue | null {
602 if (!canConvert(from, to)) return null;
603 if (from === "number" && to === "text") return typeof value === "number" ? String(value) : "";
604 return value;
605}
606
607export { normalizeDate, dateToMs, dateBucket };
608