Ctrl K
ComunidadGuías…

src/lib/gear-service.ts

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

Archivo original ↗Preguntar a KiBot
// Service-status derivation for gear.
//
// The API deliberately returns only clock-stable facts about a service schedule -
// `next_due_on`, `next_due_at_dive_count`, `next_due_at_dive_time`, `last_service_on` -
// and never a computed
// status. Status depends on today's date, so a cached response carrying it would be
// wrong the next morning (the single-gear-item cache is an hour long). Deriving it here
// keeps the API cacheable and the badge always correct.
//
// This is a near-line-for-line twin of `service_status` in the API's
// `services/gear_service.py`, which exists for the reminder email - the one consumer
// with no browser. The constants are named identically on both sides so a single
// `grep SERVICE_DUE_SOON` finds the trio; change one and you must change its twin.

import type {
  GearServiceScheduleSummary,
  ServiceKind,
} from "@/lib/api/gear-service";
import { daysBetweenIsoDates, todayIsoDate } from "@/lib/date-time";
import { countNoun } from "@/lib/format";

// How far ahead a due date starts reading as "due soon". 30 days is roughly the lead
// time for getting a regulator booked in and back before the next trip.
export const SERVICE_DUE_SOON_DAYS = 30;
// The dive-count equivalent - about one trip's worth of diving.
export const SERVICE_DUE_SOON_DIVES = 10;
// The hours-of-diving equivalent, the dive figure's twin. Compared in seconds of
// `dive_time`.
export const SERVICE_DUE_SOON_HOURS = 10;

const SECONDS_PER_HOUR = 3600;

// A remainder of `dive_time` as whole hours, rounded up - forty minutes left reads "1
// hour of diving" rather than "0". The API's `hours_of_diving` rounds the same way, so
// the email and the page never disagree about the figure.
export function hoursOfDiving(seconds: number): number {
  return Math.ceil(seconds / SECONDS_PER_HOUR);
}

export type ServiceStatus = "ok" | "due_soon" | "overdue";

// How urgent one schedule is, given the item's live dive count and dive time (seconds).
//
// Every interval arm is evaluated and the most urgent verdict wins - that's what
// implements "annually or every 100 dives, whichever comes first". A paused schedule is
// always `ok`: pausing is how a diver silences a rule they're not currently acting on.
export function serviceStatus(
  schedule: GearServiceScheduleSummary,
  diveCount: number,
  diveTime: number,
  today: string = todayIsoDate(),
): ServiceStatus {
  if (schedule.is_active === false) return "ok";

  let dueSoon = false;

  if (schedule.next_due_on) {
    const days = daysBetweenIsoDates(today, schedule.next_due_on);
    if (days <= 0) return "overdue";
    if (days <= SERVICE_DUE_SOON_DAYS) dueSoon = true;
  }

  if (schedule.next_due_at_dive_count != null) {
    const remaining = schedule.next_due_at_dive_count - diveCount;
    if (remaining <= 0) return "overdue";
    if (remaining <= SERVICE_DUE_SOON_DIVES) dueSoon = true;
  }

  if (schedule.next_due_at_dive_time != null) {
    const remaining = schedule.next_due_at_dive_time - diveTime;
    if (remaining <= 0) return "overdue";
    if (remaining <= SERVICE_DUE_SOON_HOURS * SECONDS_PER_HOUR) dueSoon = true;
  }

  return dueSoon ? "due_soon" : "ok";
}

// The most urgent status across all of an item's schedules, for the one chip on its
// gear card. `null` when the item has no schedules at all, for which the card shows no
// chip rather than a reassuring "ok" - nothing is being tracked, which isn't the same
// as everything being fine.
export function worstServiceStatus(
  schedules: GearServiceScheduleSummary[],
  diveCount: number,
  diveTime: number,
  today: string = todayIsoDate(),
): ServiceStatus | null {
  const active = schedules.filter((s) => s.is_active !== false);
  if (active.length === 0) return null;

  const statuses = active.map((s) =>
    serviceStatus(s, diveCount, diveTime, today),
  );
  if (statuses.includes("overdue")) return "overdue";
  if (statuses.includes("due_soon")) return "due_soon";
  return "ok";
}

