Ctrl K
ComunidadGuías…

src/lib/course.ts

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

Archivo original ↗Preguntar a KiBot
import {
  COURSE_STATUSES,
  type Course,
  type CourseStatus,
} from "@/lib/api/courses";
import {
  CERTIFICATION_AGENCIES,
  type CertificationAgency,
} from "@/lib/api/certifications";
import { labelOrFallback } from "@/lib/api/labels";

// Human labels for a course's status. Derived in the browser rather than sent by
// the API, the same way `certificationExpiryLabel` is: the wire value is a slug
// chosen for storage, and nothing about "not_passed" belongs on a screen.
const COURSE_STATUS_LABELS: Record<CourseStatus, string> = {
  planned: "Planned",
  in_progress: "In progress",
  completed: "Completed",
  incomplete: "Incomplete",
  provisional: "Provisional",
  not_passed: "Not passed",
};

/** Label for a course's status, or `""` for none; an unknown one as `labelOrFallback` says. */
export function courseStatusLabel(status: string | null | undefined): string {
  if (!status) return "";
  return labelOrFallback(COURSE_STATUS_LABELS, status);
}

/**
 * Which `Badge` variant a status wears.
 *
 * The distinction that has to survive is completed-versus-not: `default` is the
 * only variant carrying the primary colour, so the one outcome a diver scans for
 * cannot be confused with the five that mean the course is still open or went
 * wrong. Beyond that the grouping is by how much attention the state wants -
 * `not_passed` is the only one worth the destructive red, and the two states that
 * mean "unfinished business" share the warning amber. The rest - planned, under
 * way, or a status this build doesn't know - read as `outline`, the neutral one:
 * a `secondary` fill is a lightness point off a list card's grey in light.
 */
export function courseStatusBadgeVariant(
  status: string | null | undefined,
): "default" | "warning" | "destructive" | "outline" {
  switch (status) {
    case "completed":
      return "default";
    case "incomplete":
    case "provisional":
      return "warning";
    case "not_passed":
      return "destructive";
    default:
      return "outline";
  }
}

/** The agency and status vocabularies a diver's own courses actually use. */
export interface CourseVocabulary {
  agencies: CertificationAgency[];
  statuses: CourseStatus[];
}

// Canonical order first, then anything used that this build has no place for -
// a member the API grew before the app did, which `courseStatusLabel` already
// renders rather than blanking. Sorting the strays keeps the tail stable.
function inUse<T extends string>(
  canonical: readonly T[],
  used: Set<string>,
): T[] {
  const known = canonical.filter((value) => used.has(value));
  const strays = [...used]
    .filter((value) => !canonical.includes(value as T))
    .sort() as T[];
  return [...known, ...strays];
}

/**
 * Which agencies and statuses to offer for narrowing a course list: the ones the
 * courses carry, not every agency the format defines. A filter row listing
 * agencies the diver has never trained with is mostly ways to empty the list.
 *
 * A course need not name an agency, and one that doesn't contributes nothing -
 * there is no "no agency" filter to offer, since the API's `agency` takes a
 * member of the set or nothing at all.
 */
export function courseVocabulary(courses: readonly Course[]): CourseVocabulary {
  const agencies = new Set<string>();
  const statuses = new Set<string>();

  for (const course of courses) {
    if (course.agency) agencies.add(course.agency);
    if (course.status) statuses.add(course.status);
  }

  return {
    agencies: inUse(CERTIFICATION_AGENCIES, agencies),
    statuses: inUse(COURSE_STATUSES, statuses),
  };
}