refactor(terms): move term formatting from frontend to backend

This commit is contained in:
2026-01-31 00:26:41 -06:00
parent c533768362
commit cbb0a51bca
9 changed files with 210 additions and 158 deletions
+6 -4
View File
@@ -21,6 +21,7 @@ import type {
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
@@ -51,13 +52,14 @@ export type {
SubjectResultEntry,
SubjectSummary,
SubjectsResponse,
TermResponse,
TimeseriesPoint,
TimeseriesResponse,
TopCandidateResponse,
};
// Semantic aliases — these all share the CodeDescription shape
export type Term = CodeDescription;
// Semantic aliases
export type Term = TermResponse;
export type Subject = CodeDescription;
export type ReferenceEntry = CodeDescription;
@@ -268,8 +270,8 @@ export class BannerApiClient {
return this.request<Term[]>("/terms");
}
async getSubjects(termCode: string): Promise<Subject[]> {
return this.request<Subject[]>(`/subjects?term=${encodeURIComponent(termCode)}`);
async getSubjects(term: string): Promise<Subject[]> {
return this.request<Subject[]>(`/subjects?term=${encodeURIComponent(term)}`);
}
async getReference(category: string): Promise<ReferenceEntry[]> {
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TermResponse = { code: string, slug: string, description: string, };
+3
View File
@@ -20,6 +20,9 @@ export type { SubjectDetailResponse } from "./SubjectDetailResponse";
export type { SubjectResultEntry } from "./SubjectResultEntry";
export type { SubjectSummary } from "./SubjectSummary";
export type { SubjectsResponse } from "./SubjectsResponse";
export type { TermResponse } from "./TermResponse";
export type { TimelineResponse } from "./TimelineResponse";
export type { TimelineSlot } from "./TimelineSlot";
export type { TimeseriesPoint } from "./TimeseriesPoint";
export type { TimeseriesResponse } from "./TimeseriesResponse";
export type { TopCandidateResponse } from "./TopCandidateResponse";
+11 -12
View File
@@ -16,12 +16,11 @@ let open = $state(false);
let searchValue = $state("");
let containerEl = $state<HTMLDivElement>(null!);
const currentTermCode = $derived(
terms.find((t) => !t.description.includes("(View Only)"))?.code ?? ""
);
// The first term from the backend is the most current
const currentTermSlug = $derived(terms[0]?.slug ?? "");
const selectedLabel = $derived(
terms.find((t) => t.code === value)?.description ?? "Select term..."
terms.find((t) => t.slug === value)?.description ?? "Select term..."
);
const filteredTerms = $derived.by(() => {
@@ -29,8 +28,8 @@ const filteredTerms = $derived.by(() => {
const matched =
query === "" ? terms : terms.filter((t) => t.description.toLowerCase().includes(query));
const current = matched.find((t) => t.code === currentTermCode);
const rest = matched.filter((t) => t.code !== currentTermCode);
const current = matched.find((t) => t.slug === currentTermSlug);
const rest = matched.filter((t) => t.slug !== currentTermSlug);
return current ? [current, ...rest] : rest;
});
@@ -100,22 +99,22 @@ $effect(() => {
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<Combobox.Viewport class="p-0.5">
{#each filteredTerms as term, i (term.code)}
{#if i === 1 && term.code !== currentTermCode && filteredTerms[0]?.code === currentTermCode}
{#each filteredTerms as term, i (term.slug)}
{#if i === 1 && term.slug !== currentTermSlug && filteredTerms[0]?.slug === currentTermSlug}
<div class="mx-2 my-1 h-px bg-border"></div>
{/if}
<Combobox.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center px-2 text-sm
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
{term.code === value ? 'cursor-default' : 'cursor-pointer'}
{term.code === currentTermCode ? 'font-medium text-foreground' : 'text-foreground'}"
value={term.code}
{term.slug === value ? 'cursor-default' : 'cursor-pointer'}
{term.slug === currentTermSlug ? 'font-medium text-foreground' : 'text-foreground'}"
value={term.slug}
label={term.description}
>
{#snippet children({ selected })}
<span class="flex-1 truncate">
{term.description}
{#if term.code === currentTermCode}
{#if term.slug === currentTermSlug}
<span class="ml-1.5 text-xs text-muted-foreground font-normal">current</span>
{/if}
</span>
-64
View File
@@ -1,64 +0,0 @@
import { describe, expect, it } from "vitest";
import { termToBanner, termToFriendly } from "./term-format";
describe("termToFriendly", () => {
it("converts spring term correctly", () => {
expect(termToFriendly("202610")).toBe("spring-26");
expect(termToFriendly("202510")).toBe("spring-25");
});
it("converts summer term correctly", () => {
expect(termToFriendly("202620")).toBe("summer-26");
expect(termToFriendly("202520")).toBe("summer-25");
});
it("converts fall term correctly", () => {
expect(termToFriendly("202630")).toBe("fall-26");
expect(termToFriendly("202530")).toBe("fall-25");
});
it("returns null for invalid codes", () => {
expect(termToFriendly("20261")).toBe(null);
expect(termToFriendly("2026100")).toBe(null);
expect(termToFriendly("202640")).toBe(null); // Invalid semester code
expect(termToFriendly("")).toBe(null);
});
});
describe("termToBanner", () => {
it("converts spring term correctly", () => {
expect(termToBanner("spring-26")).toBe("202610");
expect(termToBanner("spring-25")).toBe("202510");
});
it("converts summer term correctly", () => {
expect(termToBanner("summer-26")).toBe("202620");
expect(termToBanner("summer-25")).toBe("202520");
});
it("converts fall term correctly", () => {
expect(termToBanner("fall-26")).toBe("202630");
expect(termToBanner("fall-25")).toBe("202530");
});
it("returns null for invalid formats", () => {
expect(termToBanner("winter-26")).toBe(null);
expect(termToBanner("spring26")).toBe(null);
expect(termToBanner("spring-2026")).toBe(null);
expect(termToBanner("26-spring")).toBe(null);
expect(termToBanner("")).toBe(null);
});
});
describe("round-trip conversion", () => {
it("converts back and forth correctly", () => {
const bannerCodes = ["202610", "202620", "202630", "202510"];
for (const code of bannerCodes) {
const friendly = termToFriendly(code);
expect(friendly).not.toBeNull();
const backToBanner = termToBanner(friendly!);
expect(backToBanner).toBe(code);
}
});
});
-48
View File
@@ -1,48 +0,0 @@
/**
* Convert between Banner's internal term codes (e.g., "202620") and human-friendly format (e.g., "summer-26")
*/
export type SemesterName = "spring" | "summer" | "fall";
const SEMESTER_CODES: Record<string, SemesterName> = {
"10": "spring",
"20": "summer",
"30": "fall",
};
const SEMESTER_TO_CODE: Record<SemesterName, string> = {
spring: "10",
summer: "20",
fall: "30",
};
/**
* Convert Banner term code (e.g., "202620") to friendly format (e.g., "summer-26")
*/
export function termToFriendly(bannerCode: string): string | null {
if (bannerCode.length !== 6) return null;
const year = bannerCode.substring(0, 4);
const semesterCode = bannerCode.substring(4, 6);
const semester = SEMESTER_CODES[semesterCode];
if (!semester) return null;
const shortYear = year.substring(2, 4);
return `${semester}-${shortYear}`;
}
/**
* Convert friendly format (e.g., "summer-26") to Banner term code (e.g., "202620")
*/
export function termToBanner(friendly: string): string | null {
const match = friendly.match(/^(spring|summer|fall)-(\d{2})$/);
if (!match) return null;
const [, semester, shortYear] = match;
const semesterCode = SEMESTER_TO_CODE[semester as SemesterName];
if (!semesterCode) return null;
const fullYear = `20${shortYear}`;
return `${fullYear}${semesterCode}`;
}
+13 -8
View File
@@ -12,7 +12,6 @@ import Footer from "$lib/components/Footer.svelte";
import Pagination from "$lib/components/Pagination.svelte";
import SearchFilters from "$lib/components/SearchFilters.svelte";
import SearchStatus, { type SearchMeta } from "$lib/components/SearchStatus.svelte";
import { termToBanner, termToFriendly } from "$lib/term-format";
import type { SortingState } from "@tanstack/table-core";
import { untrack } from "svelte";
@@ -21,10 +20,14 @@ let { data } = $props();
// Read initial state from URL params (intentionally captured once)
const initialParams = untrack(() => new URLSearchParams(data.url.search));
// Filter state - only set term from URL if present (no auto-default)
// The default term is the first one returned by the backend (most current)
const defaultTermSlug = data.terms[0]?.slug ?? "";
// Default to the first term when no URL param is present
const urlTerm = initialParams.get("term");
const bannerTerm = urlTerm ? (termToBanner(urlTerm) ?? "") : "";
let selectedTerm = $state(bannerTerm);
let selectedTerm = $state(
urlTerm && data.terms.some((t) => t.slug === urlTerm) ? urlTerm : defaultTermSlug
);
let selectedSubjects: string[] = $state(untrack(() => initialParams.getAll("subject")));
let query = $state(initialParams.get("q") ?? "");
let openOnly = $state(initialParams.get("open") === "true");
@@ -163,10 +166,6 @@ async function performSearch(
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
const params = new URLSearchParams();
const friendlyTerm = termToFriendly(term);
if (friendlyTerm) {
params.set("term", friendlyTerm);
}
for (const s of subjects) {
params.append("subject", s);
}
@@ -175,6 +174,12 @@ async function performSearch(
if (off > 0) params.set("offset", String(off));
if (sortBy) params.set("sort_by", sortBy);
if (sortDir && sortBy) params.set("sort_dir", sortDir);
// Include term in URL only when it differs from the default or other params are active
const hasOtherParams = params.size > 0;
if (term !== defaultTermSlug || hasOtherParams) {
params.set("term", term);
}
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
const t0 = performance.now();