Skip to content

refactor: rebuild the admin catalogue tables - #117

Merged
HarryRandall merged 25 commits into
mainfrom
refactor/admin-catalogue-tables
Sep 22, 2026
Merged

HarryRandall merged 25 commits into
mainfrom
refactor/admin-catalogue-tables

Conversation

@HarryRandall

Copy link
Copy Markdown
Owner

Summary

This is stack 2 of 16. It contains the focused commits between fix/catalogue-security-and-imports and refactor/admin-catalogue-tables and targets the branch immediately below it.

Verification

  • GitHub Quality gate
  • GitHub Route gate
  • GitHub Database gate
  • GitHub Browser gate

Deployment notes

Do not deploy this branch independently. Merge the stack from the bottom upwards. The one-time hosted database rebuild remains a separate approved operation after the complete stack reaches main.

@vercel

vercel Bot commented Sep 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
coursemap Ready Ready Preview Sep 22, 2026 12:58pm UTC

Base automatically changed from fix/catalogue-security-and-imports to main September 22, 2026 12:23
Four defects that all present as the interface ignoring the reader.

revalidatePath received the record page's own URL, which carries the
academic year as a query string, but it matches a route path. Every
revalidation missed, so accepting a change or publishing a draft only
appeared after a manual reload. The path is now trimmed at the query.

The record page is a server component whose tab lives in the query
string, yet it mounted an uncontrolled Tabs. The editor's redirect to
the preview after a save re-rendered the same instance and the tab never
moved. A small client wrapper controls the value from the URL, which
also keeps a reloaded or shared link on the expected tab.

The directory passed a controlled state to FilterBar. That branch calls
onQueryChange immediately, skipping the 250ms debounce the URL-bound
branch uses, so every keystroke pushed a server navigation into a
dynamic page and the pending state was lost. The directory now lets
FilterBar bind to the URL, as the other list pages do. The remaining
controlled callers filter client side, where immediate updates are
correct, so the shared component is unchanged.

The ten admin error boundaries removed with the old interface are
restored. CatalogueError survived the rebuild but nothing imported it,
so any failure under /admin/<kind> took down the whole page.

The open-review indicator is a counted badge rather than a bare amber
dot, and the record container drops a mx-auto that had no width to
centre within.
The import page held three hand-rolled lists. Runs were a column of
buttons 20rem wide whose run number, status, progress and timestamp sat
in unaligned stacked spans, so nothing lined up across rows. Records
were a wrapping flex row where a long error message pushed the Review
button onto the next line. Stages were bordered list items printing a
raw millisecond count.

All three are now DataTableShell tables. The six-column data-imports
grid already existed in catalogue-table.module.css, written for this
page and unused since the rebuild; the record table adopts it. Runs and
stages get their own grids alongside it.

The run list moves out of the narrow master column and across the page,
so model, cost and progress are readable. Durations render as seconds
past a thousand milliseconds. Run status shows a label rather than the
raw enum. The target detail placeholder is a skeleton matching the card
it replaces, instead of a bare sentence that shifted the layout when the
detail arrived.
The rebuild in A5 to A7 was specified in data-flow terms and reused none
of the existing component kit, so the plan now names the kit every
remaining project draws from and the review rule that goes with it.

The new document carries the interface, pipeline, schema and test work
still outstanding, with the findings behind each item so they are not
rediscovered: the review panel cannot show a per-field diff because
changes.ts diffs whole collections, a manual edit clears the publication
gate, summary fields are parsed and never stored, discarding a model
extraction is silent, and a failed target blocks its own retry.
changes.ts records a collection as one row, so altering a single
session's delivery mode produced an entry whose old and new values were
the whole session array. ValueDiff rendered both sides as JSON and left
the reviewer to find the difference by eye, then offered Accept or
Reject for the collection entire. That is the reason reviewing a record
did not read as a review.

The diff now walks into the change and lists only the fields that
differ, naming each one, so the example above reads "Item 1 · Delivery
mode: In person / Online". Added and removed rows are labelled as such.
Values render through CatalogueValue, recovered from the interface
removed in 80b95de, which shows a record as a definition list rather
than braces. A change too wide to reduce still falls back to both raw
values, so nothing is ever hidden.

Component tests cover a scalar, a single altered field inside a
collection, an added row and the fallback.

