Finding a stopping point :(

This commit is contained in:
2025-06-15 13:27:48 -05:00
parent 6c85c973b9
commit bc915275cf
15 changed files with 863 additions and 624 deletions

View File

@ -40,7 +40,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.58.0",
"require-in-the-middle": "^7.5.2", "require-in-the-middle": "^7.5.2",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"vaul": "^1.1.2", "vaul": "^1.1.2",

16
pnpm-lock.yaml generated
View File

@ -10,7 +10,7 @@ importers:
dependencies: dependencies:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.1.1 specifier: ^5.1.1
version: 5.1.1(react-hook-form@7.57.0(react@19.1.0)) version: 5.1.1(react-hook-form@7.58.0(react@19.1.0))
'@radix-ui/react-avatar': '@radix-ui/react-avatar':
specifier: ^1.1.10 specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -78,8 +78,8 @@ importers:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.1.0(react@19.1.0) version: 19.1.0(react@19.1.0)
react-hook-form: react-hook-form:
specifier: ^7.57.0 specifier: ^7.58.0
version: 7.57.0(react@19.1.0) version: 7.58.0(react@19.1.0)
require-in-the-middle: require-in-the-middle:
specifier: ^7.5.2 specifier: ^7.5.2
version: 7.5.2 version: 7.5.2
@ -3108,8 +3108,8 @@ packages:
peerDependencies: peerDependencies:
react: ^19.1.0 react: ^19.1.0
react-hook-form@7.57.0: react-hook-form@7.58.0:
resolution: {integrity: sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==} resolution: {integrity: sha512-zGijmEed35oNfOfy7ub99jfjkiLhHwA3dl5AgyKdWC6QQzhnc7tkWewSa+T+A2EpLrc6wo5DUoZctS9kufWJjA==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
@ -3804,10 +3804,10 @@ snapshots:
'@floating-ui/utils@0.2.9': {} '@floating-ui/utils@0.2.9': {}
'@hookform/resolvers@5.1.1(react-hook-form@7.57.0(react@19.1.0))': '@hookform/resolvers@5.1.1(react-hook-form@7.58.0(react@19.1.0))':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
react-hook-form: 7.57.0(react@19.1.0) react-hook-form: 7.58.0(react@19.1.0)
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
@ -6708,7 +6708,7 @@ snapshots:
react: 19.1.0 react: 19.1.0
scheduler: 0.26.0 scheduler: 0.26.0
react-hook-form@7.57.0(react@19.1.0): react-hook-form@7.58.0(react@19.1.0):
dependencies: dependencies:
react: 19.1.0 react: 19.1.0

View File

@ -4,6 +4,7 @@ import { Geist } from 'next/font/google';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
AuthProvider, AuthProvider,
QueryProvider,
ThemeProvider, ThemeProvider,
TVModeProvider, TVModeProvider,
} from '@/components/context'; } from '@/components/context';
@ -385,6 +386,7 @@ const geist = Geist({
variable: '--font-geist-sans', variable: '--font-geist-sans',
}); });
const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return ( return (
<html lang='en' className={`${geist.variable}`} suppressHydrationWarning> <html lang='en' className={`${geist.variable}`} suppressHydrationWarning>
@ -397,6 +399,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<QueryProvider>
<AuthProvider> <AuthProvider>
<PlausibleProvider <PlausibleProvider
domain='techtracker.gbrown.org' domain='techtracker.gbrown.org'
@ -414,6 +417,7 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
</TVModeProvider> </TVModeProvider>
</PlausibleProvider> </PlausibleProvider>
</AuthProvider> </AuthProvider>
</QueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@ -8,22 +8,7 @@ const Home = async () => {
if (!userResponse.success) { if (!userResponse.success) {
redirect('/sign-in'); redirect('/sign-in');
} else if (userResponse.data) { } else if (userResponse.data) {
redirect('/status'); redirect('/status/list');
} else return <div />; } else return <div />;
}; };
export default Home; export default Home;
//'use client';
////import { TechTable } from '@/components/status';
//import { redirect } from 'next/navigation';
//import { useAuth } from '@/components/context';
//const HomePage = () => {
//const { isAuthenticated } = useAuth();
//if (!isAuthenticated) {
//redirect('/sign-in');
//}
//redirect('/profile');
//};
//export default HomePage;

View File

@ -0,0 +1,16 @@
import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => {
return {
title: 'Status List'
};
};
const SignInLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<div>
{children}
</div>
);
};
export default SignInLayout;

View File

@ -1,6 +1,6 @@
'use server'; 'use server';
import { StatusList, TechTable } from '@/components/status'; import { StatusList } 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,6 @@ 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 <StatusList initialStatuses={usersWithStatuses} />; return <StatusList initialStatuses={usersWithStatuses} />;
} }
}; };

View File

@ -1,3 +1,4 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
export const generateMetadata = (): Metadata => { export const generateMetadata = (): Metadata => {
@ -6,11 +7,11 @@ export const generateMetadata = (): Metadata => {
}; };
}; };
const StatusLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { const SignInLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return ( return (
<div> <div>
{children} {children}
</div> </div>
); );
}; };
export default StatusLayout; export default SignInLayout;

View File

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

View File

