Skip to content

Conversation

@samuel-skean
Copy link
Contributor

@samuel-skean samuel-skean commented Dec 9, 2025

Much of this was vibe coded, but I'm pretty confident in its correctness. I certainly tested it and understand the code it added.

  • Renamed "Jump to Nearest Date" to "Jump to Next Event"
    • This more directly reflects the thing it does... though it still doesn't quite reflect the fact it'll jump to the current day's event, after that event, even after the event ends (which is behavior I want to retain). I'm totally open to suggestions.
  • Restores the filter buttons even after the course has ended.
  • Made the "Jump to Next Event" button play a short confetti animation if pressed after the course ended.
  • Display an error message in a toast if the user presses "Jump to Next Event" and there are no next events, but the course hasn't ended. Begone, silent failure! Begone!
  • Made the course end after week 15 instead of week 16.
    • This is just my preference, and it's currently the most recent commit (commit f0fbd13), so it should be super easy to undo.

Here's the transcript of the talk with GPT-5 that I had in Zed.

Make it so that when the course is ended and the user presses the "Jump to Nearest Date" button, confetti falls across
the screen and the "Jump to Nearest Date" button is replaced with the text "Course Concluded!" and becomes disabled.

**Tool Call: Search files for regex `Jump to Nearest Date`** Status: Completed

Found 1 matches:

``` <span class="action primary" id="jumpToDateButton" data-concluded={concluded}> Jump to Nearest Date </span> ```

**Tool Call: Read file `src/components/Schedule.astro`** Status: Completed

```cs484-website/src/components/Schedule.astro --- import { getCollection } from "astro:content"; import { Badge } from
"@astrojs/starlight/components"; import dayjs, { type Dayjs } from "dayjs"; import DOMPurify from
"isomorphic-dompurify"; import { marked } from "marked"; import { transformContentData } from "../utils/contentUtils";
import { calculateAbsoluteDate, formatTime } from "../utils/dateUtils";

interface Reading { link: string; name: string; grad_only?: boolean; optional?: boolean; }

interface BaseEvent { type: string; title: string; date: Dayjs; notes?: string; tentative?: boolean; }

interface ExamEvent extends BaseEvent { type: "exam"; content: string; }

interface HomeworkEvent extends BaseEvent { type: "homework release" | "homework due"; slug: string; }

interface LectureEvent extends BaseEvent { type: "lecture"; slug: string; readings?: Reading[]; }

const examsCollection = await getCollection("exams"); const exams = examsCollection .filter((entry) =>
entry.data.relative_date) .map((entry) => { const data = transformContentData(entry.data); return { type: "exam" as
const, title: data.title, date: calculateAbsoluteDate(entry.data.relative_date!), content: data.content, notes:
data.notes, tentative: data.tentative, } satisfies ExamEvent; });

const homeworks = ( await getCollection("docs", (entry) => entry.id.startsWith("homework")) ).flatMap((entry) => { const
data = transformContentData(entry.data); const events = []; if (data.relative_release) { events.push({ slug: entry.id,
type: "homework release" as const, title: data.title, date: calculateAbsoluteDate(data.relative_release), notes:
data.notes, tentative: data.tentative, } satisfies HomeworkEvent); } if (data.relative_due) { events.push({ slug:
entry.id, type: "homework due" as const, title: data.title, date: calculateAbsoluteDate(data.relative_due), notes:
data.notes, tentative: data.tentative, } satisfies HomeworkEvent); } return events; });

const lecturesCollection = await getCollection("docs", (entry) => entry.id.startsWith("lecture"), ); const lectures =
lecturesCollection .filter((entry) => entry.data.relative_date) .map((entry) => { const data =
transformContentData(entry.data); return { slug: entry.id, type: "lecture" as const, title: data.title, date:
calculateAbsoluteDate(entry.data.relative_date!), readings: data.readings, notes: data.notes, tentative: data.tentative,
} satisfies LectureEvent; });

const events = [...exams, ...homeworks, ...lectures].sort( (a, b) => a.date.valueOf() - b.date.valueOf(), );

// events is already sorted, so just check if the last event is in the past // class concludes on the Friday of finals
week

const concluded = calculateAbsoluteDate({ week: 16, day: "Friday", time: "17:00:00", }).isBefore(dayjs()); ---

<div class="controls-container"> <span class="action primary" id="jumpToDateButton" data-concluded={concluded}> Jump to
Nearest Date </span>

  <!-- Filter Buttons --> <span class="action primary filter-button" id="filterAll" data-filter="all">All</span> <span
  class="action secondary filter-button" id="filterLecture" data-filter="lecture">Lectures</span> <span class="action
  secondary filter-button" id="filterHomework" data-filter="homework">Homeworks</span> <span class="action secondary
  filter-button" id="filterExam" data-filter="exam">Exams</span> </div>

<div class="not-content ml-4 flex items-center lg:ml-0 lg:justify-center"> <table class="border-separate
border-spacing-y-2 text-sm"> <thead> <tr> <th class="hidden lg:table-cell lg:min-w-[8rem]">Type</th> <th class="hidden
lg:table-cell">Date</th> <th class="hidden lg:table-cell">Topic</th> <th class="hidden lg:table-cell">Content</th> <th
class="hidden lg:table-cell">Notes</th> </tr> </thead> <tbody> { events.map((event) => ( <tr class:list={[ {
"!bg-[var(--sl-color-accent-low)]": event?.tentative, }, "tr-class", ]} data-date={event.date.toISOString()} data-type={
event.type === "lecture" ? "lecture" : event.type === "exam" ? "exam" : "homework" }
          > <td class="td-class"> {event?.tentative && <Badge text="Tentative" variant="tip" />} <div>{event.type}</div>
            </td> <td class="td-class">{formatTime(event.date)}</td> <td class="td-class"> {(event.type === "homework
            release" || event.type === "homework due") && ( <a href={`/${event.slug}/`}>{event.title}</a> )} {event.type
            === "exam" && event.title} {event.type === "lecture" && event.title} </td> <td class="td-class"> {event.type
            === "exam" && event.content} {event.type === "lecture" && event.readings && ( <ul>
            {event.readings.map((reading: Reading) => ( <li> <a href={reading.link} target="_blank"> {reading.name} </a>
            {reading.grad_only && <span title="grad only">🧑‍🎓</span>} {reading.optional && <span title="not on
            quiz">📵</span>} </li> ))} </ul> )} </td> <td class="td-class"> {event.notes && ( <Fragment
            set:html={marked(event.notes, { async: true }) .then((r) => DOMPurify.sanitize(r)) .catch((e) => `Markdown
            parse failure: ${e}.`)} /> )} </td> </tr> )) } </tbody> </table>

  <style> @reference "tailwindcss";

    .td-class { @apply border-0 px-4 py-3 lg:first:rounded-t-none lg:first:rounded-bl-lg lg:first:rounded-tl-lg
    lg:last:rounded-b-none lg:last:rounded-br-lg lg:last:rounded-tr-lg; }

    .tr-class { @apply mb-4 flex flex-col rounded-lg odd:bg-[var(--sl-color-gray-6)] even:bg-[var(--sl-color-gray-7)]
    lg:table-row; }

    /* Dynamic zebra striping classes for filtered content - higher specificity */ tr[data-date].odd-stripe {
    background-color: var(--sl-color-gray-6); }

    tr[data-date].even-stripe { background-color: var(--sl-color-gray-7); }

    /* Controls container for same-row layout */ .controls-container { display: flex; gap: 1rem; align-items: center;
    margin-top: 1rem; margin-bottom: 1rem; flex-wrap: wrap; /* Allow wrapping on smaller screens */ }

    /* Mobile-specific styling */ @media (max-width: 768px) { .controls-container { gap: 0.75rem; justify-content:
    center; /* Center buttons on mobile */ }

      .action { padding: 0.75rem 1.5rem; font-size: var(--sl-text-base); min-width: 80px; /* Ensure minimum width for
      circular appearance */ text-align: center; } }

    /* Filter container styling */ .filter-container { margin-top: 1.5rem; margin-bottom: 1rem; display: flex; gap:
    0.5rem; align-items: center; }

    .action { display: inline-block; cursor: pointer; margin-top: 1rem; margin-bottom: 1rem; gap: 0.5em; align-items:
    center; border-radius: 999rem; padding: 0.5rem 1.125rem; color: var(--sl-color-white); line-height: 1.1875;
    text-decoration: none; font-size: var(--sl-text-sm); border: 1px solid var(--sl-color-text-accent); }
    .action.primary { background: var(--sl-color-text-accent); color: var(--sl-color-black); } ul { padding-left:
    0.5rem; } @keyframes fadeHighlight { from { background-color: var(--sl-color-accent); } }

    .highlight { animation: fadeHighlight 2s ease-out forwards; } /* Ensure hidden attribute wins over flex/table
    display from classes */ tr[hidden] { display: none !important; } </style>

  <script> import dayjs from "dayjs";

    // Lightweight toast helper reused across interactions const showToast = (message: string, duration = 3000) => { let
    toast = document.getElementById("schedule-toast"); if (!toast) { toast = document.createElement("div"); toast.id =
    "schedule-toast"; toast.style.position = "fixed"; toast.style.bottom = "20px"; toast.style.right = "20px";
    toast.style.maxWidth = "320px"; toast.style.padding = "12px 16px"; toast.style.borderRadius = "8px";
    toast.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; toast.style.backgroundColor = "var(--sl-color-accent-low)";
    toast.style.color = "var(--sl-color-text)"; toast.style.fontSize = "14px"; toast.style.zIndex = "9999";
    toast.style.opacity = "0"; toast.style.transition = "opacity 200ms ease"; document.body.appendChild(toast); }
    toast.textContent = message; toast.style.opacity = "1"; window.clearTimeout((toast as any)._hideTimer); (toast as
    any)._hideTimer = window.setTimeout(() => { toast!.style.opacity = "0"; }, duration); };

    // function to jump to the nearest date, button and functionality is set here const jumpToDate = () => { const today
    = dayjs(); const rows = document.querySelectorAll("tr[data-date]:not([hidden])"); const nearestRow =
    Array.from(rows) .filter( (row) => { const rowDate = dayjs(row.getAttribute("data-date") ?? today); return
    rowDate.isSame(today, 'day') || rowDate.isAfter(today, 'day') }, ) // Filter out past events .sort((a, b) => {
    return ( Math.abs(today.diff(dayjs(a.getAttribute("data-date") ?? today))) -
    Math.abs(today.diff(dayjs(b.getAttribute("data-date") ?? today))) // Sort by diff between today's date and event's
    date ); })[0] as HTMLElement; // Return first item (nearest date) if (!nearestRow) return; const originalColor =
    window.getComputedStyle(nearestRow).backgroundColor; nearestRow.style.backgroundColor = originalColor; // Set
    original color inline nearestRow.classList.add("highlight");

      setTimeout(() => { nearestRow.classList.remove("highlight"); nearestRow.style.backgroundColor = ""; // Revert to
      stylesheet color }, 2000);

      // Check for prefers-reduced-motion setting const prefersReducedMotion =
      window.matchMedia('(prefers-reduced-motion: reduce)').matches; nearestRow.scrollIntoView({ behavior:
      prefersReducedMotion ? "auto" : "smooth", block: "start" }); };

    const updateZebraStriping = () => { const visibleRows = document.querySelectorAll("tr[data-date]:not([hidden])");
    visibleRows.forEach((row, index) => { const htmlRow = row as HTMLElement; // Remove existing odd/even classes
    htmlRow.classList.remove('odd-stripe', 'even-stripe'); // Add appropriate class based on visible index if (index % 2
    === 0) { htmlRow.classList.add('even-stripe'); } else { htmlRow.classList.add('odd-stripe'); } }); };

    // Map URL hash to filter type const hashToFilter: Record<string, string> = { '#all': 'all', '#lectures': 'lecture',
    '#homeworks': 'homework', '#exams': 'exam' };

    // Map filter type to URL hash const filterToHash: Record<string, string> = { 'all': '#all', 'lecture': '#lectures',
    'homework': '#homeworks', 'exam': '#exams' };

    // Handles button colors, when a user selects a button that becomes the "active" filter indicated by color const
    updateLinkButtonVariants = (activeFilter: string) => { const buttons = document.querySelectorAll('.filter-button');
    buttons.forEach((button) => { const filterType = button.getAttribute('data-filter'); const isActive = filterType ===
    activeFilter;

        // Update the class for styling if (isActive) { button.classList.remove('secondary');
        button.classList.add('primary'); } else { button.classList.remove('primary'); button.classList.add('secondary');
        } }); };

    const applyFilter = (filterType = 'all') => { const rows = document.querySelectorAll("tr[data-date]");
    rows.forEach((row) => { const type = row.getAttribute("data-type"); const show = filterType === "all" || type ===
    filterType; (row as HTMLElement).hidden = !show; });

      // Update zebra striping after filtering updateZebraStriping();

      // Update button variants updateLinkButtonVariants(filterType);

      // Update URL without triggering page reload const newHash = filterToHash[filterType] || '#all'; if
      (window.location.hash !== newHash) { history.replaceState(null, '', newHash); } };

    // Grab the filter from the url if there is one const getFilterFromUrl = () => { const hash = window.location.hash
    || '#all'; return hashToFilter[hash] || 'all'; };

    // Initialize filter based on URL hash document.addEventListener("DOMContentLoaded", () => { const initialFilter =
    getFilterFromUrl(); applyFilter(initialFilter); });

    // Listen for hash changes (browser back/forward, manual URL changes) window.addEventListener('hashchange', () => {
    const newFilter = getFilterFromUrl(); applyFilter(newFilter); });

    // Add click handlers for filter buttons document.querySelector("#filterAll")?.addEventListener("click", (e) => {
    e.preventDefault(); applyFilter('all'); });

    document.querySelector("#filterLecture")?.addEventListener("click", (e) => { e.preventDefault();
    applyFilter('lecture'); });

    document.querySelector("#filterHomework")?.addEventListener("click", (e) => { e.preventDefault();
    applyFilter('homework'); });

    document.querySelector("#filterExam")?.addEventListener("click", (e) => { e.preventDefault(); applyFilter('exam');
    });

    document .querySelector("#jumpToDateButton") ?.addEventListener("click", () => { const btn =
    document.querySelector("#jumpToDateButton"); const isConcluded = btn?.getAttribute("data-concluded") === "true"; if
    (isConcluded) { showToast("This course has concluded. There are no upcoming dates to jump to."); return; }

        // If there are no upcoming visible events (same day or future), show a toast and bail const now = dayjs();
        const upcomingVisibleRows = Array.from( document.querySelectorAll("tr[data-date]:not([hidden])") ).filter((row)
        => { const rowDateStr = row.getAttribute("data-date") || ""; const rowDate = dayjs(rowDateStr); return
        rowDate.isSame(now, "day") || rowDate.isAfter(now, "day"); });

        if (upcomingVisibleRows.length === 0) { showToast("No upcoming events are visible in the current view.");
        return; }

        jumpToDate(); }); </script> </div>

```

