Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.9.0"
}
}
4 changes: 2 additions & 2 deletions src/components/DueDate.astro
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
import {
localizeTime,
formatTime,
calculateAbsoluteDate,
formatTime,
localizeTime,
} from "../utils/dateUtils";

interface Props {
Expand Down
2 changes: 1 addition & 1 deletion src/components/EnhancedMarkdownContent.astro
Original file line number Diff line number Diff line change
@@ -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;
---
Expand Down
174 changes: 140 additions & 34 deletions src/components/Schedule.astro
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
---
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;
Expand Down Expand Up @@ -103,31 +104,20 @@ 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());
---

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

<!-- Filter Buttons -->
<span class="action primary filter-button" id="filterAll" data-filter="all">All</span>
<span class="action secondary filter-button" id="filterLecture" data-filter="lecture">Lectures</span>
<span class="action secondary filter-button" id="filterHomework" data-filter="homework">Homeworks</span>
<span class="action secondary filter-button" id="filterExam" data-filter="exam">Exams</span>
</div>
)
}
<div class="controls-container">
<span class="action primary" id="jumpToNextEvent">
Jump to Next Event
</span>

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

<div class="not-content ml-4 flex items-center lg:ml-0 lg:justify-center">
<table class="border-separate border-spacing-y-2 text-sm">
Expand Down Expand Up @@ -276,6 +266,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;
}
Expand All @@ -296,9 +291,91 @@ const concluded = calculateAbsoluteDate({

<script>
import dayjs from "dayjs";
import { calculateAbsoluteDate } from "../utils/dateUtils";

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 of week 15 (the last week of instruction)

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

// Lightweight toast helper reused across interactions
const showToast = (message: string, duration = 3000) => {
let toast = document.getElementById("schedule-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "schedule-toast";
toast.setAttribute("role", "alert");
toast.setAttribute("aria-live", "assertive");
toast.setAttribute("aria-atomic", "true");
toast.style.position = "fixed";
toast.style.bottom = "20px";
if (!getIsDesktop()) {
toast.style.left = "12px";
toast.style.right = "12px";
toast.style.transform = "";
toast.style.width = "auto";
toast.style.maxWidth = "none";
} else {
toast.style.right = "20px";
toast.style.left = "";
toast.style.transform = "";
}
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)";
toast.style.backgroundColor = "var(--sl-color-accent-low)";
toast.style.color = "var(--sl-color-text)";
toast.style.fontSize = "14px";
toast.style.zIndex = "9999";
toast.style.opacity = "0";
toast.style.transition = "opacity 200ms ease";
document.body.appendChild(toast);
}
toast.textContent = message;
toast.style.opacity = "1";
window.clearTimeout((toast as any)._hideTimer);
(toast as any)._hideTimer = window.setTimeout(() => {
toast!.style.opacity = "0";
}, duration);
Comment thread
samuel-skean marked this conversation as resolved.
};
Comment thread
samuel-skean marked this conversation as resolved.

// 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 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 = 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 (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);
}

// function to jump to the nearest date, button and functionality is set here
const jumpToDate = () => {
// 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 next event after today, button and functionality is set here
const jumpToNextEvent = () => {
const today = dayjs();
const rows = document.querySelectorAll("tr[data-date]:not([hidden])");
const nearestRow = Array.from(rows)
Expand Down Expand Up @@ -369,7 +446,7 @@ const concluded = calculateAbsoluteDate({
buttons.forEach((button) => {
const filterType = button.getAttribute('data-filter');
const isActive = filterType === activeFilter;

// Update the class for styling
if (isActive) {
button.classList.remove('secondary');
Expand All @@ -388,13 +465,13 @@ const concluded = calculateAbsoluteDate({
const show = filterType === "all" || type === filterType;
(row as HTMLElement).hidden = !show;
});

// Update zebra striping after filtering
updateZebraStriping();

// Update button variants
updateLinkButtonVariants(filterType);

// Update URL without triggering page reload
const newHash = filterToHash[filterType] || '#all';
if (window.location.hash !== newHash) {
Expand Down Expand Up @@ -442,9 +519,38 @@ const concluded = calculateAbsoluteDate({
});

document
.querySelector("#jumpToDateButton")
.querySelector("#jumpToNextEvent")
?.addEventListener("click", () => {
jumpToDate();
const btn = document.querySelector("#jumpToNextEvent") as HTMLElement | null;
if (courseIsConcluded) {
// 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;
}
Comment thread
samuel-skean marked this conversation as resolved.

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

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

jumpToNextEvent();
});
</script>
</div>
2 changes: 1 addition & 1 deletion src/content.config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/utils/contentUtils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down