import { describe, expect, it } from "vitest"; import { dive as baseDive } from "@/test/dive-fixtures"; import type { TripPart } from "@/lib/api/trips"; import { formatDiveDays, tripDiveSections, tripPartForDay, } from "./trip-dive-sections"; const dive = (uuid: string, start_time: string) => baseDive({ uuid, start_time }); const uuids = (sections: ReturnType) => sections.map((section) => section.kind === "part" ? { part: section.partIndex, dives: section.dives.map((d) => d.uuid) } : { loose: section.dives.map((d) => d.uuid) }, ); const DAHAB: TripPart = { location: { name: "Dahab" }, start_date: "2026-04-03", end_date: "2026-04-08", }; const SHARM: TripPart = { location: { name: "Sharm" }, start_date: "2026-04-10", end_date: "2026-04-12", }; describe("tripDiveSections", () => { it("puts each dive under the part its day falls in, between the dives none covers", () => { // Newest first, as the trip lists them. const dives = [ dive("after", "2026-04-14T09:00:00+02:00"), dive("sharm", "2026-04-12T09:00:00+02:00"), dive("gap-2", "2026-04-09T15:00:00+02:00"), dive("gap-1", "2026-04-09T09:00:00+02:00"), dive("dahab-2", "2026-04-08T09:00:00+02:00"), dive("dahab-1", "2026-04-03T09:00:00+02:00"), ]; expect(uuids(tripDiveSections(dives, [DAHAB, SHARM]))).toEqual([ { loose: ["after"] }, { part: 1, dives: ["sharm"] }, { loose: ["gap-2", "gap-1"] }, { part: 0, dives: ["dahab-2", "dahab-1"] }, ]); }); it("reads a dive's day off its own wall clock, not the instant's UTC day", () => { // 00:30 on Apr 3 in Dahab is still Apr 2 in UTC. const dives = [dive("early", "2026-04-03T00:30:00+02:00")]; expect(uuids(tripDiveSections(dives, [DAHAB]))).toEqual([ { part: 0, dives: ["early"] }, ]); }); it("covers a dive with no time recorded, or no offset", () => { const dives = [ dive("naive", "2026-04-05T10:00:00"), dive("date-only", "2026-04-04"), ]; expect(uuids(tripDiveSections(dives, [DAHAB]))).toEqual([ { part: 0, dives: ["naive", "date-only"] }, ]); }); it("runs a part with only a start on from it, and one with only an end back to it", () => { const parts: TripPart[] = [ { location: { name: "Nowhen" } }, { start_date: "2026-09-01" }, ]; const dives = [ dive("oct", "2026-10-03T09:00:00Z"), dive("sep", "2026-09-07T09:00:00Z"), dive("aug", "2026-08-18T09:00:00Z"), ]; expect(uuids(tripDiveSections(dives, parts))).toEqual([ { part: 1, dives: ["oct", "sep"] }, { part: 0, dives: [] }, { loose: ["aug"] }, ]); expect( uuids(tripDiveSections(dives, [{ end_date: "2026-09-07" }])), ).toEqual([{ loose: ["oct"] }, { part: 0, dives: ["sep", "aug"] }]); }); it("gives a day two parts cover to the first of them, and keeps one section per part", () => { const overlapping: TripPart = { start_date: "2026-04-05", end_date: "2026-04-11", }; const dives = [ dive("sharm", "2026-04-11T09:00:00Z"), dive("both", "2026-04-06T09:00:00Z"), dive("dahab", "2026-04-04T09:00:00Z"), dive("only-overlap", "2026-04-09T09:00:00Z"), ]; expect(uuids(tripDiveSections(dives, [DAHAB, overlapping]))).toEqual([ { part: 1, dives: ["sharm", "only-overlap"] }, { part: 0, dives: ["both", "dahab"] }, ]); }); it("slots a part holding no dive in where its last day falls, splitting the dives around it", () => { const empty: TripPart = { location: { name: "Safaga" }, start_date: "2026-04-12", end_date: "2026-04-13", }; const dives = [ dive("after", "2026-04-15T09:00:00Z"), dive("gap-newer", "2026-04-14T09:00:00Z"), dive("gap-older", "2026-04-09T09:00:00Z"), dive("dahab", "2026-04-04T09:00:00Z"), ]; expect(uuids(tripDiveSections(dives, [DAHAB, empty]))).toEqual([ { loose: ["after", "gap-newer"] }, { part: 1, dives: [] }, { loose: ["gap-older"] }, { part: 0, dives: ["dahab"] }, ]); // Between two parts' cards, with no loose dive to split. expect( uuids(tripDiveSections([dives[0], dives[3]], [DAHAB, empty])), ).toEqual([ { loose: ["after"] }, { part: 1, dives: [] }, { part: 0, dives: ["dahab"] }, ]); }); it("puts a part with no dates just above the part it follows", () => { const nowhen: TripPart = { location: { name: "Nowhen" } }; const dives = [ dive("sharm", "2026-04-11T09:00:00Z"), dive("dahab", "2026-04-04T09:00:00Z"), ]; expect(uuids(tripDiveSections(dives, [DAHAB, nowhen, SHARM]))).toEqual([ { part: 2, dives: ["sharm"] }, { part: 1, dives: [] }, { part: 0, dives: ["dahab"] }, ]); // Leading the trip, it goes below the part it precedes. expect(uuids(tripDiveSections(dives, [nowhen, DAHAB, SHARM]))).toEqual([ { part: 2, dives: ["sharm"] }, { part: 1, dives: ["dahab"] }, { part: 0, dives: [] }, ]); }); it("lists every part of a trip with no dives, newest first", () => { expect(uuids(tripDiveSections([], [DAHAB, SHARM]))).toEqual([ { part: 1, dives: [] }, { part: 0, dives: [] }, ]); }); it("leaves out an empty dated part the loaded pages may not have reached yet", () => { const later: TripPart = { location: { name: "Safaga" }, start_date: "2026-04-14", end_date: "2026-04-15", }; const toEnd: TripPart = { location: { name: "Cairo" }, end_date: "2026-04-01", }; const nowhen: TripPart = { location: { name: "Nowhen" } }; const parts = [toEnd, DAHAB, SHARM, later, nowhen]; // The first page holds only the newest part's dives. const loaded = [dive("sharm", "2026-04-11T09:00:00Z")]; // Safaga lies wholly after the oldest loaded day, so its emptiness is // settled; Dahab and Cairo reach back past it and wait. The dateless part // keeps its place beside the part before it. expect(uuids(tripDiveSections(loaded, parts, { complete: false }))).toEqual( [ { part: 4, dives: [] }, { part: 3, dives: [] }, { part: 2, dives: ["sharm"] }, ], ); // A part sharing the oldest loaded day waits too: that day's other dives // may be on the next page. expect( uuids( tripDiveSections( [dive("dahab", "2026-04-03T09:00:00Z")], [DAHAB, SHARM], { complete: false, }, ), ), ).toEqual([ { part: 1, dives: [] }, { part: 0, dives: ["dahab"] }, ]); // Once the list has ended, every part is drawn. expect(uuids(tripDiveSections(loaded, parts))).toEqual([ { part: 4, dives: [] }, { part: 3, dives: [] }, { part: 2, dives: ["sharm"] }, { part: 1, dives: [] }, { part: 0, dives: [] }, ]); }); }); describe("tripPartForDay", () => { it("meets in the middle between a part dated from its start and one dated to its end", () => { const parts: TripPart[] = [ { start_date: "2021-03-20" }, { end_date: "2021-04-06" }, ]; expect(tripPartForDay(parts, "2021-03-25")).toBe(0); expect(tripPartForDay(parts, "2021-04-02")).toBe(1); // Past the other's date, each is the only one reaching the day. expect(tripPartForDay(parts, "2021-03-19")).toBe(1); expect(tripPartForDay(parts, "2021-04-07")).toBe(0); }); it("gives a part with both dates its days over an open-ended one", () => { const parts: TripPart[] = [ { start_date: "2026-09-01" }, { start_date: "2026-09-10", end_date: "2026-09-12" }, ]; expect(tripPartForDay(parts, "2026-09-11")).toBe(1); expect(tripPartForDay(parts, "2026-09-13")).toBe(0); }); it("gives a day two parts reach equally to the first of them", () => { const parts: TripPart[] = [ { start_date: "2026-04-03", end_date: "2026-04-08" }, { start_date: "2026-04-08", end_date: "2026-04-12" }, ]; expect(tripPartForDay(parts, "2026-04-08")).toBe(0); }); }); describe("formatDiveDays", () => { it("spans the dives' first day to their last, on their own wall clocks", () => { // The newest is the 15th in UTC and the 14th where it was made. const dives = [ dive("late", "2026-04-14T23:30:00-03:00"), dive("mid", "2026-04-11T09:00:00+02:00"), dive("early", "2026-04-09"), ]; expect(formatDiveDays(dives)).toBe("Apr 9 - Apr 14, 2026"); }); it("names the one day dives all made on it share", () => { expect( formatDiveDays([ dive("second", "2026-04-09T15:00:00+02:00"), dive("first", "2026-04-09T09:00:00+02:00"), ]), ).toBe("Apr 9, 2026"); }); it("keeps the year on both ends of a run over New Year", () => { expect( formatDiveDays([ dive("new", "2027-01-02T09:00:00+07:00"), dive("old", "2026-12-30T09:00:00+07:00"), ]), ).toBe("Dec 30, 2026 - Jan 2, 2027"); }); });