export function serviceStatusLabel(status: ServiceStatus): string {
  if (status === "overdue") return "Overdue";
  if (status === "due_soon") return "Due soon";
  return "In service";
}

// Maps onto the `Badge` variants already in the design system rather than introducing
// new colours. All three are filled, and the scale is read by hue rather than by
// weight: teal for settled, the brand coral for due-soon, --destructive-solid for
// overdue. Not `secondary` for due-soon: it is nearly invisible on a card in dark
// mode and, being the same chip as "Rented", doesn't read as a status at all; nor
// --warning's amber, which reads as a fourth accent beside a palette that has three.
export function serviceStatusBadgeVariant(
  status: ServiceStatus,
): "destructive" | "coral" | "teal" {
  if (status === "overdue") return "destructive";
  if (status === "due_soon") return "coral";
  return "teal";
}

type DueUnit = "day" | "dive" | "hour";

// When a schedule is next due, by whichever arm is the urgent one: a regulator that
// has run out of dives shouldn't be described by a date that's still months away.
// `count` is how far past due, or how far off - hours rounded up (`hoursOfDiving`);
// `null` for a schedule with no arm. The precedence is the digest's `_due_text`: an
// overdue dive or hours arm over a date that is not overdue, the dive arm before the
// hours arm, and otherwise the date, which is the arm divers think in.
function nextServiceDue(
  schedule: GearServiceScheduleSummary,
  diveCount: number,
  diveTime: number,
  today: string,
): { overdue: boolean; count: number; unit: DueUnit } | null {
  const days = schedule.next_due_on
    ? daysBetweenIsoDates(today, schedule.next_due_on)
    : null;
  const remaining =
    schedule.next_due_at_dive_count != null
      ? schedule.next_due_at_dive_count - diveCount
      : null;
  const timeRemaining =
    schedule.next_due_at_dive_time != null
      ? schedule.next_due_at_dive_time - diveTime
      : null;

  const dateOverdue = days != null && days <= 0;
  const divesOverdue = remaining != null && remaining <= 0;
  const timeOverdue = timeRemaining != null && timeRemaining <= 0;

  if (divesOverdue && !dateOverdue) {
    return { overdue: true, count: -remaining!, unit: "dive" };
  }
  if (timeOverdue && !dateOverdue) {
    return {
      overdue: true,
      count: hoursOfDiving(-timeRemaining!),
      unit: "hour",
    };
  }
  if (dateOverdue) return { overdue: true, count: -days!, unit: "day" };
  if (days != null) return { overdue: false, count: days, unit: "day" };
  if (remaining != null) {
    return { overdue: false, count: remaining, unit: "dive" };
  }
  if (timeRemaining != null) {
    return {
      overdue: false,
      count: hoursOfDiving(timeRemaining),
      unit: "hour",
    };
  }
  return null;
}

// "3 dives", "1 day", "12 hours of diving" - an hours figure always says it is
// diving, so it never reads as clock hours.
export function countOf(count: number, unit: DueUnit): string {
  const counted = countNoun(count, unit);
  return unit === "hour" ? `${counted} of diving` : counted;
}

// When an item's next service falls, read off each active schedule's urgent arm
// as `nextServiceDue` picks it: `overdue` when a dive count or an hours figure has
// run out, since that schedule's date says nothing; else the earliest due date, a
// past one included; else the fewest dives left; else, with only hours to go on,
// the fewest hours. `null` when no active schedule has an arm.
export function nextService(
  schedules: GearServiceScheduleSummary[],
  diveCount: number,
  diveTime: number,
  today: string = todayIsoDate(),
):
  | { kind: "overdue" }
  | { kind: "date"; on: string }
  | { kind: "dives"; remaining: number }
  | { kind: "hours"; remaining: number }
  | null {
  let date: string | null = null;
  let dives: number | null = null;
  let hours: number | null = null;
  for (const schedule of schedules) {
    if (schedule.is_active === false) continue;
    const due = nextServiceDue(schedule, diveCount, diveTime, today);
    if (!due) continue;
    if (due.unit === "day") {
      if (!date || schedule.next_due_on! < date) date = schedule.next_due_on!;
      continue;
    }
    if (due.overdue) return { kind: "overdue" };
    if (due.unit === "dive") dives = Math.min(dives ?? due.count, due.count);
    else hours = Math.min(hours ?? due.count, due.count);
  }
  if (date) return { kind: "date", on: date };
  if (dives != null) return { kind: "dives", remaining: dives };
  if (hours != null) return { kind: "hours", remaining: hours };
  return null;
}

