From adeb8473b726bbf1f3fa04692b5347ab0d4f0f35 Mon Sep 17 00:00:00 2001 From: geulah Date: Mon, 6 Oct 2025 00:15:36 +0100 Subject: [PATCH] feat(autosave): add local autosave functionality with status indicator implement autosave hook that persists CV data to localStorage with debounce add status component to display save state in header extend store to track save status and errors --- cv-engine/src/App.tsx | 8 ++- cv-engine/src/components/AutosaveStatus.tsx | 28 +++++++++ cv-engine/src/hooks/useLocalAutosave.ts | 66 +++++++++++++++++++++ cv-engine/src/store/cvStore.ts | 17 +++++- 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 cv-engine/src/components/AutosaveStatus.tsx create mode 100644 cv-engine/src/hooks/useLocalAutosave.ts diff --git a/cv-engine/src/App.tsx b/cv-engine/src/App.tsx index f485b54..fed8c1d 100644 --- a/cv-engine/src/App.tsx +++ b/cv-engine/src/App.tsx @@ -7,9 +7,12 @@ import SkillsEditor from './editors/SkillsEditor'; import SummaryEditor from './editors/SummaryEditor'; import PreviewPanel from './components/PreviewPanel'; import { useActiveStep } from './store/cvStore'; +import { useLocalAutosave } from './hooks/useLocalAutosave'; +import AutosaveStatus from './components/AutosaveStatus'; const App: React.FC = () => { const activeStep = useActiveStep(); + useLocalAutosave('cv-engine:draft', 800); // Render the appropriate editor based on the active step const renderEditor = () => { @@ -35,7 +38,10 @@ const App: React.FC = () => {
-

CV Engine

+
+

CV Engine

+ +
diff --git a/cv-engine/src/components/AutosaveStatus.tsx b/cv-engine/src/components/AutosaveStatus.tsx new file mode 100644 index 0000000..13d1195 --- /dev/null +++ b/cv-engine/src/components/AutosaveStatus.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useSaveStatus, useLastSaved, useSaveError } from '../store/cvStore'; + +interface AutosaveStatusProps { + className?: string; +} + +const AutosaveStatus: React.FC = ({ className = '' }) => { + const status = useSaveStatus(); + const lastSaved = useLastSaved(); + const error = useSaveError(); + + let text = ''; + if (status === 'saving') text = 'Saving…'; + if (status === 'saved') text = `Saved ${lastSaved ? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(lastSaved) : ''}`; + if (status === 'error') text = `Save error${error ? `: ${error}` : ''}`; + if (status === 'idle') text = 'All changes up to date'; + + const color = status === 'error' ? 'text-red-600' : status === 'saving' ? 'text-gray-600' : 'text-green-600'; + + return ( +
+ {text} +
+ ); +}; + +export default AutosaveStatus; \ No newline at end of file diff --git a/cv-engine/src/hooks/useLocalAutosave.ts b/cv-engine/src/hooks/useLocalAutosave.ts new file mode 100644 index 0000000..72a224d --- /dev/null +++ b/cv-engine/src/hooks/useLocalAutosave.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef } from 'react'; +import { useCvStore } from '../store/cvStore'; +import { validateCV } from '../schema/cvSchema'; + +export function useLocalAutosave(storageKey = 'cv-engine:draft', debounceMs = 800) { + const cv = useCvStore(s => s.cv); + const isDirty = useCvStore(s => s.isDirty); + const hydrate = useCvStore(s => s.hydrate); + const setSaveStatus = useCvStore(s => s.setSaveStatus); + const setSaveError = useCvStore(s => s.setSaveError); + const markSaved = useCvStore(s => s.markSaved); + + const timerRef = useRef(null); + + // Load draft on mount + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey); + const rawLastSaved = localStorage.getItem(`${storageKey}:lastSaved`); + if (raw) { + const parsed = JSON.parse(raw); + const result = validateCV(parsed); + if (result.success) { + const last = rawLastSaved ? new Date(rawLastSaved) : null; + hydrate(result.data, last ?? null); + setSaveStatus('saved'); + } else { + setSaveError('Found invalid draft in storage; ignoring.'); + setSaveStatus('error'); + } + } + } catch (e: any) { + setSaveError('Failed to load draft from storage.'); + setSaveStatus('error'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + // Debounced autosave when dirty + useEffect(() => { + if (!isDirty) return; + setSaveStatus('saving'); + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + timerRef.current = window.setTimeout(() => { + try { + const json = JSON.stringify(cv); + localStorage.setItem(storageKey, json); + const nowIso = new Date().toISOString(); + localStorage.setItem(`${storageKey}:lastSaved`, nowIso); + markSaved(new Date(nowIso)); + } catch (e: any) { + setSaveError(e?.message || 'Failed to save to storage.'); + setSaveStatus('error'); + } + }, debounceMs); + + return () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [cv, isDirty, storageKey, debounceMs, setSaveStatus, setSaveError, markSaved]); +} \ No newline at end of file diff --git a/cv-engine/src/store/cvStore.ts b/cv-engine/src/store/cvStore.ts index 1fa8771..ccc6fb7 100644 --- a/cv-engine/src/store/cvStore.ts +++ b/cv-engine/src/store/cvStore.ts @@ -13,10 +13,13 @@ interface CvState { activeStep: EditorStep; isDirty: boolean; lastSaved: Date | null; + saveStatus: 'idle' | 'saving' | 'saved' | 'error'; + saveError: string | null; errors: Record; // Actions setCv: (cv: z.infer) => void; + hydrate: (cv: z.infer, lastSaved?: Date | null) => void; updatePersonal: (personal: z.infer['personal']) => void; updateWork: (work: z.infer['work']) => void; updateEducation: (education: z.infer['education']) => void; @@ -50,6 +53,9 @@ interface CvState { // State management setDirty: (isDirty: boolean) => void; setLastSaved: (date: Date | null) => void; + setSaveStatus: (status: CvState['saveStatus']) => void; + setSaveError: (error: string | null) => void; + markSaved: (date?: Date) => void; validateActiveSection: () => boolean; resetErrors: () => void; } @@ -61,10 +67,14 @@ export const useCvStore = create((set, get) => ({ activeStep: 'personal', isDirty: false, lastSaved: null, + saveStatus: 'idle', + saveError: null, errors: {}, // Actions setCv: (cv) => set({ cv, isDirty: true }), + + hydrate: (cv, lastSaved) => set({ cv, isDirty: false, lastSaved: lastSaved ?? null }), updatePersonal: (personal) => { const result = validateSection('personal', personal); @@ -274,6 +284,9 @@ export const useCvStore = create((set, get) => ({ // State management setDirty: (isDirty) => set({ isDirty }), setLastSaved: (lastSaved) => set({ lastSaved }), + setSaveStatus: (saveStatus) => set({ saveStatus }), + setSaveError: (saveError) => set({ saveError }), + markSaved: (date) => set({ lastSaved: date ?? new Date(), isDirty: false, saveStatus: 'saved', saveError: null }), validateActiveSection: () => { const { activeStep, cv } = get(); @@ -366,4 +379,6 @@ export const useSummaryJson = () => useCvStore(state => state.cv.summaryJson); export const useTemplateId = () => useCvStore(state => state.cv.templateId); export const useActiveStep = () => useCvStore(state => state.activeStep); export const useIsDirty = () => useCvStore(state => state.isDirty); -export const useLastSaved = () => useCvStore(state => state.lastSaved); \ No newline at end of file +export const useLastSaved = () => useCvStore(state => state.lastSaved); +export const useSaveStatus = () => useCvStore(state => state.saveStatus); +export const useSaveError = () => useCvStore(state => state.saveError); \ No newline at end of file