@@ -6,6 +6,8 @@ import dynamic from "next/dynamic";
66import { useCallback , useEffect , useMemo , useState } from "react" ;
77import type {
88 AnalyticsBreakdownRow ,
9+ AnalyticsLive ,
10+ AnalyticsLiveResponse ,
911 AnalyticsOverview ,
1012 AnalyticsOverviewResponse ,
1113 AnalyticsTimeSeriesPoint ,
@@ -45,6 +47,15 @@ const OverviewChart = dynamic(() => import("./analytics/OverviewChart"), {
4547 loading : ( ) => < div className = "h-[300px] w-full animate-pulse rounded-lg bg-bg-sunken/40" /> ,
4648} ) ;
4749
50+ const LiveView = dynamic ( ( ) => import ( "./analytics/LiveView" ) , {
51+ ssr : false ,
52+ loading : ( ) => < div className = "h-[480px] w-full animate-pulse rounded-2xl bg-bg-sunken/40" /> ,
53+ } ) ;
54+
55+ type ViewMode = "overview" | "live" ;
56+ const LIVE_POLL_MS = 20_000 ;
57+ const OVERVIEW_POLL_MS = 30_000 ;
58+
4859type WindowOption = { label : string ; seconds : number } ;
4960const WINDOW_OPTIONS : WindowOption [ ] = [
5061 { label : "24h" , seconds : 24 * 60 * 60 } ,
@@ -111,9 +122,12 @@ export default function AnalyticsClient({
111122 initialError ?: string ;
112123} ) {
113124 const [ analytics , setAnalytics ] = useState ( initialAnalytics ) ;
125+ const [ live , setLive ] = useState < AnalyticsLive | null > ( null ) ;
126+ const [ viewMode , setViewMode ] = useState < ViewMode > ( "overview" ) ;
114127 const [ windowSeconds , setWindowSeconds ] = useState ( WINDOW_OPTIONS [ 0 ] . seconds ) ;
115128 const [ granularity , setGranularity ] = useState < Granularity > ( defaultGranularity ( WINDOW_OPTIONS [ 0 ] . seconds ) ) ;
116129 const [ loading , setLoading ] = useState ( false ) ;
130+ const [ liveLoading , setLiveLoading ] = useState ( false ) ;
117131 const [ error , setError ] = useState ( initialError ) ;
118132
119133 const refresh = useCallback ( async ( nextWindowSeconds : number ) => {
@@ -139,22 +153,66 @@ export default function AnalyticsClient({
139153 void refresh ( windowSeconds ) ;
140154 } , [ refresh , windowSeconds ] ) ;
141155
156+ const refreshLive = useCallback ( async ( ) => {
157+ setLiveLoading ( true ) ;
158+ setError ( "" ) ;
159+ try {
160+ const response = await fetch ( "/api/analytics/live" , { cache : "no-store" } ) ;
161+ const body = ( await response . json ( ) ) as AnalyticsLiveResponse | { message ?: string } ;
162+ if ( ! response . ok || ! ( "live" in body ) ) {
163+ throw new Error ( "message" in body && body . message ? body . message : "Live analytics refresh failed" ) ;
164+ }
165+ setLive ( body . live ) ;
166+ } catch ( refreshError ) {
167+ setError ( refreshError instanceof Error ? refreshError . message : "Live analytics refresh failed" ) ;
168+ } finally {
169+ setLiveLoading ( false ) ;
170+ }
171+ } , [ ] ) ;
172+
142173 useEffect ( ( ) => {
174+ if ( viewMode !== "overview" ) return ;
143175 const onFocus = ( ) => refreshCurrentWindow ( ) ;
144176 const onVisibilityChange = ( ) => {
145177 if ( ! document . hidden ) refreshCurrentWindow ( ) ;
146178 } ;
147- const interval = window . setInterval ( refreshCurrentWindow , 30_000 ) ;
179+ const interval = window . setInterval ( refreshCurrentWindow , OVERVIEW_POLL_MS ) ;
148180 window . addEventListener ( "focus" , onFocus ) ;
149181 document . addEventListener ( "visibilitychange" , onVisibilityChange ) ;
150182 return ( ) => {
151183 window . clearInterval ( interval ) ;
152184 window . removeEventListener ( "focus" , onFocus ) ;
153185 document . removeEventListener ( "visibilitychange" , onVisibilityChange ) ;
154186 } ;
155- } , [ refreshCurrentWindow ] ) ;
187+ } , [ refreshCurrentWindow , viewMode ] ) ;
188+
189+ useEffect ( ( ) => {
190+ if ( viewMode !== "live" ) return ;
191+ void refreshLive ( ) ;
192+ const onFocus = ( ) => refreshLive ( ) ;
193+ const onVisibilityChange = ( ) => {
194+ if ( ! document . hidden ) refreshLive ( ) ;
195+ } ;
196+ const interval = window . setInterval ( ( ) => {
197+ if ( ! document . hidden ) refreshLive ( ) ;
198+ } , LIVE_POLL_MS ) ;
199+ window . addEventListener ( "focus" , onFocus ) ;
200+ document . addEventListener ( "visibilitychange" , onVisibilityChange ) ;
201+ return ( ) => {
202+ window . clearInterval ( interval ) ;
203+ window . removeEventListener ( "focus" , onFocus ) ;
204+ document . removeEventListener ( "visibilitychange" , onVisibilityChange ) ;
205+ } ;
206+ } , [ refreshLive , viewMode ] ) ;
207+
208+ function selectViewMode ( nextMode : ViewMode ) {
209+ setViewMode ( nextMode ) ;
210+ setError ( "" ) ;
211+ if ( nextMode === "live" && ! live ) void refreshLive ( ) ;
212+ }
156213
157214 function selectWindow ( nextWindowSeconds : number ) {
215+ setViewMode ( "overview" ) ;
158216 setWindowSeconds ( nextWindowSeconds ) ;
159217 setGranularity ( defaultGranularity ( nextWindowSeconds ) ) ;
160218 void refresh ( nextWindowSeconds ) ;
@@ -179,14 +237,36 @@ export default function AnalyticsClient({
179237 const actions = (
180238 < >
181239 < div className = "hidden items-center gap-1 rounded-lg border border-line bg-card p-1 sm:flex" >
240+ < button
241+ type = "button"
242+ onClick = { ( ) => selectViewMode ( "live" ) }
243+ className = { cn (
244+ "flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[12.5px] font-medium transition-colors" ,
245+ viewMode === "live"
246+ ? "bg-[#0c1117] text-white shadow-[0_0_0_1px_rgba(20,184,166,0.35)]"
247+ : "text-muted hover:bg-bg-sunken hover:text-ink"
248+ ) }
249+ >
250+ < span className = "relative flex size-1.5" >
251+ { viewMode === "live" ? (
252+ < >
253+ < span className = "absolute inline-flex size-full animate-ping rounded-full bg-[#14b8a6] opacity-50 motion-reduce:animate-none" />
254+ < span className = "relative inline-flex size-1.5 rounded-full bg-[#14b8a6]" />
255+ </ >
256+ ) : (
257+ < span className = "inline-flex size-1.5 rounded-full bg-[#14b8a6]/70" />
258+ ) }
259+ </ span >
260+ Live
261+ </ button >
182262 { WINDOW_OPTIONS . map ( ( option ) => (
183263 < button
184264 key = { option . seconds }
185265 type = "button"
186266 onClick = { ( ) => selectWindow ( option . seconds ) }
187267 className = { cn (
188268 "rounded-md px-2.5 py-1.5 text-[12.5px] font-medium transition-colors" ,
189- windowSeconds === option . seconds
269+ viewMode === "overview" && windowSeconds === option . seconds
190270 ? "bg-ink text-bg"
191271 : "text-muted hover:bg-bg-sunken hover:text-ink"
192272 ) }
@@ -195,9 +275,15 @@ export default function AnalyticsClient({
195275 </ button >
196276 ) ) }
197277 </ div >
198- < Button variant = "secondary" size = "sm" className = "rounded-md" onClick = { refreshCurrentWindow } disabled = { loading } >
199- < RefreshCw className = { cn ( "size-3.5" , loading && "animate-spin" ) } />
200- { loading ? "Refreshing" : "Refresh" }
278+ < Button
279+ variant = "secondary"
280+ size = "sm"
281+ className = "rounded-md"
282+ onClick = { viewMode === "live" ? refreshLive : refreshCurrentWindow }
283+ disabled = { viewMode === "live" ? liveLoading : loading }
284+ >
285+ < RefreshCw className = { cn ( "size-3.5" , ( viewMode === "live" ? liveLoading : loading ) && "animate-spin" ) } />
286+ { viewMode === "live" ? ( liveLoading ? "Syncing" : "Sync" ) : loading ? "Refreshing" : "Refresh" }
201287 </ Button >
202288 </ >
203289 ) ;
@@ -296,13 +382,26 @@ export default function AnalyticsClient({
296382 < SubHeader title = "Analytics" docsHref = "/docs" docsLabel = "Analytics API" actions = { actions } />
297383 < DashboardContent >
298384 { error ? < Callout tone = "danger" > { error } </ Callout > : null }
299- { ! analytics && ! error ? (
385+
386+ { viewMode === "live" ? (
387+ live ? (
388+ < LiveView live = { live } loading = { liveLoading } />
389+ ) : liveLoading ? (
390+ < div className = "h-[480px] animate-pulse rounded-2xl bg-bg-sunken/40" />
391+ ) : (
392+ < Panel >
393+ < p className = "text-[13.5px] text-muted" > No live analytics available yet.</ p >
394+ </ Panel >
395+ )
396+ ) : null }
397+
398+ { viewMode === "overview" && ! analytics && ! error ? (
300399 < Panel >
301400 < p className = "text-[13.5px] text-muted" > No analytics available yet.</ p >
302401 </ Panel >
303402 ) : null }
304403
305- { analytics ? (
404+ { viewMode === "overview" && analytics ? (
306405 < div className = "flex flex-col gap-4" >
307406 < StatStrip items = { statItems } />
308407
0 commit comments