Subject Units — invert the hours model (hours on the main subject) — implementation plan¶
Spec: 2026-07-20-subject-units-iteration-1-design.md (Approved 2026-07-20).
Baseline: dev tree as of 2026-07-20, with subject-units landed (c356e77, migration
20260716092919_subject_units committed). File/line anchors come from the design investigation of
this tree and should be reconfirmed at implementation (they drift).
Core inversion recap. A main subject (isContainer ⟺ has ≥1 unit child) is now an ordinary
leaf-with-cells: it owns its per-(grade) CurriculumSubjectHours cells (weeklyHours,
gradingScaleId, periodDuration, window) and may carry levelId/roomIds. A unit
(parentSubjectId set) carries only name/id/code + criteria (+ criteriaGradingScaleId
iff criteriaLevel = UNIT); it has no hours/scale/level/rooms/periodDuration. Grades stay per-unit
but the subject-mark scale resolves from the main subject. No schema/data migration — every
column already exists; no environment holds container/unit rows. This iteration is mostly removal
of the 2026-07-14 container-Σ / ∪-offered / timetable-Σ machinery.
Milestones are independently shippable in order. Within a milestone, tasks are dependency-ordered.
Verification commands run only when Fabio asks (per feedback_no_auto_build).
M0 — Orient: sweep the surfaces that reference the old ownership¶
Before editing, git grep these symbols so nothing is missed (each is a removal or flip site):
effectiveHours, effectiveWeeklyHours, computeEffectiveHours, CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS,
sumUnitHoursByGrade, containerHours, anchorOffersGrade, findSubjectGradeCell, scaleSubjectId,
parentSubjectId. Note the setup-summary projection reuses buildGridBody (curriculum.service.ts
comment ~127) — dropping effectiveHours must not break the summary shape or its spec; confirm the
summary doesn't assert effectiveHours.
M1 — Contract + validation + grid authoring & read (curriculum module)¶
Task 1 — Error codes (add one, remove one) + label coverage¶
src/common/constants/error-codes.ts:- Add
CURRICULUM_UNIT_HAS_CELLS(400). Params:{ subjectName, field }(mirror the removed container code's params). Classify inERROR_TEXT_PARAMS/ERROR_INTERNAL_PARAMSas the container code was. - Remove
CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS(enum member + any classification entry). src/common/i18n/error-messages.catalog.ts: addERROR_MESSAGES{en_US,it_IT}forCURRICULUM_UNIT_HAS_CELLS("A unit carries only criteria — it cannot have hours, a grading scale, a level, rooms, or a period duration." / it_IT); remove the container-code entry. Build-enforced byerror-messages.drift.spec.ts.src/common/constants/error-examples.ts: add aCURRICULUM_UNIT_HAS_CELLSexample row; remove the container-code row.error-examples.drift.spec.tsmust pass.- Label coverage: no DTO input field is added or removed that changes
FIELD_LABELS(the unit DTO's removed props were validated inputs — dropping their class-validator decorators means their labels become dead and must be removed fromfield-labels.catalog.tsto keeplabel-coverage.drift.spec.tsgreen). Confirm which unit props had labels (hours,levelId,roomIds,gradingScaleId,periodDurationon the unit DTO) and remove any that no longer decorate a validated field.criteriaLevel/units/unitIds/curriculumSubjectIdlabels stay.
Task 2 — DTOs + structural interface¶
src/curriculum/dto/scopes/curriculum-configuration.dto.ts:CurriculumUnitInputDto— tighten to criteria-only: keepid?,name,code?,criteria?,criteriaGradingScaleId?. Removehours,gradingScaleId,levelId,roomIds,periodDuration. (Server rejects them viaCURRICULUM_UNIT_HAS_CELLSif sent — see Task 3 — but the DTO should also stop declaring them so they're not silently whitelisted.)CurriculumSubjectInputDto— unchanged (keepsunits?,criteriaLevel?, and its ownhours/scale/level/rooms — the main subject now uses them).CurriculumSubjectResponseDto— removeeffectiveHours(and anyeffectiveWeeklyHourssub-shape it referenced). Containerhoursis its own cells; unithoursprojects[]. KeepisContainer,criteriaLevel,units[].src/curriculum/interfaces/bulk-sync.interfaces.ts: trimGridSubjectInput's unit-carried fields to match the DTO (the sync reads this structural type — keep in lockstep). The container still carries hours/scale/level/rooms onGridSubjectInput.
Task 3 — Grid validation (flip the cell-owner check)¶
src/curriculum/curriculum.validation.ts — validateSubjectList (~234):
- Remove the container-has-cells rejection (~269-275,
CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS). A container now validates its own cells exactly like a leaf: callassertHoursCells(s, …)on the container regardless of whether it has units. - Add the unit guard: when recursing into
units(allowUnits = falsepath, ~264-316), reject any unit that carrieshours(taught cells),levelId,roomIds,gradingScaleId, orperiodDuration→CURRICULUM_UNIT_HAS_CELLS(params{ subjectName: unit.name, field }). Units validate only theircriteriaplacement. - Keep
criteriaLevelplacement checks +CURRICULUM_CRITERIA_LEVEL_MISMATCH(~278-307) and the one-level-deep structural guard. - Note:
assertHoursCells/isTaughtCell/window validation are unchanged — they now run against the container's own cells (which is what they were always built for).
Task 4 — Grid family-sync (units shed cells; container keeps them)¶
src/curriculum/curriculum-structure-sync.ts:
- The parent→unit linking (
syncUnits,syncSubjectListwithparentSubjectId, ~769-861) stays — units are still nested rows under the container. What changes is what the unit row carries: - Unit
onCreate/onUpdatemust not writegradingScaleId/levelId/periodDuration(leave null) and the batched leaf pass (syncLeafChildren/syncedSubjectsfan-out) must not write a unit'shours/rooms(units have none). Confirm the batched pass keys off the DTO shape — since the tightenedGridSubjectInputno longer carries those on a unit, the writes naturally no-op, but verify no unconditionalhours: []reset or rooms clear misfires for a unit vs. a leaf/container. - The container row now writes its own
hours/gradingScaleId/levelId/rooms/periodDurationthrough the same leaf pass it already uses for a leaf — no special-casing needed (a container is a top-level/track/block subject with children; its own-cell write path is the leaf path). criteriaLevelOf(~781) unchanged (container → SUBJECT default, leaf/unit → null).src/curriculum/curriculum.queries.ts—curriculumGridInclude(~57): keepparentSubjectId: nulltop-level filter + nestedchildSubjects. Trim the nestedchildSubjectsselect to drophours/rooms/level/gradingScale/periodDuration(units carry none) and keepcriteria+criteriaGradingScale. The top-level subject select keeps hours/scale/level/rooms (container + leaf both use them). Keep theallGridSubjectswalker guard (curriculum-structure-sync.ts:163-172).
Task 5 — Grid read mapper (main subject = own cells; unit = criteria-only)¶
src/curriculum/curriculum.service.ts — buildGridBody (~129):
- Delete
computeEffectiveHours(~201-219) and theeffectiveHoursfields onmapUnit/mapSubject(~226, ~236). mapSubject(~228):hourscomes frommapLeaf(s)— the container's own cells (already whatmapLeafprojects). KeepisContainer+criteriaLevel+units: units.map(mapUnit).mapUnit(~221): collapse to criteria-only. It should projectid,name,code,isMandatory,hours: [],isContainer: false,criteriaLevel: null,units: [], the criteria projection (subject-level criteria + criteria scale), and omit the leaf-only scale/level/rooms/ periodDuration/effectivePeriodDuration fields (units don't carry them). Decide whether to keep a minimalmapLeaf-derived criteria projection or build a dedicatedmapUnitbody — a dedicated body is cleaner now that a unit shares almost nothing with a leaf. Keep the response DTO in sync (Task 2).- Confirm the setup-summary consumer of
buildGridBodystill type-checks aftereffectiveHoursremoval.
Ship check (M1): author Math with its own cells (e.g. 5h in a grade) + units Algebra/Geometry
carrying only criteria via PATCH /curricula/:id; GET returns Math's own hours, units[].hours = [],
no effectiveHours key; a unit sending hours/levelId → 400 CURRICULUM_UNIT_HAS_CELLS; a cell
on Math is accepted.
M2 — Coverage + scheduling reverts (subject-groups + timetables)¶
Task 6 — SG offered predicate → plain own-cell; delete the ∪ rule¶
src/subject-groups/subject-groups.service.ts:
anchorOffersGrade(helper, imported ~91; def insubject-groups.queries.tsor the service): the unit-reject stays; the container ∪-children rule is deleted — offered-in-grade is nowsubject.hours.some(isTaughtCell && gradeId === G), identical to a leaf. Rename/simplify the helper if its name/comment implies the ∪ semantics.curriculumSubjectAnchorSelect(subject-groups.queries.ts:422): keepparentSubjectId(unit-reject)- the container's own
hours; remove thechildSubjects.hoursload added for the ∪ rule (no longer needed). resolveWeeklyHours(~1927): unchanged — it already reads own cells; a container now has its own cell so it resolves directly (no Σ).- The eligibility picker path (~1585-1588) uses the same
anchorOffersGrade— verify it's covered. findBoardSubjectsForCurricula(subject-groups.queries.ts:728): keepparentSubjectId: null(units never board rows). The offered gates ingetGroupedCourses/getMissingCourses/countOfferednow read the container's ownofferedCells— remove any shared-container-offered-predicate indirection added in the 2026-07-14 iteration; a container behaves like a leaf on the boards.
Task 7 — Timetable: delete the container Σ-synthesis¶
src/timetables/timetables.queries.ts:
findSubjectGradeCells(~1505): delete the container-Σ block — the secondOR(subject: { parentSubjectId: { in: subjectIds } }, ~1520-1524), thecontainerHoursmap (~1541-1556), and the synthesis loop (~1561-1567). A container has its own cell now, so the{ OR: pairs }branch finds it directly. Thesubject: { select: { parentSubjectId } }select (~1534) is no longer needed.findSubjectGradeCell(singular) — if it was made container-aware / delegated to the plural for the Σ, revert it to the plain own-cell lookup.- Diagnostics snapshot loader
resolveFor/buildDiagnosticsSnapshot(~965 per the baseline plan Task 9): remove the container Σ-hours synthesis from the anchor'schildSubjects.hoursinclude; the container's own cell now feedsweeklyHours.HOUR_BUDGET(timetables.diagnostics.tscheckHourBudget~973) is unchanged — it reads the (now real) own-cellweeklyHours; the null-own-cell short-circuit is no longer hit for a container. lessonGraphInclude/ any anchor select that addedchildSubjects.hourspurely for the Σ: drop it.- Duration cascade (
resolveEffectivePeriodDuration,period-math.ts): unchanged — the container now has a cell, so cell→subject→grade→curriculum resolves normally.
Ship check (M2): SG create allowed on Math (own cell), rejected on Algebra; place + publish Math's slot with HOUR_BUDGET green reading Math's own cell; boards show Math once, no unit rows.
M3 — Grades: flip the scale owner to the main subject¶
Task 8 — resolveGradingSubject scale owner = container¶
src/grades/grades.service.ts — resolveGradingSubject (~81):
- Container branch (~112-124): change
scaleSubjectIdfromunit.id→sg.curriculumSubjectId(the main subject / cell owner).curriculumSubjectId(entry stamp) staysunit.id,parentSubjectId/parentSubjectName/parentSubjectCodestay the container,criteriaSubjectIdstays container (SUBJECT) / unit (UNIT). Update the interface/JSDoc (~61-79) — "the subject-mark scale always cascades against the main subject; the mark is still stamped on the unit." - Leaf branch (~85-95): unchanged.
update()criteria-subject resolution (~346-353) and the gradebook branch (~631-669): thescaleSubjectIdfor a container SG becomes the container id there too. Audit those two call sites.
src/grades/grades.scale-context.ts — loadScaleContext (~83):
- Signature unchanged. The "cell-less criteria subject" branch (
criteriaCell = nullwhencriteriaSubjectId !== scaleSubjectId, ~139-140) now fires for a UNIT criteria owner (the unit has no cell) instead of a container — same code path, mirror inputs. Update the JSDoc (~64-82) so it describes the inverted ownership: scale owner = the main subject (has cell); criteria owner = main subject (SUBJECT) or unit (UNIT, no own cell → criteria scale cascadesunit.criteriaGradingScale ?? subjectRef). grades.queries.tsloadSubjectGroupForGrade: the select already loadscriteriaLevel+units(childSubjects). For a UNIT-level criteria owner, ensure the unit'scriteria+criteriaGradingScaleare loadable (the scale-context loads the criteria subject separately by id, so a per-unit fetch already works). Confirm no unithoursload remains (units have none).
Task 9 — Gradebook: scale shared from the container¶
src/grades/grades.service.ts — getSubjectGroupGradebook (~497 / container branch ~620-669):
- Per-unit
units[]columns stay. The subject-mark scale is now the container's (shared across units) — resolved once fromsg.curriculumSubjectId. Criteria columns still followcriteriaLevel(SUBJECT→ one shared context;UNIT→ per-unit criteria, scale still the container's). student-grades.dto.ts/subject-group-gradebook.dto.ts: shapes unchanged; verify no field implied a per-unit subject-mark scale.
Ship check (M3): POST /grades on the Math SG requires a unit, resolves Math's scale (not the
unit's), stamps parentSubject* = Math; GET /grades/subject-group/:mathId returns per-unit columns
sharing Math's subject-mark scale.
M4 — Student read surface + verification + docs¶
Task 10 — Student assignment block¶
src/students/class-assignment.mapper.ts + study-plan.mapper.ts — a main subject surfaces once with
its own weekly hours (own cell). If resolveWeeklyHours there synthesised a container Σ (baseline
plan Task 13 / M6), revert it to the plain own-cell lookup. Light confirmation; likely a small delete.
Task 11 — Attendance + selection-consistency (regression only — no change expected)¶
- Attendance (
unitIds→coveredUnit*) and selection-consistency (parentSubjectId: nullfilter) are unchanged by the inversion. Run their specs as regression; do not edit unless a spec breaks because a unit no longer carries hours (e.g. a fixture that gave a unit a cell must move the cell to the container).
Task 12 — E2E + full verification (user-triggered)¶
test/curriculum*.e2e-spec.ts: author + read Math-with-own-cells + criteria-only units;GEThas Math's own hours,units[].hours = [], noeffectiveHours; unit-with-hours → 400; Math-with-cells accepted (regression against the removed container rejection).- SG e2e: create rejected on a unit, allowed on Math; place + publish Math from its own cell.
- Grades e2e: container-SG unit-required + scale-from-Math + parent stamp; gradebook per-unit columns.
- Attendance e2e:
unitIdssnapshot (regression, unchanged). - Verification (run only when Fabio asks — no migration to run):
npm run build,npx jest curriculum curriculum.validation curriculum-structure-sync subject-groups grades grades.scale-context timetables.queries selection-consistency attendance label-coverage.drift error-examples.drift error-messages.drift, the e2e suites above.
Task 13 — Docs + FE guide + memory¶
docs/14-homerooms-subject-groups.md§1.4: flip "container = Σ units" → "main subject owns its cells; units carry only criteria; offered ⟺ own cell".docs/21-grades.md§3.1: subject-mark scale resolves from the main subject; criteria per level.docs/19-attendance.md: verify wording (no behavioural change).docs/REFERENCE.md: curriculum module row + glossary; removeeffectiveHours/CURRICULUM_SUBJECT_CONTAINER_HAS_CELLSmentions; addCURRICULUM_UNIT_HAS_CELLS.docs/fe-guides/: supersede2026-07-16-subject-units-BREAKING.mdwith2026-07-20-subject-units-hours-inversion-BREAKING.md(hours moved to the main subject; units criteria-only;effectiveHoursremoved; unit-with-hours → 400). Mark the old guide superseded.- Memory: update
project_subject_unitsto the inverted model after landing.
Sequencing notes¶
- M0 sweep first — the removal sites must all be found before editing (missing one leaves a Σ path live).
- M1 (curriculum contract/validation/sync/read) blocks M2/M3 (they depend on the container owning cells).
- M2 (coverage/timetable reverts) and M3 (grades scale flip) are independent after M1.
- The two behaviours to get right first (TDD): the validation flip (Task 3 — container cells accepted, unit cells rejected) and the grades scale-owner flip (Task 8) — they encode the inverted semantics.
- This iteration deletes more than it adds — the diff should show net-negative lines in
curriculum.service.ts,timetables.queries.ts, andsubject-groups. If a milestone grows those files, re-check that the Σ/∪ machinery is actually being removed, not layered over. - Commit checkpoint discipline: spec + this plan first (design-gate — spec committed
9544da6; commit this plan next), then per-milestone commits. Keep the unrelated self-service-profile-editing working tree out of these commits.