From f642957c2d95322f34bfd2304e38f5e6e19aa587 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:29:21 +0700 Subject: [PATCH 01/12] feat: add web program countdown component --- components/arcade/program-countdown.tsx | 242 ++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 components/arcade/program-countdown.tsx diff --git a/components/arcade/program-countdown.tsx b/components/arcade/program-countdown.tsx new file mode 100644 index 000000000..1e84184e9 --- /dev/null +++ b/components/arcade/program-countdown.tsx @@ -0,0 +1,242 @@ +"use client" + +import { Clock, Gamepad2 } from "lucide-react" +import { createPortal } from "react-dom" +import { useEffect, useMemo, useState } from "react" + +const HOST_SELECTOR = ".program-countdown-host" +const HERO_SELECTOR = ".arcade-hero" +const DEFAULT_TIME_ZONE_OFFSET = "+05:30" + +type CountdownUnit = "day" | "hour" | "minute" | "second" + +type ProgramCountdownConfig = { + id: "facilitator" | "arcade" + title: string + deadline: string + enabled: boolean + tone: "orange" | "blue" +} + +type CountdownParts = { + days: number + hours: number + minutes: number + seconds: number + ended: boolean +} + +function readBoolean(value: string | undefined, fallback: boolean): boolean { + if (!value?.trim()) return fallback + + const normalized = value.trim().toLowerCase() + if (["0", "false", "off", "no"].includes(normalized)) return false + if (["1", "true", "on", "yes"].includes(normalized)) return true + return fallback +} + +function defaultSeasonDeadline(now: Date): string { + const year = now.getFullYear() + const month = now.getMonth() + 1 + const monthDay = month <= 6 ? "06-30" : "12-31" + return `${year}-${monthDay}T23:59:59${DEFAULT_TIME_ZONE_OFFSET}` +} + +function countdownParts(deadline: string, nowMs: number): CountdownParts { + const deadlineMs = new Date(deadline).getTime() + if (!Number.isFinite(deadlineMs)) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, ended: true } + } + + const diff = deadlineMs - nowMs + if (diff <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, ended: true } + } + + return { + days: Math.floor(diff / 86_400_000), + hours: Math.floor((diff % 86_400_000) / 3_600_000), + minutes: Math.floor((diff % 3_600_000) / 60_000), + seconds: Math.floor((diff % 60_000) / 1_000), + ended: false, + } +} + +function currentLocale(): string | undefined { + if (typeof document === "undefined") return undefined + return document.documentElement.lang || navigator.language || undefined +} + +function unitLabel(unit: CountdownUnit, locale?: string): string { + try { + const unitPart = new Intl.NumberFormat(locale, { + style: "unit", + unit, + unitDisplay: "long", + }) + .formatToParts(2) + .find((part) => part.type === "unit")?.value + + if (unitPart) return unitPart + } catch { + // Use the English fallback below when Intl unit formatting is unavailable. + } + + return `${unit}s` +} + +function deadlineLabel(deadline: string, locale?: string): string { + const parsed = new Date(deadline) + if (Number.isNaN(parsed.getTime())) return "—" + + try { + return new Intl.DateTimeFormat(locale, { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }).format(parsed) + } catch { + return parsed.toISOString() + } +} + +function formatPart(value: number): string { + return String(value).padStart(2, "0") +} + +function ProgramCard({ + config, + locale, + nowMs, +}: { + config: ProgramCountdownConfig + locale?: string + nowMs: number +}) { + const remaining = countdownParts(config.deadline, nowMs) + const Icon = config.id === "arcade" ? Gamepad2 : Clock + const timeParts: Array<{ unit: CountdownUnit; value: number }> = [ + { unit: "day", value: remaining.days }, + { unit: "hour", value: remaining.hours }, + { unit: "minute", value: remaining.minutes }, + { unit: "second", value: remaining.seconds }, + ] + + return ( +
+
+ +
+ {config.title} + Deadline · {deadlineLabel(config.deadline, locale)} +
+
+ +
+ {timeParts.map((part, index) => ( +
+ {index > 0 ? : null} +
+ {formatPart(part.value)} + {unitLabel(part.unit, locale)} +
+
+ ))} +
+
+ ) +} + +export default function ProgramCountdown() { + const [host, setHost] = useState(null) + const [locale, setLocale] = useState(undefined) + const [nowMs, setNowMs] = useState(() => Date.now()) + + useEffect(() => { + const hero = document.querySelector(HERO_SELECTOR) + if (!hero?.parentElement) return + + let countdownHost = document.querySelector(HOST_SELECTOR) + let created = false + + if (!countdownHost) { + countdownHost = document.createElement("div") + countdownHost.className = HOST_SELECTOR.slice(1) + hero.insertAdjacentElement("afterend", countdownHost) + created = true + } + + setHost(countdownHost) + + return () => { + if (created) countdownHost?.remove() + } + }, []) + + useEffect(() => { + const syncLocale = () => setLocale(currentLocale()) + syncLocale() + + const observer = new MutationObserver(syncLocale) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["lang", "data-locale"], + }) + + return () => observer.disconnect() + }, []) + + useEffect(() => { + const timer = window.setInterval(() => setNowMs(Date.now()), 1_000) + return () => window.clearInterval(timer) + }, []) + + const programs = useMemo(() => { + const fallbackDeadline = defaultSeasonDeadline(new Date(nowMs)) + + return [ + { + id: "facilitator", + title: "Facilitator Program", + deadline: + process.env.NEXT_PUBLIC_COUNTDOWN_DEADLINE_FACILITATOR?.trim() || + fallbackDeadline, + enabled: readBoolean( + process.env.NEXT_PUBLIC_COUNTDOWN_ENABLED_FACILITATOR, + false, + ), + tone: "orange", + }, + { + id: "arcade", + title: "Arcade", + deadline: + process.env.NEXT_PUBLIC_COUNTDOWN_DEADLINE_ARCADE?.trim() || + fallbackDeadline, + enabled: readBoolean( + process.env.NEXT_PUBLIC_COUNTDOWN_ENABLED_ARCADE, + true, + ), + tone: "blue", + }, + ].filter((program) => program.enabled) as ProgramCountdownConfig[] + }, [nowMs]) + + if (!host || programs.length === 0) return null + + return createPortal( +
+ {programs.map((program) => ( + + ))} +
, + host, + ) +} From e9c87396b28300342d0b3b102e4c5a5ab9fb2013 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:29:43 +0700 Subject: [PATCH 02/12] style: add responsive countdown cards --- app/styles/program-countdown.css | 228 +++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 app/styles/program-countdown.css diff --git a/app/styles/program-countdown.css b/app/styles/program-countdown.css new file mode 100644 index 000000000..60562d511 --- /dev/null +++ b/app/styles/program-countdown.css @@ -0,0 +1,228 @@ +.program-countdown-host{ + position:relative; + z-index:2; + width:min(1280px,calc(100% - 40px)); + margin:0 auto 18px; +} + +.program-countdown-grid{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:12px; +} + +.program-countdown-grid.is-single{ + grid-template-columns:minmax(0,1fr); +} + +.program-countdown-card{ + --countdown-accent:var(--arcade-orange); + --countdown-accent-soft:rgba(255,159,28,.18); + --countdown-border:rgba(255,159,28,.30); + display:grid; + grid-template-columns:minmax(190px,.85fr) minmax(340px,1.15fr); + align-items:center; + gap:18px; + min-height:112px; + padding:16px 18px; + border:1px solid var(--countdown-border); + border-radius:14px; + background: + radial-gradient(circle at 10% 10%,var(--countdown-accent-soft),transparent 52%), + linear-gradient(145deg,rgba(12,23,48,.94),rgba(7,14,31,.96)); + box-shadow:0 20px 55px rgba(0,0,0,.20),inset 0 1px 0 rgba(255,255,255,.025); +} + +.program-countdown-card.tone-blue{ + --countdown-accent:var(--arcade-cyan); + --countdown-accent-soft:rgba(34,211,238,.14); + --countdown-border:rgba(34,211,238,.26); +} + +.program-countdown-card.is-ended{ + opacity:.72; +} + +.program-countdown-heading{ + display:flex; + align-items:center; + gap:12px; + min-width:0; +} + +.program-countdown-icon{ + display:grid; + place-items:center; + flex:0 0 auto; + width:42px; + height:42px; + border:1px solid var(--countdown-border); + border-radius:11px; + background:var(--countdown-accent-soft); + color:var(--countdown-accent); +} + +.program-countdown-icon svg{ + width:21px; + height:21px; +} + +.program-countdown-heading>div{ + min-width:0; + display:grid; + gap:5px; +} + +.program-countdown-heading strong{ + color:#f7f9ff; + font-size:.83rem; + font-weight:800; + letter-spacing:.02em; +} + +.program-countdown-heading span{ + overflow:hidden; + color:var(--arcade-muted); + font-size:.68rem; + line-height:1.45; + text-overflow:ellipsis; + white-space:nowrap; +} + +.program-countdown-heading span b{ + color:var(--countdown-accent); + font-weight:700; +} + +.program-countdown-timer{ + display:flex; + align-items:center; + justify-content:flex-end; + gap:6px; + min-width:0; +} + +.program-countdown-part-wrap{ + display:flex; + align-items:center; + gap:6px; + min-width:0; +} + +.program-countdown-part{ + display:grid; + place-items:center; + min-width:66px; + padding:9px 8px 8px; + border:1px solid rgba(148,163,184,.14); + border-radius:10px; + background:rgba(3,10,25,.52); +} + +.program-countdown-part strong{ + color:var(--countdown-accent); + font-variant-numeric:tabular-nums; + font-size:1.08rem; + line-height:1; +} + +.program-countdown-part span{ + max-width:62px; + margin-top:5px; + overflow:hidden; + color:#899ab6; + font-size:.61rem; + line-height:1.1; + text-overflow:ellipsis; + white-space:nowrap; +} + +.program-countdown-separator{ + color:var(--countdown-accent); + font-size:.9rem; + font-weight:800; + opacity:.7; +} + +.light .program-countdown-card{ + background: + radial-gradient(circle at 10% 10%,var(--countdown-accent-soft),transparent 52%), + linear-gradient(145deg,rgba(255,255,255,.98),rgba(246,248,255,.98)); + box-shadow:0 14px 35px rgba(37,51,82,.10); +} + +.light .program-countdown-heading strong{ + color:#172039; +} + +.light .program-countdown-heading span{ + color:#66718a; +} + +.light .program-countdown-part{ + border-color:rgba(100,116,139,.18); + background:rgba(255,255,255,.72); +} + +.light .program-countdown-part span{ + color:#66718a; +} + +@media (max-width:1000px){ + .program-countdown-grid{ + grid-template-columns:1fr; + } +} + +@media (max-width:720px){ + .program-countdown-host{ + width:min(100% - 24px,1280px); + margin-bottom:14px; + } + + .program-countdown-card{ + grid-template-columns:1fr; + gap:13px; + min-height:0; + padding:14px; + } + + .program-countdown-timer{ + justify-content:stretch; + gap:4px; + } + + .program-countdown-part-wrap{ + flex:1 1 0; + gap:4px; + } + + .program-countdown-part{ + flex:1 1 0; + min-width:0; + padding:9px 4px 8px; + } + + .program-countdown-part strong{ + font-size:1rem; + } + + .program-countdown-part span{ + max-width:48px; + font-size:.56rem; + } +} + +@media (max-width:390px){ + .program-countdown-heading span{ + white-space:normal; + } + + .program-countdown-part strong{ + font-size:.92rem; + } + + .program-countdown-separator{ + font-size:.75rem; + } +} From ee6c01f6a93b8dee39ed126cdb89e9b79800b77e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:30:04 +0700 Subject: [PATCH 03/12] style: load countdown styles --- app/layout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/layout.tsx b/app/layout.tsx index 644580c1b..06fbbba03 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,6 +11,7 @@ import "./styles/redesign-dashboard.css" import "./styles/redesign-results.css" import "./styles/redesign-components.css" import "./styles/redesign-responsive.css" +import "./styles/program-countdown.css" import "./styles/monthly-games.css" import "./styles/monthly-games-mobile-deadline.css" import "./styles/facilitator-panel.css" From 0871ee75b8c4278b7546a8723fb253200ada9a5b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:30:14 +0700 Subject: [PATCH 04/12] feat: mount countdown on default homepage --- app/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/page.tsx b/app/page.tsx index fd9f5ea6d..b8bcc8d2e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import FacilitatorAnalyzerOption from "@/components/arcade/facilitator-analyzer- import FacilitatorPanelGate from "@/components/arcade/facilitator-panel-gate" import FreshScoreCheckEnhancer from "@/components/arcade/fresh-score-check-enhancer" import MonthlyGamesPanelGate from "@/components/arcade/monthly-games-panel-gate" +import ProgramCountdown from "@/components/arcade/program-countdown" import ShareProfileEnhancer from "@/components/arcade/share-profile-enhancer" import TierStatusIconEnhancer from "@/components/arcade/tier-status-icon-enhancer" import ArcadeRouteLinks from "@/components/app/arcade-route-links" @@ -29,6 +30,7 @@ export default function Page() { return ( <> + From 6feb95d8f8cf5b7b6bea9bf2e0b5bb6d14e84a1d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:30:29 +0700 Subject: [PATCH 05/12] feat: mount countdown on localized homepages --- app/[locale]/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index bd6ab2cb6..0e1608e17 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -7,6 +7,7 @@ import FacilitatorAnalyzerOption from "@/components/arcade/facilitator-analyzer- import FacilitatorPanelGate from "@/components/arcade/facilitator-panel-gate" import FreshScoreCheckEnhancer from "@/components/arcade/fresh-score-check-enhancer" import MonthlyGamesPanelGate from "@/components/arcade/monthly-games-panel-gate" +import ProgramCountdown from "@/components/arcade/program-countdown" import ShareProfileEnhancer from "@/components/arcade/share-profile-enhancer" import TierStatusIconEnhancer from "@/components/arcade/tier-status-icon-enhancer" import ArcadeRouteLinks from "@/components/app/arcade-route-links" @@ -83,6 +84,7 @@ export default async function LocalizedPage({ params }: LocalizedPageProps) { <>

{title}

{description}

+ From b70b245a7877d45141c158d24a30635ff3a30527 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:31:00 +0700 Subject: [PATCH 06/12] test: cover program countdown integration --- tests/program-countdown.test.mjs | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/program-countdown.test.mjs diff --git a/tests/program-countdown.test.mjs b/tests/program-countdown.test.mjs new file mode 100644 index 000000000..06921ec97 --- /dev/null +++ b/tests/program-countdown.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" + +const countdown = readFileSync( + new URL("../components/arcade/program-countdown.tsx", import.meta.url), + "utf8", +) +const styles = readFileSync( + new URL("../app/styles/program-countdown.css", import.meta.url), + "utf8", +) +const layout = readFileSync(new URL("../app/layout.tsx", import.meta.url), "utf8") +const page = readFileSync(new URL("../app/page.tsx", import.meta.url), "utf8") +const localizedPage = readFileSync( + new URL("../app/[locale]/page.tsx", import.meta.url), + "utf8", +) + +test("program countdown is mounted on default and localized homepages", () => { + assert.match(page, /import ProgramCountdown/) + assert.match(page, //) + assert.match(localizedPage, /import ProgramCountdown/) + assert.match(localizedPage, //) +}) + +test("program countdown follows extension-style seasonal defaults and supports overrides", () => { + assert.match(countdown, /month <= 6 \? "06-30" : "12-31"/) + assert.match(countdown, /DEFAULT_TIME_ZONE_OFFSET = "\+05:30"/) + assert.match(countdown, /NEXT_PUBLIC_COUNTDOWN_DEADLINE_FACILITATOR/) + assert.match(countdown, /NEXT_PUBLIC_COUNTDOWN_ENABLED_FACILITATOR/) + assert.match(countdown, /NEXT_PUBLIC_COUNTDOWN_DEADLINE_ARCADE/) + assert.match(countdown, /NEXT_PUBLIC_COUNTDOWN_ENABLED_ARCADE/) + assert.match(countdown, /setInterval\(\(\) => setNowMs\(Date\.now\(\)\), 1_000\)/) +}) + +test("program countdown uses localized Intl unit labels and a responsive stylesheet", () => { + assert.match(countdown, /new Intl\.NumberFormat\(locale/) + assert.match(countdown, /formatToParts\(2\)/) + assert.match(countdown, /new Intl\.DateTimeFormat\(locale/) + assert.match(countdown, /hero\.insertAdjacentElement\("afterend", countdownHost\)/) + assert.match(layout, /\.\/styles\/program-countdown\.css/) + assert.match(styles, /\.program-countdown-grid/) + assert.match(styles, /@media \(max-width:720px\)/) + assert.match(styles, /\.light \.program-countdown-card/) +}) From 85556646b2cad1d8c369aef929bed06c30cc576c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:32:55 +0700 Subject: [PATCH 07/12] fix: stack dual countdown cards before they overflow --- app/styles/program-countdown.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/styles/program-countdown.css b/app/styles/program-countdown.css index 60562d511..85cc75eea 100644 --- a/app/styles/program-countdown.css +++ b/app/styles/program-countdown.css @@ -168,7 +168,7 @@ color:#66718a; } -@media (max-width:1000px){ +@media (max-width:1160px){ .program-countdown-grid{ grid-template-columns:1fr; } From d3abfd35ce6d0ab6db87a4480fb18e8819894d46 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:40:29 +0700 Subject: [PATCH 08/12] feat: expose extension-compatible countdown config --- next.config.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/next.config.mjs b/next.config.mjs index e87fc3965..a942de12f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,6 +5,26 @@ import path from "node:path" const isStaticExport = process.env.NEXT_STATIC_EXPORT === "true" const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "" +const publicRuntimeEnvKeys = [ + "WXT_FIREBASE_API_KEY", + "WXT_FIREBASE_AUTH_DOMAIN", + "WXT_FIREBASE_PROJECT_ID", + "WXT_FIREBASE_STORAGE_BUCKET", + "WXT_FIREBASE_MESSAGING_SENDER_ID", + "WXT_FIREBASE_APP_ID", + "WXT_FIREBASE_FETCH_INTERVAL_MS", + "WXT_FIREBASE_FETCH_TIMEOUT_MS", + "WXT_COUNTDOWN_DEADLINE_FACILITATOR", + "WXT_COUNTDOWN_ENABLED_FACILITATOR", + "WXT_COUNTDOWN_DEADLINE_ARCADE", + "WXT_COUNTDOWN_ENABLED_ARCADE", + "WXT_FORCE_REMOTE_CONFIG", +] + +const publicRuntimeEnv = Object.fromEntries( + publicRuntimeEnvKeys.map((key) => [key, process.env[key] ?? ""]), +) + const nextConfig = { ...(isStaticExport ? { @@ -13,6 +33,7 @@ const nextConfig = { basePath, } : {}), + env: publicRuntimeEnv, images: { unoptimized: true, }, From 5134a4aee1f5521f9a30708901c63fe89f1735de Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:41:06 +0700 Subject: [PATCH 09/12] feat: sync countdown with Firebase Remote Config --- components/arcade/program-countdown.tsx | 272 +++++++++++++++++++++--- 1 file changed, 238 insertions(+), 34 deletions(-) diff --git a/components/arcade/program-countdown.tsx b/components/arcade/program-countdown.tsx index 1e84184e9..dbfc19b90 100644 --- a/components/arcade/program-countdown.tsx +++ b/components/arcade/program-countdown.tsx @@ -7,8 +7,12 @@ import { useEffect, useMemo, useState } from "react" const HOST_SELECTOR = ".program-countdown-host" const HERO_SELECTOR = ".arcade-hero" const DEFAULT_TIME_ZONE_OFFSET = "+05:30" +const FIREBASE_CDN_VERSION = "12.18.0" +const DEFAULT_FETCH_INTERVAL_MS = 900_000 +const DEFAULT_FETCH_TIMEOUT_MS = 60_000 type CountdownUnit = "day" | "hour" | "minute" | "second" +type CountdownSource = "fallback" | "remote" type ProgramCountdownConfig = { id: "facilitator" | "arcade" @@ -26,6 +30,41 @@ type CountdownParts = { ended: boolean } +type FirebaseClientConfig = { + apiKey: string + authDomain: string + projectId: string + storageBucket: string + messagingSenderId: string + appId: string +} + +type FirebaseAppModule = { + initializeApp: (config: FirebaseClientConfig) => unknown + getApps: () => unknown[] + getApp: () => unknown +} + +type RemoteConfigValue = { + asString: () => string + asBoolean: () => boolean + getSource?: () => string +} + +type RemoteConfigInstance = { + defaultConfig: Record + settings: { + minimumFetchIntervalMillis: number + fetchTimeoutMillis: number + } +} + +type FirebaseRemoteConfigModule = { + getRemoteConfig: (app: unknown) => RemoteConfigInstance + fetchAndActivate: (remoteConfig: RemoteConfigInstance) => Promise + getValue: (remoteConfig: RemoteConfigInstance, key: string) => RemoteConfigValue +} + function readBoolean(value: string | undefined, fallback: boolean): boolean { if (!value?.trim()) return fallback @@ -35,6 +74,11 @@ function readBoolean(value: string | undefined, fallback: boolean): boolean { return fallback } +function readDuration(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value ?? "", 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} + function defaultSeasonDeadline(now: Date): string { const year = now.getFullYear() const month = now.getMonth() + 1 @@ -42,6 +86,169 @@ function defaultSeasonDeadline(now: Date): string { return `${year}-${monthDay}T23:59:59${DEFAULT_TIME_ZONE_OFFSET}` } +function defaultPrograms(): ProgramCountdownConfig[] { + const fallbackDeadline = defaultSeasonDeadline(new Date()) + + return [ + { + id: "facilitator", + title: "Facilitator Program", + deadline: + process.env.WXT_COUNTDOWN_DEADLINE_FACILITATOR?.trim() || + fallbackDeadline, + enabled: readBoolean( + process.env.WXT_COUNTDOWN_ENABLED_FACILITATOR, + true, + ), + tone: "orange", + }, + { + id: "arcade", + title: "Arcade", + deadline: + process.env.WXT_COUNTDOWN_DEADLINE_ARCADE?.trim() || fallbackDeadline, + enabled: readBoolean(process.env.WXT_COUNTDOWN_ENABLED_ARCADE, true), + tone: "blue", + }, + ] +} + +function firebaseConfig(): FirebaseClientConfig { + return { + apiKey: process.env.WXT_FIREBASE_API_KEY?.trim() || "", + authDomain: process.env.WXT_FIREBASE_AUTH_DOMAIN?.trim() || "", + projectId: process.env.WXT_FIREBASE_PROJECT_ID?.trim() || "", + storageBucket: process.env.WXT_FIREBASE_STORAGE_BUCKET?.trim() || "", + messagingSenderId: + process.env.WXT_FIREBASE_MESSAGING_SENDER_ID?.trim() || "", + appId: process.env.WXT_FIREBASE_APP_ID?.trim() || "", + } +} + +function isLocalEnvironment(): boolean { + const host = window.location.hostname + const localHost = host === "localhost" || host === "127.0.0.1" + const development = process.env.NODE_ENV === "development" || localHost + const forceRemote = readBoolean(process.env.WXT_FORCE_REMOTE_CONFIG, false) + return development && !forceRemote +} + +async function importBrowserModule(url: string): Promise { + return import(/* webpackIgnore: true */ url) as Promise +} + +function remoteString( + remoteModule: FirebaseRemoteConfigModule, + remoteConfig: RemoteConfigInstance, + key: string, + fallback: string, +): string { + const value = remoteModule.getValue(remoteConfig, key) + const source = value.getSource?.() + const remoteValue = value.asString() + return source === "remote" && remoteValue ? remoteValue : fallback +} + +function remoteBoolean( + remoteModule: FirebaseRemoteConfigModule, + remoteConfig: RemoteConfigInstance, + key: string, + fallback: boolean, +): boolean { + const value = remoteModule.getValue(remoteConfig, key) + return value.getSource?.() === "remote" ? value.asBoolean() : fallback +} + +async function loadRemotePrograms( + fallbackPrograms: ProgramCountdownConfig[], +): Promise<{ programs: ProgramCountdownConfig[]; source: CountdownSource }> { + if (isLocalEnvironment()) { + return { programs: fallbackPrograms, source: "fallback" } + } + + const config = firebaseConfig() + if (!config.apiKey || !config.projectId) { + return { programs: fallbackPrograms, source: "fallback" } + } + + try { + const [appModule, remoteModule] = await Promise.all([ + importBrowserModule( + `https://www.gstatic.com/firebasejs/${FIREBASE_CDN_VERSION}/firebase-app.js`, + ), + importBrowserModule( + `https://www.gstatic.com/firebasejs/${FIREBASE_CDN_VERSION}/firebase-remote-config.js`, + ), + ]) + + const app = + appModule.getApps().length > 0 + ? appModule.getApp() + : appModule.initializeApp(config) + const remoteConfig = remoteModule.getRemoteConfig(app) + const facilitator = fallbackPrograms.find((item) => item.id === "facilitator")! + const arcade = fallbackPrograms.find((item) => item.id === "arcade")! + + remoteConfig.defaultConfig = { + countdown_deadline_facilitator: facilitator.deadline, + countdown_enabled_facilitator: facilitator.enabled, + countdown_deadline_arcade: arcade.deadline, + countdown_enabled_arcade: arcade.enabled, + } + remoteConfig.settings = { + minimumFetchIntervalMillis: readDuration( + process.env.WXT_FIREBASE_FETCH_INTERVAL_MS, + DEFAULT_FETCH_INTERVAL_MS, + ), + fetchTimeoutMillis: readDuration( + process.env.WXT_FIREBASE_FETCH_TIMEOUT_MS, + DEFAULT_FETCH_TIMEOUT_MS, + ), + } + + await remoteModule.fetchAndActivate(remoteConfig) + + return { + source: "remote", + programs: [ + { + ...facilitator, + deadline: remoteString( + remoteModule, + remoteConfig, + "countdown_deadline_facilitator", + facilitator.deadline, + ), + enabled: remoteBoolean( + remoteModule, + remoteConfig, + "countdown_enabled_facilitator", + facilitator.enabled, + ), + }, + { + ...arcade, + deadline: remoteString( + remoteModule, + remoteConfig, + "countdown_deadline_arcade", + arcade.deadline, + ), + enabled: remoteBoolean( + remoteModule, + remoteConfig, + "countdown_enabled_arcade", + arcade.enabled, + ), + }, + ], + } + } catch (error) { + console.warn("Program countdown Remote Config unavailable; using defaults.", error) + return { programs: fallbackPrograms, source: "fallback" } + } +} + function countdownParts(deadline: string, nowMs: number): CountdownParts { const deadlineMs = new Date(deadline).getTime() if (!Number.isFinite(deadlineMs)) { @@ -158,6 +365,8 @@ export default function ProgramCountdown() { const [host, setHost] = useState(null) const [locale, setLocale] = useState(undefined) const [nowMs, setNowMs] = useState(() => Date.now()) + const [programs, setPrograms] = useState([]) + const [source, setSource] = useState("fallback") useEffect(() => { const hero = document.querySelector(HERO_SELECTOR) @@ -198,42 +407,37 @@ export default function ProgramCountdown() { return () => window.clearInterval(timer) }, []) - const programs = useMemo(() => { - const fallbackDeadline = defaultSeasonDeadline(new Date(nowMs)) - - return [ - { - id: "facilitator", - title: "Facilitator Program", - deadline: - process.env.NEXT_PUBLIC_COUNTDOWN_DEADLINE_FACILITATOR?.trim() || - fallbackDeadline, - enabled: readBoolean( - process.env.NEXT_PUBLIC_COUNTDOWN_ENABLED_FACILITATOR, - false, - ), - tone: "orange", - }, - { - id: "arcade", - title: "Arcade", - deadline: - process.env.NEXT_PUBLIC_COUNTDOWN_DEADLINE_ARCADE?.trim() || - fallbackDeadline, - enabled: readBoolean( - process.env.NEXT_PUBLIC_COUNTDOWN_ENABLED_ARCADE, - true, - ), - tone: "blue", - }, - ].filter((program) => program.enabled) as ProgramCountdownConfig[] - }, [nowMs]) - - if (!host || programs.length === 0) return null + useEffect(() => { + let active = true + const fallbackPrograms = defaultPrograms() + setPrograms(fallbackPrograms) + setSource("fallback") + + void loadRemotePrograms(fallbackPrograms).then((resolved) => { + if (!active) return + setPrograms(resolved.programs) + setSource(resolved.source) + }) + + return () => { + active = false + } + }, []) + + const enabledPrograms = useMemo( + () => programs.filter((program) => program.enabled), + [programs], + ) + + if (!host || enabledPrograms.length === 0) return null return createPortal( -
- {programs.map((program) => ( +
+ {enabledPrograms.map((program) => ( ))}
, From cc798db51dc4b5d15595082c70578b568d23ee0d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:41:30 +0700 Subject: [PATCH 10/12] ci: inject Firebase countdown config into production build --- .github/workflows/nextjs.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml index d17d61988..6e78a89f3 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/nextjs.yml @@ -43,6 +43,19 @@ jobs: NEXT_PUBLIC_BASE_PATH: "" NEXT_PUBLIC_ENABLE_ANALYTICS: "true" NEXT_PUBLIC_GA_ID: "G-SJNYKL2864" + WXT_FIREBASE_API_KEY: ${{ secrets.WXT_FIREBASE_API_KEY || vars.WXT_FIREBASE_API_KEY }} + WXT_FIREBASE_AUTH_DOMAIN: ${{ secrets.WXT_FIREBASE_AUTH_DOMAIN || vars.WXT_FIREBASE_AUTH_DOMAIN }} + WXT_FIREBASE_PROJECT_ID: ${{ secrets.WXT_FIREBASE_PROJECT_ID || vars.WXT_FIREBASE_PROJECT_ID }} + WXT_FIREBASE_STORAGE_BUCKET: ${{ secrets.WXT_FIREBASE_STORAGE_BUCKET || vars.WXT_FIREBASE_STORAGE_BUCKET }} + WXT_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.WXT_FIREBASE_MESSAGING_SENDER_ID || vars.WXT_FIREBASE_MESSAGING_SENDER_ID }} + WXT_FIREBASE_APP_ID: ${{ secrets.WXT_FIREBASE_APP_ID || vars.WXT_FIREBASE_APP_ID }} + WXT_FIREBASE_FETCH_INTERVAL_MS: ${{ secrets.WXT_FIREBASE_FETCH_INTERVAL_MS || vars.WXT_FIREBASE_FETCH_INTERVAL_MS }} + WXT_FIREBASE_FETCH_TIMEOUT_MS: ${{ secrets.WXT_FIREBASE_FETCH_TIMEOUT_MS || vars.WXT_FIREBASE_FETCH_TIMEOUT_MS }} + WXT_COUNTDOWN_DEADLINE_FACILITATOR: ${{ secrets.WXT_COUNTDOWN_DEADLINE_FACILITATOR || vars.WXT_COUNTDOWN_DEADLINE_FACILITATOR }} + WXT_COUNTDOWN_ENABLED_FACILITATOR: ${{ secrets.WXT_COUNTDOWN_ENABLED_FACILITATOR || vars.WXT_COUNTDOWN_ENABLED_FACILITATOR }} + WXT_COUNTDOWN_DEADLINE_ARCADE: ${{ secrets.WXT_COUNTDOWN_DEADLINE_ARCADE || vars.WXT_COUNTDOWN_DEADLINE_ARCADE }} + WXT_COUNTDOWN_ENABLED_ARCADE: ${{ secrets.WXT_COUNTDOWN_ENABLED_ARCADE || vars.WXT_COUNTDOWN_ENABLED_ARCADE }} + WXT_FORCE_REMOTE_CONFIG: ${{ secrets.WXT_FORCE_REMOTE_CONFIG || vars.WXT_FORCE_REMOTE_CONFIG }} run: yarn build - name: Verify production asset paths and analytics From 259b2809ecc86276282dbe15b1b4211a271c47cc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:41:51 +0700 Subject: [PATCH 11/12] test: cover Firebase Remote Config countdown sync --- tests/program-countdown.test.mjs | 43 ++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/program-countdown.test.mjs b/tests/program-countdown.test.mjs index 06921ec97..61add30a8 100644 --- a/tests/program-countdown.test.mjs +++ b/tests/program-countdown.test.mjs @@ -11,6 +11,7 @@ const styles = readFileSync( "utf8", ) const layout = readFileSync(new URL("../app/layout.tsx", import.meta.url), "utf8") +const nextConfig = readFileSync(new URL("../next.config.mjs", import.meta.url), "utf8") const page = readFileSync(new URL("../app/page.tsx", import.meta.url), "utf8") const localizedPage = readFileSync( new URL("../app/[locale]/page.tsx", import.meta.url), @@ -24,16 +25,48 @@ test("program countdown is mounted on default and localized homepages", () => { assert.match(localizedPage, //) }) -test("program countdown follows extension-style seasonal defaults and supports overrides", () => { +test("program countdown uses the same WXT defaults and seasonal fallback as the extension", () => { assert.match(countdown, /month <= 6 \? "06-30" : "12-31"/) assert.match(countdown, /DEFAULT_TIME_ZONE_OFFSET = "\+05:30"/) - assert.match(countdown, /NEXT_PUBLIC_COUNTDOWN_DEADLINE_FACILITATOR/) - assert.match(countdown, /NEXT_PUBLIC_COUNTDOWN_ENABLED_FACILITATOR/) - assert.match(countdown, /NEXT_PUBLIC_COUNTDOWN_DEADLINE_ARCADE/) - assert.match(countdown, /NEXT_PUBLIC_COUNTDOWN_ENABLED_ARCADE/) + assert.match(countdown, /WXT_COUNTDOWN_DEADLINE_FACILITATOR/) + assert.match(countdown, /WXT_COUNTDOWN_ENABLED_FACILITATOR/) + assert.match(countdown, /WXT_COUNTDOWN_DEADLINE_ARCADE/) + assert.match(countdown, /WXT_COUNTDOWN_ENABLED_ARCADE/) assert.match(countdown, /setInterval\(\(\) => setNowMs\(Date\.now\(\)\), 1_000\)/) }) +test("program countdown fetches Firebase Remote Config and accepts only remote values", () => { + assert.match(countdown, /firebase-app\.js/) + assert.match(countdown, /firebase-remote-config\.js/) + assert.match(countdown, /fetchAndActivate\(remoteConfig\)/) + assert.match(countdown, /getValue: \(remoteConfig: RemoteConfigInstance, key: string\)/) + assert.match(countdown, /value\.getSource\?\.\(\) === "remote"/) + assert.match(countdown, /source === "remote" && remoteValue/) + assert.match(countdown, /minimumFetchIntervalMillis/) + assert.match(countdown, /fetchTimeoutMillis/) + assert.match(countdown, /WXT_FORCE_REMOTE_CONFIG/) +}) + +test("Next exposes the extension-compatible WXT Firebase settings to the browser build", () => { + for (const key of [ + "WXT_FIREBASE_API_KEY", + "WXT_FIREBASE_AUTH_DOMAIN", + "WXT_FIREBASE_PROJECT_ID", + "WXT_FIREBASE_STORAGE_BUCKET", + "WXT_FIREBASE_MESSAGING_SENDER_ID", + "WXT_FIREBASE_APP_ID", + "WXT_FIREBASE_FETCH_INTERVAL_MS", + "WXT_FIREBASE_FETCH_TIMEOUT_MS", + "WXT_COUNTDOWN_DEADLINE_FACILITATOR", + "WXT_COUNTDOWN_ENABLED_FACILITATOR", + "WXT_COUNTDOWN_DEADLINE_ARCADE", + "WXT_COUNTDOWN_ENABLED_ARCADE", + ]) { + assert.match(nextConfig, new RegExp(key)) + } + assert.match(nextConfig, /env: publicRuntimeEnv/) +}) + test("program countdown uses localized Intl unit labels and a responsive stylesheet", () => { assert.match(countdown, /new Intl\.NumberFormat\(locale/) assert.match(countdown, /formatToParts\(2\)/) From 021e1e15e4bb96c0fad2c6097b19da318c308dbb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 11 Sep 2026 17:42:29 +0700 Subject: [PATCH 12/12] ci: inject Firebase countdown config into PR preview --- .github/workflows/pr-preview.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index ad5adcd68..98978279f 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -112,6 +112,19 @@ jobs: NEXT_PUBLIC_ENABLE_ANALYTICS: "false" NEXT_PUBLIC_GA_ID: "" NEXT_PUBLIC_COOKIE_CONSENT_PREVIEW: "true" + WXT_FIREBASE_API_KEY: ${{ secrets.WXT_FIREBASE_API_KEY || vars.WXT_FIREBASE_API_KEY }} + WXT_FIREBASE_AUTH_DOMAIN: ${{ secrets.WXT_FIREBASE_AUTH_DOMAIN || vars.WXT_FIREBASE_AUTH_DOMAIN }} + WXT_FIREBASE_PROJECT_ID: ${{ secrets.WXT_FIREBASE_PROJECT_ID || vars.WXT_FIREBASE_PROJECT_ID }} + WXT_FIREBASE_STORAGE_BUCKET: ${{ secrets.WXT_FIREBASE_STORAGE_BUCKET || vars.WXT_FIREBASE_STORAGE_BUCKET }} + WXT_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.WXT_FIREBASE_MESSAGING_SENDER_ID || vars.WXT_FIREBASE_MESSAGING_SENDER_ID }} + WXT_FIREBASE_APP_ID: ${{ secrets.WXT_FIREBASE_APP_ID || vars.WXT_FIREBASE_APP_ID }} + WXT_FIREBASE_FETCH_INTERVAL_MS: ${{ secrets.WXT_FIREBASE_FETCH_INTERVAL_MS || vars.WXT_FIREBASE_FETCH_INTERVAL_MS }} + WXT_FIREBASE_FETCH_TIMEOUT_MS: ${{ secrets.WXT_FIREBASE_FETCH_TIMEOUT_MS || vars.WXT_FIREBASE_FETCH_TIMEOUT_MS }} + WXT_COUNTDOWN_DEADLINE_FACILITATOR: ${{ secrets.WXT_COUNTDOWN_DEADLINE_FACILITATOR || vars.WXT_COUNTDOWN_DEADLINE_FACILITATOR }} + WXT_COUNTDOWN_ENABLED_FACILITATOR: ${{ secrets.WXT_COUNTDOWN_ENABLED_FACILITATOR || vars.WXT_COUNTDOWN_ENABLED_FACILITATOR }} + WXT_COUNTDOWN_DEADLINE_ARCADE: ${{ secrets.WXT_COUNTDOWN_DEADLINE_ARCADE || vars.WXT_COUNTDOWN_DEADLINE_ARCADE }} + WXT_COUNTDOWN_ENABLED_ARCADE: ${{ secrets.WXT_COUNTDOWN_ENABLED_ARCADE || vars.WXT_COUNTDOWN_ENABLED_ARCADE }} + WXT_FORCE_REMOTE_CONFIG: ${{ secrets.WXT_FORCE_REMOTE_CONFIG || vars.WXT_FORCE_REMOTE_CONFIG }} run: | yarn install --frozen-lockfile --non-interactive yarn build