**Tool Call: src/components/Schedule.astro** Status: Completed

Diff: src/components/Schedule.astro ``` --- import { getCollection } from "astro:content"; import { Badge } from
"@astrojs/starlight/components"; import dayjs, { type Dayjs } from "dayjs"; import DOMPurify from
"isomorphic-dompurify"; import { marked } from "marked"; import { transformContentData } from "../utils/contentUtils";
import { calculateAbsoluteDate, formatTime } from "../utils/dateUtils";

interface Reading { link: string; name: string; grad_only?: boolean; optional?: boolean; }

interface BaseEvent { type: string; title: string; date: Dayjs; notes?: string; tentative?: boolean; }

interface ExamEvent extends BaseEvent { type: "exam"; content: string; }

interface HomeworkEvent extends BaseEvent { type: "homework release" | "homework due"; slug: string; }

interface LectureEvent extends BaseEvent { type: "lecture"; slug: string; readings?: Reading[]; }

const examsCollection = await getCollection("exams"); const exams = examsCollection .filter((entry) =>
entry.data.relative_date) .map((entry) => { const data = transformContentData(entry.data); return { type: "exam" as
const, title: data.title, date: calculateAbsoluteDate(entry.data.relative_date!), content: data.content, notes:
data.notes, tentative: data.tentative, } satisfies ExamEvent; });

const homeworks = ( await getCollection("docs", (entry) => entry.id.startsWith("homework")) ).flatMap((entry) => { const
data = transformContentData(entry.data); const events = []; if (data.relative_release) { events.push({ slug: entry.id,
type: "homework release" as const, title: data.title, date: calculateAbsoluteDate(data.relative_release), notes:
data.notes, tentative: data.tentative, } satisfies HomeworkEvent); } if (data.relative_due) { events.push({ slug:
entry.id, type: "homework due" as const, title: data.title, date: calculateAbsoluteDate(data.relative_due), notes:
data.notes, tentative: data.tentative, } satisfies HomeworkEvent); } return events; });

const lecturesCollection = await getCollection("docs", (entry) => entry.id.startsWith("lecture"), ); const lectures =
lecturesCollection .filter((entry) => entry.data.relative_date) .map((entry) => { const data =
transformContentData(entry.data); return { slug: entry.id, type: "lecture" as const, title: data.title, date:
calculateAbsoluteDate(entry.data.relative_date!), readings: data.readings, notes: data.notes, tentative: data.tentative,
} satisfies LectureEvent; });

const events = [...exams, ...homeworks, ...lectures].sort( (a, b) => a.date.valueOf() - b.date.valueOf(), );

// events is already sorted, so just check if the last event is in the past // class concludes on the Friday of finals
week

const concluded = calculateAbsoluteDate({ week: 16, day: "Friday", time: "17:00:00", }).isBefore(dayjs()); ---

<div class="controls-container"> <span class="action primary" id="jumpToDateButton" data-concluded={concluded}> Jump to
Nearest Date </span>

  <!-- Filter Buttons --> <span class="action primary filter-button" id="filterAll" data-filter="all">All</span> <span
  class="action secondary filter-button" id="filterLecture" data-filter="lecture">Lectures</span> <span class="action
  secondary filter-button" id="filterHomework" data-filter="homework">Homeworks</span> <span class="action secondary
  filter-button" id="filterExam" data-filter="exam">Exams</span> </div>

<div class="not-content ml-4 flex items-center lg:ml-0 lg:justify-center"> <table class="border-separate
border-spacing-y-2 text-sm"> <thead> <tr> <th class="hidden lg:table-cell lg:min-w-[8rem]">Type</th> <th class="hidden
lg:table-cell">Date</th> <th class="hidden lg:table-cell">Topic</th> <th class="hidden lg:table-cell">Content</th> <th
class="hidden lg:table-cell">Notes</th> </tr> </thead> <tbody> { events.map((event) => ( <tr class:list={[ {
"!bg-[var(--sl-color-accent-low)]": event?.tentative, }, "tr-class", ]} data-date={event.date.toISOString()} data-type={
event.type === "lecture" ? "lecture" : event.type === "exam" ? "exam" : "homework" }
          > <td class="td-class"> {event?.tentative && <Badge text="Tentative" variant="tip" />} <div>{event.type}</div>
            </td> <td class="td-class">{formatTime(event.date)}</td> <td class="td-class"> {(event.type === "homework
            release" || event.type === "homework due") && ( <a href={`/${event.slug}/`}>{event.title}</a> )} {event.type
            === "exam" && event.title} {event.type === "lecture" && event.title} </td> <td class="td-class"> {event.type
            === "exam" && event.content} {event.type === "lecture" && event.readings && ( <ul>
            {event.readings.map((reading: Reading) => ( <li> <a href={reading.link} target="_blank"> {reading.name} </a>
            {reading.grad_only && <span title="grad only">🧑‍🎓</span>} {reading.optional && <span title="not on
            quiz">📵</span>} </li> ))} </ul> )} </td> <td class="td-class"> {event.notes && ( <Fragment
            set:html={marked(event.notes, { async: true }) .then((r) => DOMPurify.sanitize(r)) .catch((e) => `Markdown
            parse failure: ${e}.`)} /> )} </td> </tr> )) } </tbody> </table>

  <style> @reference "tailwindcss";

    .td-class { @apply border-0 px-4 py-3 lg:first:rounded-t-none lg:first:rounded-bl-lg lg:first:rounded-tl-lg
    lg:last:rounded-b-none lg:last:rounded-br-lg lg:last:rounded-tr-lg; }

    .tr-class { @apply mb-4 flex flex-col rounded-lg odd:bg-[var(--sl-color-gray-6)] even:bg-[var(--sl-color-gray-7)]
    lg:table-row; }

    /* Dynamic zebra striping classes for filtered content - higher specificity */ tr[data-date].odd-stripe {
    background-color: var(--sl-color-gray-6); }

    tr[data-date].even-stripe { background-color: var(--sl-color-gray-7); }

    /* Controls container for same-row layout */ .controls-container { display: flex; gap: 1rem; align-items: center;
    margin-top: 1rem; margin-bottom: 1rem; flex-wrap: wrap; /* Allow wrapping on smaller screens */ }

    /* Mobile-specific styling */ @media (max-width: 768px) { .controls-container { gap: 0.75rem; justify-content:
    center; /* Center buttons on mobile */ }

      .action { padding: 0.75rem 1.5rem; font-size: var(--sl-text-base); min-width: 80px; /* Ensure minimum width for
      circular appearance */ text-align: center; } }

    /* Filter container styling */ .filter-container { margin-top: 1.5rem; margin-bottom: 1rem; display: flex; gap:
    0.5rem; align-items: center; }

    .action { display: inline-block; cursor: pointer; margin-top: 1rem; margin-bottom: 1rem; gap: 0.5em; align-items:
    center; border-radius: 999rem; padding: 0.5rem 1.125rem; color: var(--sl-color-white); line-height: 1.1875;
    text-decoration: none; font-size: var(--sl-text-sm); border: 1px solid var(--sl-color-text-accent); }
    .action.primary { background: var(--sl-color-text-accent); color: var(--sl-color-black); }
    .action[aria-disabled="true"] { pointer-events: none; opacity: 0.6; cursor: not-allowed; } ul { padding-left:
    0.5rem; } @keyframes fadeHighlight { from { background-color: var(--sl-color-accent); } }

    .highlight { animation: fadeHighlight 2s ease-out forwards; } /* Ensure hidden attribute wins over flex/table
    display from classes */ tr[hidden] { display: none !important; } </style>

  <script> import dayjs from "dayjs";

    // Lightweight toast helper reused across interactions const showToast = (message: string, duration = 3000) => { let
    toast = document.getElementById("schedule-toast"); if (!toast) { toast = document.createElement("div"); toast.id =
    "schedule-toast"; toast.style.position = "fixed"; toast.style.bottom = "20px"; toast.style.right = "20px";
    toast.style.maxWidth = "320px"; toast.style.padding = "12px 16px"; toast.style.borderRadius = "8px";
    toast.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; toast.style.backgroundColor = "var(--sl-color-accent-low)";
    toast.style.color = "var(--sl-color-text)"; toast.style.fontSize = "14px"; toast.style.zIndex = "9999";
    toast.style.opacity = "0"; toast.style.transition = "opacity 200ms ease"; document.body.appendChild(toast); }
    toast.textContent = message; toast.style.opacity = "1"; window.clearTimeout((toast as any)._hideTimer); (toast as
    any)._hideTimer = window.setTimeout(() => { toast!.style.opacity = "0"; }, duration); };

    // Simple confetti effect across the screen for a short duration const launchConfetti = () => { const duration =
    2500; const endTime = Date.now() + duration; const colors = ["#f94144", "#f3722c", "#f9c74f", "#90be6d", "#43aa8b",
    "#577590"];

      const createPiece = () => { const piece = document.createElement("div"); const size = Math.random() * 8 + 6; //
      6px - 14px piece.style.position = "fixed"; piece.style.top = "-20px"; piece.style.left = Math.random() * 100 +
      "vw"; piece.style.width = size + "px"; piece.style.height = size * 0.6 + "px"; piece.style.backgroundColor =
      colors[Math.floor(Math.random() * colors.length)]; piece.style.transform = `rotate(${Math.random() * 360}deg)`;
      piece.style.opacity = "0.9"; piece.style.zIndex = "9999"; piece.style.borderRadius = "2px"; piece.style.willChange
      = "transform, top, left, opacity"; document.body.appendChild(piece);

        const fallDuration = Math.random() * 1500 + 1500; // 1.5s - 3s const horizontalDrift = (Math.random() - 0.5) *
        40; // -20px to 20px const spin = (Math.random() - 0.5) * 720; // -360deg to 360deg

        const start = performance.now(); const animate = (now: number) => { const t = Math.min(1, (now - start) /
        fallDuration); piece.style.top = `${t * 100}vh`; piece.style.left = `calc(${piece.style.left} + ${t *
        horizontalDrift}px)`; piece.style.transform = `rotate(${t * spin}deg)`; piece.style.opacity = `${1 - t}`;

          if (t < 1) { requestAnimationFrame(animate); } else { piece.remove(); } }; requestAnimationFrame(animate); };

      const interval = setInterval(() => { for (let i = 0; i < 12; i++) createPiece(); if (Date.now() > endTime)
      clearInterval(interval); }, 200); };

    // function to jump to the nearest date, button and functionality is set here const jumpToDate = () => { const today
    = dayjs(); const rows = document.querySelectorAll("tr[data-date]:not([hidden])"); const nearestRow =
    Array.from(rows) .filter( (row) => { const rowDate = dayjs(row.getAttribute("data-date") ?? today); return
    rowDate.isSame(today, 'day') || rowDate.isAfter(today, 'day') }, ) // Filter out past events .sort((a, b) => {
    return ( Math.abs(today.diff(dayjs(a.getAttribute("data-date") ?? today))) -
    Math.abs(today.diff(dayjs(b.getAttribute("data-date") ?? today))) // Sort by diff between today's date and event's
    date ); })[0] as HTMLElement; // Return first item (nearest date) if (!nearestRow) return; const originalColor =
    window.getComputedStyle(nearestRow).backgroundColor; nearestRow.style.backgroundColor = originalColor; // Set
    original color inline nearestRow.classList.add("highlight");

      setTimeout(() => { nearestRow.classList.remove("highlight"); nearestRow.style.backgroundColor = ""; // Revert to
      stylesheet color }, 2000);

      // Check for prefers-reduced-motion setting const prefersReducedMotion =
      window.matchMedia('(prefers-reduced-motion: reduce)').matches; nearestRow.scrollIntoView({ behavior:
      prefersReducedMotion ? "auto" : "smooth", block: "start" }); };

    const updateZebraStriping = () => { const visibleRows = document.querySelectorAll("tr[data-date]:not([hidden])");
    visibleRows.forEach((row, index) => { const htmlRow = row as HTMLElement; // Remove existing odd/even classes
    htmlRow.classList.remove('odd-stripe', 'even-stripe'); // Add appropriate class based on visible index if (index % 2
    === 0) { htmlRow.classList.add('even-stripe'); } else { htmlRow.classList.add('odd-stripe'); } }); };

    // Map URL hash to filter type const hashToFilter: Record<string, string> = { '#all': 'all', '#lectures': 'lecture',
    '#homeworks': 'homework', '#exams': 'exam' };

    // Map filter type to URL hash const filterToHash: Record<string, string> = { 'all': '#all', 'lecture': '#lectures',
    'homework': '#homeworks', 'exam': '#exams' };

    // Handles button colors, when a user selects a button that becomes the "active" filter indicated by color const
    updateLinkButtonVariants = (activeFilter: string) => { const buttons = document.querySelectorAll('.filter-button');
    buttons.forEach((button) => { const filterType = button.getAttribute('data-filter'); const isActive = filterType ===
    activeFilter;

        // Update the class for styling if (isActive) { button.classList.remove('secondary');
        button.classList.add('primary'); } else { button.classList.remove('primary'); button.classList.add('secondary');
        } }); };

    const applyFilter = (filterType = 'all') => { const rows = document.querySelectorAll("tr[data-date]");
    rows.forEach((row) => { const type = row.getAttribute("data-type"); const show = filterType === "all" || type ===
    filterType; (row as HTMLElement).hidden = !show; });

      // Update zebra striping after filtering updateZebraStriping();

      // Update button variants updateLinkButtonVariants(filterType);

      // Update URL without triggering page reload const newHash = filterToHash[filterType] || '#all'; if
      (window.location.hash !== newHash) { history.replaceState(null, '', newHash); } };

    // Grab the filter from the url if there is one const getFilterFromUrl = () => { const hash = window.location.hash
    || '#all'; return hashToFilter[hash] || 'all'; };

    // Initialize filter based on URL hash document.addEventListener("DOMContentLoaded", () => { const initialFilter =
    getFilterFromUrl(); applyFilter(initialFilter); });

    // Listen for hash changes (browser back/forward, manual URL changes) window.addEventListener('hashchange', () => {
    const newFilter = getFilterFromUrl(); applyFilter(newFilter); });

    // Add click handlers for filter buttons document.querySelector("#filterAll")?.addEventListener("click", (e) => {
    e.preventDefault(); applyFilter('all'); });

    document.querySelector("#filterLecture")?.addEventListener("click", (e) => { e.preventDefault();
    applyFilter('lecture'); });

    document.querySelector("#filterHomework")?.addEventListener("click", (e) => { e.preventDefault();
    applyFilter('homework'); });

    document.querySelector("#filterExam")?.addEventListener("click", (e) => { e.preventDefault(); applyFilter('exam');
    });

    document .querySelector("#jumpToDateButton") ?.addEventListener("click", () => { const btn =
    document.querySelector("#jumpToDateButton") as HTMLElement | null; const isConcluded =
    btn?.getAttribute("data-concluded") === "true"; if (isConcluded) { // Trigger celebratory confetti launchConfetti();

          // Replace button text and disable further interaction if (btn) { btn.textContent = "Course Concluded!";
          btn.setAttribute("aria-disabled", "true"); btn.setAttribute("title", "Course Concluded!"); } return; }

        // If there are no upcoming visible events (same day or future), show a toast and bail const now = dayjs();
        const upcomingVisibleRows = Array.from( document.querySelectorAll("tr[data-date]:not([hidden])") ).filter((row)
        => { const rowDateStr = row.getAttribute("data-date") || ""; const rowDate = dayjs(rowDateStr); return
        rowDate.isSame(now, "day") || rowDate.isAfter(now, "day"); });

        if (upcomingVisibleRows.length === 0) { showToast("No upcoming events are visible in the current view.");
        return; }

        jumpToDate(); }); </script> </div>

```

