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
This commit is contained in:
geulah 2025-10-06 00:15:36 +01:00
parent 3468cf7a43
commit adeb8473b7
4 changed files with 117 additions and 2 deletions

View File

@ -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 = () => {
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">CV Engine</h1>
<AutosaveStatus />
</div>
</div>
</header>

View File

@ -0,0 +1,28 @@
import React from 'react';
import { useSaveStatus, useLastSaved, useSaveError } from '../store/cvStore';
interface AutosaveStatusProps {
className?: string;
}
const AutosaveStatus: React.FC<AutosaveStatusProps> = ({ 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 (
<div className={`text-sm ${color} ${className}`} aria-live="polite" role="status">
{text}
</div>
);
};
export default AutosaveStatus;

View File

@ -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<number | null>(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]);
}

View File

@ -13,10 +13,13 @@ interface CvState {
activeStep: EditorStep;
isDirty: boolean;
lastSaved: Date | null;
saveStatus: 'idle' | 'saving' | 'saved' | 'error';
saveError: string | null;
errors: Record<string, string[]>;
// Actions
setCv: (cv: z.infer<typeof CvSchema>) => void;
hydrate: (cv: z.infer<typeof CvSchema>, lastSaved?: Date | null) => void;
updatePersonal: (personal: z.infer<typeof CvSchema>['personal']) => void;
updateWork: (work: z.infer<typeof CvSchema>['work']) => void;
updateEducation: (education: z.infer<typeof CvSchema>['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,11 +67,15 @@ export const useCvStore = create<CvState>((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);
if (result.success) {
@ -274,6 +284,9 @@ export const useCvStore = create<CvState>((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();
@ -367,3 +380,5 @@ 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);
export const useSaveStatus = () => useCvStore(state => state.saveStatus);
export const useSaveError = () => useCvStore(state => state.saveError);