import { assert, assertArrayIncludes, assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { bookedRangeLabel } from "#shared/date-labels.ts"; import { addDays, addMonthsIso, bookedSpanDays, daysAgo, formatDatetimeLabel, formatDatetimeShort, formatTimeAgo, getAvailableDates, getNextBookableDate, isBookingRangeValid, listingDateToCalendarDate, normalizeDatetime, parseIsoDateParam, widestDatedEntry, } from "#shared/dates.ts"; import { DAY_NAMES, VALID_DAY_NAMES } from "#shared/day-names.ts"; import { todayInTz } from "#shared/timezone.ts"; import { today } from "#test-utils/booking-model-fixtures.ts"; import { testDate } from "#test-utils/dates.ts"; import { testHoliday } from "#test-utils/db-helpers/holidays.ts"; import { testListing } from "#test-utils/factories.ts"; import { testWithSetting, useSetting } from "#test-utils/settings.ts"; describe("dates", () => { useSetting({ timezone: "UTC" }); describe("addDays", () => { test("adds positive days to a date", () => { expect(addDays(testDate("2026-01-01"), 5)).toBe("2026-01-06"); }); test("adds zero days returns same date", () => { expect(addDays(testDate("2026-06-15"), 0)).toBe("2026-06-15"); }); test("handles month boundary", () => { expect(addDays(testDate("2026-01-30"), 3)).toBe("2026-02-02"); }); test("handles year boundary", () => { expect(addDays(testDate("2026-12-30"), 5)).toBe("2027-01-04"); }); test("handles large number of days", () => { expect(addDays(testDate("2026-01-01"), 365)).toBe("2027-01-01"); }); }); describe("addMonthsIso", () => { test("clamps Jan 31 + 1mo to Feb 28", () => { expect(addMonthsIso("2026-01-31T12:00:00.000Z", 1)).toBe( "2026-02-28T12:00:00.000Z", ); }); test("clamps Jan 31 + 1mo to Feb 29 in leap year", () => { expect(addMonthsIso("2024-01-31T12:00:00.000Z", 1)).toBe( "2024-02-29T12:00:00.000Z", ); }); test("clamps to Feb 28 in a non-leap century year (1900)", () => { // 1900 is divisible by 100 but not 400, so it is NOT a leap year. expect(addMonthsIso("1900-01-31T12:00:00.000Z", 1)).toBe( "1900-02-28T12:00:00.000Z", ); }); test("clamps to Feb 29 in a leap century year (2000)", () => { // 2000 is divisible by 400, so it IS a leap year. expect(addMonthsIso("2000-01-31T12:00:00.000Z", 1)).toBe( "2000-02-29T12:00:00.000Z", ); }); test("clamps Mar 31 + 1mo to Apr 30", () => { expect(addMonthsIso("2026-03-31T00:00:00.000Z", 1)).toBe( "2026-04-30T00:00:00.000Z", ); }); test("year rollover Dec 15 + 1mo", () => { expect(addMonthsIso("2026-12-15T00:00:00.000Z", 1)).toBe( "2027-01-15T00:00:00.000Z", ); }); test("12-month renewal", () => { expect(addMonthsIso("2026-05-17T00:00:00.000Z", 12)).toBe( "2027-05-17T00:00:00.000Z", ); }); test("zero months returns canonical ISO string", () => { expect(addMonthsIso("2026-05-17T09:30:00.000Z", 0)).toBe( "2026-05-17T09:30:00.000Z", ); }); test("preserves time component", () => { expect(addMonthsIso("2026-01-15T14:30:45.123Z", 2)).toBe( "2026-03-15T14:30:45.123Z", ); }); }); describe("getAvailableDates", () => { /** A daily listing with a 5-day duration, full week, wide date range — * the fixture two duration-override tests share. */ const dailyOverrideListing = () => testListing({ bookable_days: [...VALID_DAY_NAMES], duration_days: 5, listing_type: "daily", maximum_days_after: 14, minimum_days_before: 0, }); /** The daily-override listing plus a single one-day holiday at day+2 — * the shared arrange behind the duration-override tests below. */ const overrideListingWithDay2Holiday = () => { const holidayDay = addDays(testDate(today()), 2); return { holidays: [ testHoliday({ end_date: holidayDay, name: "H", start_date: holidayDay, }), ], listing: dailyOverrideListing(), }; }; test("returns dates filtered by bookable days", () => { const listing = testListing({ bookable_days: ["Monday"], listing_type: "daily", maximum_days_after: 14, minimum_days_before: 0, }); const dates = getAvailableDates(listing, []); expect(dates.length).toBeGreaterThan(0); // Every returned date should be a Monday for (const d of dates) { const dayName = DAY_NAMES[new Date(`${d}T00:00:00Z`).getUTCDay()]; expect(dayName).toBe("Monday"); } }); const dailyListingWithAllDays = () => testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 7, minimum_days_before: 0, }); const makeHoliday = (name: string, start: string, end: string) => [ { end_date: end, id: 1, name, start_date: start }, ]; test("excludes holidays", () => { const holidayDate = addDays(testDate(today()), 1); const dates = getAvailableDates( dailyListingWithAllDays(), makeHoliday("Holiday", holidayDate, holidayDate), ); assert(!dates.includes(holidayDate)); }); test("excludes holiday ranges", () => { const holidayStart = addDays(testDate(today()), 1); const holidayEnd = addDays(testDate(today()), 3); const dates = getAvailableDates( dailyListingWithAllDays(), makeHoliday("Holiday Range", holidayStart, holidayEnd), ); assert(!dates.includes(holidayStart)); assert(!dates.includes(addDays(testDate(today()), 2))); assert(!dates.includes(holidayEnd)); }); test("respects minimum_days_before", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 10, minimum_days_before: 3, }); const dates = getAvailableDates(listing, []); const earliest = dates[0]!; expect(earliest >= addDays(testDate(today()), 3)).toBe(true); }); test("uses 730 days when maximum_days_after is 0 (unlimited)", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 0, minimum_days_before: 0, }); const dates = getAvailableDates(listing, []); const latest = dates[dates.length - 1]!; // Should extend close to 730 days (2 years) expect(latest >= addDays(testDate(today()), 700)).toBe(true); }); test("respects maximum_days_after when non-zero", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 7, minimum_days_before: 0, }); const dates = getAvailableDates(listing, []); const latest = dates[dates.length - 1]!; expect(latest <= addDays(testDate(today()), 7)).toBe(true); }); test("returns empty array when no bookable days match", () => { // Pick a day name that is not in the 7-day range from today. const listing = testListing({ bookable_days: [], listing_type: "daily", maximum_days_after: 7, minimum_days_before: 0, }); const dates = getAvailableDates(listing, []); expect(dates).toEqual([]); }); test("excludes multi-day start dates whose range covers a holiday", () => { const holidayStart = addDays(testDate(today()), 3); const holidays = [ { end_date: holidayStart, id: 1, name: "Conflict", start_date: holidayStart, }, ]; const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], duration_days: 3, listing_type: "daily", maximum_days_after: 14, minimum_days_before: 0, }); const dates = getAvailableDates(listing, holidays); // day+1 start → covers day 1,2,3 → contains holidayStart → excluded assert(!dates.includes(addDays(testDate(today()), 1))); // day+3 start is the holiday itself → excluded assert(!dates.includes(holidayStart)); // day+4 start → covers day 4,5,6 → no holiday → included assertArrayIncludes(dates, [addDays(testDate(today()), 4)]); }); test("durationOverride filters by the given span instead of duration_days", () => { // duration_days (5) is the max, but a customisable listing's date list // is built per day, so every bookable start appears. const { listing, holidays } = overrideListingWithDay2Holiday(); // With duration 5 the day+1 start spans the holiday and is excluded; // with an override of 1 it is offered. assert( !getAvailableDates(listing, holidays).includes( addDays(testDate(today()), 1), ), ); assertArrayIncludes(getAvailableDates(listing, holidays, 1), [ addDays(testDate(today()), 1), ]); }); test("durationOverride of 0 is clamped to one day, not treated as absent", () => { // An explicit 0 is a provided override (clamped to the 1-day minimum), // distinct from omitting it, which falls back to duration_days. // `0 ?? duration_days` keeps the 0. const { listing, holidays } = overrideListingWithDay2Holiday(); // Override 0 → a 1-day span: the day+1 start clears the day+2 holiday. // Were 0 mistaken for "absent" it would use duration_days (5), which // spans the holiday, and the day would be excluded. assertArrayIncludes(getAvailableDates(listing, holidays, 0), [ addDays(testDate(today()), 1), ]); }); }); describe("isBookingRangeValid", () => { const dailyAllDays = () => testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 10, minimum_days_before: 0, }); test("accepts a holiday-free range within the booking window", () => { expect(isBookingRangeValid(dailyAllDays(), today(), 3, [])).toBe(true); }); test("rejects a range that overlaps a holiday", () => { const holidayDay = addDays(testDate(today()), 2); const holidays = [ { end_date: holidayDay, id: 1, name: "H", start_date: holidayDay }, ]; expect(isBookingRangeValid(dailyAllDays(), today(), 3, holidays)).toBe( false, ); }); test("rejects a range that runs past the booking window", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 2, minimum_days_before: 0, }); expect(isBookingRangeValid(listing, today(), 5, [])).toBe(false); }); test("rejects a start date before the earliest bookable date", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 10, minimum_days_before: 2, }); expect(isBookingRangeValid(listing, today(), 1, [])).toBe(false); }); test("rejects a range that hits a non-bookable weekday", () => { // Only Mondays are bookable, so a 2-day span from a Monday includes // Tuesday and must be rejected. const listing = testListing({ bookable_days: ["Monday"], listing_type: "daily", maximum_days_after: 21, minimum_days_before: 0, }); const monday = getAvailableDates(listing, [])[0]!; expect(isBookingRangeValid(listing, monday, 2, [])).toBe(false); }); }); describe("parseIsoDateParam", () => { test("accepts a well-formed real date", () => { expect(parseIsoDateParam("2026-08-20")).toBe("2026-08-20"); }); test("rejects an absent value", () => { expect(parseIsoDateParam(null)).toBe(null); }); test("rejects a malformed value", () => { expect(parseIsoDateParam("20/08/2026")).toBe(null); expect(parseIsoDateParam("2026-8-20")).toBe(null); }); test("rejects a rolled-over calendar impossibility", () => { expect(parseIsoDateParam("2026-02-30")).toBe(null); }); test("rejects an unparseable month", () => { expect(parseIsoDateParam("2026-99-01")).toBe(null); }); }); describe("bookedRangeLabel", () => { test("is empty without a booked date", () => { expect(bookedRangeLabel(null, null)).toBe(""); }); test("a single booked day reads as one date", () => { expect(bookedRangeLabel("2027-02-02", null)).toBe( "Tuesday 2 February 2027", ); }); test("the stored [start, end) range renders its actual span", () => { expect(bookedRangeLabel("2027-02-02", "2027-02-05")).toBe( "2\u20134 February 2027", ); }); test("a degenerate stored range (one day) reads as one date", () => { expect(bookedRangeLabel("2027-02-02", "2027-02-03")).toBe( "Tuesday 2 February 2027", ); }); test("a legacy row without a stored end falls back to the fixed duration", () => { expect(bookedRangeLabel("2027-02-02", null, 3)).toBe( "2\u20134 February 2027", ); }); test("the stored range outranks the fixed-duration fallback", () => { // Booked 2 days of a 7-day-max customisable listing: the stored range wins. expect(bookedRangeLabel("2027-02-02", "2027-02-04", 7)).toBe( "2\u20133 February 2027", ); }); }); describe("widestDatedEntry", () => { const entry = (date: string | null, endDate: string | null) => ({ attendee: { date, end_date: endDate }, }); test("is null when every entry is date-less", () => { expect(widestDatedEntry([entry(null, null)])).toBeNull(); }); test("picks the entry whose booked range ends last", () => { const narrow = entry("2027-02-02", "2027-02-03"); const wide = entry("2027-02-02", "2027-02-06"); const narrowB = entry("2027-02-02", "2027-02-03"); expect(widestDatedEntry([narrow, wide, narrowB])).toBe(wide); }); test("an end-less dated entry sorts below any ranged stay", () => { const dayOnly = entry("2027-02-02", null); const ranged = entry("2027-02-02", "2027-02-04"); expect(widestDatedEntry([dayOnly, ranged])).toBe(ranged); expect(widestDatedEntry([dayOnly])).toBe(dayOnly); }); }); describe("bookedSpanDays", () => { test("counts the whole days of a [start, end) range", () => { expect( bookedSpanDays("2026-08-01T00:00:00Z", "2026-08-03T00:00:00.000Z"), ).toBe(2); }); test("a one-day range and a degenerate range both read as 1", () => { expect( bookedSpanDays("2026-08-01T00:00:00Z", "2026-08-02T00:00:00.000Z"), ).toBe(1); expect( bookedSpanDays("2026-08-01T00:00:00Z", "2026-08-01T00:00:00.000Z"), ).toBe(1); }); test("a missing start or end reads as 1", () => { expect(bookedSpanDays(null, "2026-08-02T00:00:00Z")).toBe(1); expect(bookedSpanDays("2026-08-01T00:00:00Z", null)).toBe(1); }); }); describe("getNextBookableDate", () => { test("returns the first available date", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 14, minimum_days_before: 0, }); const result = getNextBookableDate(listing, []); expect(result).toBe(today()); }); test("returns null when no bookable days configured", () => { const listing = testListing({ bookable_days: [], listing_type: "daily", maximum_days_after: 7, minimum_days_before: 0, }); expect(getNextBookableDate(listing, [])).toBeNull(); }); test("skips start dates whose multi-day range extends past the window", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], duration_days: 3, listing_type: "daily", maximum_days_after: 4, minimum_days_before: 0, }); const result = getNextBookableDate(listing, []); expect(result).toBe(today()); const dates = getAvailableDates(listing, []); // A 3-day booking can only start on days 0, 1, or 2 — day 3 and 4 // can't fit a 3-day range within the 4-day window. expect(dates.length).toBeLessThanOrEqual(3); assert(!dates.includes(addDays(testDate(today()), 4))); }); test("skips holidays", () => { const todayStr = today(); const holidays = [ { end_date: todayStr, id: 1, name: "Holiday", start_date: todayStr }, ]; const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 7, minimum_days_before: 0, }); const result = getNextBookableDate(listing, holidays); expect(result).toBe(addDays(testDate(todayStr), 1)); }); test("respects minimum_days_before", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 10, minimum_days_before: 3, }); const result = getNextBookableDate(listing, []); expect(result).toBe(addDays(testDate(today()), 3)); }); test("returns null when all dates fall on holidays", () => { const start = addDays(testDate(today()), 1); const end = addDays(testDate(today()), 3); const holidays = [ { end_date: end, id: 1, name: "Long Holiday", start_date: start }, ]; const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 3, minimum_days_before: 1, }); expect(getNextBookableDate(listing, holidays)).toBeNull(); }); test("uses 730 days when maximum_days_after is 0", () => { const listing = testListing({ bookable_days: [...VALID_DAY_NAMES], listing_type: "daily", maximum_days_after: 0, minimum_days_before: 0, }); const result = getNextBookableDate(listing, []); expect(result).toBe(today()); }); test("returns first matching day when only specific days are bookable", () => { const listing = testListing({ bookable_days: ["Monday"], listing_type: "daily", maximum_days_after: 14, minimum_days_before: 0, }); const result = getNextBookableDate(listing, []); expect(result).not.toBeNull(); const dayName = DAY_NAMES[new Date(`${result}T00:00:00Z`).getUTCDay()]; expect(dayName).toBe("Monday"); }); }); describe("normalizeDatetime", () => { test("normalizes datetime-local (16 chars) to full ISO string", () => { const result = normalizeDatetime("2026-06-15T14:30", "date"); expect(result).toBe("2026-06-15T14:30:00.000Z"); }); test("handles datetime with seconds", () => { const result = normalizeDatetime("2026-06-15T14:30:00", "date"); expect(result).toBe("2026-06-15T14:30:00.000Z"); }); test("throws on invalid datetime string", () => { expect(() => normalizeDatetime("not-a-date", "date")).toThrow( "Invalid date", ); }); test("includes the label in the error message", () => { expect(() => normalizeDatetime("bad-value", "closes_at")).toThrow( "Invalid closes_at", ); }); testWithSetting( "converts datetime-local to UTC using timezone", { timezone: "Europe/London" }, () => { // 14:30 BST (June) = 13:30 UTC const result = normalizeDatetime("2026-06-15T14:30", "date"); expect(result).toBe("2026-06-15T13:30:00.000Z"); }, ); }); describe("listingDateToCalendarDate", () => { test("converts UTC datetime to YYYY-MM-DD in UTC", () => { expect(listingDateToCalendarDate("2026-06-15T14:00:00.000Z")).toBe( "2026-06-15", ); }); testWithSetting( "converts UTC datetime to local date in timezone", { timezone: "Europe/London" }, () => { // 23:30 UTC on June 15 = 00:30 BST on June 16 (Europe/London in summer) expect(listingDateToCalendarDate("2026-06-15T23:30:00.000Z")).toBe( "2026-06-16", ); }, ); test("returns null for empty string", () => { expect(listingDateToCalendarDate("")).toBeNull(); }); test("returns null for invalid datetime", () => { expect(listingDateToCalendarDate("not-a-date")).toBeNull(); }); testWithSetting( "returns null for invalid timezone", { timezone: "Invalid/Zone" }, () => { expect( listingDateToCalendarDate("2026-06-15T14:00:00.000Z"), ).toBeNull(); }, ); test("handles midnight UTC", () => { expect(listingDateToCalendarDate("2026-03-01T00:00:00.000Z")).toBe( "2026-03-01", ); }); test("pads single-digit month and day", () => { expect(listingDateToCalendarDate("2026-01-05T12:00:00.000Z")).toBe( "2026-01-05", ); }); }); describe("daysAgo", () => { test("returns null for empty string", () => { expect(daysAgo("")).toBeNull(); }); test("returns null for an invalid date", () => { expect(daysAgo("not-a-date")).toBeNull(); }); test("returns null for today's date", () => { const todayStr = today(); expect(daysAgo(`${todayStr}T12:00:00.000Z`)).toBeNull(); }); test("returns null for future date", () => { const futureStr = addDays(testDate(today()), 5); expect(daysAgo(`${futureStr}T12:00:00.000Z`)).toBeNull(); }); test("returns 1 for yesterday", () => { const yesterdayStr = addDays(testDate(today()), -1); expect(daysAgo(`${yesterdayStr}T12:00:00.000Z`)).toBe(1); }); test("returns correct count for multiple days ago", () => { const pastStr = addDays(testDate(today()), -10); expect(daysAgo(`${pastStr}T12:00:00.000Z`)).toBe(10); }); testWithSetting( "respects timezone when determining past date", { timezone: "Asia/Tokyo" }, () => { // Asia/Tokyo is UTC+9, so 16:00 UTC = 01:00 next day in Tokyo. const todayTokyo = todayInTz("Asia/Tokyo"); const yesterdayTokyo = addDays(testDate(todayTokyo), -1); expect(daysAgo(`${yesterdayTokyo}T16:00:00.000Z`)).toBeNull(); }, ); }); describe("formatTimeAgo", () => { const base = Date.parse("2026-06-15T12:00:00.000Z"); test("returns null for an unparseable timestamp", () => { expect(formatTimeAgo("not-a-date", base)).toBeNull(); }); test("returns null for a future timestamp", () => { expect(formatTimeAgo("2026-06-15T12:00:01.000Z", base)).toBeNull(); }); test("labels a sub-minute span in seconds", () => { expect(formatTimeAgo("2026-06-15T11:59:30.000Z", base)).toBe( "30 seconds ago", ); }); test("uses 'now' at zero elapsed", () => { expect(formatTimeAgo("2026-06-15T12:00:00.000Z", base)).toBe("now"); }); test("uses singular minute at exactly one minute", () => { expect(formatTimeAgo("2026-06-15T11:59:00.000Z", base)).toBe( "1 minute ago", ); }); test("pluralises minutes", () => { expect(formatTimeAgo("2026-06-15T11:45:00.000Z", base)).toBe( "15 minutes ago", ); }); test("uses singular hour at exactly one hour", () => { expect(formatTimeAgo("2026-06-15T11:00:00.000Z", base)).toBe( "1 hour ago", ); }); test("pluralises hours", () => { expect(formatTimeAgo("2026-06-15T09:00:00.000Z", base)).toBe( "3 hours ago", ); }); test("uses 'yesterday' at exactly one day", () => { expect(formatTimeAgo("2026-06-14T12:00:00.000Z", base)).toBe("yesterday"); }); test("pluralises days", () => { expect(formatTimeAgo("2026-06-13T12:00:00.000Z", base)).toBe( "2 days ago", ); }); }); describe("formatDatetimeLabel", () => { test("formats ISO datetime as human-readable string", () => { const label = formatDatetimeLabel("2026-06-15T14:00:00.000Z"); assertStringIncludes(label, "Monday 15 June 2026 at 14:00"); }); test("pads single-digit hours and minutes", () => { const label = formatDatetimeLabel("2026-01-05T09:05:00.000Z"); assertStringIncludes(label, "Monday 5 January 2026 at 09:05"); }); test("handles midnight", () => { const label = formatDatetimeLabel("2026-03-01T00:00:00.000Z"); assertStringIncludes(label, "Sunday 1 March 2026 at 00:00"); }); }); describe("formatDatetimeShort", () => { testWithSetting( "uses configured timezone (Europe/London BST)", { timezone: "Europe/London" }, () => { // 13:00 UTC on 7 April 2026 is during BST (UTC+1) → 14:00 local expect(formatDatetimeShort("2026-04-07T13:00:00.000Z")).toBe( "2026-04-07 14:00", ); }, ); testWithSetting( "uses configured timezone (Europe/London GMT)", { timezone: "Europe/London" }, () => { expect(formatDatetimeShort("2026-01-15T09:05:00.000Z")).toBe( "2026-01-15 09:05", ); }, ); testWithSetting( "uses configured timezone (Asia/Tokyo)", { timezone: "Asia/Tokyo" }, () => { expect(formatDatetimeShort("2026-06-15T05:30:00.000Z")).toBe( "2026-06-15 14:30", ); }, ); }); });