I found the `Jump to Nearest Date` button in `src/components/Schedule.astro` and implemented the behavior you described.

Changes made:
- When the course has concluded and the button is pressed, a lightweight confetti animation is launched.
- The button text is replaced with “Course Concluded!” and the button becomes disabled via `aria-disabled="true"`,
  pointer-events off, reduced opacity, and `cursor: not-allowed`.
- Added minimal styling for the disabled state.
- Kept the rest of the jump and filter logic intact.

Relevant snippet: ```cs484-website/src/components/Schedule.astro#L523-557 document .querySelector("#jumpToDateButton")
?.addEventListener("click", () => { const btn = document.querySelector("#jumpToDateButton") as HTMLElement | null; const
isConcluded = btn?.getAttribute("data-concluded") === "true"; if (isConcluded) { // Trigger celebratory confetti
launchConfetti();

      // Replace button text and disable further interaction if (btn) { btn.textContent = "Course Concluded!";
      btn.setAttribute("aria-disabled", "true"); btn.setAttribute("title", "Course Concluded!"); } return; }

    // existing upcoming-rows check and jumpToDate() call... }); ```

The confetti effect helper: ```cs484-website/src/components/Schedule.astro#L325-381 // Simple confetti effect across the
screen for a short duration const launchConfetti = () => { const duration = 2500; const endTime = Date.now() + duration;
const colors = ["#f94144", "#f3722c", "#f9c74f", "#90be6d", "#43aa8b", "#577590"];

  const createPiece = () => { const piece = document.createElement("div"); const size = Math.random() * 8 + 6; // 6px -
  14px piece.style.position = "fixed"; piece.style.top = "-20px"; piece.style.left = Math.random() * 100 + "vw";
  piece.style.width = size + "px"; piece.style.height = size * 0.6 + "px"; piece.style.backgroundColor =
  colors[Math.floor(Math.random() * colors.length)]; piece.style.transform = `rotate(${Math.random() * 360}deg)`;
  piece.style.opacity = "0.9"; piece.style.zIndex = "9999"; piece.style.borderRadius = "2px"; piece.style.willChange =
  "transform, top, left, opacity"; document.body.appendChild(piece);

    const fallDuration = Math.random() * 1500 + 1500; // 1.5s - 3s const horizontalDrift = (Math.random() - 0.5) * 40;
    // -20px to 20px const spin = (Math.random() - 0.5) * 720; // -360deg to 360deg

    const start = performance.now(); const animate = (now: number) => { const t = Math.min(1, (now - start) /
    fallDuration); piece.style.top = `${t * 100}vh`; piece.style.left = `calc(${piece.style.left} + ${t *
    horizontalDrift}px)`; piece.style.transform = `rotate(${t * spin}deg)`; piece.style.opacity = `${1 - t}`;

      if (t < 1) { requestAnimationFrame(animate); } else { piece.remove(); } }; requestAnimationFrame(animate); };

  const interval = setInterval(() => { for (let i = 0; i < 12; i++) createPiece(); if (Date.now() > endTime)
  clearInterval(interval); }, 200); }; ```

