diff --git a/freebuff/web/src/app/home-client.tsx b/freebuff/web/src/app/home-client.tsx index 6b076688d..9a468316a 100644 --- a/freebuff/web/src/app/home-client.tsx +++ b/freebuff/web/src/app/home-client.tsx @@ -13,7 +13,7 @@ import { CopyButton } from '@/components/copy-button' import { HeroGrid } from '@/components/hero-grid' import { Icons } from '@/components/icons' import { cn } from '@/lib/utils' -import { CompactLiveStats } from './live/live-client' +import { HomepageLiveStats } from './live/live-summary' const INSTALL_COMMAND = 'npm install -g freebuff' @@ -569,7 +569,7 @@ export default function HomeClient() { - + ) } diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx index 2bf3995ee..6a8cf3c72 100644 --- a/freebuff/web/src/app/live/live-client.tsx +++ b/freebuff/web/src/app/live/live-client.tsx @@ -9,21 +9,18 @@ import { useEffect, useState } from 'react' import { CopyButton } from '@/components/copy-button' import { cn } from '@/lib/utils' +import { + EMPTY_LIVE_STATS, + countryName, + useLiveStats, +} from './live-stats-client' import { COUNTRY_POINTS, WORLD_LAND_PATHS } from './world-map-data' import type { FreebuffLiveStats } from '@/server/live-stats' import type { LucideIcon } from 'lucide-react' const INSTALL_COMMAND = 'npm install -g freebuff' -const POLL_MS = 60_000 const MAP_SIZE = { width: 1000, height: 520 } -const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' }) -const EMPTY_LIVE_STATS: FreebuffLiveStats = { - totalLiveUsers: 0, - countries: [], - models: [], - generatedAt: '1970-01-01T00:00:00.000Z', -} type CountryPoint = readonly [lat: number, lon: number] type PlottedCountry = FreebuffLiveStats['countries'][number] & { point: CountryPoint @@ -47,14 +44,6 @@ const SETUP_STEPS = [ 'freebuff', ] -function countryName(code: string): string { - if (code === 'UNKNOWN') { - return 'Unknown' - } - - return /^[A-Z]{2}$/.test(code) ? (REGION_NAMES.of(code) ?? code) : code -} - function formattedTime(iso: string): string { return new Intl.DateTimeFormat(undefined, { hour: 'numeric', @@ -113,40 +102,6 @@ function isPlottedCountry( return country !== null } -function useLiveStats( - initialStats: FreebuffLiveStats, - options: { refreshOnMount?: boolean } = {}, -) { - const [stats, setStats] = useState(initialStats) - - useEffect(() => { - let isMounted = true - - async function refresh() { - try { - const response = await fetch('/api/live', { cache: 'no-store' }) - if (response.ok && isMounted) { - setStats((await response.json()) as FreebuffLiveStats) - } - } catch { - // Keep the previous snapshot if a transient refresh fails. - } - } - - if (options.refreshOnMount) { - void refresh() - } - - const interval = window.setInterval(refresh, POLL_MS) - return () => { - isMounted = false - window.clearInterval(interval) - } - }, [options.refreshOnMount]) - - return stats -} - function LiveUsersHero({ value }: { value: number }) { return (
diff --git a/freebuff/web/src/app/live/live-stats-client.ts b/freebuff/web/src/app/live/live-stats-client.ts new file mode 100644 index 000000000..95969a06c --- /dev/null +++ b/freebuff/web/src/app/live/live-stats-client.ts @@ -0,0 +1,87 @@ +'use client' + +import { useEffect, useState } from 'react' + +import type { FreebuffLiveStats } from '@/server/live-stats' + +const POLL_MS = 60_000 +const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' }) + +export const EMPTY_LIVE_STATS: FreebuffLiveStats = { + totalLiveUsers: 0, + countries: [], + models: [], + generatedAt: '1970-01-01T00:00:00.000Z', +} + +export function countryName(code: string): string { + if (code === 'UNKNOWN') { + return 'Unknown' + } + + return /^[A-Z]{2}$/.test(code) ? (REGION_NAMES.of(code) ?? code) : code +} + +export function useLiveStats( + initialStats: FreebuffLiveStats, + options: { + enabled?: boolean + pauseWhenHidden?: boolean + refreshOnMount?: boolean + } = {}, +) { + const { + enabled = true, + pauseWhenHidden = false, + refreshOnMount = false, + } = options + const [stats, setStats] = useState(initialStats) + + useEffect(() => { + if (!enabled) { + return + } + + let isMounted = true + + async function refresh() { + if (pauseWhenHidden && document.visibilityState === 'hidden') { + return + } + + try { + const response = await fetch('/api/live', { cache: 'no-store' }) + if (response.ok && isMounted) { + setStats((await response.json()) as FreebuffLiveStats) + } + } catch { + // Keep the previous snapshot if a transient refresh fails. + } + } + + if (refreshOnMount) { + void refresh() + } + + const interval = window.setInterval(refresh, POLL_MS) + const refreshWhenVisible = () => { + if (document.visibilityState === 'visible') { + void refresh() + } + } + + if (pauseWhenHidden) { + document.addEventListener('visibilitychange', refreshWhenVisible) + } + + return () => { + isMounted = false + window.clearInterval(interval) + if (pauseWhenHidden) { + document.removeEventListener('visibilitychange', refreshWhenVisible) + } + } + }, [enabled, pauseWhenHidden, refreshOnMount]) + + return stats +} diff --git a/freebuff/web/src/app/live/live-summary.tsx b/freebuff/web/src/app/live/live-summary.tsx new file mode 100644 index 000000000..6e64adbcd --- /dev/null +++ b/freebuff/web/src/app/live/live-summary.tsx @@ -0,0 +1,166 @@ +'use client' + +import { ArrowRight, Cpu, Globe2 } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useRef, useState } from 'react' + +import { + EMPTY_LIVE_STATS, + countryName, + useLiveStats, +} from './live-stats-client' + +import type { FreebuffLiveStats } from '@/server/live-stats' +import type { LucideIcon } from 'lucide-react' + +function useHomepageLiveStats(initialStats: FreebuffLiveStats) { + const [isVisible, setIsVisible] = useState(false) + const sectionRef = useRef(null) + const stats = useLiveStats(initialStats, { + enabled: isVisible, + pauseWhenHidden: true, + refreshOnMount: true, + }) + + useEffect(() => { + const section = sectionRef.current + if (!section || !('IntersectionObserver' in window)) { + setIsVisible(true) + return + } + + const observer = new IntersectionObserver( + ([entry]) => setIsVisible(entry.isIntersecting), + { rootMargin: '240px 0px', threshold: 0.01 }, + ) + + observer.observe(section) + return () => observer.disconnect() + }, []) + + return { sectionRef, stats } +} + +function LiveRows({ + title, + icon: Icon, + rows, + emptyLabel, +}: { + title: string + icon: LucideIcon + rows: { label: string; value: number; sublabel?: string }[] + emptyLabel: string +}) { + return ( +
+
+

