Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -49,7 +50,11 @@ const router = createBrowserRouter([
]);

function App() {
return <RouterProvider router={router} />;
return (
<SimulationProvider>
<RouterProvider router={router} />
</SimulationProvider>
);
}

export default App;
6 changes: 3 additions & 3 deletions frontend/src/components/InvestmentBuilder.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<Investment[]>([
createEmptyInvestment(),
]);
const { investments, setInvestments } = useSimulationContext();
const [showValidation, setShowValidation] = useState(false);

// Create a new empty investment
Expand Down
103 changes: 103 additions & 0 deletions frontend/src/context/SimulationContext.tsx
Original file line number Diff line number Diff line change
@@ -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<SimulationContextValue | null>(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<Investment[]>(
'moshimo:investments',
[createEmptyInvestment()],
);
const [simulationResults, setSimulationResults] =
usePersistedState<SimulationResponse | null>('moshimo:results', null);
const [lastRequest, setLastRequest] =
usePersistedState<SimulationRequest | null>('moshimo:lastRequest', null);
const [timeframe, setTimeframe] = usePersistedState<string>(
'moshimo:timeframe',
'ALL',
);

// ── Transient state (ephemeral — not persisted) ─────────────────
const [toast, setToast] = useState<ToastState | null>(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 (
<SimulationContext.Provider value={value}>
{children}
</SimulationContext.Provider>
);
}
72 changes: 72 additions & 0 deletions frontend/src/hooks/usePersistedState.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
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<T>(() => {
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];
}
48 changes: 24 additions & 24 deletions frontend/src/hooks/useSimulation.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<SimulationResponse | null>(null);
const [simulationError, setSimulationError] = useState<string | null>(null);
const [timeframe, setTimeframe] = useState<string>('ALL');
const [lastRequest, setLastRequest] = useState<SimulationRequest | null>(null);
const [toast, setToast] = useState<ToastState | null>(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',
});
Expand All @@ -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 ||
Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/hooks/useSimulationContext.ts
Original file line number Diff line number Diff line change
@@ -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 <SimulationProvider>.
*/
export function useSimulationContext(): SimulationContextValue {
const ctx = useContext(SimulationContext);
if (!ctx) {
throw new Error(
'useSimulationContext must be used within a <SimulationProvider>',
);
}
return ctx;
}
Loading