Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f15a8af
feat: Update button label for inflation adjustment in AccumulationStr…
meva Feb 24, 2026
3b95044
feat: Refactor InputSection styles for improved layout and spacing
meva Feb 24, 2026
39ab0e7
feat: Adjust container padding and spacing in InputSection for improv…
meva Feb 24, 2026
5572d3d
feat: Enhance layout of Annual Contributions section with improved st…
meva Feb 24, 2026
298cb08
feat: Update tooltip content in InputSection for clarity on spending …
meva Feb 24, 2026
fb9f835
feat: Adjust grid layout in InputSection for improved spacing and res…
meva Feb 24, 2026
54030f8
feat: Add saved timestamp functionality to InputSection and display c…
meva Feb 24, 2026
4b9cfac
feat: Add inflation callout display in InputSection based on spending…
meva Feb 24, 2026
113d57d
feat: Add Tax Reference modal and update active tab management in App…
meva Feb 24, 2026
c9e8244
feat: Enhance LongevityAnalysis component with outcome tier styling a…
meva Feb 24, 2026
45b9811
feat: Add shortfall and risk guidance calculations in StrategyResults…
meva Feb 24, 2026
27d1737
feat: Remove unnecessary flex styling from header in App component
meva Feb 24, 2026
73cefca
feat: Implement ErrorBoundary component for handling calculation erro…
meva Feb 24, 2026
72cb266
feat: Update button text and layout for transitioning to withdrawal p…
meva Feb 24, 2026
f39c1e8
feat: Add profile validation and error handling in App component
meva Feb 24, 2026
dd63ca1
feat: Enhance range inputs with labels for spending need, rate of ret…
meva Feb 24, 2026
81cda84
feat: Enhance modal components with click-to-close functionality and …
meva Feb 24, 2026
8516080
feat: Add click-to-close functionality to WizardModal for improved us…
meva Feb 24, 2026
7754d18
feat: Enhance input components with additional props for accessibilit…
meva Feb 24, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ deploy.sh
todo.md
.agent/*
.github/copilot-instructions.md
improvements.md

.agent/*

Expand Down
152 changes: 102 additions & 50 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -36,12 +37,14 @@ const App: React.FC = () => {
const [profile, setProfile] = useState<UserProfile>(INITIAL_PROFILE);
const [strategyResult, setStrategyResult] = useState<StrategyResult | null>(null);
const [longevityResult, setLongevityResult] = useState<LongevityResult | null>(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<Date | null>(null);

// Computed Retirement Profile
// If the user is currently 55 but retiring at 65, we must project their assets
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -211,7 +231,25 @@ const App: React.FC = () => {
setApiKey={setApiKey}
onReset={handleReset}
/>
<div className="bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-900/50 px-4 py-2 transition-colors">
{isReferenceOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200" onClick={() => setIsReferenceOpen(false)}>
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] border border-slate-200 dark:border-slate-800 overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 shrink-0">
<h2 className="text-lg font-bold text-slate-800 dark:text-white flex items-center gap-2">
<HelpCircle className="w-5 h-5 text-blue-600" />
Tax Reference
</h2>
<button onClick={() => setIsReferenceOpen(false)} className="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="overflow-y-auto p-4">
<TaxReference />
</div>
</div>
</div>
)}
<div className="bg-amber-100 dark:bg-amber-950/50 border-b border-amber-300 dark:border-amber-900/50 border-l-4 border-l-amber-500 px-4 py-2 transition-colors">
<div className="max-w-7xl mx-auto flex items-center justify-center gap-2 text-[10px] md:text-xs font-medium text-amber-900 dark:text-amber-200 text-center uppercase tracking-wider">
<AlertTriangle className="w-3 h-3 text-amber-600" />
Educational purposes only. No professional financial or tax advice intended.
Expand All @@ -223,16 +261,21 @@ const App: React.FC = () => {
<div className="flex items-center gap-3">
<img src="/Images/logo.png" alt="FiscalSunset Logo" className="w-10 h-10 object-contain" />
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-1">FiscalSunset<span className="text-blue-600">.</span></h1>
<h1 className="text-xl font-bold text-slate-900 dark:text-white">FiscalSunset<span className="text-blue-600">.</span></h1>
<p className="text-[10px] text-slate-500 font-medium uppercase tracking-tighter">Tax-Efficient Planner</p>
</div>
</div>
<button onClick={toggleTheme} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors">
{isDarkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
<button onClick={() => setIsSettingsOpen(true)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors ml-1">
<Settings className="w-5 h-5" />
</button>
<div className="flex items-center gap-1">
<button onClick={() => setIsReferenceOpen(true)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors" title="Tax Reference">
<HelpCircle className="w-5 h-5" />
</button>
<button onClick={toggleTheme} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors">
{isDarkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
<button onClick={() => setIsSettingsOpen(true)} className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors">
<Settings className="w-5 h-5" />
</button>
</div>
</div>
</header>

Expand All @@ -243,6 +286,7 @@ const App: React.FC = () => {
profile={profile}
setProfile={setProfile}
onRestartWizard={() => setIsWizardOpen(true)}
savedAt={savedAt}
/>
</div>

Expand All @@ -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 => (
<button
key={tab.id}
Expand All @@ -274,39 +317,48 @@ const App: React.FC = () => {
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-slate-200 dark:from-slate-800 to-transparent pointer-events-none rounded-r-xl md:hidden" />
</div>

{strategyResult && longevityResult ? (
<div className="animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className={activeTab === 'withdrawal' ? 'block' : 'hidden'}>
<StrategyResults
result={strategyResult}
profile={retirementProfile}
isDarkMode={isDarkMode}
apiKey={apiKey}
onOpenSettings={() => setIsSettingsOpen(true)}
/>
</div>
<div className={activeTab === 'accumulation' ? 'block' : 'hidden'}>
<AccumulationStrategy
profile={profile}
setProfile={setProfile}
isDarkMode={isDarkMode}
onRetire={() => setActiveTab('withdrawal')}
/>
</div>
<div className={activeTab === 'longevity' ? 'block' : 'hidden'}>
<LongevityAnalysis longevity={longevityResult} profile={retirementProfile} isDarkMode={isDarkMode} />
<ErrorBoundary onReset={() => setProfile(p => ({ ...p }))}>
{strategyResult && longevityResult ? (
<div className="animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className={activeTab === 'withdrawal' ? 'block' : 'hidden'}>
<StrategyResults
result={strategyResult}
profile={retirementProfile}
isDarkMode={isDarkMode}
apiKey={apiKey}
onOpenSettings={() => setIsSettingsOpen(true)}
/>
</div>
<div className={activeTab === 'accumulation' ? 'block' : 'hidden'}>
<AccumulationStrategy
profile={profile}
setProfile={setProfile}
isDarkMode={isDarkMode}
onRetire={() => setActiveTab('withdrawal')}
/>
</div>
<div className={activeTab === 'longevity' ? 'block' : 'hidden'}>
<LongevityAnalysis longevity={longevityResult} profile={retirementProfile} isDarkMode={isDarkMode} />
</div>
<div className={activeTab === 'fire' ? 'block' : 'hidden'}>
<FireAnalysis profile={profile} isDarkMode={isDarkMode} />
</div>
<div className={activeTab === 'scenarios' ? 'block' : 'hidden'}>
<WhatIfAnalysis profile={profile} isDarkMode={isDarkMode} />
</div>
</div>
<div className={activeTab === 'fire' ? 'block' : 'hidden'}>
<FireAnalysis profile={profile} isDarkMode={isDarkMode} />
) : !isProfileValid ? (
<div className="h-96 flex items-center justify-center">
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl p-6 max-w-md text-center space-y-3">
<AlertTriangle className="w-8 h-8 text-red-500 mx-auto" />
<h3 className="text-sm font-bold text-red-800 dark:text-red-300">Fix input errors to see results</h3>
<ul className="text-xs text-red-700 dark:text-red-400 space-y-1">
{profileErrors.map((err, i) => <li key={i}>{err}</li>)}
</ul>
</div>
</div>
<div className={activeTab === 'scenarios' ? 'block' : 'hidden'}>
<WhatIfAnalysis profile={profile} isDarkMode={isDarkMode} />
</div>
<div className={activeTab === 'reference' ? 'block' : 'hidden'}>
<TaxReference />
</div>
</div>
) : <div className="h-96 flex items-center justify-center text-slate-400">Loading strategy...</div>}
) : <div className="h-96 flex items-center justify-center text-slate-400">Loading strategy...</div>}
</ErrorBoundary>
</div>
</div>
</main>
Expand Down
64 changes: 64 additions & 0 deletions src/components/common/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';

interface Props {
children: React.ReactNode;
onReset?: () => void;
}

interface State {
hasError: boolean;
error: Error | null;
}

class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}

handleReset = () => {
this.setState({ hasError: false, error: null });
this.props.onReset?.();
};

render() {
if (this.state.hasError) {
return (
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl p-6 text-center space-y-4">
<AlertTriangle className="w-10 h-10 text-red-500 mx-auto" />
<div>
<h3 className="text-lg font-bold text-red-800 dark:text-red-300">Something went wrong</h3>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">
A calculation error occurred. This can happen with unusual input combinations.
</p>
{this.state.error && (
<p className="text-xs text-red-500 dark:text-red-500 mt-2 font-mono">
{this.state.error.message}
</p>
)}
</div>
<button
onClick={this.handleReset}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-red-600 text-white hover:bg-red-700 transition-colors"
>
<RefreshCw className="w-4 h-4" />
Recalculate
</button>
</div>
);
}

return this.props.children;
}
}

export default ErrorBoundary;
18 changes: 11 additions & 7 deletions src/components/features/AccumulationStrategy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const AccumulationStrategy: React.FC<AccumulationStrategyProps> = ({ profile, se
</h3>
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-lg border border-slate-200 dark:border-slate-700">
<button onClick={() => setIsInflationAdjusted(false)} className={`px-4 py-2 text-xs font-bold rounded-md min-h-[36px] transition-colors ${!isInflationAdjusted ? 'bg-white dark:bg-slate-700 text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}>Nominal</button>
<button onClick={() => setIsInflationAdjusted(true)} className={`px-4 py-2 text-xs font-bold rounded-md min-h-[36px] transition-colors ${isInflationAdjusted ? 'bg-white dark:bg-slate-700 text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}>Adj Inflation's $</button>
<button onClick={() => setIsInflationAdjusted(true)} className={`px-4 py-2 text-xs font-bold rounded-md min-h-[36px] transition-colors ${isInflationAdjusted ? 'bg-white dark:bg-slate-700 text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}>Inflation-Adjusted $</button>
<Tooltip content="Clarifies the difference between the 'face value' of the portfolio in the future vs. its equivalent value in today's economy." className="mr-1" />
</div>
</div>
Expand Down Expand Up @@ -149,12 +149,15 @@ const AccumulationStrategy: React.FC<AccumulationStrategyProps> = ({ profile, se
<p className="text-blue-100 mt-1 max-w-sm">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</p>
</div>
</div>
<button
onClick={transitionToRetirement}
className="bg-white text-blue-700 px-8 py-4 rounded-2xl font-bold flex items-center gap-2 hover:bg-blue-50 transition-all shadow-2xl shrink-0 group hover:scale-105 active:scale-95"
>
Confirm & Transition to Retirement <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
<div className="flex flex-col items-center gap-1.5 shrink-0">
<button
onClick={transitionToRetirement}
className="bg-white text-blue-700 px-8 py-4 rounded-2xl font-bold flex items-center gap-2 hover:bg-blue-50 transition-all shadow-2xl group hover:scale-105 active:scale-95"
>
Explore Withdrawal Phase <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</button>
<span className="text-xs text-blue-200">You can return to accumulation view anytime.</span>
</div>
</div>

{/* Portfolio Path Chart (Full Width) */}
Expand Down Expand Up @@ -192,6 +195,7 @@ const AccumulationStrategy: React.FC<AccumulationStrategyProps> = ({ profile, se
labelFormatter={(label) => `Age ${label}`}
labelStyle={{ fontWeight: 'bold', color: tooltipText }}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: '12px', paddingTop: '8px' }} />
<Area type="monotone" dataKey="trad" stackId="1" stroke={ACCOUNT_COLORS.trad} fill={ACCOUNT_COLORS.trad} fillOpacity={0.6} name="Trad IRA/401k" />
<Area type="monotone" dataKey="roth" stackId="1" stroke={ACCOUNT_COLORS.roth} fill={ACCOUNT_COLORS.roth} fillOpacity={0.6} name="Roth IRA/401k" />
<Area type="monotone" dataKey="brokerage" stackId="1" stroke={ACCOUNT_COLORS.brokerage} fill={ACCOUNT_COLORS.brokerage} fillOpacity={0.6} name="Brokerage" />
Expand Down
Loading