Ctrl K
ComunidadGuías…

src/lib/date-time.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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");
  });
});