Disabled styling for the button: ```cs484-website/src/components/Schedule.astro#L272-279 .action[aria-disabled="true"] {
pointer-events: none; opacity: 0.6; cursor: not-allowed; } ```

This should satisfy the UX you asked for. If you prefer using a library like `canvas-confetti` for a more realistic
effect, I can wire that in, but the current approach avoids new dependencies and works across modern browsers.

Yeah, use that library instead.

**Tool Call: src/components/Schedule.astro** Status: Completed

Diff: src/components/Schedule.astro ``` --- import { getCollection } from "astro:content"; import { Badge } from
"@astrojs/starlight/components"; import dayjs, { type Dayjs } from "dayjs"; import DOMPurify from
"isomorphic-dompurify"; import { marked } from "marked"; import { transformContentData } from "../utils/contentUtils";
import { calculateAbsoluteDate, formatTime } from "../utils/dateUtils";

interface Reading { link: string; name: string; grad_only?: boolean; optional?: boolean; }

interface BaseEvent { type: string; title: string; date: Dayjs; notes?: string; tentative?: boolean; }

interface ExamEvent extends BaseEvent { type: "exam"; content: string; }

interface HomeworkEvent extends BaseEvent { type: "homework release" | "homework due"; slug: string; }

interface LectureEvent extends BaseEvent { type: "lecture"; slug: string; readings?: Reading[]; }

const examsCollection = await getCollection("exams"); const exams = examsCollection .filter((entry) =>
entry.data.relative_date) .map((entry) => { const data = transformContentData(entry.data); return { type: "exam" as
const, title: data.title, date: calculateAbsoluteDate(entry.data.relative_date!), content: data.content, notes:
data.notes, tentative: data.tentative, } satisfies ExamEvent; });

const homeworks = ( await getCollection("docs", (entry) => entry.id.startsWith("homework")) ).flatMap((entry) => { const
data = transformContentData(entry.data); const events = []; if (data.relative_release) { events.push({ slug: entry.id,
type: "homework release" as const, title: data.title, date: calculateAbsoluteDate(data.relative_release), notes:
data.notes, tentative: data.tentative, } satisfies HomeworkEvent); } if (data.relative_due) { events.push({ slug:
entry.id, type: "homework due" as const, title: data.title, date: calculateAbsoluteDate(data.relative_due), notes:
data.notes, tentative: data.tentative, } satisfies HomeworkEvent); } return events; });

const lecturesCollection = await getCollection("docs", (entry) => entry.id.startsWith("lecture"), ); const lectures =
lecturesCollection .filter((entry) => entry.data.relative_date) .map((entry) => { const data =
transformContentData(entry.data); return { slug: entry.id, type: "lecture" as const, title: data.title, date:
calculateAbsoluteDate(entry.data.relative_date!), readings: data.readings, notes: data.notes, tentative: data.tentative,
} satisfies LectureEvent; });

const events = [...exams, ...homeworks, ...lectures].sort( (a, b) => a.date.valueOf() - b.date.valueOf(), );

// events is already sorted, so just check if the last event is in the past // class concludes on the Friday of finals
week

const concluded = calculateAbsoluteDate({ week: 15, day: "Friday", time: "17:00:00", }).isBefore(dayjs()); ---

<div class="controls-container"> <span class="action primary" id="jumpToDateButton" data-concluded={concluded}> Jump to
Nearest Date </span>

  <!-- Filter Buttons --> <span class="action primary filter-button" id="filterAll" data-filter="all">All</span> <span
  class="action secondary filter-button" id="filterLecture" data-filter="lecture">Lectures</span> <span class="action
  secondary filter-button" id="filterHomework" data-filter="homework">Homeworks</span> <span class="action secondary
  filter-button" id="filterExam" data-filter="exam">Exams</span> </div>

<div class="not-content ml-4 flex items-center lg:ml-0 lg:justify-center"> <table class="border-separate
border-spacing-y-2 text-sm"> <thead> <tr> <th class="hidden lg:table-cell lg:min-w-[8rem]">Type</th> <th class="hidden
lg:table-cell">Date</th> <th class="hidden lg:table-cell">Topic</th> <th class="hidden lg:table-cell">Content</th> <th
class="hidden lg:table-cell">Notes</th> </tr> </thead> <tbody> { events.map((event) => ( <tr class:list={[ {
"!bg-[var(--sl-color-accent-low)]": event?.tentative, }, "tr-class", ]} data-date={event.date.toISOString()} data-type={
event.type === "lecture" ? "lecture" : event.type === "exam" ? "exam" : "homework" }
          > <td class="td-class"> {event?.tentative && <Badge text="Tentative" variant="tip" />} <div>{event.type}</div>
            </td> <td class="td-class">{formatTime(event.date)}</td> <td class="td-class"> {(event.type === "homework
            release" || event.type === "homework due") && ( <a href={`/${event.slug}/`}>{event.title}</a> )} {event.type
            === "exam" && event.title} {event.type === "lecture" && event.title} </td> <td class="td-class"> {event.type
            === "exam" && event.content} {event.type === "lecture" && event.readings && ( <ul>
            {event.readings.map((reading: Reading) => ( <li> <a href={reading.link} target="_blank"> {reading.name} </a>
            {reading.grad_only && <span title="grad only">🧑‍🎓</span>} {reading.optional && <span title="not on
            quiz">📵</span>} </li> ))} </ul> )} </td> <td class="td-class"> {event.notes && ( <Fragment
            set:html={marked(event.notes, { async: true }) .then((r) => DOMPurify.sanitize(r)) .catch((e) => `Markdown
            parse failure: ${e}.`)} /> )} </td> </tr> )) } </tbody> </table>

  <style> @reference "tailwindcss";

    .td-class { @apply border-0 px-4 py-3 lg:first:rounded-t-none lg:first:rounded-bl-lg lg:first:rounded-tl-lg
    lg:last:rounded-b-none lg:last:rounded-br-lg lg:last:rounded-tr-lg; }

    .tr-class { @apply mb-4 flex flex-col rounded-lg odd:bg-[var(--sl-color-gray-6)] even:bg-[var(--sl-color-gray-7)]
    lg:table-row; }

    /* Dynamic zebra striping classes for filtered content - higher specificity */ tr[data-date].odd-stripe {
    background-color: var(--sl-color-gray-6); }

    tr[data-date].even-stripe { background-color: var(--sl-color-gray-7); }

    /* Controls container for same-row layout */ .controls-container { display: flex; gap: 1rem; align-items: center;
    margin-top: 1rem; margin-bottom: 1rem; flex-wrap: wrap; /* Allow wrapping on smaller screens */ }

    /* Mobile-specific styling */ @media (max-width: 768px) { .controls-container { gap: 0.75rem; justify-content:
    center; /* Center buttons on mobile */ }

      .action { padding: 0.75rem 1.5rem; font-size: var(--sl-text-base); min-width: 80px; /* Ensure minimum width for
      circular appearance */ text-align: center; } }

    /* Filter container styling */ .filter-container { margin-top: 1.5rem; margin-bottom: 1rem; display: flex; gap:
    0.5rem; align-items: center; }

    .action { display: inline-block; cursor: pointer; margin-top: 1rem; margin-bottom: 1rem; gap: 0.5em; align-items:
    center; border-radius: 999rem; padding: 0.5rem 1.125rem; color: var(--sl-color-white); line-height: 1.1875;
    text-decoration: none; font-size: var(--sl-text-sm); border: 1px solid var(--sl-color-text-accent); }
    .action.primary { background: var(--sl-color-text-accent); color: var(--sl-color-black); }
    .action[aria-disabled="true"] { pointer-events: none; opacity: 0.6; cursor: not-allowed; } ul { padding-left:
    0.5rem; } @keyframes fadeHighlight { from { background-color: var(--sl-color-accent); } }

    .highlight { animation: fadeHighlight 2s ease-out forwards; } /* Ensure hidden attribute wins over flex/table
    display from classes */ tr[hidden] { display: none !important; } </style>

  <script> import dayjs from "dayjs";

    // Lightweight toast helper reused across interactions const showToast = (message: string, duration = 3000) => { let
    toast = document.getElementById("schedule-toast"); if (!toast) { toast = document.createElement("div"); toast.id =
    "schedule-toast"; toast.style.position = "fixed"; toast.style.bottom = "20px"; toast.style.right = "20px";
    toast.style.maxWidth = "320px"; toast.style.padding = "12px 16px"; toast.style.borderRadius = "8px";
    toast.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; toast.style.backgroundColor = "var(--sl-color-accent-low)";
    toast.style.color = "var(--sl-color-text)"; toast.style.fontSize = "14px"; toast.style.zIndex = "9999";
    toast.style.opacity = "0"; toast.style.transition = "opacity 200ms ease"; document.body.appendChild(toast); }
    toast.textContent = message; toast.style.opacity = "1"; window.clearTimeout((toast as any)._hideTimer); (toast as
    any)._hideTimer = window.setTimeout(() => { toast!.style.opacity = "0"; }, duration); };

    // Confetti effect using canvas-confetti (loaded via CDN on demand) const launchConfetti = async () => { const
    ensureConfetti = () => new Promise<void>((resolve, reject) => { if ((window as any).confetti) return resolve();
    const script = document.createElement("script"); script.src =
    "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"; script.async = true;
    script.onload = () => resolve(); script.onerror = () => reject(new Error("Failed to load confetti library"));
    document.head.appendChild(script); });

      try { await ensureConfetti(); const confetti = (window as any).confetti;

        // Fire a few bursts for a celebratory feel const defaults = { spread: 360, startVelocity: 45, decay: 0.92,
        scalar: 1.0 }; confetti({ particleCount: 80, origin: { y: 0.2 }, ...defaults }); confetti({ particleCount: 120,
        origin: { y: 0.3 }, ...defaults }); confetti({ particleCount: 180, origin: { y: 0.4 }, ...defaults });

        // A gentle fall for a couple seconds const end = Date.now() + 2500; const interval = setInterval(() => {
        confetti({ particleCount: 20, angle: 60, spread: 55, origin: { x: 0 }, }); confetti({ particleCount: 20, angle:
        120, spread: 55, origin: { x: 1 }, }); if (Date.now() > end) clearInterval(interval); }, 150); } catch (e) { //
        Fallback: no-op if library fails to load console.warn("Confetti unavailable:", e); } };

    // function to jump to the nearest date, button and functionality is set here const jumpToDate = () => { const today
    = dayjs(); const rows = document.querySelectorAll("tr[data-date]:not([hidden])"); const nearestRow =
    Array.from(rows) .filter( (row) => { const rowDate = dayjs(row.getAttribute("data-date") ?? today); return
    rowDate.isSame(today, 'day') || rowDate.isAfter(today, 'day') }, ) // Filter out past events .sort((a, b) => {
    return ( Math.abs(today.diff(dayjs(a.getAttribute("data-date") ?? today))) -
    Math.abs(today.diff(dayjs(b.getAttribute("data-date") ?? today))) // Sort by diff between today's date and event's
    date ); })[0] as HTMLElement; // Return first item (nearest date) if (!nearestRow) return; const originalColor =
    window.getComputedStyle(nearestRow).backgroundColor; nearestRow.style.backgroundColor = originalColor; // Set
    original color inline nearestRow.classList.add("highlight");

      setTimeout(() => { nearestRow.classList.remove("highlight"); nearestRow.style.backgroundColor = ""; // Revert to
      stylesheet color }, 2000);

      // Check for prefers-reduced-motion setting const prefersReducedMotion =
      window.matchMedia('(prefers-reduced-motion: reduce)').matches; nearestRow.scrollIntoView({ behavior:
      prefersReducedMotion ? "auto" : "smooth", block: "start" }); };

    const updateZebraStriping = () => { const visibleRows = document.querySelectorAll("tr[data-date]:not([hidden])");
    visibleRows.forEach((row, index) => { const htmlRow = row as HTMLElement; // Remove existing odd/even classes
    htmlRow.classList.remove('odd-stripe', 'even-stripe'); // Add appropriate class based on visible index if (index % 2
    === 0) { htmlRow.classList.add('even-stripe'); } else { htmlRow.classList.add('odd-stripe'); } }); };

    // Map URL hash to filter type const hashToFilter: Record<string, string> = { '#all': 'all', '#lectures': 'lecture',
    '#homeworks': 'homework', '#exams': 'exam' };

    // Map filter type to URL hash const filterToHash: Record<string, string> = { 'all': '#all', 'lecture': '#lectures',
    'homework': '#homeworks', 'exam': '#exams' };

    // Handles button colors, when a user selects a button that becomes the "active" filter indicated by color const
    updateLinkButtonVariants = (activeFilter: string) => { const buttons = document.querySelectorAll('.filter-button');
    buttons.forEach((button) => { const filterType = button.getAttribute('data-filter'); const isActive = filterType ===
    activeFilter;

        // Update the class for styling if (isActive) { button.classList.remove('secondary');
        button.classList.add('primary'); } else { button.classList.remove('primary'); button.classList.add('secondary');
        } }); };

    const applyFilter = (filterType = 'all') => { const rows = document.querySelectorAll("tr[data-date]");
    rows.forEach((row) => { const type = row.getAttribute("data-type"); const show = filterType === "all" || type ===
    filterType; (row as HTMLElement).hidden = !show; });

      // Update zebra striping after filtering updateZebraStriping();

      // Update button variants updateLinkButtonVariants(filterType);

      // Update URL without triggering page reload const newHash = filterToHash[filterType] || '#all'; if
      (window.location.hash !== newHash) { history.replaceState(null, '', newHash); } };

    // Grab the filter from the url if there is one const getFilterFromUrl = () => { const hash = window.location.hash
    || '#all'; return hashToFilter[hash] || 'all'; };

    // Initialize filter based on URL hash document.addEventListener("DOMContentLoaded", () => { const initialFilter =
    getFilterFromUrl(); applyFilter(initialFilter); });

    // Listen for hash changes (browser back/forward, manual URL changes) window.addEventListener('hashchange', () => {
    const newFilter = getFilterFromUrl(); applyFilter(newFilter); });

    // Add click handlers for filter buttons document.querySelector("#filterAll")?.addEventListener("click", (e) => {
    e.preventDefault(); applyFilter('all'); });

    document.querySelector("#filterLecture")?.addEventListener("click", (e) => { e.preventDefault();
    applyFilter('lecture'); });

    document.querySelector("#filterHomework")?.addEventListener("click", (e) => { e.preventDefault();
    applyFilter('homework'); });

    document.querySelector("#filterExam")?.addEventListener("click", (e) => { e.preventDefault(); applyFilter('exam');
    });

    document .querySelector("#jumpToDateButton") ?.addEventListener("click", () => { const btn =
    document.querySelector("#jumpToDateButton") as HTMLElement | null; const isConcluded =
    btn?.getAttribute("data-concluded") === "true"; if (isConcluded) { // Trigger celebratory confetti launchConfetti();

          // Replace button text and disable further interaction if (btn) { btn.textContent = "Course Concluded!";
          btn.setAttribute("aria-disabled", "true"); btn.setAttribute("title", "Course Concluded!"); } return; }

        // If there are no upcoming visible events (same day or future), show a toast and bail const now = dayjs();
        const upcomingVisibleRows = Array.from( document.querySelectorAll("tr[data-date]:not([hidden])") ).filter((row)
        => { const rowDateStr = row.getAttribute("data-date") || ""; const rowDate = dayjs(rowDateStr); return
        rowDate.isSame(now, "day") || rowDate.isAfter(now, "day"); });

        if (upcomingVisibleRows.length === 0) { showToast("No upcoming events are visible in the current view.");
        return; }

        jumpToDate(); }); </script> </div>

```

