Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .forge/features/instant-judging-updates/evidence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Judging live-update evidence

Recorded September 16, 2026 using two independent Chromium sessions against a
local Next.js development server and disposable PostgreSQL database. All people,
projects, and room assignments are synthetic. The officer uses the real Blade
controls while the other browser stays on the judge page.

| Action | Main at 361d10a5 | SSE branch |
| --------------------------------------------- | ---------------: | ---------: |
| Publish announcement → visible to judge | 29.987 s | 0.973 s |
| Save reassignment → new room visible | 0.939 s | 0.864 s |
| Restore network → missed announcement visible | Not recorded | 0.214 s |

These are single local observations, not a benchmark or production latency
promise. The baseline reassignment happened just before its next polling refresh,
so that baseline is already quick. The announcement trial demonstrates the delay
from polling. Both recordings use the same fixture and actions.

## Videos and screenshots

The PR description embeds the before/after recordings as GIF animations, with
the timing table, result screenshots, and reproduction procedure inline. The GIFs
are 1280 pixels wide at five frames per second and preserve normal playback speed
(duration rounded by less than one frame). The MP4s below retain full resolution.

- [Before: organizer and judge](before.mp4)
- [After: organizer and judge, including recovery](after.mp4)
- Original videos: [before organizer](before/organizer.webm),
[before judge](before/judge.webm), [after organizer](after/organizer.webm),
[after judge](after/judge.webm).
- Judge screenshots: [announcement](after/announcement.png),
[reassignment](after/reassignment.png), [recovery](after/reconnected.png).

The split-screen videos play the recorded actions and waits at normal speed.
After visual review, we trimmed 27.5 seconds of startup from Before and 2.5 seconds
from After, added captions identifying the actions and simulated network outage,
and appended a three-second announcement screenshot labeled as a still image.
The timed publish-to-visible sequences remain intact. Original WebM recordings
are unchanged. The two browser contexts were recorded independently; exact latency
comes from the timestamps below, not from comparing the video clocks.

## Measurement and reproduction

1. Use an active hackathon with judging open, a rubric, two staffed rooms, and a
future Aurora appointment in ENG 101. Sign an officer and a judge into separate
browser contexts. Keep the second room's judge session current.
2. Open the officer's Rooms tab and the judge's Projects page. Browse all rooms so
Aurora remains visible after reassignment. Wait for the initial announcements
query to finish.
3. Publish “Judges: please check the updated room assignment for Aurora.” Record
button-click time and the time that exact text becomes visible to the judge.
4. Dismiss the judge's banner. In Schedule, reassign Aurora to ENG 102. Record save
click time and the time ENG 102 appears in the judge's Aurora row.
5. For recovery, put the judge context offline with Playwright, replace the
announcement from the officer context, wait at least 20 seconds offline, then
restore networking. Observe the new announcement without reloading the page.

[Before timestamps](before/timings.json) show no SSE connections.
[After timestamps](after/timings.json) include actual native EventSource opens
and messages, observed without substituting the transport. The announcement
notification arrived 223 ms after Publish; the reassignment notification arrived
151 ms after Save. Rendering/refetch then produced the visible timings above.
The recovery run opened a new stream and caught up on its initial invalidation.

[Endpoint response](sse-response.json) confirms HTTP 200, `text/event-stream`,
`X-Accel-Buffering: no`, and a real initial invalidation.

## Limits

This ran on loopback HTTP, not the production HTTPS proxy. Production must support
unbuffered streaming and a session-capable PostgreSQL connection. Discord was
intentionally disconnected in the fixture. Deadline/recovery polling remains.
The temporary database, baseline worktree, and capture helpers were removed after
verification; source videos, screenshots, timestamps, and this procedure remain.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"mode": "after",
"baseline": "361d10a5",
"viewport": {
"width": 1440,
"height": 1000
},
"events": [
{
"event": "announcement",
"buttonClickedAt": 1789592165332,
"visibleAt": 1789592166305,
"latencyMs": 973
},
{
"event": "reassignment",
"buttonClickedAt": 1789592169026,
"visibleAt": 1789592169890,
"latencyMs": 864
},
{
"event": "reconnect",
"reconnectedAt": 1789592194152,
"visibleAt": 1789592194366,
"latencyMs": 214
}
],
"readyAt": 1789592164999,
"offlineAt": 1789592171636,
"sse": {
"connections": [1789592164766, 1789592194157],
"messages": [
1789592164877, 1789592165555, 1789592169177, 1789592174105, 1789592194249
],
"opens": [1789592164856, 1789592194242],
"navigations": [
{
"at": 1789592161867,
"url": "http://127.0.0.1:3123/judge/projects?room=all"
},
{
"at": 1789592164589,
"url": "http://127.0.0.1:3123/judge/projects?room=all"
},
{
"at": 1789592165360,
"url": "http://127.0.0.1:3123/judge/projects?room=all"
},
{
"at": 1789592166063,
"url": "http://127.0.0.1:3123/judge/projects?room=all"
},
{
"at": 1789592169587,
"url": "http://127.0.0.1:3123/judge/projects?room=all"
},
{
"at": 1789592194531,
"url": "http://127.0.0.1:3123/judge/projects?room=all"
}
]
},
"finishedAt": 1789592196533
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"mode": "before",
"baseline": "361d10a5",
"viewport": {
"width": 1440,
"height": 1000
},
"events": [
{
"event": "announcement",
"buttonClickedAt": 1789591205650,
"visibleAt": 1789591235637,
"latencyMs": 29987
},
{
"event": "reassignment",
"buttonClickedAt": 1789591239742,
"visibleAt": 1789591240681,
"latencyMs": 939
}
],
"readyAt": 1789591205461,
"sse": {
"connections": [],
"messages": []
},
"finishedAt": 1789591242880
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Local filesystem roots normalized below; analyzer diagnostics unchanged.

