Skip to content

Commit 121c068

Browse files
authored
Simplify Freebuff landing live stats (#691)
1 parent a0d9c90 commit 121c068

4 files changed

Lines changed: 260 additions & 52 deletions

File tree

freebuff/web/src/app/home-client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { CopyButton } from '@/components/copy-button'
1313
import { HeroGrid } from '@/components/hero-grid'
1414
import { Icons } from '@/components/icons'
1515
import { cn } from '@/lib/utils'
16-
import { CompactLiveStats } from './live/live-client'
16+
import { HomepageLiveStats } from './live/live-summary'
1717

1818
const INSTALL_COMMAND = 'npm install -g freebuff'
1919

@@ -569,7 +569,7 @@ export default function HomeClient() {
569569
</div>
570570
</div>
571571

572-
<CompactLiveStats />
572+
<HomepageLiveStats />
573573
</div>
574574
)
575575
}

freebuff/web/src/app/live/live-client.tsx

Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,18 @@ import { useEffect, useState } from 'react'
99
import { CopyButton } from '@/components/copy-button'
1010
import { cn } from '@/lib/utils'
1111

12+
import {
13+
EMPTY_LIVE_STATS,
14+
countryName,
15+
useLiveStats,
16+
} from './live-stats-client'
1217
import { COUNTRY_POINTS, WORLD_LAND_PATHS } from './world-map-data'
1318

1419
import type { FreebuffLiveStats } from '@/server/live-stats'
1520
import type { LucideIcon } from 'lucide-react'
1621