Great—swapped the custom confetti for the canvas-confetti library and load it on-demand from a CDN when needed.

What changed:
- Added an async `launchConfetti` that dynamically loads `canvas-confetti` from jsDelivr the first time it’s needed.
- Once loaded, it fires a few celebratory bursts and a short side-fall effect.
- If the script fails to load, it logs a warning and continues without breaking the page.
- The concluded button behavior remains the same: text becomes “Course Concluded!” and the button is disabled.

Confetti integration: ```cs484-website/src/components/Schedule.astro#L327-381 // Confetti effect using canvas-confetti
(loaded via CDN on demand) const launchConfetti = async () => { const ensureConfetti = () => new Promise<void>((resolve,
reject) => { if ((window as any).confetti) return resolve(); const script = document.createElement("script"); script.src
= "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"; script.async = true; script.onload
= () => resolve(); script.onerror = () => reject(new Error("Failed to load confetti library"));
document.head.appendChild(script); });

  try { await ensureConfetti(); const confetti = (window as any).confetti;

    // Fire a few bursts for a celebratory feel const defaults = { spread: 360, startVelocity: 45, decay: 0.92, scalar:
    1.0 }; confetti({ particleCount: 80, origin: { y: 0.2 }, ...defaults }); confetti({ particleCount: 120, origin: { y:
    0.3 }, ...defaults }); confetti({ particleCount: 180, origin: { y: 0.4 }, ...defaults });

    // A gentle fall for a couple seconds const end = Date.now() + 2500; const interval = setInterval(() => { confetti({
    particleCount: 20, angle: 60, spread: 55, origin: { x: 0 }, }); confetti({ particleCount: 20, angle: 120, spread:
    55, origin: { x: 1 }, }); if (Date.now() > end) clearInterval(interval); }, 150); } catch (e) { // Fallback: no-op
    if library fails to load console.warn("Confetti unavailable:", e); } }; ```

