Skip to content

Commit 61cecfe

Browse files
authored
Merge pull request #70 from cohm/option-group-year-placement
Draw a picked option in the box it was picked from
2 parents 3daa68c + 8448e21 commit 61cecfe

3 files changed

Lines changed: 115 additions & 25 deletions

File tree

CLAUDE.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,36 @@ year the plan claims. `verified: false` warns, exactly like `programs.json`.
781781

782782
The selection modal lives in `src/components/OptionGroupModal.tsx`. Two earlier notes in this file and in `REVIEW.md` said it had been inlined into `TimelineVisualization.tsx` — it was, and then extracted again; grepping only `TimelineVisualization.tsx` for `kind` therefore suggests `minCredits` is unimplemented when it is not.
783783

784+
**A picked option is drawn where the box was, not where the data files it.** One
785+
course code can be offered by several boxes, and the boxes need not sit in the
786+
same study year. CTMAT offers SF1677/SF1678/SF1691 as the year-2 *villkorligt
787+
valfria* group **and** among the year-3 elective boxes, because a student picks
788+
one of them in year 2 and may take another as a free elective in year 3. The data
789+
file carries one entry per code, stamped `year: 2`, so resolving a pick straight
790+
from that entry drew Komplex analys in year 2 however you got there — the box the
791+
user clicked was not an input to the placement at all.
792+
793+
A picked option is therefore re-stamped to the year of the group it was picked
794+
from, keeping its own period layout. Keeping the layout is not a compromise: the
795+
group bar is only an *envelope* (its shape is the per-period maximum of its
796+
options), so an option whose shape differs from the box has always drawn its own
797+
— CTMAT's DD1351 picked in the "P2" box spans P1+P2. Only the year was ever
798+
wrong. Measured over all eight programmes, the year mismatch is CTMAT-only:
799+
exactly those three courses, all five cohorts, both spring boxes.
800+
801+
Selections are **mutually exclusive across groups** — a course is taken once, so
802+
picking it in one box releases it from any other. Without that, "the box that was
803+
clicked" has no single answer. This also bites the same-year case, which is far
804+
more common (21 codes across CTFYS and CTMAT sit in two or three boxes of the
805+
same year): picking MH1023 in the P3 box and then the P4 box now leaves the P3
806+
box an unfilled placeholder instead of quietly holding both.
807+
808+
The trap when changing any of this: the bar-drawing loop has a defensive
809+
`coursesInOptionGroups.has(code)` guard, and that set must keep meaning "an option
810+
somewhere, picked nowhere". Widening it to all option codes makes picked courses
811+
render their connector and exam markers with **no bars** — and neither `tsc` nor
812+
`eslint` sees it, because nothing about the types changes.
813+
784814
**Chart height must stay a pure function of the data.** `TimelineVisualization`
785815
used to seed its height from `svgRef.current.clientHeight` — the height its own
786816
previous render had written onto that node — and then only ever grew it

