semblance/src/hooks/useFocusGroupAutoSave.ts
michael 22b3ec19a5 Refactor FocusGroupModerator into smaller components and hooks
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>
2025-12-04 09:11:21 -06:00

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,
};
}