spb/note-spboucher-ai
Public
1/** Client-safe value helpers: display strings, formula typing, parsing. */2import { nanoid } from "nanoid";3import type { Property, SelectOption, RelationRef, FileRef, OptionColor } from "./types";4import { isError, isPending } from "./types";5import { toDate, type FVal } from "./formula";67export const newId = () => nanoid(8);8export const optionOf = (p: Property, id: unknown): SelectOption | undefined => (p.config.options ?? []).find((o) => o.id === id || o.name === id);9export const isEmpty = (v: unknown): boolean => v == null || v === "" || (Array.isArray(v) && v.length === 0) || isPending(v) || isError(v);10export const numberOf = (v: unknown): number | null => { if (typeof v === "number") return Number.isFinite(v) ? v : null; if (typeof v === "boolean") return v ? 1 : 0; if (typeof v === "string" && v.trim() !== "") { const n = Number(v.replace(/[\s,$%]/g, "")); return Number.isNaN(n) ? null : n; } return null; };11export const dateOf = (v: unknown): Date | null => (typeof v === "string" || typeof v === "number" || v instanceof Date ? toDate(v) : null);12export function fmtNumber(n: number | null, format?: string): string {13 if (n == null) return "";14 switch (format) {15 case "integer": return new Intl.NumberFormat("en-CA", { maximumFractionDigits: 0, useGrouping: false }).format(n);16 case "percent": return new Intl.NumberFormat("en-CA", { style: "percent", maximumFractionDigits: 1 }).format(n / 100);17 case "currency": return new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 2 }).format(n);18 default: return new Intl.NumberFormat("en-CA", { maximumFractionDigits: 6, useGrouping: Math.abs(n) >= 10000 }).format(n);19 }20}21export function fmtDateValue(v: unknown, includeTime?: boolean): string {22 const d = dateOf(v); if (!d) return "";23 const hasTime = includeTime || (typeof v === "string" && v.includes("T") && !/T00:00(:00)?/.test(v));24 return new Intl.DateTimeFormat("en-CA", hasTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }).format(d);25}26export function relativeDate(v: unknown): string {27 const d = dateOf(v); if (!d) return "";28 const days = Math.round((d.setHours(0, 0, 0, 0) - new Date().setHours(0, 0, 0, 0)) / 864e5);29 if (days === 0) return "today"; if (days === 1) return "tomorrow"; if (days === -1) return "yesterday";30 if (Math.abs(days) < 30) return days > 0 ? `in ${days} d` : `${-days} d ago`;31 const m = Math.round(days / 30); if (Math.abs(m) < 12) return m > 0 ? `in ${m} mo` : `${-m} mo ago`;32 const y = Math.round(days / 365); return y > 0 ? `in ${y} y` : `${-y} y ago`;33}34/** Human/CSV string for any stored or computed value. */35export function displayValue(p: Property, v: unknown): string {36 if (isPending(v)) return "…"; if (isError(v)) return `#ERR ${v.error}`;37 if (v == null || v === "") return "";38 switch (p.type) {39 case "number": return fmtNumber(numberOf(v), p.config.format);40 case "date": case "created_time": case "last_edited_time": return fmtDateValue(v, p.config.includeTime || p.type !== "date");41 case "checkbox": return v ? "Yes" : "No";42 case "select": case "status": return optionOf(p, v)?.name ?? String(v);43 case "multi_select": return (Array.isArray(v) ? v : [v]).map((x) => optionOf(p, x)?.name ?? String(x)).join(", ");44 case "relation": return (Array.isArray(v) ? (v as RelationRef[]) : []).map((r) => r.title).join(", ");45 case "file": return (Array.isArray(v) ? (v as FileRef[]) : []).map((f) => f.name).join(", ");46 case "rollup": return Array.isArray(v) ? v.map((x) => (typeof x === "object" && x && "title" in x ? (x as any).title : String(x))).join(", ") : typeof v === "number" ? fmtNumber(v) : String(v);47 case "formula": return typeof v === "boolean" ? (v ? "true" : "false") : typeof v === "number" ? fmtNumber(v) : typeof v === "string" && /^\d{4}-\d{2}-\d{2}T/.test(v) ? fmtDateValue(v) : String(v);48 default: return Array.isArray(v) ? v.map(String).join(", ") : String(v);49 }50}51/** Typed value for the formula engine. */52export function formulaValue(p: Property, v: unknown): FVal {53 if (v == null || isPending(v) || isError(v)) return null;54 switch (p.type) {55 case "number": return numberOf(v);56 case "date": case "created_time": case "last_edited_time": return dateOf(v);57 case "checkbox": return Boolean(v);58 case "select": case "status": return optionOf(p, v)?.name ?? String(v);59 case "multi_select": return (Array.isArray(v) ? v : [v]).map((x) => optionOf(p, x)?.name ?? String(x));60 case "relation": return (Array.isArray(v) ? (v as RelationRef[]) : []).map((r) => r.title);61 case "file": return (Array.isArray(v) ? (v as FileRef[]) : []).map((f) => f.name);62 case "rollup": return Array.isArray(v) ? v.map((x) => (typeof x === "object" && x && "title" in x ? (x as any).title : (x as FVal))) : (v as FVal);63 case "formula": return typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(v) ? dateOf(v) : (v as FVal);64 default: return typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? v : String(v);65 }66}67/** Sort key (null last). */68export function sortKey(p: Property, v: unknown): number | string | null {69 if (isEmpty(v)) return null;70 switch (p.type) {71 case "number": return numberOf(v);72 case "date": case "created_time": case "last_edited_time": return dateOf(v)?.getTime() ?? null;73 case "checkbox": return v ? 1 : 0;74 case "select": case "status": { const opts = p.config.options ?? []; const i = opts.findIndex((o) => o.id === v); return i >= 0 ? i : opts.length; }75 case "rollup": case "formula": return typeof v === "number" ? v : typeof v === "boolean" ? (v ? 1 : 0) : displayValue(p, v).toLowerCase();76 default: return displayValue(p, v).toLowerCase();77 }78}79/** Parse a free-text (CSV/import) value into the stored shape; may append new options to p.config.options. */80export function parseValue(p: Property, raw: string): unknown {81 const s = raw.trim(); if (!s) return null;82 switch (p.type) {83 case "number": return numberOf(s);84 case "checkbox": return /^(true|yes|y|1|x|✓|oui)$/i.test(s);85 case "date": { const d = toDate(s); return d ? (/^\d{4}-\d{2}-\d{2}$/.test(s) ? s : d.toISOString()) : null; }86 case "select": case "status": return ensureOption(p, s).id;87 case "multi_select": return s.split(/[,;|]/).map((x) => x.trim()).filter(Boolean).map((x) => ensureOption(p, x).id);88 case "url": case "text": case "title": case "ai": return s;89 default: return s;90 }91}92export function ensureOption(p: Property, name: string): SelectOption {93 const opts = (p.config.options ??= []); const f = opts.find((o) => o.name.toLowerCase() === name.toLowerCase()); if (f) return f;94 const o: SelectOption = { id: newId(), name, color: pickColor(opts.length) }; opts.push(o); return o;95}96const PALETTE: OptionColor[] = ["blue", "green", "yellow", "orange", "red", "purple", "pink", "teal", "gray"];97export const pickColor = (i: number): OptionColor => PALETTE[i % PALETTE.length];98export const titleProp = (props: Property[]) => props.find((p) => p.type === "title");99export const sortedProps = (props: Property[]) => [...props].sort((a, b) => (a.type === "title" ? -1 : b.type === "title" ? 1 : a.position - b.position));100