+ {title} +

+ +
+ {rows.length > 0 ? ( +
+ {rows.map((row) => ( +
+
+
+ {row.label} +
+ {row.sublabel && ( +
+ {row.sublabel} +
+ )} +
+
+ {row.value.toLocaleString()} +
+
+ ))} +
+ ) : ( +
+ {emptyLabel} +
+ )} +
+ ) +} + +export function HomepageLiveStats({ + initialStats = EMPTY_LIVE_STATS, +}: { + initialStats?: FreebuffLiveStats +}) { + const { sectionRef, stats } = useHomepageLiveStats(initialStats) + const isLoading = stats.generatedAt === EMPTY_LIVE_STATS.generatedAt + const topCountries = stats.countries.slice(0, 4).map((country) => ({ + label: countryName(country.countryCode), + sublabel: country.countryCode, + value: country.count, + })) + const topModels = stats.models.slice(0, 4).map((model) => ({ + label: model.displayName, + value: model.count, + })) + const countryEmptyLabel = isLoading + ? 'Loading active countries...' + : 'No active countries yet.' + const modelEmptyLabel = isLoading + ? 'Loading active models...' + : 'No active models right now.' + + return ( +
+
+
+
+
+
+ + + Active users + +
+
+ {isLoading ? '...' : stats.totalLiveUsers.toLocaleString()} +
+

+ Active Freebuff sessions right now, grouped by country and model. +

+ + View live map + + +
+ +
+ + +
+
+
+
+ ) +}