// Where a trip went, as text. Every compact surface - the trip card on /trips // and on the Home page, a detail page subtitle - shows the same joined line, so // it is composed once here rather than slightly differently in each of them. interface NamedLocation { name: string; } // What separates one place from the next. A semicolon rather than a comma // because a place's own name carries its country now ("Dahab, Egypt"), so a // comma join would read as one list of five things instead of two places: // "Dahab, Egypt, Sharm El Sheikh, Egypt". The same reason the CSV export joins // a trip's places this way. const PLACE_SEPARATOR = "; "; export interface FormatTripLocationNamesOptions { // Show at most this many names, and count the rest as "+N". Omit to show all. max?: number; } /** * The trip's locations as "Dahab, Egypt +2", or `undefined` when there are * none. * * Callers test it for a place to name - the trip card and the trip page decide * their " ยท " separator by it - rather than checking a length. */ export function formatTripLocationNames( locations?: NamedLocation[] | null, { max }: FormatTripLocationNamesOptions = {}, ): string | undefined { const names = usableNames(locations); if (names.length === 0) return undefined; const limit = resolveLimit(names.length, max); const shown = names.slice(0, limit).join(PLACE_SEPARATOR); const hidden = names.length - Math.min(limit, names.length); return hidden > 0 ? `${shown} +${hidden}` : shown; } /** * Every name in full, for the `title` beside a compacted label - or `undefined` * when that label already shows them all. * * `undefined` rather than the label itself, so the same `max` that produced the * "+N" decides whether there is anything left to reveal: a tooltip repeating * the text under the cursor is worse than no tooltip at all. */ export function formatTripLocationNamesHint( locations?: NamedLocation[] | null, { max }: FormatTripLocationNamesOptions = {}, ): string | undefined { const names = usableNames(locations); const limit = resolveLimit(names.length, max); if (names.length <= limit) return undefined; return names.join(PLACE_SEPARATOR); } // How many names are on screen. Shared rather than repeated in both functions: // the label and the hint disagreeing about it is the whole failure the hint's // `undefined` exists to avoid, and it would look right at either call site. function resolveLimit(count: number, max?: number): number { return max !== undefined && max > 0 ? max : count; } // A name is all a location is guaranteed to have, so a blank one is unusable // rather than merely unhelpful - dropping it beats rendering ", , Bohol". function usableNames(locations?: NamedLocation[] | null): string[] { return (locations ?? []) .map((location) => location.name?.trim()) .filter((name): name is string => !!name); }