Viewing Academic Year
How the staff hub knows which school year it is showing — why that lives in the URL, what keeps it valid, and the two separate questions it answers.
Every year-based screen in the staff hub — the gradebook, enrollment, CBT, payments — shows one school year at a time. Something has to remember which one.
In this app, that something is the URL. The year is a search parameter called campusAcademicYearId, sitting on the campus layout route. So a real URL looks like:
/staff-hub/campus/abc-123/gradebook?campusAcademicYearId=year-456This page explains that system. Building Year-Scoped Features is what you write on top of it.
Why the URL, and not somewhere else
There were other options — React state in a component, a Zustand store, a value fetched from the server. The URL was chosen because it gives four things at once:
| Because it is in the URL… | You get |
|---|---|
| The browser remembers it | Refreshing the page keeps the year you were looking at |
| You can copy the address | Sending a colleague a link shows them the same year |
| The router carries it | Moving between pages keeps the year, with no code in each page |
| There is one copy of it | When something is wrong, there is exactly one place to look |
That last point matters most. If the year lived in three places, they could disagree with each other.
The route just carries it
The campus layout route does one job: keep the parameter alive. It never decides what the year should be.
validateSearch: (search) =>
typeof search.campusAcademicYearId === 'string' && search.campusAcademicYearId !== ''
? { campusAcademicYearId: search.campusAcademicYearId }
: {},
search: {
middlewares: [retainSearchParams(['campusAcademicYearId'])],
},validateSearch checks the value is a non-empty string and passes it through. It never supplies a default. retainSearchParams re-adds the parameter as you navigate, so you do not have to pass it in every link.
Two things here look like small details and are not. Both come from real bugs.
Return {} — never { campusAcademicYearId: undefined }
When there is no year, this returns an empty object, leaving the key out entirely.
That looks equivalent to setting it to undefined, but it is not. retainSearchParams only restores keys that are missing from the search object. Setting the key to undefined means it is technically present, so retention skips it — and the year silently resets to current every time the user changes page.
Only one thing may write this parameter
At one point both a URL-state library and the router were writing this key. They fought, each re-adding what the other removed, and the search string grew until requests failed with 431 Request Header Fields Too Large.
The fix was to make the router the only transport, and the provider below the only writer. If you are tempted to write this parameter from a component, do not.
The provider keeps it valid
ViewingAcademicYearProvider mounts once, at the campus layout. It is the only code that writes the parameter, and its job is making sure the value is always one that actually exists.
const currentId = campusAcademicYears?.find((cay) => cay.isCurrent)?.id ?? null
const isValid = !!param && !!campusAcademicYears?.some((cay) => cay.id === param)
useEffect(() => {
if (loadingCampusAcademicYears || !campusAcademicYears) return
if (isValid) return
if (currentId) setParam(currentId)
else if (param) setParam(null)
}, [...])"Valid" has a deliberately simple definition: the id is one of the years this campus actually has. That single check handles every case:
| Situation | What happens |
|---|---|
| Someone opens the hub with no year in the URL | Not valid → set to the campus's current year |
| Someone pastes a link with a year id from a different campus | Not in this campus's list → healed to the current year |
| Someone switches campus, carrying the old year id | Same as above → healed |
| The campus has no current year set at all | The parameter is cleared, and the layout shows CampusNoCurrentYearShell instead of the hub |
Notice the last row: when there is no current year, the provider clears the parameter rather than picking something. It never invents a year that does not exist.
What your components receive
{
campusId: string
campusAcademicYearId: string | null // null until resolved, or if no current year
viewingYear: CampusAcademicYearDto | undefined
isCurrent: boolean // true while still loading, so the ring never flashes
isUnlocked: boolean // is lockedAt empty? this is the write gate
setCampusAcademicYearId: (id: string) => void
}isCurrent defaults to true while data is loading. That is deliberate — otherwise the "you are viewing a past year" ring would flash on screen for a moment on every page load.
Two questions, never mix them up
The provider gives you two booleans. They sound similar and mean completely different things.
isCurrent | isUnlocked | |
|---|---|---|
| The question it answers | "Am I looking at the year the school is currently in?" | "Am I allowed to change anything in this year?" |
| Based on | The isCurrent flag on the year | Whether lockedAt is empty |
| What it controls | The blue ring and the "back to current" pill | Every write — disabled buttons, read-only tables, blocked submits |
Because they are independent, all four combinations happen:
- Current and unlocked — the normal case. No ring, everything editable.
- Current but locked — the school sealed this year. No ring, but everything is read-only.
- Past and unlocked — a teacher finishing last year's marks. Ring shows, but writes still work.
- Past and locked — ring shows, everything read-only.
A short way to remember it: the ring tells you where you are; the lock tells you what you may change.
This matches the server exactly. The API's assertNotLocked checks lockedAt and nothing else — see Academic-Year Scoping.
Where each piece lives
| Piece | Where to find it |
|---|---|
| The year switcher dropdown | The sidebar header, src/components/navigation/staff.sidebar.tsx — not the top bar |
| Lock and unlock buttons | The academic-year list tab under Manage → Academic Year, not the switcher |
| The blue ring and "back to current" pill | src/components/auth/status-ring-host.tsx. Uses useViewingAcademicYearSafe() so it renders nothing outside the campus area. Shares its layout with the amber impersonation ring. |
| The "no current year" screen | CampusNoCurrentYearShell, in the campus route.tsx |
| Working out past / current / future | yearPhaseFromOrder(order, currentOrder) — calculated, never stored |
Where to go next
Surfaces
Define a responsive Dialog-or-Drawer with defineSurface where the props are a Zod schema, and open it from anywhere.
Building Year-Scoped Features
The conventions a feature follows on top of the viewing-year system — one hook, year-in-the-key queries, resetKey pagination, and three write gates.