From 890e4716e22e60d229d19231827fb673f2842244 Mon Sep 17 00:00:00 2001 From: Boddepalli naveen <1234naveenboddepalli@gmail.com> Date: Sun, 7 Jun 2026 20:24:32 +0530 Subject: [PATCH] Real-time dashboard --- src/components/DashboardHeader.tsx | 78 +++++++++----- src/components/StreakTracker.tsx | 51 +++++++-- src/hooks/useRealtimeSync.ts | 162 +++++++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 35 deletions(-) create mode 100644 src/hooks/useRealtimeSync.ts diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index ac6ec6e5..c5f507d9 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -18,6 +18,7 @@ import UserAvatar from "@/components/UserAvatar"; import KeyboardShortcuts from "@/components/KeyboardShortcuts"; import { Moon, Sun } from "lucide-react"; import { toast } from "sonner"; +import { useRealtimeSync } from "@/hooks/useRealtimeSync"; type DashboardSyncContextValue = { lastSynced: Date | null; @@ -110,6 +111,42 @@ export default function DashboardHeader() { setGreeting(computeCurrentGreeting()); }, []); + // Extracted to useCallback so useRealtimeSync can call it as a stable reference. + const loadSettings = useCallback(async () => { + if (!session) { + setIsPublic(null); + return; + } + try { + const res = await fetch("/api/user/settings"); + if (res.ok) { + const data = await res.json(); + setIsPublic(data.is_public === true); + } else { + setIsPublic(false); + } + } catch (error) { + console.error("Failed to load settings:", error); + setIsPublic(false); + } + }, [session]); + + useEffect(() => { + loadSettings(); + }, [loadSettings]); + + // ------------------------------------------------------------------------- + // Realtime: re-fetch user settings whenever the `users` row changes + // (e.g. is_public toggled in another tab). Falls back to 60-second polling. + // NOTE: enable Realtime for the `users` table in Supabase and ensure the + // anon role has a SELECT policy, or provide a user-scoped filter once a + // Supabase JWT is available in the session. + // ------------------------------------------------------------------------- + const { isLive: isHeaderLive } = useRealtimeSync( + "users", + ["UPDATE"], + loadSettings, + ); useEffect(() => { if (!session?.githubLogin) return; @@ -159,31 +196,6 @@ export default function DashboardHeader() { const { lastSynced } = useDashboardSync(); const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - if (!session) { - setIsPublic(null); - return; - } - - async function loadSettings() { - try { - const res = await fetch("/api/user/settings"); - - if (res.ok) { - const data = await res.json(); - setIsPublic(data.is_public === true); - } else { - setIsPublic(false); - } - } catch (error) { - console.error("Failed to load settings:", error); - setIsPublic(false); - } - } - - loadSettings(); - }, [session]); - // Extract a fallback username parameter from active session data strings const displayName = session?.user?.name || session?.githubLogin || "Developer"; useEffect(() => { @@ -252,8 +264,20 @@ export default function DashboardHeader() { coding activity at a glance

