From f8d6d6427c6d49d1d94d97ceb77f8f12369a307e Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Tue, 9 Dec 2025 10:18:05 -0600 Subject: [PATCH 01/13] Vibe coded toasts for pressing Jump to Next Date at end of course. --- src/components/Schedule.astro | 85 +++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 5a4725d..6df8fe7 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -113,21 +113,17 @@ const concluded = calculateAbsoluteDate({ }).isBefore(dayjs()); --- -{ - !concluded && ( -
- - Jump to Nearest Date - - - - All - Lectures - Homeworks - Exams -
- ) -} +
+ + Jump to Nearest Date + + + + All + Lectures + Homeworks + Exams +
@@ -297,6 +293,35 @@ const concluded = calculateAbsoluteDate({ From d75c1b22e2c17f9cf835acff1eb755b8b2e486d4 Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Tue, 9 Dec 2025 10:22:42 -0600 Subject: [PATCH 02/13] `npx biome check --write`. --- src/components/DueDate.astro | 4 ++-- src/components/EnhancedMarkdownContent.astro | 2 +- src/components/Schedule.astro | 8 ++++---- src/content.config.ts | 2 +- src/utils/contentUtils.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/DueDate.astro b/src/components/DueDate.astro index 62639c0..d2910a9 100644 --- a/src/components/DueDate.astro +++ b/src/components/DueDate.astro @@ -1,8 +1,8 @@ --- import { - localizeTime, - formatTime, calculateAbsoluteDate, + formatTime, + localizeTime, } from "../utils/dateUtils"; interface Props { diff --git a/src/components/EnhancedMarkdownContent.astro b/src/components/EnhancedMarkdownContent.astro index dceb1d3..3174dae 100644 --- a/src/components/EnhancedMarkdownContent.astro +++ b/src/components/EnhancedMarkdownContent.astro @@ -1,6 +1,6 @@ --- -import Default from "@astrojs/starlight/components/MarkdownContent.astro"; import { Aside } from "@astrojs/starlight/components"; +import Default from "@astrojs/starlight/components/MarkdownContent.astro"; const { data } = Astro.locals.starlightRoute.entry; --- diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 6df8fe7..20b1a7d 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -1,11 +1,11 @@ --- import { getCollection } from "astro:content"; -import { marked } from "marked"; -import DOMPurify from "isomorphic-dompurify"; -import dayjs, { type Dayjs } from "dayjs"; -import { formatTime, calculateAbsoluteDate } from "../utils/dateUtils"; 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; diff --git a/src/content.config.ts b/src/content.config.ts index 033dece..808fa8a 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,6 +1,6 @@ import { defineCollection, z } from "astro:content"; -import { docsSchema } from "@astrojs/starlight/schema"; import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; import { glob } from "astro/loaders"; // Relative date schema that can be reused diff --git a/src/utils/contentUtils.ts b/src/utils/contentUtils.ts index 7f3c20b..ffd6957 100644 --- a/src/utils/contentUtils.ts +++ b/src/utils/contentUtils.ts @@ -1,6 +1,6 @@ -import { calculateAbsoluteDate } from "./dateUtils"; -import type { ContentData } from "../types/content"; import { courseConfig } from "../courseConfig"; +import type { ContentData } from "../types/content"; +import { calculateAbsoluteDate } from "./dateUtils"; /** * Transforms content data by converting relative dates to absolute dates From 60a128ad1ab5c65a5ce3323b07b06173abd9087a Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Tue, 9 Dec 2025 10:27:36 -0600 Subject: [PATCH 03/13] Vibe-coded dependency-free confetti effect. --- src/components/Schedule.astro | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 20b1a7d..8a6998a 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -107,7 +107,7 @@ const events = [...exams, ...homeworks, ...lectures].sort( // class concludes on the Friday of finals week const concluded = calculateAbsoluteDate({ - week: 16, + week: 15, day: "Friday", time: "17:00:00", }).isBefore(dayjs()); @@ -272,6 +272,11 @@ const concluded = calculateAbsoluteDate({ 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; } @@ -322,6 +327,55 @@ const concluded = calculateAbsoluteDate({ }, 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(); @@ -469,10 +523,18 @@ const concluded = calculateAbsoluteDate({ document .querySelector("#jumpToDateButton") ?.addEventListener("click", () => { - const btn = document.querySelector("#jumpToDateButton"); + const btn = document.querySelector("#jumpToDateButton") as HTMLElement | null; const isConcluded = btn?.getAttribute("data-concluded") === "true"; if (isConcluded) { - showToast("This course has concluded. There are no upcoming dates to jump to."); + // 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; } From cbc189f25345aac176a78f52c47ebda6a62970fe Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Tue, 9 Dec 2025 12:01:30 -0600 Subject: [PATCH 04/13] Vibe coded confetti with a library. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ``` Jump to Nearest Date ``` **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` 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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` 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((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((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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` **Tool Call: Check project diagnostics** Status: Completed /Users/samuel-skean/Coding/Not-Mine/cs484-website/src/utils/contentUtils.ts: 0 error(s), 1 warning(s) /Users/samuel-skean/Coding/Not-Mine/cs484-website/src/styles/global.css: 1 error(s), 0 warning(s) /Users/samuel-skean/Coding/Not-Mine/cs484-website/src/styles/global.css: 0 error(s), 1 warning(s) /Users/samuel-skean/Coding/Not-Mine/cs484-website/src/components/Schedule.astro: 0 error(s), 4 warning(s) /Users/samuel-skean/Coding/Not-Mine/cs484-website/src/components/Schedule.astro: 0 error(s), 9 warning(s) /Users/samuel-skean/Coding/Not-Mine/cs484-website/src/components/Schedule.astro: 6 error(s), 0 warning(s) **Tool Call: Check diagnostics for `cs484-website/src/components/Schedule.astro`** Status: Completed warning at line 3: This import is unused. warning at line 5: This import is unused. warning at line 6: This import is unused. warning at line 8: Several of these imports are unused. warning at line 9: This import is unused. error at line 9: Cannot find module 'canvas-confetti' or its corresponding type declarations. warning at line 50: Forbidden non-null assertion. warning at line 96: Forbidden non-null assertion. warning at line 103: This variable events is unused. warning at line 110: This variable concluded is unused. warning at line 133: The class `lg:min-w-[8rem]` can be written as `lg:min-w-32` warning at line 146: The class `!bg-[var(--sl-color-accent-low)]` can be written as `bg-(--sl-color-accent-low)!` warning at line 211: The class `odd:bg-[var(--sl-color-gray-6)]` can be written as `odd:bg-(--sl-color-gray-6)` warning at line 211: The class `even:bg-[var(--sl-color-gray-7)]` can be written as `even:bg-(--sl-color-gray-7)` error at line 335: Cannot find name 'confetti'. error at line 336: Cannot find name 'confetti'. error at line 337: Cannot find name 'confetti'. error at line 342: Cannot find name 'confetti'. error at line 348: Cannot find name 'confetti'. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` **Tool Call: Check diagnostics for `cs484-website/src/components/Schedule.astro`** Status: Completed warning at line 3: This import is unused. warning at line 5: This import is unused. warning at line 6: This import is unused. warning at line 8: Several of these imports are unused. warning at line 50: Forbidden non-null assertion. warning at line 96: Forbidden non-null assertion. warning at line 103: This variable events is unused. warning at line 110: This variable concluded is unused. warning at line 133: The class `lg:min-w-[8rem]` can be written as `lg:min-w-32` warning at line 146: The class `!bg-[var(--sl-color-accent-low)]` can be written as `bg-(--sl-color-accent-low)!` warning at line 211: The class `odd:bg-[var(--sl-color-gray-6)]` can be written as `odd:bg-(--sl-color-gray-6)` warning at line 211: The class `even:bg-[var(--sl-color-gray-7)]` can be written as `even:bg-(--sl-color-gray-7)` error at line 333: Cannot find module 'canvas-confetti' or its corresponding type declarations. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` Bundled integration is done. What I changed: - Added `canvas-confetti` to your project dependencies in `package.json`. - Switched the component to use a dynamic import of `canvas-confetti` inside the client-side ` ``` **Tool Call: Check diagnostics for `cs484-website/src/components/Schedule.astro`** Status: Completed warning at line 3: This import is unused. warning at line 5: This import is unused. warning at line 6: This import is unused. warning at line 8: Several of these imports are unused. warning at line 50: Forbidden non-null assertion. warning at line 96: Forbidden non-null assertion. warning at line 103: This variable events is unused. warning at line 110: This variable concluded is unused. warning at line 133: The class `lg:min-w-[8rem]` can be written as `lg:min-w-32` warning at line 146: The class `!bg-[var(--sl-color-accent-low)]` can be written as `bg-(--sl-color-accent-low)!` warning at line 211: The class `odd:bg-[var(--sl-color-gray-6)]` can be written as `odd:bg-(--sl-color-gray-6)` warning at line 211: The class `even:bg-[var(--sl-color-gray-7)]` can be written as `even:bg-(--sl-color-gray-7)` error at line 334: Cannot find module 'canvas-confetti' or its corresponding type declarations. I added TypeScript types for `canvas-confetti` to the project and updated the dynamic import to use them properly. Changes: - Added `@types/canvas-confetti` as a dev dependency. ```cs484-website/package.json#L31-36 "devDependencies": { "@biomejs/biome": "^2.2.6", "@types/canvas-confetti": "^1.6.1" } ``` - Removed the `ts-ignore` and kept the dynamic import in the client script so Astro doesnโ€™t attempt to SSR the module. ```cs484-website/src/components/Schedule.astro#L331-350 const launchConfetti = async () => { try { const { default: confetti } = await import("canvas-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) { console.warn("Confetti unavailable:", e); } }; ``` Note: - The diagnostics still report โ€œCannot find module 'canvas-confetti' or its corresponding type declarationsโ€ until you install the new dependencies locally. - Please run your package manager to install and rebuild: - npm: npm install && npm run build - pnpm: pnpm install && pnpm build - yarn: yarn && yarn build Once installed, the TypeScript error should disappear, and youโ€™ll have typed confetti with bundled delivery. Don't fire nearly as much confetti. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I dialed the confetti way down to keep it subtle. What changed in the confetti sequence: - Reduced burst particle counts to 28, 36, and 48. - Lowered start velocity and scalar slightly. - Decreased the side-fall particle counts from 20 to 6 each side, tightened the spread a bit, and slowed the interval to 180ms. Snippet: ```cs484-website/src/components/Schedule.astro#L333-360 const { default: confetti } = await import("canvas-confetti"); // Fire a few bursts for a celebratory feel, but keep it subtle const defaults = { spread: 360, startVelocity: 40, decay: 0.92, scalar: 0.9 }; confetti({ particleCount: 28, origin: { y: 0.2 }, ...defaults }); confetti({ particleCount: 36, origin: { y: 0.3 }, ...defaults }); confetti({ particleCount: 48, origin: { y: 0.4 }, ...defaults }); // A gentle fall for a couple seconds const end = Date.now() + 2500; const interval = setInterval(() => { confetti({ particleCount: 6, angle: 60, spread: 50, origin: { x: 0 } }); confetti({ particleCount: 6, angle: 120, spread: 50, origin: { x: 1 } }); if (Date.now() > end) clearInterval(interval); }, 180); ``` This keeps the celebration but avoids overwhelming the screen. If you want it even more minimal, I can switch it to a single small burst without the falling effect. Switch to a single small burst, but keep the falling effect **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` Done. It now uses a single small burst followed by a subtle falling effect. Updated behavior: - One small celebratory burst with 24 particles at origin y=0.3. - Keeps the gentle side-fall for ~2.5 seconds with low particle counts. Key snippet: ```cs484-website/src/components/Schedule.astro#L333-360 const { default: confetti } = await import("canvas-confetti"); // Single small burst for a subtle celebration const defaults = { spread: 360, startVelocity: 38, decay: 0.92, scalar: 0.9 }; confetti({ particleCount: 24, origin: { y: 0.3 }, ...defaults }); // A gentle fall for a couple seconds const end = Date.now() + 2500; const interval = setInterval(() => { confetti({ particleCount: 6, angle: 60, spread: 50, origin: { x: 0 } }); confetti({ particleCount: 6, angle: 120, spread: 50, origin: { x: 1 } }); if (Date.now() > end) clearInterval(interval); }, 180); ``` If you want to reduce the fall duration or particle count more, I can tweak `end` and the `particleCount` values further. You still have multiple bursts. Make it just one burst, but up the particle count. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` Understood. I changed it to a single, larger burst and removed the additional bursts and the repeated side-fall interval. Updated behavior: - One burst with a higher particle count (120) and slightly higher velocity/scalar. - No repeated side-fall; the burst itself provides the falling effect. Snippet: ```cs484-website/src/components/Schedule.astro#L333-352 const { default: confetti } = await import("canvas-confetti"); // Single larger burst for celebration const defaults = { spread: 360, startVelocity: 42, decay: 0.93, scalar: 1.0 }; confetti({ particleCount: 120, origin: { y: 0.3 }, ...defaults }); // Falling effect is handled by the single burst; no repeated interval. ``` What's the point of the dynamic import and bundling? Good question. Two main reasons: performance and correctness. Why dynamic import? - Avoids server-side execution: Astro renders components on the server. `canvas-confetti` relies on the browser (canvas, window). A dynamic import inside the client ` ``` **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I tuned the confetti burst so it doesnโ€™t travel as far or feel as intense. Changes: - Reduced `spread` from 360 to 120, which narrows the angular dispersion. - Lowered `startVelocity` to 26 and `scalar` to 0.85, making particles smaller and slower. - Increased `origin.y` to 0.35 so the burst starts slightly lower on the screen. - Dropped the `particleCount` to 60 to keep it restrained. Updated snippet: ```cs484-website/src/components/Schedule.astro#L334-343 const { default: confetti } = await import("canvas-confetti"); // Single larger burst for celebration const defaults = { spread: 120, startVelocity: 26, decay: 0.86, scalar: 0.85 }; confetti({ particleCount: 60, origin: { y: 0.35 }, ...defaults }); // Falling effect is handled by the single burst; no repeated interval. ``` If you want it to fall even less, we can increase `decay` further (e.g., 0.9+) or raise `origin.y` more. Almost all the confetti immediately goes off the screen on thinner devices. Make it not do that. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I adjusted the confetti burst to be centered and less wide so it stays on-screen on thinner devices. What I changed: - Centered the burst with `origin: { x: 0.5, y: 0.4 }` so particles start from the middle. - Narrowed `spread` to 80 to reduce horizontal dispersion. - Lowered `startVelocity` and increased `decay` so particles donโ€™t shoot off as fast and settle sooner. - Slightly increased `particleCount` to 100 to compensate for the narrower spread. Snippet: ```cs484-website/src/components/Schedule.astro#L333-343 // Single centered burst for thin devices to avoid off-screen const defaults = { spread: 80, startVelocity: 20, decay: 0.9, scalar: 0.8 }; confetti({ particleCount: 100, origin: { x: 0.5, y: 0.4 }, ...defaults }); ``` If you still see particles exiting the sides on very narrow screens, I can reduce `spread` further (e.g., 60) or add a little `gravity` tweak to make them fall more vertically. Do that. I'm aiming for the confetti to mostly stay on the screen on an iPhone 11 Pro. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I tightened the effect to keep confetti on-screen for an iPhone 11 Pro: - Reduced `spread` to 60 so particles donโ€™t fan out too wide. - Lowered `startVelocity` to 16 and increased `decay` to 0.92 so they slow quickly. - Added `gravity: 0.9` to bias particles downward, keeping them in view vertically. - Slightly raised the origin to `y: 0.45` to reduce initial off-screen trajectory. - Set `particleCount` to 90 to maintain a visible burst within the tighter cone. Updated snippet: ```cs484-website/src/components/Schedule.astro#L334-343 // Single centered burst for thin devices to avoid off-screen const defaults = { spread: 60, startVelocity: 16, decay: 0.92, scalar: 0.8, gravity: 0.9 }; confetti({ particleCount: 90, origin: { x: 0.5, y: 0.45 }, ...defaults }); ``` If youโ€™d like it even more constrained, we can reduce `spread` to 45โ€“50 and increase `gravity` slightly (up to ~1.0). Make it so the confetti falls from the top with a more subtle burst effect. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I updated the confetti to fall from the top with a more subtle burst: - Origin moved to the top center: `origin: { x: 0.5, y: 0.0 }`. - Narrowed `spread` to 45 and reduced `startVelocity` to 14 for a gentler effect. - Kept `decay` at 0.92 and `scalar` at 0.8 for smaller, slower particles. - Set `gravity` to 1.0 to ensure a clean top-down fall. - Particle count adjusted to 80 to remain visible while still subtle. Snippet: ```cs484-website/src/components/Schedule.astro#L333-343 // Subtle top-fall burst for thin devices const defaults = { spread: 45, startVelocity: 14, decay: 0.92, scalar: 0.8, gravity: 1.0 }; confetti({ particleCount: 80, origin: { x: 0.5, y: 0.0 }, ...defaults }); ``` If you want it even softer, we can reduce `particleCount` to ~60 and `spread` to ~35. It still goes almost entirely off the screen immediately. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` Thanks for the detail. I adjusted the burst to shoot straight downward from the top-center, with a tighter spread and slightly higher gravity so particles stay on-screen: - Angle set to 90 degrees (downward). - Origin nudged slightly below the very top: `y: 0.02`. - Narrow spread to 30, lower start velocity to 12, increased decay to 0.94, and gravity to 1.15 for faster vertical fall and less horizontal drift. - Particle count at 70 to keep it visible but not overwhelming. Snippet: ```cs484-website/src/components/Schedule.astro#L333-343 // Subtle, tightly-focused top burst angled downward to stay on-screen const defaults = { spread: 30, startVelocity: 12, decay: 0.94, scalar: 0.8, gravity: 1.15 }; confetti({ particleCount: 70, angle: 90, origin: { x: 0.5, y: 0.02 }, ...defaults }); ``` If you still see off-screen behavior, I can reduce `spread` further (e.g., 20) and slightly raise `origin.y` to 0.06, or add `ticks` to shorten the lifetime. I changed my mind. Make the confetti come in from the sides. **Tool Call: src/components/Schedule.astro** Status: Failed 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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` **Tool Call: Read file `src/components/Schedule.astro` (lines 320-360)** Status: Completed ```cs484-website/src/components/Schedule.astro 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 dynamically imported canvas-confetti (bundled) const launchConfetti = async () => { try { const { default: confetti } = await import("canvas-confetti"); // Subtle, tightly-focused top burst angled downward to stay on-screen confetti({ particleCount: 500, angle: 270, origin: { x: 0.5, y: -0.02 }, spread: 100, startVelocity: 10, decay: 0.94, scalar: 0.8, gravity: 1.1}); // Falling effect is handled by the single burst; no repeated interval. } catch (e) { // Fallback: log and continue without confetti 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 ``` **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I switched the confetti to come in from both sides with mirrored bursts and a subtle fall. What changed: - Replaced the top-down burst with two side-entry bursts: - Left side: angle 60 from `origin: { x: 0.0, y: 0.2 }` - Right side: angle 120 from `origin: { x: 1.0, y: 0.2 }` - Kept spreads tight and particle counts modest to avoid off-screen behavior while still visible. - Gravity and decay tuned to make the confetti settle on-screen. Snippet: ```cs484-website/src/components/Schedule.astro#L333-343 // Mirrored side bursts entering from left and right, with subtle fall confetti({ particleCount: 50, angle: 60, origin: { x: 0.0, y: 0.2 }, spread: 40, startVelocity: 14, decay: 0.92, scalar: 0.8, gravity: 1.0 }); confetti({ particleCount: 50, angle: 120, origin: { x: 1.0, y: 0.2 }, spread: 40, startVelocity: 14, decay: 0.92, scalar: 0.8, gravity: 1.0 }); ``` If you want more coverage without going off-screen on narrow devices, we can slightly raise `y` to ~0.3 and reduce `spread` to ~30, or add a tiny `drift` for a more natural feel. Make the confetti go further and cover the screen on desktop, but stay mostly on screen on smaller devices. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I made the confetti responsive so it covers more of the screen on desktop while staying mostly on-screen on smaller devices. How it behaves now: - Desktop (width โ‰ฅ 1024px): higher particle count and wider spread for better coverage. - Mobile/tablet: reduced particle count, narrower spread, slightly higher gravity, and a slightly lower origin to keep particles visible on narrow screens. Snippet: ```cs484-website/src/components/Schedule.astro#L333-345 // Responsive side bursts: wide coverage on desktop, constrained on small screens const isDesktop = window.innerWidth >= 1024; const desktop = { particleCount: 180, spread: 85, startVelocity: 18, decay: 0.92, scalar: 0.9, gravity: 1.0, y: 0.25 }; const mobile = { particleCount: 70, spread: 40, startVelocity: 12, decay: 0.94, scalar: 0.8, gravity: 1.1, y: 0.30 }; const cfg = isDesktop ? desktop : mobile; confetti({ particleCount: cfg.particleCount, angle: 60, origin: { x: 0.0, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); confetti({ particleCount: cfg.particleCount, angle: 120, origin: { x: 1.0, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); ``` If you want tablet to behave closer to desktop, I can add a third breakpoint and tune values for widths between ~768โ€“1024px. It's not remotely covering the screen on desktop. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` To get real screen coverage on desktop, I bumped the desktop parameters significantly: - Particle count increased to 320 per side. - Spread widened to 140 for broader horizontal coverage. - Slightly higher start velocity and scalar to carry particles across the viewport. - Lowered origin y to 0.20 so bursts start a bit higher. Updated config: ```cs484-website/src/components/Schedule.astro#L335-345 const isDesktop = window.innerWidth >= 1024; const desktop = { particleCount: 320, spread: 140, startVelocity: 22, decay: 0.92, scalar: 0.95, gravity: 1.0, y: 0.20 }; const mobile = { particleCount: 90, spread: 50, startVelocity: 14, decay: 0.94, scalar: 0.85, gravity: 1.1, y: 0.30 }; const cfg = isDesktop ? desktop : mobile; confetti({ particleCount: cfg.particleCount, angle: 60, origin: { x: 0.0, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); confetti({ particleCount: cfg.particleCount, angle: 120, origin: { x: 1.0, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); ``` If you still want more saturation, we can add a quick second symmetric burst with a slight delay (e.g., 150ms) only on desktop, or add a small mid-screen burst to fill the center without affecting mobile. Just make the burst go further on desktop **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` Got it. I increased the desktop burstโ€™s travel distance while keeping mobile unchanged: - Wider spread: 160 - Higher start velocity: 28 - Lower decay: 0.90 (particles keep moving longer) - Slightly reduced gravity: 0.95 - Scalar: 1.0 for standard size Desktop-only config change: ```cs484-website/src/components/Schedule.astro#L335-342 const isDesktop = window.innerWidth >= 1024; const desktop = { particleCount: 320, spread: 160, startVelocity: 28, decay: 0.90, scalar: 1.0, gravity: 0.95, y: 0.20 }; const mobile = { particleCount: 90, spread: 50, startVelocity: 14, decay: 0.94, scalar: 0.85, gravity: 1.1, y: 0.30 }; const cfg = isDesktop ? desktop : mobile; ``` This should push particles further across the desktop viewport without affecting smaller devices. If you want even more reach, we can lower `gravity` to ~0.9 and `decay` to ~0.88. They still need to go further on desktop. **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I pushed the desktop burst further across the screen by tuning the parameters more aggressively: - Particle count: 360 - Spread: 200 - Start velocity: 34 - Decay: 0.88 (particles keep moving longer) - Gravity: 0.85 (less downward pull for longer travel) - Slightly higher scalar: 1.05 (a bit larger particles) - Origin y: 0.18 (a touch higher for more travel time) Updated desktop-only config: ```cs484-website/src/components/Schedule.astro#L335-343 const isDesktop = window.innerWidth >= 1024; const desktop = { particleCount: 360, spread: 200, startVelocity: 34, decay: 0.88, scalar: 1.05, gravity: 0.85, y: 0.18 }; const mobile = { particleCount: 90, spread: 50, startVelocity: 14, decay: 0.94, scalar: 0.85, gravity: 1.1, y: 0.30 }; const cfg = isDesktop ? desktop : mobile; ``` If you want maximum coverage, the next step would be to add a tiny mid-screen burst only on desktop (e.g., 120 particles at `origin.x: 0.5`) with a short delay to fill the center without impacting mobile. Sure, add that middle burst on desktop **Tool Call: src/components/Schedule.astro** Status: Failed Diff: src/components/Schedule.astro ``` ``` **Tool Call: Read file `src/components/Schedule.astro` (lines 330-352)** Status: Completed ```cs484-website/src/components/Schedule.astro // Confetti effect using dynamically imported canvas-confetti (bundled) const launchConfetti = async () => { try { const { default: confetti } = await import("canvas-confetti"); // Subtle, tightly-focused top burst angled downward to stay on-screen confetti({ particleCount: 500, angle: 270, origin: { x: 0.5, y: -0.02 }, spread: 100, startVelocity: 10, decay: 0.94, scalar: 0.8, gravity: 1.1}); // Falling effect is handled by the single burst; no repeated interval. } catch (e) { // Fallback: log and continue without confetti 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) => { ``` **Tool Call: Search files for regex `Responsive side bursts`** Status: Completed No matches found **Tool Call: Read file `src/components/Schedule.astro` (lines 320-380)** Status: Completed ```cs484-website/src/components/Schedule.astro 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 dynamically imported canvas-confetti (bundled) const launchConfetti = async () => { try { const { default: confetti } = await import("canvas-confetti"); // Subtle, tightly-focused top burst angled downward to stay on-screen confetti({ particleCount: 500, angle: 270, origin: { x: 0.5, y: -0.02 }, spread: 100, startVelocity: 10, decay: 0.94, scalar: 0.8, gravity: 1.1}); // Falling effect is handled by the single burst; no repeated interval. } catch (e) { // Fallback: log and continue without confetti 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" }); }; ``` **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()); ---
Jump to Nearest Date All Lectures Homeworks Exams
{ events.map((event) => ( )) }
{event?.tentative && }
{event.type}
{formatTime(event.date)} {(event.type === "homework release" || event.type === "homework due") && ( {event.title} )} {event.type === "exam" && event.title} {event.type === "lecture" && event.title} {event.type === "exam" && event.content} {event.type === "lecture" && event.readings && (
    {event.readings.map((reading: Reading) => (
  • {reading.name} {reading.grad_only && ๐Ÿง‘โ€๐ŸŽ“} {reading.optional && ๐Ÿ“ต}
  • ))}
)}
{event.notes && ( DOMPurify.sanitize(r)) .catch((e) => `Markdown parse failure: ${e}.`)} /> )}
``` I replaced the previous top burst with responsive side bursts and added a desktop-only mid-screen burst to fill the center. Behavior: - On desktop: - Two strong side bursts from left and right with high spread/velocity for screen coverage. - A small delayed mid-screen downward burst to fill any gaps. - On smaller devices: - Side bursts use constrained parameters to stay mostly on-screen. Snippet: ```cs484-website/src/components/Schedule.astro#L333-356 // Responsive side bursts with optional mid-screen burst on desktop const isDesktop = window.innerWidth >= 1024; const desktop = { particleCount: 360, spread: 200, startVelocity: 34, decay: 0.88, scalar: 1.05, gravity: 0.85, y: 0.18 }; const mobile = { particleCount: 90, spread: 50, startVelocity: 14, decay: 0.94, scalar: 0.85, gravity: 1.1, y: 0.30 }; const cfg = isDesktop ? desktop : mobile; // Side bursts (left and right) confetti({ particleCount: cfg.particleCount, angle: 60, origin: { x: 0.0, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); confetti({ particleCount: cfg.particleCount, angle: 120, origin: { x: 1.0, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); // Optional mid-screen burst to fill center, desktop only if (isDesktop) { setTimeout(() => { confetti({ particleCount: 140, angle: 90, origin: { x: 0.5, y: 0.25 }, spread: 120, startVelocity: 26, decay: 0.90, scalar: 1.0, gravity: 0.9 }); }, 150); } ``` If you want the center fill to be stronger, we can increase `particleCount` for the mid-screen burst or fire two quick mid-screen bursts at different angles. --- package-lock.json | 21 +++++++++- package.json | 4 +- src/components/Schedule.astro | 77 +++++++++++++---------------------- 3 files changed, 52 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8126efd..05ce574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.3", "astro": "^5.16.0", + "canvas-confetti": "^1.9.2", "dayjs": "^1.11.19", "isomorphic-dompurify": "^2.30.0", "marked": "^16.4.1", @@ -26,7 +27,8 @@ "typescript": "^5.9.3" }, "devDependencies": { - "@biomejs/biome": "^2.2.6" + "@biomejs/biome": "^2.2.6", + "@types/canvas-confetti": "^1.6.1" } }, "node_modules/@ampproject/remapping": { @@ -3096,6 +3098,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3950,6 +3959,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", diff --git a/package.json b/package.json index a391a3e..07efc59 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,14 @@ "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" + "@biomejs/biome": "^2.2.6", + "@types/canvas-confetti": "^1.6.1" } } diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 8a6998a..0346764 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -7,6 +7,7 @@ import { marked } from "marked"; import { transformContentData } from "../utils/contentUtils"; import { calculateAbsoluteDate, formatTime } from "../utils/dateUtils"; + interface Reading { link: string; name: string; @@ -107,7 +108,7 @@ const events = [...exams, ...homeworks, ...lectures].sort( // class concludes on the Friday of finals week const concluded = calculateAbsoluteDate({ - week: 15, + week: 16, day: "Friday", time: "17:00:00", }).isBefore(dayjs()); @@ -327,53 +328,33 @@ const concluded = calculateAbsoluteDate({ }, 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); + // Confetti effect using dynamically imported canvas-confetti (bundled) + const launchConfetti = async () => { + try { + const { default: confetti } = await import("canvas-confetti"); + + // Responsive side bursts with optional mid-screen burst on desktop + const isDesktop = window.innerWidth >= 1024; + const desktop = { particleCount: 360, spread: 200, startVelocity: 34, decay: 0.88, scalar: 1.05, gravity: 0.85, y: 0.30 }; + const mobile = { particleCount: 170, spread: 50, startVelocity: 25, decay: 0.94, scalar: 0.85, gravity: 1.1, y: 0.7 }; + const cfg = isDesktop ? desktop : mobile; + + // Side bursts (left and right) + confetti({ particleCount: cfg.particleCount, angle: 60, origin: { x: -0.1, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); + confetti({ particleCount: cfg.particleCount, angle: 120, origin: { x: 1.1, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); + + // Optional mid-screen burst to fill center, desktop only + if (isDesktop) { + setTimeout(() => { + confetti({ particleCount: 140, angle: 90, origin: { x: 0.5, y: 1.0 }, spread: 120, startVelocity: 26, decay: 0.90, scalar: 1.0, gravity: 0.9 }); + }, 150); + } + + // Falling effect is handled by these bursts; no repeated interval. + } catch (e) { + // Fallback: log and continue without confetti + console.warn("Confetti unavailable:", e); + } }; // function to jump to the nearest date, button and functionality is set here From f25aee1a5e0f012b94af062778d4e972e238169a Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Tue, 9 Dec 2025 12:29:36 -0600 Subject: [PATCH 05/13] Made `concluded` a client-side variable. 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 :(. --- src/components/Schedule.astro | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 0346764..50b1259 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -104,18 +104,11 @@ 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()); + ---
- + Jump to Nearest Date @@ -298,6 +291,16 @@ const concluded = calculateAbsoluteDate({
From f0fbd13d642b5518425a6c232656ee7a04306873 Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Tue, 9 Dec 2025 12:44:31 -0600 Subject: [PATCH 08/13] Made course end before finals week for purposes of the confetti thing. Feel free to revert this commit. --- src/components/Schedule.astro | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 7f94992..2a6b15e 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -296,10 +296,10 @@ const events = [...exams, ...homeworks, ...lectures].sort( const isDesktop = window.innerWidth >= 1024; // events is already sorted, so just check if the last event is in the past - // class concludes on the Friday after finals week + // class concludes on the Friday before finals week const courseIsConcluded = calculateAbsoluteDate({ - week: 16, + week: 15, day: "Friday", time: "17:00:00", }).isBefore(dayjs()); From 24cbf446e13d1fc4ff82c00d0bfd38a783d699b5 Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Tue, 9 Dec 2025 18:33:36 -0600 Subject: [PATCH 09/13] Update src/components/Schedule.astro Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/Schedule.astro | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 2a6b15e..9ec08fc 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -354,8 +354,8 @@ const events = [...exams, ...homeworks, ...lectures].sort( const cfg = isDesktop ? desktop : mobile; // Side bursts (left and right) - confetti({ particleCount: cfg.particleCount, angle: 60, origin: { x: -0.1, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); - confetti({ particleCount: cfg.particleCount, angle: 120, origin: { x: 1.1, y: cfg.y }, spread: cfg.spread, startVelocity: cfg.startVelocity, decay: cfg.decay, scalar: cfg.scalar, gravity: cfg.gravity }); + confetti({ ...cfg, angle: 60, origin: { x: -0.1, y: cfg.y } }); + confetti({ ...cfg, angle: 120, origin: { x: 1.1, y: cfg.y } }); // Optional mid-screen burst to fill center, desktop only if (isDesktop) { From efea7300fad0f5776a04c160a63106b9182571e9 Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Tue, 9 Dec 2025 18:34:42 -0600 Subject: [PATCH 10/13] Update package.json to bring confetti versions more in-line Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 07efc59..734b7d1 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,6 @@ }, "devDependencies": { "@biomejs/biome": "^2.2.6", - "@types/canvas-confetti": "^1.6.1" + "@types/canvas-confetti": "^1.9.0" } } From 237be32ac0b2a05eb85bb64613519200aacd7208 Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Wed, 10 Dec 2025 09:37:00 -0600 Subject: [PATCH 11/13] Detect if desktop with a function. This works better if the user rotates their phone or resizes their window after loading the page. Thanks, Copilot. --- src/components/Schedule.astro | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 9ec08fc..c563e69 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -293,7 +293,7 @@ const events = [...exams, ...homeworks, ...lectures].sort( import dayjs from "dayjs"; import { calculateAbsoluteDate } from "../utils/dateUtils"; - const isDesktop = window.innerWidth >= 1024; + const getIsDesktop = () => window.innerWidth >= 1024; // events is already sorted, so just check if the last event is in the past // class concludes on the Friday before finals week @@ -312,7 +312,7 @@ const events = [...exams, ...homeworks, ...lectures].sort( toast.id = "schedule-toast"; toast.style.position = "fixed"; toast.style.bottom = "20px"; - if (!isDesktop) { + if (!getIsDesktop()) { toast.style.left = "12px"; toast.style.right = "12px"; toast.style.transform = ""; @@ -323,7 +323,7 @@ const events = [...exams, ...homeworks, ...lectures].sort( toast.style.left = ""; toast.style.transform = ""; } - toast.style.maxWidth = isDesktop ? "320px" : "none"; + toast.style.maxWidth = getIsDesktop() ? "320px" : "none"; toast.style.padding = "12px 16px"; toast.style.borderRadius = "8px"; toast.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; @@ -351,14 +351,14 @@ const events = [...exams, ...homeworks, ...lectures].sort( // Responsive side bursts with optional mid-screen burst on desktop const desktop = { particleCount: 360, spread: 200, startVelocity: 34, decay: 0.88, scalar: 1.05, gravity: 0.85, y: 0.30 }; const mobile = { particleCount: 170, spread: 50, startVelocity: 25, decay: 0.94, scalar: 0.85, gravity: 1.1, y: 0.7 }; - const cfg = isDesktop ? desktop : mobile; + const cfg = getIsDesktop() ? desktop : mobile; // Side bursts (left and right) confetti({ ...cfg, angle: 60, origin: { x: -0.1, y: cfg.y } }); confetti({ ...cfg, angle: 120, origin: { x: 1.1, y: cfg.y } }); // Optional mid-screen burst to fill center, desktop only - if (isDesktop) { + if (getIsDesktop()) { setTimeout(() => { confetti({ particleCount: 140, angle: 90, origin: { x: 0.5, y: 1.0 }, spread: 120, startVelocity: 26, decay: 0.90, scalar: 1.0, gravity: 0.9 }); }, 150); From 9a0beb48f9636fd6128effe9d1337929f3d8e49b Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Wed, 10 Dec 2025 09:38:34 -0600 Subject: [PATCH 12/13] Clarified change to course conclusion date Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/Schedule.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index c563e69..237c9a6 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -296,7 +296,7 @@ const events = [...exams, ...homeworks, ...lectures].sort( const getIsDesktop = () => window.innerWidth >= 1024; // events is already sorted, so just check if the last event is in the past - // class concludes on the Friday before finals week + // class concludes on the Friday of week 15 (the last week of instruction) const courseIsConcluded = calculateAbsoluteDate({ week: 15, From 34efd947bdc20770859eadd6f974758b1ebf2983 Mon Sep 17 00:00:00 2001 From: Samuel Skean Date: Sat, 20 Dec 2025 12:27:03 -0600 Subject: [PATCH 13/13] Accessible toasts. --- src/components/Schedule.astro | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Schedule.astro b/src/components/Schedule.astro index 237c9a6..b50c078 100644 --- a/src/components/Schedule.astro +++ b/src/components/Schedule.astro @@ -310,6 +310,9 @@ const events = [...exams, ...homeworks, ...lectures].sort( if (!toast) { toast = document.createElement("div"); toast.id = "schedule-toast"; + toast.setAttribute("role", "alert"); + toast.setAttribute("aria-live", "assertive"); + toast.setAttribute("aria-atomic", "true"); toast.style.position = "fixed"; toast.style.bottom = "20px"; if (!getIsDesktop()) {