Skip to content

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 in ERROR_TEXT_PARAMS / ERROR_INTERNAL_PARAMS as the container code was.
  • Remove CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS (enum member + any classification entry).
  • src/common/i18n/error-messages.catalog.ts: add ERROR_MESSAGES{en_US,it_IT} for CURRICULUM_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 by error-messages.drift.spec.ts.
  • src/common/constants/error-examples.ts: add a CURRICULUM_UNIT_HAS_CELLS example row; remove the container-code row. error-examples.drift.spec.ts must 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 from field-labels.catalog.ts to keep label-coverage.drift.spec.ts green). Confirm which unit props had labels (hours, levelId, roomIds, gradingScaleId, periodDuration on the unit DTO) and remove any that no longer decorate a validated field. criteriaLevel/units/unitIds/curriculumSubjectId labels stay.

Task 2 — DTOs + structural interface

  • src/curriculum/dto/scopes/curriculum-configuration.dto.ts:
  • CurriculumUnitInputDto — tighten to criteria-only: keep id?, name, code?, criteria?, criteriaGradingScaleId?. Remove hours, gradingScaleId, levelId, roomIds, periodDuration. (Server rejects them via CURRICULUM_UNIT_HAS_CELLS if sent — see Task 3 — but the DTO should also stop declaring them so they're not silently whitelisted.)
  • CurriculumSubjectInputDto — unchanged (keeps units?, criteriaLevel?, and its own hours/scale/level/rooms — the main subject now uses them).
  • CurriculumSubjectResponseDtoremove effectiveHours (and any effectiveWeeklyHours sub-shape it referenced). Container hours is its own cells; unit hours projects []. Keep isContainer, criteriaLevel, units[].
  • src/curriculum/interfaces/bulk-sync.interfaces.ts: trim GridSubjectInput'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 on GridSubjectInput.

Task 3 — Grid validation (flip the cell-owner check)

src/curriculum/curriculum.validation.tsvalidateSubjectList (~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: call assertHoursCells(s, …) on the container regardless of whether it has units.
  • Add the unit guard: when recursing into units (allowUnits = false path, ~264-316), reject any unit that carries hours (taught cells), levelId, roomIds, gradingScaleId, or periodDurationCURRICULUM_UNIT_HAS_CELLS (params { subjectName: unit.name, field }). Units validate only their criteria placement.
  • Keep criteriaLevel placement 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, syncSubjectList with parentSubjectId, ~769-861) stays — units are still nested rows under the container. What changes is what the unit row carries:
  • Unit onCreate/onUpdate must not write gradingScaleId/levelId/periodDuration (leave null) and the batched leaf pass (syncLeafChildren / syncedSubjects fan-out) must not write a unit's hours/rooms (units have none). Confirm the batched pass keys off the DTO shape — since the tightened GridSubjectInput no longer carries those on a unit, the writes naturally no-op, but verify no unconditional hours: [] reset or rooms clear misfires for a unit vs. a leaf/container.
  • The container row now writes its own hours/gradingScaleId/levelId/rooms/periodDuration through 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.tscurriculumGridInclude (~57): keep parentSubjectId: null top-level filter + nested childSubjects. Trim the nested childSubjects select to drop hours/rooms/level/gradingScale/periodDuration (units carry none) and keep criteria + criteriaGradingScale. The top-level subject select keeps hours/scale/level/rooms (container + leaf both use them). Keep the allGridSubjects walker guard (curriculum-structure-sync.ts:163-172).

Task 5 — Grid read mapper (main subject = own cells; unit = criteria-only)

src/curriculum/curriculum.service.tsbuildGridBody (~129):

  • Delete computeEffectiveHours (~201-219) and the effectiveHours fields on mapUnit/mapSubject (~226, ~236).
  • mapSubject (~228): hours comes from mapLeaf(s) — the container's own cells (already what mapLeaf projects). Keep isContainer + criteriaLevel + units: units.map(mapUnit).
  • mapUnit (~221): collapse to criteria-only. It should project id, 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 minimal mapLeaf-derived criteria projection or build a dedicated mapUnit body — 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 buildGridBody still type-checks after effectiveHours removal.

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/levelId400 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 in subject-groups.queries.ts or the service): the unit-reject stays; the container ∪-children rule is deleted — offered-in-grade is now subject.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): keep parentSubjectId (unit-reject)
  • the container's own hours; remove the childSubjects.hours load 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): keep parentSubjectId: null (units never board rows). The offered gates in getGroupedCourses/getMissingCourses/countOffered now read the container's own offeredCells — 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 second OR (subject: { parentSubjectId: { in: subjectIds } }, ~1520-1524), the containerHours map (~1541-1556), and the synthesis loop (~1561-1567). A container has its own cell now, so the { OR: pairs } branch finds it directly. The subject: { 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's childSubjects.hours include; the container's own cell now feeds weeklyHours. HOUR_BUDGET (timetables.diagnostics.ts checkHourBudget ~973) is unchanged — it reads the (now real) own-cell weeklyHours; the null-own-cell short-circuit is no longer hit for a container.
  • lessonGraphInclude / any anchor select that added childSubjects.hours purely 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.tsresolveGradingSubject (~81):

  • Container branch (~112-124): change scaleSubjectId from unit.idsg.curriculumSubjectId (the main subject / cell owner). curriculumSubjectId (entry stamp) stays unit.id, parentSubjectId/parentSubjectName/parentSubjectCode stay the container, criteriaSubjectId stays 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): the scaleSubjectId for a container SG becomes the container id there too. Audit those two call sites.

src/grades/grades.scale-context.tsloadScaleContext (~83):

  • Signature unchanged. The "cell-less criteria subject" branch (criteriaCell = null when criteriaSubjectId !== 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 cascades unit.criteriaGradingScale ?? subjectRef).
  • grades.queries.ts loadSubjectGroupForGrade: the select already loads criteriaLevel + units (childSubjects). For a UNIT-level criteria owner, ensure the unit's criteria + criteriaGradingScale are loadable (the scale-context loads the criteria subject separately by id, so a per-unit fetch already works). Confirm no unit hours load remains (units have none).

Task 9 — Gradebook: scale shared from the container

src/grades/grades.service.tsgetSubjectGroupGradebook (~497 / container branch ~620-669):

  • Per-unit units[] columns stay. The subject-mark scale is now the container's (shared across units) — resolved once from sg.curriculumSubjectId. Criteria columns still follow criteriaLevel (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 (unitIdscoveredUnit*) and selection-consistency (parentSubjectId: null filter) 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; GET has Math's own hours, units[].hours = [], no effectiveHours; 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: unitIds snapshot (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; remove effectiveHours / CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS mentions; add CURRICULUM_UNIT_HAS_CELLS.
  • docs/fe-guides/: supersede 2026-07-16-subject-units-BREAKING.md with 2026-07-20-subject-units-hours-inversion-BREAKING.md (hours moved to the main subject; units criteria-only; effectiveHours removed; unit-with-hours → 400). Mark the old guide superseded.
  • Memory: update project_subject_units to 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, and subject-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.