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"); }); });