Functional Weave
Code in TypeScript

form.date-parts@1.0.1

impl/typescript.ts

5,891 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { daysInMonth, parseIsoDate } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import type { DateParts, DatePartsCheck, DateRules } from "./form_date_parts_types.ts";

const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const PARTS = ["day", "month", "year"] as const;
const ALL = ["day", "month", "year"];

// ASCII whitespace only, so all three languages trim the same characters.
function isSpace(ch: string): boolean {
  return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
}

function trim(value: string): string {
  let start = 0;
  let end = value.length;
  while (start < end && isSpace(value[start])) start++;
  while (end > start && isSpace(value[end - 1])) end--;
  return value.slice(start, end);
}

// ASCII digits only: "٢" is not a day in any of the three languages.
function isDigits(value: string): boolean {
  if (value === "") return false;
  for (const ch of value) if (ch < "0" || ch > "9") return false;
  return true;
}

// A day or month number: one or two digits.
function smallNumber(value: string, max: number): number | null {
  if (!isDigits(value) || value.length > 2) return null;
  const n = Number(value);
  return n >= 1 && n <= max ? n : null;
}

// GOV.UK asks services to accept month names in full or short ("jan"), in
// any case; "sept" is common enough to accept too.
function monthNumber(value: string): number | null {
  const numeric = smallNumber(value, 12);
  if (numeric !== null) return numeric;
  const lower = value.replace(/[A-Z]/g, (c) => c.toLowerCase());
  if (lower === "sept") return 9;
  for (let i = 0; i < 12; i++) {
    const name = MONTH_NAMES[i].toLowerCase();
    if (lower === name || lower === name.slice(0, 3)) return i + 1;
  }
  return null;
}

// "Date of birth" starts a sentence; after "Enter" it is "date of birth".
// A label that starts with an acronym ("UK arrival date") is left as it is.
function lowerFirst(label: string): string {
  const upper = (c: string | undefined) => c !== undefined && c >= "A" && c <= "Z";
  if (upper(label[0]) && !upper(label[1])) return label[0].toLowerCase() + label.slice(1);
  return label;
}

function longDate(iso: string): string {
  const d = parseIsoDate(iso);
  return `${d.day} ${MONTH_NAMES[d.month - 1]} ${d.year}`;
}

function invalid(message: string, fields: string[]): DatePartsCheck {
  return { valid: false, date: null, message, fields };
}

function joinMissing(missing: string[]): string {
  return missing.length === 1 ? missing[0] : `${missing[0]} and ${missing[1]}`;
}

/**
 * A date typed into three boxes, checked the way the GOV.UK Design System's
 * date input asks: missing or incomplete information first, then a date that
 * cannot be real, then the service's own rules, each with GOV.UK's wording.
 */
export function validateDateParts(parts: DateParts, label: string, rules: DateRules): DatePartsCheck {
  const name = trim(label);
  if (name === "") throw new Error("a date needs a label, such as \"Date of birth\", to write its error messages");
  // Rules are the caller's, so a bad one fails loudly whatever was typed.
  const timing = rules.timing ?? "any";
  if (timing !== "any" && rules.today == null) {
    throw new Error(`rules.today is needed to check that a date is ${timing.replace(/-/g, " ")}`);
  }
  if (rules.today != null) parseIsoDate(rules.today);
  if (rules.notBefore != null) parseIsoDate(rules.notBefore);
  if (rules.notAfter != null) parseIsoDate(rules.notAfter);
  if (rules.notBefore != null && rules.notAfter != null && rules.notBefore > rules.notAfter) {
    throw new Error(`rules.notBefore (${rules.notBefore}) must not be after rules.notAfter (${rules.notAfter})`);
  }

  const day = trim(parts.day);
  const month = trim(parts.month);
  const year = trim(parts.year);
  const typed = { day, month, year };

  const missing = PARTS.filter((p) => typed[p] === "");
  if (missing.length === 3) return invalid(`Enter ${lowerFirst(name)}`, ALL);
  if (missing.length > 0) return invalid(`${name} must include a ${joinMissing(missing)}`, [...missing]);
  if (isDigits(year) && year.length !== 4) return invalid("Year must include 4 numbers", ["year"]);

  const d = smallNumber(day, 31);
  const m = monthNumber(month);
  const y = isDigits(year) && Number(year) >= 1 ? Number(year) : null;
  const wrong = PARTS.filter((p) => (p === "day" ? d : p === "month" ? m : y) === null);
  if (wrong.length > 0) return invalid(`${name} must be a real date`, wrong.length === 1 ? [...wrong] : ALL);
  // 31 April or 29 February 2023: the day is what does not exist.
  if ((d as number) > daysInMonth(y as number, m as number)) return invalid(`${name} must be a real date`, ["day"]);

  const iso = `${year}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
  const today = rules.today ?? "";
  if (timing === "past" && iso >= today) return invalid(`${name} must be in the past`, ALL);
  if (timing === "past-or-today" && iso > today) return invalid(`${name} must be today or in the past`, ALL);
  if (timing === "future" && iso <= today) return invalid(`${name} must be in the future`, ALL);
  if (timing === "future-or-today" && iso < today) return invalid(`${name} must be today or in the future`, ALL);

  const { notBefore, notAfter } = rules;
  if (notBefore != null && notAfter != null) {
    if (iso < notBefore || iso > notAfter) {
      return invalid(`${name} must be between ${longDate(notBefore)} and ${longDate(notAfter)}`, ALL);
    }
  } else if (notBefore != null && iso < notBefore) {
    return invalid(`${name} must be the same as or after ${longDate(notBefore)}`, ALL);
  } else if (notAfter != null && iso > notAfter) {
    return invalid(`${name} must be the same as or before ${longDate(notAfter)}`, ALL);
  }
  return { valid: true, date: iso, message: null, fields: [] };
}