@ -0,0 +1,77 @@
// src/components/providers/query-provider.tsx
'use client';
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query';
import { useState } from 'react';
import { toast } from 'sonner';
// Define error codes for different types of errors
export const enum QueryErrorCodes {
USERS_FETCH_FAILED = 'USERS_FETCH_FAILED',
STATUS_UPDATE_FAILED = 'STATUS_UPDATE_FAILED',
// Add more as needed
}
const queryCacheOnError = (error: unknown, query: any) => {
const errorMessage = error instanceof Error ? error.message : 'Something went wrong';
switch (query.meta?.errCode) {
case QueryErrorCodes.USERS_FETCH_FAILED:
// Don't show toast for user fetch errors - handle in component
break;
default:
// Only show generic errors for unexpected failures
console.error('Query error:', error);
break;
}
};
const mutationCacheOnError = (error: unknown, variables: unknown, context: unknown, mutation: any) => {
const errorMessage = error instanceof Error ? error.message : 'Something went wrong';
switch (mutation.meta?.errCode) {
case QueryErrorCodes.STATUS_UPDATE_FAILED:
toast.error(`Failed to update status: ${errorMessage}`);
break;
default:
toast.error(`Operation failed: ${errorMessage}`);
break;
}
};
type QueryProviderProps = {
children: React.ReactNode;
};
export const QueryProvider = ({ children }: QueryProviderProps) => {
const [queryClient] = useState(() => new QueryClient({
queryCache: new QueryCache({
onError: queryCacheOnError,
}),
mutationCache: new MutationCache({
onError: mutationCacheOnError,
}),
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30 seconds
refetchOnWindowFocus: true,
retry: (failureCount, error) => {
// Don't retry on 4xx errors
if (error instanceof Error && error.message.includes('4')) {
return false;
}
return failureCount < 3;
},
},
mutations: {
retry: 1,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};

View File

@ -1,3 +1,4 @@
export { AuthProvider, useAuth } from './Auth'; export { AuthProvider, useAuth } from './Auth';
export { ThemeProvider, ThemeToggle } from './Theme'; export { ThemeProvider, ThemeToggle } from './Theme';
export { TVModeProvider, useTVMode, TVToggle } from './TVMode'; export { TVModeProvider, useTVMode, TVToggle } from './TVMode';
export * from './Query';

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 + `?provider=${result.data.provider}`; window.location.href = result.data.url;
} else { } else {
setStatusMessage(`Error: Could not sign in with Microsoft!`); setStatusMessage(`Error: Could not sign in with Microsoft!`);
} }

View File

@ -1,7 +1,5 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react"
import { createClient } from "@/utils/supabase"
import { useState, useEffect, useCallback, useRef } from "react"
import { useAuth, useTVMode } from "@/components/context" import { useAuth, useTVMode } from "@/components/context"
import { getRecentUsersWithStatuses, updateStatuses, updateUserStatus, type UserWithStatus } from "@/lib/hooks" import { getRecentUsersWithStatuses, updateStatuses, updateUserStatus, type UserWithStatus } from "@/lib/hooks"
import { BasedAvatar, Drawer, DrawerTrigger, Loading } from "@/components/ui" import { BasedAvatar, Drawer, DrawerTrigger, Loading } from "@/components/ui"
@ -9,182 +7,107 @@ import { SubmitButton } from "@/components/default"
import { toast } from "sonner" import { toast } from "sonner"
import { HistoryDrawer } from "@/components/status" import { HistoryDrawer } from "@/components/status"
import type { Profile } from "@/utils/supabase" import type { Profile } from "@/utils/supabase"
import type { RealtimeChannel } from "@supabase/supabase-js"
import { makeConditionalClassName } from "@/lib/utils" import { makeConditionalClassName } from "@/lib/utils"
import { Card, CardContent, CardHeader } from "@/components/ui/card" import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Wifi, WifiOff, Clock, User } from "lucide-react" import { RefreshCw, Clock, Wifi, WifiOff } from "lucide-react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QueryErrorCodes } from "@/components/context";
import { createClient } from "@/utils/supabase";
import type { RealtimeChannel } from '@supabase/supabase-js';
type StatusListProps = { type StatusListProps = {
initialStatuses: UserWithStatus[] initialStatuses: UserWithStatus[]
} }
export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { export const StatusList = ({ initialStatuses = [] }: StatusListProps) => { // Fixed props destructuring
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth();
const { tvMode } = useTVMode() const { tvMode } = useTVMode();
const [loading, setLoading] = useState(true) const queryClient = useQueryClient();
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [selectAll, setSelectAll] = useState(false) const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]);
const [updatingStatus, setUpdatingStatus] = useState(false) const [selectAll, setSelectAll] = useState(false);
const [statusInput, setStatusInput] = useState("") const [statusInput, setStatusInput] = useState('');
const [usersWithStatuses, setUsersWithStatuses] = useState<UserWithStatus[]>(initialStatuses) const [selectedHistoryUser, setSelectedHistoryUser] = useState<Profile | null>(null);
const [selectedHistoryUser, setSelectedHistoryUser] = useState<Profile | null>(null)
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting") const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting")
const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set()) const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set());
const channelRef = useRef<RealtimeChannel | null>(null) const channelRef = useRef<RealtimeChannel | null>(null);
const supabaseRef = useRef(createClient()) const supabaseRef = useRef(createClient());
const fetchRecentUsersWithStatuses = useCallback(async () => {
// Keep all your existing React Query code exactly as is
const {
data: usersWithStatuses = initialStatuses,
isLoading: loading,
error,
refetch,
isFetching,
dataUpdatedAt,
} = useQuery({
queryKey: ['users-with-statuses'],
queryFn: async () => {
try { try {
const response = await getRecentUsersWithStatuses() const response = await getRecentUsersWithStatuses();
if (!response.success) throw new Error(response.error) if (!response.success) throw new Error(response.error);
return response.data return response.data;
} catch (error) { } catch (error) {
toast.error(`Error fetching technicians: ${error as Error}`) toast.error(`Error fetching technicians: ${error instanceof Error ? error.message : 'Unknown error'}`);
return [] throw error;
} }
}, []) },
enabled: isAuthenticated,
refetchInterval: 30000, // Changed to 30 seconds as backup
refetchOnWindowFocus: true,
refetchOnMount: true,
initialData: initialStatuses,
meta: { errCode: QueryErrorCodes.USERS_FETCH_FAILED },
});
// Initial load // Add this new useEffect for realtime enhancement
useEffect(() => { useEffect(() => {
const loadData = async () => { if (!isAuthenticated) return;
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 let reconnectAttempts = 0
const maxReconnectAttempts = 3 const maxReconnectAttempts = 3
let reconnectTimeout: NodeJS.Timeout let reconnectTimeout: NodeJS.Timeout
let isComponentMounted = true let isComponentMounted = true
let currentChannel: RealtimeChannel | null = null let currentChannel: RealtimeChannel | null = null;
const setupRealtimeConnection = () => { const setUpRealtimeConnection = () => {
if (!isComponentMounted) return if (!isComponentMounted) return
// Clean up any existing channel first
if (currentChannel) { if (currentChannel) {
supabaseRef.current.removeChannel(currentChannel).catch(console.error) supabaseRef.current.removeChannel(currentChannel).catch((error) => {
currentChannel = null console.error(`Error unsubscribing: ${error}`);
});
currentChannel = null;
} }
setConnectionStatus('connecting');
console.log("Setting up new realtime connection...")
setConnectionStatus("connecting")
const channel = supabaseRef.current const channel = supabaseRef.current
.channel(`status_updates`, { .channel('status_updates')
config: { .on('broadcast', { event: 'status_updated' }, (payload) => {
broadcast: { self: true }, console.log('Realtime update received, triggering refetch...');
}, refetch().catch((error) => {
}) console.error(`Error refetching: ${error}`);
.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) => { .subscribe((status) => {
console.log("Subscription status:", status)
// 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('Realtime connection established');
setConnectionStatus("connected") setConnectionStatus('connected');
reconnectAttempts = 0 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") { } else if (status === 'CHANNEL_ERROR') {
console.log("Connection closed") console.log('Realtime connection failed, relying on polling');
setConnectionStatus("disconnected") setConnectionStatus('disconnected');
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (status === 'CLOSED') {
console.log('Realtime connection closed');
setConnectionStatus('disconnected');
if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) { if (isComponentMounted && reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++ reconnectAttempts++
const delay = 2000 * reconnectAttempts const delay = 2000 * reconnectAttempts
@ -197,62 +120,153 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => {
reconnectTimeout = setTimeout(() => { reconnectTimeout = setTimeout(() => {
if (isComponentMounted) { if (isComponentMounted) {
setupRealtimeConnection() setUpRealtimeConnection()
} }
}, delay) }, delay)
} else { } else {
console.log("Max reconnection attempts reached or component unmounted") 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;
currentChannel = channel channelRef.current = channel;
channelRef.current = channel
} }
const initialTimeout = setTimeout(() => { const initialTimeout = setTimeout(() => {
if (isComponentMounted) { if (isComponentMounted) {
setupRealtimeConnection() setUpRealtimeConnection()
} }
}, 1000) }, 1000)
return () => { return () => {
isComponentMounted = false isComponentMounted = false
if (initialTimeout)
if (initialTimeout) {
clearTimeout(initialTimeout) clearTimeout(initialTimeout)
} if (reconnectTimeout)
if (reconnectTimeout) {
clearTimeout(reconnectTimeout) clearTimeout(reconnectTimeout)
}
if (currentChannel) { if (currentChannel) {
console.log("Cleaning up realtime connection...") console.log('Cleaning up realtime connection...');
supabaseRef.current.removeChannel(currentChannel).catch((error) => { supabaseRef.current.removeChannel(currentChannel).catch((error) => {
console.error(`Error unsubscribing: ${error}`) console.error(`Error unsubscribing: ${error}`);
}) });
channelRef.current = null;
}
};
}, [isAuthenticated, refetch]);
// Updated mutation
const updateStatusMutation = useMutation({
mutationFn: async ({ usersWithStatuses, status }: { usersWithStatuses: UserWithStatus[], status: string }) => {
if (usersWithStatuses.length === 0) {
const result = await updateUserStatus(status);
if (!result.success) throw new Error(result.error);
return { type: 'single', result };
} else {
const result = await updateStatuses(usersWithStatuses, status);
if (!result.success) throw new Error(result.error);
return { type: 'multiple', result, count: usersWithStatuses.length };
}
},
meta: { errCode: QueryErrorCodes.STATUS_UPDATE_FAILED },
onMutate: async ({ usersWithStatuses, status }) => {
// Optimistic update logic
await queryClient.cancelQueries({ queryKey: ['users-with-statuses'] });
const previousData = queryClient.getQueryData<UserWithStatus[]>(['users-with-statuses']);
if (previousData && usersWithStatuses.length > 0) {
const now = new Date().toISOString();
const optimisticData = previousData.map(userStatus => {
if (usersWithStatuses.some(selected => selected.user.id === userStatus.user.id)) {
return {
...userStatus,
status,
created_at: now,
};
}
return userStatus;
});
queryClient.setQueryData(['users-with-statuses'], optimisticData);
// Add animation for optimistic updates
const updatedIds = usersWithStatuses.map(u => u.user.id);
setNewStatusIds(prev => new Set([...prev, ...updatedIds]));
// Remove animation after 1 second
setTimeout(() => {
setNewStatusIds(prev => {
const updated = new Set(prev);
updatedIds.forEach(id => updated.delete(id));
return updated;
});
}, 1000);
} }
channelRef.current = null return { previousData };
} },
}, [isAuthenticated]) onSuccess: (data) => {
// Handle success in the mutation function
void queryClient.invalidateQueries({ queryKey: ['users-with-statuses'] }); // Fixed floating promise
const formatTime = (timestamp: string) => { if (data.type === 'single') {
const date = new Date(timestamp) toast.success('Status updated for signed in user.');
const time = date.toLocaleTimeString("en-US", { } else {
hour: "numeric", toast.success(`Status updated for ${data.count} selected users.`);
minute: "numeric",
})
const day = date.getDate()
const month = date.toLocaleString("default", { month: "long" })
return `${time} - ${month} ${day}`
} }
setSelectedUsers([]);
setStatusInput('');
},
onError: (error, _variables, context) => { // Fixed unused variables
// Rollback optimistic update
if (context?.previousData) {
queryClient.setQueryData(['users-with-statuses'], context.previousData);
}
// Error handling is done in the global mutation cache
console.error('Status update failed:', error);
},
});
const handleUpdateStatus = () => {
if (!isAuthenticated) {
toast.error('You must be signed in to update statuses.');
return;
}
if (!statusInput.trim()) {
toast.error('Please enter a valid status.');
return;
}
updateStatusMutation.mutate({
usersWithStatuses: selectedUsers,
status: statusInput.trim()
});
};
const handleCheckboxChange = (user: UserWithStatus) => {
setSelectedUsers((prev) =>
prev.some(u => u.user.id === user.user.id)
? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
: [...prev, user]
);
};
const handleSelectAllChange = () => {
if (selectAll) {
setSelectedUsers([]);
} else {
setSelectedUsers(usersWithStatuses);
}
setSelectAll(!selectAll);
};
useEffect(() => {
setSelectAll(
selectedUsers.length === usersWithStatuses.length &&
usersWithStatuses.length > 0
);
}, [selectedUsers.length, usersWithStatuses.length]);
const getConnectionIcon = () => { const getConnectionIcon = () => {
switch (connectionStatus) { switch (connectionStatus) {
case "connected": case "connected":
@ -275,44 +289,79 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => {
} }
} }
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}`;
};
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center min-h-[400px]"> <div className='flex justify-center items-center min-h-[400px]'>
<Loading className="w-full" alpha={0.5} /> <Loading className='w-full' alpha={0.5} />
</div> </div>
) );
}
if (error) {
return (
<div className='flex flex-col justify-center items-center min-h-[400px] gap-4'>
<p className='text-red-500'>Error loading status updates</p>
<Button onClick={() => refetch()} variant='outline'>
<RefreshCw className='w-4 h-4 mr-2' />
Retry
</Button>
</div>
);
} }
const containerClassName = makeConditionalClassName({ const containerClassName = makeConditionalClassName({
context: tvMode, context: tvMode,
defaultClassName: "mx-auto space-y-4", defaultClassName: 'mx-auto space-y-4',
on: "lg:w-11/12 w-full mt-15", on: 'lg:w-11/12 w-full mt-15',
off: "w-5/6", off: 'w-5/6',
}) });
const cardClassName = makeConditionalClassName({ const cardClassName = makeConditionalClassName({
context: tvMode, context: tvMode,
defaultClassName: "transition-all duration-300 hover:shadow-md", defaultClassName: 'transition-all duration-300 hover:shadow-md',
on: "lg:text-4xl", on: 'lg:text-4xl',
off: "lg:text-base", off: 'lg:text-base',
}) });
return ( return (
<div className={containerClassName}> <div className={containerClassName}>
{/* Connection Status Header */} {/* Status Header */}
<div className="flex items-center justify-between mb-6"> <div className='flex items-center justify-between mb-6'>
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<h2 className={`font-bold ${tvMode ? "text-6xl" : "text-2xl"}`}>Tech Status</h2> <h2 className={`font-bold ${tvMode ? 'text-6xl' : 'text-2xl'}`}>
Tech Status
</h2>
{isFetching ? (
<Badge variant="outline" className="flex items-center gap-2">
<RefreshCw className='w-4 h-4 animate-spin text-blue-500' />
<span className='text-xs'>Updating...</span>
</Badge>
) : (
<Badge variant="outline" className="flex items-center gap-2"> <Badge variant="outline" className="flex items-center gap-2">
{getConnectionIcon()} {getConnectionIcon()}
<span className="text-xs">{getConnectionText()}</span> <span className='text-xs'>{getConnectionText()}</span>
</Badge> </Badge>
)}
</div> </div>
{!tvMode && usersWithStatuses.length > 0 && ( {!tvMode && usersWithStatuses.length > 0 && (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<Checkbox id="select-all" checked={selectAll} onCheckedChange={handleSelectAllChange} /> <Checkbox
<label htmlFor="select-all" className="text-sm font-medium"> id='select-all'
checked={selectAll}
onCheckedChange={handleSelectAllChange}
/>
<label htmlFor='select-all' className='text-sm font-medium'>
Select All ({selectedUsers.length} selected) Select All ({selectedUsers.length} selected)
</label> </label>
</div> </div>
@ -320,50 +369,51 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => {
</div> </div>
{/* Status Cards */} {/* Status Cards */}
<div className="space-y-3"> <div className='space-y-3'>
{usersWithStatuses.map((userWithStatus) => { {usersWithStatuses.map((userWithStatus) => {
const isSelected = selectedUsers.includes(userWithStatus.user.id) const isSelected = selectedUsers.some(u => u.user.id === userWithStatus.user.id);
const isNewStatus = newStatusIds.has(userWithStatus.user.id) const isNewStatus = newStatusIds.has(userWithStatus.user.id);
const isUpdatedByOther = userWithStatus.updated_by && userWithStatus.updated_by.id !== userWithStatus.user.id const isUpdatedByOther = userWithStatus.updated_by &&
userWithStatus.updated_by.id !== userWithStatus.user.id;
return ( return (
<Card <Card
key={userWithStatus.user.id} key={userWithStatus.user.id}
className={` className={`
${cardClassName} ${cardClassName}
${isSelected ? "ring-2 ring-primary" : ""} ${isSelected ? 'ring-2 ring-primary' : ''}
${isNewStatus ? "animate-pulse bg-primary/5 border-primary/20" : ""} ${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
hover:bg-muted/50 cursor-pointer hover:bg-muted/50 cursor-pointer
`} `}
> >
<CardHeader className="pb-3"> <CardHeader className='pb-3'>
<div className="flex items-center justify-between"> <div className='flex items-center justify-between'>
<div className="flex items-center gap-3"> <div className='flex items-center gap-3'>
{!tvMode && ( {!tvMode && (
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={() => handleCheckboxChange(userWithStatus.user.id)} onCheckedChange={() => handleCheckboxChange(userWithStatus)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
)} )}
<BasedAvatar <BasedAvatar
src={userWithStatus.user.avatar_url} src={userWithStatus.user.avatar_url}
fullName={userWithStatus.user.full_name} fullName={userWithStatus.user.full_name}
className={tvMode ? "w-16 h-16" : "w-12 h-12"} className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
/> />
<div> <div>
<h3 className={`font-semibold ${tvMode ? "text-5xl" : "text-lg"}`}> <h3 className={`font-semibold ${tvMode ? 'text-5xl' : 'text-lg'}`}>
{userWithStatus.user.full_name ?? "Unknown User"} {userWithStatus.user.full_name ?? 'Unknown User'}
</h3> </h3>
{isUpdatedByOther && ( {isUpdatedByOther && (
<div className="flex items-center gap-1 text-muted-foreground"> <div className='flex items-center gap-1 text-muted-foreground'>
<BasedAvatar <BasedAvatar
src={userWithStatus.updated_by?.avatar_url} src={userWithStatus.updated_by?.avatar_url}
fullName={userWithStatus.updated_by?.full_name} fullName={userWithStatus.updated_by?.full_name}
className="w-3 h-3" className='w-3 h-3'
/> />
{userWithStatus.updated_by && ( {userWithStatus.updated_by && (
<span className={`text-xs ${tvMode ? "text-3xl" : ""}`}> <span className={`text-xs ${tvMode ? 'text-3xl' : ''}`}>
Updated by {userWithStatus.updated_by.full_name} Updated by {userWithStatus.updated_by.full_name}
</span> </span>
)} )}
@ -371,84 +421,94 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-muted-foreground"> <div className='flex items-center gap-2 text-muted-foreground'>
<Clock className={`${tvMode ? "w-8 h-8" : "w-4 h-4"}`} /> <Clock className={`${tvMode ? 'w-8 h-8' : 'w-4 h-4'}`} />
<span className={`text-sm ${tvMode ? "text-3xl" : ""}`}> <span className={`text-sm ${tvMode ? 'text-3xl' : ''}`}>
{formatTime(userWithStatus.created_at)} {formatTime(userWithStatus.created_at)}
</span> </span>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className='pt-0'>
<Drawer> <Drawer>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<div <div
className={` className={`
p-4 rounded-lg bg-muted/30 hover:bg-muted/50 p-4 rounded-lg bg-muted/30 hover:bg-muted/50
transition-colors cursor-pointer text-left transition-colors cursor-pointer text-left
${tvMode ? "text-4xl" : "text-base"} ${tvMode ? 'text-4xl' : 'text-base'}
`} `}
onClick={() => setSelectedHistoryUser(userWithStatus.user)} onClick={() => setSelectedHistoryUser(userWithStatus.user)}
> >
<p className="font-medium">{userWithStatus.status}</p> <p className='font-medium'>{userWithStatus.status}</p>
</div> </div>
</DrawerTrigger> </DrawerTrigger>
{selectedHistoryUser === userWithStatus.user && <HistoryDrawer user={selectedHistoryUser} />} {selectedHistoryUser === userWithStatus.user && (
<HistoryDrawer user={selectedHistoryUser} />
)}
</Drawer> </Drawer>
</CardContent> </CardContent>
</Card> </Card>
) );
})} })}
</div> </div>
{usersWithStatuses.length === 0 && ( {usersWithStatuses.length === 0 && (
<Card className="p-8 text-center"> <Card className='p-8 text-center'>
<p className={`text-muted-foreground ${tvMode ? "text-4xl" : "text-lg"}`}>No status updates yet</p> <p className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}>
No status updates yet
</p>
</Card> </Card>
)} )}
{/* Status Update Input */} {/* Status Update Input */}
{!tvMode && ( {!tvMode && (
<Card className="p-6 mt-6"> <Card className='p-6 mt-6'>
<div className="flex flex-col gap-4"> <div className='flex flex-col gap-4'>
<h3 className="text-lg font-semibold">Update Status</h3> <h3 className='text-lg font-semibold'>Update Status</h3>
<div className="flex gap-4"> <div className='flex gap-4'>
<Input <Input
autoFocus autoFocus
type="text" type='text'
placeholder="What's your status?" placeholder="What's your status?"
className="flex-1 text-base" className='flex-1 text-base'
value={statusInput} value={statusInput}
onChange={(e) => setStatusInput(e.target.value)} onChange={(e) => setStatusInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey && !updateStatusMutation.isPending) {
e.preventDefault() e.preventDefault();
updateStatus().catch((error) => { handleUpdateStatus();
toast.error(`Failed to update status: ${error as Error}`)
})
} }
}} }}
disabled={updateStatusMutation.isPending}
/> />
<SubmitButton <SubmitButton
onClick={updateStatus} onClick={handleUpdateStatus}
disabled={updatingStatus} disabled={updateStatusMutation.isPending}
className="px-6" className='px-6'
> >
{selectedUsers.length > 0 ? `Update ${selectedUsers.length} Users` : "Update Status"} {updateStatusMutation.isPending
? 'Updating...'
: selectedUsers.length > 0
? `Update ${selectedUsers.length} Users`
: 'Update Status'
}
</SubmitButton> </SubmitButton>
</div> </div>
{selectedUsers.length > 0 && ( {selectedUsers.length > 0 && (
<p className="text-sm text-muted-foreground">Updating status for {selectedUsers.length} selected users</p> <p className='text-sm text-muted-foreground'>
Updating status for {selectedUsers.length} selected users
</p>
)} )}
</div> </div>
</Card> </Card>
)} )}
{/* Global Status History Drawer */} {/* Global Status History Drawer */}
<div className="flex justify-center mt-6"> <div className='flex justify-center mt-6'>
<Drawer> <Drawer>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button variant="outline" className={tvMode ? "text-3xl p-6" : ""}> <Button variant='outline' className={tvMode ? 'text-3xl p-6' : ''}>
View All Status History View All Status History
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>
@ -456,82 +516,5 @@ export const StatusList = ({ initialStatuses = [] }: StatusListProps) => {
</Drawer> </Drawer>
</div> </div>
</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

@ -1,7 +1,5 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react';
import { createClient } from '@/utils/supabase';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAuth, useTVMode } from '@/components/context'; import { useAuth, useTVMode } from '@/components/context';
import { import {
getRecentUsersWithStatuses, getRecentUsersWithStatuses,
@ -19,8 +17,14 @@ import { SubmitButton } from '@/components/default';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { HistoryDrawer } from '@/components/status'; import { HistoryDrawer } from '@/components/status';
import type { Profile } from '@/utils/supabase'; import type { Profile } from '@/utils/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
import { makeConditionalClassName } from '@/lib/utils'; import { makeConditionalClassName } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { RefreshCw, Wifi, WifiOff } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryErrorCodes } from '@/components/context';
import { createClient } from '@/utils/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
type TechTableProps = { type TechTableProps = {
initialStatuses: UserWithStatus[]; initialStatuses: UserWithStatus[];
@ -31,41 +35,194 @@ export const TechTable = ({
}: TechTableProps) => { }: TechTableProps) => {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { tvMode } = useTVMode(); const { tvMode } = useTVMode();
const [loading, setLoading] = useState(true); const queryClient = useQueryClient();
const [selectedUsers, setSelectedUsers] = useState<UserWithStatus[]>([]); 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 [selectedHistoryUser, setSelectedHistoryUser] = useState<Profile | null>(null);
useState<UserWithStatus[]>(initialStatuses); const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "disconnected">("connecting");
const [selectedHistoryUser, setSelectedHistoryUser] = const [newStatusIds, setNewStatusIds] = useState<Set<string>>(new Set());
useState<UserWithStatus | null>(null);
const channelRef = useRef<RealtimeChannel | null>(null); const channelRef = useRef<RealtimeChannel | null>(null);
const supabaseRef = useRef(createClient()); const supabaseRef = useRef(createClient());
const fetchRecentUsersWithStatuses = useCallback(async () => { // Keep all your existing React Query code exactly as is
const {
data: usersWithStatuses = initialStatuses,
isLoading: loading,
error,
refetch,
isFetching,
dataUpdatedAt,
} = useQuery({
queryKey: ['users-with-statuses'],
queryFn: async () => {
try { try {
const response = await getRecentUsersWithStatuses(); const response = await getRecentUsersWithStatuses();
if (!response.success) throw new Error(response.error); if (!response.success) throw new Error(response.error);
return response.data; return response.data;
} catch (error) { } catch (error) {
toast.error(`Error fetching technicians: ${error as Error}`); toast.error(`Error fetching technicians: ${error instanceof Error ? error.message : 'Unknown error'}`);
return []; throw error;
} }
}, []); },
enabled: isAuthenticated,
// Initial load refetchInterval: 30000, // Changed to 30 seconds as backup
useEffect(() => { refetchOnWindowFocus: true,
const loadData = async () => { refetchOnMount: true,
const data = await fetchRecentUsersWithStatuses(); initialData: initialStatuses,
setUsersWithStatuses(data); meta: { errCode: QueryErrorCodes.USERS_FETCH_FAILED },
setLoading(false);
};
loadData().catch((error) => {
console.error('Error loading data:', error);
}); });
}, [fetchRecentUsersWithStatuses, isAuthenticated]);
const updateStatus = useCallback(async () => { // Add this new useEffect for realtime enhancement
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
if (currentChannel) {
supabaseRef.current.removeChannel(currentChannel).catch((error) => {
console.error(`Error unsubscribing: ${error}`);
});
currentChannel = null;
}
setConnectionStatus('connecting');
const channel = supabaseRef.current
.channel('status_updates')
.on('broadcast', { event: 'status_updated' }, (payload) => {
console.log('Realtime update received, triggering refetch...');
refetch().catch((error) => {
console.error(`Error refetching: ${error}`);
});
})
.subscribe((status) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (status === 'SUBSCRIBED') {
console.log('Realtime connection established');
setConnectionStatus('connected');
reconnectAttempts = 0
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (status === 'CHANNEL_ERROR') {
console.log('Realtime connection failed, relying on polling');
setConnectionStatus('disconnected');
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (status === 'CLOSED') {
console.log('Realtime 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")
}
}
});
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, refetch]);
// Updated mutation
const updateStatusMutation = useMutation({
mutationFn: async ({ usersWithStatuses, status }: { usersWithStatuses: UserWithStatus[], status: string }) => {
if (usersWithStatuses.length === 0) {
const result = await updateUserStatus(status);
if (!result.success) throw new Error(result.error);
return { type: 'single', result };
} else {
const result = await updateStatuses(usersWithStatuses, status);
if (!result.success) throw new Error(result.error);
return { type: 'multiple', result, count: usersWithStatuses.length };
}
},
meta: { errCode: QueryErrorCodes.STATUS_UPDATE_FAILED },
onMutate: async ({ usersWithStatuses, status }) => {
// Optimistic update logic
await queryClient.cancelQueries({ queryKey: ['users-with-statuses'] });
const previousData = queryClient.getQueryData<UserWithStatus[]>(['users-with-statuses']);
if (previousData && usersWithStatuses.length > 0) {
const now = new Date().toISOString();
const optimisticData = previousData.map(userStatus => {
if (usersWithStatuses.some(selected => selected.user.id === userStatus.user.id)) {
return {
...userStatus,
status,
created_at: now,
};
}
return userStatus;
});
queryClient.setQueryData(['users-with-statuses'], optimisticData);
// Add animation for optimistic updates
const updatedIds = usersWithStatuses.map(u => u.user.id);
setNewStatusIds(prev => new Set([...prev, ...updatedIds]));
// Remove animation after 1 second
setTimeout(() => {
setNewStatusIds(prev => {
const updated = new Set(prev);
updatedIds.forEach(id => updated.delete(id));
return updated;
});
}, 1000);
}
return { previousData };
},
onSuccess: (data) => {
// Handle success in the mutation function
void queryClient.invalidateQueries({ queryKey: ['users-with-statuses'] }); // Fixed floating promise
if (data.type === 'single') {
toast.success('Status updated for signed in user.');
} else {
toast.success(`Status updated for ${data.count} selected users.`);
}
setSelectedUsers([]);
setStatusInput('');
},
onError: (error, _variables, context) => { // Fixed unused variables
// Rollback optimistic update
if (context?.previousData) {
queryClient.setQueryData(['users-with-statuses'], context.previousData);
}
// Error handling is done in the global mutation cache
console.error('Status update failed:', error);
},
});
const handleUpdateStatus = () => {
if (!isAuthenticated) { if (!isAuthenticated) {
toast.error('You must be signed in to update statuses.'); toast.error('You must be signed in to update statuses.');
return; return;
@ -74,32 +231,17 @@ export const TechTable = ({
toast.error('Please enter a valid status.'); toast.error('Please enter a valid status.');
return; return;
} }
try { updateStatusMutation.mutate({
if (selectedUsers.length === 0) { usersWithStatuses: selectedUsers,
const result = await updateUserStatus(statusInput); status: statusInput.trim()
if (!result.success) throw new Error(result.error); });
toast.success(`Status updated for signed in user.`); };
} else {
const result = await updateStatuses(selectedUsers, 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}`);
}
}, [isAuthenticated, statusInput, selectedUsers]);
const handleCheckboxChange = (id: string) => { const handleCheckboxChange = (user: UserWithStatus) => {
setSelectedUsers((prev) => setSelectedUsers((prev) =>
prev.includes(id) prev.some(u => u.user.id === user.user.id)
? prev.filter((prevId) => prevId !== id) ? prev.filter((prevUser) => prevUser.user.id !== user.user.id)
: [...prev, id], : [...prev, user]
); );
}; };
@ -107,7 +249,7 @@ export const TechTable = ({
if (selectAll) { if (selectAll) {
setSelectedUsers([]); setSelectedUsers([]);
} else { } else {
setSelectedUsers(usersWithStatuses.map((tech) => tech.user.id)); setSelectedUsers(usersWithStatuses);
} }
setSelectAll(!selectAll); setSelectAll(!selectAll);
}; };
@ -115,132 +257,31 @@ export const TechTable = ({
useEffect(() => { useEffect(() => {
setSelectAll( setSelectAll(
selectedUsers.length === usersWithStatuses.length && selectedUsers.length === usersWithStatuses.length &&
usersWithStatuses.length > 0, usersWithStatuses.length > 0
); );
}, [selectedUsers.length, usersWithStatuses.length]); }, [selectedUsers.length, usersWithStatuses.length]);
useEffect(() => { const getConnectionIcon = () => {
if (!isAuthenticated) return; switch (connectionStatus) {
case "connected":
let reconnectAttempts = 0; return <Wifi className="w-4 h-4 text-green-500" />
const maxReconnectAttempts = 3; // Reduced from 5 case "connecting":
let reconnectTimeout: NodeJS.Timeout; return <Wifi className="w-4 h-4 text-yellow-500 animate-pulse" />
let isComponentMounted = true; case "disconnected":
let currentChannel: RealtimeChannel | null = null; return <WifiOff className="w-4 h-4 text-red-500" />
}
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 getConnectionText = () => {
switch (connectionStatus) {
const channel = supabaseRef.current case "connected":
.channel(`status_updates`, { // Unique channel name return "Connected"
config: { case "connecting":
broadcast: { self: true } return "Connecting..."
case "disconnected":
return "Disconnected"
} }
})
.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 () => {
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]); // 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);
@ -261,6 +302,18 @@ export const TechTable = ({
); );
} }
if (error) {
return (
<div className='flex flex-col justify-center items-center min-h-[400px] gap-4'>
<p className='text-red-500'>Error loading status updates</p>
<Button onClick={() => refetch()} variant='outline'>
<RefreshCw className='w-4 h-4 mr-2' />
Retry
</Button>
</div>
);
}
const containerClassName = makeConditionalClassName({ const containerClassName = makeConditionalClassName({
context: tvMode, context: tvMode,
defaultClassName: 'mx-auto', defaultClassName: 'mx-auto',
@ -284,6 +337,21 @@ export const TechTable = ({
return ( return (
<div className={containerClassName}> <div className={containerClassName}>
{/* Status Header */}
<div className='flex items-center justify-between mb-6'>
{isFetching ? (
<Badge variant="outline" className="flex items-center gap-2">
<RefreshCw className='w-4 h-4 animate-spin text-blue-500' />
<span className='text-xs'>Updating...</span>
</Badge>
) : (
<Badge variant="outline" className="flex items-center gap-2">
{getConnectionIcon()}
<span className='text-xs'>{getConnectionText()}</span>
</Badge>
)}
</div>
<table className='w-full text-center rounded-md'> <table className='w-full text-center rounded-md'>
<thead> <thead>
<tr className='bg-muted'> <tr className='bg-muted'>
@ -312,11 +380,17 @@ export const TechTable = ({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{usersWithStatuses.map((userWithStatus, index) => ( {usersWithStatuses.map((userWithStatus, index) => {
const isSelected = selectedUsers.some(u => u.user.id === userWithStatus.user.id);
const isNewStatus = newStatusIds.has(userWithStatus.user.id);
return (
<tr <tr
key={userWithStatus.user.id} key={userWithStatus.user.id}
className={` className={`
${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'} ${index % 2 === 0 ? 'bg-muted/50' : 'bg-background'}
${isSelected ? 'ring-2 ring-primary' : ''}
${isNewStatus ? 'animate-pulse bg-primary/5 border-primary/20' : ''}
hover:bg-muted/75 transition-all duration-300 hover:bg-muted/75 transition-all duration-300
`} `}
> >
@ -325,20 +399,36 @@ export const TechTable = ({
<input <input
type='checkbox' type='checkbox'
className={checkBoxClassName} className={checkBoxClassName}
checked={selectedUsers.includes(userWithStatus.user.id)} checked={isSelected}
onChange={() => onChange={() => handleCheckboxChange(userWithStatus)}
handleCheckboxChange(userWithStatus.user.id)
}
/> />
</td> </td>
)} )}
<td className={tdClassName}> <td className={tdClassName}>
<div className='flex'> <div className='flex items-center gap-3'>
<BasedAvatar <BasedAvatar
src={userWithStatus.user.avatar_url} src={userWithStatus.user.avatar_url}
fullName={userWithStatus.user.full_name} fullName={userWithStatus.user.full_name}
className={tvMode ? 'w-16 h-16' : 'w-12 h-12'}
/> />
<p>{userWithStatus.user.full_name ?? 'Unknown User'}</p> <div>
<p className={`font-semibold ${tvMode ? 'text-5xl' : 'text-lg'}`}>
{userWithStatus.user.full_name ?? 'Unknown User'}
</p>
{userWithStatus.updated_by &&
userWithStatus.updated_by.id !== userWithStatus.user.id && (
<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'
/>
<span className={`text-xs ${tvMode ? 'text-3xl' : ''}`}>
Updated by {userWithStatus.updated_by.full_name}
</span>
</div>
)}
</div>
</div> </div>
</td> </td>
<td className={tdClassName}> <td className={tdClassName}>
@ -358,9 +448,19 @@ export const TechTable = ({
{formatTime(userWithStatus.created_at)} {formatTime(userWithStatus.created_at)}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
{usersWithStatuses.length === 0 && (
<div className='p-8 text-center'>
<p className={`text-muted-foreground ${tvMode ? 'text-4xl' : 'text-lg'}`}>
No status updates yet
</p>
</div>
)}
{!tvMode && ( {!tvMode && (
<div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'> <div className='mx-auto flex flex-row items-center justify-center py-5 gap-4'>
<input <input
@ -375,12 +475,12 @@ export const TechTable = ({
value={statusInput} value={statusInput}
onChange={(e) => setStatusInput(e.target.value)} onChange={(e) => setStatusInput(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter' && !updateStatusMutation.isPending) {
updateStatus().catch((error) => { e.preventDefault();
toast.error(`Failed to update status: ${error as Error}`); handleUpdateStatus();
});
} }
}} }}
disabled={updateStatusMutation.isPending}
/> />
<SubmitButton <SubmitButton
size='xl' size='xl'
@ -389,14 +489,39 @@ export const TechTable = ({
disabled:opacity-50 disabled:cursor-not-allowed \ disabled:opacity-50 disabled:cursor-not-allowed \
cursor-pointer' cursor-pointer'
} }
onClick={() => updateStatus()} onClick={handleUpdateStatus}
disabled={!statusInput.trim()} disabled={!statusInput.trim() || updateStatusMutation.isPending}
disabledNotLoading={true} disabledNotLoading={true}
> >
Update {updateStatusMutation.isPending
? 'Updating...'
: selectedUsers.length > 0
? `Update ${selectedUsers.length} Users`
: 'Update Status'
}
</SubmitButton> </SubmitButton>
</div> </div>
)} )}
{selectedUsers.length > 0 && !tvMode && (
<div className='text-center mt-4'>
<p className='text-sm text-muted-foreground'>
Updating status for {selectedUsers.length} selected users
</p>
</div>
)}
{/* 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> </div>
); );
}; };

View File

@ -4,6 +4,36 @@ import { createClient, type Profile } from '@/utils/supabase';
import { getSignedUrl, getUser } from '@/lib/hooks'; import { getSignedUrl, getUser } from '@/lib/hooks';
import type { Result } from '.'; import type { Result } from '.';
export const getOriginalProfile = async (
userId: string | null = null,
): Promise<Result<Profile>> => {
try {
if (userId == null) {
const user = await getUser();
if (!user.success || !user.data.id)
throw new Error('User not found');
userId = user.data.id;
}
const supabase = createClient();
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
if (error) throw error;
return { success: true, data: data as Profile };
} catch (error) {
return {
success: false,
error:
error instanceof Error
? error.message
: 'Unknown error getting profile',
};
}
};
export const getProfile = async ( export const getProfile = async (
userId: string | null = null userId: string | null = null
): Promise<Result<Profile>> => { ): Promise<Result<Profile>> => {

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { createClient } from '@/utils/supabase'; import { createClient } from '@/utils/supabase';
import type { Profile, Result } from '@/utils/supabase'; import type { Profile, Result } from '@/utils/supabase';
import { getUser, getProfile, getSignedUrl } from '@/lib/hooks'; import { getUser, getOriginalProfile, getSignedUrl } from '@/lib/hooks';
export type UserWithStatus = { export type UserWithStatus = {
id?: string; id?: string;
@ -67,9 +67,9 @@ export const getRecentUsersWithStatuses = async (): Promise<
for (const userWithStatus of filtered) { for (const userWithStatus of filtered) {
if (userWithStatus.user.avatar_url) if (userWithStatus.user.avatar_url)
userWithStatus.user.avatar_url = userWithStatus.user.avatar_url =
await getAvatarUrl(userWithStatus.updated_by?.avatar_url); await getAvatarUrl(userWithStatus.user.avatar_url);
if (userWithStatus.updated_by?.avatar_url) if (userWithStatus.updated_by?.avatar_url)
userWithStatus.user.avatar_url = userWithStatus.updated_by.avatar_url =
await getAvatarUrl(userWithStatus.updated_by?.avatar_url); await getAvatarUrl(userWithStatus.updated_by?.avatar_url);
filteredWithAvatars.push(userWithStatus); filteredWithAvatars.push(userWithStatus);
} }
@ -115,7 +115,7 @@ export const updateStatuses = async (
): Promise<Result<void>> => { ): Promise<Result<void>> => {
try { try {
const supabase = createClient(); const supabase = createClient();
const profileResponse = await getProfile(); const profileResponse = await getOriginalProfile();
if (!profileResponse.success) throw new Error('Not authenticated!'); if (!profileResponse.success) throw new Error('Not authenticated!');
const user = profileResponse.data; const user = profileResponse.data;
@ -155,7 +155,7 @@ export const updateUserStatus = async (
): Promise<Result<void>> => { ): Promise<Result<void>> => {
try { try {
const supabase = createClient(); const supabase = createClient();
const profileResponse = await getProfile(); const profileResponse = await getOriginalProfile();
if (!profileResponse.success) if (!profileResponse.success)
throw new Error(`Not authenticated! ${profileResponse.error}`); throw new Error(`Not authenticated! ${profileResponse.error}`);
const userProfile = profileResponse.data; const userProfile = profileResponse.data;