From 444ecb7b4ece9122e4e362ed3d536c790be6f1b5 Mon Sep 17 00:00:00 2001 From: Benjamin Welsh Date: Thu, 23 Apr 2026 20:59:12 -0400 Subject: [PATCH 1/2] fix time block creation --- .../src/components/SectionMutationDrawer.tsx | 126 ++++++++++++------ 1 file changed, 84 insertions(+), 42 deletions(-) diff --git a/frontend/src/components/SectionMutationDrawer.tsx b/frontend/src/components/SectionMutationDrawer.tsx index 90c4cc1..b370841 100644 --- a/frontend/src/components/SectionMutationDrawer.tsx +++ b/frontend/src/components/SectionMutationDrawer.tsx @@ -119,37 +119,67 @@ function generateBlockGroup(): string { /** * Compound AM/PM time selector — three linked selects for hour, minute, period. - * Reports the chosen time as a "HH:MM" 24-hour string via `onChange`. + * Reports the chosen time as a "HH:MM" 24-hour string via `onChange`, but only + * once hour and minute are both set. Local state retains partial selections so + * each dropdown "sticks" as the user works through them. */ +function parseTimeValue(value: string): { hour: string; minute: string; period: 'AM' | 'PM' } { + if (!value) return { hour: '', minute: '', period: 'AM' }; + const [h24, m] = value.split(':'); + const hNum = Number(h24); + return { + hour: Number.isFinite(hNum) ? String(hNum % 12 || 12) : '', + minute: m ?? '', + period: Number.isFinite(hNum) && hNum >= 12 ? 'PM' : 'AM', + }; +} + function TimeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const [h24, m] = value ? value.split(':') : ['', '']; - const hNum = h24 ? Number(h24) : null; - const hour = hNum !== null ? String(hNum % 12 || 12) : ''; - const minute = m ?? ''; - const period: 'AM' | 'PM' = hNum !== null && hNum >= 12 ? 'PM' : 'AM'; - - function emit(newHour: string, newMinute: string, newPeriod: 'AM' | 'PM') { - if (newHour && newMinute) onChange(to24Hour(newHour, newMinute, newPeriod)); + const [draft, setDraft] = useState(() => parseTimeValue(value)); + + // Re-sync local draft when the parent changes `value` externally. + useEffect(() => { + setDraft(parseTimeValue(value)); + }, [value]); + + function update(next: { hour: string; minute: string; period: 'AM' | 'PM' }) { + setDraft(next); + if (next.hour && next.minute) onChange(to24Hour(next.hour, next.minute, next.period)); } - const sel = 'text-xs border border-gray-200 rounded px-1.5 py-1 focus:outline-none focus:ring-1 focus:ring-burgundy-500 bg-white'; + const sel = 'text-xs border border-gray-200 rounded px-1.5 py-1 hover:border-gray-300 focus:outline-none focus:ring-1 focus:ring-burgundy-500 bg-white'; return (
- update({ ...draft, hour: e.target.value })} + > {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((n) => ( ))} - : - update({ ...draft, minute: e.target.value })} + > {['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map((mm) => ( ))} - update({ ...draft, period: e.target.value as 'AM' | 'PM' })} + > @@ -511,6 +541,8 @@ export default function SectionMutationDrawer(props: Props) { // Controls visibility of the inline "create time block" form const [showAddBlock, setShowAddBlock] = useState(false); + // When true, narrow the time block dropdown to the curated NEU sequences + const [showOnlyStandard, setShowOnlyStandard] = useState(false); // Submission state const [saving, setSaving] = useState(false); @@ -636,22 +668,21 @@ export default function SectionMutationDrawer(props: Props) { const courseOptions: SelectOption[] = courses.map(courseOptionFromApi); const timeBlockOptions: SelectOption[] = useMemo(() => { - // Only show multi-day blocks (the standard named sequences) and split block halves. - // Exclude single-day blocks (days.length === 1 and no block_group) — those are legacy - // seed blocks that aren't useful for manual section assignment. - const eligible = timeBlocks.filter((tb) => { - // Always keep split-block halves - if (tb.block_group != null) return true; - // Only show blocks that exactly match a known standard sequence — - // this filters out legacy seed blocks that don't correspond to any - // official NEU meeting pattern. - return STANDARD_SEQUENCES.some( - (s) => s.days === tb.days && s.start === tb.start_time && s.end === tb.end_time, - ); - }); - - // Sort by start time so the earlier block of a pair is always processed first - const sorted = [...eligible].sort((a, b) => a.start_time.localeCompare(b.start_time)); + const filtered = showOnlyStandard + ? timeBlocks.filter((tb) => { + // Always keep the currently-selected block so toggling the filter + // never hides the user's existing choice. + if (tb.time_block_id === timeBlockId) return true; + // Split-block halves are part of a named pattern — keep them. + if (tb.block_group != null) return true; + return STANDARD_SEQUENCES.some( + (s) => s.days === tb.days && s.start === tb.start_time && s.end === tb.end_time, + ); + }) + : timeBlocks; + + // Sort by start time so the earlier block of a split pair is processed first + const sorted = [...filtered].sort((a, b) => a.start_time.localeCompare(b.start_time)); const seen = new Set(); const opts: SelectOption[] = []; @@ -678,7 +709,7 @@ export default function SectionMutationDrawer(props: Props) { } return opts.sort((a, b) => a.label.localeCompare(b.label)); - }, [timeBlocks]); + }, [timeBlocks, showOnlyStandard, timeBlockId]); const facultyOptions: SelectOption[] = useMemo(() => { const rows = faculty.map((f) => { @@ -824,18 +855,29 @@ export default function SectionMutationDrawer(props: Props) { {/* Time Block */}
-
+
- {/* Only show "Add new" when a campus is known — we need it to create the block */} - {campusId != null && !showAddBlock && ( - - )} +
+ + {/* Only show "Add new" when a campus is known — we need it to create the block */} + {campusId != null && !showAddBlock && ( + + )} +
Date: Thu, 23 Apr 2026 21:16:33 -0400 Subject: [PATCH 2/2] use semester name rather than id in frontend --- frontend/src/pages/Faculty.test.tsx | 1 + frontend/src/pages/Faculty.tsx | 15 +++++++++++++-- frontend/src/pages/ScheduleList.tsx | 16 ++++++++++++++-- frontend/src/pages/Schedules.test.tsx | 3 +++ frontend/src/pages/Schedules.tsx | 8 ++++++-- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/Faculty.test.tsx b/frontend/src/pages/Faculty.test.tsx index 61eff16..95eb624 100644 --- a/frontend/src/pages/Faculty.test.tsx +++ b/frontend/src/pages/Faculty.test.tsx @@ -55,6 +55,7 @@ describe('Faculty page — Export Invite CSV', () => { mockApi = { getMeApiUsersMeGet: vi.fn().mockResolvedValue(mockAdmin), getAllCampusesCampusesGet: vi.fn().mockResolvedValue([]), + getAllSemestersSemestersGet: vi.fn().mockResolvedValue([]), getSchedulesSchedulesGet: vi.fn().mockResolvedValue([]), listUsersApiUsersGet: vi.fn().mockResolvedValue([]), getFacultyFacultyGet: vi.fn().mockResolvedValue([]), diff --git a/frontend/src/pages/Faculty.tsx b/frontend/src/pages/Faculty.tsx index 6a14c75..385af5f 100644 --- a/frontend/src/pages/Faculty.tsx +++ b/frontend/src/pages/Faculty.tsx @@ -5,6 +5,7 @@ import { type CampusResponse, type InviteResponse, type ScheduleResponse, + type SemesterResponse, } from '../api/generated'; import FacultyDrawer, { type FacultyRecord } from '../components/FacultyDrawer'; import SearchableSelect, { type SelectOption } from '../components/SearchableSelect'; @@ -73,6 +74,9 @@ export default function Faculty() { // Campuses const [campuses, setCampuses] = useState([]); + // Semesters + const [semesters, setSemesters] = useState([]); + // Faculty list const [facultyList, setFacultyList] = useState([]); const [facultyLoading, setFacultyLoading] = useState(true); @@ -111,6 +115,7 @@ export default function Faculty() { useEffect(() => { const api = getAutomatedCourseSchedulerAPI(); api.getAllCampusesCampusesGet().then(setCampuses).catch(() => {}); + api.getAllSemestersSemestersGet().then(setSemesters).catch(() => {}); api .getSchedulesSchedulesGet() .then((data) => { @@ -172,15 +177,21 @@ export default function Faculty() { }, [sections]); const totalTimeAssignments = timeCounts.first + timeCounts.second + timeCounts.third + timeCounts.none; + // Semester label lookup + const semesterLabelMap = useMemo( + () => new Map(semesters.map((s) => [s.semester_id, `${s.season} ${s.year}`])), + [semesters], + ); + // Schedule dropdown options const scheduleOptions: SelectOption[] = useMemo( () => schedules.map((s) => ({ value: s.schedule_id, label: s.name, - sublabel: `Semester ${s.semester_id}`, + sublabel: semesterLabelMap.get(s.semester_id) ?? '', })), - [schedules], + [schedules, semesterLabelMap], ); // Campus name lookup diff --git a/frontend/src/pages/ScheduleList.tsx b/frontend/src/pages/ScheduleList.tsx index b3dc32f..0e7edd3 100644 --- a/frontend/src/pages/ScheduleList.tsx +++ b/frontend/src/pages/ScheduleList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { getAutomatedCourseSchedulerAPI, @@ -17,12 +17,14 @@ import { useUser } from '../context/UserContext'; function ScheduleCard({ schedule, + semesterLabel, onClick, isAdmin, onUpdate, onDelete, }: { schedule: ScheduleResponse; + semesterLabel: string | null; onClick: () => void; isAdmin: boolean; onUpdate: (id: number, data: { name?: string; draft?: boolean }) => Promise; @@ -161,7 +163,9 @@ function ScheduleCard({

{schedule.name}

-

Semester {schedule.semester_id}

+ {semesterLabel && ( +

{semesterLabel}

+ )}
{schedule.draft ? ( @@ -699,6 +703,7 @@ function CreateScheduleModal({ onClose, onCreated }: { onClose: () => void; onCr export default function ScheduleList() { const [schedules, setSchedules] = useState([]); + const [semesters, setSemesters] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showCreate, setShowCreate] = useState(false); @@ -717,8 +722,14 @@ export default function ScheduleList() { setError('Failed to load schedules.'); setLoading(false); }); + api.getAllSemestersSemestersGet().then(setSemesters).catch(() => {}); }, []); + const semesterLabelById = useMemo( + () => new Map(semesters.map((s) => [s.semester_id, `${s.season} ${s.year}`])), + [semesters], + ); + function handleCreated(s: ScheduleResponse) { setSchedules((prev) => { if (prev.some((p) => p.schedule_id === s.schedule_id)) return prev; @@ -790,6 +801,7 @@ export default function ScheduleList() { navigate(`/schedules/${s.schedule_id}`)} onUpdate={handleUpdate} diff --git a/frontend/src/pages/Schedules.test.tsx b/frontend/src/pages/Schedules.test.tsx index 7df51ee..2d97c2a 100644 --- a/frontend/src/pages/Schedules.test.tsx +++ b/frontend/src/pages/Schedules.test.tsx @@ -86,6 +86,9 @@ describe('Schedules page', () => { getScheduleSchedulesScheduleIdGet: vi.fn().mockResolvedValue({ schedule_id: 42, name: 'Fall 2025', semester_id: 1, draft: false, campus: 1, active: true, }), + getSemesterSemestersSemesterIdGet: vi.fn().mockResolvedValue({ + semester_id: 1, season: 'Fall', year: 2025, active: true, + }), getAllCampusesCampusesGet: vi.fn().mockResolvedValue([ { campus_id: 1, name: 'Boston', active: true }, ]), diff --git a/frontend/src/pages/Schedules.tsx b/frontend/src/pages/Schedules.tsx index 49929e1..74924ad 100644 --- a/frontend/src/pages/Schedules.tsx +++ b/frontend/src/pages/Schedules.tsx @@ -60,12 +60,16 @@ function ScheduleView({ scheduleId, readOnly }: { scheduleId: number; readOnly?: const { me, meError } = useUser(); const [schedule, setSchedule] = useState(null); const [campusName, setCampusName] = useState(null); + const [semesterLabel, setSemesterLabel] = useState(null); useEffect(() => { const api = getAutomatedCourseSchedulerAPI(); api.getScheduleSchedulesScheduleIdGet(scheduleId) .then((s) => { setSchedule(s); + api.getSemesterSemestersSemesterIdGet(s.semester_id) + .then((sem) => setSemesterLabel(`${sem.season} ${sem.year}`)) + .catch(() => {}); // Resolve campus name for drawer filtering return api.getAllCampusesCampusesGet().then((campuses) => { const match = campuses.find((c) => c.campus_id === s.campus); @@ -128,8 +132,8 @@ function ScheduleView({ scheduleId, readOnly }: { scheduleId: number; readOnly?: {modeLabel}
- {schedule && ( -

Semester {schedule.semester_id}

+ {schedule && semesterLabel && ( +

{semesterLabel}

)} {meError && (