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
32 changes: 27 additions & 5 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState, createContext } from "react";
import { Routes, Route, Navigate, useParams, Link } from "react-router";
import { Routes, Route, Navigate, useParams, useNavigate, Link } from "react-router";
import Planner from "./Planner";
import WorkspacePanel from "./Workspace";
import Modal from "./Modal";
Expand Down Expand Up @@ -159,15 +159,15 @@ function TermPage() {
the section number will not be changed.
</p>
<p>
You can also limit sections be time. Above the calendar, you can
You can also limit sections by time. Above the calendar, you can
change the allowed time range for any day of the week. The course
scheduler should respect these times, and it will not generate
arrangements with courses that start before the first time or end
after the second. Note: If has a course doesn't have a time
after the second. Note: If a course doesn't have a time
(marked as A), then the scheduler will leave it blank.
</p>
<p>
We hope that this course schuduler makes your life easier! You can
We hope that this course scheduler makes your life easier! You can
find the source code{" "}
<Hyperlink
href="https://github.com/rchalamala/caltech.dev"
Expand Down Expand Up @@ -209,7 +209,7 @@ function TermPage() {
<Hyperlink href="https://github.com/ericlovesmath" text="Eric" />, &{" "}
<Hyperlink href="https://github.com/zack466" text="Zack" />
</p>
<p>Current term: {term}</p>
<TermSelector currentTerm={term ?? ""} />
</footer>
</AppState.Provider>
</AllCourses.Provider>
Expand Down Expand Up @@ -289,6 +289,28 @@ function App() {
/* Shared components */
/* ------------------------------------------------------------------ */

function TermSelector({ currentTerm }: { currentTerm: string }) {
const navigate = useNavigate();
const supportedTerms = getSupportedTermPaths();

return (
<p>
Term:{" "}
<select
className="font-mono font-bold text-orange-500 bg-transparent border-b border-orange-500 cursor-pointer focus:outline-none"
value={`/${currentTerm.toLowerCase()}`}
onChange={(e) => navigate(e.target.value)}
>
{supportedTerms.map((tp) => (
<option key={tp} value={tp}>
{tp.substring(1)}
</option>
))}
</select>
</p>
);
}

function Hyperlink(props: { href: string; text: string }) {
return (
<a
Expand Down
6 changes: 3 additions & 3 deletions src/Planner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ describe("Planner", () => {
it("renders the calendar without errors when no courses are added", () => {
renderPlanner();

// react-big-calendar renders day headers: Mon, Tue, Wed, Thu, Fri
expect(screen.getByText("Mon")).toBeInTheDocument();
expect(screen.getByText("Fri")).toBeInTheDocument();
// react-big-calendar renders day headers and time-picker labels: Mon, Tue, Wed, Thu, Fri
expect(screen.getAllByText("Mon").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Fri").length).toBeGreaterThanOrEqual(1);
});

it("renders time pickers for 5 weekdays", () => {
Expand Down
9 changes: 4 additions & 5 deletions src/Planner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Calendar, dayjsLocalizer, Views } from "react-big-calendar";
import dayjs from "dayjs";
import Flatpickr from "react-flatpickr";
import { parseTimes } from "./lib/time";
import { getCourseColorHSL } from "./lib/courseColor";
import { CourseStorage, DateData } from "./types";

import "react-big-calendar/lib/css/react-big-calendar.css";
Expand Down Expand Up @@ -61,12 +62,9 @@ function Planner() {

/** Hashes id to proper color and styling for calendar items */
const eventStyleGetter = (event: DateData) => {
const hue = ((event.id * 1.4269) % 1.0) * 360;
const sat = (((event.id * 1.7234) % 0.2) + 0.5) * 100;

return {
style: {
backgroundColor: `hsl(${hue}, ${sat}%, 70%)`,
backgroundColor: getCourseColorHSL(event.id),
cursor: "pointer",
borderStyle: "none",
borderRadius: "4px",
Expand All @@ -77,9 +75,10 @@ function Planner() {
return (
<div>
<div className="time-controls">
{[0, 1, 2, 3, 4].map((idx) => {
{["Mon", "Tue", "Wed", "Thu", "Fri"].map((day, idx) => {
return (
<div className="time-picker" key={idx}>
<span className="time-picker-label">{day}</span>
<Flatpickr
data-enable-time
options={{
Expand Down
7 changes: 5 additions & 2 deletions src/Workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,11 @@ describe("Workspace", () => {
expect(screen.getByText("1/2")).toBeInTheDocument();
});

it("shows 'All sections set' when all courses are locked", () => {
it("shows unlock hint when all courses are locked", () => {
const courses = [makeCourseStorage(1, { locked: true })];
renderWorkspace({ courses });

expect(screen.getByText(/all sections set/i)).toBeInTheDocument();
expect(screen.getByText(/unlock courses/i)).toBeInTheDocument();
});

it("calls prevArrangement and nextArrangement on arrow clicks", async () => {
Expand Down Expand Up @@ -296,7 +296,10 @@ describe("Workspace", () => {
const courses = [makeCourseStorage(1)];
const { mockState } = renderWorkspace({ courses });

// Mock window.confirm to return true
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
await user.click(screen.getByText("Remove All"));
expect(mockState.setCourses).toHaveBeenCalledWith([]);
confirmSpy.mockRestore();
});
});
127 changes: 93 additions & 34 deletions src/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import Modal, { useModal } from "./Modal";
import Select from "react-select";
import { SingleValue } from "react-select";
Expand Down Expand Up @@ -27,6 +27,7 @@ import { Collapse, IconButton, Switch } from "@mui/material";
import { UnfoldLess, UnfoldMore } from "@mui/icons-material";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { exportICS } from "./lib/ics";
import { getCourseColorHSL } from "./lib/courseColor";
import {
CourseData,
CourseIndex,
Expand Down Expand Up @@ -113,7 +114,13 @@ function SectionDropdown(props: { course: CourseStorage }) {
}
onChange={onChange}
options={course.courseData.sections}
getOptionLabel={(section) => `${section.number}`}
getOptionLabel={(section) => {
const time = section.times === "A" ? "TBD" : section.times;
const parts = [`${section.number}`];
if (section.instructor) parts.push(section.instructor);
parts.push(time);
return parts.join(" \u2014 ");
}}
isOptionSelected={(section) =>
course.sectionId !== null
? section.number ===
Expand Down Expand Up @@ -200,7 +207,10 @@ function WorkspaceEntry(props: WorkspaceEntryProps) {
}`}
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
style={{
...provided.draggableProps.style,
borderLeft: `4px solid ${getCourseColorHSL(course.courseData.id)}`,
}}
>
<div
className={`relative w-full whitespace-nowrap ${snapshot.isDragging ? "workspace-entry-dragging" : ""}`}
Expand Down Expand Up @@ -392,7 +402,11 @@ function WorkspaceScheduler() {
if (allSectionsSet) {
return (
<div className="workspace-scheduler">
<p>All sections set.</p>
<p>
{state.courses.length > 0
? "All sections locked. Unlock courses to auto-find non-conflicting schedules."
: "All sections set."}
</p>
</div>
);
} else if (total === 0) {
Expand Down Expand Up @@ -475,27 +489,62 @@ export default function Workspace({ term }: { term: string }) {
);
});

const importWorkspace = () => {
const code = prompt("Copy in the workspace code.") || "";
if (code === "") {
return;
}
try {
const shortened = JSON.parse(window.atob(code));
const courses: CourseStorageShort[] = [];
for (let i = 0; i * 4 < shortened.length; i++) {
courses.push({
courseId: shortened[i * 4],
enabled: shortened[i * 4 + 1],
locked: shortened[i * 4 + 2],
sectionId: shortened[i * 4 + 3],
});
const [importError, setImportError] = useState<string | null>(null);
const importInputRef = useRef<HTMLTextAreaElement>(null);

const [openImportModalRaw, importModal] = useModal(({ onClose }) => {
const handleImport = () => {
const code = importInputRef.current?.value.trim() || "";
if (code === "") {
return;
}
const lengthened = lengthenCourses(courses, indexedCourses);
state.setCourses(lengthened);
} catch {
alert("Error importing workspace.");
}
try {
const shortened = JSON.parse(window.atob(code));
const courses: CourseStorageShort[] = [];
for (let i = 0; i * 4 < shortened.length; i++) {
courses.push({
courseId: shortened[i * 4],
enabled: shortened[i * 4 + 1],
locked: shortened[i * 4 + 2],
sectionId: shortened[i * 4 + 3],
});
}
const lengthened = lengthenCourses(courses, indexedCourses);
state.setCourses(lengthened);
setImportError(null);
onClose();
} catch {
setImportError("Invalid workspace code. Please check and try again.");
}
};

return (
<div className="export-modal">
<p className="text-lg font-bold">Paste your workspace code:</p>
<textarea
ref={importInputRef}
className="w-full p-2 font-mono text-sm border rounded-md border-neutral-300"
rows={4}
placeholder="Paste workspace code here..."
/>
{importError && (
<p className="text-sm text-red-500">{importError}</p>
)}
<motion.button
whileHover={{ scale: 0.95 }}
whileTap={{ scale: 0.9 }}
className="flex px-4 py-2 mx-auto space-x-2 font-bold border-2 rounded-md"
onClick={handleImport}
>
<p>Import</p>
</motion.button>
</div>
);
});

const openImportModal = () => {
setImportError(null);
openImportModalRaw();
};

function onDragEnd(result: DropResult) {
Expand All @@ -514,6 +563,7 @@ export default function Workspace({ term }: { term: string }) {
return (
<div className="workspace-wrapper">
{exportModal}
{importModal}
<h2 className="mb-2 text-center">Choose Workspace...</h2>
<div
className="workspace-switcher"
Expand Down Expand Up @@ -597,21 +647,30 @@ export default function Workspace({ term }: { term: string }) {
);
}}
/>
<ControlButton text="Remove All" onClick={() => state.setCourses([])} />
<ControlButton text="Remove All" onClick={() => {
if (state.courses.length === 0 || window.confirm("Remove all courses from this workspace?")) {
state.setCourses([]);
}
}} />
<ControlButton
text="Default Schedule"
onClick={() => {
state.setCourses(
// Change based on term
DEFAULT_COURSES[term.substring(0, 2)].map((name) => ({
...getCourse(name, indexedCourses)!,
enabled: true,
locked: true,
})),
);
if (state.courses.length === 0 || window.confirm("Replace current courses with the default schedule?")) {
state.setCourses(
// Change based on term
(DEFAULT_COURSES[term.substring(0, 2)] ?? [])
.map((name) => getCourse(name, indexedCourses))
.filter((c): c is CourseStorage => c !== null)
.map((c) => ({
...c,
enabled: true,
locked: true,
})),
);
}
}}
/>
<ControlButton text="Import Workspace" onClick={importWorkspace} />
<ControlButton text="Import Workspace" onClick={openImportModal} />
<ControlButton text="Export Workspace" onClick={openExportModal} />
<ControlButton text="Export .ics" onClick={() => {
const icsContent = exportICS(term, state.courses);
Expand Down
8 changes: 8 additions & 0 deletions src/css/planner.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
flex-direction: column;
row-gap: 8px;
max-width: 4rem;
align-items: center;
}

.time-picker-label {
font-size: 0.75rem;
font-weight: 600;
text-align: center;
color: #737373;
}

.time-picker > input {
Expand Down
16 changes: 16 additions & 0 deletions src/lib/courseColor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Shared color utility for deterministic course coloring.
* Used by both the calendar (Planner) and workspace (WorkspaceEntry)
* to visually link course cards to their calendar blocks.
*/

export function getCourseColor(courseId: number): { hue: number; sat: number } {
const hue = ((courseId * 1.4269) % 1.0) * 360;
const sat = (((courseId * 1.7234) % 0.2) + 0.5) * 100;
return { hue, sat };
}

export function getCourseColorHSL(courseId: number, lightness = 70): string {
const { hue, sat } = getCourseColor(courseId);
return `hsl(${hue}, ${sat}%, ${lightness}%)`;
}
Loading