Flag codes and entry statuses are named for the reader instead of
printing the database vocabulary, and Reject all, which discards every
imported value at once, now asks first. ConfirmDialog was already used
twice in neighbouring files.
The previous commit reached for tables but invented four CSS grid
layouts for them. The interface removed in 80b95de did not work that
way, and the result was similar without being the same.

There are two table shells in the repository. ui/common/data-table wraps
an ordinary primitive table with a minimum width, which is what
CourseImportPipeline used for pipeline stages. The catalogue-table shell
lays a CSS grid over a list of records and owns the data-imports grid
written for the imports list. The stage and run tables now use the
first; the record table uses the second, with the columns that grid was
sized for.

The record table recovers what ImportsList actually did: LinkedTableRow
so the whole row is a target, CatalogueIdentity so a record reads as an
icon, title and code rather than a bare monospace button, and
CatalogueRowActions for the actions the grid already reserves a column
for. Targets carry the directory title for that, joined through the
directory entry.

Snapshot history was not a table at all. CourseImportHistory and
CatalogueReviewHistory were timelines, so the history tab is one again,
with snapshots and publications merged into a single ordered story
instead of two lists to interleave by eye. The earlier note claiming
this surface was tabular was wrong and is corrected.

The extraction summary regains the token, cost and latency grid it had
as a definition list, with truncation raised as an Alert rather than a
sentence appended to a description.
The reuse list named one table shell where the repository has two, and
the outstanding work claimed version history was tabular. Both were
inferred from the data rather than read off the component being
replaced, and both were wrong.

The list now separates the catalogue grid, which sizes columns for a
list of records, from the ordinary shell used for every other table, and
names the timeline as the shape a version or activity history takes. A
short note says to read the deleted component first and how to find it.
Artefacts were raw download links that took the reader out of the
application and into a browser rendering of a JSON file. The viewer that
did this properly was removed in 80b95de for schema reasons, not design
ones, and its dependencies all survived.

It is restored with its vertical artefact navigation, its own stylesheet
so the list does not inherit the Nova segmented-tab track, the attempt
picker for artefacts recorded more than once, syntax highlighting for
HTML and Markdown through the retained highlight.js entry, a loading
state and a retry on failure. Two pieces it depended on did not come
back: the scroll preview the viewport only used when asked, and the
change-set reader, whose artefact is JSON like the others.

The pipeline table drops the artefacts column it briefly carried.
CourseImportPipeline never had one, because the artefacts had a viewer.
Only the title text was a link in a 68px row, and the directory was the one
catalogue table with no actions column although its grid reserved the space
the sibling layouts use. Rows become LinkedTableRow, so the row is the hit
area, and each carries CatalogueRowActions with the record, its ANU page and
its latest import run. The ANU URL moves out of the record header into
anu-source.ts because three surfaces now need to point at the same page.
CatalogueLoading knew two of DataTableShell's grids, so five directory routes
fell through to a six-column selectable skeleton in front of a five-column
directory table, and the imports routes drew a bare table in front of a
toolbar, a run table and a card. Every skeleton now names the layout it
stands in for, describes each column by the shape of the cell it reserves,
and holds the directory's year picker and refresh row open.

The table skeleton is separated from the page shell so catalogue-pages.tsx
can use it as its Suspense fallback. It previously drew three grey rectangles
of its own, which meant the reader saw two different shapes in sequence.
The blockers were right-aligned text-xs text-muted-foreground, repeated in a
title attribute on the disabled button: the reason you cannot publish was the
faintest thing on the page. They are now a warning Alert with a link into the
review that clears them.

A record with no draft is no longer told it has nothing to publish. That is
the ordinary state of a finished record, not a blocker, so the publish button
is simply absent until a draft exists.
/admin/<kind>/[code] had none, so a failed snapshot or review query fell
through to the root boundary. CatalogueError's copy is about a list, so the
record gets its own variant whose way out is the list it came from rather
than the admin overview.
The changes were a <ul> of bordered <li>, each with the field, status, source
and three buttons in one wrapping flex row, so no two rows lined up and there
was no way to scan what was still open. They are decisions over a fixed
schema, so they are a table: field, current, imported, source, decision, on
the ordinary table shell rather than the catalogue grid. A change over a
collection, or one carrying a source excerpt, opens a detail row with the
existing field-by-field ValueDiff underneath.

"3 of 11 to decide" becomes the vendored Progress primitive, the excerpt
gains the link back to the ANU page it was read from, and the flag note uses
the field primitive rather than a hand-rolled <label>.

