diff --git a/.gitignore b/.gitignore index 435c512..ec298ae 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ deploy.sh todo.md .agent/* .github/copilot-instructions.md +improvements.md .agent/* diff --git a/src/App.tsx b/src/App.tsx index a988c40..80730d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,8 @@ import AccumulationStrategy from './components/features/AccumulationStrategy'; import TaxReference from './components/features/TaxReference'; import FireAnalysis from './components/features/FireAnalysis'; import { calculateStrategy, calculateLongevity } from './services/calculationEngine'; -import { TrendingUp, Calculator, AlertTriangle, BookOpen, Sun, Moon, PiggyBank, Settings, Flame, RefreshCw } from 'lucide-react'; +import { TrendingUp, Calculator, AlertTriangle, Sun, Moon, PiggyBank, Settings, Flame, RefreshCw, HelpCircle, X } from 'lucide-react'; +import ErrorBoundary from './components/common/ErrorBoundary'; import Footer from './components/layout/Footer'; import WizardModal from './components/features/wizard/WizardModal'; import SettingsModal from './components/features/SettingsModal'; @@ -36,12 +37,14 @@ const App: React.FC = () => { const [profile, setProfile] = useState(INITIAL_PROFILE); const [strategyResult, setStrategyResult] = useState(null); const [longevityResult, setLongevityResult] = useState(null); - const [activeTab, setActiveTab] = useState<'withdrawal' | 'accumulation' | 'longevity' | 'reference' | 'fire' | 'scenarios'>('accumulation'); + const [activeTab, setActiveTab] = useState<'withdrawal' | 'accumulation' | 'longevity' | 'fire' | 'scenarios'>('accumulation'); + const [isReferenceOpen, setIsReferenceOpen] = useState(false); const [isDarkMode, setIsDarkMode] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [apiKey, setApiKey] = useState(''); const [isLoaded, setIsLoaded] = useState(false); + const [savedAt, setSavedAt] = useState(null); // Computed Retirement Profile // If the user is currently 55 but retiring at 65, we must project their assets @@ -92,14 +95,31 @@ const App: React.FC = () => { }, [profile]); + // Profile validation + const profileErrors: string[] = []; + if (profile.age < profile.baseAge) profileErrors.push('Retirement age must be ≥ current age.'); + if (profile.spendingNeed <= 0) profileErrors.push('Annual spending need must be greater than $0.'); + if (profile.baseAge <= 0) profileErrors.push('Current age must be greater than 0.'); + const isProfileValid = profileErrors.length === 0; useEffect(() => { - // Run strategy on the computed RETIREMENT profile - const sResult = calculateStrategy(retirementProfile); - const lResult = calculateLongevity(retirementProfile, sResult); - setStrategyResult(sResult); - setLongevityResult(lResult); - }, [retirementProfile]); // Depend on retirementProfile instead of profile + if (!isProfileValid) { + setStrategyResult(null); + setLongevityResult(null); + return; + } + try { + // Run strategy on the computed RETIREMENT profile + const sResult = calculateStrategy(retirementProfile); + const lResult = calculateLongevity(retirementProfile, sResult); + setStrategyResult(sResult); + setLongevityResult(lResult); + } catch (error) { + console.error('Calculation error:', error); + setStrategyResult(null); + setLongevityResult(null); + } + }, [retirementProfile, isProfileValid]); // Depend on retirementProfile instead of profile useEffect(() => { const loadData = async () => { @@ -160,7 +180,7 @@ const App: React.FC = () => { useEffect(() => { if (!isLoaded) return; const timer = setTimeout(() => { - db.profiles.put({ ...profile, id: 1 }).catch(e => console.error("Save failed:", e)); + db.profiles.put({ ...profile, id: 1 }).then(() => setSavedAt(new Date())).catch(e => console.error("Save failed:", e)); }, 1000); return () => clearTimeout(timer); }, [profile, isLoaded]); @@ -211,7 +231,25 @@ const App: React.FC = () => { setApiKey={setApiKey} onReset={handleReset} /> -
+ {isReferenceOpen && ( +
setIsReferenceOpen(false)}> +
e.stopPropagation()}> +
+

+ + Tax Reference +

+ +
+
+ +
+
+
+ )} +
Educational purposes only. No professional financial or tax advice intended. @@ -223,16 +261,21 @@ const App: React.FC = () => {
FiscalSunset Logo
-

FiscalSunset.

+

FiscalSunset.

Tax-Efficient Planner

- - +
+ + + +
@@ -243,6 +286,7 @@ const App: React.FC = () => { profile={profile} setProfile={setProfile} onRestartWizard={() => setIsWizardOpen(true)} + savedAt={savedAt} />
@@ -255,8 +299,7 @@ const App: React.FC = () => { { id: 'fire', icon: Flame, label: 'FIRE Analysis' }, { id: 'withdrawal', icon: Calculator, label: 'Withdrawal' }, { id: 'longevity', icon: TrendingUp, label: 'Longevity' }, - { id: 'scenarios', icon: RefreshCw, label: 'Scenarios' }, - { id: 'reference', icon: BookOpen, label: 'Reference' } + { id: 'scenarios', icon: RefreshCw, label: 'Scenarios' } ].map(tab => ( +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/features/AccumulationStrategy.tsx b/src/components/features/AccumulationStrategy.tsx index 4b07b8d..0bda8d3 100644 --- a/src/components/features/AccumulationStrategy.tsx +++ b/src/components/features/AccumulationStrategy.tsx @@ -113,7 +113,7 @@ const AccumulationStrategy: React.FC = ({ profile, se
- +
@@ -149,12 +149,15 @@ const AccumulationStrategy: React.FC = ({ profile, se

Move over to the withdrawal tab to see how should you withdraw your money, and the strategy to pay the least amount of taxes, values are in Nominal Dollars

- +
+ + You can return to accumulation view anytime. +
{/* Portfolio Path Chart (Full Width) */} @@ -192,6 +195,7 @@ const AccumulationStrategy: React.FC = ({ profile, se labelFormatter={(label) => `Age ${label}`} labelStyle={{ fontWeight: 'bold', color: tooltipText }} /> + diff --git a/src/components/features/FireAnalysis.tsx b/src/components/features/FireAnalysis.tsx index 9445811..bba1910 100644 --- a/src/components/features/FireAnalysis.tsx +++ b/src/components/features/FireAnalysis.tsx @@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react'; import { UserProfile } from '../../types'; import { calculateFireMilestones } from '../../services/fireCalculations'; import { FireInputs } from '../../types/fire'; -import { Flame, TrendingUp, DollarSign, Calendar, RefreshCw } from 'lucide-react'; +import { Flame, TrendingUp, DollarSign, Calendar, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts'; import Tooltip from '../common/Tooltip'; @@ -82,9 +82,9 @@ const FireAnalysis: React.FC = ({ profile, isDarkMode }) => {

How is this calculated?

{isOpen ? ( - + ) : ( - + )} {isOpen && ( @@ -163,15 +163,19 @@ const FireAnalysis: React.FC = ({ profile, isDarkMode }) => { ${spendingNeed.toLocaleString()} - setSpendingNeed(Number(e.target.value))} - className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-600" - /> +
+ $20k + setSpendingNeed(Number(e.target.value))} + className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-600" + /> + $200k +

Impacts FIRE Number directly.

@@ -183,15 +187,19 @@ const FireAnalysis: React.FC = ({ profile, isDarkMode }) => { {(rateOfReturn * 100).toFixed(1)}% - setRateOfReturn(Number(e.target.value))} - className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-600" - /> +
+ 1% + setRateOfReturn(Number(e.target.value))} + className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-600" + /> + 12% +

Higher returns accelerate timeline.

@@ -203,15 +211,19 @@ const FireAnalysis: React.FC = ({ profile, isDarkMode }) => { {targetRetirementAge} - setTargetRetirementAge(Number(e.target.value))} - className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-600" - /> +
+ {profile.baseAge + 1} + setTargetRetirementAge(Number(e.target.value))} + className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-600" + /> + 80 +

Affects Coast FIRE target.

@@ -223,15 +235,19 @@ const FireAnalysis: React.FC = ({ profile, isDarkMode }) => { {formatCurrency(consultingIncome)}/yr - setConsultingIncome(Number(e.target.value))} - className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-purple-600" - /> +
+ $0 + setConsultingIncome(Number(e.target.value))} + className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-purple-600" + /> + $100k +

Side income for Barista FIRE.

diff --git a/src/components/features/InputSection.tsx b/src/components/features/InputSection.tsx index c793174..778dac5 100644 --- a/src/components/features/InputSection.tsx +++ b/src/components/features/InputSection.tsx @@ -8,9 +8,10 @@ interface InputSectionProps { profile: UserProfile; setProfile: (profile: UserProfile) => void; onRestartWizard: () => void; + savedAt?: Date | null; } -const FormattedNumberInput = ({ value, onChange, className, id }: { value: number; onChange: (val: number) => void; className?: string; id?: string }) => { +const FormattedNumberInput = ({ value, onChange, className, id, ...rest }: { value: number; onChange: (val: number) => void; className?: string; id?: string } & React.InputHTMLAttributes) => { const [displayValue, setDisplayValue] = useState(value.toLocaleString()); const lastExternalValue = React.useRef(value); @@ -32,11 +33,11 @@ const FormattedNumberInput = ({ value, onChange, className, id }: { value: numbe onChange(newVal); }; - return ; + return ; }; // Handles percentage inputs (stored as decimal, displayed as percentage) -const PercentageInput = ({ value, onChange, className, step = 0.1, id }: { value: number; onChange: (val: number) => void; className?: string; step?: number; id?: string }) => { +const PercentageInput = ({ value, onChange, className, step = 0.1, id, ...rest }: { value: number; onChange: (val: number) => void; className?: string; step?: number; id?: string } & React.InputHTMLAttributes) => { const [displayValue, setDisplayValue] = useState((value * 100).toFixed(1)); useEffect(() => { @@ -70,10 +71,30 @@ const PercentageInput = ({ value, onChange, className, step = 0.1, id }: { value } }; - return ; + return ; }; -const InputSection: React.FC = ({ profile, setProfile, onRestartWizard }) => { +const InputSection: React.FC = ({ profile, setProfile, onRestartWizard, savedAt }) => { + const [showSaved, setShowSaved] = useState(false); + const [showInflationCallout, setShowInflationCallout] = useState(false); + const prevIsSpendingReal = React.useRef(profile.isSpendingReal); + + useEffect(() => { + if (!savedAt) return; + setShowSaved(true); + const timer = setTimeout(() => setShowSaved(false), 3000); + return () => clearTimeout(timer); + }, [savedAt]); + + useEffect(() => { + if (prevIsSpendingReal.current !== profile.isSpendingReal) { + setShowInflationCallout(true); + const timer = setTimeout(() => setShowInflationCallout(false), 3000); + prevIsSpendingReal.current = profile.isSpendingReal; + return () => clearTimeout(timer); + } + }, [profile.isSpendingReal]); + const handleChange = (field: keyof UserProfile, value: any) => setProfile({ ...profile, [field]: value }); const handleAssetChange = (field: keyof UserProfile['assets'], value: number) => setProfile({ ...profile, assets: { ...profile.assets, [field]: value } }); const handleContributionChange = (field: keyof UserProfile['contributions'], value: number) => setProfile({ ...profile, contributions: { ...profile.contributions, [field]: value } }); @@ -86,8 +107,9 @@ const InputSection: React.FC = ({ profile, setProfile, onRest const inputClass = "w-full rounded-md border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500 border p-2 transition-colors"; const labelClass = "flex items-center gap-1 text-sm font-medium text-slate-600 dark:text-slate-300 mb-1"; const iconClass = "absolute left-3 top-2 text-slate-400 dark:text-slate-500"; - const containerClass = "bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 p-6 space-y-8 transition-colors"; - const headerClass = "text-xl font-bold text-slate-800 dark:text-white flex items-center gap-2 mb-4"; + const containerClass = "bg-white dark:bg-slate-900 rounded-xl shadow-sm border border-slate-200 dark:border-slate-800 p-3 space-y-3 transition-colors"; + const sectionClass = "rounded-xl p-5 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700"; + const headerClass = "text-xl font-bold text-slate-800 dark:text-white flex items-center gap-2 mb-5"; // const isFutureScenario = (Number(profile.age) || 0) !== profile.baseAge; const [activeModal, setActiveModal] = useState<'accumulation' | 'retirement' | null>(null); @@ -98,9 +120,14 @@ const InputSection: React.FC = ({ profile, setProfile, onRest return (
+
+ + ✓ Saved + +
{/* Personal Details */} -
-
+
+

@@ -191,31 +218,38 @@ const InputSection: React.FC = ({ profile, setProfile, onRest className={`px-3 py-1.5 rounded-md min-h-[32px] transition-colors ${!profile.isSpendingReal ? 'bg-white dark:bg-slate-600 text-blue-600 dark:text-blue-300 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`} >Future $
- +

$ handleChange('spendingNeed', val)} className={`${inputClass} pl-8 font-semibold text-lg`} + aria-label="Annual Spending Need in Retirement" />
+ {profile.age > profile.baseAge && ( +

+ {profile.isSpendingReal + ? `→ ~$${Math.round(profile.spendingNeed * Math.pow(1 + profile.assumptions.inflationRate, profile.age - profile.baseAge)).toLocaleString()}/yr at retirement (${profile.assumptions.inflationRate * 100}% inflation over ${profile.age - profile.baseAge} yrs)` + : 'Using nominal (future) dollar amount as entered'} +

+ )}
{/* Assets */} -
+

Assets (Portfolio)

-
+
{[ { label: 'Traditional IRA / 401k', key: 'traditionalIRA' as const }, { label: 'Roth IRA / 401k', key: 'rothIRA' as const }, @@ -235,6 +269,7 @@ const InputSection: React.FC = ({ profile, setProfile, onRest value={profile.assets[item.key] || 0} onChange={(val) => handleAssetChange(item.key, val)} className={`${inputClass} pl-8`} + aria-label={item.tooltip ? `${item.label}: ${item.tooltip}` : item.label} />
@@ -243,13 +278,15 @@ const InputSection: React.FC = ({ profile, setProfile, onRest
{/* Annual Contributions */} -
-

- - Annual Contributions -

-

For accumulation phase

-
+
+
+

+ + Annual Contributions +

+

For accumulation phase

+
+
{[ { label: 'Traditional IRA / 401k', key: 'traditionalIRA' as const }, { label: 'Roth IRA / Roth 401k', key: 'rothIRA' as const }, @@ -273,7 +310,7 @@ const InputSection: React.FC = ({ profile, setProfile, onRest
{/* Income Sources */} -
+

Income (Annual) While in Retirement @@ -303,7 +340,7 @@ const InputSection: React.FC = ({ profile, setProfile, onRest />

-
+
$ @@ -337,7 +374,7 @@ const InputSection: React.FC = ({ profile, setProfile, onRest
{/* Market Assumptions (Accumulation) */} -
+

Market Assumptions (Accumulation) @@ -372,7 +409,7 @@ const InputSection: React.FC = ({ profile, setProfile, onRest

{/* Market Assumptions (Retirement) */} -
+

Market Assumptions (Retirement) diff --git a/src/components/features/LongevityAnalysis.tsx b/src/components/features/LongevityAnalysis.tsx index aef1629..0971d07 100644 --- a/src/components/features/LongevityAnalysis.tsx +++ b/src/components/features/LongevityAnalysis.tsx @@ -14,6 +14,15 @@ interface LongevityAnalysisProps { const LongevityAnalysis: React.FC = ({ longevity, profile, isDarkMode }) => { const { projection, depletionAge, initialWithdrawalRate, sustainable } = longevity; + // Determine outcome tier for depletion age styling + const GOOD_AGE_THRESHOLD = 90; + const WARNING_AGE_THRESHOLD = 85; + const outcomeLevel: 'good' | 'caution' | 'danger' = !depletionAge || depletionAge >= GOOD_AGE_THRESHOLD + ? 'good' + : depletionAge >= WARNING_AGE_THRESHOLD + ? 'caution' + : 'danger'; + // Chart styling colors const axisColor = isDarkMode ? '#94a3b8' : '#64748b'; const gridColor = isDarkMode ? '#334155' : '#e2e8f0'; @@ -42,28 +51,43 @@ const LongevityAnalysis: React.FC = ({ longevity, profil

-

Projected Outcome

- {depletionAge ? ( + {outcomeLevel === 'good' ? ( <> - +
- Depleted at Age {depletionAge} -

Money runs out in {depletionAge - profile.age} years.

+ + {depletionAge ? `Lasts to Age ${depletionAge}` : 'Sustainable'} + +

+ {depletionAge ? `Portfolio supports ${depletionAge - profile.age} years of retirement.` : 'Portfolio lasts to age 100+.'} +

+
+ + ) : outcomeLevel === 'caution' ? ( + <> + +
+ Depleted at Age {depletionAge} +

Money runs out in {depletionAge! - profile.age} years. Consider adjustments.

) : ( <> - +
- Sustainable -

Portfolio lasts to age 100+.

+ Depleted at Age {depletionAge} +

Money runs out in {depletionAge! - profile.age} years.

)} diff --git a/src/components/features/SettingsModal.tsx b/src/components/features/SettingsModal.tsx index 521ea6e..953650d 100644 --- a/src/components/features/SettingsModal.tsx +++ b/src/components/features/SettingsModal.tsx @@ -36,8 +36,8 @@ const SettingsModal: React.FC = ({ isOpen, onClose, apiKey, }; return ( -
-
+
+
e.stopPropagation()}> {/* Header */}
diff --git a/src/components/features/StrategyResults.tsx b/src/components/features/StrategyResults.tsx index ed24cf2..de7dc50 100644 --- a/src/components/features/StrategyResults.tsx +++ b/src/components/features/StrategyResults.tsx @@ -92,6 +92,20 @@ const StrategyResults: React.FC = ({ result, profile, isDa if (!result.gapFilled) feasibility = 'Shortfall'; else if (withdrawalRate > 0.05) feasibility = 'Risk'; + // Guidance calculations + const shortfallAmount = feasibility === 'Shortfall' + ? Math.max(0, (result.nominalSpendingNeeded + result.estimatedFederalTax) - result.totalWithdrawal) + : 0; + const annualContributions = profile.contributions.traditionalIRA + profile.contributions.rothIRA + profile.contributions.brokerage + profile.contributions.hsa; + const delayYears = annualContributions > 0 && shortfallAmount > 0 + ? Math.ceil(shortfallAmount / (annualContributions * (1 + profile.assumptions.rateOfReturn))) + : null; + + // Risk guidance: how much to reduce spending to reach a safe 4% withdrawal rate + const safeWithdrawalTarget = totalPortfolio * 0.04; + const excessDraw = portfolioDraw - safeWithdrawalTarget; + const spendingReduction = feasibility === 'Risk' ? Math.max(0, Math.round(excessDraw)) : 0; + const feasibilityStyles = { Safe: 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900 text-green-700 dark:text-green-400', Risk: 'bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-900 text-amber-700 dark:text-amber-400', @@ -199,6 +213,44 @@ const StrategyResults: React.FC = ({ result, profile, isDa
+ {/* Shortfall Guidance */} + {feasibility === 'Shortfall' && shortfallAmount > 0 && ( +
+ +
+

Your plan has an annual shortfall of {formatCurrency(shortfallAmount)}

+

Consider one or more of these adjustments:

+
    +
  • Reduce spending by at least {formatCurrency(shortfallAmount)}/yr to close the gap.
  • + {delayYears !== null && delayYears > 0 && ( +
  • Delay retirement by ~{delayYears} year{delayYears > 1 ? 's' : ''} to build a larger portfolio with continued contributions.
  • + )} +
  • Increase contributions now — even small additional savings compound significantly over time.
  • +
+
+
+ )} + + {/* Risk Guidance */} + {feasibility === 'Risk' && ( +
+ +
+

+ Withdrawal rate of {(withdrawalRate * 100).toFixed(1)}% exceeds the safe 4% guideline +

+

Consider one or more of these adjustments:

+
    + {spendingReduction > 0 && ( +
  • Reduce spending by ~{formatCurrency(spendingReduction)}/yr to bring your withdrawal rate closer to 4%.
  • + )} +
  • Delay retirement to allow your portfolio more time to grow and reduce the draw-down percentage.
  • +
  • Increase contributions now to build a larger portfolio base before retirement.
  • +
+
+
+ )} + {/* Tax Engine Explanation */}
diff --git a/src/components/features/wizard/WizardModal.tsx b/src/components/features/wizard/WizardModal.tsx index 11ada36..bb19d56 100644 --- a/src/components/features/wizard/WizardModal.tsx +++ b/src/components/features/wizard/WizardModal.tsx @@ -93,8 +93,8 @@ const WizardModal: React.FC = ({ isOpen, onClose, onComplete, const progress = (effectiveCurrentStep / effectiveTotalSteps) * 100; return ( -
-
+
+
e.stopPropagation()}> {/* Header */}
diff --git a/src/components/modals/PortfolioSelectorModal.tsx b/src/components/modals/PortfolioSelectorModal.tsx index 1078793..ad63e9c 100644 --- a/src/components/modals/PortfolioSelectorModal.tsx +++ b/src/components/modals/PortfolioSelectorModal.tsx @@ -74,8 +74,8 @@ const PortfolioSelectorModal: React.FC = ({ if (!isOpen) return null; return ( -
-
+
+
e.stopPropagation()}> {/* Header */}