Naalya Handbook
Viewing Academic Year

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.

The platform handles defaulting, carrying, and healing the year (how). Your feature touches it in exactly three places — the query key, the create payload, and the isUnlocked gate — and this page walks each one with the real patterns. If you find yourself writing more year-handling than that, you're rebuilding something the platform already does.

The hook — and which variant where

VariantBehaviorUse in
useViewingAcademicYear()Throws outside the providerAnything inside the campus hub — pages, tables, surfaces
useViewingAcademicYearSafe()Returns null outside itPersisted side panels and shared chrome that mount in every layout

Both return the same value: { campusId, campusAcademicYearId, viewingYear, isCurrent, isUnlocked, setCampusAcademicYearId }. The throwing variant is a feature, not a hazard — a year-scoped component rendered outside the campus tree is a bug you want loud. The safe variant exists because two kinds of component genuinely live outside that tree: the status-ring chrome mounted in every layout, and persisted side panels, which are restored from a localStorage-backed registry whose container outlives the campus layout.

src/routes/_authenticated/staff-hub/campus.$campusId/field-trips/index.tsx
function RouteComponent() {
  const { campusId } = Route.useParams();
  const { campusAcademicYearId, isUnlocked } = useViewingAcademicYear(); // the one hook
  // no year syncing, no param preservation, no ring logic — the platform does it
}

Queries — year in the key, enabled gate

The standard query trio (*.query.ts, *.options.ts, use-*.tsx — see The Data Layer) grows two year-specific rules:

src/queries/field-trip/field-trip.options.ts
list: (campusId: string, campusAcademicYearId: string, params: ListFieldTripsQueryParams) =>
  queryOptions({
    queryKey: fieldTripQueryKeys.list(campusId, campusAcademicYearId, params), // ① year IN the key
    queryFn: () => listFieldTrips({ ...params, campusAcademicYearId }),
    enabled: !!campusAcademicYearId,                                           // ② hold while resolving
  }),

Year in the key is what makes switching years refetch instead of serving another year's cached rows — the stale-gradebook bug class, prevented by construction. React Query treats every distinct key as a distinct cache entry, so ['field-trip','list', campusA, year2024, …] and […, year2025, …] can never bleed into each other.

The enabled gate covers the resolution window: on first paint campusAcademicYearId is null until the provider validates the URL. Without the gate the request fires with no year and the API answers 400 (the required-DTO rule); with it, the query simply holds until the year exists. Real examples of both rules: enrollment (listByCampusYear), payment-item, grading, cbt, lesson-planning, campus-term.

Pagination — year as resetKey

inside a datatable component
const { params, onNext, onPrev, pageIndex, canPrev } =
  useCursorParams<FieldTripFilters>(20, undefined, campusAcademicYearId); // 3rd arg = resetKey

const { fieldTripsPage, loadingFieldTrips } =
  useFieldTrips(campusId, campusAcademicYearId ?? '', params);            // year at the call site

useCursorParams' third argument wipes the cursor stack synchronously when it changes — switching years rewinds to page 1 in the same render, with no effect, no mirror state, and no flash of page-3-of-the-wrong-year. Never put the year inside the cursor params themselves; it's a reset trigger, not a filter.

Writes — three gates, one truth

the render gate — permission outside, lock inside
<Can action={Action.CREATE} resource={Resource.FIELD_TRIP}>
  {isUnlocked && (
    <Button onClick={() => openSurface(Surface.CreateFieldTrip)}>Create Trip</Button>
  )}
</Can>
  1. Render gate — create/edit affordances are hidden (not disabled) when the year is locked: {isUnlocked && …} inside the <Can> wrapper. That's the house style across ~23 call sites; a locked year reads as "view-only", not "buttons that refuse".
  2. Table meta — datatables pass readOnly: !isUnlocked through table meta so row actions disable together (see enrollment/datatable.tsx).
  3. Surfaces — the create surface stamps campusAcademicYearId from the hook into the mutation payload and early-returns if (!isUnlocked) on submit (create-enrollment.surface.tsx, create-cbt-exam.surface.tsx). Stamping from the hook — never from a form field — guarantees the write lands in the year the user is looking at.

The UI gates are UX; the API's assertNotLocked is the truth. Bypass every gate with curl and the server still answers 400 Academic year is locked. And remember the gate is isUnlocked, never isCurrent — a past-but-unlocked year is legitimately editable, and the two axes must not be conflated.

New resource? Add Resource.FIELD_TRIP to the permission enum + role seeds on the backend; the CASL rules hydrate from /auth/me automatically.

Panels defend themselves

Persisted side panels outlive the campus layout, so a panel that renders year-scoped data guards both its context and its params:

src/panels/lesson-plans.panel.tsx (pattern)
const viewing = useViewingAcademicYearSafe();
if (!viewing) return null;               // restored outside the campus tree → render nothing

…and declares routeParams: ['campusId'] in its panel definition, so the registry re-validates it against the current URL on navigation instead of showing another campus's panel. Real examples: lesson-plans.panel.tsx, student-gradebook.panel.tsx, enrollment-detail.panel.tsx.

Where to go next

On this page