> forge@ analyze:react <repository>
> tsx scripts/analyze-react.ts "<baseline-worktree>/apps/blade/src/trpc/react.tsx"

{
"analyzedFiles": 1,
"componentCount": 0,
"failures": 1,
"results": [
{
"file": "<baseline-worktree>/apps/blade/src/trpc/react.tsx",
"ok": false,
"error": "<baseline-worktree>/apps/blade/src/trpc/react.tsx: Cannot read properties of undefined (reading 'type')"
}
]
}
(node:70677) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:70677) ExperimentalWarning: localStorage is not available because --localstorage-file was not provided.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"status": 200,
"contentType": "text/event-stream",
"buffering": "no",
"sample": "event: connected\ndata: {\"reconnectAfterInactivityMs\":45000}\n\n\ndata: {\"json\":{\"changed\":true}}\n"
}
130 changes: 130 additions & 0 deletions .forge/features/instant-judging-updates/pr-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Why

Judges could wait up to the next 30-second poll to see an organizer's announcement.
Blade now pushes a change notification when a judging transaction commits, then
refreshes the existing authorized views. The recorded local announcement appeared
in **0.973 seconds**, compared with **29.987 seconds** on main.

# What

Add a tRPC SSE subscription to the judge workspace and command center. PostgreSQL
LISTEN/NOTIFY carries invalidations between app processes. Announcements, room and
access changes, saved schedule changes, resets, and finalized scores publish
within their transactions.

The stream rechecks access, sends keepalives, and refetches on reconnect. Offline
page refreshes pause until networking returns. Existing queries retain their
room/guest filtering; polling remains for deadlines and recovery. Scope is Blade
and its API implementation.

Closes #574.

# Before and after

Organizer on the left; judge on the right. These are inline animations of the
reviewed browser recordings, at **normal speed**. The full polling wait and the
simulated network outage are shown. Startup footage was trimmed; the final
three-second result screenshot in each recording is explicitly labeled as a still.

**Before — main at `361d10a5`: the announcement waits for the next poll.**

![Before: organizer publishes an announcement; the judge waits almost 30 seconds](evidence/before.gif)

**After — SSE: announcement, reassignment, and recovery from a network outage.**

![After: instant judging updates and automatic recovery after the judge goes offline](evidence/after.gif)

| Action | Before | After |
| --------------------------------------------- | -----------: | ----------: |
| Publish announcement → visible to judge | 29.987 s | **0.973 s** |
| Save reassignment → new room visible | 0.939 s | **0.864 s** |
| Restore network → missed announcement visible | Not recorded | **0.214 s** |

These are single local observations. The baseline reassignment happened just
before a poll, so it was already quick. Exact timings were measured from the
button click to visible text, independently of video clocks. The actual SSE
notification arrived 223 ms after Publish and 151 ms after Save; the visible
result includes the subsequent refetch and rendering.

<details>
<summary>Full-size result screenshots</summary>

Announcement visible in the judge's view:

![Judge announcement](evidence/after/announcement.png)

Aurora reassigned to ENG 102:

![Judge sees the new room](evidence/after/reassignment.png)

Missed announcement received after restoring networking, without a page reload:

![Judge catches up after reconnecting](evidence/after/reconnected.png)

</details>

<details>
<summary>Recording setup and reproduction</summary>

Recorded September 16, 2026 in two independent Chromium sessions against a local
Next.js development server and disposable PostgreSQL database. People, projects,
and assignments are synthetic. Both versions use the same fixture and actions.
Discord was deliberately disconnected.

1. Create an active hackathon with judging open, a rubric, two staffed rooms, and
a future Aurora appointment in ENG 101. Sign an officer and a judge into
separate browser contexts.
2. Open the officer's Rooms tab and the judge's Projects page. Browse all rooms
so Aurora stays visible after reassignment. Let the initial queries finish.
3. Publish “Judges: please check the updated room assignment for Aurora.” Measure
from Publish to that text appearing in the judge's view.
4. Dismiss the banner, then reassign Aurora to ENG 102 from Schedule. Measure
from Save to ENG 102 appearing in the judge's Aurora row.
5. Put the judge browser offline, replace the announcement in the officer
browser, wait at least 20 seconds, and restore networking. The judge should
receive the missed announcement without reloading.

