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(); + }); +});