{minutesAgo !== null && ( -

+

{minutesAgo <= 0 ? "Synced just now" : `Synced ${minutesAgo} min ago`} + {isHeaderLive && ( + + + + + + Live + + )}

)} @@ -388,4 +412,4 @@ export default function DashboardHeader() { ); -} +} \ No newline at end of file diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index 9f681b27..4ec4751a 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -2,6 +2,7 @@ import SectionHeader from "./SectionHeader"; import { useCallback, useEffect, useState, useRef } from "react"; import { useAccount } from "@/components/AccountContext"; +import { useRealtimeSync } from "@/hooks/useRealtimeSync"; import { useCountUp } from "@/hooks/useCountUp"; import StreakMilestoneBanner from "@/components/StreakMilestoneBanner"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; @@ -243,7 +244,7 @@ export function useStreakTracker() { } } - const currentMilestone = + const currentMilestone = [...STREAK_MILESTONES] .reverse() .find( @@ -252,7 +253,7 @@ export function useStreakTracker() { data.current >= m && m > lastCelebratedMilestone ); - const shouldShowBanner = + const shouldShowBanner = currentMilestone && !dismissedMilestones.includes(currentMilestone); @@ -310,8 +311,27 @@ export function useStreakTracker() { } }; + // ------------------------------------------------------------------------- + // Realtime: re-fetch when streak_freezes rows change in Supabase. + // Falls back to 60-second polling if the WebSocket cannot connect. + // NOTE: enable Realtime for the `streak_freezes` table in the Supabase + // dashboard and ensure the anon role has a SELECT policy (or use a + // user-scoped filter once a Supabase JWT is available in the session). + // ------------------------------------------------------------------------- + const handleRealtimeFreeze = useCallback(() => { + fetchFreeze(); + fetchStreak(); + }, [fetchFreeze, fetchStreak]); + + const { isLive: isStreakLive } = useRealtimeSync( + "streak_freezes", + ["INSERT", "DELETE"], + handleRealtimeFreeze, + ); + return { selectedAccount, + isStreakLive, data, setData, contributionData, @@ -361,6 +381,7 @@ export function useStreakTracker() { export default function StreakTracker() { const { selectedAccount, + isStreakLive, data, setData, contributionData, @@ -564,7 +585,21 @@ export default function StreakTracker() { )}
- +
+ + {isStreakLive && ( + + + + + + Live + + )} +
{data &&
}
@@ -572,8 +607,8 @@ export default function StreakTracker() {
@@ -749,8 +784,8 @@ export default function StreakTracker() { onClick={handleApplyFreeze} disabled={freezeLoading || freeze?.hasFreeze} className={`rounded-md px-3 py-1 text-xs font-medium transition ${freezeLoading || freeze?.hasFreeze - ? "cursor-not-allowed opacity-50 bg-[var(--accent)]" - : "bg-[var(--accent)] hover:opacity-90" + ? "cursor-not-allowed opacity-50 bg-[var(--accent)]" + : "bg-[var(--accent)] hover:opacity-90" } text-[var(--accent-foreground)]`} > {freeze?.hasFreeze ? "Freeze Active" : "Freeze Streak"} @@ -1098,4 +1133,4 @@ export function calculateMonthlyTrend(contrib: ContributionData | undefined | nu } return { isValid: true, thisMonth, lastMonth, text, colorClass }; -} +} \ No newline at end of file diff --git a/src/hooks/useRealtimeSync.ts b/src/hooks/useRealtimeSync.ts new file mode 100644 index 00000000..eb4c54d7 --- /dev/null +++ b/src/hooks/useRealtimeSync.ts @@ -0,0 +1,162 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createClient, type RealtimeChannel } from "@supabase/supabase-js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type RealtimeEvent = "INSERT" | "UPDATE" | "DELETE" | "*"; + +export interface UseRealtimeSyncOptions { + /** + * Row-level filter forwarded to Supabase Realtime, e.g. `"user_id=eq.abc-123"`. + * Requires the table's RLS policies to allow reads for the anon role, or a + * matching Realtime policy granting SELECT for the subscribing role. + */ + filter?: string; + /** Postgres schema to watch. Defaults to `"public"`. */ + schema?: string; + /** + * Polling interval (ms) used as a graceful fallback when the WebSocket + * connection cannot be established or drops. Defaults to `60_000` ms. + */ + fallbackPollingMs?: number; +} + +export interface UseRealtimeSyncResult { + /** `true` while the Supabase Realtime WebSocket subscription is active. */ + isLive: boolean; +} + +// --------------------------------------------------------------------------- +// Lazy singleton client — one per browser tab, shared across hook instances +// --------------------------------------------------------------------------- + +let _client: ReturnType | null = null; + +function getSupabaseClient() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ""; + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""; + if (!url || !key) return null; + if (!_client) _client = createClient(url, key); + return _client; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** + * Subscribes to Postgres row-change events on a Supabase table and calls + * `onUpdate` whenever a matching event fires. + * + * Gracefully falls back to a `fallbackPollingMs` polling interval when the + * WebSocket connection is unavailable or drops. + * + * @example + * const { isLive } = useRealtimeSync( + * "goals", + * ["INSERT", "UPDATE", "DELETE"], + * loadGoals, + * { filter: `user_id=eq.${userId}` }, + * ); + */ +export function useRealtimeSync( + table: string, + events: RealtimeEvent[], + onUpdate: () => void, + options: UseRealtimeSyncOptions = {}, +): UseRealtimeSyncResult { + const { filter, schema = "public", fallbackPollingMs = 60_000 } = options; + + const [isLive, setIsLive] = useState(false); + const channelRef = useRef(null); + const pollingRef = useRef | null>(null); + + // Keep callback ref fresh so subscription handlers never close over stale state. + const onUpdateRef = useRef(onUpdate); + useEffect(() => { + onUpdateRef.current = onUpdate; + }); + + const startPolling = useCallback(() => { + if (pollingRef.current !== null) return; + pollingRef.current = setInterval(() => { + onUpdateRef.current(); + }, fallbackPollingMs); + }, [fallbackPollingMs]); + + const stopPolling = useCallback(() => { + if (pollingRef.current !== null) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }, []); + + // Stabilise `events` so an inline array definition (e.g. `["INSERT", "DELETE"]`) + // doesn't cause the effect to re-run on every render. + const eventsKey = [...events].sort().join(","); + + useEffect(() => { + const supabase = getSupabaseClient(); + + if (!supabase) { + // Supabase env vars not configured — degrade gracefully to polling only. + startPolling(); + return () => stopPolling(); + } + + const channelName = `devtrack_${table}_${Math.random().toString(36).slice(2)}`; + let channel = supabase.channel(channelName); + + for (const event of eventsKey.split(",") as RealtimeEvent[]) { + channel = channel.on( + // @ts-expect-error — "postgres_changes" is a valid literal accepted by the SDK + "postgres_changes", + { + event, + schema, + table, + ...(filter ? { filter } : {}), + }, + () => { + onUpdateRef.current(); + }, + ); + } + + channel.subscribe((status) => { + switch (status) { + case "SUBSCRIBED": + setIsLive(true); + stopPolling(); + break; + case "CLOSED": + case "CHANNEL_ERROR": + case "TIMED_OUT": + setIsLive(false); + startPolling(); + break; + default: + break; + } + }); + + channelRef.current = channel; + + return () => { + setIsLive(false); + stopPolling(); + // Non-blocking cleanup — safe to ignore the returned promise + supabase.removeChannel(channel).catch(() => undefined); + channelRef.current = null; + }; + // eventsKey replaces the `events` array in the dep list to avoid + // unnecessary re-subscriptions when the caller passes an inline literal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [table, schema, filter, eventsKey, startPolling, stopPolling]); + + return { isLive }; +} \ No newline at end of file