Skip to content

Commit 1b630c4

Browse files
committed
Add COPEN transition plans, with COPEN to CTFYS as the first
Starts #66. A COPEN student takes one common year and then transfers into a five-year programme, so what they actually study is published nowhere: COPEN's plan stops after year 1, and the target's plan assumes its own year 1. Picking COPEN and then a continuation programme now composes the real thing - COPEN year 1 followed by years 2-3 of the target, with the transition plan applied. THE PLAN RECORDS THE DIFFERENCE, NOT A COURSE LIST src/data/transitions.json holds one entry per (from, to) pair with three shapes of difference: exempt a target course the student does not take because a source course credits it - CTFYS drops SF1544, credited by COPEN's SF1546 added a course from neither published plan - SF1920 in P3 of year 2, taken with CELTE's second year moved a target course shifted to a later year, keeping its periods; unused today but the natural shape for "the target's own course, later" A hand-written combined course list would go stale the moment either programme is re-extracted, and silently - nothing would report that the composition no longer matched. Recording only the difference makes composeTransition a pure function of both programmes' current data. Year numbering needs no adjustment: the source contributes year 1 and the target years 2-3, so the composed years already read 1/2/3. WHY SF1920 AND NOT SF1922 A COPEN student has no probability course, and CTFYS teaches SF1922 in its year 1, which the transfer student was never present for. Issue #66 suggested taking SF1922 in P4 of year 2 with CTFYS's year-1 students. Taking SF1920 in P3 with CELTE's second year is better on both counts - the student does not wait until P4, and the credits land in the period the exemption emptied: SF1922 moved to y2 P4 15 / 14 / 10 / 21 SF1920 from CELTE in P3 15 / 14 / 16 / 15 Both total 60 hp, which is exactly why the load check has to be per period. The residual +-1 is structural: SF1544 carried 1 hp in P2 and 5 in P3, while SF1920 is 6 hp all in P3. An added course needs its own data, since CELTE is not a programme this app models. It is embedded in the plan in the same raw shape a data file uses and parsed with parseCourseEntries - the loader's own parser, split out for this purpose so the period/credit normalisation is not reimplemented. SF1920's values come from the live sources the extractor uses: 6 hp and P3 from CELTE's year-2 study plan, TEN1 and the SF1625 prerequisite from its course page. That prerequisite resolves inside the composed plan, because SF1625 is one of the nine COPEN courses, and the arrow is drawn. cosmeticsGroup is needed in practice: without it an added course falls to the default colour, which beside the light-tone palette reads as a bug rather than as "this came from elsewhere". SF1920 declares Matematik and renders identically to CTFYS's other maths courses. PREREQUISITE ARROWS FOLLOW THE COURSE ACTUALLY TAKEN A target course in years 2-3 states its prerequisites in the target's own terms: CTFYS's SF1683 and SI1146 both require SF1674. A transfer student never took SF1674 - they took SF1626, the same subject - so an arrow drawn to the letter starts from a course that is not in their plan and simply vanishes, leaving those courses looking as though they had no prerequisites at all. credited[] is therefore a list of objects, each able to say what it replaces, and redirectPrerequisites rewrites every reference through those equivalences. It is a general rule over the plan's data rather than a list of special cases. Exactly five CTFYS year-1 courses are referenced from years 2-3, and all five resolve: SF1674 -> SF1626 arrows into SF1683, SI1146 SF1672 -> SF1624 arrow into SF1681 DD1331 -> DD1310 arrow into DD1327 SG1112 -> SG1133 arrows into SE1055, SG1113 SK1104 -> SK1115 arrow into SH1014 Three fields feed the same map, so an equivalence is stated once: credited[].replaces, exempt[].creditedBy (SF1546 -> SF1544, which is why SF1546 carries no 'replaces'), and added[].substitutesFor. A reference that survives the rewrite but names a course outside the composed plan is reported - that means an equivalence is missing, and the symptom would otherwise be a silently absent arrow. validate-data checks the same invariant statically, so a missing equivalence fails in CI rather than only in the browser. COSMETICS HAVE TO BE MERGED The two files share no course codes, so rendering a composed chart from the target's cosmetics alone drew all nine COPEN courses in the default colour. mergeCosmetics merges by group name, so Matematik from both programmes becomes one legend row, with the target's colour winning on conflict: CTFYS has Ingenjörsämnen brick where COPEN has it turquoise. A group only the source has takes the first unused family rather than its own, because COPEN's Programmering is brick, which CTFYS already spends on Ingenjörsämnen. COPEN+CTFYS lands on exactly five families, which is the documented hard cap; an overflow is reported and those courses fall back to the default colour. FULL-TIME LOAD IS CHECKED PER PERIOD The same signal validate-data applies to the programme files, applied to the composition, because a swap can balance across a year while leaving individual periods lopsided and the year total hides it. Composed year 2 is 15/14/16/15; the ±1 is reported in the UI rather than corrected, since where the plan puts a course is the program director's call. THE CHART IS TITLED FOR BOTH PROGRAMMES "Civilingenjörsutbildning Öppen ingång -> Teknisk fysik (COPEN -> CTFYS)". The target's name is shortened because the qualification is identical on both sides and pure noise the second time. shortProgramName strips a fixed set of openers rather than the longest common word prefix, which looks more general but is wrong on the English names: "Degree Program in Engineering - Open Entrance" and "Degree Program in Engineering Physics" share "Degree Program in Engineering", which would reduce CTFYS to "Physics". An unrecognised name falls through unchanged. The composed code also reaches the export audit stamp, which is wanted; export filenames are fixed strings and unaffected. VALIDATION validate-data cross-checks each plan against both programmes in both directions. `credited` must match what the source actually teaches in those years - a course added to COPEN that nobody added to the plan would otherwise be dropped silently - and every exempt/moved code must exist in the target with the year the plan claims. verified: false warns, exactly as in programs.json. Verified in headless Chrome, COPEN + CTFYS: three years render; SF1544 and SF1922 both absent; SF1920 drawn at year 2 P3 in the Matematik colour, with the SF1625 prerequisite arrow; all nine COPEN courses present and coloured; every CTFYS year-1-only course absent; the notice line naming what was credited, dropped and added; and the load line showing 15/14/16/15. No console or page errors. COPEN alone is unchanged. Arrows verified against CTFYS alone: every arrow that started from one of the five year-1 courses now starts from its COPEN equivalent, and no arrow in the composed view starts from a CTFYS year-1 course. lint, tsc --noEmit, validate-data and the build are clean. WHAT IS NOT DONE Only COPEN -> CTFYS exists, and it is marked verified: false. The other targets (CINEK, CTMAT, ...) need their plans from @sellberg in whatever format they arrive; the schema above is the target to map them onto, and adding one is a data-only change.
1 parent bad9109 commit 1b630c4

