Skip to content

Commit aaae051

Browse files
committed
feat(analytics): add live view mode to AnalyticsClient
1 parent aa80eac commit aaae051

1 file changed

Lines changed: 107 additions & 8 deletions

File tree

apps/site/components/AnalyticsClient.tsx

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import dynamic from "next/dynamic";
66
import { useCallback, useEffect, useMemo, useState } from "react";
77
import 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+
4859
type WindowOption = { label: string; seconds: number };
4960
const 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

Comments
 (0)