From 6c85c973b90f78f69c99e09d6fa66b168ee9c7b5 Mon Sep 17 00:00:00 2001 From: Gib Date: Sun, 15 Jun 2025 00:34:04 -0500 Subject: [PATCH] Last commit before I burn it all to the ground with ReactQuery --- package.json | 2 + pnpm-lock.yaml | 34 ++ src/app/status/page.tsx | 5 +- .../auth/buttons/SignInWithMicrosoft.tsx | 2 +- src/components/status/StatusList.tsx | 537 ++++++++++++++++++ src/components/status/TechTable.tsx | 183 ++++-- src/components/status/index.tsx | 1 + src/lib/actions/status.ts | 95 ++-- src/lib/hooks/status.ts | 94 ++- 9 files changed, 786 insertions(+), 167 deletions(-) create mode 100644 src/components/status/StatusList.tsx diff --git a/package.json b/package.json index 02294a5..a24b3fd 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.50.0", "@t3-oss/env-nextjs": "^0.12.0", + "@tanstack/react-query": "^5.80.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.510.0", @@ -48,6 +49,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@tailwindcss/postcss": "^4.1.10", + "@tanstack/eslint-plugin-query": "^5.78.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "^20.19.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc4366e..83316cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@t3-oss/env-nextjs': specifier: ^0.12.0 version: 0.12.0(typescript@5.8.3)(zod@3.25.64) + '@tanstack/react-query': + specifier: ^5.80.7 + version: 5.80.7(react@19.1.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -96,6 +99,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.10 version: 4.1.10 + '@tanstack/eslint-plugin-query': + specifier: ^5.78.0 + version: 5.78.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3) '@types/cors': specifier: ^2.8.19 version: 2.8.19 @@ -1493,6 +1499,19 @@ packages: '@tailwindcss/postcss@4.1.10': resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==} + '@tanstack/eslint-plugin-query@5.78.0': + resolution: {integrity: sha512-hYkhWr3UP0CkAsn/phBVR98UQawbw8CmTSgWtdgEBUjI60/GBaEIkpgi/Bp/2I8eIDK4+vdY7ac6jZx+GR+hEQ==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + '@tanstack/query-core@5.80.7': + resolution: {integrity: sha512-s09l5zeUKC8q7DCCCIkVSns8zZrK4ZDT6ryEjxNBFi68G4z2EBobBS7rdOY3r6W1WbUDpc1fe5oY+YO/+2UVUg==} + + '@tanstack/react-query@5.80.7': + resolution: {integrity: sha512-u2F0VK6+anItoEvB3+rfvTO9GEh2vb00Je05OwlUe/A0lkJBgW1HckiY3f9YZa+jx6IOe4dHPh10dyp9aY3iRQ==} + peerDependencies: + react: ^18 || ^19 + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -4965,6 +4984,21 @@ snapshots: postcss: 8.5.5 tailwindcss: 4.1.10 + '@tanstack/eslint-plugin-query@5.78.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/utils': 8.34.0(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.29.0(jiti@2.4.2) + transitivePeerDependencies: + - supports-color + - typescript + + '@tanstack/query-core@5.80.7': {} + + '@tanstack/react-query@5.80.7(react@19.1.0)': + dependencies: + '@tanstack/query-core': 5.80.7 + react: 19.1.0 + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 diff --git a/src/app/status/page.tsx b/src/app/status/page.tsx index 4942d56..cc4804d 100644 --- a/src/app/status/page.tsx +++ b/src/app/status/page.tsx @@ -1,6 +1,6 @@ 'use server'; -import { TechTable } from '@/components/status'; +import { StatusList, TechTable } from '@/components/status'; import { getUser, getRecentUsersWithStatuses } from '@/lib/actions'; import { redirect } from 'next/navigation'; @@ -12,7 +12,8 @@ const Status = async () => { const response = await getRecentUsersWithStatuses(); if (!response.success) throw new Error(response.error); const usersWithStatuses = response.data; - return ; + //return ; + return ; } }; export default Status; diff --git a/src/components/default/auth/buttons/SignInWithMicrosoft.tsx b/src/components/default/auth/buttons/SignInWithMicrosoft.tsx index a35bb90..30e904a 100644 --- a/src/components/default/auth/buttons/SignInWithMicrosoft.tsx +++ b/src/components/default/auth/buttons/SignInWithMicrosoft.tsx @@ -48,7 +48,7 @@ export const SignInWithMicrosoft = ({ if (!updateResponse.success) throw new Error('Could not update provider!'); } } - window.location.href = result.data.url; + window.location.href = result.data.url + `?provider=${result.data.provider}`; } else { setStatusMessage(`Error: Could not sign in with Microsoft!`); } diff --git a/src/components/status/StatusList.tsx b/src/components/status/StatusList.tsx new file mode 100644 index 0000000..e613b27 --- /dev/null +++ b/src/components/status/StatusList.tsx @@ -0,0 +1,537 @@ +"use client" + +import { createClient } from "@/utils/supabase" +import { useState, useEffect, useCallback, useRef } from "react" +import { useAuth, useTVMode } from "@/components/context" +import { getRecentUsersWithStatuses, updateStatuses, updateUserStatus, type UserWithStatus } from "@/lib/hooks" +import { BasedAvatar, Drawer, DrawerTrigger, Loading } from "@/components/ui" +import { SubmitButton } from "@/components/default" +import { toast } from "sonner" +import { HistoryDrawer } from "@/components/status" +import type { Profile } from "@/utils/supabase" +import type { RealtimeChannel } from "@supabase/supabase-js" +import { makeConditionalClassName } from "@/lib/utils" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Wifi, WifiOff, Clock, User } from "lucide-react" + +type StatusListProps = { + initialStatuses: UserWithStatus[] +} + +export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { + const { isAuthenticated } = useAuth() + const { tvMode } = useTVMode() + const [loading, setLoading] = useState(true) + const [selectedUsers, setSelectedUsers] = useState([]) + const [selectAll, setSelectAll] = useState(false) + const [updatingStatus, setUpdatingStatus] = useState(false) + const [statusInput, setStatusInput] = useState("") + const [usersWithStatuses, setUsersWithStatuses] = useState(initialStatuses) + const [selectedHistoryUser, setSelectedHistoryUser] = useState(null) + const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting") + const [newStatusIds, setNewStatusIds] = useState>(new Set()) + const channelRef = useRef(null) + const supabaseRef = useRef(createClient()) + + const fetchRecentUsersWithStatuses = useCallback(async () => { + try { + const response = await getRecentUsersWithStatuses() + if (!response.success) throw new Error(response.error) + return response.data + } catch (error) { + toast.error(`Error fetching technicians: ${error as Error}`) + return [] + } + }, []) + + // Initial load + useEffect(() => { + const loadData = async () => { + const data = await fetchRecentUsersWithStatuses() + setUsersWithStatuses(data) + setLoading(false) + } + loadData().catch((error) => { + console.error("Error loading data:", error) + }) + }, [fetchRecentUsersWithStatuses, isAuthenticated]) + + const updateStatus = useCallback(async () => { + if (!isAuthenticated) { + toast.error("You must be signed in to update statuses.") + return + } + if (!statusInput.trim()) { + toast.error("Please enter a valid status.") + return + } + + try { + setUpdatingStatus(true) + if (selectedUsers.length === 0) { + const result = await updateUserStatus(statusInput) + if (!result.success) throw new Error(result.error) + toast.success("Status updated for signed in user.") + } else { + const selectedUserObjects = usersWithStatuses.filter((u) => selectedUsers.includes(u.user.id)) + const result = await updateStatuses(selectedUserObjects, statusInput) + if (!result.success) throw new Error(result.error) + toast.success(`Status updated for ${selectedUsers.length} selected users.`) + } + setSelectedUsers([]) + setStatusInput("") + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + toast.error(`Failed to update status: ${errorMessage}`) + } finally { + setUpdatingStatus(false) + } + }, [isAuthenticated, statusInput, selectedUsers, usersWithStatuses]) + + const handleCheckboxChange = (id: string) => { + setSelectedUsers((prev) => (prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id])) + } + + const handleSelectAllChange = () => { + if (selectAll) { + setSelectedUsers([]) + } else { + setSelectedUsers(usersWithStatuses.map((user) => user.user.id)) + } + setSelectAll(!selectAll) + } + + useEffect(() => { + setSelectAll(selectedUsers.length === usersWithStatuses.length && usersWithStatuses.length > 0) + }, [selectedUsers.length, usersWithStatuses.length]) + + // Real-time connection setup + useEffect(() => { + if (!isAuthenticated) return + + let reconnectAttempts = 0 + const maxReconnectAttempts = 3 + let reconnectTimeout: NodeJS.Timeout + let isComponentMounted = true + let currentChannel: RealtimeChannel | null = null + + const setupRealtimeConnection = () => { + if (!isComponentMounted) return + + // Clean up any existing channel first + if (currentChannel) { + supabaseRef.current.removeChannel(currentChannel).catch(console.error) + currentChannel = null + } + + console.log("Setting up new realtime connection...") + setConnectionStatus("connecting") + + const channel = supabaseRef.current + .channel(`status_updates`, { + config: { + broadcast: { self: true }, + }, + }) + .on("broadcast", { event: "status_updated" }, (payload) => { + const { user_status } = payload.payload as { + user_status: UserWithStatus + timestamp: string + } + console.log("Received status update:", user_status) + + // Add animation class for new status + setNewStatusIds((prev) => new Set([...prev, user_status.user.id])) + + // Remove animation class after animation completes + setTimeout(() => { + setNewStatusIds((prev) => { + const newSet = new Set(prev) + newSet.delete(user_status.user.id) + return newSet + }) + }, 1000) + + setUsersWithStatuses((prevUsers) => { + const existingUserIndex = prevUsers.findIndex((u) => u.user.id === user_status.user.id) + + if (existingUserIndex !== -1) { + const updatedUsers = [...prevUsers] + updatedUsers[existingUserIndex] = { + user: user_status.user, + status: user_status.status, + created_at: user_status.created_at, + updated_by: user_status.updated_by, + } + return updatedUsers + } else { + return [user_status, ...prevUsers] + } + }) + }) + .subscribe((status) => { + console.log("Subscription status:", status) + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (status === "SUBSCRIBED") { + console.log("Successfully subscribed to status updates!") + setConnectionStatus("connected") + reconnectAttempts = 0 + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === "CLOSED") { + console.log("Connection closed") + setConnectionStatus("disconnected") + + if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++ + const delay = 2000 * reconnectAttempts + + console.log(`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`) + + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + } + + reconnectTimeout = setTimeout(() => { + if (isComponentMounted) { + setupRealtimeConnection() + } + }, delay) + } else { + console.log("Max reconnection attempts reached or component unmounted") + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === "CHANNEL_ERROR") { + console.error("Channel error - stopping reconnection attempts") + setConnectionStatus("disconnected") + } + }) + + currentChannel = channel + channelRef.current = channel + } + + const initialTimeout = setTimeout(() => { + if (isComponentMounted) { + setupRealtimeConnection() + } + }, 1000) + + return () => { + isComponentMounted = false + + if (initialTimeout) { + clearTimeout(initialTimeout) + } + + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) + } + + if (currentChannel) { + console.log("Cleaning up realtime connection...") + supabaseRef.current.removeChannel(currentChannel).catch((error) => { + console.error(`Error unsubscribing: ${error}`) + }) + } + + channelRef.current = null + } + }, [isAuthenticated]) + + const formatTime = (timestamp: string) => { + const date = new Date(timestamp) + const time = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "numeric", + }) + const day = date.getDate() + const month = date.toLocaleString("default", { month: "long" }) + return `${time} - ${month} ${day}` + } + + const getConnectionIcon = () => { + switch (connectionStatus) { + case "connected": + return + case "connecting": + return + case "disconnected": + return + } + } + + const getConnectionText = () => { + switch (connectionStatus) { + case "connected": + return "Connected" + case "connecting": + return "Connecting..." + case "disconnected": + return "Disconnected" + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + const containerClassName = makeConditionalClassName({ + context: tvMode, + defaultClassName: "mx-auto space-y-4", + on: "lg:w-11/12 w-full mt-15", + off: "w-5/6", + }) + + const cardClassName = makeConditionalClassName({ + context: tvMode, + defaultClassName: "transition-all duration-300 hover:shadow-md", + on: "lg:text-4xl", + off: "lg:text-base", + }) + + return ( +
+ {/* Connection Status Header */} +
+
+

