"use client"; import Link from "next/link"; import { Dive, ENTRY_TYPE_LABELS } from "@/lib/api/dives"; import { labelOrFallback } from "@/lib/api/labels"; import { Trip } from "@/lib/api/trips"; import { Course } from "@/lib/api/courses"; import { Contact } from "@/lib/api/contacts"; import type { Person } from "@/lib/api/people"; import { formatContactAddress, formatWebsite } from "@/lib/contact"; import { formatDistance, haversineMeters } from "@/lib/geo-distance"; import { formatCoordinates } from "@/lib/validations/coordinates"; import { CARD_SECTION_CONTENT, Card, CardContent } from "@/components/ui/card"; import { TripLocationsLabel } from "@/components/trips/trip-locations-label"; import { tripPartLocations } from "@/lib/trip-parts"; import { DiveRecordingsCard } from "@/components/dives/dive-recordings-card"; import { PeopleList } from "@/components/people/people-list"; import { fixPoint } from "@/components/dives/dive-map-locations"; import { Building2, Globe, GraduationCap, Luggage, Phone } from "lucide-react"; import { DiveSiteIcon } from "@/components/icons/dive-site-icon"; import { DiveEnvironmentCard } from "@/components/dives/dive-environment-card"; import { DiveCardHeader } from "@/components/dives/dive-card-header"; import type { DiveCardSection } from "@/components/dives/dive-section-dialog"; import { useUnits } from "@/hooks/useUnits"; import { cn } from "@/lib/utils"; import { useWithReturnTo } from "@/hooks/useReturnTo"; interface DiveDetailSidebarProps { dive: Dive; /** Resolved separately from the dive, which stores only the trip's uuid. Null while * loading, when the dive has no trip, or when that lookup failed — all three are * non-fatal and simply hide the trip link. */ trip: Trip | null; /** The training course this dive was part of, resolved the same way and with the * same three meanings for null. */ course: Course | null; /** The dive center that ran the dive, resolved the same way and with the same * three meanings for null. */ contact: Contact | null; /** The diver's people by uuid, for the names behind `dive.people`. A person it * does not hold - still loading, or the read failed - has no row. */ people?: Readonly>; /** Called after a recording or one of its files changes, so the dive can be re-read. */ onRecordingsChanged: () => void; /** Opens a section's edit dialog; absent, the cards have no edit control. */ onEdit?: (section: DiveCardSection) => void; /** Opens the Recordings card's edit dialog. */ onEditRecordings?: () => void; } const NO_PEOPLE: Readonly> = {}; /** * The dive detail page's sidebar: where the dive was, what the water was like, and * what recorded it. * * Each card renders only when it has something to show. */ export function DiveDetailSidebar({ dive, trip, course, contact, people = NO_PEOPLE, onRecordingsChanged, onEdit, onEditRecordings, }: DiveDetailSidebarProps) { const units = useUnits(); const withReturnTo = useWithReturnTo(); const divePeople = dive.people ?? []; const hasPeople = divePeople.some( (reference) => people[reference.person_uuid], ); const contactPlace = formatContactAddress({ city: contact?.address?.city, region: contact?.address?.region, country: contact?.address?.country, }); const entry = fixPoint(dive.entry_latitude, dive.entry_longitude); const exit = fixPoint(dive.exit_latitude, dive.exit_longitude); const entryCoordinates = entry && formatCoordinates(entry.latitude, entry.longitude); const exitCoordinates = exit && formatCoordinates(exit.latitude, exit.longitude); // The hero's map is capped at zoom 9, where a surface swim is well under a pixel, so // the drift between the two fixes is a line of text or it is nothing. const drift = entry && exit ? formatDistance(haversineMeters(entry, exit), units) : null; return (
{/* Its own card rather than a row in "Location" below: a course is not a place, and the card it would otherwise join renders on the strength of the dive having one. A dive with no course looks exactly as it did before courses existed. */} {course && ( onEdit("Training"))} />
Course
{course.name}
)} {(trip || contact || dive.dive_sites.length > 0 || dive.entry_type != null || dive.boat_name != null || entryCoordinates || exitCoordinates) && ( {/* "Location", not the "Trip & Dive Site" this card was called while those were the only two things in it: a dive with GPS but no trip and no site is now one of the cases it renders for, and the blocks inside are each labelled anyway. */} onEdit("Location"))} /> {trip && (
Trip
{/* Only the name links - where a trip went is not a second way to reach it - and the icon sits against that first line rather than the middle of two. */}
{trip.name}
)} {contact && (
Dive center
{/* The two ways to reach it sit under the name at a finger's height, since a dive page on a phone is where a diver goes looking for the shop's number. */}
{contact.name}
{contactPlace && ( {contactPlace} )}
{contact.phone && ( {contact.phone} )} {contact.website && ( {formatWebsite(contact.website)} )}
)} {dive.dive_sites.length > 0 && (
{dive.dive_sites.length > 1 ? "Dive sites" : "Dive site"}
{/* Every site, in the order they were visited, as the trip page lists its parts: the hero and the cards cap the list at the first with a "+N", and this is the one surface with room to name the rest. */}
    {dive.dive_sites.map((site) => (
  • {site.name} {site.location?.name && ( {site.location.name} )}
  • ))}
)} {entryCoordinates && (
Entry
{entryCoordinates}
)} {exitCoordinates && (
Exit
{exitCoordinates}
)} {drift && (
Entry → exit
{drift}
)} {dive.entry_type != null && (
Entry type
{labelOrFallback(ENTRY_TYPE_LABELS, dive.entry_type)}
)} {dive.boat_name != null && (
Boat name
{dive.boat_name}
)}
)} {/* Its own card rather than a row on "Training" or "Location": who the diver was with is a fact about the dive whether or not a shop ran it or a course taught it. */} {hasPeople && ( onEdit("People"))} /> )} onEdit("Environment"))} /> {/* What recorded this dive, and what the account still holds from each */}
); }