Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 51 additions & 27 deletions src/components/DashboardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -252,8 +264,20 @@ export default function DashboardHeader() {
coding activity at a glance
</p>
{minutesAgo !== null && (
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
<p className="mt-1 flex items-center gap-1.5 text-xs text-[var(--muted-foreground)]">
{minutesAgo <= 0 ? "Synced just now" : `Synced ${minutesAgo} min ago`}
{isHeaderLive && (
<span
title="Live β€” connected to Supabase Realtime"
className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-500"
>
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
Live
</span>
)}
</p>
)}
</div>
Expand Down Expand Up @@ -388,4 +412,4 @@ export default function DashboardHeader() {
</div>
</header>
);
}
}
51 changes: 43 additions & 8 deletions src/components/StreakTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -243,7 +244,7 @@ export function useStreakTracker() {
}
}

const currentMilestone =
const currentMilestone =
[...STREAK_MILESTONES]
.reverse()
.find(
Expand All @@ -252,7 +253,7 @@ export function useStreakTracker() {
data.current >= m &&
m > lastCelebratedMilestone
);
const shouldShowBanner =
const shouldShowBanner =
currentMilestone &&
!dismissedMilestones.includes(currentMilestone);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -361,6 +381,7 @@ export function useStreakTracker() {
export default function StreakTracker() {
const {
selectedAccount,
isStreakLive,
data,
setData,
contributionData,
Expand Down Expand Up @@ -564,16 +585,30 @@ export default function StreakTracker() {
)}
<div ref={containerRef} className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<SectionHeader title="Commit Streaks" />
<div className="flex items-center gap-2">
<SectionHeader title="Commit Streaks" />
{isStreakLive && (
<span
title="Live β€” updates automatically"
className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-500"
>
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
Live
</span>
)}
</div>
{data && <div className="h-8 w-24" />}
</div>
<div className="grid grid-cols-2 gap-3">
{stats.map((stat) => (
<div
key={stat.label}
className={`rounded-lg p-4 text-center ${stat.highlight
? "border border-[var(--accent)]/40 bg-[var(--accent-soft)]"
: "bg-[var(--control)]"
? "border border-[var(--accent)]/40 bg-[var(--accent-soft)]"
: "bg-[var(--control)]"
}`}
aria-label={stat.tooltip}
>
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -1098,4 +1133,4 @@ export function calculateMonthlyTrend(contrib: ContributionData | undefined | nu
}

return { isValid: true, thisMonth, lastMonth, text, colorClass };
}
}
162 changes: 162 additions & 0 deletions src/hooks/useRealtimeSync.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createClient> | 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<RealtimeChannel | null>(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | 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 };
}