cleanyourtv-app
Clean Your TV를 위한 커뮤니티 제작 모바일 앱
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Web tabulation for judged competitions. An admin sets up an activity, judges score entries from their own devices, and a public page shows every judge's score and the average, ranked live.
/admin): create activities with a name, a score range (min to max), decimal places (whole numbers, or 1, 2 or 3 decimals; a pageant's sub-activities go to 2), whether scores show live (see Live scoring or a deliberation), judges with names and photos, and entries. Every photo (judges, entries and candidates, organizers) opens in a crop step first: drag it to choose what shows, zoom to make it bigger or smaller (or out to fit the whole photo), and it's saved as a small square. Each activity's tabs follow the event day: Session (the control desk), Judges (the panel, each judge's code and QR code, and approving their devices; the panel can be changed there until the session starts, including which judge chairs it: Make chair hands it to another judge), and LED wall for running the show; Entries and Settings (name, organizer, delete) for setting it up, where test sites add Test site tools (reset scores, and deleting the activity even mid-session). The live site doesn't have them, and nor do Vercel previews unless you turn them on (see Deploying to Vercel). Once its session has ended, an activity can be archived from the list of activities or its ended Session tab, when no more deliberating is needed: it moves to Archived activities (below the list) and from then on can't be reopened, deliberated again or changed, nor taken out of the archive; every judge's device is signed out and their codes stop working, the private tally closes, and the live results page closes: its link no longer works. From then on it can only be opened read-only to download its results, score sheets and the judges' notes, or deleted. Pageant mode is offered on test sites (locally, in CI and on a testing preview); the live site shows it as coming soon. On the Session tab you can hide or show ranks on the public page, and download the official results once every judge has scored every entry, from one Export menu: the results and the judges' score sheets as PDFs, the results and every judge's scores as CSV, and the judges' notes. A CSV carries the sheet's report ID and the SHA-256 fingerprint of its rows, and Check a CSV on the Settings tab says whether a copy is unchanged and matches the scores (a CSV is plain text, so it can't be locked; this is how an edited one is caught). Cells a spreadsheet would run as formulas are written as text. The PDF carries a report ID that changes if any score changes, marks each judge's sign-off (Sgd.), or their drawn signature after a deliberation, and prints the organizer's photo and the activity's signatories (for example the Board of Tabulators) with designations, set on its Settings tab. Next to it, Download judges' score sheets gives a second PDF with one sheet per judge: every entry in running order with that judge's score (and, with criteria scoring, their points for each criterion), signed by that judge and the signatories, under the same report ID. When judges wrote notes, Download judges' notes gives them as a third PDF, apart from the results. An archived activity's page has no live results link, QR code or LED wall: their pages are gone.ADMIN_PASSWORD) creates, edits and deletes organizer accounts with an email, password, organizer name and photo. Organizer passwords need at least 8 characters, a number and a special character; the form ticks the rules off as they're typed. Organizers sign in with their email and see only their own activities; their name and photo show in the admin header, and their photo, as their logo, heads their activities' live results. An organizer can change the password they were given from their Profile (their current password first); their other devices are signed out. The super admin sees every activity and can hand one to an organizer from its Settings tab; organizers' activities are listed live ones first, then the newest 50 at a time. Deleting an organizer keeps their activities for the super admin./judge): a judge enters their six-character code, or scans their QR code (which fills the code in, so they just tap Start judging), to sign that device in. The link itself signs nothing in: a messaging app previewing it, or a QR scanner checking it, would otherwise ask the organizer to approve a device that isn't the judge's. The device shows a pairing code and waits until the organizer approves it on the Judges tab, or from that judge's tile on the Session tab; only one approved device per judge can score, so a stray device that scanned the QR can't. Their screen then waits until the organizer starts the session, then shows the entry the organizer picks. They type the score on a large keypad and submit it, then wait for the next entry. Before a score goes in, the confirmation lays out each criterion's points to check, and after submitting the judge has 5 seconds to undo it; a score confirmed just before the organizer moves on still counts. With live scoring, that's final. In an event without live scoring (and in a pageant), a judge who entered a wrong score can ask to correct it while the entry is on screen (or any entry during the deliberation), with a reason: every other judge on their panel gets a pop-up they can't close until they vote (it waits while they're typing or undoing their own score), showing the reason (a pageant's also shows the score and the new one; an event's keeps the scores private), and the score changes only if every one of them agrees (one disagreeing keeps it). A judge alone on their panel asks the organizer instead, who approves or turns it down on the desk. The desk lists every correction and how the vote stands; the results PDFs wait while one is open, then list the corrections that changed a score. Once a score is in, its card lists the points for each criterion, and on a phone the count in the header opens the judge's scores so far (wider screens list them beside the keypad). Judges can keep a note on each entry: under the entry's name on a phone, or in a column beside the keypad from 1280px wide, which can pop out into a window they drag anywhere. Only that judge and the organizer read the notes: the organizer downloads them from the Session tab as a PDF of their own, a page per judge under the organizer's logo (the score sheets don't print them).|, or pasted from a spreadsheet, and edited beside each entry until judging ends. Only the organizer sees them: on the Entries tab, and as columns in the results PDF and CSV. Judges and the public pages never get them./live/<id>): every judge's score and the average for each entry, updated in real time and made to be projected. Once the session has ended it says Ended, with the date and time, in place of Live; during a deliberation it says Judges deliberating, and an event without live scoring shows its scores only once the judges have signed. With ranks on, entries are sorted by average and the top 3 stand out; with ranks off, entries stay in running order. A pageant's page follows the sub-activity being judged, with only the candidates still in the running./privacy, /terms): public pages, linked from the start page, both sign-in pages and the foot of the admin panel. The operator, contact address and effective date are in src/lib/legal.ts; the privacy policy describes what the app actually does (its cookies, logs, what the public pages show, what deleting removes), so change it together with that code./led/<id>): one entry's scores for the LED wall or video switcher, either as a slim lower third on chroma green (#00FF00) to key over the camera shot (one bar along the bottom with the entry, a tile per judge and the average, so the camera keeps the rest of the frame), or as a full-screen scoresheet. A pageant's wall shows the sub-activity being judged, with its name under the candidate's, and the candidate's photo from the setup beside it in both displays. Scores fade in as judges submit them, or, with Show scores only when every judge has scored, all at once when the last judge is in. With Keep judges anonymous, each judge's name and photo on the wall becomes a "?". The wall's Look is chosen there too: a font (standard, elegant serif, modern, condensed or classic capitals), a colour theme (navy, black and gold, crimson, emerald, royal purple, midnight teal, rose gold, sunset amber, berry, graphite, or a light pearl, champagne or sky, each keeping its text readable) and square, soft or rounded corners, for the green screen and the full screen alike. The green screen itself stays chroma green. The wall follows the entry being judged, fading each entry and score in; the admin picks the display in the LED wall tab.Built with Next.js 16, Supabase (Postgres, Realtime and Storage) and Tailwind CSS 4.
Create a Supabase project at supabase.com.
Create the database. In the Supabase dashboard open SQL Editor, paste the contents of supabase/schema.sql and run it. It creates the tables, the score rules, the change feed live screens listen to over realtime, and the photo storage buckets. Running it again is safe.
Databases set up before a feature existed need the migrations in supabase/migrations/. Once the release pipeline is set up (see Releasing), new ones reach both databases from GitHub when they're merged; to bring a database up to date by hand, run them in order: 003_led_wall.sql (also covers 002) for show ranks and the LED wall, then 004_organizer_accounts.sql for organizer accounts and the LED display settings, then 005_judging_session.sql for judging sessions, then 006_devices_criteria_signatories.sql for judge device approval, criteria scoring and PDF signatories, then 007_entry_photos_result_decimals.sql for entry photos and result decimals, then 008_judge_names_and_chair.sql for judges' first and last names and the chair, then 009_led_transition.sql (optional now: it added an LED wall animation choice that has since been removed), then 010_led_anonymous_judges.sql for keeping judges anonymous on the LED wall, then 011_pageant_mode.sql for pageant mode (segments, sub-activities, cuts and the scoring timer), then 012_pageant_parts_and_drafts.sql for sub-activities scored in parts and saved drafts, then 013_judge_panels.sql for separate boards of judges, each with its own chair (if you ran an earlier copy of 013, run it again: it's safe to repeat), then 014_led_look.sql for the LED wall's font, colour theme and corners, then 015_deductions_and_themes.sql for pageant deductions and the LED wall's newer colour themes, then 016_private_results.sql, which makes scores and the other results tables private (see How it works below), then 017_sign_in_limits.sql for limits on guessing admin passwords and judge codes (until it's run, sign-ins work without the limit), then for judges' notes (until it's run, judges' screens leave notes out), then for score corrections (until it's run, judges aren't offered them and scores stay final), then for when a session ended and archiving (until it's run, the live results say "Ended" without the time and activities can't be archived), then for live scoring on or off, the deliberation, judges' signatures and three decimal places, and two indexes for many activities at once (until it's run, every event scores live), then for admin sessions that can be ended (until it's run, sessions work as before; once it is, admins sign in again once), then , three indexes that keep deleting an entry or an activity, and the super admin's list of organizers' activities, quick however many scores and activities there are (best run between shows), then , the rule that a session with scores can't go back to not started, for databases that ran 021 before it was added there (it changes nothing on one that has it), then , for a judge correcting their own score in an event without live scoring, then for breaking ties in the deliberation and the organizer's signature that ends it, then for signatories set on each activity, which starts each one with its organizer's (until it's run, every activity prints its organizer's), then , so the database itself refuses a score once judging has ended, and a score a judge confirmed just before the organizer moved on still counts (until it's run, such a score is refused and the app alone checks the session), then , which makes the database finish what used to take the app several writes (deliberating again or reopening clears every signature in the same change; archiving signs every judge's device out in it) and keep rules the app followed on its own (the deliberation starts only with every score in, no entry is added during it, and a judge has one approved device at most). Until it's run, the app makes these changes itself, as before. Then , which has the database check that the session hasn't started and write the running order in one step (moving an entry, or shuffling an event's order), so a session starting at that moment can't begin on half a new order. Until it's run, the app checks and writes the order itself, as before. Then , which hands the chair of a board of judges from one judge to another in one step, and only before the session starts. Until it's run, the app hands it over itself. Then , for judges' calls for help (), judges seeing the overall ranking in the deliberation, and the privacy screen on judges' phones (on for activities created after it, off for those before). Until it's run, the app leaves all three out. Then , for entry details (up to three per event, such as a school or a division, seen only by the organizer on the Entries tab and in the results) and correction reasons from a list. Until it's run, the app leaves both out. Then , for judges scoring at their own pace. Until it's run, every event is judged an entry at a time. Each only adds tables and columns, so a deployed older version keeps working after you run it, except 016: an older version's screens then stop hearing realtime changes and check every few seconds instead, until this version is deployed.
Use two Supabase projects, each set up with schema.sql: one for the live site, and one for testing (running locally, the web tests and Vercel previews). Rehearsals, test runs and previews then never touch a real event.
.env.local has the testing project's values. npm run dev and npm run test:e2e use it..env.production.local has the live project's values. npm run build and npm start use it (Next.js reads it ahead of .env.local in production mode), so a laptop running the event at the venue scores into the live database. Give it every variable: one missing there falls back to .env.local.SESSION_SECRETs, so a sign-in from testing never works on the live site.vercel env pull to fill .env.local: Vercel's Development environment has none of these values (sensitive ones can't go there), so it would replace your testing settings with a file that has none of them./judge and type the code. Each device shows a pairing code: approve the matching one on the Judges tab, or from the judge's tile on the Session tab.npm start on a laptop) unless TEST_TOOLS=on says it uses a testing database.Fourteen activities live at once with three judges each (42 judges' devices, a desk and an LED wall per room) were load tested on the testing project: all their screens stayed connected and scores kept arriving, with room to spare, at many times the pace of a real show. Two things decide whether a long day holds up:
Judges' phones and tablets must be on the same Wi-Fi as the laptop. For the event itself, use a production build; it is faster and more reliable than the dev server, and it uses the live database from .env.production.local (see A live database and a testing database):
npm run build
npm start -- -H 0.0.0.0
The admin panel works at http://localhost:3000/admin on the laptop. Judge links, QR codes and the results link automatically use the laptop's Wi-Fi address (for example http://192.168.1.16:3000) so other devices can open them. Set NEXT_PUBLIC_SITE_URL to pin a specific address.
npm run dev -- -H 0.0.0.0 also works for testing on phones; the dev server allows this laptop's network addresses.
On its own the app can't tell the devices on the Wi-Fi apart. Next.js doesn't give server actions the connection's address, so the limits on guessing judge codes and admin passwords count by the X-Real-IP and X-Forwarded-For headers, which any phone can send. deploy/nginx/tabulous.conf puts nginx in front: it replaces those headers with each device's real address, lets go of stalled connections, refuses oversized requests, and caps sign-in attempts per device (judge sign-in and admin sign-in only; scoring is never slowed). The app then listens on the laptop alone, so every device goes through nginx.
Install nginx: brew install nginx on a Mac, sudo apt install nginx on Ubuntu or Debian.
Include the config inside nginx's http block. On a Mac: ln -s "$PWD/deploy/nginx/tabulous.conf" "$(brew --prefix)/etc/nginx/servers/tabulous.conf". On Linux: sudo cp deploy/nginx/tabulous.conf /etc/nginx/conf.d/, and remove the package's welcome site (sudo rm /etc/nginx/sites-enabled/default), which would otherwise answer on port 80 instead. Check it with nginx -t.
Start the app on the laptop only, then nginx:
npm run build
npm start -- -H 127.0.0.1
brew services start nginx # Linux: sudo systemctl reload nginx
The admin panel is then at http://localhost/admin, and judge links, QR codes and the results link use the laptop's Wi-Fi address without a port (http://192.168.1.16). nginx listens on port 80: if it can't open it (another app has it, or the system wants it started with sudo nginx), change both listen lines in the config to another port, such as 8080, and open that port instead. It doesn't add HTTPS: phones only trust a certificate for a real domain, so on venue Wi-Fi sign-ins still travel unencrypted. For that, run the event on Vercel.
Import the repository in Vercel and add the same environment variables. Set NEXT_PUBLIC_SITE_URL to your production URL.
The server functions run in Seoul (icn1, set in vercel.json), the region of both Supabase projects: every page and action makes a few trips to the database in a row, and from Vercel's default region (Washington, D.C.) each one crosses the Pacific. If you move the database, move the functions with it.
Give the live project's values to the Production environment only, and the testing project's to Preview (pull requests and branches), with their own SESSION_SECRET and TEST_TOOLS=on. Previews hide the test site tools, which reset scores and delete activities, unless TEST_TOOLS=on: a preview given the live variables by mistake can't wipe a real event. Running locally (and in CI) always has them.
Pull requests deploy to Vercel previews on the testing database; main deploys to production. Three things keep a release safe; each needs a one-time setting.
Only tested code reaches production. In GitHub, protect main (Settings > Rules > Rulesets, or Branches): require a pull request and the CI checks Lint, types and unit tests, Schema and migrations and Web tests. In Vercel, add the same checks as Deployment Checks (Project > Settings > Deployment Checks), so a production deployment goes live only once they've passed.
Migrations reach the databases from GitHub (.github/workflows/migrate.yml): merged into main, a new file in supabase/migrations/ is applied to the testing project, then to the live one once you approve it. Each project records what it has run, so nothing runs twice by accident. To set it up:
SUPABASE_ACCESS_TOKEN.testing and live. In each, add the variable SUPABASE_PROJECT_REF (the project's id, from its URL) and the secret SUPABASE_DB_PASSWORD (Project Settings > Database). On live, add yourself as a required reviewer: live migrations then wait for your approval.npx supabase migration repair --status applied 002 003 004 005 006 007 008 009 010 011 012 013 014 015 016 017 018 019 020 021 022 023 --linked --project-ref <live ref>. The testing project is already recorded through 023. The pipeline runs everything after that (024 first), the next time a migration is merged or when you run the workflow by hand.Writing a migration: the next number (024_what_it_does.sql), and the same change in supabase/schema.sql, which creates new databases. A migration only adds: never drop or rename anything the deployed version still uses, since the database is shared by the version before and the version after, and a rollback goes back to the version before. Remove it in a later migration, once no deployed version uses it. Make it safe to run twice (if not exists, create or replace). Never change a migration once it's merged: a database that has run it won't run it again, so the change would never reach it (the session rule added to 021 after the live project had run it is why 024 exists). Put the change in a new one. npm run check:migrations checks all of this against main, and CI runs it on every pull request.
Production changes between shows. A deploy reloads every screen that's open (a judge loses what they were typing), so don't let one go live mid-show. Either don't merge while a show is running, or turn off automatic promotion (Vercel > Settings > Environments > Production > Auto-assign Custom Production Domains): each production build then waits until you promote it. Skew Protection (Vercel Pro) lets screens open before a deploy carry on with the version they loaded. To undo a release, Instant Rollback switches back to the previous deployment; the migrations only add, so the version before keeps working on the database as it is.
Node.js is pinned in package.json (engines), which Vercel and CI both use.
activity_changes: one row per activity saying when it last changed, kept up to date by database triggers. Scores, judges, entries, activities and programs are private, so hidden ranks and anonymous judges can't be read around the app.SESSION_SECRET). Admin sessions last 12 hours and judge sessions 3 days. To change the secret without signing everyone out (mid-event, say), set SESSION_SECRET_PREVIOUS to the old value and SESSION_SECRET to the new one, and redeploy: sessions signed with either keep working, and every new one is signed with the new secret. Remove SESSION_SECRET_PREVIOUS after 3 days, once the last session it signed has run out. Changing SESSION_SECRET alone signs everyone out at once, which is what you want if it leaked. Organizer passwords are hashed with scrypt; changing one signs that organizer out on other devices, and deleting an account ends its sessions. Changing ADMIN_PASSWORD signs the super admin out everywhere.admin_sessions, named by its cookie, and checked on every request (alongside the account, so it adds no wait). Signing out deletes the row, so the session ends wherever its cookie is, a copy included; an admin's profile lists the browsers signed in to their account and signs any of them out. Every app instance checks the same table and the same signature, so any instance can serve any request.next start trusts the browser's, so there only the per-account limit holds.The server writes one JSON object per line to its log (on Vercel: the project's Logs), so lines can be searched by field, counted, and turned into percentiles. Nothing secret goes in: no passwords, judge codes, cookies or query strings.
event | When | Fields worth watching |
|---|---|---|
action | Every server action and export, once answered | action, outcome (ok, refused with the person's error, or failed when it threw), ms in all, dbMs of it waiting on the database over dbCalls calls (dbErrors failed), request (Vercel's request id) |
db_error | A database or Storage call that failed (not a duplicate a retry expects) | path (the table or file), status, the database's code and error |
slow_db | A single database call over 500 ms | path, ms |
request_error | A page, route or action that threw | route, kind, digest (the reference the admin's error screen shows) |
client_problem | A screen reporting what went wrong on it | kind (score_unreachable, action_unreachable, out_of_date, realtime_down, screen_error), where, device |
runtime | Each minute, per server, in production | loopP99Ms (how late its timers run: a server too busy to answer promptly), rssMb, heapMb |
score_not_saved and others | A write that failed, named for what it was | code, error |
ms per action (not the average: a few slow saves are what judges notice), with dbMs saying how much of it was the database.failed actions, db_errors and client_problems per action or screen. A refused action is the app saying no (a validation message), unless dbErrors is above 0.runtime.loopP99Ms and memory per server; database CPU and connections in the Supabase dashboard; Realtime's own share of database time in the show report below.supabase/show-report.sql resets the database's query statistics before a show and lists afterwards where its time went: each query's calls, mean, spread and worst case.| Command | What it does |
|---|---|
npm run dev | Start the dev server |
npm run build | Production build |
npm test | Unit tests for keypad input, score validation, ranking and progress |
npm run test:e2e | Web tests in a real browser (Playwright) |
npm run lint | ESLint |
npm run typecheck | TypeScript |
npm run check:migrations | schema.sql and the migrations agree (against origin/main, or the ref given) |
GitHub Actions runs on every pull request and every push to main (.github/workflows/ci.yml):
npm run lint, npm run typecheck, npm test.npm run check:migrations: a database set up from main's schema.sql and brought up to date by this branch's new migrations matches one created from this branch's schema.sql, and each new migration is safe to run twice.supabase/schema.sql, builds the app and runs the Playwright tests in e2e/ against it. They walk through a whole event: creating an activity with a judge photo, judges scoring on a phone and a tablet, the live board updating in real time, hiding ranks, the results PDF and the judges' score sheets, the LED wall, and resetting and deleting from Settings on a test site. A second run takes a pageant from its step-by-step setup (candidates reordered and given a photo that shows on the LED wall in both displays, separate boards of judges for the preliminary and pageant proper, and deductions for violations listed) to the final results: a cut with a tie on the line that the organizer breaks, a scoring timer that closes and is reopened, and each sub-activity's, cut's and segment's results PDF. They also check sign-in guards and scan key pages for WCAG 2.1 AA issues. The HTML report is attached to each run.To run the web tests locally, start the app with npm run dev and run E2E_PORT=3000 npm run test:e2e. They use your .env.local project, create an activity named E2E … and delete it at the end.
The UI uses Avenir Next, which is built into Apple devices. Other devices fall back to Nunito Sans from Google Fonts. If you have an Avenir web-font license, add the files with next/font/local in src/app/layout.tsx.
018_judge_notes.sql019_score_corrections.sql020_session_end_and_archive.sql021_deliberation.sql022_admin_sessions.sql023_indexes.sql024_session_restart_rule.sql025_judge_corrects_own_score.sql026_tie_breaks_and_organizer_signoff.sql027_activity_signatories.sql028_score_window.sql029_atomic_transitions.sql030_entry_order.sql031_judge_chair.sql032_assistance_ranking_privacy.sql033_entry_details_and_reasons.sql034_independent_scoring.sqlConfigure the app. Copy .env.example to .env.local and fill it in. The Supabase values are under Project Settings > API Keys. Keep real values out of .env.example, because that file is committed.
Run it.
npm install
npm run dev
Open http://localhost:3000/admin/login?as=super and sign in with ADMIN_PASSWORD as the super admin. Create organizer accounts on the Organizers page.
src/proxy.ts): only the page's own scripts run, so a script slipped into a page wouldn't, and a page can talk only to this site and the Supabase project, so nothing could be sent elsewhere.tally_opened, tally_refused) with who and on what device. A browser can't stop a screenshot, so the tally is made hard to leak instead: it carries a watermark with the viewer's name and the time, can't be selected, copied or printed, and blurs while the page is out of view (as in a phone's app switcher).TEST_TOOLS=on, including npm start on a laptop at the venue, which uses the live database.npm start on a laptop, there's no platform in front of the app to vouch for where a request came from: the per-address sign-in limits trust the browser's own headers there unless nginx is in front with deploy/nginx/tabulous.conf (see Running on a laptop over the venue Wi-Fi), which replaces them with each device's address. Everyone on the venue Wi-Fi shares the limits on Vercel (one public address). Sign in to the admin panel before the show; sessions last 12 hours. A judge code on its own is no use to a stranger: their device still needs the organizer's approval.activity_changes, so anyone can see activities' ids and when they last changed (never what), which the screens' live updates need. The lint tooling depends on a braces with a denial-of-service advisory and no fix yet; it only ever reads this project's own files.activity_changes, read straight from Supabase, so a check costs no page render) and reload their data from the server only when it did; judges', desks' and LED walls' pages also hear the change over Supabase Realtime, which makes it immediate, and while it's connected they check only every 20 seconds (the LED wall every 6), as a safety net. A missed notice or a dropped connection costs seconds, not a stale screen. Realtime's plan counts connections at their peak, so a page out of view for a minute lets go of its connection (and connects again as it comes back), every screen lets go of it once judging has ended (checking every half minute instead, the LED wall every 10 seconds), and an archived activity's pages don't check at all. The audience's live results don't hold a realtime connection (with many activities at once their phones would be most of realtime's connections, and it falls behind when each change has to reach hundreds), check every 4 seconds while on screen (not while the phone is locked), stretching to every 12 seconds while nothing changes (between entries) and back at the first change, and read through a short shared server cache keyed by when their activity last changed, so a page asked for after a change is never served the board from before it, whatever order the change and the cache's clearing happen in. Every page also refreshes when it comes back into view.Server-Timing header says where it was drawn. Photos are cropped and shrunk in the browser before they're sent, and a new pageant's candidates' photos go up ten to a request. Clearing up (deleting a removed photo or a deleted activity's photos, expired sessions, old sign-in failures) happens once the response has been sent.