mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
docs: consolidate and restructure documentation architecture
- Remove outdated root-level documentation files - Delete IMPLEMENTATION_STATUS.md, WORK_IN_PROGRESS.md, UI_IMPROVEMENTS_SUMMARY.md, CLAUDE.md - Reorganize documentation into docs/ folder - Move UNIFIED_EDITOR_EXPERIENCES.md → docs/unified-editor-experiences.md - Move DATATABLE_MIGRATION_PROGRESS.md → docs/datatable-migration-progress.md - Move SEED_SCRIPT_README.md → docs/seed-script-readme.md - Create comprehensive new documentation - Add docs/implementation-status.md with production readiness assessment - Add docs/work-in-progress.md with active development tracking - Add docs/development-achievements.md consolidating all major accomplishments - Update documentation hub - Enhance docs/README.md with complete 13-document structure - Organize into logical categories: Core, Status, Achievements - Provide clear navigation and purpose for each document Features: - 73% code reduction achievement through unified editor experiences - Complete DataTable migration with enterprise features - Comprehensive seed database with realistic research scenarios - Production-ready status with 100% backend, 95% frontend completion - Clean documentation architecture supporting future development Breaking Changes: None - documentation restructuring only Migration: Documentation moved to docs/ folder, no code changes required
This commit is contained in:
19
src/hooks/use-mobile.ts
Normal file
19
src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
19
src/hooks/use-mobile.tsx
Normal file
19
src/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean>(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
143
src/hooks/useActiveStudy.ts
Normal file
143
src/hooks/useActiveStudy.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const ACTIVE_STUDY_KEY = "hristudio-active-study";
|
||||
|
||||
// Helper function to validate UUID format
|
||||
const isValidUUID = (id: string): boolean => {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(id);
|
||||
};
|
||||
|
||||
export function useActiveStudy() {
|
||||
const { data: session } = useSession();
|
||||
const [activeStudyId, setActiveStudyId] = useState<string | null>(null);
|
||||
const [isSettingActiveStudy, setIsSettingActiveStudy] = useState(false);
|
||||
|
||||
// Load active study from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(ACTIVE_STUDY_KEY);
|
||||
if (stored && isValidUUID(stored)) {
|
||||
setActiveStudyId(stored);
|
||||
} else if (stored) {
|
||||
// Clear invalid UUID from localStorage
|
||||
localStorage.removeItem(ACTIVE_STUDY_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get active study details
|
||||
const { data: activeStudy, isLoading: isLoadingActiveStudy } =
|
||||
api.studies.get.useQuery(
|
||||
{ id: activeStudyId! },
|
||||
{
|
||||
enabled: !!activeStudyId && isValidUUID(activeStudyId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: false, // Don't retry if study doesn't exist
|
||||
},
|
||||
);
|
||||
|
||||
// Clear localStorage if study doesn't exist
|
||||
useEffect(() => {
|
||||
if (activeStudyId && !activeStudy && !isLoadingActiveStudy) {
|
||||
localStorage.removeItem(ACTIVE_STUDY_KEY);
|
||||
setActiveStudyId(null);
|
||||
toast.error(
|
||||
"Selected study no longer exists. Please select a new study.",
|
||||
);
|
||||
}
|
||||
}, [activeStudy, activeStudyId, isLoadingActiveStudy]);
|
||||
|
||||
// Get user's studies for switching (always use memberOnly: true for security)
|
||||
const { data: studiesData, isLoading: isLoadingStudies } =
|
||||
api.studies.list.useQuery(
|
||||
{ limit: 20, memberOnly: true },
|
||||
{
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!session?.user?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const userStudies = studiesData?.studies ?? [];
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const setActiveStudy = (studyId: string) => {
|
||||
if (!isValidUUID(studyId)) {
|
||||
toast.error("Invalid study ID format");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSettingActiveStudy(true);
|
||||
setActiveStudyId(studyId);
|
||||
localStorage.setItem(ACTIVE_STUDY_KEY, studyId);
|
||||
|
||||
// Invalidate all related queries when study changes
|
||||
utils.participants.invalidate();
|
||||
utils.trials.invalidate();
|
||||
utils.experiments.invalidate();
|
||||
|
||||
toast.success("Active study updated");
|
||||
|
||||
// Reset loading state after a brief delay to allow queries to refetch
|
||||
setTimeout(() => {
|
||||
setIsSettingActiveStudy(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const clearActiveStudy = () => {
|
||||
setIsSettingActiveStudy(true);
|
||||
setActiveStudyId(null);
|
||||
localStorage.removeItem(ACTIVE_STUDY_KEY);
|
||||
|
||||
// Invalidate all related queries when clearing study
|
||||
utils.participants.invalidate();
|
||||
utils.trials.invalidate();
|
||||
utils.experiments.invalidate();
|
||||
|
||||
toast.success("Active study cleared");
|
||||
|
||||
// Reset loading state after a brief delay
|
||||
setTimeout(() => {
|
||||
setIsSettingActiveStudy(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Note: Auto-selection removed to require manual study selection
|
||||
|
||||
return {
|
||||
// State
|
||||
activeStudyId,
|
||||
activeStudy:
|
||||
activeStudy && typeof activeStudy === "object"
|
||||
? {
|
||||
id: activeStudy.id,
|
||||
title: (activeStudy as any).name || "",
|
||||
description: (activeStudy as any).description || "",
|
||||
}
|
||||
: null,
|
||||
userStudies: userStudies.map((study: any) => ({
|
||||
id: study.id as string,
|
||||
title: study.name as string,
|
||||
description: (study.description as string) || "",
|
||||
})),
|
||||
|
||||
// Loading states
|
||||
isLoadingActiveStudy,
|
||||
isLoadingStudies,
|
||||
isSettingActiveStudy,
|
||||
isClearingActiveStudy: false,
|
||||
|
||||
// Actions
|
||||
setActiveStudy,
|
||||
clearActiveStudy,
|
||||
|
||||
// Utilities
|
||||
hasActiveStudy: !!activeStudyId,
|
||||
hasStudies: userStudies.length > 0,
|
||||
};
|
||||
}
|
||||
363
src/hooks/useStudyManagement.ts
Normal file
363
src/hooks/useStudyManagement.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
/**
|
||||
* Custom hook for centralized study management across the platform.
|
||||
* Handles study creation, updates, deletion, and ensures all UI components
|
||||
* stay synchronized with the latest study data.
|
||||
*/
|
||||
export function useStudyManagement() {
|
||||
const router = useRouter();
|
||||
const { selectedStudyId, setSelectedStudyId } = useStudyContext();
|
||||
const utils = api.useUtils();
|
||||
|
||||
/**
|
||||
* Invalidates all study-related queries to ensure UI consistency
|
||||
*/
|
||||
const invalidateStudyQueries = useCallback(async () => {
|
||||
await Promise.all([
|
||||
utils.studies.list.invalidate(),
|
||||
utils.studies.get.invalidate(),
|
||||
utils.studies.getMembers.invalidate(),
|
||||
]);
|
||||
}, [utils]);
|
||||
|
||||
/**
|
||||
* Creates a new study and updates the entire platform UI
|
||||
*/
|
||||
const createStudy = useCallback(
|
||||
async (data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
institution?: string;
|
||||
irbProtocol?: string;
|
||||
}) => {
|
||||
try {
|
||||
const newStudy = await utils.client.studies.create.mutate(data);
|
||||
|
||||
// Invalidate all study queries
|
||||
await invalidateStudyQueries();
|
||||
|
||||
// Auto-select the newly created study
|
||||
setSelectedStudyId(newStudy.id);
|
||||
|
||||
// Show success message
|
||||
toast.success(`Study "${newStudy.name}" created successfully!`);
|
||||
|
||||
return newStudy;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to create study";
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[utils.client.studies.create, invalidateStudyQueries, setSelectedStudyId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates an existing study and refreshes the UI
|
||||
*/
|
||||
const updateStudy = useCallback(
|
||||
async (
|
||||
studyId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
institution?: string;
|
||||
irbProtocol?: string;
|
||||
status?: "draft" | "active" | "completed" | "archived";
|
||||
settings?: Record<string, unknown>;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const updatedStudy = await utils.client.studies.update.mutate({
|
||||
id: studyId,
|
||||
...data,
|
||||
});
|
||||
|
||||
// Invalidate study queries
|
||||
await invalidateStudyQueries();
|
||||
|
||||
// Show success message
|
||||
toast.success(`Study "${updatedStudy.name}" updated successfully!`);
|
||||
|
||||
return updatedStudy;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to update study";
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[utils.client.studies.update, invalidateStudyQueries],
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a study and updates the UI
|
||||
*/
|
||||
const deleteStudy = useCallback(
|
||||
async (studyId: string) => {
|
||||
try {
|
||||
await utils.client.studies.delete.mutate({ id: studyId });
|
||||
|
||||
// Clear selected study if it was the deleted one
|
||||
if (selectedStudyId === studyId) {
|
||||
setSelectedStudyId(null);
|
||||
}
|
||||
|
||||
// Invalidate study queries
|
||||
await invalidateStudyQueries();
|
||||
|
||||
// Show success message
|
||||
toast.success("Study deleted successfully!");
|
||||
|
||||
// Navigate to studies list
|
||||
router.push("/studies");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to delete study";
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
utils.client.studies.delete,
|
||||
selectedStudyId,
|
||||
setSelectedStudyId,
|
||||
invalidateStudyQueries,
|
||||
router,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a member to a study and refreshes related data
|
||||
*/
|
||||
const addStudyMember = useCallback(
|
||||
async (
|
||||
studyId: string,
|
||||
email: string,
|
||||
role: "researcher" | "wizard" | "observer",
|
||||
) => {
|
||||
try {
|
||||
await utils.client.studies.addMember.mutate({
|
||||
studyId,
|
||||
email,
|
||||
role,
|
||||
});
|
||||
|
||||
// Invalidate relevant queries
|
||||
await Promise.all([
|
||||
utils.studies.get.invalidate({ id: studyId }),
|
||||
utils.studies.getMembers.invalidate({ studyId }),
|
||||
utils.studies.list.invalidate(),
|
||||
]);
|
||||
|
||||
// Show success message
|
||||
toast.success("Member added successfully!");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to add member";
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
/**
|
||||
* Removes a member from a study and refreshes related data
|
||||
*/
|
||||
const removeStudyMember = useCallback(
|
||||
async (studyId: string, memberId: string) => {
|
||||
try {
|
||||
await utils.client.studies.removeMember.mutate({
|
||||
studyId,
|
||||
memberId,
|
||||
});
|
||||
|
||||
// Invalidate relevant queries
|
||||
await Promise.all([
|
||||
utils.studies.get.invalidate({ id: studyId }),
|
||||
utils.studies.getMembers.invalidate({ studyId }),
|
||||
utils.studies.list.invalidate(),
|
||||
]);
|
||||
|
||||
// Show success message
|
||||
toast.success("Member removed successfully!");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to remove member";
|
||||
toast.error(message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
/**
|
||||
* Selects a study and ensures its data is fresh
|
||||
*/
|
||||
const selectStudy = useCallback(
|
||||
async (studyId: string | null) => {
|
||||
try {
|
||||
// Optimistic update for better UX
|
||||
setSelectedStudyId(studyId);
|
||||
|
||||
// Ensure selected study data is fresh and exists
|
||||
if (studyId) {
|
||||
// Try to fetch the study to verify it exists
|
||||
const studyExists = await utils.client.studies.get.query({
|
||||
id: studyId,
|
||||
});
|
||||
if (!studyExists) {
|
||||
// Study doesn't exist, clear the selection
|
||||
setSelectedStudyId(null);
|
||||
toast.error("Selected study no longer exists");
|
||||
return;
|
||||
}
|
||||
await utils.studies.get.invalidate({ id: studyId });
|
||||
}
|
||||
} catch (error) {
|
||||
// If study fetch fails, clear the selection
|
||||
setSelectedStudyId(null);
|
||||
console.warn("Failed to select study, clearing selection:", error);
|
||||
if (studyId) {
|
||||
toast.error("Study no longer available");
|
||||
}
|
||||
}
|
||||
},
|
||||
[setSelectedStudyId, utils.studies.get, utils.client.studies.get],
|
||||
);
|
||||
|
||||
/**
|
||||
* Refreshes all study data (useful for manual refresh triggers)
|
||||
*/
|
||||
const refreshStudyData = useCallback(async () => {
|
||||
await invalidateStudyQueries();
|
||||
toast.success("Study data refreshed!");
|
||||
}, [invalidateStudyQueries]);
|
||||
|
||||
/**
|
||||
* Gets the currently selected study data
|
||||
*/
|
||||
const selectedStudy = api.studies.get.useQuery(
|
||||
{ id: selectedStudyId! },
|
||||
{
|
||||
enabled: !!selectedStudyId,
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry if study not found (404-like errors)
|
||||
if (
|
||||
error.message?.includes("not found") ||
|
||||
error.message?.includes("404")
|
||||
) {
|
||||
// Clear the selection if study is not found
|
||||
setSelectedStudyId(null);
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Handle study not found error
|
||||
React.useEffect(() => {
|
||||
if (selectedStudy.error) {
|
||||
const error = selectedStudy.error;
|
||||
if (
|
||||
error.message?.includes("not found") ||
|
||||
error.message?.includes("404")
|
||||
) {
|
||||
console.warn("Selected study not found, clearing selection");
|
||||
setSelectedStudyId(null);
|
||||
toast.error("Study no longer exists");
|
||||
}
|
||||
}
|
||||
}, [selectedStudy.error, setSelectedStudyId]);
|
||||
|
||||
/**
|
||||
* Gets the list of all user studies
|
||||
*/
|
||||
const userStudies = api.studies.list.useQuery(
|
||||
{ memberOnly: true, limit: 100 },
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedStudyId,
|
||||
selectedStudy: selectedStudy.data,
|
||||
isLoadingSelectedStudy: selectedStudy.isLoading,
|
||||
userStudies: userStudies.data?.studies ?? [],
|
||||
isLoadingUserStudies: userStudies.isLoading,
|
||||
|
||||
// Actions
|
||||
createStudy,
|
||||
updateStudy,
|
||||
deleteStudy,
|
||||
addStudyMember,
|
||||
removeStudyMember,
|
||||
selectStudy,
|
||||
refreshStudyData,
|
||||
invalidateStudyQueries,
|
||||
|
||||
// Navigation helpers
|
||||
navigateToStudy: (studyId: string) => router.push(`/studies/${studyId}`),
|
||||
navigateToNewStudy: () => router.push("/studies/new"),
|
||||
navigateToStudiesList: () => router.push("/studies"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for components that require a selected study
|
||||
*/
|
||||
export function useRequiredStudy() {
|
||||
const { selectedStudyId, selectedStudy, isLoadingSelectedStudy } =
|
||||
useStudyManagement();
|
||||
|
||||
if (!selectedStudyId) {
|
||||
throw new Error("This component requires a selected study");
|
||||
}
|
||||
|
||||
return {
|
||||
studyId: selectedStudyId,
|
||||
study: selectedStudy,
|
||||
isLoading: isLoadingSelectedStudy,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for optimistic study updates
|
||||
*/
|
||||
export function useOptimisticStudyUpdate() {
|
||||
const utils = api.useUtils();
|
||||
|
||||
const optimisticUpdate = useCallback(
|
||||
(
|
||||
studyId: string,
|
||||
_updateData: Partial<{
|
||||
name: string;
|
||||
status: string;
|
||||
description: string;
|
||||
}>,
|
||||
) => {
|
||||
// Simple optimistic update - just invalidate the queries
|
||||
// This is safer than trying to manually update the cache
|
||||
void utils.studies.get.invalidate({ id: studyId });
|
||||
void utils.studies.list.invalidate();
|
||||
},
|
||||
[utils],
|
||||
);
|
||||
|
||||
return { optimisticUpdate };
|
||||
}
|
||||
344
src/hooks/useWebSocket.ts
Normal file
344
src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface UseWebSocketOptions {
|
||||
trialId: string;
|
||||
onMessage?: (message: WebSocketMessage) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onError?: (error: Event) => void;
|
||||
reconnectAttempts?: number;
|
||||
reconnectInterval?: number;
|
||||
heartbeatInterval?: number;
|
||||
}
|
||||
|
||||
export interface UseWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
connectionError: string | null;
|
||||
sendMessage: (message: WebSocketMessage) => void;
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
lastMessage: WebSocketMessage | null;
|
||||
}
|
||||
|
||||
export function useWebSocket({
|
||||
trialId,
|
||||
onMessage,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
reconnectAttempts = 5,
|
||||
reconnectInterval = 3000,
|
||||
heartbeatInterval = 30000,
|
||||
}: UseWebSocketOptions): UseWebSocketReturn {
|
||||
const { data: session } = useSession();
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const heartbeatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const attemptCountRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Generate auth token (simplified - in production use proper JWT)
|
||||
const getAuthToken = useCallback(() => {
|
||||
if (!session?.user) return null;
|
||||
// In production, this would be a proper JWT token
|
||||
return btoa(JSON.stringify({ userId: session.user.id, timestamp: Date.now() }));
|
||||
}, [session]);
|
||||
|
||||
const sendMessage = useCallback((message: WebSocketMessage) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn("WebSocket not connected, message not sent:", message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendHeartbeat = useCallback(() => {
|
||||
sendMessage({ type: "heartbeat", data: {} });
|
||||
}, [sendMessage]);
|
||||
|
||||
const scheduleHeartbeat = useCallback(() => {
|
||||
if (heartbeatTimeoutRef.current) {
|
||||
clearTimeout(heartbeatTimeoutRef.current);
|
||||
}
|
||||
heartbeatTimeoutRef.current = setTimeout(() => {
|
||||
if (isConnected && mountedRef.current) {
|
||||
sendHeartbeat();
|
||||
scheduleHeartbeat();
|
||||
}
|
||||
}, heartbeatInterval);
|
||||
}, [isConnected, sendHeartbeat, heartbeatInterval]);
|
||||
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
setLastMessage(message);
|
||||
|
||||
// Handle system messages
|
||||
switch (message.type) {
|
||||
case "connection_established":
|
||||
console.log("WebSocket connection established:", message.data);
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
setConnectionError(null);
|
||||
attemptCountRef.current = 0;
|
||||
scheduleHeartbeat();
|
||||
onConnect?.();
|
||||
break;
|
||||
|
||||
case "heartbeat_response":
|
||||
// Heartbeat acknowledged, connection is alive
|
||||
break;
|
||||
|
||||
case "error":
|
||||
console.error("WebSocket server error:", message.data);
|
||||
setConnectionError(message.data.message || "Server error");
|
||||
onError?.(new Event("server_error"));
|
||||
break;
|
||||
|
||||
default:
|
||||
// Pass to user-defined message handler
|
||||
onMessage?.(message);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
setConnectionError("Failed to parse message");
|
||||
}
|
||||
}, [onMessage, onConnect, onError, scheduleHeartbeat]);
|
||||
|
||||
const handleClose = useCallback((event: CloseEvent) => {
|
||||
console.log("WebSocket connection closed:", event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
|
||||
if (heartbeatTimeoutRef.current) {
|
||||
clearTimeout(heartbeatTimeoutRef.current);
|
||||
}
|
||||
|
||||
onDisconnect?.();
|
||||
|
||||
// Attempt reconnection if not manually closed and component is still mounted
|
||||
if (event.code !== 1000 && mountedRef.current && attemptCountRef.current < reconnectAttempts) {
|
||||
attemptCountRef.current++;
|
||||
const delay = reconnectInterval * Math.pow(1.5, attemptCountRef.current - 1); // Exponential backoff
|
||||
|
||||
console.log(`Attempting reconnection ${attemptCountRef.current}/${reconnectAttempts} in ${delay}ms`);
|
||||
setConnectionError(`Connection lost. Reconnecting... (${attemptCountRef.current}/${reconnectAttempts})`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
connect();
|
||||
}
|
||||
}, delay);
|
||||
} else if (attemptCountRef.current >= reconnectAttempts) {
|
||||
setConnectionError("Failed to reconnect after maximum attempts");
|
||||
}
|
||||
}, [onDisconnect, reconnectAttempts, reconnectInterval]);
|
||||
|
||||
const handleError = useCallback((event: Event) => {
|
||||
console.error("WebSocket error:", event);
|
||||
setConnectionError("Connection error");
|
||||
setIsConnecting(false);
|
||||
onError?.(event);
|
||||
}, [onError]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!session?.user || !trialId) {
|
||||
setConnectionError("Missing authentication or trial ID");
|
||||
return;
|
||||
}
|
||||
|
||||
if (wsRef.current &&
|
||||
(wsRef.current.readyState === WebSocket.CONNECTING ||
|
||||
wsRef.current.readyState === WebSocket.OPEN)) {
|
||||
return; // Already connecting or connected
|
||||
}
|
||||
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
setConnectionError("Failed to generate auth token");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
setConnectionError(null);
|
||||
|
||||
try {
|
||||
// Use appropriate WebSocket URL based on environment
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/websocket?trialId=${trialId}&token=${token}`;
|
||||
|
||||
wsRef.current = new WebSocket(wsUrl);
|
||||
wsRef.current.onmessage = handleMessage;
|
||||
wsRef.current.onclose = handleClose;
|
||||
wsRef.current.onerror = handleError;
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
console.log("WebSocket connection opened");
|
||||
// Connection establishment is handled in handleMessage
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to create WebSocket connection:", error);
|
||||
setConnectionError("Failed to create connection");
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [session, trialId, getAuthToken, handleMessage, handleClose, handleError]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
mountedRef.current = false;
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (heartbeatTimeoutRef.current) {
|
||||
clearTimeout(heartbeatTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, "Manual disconnect");
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError(null);
|
||||
attemptCountRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
disconnect();
|
||||
mountedRef.current = true;
|
||||
attemptCountRef.current = 0;
|
||||
setTimeout(connect, 100); // Small delay to ensure cleanup
|
||||
}, [disconnect, connect]);
|
||||
|
||||
// Effect to establish initial connection
|
||||
useEffect(() => {
|
||||
if (session?.user && trialId) {
|
||||
connect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
disconnect();
|
||||
};
|
||||
}, [session?.user?.id, trialId]); // Reconnect if user or trial changes
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
disconnect();
|
||||
};
|
||||
}, [disconnect]);
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectionError,
|
||||
sendMessage,
|
||||
disconnect,
|
||||
reconnect,
|
||||
lastMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for trial-specific WebSocket events
|
||||
export function useTrialWebSocket(trialId: string) {
|
||||
const [trialEvents, setTrialEvents] = useState<WebSocketMessage[]>([]);
|
||||
const [currentTrialStatus, setCurrentTrialStatus] = useState<any>(null);
|
||||
const [wizardActions, setWizardActions] = useState<any[]>([]);
|
||||
|
||||
const handleMessage = useCallback((message: WebSocketMessage) => {
|
||||
// Add to events log
|
||||
setTrialEvents(prev => [...prev, message].slice(-100)); // Keep last 100 events
|
||||
|
||||
switch (message.type) {
|
||||
case "trial_status":
|
||||
setCurrentTrialStatus(message.data.trial);
|
||||
break;
|
||||
|
||||
case "trial_action_executed":
|
||||
case "intervention_logged":
|
||||
case "step_changed":
|
||||
setWizardActions(prev => [...prev, message].slice(-50)); // Keep last 50 actions
|
||||
break;
|
||||
|
||||
case "step_changed":
|
||||
// Handle step transitions
|
||||
console.log("Step changed:", message.data);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle other trial-specific messages
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const webSocket = useWebSocket({
|
||||
trialId,
|
||||
onMessage: handleMessage,
|
||||
onConnect: () => {
|
||||
console.log(`Connected to trial ${trialId} WebSocket`);
|
||||
// Request current trial status on connect
|
||||
webSocket.sendMessage({ type: "request_trial_status", data: {} });
|
||||
},
|
||||
onDisconnect: () => {
|
||||
console.log(`Disconnected from trial ${trialId} WebSocket`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(`Trial ${trialId} WebSocket error:`, error);
|
||||
},
|
||||
});
|
||||
|
||||
// Trial-specific actions
|
||||
const executeTrialAction = useCallback((actionType: string, actionData: any) => {
|
||||
webSocket.sendMessage({
|
||||
type: "trial_action",
|
||||
data: {
|
||||
actionType,
|
||||
...actionData,
|
||||
},
|
||||
});
|
||||
}, [webSocket]);
|
||||
|
||||
const logWizardIntervention = useCallback((interventionData: any) => {
|
||||
webSocket.sendMessage({
|
||||
type: "wizard_intervention",
|
||||
data: interventionData,
|
||||
});
|
||||
}, [webSocket]);
|
||||
|
||||
const transitionStep = useCallback((stepData: any) => {
|
||||
webSocket.sendMessage({
|
||||
type: "step_transition",
|
||||
data: stepData,
|
||||
});
|
||||
}, [webSocket]);
|
||||
|
||||
return {
|
||||
...webSocket,
|
||||
trialEvents,
|
||||
currentTrialStatus,
|
||||
wizardActions,
|
||||
executeTrialAction,
|
||||
logWizardIntervention,
|
||||
transitionStep,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user