diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 81fc0ba..b2d8fd0 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,6 +4,7 @@ import Layout from './components/Layout/Layout';
import HomePage from './pages/Home/HomePage';
import { LoadingSpinner } from './components/LoadingSpinner';
import { ErrorBoundary } from './components/ErrorBoundary';
+import { SimulationProvider } from './context/SimulationContext';
const SimulatorPage = React.lazy(() => import('./pages/Simulator/SimulatorPage'));
const AboutPage = React.lazy(() => import('./pages/About/AboutPage'));
@@ -49,7 +50,11 @@ const router = createBrowserRouter([
]);
function App() {
- return ;
+ return (
+
+
+
+ );
}
export default App;
\ No newline at end of file
diff --git a/frontend/src/components/InvestmentBuilder.tsx b/frontend/src/components/InvestmentBuilder.tsx
index 6c50e7d..0066133 100644
--- a/frontend/src/components/InvestmentBuilder.tsx
+++ b/frontend/src/components/InvestmentBuilder.tsx
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react';
import { InvestmentForm } from './InvestmentForm';
+import { useSimulationContext } from '../hooks/useSimulationContext';
import type { Asset, Investment, SimulationRequest } from '../types/api.types';
import styles from './InvestmentBuilder.module.css';
@@ -16,11 +17,10 @@ interface InvestmentBuilderProps {
* - Dynamic form arrays: Add/remove items
* - Form validation: Only enable submit when all investments valid
* - UUID generation: crypto.randomUUID() for unique keys
+ * - Investments state lives in SimulationContext (survives navigation)
*/
export function InvestmentBuilder({ assets, onSimulate, isSimulating }: InvestmentBuilderProps) {
- const [investments, setInvestments] = useState([
- createEmptyInvestment(),
- ]);
+ const { investments, setInvestments } = useSimulationContext();
const [showValidation, setShowValidation] = useState(false);
// Create a new empty investment
diff --git a/frontend/src/context/SimulationContext.tsx b/frontend/src/context/SimulationContext.tsx
new file mode 100644
index 0000000..1a50d5f
--- /dev/null
+++ b/frontend/src/context/SimulationContext.tsx
@@ -0,0 +1,103 @@
+import { createContext, useState, useCallback } from 'react';
+import type { ReactNode } from 'react';
+import { usePersistedState } from '../hooks/usePersistedState';
+import type {
+ Investment,
+ SimulationRequest,
+ SimulationResponse,
+} from '../types/api.types';
+
+// ── Toast type (shared with useSimulation) ──────────────────────────
+export interface ToastState {
+ message: string;
+ type: 'success' | 'error';
+}
+
+// ── Context value ───────────────────────────────────────────────────
+export interface SimulationContextValue {
+ /** Current investment rows in the builder form */
+ investments: Investment[];
+ setInvestments: (investments: Investment[]) => void;
+
+ /** Latest simulation response (null before first run) */
+ simulationResults: SimulationResponse | null;
+ setSimulationResults: (results: SimulationResponse | null) => void;
+
+ /** The request that produced the current results */
+ lastRequest: SimulationRequest | null;
+ setLastRequest: (request: SimulationRequest | null) => void;
+
+ /** Selected chart timeframe */
+ timeframe: string;
+ setTimeframe: (tf: string) => void;
+
+ /** Toast notification */
+ toast: ToastState | null;
+ setToast: (toast: ToastState | null) => void;
+
+ /** Convenience: clear everything for a fresh start */
+ clearResults: () => void;
+}
+
+// sentinel value — overwritten by the provider
+export const SimulationContext = createContext(null);
+
+// ── Helper: create a blank investment row ───────────────────────────
+function createEmptyInvestment(): Investment {
+ return {
+ id: crypto.randomUUID(),
+ symbol: '',
+ amountUsd: 0,
+ purchaseDate: '',
+ };
+}
+
+// ── Provider ────────────────────────────────────────────────────────
+interface SimulationProviderProps {
+ children: ReactNode;
+}
+
+export function SimulationProvider({ children }: SimulationProviderProps) {
+ // ── Persisted state (survives navigation + page refresh) ────────
+ const [investments, setInvestments] = usePersistedState(
+ 'moshimo:investments',
+ [createEmptyInvestment()],
+ );
+ const [simulationResults, setSimulationResults] =
+ usePersistedState('moshimo:results', null);
+ const [lastRequest, setLastRequest] =
+ usePersistedState('moshimo:lastRequest', null);
+ const [timeframe, setTimeframe] = usePersistedState(
+ 'moshimo:timeframe',
+ 'ALL',
+ );
+
+ // ── Transient state (ephemeral — not persisted) ─────────────────
+ const [toast, setToast] = useState(null);
+
+ const clearResults = useCallback(() => {
+ setSimulationResults(null);
+ setLastRequest(null);
+ setTimeframe('ALL');
+ }, []);
+
+ const value: SimulationContextValue = {
+ investments,
+ setInvestments,
+ simulationResults,
+ setSimulationResults,
+ lastRequest,
+ setLastRequest,
+ timeframe,
+ setTimeframe,
+ toast,
+ setToast,
+ clearResults,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/hooks/usePersistedState.ts b/frontend/src/hooks/usePersistedState.ts
new file mode 100644
index 0000000..5a9dd15
--- /dev/null
+++ b/frontend/src/hooks/usePersistedState.ts
@@ -0,0 +1,72 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+
+type StorageType = 'session' | 'local';
+
+/**
+ * Like useState, but the value is automatically persisted to
+ * sessionStorage (default) or localStorage.
+ *
+ * - Reads the stored value on first mount; falls back to `initial`.
+ * - Writes every time the value changes.
+ * - Gracefully handles corrupt / unparseable data.
+ */
+export function usePersistedState(
+ key: string,
+ initial: T,
+ storage: StorageType = 'session',
+): [T, (value: T | ((prev: T) => T)) => void] {
+ const storageObj =
+ storage === 'local' ? globalThis.localStorage : globalThis.sessionStorage;
+
+ // Read from storage only on first mount (lazy initialiser)
+ const [value, setValue] = useState(() => {
+ try {
+ const stored = storageObj.getItem(key);
+ if (stored !== null) {
+ return JSON.parse(stored) as T;
+ }
+ } catch {
+ // corrupt data — remove it and fall back
+ storageObj.removeItem(key);
+ }
+ return initial;
+ });
+
+ // Keep a ref so the effect always has the latest value without
+ // re-running the effect closure on every render.
+ const isFirstMount = useRef(true);
+
+ // Persist whenever the value changes (skip initial mount because
+ // the lazy initialiser already read from storage).
+ useEffect(() => {
+ if (isFirstMount.current) {
+ isFirstMount.current = false;
+ return;
+ }
+ try {
+ storageObj.setItem(key, JSON.stringify(value));
+ } catch {
+ // storage full or unavailable — silently ignore
+ }
+ }, [key, value, storageObj]);
+
+ // Stable setter that mirrors React's useState API
+ const setPersistedValue = useCallback(
+ (next: T | ((prev: T) => T)) => {
+ setValue((prev) => {
+ const resolved =
+ typeof next === 'function' ? (next as (prev: T) => T)(prev) : next;
+ // Write immediately so the storage is always in sync
+ try {
+ storageObj.setItem(key, JSON.stringify(resolved));
+ } catch {
+ // ignore
+ }
+ return resolved;
+ });
+ },
+ [key, storageObj],
+ );
+
+ return [value, setPersistedValue];
+}
diff --git a/frontend/src/hooks/useSimulation.ts b/frontend/src/hooks/useSimulation.ts
index e9bf0c3..d8281db 100644
--- a/frontend/src/hooks/useSimulation.ts
+++ b/frontend/src/hooks/useSimulation.ts
@@ -1,11 +1,8 @@
import { useState } from 'react';
import { portfolioApi } from '../services/api/portfolioApi';
import type { SimulationRequest, SimulationResponse } from '../types/api.types';
-
-interface ToastState {
- message: string;
- type: 'success' | 'error';
-}
+import { useSimulationContext } from './useSimulationContext';
+import type { ToastState } from '../context/SimulationContext';
interface SimulationHookResult {
isSimulating: boolean;
@@ -22,25 +19,28 @@ interface SimulationHookResult {
/**
* Manages the full simulation lifecycle: running a simulation,
* changing timeframes, and tracking toast / error state.
+ *
+ * Persistent state (results, request, timeframe) is stored in
+ * SimulationContext and survives navigation / page refresh.
+ * Transient state (isSimulating, simulationError) stays local.
*/
export function useSimulation(): SimulationHookResult {
+ const ctx = useSimulationContext();
+
+ // ── Transient (local) state ─────────────────────────────────────
const [isSimulating, setIsSimulating] = useState(false);
- const [simulationResults, setSimulationResults] = useState(null);
const [simulationError, setSimulationError] = useState(null);
- const [timeframe, setTimeframe] = useState('ALL');
- const [lastRequest, setLastRequest] = useState(null);
- const [toast, setToast] = useState(null);
const handleSimulate = async (request: SimulationRequest) => {
try {
setIsSimulating(true);
setSimulationError(null);
- setLastRequest(request);
+ ctx.setLastRequest(request);
- const results = await portfolioApi.simulate(request, timeframe);
- setSimulationResults(results);
+ const results = await portfolioApi.simulate(request, ctx.timeframe);
+ ctx.setSimulationResults(results);
- setToast({
+ ctx.setToast({
message: '🎉 Simulation complete! Check out your results below.',
type: 'success',
});
@@ -58,24 +58,24 @@ export function useSimulation(): SimulationHookResult {
err.message ||
'Simulation failed. Please check your inputs and try again.',
);
- setSimulationResults(null);
+ ctx.setSimulationResults(null);
} finally {
setIsSimulating(false);
}
};
const handleTimeframeChange = async (newTimeframe: string) => {
- setTimeframe(newTimeframe);
+ ctx.setTimeframe(newTimeframe);
- if (lastRequest) {
+ if (ctx.lastRequest) {
try {
setIsSimulating(true);
setSimulationError(null);
// Clear stale results so the loading spinner shows immediately
- setSimulationResults(null);
+ ctx.setSimulationResults(null);
- const results = await portfolioApi.simulate(lastRequest, newTimeframe);
- setSimulationResults(results);
+ const results = await portfolioApi.simulate(ctx.lastRequest, newTimeframe);
+ ctx.setSimulationResults(results);
} catch (err: any) {
setSimulationError(
err.response?.data?.message ||
@@ -88,15 +88,15 @@ export function useSimulation(): SimulationHookResult {
}
};
- const clearToast = () => setToast(null);
+ const clearToast = () => ctx.setToast(null);
return {
isSimulating,
- simulationResults,
+ simulationResults: ctx.simulationResults,
simulationError,
- timeframe,
- lastRequest,
- toast,
+ timeframe: ctx.timeframe,
+ lastRequest: ctx.lastRequest,
+ toast: ctx.toast,
clearToast,
handleSimulate,
handleTimeframeChange,
diff --git a/frontend/src/hooks/useSimulationContext.ts b/frontend/src/hooks/useSimulationContext.ts
new file mode 100644
index 0000000..40b9495
--- /dev/null
+++ b/frontend/src/hooks/useSimulationContext.ts
@@ -0,0 +1,17 @@
+import { useContext } from 'react';
+import { SimulationContext } from '../context/SimulationContext';
+import type { SimulationContextValue } from '../context/SimulationContext';
+
+/**
+ * Convenience hook for consuming SimulationContext.
+ * Throws if used outside .
+ */
+export function useSimulationContext(): SimulationContextValue {
+ const ctx = useContext(SimulationContext);
+ if (!ctx) {
+ throw new Error(
+ 'useSimulationContext must be used within a ',
+ );
+ }
+ return ctx;
+}
diff --git a/frontend/src/test/InvestmentBuilder.test.tsx b/frontend/src/test/InvestmentBuilder.test.tsx
index 05cf7be..61abb4e 100644
--- a/frontend/src/test/InvestmentBuilder.test.tsx
+++ b/frontend/src/test/InvestmentBuilder.test.tsx
@@ -1,7 +1,15 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { InvestmentBuilder } from '../components/InvestmentBuilder';
+import { SimulationProvider } from '../context/SimulationContext';
import type { Asset } from '../types/api.types';
+import type { ReactNode } from 'react';
+
+// ── Wrapper that provides required context ─────────────────────────────
+
+function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+}
// ── Test fixtures ───────────────────────────────────────────────────────
@@ -37,6 +45,7 @@ describe('InvestmentBuilder', () => {
const onSimulate = vi.fn();
render(
,
+ { wrapper: Wrapper },
);
expect(screen.getByText('Build Your Portfolio')).toBeInTheDocument();
@@ -49,6 +58,7 @@ describe('InvestmentBuilder', () => {
const onSimulate = vi.fn();
render(
,
+ { wrapper: Wrapper },
);
const addBtn = screen.getByText('+ Add Another Investment');
@@ -60,6 +70,7 @@ describe('InvestmentBuilder', () => {
const onSimulate = vi.fn();
render(
,
+ { wrapper: Wrapper },
);
fireEvent.click(screen.getByText('+ Add Another Investment'));
@@ -72,6 +83,7 @@ describe('InvestmentBuilder', () => {
const onSimulate = vi.fn();
render(
,
+ { wrapper: Wrapper },
);
const simBtn = screen.getByRole('button', { name: /simulate/i });
@@ -82,6 +94,7 @@ describe('InvestmentBuilder', () => {
const onSimulate = vi.fn();
render(
,
+ { wrapper: Wrapper },
);
const simBtn = screen.getByRole('button', { name: /simulating/i });
@@ -92,6 +105,7 @@ describe('InvestmentBuilder', () => {
const onSimulate = vi.fn();
render(
,
+ { wrapper: Wrapper },
);
expect(screen.getByText(/Simulating\.\.\./i)).toBeInTheDocument();
@@ -101,6 +115,7 @@ describe('InvestmentBuilder', () => {
const onSimulate = vi.fn();
render(
,
+ { wrapper: Wrapper },
);
const simBtn = screen.getByRole('button', { name: /simulate/i });
diff --git a/frontend/src/test/SimulationContext.test.tsx b/frontend/src/test/SimulationContext.test.tsx
new file mode 100644
index 0000000..6bf82da
--- /dev/null
+++ b/frontend/src/test/SimulationContext.test.tsx
@@ -0,0 +1,159 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { SimulationProvider } from '../context/SimulationContext';
+import { useSimulationContext } from '../hooks/useSimulationContext';
+import type { SimulationResponse, SimulationRequest } from '../types/api.types';
+import type { ReactNode } from 'react';
+
+// ── Wrapper ─────────────────────────────────────────────────────────────
+
+function wrapper({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+// ── Fixtures ────────────────────────────────────────────────────────────
+
+const mockResults: SimulationResponse = {
+ totalInvested: 10000,
+ currentValue: 15000,
+ absoluteGain: 5000,
+ percentReturn: 50,
+ cagr: 12.5,
+ timeline: [
+ { date: '2023-01-01', value: 10000 },
+ { date: '2024-01-01', value: 15000 },
+ ],
+ holdings: [
+ {
+ symbol: 'AAPL',
+ name: 'Apple Inc.',
+ invested: 10000,
+ currentValue: 15000,
+ shares: 50,
+ purchasePrice: 200,
+ currentPrice: 300,
+ absoluteGain: 5000,
+ percentReturn: 50,
+ },
+ ],
+};
+
+const mockRequest: SimulationRequest = {
+ investments: [
+ { symbol: 'AAPL', amountUsd: 10000, purchaseDate: '2023-01-01' },
+ ],
+};
+
+// ── Tests ───────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ sessionStorage.clear();
+});
+
+describe('SimulationContext', () => {
+ it('provides default state', () => {
+ const { result } = renderHook(() => useSimulationContext(), { wrapper });
+
+ expect(result.current.investments).toHaveLength(1);
+ expect(result.current.investments[0].symbol).toBe('');
+ expect(result.current.simulationResults).toBeNull();
+ expect(result.current.lastRequest).toBeNull();
+ expect(result.current.timeframe).toBe('ALL');
+ expect(result.current.toast).toBeNull();
+ });
+
+ it('throws when used outside SimulationProvider', () => {
+ expect(() => {
+ renderHook(() => useSimulationContext());
+ }).toThrow('useSimulationContext must be used within a ');
+ });
+
+ it('updates simulationResults', () => {
+ const { result } = renderHook(() => useSimulationContext(), { wrapper });
+
+ act(() => {
+ result.current.setSimulationResults(mockResults);
+ });
+
+ expect(result.current.simulationResults).toEqual(mockResults);
+ });
+
+ it('updates lastRequest', () => {
+ const { result } = renderHook(() => useSimulationContext(), { wrapper });
+
+ act(() => {
+ result.current.setLastRequest(mockRequest);
+ });
+
+ expect(result.current.lastRequest).toEqual(mockRequest);
+ });
+
+ it('updates timeframe', () => {
+ const { result } = renderHook(() => useSimulationContext(), { wrapper });
+
+ act(() => {
+ result.current.setTimeframe('1Y');
+ });
+
+ expect(result.current.timeframe).toBe('1Y');
+ });
+
+ it('updates investments', () => {
+ const { result } = renderHook(() => useSimulationContext(), { wrapper });
+
+ const newInvestments = [
+ { id: 'test-id', symbol: 'MSFT', amountUsd: 5000, purchaseDate: '2023-06-01' },
+ ];
+ act(() => {
+ result.current.setInvestments(newInvestments);
+ });
+
+ expect(result.current.investments).toEqual(newInvestments);
+ });
+
+ it('clearResults resets results, lastRequest, and timeframe', () => {
+ const { result } = renderHook(() => useSimulationContext(), { wrapper });
+
+ // Set some state first
+ act(() => {
+ result.current.setSimulationResults(mockResults);
+ result.current.setLastRequest(mockRequest);
+ result.current.setTimeframe('1Y');
+ });
+
+ // Clear
+ act(() => {
+ result.current.clearResults();
+ });
+
+ expect(result.current.simulationResults).toBeNull();
+ expect(result.current.lastRequest).toBeNull();
+ expect(result.current.timeframe).toBe('ALL');
+ });
+
+ it('persists state to sessionStorage', () => {
+ const { result } = renderHook(() => useSimulationContext(), { wrapper });
+
+ act(() => {
+ result.current.setSimulationResults(mockResults);
+ result.current.setTimeframe('5Y');
+ });
+
+ // Verify sessionStorage has the values
+ expect(JSON.parse(sessionStorage.getItem('moshimo:results')!)).toEqual(mockResults);
+ expect(JSON.parse(sessionStorage.getItem('moshimo:timeframe')!)).toBe('5Y');
+ });
+
+ it('restores state from sessionStorage on remount', () => {
+ // Pre-populate sessionStorage as if user navigated away
+ sessionStorage.setItem('moshimo:results', JSON.stringify(mockResults));
+ sessionStorage.setItem('moshimo:lastRequest', JSON.stringify(mockRequest));
+ sessionStorage.setItem('moshimo:timeframe', JSON.stringify('1Y'));
+
+ const { result } = renderHook(() => useSimulationContext(), { wrapper });
+
+ expect(result.current.simulationResults).toEqual(mockResults);
+ expect(result.current.lastRequest).toEqual(mockRequest);
+ expect(result.current.timeframe).toBe('1Y');
+ });
+});
diff --git a/frontend/src/test/usePersistedState.test.ts b/frontend/src/test/usePersistedState.test.ts
new file mode 100644
index 0000000..115c666
--- /dev/null
+++ b/frontend/src/test/usePersistedState.test.ts
@@ -0,0 +1,114 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePersistedState } from '../hooks/usePersistedState';
+
+// ── Helpers ─────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ sessionStorage.clear();
+ localStorage.clear();
+});
+
+// ── usePersistedState ───────────────────────────────────────────────────
+
+describe('usePersistedState', () => {
+ it('returns the initial value when storage is empty', () => {
+ const { result } = renderHook(() =>
+ usePersistedState('test-key', 'initial'),
+ );
+ expect(result.current[0]).toBe('initial');
+ });
+
+ it('reads the stored value on mount', () => {
+ sessionStorage.setItem('test-key', JSON.stringify('stored'));
+
+ const { result } = renderHook(() =>
+ usePersistedState('test-key', 'initial'),
+ );
+ expect(result.current[0]).toBe('stored');
+ });
+
+ it('writes to sessionStorage when the value changes', () => {
+ const { result } = renderHook(() =>
+ usePersistedState('test-key', 'initial'),
+ );
+
+ act(() => {
+ result.current[1]('updated');
+ });
+
+ expect(result.current[0]).toBe('updated');
+ expect(JSON.parse(sessionStorage.getItem('test-key')!)).toBe('updated');
+ });
+
+ it('supports the updater-function form of setState', () => {
+ const { result } = renderHook(() =>
+ usePersistedState('test-key', 10),
+ );
+
+ act(() => {
+ result.current[1]((prev) => prev + 5);
+ });
+
+ expect(result.current[0]).toBe(15);
+ expect(JSON.parse(sessionStorage.getItem('test-key')!)).toBe(15);
+ });
+
+ it('persists objects (serialisation round-trip)', () => {
+ const obj = { name: 'AAPL', amount: 1000 };
+ const { result } = renderHook(() =>
+ usePersistedState('test-key', obj),
+ );
+
+ const updated = { name: 'MSFT', amount: 2000 };
+ act(() => {
+ result.current[1](updated);
+ });
+
+ expect(result.current[0]).toEqual(updated);
+ expect(JSON.parse(sessionStorage.getItem('test-key')!)).toEqual(updated);
+ });
+
+ it('handles corrupt data gracefully (falls back to initial)', () => {
+ sessionStorage.setItem('test-key', 'NOT-VALID-JSON{{{');
+
+ const { result } = renderHook(() =>
+ usePersistedState('test-key', 'fallback'),
+ );
+
+ expect(result.current[0]).toBe('fallback');
+ // corrupt value should have been removed
+ expect(sessionStorage.getItem('test-key')).toBeNull();
+ });
+
+ it('uses localStorage when storage param is "local"', () => {
+ localStorage.setItem('local-key', JSON.stringify('from-local'));
+
+ const { result } = renderHook(() =>
+ usePersistedState('local-key', 'initial', 'local'),
+ );
+
+ expect(result.current[0]).toBe('from-local');
+
+ act(() => {
+ result.current[1]('new-value');
+ });
+
+ expect(JSON.parse(localStorage.getItem('local-key')!)).toBe('new-value');
+ // should NOT be in sessionStorage
+ expect(sessionStorage.getItem('local-key')).toBeNull();
+ });
+
+ it('defaults to sessionStorage (not localStorage)', () => {
+ const { result } = renderHook(() =>
+ usePersistedState('test-key', 'val'),
+ );
+
+ act(() => {
+ result.current[1]('updated');
+ });
+
+ expect(sessionStorage.getItem('test-key')).not.toBeNull();
+ expect(localStorage.getItem('test-key')).toBeNull();
+ });
+});