- Nothing grows at this address. The page may have moved, or it never
- existed.
-
-
-
- Go to the homepage
-
-
-
-
-
- Plant a flower
- }
- />
-
-
- );
-}
diff --git a/sites/hacklytics2027/app/page.tsx b/sites/hacklytics2027/app/page.tsx
deleted file mode 100644
index ebc3c022..00000000
--- a/sites/hacklytics2027/app/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import Hero from "@/components/Hero";
-import HomeSections from "@/components/HomeSections";
-
-export default function HomePage() {
- return (
-
-
-
-
- );
-}
diff --git a/sites/hacklytics2027/astro.config.mjs b/sites/hacklytics2027/astro.config.mjs
new file mode 100644
index 00000000..8593219e
--- /dev/null
+++ b/sites/hacklytics2027/astro.config.mjs
@@ -0,0 +1,12 @@
+import react from "@astrojs/react";
+import tailwindcss from "@tailwindcss/vite";
+import { defineConfig } from "astro/config";
+
+export default defineConfig({
+ // Static HTML for Firebase Hosting, which serves out/ (see firebase.json).
+ outDir: "out",
+ // The port the docs and scripts/capture-vision.mts default to.
+ server: { port: 3000 },
+ integrations: [react()],
+ vite: { plugins: [tailwindcss()] },
+});
diff --git a/sites/hacklytics2027/components/Footer.tsx b/sites/hacklytics2027/components/Footer.tsx
deleted file mode 100644
index 37d2ec1f..00000000
--- a/sites/hacklytics2027/components/Footer.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-"use client";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { bloomSet, plantBed, withBlooms } from "./pixel/beds";
-import { PixelBed } from "./pixel/PixelBed";
-import { useInBloom } from "./pixel/season";
-import {
- INTEREST_URL,
- JUDGE_URL,
- PORTAL_HACKATHON_URL,
- PORTAL_ORIGIN,
-} from "@/lib/links";
-
-const navIds = ["about", "tracks", "prizes", "schedule", "sponsors", "faqs"];
-
-const FOOTER_BED = plantBed(3);
-
-export default function Footer() {
- const inBloom = useInBloom();
- // The 404's bare soil is the point of that page, so no bed in bloom under it.
- const home = usePathname() === "/";
-
- return (
-
- );
-}
diff --git a/sites/hacklytics2027/components/Hero.tsx b/sites/hacklytics2027/components/Hero.tsx
deleted file mode 100644
index fe8f7a3a..00000000
--- a/sites/hacklytics2027/components/Hero.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-"use client";
-import React, { useState, useEffect, useSyncExternalStore } from "react";
-import MlhBadge from "@/components/MlhBadge";
-import { BED_SIZE, bloomSet, plantBed, withBlooms } from "@/components/pixel/beds";
-import { PixelBed } from "@/components/pixel/PixelBed";
-import { EVENT_START, useInBloom } from "@/components/pixel/season";
-import { INTEREST_URL } from "@/lib/links";
-
-const HERO_BED = plantBed(0);
-
-// ─── Countdown ────────────────────────────────────────────────────────────
-const subscribeNoop = () => () => {};
-
-const Countdown: React.FC<{ targetDate: Date }> = ({ targetDate }) => {
- const getTimeLeft = React.useCallback(() => {
- const distance = targetDate.getTime() - Date.now();
- if (distance <= 0) return null;
- return {
- days: Math.floor(distance / (1000 * 60 * 60 * 24)),
- hours: Math.floor((distance / (1000 * 60 * 60)) % 24),
- minutes: Math.floor((distance / (1000 * 60)) % 60),
- seconds: Math.floor((distance / 1000) % 60),
- };
- }, [targetDate]);
-
- const [timeLeft, setTimeLeft] = useState(getTimeLeft);
- // False for the static HTML and hydration, true after: the build-time
- // countdown would be stale by the time anyone loads the page.
- const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false);
-
- useEffect(() => {
- const id = setInterval(() => setTimeLeft(getTimeLeft()), 1000);
- return () => clearInterval(id);
- }, [getTimeLeft]);
-
- const fmt = (n?: number) => String(n ?? 0).padStart(2, "0");
-
- const units = [
- { label: "days", value: timeLeft?.days },
- { label: "hours", value: timeLeft?.hours },
- { label: "minutes", value: timeLeft?.minutes },
- { label: "seconds", value: timeLeft?.seconds },
- ];
-
- // Quiet figures: a big number over a small unit, no colour.
- return (
-
- {units.map(({ label, value }) => (
-
-
- {mounted ? fmt(value) : "00"}
-
- {label}
-
- ))}
-
- );
-};
-
-// ─── Hero ─────────────────────────────────────────────────────────────────
-export default function Hero() {
- const inBloom = useInBloom();
- const heroBed = withBlooms(HERO_BED, bloomSet(inBloom));
-
- return (
-
-
-
-
- {/* Top padding clears the navbar and the MLH badge, which hangs to
- 98px on phones and 158px from md (90px wide, 1:1.75). */}
-
-
-
- Feb 26–28, 2027 · Klaus, Georgia Tech
-
-
- {/* One line at every width, and only multiples of 8, so a pixel
- cell is always a whole number of screen pixels. "Hacklytics"
- is 5.75em wide. The nudge undoes the tracking after the last
- letter, which pushed the ink right of centre. */}
-
- Hacklytics
-
-
-
- {/* The globe stands dead centre, with the words either side from lg
- and above it below that. -mb-24 sets it down on the soil, past
- the bed's 96px row, in the gap the bed leaves for it. This box is
- above the bed, so no flower can ever cover the globe. */}
-
-
-
-
- 36 hours of data science and AI in Atlanta, run by Data
- Science @ GT.
- {" "}
- Free to attend; meals and swag are covered.
-
-
- {/* The key art: a flat globe, a ribbon of blossoms across the
- Americas, and 8-bit text that spells "Data Science GT" and
- "Hacklytics 2027". The black is keyed out, so it sits on either
- theme. 807x705 is its native size; 480 serves 1x phones. */}
-
-
-
-
-
-
- {/* The flower bed the hero stands in. Its share in bloom tracks the
- season; visitors can plant their own. */}
-
- Plant a flower
- }
- caption={
- inBloom
- ? `${inBloom} of ${BED_SIZE} in bloom · fills in as Feb 26 gets closer`
- : undefined
- }
- />
-
-
- );
-}
diff --git a/sites/hacklytics2027/components/MlhBadge.tsx b/sites/hacklytics2027/components/MlhBadge.tsx
deleted file mode 100644
index a4a10b5b..00000000
--- a/sites/hacklytics2027/components/MlhBadge.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-/** Hangs from the top right of the page it sits on, which must be `relative`. */
-export default function MlhBadge() {
- return (
-
-
-
- );
-}
diff --git a/sites/hacklytics2027/components/Navbar.tsx b/sites/hacklytics2027/components/Navbar.tsx
deleted file mode 100644
index 06ba1673..00000000
--- a/sites/hacklytics2027/components/Navbar.tsx
+++ /dev/null
@@ -1,262 +0,0 @@
-"use client";
-import { useState, useEffect, useRef } from "react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { INTEREST_URL } from "@/lib/links";
-import { bloomSet, plantBed, withBlooms } from "./pixel/beds";
-import { PixelBed } from "./pixel/PixelBed";
-import { useInBloom } from "./pixel/season";
-import ThemeToggle from "./ThemeToggle";
-
-const navItems = [
- { name: "About", href: "/#about" },
- { name: "Tracks", href: "/#tracks" },
- { name: "Prizes", href: "/#prizes" },
- { name: "Schedule", href: "/#schedule" },
- { name: "Sponsors", href: "/#sponsors" },
- { name: "FAQ", href: "/#faqs" },
-];
-
-const MENU_BED = plantBed(2);
-
-export default function Navbar() {
- const inBloom = useInBloom();
- const pathname = usePathname();
- const [open, setOpen] = useState(false);
- const [visible, setVisible] = useState(true);
- const [scrolled, setScrolled] = useState(false);
- const lastY = useRef(0);
- const clickScrolling = useRef(false);
- const scrollTimer = useRef(null);
- const headerRef = useRef(null);
-
- useEffect(() => {
- const onScroll = () => {
- const y = window.scrollY;
- setScrolled(y > 20);
- if (y > lastY.current && !clickScrolling.current && y > 80) {
- setVisible(false);
- } else {
- setVisible(true);
- }
- lastY.current = y;
- // A nav jump's length depends on how far it goes, so it counts as over
- // once the scroll events stop, not after a fixed time: FAQ takes over a
- // second, and the header hid itself the moment a timer ran out mid-way.
- if (clickScrolling.current) {
- if (scrollTimer.current) clearTimeout(scrollTimer.current);
- scrollTimer.current = setTimeout(() => {
- clickScrolling.current = false;
- }, 150);
- }
- };
- const onMouseMove = (e: MouseEvent) => {
- if (e.clientY < 60) setVisible(true);
- };
- // Every section link on the site, whichever component renders it, scrolls
- // without touching the URL. Capture, so it runs before next/link's handler,
- // which stands down once the default is prevented.
- const onSectionLink = (e: MouseEvent) => {
- // Cmd/ctrl-click opens a new tab; that is the browser's to handle.
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0)
- return;
- const link =
- e.target instanceof Element
- ? e.target.closest("a[href*='#']")
- : null;
- if (!link) return;
- setOpen(false);
- // Off the home page (the 404), let the link navigate to /#id.
- const section =
- link.pathname === window.location.pathname
- ? document.getElementById(link.hash.slice(1))
- : null;
- if (!section) return;
- e.preventDefault();
- clickScrolling.current = true;
- setVisible(true);
- const offset = (headerRef.current?.offsetHeight ?? 0) + 16;
- window.scrollTo({
- top: section.getBoundingClientRect().top + window.scrollY - offset,
- behavior: "smooth",
- });
- scrollTimer.current = setTimeout(() => {
- clickScrolling.current = false;
- }, 1000);
- };
- window.addEventListener("scroll", onScroll, { passive: true });
- window.addEventListener("mousemove", onMouseMove, { passive: true });
- document.addEventListener("click", onSectionLink, true);
- return () => {
- window.removeEventListener("scroll", onScroll);
- window.removeEventListener("mousemove", onMouseMove);
- document.removeEventListener("click", onSectionLink, true);
- if (scrollTimer.current) clearTimeout(scrollTimer.current);
- };
- }, []);
-
- // Arriving at /#faqs, from a shared link or the 404: html is scroll-behavior
- // smooth, so the browser animated down from the top through the whole page,
- // and stalled if anything moved in the meantime. Jump there instead, then
- // drop the hash so the address stays clean. Sync the header too, since a page
- // opened mid-way fires no scroll event and stayed see-through.
- useEffect(() => {
- const frame = requestAnimationFrame(() => {
- const { hash } = window.location;
- if (hash) {
- document
- .getElementById(hash.slice(1))
- ?.scrollIntoView({ behavior: "instant", block: "start" });
- history.replaceState(history.state, "", pathname);
- setVisible(true);
- }
- // Not onScroll(): from lastY 0, landing on the section reads as a
- // scroll down and hid the header, which touch users had no way back to
- // short of scrolling up. Record where the page starts; only the
- // background depends on it.
- lastY.current = window.scrollY;
- setScrolled(window.scrollY > 20);
- });
- return () => cancelAnimationFrame(frame);
- }, [pathname]);
-
- useEffect(() => {
- document.body.classList.toggle("overflow-hidden", open);
- return () => document.body.classList.remove("overflow-hidden");
- }, [open]);
-
- return (
- <>
-
- {/* The right padding keeps clear of the MLH badge hanging from the top
- right of the hero (owned elsewhere): 20px + 56px wide on phones,
- 48px + up to 90px from md. .wrap sits in the components layer, so
- this right padding wins over its gutter. What is left on a phone
- holds the logo, Notify me and the menu button, so the theme switch
- lives in the menu until sm. */}
-
- setOpen(false)}
- className="font-display text-[16px] text-ink shrink-0 inline-flex h-11 items-center"
- >
- Hacklytics
-
-
- {/* Six links, the logo and the button do not fit below 1024px. */}
- {/* Centred on the page from xl, under nothing but the hero title's
- own centre line. There is no room for that at lg. */}
-
-
-
-
-
-
- {/* Compact on phones, where it used to hide in the menu. Below
- 356px there is no room beside the logo; the menu has it. */}
-
- Notify me
-
-
-
-
+
+ {/* One line at every width, and only multiples of 8, so a pixel
+ cell is always a whole number of screen pixels. "Hacklytics"
+ is 5.75em wide. The nudge undoes the tracking after the last
+ letter, which pushed the ink right of centre. */}
+
+ Hacklytics
+
+
+
+ {/* The globe stands dead centre, with the words either side from lg
+ and above it below that. -mb-24 sets it down on the soil, past
+ the bed's 96px row, in the gap the bed leaves for it. This box is
+ above the bed, so no flower can ever cover the globe. */}
+
+
+
+
+ 36 hours of data science and AI in Atlanta, run by Data
+ Science @ GT.
+ {" "}
+ Free to attend; meals and swag are covered.
+
+
+ {/* The key art: a flat globe, a ribbon of blossoms across the
+ Americas, and 8-bit text that spells "Data Science GT" and
+ "Hacklytics 2027". The black is keyed out, so it sits on either
+ theme. 807x705 is its native size; 480 serves 1x phones. */}
+
+
+ {/* Quiet figures: a big number over a small unit, no colour. The dim
+ zeros are the static HTML, and the inline script below fills in the
+ real numbers while the page is still being parsed, before first
+ paint, so the figures never jump from "00" to "112". It then ticks
+ them each second. */}
+
+ {units.map((unit) => (
+
+
+ 00
+
+ {unit}
+
+ ))}
+
+
+
+
+ {/* The flower bed the hero stands in. Its share in bloom tracks the
+ season; visitors can plant their own. The garden is the one part of the
+ first screen that needs React, and nothing on it is needed to read the
+ page, so it hydrates once the browser is idle. */}
+
+
+
+
+
+
diff --git a/sites/hacklytics2027/components/HomeSections.tsx b/sites/hacklytics2027/src/components/HomeSections.astro
similarity index 57%
rename from sites/hacklytics2027/components/HomeSections.tsx
rename to sites/hacklytics2027/src/components/HomeSections.astro
index 30725cb4..bb596e2a 100644
--- a/sites/hacklytics2027/components/HomeSections.tsx
+++ b/sites/hacklytics2027/src/components/HomeSections.astro
@@ -1,19 +1,17 @@
+---
import AboutSection from "./sections/AboutSection";
import TracksSection from "./sections/TracksSection";
import ScheduleSection from "./sections/Schedule/Schedule";
import FAQSection from "./sections/FAQSection";
import PrizeAndSpeakerSection from "./sections/PrizeAndSpeakerSection";
import SponsorsSection from "./sections/Sponsor";
+---
-export default function HomeSections() {
- return (
-
-
-
-
-
-
-
-
- );
-}
+
+
+
+
+
+
+
+
diff --git a/sites/hacklytics2027/src/components/MlhBadge.astro b/sites/hacklytics2027/src/components/MlhBadge.astro
new file mode 100644
index 00000000..eea9c0df
--- /dev/null
+++ b/sites/hacklytics2027/src/components/MlhBadge.astro
@@ -0,0 +1,12 @@
+---
+// Hangs from the top right of the page it sits on, which must be `relative`.
+---
+
+
+
+
diff --git a/sites/hacklytics2027/src/components/Navbar.astro b/sites/hacklytics2027/src/components/Navbar.astro
new file mode 100644
index 00000000..a4cd56d2
--- /dev/null
+++ b/sites/hacklytics2027/src/components/Navbar.astro
@@ -0,0 +1,135 @@
+---
+import { INTEREST_URL } from "@/lib/links";
+import { MenuBed } from "./pixel/GardenBeds";
+import ThemeToggle from "./ThemeToggle.astro";
+
+const navItems = [
+ { name: "About", href: "/#about" },
+ { name: "Tracks", href: "/#tracks" },
+ { name: "Prizes", href: "/#prizes" },
+ { name: "Schedule", href: "/#schedule" },
+ { name: "Sponsors", href: "/#sponsors" },
+ { name: "FAQ", href: "/#faqs" },
+];
+
+// The menu is a tinted layer over the page: hidden only by opacity, and the
+// open/hidden/scrolled states ride on data attributes that scripts/navbar.ts
+// flips, so the markup here is the whole closed, top-of-page state.
+---
+
+
+ {/* The right padding keeps clear of the MLH badge hanging from the top
+ right of the hero (owned elsewhere): 20px + 56px wide on phones,
+ 48px + up to 90px from md. .wrap sits in the components layer, so
+ this right padding wins over its gutter. What is left on a phone
+ holds the logo, Notify me and the menu button, so the theme switch
+ lives in the menu until sm. */}
+
+
+ Hacklytics
+
+
+ {/* Six links, the logo and the button do not fit below 1024px. Centred on
+ the page from xl, under nothing but the hero title's own centre line.
+ There is no room for that at lg. */}
+
+
+
+
+
+
+ {/* Compact on phones, where it used to hide in the menu. Below 356px
+ there is no room beside the logo; the menu has it. */}
+
+ Notify me
+
+
+
+
+
+
+
+{/* Hidden only by opacity, so without inert its links would stay in the tab
+ order and the accessibility tree while closed. */}
+
+ {/* Fills the space under the button on a tall phone. On a short one there
+ is none, and the theme switch below has to stay on screen. The menu
+ only opens below 1024px, so the bed hydrates only there. */}
+
+
+
+
+ Digital Bloom · 2027
+
+
+
+
+
+
+
+
diff --git a/sites/hacklytics2027/src/components/ThemeToggle.astro b/sites/hacklytics2027/src/components/ThemeToggle.astro
new file mode 100644
index 00000000..fadf59e5
--- /dev/null
+++ b/sites/hacklytics2027/src/components/ThemeToggle.astro
@@ -0,0 +1,62 @@
+---
+// Night / day switch. The greenhouse at night stays the default look for
+// anyone whose system is dark; this lets either side pick the other.
+// scripts/theme-toggle.ts wires every [data-theme-toggle] button. The icon
+// follows the theme in CSS (.theme-sun, .theme-moon in styles/globals.css), so
+// it is right on the first paint, before any script runs.
+interface Props {
+ class?: string;
+}
+const { class: className = "" } = Astro.props;
+---
+
+
diff --git a/sites/hacklytics2027/src/components/pixel/GardenBeds.tsx b/sites/hacklytics2027/src/components/pixel/GardenBeds.tsx
new file mode 100644
index 00000000..1e3759b2
--- /dev/null
+++ b/sites/hacklytics2027/src/components/pixel/GardenBeds.tsx
@@ -0,0 +1,71 @@
+import { BED_SIZE, bloomSet, plantBed, withBlooms } from "./beds";
+import { PixelBed } from "./PixelBed";
+import { useInBloom } from "./season";
+
+/*
+ * The four flower beds that need React. Each is its own island: the share in
+ * bloom comes from the clock and a click plants a flower, neither of which the
+ * static HTML can know. The seeds match the markup these beds replaced, so the
+ * same plants stand in the same places.
+ */
+
+const Heading = () =>
Plant a flower
;
+
+// The static HTML has no count yet. A blank line holds the caption's place, so
+// the bed does not grow when hydration fills it in.
+const NO_CAPTION = " ";
+
+const HERO_BED = plantBed(0);
+const MENU_BED = plantBed(2);
+const FOOTER_BED = plantBed(3);
+
+export function HeroBed() {
+ const inBloom = useInBloom();
+ return (
+ }
+ caption={
+ inBloom
+ ? `${inBloom} of ${BED_SIZE} in bloom · fills in as Feb 26 gets closer`
+ : NO_CAPTION
+ }
+ />
+ );
+}
+
+export function MenuBed() {
+ const inBloom = useInBloom();
+ return ;
+}
+
+export function FooterBed() {
+ const inBloom = useInBloom();
+ return (
+
+ );
+}
+
+/* "Nothing grows at this address": the only bed on the site with no plants. */
+export function NotFoundBed() {
+ return (
+ }
+ />
+ );
+}
diff --git a/sites/hacklytics2027/components/pixel/PixelBed.tsx b/sites/hacklytics2027/src/components/pixel/PixelBed.tsx
similarity index 99%
rename from sites/hacklytics2027/components/pixel/PixelBed.tsx
rename to sites/hacklytics2027/src/components/pixel/PixelBed.tsx
index 918c22a5..119ea886 100644
--- a/sites/hacklytics2027/components/pixel/PixelBed.tsx
+++ b/sites/hacklytics2027/src/components/pixel/PixelBed.tsx
@@ -1,4 +1,3 @@
-"use client";
import React, { useRef, useState, useSyncExternalStore } from "react";
import { BloomSprite } from "./PixelSprite";
diff --git a/sites/hacklytics2027/components/pixel/PixelSprite.tsx b/sites/hacklytics2027/src/components/pixel/PixelSprite.tsx
similarity index 100%
rename from sites/hacklytics2027/components/pixel/PixelSprite.tsx
rename to sites/hacklytics2027/src/components/pixel/PixelSprite.tsx
diff --git a/sites/hacklytics2027/components/pixel/beds.ts b/sites/hacklytics2027/src/components/pixel/beds.ts
similarity index 100%
rename from sites/hacklytics2027/components/pixel/beds.ts
rename to sites/hacklytics2027/src/components/pixel/beds.ts
diff --git a/sites/hacklytics2027/src/components/pixel/event.ts b/sites/hacklytics2027/src/components/pixel/event.ts
new file mode 100644
index 00000000..a27a09fa
--- /dev/null
+++ b/sites/hacklytics2027/src/components/pixel/event.ts
@@ -0,0 +1,4 @@
+// Check-in opens 5pm ET (the JSON-LD startDate). Without an offset the string
+// meant midnight in each visitor's own timezone. Its own module so the
+// countdown script can read it without pulling in React.
+export const EVENT_START = new Date("2027-02-26T17:00:00-05:00");
diff --git a/sites/hacklytics2027/components/pixel/season.ts b/sites/hacklytics2027/src/components/pixel/season.ts
similarity index 83%
rename from sites/hacklytics2027/components/pixel/season.ts
rename to sites/hacklytics2027/src/components/pixel/season.ts
index 31547c12..393bf26f 100644
--- a/sites/hacklytics2027/components/pixel/season.ts
+++ b/sites/hacklytics2027/src/components/pixel/season.ts
@@ -1,9 +1,7 @@
import { useSyncExternalStore } from "react";
import { BED_SIZE } from "./beds";
+import { EVENT_START } from "./event";
-// Check-in opens 5pm ET (the JSON-LD startDate). Without an offset the string
-// meant midnight in each visitor's own timezone.
-export const EVENT_START = new Date("2027-02-26T17:00:00-05:00");
// When the 2027 season opened. Beds fill in from here to EVENT_START: a tenth
// of each in bloom at the start, all of it on the day.
const SEASON_START = new Date("2026-09-01T00:00:00-04:00");
diff --git a/sites/hacklytics2027/components/pixel/spriteUri.ts b/sites/hacklytics2027/src/components/pixel/spriteUri.ts
similarity index 100%
rename from sites/hacklytics2027/components/pixel/spriteUri.ts
rename to sites/hacklytics2027/src/components/pixel/spriteUri.ts
diff --git a/sites/hacklytics2027/components/pixel/sprites.ts b/sites/hacklytics2027/src/components/pixel/sprites.ts
similarity index 100%
rename from sites/hacklytics2027/components/pixel/sprites.ts
rename to sites/hacklytics2027/src/components/pixel/sprites.ts
diff --git a/sites/hacklytics2027/components/pixel/useInView.ts b/sites/hacklytics2027/src/components/pixel/useInView.ts
similarity index 98%
rename from sites/hacklytics2027/components/pixel/useInView.ts
rename to sites/hacklytics2027/src/components/pixel/useInView.ts
index 98d1dad9..b86d1faa 100644
--- a/sites/hacklytics2027/components/pixel/useInView.ts
+++ b/sites/hacklytics2027/src/components/pixel/useInView.ts
@@ -1,4 +1,3 @@
-"use client";
import { useEffect, useRef } from "react";
diff --git a/sites/hacklytics2027/components/sections/AboutSection.tsx b/sites/hacklytics2027/src/components/sections/AboutSection.tsx
similarity index 100%
rename from sites/hacklytics2027/components/sections/AboutSection.tsx
rename to sites/hacklytics2027/src/components/sections/AboutSection.tsx
diff --git a/sites/hacklytics2027/components/sections/FAQSection.tsx b/sites/hacklytics2027/src/components/sections/FAQSection.tsx
similarity index 97%
rename from sites/hacklytics2027/components/sections/FAQSection.tsx
rename to sites/hacklytics2027/src/components/sections/FAQSection.tsx
index 54d103ab..7a66477b 100644
--- a/sites/hacklytics2027/components/sections/FAQSection.tsx
+++ b/sites/hacklytics2027/src/components/sections/FAQSection.tsx
@@ -1,5 +1,4 @@
import React from "react";
-import Link from "next/link";
import { INTEREST_URL } from "@/lib/links";
const faqItems: { q: string; a: React.ReactNode }[] = [
@@ -32,14 +31,14 @@ const faqItems: { q: string; a: React.ReactNode }[] = [
a: (
<>
Applications are not open yet.{" "}
-
Get notified
- {" "}
+ {" "}
and we’ll tell you when they do.
>
),
@@ -62,14 +61,14 @@ const faqItems: { q: string; a: React.ReactNode }[] = [
a: (
<>
All participants follow the{" "}
-
MLH Code of Conduct
-
+
. Harassment of any kind is not tolerated.
>
),
diff --git a/sites/hacklytics2027/components/sections/PrizeAndSpeakerSection.tsx b/sites/hacklytics2027/src/components/sections/PrizeAndSpeakerSection.tsx
similarity index 100%
rename from sites/hacklytics2027/components/sections/PrizeAndSpeakerSection.tsx
rename to sites/hacklytics2027/src/components/sections/PrizeAndSpeakerSection.tsx
diff --git a/sites/hacklytics2027/components/sections/Schedule/Schedule.tsx b/sites/hacklytics2027/src/components/sections/Schedule/Schedule.tsx
similarity index 95%
rename from sites/hacklytics2027/components/sections/Schedule/Schedule.tsx
rename to sites/hacklytics2027/src/components/sections/Schedule/Schedule.tsx
index d2b6fd82..086b60b4 100644
--- a/sites/hacklytics2027/components/sections/Schedule/Schedule.tsx
+++ b/sites/hacklytics2027/src/components/sections/Schedule/Schedule.tsx
@@ -1,7 +1,5 @@
-"use client";
import React from "react";
import { BloomSprite } from "../../pixel/PixelSprite";
-import { useBloomInView } from "../../pixel/useInView";
import { BUD, TULIP } from "../../pixel/sprites";
import { board } from "./data";
@@ -26,7 +24,6 @@ const split = (when: string) => {
export default function Schedule() {
const rows = board.map((row) => ({ ...row, ...split(row.when) }));
- const seedlingRef = useBloomInView();
return (
@@ -40,7 +37,7 @@ export default function Schedule() {
{/* A bud where hacking begins, in bloom where it ends. */}
-
+
();
-
+ // ribbon pink one after another, left to right. scripts/bloom.ts flips
+ // data-bloom on the first time the row is in view.
return (
@@ -28,8 +25,8 @@ export default function TracksSection() {
a row gap, which the subgrid would also put between a title and
its description; -mb-14 takes it back off the last row. */}
{tracks.map((t, i) => (
diff --git a/sites/hacklytics2027/src/layouts/Layout.astro b/sites/hacklytics2027/src/layouts/Layout.astro
new file mode 100644
index 00000000..72bb7ade
--- /dev/null
+++ b/sites/hacklytics2027/src/layouts/Layout.astro
@@ -0,0 +1,120 @@
+---
+import "../styles/fonts.generated.css";
+import "../styles/globals.css";
+import Footer from "../components/Footer.astro";
+import Navbar from "../components/Navbar.astro";
+import { FONT_PRELOADS } from "../lib/fonts.generated";
+import { INTEREST_URL } from "../lib/links";
+import { GROUND, THEME_KEY } from "../lib/theme";
+
+// Fonts: Instrument Sans for text and UI, Silkscreen for display type (hero,
+// section titles, track names, countdown). Silkscreen has one weight, so
+// nothing sets it bold: a synthesized bold smears the pixel cells. Both come
+// from scripts/subset-fonts.py, which says when to rerun it.
+
+interface Props {
+ /** Whether this is the home page; the footer's call to action and bed only belong there. */
+ home?: boolean;
+}
+const { home = false } = Astro.props;
+
+const SITE = "https://hacklytics.io";
+const title = "Hacklytics 2027: Digital Bloom · Data Science Hackathon at Georgia Tech";
+const description =
+ "Hacklytics 2027 is a free, 36-hour data science and AI hackathon run by Data Science @ GT at Georgia Tech in Atlanta, February 26–28, 2027.";
+const keywords = ["hackathon", "data science", "machine learning", "AI", "Georgia Tech", "Atlanta", "coding", "competition"];
+
+// Runs before first paint so a saved light/dark choice never flashes the other
+// theme, and points the theme-color metas at it so the bar matches the page.
+// Kept tiny and dependency-free.
+const themeScript = `try{var t=localStorage.getItem("${THEME_KEY}");if(t==="light"||t==="dark"){document.documentElement.dataset.theme=t;document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.content=t==="light"?"${GROUND.light}":"${GROUND.dark}"})}}catch(e){}`;
+
+// JSON-LD structured data for SEO Event Schema
+const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "Event",
+ name: "Hacklytics 2027: Digital Bloom",
+ startDate: "2027-02-26T17:00:00-05:00",
+ endDate: "2027-02-28T16:00:00-05:00",
+ eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
+ eventStatus: "https://schema.org/EventScheduled",
+ location: {
+ "@type": "Place",
+ name: "Klaus Advanced Computing Building",
+ address: {
+ "@type": "PostalAddress",
+ streetAddress: "266 Ferst Dr NW",
+ addressLocality: "Atlanta",
+ postalCode: "30332",
+ addressRegion: "GA",
+ addressCountry: "US",
+ },
+ },
+ image: [`${SITE}/og-image.jpg`],
+ description: "Data Science @ GT runs the Southeast’s data science hackathon: 36 hours of data science and AI at Georgia Tech.",
+ offers: {
+ "@type": "Offer",
+ url: INTEREST_URL,
+ price: "0",
+ priceCurrency: "USD",
+ // PreOrder, not InStock: registration has not opened, and the link behind
+ // this offer joins an interest list rather than securing a place. Search
+ // results that promise "register now" against a page that cannot are the
+ // kind of thing that gets rich results pulled.
+ availability: "https://schema.org/PreOrder",
+ validFrom: "2026-08-01T00:00:00-04:00",
+ },
+ organizer: {
+ "@type": "Organization",
+ name: "Data Science @ GT",
+ url: "https://datasciencegt.org",
+ },
+};
+---
+
+
+
+
+
+
+ {FONT_PRELOADS.map((href) => )}
+
+
+ {/* The browser bar follows the device setting by default; the theme
+ toggle repeats these when a choice is made. */}
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {import.meta.env.PROD && }
+
+
diff --git a/sites/hacklytics2027/lib/fonts.generated.ts b/sites/hacklytics2027/src/lib/fonts.generated.ts
similarity index 100%
rename from sites/hacklytics2027/lib/fonts.generated.ts
rename to sites/hacklytics2027/src/lib/fonts.generated.ts
diff --git a/sites/hacklytics2027/lib/links.ts b/sites/hacklytics2027/src/lib/links.ts
similarity index 100%
rename from sites/hacklytics2027/lib/links.ts
rename to sites/hacklytics2027/src/lib/links.ts
diff --git a/sites/hacklytics2027/lib/theme.ts b/sites/hacklytics2027/src/lib/theme.ts
similarity index 100%
rename from sites/hacklytics2027/lib/theme.ts
rename to sites/hacklytics2027/src/lib/theme.ts
diff --git a/sites/hacklytics2027/src/pages/404.astro b/sites/hacklytics2027/src/pages/404.astro
new file mode 100644
index 00000000..672bb405
--- /dev/null
+++ b/sites/hacklytics2027/src/pages/404.astro
@@ -0,0 +1,31 @@
+---
+import MlhBadge from "../components/MlhBadge.astro";
+import { NotFoundBed } from "../components/pixel/GardenBeds";
+import Layout from "../layouts/Layout.astro";
+---
+
+
+ {/* "Nothing grows at this address", so the soil here is bare, the only bed
+ on the site with no plants, until a visitor plants the first one. */}
+
+
+
+
+
Page not found
+
+ 404
+
+
+ Nothing grows at this address. The page may have moved, or it never
+ existed.
+
+
+ {/* Headline: the next thing to show up for, or how check-in works. */}
+
+
+ {nextEvent ? `Next up: ${nextEvent.title}` : "Check in at the door"}
+
+
+ {nextEvent
+ ? `${eventWhen(nextEvent.eventDate)}${nextEvent.location ? ` · ${nextEvent.location}` : ""}. Scan the event code or show your pass when you arrive.`
+ : "Nothing is on the calendar right now. At the next meeting, scan its code or show your pass to record attendance."}
+
+
+ Looking for hackathons? Registrations, teams and projects live in
+ the{" "}
+
+ hackathon hub
+
+ .
+
-
- {/* Headline: the next thing to show up for, or how check-in works. */}
-
-
- {nextEvent ? `Next up: ${nextEvent.title}` : "Check in at the door"}
-
-
- {nextEvent
- ? `${eventWhen(nextEvent.eventDate)}${nextEvent.location ? ` · ${nextEvent.location}` : ""}. Scan the event code or show your pass when you arrive.`
- : "Nothing is on the calendar right now. At the next meeting, scan its code or show your pass to record attendance."}
-
-
- Looking for hackathons? Registrations, teams and projects live in
- the{" "}
-
- hackathon hub
-
- .
-
+
+ {/* ── CLUB / HACKATHON ───────────────────────────── */}
+ {/* Two different things this org does, and they have different rules:
+ the hackathon is open to anyone with an account, the club is the
+ paid yearly membership. Splitting them is what stops the dashboard
+ reading as though everything is behind the same paywall. */}
+
+ )}
+
+
+ {/* ── MEMBERSHIP ─────────────────────────────────── */}
+ {/* Club view only, and it now also catches lapsed members: `isMember`
+ means paid AND unexpired, so the renew path is reachable instead of
+ being hidden behind the same flag that expired. */}
+ {view === "club" && memberStatus?.isMember && (
+
+
Membership active
+
+ {expiresOn ? `Runs until ${expiresOn}` : "Active"}
+ {endsSoon ? ` · ${daysLeft} days left` : ""}
+
+ {/* Renewing early extends from the current end date rather than
+ from today, so nobody loses time by paying ahead. */}
+ {endsSoon && (
+
+
+
+ )}
+
+ )}
+
+ {/* ── DESTINATIONS ───────────────────────────────── */}
+ {/* The hackathon half has no entry of its own: "Browse all" above leads
+ to the hub. It lists only the portals this person can open. */}
+ {(view === "club" ||
+ isJudge ||
+ isAdmin ||
+ !!portalContext?.isBugTester) && (
+
+ {/* Club Portal — members only */}
+ {view === "club" &&
+ (memberStatus?.isMember ? (
+
+ Club events, check-ins, and resources.
+
+ ) : (
+
+
Club Portal
+
+ {memberStatus?.hasLapsed
+ ? "Your membership has run out. Renew below to get back in."
+ : "Membership required. Join below to unlock access."}
+
+
+ ))}
+
+ {/* Projects — club side, but browsing is open so anyone can see
+ what membership actually buys before paying for it. */}
+ {view === "club" && (
+
+ Projects the club runs year-round. Join one, or pitch your own.
+
+ )}
+
+ {/* Become a Member — sits beside Projects so the club view says
+ what is missing where the rest of the club lives. The pay UI is
+ the block below; this jumps to it. */}
+ {view === "club" && !memberStatus?.isMember && !isAdmin && (
+
+ {formatCents(MEMBERSHIP_CENTS)}/year or{" "}
+ {formatCents(SEMESTER_MEMBERSHIP_CENTS)}/semester. Opens the Club
+ Portal and member resources.
+
+ )}
+
+ {/* Judge Portal — judges only */}
+ {isJudge && (
+
+ Your judging editions, where you sit and the projects you score.
+
+ )}
+
+ {/* Admin Panel — admins, and bug testers read-only */}
+ {(isAdmin || !!portalContext?.isBugTester) && (
+
+ Manage events, attendees, and analytics.
+
+ )}
+
+ )}
+
+ {/* The pay UI itself, below the destinations. The entry above is the
+ label; this is the block. */}
+ {view === "club" && !memberStatus?.isMember && !isAdmin && (
+
+
+ {memberStatus?.hasLapsed ? "Renew your membership" : "Become a member"}
+
-
- {/* ── CLUB / HACKATHON ───────────────────────────── */}
- {/* Two different things this org does, and they have different rules:
- the hackathon is open to anyone with an account, the club is the
- paid yearly membership. Splitting them is what stops the dashboard
- reading as though everything is behind the same paywall. */}
-
- )}
-
-
- {/* ── MEMBERSHIP ─────────────────────────────────── */}
- {/* Club view only, and it now also catches lapsed members: `isMember`
- means paid AND unexpired, so the renew path is reachable instead of
- being hidden behind the same flag that expired. */}
- {view === "club" && memberStatus?.isMember && (
-
-
Membership active
-
- {expiresOn ? `Runs until ${expiresOn}` : "Active"}
- {endsSoon ? ` · ${daysLeft} days left` : ""}
-
- {/* Renewing early extends from the current end date rather than
- from today, so nobody loses time by paying ahead. */}
- {endsSoon && (
-
-
-
- )}
-
- )}
-
- {/* ── DESTINATIONS ───────────────────────────────── */}
- {/* The hackathon half has no entry of its own: "Browse all" above leads
- to the hub. It lists only the portals this person can open. */}
- {(view === "club" ||
- isJudge ||
- isAdmin ||
- !!portalContext?.isBugTester) && (
-
- {/* Club Portal — members only */}
- {view === "club" &&
- (memberStatus?.isMember ? (
-
- Club events, check-ins, and resources.
-
- ) : (
-
-
Club Portal
-
- {memberStatus?.hasLapsed
- ? "Your membership has run out. Renew below to get back in."
- : "Membership required. Join below to unlock access."}
-
-
- ))}
-
- {/* Projects — club side, but browsing is open so anyone can see
- what membership actually buys before paying for it. */}
- {view === "club" && (
-
- Projects the club runs year-round. Join one, or pitch your own.
-
- )}
-
- {/* Become a Member — sits beside Projects so the club view says
- what is missing where the rest of the club lives. The pay UI is
- the block below; this jumps to it. */}
- {view === "club" && !memberStatus?.isMember && !isAdmin && (
-
- {formatCents(MEMBERSHIP_CENTS)}/year or{" "}
- {formatCents(SEMESTER_MEMBERSHIP_CENTS)}/semester. Opens the Club
- Portal and member resources.
-
- )}
-
- {/* Judge Portal — judges only */}
- {isJudge && (
-
- Your judging editions, where you sit and the projects you score.
-
- )}
-
- {/* Admin Panel — admins, and bug testers read-only */}
- {(isAdmin || !!portalContext?.isBugTester) && (
-
- Manage events, attendees, and analytics.
-
- )}
-
- )}
-
- {/* The pay UI itself, below the destinations. The entry above is the
- label; this is the block. */}
- {view === "club" && !memberStatus?.isMember && !isAdmin && (
-
-
- {memberStatus?.hasLapsed ? "Renew your membership" : "Become a member"}
-
+ {`${hackathon.currentParticipants} of ${hackathon.maxParticipants} spots taken`}
+
+ ) : null}
+ {nextStep && }
+
+
+ {/* Mounted empty so screen readers announce the line when it lands. */}
+
+ {justCheckedIn && (
+
+ You're checked in.
+
+ )}
+
+
+ {/* What organisers announced to this hacker, emailed or in app only.
+ The uuid, not the route param: that may be a slug. */}
+ {regStatus && }
+
+ {/* Tabs. The end padding lets the last tab scroll clear of the fade. */}
+
- {`${hackathon.currentParticipants} of ${hackathon.maxParticipants} spots taken`}
-
- ) : null}
- {nextStep && }
-
-
- {/* Mounted empty so screen readers announce the line when it lands. */}
-
- {justCheckedIn && (
-
- You're checked in.
-
- )}
-
-
- {/* What organisers announced to this hacker, emailed or in app only.
- The uuid, not the route param: that may be a slug. */}
- {regStatus && }
-
- {/* Tabs. The end padding lets the last tab scroll clear of the fade. */}
-
-
+ [
+ ["hackathon.getById", { id }, c.hackathon.getById({ id })],
+ ]}
+ >
+
+
);
}
diff --git a/sites/mainweb/app/(portal)/judge/JudgePage.tsx b/sites/mainweb/app/(portal)/judge/JudgePage.tsx
new file mode 100644
index 00000000..055e1bab
--- /dev/null
+++ b/sites/mainweb/app/(portal)/judge/JudgePage.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import { venueFormat } from "@/lib/venue-time";
+import { useEffect } from "react";
+import Link from "next/link";
+import { useSession } from "next-auth/react";
+import { useRouter } from "next/navigation";
+import { ChevronRight } from "lucide-react";
+import { loginHref } from "@/lib/safe-callback";
+import { trpc } from "@/lib/trpc";
+import { useIsClient } from "@/lib/use-is-client";
+import { usePortalContext } from "@/lib/use-portal-context";
+import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import {
+ body,
+ itemTitle,
+ label,
+ meta,
+ page,
+ pageDek,
+ pageTitle,
+ sectionRule,
+ status,
+ textLink,
+} from "@/components/portal/ui";
+
+// Statuses that take judge applications; judge.register enforces the same.
+const APPLYING = new Set(["announced", "open", "closed", "in_progress"]);
+
+function formatDates(start: Date | string, end: Date | string) {
+ const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
+ return `${venueFormat(start, opts)} – ${venueFormat(end, { ...opts, year: "numeric" })}`;
+}
+
+export default function JudgePage() {
+ const { data: session, status: authStatus } = useSession();
+ const router = useRouter();
+ const mounted = useIsClient();
+
+ // isJudge covers a judge an organiser added by email, who has no application;
+ // myApplications covers every edition the user applied to, approved or not.
+ const { data: judgeStatus, isLoading: checkingJudge } =
+ trpc.judge.isJudge.useQuery(undefined, { enabled: !!session });
+ const { data: applications, isLoading: applicationsLoading } =
+ trpc.judge.myApplications.useQuery(undefined, { enabled: !!session });
+ const { data: hackathons, isLoading: hackathonsLoading } =
+ trpc.hackathon.list.useQuery({ limit: 50 });
+ // Staff can judge any edition without applying.
+ const { data: portal } = usePortalContext();
+
+ // The approval email points here, so a signed-out visitor goes to sign in.
+ useEffect(() => {
+ if (authStatus === "unauthenticated") router.push(loginHref());
+ }, [authStatus, router]);
+
+ if (
+ !mounted ||
+ authStatus === "loading" ||
+ checkingJudge ||
+ applicationsLoading ||
+ hackathonsLoading
+ ) {
+ return ;
+ }
+
+ if (!session) return null;
+
+ const byId = new Map((hackathons ?? []).map((h) => [h.id, h]));
+
+ const judgingIds = new Set(
+ (applications ?? []).filter((a) => a.approved).map((a) => a.hackathonId),
+ );
+ if (judgeStatus?.isJudge && judgeStatus.hackathonId) {
+ judgingIds.add(judgeStatus.hackathonId);
+ }
+ if (portal?.isAdmin) {
+ for (const h of hackathons ?? []) {
+ if (h.status === "in_progress") judgingIds.add(h.id);
+ }
+ }
+ const waitingIds = new Set(
+ (applications ?? [])
+ .filter((a) => !a.approved && !judgingIds.has(a.hackathonId))
+ .map((a) => a.hackathonId),
+ );
+ const canApply = (hackathons ?? []).filter(
+ (h) =>
+ APPLYING.has(h.status) && !judgingIds.has(h.id) && !waitingIds.has(h.id),
+ );
+
+ const judging = [...judgingIds];
+ const waiting = [...waitingIds];
+
+ return (
+
+
+
Judging
+
+ {judging.length > 0
+ ? "Open your edition on judging day, then scan the QR code at your room or table to take your seat."
+ : "Apply to judge an edition. An organiser approves you before judging day."}
+
+ You will get an email when an organiser approves you.
+
+
+ )}
+
+
+
Taking judges
+ {canApply.length === 0 ? (
+
+ No edition is taking judge applications right now.
+
+ ) : (
+
+ {canApply.map((h) => (
+
+
+
{h.name}
+
+ {formatDates(h.startDate, h.endDate)}
+
+
+
+ Apply to judge
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx
index 568d5462..9cb17ba8 100644
--- a/sites/mainweb/app/(portal)/judge/page.tsx
+++ b/sites/mainweb/app/(portal)/judge/page.tsx
@@ -1,196 +1,16 @@
-"use client";
-
-import { useEffect } from "react";
-import Link from "next/link";
-import { useSession } from "next-auth/react";
-import { useRouter } from "next/navigation";
-import { ChevronRight } from "lucide-react";
-import { loginHref } from "@/lib/safe-callback";
-import { trpc } from "@/lib/trpc";
-import { useIsClient } from "@/lib/use-is-client";
-import { usePortalContext } from "@/lib/use-portal-context";
-import { LoadingScreen } from "@/components/portal/LoadingScreen";
-import {
- body,
- itemTitle,
- label,
- meta,
- page,
- pageDek,
- pageTitle,
- sectionRule,
- status,
- textLink,
-} from "@/components/portal/ui";
-
-// Statuses that take judge applications; judge.register enforces the same.
-const APPLYING = new Set(["announced", "open", "closed", "in_progress"]);
-
-function formatDates(start: Date | string, end: Date | string) {
- const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
- const s = new Date(start);
- const e = new Date(end);
- return `${s.toLocaleDateString("en-US", opts)} – ${e.toLocaleDateString("en-US", { ...opts, year: "numeric" })}`;
-}
-
-export default function JudgePage() {
- const { data: session, status: authStatus } = useSession();
- const router = useRouter();
- const mounted = useIsClient();
-
- // isJudge covers a judge an organiser added by email, who has no application;
- // myApplications covers every edition the user applied to, approved or not.
- const { data: judgeStatus, isLoading: checkingJudge } =
- trpc.judge.isJudge.useQuery(undefined, { enabled: !!session });
- const { data: applications, isLoading: applicationsLoading } =
- trpc.judge.myApplications.useQuery(undefined, { enabled: !!session });
- const { data: hackathons, isLoading: hackathonsLoading } =
- trpc.hackathon.list.useQuery({ limit: 50 });
- // Staff can judge any edition without applying.
- const { data: portal } = usePortalContext();
-
- // The approval email points here, so a signed-out visitor goes to sign in.
- useEffect(() => {
- if (authStatus === "unauthenticated") router.push(loginHref());
- }, [authStatus, router]);
-
- if (
- !mounted ||
- authStatus === "loading" ||
- checkingJudge ||
- applicationsLoading ||
- hackathonsLoading
- ) {
- return ;
- }
-
- if (!session) return null;
-
- const byId = new Map((hackathons ?? []).map((h) => [h.id, h]));
-
- const judgingIds = new Set(
- (applications ?? []).filter((a) => a.approved).map((a) => a.hackathonId),
- );
- if (judgeStatus?.isJudge && judgeStatus.hackathonId) {
- judgingIds.add(judgeStatus.hackathonId);
- }
- if (portal?.isAdmin) {
- for (const h of hackathons ?? []) {
- if (h.status === "in_progress") judgingIds.add(h.id);
- }
- }
- const waitingIds = new Set(
- (applications ?? [])
- .filter((a) => !a.approved && !judgingIds.has(a.hackathonId))
- .map((a) => a.hackathonId),
- );
- const canApply = (hackathons ?? []).filter(
- (h) =>
- APPLYING.has(h.status) && !judgingIds.has(h.id) && !waitingIds.has(h.id),
- );
-
- const judging = [...judgingIds];
- const waiting = [...waitingIds];
+import { PrefetchedPage } from "@/components/portal/PrefetchedPage";
+import JudgePage from "./JudgePage";
+export default function Page() {
return (
-
-
-
Judging
-
- {judging.length > 0
- ? "Open your edition on judging day, then scan the QR code at your room or table to take your seat."
- : "Apply to judge an edition. An organiser approves you before judging day."}
-
diff --git a/sites/mainweb/components/portal/subteams/chips.tsx b/sites/mainweb/components/portal/subteams/chips.tsx
index 2987bc0d..18427026 100644
--- a/sites/mainweb/components/portal/subteams/chips.tsx
+++ b/sites/mainweb/components/portal/subteams/chips.tsx
@@ -1,3 +1,4 @@
+import { venueFormat } from "@/lib/venue-time";
import type { SubteamApplicationStatus } from "@query/db";
import { status as statusClass } from "@/components/portal/ui";
import type { Tone } from "@/components/portal/ui";
@@ -53,7 +54,7 @@ export function SubteamChip({
}
export const shortDate = (value: Date | string) =>
- new Date(value).toLocaleDateString("en-US", {
+ venueFormat(value, {
month: "short",
day: "numeric",
year: "numeric",
diff --git a/sites/mainweb/lib/hackathon-next-step.test.ts b/sites/mainweb/lib/hackathon-next-step.test.ts
index 2b261e28..6ea1bbb9 100644
--- a/sites/mainweb/lib/hackathon-next-step.test.ts
+++ b/sites/mainweb/lib/hackathon-next-step.test.ts
@@ -519,6 +519,19 @@ describe("isWithinEditionDates", () => {
expect(isWithinEditionDates(HACKATHON, BEFORE)).toBe(false);
});
+ it("changes day at the venue's midnight, whatever zone the runtime is in", () => {
+ const edition = {
+ startDate: "2027-02-25T17:00:00Z",
+ endDate: "2027-02-27T17:00:00Z",
+ };
+ // 04:59Z is 11:59 PM on the 24th in Atlanta, though it is the 25th in Auckland.
+ expect(isWithinEditionDates(edition, new Date("2027-02-25T04:59:00Z"))).toBe(false);
+ expect(isWithinEditionDates(edition, new Date("2027-02-25T05:00:00Z"))).toBe(true);
+ // 04:59Z on the 28th is still the 27th in Atlanta, the edition's last day.
+ expect(isWithinEditionDates(edition, new Date("2027-02-28T04:59:00Z"))).toBe(true);
+ expect(isWithinEditionDates(edition, new Date("2027-02-28T05:00:00Z"))).toBe(false);
+ });
+
it("widens the start by the lead days", () => {
const threeDaysOut = new Date("2027-02-23T12:00:00Z");
expect(isWithinEditionDates(HACKATHON, threeDaysOut, 2)).toBe(false);
diff --git a/sites/mainweb/lib/hackathon-next-step.ts b/sites/mainweb/lib/hackathon-next-step.ts
index 14f9e73f..eebaa786 100644
--- a/sites/mainweb/lib/hackathon-next-step.ts
+++ b/sites/mainweb/lib/hackathon-next-step.ts
@@ -1,4 +1,5 @@
import { hackathonPathSegment } from "./hackathon-slug";
+import { venueDayNumber } from "./venue-time";
import { eventDay, eventMoment } from "../components/hackathon/judging/event-time";
/**
@@ -92,36 +93,32 @@ const formatDeadline = (at: Date) => {
return deadlineFmt.format(at);
};
-// Day granularity, on the viewer's clock: the last day counts all day.
-const startOfDay = (d: DateLike) => {
- const day = new Date(d);
- day.setHours(0, 0, 0, 0);
- return day;
-};
+// Day granularity, on the venue's calendar: the last day counts all day. The
+// server render and every viewer agree on which day it is.
+const startOfDay = venueDayNumber;
/**
* Whether today falls on the edition's days, widened by `leadDays` before it
- * starts. Viewer's clock, day granularity.
+ * starts. Venue's calendar, day granularity.
*/
export function isWithinEditionDates(
edition: { startDate: DateLike; endDate: DateLike },
now: Date,
leadDays = 0,
): boolean {
- const from = startOfDay(edition.startDate);
- from.setDate(from.getDate() - leadDays);
- const today = startOfDay(now).getTime();
- return from.getTime() <= today && today <= startOfDay(edition.endDate).getTime();
+ const from = startOfDay(edition.startDate) - leadDays;
+ const today = startOfDay(now);
+ return from <= today && today <= startOfDay(edition.endDate);
}
const isOver = (hackathon: NextStepHackathon, now: Date) =>
hackathon.status === "completed" ||
- startOfDay(hackathon.endDate).getTime() < startOfDay(now).getTime();
+ startOfDay(hackathon.endDate) < startOfDay(now);
const hasStarted = (hackathon: NextStepHackathon, now: Date) =>
hackathon.status === "in_progress" ||
hackathon.status === "completed" ||
- startOfDay(hackathon.startDate).getTime() <= startOfDay(now).getTime();
+ startOfDay(hackathon.startDate) <= startOfDay(now);
const ADMITTED = new Set(["approved", "checked_in"]);
@@ -270,7 +267,7 @@ export function hackathonNextStep(
step: "pass",
label: "Show your pass at check-in",
detail:
- startOfDay(hackathon.startDate).getTime() > startOfDay(now).getTime()
+ startOfDay(hackathon.startDate) > startOfDay(now)
? `The event starts ${eventDay(new Date(hackathon.startDate))}.`
: "Have it scanned at the door.",
href: tab("SCHEDULE"),
diff --git a/sites/mainweb/lib/portal-bootstrap.server.ts b/sites/mainweb/lib/portal-bootstrap.server.ts
new file mode 100644
index 00000000..3bf9665f
--- /dev/null
+++ b/sites/mainweb/lib/portal-bootstrap.server.ts
@@ -0,0 +1,96 @@
+import { cache } from "react";
+import { cookies, headers } from "next/headers";
+import { dehydrate, QueryClient } from "@tanstack/react-query";
+import type { Session } from "next-auth";
+import { appRouter, createContext, resolveClientIp } from "@query/api";
+
+/**
+ * The signed-in visitor's tRPC caller for this request, or null when there is
+ * none. Cached per request so the layout and a page's prefetch share one
+ * session lookup.
+ *
+ * A visitor with no session cookie (the login page, guest judges, the public
+ * hacklytics page) costs nothing here and mounts exactly as before.
+ */
+export const getPortalCaller = cache(async () => {
+ const jar = await cookies();
+ if (!jar.getAll().some((c) => c.name.endsWith("authjs.session-token"))) {
+ return null;
+ }
+ const ctx = await createContext({
+ clientIp: resolveClientIp((await headers()).get("x-forwarded-for")),
+ });
+ if (!ctx.session?.user) return null;
+ return { session: ctx.session, caller: appRouter.createCaller(ctx) };
+});
+
+export type PortalCaller = NonNullable<
+ Awaited>
+>["caller"];
+
+/** A prefetched read: `trpc..useQuery(input)` on the client hits this key. */
+export type Prefetch = readonly [
+ path: string,
+ input: unknown,
+ result: Promise,
+];
+
+/**
+ * Settles the reads and dehydrates the ones that succeeded. A failed read is
+ * left out, so the browser fetches it and reports the error as it always did.
+ */
+export async function dehydratePrefetched(entries: readonly Prefetch[]) {
+ const client = new QueryClient();
+ await Promise.all(
+ entries.map(async ([path, input, result]) => {
+ const data = await result.catch(() => undefined);
+ if (data === undefined) return;
+ const key =
+ input === undefined
+ ? [path.split("."), { type: "query" }]
+ : [path.split("."), { input, type: "query" }];
+ client.setQueryData(key, data);
+ }),
+ );
+ return dehydrate(client);
+}
+
+/**
+ * What every signed-in portal page needs before it can draw: who is signed in
+ * and the two reads the sidebar makes. Handed to the client providers, so the
+ * browser neither asks /api/auth/session for the session nor makes a tRPC
+ * round trip for the portal context after hydration.
+ */
+export async function loadPortalBootstrap() {
+ // A failed lookup (the database unreachable, say) must not take the whole
+ // portal down with it: fall back to the browser asking, as it did before.
+ const signedIn = await getPortalCaller().catch(() => null);
+ if (!signedIn) return { session: undefined, state: undefined };
+ const { caller } = signedIn;
+
+ const portal = await caller.user.getPortalContext().catch(() => null);
+ const entries: Prefetch[] = [];
+ if (portal) {
+ entries.push(["user.getPortalContext", undefined, Promise.resolve(portal)]);
+ if (!portal.isAdmin && !portal.isBugTester) {
+ entries.push([
+ "hackathon.myRegistrations",
+ undefined,
+ caller.hackathon.myRegistrations(),
+ ]);
+ }
+ }
+
+ // Only what the browser's own session carries, never the session token.
+ const { user, expires } = signedIn.session;
+ const session: Session = {
+ expires: expires ?? "",
+ user: {
+ id: user?.id,
+ name: user?.name,
+ email: user?.email,
+ image: user?.image,
+ } as Session["user"],
+ };
+ return { session, state: await dehydratePrefetched(entries) };
+}
diff --git a/sites/mainweb/lib/venue-time.test.ts b/sites/mainweb/lib/venue-time.test.ts
new file mode 100644
index 00000000..338603d5
--- /dev/null
+++ b/sites/mainweb/lib/venue-time.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import {
+ venueDayNumber,
+ venueFormat,
+ venueParts,
+} from "./venue-time";
+
+// 2026-11-06 03:30 UTC is still the evening of the 5th in Atlanta, the
+// morning of the 6th in Auckland and the afternoon of the 5th in Los Angeles.
+const LATE_NIGHT_UTC = new Date("2026-11-06T03:30:00Z");
+
+describe("venueFormat", () => {
+ it("prints the venue's day, not the runtime's", () => {
+ expect(venueFormat(LATE_NIGHT_UTC, { month: "short", day: "numeric" })).toBe(
+ "Nov 5",
+ );
+ });
+
+ it("defaults to the numeric en-US date", () => {
+ expect(venueFormat(LATE_NIGHT_UTC)).toBe("11/5/2026");
+ });
+
+ it("takes strings and timestamps", () => {
+ expect(venueFormat("2026-11-06T03:30:00Z")).toBe("11/5/2026");
+ expect(venueFormat(LATE_NIGHT_UTC.getTime())).toBe("11/5/2026");
+ });
+
+ it("follows daylight saving", () => {
+ const hour = { hour: "numeric", minute: "2-digit" } as const;
+ expect(venueFormat("2026-07-01T16:00:00Z", hour)).toBe("12:00 PM");
+ expect(venueFormat("2026-12-01T16:00:00Z", hour)).toBe("11:00 AM");
+ });
+});
+
+describe("venueParts", () => {
+ it("splits the venue's date", () => {
+ expect(venueParts(LATE_NIGHT_UTC)).toEqual({ year: 2026, month: 11, day: 5 });
+ });
+
+ it("rolls the year at the venue's midnight", () => {
+ expect(venueParts("2027-01-01T04:59:00Z").year).toBe(2026);
+ expect(venueParts("2027-01-01T05:00:00Z").year).toBe(2027);
+ });
+});
+
+describe("venueDayNumber", () => {
+ it("is the same all day at the venue and steps at its midnight", () => {
+ const before = venueDayNumber("2026-11-06T04:59:00Z");
+ const after = venueDayNumber("2026-11-06T05:00:00Z");
+ expect(after - before).toBe(1);
+ expect(venueDayNumber("2026-11-06T23:00:00Z")).toBe(after);
+ });
+
+ it("counts whole days across a month and a clock change", () => {
+ expect(
+ venueDayNumber("2026-11-08T12:00:00Z") -
+ venueDayNumber("2026-10-31T12:00:00Z"),
+ ).toBe(8);
+ });
+});
diff --git a/sites/mainweb/lib/venue-time.ts b/sites/mainweb/lib/venue-time.ts
new file mode 100644
index 00000000..3d998974
--- /dev/null
+++ b/sites/mainweb/lib/venue-time.ts
@@ -0,0 +1,59 @@
+// Portal pages render on the server and again in the browser. A date printed
+// or compared in the runtime's own zone comes out differently in the two (Cloud
+// Run is UTC), which is a hydration mismatch, and shows a remote hacker the
+// wrong day. Events run in Atlanta, so every date the portal prints and every
+// "is it today" test is on the venue's calendar, in one locale.
+export const VENUE_TZ = "America/New_York";
+
+// Built on first use, not on import: the first Intl.DateTimeFormat with a time
+// zone costs about 25 ms in a browser, and the portal navigation imports this.
+const formats = new Map();
+
+function formatter(options: Intl.DateTimeFormatOptions) {
+ const key = JSON.stringify(options);
+ let format = formats.get(key);
+ if (!format) {
+ format = new Intl.DateTimeFormat("en-US", { ...options, timeZone: VENUE_TZ });
+ formats.set(key, format);
+ }
+ return format;
+}
+
+type DateLike = Date | string | number;
+
+/**
+ * The date in en-US on the venue's calendar. No options gives "11/6/2026",
+ * what `toLocaleDateString()` prints for a visitor in the US.
+ */
+export function venueFormat(
+ value: DateLike,
+ options: Intl.DateTimeFormatOptions = {},
+): string {
+ return formatter(options).format(new Date(value));
+}
+
+/** Year, month (1-12) and day of the month on the venue's calendar. */
+export function venueParts(value: DateLike) {
+ const found = { year: 0, month: 0, day: 0 };
+ for (const part of formatter({
+ year: "numeric",
+ month: "numeric",
+ day: "numeric",
+ }).formatToParts(new Date(value))) {
+ if (part.type === "year" || part.type === "month" || part.type === "day") {
+ found[part.type] = Number(part.value);
+ }
+ }
+ return found;
+}
+
+const DAY_MS = 86_400_000;
+
+/**
+ * Which calendar day it is at the venue, as a whole number that goes up by one
+ * a day. Subtract to count days, compare to ask "is it the same day".
+ */
+export function venueDayNumber(value: DateLike): number {
+ const { year, month, day } = venueParts(value);
+ return Date.UTC(year, month - 1, day) / DAY_MS;
+}
diff --git a/sites/mainweb/package.json b/sites/mainweb/package.json
index c29092f1..3b7cdc59 100644
--- a/sites/mainweb/package.json
+++ b/sites/mainweb/package.json
@@ -27,6 +27,7 @@
"@trpc/server": "11.19.0",
"@yudiel/react-qr-scanner": "^2.6.0",
"archiver": "^8.0.0",
+ "babel-plugin-react-compiler": "1.0.0",
"chart.js": "^4.6.1",
"drizzle-orm": "0.45.4",
"geist": "^1.7.2",