Skip to content

Attendance — the day summary & overlapping counters — FE guide

Iteration on the admin attendance surface shipped 2026-07-25 (docs/fe-guides/2026-07-25-attendance-admin-apis-BREAKING.md). Consumer: sis-academic-mfe. BREAKING, twice: the seven board counters keep their names and types but stop summing to numStudents; AttendanceStudentRowDto.classification (a string) becomes classifications (a string array). Plus one new field (register[]) and one projection bugfix.

Nothing else on the surface moves: routes, scopes, anchors, both write endpoints, the write response envelope and every error code are unchanged.


1. The silent break — the counters overlap now

counters on a board card (GET /attendance/groups), on totals, on group inside a /rows response, and on the write envelope's group:

{
  "notTaken": 0, "suspended": 0, "trip": 0, "absent": 3,
  "lateEntry": 2, "earlyExit": 1, "present": 18
}

Same keys, same types, different meaning. A student is now counted in every bucket their day matches, not in one. The student who arrived late and left early is counted under lateEntry and earlyExit — deliberately, because "how many late arrivals today" has to include them.

This is silent: nothing in the payload tells you it happened. Anything derived from the sum of the seven is now wrong — a stacked bar, a donut, a "18 of 24" label, a percentage with the sum as denominator.

What to use instead:

You want Use
the denominator numStudents (unchanged)
"students with a register" numStudents - notTaken
"students not yet taken" notTaken
any single bucket's share bucket / numStudents
"how many anomalies" pick the buckets you mean and say so — do not add them and call it a count of students

Invariants that still hold: notTaken is the one bucket that never overlaps anything; every student contributes at least one bucket; every bucket is ≤ numStudents.

Trips and suspension stay exclusive — a suspended or field-tripping student is counted only there and never pairs with lateEntry/earlyExit. They are whole-day dispositions, so the student is not in the ordinary register at all.


2. The loud break — classificationclassifications

On every student row of GET /attendance/rows:

- "classification": "earlyExit"
+ "classifications": ["lateEntry", "earlyExit"]
  • Always an array, never empty. An untaken register is ["notTaken"].
  • Usually one element. Two only for the late-and-early day.
  • Ordered canonically: notTaken, suspended, trip, absent, lateEntry, earlyExit, present — the same order as the counter keys.

The old field is gone, not deprecated: reading row.classification yields undefined, so a badge keyed off it renders blank rather than throwing. Grep for it.


3. The new field — register[], the day as written

Each student row gains a second array next to items[]:

{
  "studentId": "…",
  "classifications": ["lateEntry", "earlyExit"],
  "items": [ /* one per slot the student attends — unchanged */ ],
  "register": [
    {
      "recordId": "8f0…",
      "status": "ABSENT",
      "time": null,
      "note": null,
      "startTick": 96,
      "periodLabel": "Period 1",
      "wallStart": "08:00",
      "label": "Mathematics",
      "recordedByName": "Jane Teacher",
      "recordedAt": "2026-07-27T08:02:11.000Z",
      "lastModifiedByName": null,
      "lastModifiedAt": "2026-07-27T08:02:11.000Z"
    },
    { "recordId": "…", "status": "LATE_ENTRY", "time": "10:15", "startTick": 120, "…": "…" },
    { "recordId": "…", "status": "EARLY_EXIT", "time": "13:00", "startTick": 156, "…": "…" }
  ]
}

This is the day summary — the cells actually written, oldest first. A day accumulates events, so there is no single collapsed status to render; render the array as one rich attendance cell:

register Cell
[] not taken
[PRESENT] P
[ABSENT] A
[ABSENT, LATE_ENTRY] LE 10:15
[ABSENT, LATE_ENTRY, EARLY_EXIT] LE 10:15 · EE 13:00
[PRESENT, EARLY_EXIT, LATE_ENTRY] EE 11:00 · LE 13:30 (a re-entry day)
  • DAILY departments lead with this: the teacher sees the single attendance cell they are used to, rich when the day held more than one event, and opens items[] only to inspect the slots.
  • PERIOD departments lead with items[] and show register[] / classifications[] as the highlighted day state alongside it.

Two things to know:

Do not reconstruct it by filtering items[] for source === "RECORDED". It looks equivalent and is not: a cell authored at a tick the day no longer schedules — a lesson moved after the register was taken — appears in no item, because every slot is then a projection of it. Filtering items silently drops it; register always carries it.

Each cell is named from its own frozen snapshot, not from today's timetable — label, periodLabel and wallStart say what was true when the cell was written, exactly like the cells inside GET /attendance/inconsistencies. recordId is the id you PATCH.


4. What did not change

  • items[] — same shape, same anchor, same semantics. Still one entry per slot this student attends.
  • sourceRECORDED (a stored cell, editable, has an id) / PROJECTED (carried from another cell, status only, no id) / null with record: null (not taken, which is not absent).
  • A projected slot is still the student's actual state at that tick. A late arrival still reads PRESENT at later slots, and that is correct — the strip answers "was S in the room at 11:00?". The day summary is the separate thing, and it is register[]/classifications[]. Do not "fix" the strip to repeat LATE_ENTRY all day.
  • Anchors, POST /attendance/records, PATCH /attendance/records/:id, the {records, group, inconsistencies, followUps} envelope, dayEvents[], toRegister, the filter contract, every error code.

5. The bugfix — days that open with a transition

Before: the projection carried the earliest authored cell backwards through the forward mapping. A day whose first recorded cell was LATE_ENTRY at 10:00 rendered PRESENT at 08:00 and 09:00 — asserting the student was in the room before they arrived. An EARLY_EXIT first cell rendered ABSENT over the periods actually attended.

Now the backfill uses the inverse: LATE_ENTRYABSENT before it, EARLY_EXITPRESENT before it. Everything else backfills unchanged.

Only days whose earliest authored cell is a transition are affected. A day that opens with an explicit ABSENT/PRESENT baseline — which is what the DAILY take flow produces — renders exactly as before. No counter moves: the classification never read the projection.