Skip to content

v1.13.5 - #61

Merged
pataniaeli merged 14 commits into
mainfrom
dev
Aug 29, 2026
Merged

v1.13.5#61
pataniaeli merged 14 commits into
mainfrom
dev

Conversation

@pataniaeli

Copy link
Copy Markdown
Collaborator

No description provided.

pataniaeli and others added 14 commits August 28, 2026 17:10
A user hit a broken SGA Spaces page. /api/spaces returned a transient 401 and
the page died with "l.find is not a function"; a reload cleared it.

    fetch('/api/spaces')
      .then(r => r.json())
      .then((data: Space[]) => setSpaces(data))

The annotation is a promise the code cannot keep. An error response is still
valid JSON, so r.json() resolved happily with { error: 'Unauthorized' }, that
object went into state typed as Space[], and the page died on the next
spaces.find(). `data.length > 0` had already passed silently -- undefined > 0 is
just false -- so nothing failed until the render. TypeScript cannot catch this:
the cast is asserted at a boundary where the shape is only known at runtime.

Eight call sites shared the pattern. Four degraded harmlessly on their own
(data.groups || [], (requests ?? [])), but two more could crash the same way and
one was worse than a crash:

  sga-spaces/page.tsx     the reported failure
  sga-spaces-tab.tsx      .then(setSpaces) -- identical shape, admin view
  booking-settings-tab    every field falls back to a hardcoded default, so an
                          error body silently populated the form with 0/0/24 and
                          an admin pressing Save would have written those over
                          the real booking limits
  settings-modal          error body cast to Settings, offered back for saving
  archive-tab             no .catch at all, so a rejected fetch left
                          setLoading(false) unreached and the tab hung on its
                          skeleton

getJson/getJsonArray take the fallback as an argument, which makes the failure
case impossible to leave out and keeps the return type honest.

The Spaces page handles its response directly instead, because there an empty
list is a meaningful answer -- "no rooms configured" -- and quietly substituting
one for a failed request would state something untrue. It now shows a short
error with a Try again button, which is the same remedy the reporter found.

Not fixed here: why /api/spaces 401s in the first place. Both endpoints in the
report use the plain getAuthedUser path, no user is missing a profile row and no
sessions are revoked, so this is a transient auth failure rather than a
regression. What is fixed is that it can no longer take the page down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
signOut() defaults to scope 'global' -- confirmed in auth-js:

    async _signOut({ scope } = { scope: 'global' })

which revokes every refresh token the user holds anywhere. So the 44-minute
idle timer firing on someone's laptop also ended the session on their phone, in
their other tab, and the copy the Edge middleware refreshes. Each of those then
failed its next refresh with a 400 and began serving 401s, which is how a page
that was working a moment ago starts returning Unauthorized.

Supabase's logs over 24h: of 28 refresh_token grants, 9 returned 400 -- 5 from
browsers and 4 from the Edge middleware. Alongside 28 logouts across ~27 users,
and only 3 of 24 refresh tokens ever having a parent (so rotation is barely
occurring, meaning a 400 is a revoked token rather than a rotation race).

The four sign-outs in dashboard-shell -- the Sign Out button and the three idle
paths -- now use scope 'local'. That is what signing out means on a shared
dashboard: this browser, not every device I own.

The four that mean "this account may not be used" keep the global scope, and
say so in a comment: deactivation in force-sign-out.tsx, LoginCard and
onboarding, plus an expired invite. Ending every session is the point there.

This is a user-facing fix regardless of the 401s -- signing out at a library
machine should not log you out on your phone.

