feat: table FLIP animations, improved time tooltip details & day abbreviations

This commit is contained in:
2026-01-29 03:40:40 -06:00
parent 779144a4d5
commit 78159707e2
4 changed files with 352 additions and 69 deletions
+72 -39
View File
@@ -2,15 +2,17 @@
import type { CourseResponse } from "$lib/api"; import type { CourseResponse } from "$lib/api";
import { import {
abbreviateInstructor, abbreviateInstructor,
formatTime,
formatMeetingDays, formatMeetingDays,
formatTimeRange,
formatLocation, formatLocation,
getPrimaryInstructor, getPrimaryInstructor,
isMeetingTimeTBA, isMeetingTimeTBA,
isTimeTBA, isTimeTBA,
formatMeetingTimesTooltip,
} from "$lib/course"; } from "$lib/course";
import CourseDetail from "./CourseDetail.svelte"; import CourseDetail from "./CourseDetail.svelte";
import { fade, fly, slide } from "svelte/transition"; import { fade, fly, slide } from "svelte/transition";
import { flip } from "svelte/animate";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbars } from "overlayscrollbars";
import { themeStore } from "$lib/stores/theme.svelte"; import { themeStore } from "$lib/stores/theme.svelte";
@@ -48,6 +50,13 @@ let tableWrapper: HTMLDivElement = undefined!;
let copiedCrn: string | null = $state(null); let copiedCrn: string | null = $state(null);
let copyTimeoutId: number | undefined; let copyTimeoutId: number | undefined;
// Collapse expanded row when the dataset changes to avoid stale detail rows
// and FLIP position calculation glitches from lingering expanded content
$effect(() => {
courses; // track dependency
expandedCrn = null;
});
onMount(() => { onMount(() => {
const osInstance = OverlayScrollbars(tableWrapper, { const osInstance = OverlayScrollbars(tableWrapper, {
overflow: { x: "scroll", y: "hidden" }, overflow: { x: "scroll", y: "hidden" },
@@ -192,7 +201,7 @@ const columns: ColumnDef<CourseResponse, unknown>[] = [
accessorFn: (row) => { accessorFn: (row) => {
if (row.meetingTimes.length === 0) return ""; if (row.meetingTimes.length === 0) return "";
const mt = row.meetingTimes[0]; const mt = row.meetingTimes[0];
return `${formatMeetingDays(mt)} ${formatTime(mt.begin_time)}`; return `${formatMeetingDays(mt)} ${formatTimeRange(mt.begin_time, mt.end_time)}`;
}, },
header: "Time", header: "Time",
enableSorting: true, enableSorting: true,
@@ -227,6 +236,7 @@ const table = createSvelteTable({
get data() { get data() {
return courses; return courses;
}, },
getRowId: (row) => String(row.crn),
columns, columns,
state: { state: {
get sorting() { get sorting() {
@@ -438,8 +448,8 @@ const table = createSvelteTable({
</tr> </tr>
{/each} {/each}
</thead> </thead>
<tbody> {#if loading && courses.length === 0}
{#if loading && courses.length === 0} <tbody>
{#each Array(5) as _} {#each Array(5) as _}
<tr class="border-b border-border"> <tr class="border-b border-border">
{#each table.getVisibleLeafColumns() as col} {#each table.getVisibleLeafColumns() as col}
@@ -458,7 +468,9 @@ const table = createSvelteTable({
{/each} {/each}
</tr> </tr>
{/each} {/each}
{:else if courses.length === 0} </tbody>
{:else if courses.length === 0}
<tbody>
<tr> <tr>
<td <td
colspan={visibleColumnIds.length} colspan={visibleColumnIds.length}
@@ -467,9 +479,15 @@ const table = createSvelteTable({
No courses found. Try adjusting your filters. No courses found. Try adjusting your filters.
</td> </td>
</tr> </tr>
{:else} </tbody>
{#each table.getRowModel().rows as row (row.id)} {:else}
{@const course = row.original} {#each table.getRowModel().rows as row, i (row.id)}
{@const course = row.original}
<tbody
animate:flip={{ duration: 300 }}
in:fade={{ duration: 200, delay: Math.min(i * 20, 400) }}
out:fade={{ duration: 150 }}
>
<tr <tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
course.crn course.crn
@@ -600,39 +618,54 @@ const table = createSvelteTable({
</td> </td>
{:else if colId === "time"} {:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
{#if timeIsTBA(course)} <SimpleTooltip
<span text={formatMeetingTimesTooltip(course.meetingTimes)}
class="text-xs text-muted-foreground/60" passthrough
>TBA</span >
> {#if timeIsTBA(course)}
{:else}
{@const mt =
course.meetingTimes[0]}
{#if !isMeetingTimeTBA(mt)}
<span
class="font-mono font-medium"
>{formatMeetingDays(
mt,
)}</span
>
{" "}
{/if}
{#if !isTimeTBA(mt)}
<span
class="text-muted-foreground"
>{formatTime(
mt.begin_time,
)}&ndash;{formatTime(
mt.end_time,
)}</span
>
{:else}
<span <span
class="text-xs text-muted-foreground/60" class="text-xs text-muted-foreground/60"
>TBA</span >TBA</span
> >
{:else}
{@const mt =
course.meetingTimes[0]}
<span>
{#if !isMeetingTimeTBA(mt)}
<span
class="font-mono font-medium"
>{formatMeetingDays(
mt,
)}</span
>
{" "}
{/if}
{#if !isTimeTBA(mt)}
<span
class="text-muted-foreground"
>{formatTimeRange(
mt.begin_time,
mt.end_time,
)}</span
>
{:else}
<span
class="text-xs text-muted-foreground/60"
>TBA</span
>
{/if}
{#if course.meetingTimes.length > 1}
<span
class="ml-1 text-xs text-muted-foreground/70 font-medium"
>+{course
.meetingTimes
.length -
1}</span
>
{/if}
</span>
{/if} {/if}
{/if} </SimpleTooltip>
</td> </td>
{:else if colId === "location"} {:else if colId === "location"}
<td class="py-2 px-2 whitespace-nowrap"> <td class="py-2 px-2 whitespace-nowrap">
@@ -706,9 +739,9 @@ const table = createSvelteTable({
</td> </td>
</tr> </tr>
{/if} {/if}
{/each} </tbody>
{/if} {/each}
</tbody> {/if}
</table> </table>
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Portal> <ContextMenu.Portal>
+1 -1
View File
@@ -24,7 +24,7 @@ let {
<Tooltip.Content <Tooltip.Content
{side} {side}
sideOffset={6} sideOffset={6}
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72" class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md whitespace-pre-line max-w-max"
> >
{text} {text}
</Tooltip.Content> </Tooltip.Content>
+151 -11
View File
@@ -1,14 +1,19 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
formatTime, formatTime,
formatTimeRange,
formatMeetingDays, formatMeetingDays,
formatMeetingDaysVerbose,
formatMeetingTime, formatMeetingTime,
formatMeetingTimeTooltip,
formatMeetingTimesTooltip,
abbreviateInstructor, abbreviateInstructor,
formatCreditHours, formatCreditHours,
getPrimaryInstructor, getPrimaryInstructor,
isMeetingTimeTBA, isMeetingTimeTBA,
isTimeTBA, isTimeTBA,
formatDate, formatDate,
formatDateShort,
formatMeetingDaysLong, formatMeetingDaysLong,
} from "$lib/course"; } from "$lib/course";
import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api"; import type { DbMeetingTime, CourseResponse, InstructorResponse } from "$lib/api";
@@ -53,13 +58,13 @@ describe("formatMeetingDays", () => {
formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true })) formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
).toBe("MWF"); ).toBe("MWF");
}); });
it("returns TR for tue/thu", () => { it("returns TTh for tue/thu", () => {
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TR"); expect(formatMeetingDays(makeMeetingTime({ tuesday: true, thursday: true }))).toBe("TTh");
}); });
it("returns empty string when no days", () => { it("returns MW for mon/wed", () => {
expect(formatMeetingDays(makeMeetingTime())).toBe(""); expect(formatMeetingDays(makeMeetingTime({ monday: true, wednesday: true }))).toBe("MW");
}); });
it("returns all days", () => { it("returns MTWThF for all weekdays", () => {
expect( expect(
formatMeetingDays( formatMeetingDays(
makeMeetingTime({ makeMeetingTime({
@@ -68,16 +73,56 @@ describe("formatMeetingDays", () => {
wednesday: true, wednesday: true,
thursday: true, thursday: true,
friday: true, friday: true,
saturday: true,
sunday: true,
}) })
) )
).toBe("MTWRFSU"); ).toBe("MTWThF");
});
it("returns partial abbreviation for single day", () => {
expect(formatMeetingDays(makeMeetingTime({ monday: true }))).toBe("Mon");
expect(formatMeetingDays(makeMeetingTime({ thursday: true }))).toBe("Thu");
expect(formatMeetingDays(makeMeetingTime({ saturday: true }))).toBe("Sat");
});
it("concatenates codes for other multi-day combos", () => {
expect(formatMeetingDays(makeMeetingTime({ monday: true, friday: true }))).toBe("MF");
expect(formatMeetingDays(makeMeetingTime({ tuesday: true, saturday: true }))).toBe("TSa");
expect(
formatMeetingDays(makeMeetingTime({ wednesday: true, friday: true, sunday: true }))
).toBe("WFSu");
expect(
formatMeetingDays(
makeMeetingTime({ monday: true, tuesday: true, wednesday: true, thursday: true })
)
).toBe("MTWTh");
});
it("returns empty string when no days", () => {
expect(formatMeetingDays(makeMeetingTime())).toBe("");
});
});
describe("formatTimeRange", () => {
it("elides AM when both times are AM", () => {
expect(formatTimeRange("0900", "0950")).toBe("9:009:50 AM");
});
it("elides PM when both times are PM", () => {
expect(formatTimeRange("1315", "1430")).toBe("1:152:30 PM");
});
it("keeps both markers when crossing noon", () => {
expect(formatTimeRange("1130", "1220")).toBe("11:30 AM12:20 PM");
});
it("returns TBA for null begin", () => {
expect(formatTimeRange(null, "0950")).toBe("TBA");
});
it("returns TBA for null end", () => {
expect(formatTimeRange("0900", null)).toBe("TBA");
});
it("handles midnight and noon", () => {
expect(formatTimeRange("0000", "0050")).toBe("12:0012:50 AM");
expect(formatTimeRange("1200", "1250")).toBe("12:0012:50 PM");
}); });
}); });
describe("formatMeetingTime", () => { describe("formatMeetingTime", () => {
it("formats a standard meeting time", () => { it("formats a standard meeting time with elided AM/PM", () => {
expect( expect(
formatMeetingTime( formatMeetingTime(
makeMeetingTime({ makeMeetingTime({
@@ -88,7 +133,19 @@ describe("formatMeetingTime", () => {
end_time: "0950", end_time: "0950",
}) })
) )
).toBe("MWF 9:00 AM9:50 AM"); ).toBe("MWF 9:009:50 AM");
});
it("keeps both markers when crossing noon", () => {
expect(
formatMeetingTime(
makeMeetingTime({
tuesday: true,
thursday: true,
begin_time: "1130",
end_time: "1220",
})
)
).toBe("TTh 11:30 AM12:20 PM");
}); });
it("returns TBA when no days", () => { it("returns TBA when no days", () => {
expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe( expect(formatMeetingTime(makeMeetingTime({ begin_time: "0900", end_time: "0950" }))).toBe(
@@ -96,7 +153,7 @@ describe("formatMeetingTime", () => {
); );
}); });
it("returns days + TBA when no times", () => { it("returns days + TBA when no times", () => {
expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("M TBA"); expect(formatMeetingTime(makeMeetingTime({ monday: true }))).toBe("Mon TBA");
}); });
}); });
@@ -244,3 +301,86 @@ describe("formatMeetingDaysLong", () => {
expect(formatMeetingDaysLong(makeMeetingTime())).toBe(""); expect(formatMeetingDaysLong(makeMeetingTime())).toBe("");
}); });
}); });
describe("formatDateShort", () => {
it("formats YYYY-MM-DD to short", () => {
expect(formatDateShort("2024-08-26")).toBe("Aug 26, 2024");
});
it("formats MM/DD/YYYY to short", () => {
expect(formatDateShort("12/12/2024")).toBe("Dec 12, 2024");
});
it("returns original for invalid", () => {
expect(formatDateShort("bad")).toBe("bad");
});
});
describe("formatMeetingDaysVerbose", () => {
it("returns plural for single day", () => {
expect(formatMeetingDaysVerbose(makeMeetingTime({ thursday: true }))).toBe("Thursdays");
});
it("joins two days with ampersand", () => {
expect(formatMeetingDaysVerbose(makeMeetingTime({ tuesday: true, thursday: true }))).toBe(
"Tuesdays & Thursdays"
);
});
it("uses Oxford-style ampersand for 3+ days", () => {
expect(
formatMeetingDaysVerbose(makeMeetingTime({ monday: true, wednesday: true, friday: true }))
).toBe("Mondays, Wednesdays & Fridays");
});
it("returns empty string when no days", () => {
expect(formatMeetingDaysVerbose(makeMeetingTime())).toBe("");
});
});
describe("formatMeetingTimeTooltip", () => {
it("formats full tooltip with location and dates", () => {
const mt = makeMeetingTime({
tuesday: true,
thursday: true,
begin_time: "1615",
end_time: "1730",
building_description: "Main Hall",
room: "2.206",
});
expect(formatMeetingTimeTooltip(mt)).toBe(
"Tuesdays & Thursdays, 4:155:30 PM\nMain Hall 2.206, Aug 26, 2024 Dec 12, 2024"
);
});
it("handles TBA days and times", () => {
expect(formatMeetingTimeTooltip(makeMeetingTime())).toBe("TBA\nAug 26, 2024 Dec 12, 2024");
});
it("handles days with TBA times", () => {
expect(formatMeetingTimeTooltip(makeMeetingTime({ monday: true }))).toBe(
"Mondays, TBA\nAug 26, 2024 Dec 12, 2024"
);
});
});
describe("formatMeetingTimesTooltip", () => {
it("returns TBA for empty array", () => {
expect(formatMeetingTimesTooltip([])).toBe("TBA");
});
it("joins multiple meetings with blank line", () => {
const mts = [
makeMeetingTime({
monday: true,
wednesday: true,
friday: true,
begin_time: "0900",
end_time: "0950",
}),
makeMeetingTime({
thursday: true,
begin_time: "1300",
end_time: "1400",
building_description: "Lab",
room: "101",
}),
];
const result = formatMeetingTimesTooltip(mts);
expect(result).toContain("Mondays, Wednesdays & Fridays, 9:009:50 AM");
expect(result).toContain("Thursdays, 1:002:00 PM\nLab 101");
expect(result).toContain("\n\n");
});
});
+128 -18
View File
@@ -10,21 +10,29 @@ export function formatTime(time: string | null): string {
return `${display}:${minutes} ${period}`; return `${display}:${minutes} ${period}`;
} }
/** Get day abbreviation string like "MWF" from a meeting time */ /**
* 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 { export function formatMeetingDays(mt: DbMeetingTime): string {
const days: [boolean, string][] = [ const dayDefs: [boolean, string, string][] = [
[mt.monday, "M"], [mt.monday, "M", "Mon"],
[mt.tuesday, "T"], [mt.tuesday, "T", "Tue"],
[mt.wednesday, "W"], [mt.wednesday, "W", "Wed"],
[mt.thursday, "R"], [mt.thursday, "Th", "Thu"],
[mt.friday, "F"], [mt.friday, "F", "Fri"],
[mt.saturday, "S"], [mt.saturday, "Sa", "Sat"],
[mt.sunday, "U"], [mt.sunday, "Su", "Sun"],
]; ];
return days const active = dayDefs.filter(([a]) => a);
.filter(([active]) => active) if (active.length === 0) return "";
.map(([, abbr]) => abbr) if (active.length === 1) return active[0][2];
.join(""); return active.map(([, code]) => code).join("");
} }
/** Longer day names for detail view: single day → "Thursdays", multiple → "Mon, Wed, Fri" */ /** Longer day names for detail view: single day → "Thursdays", multiple → "Mon, Wed, Fri" */
@@ -44,14 +52,38 @@ export function formatMeetingDaysLong(mt: DbMeetingTime): string {
return active.map(([, short]) => short).join(", "); return active.map(([, short]) => short).join(", ");
} }
/** Condensed meeting time: "MWF 9:00 AM9:50 AM" */ /**
* Format a time range with smart AM/PM elision.
*
* Same period: "9:009:50 AM"
* Cross-period: "11:30 AM12: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:009:50 AM" */
export function formatMeetingTime(mt: DbMeetingTime): string { export function formatMeetingTime(mt: DbMeetingTime): string {
const days = formatMeetingDays(mt); const days = formatMeetingDays(mt);
if (!days) return "TBA"; if (!days) return "TBA";
const begin = formatTime(mt.begin_time); const range = formatTimeRange(mt.begin_time, mt.end_time);
const end = formatTime(mt.end_time); if (range === "TBA") return `${days} TBA`;
if (begin === "TBA") return `${days} TBA`; return `${days} ${range}`;
return `${days} ${begin}${end}`;
} }
/** /**
@@ -151,6 +183,84 @@ export function formatLocationLong(mt: DbMeetingTime): string | null {
return mt.room ? `${name} ${mt.room}` : name; 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:155: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");
}
/** Format credit hours display */ /** Format credit hours display */
export function formatCreditHours(course: CourseResponse): string { export function formatCreditHours(course: CourseResponse): string {
if (course.creditHours != null) return String(course.creditHours); if (course.creditHours != null) return String(course.creditHours);