src/lib/date-time.test.ts
Rama main · réplica de lectura del repositorio original.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
combineStartTime,
daysBetweenIsoDates,
formatDateOnly,
formatDateTime,
formatDateTimeForForm,
formatDaysAgo,
formatDiveDateTime,
formatDiveStartTime,
formatDiveTimeOnly,
formatDurationForForm,
formatDurationHoursMinutes,
formatTripDateRange,
formatUtcOffset,
getBrowserUtcOffsetMinutes,
diveWallClockTime,
isDateOnlyStartTime,
normalizeParsedStartTime,
parseFormDuration,
parseUtcOffsetMinutes,
todayIsoDate,
splitStartTime,
formatClockAtOffset,
} from "./date-time";
describe("formatDateTimeForForm", () => {
it("formats a Date as YYYY-MM-DD HH:mm:ss", () => {
const date = new Date(2024, 5, 1, 9, 5, 3); // June 1, 2024, 09:05:03
expect(formatDateTimeForForm(date)).toBe("2024-06-01 09:05:03");
});
it("zero-pads single-digit month/day/hour/minute/second", () => {
const date = new Date(2024, 0, 2, 3, 4, 5); // Jan 2, 2024, 03:04:05
expect(formatDateTimeForForm(date)).toBe("2024-01-02 03:04:05");
});
});
describe("formatDateOnly", () => {
it("formats a bare YYYY-MM-DD string without timezone shifting", () => {
// Regression: new Date("2024-06-01") parses as UTC midnight, which can
// display as the previous day in negative-UTC-offset timezones.
expect(formatDateOnly("2024-06-01")).toBe("Jun 1, 2024");
});
it("respects custom Intl.DateTimeFormatOptions", () => {
expect(
formatDateOnly("2024-12-25", {
year: "numeric",
month: "long",
day: "numeric",
}),
).toBe("December 25, 2024");
});
it("handles the first and last days of a month", () => {
expect(formatDateOnly("2024-01-01")).toBe("Jan 1, 2024");
expect(formatDateOnly("2024-12-31")).toBe("Dec 31, 2024");
});
});
describe("formatDateTime", () => {
it("formats a full ISO datetime string with date and time in 24h format by default", () => {
expect(formatDateTime("2024-06-01T09:05:00")).toBe("Jun 1, 2024, 09:05");
});
it("respects custom Intl.DateTimeFormatOptions", () => {
expect(
formatDateTime("2024-06-01T09:05:00", {
year: "numeric",
month: "long",
day: "numeric",
}),
).toBe("June 1, 2024");
});
});
describe("parseUtcOffsetMinutes", () => {
it("parses a positive +HH:MM offset", () => {
expect(parseUtcOffsetMinutes("2021-04-04T10:04:47.910+02:00")).toBe(120);
});
it("parses a negative -HH:MM offset", () => {
expect(parseUtcOffsetMinutes("2024-01-01T06:00:00-05:00")).toBe(-300);
});
it("parses a non-hour-aligned offset", () => {
expect(parseUtcOffsetMinutes("2024-01-01T06:00:00+05:45")).toBe(345);
});
it('parses a "Z" suffix as a zero offset', () => {
expect(parseUtcOffsetMinutes("2024-01-01T06:00:00Z")).toBe(0);
});
it("returns null for a naive datetime with no offset", () => {
expect(parseUtcOffsetMinutes("2025-06-03T12:15:33.8")).toBeNull();
expect(parseUtcOffsetMinutes("2024-05-01T09:00:00")).toBeNull();
});
// A bare date ends in "-DD", which reads exactly like a "-HH" offset. Some
// dive computers export `start_time` this way, and treating the day as an
// offset would shift the dive by that many hours.
it("does not read a bare date's day as an offset", () => {
expect(parseUtcOffsetMinutes("2021-04-04")).toBeNull();
expect(parseUtcOffsetMinutes("2024-05-01")).toBeNull();
expect(parseUtcOffsetMinutes("2024-05-12")).toBeNull();
});
it("parses offsets written without a colon, or with hours only", () => {
expect(parseUtcOffsetMinutes("2024-05-01T09:00:00+0200")).toBe(120);
expect(parseUtcOffsetMinutes("2024-05-01T09:00:00+02")).toBe(120);
expect(parseUtcOffsetMinutes("2024-05-01T09:00+02:00")).toBe(120);
});
});
describe("normalizeParsedStartTime", () => {
const browserOffset = formatUtcOffset(getBrowserUtcOffsetMinutes());
it("passes an already offset-aware start_time through untouched", () => {
expect(normalizeParsedStartTime("2021-04-04T10:04:47.910+02:00")).toBe(
"2021-04-04T10:04:47.910+02:00",
);
});
it("gives a naive datetime the browser's offset, keeping its digits", () => {
expect(normalizeParsedStartTime("2025-06-03T12:15:33")).toBe(
`2025-06-03T12:15:33${browserOffset}`,
);
});
// The whole point of the date-only branch: `new Date("2021-04-04")` is UTC
// midnight, so going through it would report 2021-04-03 anywhere west of
// Greenwich. The date must survive verbatim.
it("takes a date-only start_time as local midnight on that same date", () => {
expect(normalizeParsedStartTime("2021-04-04")).toBe(
`2021-04-04T00:00:00${browserOffset}`,
);
expect(normalizeParsedStartTime("2024-05-12")).toBe(
`2024-05-12T00:00:00${browserOffset}`,
);
});
// 02:30 on 2025-03-09 does not exist in New York: the clocks jump from 02:00 to
// 03:00. A local parse lands it on 03:30, an hour off the clock the computer
// recorded. The zone and the clock are both pinned, so the offset is known.
describe("in a zone whose spring-forward gap holds the clock", () => {
const originalTz = process.env.TZ;
beforeEach(() => {
process.env.TZ = "America/New_York";
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2025-06-01T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
process.env.TZ = originalTz;
});
it("keeps a naive 02:30 at 02:30", () => {
expect(normalizeParsedStartTime("2025-03-09T02:30:00")).toBe(
"2025-03-09T02:30:00-04:00",
);
});
});
it("returns undefined for something that isn't a datetime at all", () => {
expect(normalizeParsedStartTime("not a date")).toBeUndefined();
expect(normalizeParsedStartTime("")).toBeUndefined();
});
});
describe("formatUtcOffset", () => {
it("formats a positive offset", () => {
expect(formatUtcOffset(120)).toBe("+02:00");
});
it("formats a negative offset", () => {
expect(formatUtcOffset(-300)).toBe("-05:00");
});
it("formats a zero offset", () => {
expect(formatUtcOffset(0)).toBe("+00:00");
});
it("formats a non-hour-aligned offset", () => {
expect(formatUtcOffset(345)).toBe("+05:45");
});
});
describe("getBrowserUtcOffsetMinutes", () => {
it("negates Date.prototype.getTimezoneOffset()", () => {
const spy = vi
.spyOn(Date.prototype, "getTimezoneOffset")
.mockReturnValue(-120); // e.g. UTC+02:00
expect(getBrowserUtcOffsetMinutes()).toBe(120);
spy.mockRestore();
});
});
describe("splitStartTime/combineStartTime", () => {
it("splits an offset-aware ISO string into wall-clock + offset", () => {
const { localDateTime, offsetMinutes } = splitStartTime(
"2021-04-04T10:04:47+02:00",
);
expect(localDateTime).toBe("2021-04-04 10:04:47");
expect(offsetMinutes).toBe(120);
});
it("never converts through the browser's own timezone", () => {
// Regardless of what Date.prototype.getTimezoneOffset() the environment
// running this test happens to report, the *wall-clock* digits read back
// out must exactly match what was embedded in the string.
const spy = vi
.spyOn(Date.prototype, "getTimezoneOffset")
.mockReturnValue(300); // e.g. UTC-05:00
const { localDateTime, offsetMinutes } = splitStartTime(
"2021-04-04T10:04:47+02:00",
);
expect(localDateTime).toBe("2021-04-04 10:04:47");
expect(offsetMinutes).toBe(120);
spy.mockRestore();
});
// Not `0`: an imported dive whose zone was never recorded arrives naive, and
// collapsing it to UTC moves the clock and invents a zone the first save then
// writes down.
it("reports a null offset for a string carrying none, rather than UTC", () => {
const { offsetMinutes } = splitStartTime("2024-05-01T09:00:00");
expect(offsetMinutes).toBeNull();
});
it("keeps a naive string's wall clock exactly as written", () => {
// Load-bearing only outside UTC: reading a naive value through `new Date()`
// parses it as *local*, shifting these digits by whatever offset the reader
// is sitting in - and leaving them correct, by luck, in UTC. The round-trip
// below is the assertion that fails in every zone.
const { localDateTime } = splitStartTime("2026-04-17T11:49:23");
expect(localDateTime).toBe("2026-04-17 11:49:23");
});
it("round-trips a naive string back to itself, offset and all", () => {
// The one that fails in **every** timezone, UTC included, and the one that
// matters: this is the save path. Under the old code UTC produced
// "2026-04-17T11:49:23+00:00" - the right hour with a fabricated zone welded
// on - and every other zone produced a wrong hour as well.
const original = "2026-04-17T11:49:23";
const { localDateTime, offsetMinutes } = splitStartTime(original);
expect(combineStartTime(localDateTime, offsetMinutes)).toBe(original);
});
it("writes no offset when combining with a null one", () => {
expect(combineStartTime("2026-04-17 11:49:23", null)).toBe(
"2026-04-17T11:49:23",
);
});
it("adopts a real offset chosen from the unknown state, keeping the clock", () => {
// The only exit from the unknown state, and the diver's own deliberate act:
// the wall clock they were shown is the wall clock that gets the zone.
const { localDateTime } = splitStartTime("2026-04-17T11:49:23");
expect(combineStartTime(localDateTime, 180)).toBe(
"2026-04-17T11:49:23+03:00",
);
});
it("combineStartTime is the inverse of splitStartTime", () => {
expect(combineStartTime("2021-04-04 10:04:47", 120)).toBe(
"2021-04-04T10:04:47+02:00",
);
});
it("round-trips split -> combine", () => {
const original = "2024-01-01T06:00:00-05:00";
const { localDateTime, offsetMinutes } = splitStartTime(original);
expect(combineStartTime(localDateTime, offsetMinutes)).toBe(original);
});
// The save path for a dive whose time of day was never recorded, and it fails
// in every zone if the date goes through `Date`: UTC gives back
// "2002-06-18T00:00:00", a midnight the API would store as the dive's time.
it("round-trips a bare date back to itself, with no time and no offset", () => {
const { localDateTime, offsetMinutes } = splitStartTime("2002-06-18");
expect(localDateTime).toBe("2002-06-18");
expect(offsetMinutes).toBeNull();
expect(combineStartTime(localDateTime, offsetMinutes)).toBe("2002-06-18");
});
});
describe("isDateOnlyStartTime", () => {
it("is true for a bare date and nothing else", () => {
expect(isDateOnlyStartTime("2002-06-18")).toBe(true);
expect(isDateOnlyStartTime("2002-06-18T00:00:00")).toBe(false);
expect(isDateOnlyStartTime("2002-06-18T10:00:00+02:00")).toBe(false);
expect(isDateOnlyStartTime("")).toBe(false);
expect(isDateOnlyStartTime(undefined)).toBe(false);
expect(isDateOnlyStartTime(null)).toBe(false);
});
});
describe("a dive whose time of day was never recorded", () => {
it("lists as its date, with no clock", () => {
// The default options ask for an hour and a minute; a bare date has neither
// and would print "00:00" in every zone.
expect(formatDiveDateTime("2002-06-18")).toBe("Jun 18, 2002");
expect(
formatDiveDateTime("2002-06-18", { hour: "2-digit", minute: "2-digit" }),
).toBe("Jun 18, 2002");
expect(
formatDiveDateTime("2002-06-18", {
year: "numeric",
month: "short",
day: "numeric",
}),
).toBe("Jun 18, 2002");
});
it("heads its page with the date and stops", () => {
const line = formatDiveStartTime("2002-06-18");
expect(line).toBe("Tuesday, June 18, 2002");
expect(line).not.toMatch(/ at |UTC/);
});
it("sits at the start of its own day on a timeline", () => {
const at = new Date(diveWallClockTime("2002-06-18"));
expect(at.toISOString()).toBe("2002-06-18T00:00:00.000Z");
});
});
describe("formatDiveDateTime/formatDiveTimeOnly", () => {
it("displays a dive's own offset, not the browser's", () => {
const spy = vi
.spyOn(Date.prototype, "getTimezoneOffset")
.mockReturnValue(300); // e.g. UTC-05:00
expect(
formatDiveTimeOnly("2021-04-04T10:04:47+02:00", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}),
).toBe("10:04");
spy.mockRestore();
});
it("formats the date component in the dive's own timezone", () => {
expect(
formatDiveDateTime("2021-04-04T23:30:00+02:00", {
year: "numeric",
month: "short",
day: "numeric",
}),
).toBe("Apr 4, 2021");
});
});
describe("formatDiveStartTime", () => {
it("puts the date, the wall-clock time and the offset on one line", () => {
expect(formatDiveStartTime("2021-04-04T10:04:47+02:00")).toBe(
"Sunday, April 4, 2021 at 10:04 (UTC+02:00)",
);
});
it("reads the dive's own offset rather than the browser's", () => {
// The whole point of the dive-specific formatters: a dive logged at 09:00 in
// Thailand reads 09:00 from anywhere, and the header has to name the offset it
// is quoting or the number is unverifiable.
const spy = vi
.spyOn(Date.prototype, "getTimezoneOffset")
.mockReturnValue(300); // e.g. UTC-05:00
expect(formatDiveStartTime("2021-04-04T09:00:00+07:00")).toBe(
"Sunday, April 4, 2021 at 09:00 (UTC+07:00)",
);
spy.mockRestore();
});
it("keeps the date on the dive's own side of midnight", () => {
// 23:30+02:00 is the 4th where the dive happened and the 4th at 21:30 UTC, so
// this only bites where the offset would carry it over - here, a negative one.
expect(formatDiveStartTime("2021-04-04T23:30:00-05:00")).toBe(
"Sunday, April 4, 2021 at 23:30 (UTC-05:00)",
);
});
it("claims no zone at all for a dive whose offset was never recorded", () => {
// Fails in every timezone under the old code, which appended "(UTC+00:00)"
// unconditionally - a statement about where the dive happened that nothing
// in the record supports. The clock is what was written down; the zone is
// genuinely not known, and the header says so by saying nothing.
const line = formatDiveStartTime("2026-04-17T11:49:23");
expect(line).toBe("Friday, April 17, 2026 at 11:49");
expect(line).not.toMatch(/UTC/);
});
});
describe("formatDurationForForm", () => {
it("formats seconds as MM:SS", () => {
expect(formatDurationForForm(45 * 60 + 30)).toBe("45:30");
});
it("zero-pads seconds under 10", () => {
expect(formatDurationForForm(60 + 5)).toBe("1:05");
});
it("formats zero seconds", () => {
expect(formatDurationForForm(0)).toBe("0:00");
});
});
describe("parseFormDuration", () => {
it("parses MM:SS into total seconds", () => {
expect(parseFormDuration("45:30")).toBe(45 * 60 + 30);
});
it("reads a colonless value as whole minutes", () => {
expect(parseFormDuration("45")).toBe(45 * 60);
expect(parseFormDuration("0")).toBe(0);
});
it("round-trips with formatDurationForForm", () => {
const seconds = 125;
expect(parseFormDuration(formatDurationForForm(seconds))).toBe(seconds);
});
});
describe("formatDurationHoursMinutes", () => {
it("formats sub-hour durations as minutes only", () => {
expect(formatDurationHoursMinutes(45 * 60)).toBe("45min");
});
it("formats hour-plus durations with remaining minutes", () => {
expect(formatDurationHoursMinutes(90 * 60)).toBe("1h 30min");
});
it("omits minutes when the duration is an exact number of hours", () => {
expect(formatDurationHoursMinutes(120 * 60)).toBe("2h");
});
it("rounds to the nearest minute", () => {
expect(formatDurationHoursMinutes(59.6 * 60)).toBe("1h");
});
});
describe("formatDaysAgo", () => {
it.each([
[0, "today"],
[1, "yesterday"],
[3, "3 days ago"],
[7, "last week"],
[20, "2 weeks ago"],
[45, "last month"],
[200, "6 months ago"],
[400, "last year"],
[1100, "3 years ago"],
])("reads %i days as %s", (days, expected) => {
expect(formatDaysAgo(days)).toBe(expected);
});
it("reads a day ahead of the viewer's as today", () => {
expect(formatDaysAgo(-1)).toBe("today");
});
});
describe("formatTripDateRange", () => {
it("returns undefined when neither date is set", () => {
expect(formatTripDateRange(undefined, undefined)).toBeUndefined();
});
it("omits the year from the start date when both dates share a year", () => {
expect(formatTripDateRange("2024-06-01", "2024-06-08")).toBe(
"Jun 1 - Jun 8, 2024",
);
});
it("includes the year on both dates when they span different years", () => {
expect(formatTripDateRange("2024-12-30", "2025-01-02")).toBe(
"Dec 30, 2024 - Jan 2, 2025",
);
});
it("formats just the start date when only start_date is set", () => {
expect(formatTripDateRange("2024-06-01", undefined)).toBe("Jun 1, 2024");
});
it("formats just the end date when only end_date is set", () => {
expect(formatTripDateRange(undefined, "2024-06-08")).toBe("Jun 8, 2024");
});
});
describe("todayIsoDate", () => {
it("formats as YYYY-MM-DD", () => {
expect(todayIsoDate(new Date(2026, 7, 9, 23, 30))).toBe("2026-08-09");
});
it("uses local date parts, not UTC", () => {
// 23:30 local on the 9th is already the 10th in UTC for any positive offset, so a
// `toISOString()`-based implementation would report tomorrow for half the world.
const late = new Date(2026, 7, 9, 23, 30);
expect(todayIsoDate(late)).toBe(
`${late.getFullYear()}-08-0${late.getDate()}`,
);
});
});
describe("daysBetweenIsoDates", () => {
it("counts forward and backward", () => {
expect(daysBetweenIsoDates("2026-08-10", "2026-08-20")).toBe(10);
expect(daysBetweenIsoDates("2026-08-10", "2026-08-01")).toBe(-9);
expect(daysBetweenIsoDates("2026-08-10", "2026-08-10")).toBe(0);
});
it("crosses month and year boundaries", () => {
expect(daysBetweenIsoDates("2026-12-25", "2027-01-01")).toBe(7);
expect(daysBetweenIsoDates("2028-02-28", "2028-03-01")).toBe(2); // leap year
});
it("is exact across a DST transition", () => {
// Most of Europe springs forward on 29 March 2026 and falls back on 25 October.
// A naive millisecond division would return 6.958… or 7.041… days here; rounding
// local-midnight Dates absorbs the 23- and 25-hour day.
expect(daysBetweenIsoDates("2026-03-26", "2026-04-02")).toBe(7);
expect(daysBetweenIsoDates("2026-10-22", "2026-10-29")).toBe(7);
});
it("never shifts a bare date by a day", () => {
// The `new Date("2026-08-20")` trap: that parses as UTC midnight, which is the
// 19th anywhere west of Greenwich. Building from split parts avoids it, so this
// holds regardless of the machine's timezone.
expect(daysBetweenIsoDates("2026-08-10", "2026-08-20")).toBe(10);
expect(daysBetweenIsoDates("2026-01-01", "2026-01-02")).toBe(1);
});
});
describe("formatClockAtOffset", () => {
it("reads an instant's clock at the offset given, not the one it was written at", () => {
expect(formatClockAtOffset("2026-09-15T06:30:00+00:00", 180)).toBe("09:30");
expect(formatClockAtOffset("2026-09-15T09:30:00+03:00", 180)).toBe("09:30");
expect(formatClockAtOffset("2026-01-15T08:00:00Z", 120)).toBe("10:00");
});
it("carries across midnight and a negative offset", () => {
expect(formatClockAtOffset("2026-09-15T02:15:00Z", -300)).toBe("21:15");
});
});