Ctrl K
ComunidadGuías…

src/lib/import-changes.ts

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

Archivo original ↗Preguntar a KiBot
import {
  CURRENT_LABELS,
  DIVE_TYPE_LABELS,
  ENTRY_TYPE_LABELS,
  WATER_TYPE_LABELS,
  WAVES_LABELS,
  WEATHER_LABELS,
  type DiveFileInfo,
  type DiveMixture,
  type DiveProfileInfo,
  type Recording,
  type RecordingDecoModel,
  type RecordingDevice,
} from "@/lib/api/dives";
import { gearItemLabel } from "@/lib/api/gear";
import { labelOrFallback } from "@/lib/api/labels";
import type { LogbookImportChange } from "@/lib/api/logbook-import";
import type { SpeciesSummary } from "@/lib/api/species";
import {
  formatDiveDateTime,
  formatDurationHoursMinutes,
} from "@/lib/date-time";
import {
  GAS_ROLE_LABELS,
  TANK_USAGE_LABELS,
  gasName,
} from "@/lib/dive-mixtures";
import { PROFILE_CHANNELS } from "@/lib/dive-profile";
import {
  decoModelLabel,
  MODE_LABELS,
  recordingDeviceLabel,
  SALINITY_LABELS,
  UNNAMED_DEVICE_LABEL,
} from "@/lib/dive-recordings";
import { countNoun } from "@/lib/format";
import { speciesDisplayName } from "@/lib/species";
import {
  formatAltitude,
  formatDepth,
  formatPressure,
  formatTemperature,
  formatVisibility,
  formatWeight,
  type UnitSystem,
} from "@/lib/units";

/**
 * One line of a modified dive's panel: an element of a list added or removed, as
 * a sentence, or a value with what it was and what it becomes.
 */
export type ImportChangeRow =
  | { kind: "element"; text: string }
  | { kind: "value"; label: string; before: string; after: string };

type Format = (value: unknown, units: UnitSystem) => string;

interface Member {
  label: string;
  format: Format;
}

// A value this build cannot read as its member's type, or a member it has no name
// for: shown as the API sent it, since the list is the writer's.
function asSent(value: unknown): string {
  return typeof value === "string" ? value : JSON.stringify(value);
}

const plain: Format = (value) => asSent(value);

const measured =
  (format: (value: number, units: UnitSystem) => string): Format =>
  (value, units) =>
    typeof value === "number" ? format(value, units) : asSent(value);

const worded =
  (labels: Readonly<Partial<Record<string, string>>>): Format =>
  (value) =>
    typeof value === "string" ? labelOrFallback(labels, value) : asSent(value);

const when: Format = (value) =>
  typeof value === "string" ? formatDiveDateTime(value) : asSent(value);

const seconds: Format = measured((value) => formatDurationHoursMinutes(value));
const milliseconds: Format = measured((value) =>
  formatDurationHoursMinutes(Math.round(value / 1000)),
);
const depth: Format = measured((value, units) => formatDepth(value, units));
const temperature: Format = measured((value, units) =>
  formatTemperature(value, units),
);
const pressure: Format = measured((value, units) =>
  formatPressure(value, units),
);
const percent: Format = measured((value) => `${value}%`);

// A link to a record held by uuid alone, which names nothing a diver can read:
// that the dive has one is what can be said without reading another route.
const linked: Format = () => "Linked";

// A profile's curves, as the dive page's chart names them.
const curveName: Format = worded(
  Object.fromEntries(
    Object.values(PROFILE_CHANNELS).map(({ key, label }) => [key, label]),
  ),
);

// The dive's own members, in the dive form's words.
const DIVE_MEMBERS: Record<string, Member> = {
  start_time: { label: "Start time", format: when },
  dive_number: { label: "Dive number", format: plain },
  submerged_time: { label: "Dive time", format: seconds },
  max_depth: { label: "Maximum depth", format: depth },
  avg_depth: { label: "Average depth", format: depth },
  bottom_temperature: { label: "Bottom temperature", format: temperature },
  air_temperature: { label: "Air temperature", format: temperature },
  visibility: {
    label: "Visibility",
    format: measured((value, units) => formatVisibility(value, units)),
  },
  altitude: {
    label: "Altitude",
    format: measured((value, units) => formatAltitude(value, units)),
  },
  weight: {
    label: "Weight",
    format: measured((value, units) => formatWeight(value, units)),
  },
  water_type: { label: "Water type", format: worded(WATER_TYPE_LABELS) },
  type: { label: "Dive type", format: worded(DIVE_TYPE_LABELS) },
  entry_type: { label: "Entry type", format: worded(ENTRY_TYPE_LABELS) },
  waves: { label: "Waves", format: worded(WAVES_LABELS) },
  current: { label: "Current", format: worded(CURRENT_LABELS) },
  weather: { label: "Weather", format: worded(WEATHER_LABELS) },
  boat_name: { label: "Boat name", format: plain },
  notes: { label: "Notes", format: plain },
  rating: { label: "Rating", format: measured((value) => `${value} / 5`) },
  entry_latitude: { label: "Entry latitude", format: plain },
  entry_longitude: { label: "Entry longitude", format: plain },
  exit_latitude: { label: "Exit latitude", format: plain },
  exit_longitude: { label: "Exit longitude", format: plain },
  trip_uuid: { label: "Trip", format: linked },
  course_uuid: { label: "Course", format: linked },
  contact_uuid: { label: "Dive center", format: linked },
};

