feat: simulation state management (React Context) and sharing/export

This commit is contained in:
2026-02-22 20:00:33 +01:00
parent 87370d8fad
commit 7defeb87b3
2 changed files with 249 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
'use client';
import React, {
createContext,
useContext,
useReducer,
useMemo,
useEffect,
useCallback,
type ReactNode,
} from 'react';
import type { SimulationState, SimulationResults, LoanInput } from '@/lib/types';
import { computeSimulation } from '@/lib/calculations';
import { defaultStateNeuf, defaultStateAncien, createEmptyLoan, generateLoanId } from '@/lib/defaults';
import { decodeState } from '@/lib/sharing';
// ─── Actions ────────────────────────────────────────────────────────────────
type Action =
| { type: 'SET_FIELD'; field: keyof SimulationState; value: unknown }
| { type: 'SET_PROPERTY_TYPE'; value: 'neuf' | 'ancien' }
| { type: 'ADD_LOAN'; loan: LoanInput }
| { type: 'REMOVE_LOAN'; id: string }
| { type: 'UPDATE_LOAN'; id: string; field: keyof LoanInput; value: unknown }
| { type: 'TOGGLE_LOAN'; id: string }
| { type: 'LOAD_STATE'; state: SimulationState }
| { type: 'RESET' };
function reducer(state: SimulationState, action: Action): SimulationState {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SET_PROPERTY_TYPE': {
if (action.value === state.propertyType) return state;
// Switch between neuf and ancien defaults
const base = action.value === 'neuf' ? defaultStateNeuf() : defaultStateAncien();
return {
...state,
propertyType: action.value,
notaryFeesRate: base.notaryFeesRate,
loans: action.value === 'ancien'
? state.loans.filter((l) => l.name !== 'Prêt Taux Zéro')
: state.loans,
};
}
case 'ADD_LOAN':
return { ...state, loans: [...state.loans, action.loan] };
case 'REMOVE_LOAN':
return { ...state, loans: state.loans.filter((l) => l.id !== action.id) };
case 'UPDATE_LOAN':
return {
...state,
loans: state.loans.map((l) =>
l.id === action.id ? { ...l, [action.field]: action.value } : l
),
};
case 'TOGGLE_LOAN':
return {
...state,
loans: state.loans.map((l) =>
l.id === action.id ? { ...l, enabled: !l.enabled } : l
),
};
case 'LOAD_STATE':
return action.state;
case 'RESET':
return defaultStateNeuf();
default:
return state;
}
}
// ─── Context ────────────────────────────────────────────────────────────────
interface SimulationContextValue {
state: SimulationState;
results: SimulationResults;
setField: (field: keyof SimulationState, value: unknown) => void;
setPropertyType: (type: 'neuf' | 'ancien') => void;
addLoan: (loan: LoanInput) => void;
removeLoan: (id: string) => void;
updateLoan: (id: string, field: keyof LoanInput, value: unknown) => void;
toggleLoan: (id: string) => void;
loadState: (state: SimulationState) => void;
reset: () => void;
}
const SimulationContext = createContext<SimulationContextValue | null>(null);
// ─── Provider ───────────────────────────────────────────────────────────────
export function SimulationProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, defaultStateNeuf());
// Load state from URL hash on mount
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash) {
decodeState(hash).then((loaded) => {
if (loaded) {
// Re-generate loan IDs to avoid conflicts
const withIds = {
...loaded,
loans: loaded.loans.map((l) => ({ ...l, id: generateLoanId() })),
};
dispatch({ type: 'LOAD_STATE', state: withIds });
}
});
}
}, []);
const results = useMemo(() => computeSimulation(state), [state]);
const setField = useCallback(
(field: keyof SimulationState, value: unknown) =>
dispatch({ type: 'SET_FIELD', field, value }),
[]
);
const setPropertyType = useCallback(
(type: 'neuf' | 'ancien') => dispatch({ type: 'SET_PROPERTY_TYPE', value: type }),
[]
);
const addLoan = useCallback(
(loan: LoanInput) => dispatch({ type: 'ADD_LOAN', loan }),
[]
);
const removeLoan = useCallback(
(id: string) => dispatch({ type: 'REMOVE_LOAN', id }),
[]
);
const updateLoan = useCallback(
(id: string, field: keyof LoanInput, value: unknown) =>
dispatch({ type: 'UPDATE_LOAN', id, field, value }),
[]
);
const toggleLoan = useCallback(
(id: string) => dispatch({ type: 'TOGGLE_LOAN', id }),
[]
);
const loadState = useCallback(
(s: SimulationState) => dispatch({ type: 'LOAD_STATE', state: s }),
[]
);
const reset = useCallback(() => dispatch({ type: 'RESET' }), []);
const value = useMemo(
() => ({
state,
results,
setField,
setPropertyType,
addLoan,
removeLoan,
updateLoan,
toggleLoan,
loadState,
reset,
}),
[state, results, setField, setPropertyType, addLoan, removeLoan, updateLoan, toggleLoan, loadState, reset]
);
return (
<SimulationContext.Provider value={value}>
{children}
</SimulationContext.Provider>
);
}
// ─── Hook ───────────────────────────────────────────────────────────────────
export function useSimulation(): SimulationContextValue {
const ctx = useContext(SimulationContext);
if (!ctx) {
throw new Error('useSimulation must be used within SimulationProvider');
}
return ctx;
}

57
src/lib/sharing.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { SimulationState } from './types';
/**
* Encode simulation state into a shareable URL hash.
* Uses lz-string for compression.
*/
export async function encodeState(state: SimulationState): Promise<string> {
const { compressToEncodedURIComponent } = await import('lz-string');
const json = JSON.stringify(state);
return compressToEncodedURIComponent(json);
}
/**
* Decode simulation state from a URL hash.
*/
export async function decodeState(hash: string): Promise<SimulationState | null> {
try {
const { decompressFromEncodedURIComponent } = await import('lz-string');
const json = decompressFromEncodedURIComponent(hash);
if (!json) return null;
return JSON.parse(json) as SimulationState;
} catch {
return null;
}
}
/**
* Export yearly data as CSV.
*/
export function exportCSV(headers: string[], rows: (string | number)[][]): void {
const bom = '\uFEFF'; // UTF-8 BOM for Excel
const sep = ';';
const lines = [
headers.join(sep),
...rows.map((row) => row.map((v) => (typeof v === 'number' ? v.toFixed(2) : v)).join(sep)),
];
const csv = bom + lines.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `simulation-pret-immo-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/**
* Copy text to clipboard.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}