1722
const INSTALL_COMMAND = 'npm install -g freebuff'
18-
const POLL_MS = 60_000
1923
const MAP_SIZE = { width: 1000, height: 520 }
20-
const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' })
21-
const EMPTY_LIVE_STATS: FreebuffLiveStats = {
22-
totalLiveUsers: 0,
23-
countries: [],
24-
models: [],
25-
generatedAt: '1970-01-01T00:00:00.000Z',
26-
}
2724
type CountryPoint = readonly [lat: number, lon: number]
2825
type PlottedCountry = FreebuffLiveStats['countries'][number] & {
2926
point: CountryPoint
@@ -47,14 +44,6 @@ const SETUP_STEPS = [
4744
'freebuff',
4845
]
4946

50-
function countryName(code: string): string {
51-
if (code === 'UNKNOWN') {
52-
return 'Unknown'
53-
}
54-
55-
return /^[A-Z]{2}$/.test(code) ? (REGION_NAMES.of(code) ?? code) : code
56-
}
57-
5847
function formattedTime(iso: string): string {
5948
return new Intl.DateTimeFormat(undefined, {
6049
hour: 'numeric',
@@ -113,40 +102,6 @@ function isPlottedCountry(
113102
return country !== null
114103
}
115104

116-
function useLiveStats(
117-
initialStats: FreebuffLiveStats,
118-
options: { refreshOnMount?: boolean } = {},
119-
) {
120-
const [stats, setStats] = useState(initialStats)
121-
122-
useEffect(() => {
123-
let isMounted = true
124-
125-
async function refresh() {
126-
try {
127-
const response = await fetch('/api/live', { cache: 'no-store' })
128-
if (response.ok && isMounted) {
129-
setStats((await response.json()) as FreebuffLiveStats)
130-
}
131-
} catch {
132-
// Keep the previous snapshot if a transient refresh fails.
133-
}
134-
}
135-
136-
if (options.refreshOnMount) {
137-
void refresh()
138-
}
139-
140-
const interval = window.setInterval(refresh, POLL_MS)
141-
return () => {
142-
isMounted = false
143-
window.clearInterval(interval)
144-
}
145-
}, [options.refreshOnMount])
146-
147-
return stats
148-
}
149-
150105
function LiveUsersHero({ value }: { value: number }) {
151106
return (
152107
<div className="relative overflow-hidden rounded-lg border border-acid-matrix/35 bg-[radial-gradient(circle_at_20%_20%,rgba(124,255,63,0.22),transparent_34%),linear-gradient(135deg,rgba(124,255,63,0.12),rgba(34,211,238,0.06)_48%,rgba(255,255,255,0.04))] p-5 shadow-[0_0_55px_rgba(124,255,63,0.16),inset_0_1px_0_rgba(255,255,255,0.12)] md:min-w-[310px] md:p-6">
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
5+
import type { FreebuffLiveStats } from '@/server/live-stats'
6+
7+
const POLL_MS = 60_000
8+
const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' })
9+
10+
export const EMPTY_LIVE_STATS: FreebuffLiveStats = {
11+
totalLiveUsers: 0,
12+
countries: [],
13+
models: [],
14+
generatedAt: '1970-01-01T00:00:00.000Z',
15+
}
16+
17+
export function countryName(code: string): string {
18+
if (code === 'UNKNOWN') {
19+
return 'Unknown'
20+
}
21+
22+
return /^[A-Z]{2}$/.test(code) ? (REGION_NAMES.of(code) ?? code) : code
23+
}
24+
25+
export function useLiveStats(
26+
initialStats: FreebuffLiveStats,
27+
options: {
28+
enabled?: boolean
29+
pauseWhenHidden?: boolean
30+
refreshOnMount?: boolean
31+
} = {},
32+
) {
33+
const {
34+
enabled = true,
35+
pauseWhenHidden = false,
36+
refreshOnMount = false,
37+
} = options
38+
const [stats, setStats] = useState(initialStats)
39+
40+
useEffect(() => {
41+
if (!enabled) {
42+
return
43+
}
44+
45+
let isMounted = true
46+
47+
async function refresh() {
48+
if (pauseWhenHidden && document.visibilityState === 'hidden') {
49+
return
50+
}
51+
52+
try {
53+
const response = await fetch('/api/live', { cache: 'no-store' })
54+
if (response.ok && isMounted) {
55+
setStats((await response.json()) as FreebuffLiveStats)
56+
}
57+
} catch {
58+
// Keep the previous snapshot if a transient refresh fails.
59+
}
60+
}
61+
62+
if (refreshOnMount) {
63+
void refresh()
64+
}
65+
66+
const interval = window.setInterval(refresh, POLL_MS)
67+
const refreshWhenVisible = () => {
68+
if (document.visibilityState === 'visible') {
69+
void refresh()
70+
}
71+
}
72+
73+
if (pauseWhenHidden) {
74+
document.addEventListener('visibilitychange', refreshWhenVisible)
75+
}
76+
77+
return () => {
78+
isMounted = false
79+
window.clearInterval(interval)
80+
if (pauseWhenHidden) {
81+
document.removeEventListener('visibilitychange', refreshWhenVisible)
82+
}
83+
}
84+
}, [enabled, pauseWhenHidden, refreshOnMount])
85+
86+
return stats
87+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
'use client'
2+
3+
import { ArrowRight, Cpu, Globe2 } from 'lucide-react'
4+
import Link from 'next/link'
5+
import { useEffect, useRef, useState } from 'react'
6+
7+
import {
8+
EMPTY_LIVE_STATS,
9+
countryName,
10+
useLiveStats,
11+
} from './live-stats-client'
12+
13+
import type { FreebuffLiveStats } from '@/server/live-stats'
14+
import type { LucideIcon } from 'lucide-react'
15+
16+
function useHomepageLiveStats(initialStats: FreebuffLiveStats) {
17+
const [isVisible, setIsVisible] = useState(false)
18+
const sectionRef = useRef<HTMLElement>(null)
19+
const stats = useLiveStats(initialStats, {
20+
enabled: isVisible,
21+
pauseWhenHidden: true,
22+
refreshOnMount: true,
23+
})
24+
25+
useEffect(() => {
26+
const section = sectionRef.current
27+
if (!section || !('IntersectionObserver' in window)) {
28+
setIsVisible(true)
29+
return
30+
}
31+
32+
const observer = new IntersectionObserver(
33+
([entry]) => setIsVisible(entry.isIntersecting),
34+
{ rootMargin: '240px 0px', threshold: 0.01 },
35+
)
36+
37+
observer.observe(section)
38+
return () => observer.disconnect()
39+
}, [])
40+
41+
return { sectionRef, stats }
42+
}
43+
44+
function LiveRows({
45+
title,
46+
icon: Icon,
47+
rows,
48+
emptyLabel,
49+
}: {
50+
title: string
51+
icon: LucideIcon
52+
rows: { label: string; value: number; sublabel?: string }[]
53+
emptyLabel: string
54+
}) {
55+
return (
56+
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-4">
57+
<div className="mb-4 flex items-center justify-between gap-3">
58+
<h3 className="font-mono text-xs uppercase tracking-[0.18em] text-white/46">
59+
{title}
60+
</h3>
61+
<Icon className="h-4 w-4 text-cyan-300" aria-hidden />
62+
</div>
63+
{rows.length > 0 ? (
64+
<div className="space-y-2">
65+
{rows.map((row) => (
66+
<div
67+
key={`${row.label}-${row.sublabel ?? ''}`}
68+
className="flex items-center justify-between gap-3 rounded-md bg-black/25 px-3 py-2"
69+
>
70+
<div className="min-w-0">
71+
<div className="truncate text-sm font-medium text-white/86">
72+
{row.label}
73+
</div>
74+
{row.sublabel && (
75+
<div className="font-mono text-[11px] text-white/36">
76+
{row.sublabel}
77+
</div>
78+
)}
79+
</div>
80+
<div className="font-mono text-base text-acid-matrix">
81+
{row.value.toLocaleString()}
82+
</div>
83+
</div>
84+
))}
85+
</div>
86+
) : (
87+
<div className="rounded-md border border-dashed border-white/12 bg-black/20 px-3 py-5 text-center text-sm text-white/45">
88+
{emptyLabel}
89+
</div>
90+
)}
91+
</div>
92+
)
93+
}
94+
95+
export function HomepageLiveStats({
96+
initialStats = EMPTY_LIVE_STATS,
97+
}: {
98+
initialStats?: FreebuffLiveStats
99+
}) {
100+
const { sectionRef, stats } = useHomepageLiveStats(initialStats)
101+
const isLoading = stats.generatedAt === EMPTY_LIVE_STATS.generatedAt
102+
const topCountries = stats.countries.slice(0, 4).map((country) => ({
103+
label: countryName(country.countryCode),
104+
sublabel: country.countryCode,
105+
value: country.count,
106+
}))
107+
const topModels = stats.models.slice(0, 4).map((model) => ({
108+
label: model.displayName,
109+
value: model.count,
110+
}))
111+
const countryEmptyLabel = isLoading
112+
? 'Loading active countries...'
113+
: 'No active countries yet.'
114+
const modelEmptyLabel = isLoading
115+
? 'Loading active models...'
116+
: 'No active models right now.'
117+
118+
return (
119+
<section
120+
ref={sectionRef}
121+
className="relative overflow-hidden bg-black py-14 md:py-20"
122+
>
123+
<div className="absolute inset-0 bg-[linear-gradient(rgba(124,255,63,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.035)_1px,transparent_1px)] bg-[size:56px_56px]" />
124+
<div className="relative container mx-auto px-4">
125+
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-end">
126+
<div>
127+
<div className="flex items-center gap-3">
128+
<span className="h-2.5 w-2.5 rounded-full bg-acid-matrix shadow-[0_0_20px_rgba(124,255,63,0.9)]" />
129+
<span className="font-mono text-xs uppercase tracking-[0.22em] text-white/48">
130+
Active users
131+
</span>
132+
</div>
133+
<div className="mt-3 font-mono text-6xl font-medium leading-none text-acid-matrix neon-text md:text-8xl">
134+
{isLoading ? '...' : stats.totalLiveUsers.toLocaleString()}
135+
</div>
136+
<p className="mt-4 max-w-md text-sm leading-6 text-white/52 md:text-base">
137+
Active Freebuff sessions right now, grouped by country and model.
138+
</p>
139+
<Link
140+
href="/live"
141+
className="mt-6 inline-flex items-center gap-2 rounded-md border border-acid-matrix/45 bg-acid-matrix/10 px-4 py-2 text-sm font-medium text-acid-matrix transition-colors hover:bg-acid-matrix/15"
142+
>
143+
<span>View live map</span>
144+
<ArrowRight className="h-4 w-4" aria-hidden />
145+
</Link>
146+
</div>
147+
148+
<div className="grid gap-4 md:grid-cols-2">
149+
<LiveRows
150+
title="Top countries"
151+
icon={Globe2}
152+
rows={topCountries}
153+
emptyLabel={countryEmptyLabel}
154+
/>
155+
<LiveRows
156+
title="Models"
157+
icon={Cpu}
158+
rows={topModels}
159+
emptyLabel={modelEmptyLabel}
160+
/>
161+
</div>
162+
</div>
163+
</div>
164+
</section>
165+
)
166+
}

0 commit comments

Comments
 (0)