From 271604ab9608c65099da876752677975dc4e7807 Mon Sep 17 00:00:00 2001 From: Zaid Ahmad <109442753+zaidahmad16@users.noreply.github.com> Date: Tue, 26 May 2026 13:23:49 -0400 Subject: [PATCH 1/2] feat: add Business concentrations/streams, fix layouts, update calendar year --- backend/main.py | 43 +++++++++++++++++---------- frontend/app/map/components/Notes.jsx | 2 +- frontend/app/map/page.js | 33 +++++++++++++++++--- frontend/app/page.js | 8 ++--- 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/backend/main.py b/backend/main.py index 5d267d0..f827449 100644 --- a/backend/main.py +++ b/backend/main.py @@ -261,30 +261,43 @@ def get_program(request: Request, program_id: int): """, (program_id,)) edges = cur.fetchall() - # for concentrations, merge in the base degree requirements so the full - # program path is visible alongside the concentration-specific courses + # for concentrations and streams, merge the full base degree so the + # complete program path is visible alongside the specific courses base_reqs = [] base_edges = [] - if degree.lower().startswith("concentration in"): - cur.execute(""" - SELECT program_id, degree FROM programs - WHERE dept_id = %s - AND program_id != %s - AND degree ILIKE ANY(ARRAY['%%B.A. Honours%%', '%%B.Sc. Honours%%', '%%B.Eng. Honours%%', - '%%Bachelor%%Honours%%']) - ORDER BY program_id - LIMIT 1 - """, (dept_id, program_id)) + deg_lower = degree.lower() + is_concentration = deg_lower.startswith("concentration in") + is_stream = deg_lower.startswith("stream in") + if is_concentration or is_stream: + if is_stream: + # streams belong to the International Business Honours degree + base_query = """ + SELECT program_id, degree FROM programs + WHERE dept_id = %s + AND program_id != %s + AND degree ILIKE '%%International Business%%Honours%%' + ORDER BY program_id + LIMIT 1 + """ + else: + # concentrations belong to the first honours degree in the department + base_query = """ + SELECT program_id, degree FROM programs + WHERE dept_id = %s + AND program_id != %s + AND degree ILIKE ANY(ARRAY['%%B.A. Honours%%', '%%B.Sc. Honours%%', '%%B.Eng. Honours%%', + '%%Bachelor%%Honours%%']) + ORDER BY program_id + LIMIT 1 + """ + cur.execute(base_query, (dept_id, program_id)) base_row = cur.fetchone() if base_row: base_prog_id = base_row[0] - # only pull year 1 and 2 courses from the base program — year 3+ are - # largely electives that the concentration already covers cur.execute(""" SELECT type, courses, credits, description, layout_col, layout_row FROM program_requirements WHERE program_id = %s - AND layout_col < 2 ORDER BY req_id """, (base_prog_id,)) base_reqs = cur.fetchall() diff --git a/frontend/app/map/components/Notes.jsx b/frontend/app/map/components/Notes.jsx index 0d2ae76..9405152 100644 --- a/frontend/app/map/components/Notes.jsx +++ b/frontend/app/map/components/Notes.jsx @@ -80,7 +80,7 @@ export const Notes = ({ notes = [], degree, open: controlledOpen, onOpenChange }

- Course data reflects the 2025–2026 Undergraduate Calendar. Always verify with the official Carleton University calendar before enrolling. + Course data reflects the 2026–2027 Undergraduate Calendar. Always verify with the official Carleton University calendar before enrolling.

Not affiliated with or endorsed by Carleton University. diff --git a/frontend/app/map/page.js b/frontend/app/map/page.js index ed00cc4..40ef5b3 100644 --- a/frontend/app/map/page.js +++ b/frontend/app/map/page.js @@ -37,6 +37,20 @@ const shortenProgram = (degree = '') => { return c.length > 30 ? c.slice(0, 28) + '…' : c } + // Standalone "Concentration in X (N credits)" — show just the topic + const concM = degree.match(/^Concentration\s+in\s+(.+?)(?:\s*\(|$)/i) + if (concM) { + const c = concM[1].trim() + return c.length > 30 ? c.slice(0, 28) + '…' : c + } + + // Standalone "Stream in X (N credits)" — show just the topic + const streamM = degree.match(/^Stream\s+in\s+(.+?)(?:\s*\(|$)/i) + if (streamM) { + const c = streamM[1].trim() + return c.length > 30 ? c.slice(0, 28) + '…' : c + } + const specifics = [ [/artificial intelligence|machine learning/i, 'AI & Machine Learning'], [/cybersecurity|cyber security/i, 'Cybersecurity'], @@ -51,8 +65,8 @@ const shortenProgram = (degree = '') => { ] for (const [re, label] of specifics) if (re.test(degree)) return label - // Cognitive Science concentrations — extract the concentration name - const cogM = degree.match(/concentration\s+in\s+(.+?)(?:\s{2,}|$)/i) + // Any remaining "Concentration in X" embedded in a longer degree name + const cogM = degree.match(/concentration\s+in\s+(.+?)(?:\s*\(|\s{2,}|$)/i) if (cogM) { const c = cogM[1].trim() return c.length > 30 ? c.slice(0, 28) + '…' : c @@ -85,6 +99,10 @@ export default function MapPage() { const [nodes, setNodes] = useState([]) const [edges, setEdges] = useState([]) const [selectedNode, setSelectedNode] = useState(null) + const pillScrollRef = useRef(null) + const scrollPills = (dir) => { + if (pillScrollRef.current) pillScrollRef.current.scrollBy({ left: dir * 200, behavior: 'smooth' }) + } const [showPicker, setShowPicker] = useState(false) const [showNotes, setShowNotes] = useState(false) const [showCompare, setShowCompare] = useState(false) @@ -234,7 +252,12 @@ export default function MapPage() { background: 'var(--color-paper)', }}> - + {/* nav bar */}

Program -
+ +
{programs.length === 0 ? ( Loading programs… @@ -429,6 +453,7 @@ export default function MapPage() { )) )}
+
)}
diff --git a/frontend/app/page.js b/frontend/app/page.js index 736b0ea..9cbeb19 100644 --- a/frontend/app/page.js +++ b/frontend/app/page.js @@ -145,7 +145,7 @@ export default function Home() { textTransform: 'uppercase', marginBottom: 'var(--space-sm)', }}> - 2025–2026 Undergraduate Calendar + 2026–2027 Undergraduate Calendar

{stats.departments - ? `All ${stats.departments} departments · ${stats.programs} programs · 2025–2026 Undergraduate Calendar` - : 'Every department and program · 2025–2026 Undergraduate Calendar'} + ? `All ${stats.departments} departments · ${stats.programs} programs · 2026–2027 Undergraduate Calendar` + : 'Every department and program · 2026–2027 Undergraduate Calendar'}

Not affiliated with or endorsed by Carleton University. - Course data: 2025–2026 Undergraduate Calendar. + Course data: 2026–2027 Undergraduate Calendar.

From 24aa7f5d0ee21da74870da4d6c409b4961e0bfb6 Mon Sep 17 00:00:00 2001 From: Zaid Ahmad <109442753+zaidahmad16@users.noreply.github.com> Date: Tue, 26 May 2026 13:47:57 -0400 Subject: [PATCH 2/2] feat: mobile layout, compare programs, and UX improvements --- frontend/app/map/components/CompareModal.jsx | 404 +++++++++++-------- frontend/app/map/components/CoursePanel.jsx | 275 +++++++------ frontend/app/map/components/MapMenubar.jsx | 21 +- frontend/app/map/page.js | 81 +++- 4 files changed, 481 insertions(+), 300 deletions(-) diff --git a/frontend/app/map/components/CompareModal.jsx b/frontend/app/map/components/CompareModal.jsx index 41098e5..7c5a8e2 100644 --- a/frontend/app/map/components/CompareModal.jsx +++ b/frontend/app/map/components/CompareModal.jsx @@ -1,37 +1,64 @@ -/* Hallmark · component: CompareModal · genre: modern-minimal · theme: custom (Carleton) - * states: closed · selecting · loading · ready (3-col diff) - * pre-emit critique: P5 H5 E5 S5 R5 V5 - */ - 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { API } from '../utils/constants' -const getCourses = (map) => new Set( - (map?.requirements || []) - .filter(r => r.courses?.length > 0) - .flatMap(r => r.courses) +// ── helpers ──────────────────────────────────────────────────────────────────── + +const getCodes = (map) => new Set( + (map?.requirements || []).filter(r => r.courses?.length).flatMap(r => r.courses) ) +// build code → course name from the requirement descriptions (single-course reqs only) +const getNameMap = (map) => { + const m = {} + for (const req of (map?.requirements || [])) { + if (req.courses?.length === 1 && req.description) + m[req.courses[0]] = req.description + } + return m +} + +const totalCredits = (map) => + (map?.requirements || []).reduce((s, r) => s + (r.credits ?? 0), 0) + +const pct = (a, b) => { + const union = new Set([...a, ...b]).size + if (!union) return 0 + return Math.round((new Set([...a].filter(c => b.has(c))).size / union) * 100) +} + +// ── CompareModal ─────────────────────────────────────────────────────────────── + export const CompareModal = ({ open, onClose, departments }) => { - const [deptId, setDeptId] = useState('') - const [progIdA, setProgIdA] = useState('') - const [progIdB, setProgIdB] = useState('') - const [programs, setPrograms] = useState([]) - const [mapA, setMapA] = useState(null) - const [mapB, setMapB] = useState(null) - const [loading, setLoading] = useState(false) + const [deptA, setDeptA] = useState('') + const [deptB, setDeptB] = useState('') + const [progsA, setProgsA] = useState([]) + const [progsB, setProgsB] = useState([]) + const [progIdA, setProgIdA] = useState('') + const [progIdB, setProgIdB] = useState('') + const [mapA, setMapA] = useState(null) + const [mapB, setMapB] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!open) return + const h = e => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', h) + return () => window.removeEventListener('keydown', h) + }, [open, onClose]) + + useEffect(() => { + if (!deptA) { setProgsA([]); setProgIdA(''); setMapA(null); return } + setProgIdA(''); setMapA(null) + fetch(`${API}/programs?dept=${deptA}`).then(r => r.json()).then(setProgsA) + }, [deptA]) useEffect(() => { - if (!deptId) { setPrograms([]); setProgIdA(''); setProgIdB(''); return } - setPrograms([]) - setProgIdA('') - setProgIdB('') - setMapA(null) - setMapB(null) - fetch(`${API}/programs?dept=${deptId}`).then(r => r.json()).then(setPrograms) - }, [deptId]) + if (!deptB) { setProgsB([]); setProgIdB(''); setMapB(null); return } + setProgIdB(''); setMapB(null) + fetch(`${API}/programs?dept=${deptB}`).then(r => r.json()).then(setProgsB) + }, [deptB]) useEffect(() => { if (!progIdA || !progIdB) { setMapA(null); setMapB(null); return } @@ -44,21 +71,31 @@ export const CompareModal = ({ open, onClose, departments }) => { .catch(() => setLoading(false)) }, [progIdA, progIdB]) - useEffect(() => { - if (!open) return - const h = e => { if (e.key === 'Escape') onClose() } - window.addEventListener('keydown', h) - return () => window.removeEventListener('keydown', h) - }, [open, onClose]) + const swap = useCallback(() => { + setDeptA(deptB); setDeptB(deptA) + setProgIdA(progIdB); setProgIdB(progIdA) + setProgsA(progsB); setProgsB(progsA) + setMapA(mapB); setMapB(mapA) + }, [deptA, deptB, progIdA, progIdB, progsA, progsB, mapA, mapB]) if (!open) return null - const codesA = getCourses(mapA) - const codesB = getCourses(mapB) - const onlyA = mapA ? [...codesA].filter(c => !codesB.has(c)) : [] - const inBoth = mapA ? [...codesA].filter(c => codesB.has(c)) : [] - const onlyB = mapB ? [...codesB].filter(c => !codesA.has(c)) : [] + const codesA = getCodes(mapA) + const codesB = getCodes(mapB) + const namesA = getNameMap(mapA) + const namesB = getNameMap(mapB) + const nameMap = { ...namesB, ...namesA } + + const onlyA = mapA ? [...codesA].filter(c => !codesB.has(c)) : [] + const inBoth = mapA ? [...codesA].filter(c => codesB.has(c)) : [] + const onlyB = mapB ? [...codesB].filter(c => !codesA.has(c)) : [] const hasDiff = mapA && mapB && !loading + const overlap = hasDiff ? pct(codesA, codesB) : null + + const credA = hasDiff ? totalCredits(mapA).toFixed(1) : null + const credB = hasDiff ? totalCredits(mapB).toFixed(1) : null + + const bothSelected = progIdA && progIdB return ( <> @@ -70,7 +107,6 @@ export const CompareModal = ({ open, onClose, departments }) => { background: 'rgba(0,0,0,0.32)', backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)', - animation: 'backdrop-in 180ms var(--ease-out) both', }} /> @@ -88,8 +124,8 @@ export const CompareModal = ({ open, onClose, departments }) => { aria-label="Compare programs" style={{ pointerEvents: 'auto', - width: 'min(680px, 100%)', - maxHeight: 'min(640px, calc(100vh - 32px))', + width: 'min(800px, 100%)', + maxHeight: 'min(700px, calc(100vh - 32px))', background: 'var(--color-paper)', borderRadius: 10, border: '1px solid var(--color-rule)', @@ -98,113 +134,139 @@ export const CompareModal = ({ open, onClose, departments }) => { flexDirection: 'column', overflow: 'hidden', fontFamily: 'var(--font-body)', - animation: 'picker-in 200ms var(--ease-out) both', }} >
- {/* ── Header ──────────────────────────────────────────────────────── */} + {/* ── Header ── */}
- - Compare programs - +
+ + Compare programs + + {overlap !== null && ( + = 50 ? 'var(--color-accent-soft)' : 'var(--color-paper-2)', + color: overlap >= 50 ? 'var(--color-accent)' : 'var(--color-ink-3)', + borderRadius: 'var(--radius-pill)', + padding: '2px 10px', + fontSize: 11, fontWeight: 600, + }}> + {overlap}% overlap + + )} +
- {/* ── Selectors ───────────────────────────────────────────────────── */} + {/* ── Selectors ── */}
- ({ value: String(d.dept_id), label: d.name })), - ]} - /> - - {deptId && ( -
+
+ + {/* Program A */} +
({ value: String(p.program_id), label: p.degree })), - ]} + label="Department A" + value={deptA} + onChange={setDeptA} + options={[{ value: '', label: 'Select department…' }, ...departments.map(d => ({ value: String(d.dept_id), label: d.name }))]} /> - vs + {deptA && ( + ({ value: String(p.program_id), label: p.degree }))]} + /> + )} +
+ + {/* Swap button */} + + + {/* Program B */} +
({ value: String(p.program_id), label: p.degree })), - ]} + label="Department B" + value={deptB} + onChange={setDeptB} + options={[{ value: '', label: 'Select department…' }, ...departments.map(d => ({ value: String(d.dept_id), label: d.name }))]} /> + {deptB && ( + ({ value: String(p.program_id), label: p.degree }))]} + /> + )}
- )} +
- {/* ── Diff body ───────────────────────────────────────────────────── */} + {/* ── Diff body ── */}
- {!deptId && } - {deptId && (!progIdA || !progIdB) && } + {!deptA && !deptB && } + {(deptA || deptB) && !bothSelected && !loading && } {loading && } {hasDiff && ( -
+
@@ -212,22 +274,24 @@ export const CompareModal = ({ open, onClose, departments }) => { )}
- {/* ── Footer ──────────────────────────────────────────────────────── */} + {/* ── Footer ── */} {hasDiff && (
- {onlyA.length} unique to A + {onlyA.length} unique to A · {inBoth.length} shared · - {onlyB.length} unique to B + {onlyB.length} unique to B + + A: {credA} cr · B: {credB} cr +
)}
@@ -236,14 +300,70 @@ export const CompareModal = ({ open, onClose, departments }) => { ) } -// ── SelectField ──────────────────────────────────────────────────────────────── +// ── DiffCol ─────────────────────────────────────────────────────────────────── + +const DiffCol = ({ title, subtitle, credits, codes, nameMap, accent, bg, center }) => ( +
+ {/* column header */} +
+
+ {title} +
+
+ {subtitle} +
+ {credits && ( +
+ {credits} credits total +
+ )} +
+ + {/* divider */} +
+ + {codes.length === 0 + ?
None
+ : ( +
+ {codes.map(code => ( +
+ + {code} + + {nameMap[code] && ( + + {nameMap[code]} + + )} +
+ ))} +
+ ) + } +
+) + +// ── SelectField ─────────────────────────────────────────────────────────────── const SelectField = ({ label, value, onChange, options }) => (
- + {label}
@@ -252,14 +372,11 @@ const SelectField = ({ label, value, onChange, options }) => ( onChange={e => onChange(e.target.value)} style={{ appearance: 'none', WebkitAppearance: 'none', - border: '1px solid var(--color-rule)', - borderRadius: 'var(--radius-input)', - padding: '6px 28px 6px 10px', - fontSize: 'var(--text-sm)', + border: '1px solid var(--color-rule)', borderRadius: 'var(--radius-input)', + padding: '6px 28px 6px 10px', fontSize: 'var(--text-sm)', fontFamily: 'var(--font-body)', color: value ? 'var(--color-ink)' : 'var(--color-ink-3)', - background: 'var(--color-paper-2)', - cursor: 'pointer', outline: 'none', width: '100%', + background: 'var(--color-paper-2)', cursor: 'pointer', outline: 'none', width: '100%', transition: 'border-color var(--dur-short) var(--ease-out)', }} onFocus={e => { e.target.style.borderColor = 'var(--color-accent)' }} @@ -269,63 +386,18 @@ const SelectField = ({ label, value, onChange, options }) => (
) -// ── DiffCol ──────────────────────────────────────────────────────────────────── - -const DiffCol = ({ title, subtitle, codes, accent, bg }) => ( -
-
-
- {title} -
-
- {subtitle} -
-
- {codes.length === 0 - ?
None
- : ( -
- {codes.map(code => ( - - {code} - - ))} -
- ) - } -
-) - -// ── Placeholder ──────────────────────────────────────────────────────────────── +// ── Placeholder ─────────────────────────────────────────────────────────────── const Placeholder = ({ text }) => (
{text}
diff --git a/frontend/app/map/components/CoursePanel.jsx b/frontend/app/map/components/CoursePanel.jsx index 1d35b33..b331563 100644 --- a/frontend/app/map/components/CoursePanel.jsx +++ b/frontend/app/map/components/CoursePanel.jsx @@ -1,153 +1,188 @@ -/* course detail panel, always in the DOM and slides in from the right */ -import { useEffect } from 'react' +/* course detail panel — right sidebar on desktop, bottom drawer on mobile */ -export const CoursePanel = ({ node, onClose }) => { +export const CoursePanel = ({ node, onClose, isMobile }) => { const isOpen = node != null && !node.data?.isElective - useEffect(() => { - if (!isOpen) return - const handler = (e) => { if (e.key === 'Escape') onClose() } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [isOpen, onClose]) const { code, name, description, credit, prerequisites, offerings } = isOpen ? node.data : {} - // visibility is delayed on close so the element leaves the tab order only after the slide animation finishes - const panelStyle = isOpen + const panelStyle = isMobile + ? isOpen + ? { + transform: 'translateY(0)', + visibility: 'visible', + pointerEvents: 'auto', + transition: 'transform var(--dur-medium) var(--ease-out)', + boxShadow: '0 -4px 32px rgba(0,0,0,0.12)', + } + : { + transform: 'translateY(100%)', + visibility: 'hidden', + pointerEvents: 'none', + transition: 'transform var(--dur-medium) var(--ease-out), visibility 0s linear 280ms', + boxShadow: 'none', + } + : isOpen + ? { + transform: 'translateX(0)', + visibility: 'visible', + pointerEvents: 'auto', + transition: 'transform var(--dur-medium) var(--ease-out)', + boxShadow: '-4px 0 24px rgba(0,0,0,0.08)', + } + : { + transform: 'translateX(100%)', + visibility: 'hidden', + pointerEvents: 'none', + transition: 'transform var(--dur-medium) var(--ease-out), visibility 0s linear 280ms', + boxShadow: 'none', + } + + const panelPosition = isMobile ? { - transform: 'translateX(0)', - visibility: 'visible', - pointerEvents: 'auto', - transition: 'transform var(--dur-medium) var(--ease-out)', - boxShadow: '-4px 0 24px rgba(0,0,0,0.08)', + bottom: 0, left: 0, right: 0, + width: '100%', + height: '65vh', + borderRadius: '12px 12px 0 0', + borderTop: '1px solid var(--color-rule)', } : { - transform: 'translateX(100%)', - visibility: 'hidden', - pointerEvents: 'none', - transition: 'transform var(--dur-medium) var(--ease-out), visibility 0s linear 280ms', - boxShadow: 'none', + right: 0, top: 0, + width: 340, + height: '100vh', + borderLeft: '1px solid var(--color-rule)', } return ( <> - {isOpen && ( + {isOpen && ( +