Stop reading the whole club to draw the organisers' dashboard - #7
Open
abhinavjha0239 wants to merge 12 commits into
Open
Stop reading the whole club to draw the organisers' dashboard#7abhinavjha0239 wants to merge 12 commits into
abhinavjha0239 wants to merge 12 commits into
Conversation
Opening /admin read every profile, every mentor and every enrollment — on
load AND on every Refresh. At 1,000 members that was ~1,500 reads a press,
and about 33 presses would exhaust the 50,000-a-day free quota for
EVERYONE, members reading their own profile included. Three organisers
planning a cohort could get there in an afternoon.
Measured against the emulator, opening the page now costs 27 aggregate
queries and ~34 documents at 1,500 members and 1,500 enrolments. The same
page at 300 members costs the same: aggregates are billed on the size of
the answer, not the scan.
WHAT MOVED WHERE:
counts, the 8-week trend aggregate queries, one read each at any size
demand per mentor two aggregates per mentor. Also what the
delete guard needs — "has anybody picked
them" is a count, not a list, and it was the
single most expensive thing on the page
the members table pages of 25, with Load more
the interest list pages of 25, joined to just the profiles
those rows name via documentId() in [...],
one query rather than 25 round trips or a
scan of the membership
breakdowns, exports a full scan, behind a button that says what
it will cost
WHY THE BREAKDOWNS STILL SCAN. Batch, branch and year are derived from the
address rather than stored — which is what makes them unforgeable, and
also what makes them unqueryable. There is no where('batch','==',...) to
count with. That trade is written up in FIREBASE.md; this is the bill.
TWO CORRECTNESS FIXES that pagination forced into the open. Copy emails
and Export CSV acted on `filtered`, which is now only the loaded pages —
so exporting a club of 1,500 would have written a CSV of 25 and looked
entirely successful. Both load everything first. And the membership toggle
patched only `rows`, so once a full scan was on screen it appeared to do
nothing; it patches both row sets.
Cursors are snapshots, not created_at values: a timestamp cursor silently
skips rows whenever two profiles share a second, which is exactly what a
build day produces.
Verified against the emulator at 1,500 members and 1,500 enrolments.
rules:emulator 218 passed, e2e:mentorship 22 passed, smoke 104 with no
failures, browsers clean on three engines, typecheck and lint clean.
qa reports 4,356 issues and e2e:auth fails — both identical on clean main
before this change, and neither is touched by it.
`html { font-size: 75% }` scaled the whole page to a 12px root so a section
would fit a 1080p laptop without the reader zooming. The reasoning above it
is sound, including using a percentage so somebody running a 20px default
still gets their proportional increase.
But the small type came down with everything else, and it was already at
the floor. 11/12/13/14px became 8.25/9/9.75/10.5. The QA sweep reported
4,356 issues across 80 combinations — 3,960 tiny-text and 396 small-tap,
identical in light and dark, which is what told us it was size and not
colour. Every reported value was exactly 0.75x an intended one.
The file already contains the principle that settles it, three paragraphs
above the change: hairlines, borders and the 44px touch targets are
physical constants, and "a thumb does not get smaller because the type
did". An eye does not get better because the layout shrank. Readable text
belongs in that same category; the exemption list was one item short.
TYPE: the small end is recompressed into an ordered band starting at the
11px floor — 11, 11.5, 12, 12.5, 12.75, 13.5. It stays in rem, so a reader
who has set a larger default still gets it scaled. Raising the sub-floor
steps alone was not an option: lifting 0.875rem back to 14px would have
overtaken 0.9375rem sitting at 11.25px and inverted the scale.
TOUCH TARGETS: the ones that had drifted under 40px are px again, since
they are the physical constants the file already says they are — the nav
links and wordmark, the theme toggle, the ticker's pause button (whose own
comment says a 32px button here "would have been a real defect"), the
console input's min-height, the citation links, and a min-height on the
form fields so they hold the floor whatever the type scale does later.
qa: 4,356 -> 0 across 80 combinations. smoke 104 with no failures,
browsers clean on three engines, palette, rules, typecheck, lint and
build all pass.
e2e:mentorship is intermittent in this environment and is NOT affected by
this change: bisected by stashing it, the same 9-passed/5-failed appears
either way, and the failures are all downstream of the member's popup
sign-in not completing — the emulator wedge documented in e2e-auth.mjs.
The organiser half passes every time.
/join held an anonymous application form: name, email, year and branch,
hostel, GitHub, experience, path, and ten programme checkboxes. It is a
sign-in card again.
WHY, GIVEN THE ARGUMENT FOR THE FORM WAS GOOD. It was: requiring a Google
account before somebody may apply puts a requirement in front of the
club's front door, and the headline three inches away promises the
opposite. That is true of a club that admits anybody.
This one does not. Membership IS an @sst.scaler.com address — that is the
whole test, and it is the one thing a form cannot check. A form asks a
stranger to type an address they may not own and leaves an organiser to
verify it by hand; signing in with the college account proves it in one
tap and produces a record nobody had to check. The door and the test are
now the same act, and there is no application queue to read.
The two tiles, the headline and the closing band are unchanged. The flow
changed, not the page — except for the copy that named a form that no
longer exists ("Applications open", "without applying", "the same form").
SEALED, NOT JUST UNUSED. components/ApplyForm.tsx and web/lib/
applications.ts are deleted, applications/{id} goes back to
`allow create: if false`, and isWellFormedApplication is removed with it.
Leaving an open unauthenticated write endpoint for a form nobody can reach
is a spam surface with no user, and an unused validator on a sealed
collection is an invitation to reopen it by deleting one line.
THE HISTORY THIS HAS TO RESPECT. A merge once closed this same door with a
comment calling the collection legacy WHILE the form was still on the
page, and every application submitted in between was silently refused —
the form rendered, the applicant filled it in, the write failed. Closing
it is only safe because the form is gone in this same commit. The rules
comment says so, and the checks now assert BOTH halves: no client may
create, and lib/applications.ts does not exist. If a form ever comes back,
they fail and force the rule to move with it.
Checks updated rather than deleted, in both directions: rules.mjs drops
the field-parity check that had nothing left to compare and asserts the
seal instead; the four "a stranger may apply" cases in rules-emulator.mjs
became denials rather than disappearing, so a reopened door is loud;
smoke's four form assertions became "offers sign-in, no fields survive,
and the page states the one address that can register".
rules 100%, rules:emulator 218 passed, smoke 103 with no failures, qa 0
issues across 80 combinations, typecheck and lint clean. Sign-in driven
end to end against the emulators: /join -> Google -> /dashboard with
?path= intact, and the batch read from the address as 2023-27 - BCS.
Two changes the club asked for, and both reverse a decision made for a reason worth recording. FINISHING THE PROFILE IS A GATE AGAIN. The form used to render instead of the dashboard, and the club's own organisers reported the site "has no dashboard" — they had met a hostel dropdown and never got past it. The fix then was to demote it to a panel and let everything through. It gates again because nobody should be half-registered: an organiser reading the roster should be able to trust a row means a member. What is different is where the form lives. It is not in the dashboard at all. An incomplete profile goes to /onboarding, which is a PAGE about being three questions — a two-step spine showing sign-in was step one, a heading that says "Three questions", a button that says "Finish joining" and lands on the dashboard. The old failure was a form with no frame: it looked like the destination rather than a step, so nothing said you were nearly through. The gate lives in one place, RequireProfile, rather than being re-implemented per route. /onboarding also loses the dashboard's sidebar. Three links offering Good first issues, Projects and My details beside the one screen a member has to finish are three invitations to leave it, and two of them lead to a page that would bounce them straight back. The bar stays: somebody who signed in with the wrong Google account needs a way out that is not the back button. ONE PAGE BECAME THREE. Four figures, the forms, the board, the sessions, the GitHub panel, the profile record, the profile form and the whole mentorship flow were two columns on one route — everything the club could say arrived at once, so nothing arrived first. /dashboard what is waiting, what is on, what to do next /dashboard/mentorship the GSoC cohort, a decision made once a term /dashboard/details the record the club holds, and the form for it Mentorship was the last thing under four weekly panels: the club's headline activity, buried, and mixed in with things that change every week. "My details" was an anchor to a panel — the kind of nav item somebody presses once, watches the page jump, and stops trusting. The spacing is the other half. Panels ran together at space-y-5 into one column of cards with no rhythm; sections open on a real heading and sit at 6/8. The visually hidden h1 and the apology in its comment are gone with the reason for it. THREE DEFECTS FOUND ON THE WAY. /onboarding rendered a second <main id="main"> inside the shell's — a duplicate id, two landmarks and an ambiguous skip-link target. It also carried `.page-top`, which is clearance for the floating marketing nav, stacking two clearances inside a shell that already pads. And the bar read "OSC / DASHBOARD" over a form standing between the member and the dashboard. e2e-mentorship walks the gate now rather than landing on /dashboard cold, and its sign-in helper is the hardened one from e2e-auth: real keystrokes, the submit pressed until the ACCOUNT EXISTS rather than for four seconds, and a loud error naming the popup when it does not. It had been filling the form, clicking once and waiting — which works for the first identity in a run and silently fails for the second, so the member never signed in and five assertions about the mentorship section failed as though the section were missing. typecheck, lint, rules, browsers clean. rules:emulator 218 passed, smoke 103 with no failures, qa 0 issues across 80 combinations. The gate and all three sections driven end to end against the emulators: first sign-in lands on /onboarding, finishing lands on /dashboard, and each section renders one <main> and its own h1.
/admin rendered six components on one route — the membership table with its breakdowns and export, the mentor list, the interest list, the notice composer, the session editor, the form builder and the roster. About 3,800 lines in one column. An organiser opening it to answer "how many joined this week" scrolled past a form builder to find out. Seven routes now, one concern each: /admin the numbers, and the way into everything else /admin/members the roster, the breakdowns, the export /admin/mentorship publish mentors, and see who asked for whom /admin/notices the board every member reads /admin/sessions when the club meets /admin/forms ask the club something, read the answers /admin/team who is an organiser THE OVERVIEW IS ALL AGGREGATES. Members, joined this week, enrolled, mentors published, and the eight-week trend — about thirteen reads, and the same thirteen whether the club is thirty people or three thousand. Nothing on it reads a member document. The sections that genuinely need documents pay for themselves when opened, which is the point of splitting rather than a side effect of it. EACH ROUTE OWNS ITS READS, reversing the rule that held while this was one page. One component used to issue every query so two panels could not disagree a moment after a write; with a route per concern the argument inverts, and opening the membership table should not pay for the mentor list. Mentorship therefore owns its own mentors, demand counts, paged interest list and full-scan button rather than being handed them. The "organisers only" card was seven copies waiting to drift into seven slightly different ways of telling somebody they are not an organiser. It is components/admin/Gate.tsx now, and it keeps the three-state check — `isAdmin === undefined` is the check still running, and showing the refusal during it tells every organiser they are not one, on every load. The sidebar carries the organiser sections while you are inside /admin and hides them everywhere else: six admin links in a bar about a member's week are six links most readers can never use. /dashboard/mentorship and /dashboard/details join the QA sweep, so every signed-in route's signed-out state is measured — that state is what a stranger who guesses the URL sees. typecheck, lint, rules and browsers clean. rules:emulator 218 passed, smoke 119 with no failures, qa 0 issues across 96 combinations. All seven organiser routes driven end to end against the emulators, signed in as an owner and signed out: each renders one <main>, its own h1, the correct refusal when signed out, and no page errors.
The Auth emulator's popup stops responding after the first successful sign-in
in a browser: the form fills, the button is enabled, the clicks land, no error
appears, and the account is never created. e2e-auth.mjs already works around it
by launching a whole new browser process per identity. The cost is not the
popup — it is that opening the organisers' area, seven routes now, took several
attempts each time, so the temptation is to stop looking at it.
DevLogin creates the account against the emulator's admin API with
emailVerified already true — before signing in, so the token is minted right
rather than needing a refresh the client will not do for an hour — seeds
admins/{email} with the owner token for the organiser, and signs in with a
password. It lives on the app shell, so switching between the two identities is
one click from any of the eleven signed-in routes rather than sign out, /join,
sign in.
WHAT THE FENCE AROUND IT LOOKED LIKE, AND WHY IT NEEDED A CHECK. The slot first
keyed on NEXT_PUBLIC_FIRESTORE_EMULATOR, which reads like the natural switch and
eliminates nothing: Next inlines a NEXT_PUBLIC_* reference only when the
variable has a non-empty value, so an empty one stays a runtime lookup, the
ternary never folds, and the whole component shipped as its own chunk —
measured, in out/_next/static/chunks/1651.*.js, `Bearer owner` included. It
rendered nothing and it was all there to read. Keyed on NODE_ENV it folds, and
scripts/assert-no-dev-login.mjs now greps the built site for its strings and
fails build:static if it finds them, because that claim was false the first time
it was made.
Two things found on the way:
* securityHeaders() dropped projectId. buildCSP takes it; the signature did
not name it, and an object rest parameter discards what it does not name.
Both generators passed it, each with a comment explaining that it is what
stops connect-src falling back to a wildcard, and both were ignored — so the
deployed policy had no Cloud Functions origin at all and the dashboard's
"check GitHub now" callable would have been blocked before a request left
the browser. htaccess.mjs was passing neither value, so the two files
carried different policies for the same site; it reads .env.local now, as
hosting-config.mjs already did.
* SignInCard still offered "the application form asks for no account at all".
True while /join carried an anonymous form; since joining became sign-in
only it was a link to a page that would ask for the very account it promised
you did not need. Removed from both branches.
typecheck 0, lint clean, rules pass, rules:emulator 218 passed, smoke 119/0,
qa 0 issues across 96 combinations, browsers 0 failures across three engines.
Dev login checked by hand end to end: organiser lands on /admin with the roster
readable, member lands on /onboarding with the batch line reading 2023-27 · BCS
· 4th year · Roll 10045, and finishing it lands on the dashboard. The
no-dev-login check was confirmed red as well as green by planting a marker in
out/index.html.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The site was rendering at three quarters. globals.css carried
`html { font-size: 75% }`, so a 12px root put body copy at 13.5px, eyebrow
labels at 11px and section headings at 21.75px — against the 18px, 14.7px and
29px every token in tailwind.config.ts was written for. Those values are
Apple's, measured, and the comments in that file say so; `sm: 1.0625rem` is
17px only if the root is 16.
The 75% was added because "a 1080p laptop had to be zoomed to 75% before a
section fit on screen". The observation was real and the diagnosis was not:
what did not fit was the section, and a section is mostly padding and leading.
It also did not work — the home page was still 10,566px with the root at 75%.
The height was in the spacing all along, and that is the next commit.
FONT SIZES WERE SET IN THREE PLACES THAT DISAGREED: 8 tokens here, 18 more
`font-size` declarations inside globals.css in five values it did not share,
and 148 hand-written `text-[…rem]` utilities. `.label` was the sharpest case —
1.0417rem in the stylesheet, 0.9167rem as a token, both in use, same name. 73
of the 148 arbitrary sizes were exact duplicates of a token typed by hand, and
`text-[1rem]`, the most common type utility in the codebase at 74 uses, was a
ninth size with no token at all.
So the bottom of the scale is re-cut to 13 / 14 / 16 / 18 — four steps a reader
can tell apart, where there were four inside 3.3px — and 1rem, where those 74
uses already were, is now `sm`. Every arbitrary size is gone: 149 replaced by
tokens, six px leftovers converted, and the four one-off display clamps folded
into display-xl/lg. The vw terms in the fluid clamps go back to full value;
they had been multiplied by 0.75 to ride the root.
Measured on the home page: 12 distinct font sizes down to 9, the six-value
cluster between 11px and 13.5px gone, and no fractional pixels left except the
one fluid step that is meant to be fluid.
Two things found on the way:
* qa.mjs flagged text below 11px while its own header said 12px. The site's
smallest text was 11.0004px, passing by four ten-thousandths of a pixel —
so the sweep reported clean on a site whose body copy was 13.5px, and "0
issues across 96 combinations" was never evidence the typography was sound.
The floor is 12 again. A floor moved to fit what it measures is not a floor.
* The deferred pixel pass measured elements through fixed overlays. A locator
screenshot scrolls to its own target, so an element can land under the nav
plate or the outline panel — both frosted — and be sampled through them. It
reported an orange sticky note at 1.63:1 on "painted rgb(60,47,39)" in
exactly one of eight combinations; the note is black on #fdba74, 12.4:1, and
the odd one out was the one with a panel open in the corner it scrolls into.
Overlays are hidden for the capture and restored before the reference shot.
typecheck 0, lint clean, palette passes, smoke 119/0, qa 0 issues across 96
combinations at the raised floor, browsers 0 failures across three engines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restoring the 16px root took the home page from 10,566px to 13,933 — the type grew and nothing else moved. This pays for it, and the cheapest height to remove is text nobody was going to read. THE COPY. 14 paragraphs over 40 words are rewritten to fit under it, keeping every number, programme name and upstream link and cutting the justification, the throat-clearing and the second explanation of the first explanation. Four section standfirsts on the home page go the same way. Over-40 paragraphs across the site: 31 to 17. The 17 are not an unfinished job in the same sense. Six are the member testimonial quotes on the home page — those are people's own words, attributed, and editing somebody's quote to hit a word count puts words in their mouth. The remaining 11 are on /programmes, /projects and /how-to-join and are the next pass. THE SPACE, on one rhythm instead of five. Sections carried pt-24/sm:pt-32, pt-20/sm:pt-24, pt-12/sm:pt-16, pb-28/sm:pb-40 and pb-24/pt-4 — 90 padding tokens across 15 files, retuned to one scale a third smaller. A second layer of bloat sat inside the sections rather than between them: 28 mt-12 to mt-32 gaps, also brought down a step. And the two body leadings, 1.72 to 1.6 and 1.62 to 1.5. Those ratios were set against 13.5px and 17.3px text, where generous leading is what makes small type readable; at 18px and 24px the same ratio is 31px and 39px of line box, which reads as gappy rather than airy. Leading is relative to size and a ratio tuned at one size does not survive a third being added to it. Home page 13,933px -> 12,709px. That is short of the 7,000 the plan aims at, and the remaining height is no longer in the spacing: nine sections averaging 1,200px, of which two are over 2,000 — a four-tile grid plus a three-column block plus an 820px timeline in one, and a 3x6 comparison table in the other. Getting under 7,000 means showing fewer items or moving sections to sub-pages, which is a content decision rather than a spacing one, so it is not made here. typecheck 0, lint clean, palette passes, qa 0 issues across 96 combinations, smoke 119/0, browsers 0 failures across three engines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four sub-page mastheads wore display-xl alongside the home page hero. At the old 12px root that was 63px and merely large; at 84px a sixteen-word title — "Paid, competitive, and open to beginners. Most students never apply because nobody told them these exist." — is four lines that fill a 1440x900 viewport on their own, with the chip above it, the standfirst below it, and nothing else on screen. /programmes, /projects, /team and /how-to-join take display-lg now. A step called xl that four pages use is not a step. It belongs to the home page hero, which is two words. Not fixed here, and visible in the same screenshot: a page masthead and the first section heading under it are now both display-lg, both black-and-blue, both underlined with the drawn stroke, and nothing tells them apart. The stroke is the answer rather than another size — it is meant to mark the page's primary heading and currently sits under every h2 as well, which is why it has stopped meaning anything. That is the decoration pass. typecheck 0, lint clean, qa 0 issues across 96 combinations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The site is handmade and should stay handmade. What read as scrappy was not the tilt or the paper, it was that there were thirty-odd one-off devices, most used once or twice, each invented for the section it sits in. A crafted object is premium precisely when the same hand is visible throughout. THE ANGLE, now literally one number. Chips sat at -1.5deg, notes defaulted to -2.5 and were overridden at the call site with -4, 4, 3.5 and -3.5, stickers used -4, -3, -2, 3 and 4, and the tape across a note ran at 3. Nine magnitudes doing one job: "a person put this here". They all read `--tilt-placed` now, and the tape negates it so it leans against the note it is holding down rather than with it. THE PAPER, three stocks from one pad instead of seven. Mint #86efac, pink #f9a8d4, sky #7dd3fc, lime #bef264, orange #fdba74 and lilac #c4b5fd were each a reasonable pick for the section they landed in, and each took "a tape from a different corner of the wheel to its own fill" — so eleven notes carried seven papers and seven tapes between them. None of those colours was the site's blue or its yellow. Now: the yellow default, a warm cream, a soft blue off the accent, and one warm grey tape on all three. Black measures 18.3:1, 15.6:1 and 14.9:1 on them. THE CORNERS, ten radii down to four. The home page rendered 4, 5, 6, 8, 9, 10, 12, 18, 20, 24, 28 and the pill; several are indistinguishable at the sizes they were used, which is drift rather than craft. inline 10 / tile 18 / panel 28 / full, with a stated exception for the 2px on the contribution-wall cells and the focus ring, which are not surfaces. 33 utilities and 10 CSS declarations folded in; measured on the home page, 10 distinct values down to 4. THE INK. .chip-violet is gone — the only violet on the site, worn by two badges and a sticker, and a colour appearing three times is not a colour anybody reads as meaning anything. Sticker tones go from blue|violet|mint to blue|pop. Decorative mint goes the same way. What stays is the mint/red pair on /how-to-join and "OSC club", where the colour IS the argument rather than decoration — cutting those to hit a number would have been worse than keeping them. AND THE DRAWN RULE, on the page's primary heading only. Duo rendered it under all 34 headings on the site, and a mark under everything marks nothing. It had just cost a real hierarchy: with the sub-page mastheads moved down to display-lg, a page title and the first section heading under it were the same size, the same black-and-blue, and both underlined. Restricting the mark restores the distinction without inventing a third heading size. typecheck 0, lint clean, palette passes, qa 0 issues across 96 combinations, smoke 119/0, browsers 0 failures across three engines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THE PHONE NAV. Measured at 390px, the link strip was a 187px-wide window onto 407px of links: four of six destinations — Hall of Fame, Team, How to Join and most of Programmes — sat off-screen behind a fade, reachable only by dragging a strip most readers never think to drag. The strip and its fade were a reasonable answer to "six links do not fit across 390px" and they did not work. A site whose nav hides two-thirds of itself on the commonest phone size does not have a nav on phones. The strip is md+ now, where all six genuinely fit, and below that a disclosure button opens a panel with the same six plus the two the bar drops on small screens — GitHub, and Sign in, which is the returning member's only door in the chrome. 44x44 button, 48px rows, closes on Escape and on navigation. The panel is `.plate-solid` rather than `.plate`. The bar can be 80% and frosted because what shows through it is a page scrolling under a 56px strip — that translucency is the effect. A panel of eight links is not that: it covers content, and at 0.8 the hero's headline and its yellow highlight read straight through the list. Same border, same shadow, same corner; opaque ground. THE APP BAR CRUMB read the literal string "DASHBOARD" on every signed-in route except /onboarding, so all seven organiser routes said "OSC / DASHBOARD" over the members table, the mentor list or the roster. It reads the route now, and derives the name from NAV rather than a second list, so a route cannot be renamed in one place and keep its old name in the other — which is the drift that caused this. THE DUPLICATE NAV ITEM. Two rows three apart both said "Mentorship", both with the compass icon, pointing at /admin/mentorship and /dashboard/mentorship, and inside /admin both rendered. The organiser one is "Mentors" now — it is where you publish mentors and read the interest list, as against where a member picks one. The browsers suite asserted the old arrangement — "scrollable=true applied=true" — which was true and was describing the bug. It now checks what matters on a phone: every route reachable, behind a control at or above the touch floor, with the strip out of the way. Its click and its measurement are separate steps because React renders the panel on the next tick, and reading the DOM in the same evaluate reports an empty menu. Also restored five comments the radius sweep had rewritten: "a floating rounded plate" is prose, not a class name. typecheck 0, lint clean, rules pass, qa 0 issues across 96 combinations, smoke 119/0, browsers 0 failures across three engines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The members table and the interest list both scrolled horizontally with no affordance at all — 1151px of table into 942 on a 1280 laptop, with the GitHub column cut off mid-header and nothing on screen suggesting there was anything beyond it. An organiser reading the roster had no reason to think it was not complete, which is the worst kind of missing: it does not look broken. `.table-scroll` fades the trailing edge with a mask rather than an overlaid gradient, for the reason the nav strip uses one — these tables sit on cards whose ground differs between the two themes, so an overlay in a fixed colour is a visible block on one of them. The fade retracts as you reach the end, via `animation-timeline: scroll(self inline)`, so it stops claiming there is more once there is not. Browsers without scroll-driven animations keep it static, which is the honest fallback: the table does scroll, and all that is lost is the fade going away at the end. typecheck 0, lint clean, qa 0 issues across 96 combinations, smoke 119/0, browsers 0 failures across three engines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Opening
/adminread every profile, every mentor and every enrolment — on load and on every Refresh. At 1,000 members that is ~1,500 reads a press, and about 33 presses exhausts the 50,000-a-day free quota — for everyone, including members trying to read their own profile. Three organisers planning a cohort could get there in an afternoon.Measured, not estimated
Against the emulator with a seeded club:
/adminA full scan of the last one would be 3,000 documents. The cost doesn't grow with the club, because aggregates are billed on the size of the answer, not the scan.
What moved where
documentId() in [...]— one query, not 25 round trips and not a membership scanWhy the breakdowns still scan. Batch, branch and year are derived from the address rather than stored — which is what makes them unforgeable, and also what makes them unqueryable. There is no
where('batch','==',…)to count with. That trade is written up inFIREBASE.md; this is the bill for it.Two correctness fixes pagination forced into the open
filtered, which is now only the loaded pages. Exporting a club of 1,500 would have written a CSV of 25 and looked entirely successful. Both load everything first.rows, so once a full scan was on screen it appeared to do nothing. It patches both row sets now.Cursors are snapshots, not
created_atvalues — a timestamp cursor silently skips rows whenever two profiles share a second, which is exactly what a build day produces.Verification
Run at 1,500 members and 1,500 enrolments against the emulators:
typecheck,lintrulesrules:emulatorsmokee2e:mentorshipbrowsersTwo pre-existing failures on main, untouched by this
Both were measured on clean
mainbefore this branch existed, and this change alters neither:qareports 4,356 issues across 80 combinations. Identical number with and without this branch. The project's stated bar is zero and "not negotiable", somainis currently well outside it — worth its own fix.e2e:authfails. Main's own copy of that file fails 7 assertions on main, because/joinbecame an application form and the suite still expects the sign-in card. This branch's copy fails 3, since the popup-handling work here fixes some of them. It is not fixed properly here — that belongs with whoever changed the join flow.Neither blocks CI, which runs typecheck, lint, palette, rules, build, smoke, qa and browsers but not the emulator suites.
🤖 Generated with Claude Code