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 = () => {
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