src/lib/date-time.ts
Rama main · réplica de lectura del repositorio original.
/** A number as at least two digits, as a date or a clock writes it: 7 -> "07". */
export function pad(value: number): string {
return String(value).padStart(2, "0");
}
// Today as "YYYY-MM-DD" in the *viewer's* timezone. Built from local getters rather
// than `toISOString()`, which would render UTC and so show tomorrow's (or yesterday's)
// date for anyone far enough east or west.
export function todayIsoDate(now: Date = new Date()): string {
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
// Whole days from `from` to `to`, both bare "YYYY-MM-DD" strings. Negative once `to` is
// in the past.
//
// Both dates are constructed from their split parts, never `new Date(dateString)`: a
// bare date string parses as UTC midnight, which lands on the previous day in every
// negative-UTC-offset timezone and would make a due date read as one day closer than it
// is. Same reasoning as `formatDateOnly()`.
//
// Using local midnight for both ends also makes this immune to DST: the two Dates shift
// by the same offset, and rounding the millisecond difference absorbs the one 23- or
// 25-hour day in between.
export function daysBetweenIsoDates(from: string, to: string): number {
const [fromYear, fromMonth, fromDay] = from.split("-").map(Number);
const [toYear, toMonth, toDay] = to.split("-").map(Number);
const start = new Date(fromYear, fromMonth - 1, fromDay);
const end = new Date(toYear, toMonth - 1, toDay);
return Math.round((end.getTime() - start.getTime()) / 86_400_000);
}
// Formats a Date as "YYYY-MM-DD HH:mm:ss", the format used throughout the dive forms.
export function formatDateTimeForForm(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours(),
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// --- UTC-offset-aware dive `start_time` helpers ---
//
// The API's `start_time` is *usually* an offset-aware ISO 8601 string, e.g.
// "2021-04-04T10:04:47.910+02:00" - the offset is the dive's *own* original
// timezone (wherever/whatever logged it), not the viewer's. The dive form
// splits that single string into a "YYYY-MM-DD HH:mm:ss" wall-clock string
// (fed to `DateTimePicker`, same format `formatDateTimeForForm()` produces)
// plus a UTC offset in minutes (fed to `UtcOffsetSelect`), and combines them
// back before submitting - all without ever converting through the
// *browser's* timezone, so a dive always displays in the timezone it was
// actually logged in. See `dive-file-import.tsx` for how a dive-computer
// file's `start_time` (which may or may not carry its own explicit offset)
// feeds into this.
//
// "Usually", not "always": a dive imported from a DiveJSON document may have no
// offset at all. The format defines an offset-less local date-time as a third
// state - the wall clock is recorded and the instant is genuinely unknown - so
// that a converter never has to fabricate one or drop the dive. The API sends
// that state through as a naive `start_time` ("2026-04-17T11:49:23") and accepts
// one back on a dive that already has none.
//
// **That state is a `null` offset, and it has to survive every helper here.**
// It is not the same as UTC and must never be shown or saved as one: printing
// "+00:00" beside the clock is a claim about the world that nothing recorded.
// `parseUtcOffsetMinutes()` returns `null` for it, `splitStartTime()` hands
// that `null` on, `combineStartTime()` writes no offset when it gets one back,
// and `formatDiveStartTime()` prints no zone at all. See DECISIONS.md, "An
// unknown UTC offset is a third state, and `new Date()` never sees an
// offset-less string".
//
// **A fourth state is a bare date** ("2002-06-18"): the day was recorded and the
// time of day was not, so there is no clock to show and no offset to have.
// `isDateOnlyStartTime()` names it, `formatDiveDateTime()` and
// `formatDiveStartTime()` print the date and stop, and `splitStartTime()` hands
// the date back without a time for `combineStartTime()` to return unchanged.
// Midnight is never a stand-in for it: that is a time nobody recorded.
// Matches a trailing UTC offset ("Z", "+HH:MM", "+HHMM", or "+HH") on an ISO
// 8601 datetime string.
//
// The offset must follow a time component, and that is not a nicety: without
// it, `-04` matches the *day* of a bare "2021-04-04", which a dive computer is
// entirely capable of exporting. That date would then be treated as
// offset-aware and shifted by four hours - silently moving the dive - and it
// would satisfy `diveCreateSchema`'s "must include a UTC offset" refine on the
// way through. The `\d{2}:\d{2}` lookbehind-by-capture anchors the match to a
// real "HH:MM" (optionally with seconds/fraction) so only a genuine offset
// counts.
const OFFSET_SUFFIX_REGEX =
/\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:(Z)|([+-])(\d{2}):?(\d{2})?)$/;
// Returns the UTC offset (in minutes) embedded in an ISO 8601 datetime
// string, e.g. 120 for "...+02:00", or `null` if it has none (a "naive"
// datetime with no offset at all, e.g. how some dive computers export
// `start_time`).
export function parseUtcOffsetMinutes(isoString: string): number | null {
const match = isoString.match(OFFSET_SUFFIX_REGEX);
if (!match) return null;
if (match[1] === "Z") return 0;
const sign = match[2] === "-" ? -1 : 1;
const hours = Number(match[3]);
const minutes = Number(match[4] ?? 0);
return sign * (hours * 60 + minutes);
}
// Formats a UTC offset in minutes as "+02:00" / "-05:30" / "+00:00".
export function formatUtcOffset(offsetMinutes: number): string {
const sign = offsetMinutes < 0 ? "-" : "+";
const abs = Math.abs(offsetMinutes);
return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
}
// The browser's current UTC offset in minutes (e.g. 120 for UTC+02:00) - the
// default offset for a new dive, and the fallback when importing a
// dive-computer file whose `start_time` has no explicit offset of its own.
// `Date.prototype.getTimezoneOffset()` returns the *negated* value (minutes
// to ADD to local time to reach UTC), so its sign is flipped here.
export function getBrowserUtcOffsetMinutes(): number {
return -new Date().getTimezoneOffset();
}
// A date-time carrying no offset, e.g. "2026-04-17T11:49:23" or
// "2026-04-17T11:49:23.910". A space instead of the "T" is accepted because that
// is the shape `formatDateTimeForForm()` produces.
const NAIVE_DATE_TIME_REGEX =
/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/;
// Parses a date-time that carries no UTC offset, pinning it to UTC so that the
// *UTC* getters read its digits back unchanged.
//
// `new Date("2026-04-17T11:49:23")` is *specified* to parse as the browser's
// local time, so without the "Z" a dive logged at 11:49 with no recorded offset
// would be shifted by the viewer's own offset on the way through
// `shiftByEmbeddedOffset` - displaying as 08:49 in UTC+03:00 and as 14:49 in
// UTC-03:00, neither of which anything recorded. Appending "Z" removes the
// browser from the calculation entirely.
//
// A bare "YYYY-MM-DD" is left alone: ECMAScript already parses the date-only
// form as UTC, and "2026-04-17Z" is not a date-time at all. The instant that
// gives is the start of the day, which is where a date-only dive sorts; it
// places the dive on a timeline and is never read back as a time of day. Anything
// else is handed to `Date` unchanged, so an unparseable value comes back NaN.
function parseAsWallClockUtc(isoString: string): Date {
return NAIVE_DATE_TIME_REGEX.test(isoString)
? new Date(`${isoString.replace(" ", "T")}Z`)
: new Date(isoString);
}
// Shifts an ISO datetime string's underlying instant by its own embedded UTC
// offset. The result is a `Date` whose *UTC* getters read back the original
// wall-clock time - shared by every function below that needs to read or display
// that wall-clock time without ever converting through the browser's own
// timezone.
//
// `offsetMinutes` is `null` for a string that carries no offset, and callers
// must pass that `null` on rather than collapsing it to zero: the wall clock is
// still exact, and it is only the zone that is unknown.
function shiftByEmbeddedOffset(isoString: string): {
shifted: Date;
offsetMinutes: number | null;
} {
const offsetMinutes = parseUtcOffsetMinutes(isoString);
if (offsetMinutes === null) {
return { shifted: parseAsWallClockUtc(isoString), offsetMinutes: null };
}
return {
shifted: new Date(new Date(isoString).getTime() + offsetMinutes * 60_000),
offsetMinutes,
};
}
// A dive's `start_time` as a timestamp whose *UTC* getters read back the dive's
// own wall-clock time - the numeric counterpart to `formatDiveDateTime()`, for
// code that has to position or bucket a dive on a timeline rather than print it.
//
// Not the same as `new Date(startTime).getTime()`, and the difference is the
// whole point: a dive at 00:30 on New Year's Day in Thailand (+07:00) is still
// the previous year in UTC, and would land in the wrong year on a chart. Read it
// back with `getUTC*` (or format with `timeZone: "UTC"`), never the local
// getters, exactly as `formatDiveDateTime()` does.
export function diveWallClockTime(startTime: string): number {
return shiftByEmbeddedOffset(startTime).shifted.getTime();
}
// Where the API places a dive's `start_time` when it orders dives: the instant
// it names, its wall clock read as UTC where it carries no offset, the start of
// its day where it is a bare date. The dive list, the number suggestion and a
// renumber's scope all compare dives this way.
export function diveStartInstant(startTime: string): number {
return parseAsWallClockUtc(startTime).getTime();
}
// A `start_time` that carries only a date, with no time at all, e.g.
// "2021-04-04".
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
// Whether a dive's `start_time` is a bare date - a dive whose time of day was
// never recorded, which only a logbook import can create. The API marks the
// state by this shape alone; no read carries a flag beside it.
export function isDateOnlyStartTime(
startTime: string | null | undefined,
): startTime is string {
return startTime != null && DATE_ONLY_REGEX.test(startTime);
}
// Splits an ISO 8601 datetime string (e.g. the API's dive `start_time`) into its
// wall-clock component - formatted like `formatDateTimeForForm()` - and its UTC
// offset in minutes, *without* ever converting through the browser's own
// timezone.
//
// `offsetMinutes` is `null` when the string carries no offset. That is the
// dive's unknown-offset state travelling out to `UtcOffsetSelect`'s "Not
// recorded" option, and `combineStartTime()` takes it straight back. A `0` here
// would have an imported dive's first save write both the wrong hour and a
// "+00:00" nobody chose.
//
// A bare date comes back as the date alone, `localDateTime` "YYYY-MM-DD" with no
// time after it, so the first ten characters are still the day and nothing
// downstream is handed a midnight to display or save.
export function splitStartTime(isoString: string): {
localDateTime: string;
offsetMinutes: number | null;
} {
if (isDateOnlyStartTime(isoString)) {
return { localDateTime: isoString, offsetMinutes: null };
}
const { shifted, offsetMinutes } = shiftByEmbeddedOffset(isoString);
return { localDateTime: formatUtcWallClock(shifted), offsetMinutes };
}
// `formatDateTimeForForm()` read through the UTC getters, for a `Date` whose UTC
// fields hold a wall clock - what `shiftByEmbeddedOffset()` and
// `parseAsWallClockUtc()` return.
function formatUtcWallClock(date: Date): string {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(
date.getUTCDate(),
)} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
}
// Inverse of `splitStartTime()`: combines a "YYYY-MM-DD HH:mm:ss" wall-clock
// string (as produced by `DateTimePicker`) and a UTC offset in minutes into the
// single ISO 8601 string the API expects for `start_time`.
//
// A `null` offset writes no offset at all, which is the point: the diver editing
// a dive whose zone was never recorded is saving a wall clock and nothing more.
// Appending "+00:00" instead would be this function inventing a fact, and the
// API would store it - the state is unrecoverable once written.
export function combineStartTime(
localDateTime: string,
offsetMinutes: number | null,
): string {
const wallClock = localDateTime.replace(" ", "T");
return offsetMinutes === null
? wallClock
: `${wallClock}${formatUtcOffset(offsetMinutes)}`;
}
// Normalizes a dive-computer file's raw `start_time` into the single
// offset-aware `start_time` string the form (`DiveStartTimeField`) and the API
// both expect. Dive computers export it in three shapes:
//
// - With an explicit offset, e.g. "2021-04-04T10:04:47.910+02:00" - already the
// shape we want, so it's used as-is.
// - Date-only, e.g. "2021-04-04" - taken as midnight wall-clock, because a new
// dive needs an instant and a diver can correct the time. It is never applied
// to a dive already carrying only its date: `applyParsedDiveToForm` keeps the
// bare date there. Checked *before* the naive branch below, because `new Date("2021-04-04")` parses as
// UTC midnight and reading it back with local getters shows the previous day
// west of Greenwich (see DECISIONS.md, "Bare `YYYY-MM-DD` dates must not go
// through `new Date(dateString)`").
// - Naive/local, e.g. "2025-06-03T12:15:33.8" - its literal date/time digits
// are kept and combined with the browser's current offset, the best available
// default. It's on the diver to correct it if their computer's clock was set
// to a different zone than wherever they are now. The digits are read through
// `parseAsWallClockUtc()`, never local getters: a local parse moves a clock
// that falls in the browser's spring-forward gap by an hour.
//
// Returns `undefined` for anything else.
export function normalizeParsedStartTime(
rawStartTime: string,
): string | undefined {
if (parseUtcOffsetMinutes(rawStartTime) !== null) {
return rawStartTime;
}
if (DATE_ONLY_REGEX.test(rawStartTime)) {
return combineStartTime(
`${rawStartTime} 00:00:00`,
getBrowserUtcOffsetMinutes(),
);
}
if (!NAIVE_DATE_TIME_REGEX.test(rawStartTime)) return undefined;
const wallClock = parseAsWallClockUtc(rawStartTime);
if (Number.isNaN(wallClock.getTime())) return undefined;
return combineStartTime(
formatUtcWallClock(wallClock),
getBrowserUtcOffsetMinutes(),
);
}
// The default `start_time` for a brand-new dive: right now, in the browser's
// own current UTC offset (the best available guess for someone logging a
// dive shortly after diving it).
export function nowStartTime(): string {
return combineStartTime(
formatDateTimeForForm(new Date()),
getBrowserUtcOffsetMinutes(),
);
}
// Formats a dive's `start_time` for display *in the dive's own original
// timezone*, not the viewer's browser timezone - e.g. a dive logged at 09:00
// in Thailand (+07:00) always shows as 09:00, no matter where it's viewed
// from. Only use this for a dive's `start_time`; other timestamps (e.g.
// `created_at`) should keep using `formatDateTime()` below, which
// intentionally shows the viewer's own local time.
//
// A bare-date `start_time` prints its date and no clock, whatever `options`
// asks for: the time-of-day parts are dropped rather than rendered as midnight.
export function formatDiveDateTime(
startTime: string,
options?: Intl.DateTimeFormatOptions,
): string {
const requested = options ?? {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
};
return shiftByEmbeddedOffset(startTime).shifted.toLocaleDateString("en-US", {
hour12: false,
...(isDateOnlyStartTime(startTime) ? withoutClock(requested) : requested),
timeZone: "UTC",
});
}
// `options` with every time-of-day part removed, so that `Intl` prints the date
// alone - and the date parts put back when that leaves none, since
// `toLocaleDateString` would otherwise fall back to a numeric date.
function withoutClock(
options: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormatOptions {
const date = { ...options };
for (const key of CLOCK_OPTIONS) delete date[key];
return Object.keys(date).length > 0
? date
: { year: "numeric", month: "short", day: "numeric" };
}
const CLOCK_OPTIONS = [
"hour",
"minute",
"second",
"dayPeriod",
"hour12",
"hourCycle",
"timeStyle",
"timeZoneName",
] as const satisfies readonly (keyof Intl.DateTimeFormatOptions)[];
// Time-of-day counterpart to `formatDiveDateTime()` - see its docs above.
export function formatDiveTimeOnly(
startTime: string,
options?: Intl.DateTimeFormatOptions,
): string {
return shiftByEmbeddedOffset(startTime).shifted.toLocaleTimeString("en-US", {
hour12: false,
...(options ?? { hour: "2-digit", minute: "2-digit" }),
timeZone: "UTC",
});
}
// An instant's clock time at a given offset, "09:30" - for a time that belongs to
// a record carrying its own offset rather than to the viewer's zone, such as a
// tide turning at a dive whose conditions were resolved at +03:00.
export function formatClockAtOffset(
instant: string,
offsetMinutes: number,
): string {
return new Date(
new Date(instant).getTime() + offsetMinutes * 60_000,
).toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
});
}
// A dive's start as one line: the date, the wall-clock time it was entered in,
// and the offset that clock was on - "Sunday, April 4, 2021 at 10:04
// (UTC+02:00)". For where two dives have to be told apart by when they started
// - the import's match rows - rather than for reading one dive.
//
// Composed from the two functions above rather than asking `Intl` for the date
// and the time in one call. en-US does join them with " at ", but which
// separator a locale reaches for is an ICU detail, and the offset has to be
// appended by hand at the end either way - so the whole line is assembled here
// rather than half of it being inherited.
//
// A `start_time` carrying no offset prints the date and the clock and **stops** -
// no "(UTC...)" parenthetical at all, because a wall clock with no zone beside it
// is exactly what it means. Saying "UTC" would be a claim about where the dive
// happened, which is the one thing nothing here knows.
//
// A bare-date `start_time` stops earlier still, after the date: "Tuesday, June
// 18, 2002" and no "at" at all.
export function formatDiveStartTime(startTime: string): string {
const date = formatDiveDateTime(startTime, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
if (isDateOnlyStartTime(startTime)) return date;
const offsetMinutes = parseUtcOffsetMinutes(startTime);
const zone =
offsetMinutes === null ? "" : ` (UTC${formatUtcOffset(offsetMinutes)})`;
return `${date} at ${formatDiveTimeOnly(startTime)}${zone}`;
}
// Formats a plain "YYYY-MM-DD" date (no time component, e.g. a trip part's
// start or end date) without going through timezone-sensitive UTC parsing -
// using `new Date(dateString)` directly can shift the displayed day by one in
// negative-UTC-offset timezones since bare date strings parse as UTC midnight.
export function formatDateOnly(
dateString: string,
options?: Intl.DateTimeFormatOptions,
): string {
const [year, month, day] = dateString.split("-").map(Number);
return new Date(year, month - 1, day).toLocaleDateString(
"en-US",
options ?? { year: "numeric", month: "short", day: "numeric" },
);
}
// Formats a full ISO datetime string (e.g. any record's `created_at`) for
// display *in the viewer's own browser timezone*. Unlike `formatDateOnly()`,
// this goes through `new Date(dateString)` directly since the input already
// carries a time component (and, typically, a timezone offset) - only bare
// `YYYY-MM-DD` dates need the manual local-construction workaround.
//
// Do NOT use this for a dive's `start_time` - use `formatDiveDateTime()`
// above instead, which displays in the dive's own original timezone rather
// than the viewer's.
export function formatDateTime(
dateString: string,
options?: Intl.DateTimeFormatOptions,
): string {
return new Date(dateString).toLocaleDateString("en-US", {
hour12: false,
...(options ?? {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}),
});
}
// Formats a duration given in seconds as "MM:SS" (the format used in dive
// forms - see `durationField()` in `lib/validations/dive.ts` for the matching
// input validation).
export function formatDurationForForm(durationSeconds: number): string {
const minutes = Math.floor(durationSeconds / 60);
const seconds = durationSeconds % 60;
return `${minutes}:${pad(seconds)}`;
}
// Parses the form representation back into a duration in seconds. Assumes
// `value` already matches `durationField()`'s format, which admits both "MM:SS"
// and a bare "MM" - a colonless entry is whole minutes, so the missing half is
// zero seconds rather than a parse failure. `formatDurationForForm` always
// writes the "MM:SS" half, so a "45" typed once comes back as "45:00" on the
// next read of the dive.
export function parseFormDuration(value: string): number {
const [minutes, seconds] = value.split(":").map(Number);
return minutes * 60 + (seconds ?? 0);
}
// Formats a duration given in seconds as "Xh Ym" (or just "Ymin" under an hour).
export function formatDurationHoursMinutes(durationSeconds: number): string {
const totalMinutes = Math.round(durationSeconds / 60);
if (totalMinutes < 60) {
return `${totalMinutes}min`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes > 0 ? `${hours}h ${minutes}min` : `${hours}h`;
}
const RELATIVE = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
// How long ago a day was, coarsening as it recedes - "today", "yesterday",
// "3 days ago", "last week", "4 months ago", "2 years ago". Never in the
// future: a day ahead of the viewer's - a dive logged in a zone east of them -
// is today.
export function formatDaysAgo(days: number): string {
const ago = Math.max(0, days);
if (ago < 7) return RELATIVE.format(-ago, "day");
if (ago < 30) return RELATIVE.format(-Math.floor(ago / 7), "week");
if (ago < 365) return RELATIVE.format(-Math.floor(ago / 30), "month");
return RELATIVE.format(-Math.floor(ago / 365), "year");
}
// Formats a date range - a trip's span, a course's dates - for display, e.g.
// "Jun 1 - Jun 8, 2024".
// Returns `undefined` if neither date is set.
export function formatTripDateRange(
startDate?: string,
endDate?: string,
options?: Intl.DateTimeFormatOptions,
): string | undefined {
if (!startDate && !endDate) return undefined;
if (startDate && endDate) {
const resolvedOptions = options ?? {
year: "numeric",
month: "short",
day: "numeric",
};
const sameYear = startDate.split("-")[0] === endDate.split("-")[0];
// Drop the year from the start date when both dates fall in the same
// year, e.g. "Jun 1 - Jun 8, 2024" instead of "Jun 1, 2024 - Jun 8, 2024".
const startOptions = sameYear
? { ...resolvedOptions, year: undefined }
: resolvedOptions;
return `${formatDateOnly(startDate, startOptions)} - ${formatDateOnly(endDate, resolvedOptions)}`;
}
return formatDateOnly((startDate ?? endDate) as string, options);
}