-
Notifications
You must be signed in to change notification settings - Fork 12
Confetti Easter Egg When Pressing Jump to Nearest Date #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
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 :(.
Feel free to revert this commit.
There was a problem hiding this 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.
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>
Much of this was vibe coded, but I'm pretty confident in its correctness. I certainly tested it and understand the code it added.