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
+
+
+
+
+
+
+
+
+
+
+
+ )
+}