The click handler still triggers this when concluded: ```cs484-website/src/components/Schedule.astro#L523-557 const btn
= document.querySelector("#jumpToDateButton") as HTMLElement | null; const isConcluded =
btn?.getAttribute("data-concluded") === "true"; if (isConcluded) { launchConfetti(); if (btn) { btn.textContent =
"Course Concluded!"; btn.setAttribute("aria-disabled", "true"); btn.setAttribute("title", "Course Concluded!"); }
return; } ```

If you’d prefer bundling the library instead of a CDN, I can add it to your dependencies and import it properly, but the
current approach keeps bundle size down and only loads it when necessary.

Bundle it.

**Tool Call: 1 match** Status: Completed

file:///Users/samuel-skean/Coding/Not-Mine/cs484-website/package.json

**Tool Call: Read file `package.json`** Status: Completed

```cs484-website/package.json { "name": "starlight", "type": "module", "version": "0.0.1", "scripts": { "dev": "astro
dev", "start": "astro dev", "build": "astro build", "preview": "astro preview", "astro": "astro", "format": "biome
format --write .", "lint": "biome lint --apply ." }, "dependencies": { "@astrojs/check": "^0.9.5",
"@astrojs/cloudflare": "^12.6.11", "@astrojs/react": "^4.4.2", "@astrojs/starlight": "^0.36.2",
"@astrojs/starlight-tailwind": "^4.0.2", "@tailwindcss/vite": "^4.1.17", "@types/react": "^19.2.2", "@types/react-dom":
"^19.2.3", "astro": "^5.16.0", "dayjs": "^1.11.19", "isomorphic-dompurify": "^2.30.0", "marked": "^16.4.1", "react":
"^19.2.0", "react-dom": "^19.2.0", "tailwindcss": "^4.1.17", "typescript": "^5.9.3" }, "devDependencies": {
"@biomejs/biome": "^2.2.6" } }

```

