Expected behaviour derived from server semantics (src/modules/academics/services/*)
shared interaction rules (00-shared/08), the common mistakes users make with
structure data, and the UX countermeasures. Purpose: a playbook of what the user
does so the flows never surprise.
Duplicates are rejected by name/code, never silently merged: creating a year
or grade with an existing name, or a subject with an existing code, returns
409 DUPLICATE_RESOURCE with exact server copy ("Academic year "X" already
exists.", academic-year.service.ts:25-29; grade.service.ts:21-23;
subject.service.ts:21-25). The client must never pre-empt with its own copy
— render the server message verbatim.
set-current is a global, one-way-ish flip: PATCH /academic-years/:id/set-current
clears isCurrent on every other year, then sets the target isCurrent:true
and status:"active" (academic-year.service.ts:69-83). The old current year
does not go to archived automatically — it stays active minus the flag.
Users expect "old year becomes archived"; the UI must show the real result.
Delete is soft and silent to children: grade/section/class/assignment delete
= softDelete (base.repository.ts:68-74); lists simply stop showing the row.
Nothing cascades — sections/classes/assignments pointing at a deleted grade
remain in the DB and keep appearing in other lists (OQ-3).
Correction of an assignment is delete + recreate: there is no assignment
update endpoint (subject-assignment.controller.ts:19-39); a wrong teacher is
fixed by removing the row and adding a new one.
Ordering is server-given, not user-sortable: grades render in displayOrder
asc (grade.service.ts:37-39); years newest-first (createdAt:-1,
academic-year.service.ts:45-47); classes/sections/subjects in insertion order
(class.service.ts:41-53, section.service.ts:41-48, subject.service.ts:35-47).
Reordering grades = editing displayOrder (int ≥ 0), not drag-and-drop (until a
reorder endpoint exists — (planned)).
q (global search) is accepted but ignored: PaginationQueryDto.q
(pagination-query.dto.ts:26-29) is parsed on every list endpoint but no
academics service filters on it (services filter {}, e.g. grade.service.ts:41-43).
Users will type in a search box and get full lists — client search must be local.
Grade/section/class status is an open string (default 'active',
grade.schema.ts:21-22, section.schema.ts:24-25, class.schema.ts:33-34);
only academic-year status is an enum (upcoming|active|archived,
academic-year.schema.ts:7-11). Treat any non-active value as inactive.
Power user (admin/coordinator, desktop): keyboard N = new entity on list
screens, Ctrl+Enter = submit; year switcher keyboard shortcut; bulk-skip
pattern in ladder setup (create Grade 1 → "Save & new" stays in form,
displayOrder auto-incremented).
New user (fresh tenant): first-run empty states chain — create year →
create grades → create sections → create classes → assign subjects. Each
AppEmptyState CTA navigates the next step; a mini checklist ("Step 2 of 5")
helps structure-first onboarding (proposed).
One-thumb phone: all admin forms are full-screen with the primary CTA at the
bottom (thumb zone); the year switcher and filter chips stay within the top ⅓.
TalkBack/VoiceOver: tree nodes announce level + counts
("Grade 10, expanded, 4 sections"); chips announce full labels
("Section A, capacity 40"); conflict banners read in a live region.
Dynamic type 200%: chips and status badges wrap (no fixed widths); SectionChips
overflow falls back to "+N more" (07 §2).
Reduced motion: no shake on validation; errors = color + icon + text
(00-shared/08 §4).
App killed mid-create form: form state lost (memory only) → return = fresh
form; cached lists still available (13 §3) so the user can re-check duplicates.
Server 5xx on create: generic error + requestId; form values preserved;
suggest retry. Duplicate re-submit risk: a 409 "already exists" then means the
first attempt actually succeeded — the client should offer "Open existing".
401 mid-flow: silent refresh → replay once → sessionExpired → re-login,
in-progress form preserved in memory.
Two admins editing the same structure: last-write-wins — every doc carries a
version counter incremented per update (base.repository.ts:57-66) but the
client does not send it (PATCH bodies are plain $set DTOs,
update-class.dto.ts:4-50); no 409/412 optimistic-concurrency errors surface.
UI: refresh lists on focus; treat remote changes as truth.
set-current raced from two devices: both PATCHes run sequentially server-side;
the later one wins — the client re-reads the years list after the call to render
the true current year.