The justification table — the atomic FE guide for the "all justifications" modal¶
STATUS: AUTHORITATIVE for this screen. Published 2026-08-10; amended the same day to be self-contained. Owner: backend. Amend in place.
Everything this screen needs is inline — the request and response shape of every route it calls, the gating rules, the error table. You should not need another file to build it. The attendance family guide remains authoritative for the wider family surface (the child overview, the day view, the notification center) and is the base contract this guide restates; if the two ever disagree, the family guide wins — and tell the backend, that's a doc bug.
No backend work is needed for this screen. Everything below is built from endpoints that shipped on 2026-08-10.
Updated 2026-08-10 (pickup removal) — BREAKING on EARLY_EXIT. Product removed the "who picks the student up" selector:
pickupPersonType/pickupPersonIdare no longer accepted onPOST/PATCH(sending them is a400), the threepickupPerson*fields are gone from every response, andPICKUP_PERSON_NOT_ELIGIBLEno longer exists. An EARLY_EXIT is date + time (+ optional reason). Build no pickup picker; the copy below has been rewritten accordingly.Updated 2026-08-10 (office acks are justifications) — additive, and it REVERSES one product call below. A day the school settles directly (a phone call, the front desk) now records a real justification row, so it lands in the justified bucket of this very table instead of vanishing from both. Every justification row gains
channel(APP | PHONE | IN_PERSON) — renderPHONE/IN_PERSONrows as "recorded by the school", with edit/withdraw hidden (canEditis alwaysfalseon them) and documents still allowed. The two buckets stay disjoint — the ack closes the pending event and creates the justified row in one commit — so the merge logic below is unchanged. §10's "phoned days show nothing" entry is rewritten accordingly.
Scope: the modal that shows a family every attendance matter for their children — what the school is still waiting on, and what the family has already told them — as one filterable table sorted newest-first, future days above past ones, with the submit / revise / withdraw / documents actions on each row.
1. The one idea¶
Two things can appear in this table, and they come from two endpoints:
| Row status | Means | Comes from |
|---|---|---|
| Pending | The school recorded an absence or late arrival and is waiting for an answer. | GET /attendance/family/to-justify |
| Justified | The family made a statement about a day — either answering the school, or announcing a day in advance. | GET /attendance/family/justifications |
The two lists never overlap. That is a backend guarantee, not a
coincidence: the moment a justification covers an event, that event stops
being open and leaves to-justify. So you can concatenate the two results
with no deduplication, no cross-referencing, and no risk of showing the same
day twice as both pending and justified.
This is also exactly the user's mental model — "what do they want from me" and "what have I told them" — so the two buckets are the status filter.
Everything else is client-side. Fetch both lists in full, normalize them into one row shape, then sort and filter in memory. Per-family volumes are tens of rows per school year, so this is cheap and gives you instant filtering with no refetch. The two-endpoint merge is the platform's response-composition doctrine, not a workaround — do not wait for a unified endpoint.
2. Conventions on every call¶
- Base path: every path below is relative to
/api/v1— the wire path ofGET /attendance/family/to-justifyis/api/v1/attendance/family/to-justify. All routes require authentication; none is public. - Dates are calendar days in the school's timezone. Every date — query
param or body field — is exactly
YYYY-MM-DD. A full ISO instant is rejected400 VALIDATION_FAILED, not truncated. Never derive the day through the browser clock: the school's own timezone is the register's clock, and "today" below always means school-today. - Pagination is the platform standard:
page/limitquery params,{ data: [...], meta: { total, page, limit, totalPages } }envelope,limitcapped at 100. - The error body is the platform envelope — branch on
code, rendermessages[lang]verbatim (backend-owned, already localized):
{
"statusCode": 422,
"code": "JUSTIFICATION_NOT_EDITABLE",
"messages": { "en_US": "…", "it_IT": "…" },
"field": "…", // only on field-locatable validation errors
"timestamp": "2026-09-10T09:12:33.120Z",
"path": "/attendance/family/justifications/…"
}
- Who can do what. Reads (both lists, the download URL) ride the
attendanceread grant every family session holds. Every mutation additionally needs theattendance.justifyaction fromGET /permissions(referent-only today — render the whole write surface off that flag, never off the role name) plus a per-childcanWritelink flag (§3), plus — for revise/withdraw only — the per-rowcanEdit(§8.1).
3. The data you need¶
Three calls, once when the modal opens.
GET /attendance/family/to-justify?page=1&limit=100
GET /attendance/family/justifications?page=1&limit=100
GET /auth/profile → profiles.referent.students.items[]
Every row in both lists carries studentName, so GET /students is not part
of this screen's requirements — see the child map below for the one case
where you might still want it.
Both attendance lists are paginated and capped at limit=100. Page until
meta.page >= meta.totalPages. A family of three children with a heavy year
is two or three calls per list; most families are one each. Do not stop at
the first page — see §9 for why that would silently truncate the table.
Do not pass the filters (studentId / from / kind) on the initial
loads. They exist for narrower screens — the "what's coming up" landing
widget — and for families whose year outgrows client-side volumes (§7); this
table wants the whole year and does its own filtering.
The child map¶
There is no name join: every row already carries studentName. The map
exists for canWrite, which the rows cannot tell you:
type Child = { studentId: string; canWrite: boolean };
// canWrite ← GET /auth/profile → profiles.referent.students.items[]
const children = new Map<string, Child>();
canWrite decides whether a row's actions are offered at all (§8). For the
child filter's option list, derive (studentId, studentName) pairs from
the assembled rows themselves — that covers every child who can appear in the
table. Keep GET /students only if you want the filter to also list a linked
child with zero rows this year.
4. The two payloads¶
GET /attendance/family/to-justify?page=&limit=&studentId=&from=¶
Every event still awaiting the family's answer, across all the caller's
linked children in the active year, newest day first. studentId narrows to
one child, from keeps events dated on/after a day; an unlinked or unknown
studentId answers an empty page, never an error.
{
"data": [
{
"eventId": "…",
"date": "2026-09-07", // the school day it is about
"eventType": "ABSENT", // ABSENT | LATE_ENTRY — what the register recorded
"suggestedKind": "ABSENCE", // the justification kind that covers it — §5
"state": "UNDER_REVIEW", // UNDER_REVIEW | NOTIFIED | FOLLOW_UP
"studentId": "…",
"studentName": "Ada Rossi",
"surfacedByName": "Anna Bruni" // who recorded it; null if unresolvable
}
],
"meta": { "total": 1, "page": 1, "limit": 100, "totalPages": 1 }
}
state is the school's escalation ladder (notified → reminded), not
something a family acts on — every pending row means the same thing to them:
answer this. You will never receive a resolved or cancelled state here.
GET /attendance/family/justifications?page=&limit=&studentId=&from=&kind=¶
Every communication the family submitted this school year, newest
submission first (see the ordering trap in §6). studentId and kind
narrow; from filters by coverage — it keeps rows whose endDate is on
or after the day, so a multi-day absence already under way still matches.
{
"data": [
{
"id": "…",
"studentId": "…",
"studentName": "Ada Rossi",
"kind": "ABSENCE", // ABSENCE | LATE_ENTRY | EARLY_EXIT
"startDate": "2026-09-14",
"endDate": "2026-09-18", // === startDate for the single-day kinds
"time": null, // HH:mm — arrival (LATE_ENTRY) / leave (EARLY_EXIT)
"reason": "surgery",
"attachments": [ // { fileId, fileName }[]
{ "fileId": "…", "fileName": "cert.pdf" }
],
"submittedByName": "Maria Rossi", // snapshotted at submit — a STAFF name when channel ≠ APP
"submittedAt": "2026-09-09T18:00:00.000Z",
"channel": "APP", // APP = submitted here | PHONE | IN_PERSON = recorded by the school
"canEdit": true // §8.1 — drives revise/withdraw, nothing else
}
],
"meta": { "total": 1, "page": 1, "limit": 100, "totalPages": 1 }
}
Every mutation (POST 201, PATCH 200) answers this same full
JustificationDto, canEdit recomputed.
channel says who recorded the statement (2026-08-10). APP is a family
submission. PHONE / IN_PERSON means the school settled the day directly
and the ack recorded the justification on the family's behalf — an ordinary
row of this list, with a staff submittedByName and canEdit: false. Badge
those rows off channel; see §10.
5. One row shape¶
Normalize both payloads into a single type before they reach the table. Every sort and filter below operates on this shape, not on the raw DTOs.
type MatterStatus = 'PENDING' | 'JUSTIFIED';
type MatterKind = 'ABSENCE' | 'LATE_ENTRY' | 'EARLY_EXIT';
type Matter = {
key: string; // `event:${eventId}` | `justification:${id}`
status: MatterStatus;
studentId: string;
studentName: string;
kind: MatterKind; // unified — see the mapping below
startDate: string; // YYYY-MM-DD
endDate: string; // === startDate for everything single-day
sortDate: string; // === startDate; the day the row is ABOUT
// Justified rows only
justificationId?: string;
time?: string | null; // HH:mm — arrival / leave
reason?: string | null;
attachments?: { fileId: string; fileName: string }[];
submittedByName?: string; // a STAFF name when channel ≠ APP
submittedAt?: string;
channel?: 'APP' | 'PHONE' | 'IN_PERSON'; // who recorded it — badge, never infer from the name
canEdit?: boolean; // §8.1 — drives the row actions
// Pending rows only
eventId?: string;
surfacedByName?: string | null; // who recorded it
};
Mapping¶
Matter field |
From a pending item | From a justification |
|---|---|---|
status |
'PENDING' |
'JUSTIFIED' |
key |
`event:${eventId}` |
`justification:${id}` |
studentName |
studentName verbatim |
studentName verbatim |
kind |
suggestedKind verbatim |
kind verbatim |
startDate / endDate / sortDate |
all three = date |
startDate / endDate / startDate |
channel |
(omit — a pending row is nobody's statement yet) | channel verbatim |
canEdit |
(omit — see §8.1) | canEdit verbatim |
You never derive the enum mapping. A pending item still carries
eventType (ABSENT — what the register says happened), but it also carries
suggestedKind (ABSENCE — what a justification covering it is called).
Take suggestedKind as the unified kind and the two payloads share one
vocabulary with zero translation on your side. EARLY_EXIT exists only on
justified rows — no event type corresponds to it, because an early exit
always involves a person collecting the child.
A multi-day absence is one row. startDate–endDate describes the range,
and the row sorts on startDate. Do not expand it into a row per covered day:
five rows sharing one id would make the edit and withdraw controls act on a
row the user did not click, and a single withdraw would silently remove four
other rows from the table.
6. Sorting¶
Sort by sortDate descending — future days first, then today, then the
past. That is what "sorted by recency" means here: a family cares most about
what is coming and what just happened.
Add a deterministic tie-break so the table does not reorder between renders when several rows share a date:
matters.sort((a, b) =>
b.sortDate.localeCompare(a.sortDate) || // future → past
Number(b.status === 'PENDING') - Number(a.status === 'PENDING') ||
a.studentName.localeCompare(b.studentName) ||
a.key.localeCompare(b.key),
);
Pending sorts above justified within a day on purpose: on a day that has both, the one needing action is the one to show first.
sortDate is a YYYY-MM-DD string, so plain string comparison is correct
ordering — no Date parsing, and no timezone to get wrong. Resist converting
these to Date objects: these are calendar days, and a Date will drag a
local offset into them that can shift a row by a day.
The ordering trap. The justifications endpoint is sorted by submission
time, not by the days it covers — a row submitted months ago can still
cover next week. That is exactly why this section sorts client-side on
sortDate after fetching everything: do not assume upcoming rows sit on the
endpoint's page one.
7. Filters¶
All client-side, all instant, none needing a refetch.
| Filter | Applies to | How |
|---|---|---|
| Status | both | status === 'PENDING' \| 'JUSTIFIED' — the two buckets. |
| Child | both | studentId; options derived from the rows (§3). Multi-select is a plain includes. |
| What happened | both | the unified kind. Note EARLY_EXIT only ever matches justified rows. |
| Date range | both | overlap, not containment: row.endDate >= filterFrom && row.startDate <= filterTo. A multi-day absence must match a filter that touches any day it covers. |
| Has documents | justified | (attachments?.length ?? 0) > 0. Renders as "with certificate". |
| Free text | justified | reason and submittedByName. Pending rows have no free text of their own — decide whether they drop out of a text search or always pass; dropping out is the less surprising default. |
Child and date can also be pushed server-side: both endpoints take
studentId and from, and the justifications list takes kind. For a
typical family the client-side approach above is strictly better — instant,
no refetch — but if a heavy year makes the full fetch feel expensive, narrow
at the source and keep the rest of this section unchanged.
Two filters that look useful and are not:
- "Editable only" —
canEditis a temporal fact that changes at midnight, so a filter on it goes stale while the modal is open. Show it as a per-row affordance (§8) instead. - Event
state(NOTIFIED/FOLLOW_UP/UNDER_REVIEW) — this is the school's escalation ladder, not information a family can act on. Every pending row means the same thing to them: answer this.
8. Managing rows — the CRUD¶
8.1 The three gates, and which control reads which¶
| Gate | Comes from | Governs |
|---|---|---|
attendance.justify action |
GET /permissions |
whether the write surface exists at all — student sessions hold the reads but never this, so their table renders read-only |
canWrite |
the child map (§3) | all actions on that child's rows: justify, revise, withdraw, documents |
canEdit |
the row itself | revise and withdraw only — never documents, never the pending-row justify action |
canEdit is true only while the first day the row covers is still in the
future and the caller's link has canWrite and the row is the family's
own (channel === 'APP'). Once a covered day begins, the whole statement is
final — including a multi-day absence that started yesterday and runs through
next week; there is no partial editing of the future tail. An office-recorded
row (channel PHONE/IN_PERSON) is never editable at all, whatever its
dates — it is the school's statement, not the family's.
canEditcannot be derived fromGET /permissions. Theattendance.justifygrant is unconditional — a referent always holds it — while the window is a per-row temporal fact. A table that greys out actions based on the permissions payload will offer edit on frozen rows and collect 422s. ReadcanEditoff the row, and nothing else.
8.2 Justify a pending row / announce a day — POST /attendance/family/justifications¶
The action on a pending row is justify this day: open the form
pre-filled with the row's studentId, date and suggestedKind, and
submit. Matching is by kind and date — there is no event id in the
request; coverage happens server-side the moment the row lands. The same
form, opened blank, is how a family announces a future day.
// ABSENCE — a date range + reason, no time:
{ "studentId": "…", "kind": "ABSENCE",
"startDate": "2026-09-10", "endDate": "2026-09-12", "reason": "influenza" }
// LATE_ENTRY — one day + arrival time + reason:
{ "studentId": "…", "kind": "LATE_ENTRY",
"startDate": "2026-09-10", "time": "09:15", "reason": "medical appointment" }
// EARLY_EXIT — one day + leave time; reason optional (pickup selector
// removed 2026-08-10):
{ "studentId": "…", "kind": "EARLY_EXIT",
"startDate": "2026-09-10", "time": "14:00" }
The kind-conditional shape is validated strictly — mirror it in the form; any
mismatch (and any range outside the school year) is 400 VALIDATION_FAILED:
| Field | ABSENCE |
LATE_ENTRY |
EARLY_EXIT |
|---|---|---|---|
endDate |
required, ≥ startDate |
omit (or equal to startDate) |
omit (or equal) |
time (HH:mm) |
forbidden | required | required |
reason |
required | required | optional |
201 answers the full JustificationDto (§4). What a submission covers:
Submitted kind |
Covers |
|---|---|
ABSENCE |
ABSENT events only |
LATE_ENTRY |
LATE_ENTRY events only |
EARLY_EXIT |
nothing — informational (no event type exists for early exits) |
Past days. Today and every day ahead: the form is always open, nothing to
check client-side. A past day is accepted only where the school actually
recorded something to answer — in practice, exactly the days sitting on the
pending side of this table — anything else is
422 JUSTIFICATION_DAY_NOT_FLAGGED; render its messages verbatim, they
name the day and what is still available. Three consequences for this screen:
- There is no deadline. A pending row stays answerable for as long as the year is open. Do not build a countdown, grey out old rows, or hide the justify control by age.
EARLY_EXITis today-or-future only — an early exit is inherently an announcement. Offer the kind only for today onward.- A range starting in the past is judged on its past part alone — one flagged day anywhere in it is enough; future days neither rescue nor hurt.
Resubmission is not an error. Submitting the same kind on the same day
replaces the existing communication in place — same id, new content,
attachments kept. A new ABSENCE absorbs any late-arrival/early-exit
rows inside its range (they are hard-deleted). A contradiction — a day
already covered by an absence, or two overlapping absences — is
409 JUSTIFICATION_CONFLICT; its messages name the blocking communication.
Coverage and consistency are separate: a different school-recorded follow-up state creates a new to-justify item, but the conflict rules above still prevent the family from submitting a communication that contradicts an existing absence-covered day.
No pickup picker. The "who picks the student up" selector was removed by
product decision 2026-08-10 — an EARLY_EXIT no longer names a person, and the
API neither accepts nor returns the pickupPerson* fields.
8.3 Revise — PATCH /attendance/family/justifications/:id¶
Gate: canEdit === true. Send only what changes — startDate,
endDate, time, reason; anything omitted keeps its current value. 200
answers the full refreshed JustificationDto.
kindandstudentIdare not accepted — a body containing either is a plain400. Changing the kind, or moving a communication to a sibling, is withdraw-and-resubmit.- For
LATE_ENTRY/EARLY_EXIT, sendingstartDatealone moves the whole (single-day) communication — you do not also needendDate. - For
ABSENCE, if the newstartDateis later than the currentendDate, send both or you get a400. - The merged result must satisfy everything a fresh submission would: the kind-shape table, inside the school year.
- The window applies twice — to the stored row and to the proposed range,
so an edit cannot back-date a statement into a day that has begun. Either
violation is
422 JUSTIFICATION_NOT_EDITABLE.
One behaviour differs from submitting. Where re-submitting the same kind
on the same day silently replaces, moving a communication onto a day
that already has the same kind is a 409 JUSTIFICATION_CONFLICT — an edit
already has a target row, so nothing is destroyed as a side effect. The
family withdraws the other one first. Everything else about the conflict
rules is identical, including an absence widened over a late arrival, which
still absorbs it.
8.4 Withdraw — DELETE /attendance/family/justifications/:id¶
Gate: canEdit === true. 204, and it deserves a real confirmation dialog,
because it is not a soft hide:
- the register entries the announcement pre-filled are removed too — the consequence worth naming in the dialog. Any entry a teacher has since edited is kept: their correction is their own statement about the day;
- a day whose register the school wrote goes back to needing an answer and will reappear on the pending side after the correction grace (~15 min), so the school will chase it again. A day whose entries came from the announcement itself simply goes quiet;
- its documents are deleted with it;
- a day the school already settled itself (a phone call, the front desk) is
never touched — that resolution was the school's decision, and since
2026-08-10 it is its own row here (
channel≠APP), which withdraw cannot reach.
Withdrawing and immediately re-submitting inside the grace is silent — fixing a mistake straight away triggers no notification.
8.5 Documents — POST / DELETE / GET …/:id/files¶
Gate: canWrite only — never canEdit. Evidence runs on its own clock:
a medical certificate normally arrives after the child is back, so the row it
belongs to has almost always started. Attachment controls stay live on a row
whose edit and withdraw controls are gone; this is deliberate, and the one
place where "the past is read-only" does not hold.
POST /attendance/family/justifications/:id/files—multipart/form-data, field namefile, one file per call, up to 5 per justification, max 10 MB,application/pdf/image/jpeg/image/png. Answers the file's metadata (id,fileName,mimeType,byteSize, …) — thatidis what the row'sattachments[]callsfileId.ABSENCEandLATE_ENTRYonly — anEARLY_EXITannouncement carries no evidence (422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED).DELETE …/:id/files/:fileId—204. AfileIdbelonging to a different justification is a plain404. Deleting frees a slot under the cap.GET …/:id/files/:fileId— mints a signed URL{ url, fileName, mimeType, byteSize, expiresAt }; fetch it directly, it expires — don't cache it. This one is a read: student sessions can download.
8.6 Errors on the write path¶
| Case | Answer |
|---|---|
| kind-shape mismatch, out-of-year range | 400 VALIDATION_FAILED |
kind or studentId in a PATCH body |
400 (not in the contract — §8.3) |
read-only link (canWrite false) |
403 |
| student session on any write | 403 |
unlinked studentId in a POST body |
404 NOT_FOUND |
| justification not the caller's / nonexistent | 404 JUSTIFICATION_NOT_FOUND |
the communication has already started (PATCH/DELETE) |
422 JUSTIFICATION_NOT_EDITABLE |
a PATCH that would back-date into a started day |
422 JUSTIFICATION_NOT_EDITABLE |
an office-recorded row (channel ≠ APP) on PATCH/DELETE |
422 JUSTIFICATION_NOT_EDITABLE |
| the range collides with another communication (an office-recorded one included) | 409 JUSTIFICATION_CONFLICT |
a past day the school never flagged (or any past EARLY_EXIT) |
422 JUSTIFICATION_DAY_NOT_FLAGGED |
file on an EARLY_EXIT |
422 JUSTIFICATION_ATTACHMENTS_NOT_ALLOWED |
| 6th file | 409 JUSTIFICATION_ATTACHMENT_LIMIT |
| oversized / wrong-type file | standard file-upload errors (413 / 422) |
Note what is absent: there is no "already justified" error. Covering an
already-covered day is either an in-place replace or a 409 per §8.2 — the
matrix, not a separate code.
9. Refetching¶
Refetch both lists after any successful submit, revise or withdraw. They are two halves of one picture and every mutation can move a row between them:
| The user did | What changes |
|---|---|
| Submitted a justification | a pending row disappears, a justified row appears |
| Widened an absence over another announcement | the swallowed row disappears (backend absorb) |
| Re-submitted the same kind on the same day | an existing row is replaced in place — same id, new content |
| Withdrew a justification | the row disappears; its school-written days return as pending after ~15 min |
Because of rows two and three, do not treat justificationId as a stable
handle across mutations, and do not patch the table optimistically from a
mutation response alone — a single write can remove rows you did not touch.
Refetch and rebuild.
The ~15 min lag is the correction grace: a teacher's mistyped absence corrected inside that window never reaches the family at all. It means a just-recorded absence will not appear immediately, and a just-withdrawn day will not return immediately. Neither is a bug, and neither needs polling — the modal reloading on open is enough. (The grace is env-tunable per environment; don't hardcode the number in copy.)
Document mutations (§8.5) don't move rows between buckets — after an upload
or delete you may just patch the row's attachments locally, or refetch;
either is correct.
10. Things that will look like bugs and are not¶
A day showing both a pending row and a justified row. Legitimate: an
EARLY_EXIT announcement covers no event, so a child who was announced as
leaving at 14:00 and also recorded absent that morning has one of each. The
early exit does not answer the absence, and the school still wants an answer.
A day the family phoned the office about appearing as justified — with a
staff name on it. (Reversed 2026-08-10 — this table used to show such days
in neither bucket.) When the school resolves a day directly — a phone call,
a conversation at the desk — the ack records a justification on the family's
behalf, so the day lands in the justified bucket like any submission.
channel (PHONE / IN_PERSON) is the tell — render a "recorded by the
school" marker off it, never off submittedByName (which is the staff
member's name, not a bug). Edit and withdraw stay hidden (canEdit: false,
always); documents still attach. A family ABSENCE overlapping such a day is
a 409 JUSTIFICATION_CONFLICT, same as against their own earlier statement.
A justification for a day with no absence on it. Announcements are forward-looking; the register catches up later, or the child turns out to be fine and it never does. A justified row does not imply the school recorded anything.
A pending row for a future date. Rare but real: the school can record a known future absence in advance. It is pending because nobody has explained it.
Student sessions see the table read-only. A student holds the same reads
but never the justify action, so every row's actions are hidden and every
canEdit is false. Build one table and let the gates empty it of controls
rather than branching the screen.
11. Worked example¶
Three children, school-today is 2026-09-10.
GET /attendance/family/to-justify?limit=100
{ data: [
{ eventId:'e1', date:'2026-09-09', eventType:'ABSENT',
suggestedKind:'ABSENCE', studentId:'s1', studentName:'Ada Rossi',
surfacedByName:'M. Bruni' },
{ eventId:'e2', date:'2026-09-04', eventType:'LATE_ENTRY',
suggestedKind:'LATE_ENTRY', studentId:'s2', studentName:'Bo Rossi',
surfacedByName:'M. Bruni' },
], meta:{ total:2, page:1, limit:100, totalPages:1 } }
GET /attendance/family/justifications?limit=100
{ data: [
{ id:'j1', studentId:'s1', studentName:'Ada Rossi', kind:'ABSENCE',
startDate:'2026-09-14', endDate:'2026-09-18',
reason:'surgery', attachments:[], canEdit:true, … },
{ id:'j2', studentId:'s2', studentName:'Bo Rossi', kind:'EARLY_EXIT',
startDate:'2026-09-10', endDate:'2026-09-10', time:'14:00',
canEdit:false, … },
{ id:'j3', studentId:'s1', studentName:'Ada Rossi', kind:'ABSENCE',
startDate:'2026-09-01', endDate:'2026-09-01',
reason:'flu', attachments:[{fileId:'f1',fileName:'cert.pdf'}],
canEdit:false, … },
], meta:{ total:3, page:1, limit:100, totalPages:1 } }
Merged, normalized and sorted:
| sortDate | Child | What | Status | Row actions |
|---|---|---|---|---|
| 14–18 Sep | Ada Rossi | Absence | Justified | edit · withdraw · documents |
| 10 Sep | Bo Rossi | Early exit (14:00) | Justified | no documents (EARLY_EXIT) — no actions left; the day has begun |
| 09 Sep | Ada Rossi | Absence | Pending | justify this |
| 04 Sep | Bo Rossi | Late arrival | Pending | justify this |
| 01 Sep | Ada Rossi | Absence (1 document) | Justified | documents only |
Note row two: canEdit is false because 10 Sep is today — the window is
strictly future — and an early exit never carries documents, so the row has
no live control at all. And note that no row is duplicated even though j3
almost certainly resolved an event on 1 Sep: that event is resolved, so it
never reached to-justify.
12. Suggested build order¶
- The two fetch loops with page exhaustion, and the child map (
canWrite). Logmeta.totalagainst the row count you assembled — that catches a truncating loop immediately, which is the one bug in this screen that produces a plausible-looking table. - The
Matternormalizer. Unit-test the range fields and thatkindreadssuggestedKindon pending rows; everything downstream depends on it. - Sort and render, no filters. Confirm future-first ordering against a multi-day row.
- Filters, cheapest first: status, child, kind, then date-range overlap.
- The submit form (§8.2) with the kind-shape table mirrored, wired to the pending rows' justify this pre-fill.
- Revise + withdraw (§8.3–§8.4), gated on
canEdit, with the withdraw confirmation naming its consequences. - Documents (§8.5), gated on
canWrite— deliberately not oncanEdit.
Student lifecycle (2026-09-03)¶
GET /attendance/justifications lists rows of ENROLLED students only; a justification of a student who has since left is no longer in the table (the row and its attachments still exist, and justification-by-id still answers). No payload change. Spec: docs/superpowers/specs/2026-09-03-student-participation-status-fences-design.md.