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 (full rewrite: RefinedRoot+RefinedFonts, editorial masthead, .ohx-card gating panels, calm --surface-2 panels for auth/apply gates; split error → teamsError+formError; lazy Slack/nonprofit fetches gated by activeStep/showNewTeamForm + slackFetchedRef/nonprofitFetchedRef; onTeamUpdated callback propagated to TeamStatusPanel so DevPost/demo label updates without reload; fetchMyTeams() after submit + scroll to #team-hub; noindex meta; no PII logs; active_days=365 for Slack fetch), 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. 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 a Hanken-Grotesk-with-system-fallback stack; the webfont only loads on the two refined pages, so elsewhere it renders in system-ui (intentional — avoids a global font load).
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.
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 two tabs (?tab=templates|social, shallow-synced): the new EmailTemplateManager and the existing SocialMediaManagement (unchanged, embeds cleanly — it has no router deps). 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.
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 are injected via
useEffect, NOT<RefinedFonts/>. On this pagenext/headsilently drops the Google Fonts<link>s (the<Head>lives in the client-rendered withAuthInfo subtree next to the JSON-LD<script>+ meta). A one-time effect appends the Fraunces/Hanken stylesheet todocument.head(guarded by#ohx-refined-fonts). Don't revert to<RefinedFonts/>here — it renders as serif fallback. - 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).
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. 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. 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 in admin review, add it to the type's secondaryFields/additionalFields + labelMap in src/components/admin/ApplicationReviewCard.js (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 "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|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(Judging).
Known minor regression (embedded mode): the workbench's internal setSnackbar calls don't currently render any toast UI because the AdminPage wrapper that owned the Snackbar is skipped. Admin actions still work; dialogs close and lists refresh. To fix later: thread setSnackbar through onSnack in the workbenches.
- 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,meals,screening,deposit. While any of these are dirty, autosave is paused so unrelated text edits don't sneak through. - 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. - 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. 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). Both autosave viasetField. - 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.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_TAGSinMealManagement.jsand the backendvalidators.py. Admin UI lives insrc/pages/admin/hackathons/index.jsAdvanced Settings tab. NewMealManagementcomponent handles meal editing.
- 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.
Key load-bearing patterns in src/pages/hack/[event_id]/manageteam.js:
- Split error state:
teamsErroris only set fromfetchMyTeams()→ passed toTeamStatusPanelaserror;formErroris only set from form validation/submit. Never merge them —teamsErrorshowing causes the TeamStatusPanel to display an error card rather than the team hub, wiping the team list off-screen on a non-fatal form error. - Lazy Slack + nonprofit fetches: Slack users (
active_days=365) and nonprofit details are only fetched whenactiveStep >= 1ORshowNewTeamFormis true, guarded byslackFetchedRef.current/nonprofitFetchedRef.currentrespectively. Do NOT fetch either on initial load — keeps the initial page to ~3 API calls. onTeamUpdatedcontract:manageteam.jspassesonTeamUpdated = useCallback((teamId, partial) => setMyTeams(prev => prev.map(t => t.id === teamId ? { ...t, ...partial } : t)), [])toTeamStatusPanel.TeamStatusPanelcalls it after a successful DevPost/demo-video save so the parent state updates without a full re-fetch (button label refreshes immediately).- Post-submit flow: after successful team creation, call
fetchMyTeams()then scroll to#team-hub— do NOT show a mega-panel; the team hub section already shows the new team card. postLoginRedirectUrlSSR guard (same pattern asfindteam.js):currentUrl || (typeof window !== "undefined" ? window.location.href : undefined)— preventswindowaccess during SSR.
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 (blue #e3f2fd → #ede7f6/#e8eaf6, border #90caf9):
!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.
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). 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:
TeamCreation/TeamStatusPanel.jshas its own "Demo Video" section beside the DevPost section with live preview viaLiteVideoThumbnail. Validates URL is one of the 4 supported providers client-side. 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.
"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.
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. - 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.
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.
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 * * *).