Ctrl K
ComunidadGuías…

src/lib/species.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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",
  });
}