Naalya Handbook
Viewing Academic Year

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-456

This 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 itRefreshing the page keeps the year you were looking at
You can copy the addressSending a colleague a link shows them the same year
The router carries itMoving between pages keeps the year, with no code in each page
There is one copy of itWhen 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.

src/routes/_authenticated/staff-hub/campus.$campusId/route.tsx
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.

src/components/providers/viewing-academic-year.provider.tsx
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:

SituationWhat happens
Someone opens the hub with no year in the URLNot valid → set to the campus's current year
Someone pastes a link with a year id from a different campusNot in this campus's list → healed to the current year
Someone switches campus, carrying the old year idSame as above → healed
The campus has no current year set at allThe 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

ViewingAcademicYearValue
{
  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.

isCurrentisUnlocked
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 onThe isCurrent flag on the yearWhether lockedAt is empty
What it controlsThe blue ring and the "back to current" pillEvery 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

PieceWhere to find it
The year switcher dropdownThe sidebar header, src/components/navigation/staff.sidebar.tsx — not the top bar
Lock and unlock buttonsThe academic-year list tab under Manage → Academic Year, not the switcher
The blue ring and "back to current" pillsrc/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" screenCampusNoCurrentYearShell, in the campus route.tsx
Working out past / current / futureyearPhaseFromOrder(order, currentOrder) — calculated, never stored

Where to go next

On this page