**Tool Call: package.json** Status: Completed

Diff: package.json ``` { "name": "starlight", "type": "module", "version": "0.0.1", "scripts": { "dev": "astro dev",
"start": "astro dev", "build": "astro build", "preview": "astro preview", "astro": "astro", "format": "biome format
--write .", "lint": "biome lint --apply ." }, "dependencies": { "@astrojs/check": "^0.9.5", "@astrojs/cloudflare":
"^12.6.11", "@astrojs/react": "^4.4.2", "@astrojs/starlight": "^0.36.2", "@astrojs/starlight-tailwind": "^4.0.2",
"@tailwindcss/vite": "^4.1.17", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.3", "astro": "^5.16.0", "dayjs":
"^1.11.19", "isomorphic-dompurify": "^2.30.0", "marked": "^16.4.1", "canvas-confetti": "^1.9.2", "react": "^19.2.0",
"react-dom": "^19.2.0", "tailwindcss": "^4.1.17", "typescript": "^5.9.3" }, "devDependencies": { "@biomejs/biome":
"^2.2.6" } }

```

**Tool Call: Search files for regex `launchConfetti`** Status: Completed

Found 2 matches:

```

    // Confetti effect using canvas-confetti (loaded via CDN on demand) const launchConfetti = async () => { const
    ensureConfetti = () => new Promise<void>((resolve, reject) => { ```

``` if (isConcluded) { // Trigger celebratory confetti launchConfetti();

          // Replace button text and disable further interaction if (btn) { btn.textContent = "Course Concluded!";
          btn.setAttribute("aria-disabled", "true"); btn.setAttribute("title", "Course Concluded!"); } return; ```

1 lines remaining in ancestor node. Read the file to see all.

**Tool Call: Read file `src/components/Schedule.astro`** Status: Completed

```cs484-website/src/components/Schedule.astro This file was too big to read all at once.

interface Reading [L10-15] interface BaseEvent [L17-23] interface ExamEvent [L25-28] interface HomeworkEvent [L30-33]
interface LectureEvent [L35-39] const examsCollection [L41] const exams [L42-54] const data [L45] type [L47] title [L48]
date [L49] content [L50] notes [L51] tentative [L52] const homeworks [L56-82] const data [L59] const events [L60] slug
[L63] type [L64] title [L65] date [L66] notes [L67] tentative [L68] slug [L73] type [L74] title [L75] date [L76] notes
[L77] tentative [L78] const lecturesCollection [L84-86] const lectures [L87-100] const data [L90] slug [L92] type [L93]
title [L94] date [L95] readings [L96] notes [L97] tentative [L98] const events [L102-104] const concluded [L109-113]
week [L110] day [L111] time [L112] !bg-[var(--sl-color-accent-low)] [L145] async [L190] "tailwindcss"; [L203-207]
.tr-class [L209-211] tr[data-date].odd-stripe [L214-216] tr[data-date].even-stripe [L218-220] .controls-container
[L223-230] @media (max-width: 768px) [L233-245] .controls-container [L234-237] .action [L239-244] .filter-container
[L248-254] .action [L256-270] .action.primary [L271-274] .action[aria-disabled="true"] [L275-279] ul [L280-282]
.highlight [L289-291] tr[hidden] [L293-295] const showToast [L302-328] let toast [L303] const launchConfetti [L331-374]
const ensureConfetti [L332-341] const script [L335] const confetti [L345] const defaults [L348] spread [L348]
startVelocity [L348] decay [L348] scalar [L348] particleCount [L349] origin [L349] y [L349] particleCount [L350] origin
[L350] y [L350] particleCount [L351] origin [L351] y [L351] const end [L354] const interval [L355-369] particleCount
[L357] angle [L358] spread [L359] origin [L360] x [L360] particleCount [L363] angle [L364] spread [L365] origin [L366] x
[L366] const jumpToDate [L377-409] const today [L378] const rows [L379] const nearestRow [L380-392] const rowDate [L383]
const originalColor [L394] const prefersReducedMotion [L404] behavior [L406] block [L407] const updateZebraStriping
[L411-424] const visibleRows [L412] const htmlRow [L414] const hashToFilter [L427-432]
 #all [L428] lectures [L429] homeworks [L430] exams [L431]
const filterToHash [L435-440] all [L436] lecture [L437] homework [L438] exam [L439] const updateLinkButtonVariants
[L443-458] const buttons [L444] const filterType [L446] const isActive [L447] const applyFilter [L460-479] const rows
[L461] const type [L463] const show [L464] const newHash [L475] const getFilterFromUrl [L482-485] const hash [L483]
const initialFilter [L489] const newFilter [L495] const btn [L523] const isConcluded [L524] const now [L539] const
upcomingVisibleRows [L540-546] const rowDateStr [L543] const rowDate [L544]

Showing symbols 1-134 (total symbols: 134)

Using the line numbers in this outline, you can call this tool again while specifying the start_line and end_line fields
to see the implementations of symbols in the outline.

Alternatively, you can fall back to the `grep` tool (if available) to search the file for specific content. ```

**Tool Call: src/components/Schedule.astro** Status: Completed

