// How a species reads on screen. Composed once here because the surfaces that // show one - the dive form's picker, the dive page's card, the life list and the // species page - have to agree on the same two decisions: what to call a species // that has no common name, and how to say that a row is not a species at all. import { formatDiveDateTime } from "@/lib/date-time"; interface NamedSpecies { scientific_name: string; common_name: string | null; } interface RankedSpecies extends NamedSpecies { rank: string; } /** * What to call this species, given that most of the ocean has no English name. * * The common name when there is one, the scientific name otherwise - never an * empty string or a placeholder. `common_name` is genuinely often null (WoRMS * carries a single Japanese vernacular for the clownfish, and Wikidata's English * label for it *is* the binomial), so the fallback is the normal case for * anything that isn't charismatic megafauna, not an error path. */ export function speciesDisplayName(species: NamedSpecies): string { return species.common_name ?? species.scientific_name; } /** * The scientific name to show *beside* the display name, or `undefined` when it * would just repeat it. * * Exists so a caller can decide with `&&` whether to render the italic * second half of a row at all: when the display name already is the scientific * name, printing it twice is noise. */ export function speciesSecondaryName( species: NamedSpecies, ): string | undefined { return species.common_name ? species.scientific_name : undefined; } /** * The rank, when it is worth showing at all. * * `rank` is mostly WoRMS's open vocabulary passed through unmodified, but a * literal `"unknown"` is not a rank - it is the API's placeholder for "no rank * to report", and it has **two** writers (`species_service.py`'s * `_wikidata_result` and `_worms_taxon`). A Wikidata-only search hit gets it when * its entity carries no taxon-rank statement, or names a rank the API's map does * not cover; and a WoRMS record that simply arrived without the field gets the * same value, because `rank` is a NOT NULL column and the API would rather store * the sentinel than refuse an otherwise good record. Printed as-is it renders * "Manta americana, unknown", which reads as a statement about the animal rather * than about how much is known, so it is dropped alongside the blank. * * That second writer is the one worth remembering, because it is the reason this * is not a picker-only concern: resolve won't invent a row without the * authoritative record, but the authoritative record itself may omit the rank - * so a catalog row can carry the sentinel, and the dive detail card's * `speciesNameWithRank` is a live guard rather than a defensive one. * * Nothing upstream to fix, and no better value to pass through: the API's merge * treats the sentinel as a placeholder rather than a claim, so a real rank from * either source displaces it, and making it nullable would mean a search hit and * the catalog row it becomes disagreed about whether the field is optional. * Dropping it here is the display half of that split. * * How much of a search page carries it is not a property of the data and gets no * number here: search answers with whatever arrived inside its fan-out budget, * so a slow minute at WoRMS leaves more of the page Wikidata-only, and two * consecutive searches for the same word legitimately disagree. It is a tail * case, because the API reads a rank off the Wikidata entity too, which is what * gives the picker's rank caption something to print on a bare upstream row. * * The string is not stable either, and for a reason that has nothing to do with * the sentinel: the two registers can hold different real ranks for the same * taxon and the merge is first-writer-wins, so *Mysticeti* comes back * "Superfamily" or "Parvorder" depending on which side answered. Nothing here * should assert a rank string against live data. `speciesNameWithRank`'s test * for "Species" below survives it in the case that has been measured, where both * spellings sit well above genus and only the caption moves - which is an * observation about that case rather than a guarantee about every taxon. * * Case-insensitive because the vocabulary around it is WoRMS's to recapitalise, * not ours to depend on. */ export function speciesRankLabel(rank: string): string | undefined { const trimmed = rank?.trim(); if (!trimmed || trimmed.toLowerCase() === "unknown") return undefined; return trimmed; } /** * The scientific name, with the rank named when the row is not a species. * * Sightings are deliberately not restricted to species rank - "a moray eel" is * an honest log entry and resolves to the family *Muraenidae* - so a row has to * say when it is broader than it looks. A binomial is self-evidently a species * and gets no suffix; "Muraenidae" alone would read as one, so it becomes * "Muraenidae (Family)". */ export function speciesNameWithRank(species: RankedSpecies): string { const rank = speciesRankLabel(species.rank); if (!rank || rank.toLowerCase() === "species") return species.scientific_name; return `${species.scientific_name} (${rank})`; } /** * A sighting's dive start time as a bare date - a species card's and the species * page's last sighting. * * **The input is a dive `start_time`, so it reads in the zone of the dive behind * it rather than in UTC or the viewer's** - the app-wide contract every * dive-derived surface honours. That is why this goes through * `formatDiveDateTime` and not `formatDateTime`: the latter would re-derive the * *viewer's* local time and report a dive logged in Thailand at the reader's * clock. The error is invisible against any dive logged at `+00:00`, which is * most fixtures and almost no real log. * * An imported dive may carry **no** offset, and then the wall clock is the whole * of what was recorded. `formatDiveDateTime` prints those digits as they stand, * so such a sighting lands on the day the diver wrote down - which is the only * day there is to land on. */ export function speciesSeenOn(startTime: string): string { return formatDiveDateTime(startTime, { year: "numeric", month: "short", day: "numeric", }); }