const MIXTURE_MEMBERS: Record<string, Member> = {
  gas_number: { label: "Gas number", format: plain },
  volume: { label: "Volume", format: measured((value) => `${value} L`) },
  start_pressure: { label: "Start pressure", format: pressure },
  end_pressure: { label: "End pressure", format: pressure },
  oxygen: { label: "O₂", format: percent },
  helium: { label: "He", format: percent },
  po2_limit: {
    label: "ppO₂ limit",
    format: measured((value) => `${value} bar`),
  },
  role: { label: "Role", format: worded(GAS_ROLE_LABELS) },
  usage: { label: "Usage", format: worded(TANK_USAGE_LABELS) },
  gear_item: {
    label: "Cylinder",
    format: (value) =>
      gearItemLabel(value as { name: string; brand?: string | null }),
  },
};

const RECORDING_MEMBERS: Record<string, Member> = {
  mode: { label: "Mode", format: worded(MODE_LABELS) },
  salinity: { label: "Salinity", format: worded(SALINITY_LABELS) },
  started_at: { label: "Start", format: when },
  cns_start: { label: "CNS at the start", format: percent },
  cns_end: { label: "CNS at the end", format: percent },
  otu_start: { label: "OTU at the start", format: plain },
  otu_end: { label: "OTU at the end", format: plain },
  surface_pressure_bar: {
    label: "Surface pressure",
    format: measured((value) => `${value} bar`),
  },
  device: {
    label: "Device",
    format: (value) =>
      recordingDeviceLabel(value as RecordingDevice) ?? UNNAMED_DEVICE_LABEL,
  },
  deco_model: {
    label: "Decompression model",
    format: (value) =>
      decoModelLabel(value as RecordingDecoModel) ?? asSent(value),
  },
  profile: {
    label: "Profile",
    format: (value, units) =>
      recordingProfileSummary(value as DiveProfileInfo, units),
  },
};

const DEVICE_MEMBERS: Record<string, Member> = {
  brand: { label: "Brand", format: plain },
  model: { label: "Model", format: plain },
  serial: { label: "Serial number", format: plain },
  firmware: { label: "Firmware", format: plain },
  name: { label: "Name", format: plain },
  dive_number: { label: "Its dive number", format: plain },
};

const DECO_MODEL_MEMBERS: Record<string, Member> = {
  algorithm: { label: "Algorithm", format: plain },
  name: { label: "Name", format: plain },
  gf_low: { label: "GF low", format: percent },
  gf_high: { label: "GF high", format: percent },
  conservatism: { label: "Conservatism", format: plain },
};

const PROFILE_MEMBERS: Record<string, Member> = {
  duration: { label: "Span", format: milliseconds },
  depth_sample_count: { label: "Samples", format: plain },
  event_count: { label: "Markers", format: plain },
  max_depth: { label: "Maximum depth", format: depth },
  max_ceiling: { label: "Deepest ceiling", format: depth },
  min_temperature: { label: "Lowest temperature", format: temperature },
  max_temperature: { label: "Highest temperature", format: temperature },
  min_pressure: { label: "Lowest pressure", format: pressure },
  max_pressure: { label: "Highest pressure", format: pressure },
  max_heart_rate: {
    label: "Highest heart rate",
    format: measured((value) => `${value} bpm`),
  },
  average_heart_rate: {
    label: "Average heart rate",
    format: measured((value) => `${value} bpm`),
  },
};

/** What a recording's samples come to, on one line: span, depth and how many. */
export function recordingProfileSummary(
  profile: DiveProfileInfo,
  units: UnitSystem,
): string {
  return [
    formatDurationHoursMinutes(Math.round(profile.duration / 1000)),
    profile.max_depth != null ? formatDepth(profile.max_depth, units) : null,
    countNoun(profile.depth_sample_count, "sample"),
  ]
    .filter((part): part is string => part !== null)
    .join(" · ");
}

/** A cylinder on one line: its gas, then its vessel's size where it has one. */
export function mixtureSummary(mixture: DiveMixture): string {
  return [
    gasName(mixture.oxygen, mixture.helium) ?? "Gas not recorded",
    mixture.volume != null ? `${mixture.volume} L` : null,
  ]
    .filter((part): part is string => part !== null)
    .join(", ");
}

function recordingSummary(recording: Recording): string {
  return [
    recordingDeviceLabel(recording.device) ?? UNNAMED_DEVICE_LABEL,
    ...(recording.files ?? []).map((file) => file.original_filename),
  ].join(", ");
}

interface Element {
  noun: string;
  summary?: (value: never) => string;
}