7 files changed

Lines changed: 1224 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,141 @@ flags the rest.
574574
6. `npm run validate-data` and read the full-time warnings — they are the fastest
575575
check that the extraction is sane.
576576

577+
### Transition plans (`src/data/transitions.json`)
578+
579+
COPEN (Öppen ingång) students take one common year and then transfer into a
580+
five-year programme, so what they actually study is published nowhere: COPEN's
581+
plan stops after year 1, and the target's plan assumes its own year 1. A
582+
transition plan records the **difference** from the target's published plan, and
583+
`src/lib/transitions.ts` composes the view at runtime.
584+
585+
```json
586+
{ "from": "COPEN", "to": "CTFYS", "sourceYears": [1],
587+
"credited": [{ "code": "SF1626", "replaces": ["SF1674"] }, ""],
588+
"exempt": [{ "code": "SF1544", "creditedBy": "SF1546", "note": "" }],
589+
"added": [{ "code": "SF1920", "year": 2, "periodCredits": { "P3": 6 },
590+
"substitutesFor": "SF1922", "fromProgram": "CELTE",
591+
"cosmeticsGroup": "Matematik", "…": "" }],
592+
"moved": [{ "code": "", "fromYear": 1, "toYear": 2 }],
593+
"verified": false }
594+
```
595+
596+
Three shapes of difference, and the third is the one that took a correction:
597+
598+
- **`exempt`** — a target course the student does not take, because a source
599+
course credits it. CTFYS drops SF1544, credited by COPEN's SF1546.
600+
- **`moved`** — a target course shifted to a later year, keeping its periods.
601+
Nothing uses it today; kept because it is the natural shape for "take the
602+
target's own course, just later".
603+
- **`added`** — a course from *neither* published plan. COPEN teaches no
604+
probability course and CTFYS teaches SF1922 in its year 1, which the transfer
605+
student was never present for. The plan therefore adds **SF1920 in P3 of year
606+
2, taken with CELTE's second year** — same subject, and it lands in the period
607+
the exemption emptied.
608+
609+
The first draft of this plan used `moved` for SF1922 into year 2 P4, which is
610+
what issue #66 described. That is worse on the arithmetic *and* worse for the
611+
student: it means waiting until P4 for a course the CELTE offering provides in
612+
P3. Per-period load, composed year 2:
613+
614+
| variant | P1 | P2 | P3 | P4 |
615+
|---|---|---|---|---|
616+
| SF1922 moved to y2 P4 | 15 | 14 | **10** | **21** |
617+
| SF1920 from CELTE in P3 | 15 | 14 | 16 | 15 |
618+
619+
Both total 60 hp, which is exactly why the check has to be per period. The
620+
residual ±1 is structural: SF1544 carried 1 hp in P2 and 5 in P3, while SF1920 is
621+
6 hp all in P3.
622+
623+
**`credited[].replaces` is what makes the prerequisite arrows right**, and it is
624+
the reason `credited` is a list of objects rather than codes. A target course in
625+
years 2-3 states its prerequisites in the target's own terms — CTFYS's SF1683 and
626+
SI1146 both require SF1674 — but a transfer student never took SF1674; they took
627+
SF1626, the same subject. Drawn to the letter, the arrow would start from a course
628+
that is not in their plan and simply vanish, leaving those courses looking as
629+
though they had no prerequisites at all.
630+
631+
`redirectPrerequisites` therefore rewrites every reference through the plan's own
632+
equivalences. It is a general rule over the data, not a list of special cases:
633+
exactly five CTFYS year-1 courses are referenced from years 2-3, and all five
634+
resolve through it.
635+
636+
| target course referenced | credited course it becomes | arrows affected |
637+
|---|---|---|
638+
| SF1674 Flervariabelanalys | SF1626 | → SF1683, → SI1146 |
639+
| SF1672 Linjär algebra | SF1624 | → SF1681 |
640+
| DD1331 Grundläggande programmering | DD1310 | → DD1327 |
641+
| SG1112 Mekanik I | SG1133 | → SE1055, → SG1113 |
642+
| SK1104 Klassisk fysik | SK1115 | → SH1014 |
643+
644+
Three fields feed the same rewrite map, so an equivalence is stated once:
645+
`credited[].replaces`, `exempt[].creditedBy` (SF1546 → SF1544, which is why
646+
SF1546 carries no `replaces`), and `added[].substitutesFor`. A reference that
647+
survives the rewrite but names a course outside the composed plan is **reported**
648+
— that means an equivalence is missing, and the symptom would otherwise be a
649+
silently absent arrow. `validate-data` checks the same invariant statically, so it
650+
fails in CI rather than only in the browser.
651+
652+
**An `added` course needs its own data**, since it comes from a programme this
653+
app does not model — CELTE is not in `programs.json`. It is embedded in the plan
654+
in the same raw shape a data file uses and parsed with `parseCourseEntries`, the
655+
loader's own parser, so there is no second implementation of the period/credit
656+
normalisation. `cosmeticsGroup` is required in practice: without it the course
657+
falls to the default colour, which beside the light-tone palette reads as a bug
658+
rather than as "this came from elsewhere". SF1920's values were read from the
659+
live sources the extractor uses — 6 hp and P3 from CELTE's year-2 study plan,
660+
`TEN1` and the `SF1625` prerequisite from its course page — and that prerequisite
661+
resolves inside the composed plan, because SF1625 is one of the nine COPEN
662+
courses.
663+
664+
**Declarative on purpose.** A hand-written combined course list would go stale
665+
the moment either programme is re-extracted, and silently: nothing would say the
666+
composed plan no longer matches. Recording only the difference means the
667+
composition is a pure function of both programmes' current data.
668+
669+
**Year numbering needs no adjustment.** The source contributes year 1 and the
670+
target years 2-3, so the composed years already read 1/2/3. A `moved` course is
671+
re-stamped to its new year but **keeps its periods** — CTFYS's SF1922 runs in P4
672+
either way, because a transfer student sits the same P4 offering as CTFYS's own
673+
year-1 students, just a year later.
674+
675+
**The two cosmetics files share no course codes**, so a composed chart rendered
676+
from the target's file alone would draw all nine COPEN courses in the default
677+
colour. `mergeCosmetics` merges by group *name* (so "Matematik" from both becomes
678+
one legend row), the target's colour winning on conflict — CTFYS has
679+
Ingenjörsämnen = brick where COPEN has it turquoise. A group only the source has
680+
takes the first unused family rather than its own, since COPEN's `Programmering`
681+
is brick, which CTFYS already spends on Ingenjörsämnen. COPEN+CTFYS lands on
682+
exactly five families, which is the hard cap; an overflow is reported and those
683+
courses fall back to the default colour.
684+
685+
**The composition is checked against full-time load per period**, the same signal
686+
`validate-data` applies to the programme files, because a swap can balance across
687+
a year while leaving individual periods lopsided — and the year total hides it
688+
completely. COPEN→CTFYS now comes out 15/14/16/15 in year 2. Reported in the UI
689+
rather than corrected: where the plan puts a course is the program director's
690+
call.
691+
692+
**The chart is titled for both programmes**
693+
"Civilingenjörsutbildning Öppen ingång → Teknisk fysik (COPEN → CTFYS)". The
694+
target's name is shortened by `shortProgramName`, because the qualification is
695+
identical on both sides and pure noise the second time. That strips a **fixed**
696+
set of openers rather than the longest common word prefix, which looks more
697+
general but is wrong on the English names: "Degree Program in Engineering - Open
698+
Entrance" and "Degree Program in Engineering Physics" share "Degree Program in
699+
Engineering", which would reduce CTFYS to "Physics". An unrecognised name falls
700+
through unchanged.
701+
702+
The composed code also reaches the export audit stamp ("COPEN → CTFYS · build … ·
703+
date"), which is the wanted behaviour; export *filenames* are fixed strings and
704+
unaffected.
705+
706+
`validate-data` cross-checks each plan both ways against both programmes:
707+
`credited` must match what the source actually teaches in those years (a course
708+
added to COPEN that nobody added to the plan would otherwise be dropped
709+
silently), and every `exempt` / `moved` code must exist in the target with the
710+
year the plan claims. `verified: false` warns, exactly like `programs.json`.
711+
577712
### Types (`src/types/`)
578713

579714
- **`course.ts`**`Course`, `Period`, `CourseCredit`, `OptionGroup` interfaces

scripts/validate-data.mjs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,179 @@ function validateCosmetics(cosmetics, file, courseCodes) {
795795

796796
function round(x) { return Math.round(x * 100) / 100; }
797797

798+
799+
// ---------- transition plans ----------
800+
//
801+
// A COPEN student takes one year of COPEN and then two years of the programme
802+
// they transfer into. src/data/transitions.json records the *difference* from
803+
// the target's published plan rather than a combined course list, so these
804+
// checks exist to catch the plan drifting out of step with either programme's
805+
// data — which is exactly what a hand-written combined list would hide.
806+
function validateTransitions(plans, file, programs, coursesByProgram) {
807+
if (!Array.isArray(plans)) { err(file, 'expected an array of transition plans'); return; }
808+
const seen = new Set();
809+
810+
plans.forEach((plan, i) => {
811+
const ctx = `[${i}]`;
812+
if (!plan || typeof plan !== 'object') { err(file, `${ctx} not an object`); return; }
813+
const label = `${plan.from} -> ${plan.to}`;
814+
815+
for (const field of ['from', 'to']) {
816+
if (typeof plan[field] !== 'string' || !plan[field]) {
817+
err(file, `${ctx} '${field}' must be a non-empty string`);
818+
return;
819+
}
820+
if (!programs.some((p) => p?.code === plan[field])) {
821+
err(file, `${ctx} ${label}: '${plan[field]}' is not a program in programs.json`);
822+
}
823+
}
824+
if (plan.from === plan.to) err(file, `${ctx} ${label}: 'from' and 'to' are the same program`);
825+
826+
const key = `${plan.from}->${plan.to}`;
827+
if (seen.has(key)) err(file, `${ctx} duplicate plan for ${label}`);
828+
seen.add(key);
829+
830+
if (!Array.isArray(plan.sourceYears) || plan.sourceYears.length === 0
831+
|| plan.sourceYears.some((y) => !Number.isInteger(y) || y < 1)) {
832+
err(file, `${ctx} ${label}: 'sourceYears' must be a non-empty array of positive integers`);
833+
return;
834+
}
835+
if (!Array.isArray(plan.credited) || plan.credited.some((c) => !c || typeof c.code !== 'string')) {
836+
err(file, `${ctx} ${label}: 'credited' must be an array of { code, replaces? } objects`);
837+
return;
838+
}
839+
840+
const src = coursesByProgram.get(plan.from);
841+
const tgt = coursesByProgram.get(plan.to);
842+
if (!src || !tgt) {
843+
warn(file, `${ctx} ${label}: could not load course data for both programs — skipped the cross-checks`);
844+
return;
845+
}
846+
847+
// `credited` must match what the source programme actually teaches in those
848+
// years, in both directions. A course added to COPEN that nobody added here
849+
// would otherwise be silently dropped from the composed plan.
850+
const inSourceYears = new Set(
851+
[...src.values()].filter((c) => plan.sourceYears.includes(c.year)).map((c) => c.code));
852+
const creditedCodes = plan.credited.map((c) => c.code);
853+
for (const code of creditedCodes) {
854+
if (!inSourceYears.has(code)) {
855+
err(file, `${ctx} ${label}: credits '${code}', which is not in ${plan.from} year ${plan.sourceYears.join('/')}`);
856+
}
857+
}
858+
for (const code of inSourceYears) {
859+
if (!creditedCodes.includes(code)) {
860+
warn(file, `${ctx} ${label}: ${plan.from} year ${plan.sourceYears.join('/')} teaches '${code}' but the plan does not credit it — plan may be out of date`);
861+
}
862+
}
863+
864+
// `replaces` drives the prerequisite rewrite, so a wrong code here shows up
865+
// as a silently missing arrow rather than an error. Check both ends.
866+
const replacedBy = new Map();
867+
for (const credit of plan.credited) {
868+
for (const target of credit.replaces ?? []) {
869+
if (!tgt.has(target)) {
870+
err(file, `${ctx} ${label}: '${credit.code}' replaces '${target}', which ${plan.to} does not list`);
871+
continue;
872+
}
873+
if (!plan.sourceYears.includes(tgt.get(target).year)) {
874+
warn(file, `${ctx} ${label}: '${credit.code}' replaces '${target}', which ${plan.to} teaches in year ${tgt.get(target).year} — outside the years the source replaces`);
875+
}
876+
if (replacedBy.has(target)) {
877+
err(file, `${ctx} ${label}: '${target}' is replaced by both '${replacedBy.get(target)}' and '${credit.code}'`);
878+
}
879+
replacedBy.set(target, credit.code);
880+
}
881+
}
882+
883+
// Every target-programme prerequisite pointing into the years the source
884+
// replaces must have an equivalence, or its arrow vanishes from the chart.
885+
const targetProgram = programs.find((p) => p?.code === plan.to);
886+
const targetData = targetProgram?.dataFile ? loadJson(join(dataDir, targetProgram.dataFile)) : null;
887+
if (Array.isArray(targetData)) {
888+
const exemptCodes = new Set((plan.exempt ?? []).map((e) => e.code));
889+
for (const e of targetData) {
890+
if (!e?.code || e.type === 'optionGroup' || e.type === 'cohortMeta') continue;
891+
const year = tgt.get(e.code)?.year;
892+
if (year == null || plan.sourceYears.includes(year)) continue; // not in the student's plan
893+
if (exemptCodes.has(e.code)) continue;
894+
const pres = [...(e.prerequisitesCompleted ?? []), ...(e.prerequisitesParticipation ?? []), ...(e.prerequisites ?? [])];
895+
for (const pre of new Set(pres)) {
896+
const preYear = tgt.get(pre)?.year;
897+
if (preYear == null || !plan.sourceYears.includes(preYear)) continue;
898+
if (!replacedBy.has(pre)) {
899+
warn(file, `${ctx} ${label}: '${e.code}' requires '${pre}' from ${plan.to} year ${preYear}, which no credited course replaces — its prerequisite arrow will be missing`);
900+
}
901+
}
902+
}
903+
}
904+
905+
for (const ex of plan.exempt ?? []) {
906+
if (!ex || typeof ex.code !== 'string') { err(file, `${ctx} ${label}: 'exempt' entry needs a 'code'`); continue; }
907+
if (!tgt.has(ex.code)) {
908+
err(file, `${ctx} ${label}: exempts '${ex.code}', which ${plan.to} does not list`);
909+
}
910+
if (ex.creditedBy && !src.has(ex.creditedBy)) {
911+
err(file, `${ctx} ${label}: '${ex.code}' is credited by '${ex.creditedBy}', which ${plan.from} does not list`);
912+
}
913+
}
914+
915+
for (const mv of plan.moved ?? []) {
916+
if (!mv || typeof mv.code !== 'string') { err(file, `${ctx} ${label}: 'moved' entry needs a 'code'`); continue; }
917+
const course = tgt.get(mv.code);
918+
if (!course) {
919+
err(file, `${ctx} ${label}: moves '${mv.code}', which ${plan.to} does not list`);
920+
continue;
921+
}
922+
if (course.year !== mv.fromYear) {
923+
err(file, `${ctx} ${label}: moves '${mv.code}' from year ${mv.fromYear}, but ${plan.to} has it in year ${course.year}`);
924+
}
925+
if (plan.sourceYears.includes(mv.toYear)) {
926+
err(file, `${ctx} ${label}: moves '${mv.code}' into year ${mv.toYear}, which is taken in ${plan.from}`);
927+
}
928+
if (mv.toYear <= mv.fromYear) {
929+
warn(file, `${ctx} ${label}: moves '${mv.code}' from year ${mv.fromYear} to ${mv.toYear} — not a later year, check this is intended`);
930+
}
931+
}
932+
933+
// `added` courses come from a programme this app does not model (SF1920 is
934+
// taken from CELTE), so there is no third data file to cross-check against.
935+
// What can be checked is that the embedded course is internally coherent and
936+
// does not collide with the target's own plan.
937+
for (const ad of plan.added ?? []) {
938+
if (!ad || typeof ad.code !== 'string') { err(file, `${ctx} ${label}: 'added' entry needs a 'code'`); continue; }
939+
if (tgt.has(ad.code)) {
940+
err(file, `${ctx} ${label}: adds '${ad.code}', which ${plan.to} already lists — it would appear twice`);
941+
}
942+
if (src.has(ad.code)) {
943+
err(file, `${ctx} ${label}: adds '${ad.code}', which the student already took in ${plan.from}`);
944+
}
945+
if (typeof ad.totalCredits !== 'number' || ad.totalCredits <= 0) {
946+
err(file, `${ctx} ${label}: '${ad.code}' needs a positive 'totalCredits'`);
947+
}
948+
if (!Number.isInteger(ad.year) || plan.sourceYears.includes(ad.year)) {
949+
err(file, `${ctx} ${label}: '${ad.code}' has year ${ad.year}, which is not one of the target's own years`);
950+
}
951+
const sum = PERIODS_ORDERED.reduce((a, p) => a + (Number(ad.periodCredits?.[p]) || 0), 0);
952+
if (Math.abs(sum - ad.totalCredits) > CREDIT_TOLERANCE) {
953+
err(file, `${ctx} ${label}: '${ad.code}' periodCredits sum to ${sum} hp but totalCredits is ${ad.totalCredits}`);
954+
}
955+
if (ad.substitutesFor && !tgt.has(ad.substitutesFor)) {
956+
err(file, `${ctx} ${label}: '${ad.code}' substitutes for '${ad.substitutesFor}', which ${plan.to} does not list`);
957+
}
958+
for (const pre of [...(ad.prerequisitesCompleted ?? []), ...(ad.prerequisitesParticipation ?? [])]) {
959+
if (!src.has(pre) && !tgt.has(pre) && !(plan.added ?? []).some((o) => o.code === pre)) {
960+
warn(file, `${ctx} ${label}: '${ad.code}' requires '${pre}', which is in neither programme — the arrow will not be drawn`);
961+
}
962+
}
963+
}
964+
965+
if (plan.verified !== true) {
966+
warn(file, `${ctx} ${label}: not yet verified — confirm against the program director's transition plan`);
967+
}
968+
});
969+
}
970+
798971
// ---------- main ----------
799972

800973
// `--include <PROGRAM>=<path>` validates a data file that is NOT registered in
@@ -893,6 +1066,40 @@ if (programs != null) {
8931066
}
8941067
}
8951068

1069+
// Transition plans need both programmes' course data, so this runs after the
1070+
// per-program validation above has already loaded and checked them.
1071+
const transitionsFile = join(dataDir, 'transitions.json');
1072+
if (existsSync(transitionsFile)) {
1073+
const plans = loadJson(transitionsFile);
1074+
if (plans != null) {
1075+
console.log('• transitions');
1076+
const byProgram = new Map();
1077+
for (const plan of Array.isArray(plans) ? plans : []) {
1078+
for (const code of [plan?.from, plan?.to]) {
1079+
if (!code || byProgram.has(code)) continue;
1080+
const program = programs.find((p) => p?.code === code);
1081+
if (!program?.dataFile) continue;
1082+
const data = loadJson(join(dataDir, program.dataFile));
1083+
if (!Array.isArray(data)) continue;
1084+
const map = new Map();
1085+
for (const e of data) {
1086+
if (e?.code && e.type !== 'optionGroup' && e.type !== 'cohortMeta') {
1087+
// A year-spanning course has no top-level `year`; use its first.
1088+
const spanned = Object.keys(e.periodCredits ?? {})
1089+
.map((k) => /^Year(\d+)$/.exec(k)?.[1])
1090+
.filter(Boolean)
1091+
.map(Number)
1092+
.sort((a, b) => a - b);
1093+
map.set(e.code, { code: e.code, year: e.year ?? spanned[0] ?? null });
1094+
}
1095+
}
1096+
byProgram.set(code, map);
1097+
}
1098+
}
1099+
validateTransitions(plans, transitionsFile, programs, byProgram);
1100+
}
1101+
}
1102+
8961103
const periodsFile = join(dataDir, 'academic-periods.json');
8971104
const periods = loadJson(periodsFile);
8981105
if (periods != null) {

0 commit comments

Comments
 (0)