Last commit before I burn it all to the ground with ReactQuery

This commit is contained in:
2025-06-15 00:34:04 -05:00
parent d78c139ffb
commit 6c85c973b9
9 changed files with 786 additions and 167 deletions

View File

@ -31,6 +31,7 @@
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.50.0", "@supabase/supabase-js": "^2.50.0",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.80.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.510.0", "lucide-react": "^0.510.0",
@ -48,6 +49,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.10", "@tailwindcss/postcss": "^4.1.10",
"@tanstack/eslint-plugin-query": "^5.78.0",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/node": "^20.19.0", "@types/node": "^20.19.0",

34
pnpm-lock.yaml generated
View File

@ -50,6 +50,9 @@ importers:
'@t3-oss/env-nextjs': '@t3-oss/env-nextjs':
specifier: ^0.12.0 specifier: ^0.12.0
version: 0.12.0(typescript@5.8.3)(zod@3.25.64) 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: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@ -96,6 +99,9 @@ importers:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.1.10 specifier: ^4.1.10
version: 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': '@types/cors':
specifier: ^2.8.19 specifier: ^2.8.19
version: 2.8.19 version: 2.8.19
@ -1493,6 +1499,19 @@ packages:
'@tailwindcss/postcss@4.1.10': '@tailwindcss/postcss@4.1.10':
resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==} 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': '@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@ -4965,6 +4984,21 @@ snapshots:
postcss: 8.5.5 postcss: 8.5.5
tailwindcss: 4.1.10 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': '@tybys/wasm-util@0.9.0':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1

View File

@ -1,6 +1,6 @@
'use server'; 'use server';
import { TechTable } from '@/components/status'; import { StatusList, TechTable } from '@/components/status';
import { getUser, getRecentUsersWithStatuses } from '@/lib/actions'; import { getUser, getRecentUsersWithStatuses } from '@/lib/actions';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
@ -12,7 +12,8 @@ const Status = async () => {
const response = await getRecentUsersWithStatuses(); const response = await getRecentUsersWithStatuses();
if (!response.success) throw new Error(response.error); if (!response.success) throw new Error(response.error);
const usersWithStatuses = response.data; const usersWithStatuses = response.data;
return <TechTable initialStatuses={usersWithStatuses} />; //return <TechTable initialStatuses={usersWithStatuses} />;
return <StatusList initialStatuses={usersWithStatuses} />;
} }
}; };
export default Status; export default Status;

View File