// A dive's lists, whose elements are added and removed whole.
const DIVE_ELEMENTS: Record<string, Element> = {
  dive_sites: {
    noun: "Dive site",
    summary: (site: { name: string }) => site.name,
  },
  gear_items: {
    noun: "Gear",
    summary: (item: { name: string; brand?: string | null }) =>
      gearItemLabel(item),
  },
  sightings: {
    noun: "Species",
    summary: (sighting: SpeciesSummary) => speciesDisplayName(sighting),
  },
  tags: { noun: "Tag", summary: (tag: string) => tag },
  people: { noun: "Person" },
};

function elementRow(
  noun: string,
  change: LogbookImportChange,
  summary?: (value: never) => string,
): ImportChangeRow {
  const removed = change.after === null;
  const value = (removed ? change.before : change.after) as never;
  const text = summary?.(value);
  return {
    kind: "element",
    text: `${noun} ${removed ? "removed" : "added"}${text ? ` — ${text}` : ""}`,
  };
}

function valueRow(
  label: string,
  { before, after }: LogbookImportChange,
  format: Format,
  units: UnitSystem,
): ImportChangeRow {
  const shown = (value: unknown) =>
    value === null ? "—" : format(value, units);
  return { kind: "value", label, before: shown(before), after: shown(after) };
}

const isIndex = (segment: string | undefined) =>
  segment !== undefined && /^\d+$/.test(segment);

// Whether one of the two sides is absent: an element added or removed, rather
// than a list position whose value moved, which is a change of a value like any.
const isWholeElement = ({ before, after }: LogbookImportChange) =>
  before === null || after === null;

function recordingRow(
  index: number,
  rest: string[],
  change: LogbookImportChange,
  units: UnitSystem,
): ImportChangeRow | null {
  const prefix = `Recording ${index + 1}: `;
  const [first, second, third] = rest;
  if (rest.length === 0) {
    return elementRow(`Recording ${index + 1}`, change, recordingSummary);
  }
  if (first === "files" && isIndex(second)) {
    if (rest.length === 2) {
      return elementRow(
        "File",
        change,
        (file: DiveFileInfo) => file.original_filename,
      );
    }
    return rest.length === 3 && third === "original_filename"
      ? valueRow(`${prefix}File name`, change, plain, units)
      : null;
  }
  if (rest.length === 1 && first in RECORDING_MEMBERS) {
    const { label, format } = RECORDING_MEMBERS[first];
    return valueRow(`${prefix}${label}`, change, format, units);
  }
  if (first === "profile" && second === "channels" && isIndex(third)) {
    return isWholeElement(change)
      ? elementRow(`${prefix}Curve`, change, (channel: unknown) =>
          curveName(channel, units),
        )
      : valueRow(`${prefix}Curve`, change, curveName, units);
  }
  const nested = {
    device: DEVICE_MEMBERS,
    deco_model: DECO_MODEL_MEMBERS,
    profile: PROFILE_MEMBERS,
  }[first];
  if (nested && rest.length === 2 && second in nested) {
    const { label, format } = nested[second];
    const owner = RECORDING_MEMBERS[first].label;
    return valueRow(`${prefix}${owner} ${lower(label)}`, change, format, units);
  }
  return null;
}

const lower = (label: string) =>
  /^[A-Z][a-z]/.test(label)
    ? label.charAt(0).toLowerCase() + label.slice(1)
    : label;

function mixtureRow(
  index: number,
  rest: string[],
  change: LogbookImportChange,
  units: UnitSystem,
): ImportChangeRow | null {
  const tank = `Tank ${index + 1}`;
  if (rest.length === 0) {
    return elementRow(tank, change, mixtureSummary);
  }
  const member = rest.length === 1 ? MIXTURE_MEMBERS[rest[0]] : undefined;
  return member
    ? valueRow(`${tank}: ${member.label}`, change, member.format, units)
    : null;
}

function labelledRow(
  change: LogbookImportChange,
  units: UnitSystem,
): ImportChangeRow | null {
  const [head, second, ...rest] = change.member.split("/");
  if (second === undefined) {
    const member = DIVE_MEMBERS[head];
    return member ? valueRow(member.label, change, member.format, units) : null;
  }
  if (!isIndex(second)) return null;
  const index = Number(second);
  if (head === "recordings") return recordingRow(index, rest, change, units);
  if (head === "mixtures") return mixtureRow(index, rest, change, units);
  const element = DIVE_ELEMENTS[head];
  if (!element || rest.length > 0) return null;
  return isWholeElement(change)
    ? elementRow(element.noun, change, element.summary)
    : valueRow(element.noun, change, plain, units);
}

/**
 * One entry of a modified dive's `changes`, in the diver's words and units.
 *
 * Never drops an entry: the list is the writer's, and a build that predates a
 * member shows it by its path rather than swallowing what the import does.
 */
export function describeImportChange(
  change: LogbookImportChange,
  units: UnitSystem,
): ImportChangeRow {
  return (
    labelledRow(change, units) ?? valueRow(change.member, change, plain, units)
  );
}