src/lib/trip-parts.ts
Rama main · réplica de lectura del repositorio original.
// A trip is a sequence of parts, and the two things every surface wants from
// that sequence are the span it covers and the places it went. Both are derived
// here rather than at each call site, because a list cell, a page subtitle and a
// Home card disagreeing about where a trip was is invisible until someone
// holds two of them side by side.
import type { Location } from "@/lib/api/location";
import type { TripPart } from "@/lib/api/trips";
import { formatTripDateRange } from "@/lib/date-time";
export interface TripSpan {
start?: string;
end?: string;
}
/**
* A trip's span: the earliest `start_date` and the latest `end_date` across its
* parts.
*
* Each end is taken independently, matching the API's own derivation. A trip
* whose only dated part carries an end and no start has an end and no start -
* what the data says, rather than an invented range. A trip with no parts, or
* none carrying dates, has neither, which is a state the app has never had
* before: every caller has to answer for it rather than format an absence.
*
* Dates are bare `YYYY-MM-DD`, so they compare as strings - no `Date` is
* constructed here, which is the same reason `formatDateOnly` splits them
* (DECISIONS.md, "Bare `YYYY-MM-DD` dates must not go through
* `new Date(dateString)`").
*/
export function tripSpan(parts?: TripPart[] | null): TripSpan {
let start: string | undefined;
let end: string | undefined;
for (const part of parts ?? []) {
if (part.start_date && (start === undefined || part.start_date < start)) {
start = part.start_date;
}
if (part.end_date && (end === undefined || part.end_date > end)) {
end = part.end_date;
}
}
return { start, end };
}
/**
* A trip's span, formatted the way every surface shows it, or `undefined` when
* its parts carry no dates at all.
*
* `formatTripDateRange` takes two dates and is shared with the courses pages,
* so the reduction happens here and its signature is left alone. `undefined`
* rather than "" for the same reason it gives: a table wants "-" where a
* subtitle wants to disappear.
*/
export function formatTripSpan(parts?: TripPart[] | null): string | undefined {
const { start, end } = tripSpan(parts);
return formatTripDateRange(start, end);
}
/**
* The places a trip went, in the diver's order, skipping the parts that have
* none.
*
* What the label helpers and the maps take: both were written against a flat
* list of locations and still are, because a dive site's page and a dive's
* sidebar hand them theirs (`formatTripLocationNames`,
* `components/map/map-backdrop.tsx`). A placeless part contributes nothing
* here rather than an unnamed gap.
*/
export function tripPartLocations(parts?: TripPart[] | null): Location[] {
return (parts ?? [])
.map((part) => part.location)
.filter((location): location is Location => !!location);
}
/**
* One part's dates as the trip page shows them, or `undefined` when it has
* none. A part with one date reads "From" or "Until" it, rather than as a
* single day: a part with only a start runs on from it, and the trip page
* groups dives that way (`tripPartForDay`).
*/
export function formatTripPartDates(part: TripPart): string | undefined {
const start = part.start_date ?? undefined;
const end = part.end_date ?? undefined;
if (start && !end) return `From ${formatTripDateRange(start)}`;
if (end && !start) return `Until ${formatTripDateRange(undefined, end)}`;
return formatTripDateRange(start, end);
}