Diff: src/components/Schedule.astro ``` --- import { getCollection } from "astro:content"; import { Badge } from
"@astrojs/starlight/components"; import dayjs, { type Dayjs } from "dayjs"; import DOMPurify from
"isomorphic-dompurify"; import { marked } from "marked"; import { transformContentData } from "../utils/contentUtils";
import { calculateAbsoluteDate, formatTime } from "../utils/dateUtils"; import confetti from "canvas-confetti";

interface Reading { link: string; name: string; grad_only?: boolean; optional?: boolean; }

interface BaseEvent { type: string; title: string; date: Dayjs; notes?: string; tentative?: boolean; }

interface ExamEvent extends BaseEvent { type: "exam"; content: string; }

interface HomeworkEvent extends BaseEvent { type: "homework release" | "homework due"; slug: string; }

interface LectureEvent extends BaseEvent { type: "lecture"; slug: string; readings?: Reading[]; }

const examsCollection = await getCollection("exams"); const exams = examsCollection .filter((entry) =>
entry.data.relative_date) .map((entry) => { const data = transformContentData(entry.data); return { type: "exam" as
const, title: data.title, date: calculateAbsoluteDate(entry.data.relative_date!), content: data.content, notes:
data.notes, tentative: data.tentative, } satisfies ExamEvent; });

const homeworks = ( await getCollection("docs", (entry) => entry.id.startsWith("homework")) ).flatMap((entry) => { const
data = transformContentData(entry.data); const events = []; if (data.relative_release) { events.push({ slug: entry.id,
type: "homework release" as const, title: data.title, date: calculateAbsoluteDate(data.relative_release), notes:
data.notes, tentative: data.tentative, } satisfies HomeworkEvent); } if (data.relative_due) { events.push({ slug:
entry.id, type: "homework due" as const, title: data.title, date: calculateAbsoluteDate(data.relative_due), notes:
data.notes, tentative: data.tentative, } satisfies HomeworkEvent); } return events; });

const lecturesCollection = await getCollection("docs", (entry) => entry.id.startsWith("lecture"), ); const lectures =
lecturesCollection .filter((entry) => entry.data.relative_date) .map((entry) => { const data =
transformContentData(entry.data); return { slug: entry.id, type: "lecture" as const, title: data.title, date:
calculateAbsoluteDate(entry.data.relative_date!), readings: data.readings, notes: data.notes, tentative: data.tentative,
} satisfies LectureEvent; });

const events = [...exams, ...homeworks, ...lectures].sort( (a, b) => a.date.valueOf() - b.date.valueOf(), );

// events is already sorted, so just check if the last event is in the past // class concludes on the Friday of finals
week

const concluded = calculateAbsoluteDate({ week: 15, day: "Friday", time: "17:00:00", }).isBefore(dayjs()); ---

<div class="controls-container"> <span class="action primary" id="jumpToDateButton" data-concluded={concluded}> Jump to
Nearest Date </span>

  <!-- Filter Buttons --> <span class="action primary filter-button" id="filterAll" data-filter="all">All</span> <span
  class="action secondary filter-button" id="filterLecture" data-filter="lecture">Lectures</span> <span class="action
  secondary filter-button" id="filterHomework" data-filter="homework">Homeworks</span> <span class="action secondary
  filter-button" id="filterExam" data-filter="exam">Exams</span> </div>

<div class="not-content ml-4 flex items-center lg:ml-0 lg:justify-center"> <table class="border-separate
border-spacing-y-2 text-sm"> <thead> <tr> <th class="hidden lg:table-cell lg:min-w-[8rem]">Type</th> <th class="hidden
lg:table-cell">Date</th> <th class="hidden lg:table-cell">Topic</th> <th class="hidden lg:table-cell">Content</th> <th
class="hidden lg:table-cell">Notes</th> </tr> </thead> <tbody> { events.map((event) => ( <tr class:list={[ {
"!bg-[var(--sl-color-accent-low)]": event?.tentative, }, "tr-class", ]} data-date={event.date.toISOString()} data-type={
event.type === "lecture" ? "lecture" : event.type === "exam" ? "exam" : "homework" }
          > <td class="td-class"> {event?.tentative && <Badge text="Tentative" variant="tip" />} <div>{event.type}</div>
            </td> <td class="td-class">{formatTime(event.date)}</td> <td class="td-class"> {(event.type === "homework
            release" || event.type === "homework due") && ( <a href={`/${event.slug}/`}>{event.title}</a> )} {event.type
            === "exam" && event.title} {event.type === "lecture" && event.title} </td> <td class="td-class"> {event.type
            === "exam" && event.content} {event.type === "lecture" && event.readings && ( <ul>
            {event.readings.map((reading: Reading) => ( <li> <a href={reading.link} target="_blank"> {reading.name} </a>
            {reading.grad_only && <span title="grad only">🧑‍🎓</span>} {reading.optional && <span title="not on
            quiz">📵</span>} </li> ))} </ul> )} </td> <td class="td-class"> {event.notes && ( <Fragment
            set:html={marked(event.notes, { async: true }) .then((r) => DOMPurify.sanitize(r)) .catch((e) => `Markdown
            parse failure: ${e}.`)} /> )} </td> </tr> )) } </tbody> </table>

  <style> @reference "tailwindcss";

    .td-class { @apply border-0 px-4 py-3 lg:first:rounded-t-none lg:first:rounded-bl-lg lg:first:rounded-tl-lg
    lg:last:rounded-b-none lg:last:rounded-br-lg lg:last:rounded-tr-lg; }

    .tr-class { @apply mb-4 flex flex-col rounded-lg odd:bg-[var(--sl-color-gray-6)] even:bg-[var(--sl-color-gray-7)]
    lg:table-row; }

    /* Dynamic zebra striping classes for filtered content - higher specificity */ tr[data-date].odd-stripe {
    background-color: var(--sl-color-gray-6); }

    tr[data-date].even-stripe { background-color: var(--sl-color-gray-7); }

    /* Controls container for same-row layout */ .controls-container { display: flex; gap: 1rem; align-items: center;
    margin-top: 1rem; margin-bottom: 1rem; flex-wrap: wrap; /* Allow wrapping on smaller screens */ }

    /* Mobile-specific styling */ @media (max-width: 768px) { .controls-container { gap: 0.75rem; justify-content:
    center; /* Center buttons on mobile */ }

      .action { padding: 0.75rem 1.5rem; font-size: var(--sl-text-base); min-width: 80px; /* Ensure minimum width for
      circular appearance */ text-align: center; } }

    /* Filter container styling */ .filter-container { margin-top: 1.5rem; margin-bottom: 1rem; display: flex; gap:
    0.5rem; align-items: center; }

    .action { display: inline-block; cursor: pointer; margin-top: 1rem; margin-bottom: 1rem; gap: 0.5em; align-items:
    center; border-radius: 999rem; padding: 0.5rem 1.125rem; color: var(--sl-color-white); line-height: 1.1875;
    text-decoration: none; font-size: var(--sl-text-sm); border: 1px solid var(--sl-color-text-accent); }
    .action.primary { background: var(--sl-color-text-accent); color: var(--sl-color-black); }
    .action[aria-disabled="true"] { pointer-events: none; opacity: 0.6; cursor: not-allowed; } ul { padding-left:
    0.5rem; } @keyframes fadeHighlight { from { background-color: var(--sl-color-accent); } }

    .highlight { animation: fadeHighlight 2s ease-out forwards; } /* Ensure hidden attribute wins over flex/table
    display from classes */ tr[hidden] { display: none !important; } </style>

  <script> import dayjs from "dayjs";

    // Lightweight toast helper reused across interactions const showToast = (message: string, duration = 3000) => { let
    toast = document.getElementById("schedule-toast"); if (!toast) { toast = document.createElement("div"); toast.id =
    "schedule-toast"; toast.style.position = "fixed"; toast.style.bottom = "20px"; toast.style.right = "20px";
    toast.style.maxWidth = "320px"; toast.style.padding = "12px 16px"; toast.style.borderRadius = "8px";
    toast.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; toast.style.backgroundColor = "var(--sl-color-accent-low)";
    toast.style.color = "var(--sl-color-text)"; toast.style.fontSize = "14px"; toast.style.zIndex = "9999";
    toast.style.opacity = "0"; toast.style.transition = "opacity 200ms ease"; document.body.appendChild(toast); }
    toast.textContent = message; toast.style.opacity = "1"; window.clearTimeout((toast as any)._hideTimer); (toast as…
Previous commits already removed the previous usage of this variable on the server.

I think this is better for 2 reasons:
1. there's no good reason to prevent people from filtering after the course.
2. it would break my easter egg :(.
Copilot AI review requested due to automatic review settings December 9, 2025 23:20
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances the Schedule component with a celebratory confetti animation for course conclusion, improves user feedback with toast notifications, and adjusts the course end date. The changes make the interface more engaging while addressing silent failure modes.

Key Changes:

  • Renamed "Jump to Nearest Date" button to "Jump to Next Event" for clarity
  • Added confetti animation when clicking the button after course conclusion (week 15)
  • Implemented toast notifications for better user feedback when no upcoming events exist
  • Made filter buttons and "Jump to Next Event" button always visible (previously hidden after course end)
  • Reordered imports alphabetically across multiple files

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/components/Schedule.astro Core feature implementation: added confetti effect, toast notifications, always-visible controls, and changed course end to week 15
package.json Added canvas-confetti library (^1.9.2) and its type definitions (^1.6.1)
package-lock.json Lockfile updates for canvas-confetti (1.9.4) and @types/canvas-confetti (1.9.0)
src/utils/contentUtils.ts Alphabetically reordered imports (no functional changes)
src/content.config.ts Alphabetically reordered imports (no functional changes)
src/components/EnhancedMarkdownContent.astro Alphabetically reordered imports (no functional changes)
src/components/DueDate.astro Alphabetically reordered imports (no functional changes)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

samuel-skean and others added 5 commits December 9, 2025 18:33
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This works better if the user rotates their phone or resizes their window after loading the page.

Thanks, Copilot.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant