refactor: Update NotificationPopup and use-notifications for improved UI and performance

- Removed Badge component from NotificationPopup and replaced it with status icons for better visual representation.
- Refactored getStatusBadge to getStatusIcon for clarity and updated icon sizes.
- Enhanced useNotifications hook to utilize a ref for initialization tracking, improving performance and preventing unnecessary re-initializations.
- Optimized notification fetching logic with improved error handling and real-time updates using Electric SQL.
- Adjusted loading state management to ensure UI responsiveness during data fetching.
This commit is contained in:
Anish Sarkar 2026-01-13 00:06:21 +05:30
parent 93d17b51f5
commit 44605749c5
3 changed files with 192 additions and 180 deletions

View file

@ -4,7 +4,6 @@ import { Bell, Check, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "luc
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import type { Notification } from "@/hooks/use-notifications";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
@ -40,34 +39,18 @@ export function NotificationPopup({
}
};
const getStatusBadge = (notification: Notification) => {
const getStatusIcon = (notification: Notification) => {
const status = notification.metadata?.status as string | undefined;
if (!status) return null;
switch (status) {
case "in_progress":
return (
<Badge variant="secondary" className="text-xs">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
In Progress
</Badge>
);
return <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />;
case "completed":
return (
<Badge variant="default" className="text-xs bg-green-600 hover:bg-green-700">
<CheckCircle2 className="h-3 w-3 mr-1" />
Completed
</Badge>
);
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return (
<Badge variant="destructive" className="text-xs">
<AlertCircle className="h-3 w-3 mr-1" />
Failed
</Badge>
);
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return null;
return <Bell className="h-4 w-4 text-muted-foreground" />;
}
};
@ -76,13 +59,7 @@ export function NotificationPopup({
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4" />
<h3 className="font-semibold text-sm">Notifications</h3>
{unreadCount > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
{unreadCount > 0 && (
<Button
@ -91,7 +68,7 @@ export function NotificationPopup({
onClick={handleMarkAllAsRead}
className="h-7 text-xs"
>
<CheckCheck className="h-3.5 w-3.5 mr-1" />
<CheckCheck className="h-3.5 w-3.5 mr-0" />
Mark all read
</Button>
)}
@ -121,6 +98,9 @@ export function NotificationPopup({
)}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(notification)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<p
@ -131,12 +111,6 @@ export function NotificationPopup({
>
{notification.title}
</p>
<div className="flex items-center gap-2 shrink-0">
{getStatusBadge(notification)}
{!notification.read && (
<div className="h-2 w-2 rounded-full bg-primary mt-1.5" />
)}
</div>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{notification.message}
@ -145,20 +119,6 @@ export function NotificationPopup({
<span className="text-xs text-muted-foreground">
{formatTime(notification.created_at)}
</span>
{!notification.read && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(notification.id);
}}
>
<Check className="h-3 w-3 mr-1" />
Mark read
</Button>
)}
</div>
</div>
</div>
@ -172,4 +132,3 @@ export function NotificationPopup({
</div>
);
}

View file

@ -19,14 +19,18 @@ export interface Notification {
export function useNotifications(userId: string | null) {
const [electric, setElectric] = useState<ElectricClient | null>(null)
const [notifications, setNotifications] = useState<Notification[]>([])
const [initialized, setInitialized] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const syncHandleRef = useRef<SyncHandle | null>(null)
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null)
// Use ref instead of state to track initialization - prevents cleanup from running when set
const initializedRef = useRef(false)
// Initialize Electric SQL and start syncing with real-time updates
useEffect(() => {
if (!userId || initialized) return
// Use ref to prevent re-initialization without triggering cleanup
if (!userId || initializedRef.current) return
initializedRef.current = true
let mounted = true
@ -55,12 +59,19 @@ export function useNotifications(userId: string | null) {
hasInitialSyncPromise: !!handle.initialSyncPromise,
})
// Wait for initial sync to complete if the promise is available
if (handle.initialSyncPromise) {
// Optimized: Check if already up-to-date before waiting
if (handle.isUpToDate) {
console.log('Sync already up-to-date, skipping wait')
} else if (handle.initialSyncPromise) {
// Only wait if not already up-to-date
console.log('Waiting for initial sync to complete...')
try {
await handle.initialSyncPromise
console.log('Initial sync promise resolved, checking status:', {
// Use Promise.race with a shorter timeout to avoid long waits
await Promise.race([
handle.initialSyncPromise,
new Promise(resolve => setTimeout(resolve, 2000)), // Max 2s wait
])
console.log('Initial sync promise resolved or timed out, checking status:', {
isUpToDate: handle.isUpToDate,
})
} catch (syncErr) {
@ -80,122 +91,74 @@ export function useNotifications(userId: string | null) {
}
syncHandleRef.current = handle
setInitialized(true)
setLoading(false)
setError(null)
// Fetch notifications after sync is complete (we already waited above)
await fetchNotifications(electricClient.db)
// Set up real-time updates using PGlite live queries
// Electric SQL syncs data to PGlite in real-time via WebSocket/HTTP
// Electric SQL syncs data to PGlite in real-time via HTTP streaming
// PGlite live queries detect when the synced data changes and trigger callbacks
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = electricClient.db as any
// Use PGlite's live query API for real-time updates
// Based on latest PGlite docs: db.live.query(query, params, callback)
// CORRECT API: await db.live.query() then use .subscribe()
if (db.live?.query && typeof db.live.query === 'function') {
const liveQuery = db.live.query(
// IMPORTANT: db.live.query() returns a Promise - must await it!
const liveQuery = await db.live.query(
`SELECT * FROM notifications WHERE user_id = $1 ORDER BY created_at DESC`,
[userId],
(result: { rows: Notification[] }) => {
// This callback fires automatically when Electric SQL syncs changes
if (mounted) {
setNotifications(result.rows)
}
}
[userId]
)
// Set initial results immediately
if (liveQuery.initialResults) {
setNotifications(liveQuery.initialResults.rows)
if (!mounted) {
liveQuery.unsubscribe?.()
return
}
if (mounted && liveQuery && typeof liveQuery.unsubscribe === 'function') {
liveQueryRef.current = liveQuery
// Set initial results immediately from the resolved query
if (liveQuery.initialResults?.rows) {
console.log('📋 Initial live query results:', liveQuery.initialResults.rows.length)
setNotifications(liveQuery.initialResults.rows)
} else if (liveQuery.rows) {
// Some versions have rows directly on the result
console.log('📋 Initial live query results (direct):', liveQuery.rows.length)
setNotifications(liveQuery.rows)
}
// Subscribe to changes - this is the correct API!
// The callback fires automatically when Electric SQL syncs new data to PGlite
if (typeof liveQuery.subscribe === 'function') {
liveQuery.subscribe((result: { rows: Notification[] }) => {
console.log('🔔 Live query update received:', result.rows?.length || 0, 'notifications')
if (mounted && result.rows) {
setNotifications(result.rows)
}
})
console.log('✅ Real-time notifications enabled via PGlite live queries')
} else {
console.warn('⚠️ Live query subscribe method not available')
}
// Store for cleanup
if (typeof liveQuery.unsubscribe === 'function') {
liveQueryRef.current = liveQuery
}
} else {
// Fallback: Monitor sync handle for updates
// Electric SQL's syncShape should trigger updates, but we need to detect them
// This is a lightweight approach that only checks when sync indicates changes
console.warn('PGlite live queries not available - using sync-based change detection')
let lastNotificationIds = new Set<number>()
const checkForSyncUpdates = async () => {
if (!mounted) return
try {
const result = await electricClient.db.query<Notification>(
`SELECT * FROM notifications WHERE user_id = $1 ORDER BY created_at DESC`,
[userId]
)
// PGlite query returns { rows: [] } format
const rows = result.rows || []
// Only update if data actually changed
const currentIds = new Set(rows.map(r => r.id))
const currentHash = JSON.stringify(
rows.map(r => ({ id: r.id, read: r.read, updated_at: r.updated_at }))
)
// Check if IDs changed (new/deleted notifications)
const idsChanged =
currentIds.size !== lastNotificationIds.size ||
[...currentIds].some(id => !lastNotificationIds.has(id)) ||
[...lastNotificationIds].some(id => !currentIds.has(id))
if (idsChanged) {
setNotifications(rows)
lastNotificationIds = currentIds
} else {
// Check if any notification properties changed (e.g., read status)
// Compare with current state
setNotifications(prev => {
const prevHash = JSON.stringify(
prev.map(r => ({ id: r.id, read: r.read, updated_at: r.updated_at }))
)
if (prevHash !== currentHash) {
return rows
}
return prev
})
}
} catch (err) {
console.error('Failed to check for notification updates:', err)
}
// Check again after a short delay (Electric SQL syncs are fast)
if (mounted) {
setTimeout(checkForSyncUpdates, 500) // Check every 500ms - Electric SQL syncs are near-instant
}
}
// Start monitoring
checkForSyncUpdates()
liveQueryRef.current = {
unsubscribe: () => {
mounted = false
}
}
console.error('❌ PGlite live queries not available - db.live.query is not a function')
console.log('db.live:', db.live)
}
} catch (liveErr) {
console.warn('Failed to set up real-time updates:', liveErr)
// Minimal fallback - this should rarely be needed
liveQueryRef.current = {
unsubscribe: () => {}
}
console.error('❌ Failed to set up real-time updates:', liveErr)
}
} catch (err) {
if (!mounted) return
console.error('Failed to initialize Electric SQL:', err)
setError(err instanceof Error ? err : new Error('Failed to initialize Electric SQL'))
// Still mark as initialized so the UI doesn't block
setInitialized(true)
// Still mark as loaded so the UI doesn't block
setLoading(false)
}
}
@ -231,6 +194,9 @@ export function useNotifications(userId: string | null) {
return () => {
mounted = false
// Reset initialization state so we can reinitialize with a new userId
initializedRef.current = false
setLoading(true)
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe()
syncHandleRef.current = null
@ -240,7 +206,9 @@ export function useNotifications(userId: string | null) {
liveQueryRef.current = null
}
}
}, [userId, initialized])
// Only depend on userId - using ref for initialization tracking to prevent cleanup issues
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId])
// Mark notification as read (local only - needs backend sync)
const markAsRead = useCallback(
@ -301,7 +269,7 @@ export function useNotifications(userId: string | null) {
unreadCount,
markAsRead,
markAllAsRead,
loading: !initialized,
loading,
error,
}
}

View file

@ -167,16 +167,45 @@ export async function initElectric(): Promise<ElectricClient> {
// Note: mapColumns is OPTIONAL per pglite-sync types.ts
// Create a promise that resolves when initial sync is complete
// Using recommended approach: check isUpToDate immediately, watch stream, shorter timeout
// IMPORTANT: We don't unsubscribe from the stream - it must stay active for real-time updates
let resolveInitialSync: () => void
let rejectInitialSync: (error: Error) => void
let syncResolved = false
const initialSyncPromise = new Promise<void>((resolve, reject) => {
resolveInitialSync = resolve
rejectInitialSync = reject
// Safety timeout - if sync doesn't complete in 30s, something is wrong
setTimeout(() => {
console.warn(`⚠️ Sync timeout for ${table} - sync did not complete in 30s`)
resolve() // Resolve anyway to not block, but log warning
}, 30000)
resolveInitialSync = () => {
if (!syncResolved) {
syncResolved = true
// DON'T unsubscribe from stream - it needs to stay active for real-time updates
resolve()
}
}
rejectInitialSync = (error: Error) => {
if (!syncResolved) {
syncResolved = true
// DON'T unsubscribe from stream even on error - let Electric handle it
reject(error)
}
}
// Shorter timeout (5 seconds) as fallback
const timeoutId = setTimeout(() => {
if (!syncResolved) {
console.warn(`⚠️ Sync timeout for ${table} - checking isUpToDate one more time...`)
// Check isUpToDate one more time before resolving
// This will be checked after shape is created
setTimeout(() => {
if (!syncResolved) {
console.warn(`⚠️ Sync timeout for ${table} - resolving anyway after 5s`)
resolveInitialSync()
}
}, 100)
}
}, 5000)
// Store timeout ID for cleanup if needed
// Note: timeout will be cleared if sync completes early
})
const shapeConfig = {
@ -220,35 +249,91 @@ export async function initElectric(): Promise<ElectricClient> {
streamType: typeof shape?.stream,
})
// Debug the stream if available
if (shape?.stream) {
const stream = shape.stream as any
console.log('Shape stream details:', {
shapeHandle: stream?.shapeHandle,
lastOffset: stream?.lastOffset,
isUpToDate: stream?.isUpToDate,
error: stream?.error,
hasSubscribe: typeof stream?.subscribe === 'function',
hasUnsubscribe: typeof stream?.unsubscribe === 'function',
})
// Try to subscribe to the stream to see if it's receiving messages
if (typeof stream?.subscribe === 'function') {
console.log('Subscribing to shape stream for debugging...')
stream.subscribe((messages: unknown[]) => {
console.log('🔵 Shape stream received messages:', messages?.length || 0)
if (messages && messages.length > 0) {
console.log('First message:', JSON.stringify(messages[0], null, 2))
}
// Recommended Approach Step 1: Check isUpToDate immediately
if (shape.isUpToDate) {
console.log(`✅ Sync already up-to-date for ${table} (resuming from previous state)`)
resolveInitialSync()
} else {
// Recommended Approach Step 2: Subscribe to stream and watch for "up-to-date" message
if (shape?.stream) {
const stream = shape.stream as any
console.log('Shape stream details:', {
shapeHandle: stream?.shapeHandle,
lastOffset: stream?.lastOffset,
isUpToDate: stream?.isUpToDate,
error: stream?.error,
hasSubscribe: typeof stream?.subscribe === 'function',
hasUnsubscribe: typeof stream?.unsubscribe === 'function',
})
// Subscribe to the stream to watch for "up-to-date" control message
// NOTE: We keep this subscription active - don't unsubscribe!
// The stream is what Electric SQL uses for real-time updates
if (typeof stream?.subscribe === 'function') {
console.log('Subscribing to shape stream to watch for up-to-date message...')
// Subscribe but don't store unsubscribe - we want it to stay active
stream.subscribe((messages: unknown[]) => {
// Continue receiving updates even after sync is resolved
if (!syncResolved) {
console.log('🔵 Shape stream received messages:', messages?.length || 0)
}
// Check if any message indicates sync is complete
if (messages && messages.length > 0) {
for (const message of messages) {
const msg = message as any
// Check for "up-to-date" control message
if (msg?.headers?.control === 'up-to-date' ||
msg?.headers?.electric_up_to_date === 'true' ||
(typeof msg === 'object' && 'up-to-date' in msg)) {
if (!syncResolved) {
console.log(`✅ Received up-to-date message for ${table}`)
resolveInitialSync()
}
// Continue listening for real-time updates - don't return!
}
}
if (!syncResolved && messages.length > 0) {
console.log('First message:', JSON.stringify(messages[0], null, 2))
}
}
// Also check stream's isUpToDate property after receiving messages
if (!syncResolved && stream?.isUpToDate) {
console.log(`✅ Stream isUpToDate is true for ${table}`)
resolveInitialSync()
}
})
// Also check stream's isUpToDate property immediately
if (stream?.isUpToDate) {
console.log(`✅ Stream isUpToDate is true immediately for ${table}`)
resolveInitialSync()
}
}
// Also poll isUpToDate periodically as a backup (every 200ms)
const pollInterval = setInterval(() => {
if (syncResolved) {
clearInterval(pollInterval)
return
}
if (shape.isUpToDate || stream?.isUpToDate) {
console.log(`✅ Sync completed (detected via polling) for ${table}`)
clearInterval(pollInterval)
resolveInitialSync()
}
}, 200)
// Clean up polling when promise resolves
initialSyncPromise.finally(() => {
clearInterval(pollInterval)
})
} else {
console.warn(`⚠️ No stream available for ${table}, relying on callback and timeout`)
}
}
// Wait briefly to see if sync starts
await new Promise(resolve => setTimeout(resolve, 100))
console.log('Shape sync result (after 100ms):', {
isUpToDate: shape?.isUpToDate,
})
// Return the shape handle - isUpToDate is a getter that reflects current state
return {