Tech Status

+ + {getConnectionIcon()} + {getConnectionText()} + +
+ + {!tvMode && usersWithStatuses.length > 0 && ( +
+ + +
+ )} +
+ + {/* Status Cards */} +
+ {usersWithStatuses.map((userWithStatus) => { + const isSelected = selectedUsers.includes(userWithStatus.user.id) + const isNewStatus = newStatusIds.has(userWithStatus.user.id) + const isUpdatedByOther = userWithStatus.updated_by && userWithStatus.updated_by.id !== userWithStatus.user.id + + return ( + + +
+
+ {!tvMode && ( + handleCheckboxChange(userWithStatus.user.id)} + onClick={(e) => e.stopPropagation()} + /> + )} + +
+

+ {userWithStatus.user.full_name ?? "Unknown User"} +

+ {isUpdatedByOther && ( +
+ + {userWithStatus.updated_by && ( + + Updated by {userWithStatus.updated_by.full_name} + + )} +
+ )} +
+
+
+ + + {formatTime(userWithStatus.created_at)} + +
+
+
+ + + +
setSelectedHistoryUser(userWithStatus.user)} + > +

{userWithStatus.status}

+
+
+ {selectedHistoryUser === userWithStatus.user && } +
+
+
+ ) + })} +
+ + {usersWithStatuses.length === 0 && ( + +

No status updates yet

+
+ )} + + {/* Status Update Input */} + {!tvMode && ( + +
+

Update Status

+
+ setStatusInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + updateStatus().catch((error) => { + toast.error(`Failed to update status: ${error as Error}`) + }) + } + }} + /> + + {selectedUsers.length > 0 ? `Update ${selectedUsers.length} Users` : "Update Status"} + +
+ {selectedUsers.length > 0 && ( +

Updating status for {selectedUsers.length} selected users

+ )} +
+
+ )} + + {/* Global Status History Drawer */} +
+ + + + + + +
+
+ ) +} +//'use client'; + +//import { createClient } from '@/utils/supabase'; +//import { useState, useEffect, useCallback, useRef } from 'react'; +//import { useAuth, useTVMode } from '@/components/context'; +//import { + //getRecentUsersWithStatuses, + //updateStatuses, + //updateUserStatus, + //type UserWithStatus, +//} from '@/lib/hooks'; +//import { + //BasedAvatar, + //Drawer, + //DrawerTrigger, + //Loading, +//} from '@/components/ui'; +//import { SubmitButton } from '@/components/default'; +//import { toast } from 'sonner'; +//import { HistoryDrawer } from '@/components/status'; +//import type { Profile } from '@/utils/supabase'; +//import type { RealtimeChannel } from '@supabase/supabase-js'; +//import { makeConditionalClassName } from '@/lib/utils'; + +//export const StatusList = (initialStatuses: UserWithStatus[]) => { + //const { isAuthenticated } = useAuth(); + //const { tvMode } = useTVMode(); + //const [loading, setLoading] = useState(true); + //const [selectedUsers, setSelectedUsers] = useState([]); + //const [selectAll, setSelectAll] = useState(false); + //const [updatingStatus, setUpdatingStatus] = useState(false); + //const [statusInput, setStatusInput] = useState(''); + //const [usersWithStatuses, setUsersWithStatuses] = useState(initialStatuses); + //const [selectedHistoryUser, setSelectedHistoryUser] = useState(null); + //const channelRef = useRef(null); + //const supabaseRef = useRef(createClient()); + + //const fetchRecentUsersWithStatuses = useCallback(async () => { + //const response = await getRecentUsersWithStatuses(); + //if (response.success) return response.data; + //else toast.error('Error fetching users!'); + //return []; + //}, []); + + //useEffect(() => { + //const loadData = async () => { + //const data = await fetchRecentUsersWithStatuses(); + //setUsersWithStatuses(data); + //}; + //loadData() + //.catch(()=> toast.error('Error fetching users.')) + //.finally(() => setLoading(false)); + //}, [fetchRecentUsersWithStatuses, isAuthenticated]); + + //const updateStatus = useCallback(async () => { + //try { + //if (!isAuthenticated) throw new Error('Not authenticated.'); + //if (!statusInput.trim()) throw new Error('Not a valid status.') + + //setUpdatingStatus(true); + //if (selectedUsers.length === 0) { + //const result = await updateUserStatus(statusInput); + //if (!result.success) throw new Error('Could not updateUserStatus!') + //toast.success('Status updated!'); + //} else { + //const result = await updateStatuses(selectedUsers, statusInput); + //} + + //} catch (error) { + //toast.error(`Error updating statuses: ${error as Error}`); + //} + //}); + + //return ( + //
+ //); +//}; diff --git a/src/components/status/TechTable.tsx b/src/components/status/TechTable.tsx index e19d4d3..a16042a 100755 --- a/src/components/status/TechTable.tsx +++ b/src/components/status/TechTable.tsx @@ -32,14 +32,15 @@ export const TechTable = ({ const { isAuthenticated } = useAuth(); const { tvMode } = useTVMode(); const [loading, setLoading] = useState(true); - const [selectedIds, setSelectedIds] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); const [selectAll, setSelectAll] = useState(false); const [statusInput, setStatusInput] = useState(''); const [usersWithStatuses, setUsersWithStatuses] = useState(initialStatuses); const [selectedHistoryUser, setSelectedHistoryUser] = - useState(null); + useState(null); const channelRef = useRef(null); + const supabaseRef = useRef(createClient()); const fetchRecentUsersWithStatuses = useCallback(async () => { try { @@ -74,28 +75,28 @@ export const TechTable = ({ return; } try { - if (selectedIds.length === 0) { + if (selectedUsers.length === 0) { const result = await updateUserStatus(statusInput); if (!result.success) throw new Error(result.error); toast.success(`Status updated for signed in user.`); } else { - const result = await updateStatuses(selectedIds, statusInput); + const result = await updateStatuses(selectedUsers, statusInput); if (!result.success) throw new Error(result.error); toast.success( - `Status updated for ${selectedIds.length} selected users.`, + `Status updated for ${selectedUsers.length} selected users.`, ); } - setSelectedIds([]); + setSelectedUsers([]); setStatusInput(''); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); toast.error(`Failed to update status: ${errorMessage}`); } - }, [isAuthenticated, statusInput, selectedIds]); + }, [isAuthenticated, statusInput, selectedUsers]); const handleCheckboxChange = (id: string) => { - setSelectedIds((prev) => + setSelectedUsers((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id], @@ -104,76 +105,142 @@ export const TechTable = ({ const handleSelectAllChange = () => { if (selectAll) { - setSelectedIds([]); + setSelectedUsers([]); } else { - setSelectedIds(usersWithStatuses.map((tech) => tech.user.id)); + setSelectedUsers(usersWithStatuses.map((tech) => tech.user.id)); } setSelectAll(!selectAll); }; useEffect(() => { setSelectAll( - selectedIds.length === usersWithStatuses.length && + selectedUsers.length === usersWithStatuses.length && usersWithStatuses.length > 0, ); - }, [selectedIds.length, usersWithStatuses.length]); + }, [selectedUsers.length, usersWithStatuses.length]); useEffect(() => { if (!isAuthenticated) return; - const supabase = createClient(); - const channel = supabase - .channel('status_updates', { - config: { broadcast: { self: true }} - }) - .on('broadcast', { event: 'status_updated' }, (payload) => { - const { user_status } = payload.payload as { - user_status: UserWithStatus; - timestamp: string; - }; - console.log('Received status update:', user_status); + let reconnectAttempts = 0; + const maxReconnectAttempts = 3; // Reduced from 5 + let reconnectTimeout: NodeJS.Timeout; + let isComponentMounted = true; + let currentChannel: RealtimeChannel | null = null; - setUsersWithStatuses((prevUsers) => { - const existingUserIndex = prevUsers.findIndex((u) => - u.user.id === user_status.user.id, - ); + const setupRealtimeConnection = () => { + if (!isComponentMounted) return; - if (existingUserIndex !== -1) { - const updatedUsers = [...prevUsers]; - updatedUsers[existingUserIndex] = { - user: user_status.user, // Use the user from the broadcast - status: user_status.status, - created_at: user_status.created_at, - updated_by: user_status.updated_by, - }; - return updatedUsers; - } else { - // Add new user to list! - return [user_status, ...prevUsers]; - } - }); - }) - .subscribe((status) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - if (status === 'SUBSCRIBED') { - console.log('Successfully subscribed to status updates!'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - } else if (status === 'CHANNEL_ERROR') { - console.error('Error subscribing to status updates.') + // Clean up any existing channel first + if (currentChannel) { + supabaseRef.current.removeChannel(currentChannel).catch(console.error); + currentChannel = null; } - }); - channelRef.current = channel; + console.log('Setting up new realtime connection...'); + + const channel = supabaseRef.current + .channel(`status_updates`, { // Unique channel name + config: { + broadcast: { self: true } + } + }) + .on('broadcast', { event: 'status_updated' }, (payload) => { + const { user_status } = payload.payload as { + user_status: UserWithStatus; + timestamp: string; + }; + console.log('Received status update:', user_status); + + setUsersWithStatuses((prevUsers) => { + const existingUserIndex = prevUsers.findIndex((u) => + u.user.id === user_status.user.id, + ); + + if (existingUserIndex !== -1) { + const updatedUsers = [...prevUsers]; + updatedUsers[existingUserIndex] = { + user: user_status.user, + status: user_status.status, + created_at: user_status.created_at, + updated_by: user_status.updated_by, + }; + return updatedUsers; + } else { + return [user_status, ...prevUsers]; + } + }); + }) + .subscribe((status) => { + console.log('Subscription status:', status); + // ignore this enum error please + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (status === 'SUBSCRIBED') { + console.log('Successfully subscribed to status updates!'); + reconnectAttempts = 0; + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + } else if (status === 'CLOSED') { + console.log('Connection closed'); + + // Only reconnect if we haven't exceeded max attempts and component is still mounted + if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + const delay = 2000 * reconnectAttempts; // Linear backoff instead of exponential + + console.log(`Reconnecting after close ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`); + + // Clear any existing timeout + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + } + + reconnectTimeout = setTimeout(() => { + if (isComponentMounted) { + setupRealtimeConnection(); + } + }, delay); + } else { + console.log('Max reconnection attempts reached or component unmounted'); + } + + } else if (status === 'CHANNEL_ERROR') { + console.error('Channel error - stopping reconnection attempts'); + // Don't reconnect on channel errors to avoid infinite loops + } + }); + + currentChannel = channel; + channelRef.current = channel; + }; + + // Add a small delay before initial connection to ensure auth is stable + const initialTimeout = setTimeout(() => { + if (isComponentMounted) { + setupRealtimeConnection(); + } + }, 1000); return () => { - if (channelRef.current) { - supabase.removeChannel(channelRef.current).catch((error) => { - console.error(`Error unsubscribing from status updates: ${error}`); - }); - channelRef.current = null; + isComponentMounted = false; + + if (initialTimeout) { + clearTimeout(initialTimeout); } + + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + } + + if (currentChannel) { + console.log('Cleaning up realtime connection...'); + supabaseRef.current.removeChannel(currentChannel).catch((error) => { + console.error(`Error unsubscribing: ${error}`); + }); + } + + channelRef.current = null; }; - }, [isAuthenticated]); + }, [isAuthenticated]); // Keep this dependency but make the connection more stable const formatTime = (timestamp: string) => { const date = new Date(timestamp); @@ -258,7 +325,7 @@ export const TechTable = ({ handleCheckboxChange(userWithStatus.user.id) } diff --git a/src/components/status/index.tsx b/src/components/status/index.tsx index be0ed76..d75607f 100644 --- a/src/components/status/index.tsx +++ b/src/components/status/index.tsx @@ -1,2 +1,3 @@ export * from './HistoryDrawer'; +export * from './StatusList'; export * from './TechTable'; diff --git a/src/lib/actions/status.ts b/src/lib/actions/status.ts index cbc096a..31c560e 100644 --- a/src/lib/actions/status.ts +++ b/src/lib/actions/status.ts @@ -24,6 +24,16 @@ type PaginatedHistory = { export const getRecentUsersWithStatuses = async (): Promise< Result > => { + const getAvatarUrl = async (url: string | null | undefined) => { + if (!url) return null; + const avatarUrl = await getSignedUrl({ + bucket: 'avatars', + url, + transform: { width: 128, height: 128 }, + }); + if (avatarUrl.success) return avatarUrl.data; + else return null; + }; try { const supabase = await createServerClient(); const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24); @@ -45,6 +55,7 @@ export const getRecentUsersWithStatuses = async (): Promise< if (error) throw error as Error; if (!data?.length) return { success: true, data: [] }; + // 3️⃣ client-side dedupe: keep the first status you see per user const seen = new Set(); const filtered = data.filter((row) => { if (seen.has(row.user.id)) return false; @@ -53,32 +64,16 @@ export const getRecentUsersWithStatuses = async (): Promise< }); const filteredWithAvatars = new Array(); - for (const userWithStatus of filtered) { - - if (userWithStatus.user.avatar_url) { - const avatarResponse = await getSignedUrl({ - bucket: 'avatars', - url: userWithStatus.user.avatar_url, - }); - if (avatarResponse.success) { - userWithStatus.user.avatar_url = avatarResponse.data; - } else userWithStatus.user.avatar_url = null; - } else userWithStatus.user.avatar_url = null; - - if (userWithStatus.updated_by?.avatar_url) { - const updatedByAvatarResponse = await getSignedUrl({ - bucket: 'avatars', - url: userWithStatus.updated_by.avatar_url ?? '', - }); - if (updatedByAvatarResponse.success) { - userWithStatus.updated_by.avatar_url = updatedByAvatarResponse.data; - } else userWithStatus.updated_by.avatar_url = null; - } else { - if (userWithStatus.updated_by) userWithStatus.updated_by.avatar_url = null; - } + if (userWithStatus.user.avatar_url) + userWithStatus.user.avatar_url = + await getAvatarUrl(userWithStatus.updated_by?.avatar_url); + if (userWithStatus.updated_by?.avatar_url) + userWithStatus.user.avatar_url = + await getAvatarUrl(userWithStatus.updated_by?.avatar_url); filteredWithAvatars.push(userWithStatus); } + return { success: true, data: filteredWithAvatars }; } catch (error) { return { success: false, error: `Error: ${error as Error}` }; @@ -115,41 +110,36 @@ export const broadcastStatusUpdates = async ( }; export const updateStatuses = async ( - userIds: string[], + usersWithStatuses: UserWithStatus[], status: string, ): Promise> => { try { const supabase = await createServerClient(); const profileResponse = await getProfile(); if (!profileResponse.success) throw new Error('Not authenticated!'); - const userProfile = profileResponse.data; + const user = profileResponse.data; - const inserts = userIds.map((userId) => ({ - user_id: userId, - status, - updated_by_id: userProfile.id, - })); + const { + data: insertedStatuses, + error: insertedStatusesError + } = await supabase + .from('statuses') + .insert(usersWithStatuses.map((userWithStatus) => ({ + user_id: userWithStatus.user.id, + status, + updated_by_id: user.id, + }))) + .select(); - const { data: insertedStatuses, error: insertedStatusesError } = - await supabase.from('statuses').insert(inserts).select(); - if (insertedStatusesError) throw insertedStatusesError as Error; - - if (insertedStatuses) { - const broadcastArray = new Array(insertedStatuses.length); - for (const insertedStatus of insertedStatuses) { - const profileResponse = await getProfile(insertedStatus.user_id) - if (!profileResponse.success) throw new Error(profileResponse.error); - const profile = profileResponse.data; - if (profile) { - broadcastArray.push({ - user: profile, - status: insertedStatus.status, - created_at: insertedStatus.created_at, - updated_by: userProfile, - }); - } - } - await broadcastStatusUpdates(broadcastArray); + if (insertedStatusesError) throw new Error('Couldn\'t insert statuses!'); + else if (insertedStatuses) { + const createdAtFallback = new Date(Date.now()).toISOString(); + await broadcastStatusUpdates(usersWithStatuses.map((s, i) => { return { + user: s.user, + status: status, + created_at: insertedStatuses[i]?.created_at ?? createdAtFallback, + updated_by: user, + }})); } return { success: true, data: undefined }; } catch (error) { @@ -181,14 +171,13 @@ export const updateUserStatus = async ( .single(); if (insertedStatusError) throw insertedStatusError as Error; - const userStatus: UserWithStatus = { + await broadcastStatusUpdates([{ user: userProfile, status: insertedStatus.status, created_at: insertedStatus.created_at, updated_by: userProfile, - }; + }]); - await broadcastStatusUpdates([userStatus]); return { success: true, data: undefined }; } catch (error) { return { diff --git a/src/lib/hooks/status.ts b/src/lib/hooks/status.ts index ce53572..01188c5 100644 --- a/src/lib/hooks/status.ts +++ b/src/lib/hooks/status.ts @@ -24,6 +24,16 @@ type PaginatedHistory = { export const getRecentUsersWithStatuses = async (): Promise< Result > => { + const getAvatarUrl = async (url: string | null | undefined) => { + if (!url) return null; + const avatarUrl = await getSignedUrl({ + bucket: 'avatars', + url, + transform: { width: 128, height: 128 }, + }); + if (avatarUrl.success) return avatarUrl.data; + else return null; + }; try { const supabase = createClient(); const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24); @@ -54,32 +64,16 @@ export const getRecentUsersWithStatuses = async (): Promise< }); const filteredWithAvatars = new Array(); - for (const userWithStatus of filtered) { - - if (userWithStatus.user.avatar_url) { - const avatarResponse = await getSignedUrl({ - bucket: 'avatars', - url: userWithStatus.user.avatar_url, - }); - if (avatarResponse.success) { - userWithStatus.user.avatar_url = avatarResponse.data; - } else userWithStatus.user.avatar_url = null; - } else userWithStatus.user.avatar_url = null; - - if (userWithStatus.updated_by?.avatar_url) { - const updatedByAvatarResponse = await getSignedUrl({ - bucket: 'avatars', - url: userWithStatus.updated_by.avatar_url ?? '', - }); - if (updatedByAvatarResponse.success) { - userWithStatus.updated_by.avatar_url = updatedByAvatarResponse.data; - } else userWithStatus.updated_by.avatar_url = null; - } else { - if (userWithStatus.updated_by) userWithStatus.updated_by.avatar_url = null; - } + if (userWithStatus.user.avatar_url) + userWithStatus.user.avatar_url = + await getAvatarUrl(userWithStatus.updated_by?.avatar_url); + if (userWithStatus.updated_by?.avatar_url) + userWithStatus.user.avatar_url = + await getAvatarUrl(userWithStatus.updated_by?.avatar_url); filteredWithAvatars.push(userWithStatus); } + return { success: true, data: filteredWithAvatars }; } catch (error) { return { success: false, error: `Error: ${error as Error}` }; @@ -116,41 +110,36 @@ export const broadcastStatusUpdates = async ( }; export const updateStatuses = async ( - userIds: string[], + usersWithStatuses: UserWithStatus[], status: string, ): Promise> => { try { const supabase = createClient(); const profileResponse = await getProfile(); if (!profileResponse.success) throw new Error('Not authenticated!'); - const userProfile = profileResponse.data; + const user = profileResponse.data; - const inserts = userIds.map((userId) => ({ - user_id: userId, - status, - updated_by_id: userProfile.id, - })); + const { + data: insertedStatuses, + error: insertedStatusesError + } = await supabase + .from('statuses') + .insert(usersWithStatuses.map((userWithStatus) => ({ + user_id: userWithStatus.user.id, + status, + updated_by_id: user.id, + }))) + .select(); - const { data: insertedStatuses, error: insertedStatusesError } = - await supabase.from('statuses').insert(inserts).select(); - if (insertedStatusesError) throw insertedStatusesError as Error; - - if (insertedStatuses) { - const broadcastArray = new Array(insertedStatuses.length); - for (const insertedStatus of insertedStatuses) { - const profileResponse = await getProfile(insertedStatus.user_id) - if (!profileResponse.success) throw new Error(profileResponse.error); - const profile = profileResponse.data; - if (profile) { - broadcastArray.push({ - user: profile, - status: insertedStatus.status, - created_at: insertedStatus.created_at, - updated_by: userProfile, - }); - } - } - await broadcastStatusUpdates(broadcastArray); + if (insertedStatusesError) throw new Error('Couldn\'t insert statuses!'); + else if (insertedStatuses) { + const createdAtFallback = new Date(Date.now()).toISOString(); + await broadcastStatusUpdates(usersWithStatuses.map((s, i) => { return { + user: s.user, + status: status, + created_at: insertedStatuses[i]?.created_at ?? createdAtFallback, + updated_by: user, + }})); } return { success: true, data: undefined }; } catch (error) { @@ -182,14 +171,13 @@ export const updateUserStatus = async ( .single(); if (insertedStatusError) throw insertedStatusError as Error; - const userStatus: UserWithStatus = { + await broadcastStatusUpdates([{ user: userProfile, status: insertedStatus.status, created_at: insertedStatus.created_at, updated_by: userProfile, - }; + }]); - await broadcastStatusUpdates([userStatus]); return { success: true, data: undefined }; } catch (error) { return {