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 tonumStudents;AttendanceStudentRowDto.classification(a string) becomesclassifications(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 — classification → classifications¶
On every student row of GET /attendance/rows:
- 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 showregister[]/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, sameanchor, same semantics. Still one entry per slot this student attends.source—RECORDED(a stored cell, editable, has an id) /PROJECTED(carried from another cell, status only, no id) /nullwithrecord: 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
PRESENTat 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 isregister[]/classifications[]. Do not "fix" the strip to repeatLATE_ENTRYall 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_ENTRY → ABSENT before it,
EARLY_EXIT → PRESENT 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.