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/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 && ( +