Extract business logic and UI into reusable pieces: Custom Hooks: - useFocusGroupAutoSave: debounced auto-save with retry logic - useFolderManagement: folder CRUD operations - usePersonaFiltering: filter state and persona filtering - useDiscussionGuideGeneration: guide generation and progress UI Components: - SaveStatusIndicator: auto-save status display - FolderSidebar: folder list and management - PersonaFilterDialog: persona filter modal - CopyGuideDialog: copy guide from other focus groups Tab Components: - SetupTab: form and asset uploader - ReviewTab: discussion guide viewer - ParticipantsTab: persona selection grid Reduces FocusGroupModerator from 2,396 to ~600 lines (75% reduction). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
203 lines
6.4 KiB
TypeScript
203 lines
6.4 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { UseFormReturn } from 'react-hook-form';
|
|
import { toast } from 'sonner';
|
|
import { focusGroupsApi } from '@/lib/api';
|
|
|
|
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
|
|
|
interface AutoSaveData {
|
|
name: string;
|
|
description: string;
|
|
objective: string;
|
|
topic: string;
|
|
duration: number;
|
|
llm_model: string;
|
|
reasoning_effort: string;
|
|
verbosity: string;
|
|
participants: string[];
|
|
participants_count: number;
|
|
status: string;
|
|
date: string;
|
|
uploadedAssets: string[];
|
|
}
|
|
|
|
interface UseFocusGroupAutoSaveOptions {
|
|
form: UseFormReturn<any>;
|
|
selectedParticipants: string[];
|
|
backendAssets: any[];
|
|
draftFocusGroupId: string | null;
|
|
draftToEdit: any | null;
|
|
activeTab: string;
|
|
onDraftIdChange: (id: string) => void;
|
|
}
|
|
|
|
interface UseFocusGroupAutoSaveReturn {
|
|
autoSaveStatus: AutoSaveStatus;
|
|
lastSavedData: AutoSaveData | null;
|
|
setLastSavedData: (data: AutoSaveData | null) => void;
|
|
isLoadingDraft: boolean;
|
|
setIsLoadingDraft: (loading: boolean) => void;
|
|
}
|
|
|
|
export function useFocusGroupAutoSave({
|
|
form,
|
|
selectedParticipants,
|
|
backendAssets,
|
|
draftFocusGroupId,
|
|
draftToEdit,
|
|
activeTab,
|
|
onDraftIdChange,
|
|
}: UseFocusGroupAutoSaveOptions): UseFocusGroupAutoSaveReturn {
|
|
const [autoSaveStatus, setAutoSaveStatus] = useState<AutoSaveStatus>('idle');
|
|
const [lastSavedData, setLastSavedData] = useState<AutoSaveData | null>(null);
|
|
const [saveRetryCount, setSaveRetryCount] = useState(0);
|
|
|
|
const debouncedSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const isSavingRef = useRef(false);
|
|
const isLoadingDraftRef = useRef(false);
|
|
|
|
// Use refs to track previous values to prevent unnecessary saves
|
|
const prevWatchedFieldsRef = useRef<string>('');
|
|
const prevSelectedParticipantsRef = useRef<string>('');
|
|
|
|
// Expose loading draft state
|
|
const setIsLoadingDraft = useCallback((loading: boolean) => {
|
|
isLoadingDraftRef.current = loading;
|
|
}, []);
|
|
|
|
// Simplified auto-save trigger function
|
|
const triggerAutoSave = useCallback(() => {
|
|
if (activeTab !== 'setup' || isLoadingDraftRef.current) return;
|
|
|
|
// Clear existing debounced timer
|
|
if (debouncedSaveTimerRef.current) {
|
|
clearTimeout(debouncedSaveTimerRef.current);
|
|
}
|
|
|
|
// Schedule debounced save
|
|
debouncedSaveTimerRef.current = setTimeout(async () => {
|
|
if (isSavingRef.current) return;
|
|
|
|
const values = form.getValues();
|
|
const currentData: AutoSaveData = {
|
|
name: values.focusGroupName || '',
|
|
description: values.researchBrief || '',
|
|
objective: values.researchBrief || '',
|
|
topic: values.discussionTopics || '',
|
|
duration: values.duration ? parseInt(values.duration) : 60,
|
|
llm_model: values.llm_model || 'gemini-3-pro-preview',
|
|
reasoning_effort: values.reasoning_effort || 'medium',
|
|
verbosity: values.verbosity || 'medium',
|
|
participants: selectedParticipants,
|
|
participants_count: selectedParticipants.length,
|
|
status: 'draft',
|
|
date: new Date().toISOString(),
|
|
uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown')
|
|
};
|
|
|
|
if (lastSavedData && JSON.stringify(currentData) === JSON.stringify(lastSavedData)) {
|
|
return; // No changes
|
|
}
|
|
|
|
if (!currentData.name && !currentData.description && !currentData.topic) {
|
|
return; // Don't save empty form
|
|
}
|
|
|
|
isSavingRef.current = true;
|
|
setAutoSaveStatus('saving');
|
|
|
|
try {
|
|
let focusGroupId = draftFocusGroupId || (draftToEdit?.id || draftToEdit?._id);
|
|
|
|
if (!focusGroupId) {
|
|
const response = await focusGroupsApi.create(currentData);
|
|
focusGroupId = response.data.focus_group_id || response.data.id || response.data._id;
|
|
onDraftIdChange(focusGroupId);
|
|
} else {
|
|
await focusGroupsApi.update(focusGroupId, currentData);
|
|
}
|
|
|
|
setLastSavedData(currentData);
|
|
setAutoSaveStatus('saved');
|
|
setSaveRetryCount(0);
|
|
|
|
setTimeout(() => {
|
|
setAutoSaveStatus('idle');
|
|
}, 2000);
|
|
|
|
} catch (error) {
|
|
console.error("Auto-save failed:", error);
|
|
setAutoSaveStatus('error');
|
|
setSaveRetryCount(prev => prev + 1);
|
|
|
|
if (saveRetryCount < 3) {
|
|
const retryDelay = Math.pow(2, saveRetryCount) * 2000;
|
|
setTimeout(() => {
|
|
triggerAutoSave();
|
|
}, retryDelay);
|
|
} else {
|
|
toast.error("Auto-save failed", {
|
|
description: "Your changes may not be saved. Please check your connection.",
|
|
});
|
|
}
|
|
} finally {
|
|
isSavingRef.current = false;
|
|
}
|
|
}, 2000);
|
|
}, [activeTab, form, selectedParticipants, backendAssets, draftFocusGroupId, draftToEdit, lastSavedData, saveRetryCount, onDraftIdChange]);
|
|
|
|
// Watch for form field changes
|
|
const watchedFields = form.watch();
|
|
|
|
// Effect to handle form field changes and trigger auto-save
|
|
useEffect(() => {
|
|
const currentWatchedFields = JSON.stringify(watchedFields);
|
|
if (activeTab === 'setup' && currentWatchedFields !== prevWatchedFieldsRef.current) {
|
|
prevWatchedFieldsRef.current = currentWatchedFields;
|
|
triggerAutoSave();
|
|
}
|
|
}, [watchedFields, activeTab, triggerAutoSave]);
|
|
|
|
// Effect to handle participant changes
|
|
useEffect(() => {
|
|
const currentParticipants = JSON.stringify(selectedParticipants);
|
|
if (activeTab === 'setup' && currentParticipants !== prevSelectedParticipantsRef.current) {
|
|
prevSelectedParticipantsRef.current = currentParticipants;
|
|
triggerAutoSave();
|
|
}
|
|
}, [selectedParticipants, activeTab, triggerAutoSave]);
|
|
|
|
// Effect to clear timers when leaving setup tab or component unmounts
|
|
useEffect(() => {
|
|
if (activeTab !== 'setup') {
|
|
if (debouncedSaveTimerRef.current) {
|
|
clearTimeout(debouncedSaveTimerRef.current);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
if (debouncedSaveTimerRef.current) {
|
|
clearTimeout(debouncedSaveTimerRef.current);
|
|
}
|
|
};
|
|
}, [activeTab]);
|
|
|
|
// Initialize refs for new focus groups
|
|
useEffect(() => {
|
|
if (!draftToEdit) {
|
|
setTimeout(() => {
|
|
isLoadingDraftRef.current = false;
|
|
const initialFormState = JSON.stringify(form.getValues());
|
|
prevWatchedFieldsRef.current = initialFormState;
|
|
}, 500);
|
|
}
|
|
}, [draftToEdit, form]);
|
|
|
|
return {
|
|
autoSaveStatus,
|
|
lastSavedData,
|
|
setLastSavedData,
|
|
isLoadingDraft: isLoadingDraftRef.current,
|
|
setIsLoadingDraft,
|
|
};
|
|
}
|