The baseline opened no SSE connection. The after recording used native
EventSource with no substituted transport. The endpoint returned HTTP 200,
`text/event-stream`, `X-Accel-Buffering: no`, and an initial invalidation.
Original WebMs, MP4s, screenshots, and timestamps are retained in this PR's
feature bundle for provenance; the comparison above is self-contained.

</details>

# Test Plan

[CI on `6b70662d`](https://github.com/KnightHacks/forge/actions/runs/35152330825): **passed**, including all **2,481 tests**, database tests, migrations, lint, formatting, typechecking, and the full production build.

- Repository `pnpm format`, `pnpm lint`, and `pnpm typecheck`: passed.
- Initial focused tests: 25 API tests and 11 Blade tests passed.
- CI exposed two existing page tests that assumed the workspace was the outer
React element. Reproduced both failures locally and updated them to render the
page and inspect workspace props. The original hackathon-selection/read-only
assertions remain; both tests also check the listener's hackathon scope.
- Full CI test command, `pnpm exec turbo run test --filter='!@forge/db'`,
against a fresh PostgreSQL 16 container: **2,481 tests passed** across 351 files,
including all 988 API tests and all 881 Blade tests. The database package has
its own CI job, which passed on the initial PR run.
- Full monorepo production build: **21 tasks passed**, with no cached tasks.
Ran `pnpm build --env-mode=loose` with temporary process values from
`.env.example`, allowing those CI example values through Turbo. No environment
files or build configuration were changed.
- Four changed/new React components/pages passed strict analysis.
- `pnpm analyze:react:changed` hits an existing parser error in the tRPC provider:
`Cannot read properties of undefined (reading 'type')`. Reproduced on main at
`361d10a5`; no check bypass was added.

Local workstation roots in the analyzer transcript are redacted. CodeRabbit's
advisory docstring-coverage suggestion was not applied: the transport helpers
already document transaction delivery, listener ownership, and reconnect behavior;
extra docstrings repeating handler names would add little.

Production proxy streaming and database connection mode still need verification;
the recordings use loopback HTTP. No deployment was run. Schedule solver progress
and private draft autosaves retain their existing refresh behavior.

## Checklist

- [x] Database: no schema changes.
- [x] Environment Variables: no environment variables changed.
18 changes: 18 additions & 0 deletions .forge/features/instant-judging-updates/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Instant Judging Updates

Status: Implemented and verified locally; production streaming check remains.

Judges and officers should see committed announcement, room assignment, schedule,
and scoring changes as they happen, without waiting for a polling interval.
The user confirmed immediate updates and requested before/after videos for PR proof.

Scope: Blade judging pages and their API writes. Existing access rules, input
forms, navigation, and score ownership remain the contract. Disconnected browsers
catch up when they reconnect. Other apps and hacker portal live updates are out
of scope.

Acceptance: a connected judge receives announcements and schedule repairs without
waiting for the current 15/30-second intervals. Record elapsed time in separate
organizer/judge browser sessions. No promise of zero network or rendering latency.
Refetch after reconnect, preserve unsaved answers, and stop delivery after access
revocation. Keep current time-based refreshes for deadlines and recovery.
33 changes: 33 additions & 0 deletions .forge/features/instant-judging-updates/srd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Instant Judging Updates SRD

Use tRPC 11 SSE subscriptions on Blade's existing HTTPS endpoint. Queries and
mutations stay on HTTP. The client subscribes once per judging page and invalidates
judging/project queries and refreshes Server Components on each notification.
The subscription sends an initial invalidation after LISTEN is ready so reconnects
and the initial fetch/subscribe race recover from the database.

Use PostgreSQL LISTEN/NOTIFY through the existing driver/pool. One dedicated
connection per API module instance serves all local subscribers and is destroyed
when the last subscriber disconnects. PostgreSQL distributes notifications across
processes. Publish inside transactions; rollbacks emit nothing and Discord delivery
cannot delay the notification. Payloads contain only the hackathon ID, never scores,
messages, credentials, or personal data. Coalesce pending invalidations per subscriber.

Reuse judgeProcedure and existing hackathon selection. Guests are restricted to
their hackathon. Recheck guest access and member permissions on each event and every
30 seconds while idle. The client refetches through existing room/guest-filtered
queries. Use 15-second SSE keepalives and reconnect after 45 seconds of silence.
Retry transient stream failures with capped backoff; do not retry access denials.
Reset the subscription when the browser comes online. Skip background server
refreshes while offline: Next.js otherwise falls back to a full navigation when
an RSC request fails, losing the page needed to reconnect.

No schema, migration, dependency, environment, or production deployment changes.
The deployment requires a session-capable PostgreSQL connection (not transaction
pooling) and end-to-end unbuffered HTTPS streaming. Validate actual proxy behavior
before claiming production delivery. Existing polling remains for recovery and
clock-driven deadline reconciliation. Private draft autosaves do not broadcast.

Validate API/Blade and affected API consumers with typechecking, scoped regression
and integration tests, React analysis, lint/format, and browser recordings. Baseline
is main at 361d10a5. Capture before/after against synthetic local judging data.
Loading
Loading