src/lib/date-input.ts
Rama main · réplica de lectura del repositorio original.
// Reading a date a diver typed or pasted, as opposed to one the calendar
// produced. `lib/date-time.ts` formats and shifts dates that are already known
// to be well-formed; everything here is about text that may be nothing of the
// sort.
//
// **Year first, always.** The separators are loose because they cost nothing -
// "2024/06/01", "2024.06.01" and "20240601" are all one date and only one date.
// Day-first and month-first orders are not accepted at any price: "01/06/2024"
// is the 1st of June to most of the world and the 6th of January to the rest,
// and a picker that guesses stores a dive on the wrong day without ever saying
// so. `DatePicker`'s placeholder spells the order out; a field that overrides it
// for something more useful owes the same hint in the copy beside the box.
import { pad } from "@/lib/date-time";
function padYear(value: number): string {
return String(value).padStart(4, "0");
}
// A date part with loose separators, e.g. "2024-6-1", "2024/06/01", "2024.6.01".
const DATE_PART = "(\\d{4})[-/. ](\\d{1,2})[-/. ](\\d{1,2})";
// The separatorless form a spreadsheet or a dive-computer export writes.
const COMPACT_DATE_PART = "(\\d{4})(\\d{2})(\\d{2})";
const DATE_REGEX = new RegExp(`^(?:${DATE_PART}|${COMPACT_DATE_PART})$`);
// The same date, optionally followed by a wall-clock time: "T" or a space, then
// "HH:mm" with optional seconds and an optional fraction that this field has no
// room for and drops.
//
// Anchored at both ends, which is what refuses a trailing "Z" or "+02:00". That
// refusal is the point rather than a limitation: the value this produces is a
// wall clock with no zone attached, and the zone of a pasted ISO string would
// have to be either honoured - which this function cannot do, it returns a
// string with nowhere to put one - or dropped, which moves the dive by the
// offset and says nothing. `DiveStartTimeField` keeps the offset in a control of
// its own for exactly that reason.
const DATE_TIME_REGEX = new RegExp(
`^(?:${DATE_PART}|${COMPACT_DATE_PART})(?:[T ](\\d{1,2}):(\\d{1,2})(?::(\\d{1,2})(?:\\.\\d+)?)?)?$`,
);
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const isLeapYear = (year: number) =>
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
// Rejects the 31st of a 30-day month and the 29th of a common February, rather
// than letting `new Date(2025, 1, 29)` roll it into March and store a day the
// diver never typed. Done in arithmetic because `new Date(year, ...)` remaps a
// two-digit year to the 1900s, and a four-digit "0024" is text this has to read.
function isRealDate(year: number, month: number, day: number): boolean {
if (month < 1 || month > 12 || day < 1) return false;
const last = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
return day <= last;
}
// The three date capture groups of either alternative above - only one of the
// two ever matches, so the first defined triple wins.
function datePartsOf(match: RegExpMatchArray): [number, number, number] {
const [year, month, day] = match[1] ? match.slice(1, 4) : match.slice(4, 7);
return [Number(year), Number(month), Number(day)];
}
/**
* Reads typed or pasted text as a "YYYY-MM-DD" date.
*
* Returns `""` for blank text - the value every date field in this app holds
* when it has no date - and `null` for text that is not a date at all, which is
* a distinction the caller has to keep: `""` is a diver clearing the field and
* `null` is one mid-keystroke.
*/
export function parseDateInput(raw: string): string | null {
const text = raw.trim();
if (!text) return "";
const match = text.match(DATE_REGEX);
if (!match) return null;
const [year, month, day] = datePartsOf(match);
if (!isRealDate(year, month, day)) return null;
return `${padYear(year)}-${pad(month)}-${pad(day)}`;
}
/**
* Reads typed or pasted text as a "YYYY-MM-DD HH:mm:ss" wall-clock date-time.
*
* A time is optional and a bare date means midnight, matching what picking a
* date in the calendar with the time fields untouched already does. Returns
* `""` for blank text and `null` for anything unreadable, as `parseDateInput`
* does.
*/
export function parseDateTimeInput(raw: string): string | null {
const text = raw.trim();
if (!text) return "";
const match = text.match(DATE_TIME_REGEX);
if (!match) return null;
const [year, month, day] = datePartsOf(match);
if (!isRealDate(year, month, day)) return null;
const hours = Number(match[7] ?? 0);
const minutes = Number(match[8] ?? 0);
const seconds = Number(match[9] ?? 0);
// Out of range is a typo, not something to clamp: "25:00" silently becoming
// 23:00 is a dive an hour earlier than the one being logged.
if (hours > 23 || minutes > 59 || seconds > 59) return null;
return `${padYear(year)}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}