// A short human phrase for when a schedule is next due: "Overdue by 3 dives",
// "Due in 10 days", "Due in 12 hours of diving".
export function formatServiceDue(
  schedule: GearServiceScheduleSummary,
  diveCount: number,
  diveTime: number,
  today: string = todayIsoDate(),
): string {
  const due = nextServiceDue(schedule, diveCount, diveTime, today);
  if (!due) return "No due date";
  if (due.overdue) {
    // Due *today* is already overdue, not "nearly due" - `serviceStatus` above badges
    // it destructive, and the API agrees on both counts: `service_status` uses
    // `today >= next_due_on`, and the reminder digest emails "overdue since 11 Aug
    // 2026" for a schedule due that morning. A bare "Due today" here read as
    // reassurance directly under a red Overdue badge, so it names the state first and
    // keeps the useful "and it's today" as the qualifier.
    if (due.unit === "day" && due.count === 0) return "Overdue (due today)";
    return `Overdue by ${countOf(due.count, due.unit)}`;
  }
  return `Due in ${countOf(due.count, due.unit)}`;
}

// The same phrase less the word the badge above it already says - "by 3 dives",
// "in 10 days", "in 12 hours of diving", "today" - for the notifications bell, which
// stacks the two.
export function formatServiceDueQualifier(
  schedule: GearServiceScheduleSummary,
  diveCount: number,
  diveTime: number,
  today: string = todayIsoDate(),
): string {
  const due = nextServiceDue(schedule, diveCount, diveTime, today);
  if (!due) return "no due date";
  if (due.overdue) {
    if (due.unit === "day" && due.count === 0) return "today";
    return `by ${countOf(due.count, due.unit)}`;
  }
  return `in ${countOf(due.count, due.unit)}`;
}

// A schedule the "add service schedule" dialog can prefill, keyed off the gear type.
export interface ServiceSchedulePreset {
  kind: ServiceKind;
  interval_months?: number;
  interval_dives?: number;
}

// Conservative, common-practice starting points - NOT authoritative safety guidance.
// Manufacturer service intervals differ, and cylinder test periods are set by
// jurisdiction (five years in much of the US and EU, two and a half in some regimes for
// some cylinder types). These exist to save typing; the diver is expected to change
// them to whatever their kit and their local rules actually require, which is why every
// field stays editable and nothing is filled in silently on save.
const SERVICE_PRESETS: Partial<Record<string, ServiceSchedulePreset[]>> = {
  // The one piece of kit commonly specified both ways ("annually or every 100 dives").
  regulator: [{ kind: "service", interval_months: 12, interval_dives: 100 }],
  bcd: [{ kind: "service", interval_months: 12 }],
  wing: [{ kind: "service", interval_months: 12 }],
  vest: [{ kind: "service", interval_months: 12 }],
  // A cylinder needs both, on different clocks - the case a single interval per item
  // could never have expressed.
  cylinder: [
    { kind: "visual_inspection", interval_months: 12 },
    { kind: "hydrostatic_test", interval_months: 60 },
  ],
  computer: [{ kind: "battery", interval_months: 24 }],
  light: [{ kind: "battery", interval_months: 24 }],
  // Valves and the dry zipper, rather than a full strip-down.
  drysuit: [{ kind: "service", interval_months: 24 }],
};

// Suggested schedules for a gear type. Returns `[]` for anything without a convention
// worth suggesting (a mask, a knife) and for gear with no type set at all.
export function defaultSchedulesForGearType(
  type: string | null | undefined,
): ServiceSchedulePreset[] {
  if (!type) return [];
  return SERVICE_PRESETS[type] ?? [];
}