src/lib/trip-dive-sections.ts
Rama main · réplica de lectura del repositorio original.
// A trip page lists its dives under the parts they were made on. Nothing stores
// which part a dive belongs to: it is read off the dive's own day against each
// part's dates, so editing a part's dates regroups the dives with no write.
import type { Dive } from "@/lib/api/dives";
import type { TripPart } from "@/lib/api/trips";
import { formatTripDateRange, splitStartTime } from "@/lib/date-time";
export type TripDiveSection =
| { kind: "part"; part: TripPart; partIndex: number; dives: Dive[] }
| { kind: "loose"; dives: Dive[] };
// Whole days from `from` to `to`, both bare `YYYY-MM-DD`. Built with
// `Date.UTC` from the digits, never `new Date(dateString)` (DECISIONS.md, "Bare
// `YYYY-MM-DD` dates must not go through `new Date(dateString)`").
function daysBetween(from: string, to: string): number {
const utc = (date: string) => {
const [year, month, day] = date.split("-").map(Number);
return Date.UTC(year, month - 1, day);
};
return Math.round((utc(to) - utc(from)) / 86_400_000);
}
/**
* The index of the part a day falls in, or -1 when none does.
*
* A part with both dates covers the days between them, and beats any part with
* one: a side trip dated inside an open-ended stay is the side trip's. A part
* with only a start runs on from it - the stretch the diver is still on - and
* one with only an end runs back to it. Where two such parts both reach a day,
* the one whose date is nearer has it, so a trip's first part dated from its
* start and last part dated to its end meet in the middle. A part with no dates
* covers no day. Ties go to the first part in the diver's order.
*
* The rule has three copies: this one, `opendiving-api`'s `part_for_day`
* (`crud_dives.py`), for the per-part `candidate_count` on a trip read and the
* part scope of `POST /trip/{uuid}/dives`, and opendiving-ios's
* `TripText.partForDay(_:day:)` in OpenDivingKit. A change here is owed in both.
*/
export function tripPartForDay(parts: TripPart[], day: string): number {
let best = -1;
let bestDistance = Infinity;
parts.forEach((part, index) => {
const start = part.start_date;
const end = part.end_date;
let distance: number;
if (start && end) {
if (start > day || day > end) return;
distance = -1;
} else if (start) {
if (start > day) return;
distance = daysBetween(start, day);
} else if (end) {
if (day > end) return;
distance = daysBetween(day, end);
} else {
return;
}
if (distance < bestDistance) {
best = index;
bestDistance = distance;
}
});
return best;
}
function diveDay(dive: Dive): string {
return splitStartTime(dive.start_time).localDateTime.slice(0, 10);
}
/**
* The wall-clock days a run of dives spans, its first to its last, formatted
* as a part's dates are - one day where they all share it.
*/
export function formatDiveDays(dives: Dive[]): string | undefined {
const days = dives.map(diveDay).sort();
const first = days[0];
const last = days[days.length - 1];
return formatTripDateRange(first, last === first ? undefined : last);
}
/**
* The trip's parts and dives as a run of sections, newest first as the dives
* come: a section for every part, and the dives no part's dates cover between
* them, a run of neighbours to one section.
*
* A dive is the part's whose dates cover its own wall-clock day - the day the
* diver was there, not the viewer's - by `tripPartForDay`. A part
* sits where its first dive falls; one holding none, where its last day falls
* among the dives; and one with no dates either, just above the part it follows
* in the diver's order - below the one it precedes when it leads.
*
* `complete: false` is a list with pages still to load. A dated part holding
* no loaded dive is then left out unless every day it covers is later than the
* oldest loaded dive's - its dives may be on the next page, and a part drawn
* empty and then filled says the wrong thing first. A part with only an end
* runs back without limit, so it waits for the list to end.
*/
export function tripDiveSections(
dives: Dive[],
parts: TripPart[],
{ complete = true }: { complete?: boolean } = {},
): TripDiveSection[] {
const partSections = parts.map((part, partIndex) => ({
kind: "part" as const,
part,
partIndex,
dives: [] as Dive[],
}));
// Whether a part holds a dive is only known once every dive is read, so the
// dated parts are kept by their last day and the empty ones slotted in after.
const pending = partSections
.map((section) => ({
section,
day: section.part.end_date ?? section.part.start_date,
}))
.filter((entry): entry is typeof entry & { day: string } => !!entry.day);
const placed = new Set<number>();
const sections: TripDiveSection[] = [];
for (const dive of dives) {
const day = diveDay(dive);
const partIndex = tripPartForDay(parts, day);
if (partIndex !== -1) {
partSections[partIndex].dives.push(dive);
if (!placed.has(partIndex)) {
placed.add(partIndex);
sections.push(partSections[partIndex]);
}
continue;
}
const last = sections[sections.length - 1];
if (last?.kind === "loose") last.dives.push(dive);
else sections.push({ kind: "loose", dives: [dive] });
}
const oldest = dives.length > 0 ? diveDay(dives[dives.length - 1]) : null;
const unsettled = new Set(
complete
? []
: pending
.filter(({ section }) => {
const start = section.part.start_date;
return !start || oldest === null || start <= oldest;
})
.map(({ section }) => section.partIndex),
);
// A dive-less dated part goes above the first section that is wholly older
// than its last day, splitting a run of loose dives where it falls inside one.
for (const { section, day } of pending) {
if (placed.has(section.partIndex) || unsettled.has(section.partIndex)) {
continue;
}
let at = sections.length;
for (let i = 0; i < sections.length; i += 1) {
const current = sections[i];
if (current.kind === "part") {
const newest = current.dives[0];
const currentDay = newest
? diveDay(newest)
: (current.part.end_date ?? current.part.start_date);
if (currentDay && currentDay < day) {
at = i;
break;
}
continue;
}
const split = current.dives.findIndex((dive) => diveDay(dive) < day);
if (split === -1) continue;
if (split > 0) {
sections.splice(i + 1, 0, {
kind: "loose",
dives: current.dives.splice(split),
});
at = i + 1;
} else {
at = i;
}
break;
}
sections.splice(at, 0, section);
placed.add(section.partIndex);
}
// Dateless parts, by their neighbours in the diver's order. Shown newest
// first, the part a dateless one follows sits below it.
for (const section of partSections) {
if (placed.has(section.partIndex) || unsettled.has(section.partIndex)) {
continue;
}
const sectionAt = (partIndex: number) =>
sections.findIndex((s) => s.kind === "part" && s.partIndex === partIndex);
let at = -1;
for (let i = section.partIndex - 1; i >= 0 && at === -1; i -= 1) {
if (placed.has(i)) at = sectionAt(i);
}
if (at === -1) {
for (let i = section.partIndex + 1; i < parts.length; i += 1) {
if (placed.has(i)) {
at = sectionAt(i) + 1;
break;
}
}
}
sections.splice(at === -1 ? sections.length : at, 0, section);
placed.add(section.partIndex);
}
return sections;
}