src/components/OptionGroupModal.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -153,16 +153,23 @@ export default function OptionGroupModal({
153153
};
154154

155155
const commit = () => {
156+
// A course is taken once, but it can be offered by several boxes — CTMAT
157+
// lists SF1677/SF1678/SF1691 both in the year-2 villkorligt valfria group
158+
// and in the year-3 elective boxes. Selections are therefore mutually
159+
// exclusive across groups: picking a course here releases it from whatever
160+
// other box held it, so it has exactly one place in the chart, which is the
161+
// box the user last clicked. Emptied groups are dropped rather than left as
162+
// an empty array, matching how this group is cleared below.
163+
const next: Record<string, string[]> = {};
164+
Object.entries(selectedOptionPerGroup).forEach(([name, codes]) => {
165+
if (name === optionGroup.name) return;
166+
const kept = codes.filter(code => !highlightedOptionCodes.includes(code));
167+
if (kept.length > 0) next[name] = kept;
168+
});
156169
if (highlightedOptionCodes.length > 0) {
157-
onSelectedOptionPerGroupChange({
158-
...selectedOptionPerGroup,
159-
[optionGroup.name]: highlightedOptionCodes,
160-
});
161-
} else {
162-
const next = { ...selectedOptionPerGroup };
163-
delete next[optionGroup.name];
164-
onSelectedOptionPerGroupChange(next);
170+
next[optionGroup.name] = highlightedOptionCodes;
165171
}
172+
onSelectedOptionPerGroupChange(next);
166173
onClose();
167174
onHighlightedOptionCodesChange([]);
168175
};

src/components/TimelineVisualization.tsx

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -833,26 +833,74 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
833833

834834
// Separate option groups and individual courses, and identify courses that should be hidden
835835
const optionGroups = courses.filter(isOptionGroup);
836-
// A course can be an option in SEVERAL groups: an elective that runs in both
837-
// P3 and P4 is offered by both period boxes. So membership and selection are
838-
// collected separately, and a course is hidden only when it is an option
839-
// somewhere and picked nowhere — otherwise picking it in the P3 box would be
840-
// undone by the P4 box still listing it as unpicked.
836+
// A course can be an option in SEVERAL groups, and those groups can sit in
837+
// DIFFERENT study years. CTMAT offers SF1677/SF1678/SF1691 twice: once as the
838+
// year-2 villkorligt valfria group, and again among the year-3 elective boxes,
839+
// because a student picks one of them in year 2 and may take another as a free
840+
// elective in year 3. The data file carries a single entry per course code,
841+
// stamped `year: 2`, so rendering a picked option from its own entry drew it
842+
// in year 2 whichever box had been clicked — picking Komplex analys in the
843+
// year-3 P3 box made it appear in year 2.
844+
//
845+
// So a picked option is re-stamped to the year of the group it was picked
846+
// from: the box the user actually clicked. Its own period layout is kept,
847+
// which is what every other option in these boxes already does — the group
848+
// bar is only an envelope (its shape is the per-period maximum of its
849+
// options), so DD1351 picked in the "P2" box has always drawn across P1+P2.
850+
// Only the year was ever wrong.
841851
const optionOf = new Set<string>();
842-
const pickedAnywhere = new Set<string>();
852+
const pickedIn = new Map<string, OptionGroup>();
843853
optionGroups.forEach(og => {
844-
const selectedCodes = selectedOptionPerGroup[og.name] ?? [];
845854
og.options.forEach(optionCode => optionOf.add(optionCode));
846-
selectedCodes.forEach(code => pickedAnywhere.add(code));
855+
(selectedOptionPerGroup[og.name] ?? []).forEach(code => {
856+
// First group wins. The modal keeps selections mutually exclusive across
857+
// groups, so this normally decides nothing; it only makes a hand-edited
858+
// or stale URL that picks one code in two boxes render one bar
859+
// deterministically rather than two bars sharing a code.
860+
if (!pickedIn.has(code)) pickedIn.set(code, og);
861+
});
847862
});
848-
const coursesInOptionGroups = new Set(
849-
[...optionOf].filter(code => !pickedAnywhere.has(code)));
850863

851-
// Filter courses to only include individual courses (not in option groups)
852-
const individualCourses = courses.filter(c => {
853-
if (isOptionGroup(c)) return false;
854-
return !coursesInOptionGroups.has((c as Course).code);
855-
}) as Course[];
864+
// Shift a course into `targetYear`, preserving its period layout and the
865+
// relative offsets of a course that spans study years.
866+
const placeCourseInYear = (course: Course, targetYear: number): Course => {
867+
const baseYear = course.credits.length
868+
? Math.min(...course.credits.map(c => c.year))
869+
: course.year;
870+
const delta = targetYear - baseYear;
871+
if (delta === 0) return course;
872+
const shiftYearKeys = <T,>(m?: Record<number, T>): Record<number, T> | undefined => {
873+
if (!m) return undefined;
874+
const out: Record<number, T> = {};
875+
Object.entries(m).forEach(([y, v]) => { out[Number(y) + delta] = v; });
876+
return out;
877+
};
878+
return {
879+
...course,
880+
year: course.year + delta,
881+
credits: course.credits.map(c => ({ ...c, year: c.year + delta })),
882+
examsByYear: shiftYearKeys(course.examsByYear),
883+
reexamsByYear: shiftYearKeys(course.reexamsByYear),
884+
};
885+
};
886+
887+
// Courses hidden because they are an option somewhere and picked nowhere. A
888+
// course picked in one box must NOT land in this set: the bar-drawing loop
889+
// uses it as a defensive "skip option courses" guard, so adding picked ones
890+
// here silently drops their bars while still drawing their connector and exam
891+
// markers.
892+
const coursesInOptionGroups = new Set(
893+
[...optionOf].filter(code => !pickedIn.has(code)));
894+
895+
// Picked options are re-emitted in file order, so the stacking lanes within a
896+
// period are unaffected.
897+
const individualCourses = courses.flatMap<Course>(c => {
898+
if (isOptionGroup(c)) return [];
899+
const course = c as Course;
900+
const group = pickedIn.get(course.code);
901+
if (group) return [placeCourseInYear(course, group.year)];
902+
return coursesInOptionGroups.has(course.code) ? [] : [course];
903+
});
856904
// Lookup map built once and reused everywhere a Course needs to be
857905
// resolved by code (prereq routing, focus mode, dispatch context). This
858906
// turns several per-arrow / per-bar O(n) `find(...)` scans into O(1).
@@ -2935,8 +2983,13 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
29352983
const og = courses.filter(isOptionGroup).find(c => (c as OptionGroup).name === ogName) as OptionGroup | undefined;
29362984
return og ? og.year === focusYear : false;
29372985
}
2938-
// Otherwise it's a course code
2939-
const c = courses.find(cc => isCourse(cc) && (cc as Course).code === id) as Course | undefined;
2986+
// Otherwise it's a course code. Resolve through the rendered-course map
2987+
// first: an option picked from a group is drawn in that group's year,
2988+
// which the raw entry in `courses` does not know about, so scanning
2989+
// `courses` would dim a year-3 pick of a course the data files under
2990+
// year 2. Fall back to the raw scan before the map is populated.
2991+
const c = dispatchCtxRef.current.individualCoursesByCode.get(id)
2992+
?? (courses.find(cc => isCourse(cc) && (cc as Course).code === id) as Course | undefined);
29402993
if (!c) return false;
29412994
return c.credits.some((cr: CourseCredit) => Number(cr.year) === focusYear);
29422995
};

0 commit comments

Comments
 (0)