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: [] };
}