SPB Git
7.1 KB · 100 lines typescript
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
Files · 2c37cb5a3e2af18e5ba361996d453cab97da82c2
Menu

Theme