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
8 changes: 6 additions & 2 deletions frontend/src/components/dashboard/ContributionGrowthChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@ interface ContributionGrowthChartProps {
isDarkMode: boolean
projectionData: { age: number; Total: number }[]
annualContribution: number
showRealValues: boolean
inflationRate: number
}

export function ContributionGrowthChart({
isDarkMode,
projectionData,
annualContribution,
showRealValues,
inflationRate,
}: ContributionGrowthChartProps) {
const data = useMemo(
() => buildContributionGrowth({ projectionData, annualContribution }),
[projectionData, annualContribution]
() => buildContributionGrowth({ projectionData, annualContribution, showRealValues, inflationRate }),
[projectionData, annualContribution, showRealValues, inflationRate]
)

if (data.length === 0) return null
Expand Down
68 changes: 62 additions & 6 deletions frontend/src/components/dashboard/GrowthChart.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import type { ProjectionDataPoint } from '@/types/household'
import { formatCompactMoney, formatMoney } from '@/lib/formatting'
Expand All @@ -6,18 +7,73 @@ import { TrendingUp } from 'lucide-react'
interface GrowthChartProps {
isDarkMode: boolean
currentProjectionData: ProjectionDataPoint[]
conservativeProjectionData: ProjectionDataPoint[]
optimisticProjectionData: ProjectionDataPoint[]
yearsToRetirement: number
expectedReturn: number
}

export function GrowthChart({ isDarkMode, currentProjectionData, yearsToRetirement }: GrowthChartProps) {
export function GrowthChart({
isDarkMode,
currentProjectionData,
conservativeProjectionData,
optimisticProjectionData,
yearsToRetirement,
expectedReturn,
}: GrowthChartProps) {
const [activeScenario, setActiveScenario] = useState<'conservative' | 'realistic' | 'optimistic'>('realistic')

if (yearsToRetirement <= 0 || currentProjectionData.length === 0) return null

const chartData = activeScenario === 'conservative'
? conservativeProjectionData
: activeScenario === 'optimistic'
? optimisticProjectionData
: currentProjectionData

return (
<div className="animate-fade-in-up-delay-3 card p-6">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-primary" />
Growth Projection
</h2>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<h2 className="text-xl font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-primary" />
Growth Projection
</h2>

{/* Scenario Toggle */}
<div className="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-xl self-start sm:self-center">
<button
onClick={() => setActiveScenario('conservative')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
activeScenario === 'conservative'
? 'bg-white dark:bg-gray-700 text-gray-800 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
Conservative ({Math.max(0, expectedReturn - 2)}%)
</button>
<button
onClick={() => setActiveScenario('realistic')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
activeScenario === 'realistic'
? 'bg-white dark:bg-gray-700 text-gray-800 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
Realistic ({expectedReturn}%)
</button>
<button
onClick={() => setActiveScenario('optimistic')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
activeScenario === 'optimistic'
? 'bg-white dark:bg-gray-700 text-gray-800 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
Optimistic ({expectedReturn + 2}%)
</button>
</div>
</div>

<div className="mb-4 flex gap-4 text-sm">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-indigo-500"></span>
Expand All @@ -34,7 +90,7 @@ export function GrowthChart({ isDarkMode, currentProjectionData, yearsToRetireme
</div>
<div className="h-96">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={currentProjectionData} margin={{ top: 30, right: 50, left: 0, bottom: 25 }}>
<LineChart data={chartData} margin={{ top: 30, right: 50, left: 0, bottom: 25 }}>
<CartesianGrid strokeDasharray="3 3" stroke={isDarkMode ? '#374151' : '#e5e7eb'} strokeOpacity={0.25} />
<XAxis
dataKey="age"
Expand Down
116 changes: 87 additions & 29 deletions frontend/src/components/dashboard/ProjectionsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense } from 'react'
import { lazy, Suspense, useState } from 'react'
import { useProjectionContext } from '@/contexts/ProjectionContext'
import { useAssumptions } from '@/contexts/AssumptionsContext'
import { useDarkMode } from '@/hooks/useDarkMode'
Expand All @@ -22,6 +22,7 @@ export function ProjectionsTab() {
const [isDarkMode] = useDarkMode()
const projection = useProjectionContext()
const assumptions = useAssumptions()
const [accumulationView, setAccumulationView] = useState<'growth' | 'contributions' | 'volatility'>('growth')

const projectionData = projection.currentProjectionData
const startAge = projectionData.length > 0 ? projectionData[0].age : 0
Expand All @@ -30,36 +31,91 @@ export function ProjectionsTab() {

return (
<div className="space-y-6">
<Suspense fallback={<ChartFallback />}>
<GrowthChart
isDarkMode={isDarkMode}
currentProjectionData={projection.currentProjectionData}
yearsToRetirement={projection.yearsToRetirement}
/>
</Suspense>
{/* 1. Accumulation Phase (Saving) Section */}
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-gray-200/50 dark:border-gray-700/50 pb-3">
<div>
<h2 className="text-xl font-bold text-gray-800 dark:text-white">1. Saving Phase (Accumulation)</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Projecting your growth path up to retirement age</p>
</div>

<div className="flex bg-gray-100 dark:bg-gray-800 p-1 rounded-xl self-start md:self-center">
<button
onClick={() => setAccumulationView('growth')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
accumulationView === 'growth'
? 'bg-white dark:bg-gray-700 text-gray-800 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-250'
}`}
>
Standard Growth
</button>
<button
onClick={() => setAccumulationView('contributions')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
accumulationView === 'contributions'
? 'bg-white dark:bg-gray-700 text-gray-800 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-250'
}`}
>
Contribution vs. Growth
</button>
<button
onClick={() => setAccumulationView('volatility')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
accumulationView === 'volatility'
? 'bg-white dark:bg-gray-700 text-gray-800 dark:text-white shadow-sm'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-250'
}`}
>
Market Volatility (Monte Carlo)
</button>
</div>
</div>

<Suspense fallback={<ChartFallback />}>
<MonteCarloChart
isDarkMode={isDarkMode}
initialBalance={projectionData.length > 0 ? projectionData[0].Total : 0}
annualContribution={projection.totalAnnualContributions}
yearsToRetirement={projection.yearsToRetirement}
expectedReturn={assumptions.expectedReturn}
inflationRate={assumptions.inflationRate}
startAge={startAge}
showRealValues={assumptions.showRealValues}
/>
</Suspense>
<Suspense fallback={<ChartFallback />}>
{accumulationView === 'growth' && (
<GrowthChart
isDarkMode={isDarkMode}
currentProjectionData={projection.currentProjectionData}
conservativeProjectionData={projection.conservativeProjectionData}
optimisticProjectionData={projection.optimisticProjectionData}
yearsToRetirement={projection.yearsToRetirement}
expectedReturn={assumptions.expectedReturn}
/>
)}
{accumulationView === 'contributions' && (
<ContributionGrowthChart
isDarkMode={isDarkMode}
projectionData={projectionData}
annualContribution={projection.totalAnnualContributions}
showRealValues={assumptions.showRealValues}
inflationRate={assumptions.inflationRate}
/>
)}
{accumulationView === 'volatility' && (
<MonteCarloChart
isDarkMode={isDarkMode}
initialBalance={projectionData.length > 0 ? projectionData[0].Total : 0}
annualContribution={projection.totalAnnualContributions}
yearsToRetirement={projection.yearsToRetirement}
expectedReturn={assumptions.expectedReturn}
inflationRate={assumptions.inflationRate}
startAge={startAge}
showRealValues={assumptions.showRealValues}
/>
)}
</Suspense>
</div>

<Suspense fallback={<ChartFallback />}>
<ContributionGrowthChart
isDarkMode={isDarkMode}
projectionData={projectionData}
annualContribution={projection.totalAnnualContributions}
/>
</Suspense>
{/* 2. Decumulation Phase (Spending) Section */}
<div className="space-y-4 pt-4 border-t border-gray-200/50 dark:border-gray-700/50">
<div>
<h2 className="text-xl font-bold text-gray-800 dark:text-white">2. Spending Phase (Decumulation)</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Projecting how long your savings will last in retirement</p>
</div>

<Suspense fallback={<ChartFallback />}>
<Suspense fallback={<ChartFallback />}>
<DrawdownChart
isDarkMode={isDarkMode}
startBalance={startBalance}
Expand All @@ -71,8 +127,10 @@ export function ProjectionsTab() {
plannedGifts={assumptions.plannedGifts}
minCurrentAge={projection.minCurrentAge}
/>
</Suspense>
</Suspense>
</div>

{/* Educational Footer Info */}
<div className="card p-4 sm:p-6">
<h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-3 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-indigo-500" />
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/contexts/ProjectionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ interface ProjectionContextValueTyped {
wasmLoaded: boolean
wasmError: string | null
projectionData: ProjectionDataPoint[]
conservativeProjectionData: ProjectionDataPoint[]
optimisticProjectionData: ProjectionDataPoint[]
realProjectionData: ProjectionDataPoint[]
currentProjectionData: ProjectionDataPoint[]
householdRetirementAge: number
Expand Down Expand Up @@ -109,6 +111,14 @@ export function ProjectionProvider({ children }: { children: ReactNode }) {
() => buildCombinedProjection(people, calculateProjection, expectedReturn, inflationRate, showRealValues, householdRetirementAge),
[people, calculateProjection, expectedReturn, inflationRate, showRealValues, householdRetirementAge]
)
const conservativeProjectionData = useMemo(
() => buildCombinedProjection(people, calculateProjection, Math.max(0, expectedReturn - 2), inflationRate, showRealValues, householdRetirementAge),
[people, calculateProjection, expectedReturn, inflationRate, showRealValues, householdRetirementAge]
)
const optimisticProjectionData = useMemo(
() => buildCombinedProjection(people, calculateProjection, expectedReturn + 2, inflationRate, showRealValues, householdRetirementAge),
[people, calculateProjection, expectedReturn, inflationRate, showRealValues, householdRetirementAge]
)
const realProjectionData = useMemo(
() => buildCombinedProjection(people, calculateProjection, expectedReturn, inflationRate, true, householdRetirementAge),
[people, calculateProjection, expectedReturn, inflationRate, householdRetirementAge]
Expand All @@ -117,7 +127,8 @@ export function ProjectionProvider({ children }: { children: ReactNode }) {
return (
<ProjectionContext.Provider value={{
wasmLoaded, wasmError,
projectionData, realProjectionData, currentProjectionData: projectionData,
projectionData, conservativeProjectionData, optimisticProjectionData,
realProjectionData, currentProjectionData: projectionData,
householdRetirementAge, yearsToRetirement, minCurrentAge,
totalAnnualIncome, totalAnnualPension, totalAnnualCpp, totalAnnualOas,
totalPortfolio, totalAnnualContributions,
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/lib/projectionSim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,16 @@ export interface ContributionGrowthPoint {
export interface ContributionGrowthParams {
projectionData: { age: number; Total: number }[]
annualContribution: number
showRealValues?: boolean
inflationRate?: number
}

// Splits the accumulation path into cumulative contributions vs cumulative
// investment growth, using the actual projected balances (so the split is exact).
export function buildContributionGrowth(
params: ContributionGrowthParams
): ContributionGrowthPoint[] {
const { projectionData, annualContribution } = params
const { projectionData, annualContribution, showRealValues = true, inflationRate = 2.5 } = params
if (projectionData.length === 0) return []

const result: ContributionGrowthPoint[] = []
Expand All @@ -122,12 +124,14 @@ export function buildContributionGrowth(
result.push({ age: projectionData[0].age, contributions: initial, growth: 0, total: initial })

for (let i = 1; i < projectionData.length; i++) {
cumContrib += annualContribution
const deflateFactor = showRealValues ? Math.pow(1 + inflationRate / 100, i) : 1
cumContrib += annualContribution / deflateFactor
const total = projectionData[i].Total
const growth = Math.max(0, total - cumContrib)
result.push({
age: projectionData[i].age,
contributions: cumContrib,
growth: total - cumContrib,
contributions: total - growth,
growth,
total,
})
}
Expand Down