src/lib/course.ts
Rama main · réplica de lectura del repositorio original.
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),
};
}