mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -06:00
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
|
||
|
||
/** Convert "0900" to "9:00 AM" */
|
||
export function formatTime(time: string | null): string {
|
||
if (!time || time.length !== 4) return "TBA";
|
||
const hours = parseInt(time.slice(0, 2), 10);
|
||
const minutes = time.slice(2);
|
||
const period = hours >= 12 ? "PM" : "AM";
|
||
const display = hours > 12 ? hours - 12 : hours === 0 ? 12 : hours;
|
||
return `${display}:${minutes} ${period}`;
|
||
}
|
||
|
||
/**
|
||
* Compact day abbreviation for table cells.
|
||
*
|
||
* Single day → 3-letter: "Mon", "Thu"
|
||
* Multi-day → concatenated codes: "MWF", "TTh", "MTWTh", "TSa"
|
||
*
|
||
* Codes use single letters where unambiguous (M/T/W/F) and
|
||
* two letters where needed (Th/Sa/Su).
|
||
*/
|
||
export function formatMeetingDays(mt: DbMeetingTime): string {
|
||
const dayDefs: [boolean, string, string][] = [
|
||
[mt.monday, "M", "Mon"],
|
||
[mt.tuesday, "T", "Tue"],
|
||
[mt.wednesday, "W", "Wed"],
|
||
[mt.thursday, "Th", "Thu"],
|
||
[mt.friday, "F", "Fri"],
|
||
[mt.saturday, "Sa", "Sat"],
|
||
[mt.sunday, "Su", "Sun"],
|
||
];
|
||
const active = dayDefs.filter(([a]) => a);
|
||
if (active.length === 0) return "";
|
||
if (active.length === 1) return active[0][2];
|
||
return active.map(([, code]) => code).join("");
|
||
}
|
||
|
||
/** Longer day names for detail view: single day → "Thursdays", multiple → "Mon, Wed, Fri" */
|
||
export function formatMeetingDaysLong(mt: DbMeetingTime): string {
|
||
const days: [boolean, string, string][] = [
|
||
[mt.monday, "Mon", "Mondays"],
|
||
[mt.tuesday, "Tue", "Tuesdays"],
|
||
[mt.wednesday, "Wed", "Wednesdays"],
|
||
[mt.thursday, "Thur", "Thursdays"],
|
||
[mt.friday, "Fri", "Fridays"],
|
||
[mt.saturday, "Sat", "Saturdays"],
|
||
[mt.sunday, "Sun", "Sundays"],
|
||
];
|
||
const active = days.filter(([a]) => a);
|
||
if (active.length === 0) return "";
|
||
if (active.length === 1) return active[0][2];
|
||
return active.map(([, short]) => short).join(", ");
|
||
}
|
||
|
||
/**
|
||
* Format a time range with smart AM/PM elision.
|
||
*
|
||
* Same period: "9:00–9:50 AM"
|
||
* Cross-period: "11:30 AM–12:20 PM"
|
||
* Missing: "TBA"
|
||
*/
|
||
export function formatTimeRange(begin: string | null, end: string | null): string {
|
||
if (!begin || begin.length !== 4 || !end || end.length !== 4) return "TBA";
|
||
|
||
const bHours = parseInt(begin.slice(0, 2), 10);
|
||
const eHours = parseInt(end.slice(0, 2), 10);
|
||
const bPeriod = bHours >= 12 ? "PM" : "AM";
|
||
const ePeriod = eHours >= 12 ? "PM" : "AM";
|
||
|
||
const bDisplay = bHours > 12 ? bHours - 12 : bHours === 0 ? 12 : bHours;
|
||
const eDisplay = eHours > 12 ? eHours - 12 : eHours === 0 ? 12 : eHours;
|
||
|
||
const endStr = `${eDisplay}:${end.slice(2)} ${ePeriod}`;
|
||
if (bPeriod === ePeriod) {
|
||
return `${bDisplay}:${begin.slice(2)}–${endStr}`;
|
||
}
|
||
return `${bDisplay}:${begin.slice(2)} ${bPeriod}–${endStr}`;
|
||
}
|
||
|
||
/** Condensed meeting time: "MWF 9:00–9:50 AM" */
|
||
export function formatMeetingTime(mt: DbMeetingTime): string {
|
||
const days = formatMeetingDays(mt);
|
||
if (!days) return "TBA";
|
||
const range = formatTimeRange(mt.begin_time, mt.end_time);
|
||
if (range === "TBA") return `${days} TBA`;
|
||
return `${days} ${range}`;
|
||
}
|
||
|
||
/**
|
||
* Progressively abbreviate an instructor name to fit within a character budget.
|
||
*
|
||
* Tries each level until the result fits `maxLen`:
|
||
* 1. Full name: "Ramirez, Maria Elena"
|
||
* 2. Abbreviate trailing given names: "Ramirez, Maria E."
|
||
* 3. Abbreviate all given names: "Ramirez, M. E."
|
||
* 4. First initial only: "Ramirez, M."
|
||
*
|
||
* Names without a comma (e.g. "Staff") are returned as-is.
|
||
*/
|
||
export function abbreviateInstructor(name: string, maxLen: number = 18): string {
|
||
if (name.length <= maxLen) return name;
|
||
|
||
const commaIdx = name.indexOf(", ");
|
||
if (commaIdx === -1) return name;
|
||
|
||
const last = name.slice(0, commaIdx);
|
||
const parts = name.slice(commaIdx + 2).split(" ");
|
||
|
||
// Level 2: abbreviate trailing given names, keep first given name intact
|
||
// "Maria Elena" → "Maria E."
|
||
if (parts.length > 1) {
|
||
const abbreviated = [parts[0], ...parts.slice(1).map((p) => `${p[0]}.`)].join(" ");
|
||
const result = `${last}, ${abbreviated}`;
|
||
if (result.length <= maxLen) return result;
|
||
}
|
||
|
||
// Level 3: abbreviate all given names
|
||
// "Maria Elena" → "M. E."
|
||
if (parts.length > 1) {
|
||
const allInitials = parts.map((p) => `${p[0]}.`).join(" ");
|
||
const result = `${last}, ${allInitials}`;
|
||
if (result.length <= maxLen) return result;
|
||
}
|
||
|
||
// Level 4: first initial only
|
||
// "Maria Elena" → "M." or "John" → "J."
|
||
return `${last}, ${parts[0][0]}.`;
|
||
}
|
||
|
||
/** Get primary instructor from a course, or first instructor */
|
||
export function getPrimaryInstructor(
|
||
instructors: InstructorResponse[]
|
||
): InstructorResponse | undefined {
|
||
return instructors.find((i) => i.isPrimary) ?? instructors[0];
|
||
}
|
||
|
||
/** Check if a meeting time has no scheduled days */
|
||
export function isMeetingTimeTBA(mt: DbMeetingTime): boolean {
|
||
return (
|
||
!mt.monday &&
|
||
!mt.tuesday &&
|
||
!mt.wednesday &&
|
||
!mt.thursday &&
|
||
!mt.friday &&
|
||
!mt.saturday &&
|
||
!mt.sunday
|
||
);
|
||
}
|
||
|
||
/** Check if a meeting time has no begin/end times */
|
||
export function isTimeTBA(mt: DbMeetingTime): boolean {
|
||
return !mt.begin_time || mt.begin_time.length !== 4;
|
||
}
|
||
|
||
/** Format a date string to "January 20, 2026". Accepts YYYY-MM-DD or MM/DD/YYYY. */
|
||
export function formatDate(dateStr: string): string {
|
||
let year: number, month: number, day: number;
|
||
if (dateStr.includes("-")) {
|
||
[year, month, day] = dateStr.split("-").map(Number);
|
||
} else if (dateStr.includes("/")) {
|
||
[month, day, year] = dateStr.split("/").map(Number);
|
||
} else {
|
||
return dateStr;
|
||
}
|
||
if (!year || !month || !day) return dateStr;
|
||
const date = new Date(year, month - 1, day);
|
||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
||
}
|
||
|
||
/** Short location string from first meeting time: "MH 2.206" or campus fallback */
|
||
export function formatLocation(course: CourseResponse): string | null {
|
||
for (const mt of course.meetingTimes) {
|
||
if (mt.building && mt.room) return `${mt.building} ${mt.room}`;
|
||
if (mt.building) return mt.building;
|
||
}
|
||
return course.campus ?? null;
|
||
}
|
||
|
||
/** Longer location string using building description: "Main Hall 2.206" */
|
||
export function formatLocationLong(mt: DbMeetingTime): string | null {
|
||
const name = mt.building_description ?? mt.building;
|
||
if (!name) return null;
|
||
return mt.room ? `${name} ${mt.room}` : name;
|
||
}
|
||
|
||
/** Format a date as "Aug 26, 2024". Accepts YYYY-MM-DD or MM/DD/YYYY. */
|
||
export function formatDateShort(dateStr: string): string {
|
||
let year: number, month: number, day: number;
|
||
if (dateStr.includes("-")) {
|
||
[year, month, day] = dateStr.split("-").map(Number);
|
||
} else if (dateStr.includes("/")) {
|
||
[month, day, year] = dateStr.split("/").map(Number);
|
||
} else {
|
||
return dateStr;
|
||
}
|
||
if (!year || !month || !day) return dateStr;
|
||
const date = new Date(year, month - 1, day);
|
||
return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||
}
|
||
|
||
/**
|
||
* Verbose day names for tooltips: "Tuesdays & Thursdays", "Mondays, Wednesdays & Fridays".
|
||
* Single day → plural: "Thursdays".
|
||
*/
|
||
export function formatMeetingDaysVerbose(mt: DbMeetingTime): string {
|
||
const dayDefs: [boolean, string][] = [
|
||
[mt.monday, "Mondays"],
|
||
[mt.tuesday, "Tuesdays"],
|
||
[mt.wednesday, "Wednesdays"],
|
||
[mt.thursday, "Thursdays"],
|
||
[mt.friday, "Fridays"],
|
||
[mt.saturday, "Saturdays"],
|
||
[mt.sunday, "Sundays"],
|
||
];
|
||
const active = dayDefs.filter(([a]) => a).map(([, name]) => name);
|
||
if (active.length === 0) return "";
|
||
if (active.length === 1) return active[0];
|
||
return active.slice(0, -1).join(", ") + " & " + active[active.length - 1];
|
||
}
|
||
|
||
/**
|
||
* Full verbose tooltip for a single meeting time:
|
||
* "Tuesdays & Thursdays, 4:15–5:30 PM\nMain Hall 2.206 · Aug 26 – Dec 12, 2024"
|
||
*/
|
||
export function formatMeetingTimeTooltip(mt: DbMeetingTime): string {
|
||
const days = formatMeetingDaysVerbose(mt);
|
||
const range = formatTimeRange(mt.begin_time, mt.end_time);
|
||
let line1: string;
|
||
if (!days && range === "TBA") {
|
||
line1 = "TBA";
|
||
} else if (!days) {
|
||
line1 = range;
|
||
} else if (range === "TBA") {
|
||
line1 = `${days}, TBA`;
|
||
} else {
|
||
line1 = `${days}, ${range}`;
|
||
}
|
||
|
||
const parts = [line1];
|
||
|
||
const loc = formatLocationLong(mt);
|
||
const dateRange =
|
||
mt.start_date && mt.end_date
|
||
? `${formatDateShort(mt.start_date)} – ${formatDateShort(mt.end_date)}`
|
||
: null;
|
||
|
||
if (loc && dateRange) {
|
||
parts.push(`${loc}, ${dateRange}`);
|
||
} else if (loc) {
|
||
parts.push(loc);
|
||
} else if (dateRange) {
|
||
parts.push(dateRange);
|
||
}
|
||
|
||
return parts.join("\n");
|
||
}
|
||
|
||
/** Full verbose tooltip for all meeting times on a course, newline-separated. */
|
||
export function formatMeetingTimesTooltip(meetingTimes: DbMeetingTime[]): string {
|
||
if (meetingTimes.length === 0) return "TBA";
|
||
return meetingTimes.map(formatMeetingTimeTooltip).join("\n\n");
|
||
}
|
||
|
||
/**
|
||
* Delivery concern category for visual accent on location cells.
|
||
* - "online": fully online with no physical location (OA, OS, OH without INT building)
|
||
* - "internet": internet campus with INT building code
|
||
* - "hybrid": mix of online and in-person (HB, H1, H2)
|
||
* - "off-campus": in-person but not on Main Campus
|
||
* - null: normal in-person on main campus (no accent)
|
||
*/
|
||
export type DeliveryConcern = "online" | "internet" | "hybrid" | "off-campus" | null;
|
||
|
||
const ONLINE_METHODS = new Set(["OA", "OS", "OH"]);
|
||
const HYBRID_METHODS = new Set(["HB", "H1", "H2"]);
|
||
const MAIN_CAMPUS = "11";
|
||
const ONLINE_CAMPUSES = new Set(["9", "ONL"]);
|
||
|
||
export function getDeliveryConcern(course: CourseResponse): DeliveryConcern {
|
||
const method = course.instructionalMethod;
|
||
if (method && ONLINE_METHODS.has(method)) {
|
||
const hasIntBuilding = course.meetingTimes.some((mt: DbMeetingTime) => mt.building === "INT");
|
||
return hasIntBuilding ? "internet" : "online";
|
||
}
|
||
if (method && HYBRID_METHODS.has(method)) return "hybrid";
|
||
if (course.campus && course.campus !== MAIN_CAMPUS && !ONLINE_CAMPUSES.has(course.campus)) {
|
||
return "off-campus";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Border accent color for each delivery concern type. */
|
||
export function concernAccentColor(concern: DeliveryConcern): string | null {
|
||
switch (concern) {
|
||
case "online":
|
||
return "#3b82f6"; // blue-500
|
||
case "internet":
|
||
return "#06b6d4"; // cyan-500
|
||
case "hybrid":
|
||
return "#a855f7"; // purple-500
|
||
case "off-campus":
|
||
return "#f59e0b"; // amber-500
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Location display text for the table cell.
|
||
* Falls back to "Online" for online courses instead of showing a dash.
|
||
*/
|
||
export function formatLocationDisplay(course: CourseResponse): string | null {
|
||
const loc = formatLocation(course);
|
||
if (loc) return loc;
|
||
const concern = getDeliveryConcern(course);
|
||
if (concern === "online") return "Online";
|
||
return null;
|
||
}
|
||
|
||
/** Tooltip text for the location column: long-form location + delivery note */
|
||
export function formatLocationTooltip(course: CourseResponse): string | null {
|
||
const parts: string[] = [];
|
||
|
||
for (const mt of course.meetingTimes) {
|
||
const loc = formatLocationLong(mt);
|
||
if (loc && !parts.includes(loc)) parts.push(loc);
|
||
}
|
||
|
||
const locationLine = parts.length > 0 ? parts.join(", ") : null;
|
||
|
||
const concern = getDeliveryConcern(course);
|
||
let deliveryNote: string | null = null;
|
||
if (concern === "online") deliveryNote = "Online";
|
||
else if (concern === "internet") deliveryNote = "Internet";
|
||
else if (concern === "hybrid") deliveryNote = "Hybrid";
|
||
else if (concern === "off-campus") deliveryNote = "Off-campus";
|
||
|
||
if (locationLine && deliveryNote) return `${locationLine}\n${deliveryNote}`;
|
||
if (locationLine) return locationLine;
|
||
if (deliveryNote) return deliveryNote;
|
||
return null;
|
||
}
|
||
|
||
/** Number of open seats in a course section */
|
||
export function openSeats(course: CourseResponse): number {
|
||
return Math.max(0, course.maxEnrollment - course.enrollment);
|
||
}
|
||
|
||
/** Text color class for seat availability: red (full), yellow (low), green (open) */
|
||
export function seatsColor(course: CourseResponse): string {
|
||
const open = openSeats(course);
|
||
if (open === 0) return "text-status-red";
|
||
if (open <= 5) return "text-yellow-500";
|
||
return "text-status-green";
|
||
}
|
||
|
||
/** Background dot color class for seat availability */
|
||
export function seatsDotColor(course: CourseResponse): string {
|
||
const open = openSeats(course);
|
||
if (open === 0) return "bg-red-500";
|
||
if (open <= 5) return "bg-yellow-500";
|
||
return "bg-green-500";
|
||
}
|
||
|
||
/** Text color class for a RateMyProfessors rating */
|
||
export function ratingColor(rating: number): string {
|
||
if (rating >= 4.0) return "text-status-green";
|
||
if (rating >= 3.0) return "text-yellow-500";
|
||
return "text-status-red";
|
||
}
|
||
|
||
/** Format credit hours display */
|
||
export function formatCreditHours(course: CourseResponse): string {
|
||
if (course.creditHours != null) return String(course.creditHours);
|
||
if (course.creditHourLow != null && course.creditHourHigh != null) {
|
||
return `${course.creditHourLow}–${course.creditHourHigh}`;
|
||
}
|
||
return "—";
|
||
}
|