The record page showed every review at full size, so five imports meant five
400px cards. Only the latest is a live decision; the rest sit behind a
disclosure.
The runs page loaded every run for the kind, capped at twenty-five with
no offset, and offered no way to find one. It then polled
router.refresh() every four seconds while a run was active, refetching
the whole server page, every run and every target, to learn that one
counter had moved, and resetting the reader's scroll position each time.

The loader now returns a page: the run rows for the current query, sort
and page, plus the selected run with its targets. Only the selected run
carries target rows, so listing runs no longer reads every target of
every run. Search, status filter, sort and paging bind to the URL
through FilterBar, SortMenu and Pagination, as the imports toolbar
removed in 80b95de did.

An active run is watched through loadImportRunProgress, which reads four
counters from one row. The counters are stored against the run they came
from, so a reading can never be shown for another run, and a full
refresh happens once, when the run settles.

The empty state moved inside the table so the toolbar stays reachable,
and says whether the list is empty or merely filtered.
A manual edit cleared the publication gate. catalogue_publish_blockers()
found open review entries through the draft's import_target_id, which
only an imported or applied snapshot carries, so any hand-edited draft
had an empty blocker list. Opening a review with a blocking flag and
making one trivial edit was enough to publish past it. Blockers now
resolve through the item year.

Structure summary fields were parsed, carried through the projection and
hashed into projectionSha256, but had nowhere to land: the requirements
migration dropped academic_structure_summary_fields and nothing replaced
it. Every programme import discarded them, and hashing data the database
never stored meant two snapshots could differ by hash alone with nothing
visible to show for it. They have a table, a write, a read and a place
in the review comparison.

Discarding a model extraction was silent. The target ended `ready` with
a null error code and every stage completed, and only
catalogue_extractions recorded what had happened. The merge now emits a
blocking flag, and the target carries the code and reason.
academicStructureModelResponseError, which explains a truncated
response and which nothing had ever imported, is wired in so the cause
reaches the extraction record.

The structure merge accepted or rejected the whole model response, so
one unusable fee cost the requirement tree. It is field by field, as the
course merge already was, with an accepted and a rejected list.

A target whose worker died stayed queued and the active-target index
then refused every retry for that item year, escapable only by a
hand-written statement. Recovery no longer needs one, and the sweep it
depends on gains its missing index.
The runs page is read on the server and driven from the client, and the
client needs the sort default and the status list to build its toolbar.
Importing those constants from admin-catalogue.ts pulled a module that
begins `import "server-only"` into the browser bundle, which typecheck
and lint both allow and only the production build rejects.

The vocabulary moves to catalogue-kinds.ts, which the client already
reads. admin-catalogue.ts re-exports it, so nothing on the server
changes.
Every other admin page hands its tab bar to AppShell, which draws it
directly under the breadcrumb. The record page rendered its own inside
the content instead, so arriving from the directory moved the tabs down
the page, and the preview's own tabs then sat under a second bar at a
different indent. The provider now wraps the shell, as SectionTabs has
always documented, and only the preview nests tabs of its own.

The directory header repeated what the page already showed. "3012
listed" is the number the table footer prints, and an Import runs button
sat immediately below an Import runs tab. Both are gone; the refresh
time moves onto the action it describes, and the line now speaks only
when the listing has never been fetched, failed, or carries a warning.

A record could read "Published" on its own page and "Ready for review"
in the import runs list at the same time. A first import has no baseline,
so its changes are recorded as accepted and the candidate becomes the
draft without an Apply step, and applied_snapshot_id was never set. It is
set at that point now, backfilled for the targets already written that
way, and the outcome reads Applied.
The review tab was a run summary, seven identical full-width flag cards
and a thirty-row table, all at the same size and weight, so nothing on it
said what needed attention or whether the record could be published.

The record header now opens with the verdict: what state the record is
in, why a draft is held back, and the single control that moves it on,
from outstanding decisions through applying a review to publishing.
recordNextStep mirrors catalogue_publish_blockers, including its rule
that only the newest finished review and the one behind the draft gate a
publication, so the interface never offers a publication the database
will refuse. Publishing is outward-facing, so it now confirms.

Flags are split by weight rather than repeated. A blocking flag keeps its
own row and its note field because it is a decision; the rest collapse
into one row per kind, which turns six copies of "Model disagreed with
the parser" into one line naming the six fields. Flags carry only the
leaf key the extractor used, so they were printing camelCase at the
reader; they now resolve through the same label map as changes.

