Full spec + rollout checklist: docs/refined-design-system.md (read this before extending the look to more pages). Migrated so far: / (src/pages/index.js), /projects (src/components/ProjectList/*), the global NavBar (incl. a refined mobile layout — centered logo + refined dropdown), /about, /about/judges, /about/mentors (Mentorship rewritten as refined sections), /about/success-stories, /about/completion, /sponsor, /nonprofits (NonProfitList + new calm card NonProfitListTileRefined; original NonProfitListTile stays for event pages), /blog (BlogPage; reused News list kept), /onboarding (refined chrome; wizard logic intact; July 2026 content overhaul — 9 steps: Welcome, Mission, How It Works [full lifecycle incl. post-hackathon Definition of Done + project-status ladder], Get Involved [hacker/mentor/judge/volunteer/nonprofit role cards], Using the Site [site map + first-steps guide + "Anatomy of a project page" numbered walkthrough (problem → code → plan → people; mirrors ProblemStatement.js section order — keep in sync) + "public portfolio" band: GitHub/Slack/demo-videos/public-profile are what recruiters can review; work is documented as GitHub Issues for public credit — no issues in a repo → pull the code and write them like a PM], Slack, Introduce Yourself, FAQs, Feedback. New sections: HowItWorksSection/RolesSection/WebsiteTourSection in src/components/Onboarding/; JudgingOverview/MentoringOverview/BuddySystem are now orphaned — don't re-add them or their claims. FAQ answers must reflect real flows: there are NO project leads, NO commenting on projects, NO buddy system — joining a project = "Want to help?" toggle / its Slack channel / GitHub repo (write Issues like a PM if none exist) / next hackathon. Design contract: step content renders inside a scoped onboardingTheme ThemeProvider (in pages/onboarding/index.js — Fraunces headings, Hanken body, navy/terracotta palette, flat hairline Papers/Cards; don't reintroduce elevation shadows, hover-lifts, or rainbow chips) and every step opens with the shared StepHeader (renders <h2>; the page masthead owns the <h1>). Slack step leads with the two-account gotcha (an ohack.dev login [usually Google] does NOT create a Slack account — join via /signup) and real channel deep links: #introductions C01EY49JV8U, #ask-a-mentor C01E5CGDQ74, #random C06BRHRS5BQ, plus per-project #npo-* channels; never reference #help/#team-formation/#buddy-matching/#project-matching — they don't exist), /signup (full rewrite: editorial hero, "What you get" benefit cards, numbered join steps beside the framed join_slack_1.png, navy CTA band; primary CTA is now a real <a href={slackSignupUrl} target="_blank"> — dropped the JS router.push/window.open branch; handleSignupClick only fires the CompleteRegistration GA event), /volunteer, /about/hackers, /about/process (refined chrome; Mermaid flow + Gantt diagrams kept in calm card frames), /praise (refined chrome; PraiseBoard feed kept), /hack/code-of-conduct (full rewrite: editorial hero, core-values cards, numbered accessibility list, expected-vs-unacceptable two-col w/ +/× markers, navy CTA band), /profile/[userid] public (PublicProfile), and a full refined pass on the own /profile editor (Profile.js — editorial masthead, hairline sticky tab strip, scoped navy/terracotta MUI theme, PanelHeader on every tab, privacy legend on Basic Info, hairline rules in Volunteer History, .ohx-card Giveaway Entries; form logic untouched), /myfeedback (full rewrite: editorial hero, navy CircularProgress score ring w/ Fraunces number, hairline skill accordions w/ navy LinearProgress bars, .ohx-card entries + .ohx-tag chips) and /feedback/[userid] (GiveFeedback, ssr:false: hero + Section-framed form, navy-themed MUI Select/Radio/Slider/Checkbox, native .ohx-btn submit; also fixed a pre-existing hooks-order bug where if (!user) returned before useEffect), /hack/[event_id]/manageteam (Sep 2026: rebuilt as the Team Dashboard — src/components/TeamDashboard/*, the DevPost replacement; composition + invariants in the "Team Dashboard" section below), and /hack/[event_id]/team/[team_id] (full rewrite: editorial masthead + hairline .ohx-card sections + a deep-linkable sticky Table of Contents — sections are <section id scrollMarginTop:96> with hover # copy-link anchors (keyboard accessible via &:focus-visible), the TOC lists only present sections (aria-current="location") and tracks the active one via IntersectionObserver; embedded MentorTeamPanel/TeamCompletionChecklist keep their own styling in anchored headed={false} wrappers). Key invariants on this page: (1) Status labels come from TEAM_STATUS_OPTIONS.find() via statusLabel() — never render raw enum strings like NONPROFIT_SELECTED; winning statuses get a 🏆 prefix from getWinningStatus(); INACTIVE skips the status tag since the dot already conveys it. (2) SectionBlock is module-scope (not inside the component) — defining it inside caused a remount storm on every IntersectionObserver setActiveId tick, wiping in-progress mentor note drafts and reloading the demo iframe; it receives copiedId/onCopyLink as props. (3) parseLocalDate from src/lib/dateUtils.js is used for all event window checks (eventHasStarted, eventEnded) so date-only strings aren't parsed as UTC midnight. (4) Membership check uses useTeamMembership(eventId, teamId) from src/hooks/use-team-membership.js — shared by the page and passed as isOnTeam/membershipChecked props to TeamCompletionChecklist (no duplicate fetch). Page uses result to show: "Manage your team" primary CTA for members, a combined nudge card for missing DevPost/demo, and a ghost "Want to join this team?" affordance for non-members when status is joinable. (5) Nonprofit: never render selected_nonprofit_id text — only show when nonprofitData.name is available; getStaticProps fetches { name, description } into nonprofitData; a "Nonprofit partner" section card links to /nonprofit/<id>. (6) awards[] rendered as ohx-tag--accent chips after the status tag. (7) All GitHub repos in github_links[] rendered (handles both string and {link,name} shapes). (8) getStaticProps rethrows network errors (ISR keeps the last good version) and returns notFound only on genuine 404. (9) Project section first (Sep 2026): TeamProjectSection (id="project") renders before every other section when hasProjectContent(team); members without a write-up get the "Tell the story of what you built." card → manageteam#project; legacy teams (no project_*) render no Project section and no submission tag. (10) OG + JSON-LD come from src/components/Teams/projectMeta.js (buildTeamOgImage/buildTeamDescription/buildProjectJsonLd) — never hand-compose them in the page; see "Project pages & gallery". Harmonized (not full rewrite): /hack index (keeps its bespoke finder + HackPageNav; hero/CTAs/Support band recolored via inline RX tokens). The upcoming/current event cards were refined too: HackathonList full-mode heading → Fraunces eyebrow; EventFeature full card rewritten to a refined hairline card (eyebrow date + quiet type tag, Fraunces title, navy donation rings, navy-primary/ghost event-link buttons, "View event →"; fixed the old nested-anchor bug; var-fallback colors since /hack isn't a RefinedRoot). ImpactMetrics (shared with the archive) → warm --surface-2 tiles with Fraunces navy numbers + "Impact at a glance" overline (was rainbow color-prop numbers). /hack/[event_id] got a masthead-led refined pass: body wrapped in <RefinedRoot> (it's now the <main>; all section IDs + TableOfContents + FloatingNavigation preserved), HackathonHeader rebuilt into a scope-independent editorial masthead (inline styles w/ var(--x, fallback) so /agenda + /census also benefit), refined "Build a team" buttons + recap teaser. Phase-2 refined the event-only sub-components: NonprofitList (calm clickable cards + navy view-toggle), EventConstraints, DonationProgress (navy rings), EventCountdown (navy countdown card replacing the gradient + flat timeline), TeamList/TeamCard (flat hairline cards, Fraunces team names, refined "Team members" label + navy Join button — TeamList is event-page-only despite earlier substring matches in admin/event-teams; profile avatars load lazily per-card via IntersectionObserver with rootMargin: "200px", tracked by fetchedTeamProfilesRef Set — do NOT restore the eager fetchTeamMemberProfiles(teams) call on mount that fired N parallel requests for all 30+ cards at once). Backend C7 enrichment: get_single_hackathon_event (services/hackathons_service.py) now calls _enrich_teams_users_batch after converting team DocumentReferences — one db.get_all across all members of all teams, deduped, so the frontend receives users[] already as {id, user_id, name, nickname, profile_image} objects. HackathonResults.js/TeamList.js handle both the old id-string and new object shapes (backwards-compatible). The list endpoint get_hackathon_list is NOT enriched — only the single-event getter., and HackathonLeaderboard (flat cards, warm stat tiles w/ terracotta icons + Fraunces navy numbers, squared navy Org button). Phase 3 (done) finished the event page: EventLinks (6 rainbow app buttons → one calm uniform card set + calm social-proof strip), InteractiveFAQ (flat hairline accordions), HackathonResults (calm --surface-2 frame, Fraunces navy stat numbers; gold/silver/bronze winner medals kept), VolunteerList (PersonCard → flat hairline). The shared ones (InteractiveFAQ, HackathonResults) use inline CSS-var fallbacks (var(--ink,#16181D)) so they render refined on their non-RefinedRoot routes too (verified on /hack/[event_id]/results + OnboardingFAQ) — no per-route gating. A final chrome pass refined TableOfContents (navy active pill, was magenta), FloatingNavigation (navy FAB), VolunteerList (navy "Learn more" + navy availability chips; in-person stays green), and MentorAvailability (navy banner/Slack button). The event page is now fully refined end-to-end. Also refined: /hack/request (host-an-event page + HackathonRequestForm) and /office-hours. /hack/request is student-first — org-type selectable cards default to university (was corporate); the $5k budget minimum was removed (slider min $0, never blocks submit, reframed as optional funding); the donation-% ask is corporate-only (forced to 0 at submit otherwise); phone is optional; rainbow Papers → calm --surface-2 cards; everything navy/terracotta via a scoped MUI ThemeProvider (createTheme(base, …)). formData keys are preserved — HackathonRequestDetailDialog, the edit page /hack/request/[request_id], and the backend all depend on them. Scroll gotcha: step nav now scrollIntoViews a formTopRef on the form's Paper (scrollMarginTop:88), NOT window.scrollTo(0,0) (which overshot to page-top now that the form sits below the hero). /office-hours now renders its JSON-LD, uses the idempotent initFacebookPixel (was a raw ReactPixel.init — CWV rule), and dropped the artificial 1s loading delay + dead ICS code. Also refined: /nonprofit/[nonprofit_id] (NonProfit.js — editorial masthead, meta row as .ohx-tag/.ohx-links, projects list, canonical www fix) and /project/[project_id] (Project.js — RefinedRoot chrome, soft breadcrumb, canonical). Both share ProblemStatement.js which was fully restyled (purple gradient hero → calm --surface-2 band; gradient chips → .ohx-tag; gradient metric tiles → Fraunces navy tiles; gradient section headers → hairline .ohx-card; gradient CTAs → .ohx-btn). Key invariant: ProblemStatement is ONLY ever inside a RefinedRoot, so it may use scoped .ohx-* classes directly (no inline var(--x, fallback) needed). Pass headingLevel="h2" from the nonprofit page (N cards, nonprofit name is the <h1>) and headingLevel="h1" from the project page (project title is the <h1>). The "Want to help?" toggle is hidden when status === "production" (live projects don't need volunteers) but stays visible for users already helping so they can toggle off; maintenance status keeps the toggle. "Code & Tasks" (July 2026 discoverability fix): it is an ALWAYS-VISIBLE section (id="code-and-tasks-<ps_id>" — unique per problem statement because nonprofit pages render N cards; scrollMarginTop: 96) directly after the Project Description — never move it back into the collapsed accordion trio (burying it was the bug; only References/Events stay accordions). Repo list = problem_statement.github (handles legacy string shape) MERGED with repos from hackathon teams in the already-fetched event data (team.problem_statements includes the ps id; selected_nonprofit_id-vs-resolvedNonprofits fallback applies ONLY to teams with no problem_statements — avoids claiming a sibling project's repos on multi-project nonprofits), deduped by normalizeRepoLink; team repos render under a "from hackathon teams" label with "Built by {team} ({event})" attribution, and a duplicate just enriches the project-level card. Live issue data (counts + top-5 open titles per repo) is fetched through the public backend proxy GET /api/github/issues?org&repo&state=all ONLY once the section scrolls near (fire-once IntersectionObserver, rootMargin: 200px) — batched Promise.all + issuesRequestedRef dedupe + ONE setState (never per-repo), errors swallowed (static links still render); backend caches this route 10 min. RepoCard + repo-link helpers are module-scope (SectionBlock remount lesson). When no repos exist at all, render the "Where's the code?" --surface-2 card (event-page links + slack_channel button) — never hide the section silently. Header top row shows an #code-and-tasks anchor .ohx-tag when repos exist. Backend fix-forward: approve_team (api/teams/teams_service.py::_link_repo_to_problem_statements) now also appends the created repo to the linked problem statement(s)' github array (team problem_statements refs preferred; single-PS-nonprofit fallback; ambiguous → skip + log) and clears get_single_problem_statement_old's cache. Events & Teams section: src/components/Events/Events.js was rewritten as refined event cards — title is event.title (NOT {location} {type} — that rendered a street address as the heading), Past/Upcoming .ohx-tag, compact date range via parseLocalDate, location + description as muted meta, constraint .ohx-tags, ghost Event-page/DevPost buttons, plus a read-only teams list (name, member first-names, link to /hack/<event_id>/team/<id>) fed by teamsByEvent from the same ProblemStatement memo that derives team repos. The old interactive join/leave path was dead (its teams state was never populated) and was removed from ProblemStatement along with the per-user userDetails fetch — event-teams.js and event-team.js are now orphaned; don't re-add join/leave here (team joining lives on the event page / manageteam). Reference Documents: ReferenceItem.js restyled from outlined MUI buttons to hairline row-cards (ohx-card--hover <a> rows: navy kind icon + sentence-case name + kind .ohx-tag [GitHub/Slides/Document/Video/Link] + open-in-new indicator; docs.google.com/presentation now classified as Slides); video references keep their VideoDisplay embed. Only ProblemStatement consumes it, so .ohx-* classes are safe there. styles/nonprofit/styles.js is now orphaned — leave in place.
See docs/refined-design-system.md. Pattern for new pages: keep Head/getStaticProps/schema verbatim, add <RefinedFonts/>, wrap body in <RefinedRoot>, rebuild sections with .ohx-* classes, one <h1>, preserve GA.
The homepage and projects page were reimagined away from the old busy/multi-gradient look into a calm "civic editorial" system. Shared tokens + utility classes live in src/components/design/refined.js:
<RefinedRoot>— astyled('main')scope that defines CSS variables (--paper,--ink,--branddeep navy#1B3A6B,--accentterracotta#E2552E,--displayFraunces,--bodyHanken Grotesk) and utility classNames (.ohx-wrap,.ohx-display,.ohx-eyebrow,.ohx-lead,.ohx-btn(--primary|--ghost),.ohx-link,.ohx-card(--hover),.ohx-tag(--accent),.ohx-sponsorsgrayscale logos,.risestaggered load anim w/animationDelay). Everything is scoped — it does NOT touch the global MUI light theme.<RefinedFonts />— Google Fonts<link>s (Fraunces + Hanken Grotesk, preconnect +display=swap); drop into each page'snext/head.Eyebrow,Stat,Arrow— small shared presentational atoms.- Rule of thumb here: one accent color, hairline rules, generous whitespace, ONE primary CTA per section. Don't reintroduce colored chips/gradients — that's the "too busy" regression we removed.
LeadFormgained abareprop (drops its lavender box + icon + "Stay in the loop" heading) so the homepage newsletter band provides its own calm framing.useHackathonEventsnow returnsloading(was destructured but never returned). Homepage uses it to reserve event-grid space (CLS).- Projects page no longer uses
ProjectList/filters/*(FilterBar/ProjectSearch) or theLoginOrRegisterbanner — ProjectList has its own inline search + sort<select>+ quiet status-toggle tags. Those filter files are now orphaned but left in place. - Next dev gotcha (verifying these pages): Next 16 dev chunks are NOT content-hashed, so the browser serves stale JS across hard navigations even after a successful Fast Refresh rebuild. To verify a change visually, disable cache via CDP (
Network.setCacheDisabled+clearBrowserCache) — a plain reload/?cachebustwill keep showing the old bundle.
src/components/Navbar/Navbar.js + styles.js were restyled from the solid-blue MUI AppBar to a light, frosted "civic editorial" bar so it's cohesive with the refined pages everywhere: backgroundColor: rgba(251,250,246,0.82) + backdropFilter: blur + borderBottom: 1px solid #E7E1D4, elevation={0}, ink (#16181D) links, hover → brand navy #1B3A6B. Load button is a squared navy button (no more 1.5rem rounded pill). Links are sentence-case (textTransform: none) — keep the page-link <Button>s and the dropdown NavbarButtons in sync or the casing diverges (MUI Button defaults to uppercase). Logo swapped from the white wordmark to OpportunityHack_Logo_Dark_Blue_Banner.png (3:1) so it shows on the light bar; the matching <link rel=preload> was updated too. Don't change the SSR/64px-height/fixed-width-auth-slot invariants (CWV). Nav link font is FONT_BODY from src/styles/fonts.js — since the Aug 2026 typography overhaul the Hanken webfont loads globally via next/font, so the NavBar renders it on every route.
ProblemStatement no longer shows helpers as a bare count + switch. Right under the metric tiles it renders src/components/ProblemStatement/HelpersRoster.js (module-scope subcomponents; only ever inside a RefinedRoot, so .ohx-* is fine): people who raised a hand split into Developers / Mentors as avatar+name chips linking to /profile/<db_id> (Firestore doc id — never the OAuth user_id), each with "since " (relative under 14 days), a "· you" marker, +N more past 8 per group, a Slack CTA to the project's #slack_channel ("that's where helpers coordinate"), and the existing "Want to help?" switch in its footer (same gating: hidden on production/paused except for current helpers). Data: GET /api/problem-statements/<id>/helpers (backend services/problem_statements_service.py::get_problem_statement_helpers, 60s cache cleared on every toggle) returns {helpers:[{db_id,user_id,type,since,name,nickname,profile_image}], counts:{hacker,mentor,total}, slack_channel} — ONE batched request per project. It replaced the old effect that fetched a full profile per helping entry only to find the current user; the current-user check is now findCurrentUserHelper(helpers, profile) (db id first, user_id fallback — the own-profile payload carries id). Pure helpers + tests: helpersData.js (normalizeHelpers applies the SAME dedupe as the backend — earliest timestamp wins as since, latest type wins — to the static helping array so counts SSR without duplicates; upsertHelper/removeHelper for the optimistic toggle, followed by a refetch AFTER the toggle promise resolves). enriched=false (endpoint 404/older backend) renders a counts-only sentence instead of nameless chips. Helping entries already carry timestamp (no schema change); the toggle service now updates a returning helper's row in place (keeps the original timestamp) instead of appending duplicates.
src/components/design/DonateNudge.js is the single source for donation CTAs: DONATE_URL (the Givebutter general fund givebutter.com/a5MSes — the same link /sponsor uses), donateHref(placement) (appends UTM params, utm_content=<placement>), trackDonateClick(placement) (GA DONATION category, label donate_click, placement param), DonateLink (renders .ohx-link; pass plain on Portal surfaces like MUI Dialogs where RefinedRoot classes/vars don't reach), and DonateCard (hairline .ohx-card + ghost button; RefinedRoot-only; max ONE per page). Placements: homepage "Made possible by" band (home_sponsors — a muted "…and by individual donors" line + third .ohx-link), onboarding Mission step closing card (onboarding_mission), FAQ "How is Opportunity Hack funded?" (onboarding_faq), Feedback thank-you card (onboarding_feedback_thanks), completion dialog (onboarding_complete). Rules: plain links only — never load Givebutter's widget script on landing/onboarding (third-party JS hurts CWV and the widget is off-brand); the legacy GiveButterWidget (MUI gradient card, widget p5Ak4p) stays confined to application success screens. Ask AFTER value is delivered — never on the Welcome step. Phase-2 idea: live goal/raised numbers via the Givebutter API in getStaticProps, not the Goal Bar widget.
src/lib/projectStatus.js is the single source of truth for project statuses: the five-stage ladder (concept → hackathon → post-hackathon → production → maintenance) plus the off-ramp paused (work intentionally on hold — NOT a ladder rung). Consumers: ProjectProgress (refined stepper rail on project pages + a visible one-line explanation of the current stage; paused mutes the whole rail), ProblemStatement (renderStatus tags: production → "Live", maintenance → "Live" + "Welcomes Help", paused → quiet "Paused"; the help toggle is gated by acceptsNewHelpers() — hidden for production AND paused except for existing helpers), ProjectList/ProjectCard/FeaturedProjects (filter chips, labels, paused sinks in help-oriented sorts and is never featured), the onboarding HowItWorksSection ladder, admin /admin/problems (status is a Select of catalog values — don't revert to free-text; legacy values render as "(legacy)" options), and /project/[project_id] SEO meta. Add a new status to the catalog, not to individual components. Backend needs no change — status is a free-form string passed through create/update (model/problem_statement.py). ProjectProgress is only ever rendered inside a RefinedRoot so it uses refined CSS vars directly; the old duplicate src/components/project-progress.js and ProjectProgress/styles.js were deleted — don't re-add them.
npm run dev- Start development servernpm run build- Build for productionnpm run start- Start production servernpm run postbuild- Generate sitemap (runs automatically after build)npm run test- Run Jest unit testsnpm run test:e2e- Run Playwright E2E testsnpx eslint src/**/*.js- Run ESLint on specific filesnpx prettier --write src/**/*.js- Format code with Prettier
- Use functional components with React hooks
- Dynamic imports with Next.js for code splitting
- Use Material UI (MUI) components for consistent UI
- Follow React best practices for performance
- Proper error boundaries and fallbacks for dynamic imports
- Organize imports: React, Next.js, libraries, then local imports
- Prefer async/await over promise chains
- Use descriptive variable/function names (camelCase)
- Component file structure: imports, component, exports
/src/pages- Next.js routes/src/components- Reusable React components/src/lib- Utility functions and shared code/public- Static assets
- Node v22.x
- Next.js 16.x
- Material UI for components
- Use nvm for Node Version Management
- Don't worry about writing or running tests
- Jest for unit tests
- Playwright for end-to-end tests
- Test files located in
__tests__folders for components - E2E tests located in
/src/tests/e2e/ - Mock implementation examples available in test files
AdminPage's AdminPageContainer (src/components/admin/AdminPage.js) is a plain Box (NOT MUI Container) with width: 100%, maxWidth: none at all breakpoints. The Container variant kept reintroducing a 1400px desktop cap via its internal media-query rules even with maxWidth={false} — using Box avoids that. Admin tables (teams, volunteer, profile, hackathon edit) need the full viewport on desktop. Don't switch back to Container or add a maxWidth here. If a specific admin section wants centered narrower content, scope it to that section's wrapper. SectionContainer (src/components/admin/hackathon-edit/SectionContainer.js) carries explicit width: 100%; boxSizing: border-box on its Paper so every section renders to the same visible width regardless of inner content (TextField stack vs. Grid of cards).
Every section under /admin/hackathons/[event_id]?section=... MUST render through SectionContainer so the outer frame width is identical across sidebar tabs. Heavy sections (Teams, Judging, Volunteer, CheckIn) that embed their own Paper-laden workbenches/tabs use <SectionContainer disableGutters> — the outer Paper border + width:100%; boxSizing:border-box contract is preserved, but the inner 24px padding is dropped so the embedded content doesn't double-pad. Heavy sections add their own Box sx={{ p: { xs:2, md:3 } }} around the body (after the Tabs strip if any) so spacing inside the frame still feels right. HackathonAdminLayout's content Box uses scrollbarGutter: stable so a section with internal scrolling doesn't shift the visible width by ~17px. Don't reintroduce bare <Box> section roots — that breaks the frame consistency and is the bug that motivated this contract.
Initially the section frame alone wasn't enough — MealsSection was visibly wider than ScheduleSection because something deep in its tree (Grid item width math or a fixed-width input row) was pushing horizontal overflow up to the document body, which made the whole AdminPage card grow and shift the sidebar sideways between section navigations. The fix is layered overflowX: hidden clips at every level above the section so overflow never escapes:
AdminPageroot<Box>:overflowX: hidden+maxWidth: 100%AdminPageContainerstyled:overflowX: hidden+minWidth: 0AdminPageContentstyled:overflowX: hidden+width: 100%; maxWidth: 100%; minWidth: 0; boxSizing: border-boxHackathonAdminLayoutroot flex: already hasoverflow: hiddenHackathonAdminLayoutcontent scroll box:overflowX: hidden; minWidth: 0; scrollbarGutter: stable; overflowY: autoSectionContainerPaper:width: 100%; maxWidth: 100%; minWidth: 0; boxSizing: border-box
Removing any of these and a section that contains wide intrinsic min-content (e.g. side-by-side fixed-width inputs, a wide table, a long unbreakable label) will start shifting the whole admin layout sideways again. Tooltips/dialogs render through MUI Portal so they're not affected by these clips.
Patterns that must stay in place to keep Google Search Console CWV green:
AxiosWrapperin_app.jsMUST stay as a plain static import —dynamic(ssr:false)there disables SSR for the entire app tree (empty<body>, empty titles, CWV collapse; June 2026 incident). The placeholder invariants below only work because the full tree SSRs; nothing aboveNavBarin_app.jsmay bessr: false.NavBarandFooteraressr: truein_app.js; their loading placeholders in_app.jsmatch the rendered heights (NavBar 64px, Footer 760px/560px mobile/desktop). Don't flip them back tossr: false.- The auth-reactive right side of
Navbar.js(Log In button ↔ Avatar) must stay inside the fixed-width slot (minWidth: { xs: 56, md: 140 }). Adding content there requires keeping both branches the same width. HeartsLeaderboardreservesminHeight: { xs: 128, md: 172 }in both its loading placeholder onpages/index.jsand in the component's empty state — don't returnnullfrom it.- Any new above-the-fold async component on the homepage must reserve space via
minHeightin its loading fallback.SimplePlaceholder(opacity:0 with no height) is not enough. - Raw
<img>tags needwidth/heightattributes. Prefernext/imagewith explicit dimensions. - Iframes (YouTube, Instagram, Calendar) must be wrapped in an aspect-ratio container (the existing pattern is
paddingBottom: '56.25%'withheight: 0+ absolutely-positioned iframe) or given a fixed pixel height. initFacebookPixelinsrc/lib/ga/index.jsis idempotent viapixelInitPromise. Don't addReactPixel.initcalls outside of it.
Reviewer-first table (src/components/admin/NonprofitApplicationTable.js): 4 merged columns instead of the old 10 raw-field ones — legacy duplicate fields are collapsed per row via exported accessors applicationOrganization (organization||charityName), applicationContactName (name||contactName), applicationIdeaText (idea||technicalProblem). The page's sort/filter (src/pages/admin/nonprofit/application.js) uses the SAME accessors — keep them as the single source if fields change. Idea column takes ~46% width, 3-line clamp; "Show more" unclamps in place, and a full-width detail panel appears only for fields with no column (technicalProblem-alongside-idea, solutionBenefits, notes). Don't re-add per-field columns — that's the smushed-Idea regression this replaced. Header row hides below md (mobile uses the stacked data-label cards). No backend change; data is the project_applications collection via GET /api/messages/npo/applications.
Search-first people-finder. Single file: src/pages/admin/profile/index.js. Backend GET /api/messages/admin/profiles returns all users; filtering is client-side across ~14 fields (no server-side search). Auth: userClass.hasPermission("profile.admin").
Backend payload is lean by design. get_all_profiles() (api/messages/messages_service.py) explicitly projects only the fields the admin search needs (_ADMIN_PROFILE_LEAN_FIELDS) — dropping the heavy history field, mailing address fields, want_stickers, and propel_id. badges/teams/hackathons are returned as id-string arrays (the frontend only reads .length on these). volunteering is compressed to [{hours}]. Wrapped in a 5-min TTL cache (@cached(TTLCache(maxsize=1, ttl=300))). If you add a new field to the admin profile UI, add it to both _ADMIN_PROFILE_LEAN_FIELDS AND clear the cache by restarting (or extend the cache invalidation hook). The per-row /profile/<id> route still returns the full doc when an admin opens an individual profile.
Load-bearing details:
?q=<term>is the canonical search state and the destination of the Chromeohadminsite-search shortcut (https://www.ohack.dev/admin/profile?q=%s). Do NOT add redirects that strip query params (e.g.router.replace('/admin/profile')without preserving...router.query) — it silently breaks the shortcut.- URL ↔ input sync uses the CLAUDE.md "Shareable dialog state" pattern: hydrate once with
initFromUrlRef, react to back/forward via a separate effect with alastUrlQRefecho guard, write to URL vialodash.debounce(250ms) withrouter.replace({ shallow: true, scroll: false }). - Keyboard:
⌘Kor/focuses search (with typing-elsewhere guard);Escclears query + focuses search; rows aretabIndex={0}withEnter/Spaceopening/profile/{id}in a new tab. - Default view is compact list (
Table), not cards. Toggle persisted inlocalStorage["ohack.adminProfile.viewMode"](values:"list" | "grid"). - Other localStorage keys:
ohack.adminProfile.setupHelpDismissed(Chrome-shortcut tip banner),ohack.adminProfile.listToastSeen(reserved for a future toast). - Quick actions on every row/card link to
/profile/{user.id}— that's the Firestoreid, NOTuser_id(gotcha). Volunteer deep link uses/admin/volunteer?filter=<email>(the volunteer page readsfilter=, notsearch=). BestMatchHeroshows when the query is an exact name/email match or an@-shaped query that uniquely hits one email.MatchPillsstrip shows when 2–5 results.highlightMatch(text, query)is a single-substring helper (not multi-term). Stays consistent with the underlying filter, which also matches the full string against each field.UserSearchDialog.jsstill duplicates the fetch+filter logic — extract a shareduseAdminProfilesSearch()hook when convenient.- Scale guard (~3.5k profiles — don't regress): the search input goes through
useDeferredValue(TextField updates onfilter; the filter/sort memo,bestMatch, and result lists key ondeferredFilter), and results are render-capped atINITIAL_VISIBLE_ROWS(100) with a "Show N more / Show all" footer (visibleCount, reset on query/sort/view change). Filtering still scans all profiles; only the top slice mounts. Rendering the full list unconditionally froze initial load and every keystroke (each row carries ~6 Tooltips + Avatar + LinearProgress + 5 IconButtons).
- The component accepts an optional
fixedSubjectprop. When set, the Subject field is read-only and that exact value is sent. ContactSubmissionDetailDialogpasses a subject derived fromsubmission.inquiryTypematching the backend format inbackend-ohack.dev/api/contact/contact_service.py:Contact Us: {inquiry_type_display.lower()} - Opportunity Hack. TheINQUIRY_TYPE_DISPLAYmap inContactSubmissionDetailDialog.jsmust stay in sync with the backend's map so admin replies thread with the original confirmation email.
/admin/social-media is now a redirect stub → /admin/communication?tab=social. The Communication page (src/pages/admin/communication/index.js) has THREE tabs (?tab=templates|email|social, shallow-synced by SLUG lookup — appending slugs never breaks deep links): EmailTemplateManager, EmailCommunication (Aug 2026 — extracted from SocialMediaManagement, see below), and SocialMediaManagement (now social-only: platform status, adhoc Slack message, Threads/news posting; its internal sub-tabs and all email state/JSX were removed, and its loading gate no longer blocks the email UI while social credentials validate). The old social-media page had a Rules-of-Hooks violation (useCallback after a conditional return) — fixed in the new page; don't reintroduce early returns above hooks there.
Props {accessToken, orgId, onSnack} (EmailTemplateManager convention — no useAuthInfo inside). Two modes via a ToggleButtonGroup:
- Personalized / small batch (the extracted legacy flow): Slack-user picker + paste/CSV custom emails →
BatchEmailDialog. New "Include inactive accounts" toggle — default fetch isactive_days=365, toggle →10000(disabled/deleted/bot Slack accounts are ALWAYS excluded server-side; "inactive" = no Slack profile-record update in the window). Refetch on toggle prunes selections to visible users with an info snackbar (what-you-see-is-what-you-send). Slack-sourced recipients are taggedsource: "slack"ingetSelectedUsers()— load-bearing:batchEmailServiceroutes email-only sources (custom|csv|slack) away from/api/admin/{id}/message(Slack IDs aren't user-doc ids; that was a silent misroute) and through the batch path. - Broadcast via Resend (
src/components/admin/broadcast/{BroadcastComposer,BroadcastSourcePicker,BroadcastStatusPanel}.js+src/lib/broadcastService.js): 4-step Stepper — sources (registered users / leads / Slack w/ inactive toggle / event volunteers by type+event+approved-only / contact-form submissions filtered by inquiry type [multi-select;INQUIRY_TYPE_OPTIONSinBroadcastSourcePicker.js— keep in sync withsrc/pages/contact/index.jsINQUIRY_TYPES] + optionalreceiveUpdates-opt-in-only / pasted emails) → preview counts (POST /api/admin/broadcasts/preview) → pick/create a standing segment (freeSolo Autocomplete; "Everyone" exists) and sync contacts (background job, 3s polling ofsync-status; 409already_running→ just watch;stalled→ safe retry) → compose (optional template seed with a placeholder lint —[PLACEHOLDER]s are NOT substituted in broadcasts, ack checkbox required; Resend merge tags like{{{FIRST_NAME|there}}}work) → confirm + Save-as-draft / Send-now / schedule. Cost guardrail: Resend bills marketing by CONTACT count (OHack is on the FREE 1,000-contact marketing tier as of Aug 2026; 5k=$40/mo) — the UI warns when a preview/sync exceedscontact_limitfrom the backend (RESEND_MARKETING_CONTACT_LIMIT). Unsubscribes are handled by Resend automatically; segment size ≠ delivered count. - Contact manager (
broadcast/ContactManagerPanel.js, bottom of Broadcast mode): quota bar (total vscontact_limit), searchable contact table (render-capped at 200 rows), and quota-reclaim deletes — "Delete unsubscribed (N)" (the safe first lever: unsubscribed contacts can't receive broadcasts but STILL count against quota), "Delete selected", and a typed-DELETE-confirm "Delete ALL" (warns it erases unsubscribe preferences). Deletes are GLOBAL Resend contacts (that's what frees quota), run as one background job at a time (POST /api/admin/broadcasts/contacts/prune, modesunsubscribed|emails|all, polled viaprune-status; contacts listed viaGET /api/admin/broadcasts/contacts, 60s server cache,?force=trueto bust). - Shared email/Slack-token parsing utils moved to
src/lib/emailParsing.js(validateEmail,parseEmailsFromText,parseCsvFile,normalizeSlackLookupToken,parseSlackLookupInput) — used by both modes; don't re-inline them. BatchEmailDialogaccepts an optionalonSnackprop — there is no SnackbarProvider in src/, so its notistack calls are silently swallowed unless the page's snackbar is threaded through (VolunteerWorkbench still uses the notistack default, unchanged).- GA (ADMIN category):
admin_email_batch_sent,admin_email_inactive_toggle,broadcast_preview,broadcast_sync_started/_completed,broadcast_template_seeded,broadcast_created_draft,broadcast_sent.
src/lib/batchEmailService.js sendBatchEmails() now partitions recipients: email-only recipients (source custom|csv|slack) go through POST /api/admin/broadcasts/batch-send in chunks of ≤100 (server-side resend.Batch.send, transactional quota) unless the message contains a [QRCode:...] marker (Batch has no attachments → those and any 404-from-older-backend fall back to the per-recipient /api/admin/email/send worker pool, MAX_PARALLEL_SENDS=8); registered users keep /api/admin/{id}/message per-recipient (it also Slack-DMs them — don't collapse that into batch). onProgress + {results, summary} contracts are preserved so BatchEmailDialog/VolunteerWorkbench retry-failed still works.
Email templates live in Firestore now (collection email_templates, doc id = template slug) with an append-only versions subcollection for history. Backend: services/email_templates_service.py + a dedicated blueprint api/email_templates/email_templates_views.py (NOT messages_views — that file is frozen per backend CLAUDE.md) serving /api/admin/templates (GET list / POST create / PATCH / DELETE / GET <id>/versions / POST <id>/revert / POST seed), all volunteer.admin-gated. Versioning rules: content edits bump version and append a snapshot; status-only patches don't bump; revert never rewrites history — it copies the old version's content forward as a new version with change_note "Reverted to version N". Auto-seeds from services/email_templates_seed.py on first list call; POST /seed ("Restore defaults" button) re-inserts missing seed templates only, never overwrites edits.
email_templates_seed.pyis GENERATED from the frontend'ssrc/lib/messageTemplates.jsMESSAGE_TEMPLATES(the original 22 hardcoded templates). Regenerate rather than hand-editing; editing it does NOT change live emails — the DB is the source of truth after seeding.MESSAGE_TEMPLATESinmessageTemplates.jsstays as the fallback + seed source — don't delete it.filterTemplatesByType(type, templates?)andgetTemplateById(id, templates?)now take an optional templates object;groupTemplatesByCategory(flatList)converts the backend array into the legacy grouped shape (excludesstatus === "archived").useEmailTemplates({accessToken, orgId, enabled})(src/hooks/use-email-templates.js) fetches the admin list with a module-level 60s cache (the volunteer dialogs mount per-row; this prevents refetch storms) and falls back to the hardcoded set while loading/on error.refresh(true)busts the cache after admin edits.VolunteerCommunication.js+BatchEmailDialog.jsconsume the hook (fetch gated on dialog open). VolunteerCommunication's dialog JSX was previously duplicated wholesale in both return branches — now rendered once (messageDialogvariable); keep it that way. BatchEmailDialog's denial auto-select usesgetTemplateById(id, templates)with anautoAppliedMessageRefguard so the async template load doesn't clobber a message the admin already started editing. On the results step, failed sends can be retried in place and copied to clipboard from the currentresults.results[]entries, so keep per-user failures as structured{ user, success, error }items rather than collapsing them into summary-only state.BatchEmailService.sendBatchEmails()now uses a bounded worker pool (MAX_PARALLEL_SENDS = 8) rather than a purely sequential loop, so the dialog progress UI reports completion counts while multiple requests are in flight.- Template body conventions unchanged:
[EVENT_ID]/[VOLUNTEER_ID]/[VOLUNTEER_TYPE]auto-replaced at send time; any other[UPPERCASE]placeholder prompts the sender (seedetectPlaceholders/PLACEHOLDER_LABELS). The templatetitledoubles as the email subject.
The following SEO pillar pages follow the hackathon-judge-opportunities.js pattern (getStaticProps with openGraphData + structuredData arrays, initFacebookPixel in useEffect, trackEvent on button clicks):
/coding-for-nonprofits—src/pages/coding-for-nonprofits/index.js— covers the free software development model, 3-step process, project types, FAQ (8 items), FAQPage schema. Internal links from homepage (Button), about page (inline Link), and NonProfitList component (Alert callout)./hackathon-judge-opportunities—src/pages/hackathon-judge-opportunities.js/recruit-tech-talent—src/pages/recruit-tech-talent/index.js— recruiter-targeted funnel (slug = canonical, keyword angle "recruit tech talent / hire developers"). Refined design (RefinedRoot/.ohx-*/Fraunces, like/sponsor) but uses the pillargetStaticPropsSEO pattern (openGraphData + structuredData: WebPage→about:Service, BreadcrumbList, FAQPage;FAQ_ITEMSis module-scope so the rendered<details>accordions and the JSON-LD stay in sync). Lead hooks: proof-over-résumés, mission-driven retention, see-candidates-in-action, and the grit/funnel narrative (mirrorsHackathonFunnel.jscopy). Primary CTA →/contact?type=recruit(newrecruitINQUIRY_TYPE added incontact/index.js, links to/sponsor); secondary →/sponsor. Sponsor-tier facts cited (Transformer $5k = résumé access + during/post recruiting; Visionary $10k = pre/during/post) must stay in sync withsrc/data/sponsorData.js+ sponsor benefit grid. Cross-linked from homepage sponsors section +/sponsorhero;recruitadded tonext-sitemap.config.js0.8-priority regex.
Static pages targeting organic search impressions. Each uses getStaticProps with the full openGraphData + structuredData pattern. No Organization node in the page's @graph (global one in _app.js handles it).
/hackathon-judging-criteria— 4-category rubric (Scope, Documentation, Polish, Security), HowTo + FAQPage schema. Linked from/hackathon-judge-opportunitiesExpert Evaluation section./coding-for-nonprofits— free software for nonprofits, FAQPage schema.
SEO landing pages at /coding-for-nonprofits (service: free software model) and /hackathon-for-social-good (event: the hackathon experience). Cross-linked from homepage (index.js pillar link buttons), about page, and each other. Both follow the same pattern: getStaticProps with full OG/Twitter meta + structured data (WebPage, BreadcrumbList, FAQPage). The hackathon page also includes an Event schema node for Fall 2026. Do not duplicate content between the two — keep the service/event distinction.
Full CMS for the news Firestore collection. Mirrors the /admin/hackathons/[event_id] pattern (sidebar + hybrid autosave/explicit save).
- List page:
src/pages/admin/blog/index.js. Search across title/description/author/tags, status filter chips (All / Published / Drafts / Archived), table with status chips and inline actions (edit, view-public, delete). "+ New post" creates a draft viaPOST /api/messages/admin/newsand redirects to the editor. Delete is hard delete viaDELETE /api/messages/admin/news/<id>(Firestore doc removed). - Editor:
src/pages/admin/blog/[id].js+src/components/admin/blog-edit/(mirrorshackathon-edit/):useBlogAdmin— hybrid save:content(title/body/featured_image) andseo(all seo.* fields) sections require explicit Save;metadata(author, tags, status, slug, published_at) autosaves on change (debounced 1.5s). Status change uses a dedicatedsetStatus()that bypasses the debounce so the publish/unpublish chip updates immediately.beforeunloadwarns when any explicit section is dirty.- Reuses
SectionContainerfromhackathon-edit/for the sticky save bar. - URL state:
?section=content|seo|metadata(shallow router replace).
- Body format: posts have
content_format("html" | "markdown"). Markdown is authored with@uiw/react-md-editor(dynamic, ssr:false) and rendered withreact-markdowninSingleNews.js. Switching from markdown to plain in the editor does NOT delete the markdown — both fields persist. Legacy posts default to "html" so they render unchanged. - Backend routes (in
backend-ohack.dev/api/messages/messages_views.py):GET /api/messages/admin/news?limit=&status=— admin list (includes drafts/archived). Service:admin_list_news.POST /api/messages/admin/news— create. Service:admin_create_news. Skips OpenAI image generation whenfeatured_imageis supplied. Stampsslack_ts=time.time()if missing so existing ordering keeps working.PATCH /api/messages/admin/news/<id>— partial update. Service:admin_update_news. Only keys in_ADMIN_ALLOWED_KEYSget through; clearsget_newscache.DELETE /api/messages/admin/news/<id>— hard delete. Service:admin_delete_news.- All four are auth-gated with
volunteer.admin. The original publicPOST /api/messages/news(X-Api-Key) is untouched — the Slack integration depends on it.
- Public
get_newsfiltering:services/news_service.py::_is_publicly_visiblefilters outstatus in ("draft", "archived")for both the list and single-item routes. Over-fetches by 3x so the limit-after-filter still returns enough. - New optional fields on a news doc (all optional; legacy docs without them stay valid):
content_markdown,content_format("html"|"markdown")featured_image(overrides the auto-generatedimage)author: { name, email, propel_user_id, db_id }tags: string[],slug,status("draft"|"published"|"archived"),published_at(ISO)seo: { title, description, keywords[], canonical, og_image }last_updated_by,created_by
- Public-side honoring (
src/pages/blog/[blog_id].js+src/components/News/SingleNews.js):- When
seo.title|description|canonical|og_imageare set, they win; otherwise current auto-derivation is the fallback. - When
content_format === "markdown", the body renders via<ReactMarkdown>; when not, the legacydescriptionplain-text path renders. tags[]render as clickable chips; falls back to hashtag regex extraction when absent.published_at→article:published_time(falls back toslack_ts_human_readable).- Markdown
<img>s render withloading="lazy"andmax-width: 100%; height: autoto preserve CWV.
- When
- GA tracking: admin actions emit events under
EventCategory.ADMIN—admin_blog_view_list,admin_blog_create,admin_blog_edit_save(per section),admin_blog_publish,admin_blog_unpublish,admin_blog_archive,admin_blog_delete,admin_blog_open_ga. Public-side tracking is unchanged (lives inSingleNews.jsgaButtonhelper +ScrollTracker).
The social media integration system allows admins to post Opportunity Hack news to various social media platforms directly from the admin panel. The system is designed with a generalized architecture that makes it easy to add new social media platforms.
- Service Layer: Abstract
SocialMediaServicebase class with platform-specific implementations - News Service: Fetches news from
/api/messages/newsendpoint - Social Media Manager: Orchestrates posting to multiple platforms
- Admin UI: Located at
/admin/social-mediafor managing posts
- Threads: Fully implemented with Meta's Threads API
- Twitter/X: Placeholder for future implementation
- LinkedIn: Placeholder for future implementation
Configure these in your .env file:
# Threads API Configuration
THREADS_ACCESS_TOKEN=your_threads_access_token_here
THREADS_USER_ID=your_threads_user_id_here
THREADS_USERNAME=opportunityhack- Create a Meta Developer account at https://developers.facebook.com/
- Create a new app and enable the Threads API
- Generate a long-lived access token for your Threads account
- Get your Threads user ID from the API
- Add the credentials to your
.envfile
- Navigate to
/admin/social-mediain the admin panel - The system will automatically fetch latest news from the backend
- Use "Preview Mode" (dry run) to see formatted posts before publishing
- Configure which platforms to post to in the settings
- Click "Post to Social Media" to publish (or "Preview Posts" in dry run mode)
To add a new social media platform:
- Create a new service class extending
SocialMediaService:
// src/lib/social-media/TwitterService.js
import { SocialMediaService } from "./SocialMediaService";
export class TwitterService extends SocialMediaService {
constructor(credentials) {
super(credentials);
this.name = "Twitter";
this.characterLimit = 280;
}
async validateCredentials() {
// Implement Twitter credential validation
}
async post(content) {
// Implement Twitter posting logic
}
}- Add the service to the manager in
SocialMediaManager.js:
// In createFromEnvironment method
if (env.TWITTER_API_KEY && env.TWITTER_API_SECRET) {
const twitterService = new TwitterService({
apiKey: env.TWITTER_API_KEY,
apiSecret: env.TWITTER_API_SECRET,
// ... other credentials
});
manager.registerService("twitter", twitterService);
}- Add environment variables to
.env - Update
SUPPORTED_PLATFORMSinsrc/lib/social-media/index.js
Refined rewrite (RefinedRoot + .ohx-*, navy/terracotta, native form controls — no MUI for inputs). Page is wrapped in withAuthInfo (client-rendered), which has two load-bearing consequences:
- Fonts load globally via next/font (Aug 2026) — the old
useEffectGoogle-Fonts injection was removed along with<RefinedFonts/>(now a null stub). Nothing font-related is needed on this page anymore. - Verify with cache disabled (Next 16 dev stale-chunk gotcha) — a plain reload serves old JS and the change looks like it didn't apply.
Tracking model = two numbers: committed vs actively-tracked.
- Live session (
FunVolunteerTimer) is wall-clock based. Start POSTs{commitmentHours, reason}and persists{startEpoch, commitmentHours, reason}tolocalStorage["volunteeringSession"]. Elapsed =now − startEpoch, capped at the committed total (prevents overnight runaway) — survives refresh/background tabs (wassetTimeouttick-counting, which throttled in bg tabs and lost the session on refresh via a stale-isVolunteeringsave). Auto-finalizes (POSTfinalHours) when elapsed hits the commitment; manual "End" POSTs elapsed. LegacylocalStorage["volunteeringState"]is cleared on load. - Manual log ("Log time you already did"): POSTs
{commitmentHours:h, finalHours:h, reason, manual:true, timestamp}— one entry carrying BOTH so both totals + the table row reflect it (manualtag shown). Backdates viatimestamp. - Date range uses native
<input type=date>; fetch normalizes to start-of-day/end-of-day ISO so the selected end day is inclusive (the old MUI DatePicker sent local-midnight → UTC, excluding same-day sessions). FunVolunteerTimerring shows elapsed filling toward the commitment;VolunteerStatsTableis a hairline day-grouped table. Both use CSS-var fallbacks so they render refined insideRefinedRoot. On-themeToast(not MUI Snackbar); single inline error (no Alert+Snackbar duplicate).
Backend (backend-ohack.dev/services/users_service.py): save_volunteering_time/get_volunteering_time go through _resolve_and_ensure_user() which lazily creates the users doc — new users with no profile doc previously 404'd on both read and write (the "Failed to load your volunteer data" bug + couldn't start a session). get_volunteering_time returns ([],0,0) (never None/404) and filters in ONE pass (an entry may carry commitmentHours, finalHours, or both — no duplicate table rows).
Self-service page where a volunteer answers a branching checklist that picks one of four letter types (General Volunteer, SE/OPT, Mentor, Judge), fills details against a live preview, and submits to OHack to review/sign. Auth-gated (RequiredAuthProvider, like the application forms) with profile prefill of recipient name/email.
- Files: page
src/pages/hack/[event_id]/letters.js; logic/templates insrc/components/Letters/—letterConfig.js(pure:ORGconstants,runChecklist(answers),*_FIELDS,encode/decodeLetterState),LetterChecklist.js(Q1–Q4 branching UI),LetterPreview.js(the print surface; renders all 4 letters). - Decision safety (do not regress): OPT letter is offered ONLY when Q3=initial post-completion OPT AND all 4 Q4 acks checked. STEM extension → blocked to General; "not sure" → advisory + General; mentor/judge branches never reach OPT.
runChecklistis unit-coverable in isolation — keep its branch table intact. - Wording: General + SE/OPT bodies reproduce the two reference
.docxverbatim (with variable substitution); Mentor/Judge are event-based service confirmations. Every letter carries the guardrail bullets (volunteer not employee; no compensation; no visa sponsorship; no immigration advice/certification) — never add immigration/legal certifications. - Submission reuses
POST /api/contact(no backend change) withinquiryType: "volunteer_letter". The message packs a readable summary + a shareable?d=<base64>link that re-renders the filled letter so the reviewer types the signer block and prints.volunteer_letteris mapped inContactSubmissionDetailDialog.jsINQUIRY_TYPE_DISPLAY(mirror in backendcontact_service.pyfor reply threading if needed). - Signer block (
SIGNER_FIELDS) is OHack-filled at sign time, left blank by the volunteer. Print uses a global@media print { visibility }trick (only#letter-print-rootshows) +@page { size: Letter; margin: 1in }— noreact-to-print. Page isnoindex. Shareable state via?d=follows the "Shareable dialog state" pattern (hydrate-once ref + debounced shallowrouter.replace).
All five application forms are refined (civic-editorial) as of Aug 2026. mentor-application.js is the canonical pattern; judge/sponsor/volunteer were restyled to match it (presentation-only — form logic, validation, submit payloads, reCAPTCHA, persistence, judge passcode unlock, and SEO/JSON-LD untouched). The shared sx constants (refinedFieldSx, refinedStepperSx+refinedStepperMobileSx, {info,warning,success,error}AlertSx, primaryButtonSx/ghostButtonSx, stepTitleSx/stepLeadSx, emphasisPanelSx, eventMarkdownSx, etc.) live in src/components/ApplicationForm/refinedStyles.js — import from there, never re-declare local copies (mentor/judge/sponsor/volunteer all import it; hacker still carries an older partial local set). Structure per page: RefinedRoot shell → editorial masthead (Eyebrow + Fraunces h1 w/ italic span + lead + 3 Stat cards + event-details card + side image card) → ApplicationNav → QR card (gated Boolean(volunteerId) && isSelected — the component also self-gates; the outer gate avoids an empty card) → stepper card → form card; each step opens with Eyebrow/stepTitleSx/stepLeadSx. Step nav scrolls to stepContentRef (scrollMarginTop 96), not page top — the masthead is tall. Use the shared scrollToStepContent(ref) from ApplicationForm/stepScroll.js (rAF-deferred, moves focus to the step container [tabIndex={-1} + outline: "none" on the Box], respects prefers-reduced-motion) — don't hand-roll scrollIntoView/window.scrollTo in step handlers; the copy-pasted version is how volunteer drifted back to page-top scrolling. Hacker keeps its own equivalent ID-based helpers (scrollToProgressSection).
Typography (Aug 2026 readability fix): all five forms wrap their body in <ThemeProvider theme={refinedFormTheme}> (exported from refinedStyles.js, built from assets/theme.js via createTheme(baseTheme, …)). It pins FONT_BODY + px sizes (body1 17px/1.6 — matches RefinedRoot's base, body2 14.5px, button 15px, caption 13px, subtitle1 17px, h6 20px). All fontSize values in the five forms AND the shared constants are px (e.g. stepTitleSx 26px/32px, stepper labels 14.5px/11.5px mobile). formProseSx (maxWidth: "40em" ≈ 70 chars/line at 17px) caps multi-sentence prose inside the full-width form cards; the judge "What good judging looks like" panel uses it, with --ink (not --muted) on its primary copy. Note createTheme(theme, overrides) only deep-merges — pin fontFamily/fontSize per variant; the top-level typography.fontFamily does NOT regenerate variants.
Top spacing contract (fixes the giant page-load gap): the outer <section className="ohx-wrap"> uses style={formSectionStyle} from refinedStyles.js (clamp(88px, 9vh, 108px) top — the section is the FIRST in-flow element and clears the absolute 64px NavBar itself). FormPersistenceControls renders INSIDE the form flow (right above the stepper card) with sx={{ mt: 0, mb: 2 }} — never move it back above the <section>: its default mt: 10 (NavBar clearance for legacy pages) stacks with the section padding and produced ~250px of dead space before the masthead.
Intro video (IntroVideoField, judge-only today — designed for mentor/hacker reuse): src/components/ApplicationForm/IntroVideoField.js is a controlled field (URL string lives in the parent's formData, so persistence + submit flow through for free). Upload path reuses the portfolio bio-video signed-URL mint (POST /api/users/profile/bio-video/upload-url → XHR PUT to GCS) but deliberately NEVER calls the POST /api/users/profile/bio-video finalize — the applicant's public profile stays untouched; the file just lands under users/<db_id>/ on the CDN. Link path allowlists YouTube/Vimeo/Loom (mirror of backend ALLOWED_VIDEO_LINK_HOSTS in users_service.py — keep in sync). Judge form: formData.introductionVideoUrl, required in validateBackgroundAndExperience, rendered in Step 2 after whyJudge, mapped in loadFormDataSequentially, GA judge_app_intro_video_added (event_label = upload|link), and surfaced as a link in admin review (ApplicationReviewCard judge secondaryFields + labelMap + all three isLink arrays). Judge photo (UploadPhoto, formData.photoUrl): required + validated in validateBackgroundAndExperience (checks formData.photoUrl || uploadedPhotoUrlRef.current), rendered right after the video in a framed --surface-2 panel with copy stating it's PUBLIC on DevPost + ohack.dev (deliberate contrast to the video's "review team only" note) — don't demote it back to a bare label/button below the video card (it was getting missed).
Shared scaffolding lives in src/components/ApplicationForm/. Use these instead of re-implementing in each form:
PronounsPicker— chip-based picker with curated pronouns + "Add your own". Stores a comma-joined string (back-compatible with old free-text values). All four forms use it.OHackParticipationSelect— the "How many Opportunity Hack hackathons have you attended?" dropdown. Helper text makes clear it's about OHack only, not other hackathons.ProfileAutofillNotice— reusable green "auto-filled from your profile" alert.MealMenu— restaurant-style meal selector foreventData.constraints.meals.DietaryRestrictionsSelect— dropdown (multi-select + exclusive "None" + "Other" detail) forformData.dietaryRestrictionson ALL FOUR forms. Stored as a human-readable comma-joined string (legacy free-text parses back in; no backend change). Only rendered for people who'll eat on site: hacker gates on!eventData?.isOnlineEvent, mentor/judge/volunteer on!isVirtualEvent() && inPerson === "Yes!"/"Yes". Don't revert to free text. Shown in adminApplicationReviewCard+VolunteerEditDialogfor all four types (via thedietaryRestrictionsschema field). Parse/serialize helpers are exported and unit-tested (__tests__/DietaryRestrictionsSelect.test.js). Primary copy on these forms usesbody1. Reservebody2for true helper text under inputs.
Adding a new application field needs NO backend change. Submissions POST to /api/{type}/application/<event_id>/{submit,update} → handle_submit → create_or_update_volunteer (services/volunteers_service.py), which persists the entire volunteer_data dict (volunteer_doc.update(volunteer_data) on create, set(merge=True) on update) — there is no field allowlist for volunteer/mentor/judge/hacker apps (unlike save_hackathon). New form fields flow through and are stored as-is. The one exception is a small denylist: STAFF_OWNED_VOLUNTEER_FIELDS (services/volunteers_service.py) is stripped from every self-service submit/update, so approval (isSelected), check-in (checkInTime/isCheckedIn/checkedIn/…) and refund bookkeeping (deposit_status, deposit_refund_*) are server-authoritative and can NOT round-trip through a form — don't add a form field with one of those names expecting it to persist. This exists because all five forms used to ship isSelected: false from their initialFormData, silently un-approving an approved applicant on every edit (Aug 2026); never re-add isSelected to a form's initialFormData or submit payload. Deposit payment fields (stripe_payment_intent_id, deposit_amount_cents, deposit_disposition) are deliberately NOT in the denylist — the hacker Stripe return sets those on /update. The submit/update routes are also @auth.require_user now (identity comes from the token, never a body user_id). Mirror the expertise/softwareEngineeringSpecifics pattern: keep multi-selects as arrays in form state, join to a comma-string at submit (swapping an "Other" option for its free-text value), and split back on loadPreviousSubmission. To make a new field visible (and editable) in admin, add it to the type's section in src/components/admin/volunteer/applicationSchema.js with a review tier — the card, dialog, table search and "All submitted fields" all derive from that one schema (empty values are auto-skipped, so legacy rows stay clean). Mentor form captures AI-tool usage (aiTools[]+otherAiTools → joined aiToolsUsed, plus aiToolsExperience) in Step 2.
The judge form is hard-gated behind LMS training: JudgeTrainingGate (src/components/ApplicationForm/JudgeTrainingGate.js) renders in place of the stepper + form (they don't mount at all) until BOTH certificates verify. The bundle is lms.ohack.dev/bundles/kn7ect1nhxqkcn2tp32tbypzdx8ckjx6 ("Opportunity Hack Judging": Judge Intro + Using the judging tool; each quiz pass issues a cert at lms.ohack.dev/certificate/<64-hex>).
Detection is automatic first, manual paste is the fallback. lms.ohack.dev is SSO-only through the SAME PropelAuth instance as www.ohack.dev (auth.ohack.dev), and the LMS's Convex deployment (default majestic-trout-419.convex.cloud, override NEXT_PUBLIC_LMS_CONVEX_URL) trusts it as a customJwt issuer with EXTERNAL_AUTH_TRUST_EMAILS=true. The gate therefore calls the Convex Functions HTTP API with the judge's own accessToken (Authorization: Bearer): externalAuth:ensureExternalUser (once per mount, links/creates the LMS account) then certificates:getMyCertificates; matched certs (newest issuedAt per slot, shareToken never reused across slots) seed the verification token cache and are written into the URL fields via onValueChange — the existing debounced evaluate() then verifies from cache with no extra network. Triggers: token-presence mount effect (Boolean(accessToken), NEVER the raw token — PropelAuth rotates on refocus; all volatile inputs read through refs per the token-rotation-stability pattern), visibilitychange refocus (throttled 15s), and an explicit "check again" button (bypasses throttle). Auth-classified failures set authFailedRef to stop refocus retries — this is the dev caveat: localhost logs into a propelauthtest issuer prod LMS doesn't trust, so auto-detect lands in unavailable and manual paste (anonymous query, works everywhere) takes over. Conflict rule: a verified slot value is never overwritten by auto-detect; non-verified/broken values are.
Manual verification is client-side against the LMS's public Convex query certificates:getCertificateByShareToken (anonymous + CORS-open by design — same query the LMS's own /certificate/:token page uses). Note getMyCertificates payloads carry NO recipientName — the verified summary line must render only present parts. Slot matching is by regex over the cert's quizTitle+targetTitle (/judge\s*intro/i, /judging\s*tool/i in JUDGE_TRAINING_CERTS) — keep in sync if LMS video/quiz titles change; duplicate tokens across the two fields are rejected. Cert URLs live in formData (judgeTrainingIntroCertUrl/judgeTrainingToolCertUrl) so they autosave, hydrate from a previous submission (returning judges auto-unlock via re-verification), and submit with the application (no backend change); submit also stamps judgeTrainingCompleted and handleSubmit re-checks trainingVerified. Both links render as clickable links in admin ApplicationReviewCard (judge secondaryFields + labelMap + all three isLink arrays). GA: judge_app_training_link_click, judge_app_training_cert_verified (intro|tool), judge_app_training_unlocked, judge_app_training_autocheck (label=mount|refocus|manual, value=match count), judge_app_training_autodetected (intro|tool), judge_app_training_autocheck_failed (auth|network|server), judge_app_training_manual_fallback_open.
The Judges tab of the volunteer workbench (/admin/hackathons/<event>?section=volunteer) reviews the PR-345 judge fields. Load-bearing pieces:
src/lib/lmsClient.jsis the shared LMS Convex client — the transport (lmsQuery/lmsMutationwith{path, args, format:"json"}+ auth/network/server error codes),extractCertToken/certUrlForToken,verifyCertToken(anonymous cert lookup, module-level promise cache with rejected-promise eviction), and the moved-here constantsJUDGE_TRAINING_CERTS/JUDGE_TRAINING_BUNDLE_URL.JudgeTrainingGate.jsre-exports all its old public names (judge-application.js imports unchanged) — don't re-inline transport code into the gate.src/hooks/use-judge-training-status.js(+ exportednormalizeEmail) runs two parallel passes and merges into ONE setState: (a) anonymous verification of every storedjudgeTraining*CertUrl(works for all admins), (b) an authed rollup —quizzes:listQuizzes→ title-regex match viaJUDGE_TRAINING_CERTS[].match→quizzes:getQuizResults {quizId}×2 +users:listUsers(userId→email join) — behind a module-level 60s TTL cache. The LMS soft-degrades withoutMANAGE_QUIZZES(listQuizzes→[],getQuizResults→null, butlistUsersthrows), so zero matched quizzes is treated as an auth failure →lmsAccess: "certs-only", never rendered as "no attempts".lmsAccess:null | "full" | "certs-only" | "unavailable". Token-rotation pattern honored (refs +Boolean(accessToken)+ judges fingerprint). Attempt data therefore only shows for admins whose LMS account (email-linked) has an owner/admin/editor role; localhost dev-issuer always lands in certs-only.VolunteerWorkbenchowns the single hook call (gatedtabValue === 1= judges) and ONE page-level videoDialog(VideoDisplay); it passestrainingStatusByEmail/trainingLmsAccess/onPlayVideoto BOTHVolunteerTableandApplicationReviewList. Hook + dialog state are declared before the!isAdminearly return (hook-order).ApplicationReviewCard: judge cards render the module-scopeJudgeTrainingPanel(LiteVideoThumbnail → workbench dialog, per-slot cert rows w/ score+issued date+attempts,judgeTrainingCompletedchip, training-bundle link). The video/cert URL fields were REMOVED from judgesecondaryFieldsand added to thealreadyRenderedset — don't re-add them as raw field rows. The 3 duplicatedisLinkarrays are now one moduleLINK_FIELDSconstant, and the expanded secondaryFields grid passes the isLink arg (was a bug — links degraded to plain text when expanded).VolunteerTable: judges-onlytraining+introVideocolumns (sortable: false, honored in the header) via module-scopetrainingChipConfig(also used by the mobile card view). Chip falls back tovolunteer.judgeTrainingCompletedwhile LMS data is pending/unavailable. Tooltip shows attempts per cert viaattemptsSummary: rollup first (lmsAccess === "full"), else theattemptCount/attemptsToPassfields the anonymous cert lookup returns (LMS ≥ Sep 2026 — works for every admin, verified slots only;TrainingSlotRowin the review card has the same fallback); a footer line explains when neither source has data. Judge column order is review-first:id, name, status, training, introVideo, title, company, …base…, checkedIn, background(built from abasemap ofbaseColumns— don't spread...baseColumnsback in front). Company: judge applications storecompanyName(notcompany) — read throughcompanyOf(volunteer)(exported; cell + mobile card) and the workbench sort has a matchingorderBy === "company"branch; search already covered both keys.- Hook gotcha (Sep 2026): the rollup pass MUST call
externalAuth:ensureExternalUserfirst (ensureLmsAccountLinked, module-cached) — the LMS resolves an external PropelAuth identity only through theauthIdentitieslink that mutation creates, so without it even an LMS owner's admin queries resolve to no user, soft-degrade to[], and the hook lands incerts-only(that's why prod tooltips showed no attempts). The judge form's gate always did this; the admin hook didn't. - Backend privacy fix:
introductionVideoUrl+ both cert URL fields are inPUBLIC_VOLUNTEER_DENYLIST(common/utils/firebase.py) — the UNauthenticated judge route must not leak them (the form promises the video is review-team-only). Admin route unaffected. Don't remove them from the denylist.
Every application (mentor/judge/volunteer/hacker/sponsor) carries TWO independent decision fields, and the admin UI keeps them segmented on purpose — words, look, placement, transport, filters:
- Review axis =
status(internal pipeline). Catalog + helpers insrc/lib/applicationStatus.js(pending | approved | waitlisted | verified_travel | confirmed | denied | withdrew | no_show;normalizeStatusfolds case/blank, unknown legacy values pass through and render as "(legacy)";rosterReady/rosterConflictare the two mismatch tests). Rendered ONLY viavolunteer/StatusControls.js(StatusChip,InlineStatusSelectin card headers + table cells,StatusPickerin dialog/bulk bar). Approve / Waitlist / Deny buttons writestatus— they never touchisSelected. Writes go through the genericPATCH /api/messages/hackathon/<event>/<type>with a{ id, status }body. - Roster axis =
isSelected("on the public event page, participant tools unlocked"). Rendered ONLY viavolunteer/RosterControls.js(RosterToggleglobe+switch — never a status-style chip;RosterConfirmDialogfor bulk). UI copy says "roster"/"on roster", never "selected"/"approved". Writes go through the dedicatedPOST /api/admin/volunteer/<id>/select{ selected }route (backendupdate_volunteer_selection— server-authoritative, clears the participant's caches, Slack-audited).isSelectednever travels in a PATCH body from the admin UI;buildPatchreturns{ patch, roster }and the workbench sends them on separate transports (patchVolunteer/selectVolunteer, optimisticapplyLocal+ per-rowpendingIds, revert on failure — no whole-table spinner). Backend PR-D1 (permissionvolunteer.admin, bool validation,get_volunteer_by_event.cache_clear()) must be deployed before this frontend. - Presets bridge the axes: "Ready for roster" (
status ∈ approved/verified_travel/confirmed && !isSelected) and "Roster conflicts" (isSelected && terminal status) are clickable chips in both views (preset=ready|conflictin the URL) and "Add all ready (N)" is the one-click publish. Filter state per tab lives inDEFAULT_FILTER_STATE(statusFilter,selectedFilter,preset);statusFilter=approvedbookmarks now meanstatus, notisSelected. - Emails follow the axis: "Email roster", Slack invites and certificates key on
isSelected; rejection emails key onstatus === "denied"("Email denied (N)"), plus "Email waitlisted (N)".BatchEmailService.filterUsersByAudience(users, "roster"|"denied"|"waitlisted");BatchEmailDialogtakesaudience(legacyisSelectedUsersstill accepted). Never revert to!isSelectedfor rejections — that would email pending and approved-but-unpublished people. - ONE edit dialog:
VolunteerEditDialog(schema-driven, controlled, diff-based; sticky two-pane Decision bar Review | Event roster; flat sections; System is the only accordion) opens from BOTH the table pencil and the card's "Edit details" with the LIVE row derived by id (editingVolunteerId, stale-snapshot lesson).ApplicationEditDialog.js(the stepper whose judge fields bound to non-existent keys and flattened hacker arrays) was deleted — don't re-add it. volunteer/applicationSchema.jsis the single field source for dialog, review card, table search and "All submitted fields": per-type sections withreadFromaliases /writeTomirrors (judgebiography↔shortBio↔shortBiographyis ONE field that writes all three soVolunteerList's fallback keeps working;backgroundAreas↔background,agreedToCodeOfConduct↔codeOfConduct,linkedinProfile↔linkedin,inPersonwritesisInPerson+ the form's own string convention — mentor uses "Yes!"/"No, I'll be virtual"). Multiselects are shape-preserving (arrays stay arrays, Sheets-imported strings stay strings).LINK_FIELDS,getRenderedKeys(replaces the card'salreadyRenderedliteral),getFieldLabel,getReviewFieldsandgetSearchValuesall derive from it. To add/expose a field in admin, add it to the schema — not to a component. Training-link fields arereview: nullthere (JudgeTrainingPanel renders them). Sheets bulk-paste lives involunteer/bulkPaste.js(isSelectedstripped).
judge-application.js has an isVirtualEvent() helper (location contains global/virtual/online/remote — same heuristic as the volunteer form's). At physical events, validateAvailability blocks (not warns) inPerson !== "Yes" and canAttendJudging === "No" on both step-Next and submit; the availability step shows blocking error alerts that route the applicant to the mentor application (mentors can be virtual) or /hack online events. "Partial" judging-window attendance stays allowed. Virtual events keep the soft-warning behavior. Don't reintroduce the old "remote judging is possible" soft warning at physical events.
The "Find your team" picker (InterestsTeamsStep.js TeamBrowser) lists distinct teamCode values that already-registered hackers entered for the event, NOT teams from /api/messages/teams. First hacker types a code; later hackers pick it so they don't have to remember it. Data: hacker-application.js fetches GET /api/messages/hackathon/${event_id}/hacker, reads data[].teamCode, dedupes case-insensitively (first-seen casing kept) into eventTeams as [{ code, count }] sorted by code. teamCode is NOT in the backend PUBLIC_VOLUNTEER_DENYLIST (common/utils/firebase.py), so the public hacker endpoint already exposes it (no backend change). TeamBrowser filters/selects/renders by t.code/t.count; clicking sets formData.teamCode. Don't revert to listing team objects (t.name/t.users).
Country is a curated Autocomplete (COUNTRY_OPTIONS); State is a Select of US_STATE_OPTIONS only when country === "United States", otherwise a free-text "State / Province / Region". County (ARIZONA_COUNTY_OPTIONS) only renders when country=US AND state=Arizona. There is no user-facing "Arizona Residency" dropdown — arizonaResident is derived at submit time from country+state and sent in the payload so legacy downstream consumers still receive it. If you re-introduce a dropdown for this, you'll create the redundancy we just removed. (The 2026 tax credit / QCO question was also removed — no QCO this year.) All option arrays (PARTICIPANT_TYPE_OPTIONS, COUNTRY_OPTIONS, etc.) are module-scope constants at the top of hacker-application.js so they're not reallocated on every render — keep them there or Autocomplete will lose its memoization.
Two top-level fields on the hackathon doc (NOT under constraints):
event_photos: [{ url, caption?, credit?, sort_order? }]social_posts: [{ platform: "linkedin"|"instagram"|"threads"|"article", url, caption? }]
Both flow through the existing PATCH /api/messages/hackathon. Backend caps live in validators.py (MAX_EVENT_PHOTOS=100, MAX_SOCIAL_POSTS=25); social URL hosts are validated against the chosen platform, while article accepts any normal http(s) news URL.
Admin UI: EventMediaManagement component (src/components/admin/EventMediaManagement.js) renders inside the "Event Photos & Social Posts" Accordion in the admin Advanced Settings tab. Photos uploader is gated until event_id is set; posts to /api/messages/upload-image with directory=hackathons/{event_id}/photos.
Public surfaces:
/hack/[event_id]/media— full carousel (react-responsive-carousel) + Instagram embeds (react-social-media-embed, dynamic ssr:false) + LinkedIn/Threads/article link cards. IncludesImageGalleryJSON-LD./hack/[event_id]— compact teaser block above theTableOfContents(right afterHackathonResults). When photos exist it shows the 3-thumbnail strip; when onlysocial_postsexist it falls back to small coverage cards. It renders whenever eitherevent_photosorsocial_postshas content and links through to/media./hack/[event_id]/upload— legacy page is now a redirect stub pointing users to/admin/hackathonsand/media.
PlanningCardDialog has an inline budget editor (amount USD, bucket: food/prize/swag, state: estimated/committed/paid, vendor). Edits PATCH card.budget and feed PlanningBudgetWidget (event page widget gated by planning.budget_widget_on_event_page). Backend constants live in model/planning.py (ALLOWED_BUDGET_BUCKETS, ALLOWED_BUDGET_STATES, MAX_BUDGET_CENTS); keep frontend select options in sync. Clear with { budget: null }. Read-only viewers still see the chip; editors get the form.
The old "Edit Hackathon" Dialog at /admin/hackathons is gone. Editing now lives on /admin/hackathons/[event_id] with a left sidebar navigating between sections (?section=overview|schedule|deadlines|meals|participants|judges|nonprofits|media|planning|donations|links|volunteer|teams|judging|checkin). URLs are deep-linkable for sharing.
Sidebar is grouped. Sections in sectionsManifest.js carry a group field — "config" (Configure: overview…links) vs. "ops" (Operate: volunteer/teams/judging/checkin). HackathonAdminLayout renders one labeled List per group with an "overline" header. Legacy entries without a group default to "config".
Volunteer / Teams / Judging / Check-in are consolidated here. The old standalone routes (/admin/volunteer, /admin/teams, /admin/judging, /admin/check-in) are now redirect stubs that forward to /admin/hackathons/<event_id>?section=<slug> (preserving all other query params). The actual workbenches live at:
src/components/admin/volunteer/VolunteerWorkbench.js— accepts{ userClass, embedded, externalEventId, onSnack }. Whenembedded=true, skips theAdminPagechrome, hides the in-page event picker, and trustsexternalEventIdinstead of the URL. URL writeback is short-circuited.src/components/admin/checkin/CheckInWorkbench.js— sameembeddedcontract.TeamsSection/JudgingSectionare thin section files that lazy-load existing components (TeamManagement,TeamAssignments,JudgingRound1/2/Results) and wireselectedHackathontoadmin.hackathon.event_id(the setter is a no-op since the host URL owns the event).- Sub-tab state for Teams/Judging persists via
?subtab=management|assignments|stats(Teams) /round1|round2|results|peer-vote(Judging —peer-voteis the Hackers' Choice results table,PeerVoteResults.js; the judging rounds themselves are untouched).
Embedded-mode snackbars: the workbench's decision/edit writes report through its snack() helper, which also calls the host page's onSnack(message, severity) when embedded (the AdminPage Snackbar isn't mounted there). Older setSnackbar-only call sites (fetch errors, Slack/email completions) still render nothing when embedded — route new toasts through snack().
- Page:
src/pages/admin/hackathons/[event_id].js. List page (index.js) routes "Edit" buttons here and keeps a small "Add Hackathon" modal that bootstraps a row then redirects. - Layout:
src/components/admin/hackathon-edit/HackathonAdminLayout.js— sticky header w/ save indicator, sidebar fromsectionsManifest.js. - Hook:
src/components/admin/hackathon-edit/useHackathonAdmin.js— ownsdraft+committedstate and runs the hybrid save model:- Autosave (debounced 1.5s PATCH
/api/messages/hackathon): all "low-risk" keys. - Explicit Save (sticky bar inside
SectionContainer):overview-dates,schedule,deadlines(ownsdeadlines+countdowns— see "Hackathon deadlines section"),meals,screening,deposit. While any of these are dirty, autosave is paused so unrelated text edits don't sneak through.SectionContainertakes an optionalsaveDisabledprop so a section can block Save on its own validation errors. - The sidebar shows a yellow dot on sections with unsaved changes.
beforeunloadwarns. - Token-rotation stability (don't regress): PropelAuth mints a fresh
accessTokenon tab refocus. The hook reads token/orgId through refs (accessTokenRef/orgIdRef) sofetchHackathon/pushPatchstay identity-stable, and the load effect is keyed on token presence (!!accessToken), not value. Keying anything on the rawaccessTokenre-triggers the load →loadingflips → the page unmounted the whole layout (looked like a full page refresh on tab switch) andsetDraftclobbered unsaved edits. The page also gates its blocking spinner onadmin.loading && !admin.hackathonso background refetches keep the layout (and embedded workbenches) mounted.
- Autosave (debounced 1.5s PATCH
- Section components live in
src/components/admin/hackathon-edit/sections/*.js. Each receives{ admin, accessToken, orgId, onSnack }. To add a new section: append tosectionsManifest.js+ create<Slug>Section.js+ add to thesectionLoadersmap in[event_id].js(and, for explicit-save sections,EXPLICIT_SAVE_SECTIONS+SECTION_LABELS+collectKeysForSectionsinuseHackathonAdmin.js). - Top-level optional fields must be allowlisted in the backend save, or they silently vanish.
save_hackathon(backend-ohack.dev/services/hackathons_service.py) builds the saved doc from an explicit key dict, so any NEW top-level field (i.e. NOT underconstraints) is dropped by themerge=Truewrite unless it's added to thefor optional_key in (...)passthrough loop AND validated invalidate_hackathon_data_partial(common/utils/validators.py). Current passthrough keys:github_org,mentor_slack_channel,deadlines(update path writesDELETE_FIELDfor explicit nulls). This bit both — their admin UI existed but never persisted until the passthrough was added.OverviewSection.jsrenders both;github_orghas a click-through togithub.com/<slug>via thegithubOrgSlug()normalizer (strips full URL /@/ trailing path) and normalizes the stored value to the bare slug on blur (not on change — a URL typed char-by-char would be mangled). The backend mirrors this (validators.normalize_github_org, applied on save AND inapprove_team) because a storedhttps://github.com/<org>URL was passed verbatim to PyGithub'sget_organization→ 404 → 500 on "Approve Team" (Sep 2026). Both autosave viasetField. Relatedly,POST /api/team/approveanswers HTTP 200 with{success:false, message}for handled failures —TeamManagement.handleApproveTeamMUST keep itselsebranch that snackbarsmessage, or those failures are silent. - Removed legacy components (do not re-introduce):
DonationManagement.js(had bolt-on "Update Donation Data" button),MealManagement.js(free-text time field),CountdownManagement.js(modal-per-edit, no reorder). Their replacements (inline donation editor,MealsSection,ScheduleSection) live underhackathon-edit/sections/. - The Meals editor uses
@hello-pangea/dndfor drag-reorder, a realDateTimePickerconstrained to the event window, a "Clone" button per slot, and a side-by-side "Hacker preview" pane (toggleable). - The Schedule editor groups countdowns by day in a timeline view with "Quick add" presets (Kickoff, Workshop, Coffee break, Lunch, Judging starts, Awards, Wrap-up). Single timezone selector at the top of the section defaults to the hackathon's
timezone. List view is a fallback that supports drag-reorder. ALLOWED_DIETARY_TAGSlives inMealsSection.js(was previously in the deletedMealManagement.js). Keep in sync with backendvalidators.py.
The Meals editor has a "Browse menu" button on each meal slot that opens a searchable catalog picker. Catalog files live in src/components/admin/hackathon-edit/catalog/:
fatFreddysCatalog.js— seeded items from Fat Freddy's Catering (Phoenix). Update freely as menus change. Items have{ id, category, name, description, price_cents, unit, min_quantity, dietary_tags, bundled_with? }.unitis"per_person" | "each" | "fixed".catalogStorage.js— combines the seeded catalog with user-added items persisted tolocalStorageunderohack_admin_menu_catalog_v1. New vendors / items added via the picker's "Add a custom item" form go here. (Promote to backend storage if you want cross-device sharing.)MenuCatalogPicker.js— the dialog. Filters by vendor + category, search across name/description, multi-select to add to a meal.formatCurrency.js— USD formatters andcomputeItemCostCents/computeMealCostCents/computeAllMealsCostCents.
Cost extras stored on the hackathon doc (both pass through the permissive validate_meals validator without backend changes):
constraints.meals_estimated_headcount(int, default 50) — used as the default people-eating count for cost estimates.meal.headcount_override(int, optional) — per-slot override when not all attendees eat that meal.meal.items[i]gains optionalprice_cents,unit,quantity(foreach),vendor,catalog_item_idfields.
MealsSection shows a "Estimated cost" summary card at the top of the section using the Fat Freddy's quote defaults (8.6% AZ tax, ~10% gratuity, 2.9% card surcharge, $45 delivery) — toggleable. Per-meal subtotals display as a green chip on each meal card; per-item cost shows under each priced item.
The constraints object on a hackathon doc carries per-event toggles. Keys consumed by the application forms:
judge_venue_arrival_time(HH:MM, 24-hour) — judge form's Availability step shows it when set; falls back to existing default copy when null.judge_judging_start_time/judge_judging_end_time(HH:MM, 24-hour) — the final-day judging window. Drives ALL judging-window copy on the judge form (schedule alert, "For this event" panel, commitment question, and the physical-event blocking validation message) viagetJudgingWindow(eventData); defaults to 15:00/17:30 when unset. Admin UI inhackathon-edit/sections/JudgesSection.js(next to arrival time); backend keys validated viaJUDGE_TIME_CONSTRAINT_KEYSinvalidators.py. Times display throughformatTime12h(module-scope injudge-application.js).hacker_deposit: { enabled, default_amount_cents }— when enabled, hacker form's Review step adds deposit fields and routes through Stripe Checkout (see below) before submit.meals: [{ id, name, time, catering_provided, dietary_tags, items: [{ id, name, description, dietary_tags }] }]— hacker form renders aMealMenufor each slot when in-person and meals are configured. Alloweddietary_tagsare validated server-side; keep them in sync withALLOWED_DIETARY_TAGSinMealsSection.jsand the backendvalidators.py.meals_mode("menu" default | "schedule") +meals_note(optional string ≤ 500) — how meals render on the hacker form."schedule"= times-only: the hacker form swapsMealMenufor the read-onlyMealSchedule(src/components/ApplicationForm/MealSchedule.js— also exportsgetMealsMode/formatMealTime/MEALS_MODE_*/MEALS_NOTE_MAX_LENGTH, all unit-tested in__tests__/MealSchedule.test.js;formatMealTimerenders admin-picked ISO times human-readably in BOTH components);meals_noteshows above the schedule. AdminMealsSectionhas a "What hackers see" toggle: schedule mode hides the items editor/catalog/costs/headcount (items data is KEPT, not deleted), shows the note field, previews via the realMealSchedule, and quick-add creates slots withitems: [](a blank item would fail backendvalidate_meals). Anything except explicit"schedule"resolves to"menu"(legacy docs unchanged). Backend:ALLOWED_MEALS_MODES/MAX_MEALS_NOTE_LENGTHinvalidators.py(both hackathon validators) — keep in sync with the frontend constants. Mentor/judge/volunteer forms also render the read-onlyMealSchedule(regardless ofmeals_mode— those roles never pick items) directly above theirDietaryRestrictionsSelect, under the SAME in-person gate (!isVirtualEvent() && inPerson === "Yes!"/"Yes"); the component owns the no-meals case (renders null), so pages passconstraints?.meals || []with no length check. Mentor + volunteersetEventDatanow retainconstraints(judge already did) — don't drop that key or the schedule silently disappears. Meal editing lives inhackathon-edit/sections/MealsSection.js(/admin/hackathons/[event_id]?section=meals) —MealManagementwas deleted (see "Removed legacy components").
- Frontend route
/api/applications/hacker-deposit/checkoutcreates a Stripe Checkout session; success URL is the hacker form with?deposit_session_id=.... - Frontend route
/api/applications/hacker-deposit/sessionretrieves the session by id and returns{ payment_status, payment_intent_id, amount_total, metadata }. - Hacker form auto-saves to localStorage, so the form survives the Stripe round-trip. On return, it reads the session id, populates
stripePaymentIntentId/depositAmountCents/depositDisposition, jumps to Review, and the next submit posts the application with those fields. Submission fields:stripe_payment_intent_id,deposit_amount_cents,deposit_disposition("refund" | "donate"). - Refund flow (admin):
POST /api/admin/hacker/<volunteer_id>/refund-depositinbackend-ohack.dev/api/volunteers/volunteers_views.py(auth:volunteer.admin). Body{ override: bool }— override is required whendeposit_disposition === "donate". Stripe call is fired BEFORE the Firestore mutation so a partial failure leaves the refund visible in the Stripe dashboard for human reconciliation rather than disappearing. Stripe API key sourced fromSTRIPE_SECRET_KEYenv var (same name as the frontend). The service writesdeposit_status,deposit_refund_id,deposit_refund_amount_cents,deposit_refunded_at,deposit_refunded_byon the volunteer doc, and on Stripe error setsdeposit_status="refund_failed"+deposit_refund_status_msg. Slack audit message posted viasend_slack_audit. - Admin UI:
/admin/volunteer?tab=3shows a "Deposit" column on the Hackers tab wheneventData.constraints.hacker_deposit.enabled === true. Click any chip →HackerDepositRefundDialog. Chip states: Paid / Unpaid (warning, no PI on file) / Donated (paid + disposition=donate) / Refunded / Refund failed (with Stripe error in tooltip). Donate-override is two clicks deep (extra friction). Components:src/components/admin/HackerDepositChip.js,src/components/admin/HackerDepositRefundDialog.js. - Bulk refund (end-of-event): Same admin page surfaces a "Refund N eligible deposits ($X)" button above the table when
depositEnabled. Eligible =deposit_status=paid AND deposit_disposition=refund. Donate and refund_failed rows are excluded by design (each needs a per-row decision). Backend routePOST /api/admin/hackathon/<event_id>/refund-eligible-depositsprocesses per-row with error capture and returns{ refunded, failed, total_amount_cents }. Component:src/components/admin/HackerDepositBulkRefundDialog.js. - Stripe webhook:
POST /api/webhooks/stripe/hacker-depositon the backend (no PropelAuth — Stripe signature is the only auth). RequiresSTRIPE_HACKER_DEPOSIT_WEBHOOK_SECRETenv var on the backend (deliberately distinct from the frontend store webhook's secret — see below). Subscribed events:checkout.session.completed(self-heals the volunteer doc when the form's session-status read missed it — matches bymetadata.hacker_email+event_id, only updates an existing doc, never regressesrefunded→paid) andcharge.refunded(confirms async refund settlement — matches by ourmetadata.volunteer_idon the refund object). Both handlers are idempotent. Configure the endpoint URL + the two event types in the Stripe dashboard and copy the signing secret toSTRIPE_HACKER_DEPOSIT_WEBHOOK_SECRET. - Stripe webhook env-var naming (important): there are now TWO Stripe webhook endpoints in this project. The frontend's
/api/store/webhook.jsreadsSTRIPE_STORE_WEBHOOK_SECRET(with a legacy fallback toSTRIPE_WEBHOOK_SECRETduring rollout). The backend's hacker-deposit webhook readsSTRIPE_HACKER_DEPOSIT_WEBHOOK_SECRET. Each endpoint in the Stripe dashboard has its OWN signing secret — using the same value for both will cause one of them to fail signature verification. Don't reintroduce a genericSTRIPE_WEBHOOK_SECRET. - Still open: no admin view of orphaned Stripe payments (hackers who paid but never submitted the application — webhook logs a warning but doesn't persist anywhere for later reconciliation). For Fall 2026 this is acceptable if deposits stay disabled or volume is low; revisit if a future event has >0 cases.
manageteam.js is now a thin composition: the auth wrapper and every fetch/handler stay in the page (verbatim from the earlier refined pass); presentation lives in src/components/TeamDashboard/*, and every section component there is module-scope (SectionBlock remount lesson). TeamCreation/TeamStatusPanel.js + FormStepper.js were deleted — don't re-add them (DemoVideoEditor/DevPostEditor carry their URL validators verbatim). Plan + contracts: docs/plans/team-dashboard-devpost-replacement.md (Parts 2.1 / 3 / 4-B) — Part B of docs/plans/manageteam-eventpage-improvements.md is superseded by it.
- Composition (
TeamDashboard.js, rendered withkey={activeTeam.id}so the per-team editors re-seed on aTeamSwitcherswitch):TeamStatusHero(IN_REVIEW only — one 220×220 waiting video) →DeadlineStrip→DeliverablesChecklist→HackersChoiceCard(=PeerVoteCTA variant="dashboard") →ProjectWriteupEditor→DemoVideoEditor→CodeActivityCard→MentorSupportCard(+MentorAvailabilityToggle) →SlackCoachCard→TeamRoster→DevPostEditor(framed as optional, last). IN_REVIEW renders hero + strip + Slack + roster only. Section ids:deliverables, project, demo, devpost, code, mentors, slack, roster—#projectis deep-linked from the team page and the gallery ("Add your project story →"), keep it.TeamMastheadowns the page's only<h1>("Your team" / "Create a team"), status viastatusLabel()(🏆 winning,INACTIVEskipped), nonprofit fromevent.nonprofits(no fan-out),awards[]as accent tags, "View public project page →".CreateTeamFlow+GatingPanelsare JSX moved out of the page (handlers stay in the page); the stepper usesrefinedStepperSx/refinedFormThemeand an honest indeterminateLinearProgress(no simulated progress bar). - Page invariants kept (don't regress):
RequiredAuthProvider+ thepostLoginRedirectUrlSSR guard (currentUrl || (typeof window !== "undefined" ? window.location.href : undefined)); split error state —teamsError(only fromfetchMyTeams, renders the hub's calm error card + Retry) vsformError(create-team validation/submit) — never merge them; lazy Slack (active_days=365) + nonprofit fetches gated onactiveStep/showNewTeamForm+slackFetchedRef/nonprofitFetchedRef— never on initial load;handleTeamUpdated(teamId, partial)merges intomyTeamsand every editor/toggle reports through it (no refetch after a save); post-createfetchMyTeams()+ scroll to#team-hub;noindexmeta (inside the client-rendered tree, so not SSR'd — pre-existing);SurveyCTA. Initial load for a team holder = hackathon,team/<event>/me, hacker application,users/profile(whole payload stored asprofile— own db id for the roster "You" tag),messages/team/<id>(roster —/mestripsusers[]); GitHub calls only once the deliverables checklist OR the code card scrolls near (useGithubActivity(team, [checklistRef, codeCardRef])— fire-once; the checklist sits at the top, so in practice the fetch fires on dashboard view. Observing only the card left the checklist's "Push code" row on "Checking your repo's activity…" until the user scrolled to the bottom). - Fixed here (Part 9): #8
hasApprovedTeam=myTeams.some(t => t.status && t.status !== "IN_REVIEW" && t.status !== "INACTIVE")(it compared against non-existentAPPROVED/PROJECT_COMPLETE, so it was always false); #9 findteam'ssessionStorage['team_members']handoff is read once on mount →teamMembersobjects{id: slack_user_id, name, real_name}(opens the create form when a team already exists) then the key is removed; #10TeamMemberManager's render-bodyconsole.logof the Slack user list removed. Also found: the stepper's Back/Next and the member/nonprofit buttons had notype, so inside the<form onSubmit>they defaulted to submit — clicking Back on the confirmation step could silently create the team; all aretype="button"now. - Libs/hooks:
src/lib/teamDeliverables.js—deadlineState({deadlines, endDate, timezone, submissionStatus, submittedAt})→submitted|open|late_open|closed|event_ends|none(+urgentunder 6h;event_endsfalls back to end-of-day ofend_datein the event tz),deriveDeliverables→ rows slack/code/story/video/submit/devpost/completion with statedone|todo|pending|locked|optional(pending= GitHub activity not loaded yet; never assert "No commits yet" before the fire-once IO fires),canSubmit,submitBlockedReason.use-team-project.js— draft vs committed, 1.5s debouncedPOST /api/team/<id>/projectof changed keys only,saveState.statusidle|saving|saved|error|closed|unavailable(409submissions_closed→closed, autosave stops and the deadline/late-until render viaformatDeadlineMoment; 404 →unavailable"Project writeups aren't available for this event yet."),submit()flushes the pending save thenPOST /project/submit; re-seeds only whenteam.idchanges; the unmount flush is keyed on a real-unmount ref, NOT on the token-rotatingdoSave.use-github-activity.js— fire-once IO (rootMargin 200px) over a ref OR an array of refs,Promise.allover repos → ONE setState,/activity404 →unavailable(links only). Backend excludes the repo-bootstrap account's commits (GITHUB_ACTIVITY_EXCLUDED_LOGINS, defaultgregv—create_github_reposeeds LICENSE + README as the token owner), so a fresh repo honestly reads "No commits yet"; a team member logging in as that account is invisible here too.use-public-team.js— roster viaGET /api/messages/team/<id>, refetch onvisibilitychange.src/lib/teamDashboardApi.js— fetch wrappers throwingApiError{status, body}+isSubmissionsClosed/isNotFound/isNotTeamMember/isInvalidProject/formatProjectErrors. Thumbnail upload = the existingPOST /api/messages/upload-image(directory=teams/<id>/project) — no signed-URL mint. Slack "Everyone's in" is a local ack (localStorage["ohx.team.<id>.slackConfirmed"]), not server state. Slack links viasrc/lib/slackLinks.js(slackChannelUrl,KEY_CHANNELS); repo parsing viasrc/lib/githubLinks.js(repoEntriesFromTeam, copied from ProblemStatement's private helpers — leave those alone). - Heads-down toggle:
MentorAvailabilityToggleis aradiogroup(Open to mentors / Heads-down) →POST /api/team/<id>/mentor-availability {open}; optimistic, reverts on failure, hidden on 404.mentor_help_wantedabsent ⇒trueeverywhere. Signal only. - Portal gotcha: MUI Dialog/Snackbar content renders outside
RefinedRoot's subtree, so.ohx-*classes andvar(--x)custom properties never reach it — the submit-confirm Dialog (and the vote page's) use plain MUI Buttons with literal-fallback sx. - Older-backend rule: every new call treats 404 as "feature off" — editors show "not available yet", the toggle hides, the code card shows links only,
PeerVoteCTAstays hidden. Deploy the backend (feat/submissions-peer-vote) first. - CLS reservations: hub skeleton 320, strip 64 +
minWidth 9chdigits, editor 360, video frame 320×180, code card 140, roster row 56, waiting video 220×220; every<img>has width/height. - GA (
EventCategory.ENGAGEMENT):team_dashboard_view,team_deadline_strip_view,team_project_saved,team_project_submitted,team_demo_video_saved,team_devpost_saved,team_mentor_availability_toggled,team_slack_tip_click— catalogued inga-events-reference.md.
Team docs carry the DevPost-replacement write-up (all optional; absent ⇒ legacy): project_tagline (≤140), project_story (markdown ≤20k, server-sanitized — the frontend renders it via react-markdown without rehype-raw), project_built_with[] (≤25), project_links[{label,url}] (≤10), project_thumbnail_url (own CDN, teams/<id>/project/…), project_images[], project_updated_at, project_submitted_at, project_submission_status (draft|submitted|late; absent ⇒ legacy — render no submission tag), mentor_help_wanted, awards[]. The public event payload (GET /api/messages/hackathon/<id>) carries every project_* except project_story (payload size) — only the team page and the dashboard read the story.
src/components/Teams/projectMeta.jsis the single source for submission status, OG and JSON-LD:getSubmissionStatus(team)(nullfor legacy),submissionLabel(team, tz),formatDeadline(iso, tz),projectThumbUrl(CDN thumb → YouTubehqdefault→ null),hasProjectContent(team)(the ONE gate for "has a real write-up" — deliberately ignores the derived YouTube poster so a video-only team doesn't get a duplicate Project section/TOC entry/tag),buildTeamOgImage,buildTeamDescription,buildProjectJsonLd(SoftwareSourceCode; gated on content || repo),firstRepoUrl,isProjectStoryMissing.ProjectStoryMarkdown.js(react-markdown,ssr:true, headings demoted bydemoteByand clamped at h6, lazy imgs,noopenerlinks).- Team page (
team/[team_id]/index.js):TeamProjectSectionrenders first (id="project", in the TOC) whenhasProjectContent; tagline.ohx-lead, 16:9--surface-2thumbnail box, story, built-with.ohx-tags, link tiles, submission line (public: "Submitted Sat 2:58 PM MST" / "Submitted late …" / quiet "In progress"; members: "Draft — not yet submitted." + "Edit on your dashboard →" →manageteam#project). Masthead adds a submission.ohx-tagwhen status is non-null. The member nudge covers missing story / missing demo / not submitted (DevPost is no longer nagged).<Head>OG/twitter image + card and the JSON-LD come fromprojectMeta.next/imageONLY forcdn.ohack.dev(the only allowedremotePatternshost besides giphy) — YouTube posters are plain<img width height loading=lazy>. - Gallery (
Hackathon/TeamList.js): module-scopeProjectMedia(16:9 reserved box: thumbnail →LiteVideoThumbnail→ Fraunces initial placeholder) tops each card; tagline 2-line clamp; first 3 built-with ++N; submission tag; DevPost demoted to a quiet "DevPost ↗" link (absent + member → "Add your project story →" tomanageteam#project);submittedOnlytoggle (aria-pressed, rendered only when n > 0) over a stable sort that keeps active-first as the primary order and submitted-first within it (useMemo); "No submitted projects yet." only while the toggle is on. Lazy per-card avatar loading is untouched. No new fetches. - Results (
Hackathon/HackathonResults.js— also rendered outside aRefinedRooton/results, sovar(--x, #hex)fallbacks): winner cards showproject_tagline+awards[]pills (Part 9 bug #11 — awards were never rendered; guard non-array), DevPost chip only when present; module-scopeHackersChoiceCard(newpeerVoteEnabledprop =!!constraints.peer_vote_enabled, passed from the event page andresults.js) fetchesGET /api/hackathons/<id>/peer-vote/summaryinto aminHeight 148slot — published → winner link + "{ballots} peer ballots"; 404/unpublished → "announced at the awards ceremony"; disabled → renders nothing.HackathonFunnelsublabels are source-neutral.Profile/Portfolio/TeamsShowcaseSection.jsximportsWINNING_STATUSESfromconstants/teamStatus(bug #12 — no private copy). - Judge views — demo video only, no process change:
pages/judge/[event_id].jsandpages/judge/[event_id]/team/[team_id].jsreaddemo_video_url || video_url(the backend now fills the legacyvideo_urlfromdemo_video_url); the scoring page embedsVideoDisplay(dynamic, ssr:false, 16:9 container) under the GitHub/DevPost links, gated!isRound2exactly like the pre-existing "Watch Pitch Video" button. Rubric, scoring form, rounds,JudgingRound1/2/Results,/about/judges,/hackathon-judging-criteriaandmentorCoverage.jsare untouched — verified with a prettier-normalized diff.
Assigned-slate approval vote (plan Part 2.5 — chosen over quadratic voting, pairwise comparisons and likes): eligible voters = isSelected hackers (server-checked; with constraints.peer_vote_requires_submission they must ALSO be on a submitted team), each gets a deterministic exposure-balanced slate of peer_vote_slate_size (3–10, default 5) submitted projects never including their own, picks up to peer_vote_max_picks (default 2) — "Which would you be proud to have built?"; score = Wilson lower bound of approvals/shown; one ballot per voter, picks changeable until close, admin void, admin Publish appends "Hackers' Choice" to awards[] + writes the public summary. No tallies are ever shown to voters — only the admin results table. Window defaults when deadlines.voting_* are unset: opens late_submission_until || submission, closes end-of-event (event tz); no deadlines or peer_vote_enabled=false ⇒ disabled.
- Voter page
src/pages/hack/[event_id]/vote.js(noindex,RequiredAuthProvider+ SSR-guarded redirect,dynamic ssr:false; excluded innext-sitemap.config.js) →src/components/PeerVote/PeerVotePage.js+PeerVoteSlateCard.js. FetchesGET /api/hackathons/<id>/peer-vote/slatewith the Bearer token (keyed on token presence; refetch onvisibilitychangewhile upcoming) and renders every backendstatus:disabled(also any 404 = older backend),not_eligible(reasonnot_selected_hacker→ "This vote is for registered hackers." /own_team_not_submitted→ "Submit your project to unlock voting."),upcoming(useCountdown),open(card grid + sticky "{n} of {max} picked" bar → confirm Dialog →POST …/peer-vote/ballot {picks}→ confetti [skipped under reduced motion] + "Results are announced at the awards ceremony.";reason: not_enough_submissionsrenders its own card),voted("Your picks" + "Change picks" while the window is open),closed,voided(read-only). Ballot errors branch onerr.body.error:invalid_picks/no_slate→ refetch slate + notice,not_eligible/peer_vote_disabled,voting_closed,ballot_voided. ONE page-levelVideoDisplaydialog; the confirm Dialog uses plain MUI buttons (Portal — see the dashboard's Portal gotcha). The slate payload has noproject_built_with(Part 3 contract wins over the appendix), so slate cards render no built-with tags. Accepted deviation: the page also callsgetHackathonMeta(GET /api/messages/hackathon/<id>) for the title/timezone/slate size.peer_vote_viewfires once per distinct status per visit. State matrix:PeerVote/__tests__/PeerVotePage.states.test.js. PeerVoteCTA(src/components/PeerVote/PeerVoteCTA.js, built in WS-0) is shared by the event page (right afterSurveyCTA) and the dashboard (TeamDashboard/HackersChoiceCard→variant="dashboard"): var-fallback inline styles (works in or out ofRefinedRoot), mount-gated against hydration mismatch,role="complementary", visible only whenderiveVoteWindow(deadlines, constraints, now)(peerVoteState.js— mirrors the backend defaults; alsotogglePick/canSubmit/slateLinks) isopenorupcomingwithin 24h.- Admin results:
/admin/hackathons/<event>?section=judging&subtab=peer-vote→src/components/admin/PeerVoteResults.js(4thJudgingSectiontab; the host page already gatesvolunteer.admin, matching the backend).Promise.all([GET …/peer-vote/results, GET …/summary])→ ONE setState (summary 404 → null); header "{ballots} ballots · ~{eligible} eligible";!enabledinfo alert pointing at the Deadlines section; ranked table Rank/Team/Shown/Approvals/Rate/Wilson LB/bar with ashown < 3warning; per-ballot rows fromballots_detaileach with Void (POST …/ballots/<propel_id>/void, confirm); Publish disabled untilresults.window.stateis closed (falls back to thevoting_closesprop) and not yet published (POST …/publish, confirm) → refetch + banner. Token-rotation pattern honored (refs + presence). Backend errors are{error: …}not{message: …}— readbody.errorwith the specific codes (no_ballots, …). - GA:
peer_vote_view{event_label: status, event_id},peer_vote_cta_click{event_label: variant, event_id},peer_vote_submitted{value: picks.length}; admin (EventCategory.ADMIN)admin_peer_vote_publish,admin_peer_vote_void_ballot.
hackathon-edit/sections/DeadlinesSection.js (manifest slug deadlines, group config, right after Schedule, Alarm icon) edits the top-level hackathon field deadlines {submission, late_submission_until, voting_opens, voting_closes} — ISO strings with an explicit offset, saved in the event timezone (hackathon.timezone || DEFAULT_EVENT_TIMEZONE; the test events have no timezone) — with four DateTimePickers and dual-timezone helper text. It is an explicit-save section (EXPLICIT_SAVE_SECTIONS in useHackathonAdmin.js) that deliberately owns BOTH the deadlines and countdowns keys, because "Add to countdown timeline" upserts a public "Submissions close" countdowns[] entry (upsertCountdownByName — case-insensitive name match keeps the entry's id, else appends) so the event page's EventCountdown shows the cutoff too. Pure helpers + tests in hackathon-edit/deadlinesUtils.js: validateDeadlines(d) → {errors, warnings} (late ≥ submission, voting_closes > voting_opens are errors and block Save via SectionContainer's saveDisabled prop; voting opening before the submission deadline is a warning), hoursBeforeLabel. The Hackers' Choice card on the same section writes constraints.peer_vote_enabled / peer_vote_slate_size (3–10) / peer_vote_max_picks (1..slate−1 — re-clamped in the same onChange when the slate size drops, since the backend silently drops an out-of-range value) / peer_vote_requires_submission through admin.setConstraint(...) and autosaves (not part of the explicit-save set). "Send reminder now" 24h/6h/1h (disabled while the section is dirty or there is no submission deadline) → POST /api/hackathons/<event>/deadlines/remind {kind:'submission', hours_before} (admin or X-Api-Key; 409 no_deadline/already_sent come back as {error} — surface body.error); GA admin_deadline_reminder_sent{event_label: event_id, value: hours}. Backend: deadlines is allowlisted in save_hackathon + validated in validate_hackathon_data_partial on feat/submissions-peer-vote; reminders also run hourly from a GitHub Actions cron. Deploy backend first — an older backend drops the field silently.
toIsoWithTimezone(date, tz) / safeParse live in src/lib/timezoneUtils.js (moved out of ScheduleSection.js; Schedule + Deadlines import them; formatDeadlineMoment for one-line tz-aware copy lives there too). The offset is derived from the formatted wall-clock parts (wall-clock-as-UTC minus the real instant) — NOT from Intl's timeZoneName (for America/Phoenix it is "MST", no digits to parse) and NOT from the browser's getTimezoneOffset() (the old fallback — an admin outside Arizona silently saved every deadline/countdown hours off; caught in WS-F because the test host runs in CEST). It always emits a colon offset (-07:00, never -0700) because the backend parses these with Python 3.9's datetime.fromisoformat, which rejects the colon-less form and would drop the save. Tests cover several host TZs (src/lib/__tests__/timezoneUtils.test.js).
isSelected is a single boolean that defaults to false. false is ambiguous — it covers both "still under review" and "not selected after review" — so do NOT render rejection copy on isSelected === false. Both findteam.js and manageteam.js render two distinct neutral panels (findteam.js keeps the blue #e3f2fd → #ede7f6/#e8eaf6 Papers; on manageteam they live in src/components/TeamDashboard/GatingPanels.js as refined .ohx-cards with the emoji dropped — same copy):
!application→ 📝 "Apply first to use the Team Finder" / "Apply first to manage a team" with submit-application CTA.application && isSelected === false→ ⏳ "Your application is awaiting confirmation" with an info Alert explaining ~1-week review, "while you wait" actions (Slack, year-round projects, other events), and a refresh hint for sync lag. Keep both files in sync if the copy changes. Do not callsetError(...)for these states — the dedicated panels handle it; the Alert at the top is reserved for actual fetch failures.
The team-creation member picker (TeamMemberManager.js) is a freeSolo MUI Autocomplete, so teamMembers carries BOTH Slack-user objects ({id, name, real_name, tz}) from the dropdown AND raw free-text name strings the user typed. Every consumer must handle both shapes:
- Frontend render (
TeamMemberManager.js,ConfirmationSummary.js):typeof member === 'string' ? member : (member.real_name || member.name || ''). - Backend (
queue_teaminapi/teams/teams_service.py): guard withisinstance(member, dict)thenmember.get("id")— neverif "id" in member(that's a substring test against strings; names like "Sidney"/"David" match and thenmember["id"]raisesTypeError). Only objects with a Slackidget invited to the channel and linked tousers_list; free-text names are informational-only and intentionally NOT linked. There is noSLACK_USER_ID_PREFIXconstant — build the fulloauth2|slack|{workspace}-{id}form withnormalize_slack_user_id()fromcommon/utils/oauth_providers.py.
Refined (<RefinedRoot>/<RefinedFonts> + TeamBreadcrumbs) mentor one-stop-shop. Keeps all original check-in/out logic verbatim (the gnarly availability-string parser, the /api/mentor/checkin/<event>/{in,out,status} calls + Slack notify, confirm dialogs); only the presentation was rewritten to .ohx-*. Gates unchanged: login required → registered-mentor required (/api/mentor/application/<event>), else refined apply CTA.
The headline addition is MentorTeamsTable (src/components/Mentor/MentorTeamsTable.js): a searchable/filterable/sortable table of every team for the event with mentor-relevant columns — Team (→ /team/<id>), Status, Nonprofit, Members (avatars), Coverage X/6, Open flags, Last mentor touch, and Links (Slack/GitHub/DevPost/Demo + a "Mentor →" deep-link to the team's /mentor sub-page). Filters: All / Needs attention / Active / Winning; default sort surfaces open-flag and never-touched teams first. Desktop = hairline <table> (horizontal-scroll wrapper); mobile (<md) = stacked cards.
Short alias: next.config.js redirects /hack/:event_id/mentor → /hack/:event_id/mentor-checkin (307, temporary). :event_id is one segment, so it never catches the team-level /team/<id>/mentor, nor /mentor-checkin / /mentor-application.
Mentor-application QR + remote check-in: on /hack/<event>/mentor-application, the VolunteerCheckInQR (in-person check-in) renders only for isSelected (approved) mentors — both QR spots are now gated on Boolean(volunteerId) && isSelected (also removes the previously-empty "Check-in" card for pending mentors). A module-scope RemoteCheckInNote renders beside each QR telling remote/virtual mentors (formData.inPerson === "No, I'll be virtual") to check in online via /hack/<event>/mentor-checkin instead. The shared VolunteerCheckInQR component stays generic — the mentor-checkin link lives in the page, not the component.
Data source = the page's existing GET /api/messages/hackathon/<event> fetch — no new call, no backend change. That single-event endpoint returns teams[] as full team docs (enriched users[] + mentor_* fields when present; absent for teams with no mentor activity, handled with defaults) plus nonprofits[] for the id→name map. The page captures setTeams(eventData.teams) / setNonprofits(eventData.nonprofits) in the same effect. MentorTeamsTable reuses statusLabel (teamPageData.js), isWinningStatus/getWinningStatus, and MENTOR_COVERAGE_ITEMS/relativeTime (mentorCoverage.js). Note: the list endpoint get_hackathon_list is NOT enriched — only the single-event getter is (see C7 enrichment note).
Backend send_volunteer_confirmation_email() (services/volunteers_service.py) now adds a [Pending Review] subject prefix and a yellow "your application is pending review — up to a week" banner for volunteer_type in ("mentor","judge"). Role-specific next-steps live under an "Once approved" heading. Hacker confirmations (when added) should keep the existing "received" framing since they don't go through staff review.
Per-event results live on a dedicated page /hack/[event_id]/results that renders the existing HackathonResults component plus a new HackathonFunnel viz. The funnel reads from a new public-safe summary doc.
Subcollection: hackathons/{hackathon_doc_id}/funnel/summary — counts only (no PII):
registered,started_project,submitted_project,submitted_gallery_visiblestatus_breakdown,step_breakdown,referral_breakdown,teammate_intent_breakdown,country_breakdownsource,source_files,last_updated,last_updated_by
Winning + founding-engineer counts are NOT stored in the summary — they're computed at read time from the teams collection (status in WINNING_STATUSES) so they stay fresh as judging changes. The funnel response also includes a participation block computed live: applied_as_hacker (count of volunteers docs for the event with volunteer_type=hacker, no isSelected filter — matches HackathonResults.js) and formed_team (count of unique user-doc IDs across all teams linked to this hackathon, deduped because a person could be on more than one team).
Backend: GET /api/messages/hackathon/{event_id}/funnel (5-min TTL cache, public, no auth). Service in services/hackathons_service.py::get_hackathon_funnel. Cache is cleared via clear_cache() along with the other hackathon caches.
Aggregate (all-time): GET /api/messages/hackathons/funnel/aggregate (10-min cache) sums every stage across every hackathon — no cross-event dedup, so a person in three events counts three times. Service: get_hackathon_funnel_aggregate. Page: /hack/results (no event_id) renders the same HackathonFunnel viz on aggregate data. Per-event dedup IS applied (a person on multiple winning teams in one event is counted once for that event), but across events totals are summed.
Backfill script: backend-ohack.dev/scripts/backfill_devpost_funnel.py — dry-run by default. Takes --registrants-csv and/or --projects-csv (Devpost exports). Re-running is idempotent — the summary doc is fully overwritten on --apply.
HackathonFunnel stage sublabels are source-neutral since Sep 2026 ("People who registered for the event" / "People on a project (any state)" / "People on a submitted project") — the data is no longer DevPost-specific; Phase 2 sources the stages from project_submission_status.
Front-of-house: HackathonResults accepts a fullResultsHref prop. On /hack/[event_id] it points to /hack/[event_id]/results so users can jump to the deeper page. The /results page renders the same HackathonResults widget at top + HackathonFunnel below.
Per-team demo_video_url (string) + companion demo_video_url_submitted (ISO timestamp, set on first save). Stored on the Firestore teams doc, mirrors the devpost_link shape. Allowed providers: YouTube, Vimeo, Loom, Google Drive (same set the VideoDisplay component handles).
- Backend: field in
edit_team()allowlist (api/teams/teams_service.py). Hacker self-serve viaPOST /api/team/<teamid>/demo-video(mirrors/devpost) — both routes are member-gated since Sep 2026 (self_serve_team_edit: 403not_team_member, 409submissions_closedpast the deadline, admin bypass; they used to accept any logged-in user — plan Part 9 bug #1). Admin uses the existingPATCH /api/team/edit(gated onvolunteer.admin). - Public display:
TeamList.jsshows a<LiteVideoThumbnail>per team card (CWV-safe — single lazy<img>of the YouTube hqdefault.jpg, NOT an iframe per card). Click → one page-level<Dialog>with<VideoDisplay>. Per-card iframes were rejected because 30+ embeds = ~45MB and trashes LCP/CLS. - Winners (
HackathonResultson/hack/[id]/results): inline<VideoDisplay>embed — only 3-5 cards so direct iframe is fine. Iframe hasloading="lazy". - Hacker self-serve:
TeamDashboard/DemoVideoEditor.js(the oldTeamStatusPanel.jswas deleted) — 320×180 reservedLiteVideoThumbnailframe + a page-levelVideoDisplaydialog;isValidDemoVideoUrlported verbatim (4 providers). Judges now see the same video on/judge/<event>/team/<id>(demo_video_url || video_url). LiteVideoThumbnail(src/components/VideoDisplay/LiteVideoThumbnail.js): the lite-embed component. Renders YouTube hqdefault thumb when the URL is YouTube; otherwise a generic dark "▶ Watch demo" tile (don't fetch Vimeo oEmbed per render — kills CWV). Always wraps a<button>withloading="lazy" decoding="async"+ explicitwidth/height(CLAUDE.md CWV rule).
The team detail page is now src/pages/hack/[event_id]/team/[team_id]/index.js (was [team_id].js — moved into the dir so sub-routes can exist; all relative imports there are depth-5 ../../../../../). The two heavy panels were lifted off the overview page into dedicated routes to cut vertical space:
…/team/[team_id]/mentor.js→ framesMentorTeamPanel(gated oneventHasStarted; before-start shows a calm "opens when the event starts" card, never 404).…/team/[team_id]/completion.js→ framesTeamCompletionChecklist(gated onshowCompletionChecklist = isWinningStatus || DEPLOYED/NONPROFIT_SIGNOFF; non-winning shows an "unlocks later" card, never 404; usesuseTeamMembershipfor write-gating).
On index.js the two SectionBlocks (ids #mentor-support / #completion, still in the TOC) now render compact refined summary cards — TeamMentorSummaryCard / TeamCompletionSummaryCard (src/components/Teams/) — computed from fields the public get_team already returns (mentor_checklist, mentor_open_flag_count, mentor_last_touched_at, mentor_ratings; completion_checklist, completion_status, completion_completed_at) — no backend change. Each card links to its sub-page. The mentor summary always renders (empty-state line when no activity); the completion summary renders only for winning teams (same gate as before).
Shared across the three pages: src/components/Teams/teamPageData.js (fetchTeamAndEvent — the rethrow-network / notFound-on-404 SSR fetch; statusLabel; COMPLETION_VISIBLE_STATUSES/SCROLL_OFFSET/OG_IMAGE), src/hooks/use-live-team.js (client refetch after hydration), src/components/Teams/RefinedTeamShell.js (Shell), src/components/Teams/TeamBreadcrumbs.js (refined-token breadcrumbs + BreadcrumbList JSON-LD). COMPLETION_ITEMS/COMPLETION_TOTAL are now exported from TeamCompletionChecklist.js (single source for the summary card). All three pages carry a Home › Event › Team › {leaf} breadcrumb (the overview's old "← Back to event" top link was replaced by it); sub-pages are noindex,follow with fallback:"blocking". The event-list MentorSupportSummary "Mentor details →" link (src/components/Hackathon/TeamList.js) points at …/team/<id>/mentor. The heavy panels were moved verbatim (no internal refactor) and stay dynamic(ssr:false).
On /hack/[event_id]/team/[team_id], winning teams (isWinningStatus(team.status) OR team.status in ['DEPLOYED','NONPROFIT_SIGNOFF']) see an interactive 8-item Definition of Done checklist (src/components/Teams/TeamCompletionChecklist.js) mirroring /about/completion. Each check is permanent — backend 409s on re-check, no unchecking. Each click opens a confirmation Dialog → on confirm POSTs to /api/team/<teamid>/completion/toggle with {item: <slug>}, fires react-confetti (already in deps, dynamic ssr:false same as pages/volunteer/track.js), and the backend posts a celebration message into the team's slack_channel. When 8/8 done, a giant glowing button POSTs /api/team/<teamid>/completion/complete which Slacks the team channel CCing the 6 OHack admins (TEAM_COMPLETION_SLACK_ADMINS in api/teams/teams_service.py, single source of truth for both queue_team() admin invites and completion broadcasts).
Both routes are @auth.require_user + a user_is_on_team() check. Identity matching is non-obvious here: a Firestore user doc stores TWO identity fields — user_id (the OAuth identity, e.g. oauth2|slack|..., sometimes empty) and propel_id (the PropelAuth UUID, marked PII). PropelAuth's auth_user.user_id is the propel UUID, so user_is_on_team() translates propel UUID → OAuth user_id via get_propel_user_details_by_id(...) (same as get_my_teams_by_event_id) and compares; it also falls back to propel_id direct-match for users that have it set. Frontend membership check is done via GET /api/team/<event_id>/me rather than client-side comparison, because the public team payload deliberately omits propel_id (PII). Non-members see a read-only progress view. Item slugs (deployed, nonprofit_signoff, login_details, code_updated, tasks_closed, sensitive_info_security, documentation, open_source) MUST stay in lockstep between frontend COMPLETION_ITEMS and backend COMPLETION_ITEMS. New Firestore fields on the team doc (all optional; no migration): completion_checklist, completion_status (not_started|in_progress|complete), completion_completed_at, completion_completed_by_propel_id, completion_completed_by_name.
Team member rendering on the same page: the public get_team (services/teams_service.py) now enriches team.users[] from a list of doc-id strings into {id, user_id, name, nickname, profile_image} via a single batched Firestore get_all, cached behind the existing 10-min TTL. Backwards-compatible: HackathonResults.js/TeamList.js already handle both shapes. The list endpoint get_teams_list() is NOT enriched — only the single-team getter. Profile tile links point to /profile/{user.id} (Firestore doc id, NOT propel_id — see "Public profile route" gotcha).
On /hack/<event_id>/team/<team_id>, mentors get an interactive support panel (src/components/Teams/MentorTeamPanel.js) above the completion checklist. Visible to everyone once event.start_date <= now (read-only for non-mentors, never archived). Four sections: Open concerns (flags with owner attribution + take-over), Coverage (6-item team-observation checklist mirroring MENTOR_COVERAGE_ITEMS), Judging readiness (5-criterion rubric — Scope/Documentation/Polish/Security/Accessibility, the last being the special-category prize on /about/judges; consensus = worst rating across mentors), and Notes feed (chronological, attributed, soft-delete-own). Mobile renders as <Accordion>s, desktop renders all sections inline (useMediaQuery(theme.breakpoints.down("sm"))).
Item slugs (intro_made, scope_reviewed, architecture_discussed, repo_health_checked, criteria_walkthrough, demo_devpost_reviewed) MUST stay in lockstep with backend MENTOR_COVERAGE_ITEMS in api/mentors/mentors_service.py. Same lockstep contract as TeamCompletionChecklist.
Coverage is PER-MENTOR (multi-sign-off), not a single global checkbox. Each item collects independent checks from up to COVERAGE_TARGET_MENTORS (=3) distinct mentors; an item only counts toward "X/6 covered" (and the one-time 6/6 Slack milestone) once 3 mentors sign off. A mentor can only add or clear their own check — clicking your check clears it; an item already at 3 (and not yours) renders locked. Data shape: mentor_checklist[slug] = { checks: { <propel_id>: { name, checked_at } } }. The legacy single-mentor shape ({ done, checked_by_propel_id, checked_by_name, checked_at }) is read transparently as one check and migrated into checks on next touch. All consumers compute counts via the shared helpers in mentorCoverage.js (coverageChecks, coverageItemCovered, coverageDoneCount, COVERAGE_TARGET_MENTORS) — never read .done off a coverage entry. Consumers: MentorTeamPanel, MentorTeamsTable, Hackathon/TeamList (MentorSupportSummary), TeamMentorSummaryCard. Backend Firestore gotcha: ref.set({mentor_checklist:{...}}, merge=True) deep-merges map fields, so removing a nested check requires a firestore.DELETE_FIELD sentinel at the exact key path — popping the key in Python then writing the dict does NOT delete it (this was the "Coverage cleared but still checked" bug). MockFirestore replaces maps instead of deep-merging, so this only reproduces in prod.
Mentor auth gate: server-enforced via user_is_mentor_for_event(propel_user_id, event_id) in api/mentors/mentors_service.py. Requires a volunteer doc with volunteer_type='mentor', event_id=<this event>, isSelected=True. Identity matching uses the shared _find_mentor_volunteer() resolver, which mirrors handle_get (volunteers_views.py) and tries three lookups in order: (1) raw propel UUID against the doc's user_id field — how handle_submit stores self-submitted apps, the common case; (2) PropelAuth email == doc email; (3) OAuth user_id (oauth2|slack|..., legacy docs). Lookup #1 is load-bearing: it was once missing (only email + OAuth-user_id were tried, and the OAuth-user_id query is a dead path against propel-UUID-stored docs), so any approved mentor whose application email ≠ login email got a false 403. Don't drop it. Frontend gates interactivity via GET /api/volunteer/<event_id>/me?type=mentor (returns {is_mentor, volunteer} — the volunteer subset is lean, no PII). When eventId={null} is passed, the fetch is skipped entirely — used by MentorTeamPanelDemo.js on /about/mentors to render the panel statically without backend calls.
Slack volume is intentionally quiet: only flag-raises, flag-resolutions, and the first-time "all 6 covered" milestone broadcast to the team's slack_channel. Flag-raises also heartbeat the per-event mentor channel (hackathon.mentor_slack_channel, defaulting to <event_id>-mentors lowercased with _→-). Coverage toggles, notes, take-overs, and rating changes are quiet. Each write does call send_slack_audit(...) for the audit trail.
Live updates: the panel refetches GET /api/messages/team/<id> on document.visibilitychange (tab refocus) — best-effort, errors swallowed. No polling.
Heads-down signal (Sep 2026): teams toggle mentor_help_wanted from their dashboard (POST /api/team/<id>/mentor-availability {open}); absent ⇒ true (open to mentors). === false renders a quiet tag/line on MentorTeamsTable ("Availability" column), the MentorTeamPanel header, TeamMentorSummaryCard and TeamList.MentorSupportSummary — signal only, no sorting or behaviour change; mentors are asked to check Slack before dropping in.
"Teams Ready for a Boost" extension (api/leaderboard/leaderboard_service.py::collect_mentor_panel_opportunities): the leaderboard's mentor_opportunities array now includes two new sources — any team with an open mentor_flag (up to 2 per team to limit noise), and any team with mentor_last_touched_at > 4h ago during a live event window (start_date <= now <= end_date + 1d). Renders alongside the existing GitHub-derived signals.
Hackathon field: mentor_slack_channel (optional string, max 80 chars, validated as a top-level field in common/utils/validators.py). Admin UI lives in OverviewSection.js. The frontend never reads this directly — it's purely a backend Slack-routing config.
Denormalized team fields (kept in sync by every mentor service write): mentor_last_touched_at, mentor_last_touched_by_name, mentor_open_flag_count, mentor_coverage_completed_at, mentor_coverage_completed_by_name. The leaderboard reads these directly; don't compute on the fly.
Judging readiness / coverage / flags render IDENTICALLY everywhere — the per-team MentorTeamPanel, the team-overview TeamMentorSummaryCard, and the event-page TeamList JudgingReadinessStrip all compute from the same mentor_ratings array via latestRatingsByMentor + consensusForCriterion (worst rating across mentors, per criterion). If these LOOK different across pages it's a cache-staleness mismatch, not a logic bug: the team page is live (useLiveTeam refetches get_team, whose _GET_TEAM_CACHE is registered + busted on every mentor write), but the event page (/hack/<event_id>) is getStaticProps ISR (revalidate: 60) reading the backend-cached get_single_hackathon_event (10-min TTL, NOT a registered cache). Mentor-service clear_cache() (api/mentors/mentors_service.py) therefore also calls hackathons_service.clear_cache() so mentor changes flush the event cache — without that the event #teams view lagged up to 10 min. Residual freshness floor on the event page is the 60s ISR interval (inherent; the page has no client-side teams refetch).
File: src/components/admin/TeamManagement.js (~2900 lines, hosted in src/pages/admin/teams/index.js). Three concerns added together (deliberately scoped — no full redesign):
- Demo Video column + inline-edit Popover (
TeamFieldPopover): table cell shows a 96×54 thumbnail if set, "+ Add" button if missing. Click → Popover withTextField+ liveLiteVideoThumbnailpreview + Save/Cancel/Clear. Optimistic update:handleQuickPatchPATCHes the partial and merges into localteamsstate — no full refetch. patchTeam(partial)helper: extracted fromhandleSaveTeam. Always includeidin the partial. Both the full edit Dialog andTeamFieldPopoveruse it. If you add another quick-edit field, hang it off this same helper + the Popover (parameterizefield/label/placeholder/validate/previewKind).- Filter chips above the table (state:
activeFilter):All/Winning/In review/Active/Missing DevPost/Missing Video. Pure client-side — extends the existingfilteredTeamsuseEffect. Filter resetspageto 0 so the user lands on results. - The full edit Dialog's Team Details tab also has the Demo Video URL TextField (next to DevPost), with the same
validateDemoVideoUrlhelper and aLiteVideoThumbnailpreview underneath. - Team approval lives WHERE you assign the nonprofit, not in Communication. Approving an
IN_REVIEWteam posts toPOST /api/team/approvewith{teamId, nonprofitId}— the call carriesselected_nonprofit_idand persists it server-side, so no separate "Save Changes" is needed to approve (selecting in the dropdown only updates local state; approve commits it). The "Approve Team" CTA is surfaced in two places: (1) inline in the Nonprofit Assignment tab right under the selector (green box, disabled until a nonprofit is picked), and (2) a persistent footer action in the edit dialog — green "Approve Team" when a nonprofit is selected, or an outlined "Assign nonprofit to approve" that jumps to tab 1 (setActiveTab(1)) when not. Both open the existing confirm dialog (approvalDialogOpen→handleApproveTeam). Don't re-add the approve button torenderCommunication(it was removed from there — it's unrelated to messaging and was the source of the confusing "select nonprofit → switch to Communication tab → approve" flow). Footer/inline CTAs only show forstatus === "IN_REVIEW"; approved teams show a success confirmation instead. - Nonprofit Assignment tab — always render the dropdown. Do NOT short-circuit
renderNonprofitSelectionwhenteamData.nonprofit_rankingsis missing; teams created outside the matching flow still need a manual-assign UI.fetchNonprofitsfalls back toGET /api/messages/npos(all nonprofits) when the hackathon-scopedGET /api/messages/npos/hackathon/{id}returns an empty list, so admins can still pick a nonprofit on hackathons that don't have any attached.nonprofitSource("hackathon"|"all") drives a fallback notice in the UI. - Don't N-render the component. Fetching per-repo GitHub data (issue summaries, issues) used to do a separate
setStateper repo, which re-renders this ~2900-line component once per repo (~12–30 cascading renders on load and on every dialog open). Both prefetch paths now collect results viaPromise.alland merge into a SINGLEsetGithubIssueSummaries/setGithubIssuescall. If you add another per-team batch fetch, follow the same pattern — never call setState in a forEach loop over teams/repos. - No render-body
console.logs. Logs at module top-level inside the component body (e.g.console.log("Team Data:", teamData)) fire on EVERY render. They turn a render storm into console spam and slow the page further. Keep diagnostic logs inside callbacks or effects, never in the render path. - Submissions (Sep 2026): module-scope
SubmissionChipdrives new "Submission" + "Story" columns after "Demo Video" (getSubmissionStatus→ submitted/late/draft; legacy null → "—"); filter chipsMissing story/Not submitted/Late(the DevPost filter is demoted to last); the edit Dialog's Team Details tab gains a tagline field (140 + counter), a submission-statusSelectoverride (helper: "Override only — e.g. accept a late submission."; never send an explicitnull— the backend'sPROJECT_SUBMISSION_STATUSESallowlist 400s the whole save) and a read-onlyProjectStoryMarkdownpreview with an "Edit story" toggle; "Export submissions CSV" →src/components/admin/teamSubmissionsCsv.js::buildSubmissionsCsv(RFC-4180; columns name/tagline/submission_status/submitted_at/demo_video_url/github_url/devpost_link/slack_channel/members/nonprofit; filesubmissions-<event>.csv; GAadmin_submissions_csv_export). Same ONE-setState / no-render-log rules apply.
The page is intentionally optimized so a visitor reaches an upcoming event in the first ~250px of scroll. The order is hero → upcoming events → story strip → archive → "About these events" (merged Why Join + Before signing up) → Sponsor CTA. Do not re-insert marketing copy ("Why Join") or news ("Latest Updates") between hero and events — both were removed because they pushed events 900+px below the fold.
Hero is intentionally minimal: one h1 (<h1>Hackathons for nonprofits</h1>) + two CTAs ("See upcoming events" / "Join the community"). The long "Since 2013, we've helped 100+ nonprofits..." tagline was removed because the Story Strip immediately below shows that with concrete numbers. Don't re-add the tagline.
Section IDs (load-bearing for HackPageNav and other anchor consumers):
#upcoming-eventson the upcoming events<Box>wrapper inpages/hack/index.js#since-2013on the outer<Box>ofHackathonStoryStrip#previous-eventson theOuterGridofPreviousHackathonList#about-eventson the merged Why Join + Before You Join section inpages/hack/index.js#year-{yyyy}on eachYearSectioninside the archive- All section anchors carry
scrollMarginTop: 100(or higher) so jumps don't get hidden behind the 80px NavBar.
src/components/HackathonList/HackPageNav.js — small floating widget showing the four top-level sections. Hidden until hero scrolls past (driven by a rAF-throttled scroll listener checking when h1.getBoundingClientRect().bottom < 60). Desktop: position: fixed; left: 16px; top: 50% vertical pill stack. Mobile: position: fixed; top: 64px (below NavBar) horizontal scrollable pill bar. Active state follows scroll position via the same rAF anchor-line pattern as the archive (anchorY = 160). Updating the section list = edit SECTIONS array at the top of the file.
The "Latest Updates" news block inside HackathonList is rendered ONLY in compact={true} mode (used by the home page sidebar). On /hack, the news fetch is gated by if (!compact) return undefined; in its useEffect to avoid the network call. The full-width /hack render shows a small "Read latest updates from Opportunity Hack" link to /blog below the events grid. Do not re-add news to the full-width render — it broke the events → strip → archive flow and added ~500-800px of scroll.
After the archive, #about-events combines:
- A compact "What you get" 4-up icon row (Code / Group / EmojiEvents / EventAvailable) — replaces the old "Why Join" Paper. No big photo. Icons + 1-line descriptions.
- The original "Before signing up" 3-card row (Code of Conduct / Liability Waiver / Photo Release).
Both share the same h2 ("About these events") and are stacked under
overlinesub-headings. Don't promote either back above the events — they're context, not finder-flow.
HackathonStoryStrip (src/components/HackathonList/HackathonStoryStrip.js) bridges <HackathonList /> (upcoming) and <PreviousHackathonList /> (archive) on /hack. It fetches GET /api/messages/hackathons/funnel/aggregate for stat tiles and derives a year sparkline from useHackathonEvents("previous"|"current") (no extra fetch).
- Year jump uses a CustomEvent, NOT URL hash. Clicking a year dot dispatches
window.dispatchEvent(new CustomEvent('ohack:archive-jump-year', { detail: { year } })).PreviousHackathonListlistens for that event, setsactiveYear, then scrolls#year-{yyyy}into view. Don't switch to hash-based — it would collide with deep-link patterns elsewhere. - Arizona callout lives INSIDE the strip. Previously a standalone
<Alert>between "Why Join" and Upcoming. Do not re-add it topages/hack/index.js— it now sits below the year sparkline insideHackathonStoryStrip. - CLS guardrail. Strip reserves
minHeight: { xs: 420, md: 240 }on its outer<Box>. Funnel data load showsSkeletons inside the stat tiles, not a missing component. Don't returnnullwhile loading. - Heading: strip uses
<h2 id="story-strip-heading">. Page-level h1 ("Hackathons") inpages/hack/index.jsis the only h1 — keep it that way.
PreviousHackathonList (src/components/HackathonList/PreviousHackathonList.js) renders the full 12+ year archive on one continuous scroll. No pagination, no year-filter chips — the prior design (8/page + chip filter + scroll-to-top jumps) created the "takes a while to click around" friction the redesign addresses.
- Year-grouped layout. Events are grouped by
format(parseLocalDate(start_date), 'yyyy'), sorted desc. Each year renders as a<YearSection>withid="year-{yyyy}"anchor, year heading (h3), and event-count chip.scrollMarginTop: { xs: 130, md: 100 }accounts for the NavBar (~80px). - Sticky vertical
YearRail(desktop ≥md): left-side<nav aria-label="Jump to a year">withposition: sticky; top: 84px, vertical list of year buttons sized by event count. On mobile (<md): horizontal sticky bar attop: 64px. Clicking a year scrolls to#year-{yyyy}and setsactiveYearstate (which drives the bright-blue active pill via$activestyled-component prop).activeYearis click-driven only — earlier scroll-driven IntersectionObserver/scroll-listener attempts fought smooth-scroll race conditions (last in-flight year would win the final state, landing on neighbor). Cut entirely; cleaner code, no race. - Photo-led
PastEventCard. Cards lead with a 16:9 image header fromevent.event_photos?.[0]?.url. When absent, fall back to aGradientFallbackthat hashes year → HSL hue (((year - 2013) * 37) % 360) so the wall doesn't feel monotone.next/imagewithfill+sizes+loading="lazy".event.image_urlis intentionally NOT used as a fallback (it's typically the generic OHack logo on legacy events — would make every card look the same). - Image domain allowlist.
cdn.ohack.devis innext.config.jsimages.remotePatterns. Adding new event-photo CDN domains requires updating that file too. LazyMountper card.ImpactMetricsper-card API fetch is gated by IntersectionObserver withrootMargin: '300px'. The archive is long (~30+ cards across 13 year groups); without this, every card fetches on mount → CORS-flood the backend. Keep it.- Layout flex.
<Box display="flex" flexDirection={{xs:'column', md:'row'}}>wrapsYearRail+ content area. Year rail width is108pxon desktop. Content area uses<Grid container spacing={2}>withsize={{ xs: 12, sm: 6, md: 4, lg: 3 }}— 4 cards/row on lg, 3 on md, 2 on sm, 1 on xs. - Don't reintroduce pagination. The whole point of this redesign is one continuous scroll with rail-teleport. If you need to gate something for perf, prefer further lazy-loading of card content, not paging the list.
- File:
src/pages/hackathons/arizona/index.js - Refined (civic-editorial):
RefinedRoot/RefinedFonts+.ohx-*sections — hero (italic "Arizona." + stat row), wide hero photo (2025_fall/.../IMG_9623.JPG), alternating paper/--surface-2bands (Upcoming/Past/Why-ASU/FAQ), one navy band (For Companies), numbered nonprofit steps, native<details>FAQ.getStaticProps(SEO via pageProps →_app.js) kept verbatim; only<Head><RefinedFonts/></Head>added.AZ_LOCATION_PATTERNS/isArizonaLocation/formatEventDate/trackClick(GAclick_arizona_hackathon) preserved. - Targets: "asu hackathon", "phoenix hackathon", "hack arizona", "hackathons in arizona", etc.
- Uses
useHackathonEvents("current")anduseHackathonEvents("previous")withisArizonaLocation()filter (AZ_LOCATION_PATTERNS constant at top of file). - Structured data: WebPage + BreadcrumbList + Event (Fall 2026 ASU with GeoCoordinates) + FAQPage.
- Internal links from:
pages/index.js(pillar links section),pages/hack/index.js(insideHackathonStoryStrip, not as a top-level Alert),pages/sponsor/index.js(About section).
/server-sitemap.xml(src/pages/server-sitemap.xml.js) is the server-side sitemap for dynamic routes —/hack/{event_id},/nonprofit/{id},/blog/{id}. Referenced innext-sitemap.config.jsadditionalSitemaps. It fetches from the API at request time with a 1-hour CDN cache (s-maxage=3600).- Canonical host is
www.ohack.dev. Allrel="canonical",og:url, and structured-data URLs insrc/pages/**must usehttps://www.ohack.dev/....frontend.ohack.dev301s to www vianext.config.js. Never hardcode barehttps://ohack.dev/(without www) — GSC indexed the non-www host and we redirected it all away. /hack/[event_id]canonical slug: useevent?.event_id || event_id(the backend's canonical ID), not the rawparams.event_id(which could be an alias).canonicalUrlis computed once and used for canonical, og:url, structured data, and breadcrumbs.- Soft-404 guard in
getStaticProps: backend returns200 + {}for unknown event IDs. The guardif (!data || !data.id) return { notFound: true }converts these to real 404s. - P3 judge-page consolidation:
/hackathon-judge,/hackathon-judging,/hackathon-judging-opportunitieswere deleted and 301-redirected to/hackathon-judge-opportunities(the canonical, kept). Entries removed fromnext-sitemap.config.jsexclude list. - Legacy event slug 301s in
next.config.js:season-YYYY → YYYY_seasonfor years ≤ 2025 (2026+ events natively useseason-YYYYIDs). Generated as a flat array since Next.js path-to-regexp can't put two named params in a destination without literal text between them. - JSON-LD: never render JSON as
<script>children (React HTML-escapes it → invalid). Insidenext/headuse a raw<script type="application/ld+json" dangerouslySetInnerHTML={{__html: serializeJsonLd(obj)}}/>— NOT<JsonLd/>: next/head's client head-manager doesdocument.createElement(child.type), so a component child breaks.<JsonLd/>is for body placement only._app.jsowns the global Organization (the NavBar's duplicate was deleted). BlogPosting comes fromsrc/lib/blogJsonLd.js(datePublishedomitted when unknown, nevernew Date()). - SSG error semantics (
src/lib/ssgFetch.js::fetchForStaticProps): 404/isEmpty→{notFound:true, revalidate: NOT_FOUND_REVALIDATE}; other upstream failures THROW so ISR keeps the last good copy. Throwing is only safe at request time —/nonprofit/[id],/project/[id],/blog/[id],/praise/[id]and the team pages usepaths: []+fallback:"blocking"for that reason (a getStaticProps throw duringnext buildfails the whole Vercel deploy). List pages built at deploy time (/projects,/blog,/nonprofits) MUST NEVER THROW — catch → empty props +revalidate: 60. The single-news payload has notext.id(only the list adds it) — don't gate on it./u/<slug>throws (500) on non-404 upstream failure;/profile/<id>stays null-tolerant. - Homepage pillar links in
src/pages/index.js: "For developers & volunteers" block now links to/hackathon-for-social-goodand/hackathons/arizonain addition to/projectsand Slack.
src/styles/fonts.js is the ONLY place font families are defined. It loads Fraunces + Hanken Grotesk via next/font (self-hosted, preloaded, size-adjusted fallbacks); _document.js puts their .variable classNames on <Html>, defining --font-body/--font-display on the root (reaches MUI Portals). Every JS consumer imports FONT_BODY / FONT_DISPLAY / FONT_MONO; CSS consumers use var(--font-primary) etc. from theme.css (aliases of the same vars). Never hardcode 'Hanken Grotesk'/'Fraunces' strings — next/font renames families to hashed names, so a hardcoded literal silently renders a fallback font (enforced by npm run lint + src/styles/__tests__/typography.test.js). RefinedFonts is a deprecated () => null stub (43 legacy call sites keep compiling — don't add new ones, don't re-add Google Fonts <link>s). The bespoke exception: /12-years-of-social-good loads its own Fraunces+JetBrains Mono link. Opportunistic cleanup rule: when editing a file for any other reason, also (a) delete any <RefinedFonts /> usage + its import (it renders nothing), and (b) delete stale eslint-disable comments referencing rules our config doesn't load (react-hooks/*, @next/next/* — they're the 29 lint warnings). Directory-scoped details live in src/styles/CLAUDE.md and src/components/design/CLAUDE.md.
Root font-size is 100% (16px) — it was 12px for years (the old value made every rem render at 75% of face value: 12px body, 10.5px buttons, 9px captions site-wide). When it was fixed (Aug 2026): all JS/MUI rem values were left as-is (they were authored for a 16px root and now render as intended); legacy src/styles/**/*.css + styles/nonprofit(/apply)/styles.js + refined.js's display clamps/eyebrow/tag/btn sizes were frozen to px at their old rendered look (old rem × 12) — do NOT convert those px values back to rem without re-deriving them, and never set a fixed px root again (contract test asserts 100% and zero rem in src/styles/**/*.css). Deleted as dead in the same pass: messages-grid.css, styles/{sponsors,profile,nonprofits}/styles.js, and the .button--filter/.headline/.ohack-nonprofit-feature*/.alert-notification/.indent/.material-symbols-outlined blocks. MUI's global theme (assets/theme.js) uses FONT_BODY for body + all headings (the old 'Montserrat', monospace h1 rendered actual monospace — Montserrat was never loaded).
Found while building the team dashboard; NOT fixed on purpose (each needs a product decision or a follow-up), so don't trip over them:
- Legacy
POST /api/messages/teamcreate path is broken (#5/#17):services/teams_service.py::save_teamstill callscreate_github_repowith the old positional signature. A real fix needs a hackathon-event lookup the function doesn't do, and the route is superseded byPOST /api/team/queue(whatmanageteamuses). Decide later: delete or repair — don't build on it. hackathon.devpost_urlis read but can never be written (#6):volunteers_service.pyreads it,save_hackathonhas no allowlist entry for it. DevPost is optional now; the eventlinks[]array stays the source for any DevPost link.- Public
GET /api/hacker/applications/<event_id>exposesuser_id+isSelectedfor every applicant (#14):findteam.jsmatchmaking depends onuser_id, so it was left as-is; a lean projection is the follow-up. Don't add more fields to that response. - Judge API naming split (#15): the judge pages/
judgeApi.jsreaddevpost_url/video_urlwhile the team doc storesdevpost_link/demo_video_url. The backend now fillsvideo_urlfromdemo_video_url(and keepsdevpost_url), so both names work — readdemo_video_url || video_urlin judge code and don't rename either side unilaterally. manageteam'snoindexisn't SSR'd: the<Head>sits inside theRequiredAuthProvidersubtree, so crawlers get the auth shell without the robots meta (pre-existing; the page has never been indexable content anyway — the vote page's shell puts itsnoindexoutside the auth gate, which is the better pattern for new pages).pylint -E api/submissions api/peer_votesreportsfirebase_admin.firestore has no 'transactional'/'Increment' member— false positives (dynamic re-exports fromgoogle.cloud.firestore); everything else is clean.
useAuthInfo() returns both userClass and orgHelper. They look interchangeable but they are NOT:
orgHelper.getOrgs()returns plain info objects ({orgId, orgName, ...}) with NO methods. Calling.hasPermission()on them throws (silently caught by surrounding try/catch, leaving every admin check returningfalse).userClass.getOrgByName("Opportunity Hack Org")returns the full OrgInfo object with.hasPermission(perm),.assignedRole(), etc.
Pattern to copy (matches pages/admin/index.js):
const { userClass } = useAuthInfo();
const org = userClass?.getOrgByName("Opportunity Hack Org");
const isAdmin = org?.hasPermission("volunteer.admin");orgHelper is fine for getting orgId to pass as the X-Org-Id header (orgHelper?.getOrgs()?.[0]?.orgId), but never use it for permission checks.
/profile/[userid].jsexpects the Firestore document ID (User.id), not the PropelAuthuser_id/propel_id.- Things stored across the system as
propel_user_id(assignees, editors, mentions, etc.) cannot be plugged directly into the profile URL — you need thedb_idfield too. - When the backend bundles user profiles for a list view (planning board users map, team rosters, etc.), it should include BOTH
user_id(propel) ANDdb_id. The frontend uses propel for matching and db_id for linking.
MUI Dialogs render via Portal. The ThemeProvider context flows through Portals in MUI v5+ but the underlying <textarea> / <input> element still inherits browser default styling for background and color in some configurations — most reliably broken when:
- A page-level
ThemeProvideroverridespalette.mode(e.g. local dark mode toggle). - The Dialog renders a
<TextField>withvariant="outlined"(the default).
Symptom: white textarea with light-gray placeholder, unreadable inside a dark dialog. Fix is to pin the input area to theme tokens via sx:
<TextField
sx={{
"& .MuiInputBase-root": {
bgcolor: "background.paper",
color: "text.primary",
},
"& textarea, & input": { color: "text.primary" },
}}
/>Apply this to any TextField inside a Dialog when the page uses a non-global theme override.
Pattern that bit us: a list view (board, roster, etc.) polls fresh data into boardState. A user clicks a row → we set selectedItem = item (a snapshot of the click-time value). The detail Dialog renders from selectedItem.
Two consequences when subsequent edits land:
- The Dialog shows stale fields (the saved description doesn't appear after the polled refresh).
- Optimistic-concurrency PATCHes (
If-Match: <updated_at>) use the click-timeupdated_atand 412 on every save after the first.
Fix: derive the live record from the polled state, not from the captured selection:
const liveItem = state.items.find((i) => i.id === selectedItem.id) || selectedItem;
return <DetailDialog item={liveItem} ... />This pattern applies anywhere a Dialog opens with a snapshot from a polled or paginated list.
The OHack global theme is light-only. If you need dark mode for a specific surface (e.g. the planning board), don't add a global toggle — wrap that surface in a local ThemeProvider and persist the preference per-feature in localStorage. See src/components/Planning/PlanningThemeProvider.js for the pattern (auto / light / dark cycle, prefers-color-scheme detection).
MUI py: 1 (8px each side, 16px total) is what you'll reach for instinctively, and it's wrong for any container holding multiple chips/buttons/avatars side-by-side. The result feels cramped — and when the container sits right under the global NavBar (64px), it visually crowds the navbar.
Heuristic for any "header bar" or row of controls:
py: 1.75→ minimum, acceptable on light rowspy: 2.25+minHeight: 64→ the safe default for a header-style bar with chips/buttons (matches the NavBar's own height so the surfaces feel balanced)- Add
flexShrink: 0if it lives inside a flex column — otherwise dense content can compress it.
If you reach for py: 1 on a row of controls, stop and use py: 2.25 + minHeight: 64 instead. We've fixed this same bug twice in the planning board.
A full-width MUI Alert that screams "this is public" is appropriate ONCE near the top of a form/dialog/page. Repeating the same Alert beside every input that "could expose data" (attachments, comments, descriptions) is alarmist, hurts readability, and trains users to ignore the warning entirely.
Pattern instead:
- One compact notice at the top (
<PlanningPublicNotice compact />style — single line, smaller font, no AlertTitle). - Subtle inline hints at each input that needs the reminder:
- Placeholder text:
"Post a public comment…" - Helper text under a file picker:
"PNG, JPG, WebP, GIF — max 10 MB · publicly visible"
- Placeholder text:
- Reserve full Alerts for state changes the user actually needs to act on (errors, conflicts, success).
When a Dialog represents a specific item (a card, a hackathon, a profile section), make the URL reflect what's open so users can copy/paste/share the link:
function handleOpen(item) {
setSelectedItem(item);
router.replace(
{ pathname: router.pathname, query: { ...router.query, card: item.id } },
undefined,
{ shallow: true }, // critical — no refetch, no scroll, no re-render of getStaticProps
);
}
function handleClose() {
setSelectedItem(null);
const { card: _, ...rest } = router.query;
router.replace({ pathname: router.pathname, query: rest }, undefined, {
shallow: true,
});
}
// Auto-open on mount when the URL says so:
React.useEffect(() => {
const targetId = router.query.card;
if (!targetId || !items) return;
const found = items.find((i) => i.id === targetId);
if (found && !openedRef.current) {
handleOpen(found);
openedRef.current = targetId;
}
}, [router.query.card, items]);For social-unfurl-quality previews (Slack, Twitter, LinkedIn) the URL also needs an SSR route that emits per-item OG meta tags — query-param-based opens won't unfurl. See src/pages/hack/[event_id]/plan/c/[card_id].js for the pattern (separate SSR route with getServerSideProps, fetches the item server-side, emits og:title / og:description / og:image, then re-renders the parent page with the dialog pre-opened).
Post/live-event feedback for selected volunteers + nonprofit partners. Both routes render the same src/components/Survey/EventSurvey.js (dynamic ssr:false, wrapped in ReCaptchaProvider); feedback.js is just an alias of survey.js (passes source="feedback"). Distinct from /feedback/[userid] (peer feedback) and the backend feedback collection — this writes the backend surveys collection.
- Question catalog is pure data in
src/components/Survey/surveyQuestions.js:SURVEY_QUESTIONS+getSurveyQuestions(role, mode). 4 universal (+ theroleselector, handled in the component, = 5) then per-role blocks (hacker 15 / mentor 5 / judge 6 / nonprofit 6 / volunteer 4). Each question hasroles("all"or array),mode(live|post|both),type, optionalshowIf(answers, mode). Answer IDs are stable across modes (first_timer,mentor_unreachableintentionally shared) so live + post merge. Composite types (scale_text,yesno_text) store{ value, note }. - Flow: component reads
GET /api/surveys/<event_id>/context(mode + eligible roles +requires_captcha+already_submitted) using the PropelAuth token when present, thenPOST /api/surveys/<event_id>/responses. Only currently-visible (showIf-passing) answers are sent, so switching role doesn't carry stale answers.roleis stored top-level and mirrored intoanswers.role. - Role scope: the role selector is limited to
ctx.allowed_roles(the role[s] you'reisSelectedfor). One allowed role → a fixed chip, no picker; nonprofits/anonymous are locked to Nonprofit. An effect keepsroleinsideallowed_roles, and the backend re-enforces (403). Don't widen the selector back to allROLE_OPTIONS. - Conditional follow-ups:
scale_textquestions supportnoteWhen(value)— the note box appears only when it returns true (used byhacker_onboarding: reveal "what could be improved?" only on scores 1–3, and the note is cleared if the score rises above 3). - Auth/CAPTCHA: not gated by
RequiredAuthProvider— nonprofits/anonymous can submit. Logged-inisSelectedvolunteers are trusted (no CAPTCHA); everyone else gets an invisible reCAPTCHA v3 token (useRecaptcha). Modeupcomingshows a "not started yet" card;live→"how's it going",post→"how was your experience". Pages arenoindex. Backend computes mode (timezone-aware) — the frontend does NOT recompute dates. - Discoverability:
src/components/Survey/SurveyCTA.js— a self-contained CTA (var-fallback inline styles so it works in or out ofRefinedRoot; mount-gated to avoid SSR/ISR hydration mismatch) linking to/hack/<id>/survey, rendered only once the event has started (live or ended; hidden for upcoming). Wired into: the event page/hack/[event_id](after the masthead — the only surface reaching anonymous nonprofits),mentor-checkin,manageteam,team/[team_id], andjudge-application(gated onisSelected). PasseventId+startDate/endDate/timezone; the component owns the visibility gate.
Volunteer organizer roles (Social Media Manager, Hackathon Operations Lead — Phoenix, Mentor Program Lead) with an AI-resistant application: required intro video (reuses IntroVideoField — its bio-video upload mint works as-is because applying requires login), required PDF resume (src/components/Jobs/ResumeUploadField.js → POST /api/jobs/apply/resume-upload-url, clone of the bio-video signed-URL flow, lands at job_applications/<db_id>/ on the public CDN — accepted obscured-URL risk), a role-specific work sample (min 200 chars, mirrored in backend MIN_JOB_WORK_SAMPLE_LENGTH), and a reply-within-5-days responsiveness test baked into the confirmation email. Load-bearing details:
- Backend: blueprint
api/jobs/(jobs_views.py+jobs_service.py; registered inapi/__init__.py). Collections:job_listings(doc id = slug, slug immutable after create) andjob_applications(uuid4). PublicGET /api/jobsreturns published+closed (lean fields);GET /api/jobs/<slug>404s drafts/hidden but returns closed so shared links render a calm closed panel. Apply/meroutes are@auth.require_user; submit verifies recaptcha (volunteers_serviceverify_recaptcha+ FLASK_ENV=development bypass), re-verifies resume/video URLs against the caller's own CDN prefix viaget_blob_metadata, and 409s duplicates (listing_slug+user_id). Validators incommon/utils/validators.py(validate_job_listing[_partial],validate_job_application,ALLOWED_JOB_*). Emails (Resend,_notifications_disabled()gate): applicant confirmation (reply_toquestions@ohack.org + the reply-to-confirm ask), FYI to questions@ohack.org, and warm accept/reject decision emails (POST /api/jobs/admin/applications/<id>/decision, optionalpersonal_note). TTL caches (300s) cleared on every admin write. Seed:scripts/seed_job_listings.py(dry-run default, skips existing slugs, seeds drafts). - Frontend pages: both ISR revalidate 300.
/jobsindex is a refined pillar page (FAQ_ITEMS module-scope →<details>+ FAQPage JSON-LD); its getStaticProps treats a 404 from/api/jobsas empty (deploy-ordering: backend must ship first or the page renders the empty state) but rethrows other errors (ISR keeps last good)./jobs/[slug]SSRs the listing publicly (SEO/unfurls) with a top-levelJobPostingJSON-LD node (employmentType: "VOLUNTEER"→ Google for Jobs; TELECOMMUTE + applicantLocationRequirements for remote/hybrid, TempejobLocationfor phoenix_in_person;datePosted/validThroughset conditionally — Next rejectsundefinedin props). Only the#applysection is auth-gated — viauseAuthInfo+redirectToLoginPage(app-level AuthProvider), NOT a page-level RequiredAuthProvider which would hide content from crawlers. JobApplicationForm(src/components/Jobs/): 4 steps, mentor-form patterns (refinedStyles imports,useFormPersistencelocalStorage autosave withformType:"job"/eventId:slug—loadPreviousSubmissiondeliberately NOT called; already-applied comes fromGET /api/jobs/<slug>/applications/mewith a 6sAbortSignal.timeoutso a slow backend can't pin the spinner). The<form>MUST keepnoValidate— the required MUI Selects render hidden native inputs and browser constraint validation otherwise silently blocks submission (no submit event, no visible error; this bit us). Phoenix listing (location_type === "phoenix_in_person") hard-blocksinPersonOk !== "Yes"; hours belowlisting.min_hours_per_weekblocks with a kind redirect message. Never addisSelected/staff-owned fields toinitialFormData.- Admin
/admin/jobs?tab=listings|applications(blog-admin auth pattern; registered in BOTH nav registries):src/components/admin/jobs/ListingsTab.js(table + edit Dialog — deliberately no blog-style editor pages; publish/hide quick toggle) andApplicationsTab.js(filters, detail Dialog derived live from list state — stale-snapshot gotcha —, status/notes PATCH, one-click kind-rejection/accept decision emails). - SEO plumbing:
/jobs/[slug]in next-sitemapexclude+jobssubstring in the 0.8-priority branch; jobs block inserver-sitemap.xml.js. Cross-link card on/volunteer(#rolessection). Footer deliberately untouched (CWV height contract).
One volunteer.admin-gated page (src/pages/admin/feedback/index.js) with 3 MUI tabs over 3 distinct data sources (different scopes — don't merge them into one table). Plain MUI, standard AdminPage + RequiredAuthProvider shell. Registered in BOTH nav registries (src/components/admin/AdminNavigation.js + src/pages/admin/index.js). Only the active tab mounts (lazy fetch). Panels live in src/components/admin/feedback/:
- EventSurveysPanel (event-scoped) — a sub-view toggle: "Single event" vs "Compare events" (
<CrossEventSurveys>). Single-event: event selector (defaults to most recent via/api/messages/hackathons) + live/post toggle; fetchesGET /api/surveys/<id>/summary+/responses. Renders KPIs → during-event pulse (rechartsComposedChartof live responses by hours-since-kickoff: volume bars [grey when n<3] + avg-rating line, day-boundaryReferenceLines) → needs attention (redFlags: rating ≤2 + live-distress answers likehacker_blocked/mentor_unreachable/mentor_team_concern/vol_live_issue/npo_team_waiting) → recurring themes (going-well vs to-improve keyword chips) → "What people said" (qualitative, the priority) → per-question distributions → individual-response accordions. Reuses the survey catalog (../../Survey/surveyQuestions) to label answer keys and route aggregation by questiontype— keep in sync if catalog types change. - CrossEventSurveys (the "Compare events" sub-view) —
GET /api/surveys/overview(one server-side scan, aggregates only). KPI band (total responses, latest-event rating + ▲/▼ vs prior event, best/worst) → calendar macro-trend (ComposedChart: events ordered by date, avg overall_rating [solid] + would_return [dashed] lines on 0–5, volume bars [grey when n<LOW_N=5]) → elapsed-time overlay (multi-select events, defaults to ~3 most recent with live responses; fetches each selected event's/responses?mode=liveon demand and overlayselapsedHourSeriesrating curves on a shared hours-since-kickoff axis) → per-event summary table. - Pure survey helpers:
src/components/admin/feedback/surveyAnalytics.js(aggregate/formatAnswerlifted from the panel;elapsedHourSeries[date-only start anchored at LOCAL midnight so hour 0 ≈ kickoff for same-tz admins],eventDurationHours,collectFreeText,redFlags,gist; re-exportsparseTs/extractThemesfromonboardingAnalytics). - PeerFeedbackPanel (global, all-time) —
GET /api/admin/feedback/peer. In thefeedbackmap, numeric values = 0-100 skill scores, strings = free-text;roleis metadata. Giver hidden whenis_anonymous. - OnboardingPanel (global, all-time) —
GET /api/admin/feedback/onboarding. A trends-+-actions dashboard, not a flat list. A single window toggle (90d / 12mo / all) drives everything. Sections: KPI band (responses, avg rating + ▲/▼ vs prior period, % "clear" = Very easy + Mostly clear, % willing to follow up) → Feedback over time (rechartsComposedChart: volume bars [grey when n<3] + avg-rating line, withReferenceLinemarkers at hackathon start dates; plus a 100%-stackedstackOffset="expand"clarity-mix ofeaseOfUnderstanding) → snapshot distributions (rating / ease / useful-topics) → action lists (follow-up queue =contact.willing && email, copy-emails button; needs attention = rating ≤2 or confusing ease) → keyword themes from missingTopics+improvements → response accordions (device chip). Pure helpers (bucketing, themes, device,parseTs) live insrc/components/admin/feedback/onboardingAnalytics.js. Data shape gotcha:contactForFollowupis{willing}or{willing, firstName, email}(NOTname);easeOfUnderstandingis a 4-point ordinal (Very easy▸Mostly clear▸Somewhat confusing▸Very difficult);usefulTopicsvalues come fromUSEFUL_TOPICSinonboardingAnalytics.js(current onboarding-step names + legacy "Buddy System" kept for pre-July-2026 responses; keep in sync withFeedbackSectiontopicOptions);overallRating0 = unrated (excluded from averages). The backend strips a__Timestamp__export-sentinel prefix off timestamps.
One volunteer.admin-gated page (src/pages/admin/praise-bot/index.js) configuring the Slack praise-bot (repo ohack-slack-bot/praise-bot) via the backend's praise_bot_config Firestore collection — the bot polls GET /api/praise-bot/config every ~60s, so saves take effect within a minute with no redeploy (the info banner says this; keep it). Registered in BOTH nav registries (AdminNavigation.js + pages/admin/index.js), SmartToy icon #611f69. 4 tabs synced to ?tab= (github|calendar|community|global), communication-page pattern:
- GitHub Digests — table +
src/components/admin/praisebot/GithubWatcherEditDialog.js. A watcher'ssource.modeishackathon(event_id dropdown fed by public/api/messages/hackathons; teams/repos/channels resolved live by the bot) orrepos(explicit repo list + channels). Digest cron + optional mentor rollup (cron + channel). Inline enabled Switch = one-field PATCH. - Calendar Reminders — table +
CalendarReminderEditDialog.js(public Google Calendar ICS id, channels, lead 1–240 min, poll cron). - Community — singleton doc form (intro matchmaker + weekly digest); backend 400s on a second POST, so the save handler passes
id: config.community?.idto PATCH when it exists. - Global — dry_run / llm_enabled / timezone, saved via
PATCH .../admin/config/global(upsert; never DELETE). All admin calls:${NEXT_PUBLIC_API_SERVER_URL}/api/praise-bot/admin/config[...]with Bearer +X-Org-Id. Cron UX:praisebot/CronInput.js(preset Select + free text,isValidCron= 5 whitespace fields; crons are UTC unless global timezone set — Arizona has no DST, so 9 AM AZ =0 16 * * *).
The public profile is now a shareable portfolio. Load-bearing contracts:
- Two routes, one component.
src/pages/u/[slug].js(canonical vanity URL) andsrc/pages/profile/[userid].js(legacy db-id URL) both SSR throughsrc/lib/portfolioPage.js::buildPortfolioServerSidePropsand render<PublicProfile userid initialData>— the body is server-rendered now (the olddynamic(ssr:false)served crawlers a "Loading profile…" shell; don't reintroduce it). Redirect rules:/u/<alias-or-dbid>→ 307 canonical/u/<slug>;/u/<x>with no slug → 307/profile/<id>;/profile/<id>→ 307/u/<slug>ONLY whenprofile_visibility === "public"(never redirect private profiles — a db-id link must not leak the chosen slug)./profile/*always emits noindex;/u/*isindex,followonly whenprofile_visibility === "public"(default private-first). src/lib/portfolioMeta.jsis the single source for unfurl composition (titlename — headline, description bio→why→role, og:image ladder: YouTube demo thumb [large card] → square cert PNG [summary card] → avatar [summary] → site fallback; JSON-LD Person only when public). The editor'sPortfolioPreviewCarduses the SAME helpers — keep them pure and shared or the preview lies.usePublicProfile(userId, { initialData }): when seeded from SSR it skips the mount fetch (seededRef) and readsprivacy_settingsfrom the payload (no second privacy fetch).refetchstill works. The private fallback map includes ALL portfolio privacy keys — keep in sync with backendprivacy_fields.- PublicProfile composition: PortfolioHero (avatar, h1, headline, role / "N× hackathons" / hearts-tier
.ohx-tags viagetTierForHearts+ sharedcountHeartsFromHistoryinsrc/lib/heartTiers.js; owner-only "edit your portfolio" banner is a client-side authed compare of own db id — never SSR'd) → why quote → About (bio + BioVideoSection facade + about grid + expertise) → Featured work (TeamsShowcaseSection— LiteVideoThumbnail facades + ONE page-level Dialog with VideoDisplay; zero iframes at load, TeamList pattern) → GitHubStatsSection (payloadgithub_historyelse fire-once-IO lazy fetch, minHeight 280) → CertificateWallSection (squarenext/imagetiles →/cert/<file_id>) → Badges → Hackathons (titles link to/hack/<event_id>) → Praises → Community feedback. Empty/private sections skip silently — teaser copy lives only in the editor. - Editor: Profile.js Tab index 6
#portfolio(indices 0–5 are shareable-deep-link invariants — append only).src/components/Profile/Portfolio/PortfolioTab.jsxcomposes MasterVisibility radio (PATCH/api/users/profile/visibility; "public" disabled until a slug is claimed), PortfolioPreviewCard, SlugClaimField (500ms-debouncedGET /api/users/profile/slug/check/<slug>, explicit Claim →POST /api/users/profile/slug, 409/429 surfaced; old slugs stay aliases), HeadlineBioFields (2s debounce viaupdate_profile_metadata), BioVideoUpload (signed-URL flow:POST .../bio-video/upload-url→ XHR PUT to GCS echoingrequired_headers[Content-Type + x-goog-content-length-range] →POST .../bio-videowithfinal_url; or paste a YouTube/Vimeo/Loom link into the same setter), CustomLinksEditor (array save, backend sanitizes), and the "What shows on your portfolio" PrivacyToggle checklist. Don't add portfolio fields toprofileFields.js(raffle-entry math). - Video:
VideoDisplaynow plays raw.mp4/.webm/.movvia native<video>(no iframe);LiteVideoThumbnailtakesposterUrland labels raw files "Video". - Privacy hook fix:
use-privacy-settings.jsuseswhy(the oldwhy_are_you_herekey was a silent server-side no-op) + all new portfolio keys default private. - SEO plumbing:
server-sitemap.xml.jshas a 4th block fromGET /api/users/portfolio/sitemap→/u/<slug>locs;/profile/[userid],/u/[slug],/cert/[cert_id]are in next-sitemap excludes;socialShare.jsDEFAULT_SITE_URL ishttps://www.ohack.dev(www — no redirect hop on shares). /cert/[cert_id]is ISR now (praise/[id] pattern:fallback:true, revalidate 3600, notFound on unknown id) and unfurls the signed cert PNG as og:image (summarycard — cert PNGs are square). Body stays the client-renderedCertInfoIndex.- Onboarding ties:
WebsiteTourSectionportfolio band, an IntroductionPrompt "Set up your portfolio" 3-step card, and an OnboardingFAQ "Is my profile public?" entry all describe the portfolio (private by default,/profile#portfolio,ohack.dev/u/you) — keep their claims in sync with the actual flows.
The frontend now talks ONLY to the canonical profile endpoints; /api/messages/profile* is served by thin backend delegates pending deletion:
- Own profile:
GET/POST /api/users/profile— FLAT response (no{"text":…}envelope), the full field set from the backend'sPROFILE_FIELD_SPECSregistry.use-profile-api.jsSPREADS the payload intoprofile({...data, profile_url}) — never reintroduce a hand-written field projection there (that's the "saves fine, renders blank" bug class; the backend round-trip test + this hook's spread make it structurally impossible now).update_profile_metadatakeeps its historicalonComplete("Saved Profile Metadata")string contract and refreshesprofilestate from the POST response. Deleted dead exportsget_user_by_id/get_user_profile_by_id. - By-id:
GET /api/users/<db_id>/profile(flat, safe fields incl.profile_slug, nogithub) — used by TeamList/HackathonResults/GiveFeedback; the olddata.text || dataenvelope dances were removed. - Helping toggle:
POST /api/users/profile/helping(same body:{status, problem_statement_id, type, npo_id}). - GitHub history still uses
GET /api/messages/profile/github/<username>(not part of the legacy_oldfamily; unchanged). profile.user_idis now present on the hook's profile (spread) —NonProfitListTile(.Refined)'shelping.slack_user === profile.user_idhighlight works again./myprofile(dead mock page) andsrc/components/MyProfile/*were deleted.- Hook tests:
src/hooks/__tests__/use-profile-api.test.js(URLs + spread + onComplete contract; axios needs the factory mock — automock breaks on axios v1 interop; auth mock must return a STABLE user object or the bootstrap effect loops) anduse-public-profile.test.js/use-privacy-settings.test.jswere updated to the current contracts.
The masthead HeartGauge panel (~900px tall) was replaced by a loyalty-program surface. Load-bearing contracts:
- Tab 1 was renamed Impact → "Hearts" (FavoriteIcon). Hash/index invariants hold: index 1 unchanged;
#heartsis the canonical write hash (tabHashMap/tabNames[1]='hearts'),#impactstays inhashTabMapas a legacy read alias — never remove it. Tab LABELS may change; hashes/indices stay frozen (append only, next new tab = index 7). - Tab 1 body =
src/components/Profile/Sections/HeartsRewardsTab.jsx(props{profile, isLoading, onOpenGiveaways}): StatusHero (--surface-2, Fraunces hearts number, "Claim your rewards" →/contact?type=claim_reward&hearts=N, gated on any achieved reward — contact page depends on both params), NextTierProgress, BenefitsLadder (5TIERSrows, tier color only as a small dot — no tinted backgrounds), HeartsBreakdown (per-categoryhistory.what/history.howrows viaHEART_CATEGORIES),HeartsExplainer, footer links. Subcomponents are module-scope (SectionBlock remount lesson). No giveaway content here — just theonOpenGiveawayscross-link CTA (RaffleEntries lives only on tab 5). - Masthead strip:
src/components/Profile/HeartsStatusStrip.jsxreplaced<HeartGauge/>at the masthead actions row — one-line button ("{Tier} · N hearts" + mini progress + "N to {next}") that jumps to tab 1 viasetActiveTab(1)+ shallowrouter.push('/profile#hearts')(NOThandleTabChange— avoids double GA fire). GA:hearts_status_click{source: 'profile_masthead'|'navbar_menu', event_label: tier, hearts}. src/lib/heartTiers.jsadditions:HEART_CATEGORIES({what:[[key,label,desc?]...], how:[...]}— moved from FeedbackSection, which now imports it; keep in lockstep with backend history keys incommon/utils/firebase.py) andformatHearts()(fractional 0.5-heart display). UsecountHeartsFromHistoryeverywhere — never the old per-componentcountHearts(required both sections + console-spammed).- Navbar status:
useHeartsSummary()(src/hooks/use-hearts-summary.js— module-cached 60s TTL + in-flight promise dedupe, axios [Bearer via AxiosWrapper interceptor], client-effect gated onisLoggedInso SSR/logged-out fire zero requests) feeds (a) a tier-colored 2px avatar ring (constant transparent border → tier color; zero box change) + MUIBadgeheart dot (invisible={!tier}; heart glyph#333on Gold/Platinum/Diamond, white otherwise), and (b)HeartsStatusMenuItemas the first dropdown child (returns Link-wrapped MenuItem ornull— never a Fragment; MUI Menu keyboard nav). The fixed-width auth slot + 64px bar are untouched (CWV invariant). ProfileCompletionPromptconsumesuseHeartsSummary()too (notuseProfileApi) — navbar + prompt share ONEGET /api/users/profileper page. Don't switch it back./profileitself still runs its ownuseProfileApi(needsupdate_profile_metadata).- Orphaned, do not re-add:
HeartGauge/HeartGauge.js+MilestoneProgress.js(+ test) — the old gradient panel; orphaning also dropped the unconditional recharts import from /profile.ShareableGitHubContributionsmoved to the GitHub tab (index 2, gated ongithubbeing set).
GA4 key events match on event name only, so the two conversions now have dedicated events: donation_completed (fired from GiveButterWidget.js in the donation_completed case, {value, currency:'USD'}, IN ADDITION to the existing donation_interaction, which stays the primary donation signal) and npo_form_submit ({form_name:'nonprofit_application'}, fired ONLY on success from the live form's handleSubmit in pages/nonprofits/apply/index.js and from use-nonprofit.js::handle_npo_form_submission — note that hook function has NO callers; the apply page submits via its own fetch). Both use guarded raw window.gtag calls so they stay distinct from the trackStructuredEvent naming scheme. Google Ads: the tag ID comes from NEXT_PUBLIC_GOOGLE_ADS_ID = AW-11474351176, account 371-489-1437's Google tag id (an Ads tag id is NOT the customer id — AW-3714891437 was a Sep 2026 mistake that pinged a nonexistent tag; _document.js emits no Ads config when unset). SingleNews.js's blog conversion ping sends AW-11474351176/2qwxCOXE8vccEMjost8q (the "News button click" action in that account; GOOGLE_ADS_BLOG_CONVERSION_LABEL = null skips it). GA4's Google tag G-EM3BV6M5EF is combined with AW-11474351176 with exactly two destinations (ohack.dev GA4 + Opportunity Hack Inc. Ads); AW-10941512308 is the cancelled Ads account 659-034-6027, split off 2026-09-17 — never re-add it. Navbar identify dedupe: the set(email) + "Login Email Set" effect keys on user?.email with a useRef guard (once per email per page session) — never on the user object (that identity churn produced ~130K junk user_identify/Login Email Set hits per 90 days). Test: Navbar/__tests__/Navbar.identify.test.js. Event catalog: ga-events-reference.md.
Plan + evidence: docs/plans/hardening-security-seo-reliability-2026-09.md. Frontend half of the first stacked PR; the backend half (BE PR-A) tightens the routes these changes prepare for. Invariants:
src/lib/jsonLd.jsserializeJsonLd()/src/components/JsonLd.js— every inlineapplication/ld+jsonmust go through it (escapes<as<; a user-chosen team name containing</script>reaches SSR HTML otherwise). Insidenext/headuse a raw<script type="application/ld+json" dangerouslySetInnerHTML={{__html: serializeJsonLd(obj)}}/>(next/head needs direct element children);<JsonLd data={obj} />is for body placement. Never render JSON as<script>children (React HTML-escapes it → invalid schema).next.config.jsheaders: the catch-all rule issource: "/:path((?!api/|_next/).*)"and carriesX-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy: camera=(self), …(camera stays on for the check-in QR scanner). It deliberately excludes/api/*(per-user Stripe lookups were being markedpublic, max-age=3600) — API routes set their ownCache-Control(hacker-deposit/{checkout,session}.jssendprivate, no-store). No HSTS (Vercel adds it) and no CSP yet.removeConsolekeepserror/warnin production. Contract test:src/__tests__/next.config.headers.test.js.- Store checkout (
pages/api/store/create-checkout-session.js) resolves every cart item againstsrc/data/store-products.jsonbyid— name/price/image come from the catalog, never the body; variations validated; quantity 1..MAX_ITEM_QUANTITY(50, mirrored client-side byMAX_CART_ITEM_QUANTITYinShoppingCartContext). - Hacker deposit checkout (
pages/api/applications/hacker-deposit/checkout.js) fetches the event and 400s whenconstraints.hacker_deposit.enabled !== trueor the amount is belowdefault_amount_cents, but fails open on any lookup error (never block a payment on a backend hiccup). The backend caches the event ~10 min, so enabling deposits can take that long to be honoured.hacker-application.jssendsMath.max(default, saved). src/lib/adminTeamApi.jsfetchAdminTeamDetail— TeamManagement loads team detail fromGET /api/team/admin/<id>(full doc incl.admin_notes/nonprofit_rankings) and falls back to the public route on 404 (older backend). Public team payloads no longer carry those fields once BE PR-A is deployed.use-hackathon-events.jssendsX-Org-Idon the problem-statement→event link PATCH (the backend route is org-admin gated) and returns{error}instead of callingonComplete(undefined).PlanningCardDialoguploads via axios so the interceptor adds the Bearer token (rawfetchsent none → 401).- Jest:
testPathIgnorePatternsincludes<rootDir>/.claude/(agent worktrees were being collected);jest.setup.jsmocksReact.useIdwith an incrementing counter (a constant id madegetByLabelTextresolve the wrong MUI field). The 4 previously red suites were repaired; skipped tests carry aSKIP(hardening step 0)reason.
next-sitemap.config.js: only*is special in its matcher (anchored, case-insensitive, matches across/); bracketed[param]entries never match anything, so every dynamic route is excluded with*globs.autoLastmod: falseandtransformemits nolastmod(file mtimes are meaningless on Vercel); reallastmodonly for blog entries in/server-sitemap.xml.robots.txtdisallows/api/only — admin pages carrynoindexviaAdminPage.jsinstead (aDisallow: /adminwould freeze already-indexed admin URLs). Contract test:src/__tests__/next-sitemap.config.test.js(loads the real matcher).src/lib/sitemapFields.jsbuilds/server-sitemap.xmlfrom the raw API payloads — the news API returns{text: [...]}(readingdata.newsproduced zero blog URLs for months). Nonprofit URLs are deliberately absent (noindex pages).src/lib/headMeta.js::ensureDescriptionMetaadds<meta name="description">for pages that only passog:description+ adescriptionprop._app.jsrendersopenGraphDataentries WITHOUT akeyinside a keyed<React.Fragment>unless the entry declares its ownkey— next/head only dedupesname=metas across un-keyed elements (an explicit key, even a numeric index, becomes.$0and skips it). Verify on built HTML: exactly onename="description"on/nonprofits/apply,/cert,/volunteer.BlogPage.jsseeds state from thepostsprop so/blogSSRs the list (it rendered skeletons before). The SSG error rules and the JSON-LD-in-Head rule live under "SEO infrastructure".
src/components/ErrorBoundary.jswraps ONLY<Component/>in_app.js(NavBar/Footer and their CLS placeholders are outside it) withresetKey={router.asPath}so a client-side navigation clears the fallback. It catches client render errors; SSR failures (agetStaticPropsthrow) rendersrc/pages/500.js, which is deliberately static — no data, auth or router — andnoindex. Don't add_error.js.axios.defaults.timeout = 30000(axios-wrapper.js, module scope). 30 s not less: admin bulk operations are slow and the 401 retry doubles the worst case. Per-call overrides go on the call.useHackathonEventsreturnserrorand guaranteeshackathonsis an array ([]on any failure; 403 stays quiet as before).makeRequestreturns the axioserror.responseon non-2xx — never assigndata.hackathonsfrom it without theArray.isArrayguard. Test:src/hooks/__tests__/use-hackathon-events.test.js..github/workflows/ci.ymlrunsnpm ci,npx eslint src --max-warnings=1000,npm test -- --cion PRs and pushes to develop/main with dummyNEXT_PUBLIC_*env (env.context.jsvalidates them). Nonext buildin CI on purpose — Vercel is the build gate and a CI build would fire ~550 requests at the production backend..nvmrc(20.10) andengines(22.x) still disagree; CI uses Node 22.