@ -48,7 +48,7 @@ export const SignInWithMicrosoft = ({
if (!updateResponse.success) throw new Error('Could not update provider!'); 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 { } else {
setStatusMessage(`Error: Could not sign in with Microsoft!`); setStatusMessage(`Error: Could not sign in with Microsoft!`);
} }

View File

@ -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<string[]>([])
const [selectAll, setSelectAll] = useState(false)
const [updatingStatus, setUpdatingStatus] = useState(false)
const [statusInput, setStatusInput] = useState("")
const [usersWithStatuses, setUsersWithStatuses] = useState<UserWithStatus[]>(initialStatuses)
const [selectedHistoryUser, setSelectedHistoryUser] = useState<Profile | null>(null)
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting")
const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set())
const channelRef = useRef<RealtimeChannel | null>(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 <Wifi className="w-4 h-4 text-green-500" />
case "connecting":
return <Wifi className="w-4 h-4 text-yellow-500 animate-pulse" />
case "disconnected":
return <WifiOff className="w-4 h-4 text-red-500" />
}
}
const getConnectionText = () => {
switch (connectionStatus) {
case "connected":
return "Connected"
case "connecting":
return "Connecting..."
case "disconnected":
return "Disconnected"
}
}
if (loading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<Loading className="w-full" alpha={0.5} />
</div>
)
}
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 (
<div className={containerClassName}>
{/* Connection Status Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<h2 className={`font-bold ${tvMode ? "text-6xl" : "text-2xl"}`}>Tech Status</h2>
<Badge variant="outline" className="flex items-center gap-2">
{getConnectionIcon()}
<span className="text-xs">{getConnectionText()}</span>
</Badge>
</div>
{!tvMode && usersWithStatuses.length > 0 && (
<div className="flex items-center gap-2">
<Checkbox id="select-all" checked={selectAll} onCheckedChange={handleSelectAllChange} />
<label htmlFor="select-all" className="text-sm font-medium">
Select All ({selectedUsers.length} selected)
</label>
</div>
)}
</div>
{/* Status Cards */}
<div className="space-y-3">
{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 (
<Card
key={userWithStatus.user.id}
className={`
${cardClassName}
${isSelected ? "ring-2 ring-primary" : ""}
${isNewStatus ? "animate-pulse bg-primary/5 border-primary/20" : ""}
hover:bg-muted/50 cursor-pointer
`}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{!tvMode && (
<Checkbox
checked={isSelected}
onCheckedChange={() => handleCheckboxChange(userWithStatus.user.id)}
onClick={(e) => e.stopPropagation()}
/>
)}
<BasedAvatar
src={userWithStatus.user.avatar_url}
fullName={userWithStatus.user.full_name}
className={tvMode ? "w-16 h-16" : "w-12 h-12"}
/>
<div>
<h3 className={`font-semibold ${tvMode ? "text-5xl" : "text-lg"}`}>
{userWithStatus.user.full_name ?? "Unknown User"}
</h3>
{isUpdatedByOther && (
<div className="flex items-center gap-1 text-muted-foreground">
<BasedAvatar
src={userWithStatus.updated_by?.avatar_url}
fullName={userWithStatus.updated_by?.full_name}
className="w-3 h-3"
/>
{userWithStatus.updated_by && (
<span className={`text-xs ${tvMode ? "text-3xl" : ""}`}>
Updated by {userWithStatus.updated_by.full_name}
</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className={`${tvMode ? "w-8 h-8" : "w-4 h-4"}`} />
<span className={`text-sm ${tvMode ? "text-3xl" : ""}`}>
{formatTime(userWithStatus.created_at)}
</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<Drawer>
<DrawerTrigger asChild>
<div
className={`
p-4 rounded-lg bg-muted/30 hover:bg-muted/50
transition-colors cursor-pointer text-left
${tvMode ? "text-4xl" : "text-base"}
`}
onClick={() => setSelectedHistoryUser(userWithStatus.user)}
>
<p className="font-medium">{userWithStatus.status}</p>
</div>
</DrawerTrigger>
{selectedHistoryUser === userWithStatus.user && <HistoryDrawer user={selectedHistoryUser} />}
</Drawer>
</CardContent>
</Card>
)
})}
</div>
{usersWithStatuses.length === 0 && (
<Card className="p-8 text-center">
<p className={`text-muted-foreground ${tvMode ? "text-4xl" : "text-lg"}`}>No status updates yet</p>
</Card>
)}
{/* Status Update Input */}
{!tvMode && (
<Card className="p-6 mt-6">
<div className="flex flex-col gap-4">
<h3 className="text-lg font-semibold">Update Status</h3>
<div className="flex gap-4">
<Input
autoFocus
type="text"
placeholder="What's your status?"
className="flex-1 text-base"
value={statusInput}
onChange={(e) => 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}`)
})
}
}}
/>
<SubmitButton
onClick={updateStatus}
disabled={updatingStatus}
className="px-6"
>
{selectedUsers.length > 0 ? `Update ${selectedUsers.length} Users` : "Update Status"}
</SubmitButton>
</div>
{selectedUsers.length > 0 && (
<p className="text-sm text-muted-foreground">Updating status for {selectedUsers.length} selected users</p>
)}
</div>
</Card>
)}
{/* Global Status History Drawer */}
<div className="flex justify-center mt-6">
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline" className={tvMode ? "text-3xl p-6" : ""}>
View All Status History
</Button>
</DrawerTrigger>
<HistoryDrawer />
</Drawer>
</div>
</div>
)
}
//'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<UserWithStatus[]>([]);
//const [selectAll, setSelectAll] = useState(false);
//const [updatingStatus, setUpdatingStatus] = useState(false);
//const [statusInput, setStatusInput] = useState('');
//const [usersWithStatuses, setUsersWithStatuses] = useState<UserWithStatus[]>(initialStatuses);
//const [selectedHistoryUser, setSelectedHistoryUser] = useState<UserWithStatus | null>(null);
//const channelRef = useRef<RealtimeChannel | null>(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 (
//<div/>
//);
//};

View File

@ -32,14 +32,15 @@ export const TechTable = ({
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { tvMode } = useTVMode(); const { tvMode } = useTVMode();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
const [selectAll, setSelectAll] = useState(false); const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState(''); const [statusInput, setStatusInput] = useState('');
const [usersWithStatuses, setUsersWithStatuses] = const [usersWithStatuses, setUsersWithStatuses] =
useState<UserWithStatus[]>(initialStatuses); useState<UserWithStatus[]>(initialStatuses);
const [selectedHistoryUser, setSelectedHistoryUser] = const [selectedHistoryUser, setSelectedHistoryUser] =
useState<Profile | null>(null); useState<UserWithStatus | null>(null);
const channelRef = useRef<RealtimeChannel | null>(null); const channelRef = useRef<RealtimeChannel | null>(null);
const supabaseRef = useRef(createClient());
const fetchRecentUsersWithStatuses = useCallback(async () => { const fetchRecentUsersWithStatuses = useCallback(async () => {
try { try {
@ -74,28 +75,28 @@ export const TechTable = ({
return; return;
} }
try { try {
if (selectedIds.length === 0) { if (selectedUsers.length === 0) {
const result = await updateUserStatus(statusInput); const result = await updateUserStatus(statusInput);
if (!result.success) throw new Error(result.error); if (!result.success) throw new Error(result.error);
toast.success(`Status updated for signed in user.`); toast.success(`Status updated for signed in user.`);
} else { } else {
const result = await updateStatuses(selectedIds, statusInput); const result = await updateStatuses(selectedUsers, statusInput);
if (!result.success) throw new Error(result.error); if (!result.success) throw new Error(result.error);
toast.success( toast.success(
`Status updated for ${selectedIds.length} selected users.`, `Status updated for ${selectedUsers.length} selected users.`,
); );
} }
setSelectedIds([]); setSelectedUsers([]);
setStatusInput(''); setStatusInput('');
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
toast.error(`Failed to update status: ${errorMessage}`); toast.error(`Failed to update status: ${errorMessage}`);
} }
}, [isAuthenticated, statusInput, selectedIds]); }, [isAuthenticated, statusInput, selectedUsers]);
const handleCheckboxChange = (id: string) => { const handleCheckboxChange = (id: string) => {
setSelectedIds((prev) => setSelectedUsers((prev) =>
prev.includes(id) prev.includes(id)
? prev.filter((prevId) => prevId !== id) ? prev.filter((prevId) => prevId !== id)
: [...prev, id], : [...prev, id],
@ -104,27 +105,45 @@ export const TechTable = ({
const handleSelectAllChange = () => { const handleSelectAllChange = () => {
if (selectAll) { if (selectAll) {
setSelectedIds([]); setSelectedUsers([]);
} else { } else {
setSelectedIds(usersWithStatuses.map((tech) => tech.user.id)); setSelectedUsers(usersWithStatuses.map((tech) => tech.user.id));
} }
setSelectAll(!selectAll); setSelectAll(!selectAll);
}; };
useEffect(() => { useEffect(() => {
setSelectAll( setSelectAll(
selectedIds.length === usersWithStatuses.length && selectedUsers.length === usersWithStatuses.length &&
usersWithStatuses.length > 0, usersWithStatuses.length > 0,
); );
}, [selectedIds.length, usersWithStatuses.length]); }, [selectedUsers.length, usersWithStatuses.length]);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const supabase = createClient(); let reconnectAttempts = 0;
const channel = supabase const maxReconnectAttempts = 3; // Reduced from 5
.channel('status_updates', { let reconnectTimeout: NodeJS.Timeout;
config: { broadcast: { self: true }} 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...');
const channel = supabaseRef.current
.channel(`status_updates`, { // Unique channel name
config: {
broadcast: { self: true }
}
}) })
.on('broadcast', { event: 'status_updated' }, (payload) => { .on('broadcast', { event: 'status_updated' }, (payload) => {
const { user_status } = payload.payload as { const { user_status } = payload.payload as {
@ -141,39 +160,87 @@ export const TechTable = ({
if (existingUserIndex !== -1) { if (existingUserIndex !== -1) {
const updatedUsers = [...prevUsers]; const updatedUsers = [...prevUsers];
updatedUsers[existingUserIndex] = { updatedUsers[existingUserIndex] = {
user: user_status.user, // Use the user from the broadcast user: user_status.user,
status: user_status.status, status: user_status.status,
created_at: user_status.created_at, created_at: user_status.created_at,
updated_by: user_status.updated_by, updated_by: user_status.updated_by,
}; };
return updatedUsers; return updatedUsers;
} else { } else {
// Add new user to list!
return [user_status, ...prevUsers]; return [user_status, ...prevUsers];
} }
}); });
}) })
.subscribe((status) => { .subscribe((status) => {
console.log('Subscription status:', status);
// ignore this enum error please
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (status === 'SUBSCRIBED') { if (status === 'SUBSCRIBED') {
console.log('Successfully subscribed to status updates!'); console.log('Successfully subscribed to status updates!');
reconnectAttempts = 0;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison // 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') { } else if (status === 'CHANNEL_ERROR') {
console.error('Error subscribing to status updates.') console.error('Channel error - stopping reconnection attempts');
// Don't reconnect on channel errors to avoid infinite loops
} }
}); });
currentChannel = channel;
channelRef.current = channel; channelRef.current = channel;
};
// Add a small delay before initial connection to ensure auth is stable
const initialTimeout = setTimeout(() => {
if (isComponentMounted) {
setupRealtimeConnection();
}
}, 1000);
return () => { return () => {
if (channelRef.current) { isComponentMounted = false;
supabase.removeChannel(channelRef.current).catch((error) => {
console.error(`Error unsubscribing from status updates: ${error}`); if (initialTimeout) {
}); clearTimeout(initialTimeout);
channelRef.current = null;
} }
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 formatTime = (timestamp: string) => {
const date = new Date(timestamp); const date = new Date(timestamp);
@ -258,7 +325,7 @@ export const TechTable = ({
<input <input
type='checkbox' type='checkbox'
className={checkBoxClassName} className={checkBoxClassName}
checked={selectedIds.includes(userWithStatus.user.id)} checked={selectedUsers.includes(userWithStatus.user.id)}
onChange={() => onChange={() =>
handleCheckboxChange(userWithStatus.user.id) handleCheckboxChange(userWithStatus.user.id)
} }

View File

@ -1,2 +1,3 @@
export * from './HistoryDrawer'; export * from './HistoryDrawer';
export * from './StatusList';
export * from './TechTable'; export * from './TechTable';

View File

@ -24,6 +24,16 @@ type PaginatedHistory = {
export const getRecentUsersWithStatuses = async (): Promise< export const getRecentUsersWithStatuses = async (): Promise<
Result<UserWithStatus[]> Result<UserWithStatus[]>
> => { > => {
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 { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24); 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 (error) throw error as Error;
if (!data?.length) return { success: true, data: [] }; if (!data?.length) return { success: true, data: [] };
// 3⃣ client-side dedupe: keep the first status you see per user
const seen = new Set<string>(); const seen = new Set<string>();
const filtered = data.filter((row) => { const filtered = data.filter((row) => {
if (seen.has(row.user.id)) return false; if (seen.has(row.user.id)) return false;
@ -53,32 +64,16 @@ export const getRecentUsersWithStatuses = async (): Promise<
}); });
const filteredWithAvatars = new Array<UserWithStatus>(); const filteredWithAvatars = new Array<UserWithStatus>();
for (const userWithStatus of filtered) { for (const userWithStatus of filtered) {
if (userWithStatus.user.avatar_url)
if (userWithStatus.user.avatar_url) { userWithStatus.user.avatar_url =
const avatarResponse = await getSignedUrl({ await getAvatarUrl(userWithStatus.updated_by?.avatar_url);
bucket: 'avatars', if (userWithStatus.updated_by?.avatar_url)
url: userWithStatus.user.avatar_url, userWithStatus.user.avatar_url =
}); await getAvatarUrl(userWithStatus.updated_by?.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;
}
filteredWithAvatars.push(userWithStatus); filteredWithAvatars.push(userWithStatus);
} }
return { success: true, data: filteredWithAvatars }; return { success: true, data: filteredWithAvatars };
} catch (error) { } catch (error) {
return { success: false, error: `Error: ${error as Error}` }; return { success: false, error: `Error: ${error as Error}` };
@ -115,41 +110,36 @@ export const broadcastStatusUpdates = async (
}; };
export const updateStatuses = async ( export const updateStatuses = async (
userIds: string[], usersWithStatuses: UserWithStatus[],
status: string, status: string,
): Promise<Result<void>> => { ): Promise<Result<void>> => {
try { try {
const supabase = await createServerClient(); const supabase = await createServerClient();
const profileResponse = await getProfile(); const profileResponse = await getProfile();
if (!profileResponse.success) throw new Error('Not authenticated!'); if (!profileResponse.success) throw new Error('Not authenticated!');
const userProfile = profileResponse.data; const user = profileResponse.data;
const inserts = userIds.map((userId) => ({ const {
user_id: userId, data: insertedStatuses,
error: insertedStatusesError
} = await supabase
.from('statuses')
.insert(usersWithStatuses.map((userWithStatus) => ({
user_id: userWithStatus.user.id,
status, status,
updated_by_id: userProfile.id, updated_by_id: user.id,
})); })))
.select();
const { data: insertedStatuses, error: insertedStatusesError } = if (insertedStatusesError) throw new Error('Couldn\'t insert statuses!');
await supabase.from('statuses').insert(inserts).select(); else if (insertedStatuses) {
if (insertedStatusesError) throw insertedStatusesError as Error; const createdAtFallback = new Date(Date.now()).toISOString();
await broadcastStatusUpdates(usersWithStatuses.map((s, i) => { return {
if (insertedStatuses) { user: s.user,
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length); status: status,
for (const insertedStatus of insertedStatuses) { created_at: insertedStatuses[i]?.created_at ?? createdAtFallback,
const profileResponse = await getProfile(insertedStatus.user_id) updated_by: user,
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);
} }
return { success: true, data: undefined }; return { success: true, data: undefined };
} catch (error) { } catch (error) {
@ -181,14 +171,13 @@ export const updateUserStatus = async (
.single(); .single();
if (insertedStatusError) throw insertedStatusError as Error; if (insertedStatusError) throw insertedStatusError as Error;
const userStatus: UserWithStatus = { await broadcastStatusUpdates([{
user: userProfile, user: userProfile,
status: insertedStatus.status, status: insertedStatus.status,
created_at: insertedStatus.created_at, created_at: insertedStatus.created_at,
updated_by: userProfile, updated_by: userProfile,
}; }]);
await broadcastStatusUpdates([userStatus]);
return { success: true, data: undefined }; return { success: true, data: undefined };
} catch (error) { } catch (error) {
return { return {

View File

@ -24,6 +24,16 @@ type PaginatedHistory = {
export const getRecentUsersWithStatuses = async (): Promise< export const getRecentUsersWithStatuses = async (): Promise<
Result<UserWithStatus[]> Result<UserWithStatus[]>
> => { > => {
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 { try {
const supabase = createClient(); const supabase = createClient();
const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24); const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);
@ -54,32 +64,16 @@ export const getRecentUsersWithStatuses = async (): Promise<
}); });
const filteredWithAvatars = new Array<UserWithStatus>(); const filteredWithAvatars = new Array<UserWithStatus>();
for (const userWithStatus of filtered) { for (const userWithStatus of filtered) {
if (userWithStatus.user.avatar_url)
if (userWithStatus.user.avatar_url) { userWithStatus.user.avatar_url =
const avatarResponse = await getSignedUrl({ await getAvatarUrl(userWithStatus.updated_by?.avatar_url);
bucket: 'avatars', if (userWithStatus.updated_by?.avatar_url)
url: userWithStatus.user.avatar_url, userWithStatus.user.avatar_url =
}); await getAvatarUrl(userWithStatus.updated_by?.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;
}
filteredWithAvatars.push(userWithStatus); filteredWithAvatars.push(userWithStatus);
} }
return { success: true, data: filteredWithAvatars }; return { success: true, data: filteredWithAvatars };
} catch (error) { } catch (error) {
return { success: false, error: `Error: ${error as Error}` }; return { success: false, error: `Error: ${error as Error}` };
@ -116,41 +110,36 @@ export const broadcastStatusUpdates = async (
}; };
export const updateStatuses = async ( export const updateStatuses = async (
userIds: string[], usersWithStatuses: UserWithStatus[],
status: string, status: string,
): Promise<Result<void>> => { ): Promise<Result<void>> => {
try { try {
const supabase = createClient(); const supabase = createClient();
const profileResponse = await getProfile(); const profileResponse = await getProfile();
if (!profileResponse.success) throw new Error('Not authenticated!'); if (!profileResponse.success) throw new Error('Not authenticated!');
const userProfile = profileResponse.data; const user = profileResponse.data;
const inserts = userIds.map((userId) => ({ const {
user_id: userId, data: insertedStatuses,
error: insertedStatusesError
} = await supabase
.from('statuses')
.insert(usersWithStatuses.map((userWithStatus) => ({
user_id: userWithStatus.user.id,
status, status,
updated_by_id: userProfile.id, updated_by_id: user.id,
})); })))
.select();
const { data: insertedStatuses, error: insertedStatusesError } = if (insertedStatusesError) throw new Error('Couldn\'t insert statuses!');
await supabase.from('statuses').insert(inserts).select(); else if (insertedStatuses) {
if (insertedStatusesError) throw insertedStatusesError as Error; const createdAtFallback = new Date(Date.now()).toISOString();
await broadcastStatusUpdates(usersWithStatuses.map((s, i) => { return {
if (insertedStatuses) { user: s.user,
const broadcastArray = new Array<UserWithStatus>(insertedStatuses.length); status: status,
for (const insertedStatus of insertedStatuses) { created_at: insertedStatuses[i]?.created_at ?? createdAtFallback,
const profileResponse = await getProfile(insertedStatus.user_id) updated_by: user,
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);
} }
return { success: true, data: undefined }; return { success: true, data: undefined };
} catch (error) { } catch (error) {
@ -182,14 +171,13 @@ export const updateUserStatus = async (
.single(); .single();
if (insertedStatusError) throw insertedStatusError as Error; if (insertedStatusError) throw insertedStatusError as Error;
const userStatus: UserWithStatus = { await broadcastStatusUpdates([{
user: userProfile, user: userProfile,
status: insertedStatus.status, status: insertedStatus.status,
created_at: insertedStatus.created_at, created_at: insertedStatus.created_at,
updated_by: userProfile, updated_by: userProfile,
}; }]);
await broadcastStatusUpdates([userStatus]);
return { success: true, data: undefined }; return { success: true, data: undefined };
} catch (error) { } catch (error) {
return { return {