The decision table groups its rows into details, requirements and lists,
says once above the list rows that the pipeline records a whole
collection as one decision, filters to what is still open, and links each
row to the place on the ANU page it was read from where the recorded
locator is an anchor rather than a CSS selector.

A run collapses itself when nothing in it is waiting on the reviewer, so
a record with five imports behind it is not five full-height cards
wherever the panel is rendered.

Reused: DataTableShell, ConfirmDialog, OptionPicker, ProgressRing, Alert,
Collapsible, Progress and the existing ValueDiff. The browser spec still
expected the pre-table list rows and the old disabled publish button, so
it is brought up to the interface it covers.
At 390px the flag row squeezed its own name into a column two words
wide, because the icon, the count, the bulk action and the disclosure all
competed with it for one line. The controls now drop below the name
there, as they already do in the group header, and the same applies to a
blocking flag and to the fields inside an expanded group.

The changes view picker carried aria-label="Show changes", which replaced
its visible value for anyone not looking at it, so the current view was
announced as nothing at all. It now names the control and its value
together, the way the sort menu beside the directory table does.
The Review tab carried a badge of open flags. It reported a number the
review panel below it already states, under a label that says what the
tab is for, and it moved the tab's width every time a flag was resolved,
so the row of tabs shifted under the reader's cursor mid-decision.

Each of Review, Preview, Edit and History now carries an icon, through
the shared SectionTabs rather than a second hand-rolled TabsList. The
provider still wraps AppShell so the bar renders in the shell's tabs
slot, directly under the breadcrumb.
The page was a run list stacked over the records of whichever run was
selected. That reads as two disconnected pages, and it asks the wrong
question first: an administrator looks for a record, not for the batch
that carried it, and could not see what had happened to COMP3600 without
first guessing which run imported it.

There is now one table of imported records across every run, newest
first, with the run as a column and a filter rather than a table of its
own. Search over code and title, the outcome filter, sort and paging all
act on the records themselves. Choosing a run narrows the same list and
raises a single strip carrying that run's model, cost, counters and Stop
control, so the master and detail collapse into one view. A failed record
now says why in its Outcome cell instead of only that it failed.

The table is the existing data-imports DataTableShell with LinkedTableRow,
CatalogueIdentity and CatalogueRowActions, so the pipeline stages and the
artefact viewer stay one menu item away per record. The section tabs gain
an icon each, the loading skeletons draw the table that follows them
rather than the card layout the page no longer has, and the record select
resolves its labels by id rather than through an embed PostgREST cannot
disambiguate.
ReviewPanel now collapses itself and opens only when its run still asks
for a decision, so ReviewStack's details element around the older runs
had become a disclosure over a disclosure: two clicks to read one old
import. The reviews render as a plain list and each panel decides for
itself whether to open.
PostgREST answers every request with at most 1,000 rows and says nothing
when it stops. The directory reads a year's listing to filter it in memory,
which suits a few thousand rows, but read it in one request. The 2026 course
listing holds 3,012 codes and the directory saw the first 1,000, up to
EMET1001: about two thirds of the catalogue, every MATH, PHYS, STAT and ENGN
course among them, could not be found, filtered or imported from here, and
the footer reported a total of exactly 1,000. It went unnoticed because the
test records are COMP courses, which sort near the six-hundredth code.

The truncation also broke the check for records imported without a
directory row: any item whose row fell past the thousandth was taken for one.

readAllRows pages through every response. Each caller orders by a unique key
so a row cannot move between pages; the targets query gains id as a
tiebreaker for targets created in the same instant, and the item years query
gains an order it had lacked. The directory now finds MATH1005 and reports
3,012.

The year list offered every seeded year from 2020 to 2030, eleven years of
which one held any catalogue, tall enough to cover the page tabs. It now
offers the years whose listing has been fetched, plus this year and next so
the coming handbook can be imported before it has rows. It reads the
directory statuses, one row per year and kind, so it cannot hit the same cap.
@HarryRandall
HarryRandall force-pushed the refactor/admin-catalogue-tables branch from 25741a0 to 713994c Compare September 22, 2026 12:25
@HarryRandall
HarryRandall merged commit 713994c into main Sep 22, 2026
3 of 6 checks passed
@HarryRandall
HarryRandall deleted the refactor/admin-catalogue-tables branch September 22, 2026 12:39

This branch was successfully deployed

1 active deployment
Preview 713994c6 Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant