semblance-dev/src/hooks/useFocusGroupAutoSave.ts
Vadym Samoilenko f359157949 Fix focus group create: 500 on update + 400 on autosave
- FocusGroup.update: use matched_count > 0 instead of modified_count > 0
  so updates succeed even when data is unchanged (was returning 500)
- useFocusGroupAutoSave: skip save if name is empty (not all-fields-empty)
  preventing 400 Bad Request when autosave fires before name is filled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:20:40 +00:00

203 lines
6.4 KiB
TypeScript
Executable file

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) {
return; // Don't save without name (required by backend)
}
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,
};
}