Not claimed: that this accounts for every 401. Vercel's runtime logs are not
readable with the token I have, so the reported failure could not be tied to a
specific revocation. Some 400s also fall in hours with no logouts at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: stop an error response from crashing the page (#53)
Next 16 deprecates the middleware file convention; `next dev` prints a warning
pointing at proxy.ts. Mechanical rename: the file, the exported function, and
the wording in its comments. No logic changed, and there were no
middleware-named config flags to update -- the only export beside the function
is `config.matcher`, which keeps its name.

Verified from the build rather than assumed, because a rename that Next silently
ignored would disable the auth cookie refresh rather than fail loudly.
.next/server/functions-config-manifest.json shows:

    "/_middleware": {
      "runtime": "nodejs",
      "matchers": [ ...the same matcher regex as before... ]
    }

so it is registered and still scoped to the same paths. (The legacy
middleware-manifest.json is emitted empty; registration moved to the functions
config manifest in Next 16. Worth knowing before reading that file as evidence
of anything.)

Note the runtime line: proxy.ts defaults to Node, where middleware.ts ran on
Edge. No runtime is exported, per the Next 16 default. That is the one
behavioural difference here -- the cookie refresh now runs in the function
region alongside the route handlers and the database, rather than at whichever
edge location is nearest the user. Supabase's logs currently show these refresh
calls arriving with a "Vercel Edge Functions" user agent; after this they should
arrive from us-east-1, which is where Supabase is.

Whether that measurably helps is untested and I would not assume it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit renamed the file but shipped it still exporting
`middleware`, so the build failed with "must export a function ... as a named
proxy export".

Cause worth recording: `git mv` stages the rename with the original content, and
the follow-up `git add` named the old path in its pathspec. That path no longer
existed, so git rejected the whole add -- and stderr had been redirected away, so
the one message that would have said so was swallowed. The commit therefore
carried the rename and none of the edits.

The local build passed because it compiled the working tree, which was correct.
Only the commit was wrong, and the working tree is not what CI builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chore: rename middleware.ts to proxy.ts for Next 16
The first page every user hits was re-rendering, and re-querying, on every
request -- 0.5-1.1s TTFB measured against production, against 0.23-0.39s for a
dashboard redirect.

Nothing on it is per-user. It is a comptroller's name, the active semester and a
booking count, all readable by `anon` under RLS. It was dynamic only because it
built its Supabase client from cookies(), and calling cookies() opts a route into
dynamic rendering whether or not the cookies end up mattering. Reading through a
cookie-free anon client instead lets the page prerender; `revalidate = 60`
regenerates it at most once a minute. In the build it moves from `f /` to
`o /  1m`, and locally it serves in 8-29ms from cache rather than being rebuilt
per request.

Reading as `anon` is also the more correct reading. "Bookings this semester"
means all of them, but the cookie client counted only what that visitor's RLS
allowed, so a signed-in visitor and a signed-out one saw different numbers for a
figure describing the whole organisation.

The "Active reservations right now" fact is removed rather than repaired. It
filtered on status 'Confirmed', which exists in none of the three tables it
queried -- the real vocabulary is Reserved / Tentative / Virtual / Alternate
Time / Waitlisted / Unavailable / Cancelled, and 'Confirmed' appears nowhere else
in the codebase. Its count was therefore always 0, and the fact is gated on
`> 0`, so it has never once been displayed. It also compared Boston booking
times against a UTC clock, four hours out, and rolled to the wrong day entirely
after 8pm Eastern. Three queries on the entry page's critical path, for
something that could not render.

That halves the page's database work as a side effect: six round trips down to
three, two of which are parallel.

Behaviour change: the displayed fact is picked with Math.random() at render, so
it now varies per revalidation window rather than per request. Two people
signing in within the same minute see the same piece of trivia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf: make the login page cacheable, and drop a fact that never rendered
The installed PWA showed an "SGA" wordmark while the rest of the site is branded
Chambers. Both icon files were that wordmark.

favicon.ico is the correct mark -- a white key on the navy background -- but it
holds a single 32x32 image, so it is no use as a source for a 192px or 512px
icon. The usable source is the decorative key on the login page, which is
ordinary SVG geometry: two circles and four rounded rects. These icons are that
same geometry rendered at size, so they match the favicon rather than
approximating it.

Provenance, in case they ever need regenerating: the shapes are copied verbatim
from the key in app/page.tsx, minus that element's decorative
scaleX(-1) rotate(-60deg), which leaves the key upright with the bow left and the
teeth down -- the orientation favicon.ico already uses. Background is #0a1628,
the same navy as the manifest's theme_color; the key is white; it sits at 78% of
the canvas width. Rasterised with sharp.

Deliberately full-bleed, with no rounded corners baked in, unlike the favicon.
Android applies its own mask to launcher icons, and a rounded square inside that
mask reads as a visibly clipped corner.

No rounding of the source: the geometry is vector, so 512 is genuinely sharp
rather than a 32px favicon scaled up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A weekly occurrence can already diverge from its series on room, time, status
and reservation code. This adds two more of the three overrides the issue asks
for: purpose, and hidden.

Both follow the convention already in place -- a nullable column on
weekly_room_occurrences where NULL means inherit -- the difference being that
these inherit from the `bookings` row two levels up rather than from
weekly_room_bookings.

hidden is a nullable boolean rather than `not null default false` because it
needs three states, not two: inherit, forced visible, forced hidden. A NOT NULL
default would collapse inherit into visible and make it impossible to publish a
single week of an otherwise hidden series, which is the more interesting half of
what the issue is asking for.

That third state changes how visibility is decided, and the filter in
lib/my-rooms-data.ts is reworked for it. Previously a booking was dropped when
`hidden && !canManage`. Now hidden occurrences are stripped first, and a weekly
series survives if any occurrence remains -- so a hidden series with one
occurrence forced visible shows that week and nothing else. One-time and tabling
bookings have no per-occurrence override and keep the old booking-level rule.

The stripping happens server-side, on purpose. Filtering in the client's flatten
step would mean sending a hidden occurrence to a browser not allowed to see it
and trusting the UI not to draw it, which is the shape of the leak issue #29
already had to be fixed once.

purpose resolves with `??` rather than the `||` the neighbouring room and time
fields use, since only null should mean inherit. Empty input is normalised to
null on write, so a cleared field reads as inherit rather than as a booking
whose purpose is blank.

NOT included: the per-occurrence `is_event` the issue also asks for. Storing it
is trivial, but the Events tab finds events by querying `bookings` where
is_event, and its rows are bookings with their sessions nested underneath.
Teaching it about occurrence-level events changes both that query and the
question of how such an event should be presented in that list -- a design
decision rather than a mechanical addition. Left out entirely rather than
shipping a column and a toggle that nothing reads.

The migration is not applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: use the Chambers key for the PWA icon (#50)
Completes the issue. The occurrence is the event marker, so a weekly event is
one week rather than a whole series, and it appears in the Events tab in its own
right.

is_event is NOT NULL DEFAULT false on the occurrence, not a nullable override
like the purpose and hidden columns in the previous commit. A series is not an
event that individual weeks opt out of -- one week is the event -- so the
occurrence is authoritative with nothing to inherit. The editor gets a checkbox
rather than a three-way select for the same reason, and "Clear all overrides for
this date" deliberately leaves it alone, because being an event is not an
override of anything.

Nothing needs backfilling. Every booking currently flagged is_event is a
One-Time Room, and the Administrator UI has never offered Mark Event on weekly
bookings at all -- only a badge -- so booking-level is_event stays exactly as it
is for one-time and tabling.

The Events route cannot reach these through its existing query: filtering an
embedded resource narrows the child array without selecting the parent, so a
second query fetches flagged occurrences with their ancestry and folds them in.
Each becomes its own row, with `<bookingId>:<date>` as its id -- the checklist
and the pending-actions highlighting are both keyed by row id, and every flagged
week of one series would otherwise collide on the booking's id. The row carries
occurrence_date so the detail block prints one date instead of a "Sep 1 - Sep 1"
range, and its purpose resolves through the occurrence override.

event_tracking had to change shape. booking_id was the primary key, so a booking
had exactly one checklist and two flagged weeks of the same series would have
shared it -- ticking a form on one would tick it on the other. The target is now
(booking_id, occurrence_date), null meaning the booking itself, which is what
one-time and tabling events keep using and what every existing row already is.

Keyed on the date rather than an occurrence id deliberately: the weekly PATCH
handler regenerates occurrences on every save, deleting them all and reinserting
with fresh ids, and values survive only by being carried across on the date. A
foreign key to weekly_room_occurrences(id) would have dropped every checklist
the next time anyone edited the booking. The date is the identifier that write
model actually preserves.

UNIQUE NULLS NOT DISTINCT is what makes the booking-level row work; a plain
unique index treats NULLs as distinct, so the upsert would insert a new row on
every toggle instead of updating. Both migrations were applied and rolled back
against production to confirm the primary-key swap preserves the existing rows.

Migration 20260829001000 is not applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…errides

feat: let a weekly occurrence override its purpose and visibility (#55)
package.json, the lockfile's two root entries, and the sidebar label.

Only lines 3 and 9 of package-lock.json belong to the project. object-inspect is
also on 1.13.4 further down the file, so a find-and-replace across the lockfile
would bump a dependency to a version that does not exist and break npm ci.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pataniaeli pataniaeli self-assigned this Aug 28, 2026
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
chambers Ready Ready Preview Aug 28, 2026 11:57pm

@pataniaeli
pataniaeli merged commit 485085e into main Aug 29, 2026
5 checks passed
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.

Allow occurrences to override their parents' purpose API endpoint error PWA icon is old logo

1 participant