Skip to content

Commit a28b6dc

Browse files
kitfunsoclaude
andcommitted
feat: complete Phase 5 career calculators
Add three new calculators to complete Phase 5 of the roadmap: - Job Offer Comparison Calculator: Compare total compensation including salary, bonus, equity, 401k, health benefits, PTO, and commute costs with visual breakdown and recommendations - Overtime Calculator: Calculate effective hourly rate after taxes with bracket impact warnings and break-even analysis - Salary to Hourly Calculator: Convert annual salary to true hourly rate based on actual hours worked, showing the cost of unpaid overtime All calculators support USD/GBP/EUR, mobile-responsive design, and include SEO-optimized pages with FAQs. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent efc11fb commit a28b6dc

15 files changed

Lines changed: 2969 additions & 0 deletions

File tree

src/components/calculators/JobOfferComparison/JobOfferComparison.tsx

Lines changed: 586 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* Job Offer Comparison Calculator - Calculation Logic
3+
*
4+
* Pure functions for comparing two job offers by total compensation.
5+
*/
6+
7+
import type {
8+
JobOfferComparisonInputs,
9+
JobOfferComparisonResult,
10+
JobOffer,
11+
OfferCalculation,
12+
} from './types';
13+
import type { Currency } from '../../../lib/regions';
14+
import { formatCurrency as formatCurrencyByRegion } from '../../../lib/regions';
15+
16+
/** Working days per year (52 weeks * 5 days) */
17+
const WORKING_DAYS_PER_YEAR = 260;
18+
19+
/** Average commute speed in mph for time calculations */
20+
const AVERAGE_COMMUTE_SPEED = 30;
21+
22+
/** Standard work hours per day */
23+
const WORK_HOURS_PER_DAY = 8;
24+
25+
/**
26+
* Calculate values for a single job offer
27+
*/
28+
function calculateOffer(offer: JobOffer, inputs: JobOfferComparisonInputs): OfferCalculation {
29+
const { hourlyTimeValue, costPerMile, includeCommuteTime, contribution401k } = inputs;
30+
31+
// Base and bonus
32+
const baseSalary = offer.baseSalary;
33+
const bonusAmount = baseSalary * offer.bonusPercentage;
34+
35+
// Equity
36+
const equityValue = offer.annualEquity;
37+
38+
// 401k match calculation
39+
// Match is the lesser of: (contribution * match %) or (salary * match limit)
40+
const contributionAmount = baseSalary * contribution401k;
41+
const maxMatchableAmount = baseSalary * offer.match401kLimit;
42+
const actualContribution = Math.min(contributionAmount, maxMatchableAmount);
43+
const match401kValue = actualContribution * (offer.match401kPercentage / offer.match401kLimit);
44+
45+
// Health benefits (employer value - employee cost)
46+
const annualHealthCost = offer.healthInsuranceCost * 12;
47+
const healthBenefitNet = offer.healthBenefitValue - annualHealthCost;
48+
49+
// PTO value (daily rate * days)
50+
const dailyRate = baseSalary / WORKING_DAYS_PER_YEAR;
51+
const ptoValue = dailyRate * offer.ptoDays;
52+
53+
// Commute calculations
54+
const commuteDaysPerYear = WORKING_DAYS_PER_YEAR * (offer.officeDaysPerWeek / 5);
55+
const annualCommuteMiles = offer.commuteDistance * 2 * commuteDaysPerYear; // round trip
56+
const commuteCost = annualCommuteMiles * costPerMile;
57+
58+
// Commute time value (opportunity cost)
59+
const dailyCommuteHours = (offer.commuteDistance * 2) / AVERAGE_COMMUTE_SPEED;
60+
const annualCommuteHours = dailyCommuteHours * commuteDaysPerYear;
61+
const commuteTimeValue = includeCommuteTime ? annualCommuteHours * hourlyTimeValue : 0;
62+
63+
// Other benefits
64+
const otherBenefitsValue = offer.otherBenefits;
65+
66+
// Signing bonus (counted at full value for first year comparison)
67+
const signingBonusValue = offer.signingBonus;
68+
69+
// Totals
70+
const totalCashComp = baseSalary + bonusAmount + signingBonusValue;
71+
const totalComp =
72+
totalCashComp + equityValue + match401kValue + healthBenefitNet + otherBenefitsValue;
73+
74+
// Net after commute costs and time
75+
const netComp = totalComp - commuteCost - commuteTimeValue;
76+
77+
// Effective hourly rate
78+
const totalWorkHours = WORKING_DAYS_PER_YEAR * WORK_HOURS_PER_DAY;
79+
const totalHoursIncludingCommute = totalWorkHours + annualCommuteHours;
80+
const effectiveHourlyRate = netComp / totalHoursIncludingCommute;
81+
82+
return {
83+
baseSalary,
84+
bonusAmount,
85+
equityValue,
86+
match401kValue,
87+
healthBenefitNet,
88+
ptoValue,
89+
commuteCost,
90+
commuteTimeValue,
91+
otherBenefitsValue,
92+
signingBonusValue,
93+
totalCashComp,
94+
totalComp,
95+
netComp,
96+
effectiveHourlyRate,
97+
};
98+
}
99+
100+
/**
101+
* Generate recommendation based on comparison
102+
*/
103+
function generateRecommendation(
104+
offer1: OfferCalculation,
105+
offer2: OfferCalculation,
106+
inputs: JobOfferComparisonInputs
107+
): JobOfferComparisonResult['recommendation'] {
108+
const considerations: string[] = [];
109+
110+
// Compare net compensation
111+
const netDiff = offer2.netComp - offer1.netComp;
112+
const netDiffPercent = (netDiff / offer1.netComp) * 100;
113+
114+
// Determine winner
115+
let winner: 1 | 2 | 'tie';
116+
let reason: string;
117+
118+
if (Math.abs(netDiffPercent) < 3) {
119+
winner = 'tie';
120+
reason = 'Both offers have very similar total compensation (within 3%).';
121+
} else if (netDiff > 0) {
122+
winner = 2;
123+
reason = `${inputs.offer2.name} offers ${Math.abs(netDiffPercent).toFixed(1)}% higher net compensation.`;
124+
} else {
125+
winner = 1;
126+
reason = `${inputs.offer1.name} offers ${Math.abs(netDiffPercent).toFixed(1)}% higher net compensation.`;
127+
}
128+
129+
// Add considerations
130+
if (offer2.commuteCost > offer1.commuteCost * 1.5) {
131+
considerations.push(`${inputs.offer2.name} has significantly higher commute costs.`);
132+
} else if (offer1.commuteCost > offer2.commuteCost * 1.5) {
133+
considerations.push(`${inputs.offer1.name} has significantly higher commute costs.`);
134+
}
135+
136+
if (inputs.offer2.ptoDays > inputs.offer1.ptoDays + 5) {
137+
considerations.push(
138+
`${inputs.offer2.name} offers ${inputs.offer2.ptoDays - inputs.offer1.ptoDays} more PTO days.`
139+
);
140+
} else if (inputs.offer1.ptoDays > inputs.offer2.ptoDays + 5) {
141+
considerations.push(
142+
`${inputs.offer1.name} offers ${inputs.offer1.ptoDays - inputs.offer2.ptoDays} more PTO days.`
143+
);
144+
}
145+
146+
if (offer2.equityValue > 0 && offer1.equityValue === 0) {
147+
considerations.push(`${inputs.offer2.name} includes equity which may have significant upside.`);
148+
} else if (offer1.equityValue > 0 && offer2.equityValue === 0) {
149+
considerations.push(`${inputs.offer1.name} includes equity which may have significant upside.`);
150+
}
151+
152+
if (inputs.offer2.officeDaysPerWeek < inputs.offer1.officeDaysPerWeek) {
153+
considerations.push(`${inputs.offer2.name} offers more remote flexibility.`);
154+
} else if (inputs.offer1.officeDaysPerWeek < inputs.offer2.officeDaysPerWeek) {
155+
considerations.push(`${inputs.offer1.name} offers more remote flexibility.`);
156+
}
157+
158+
if (offer2.match401kValue > offer1.match401kValue * 1.5) {
159+
considerations.push(`${inputs.offer2.name} has significantly better 401k matching.`);
160+
} else if (offer1.match401kValue > offer2.match401kValue * 1.5) {
161+
considerations.push(`${inputs.offer1.name} has significantly better 401k matching.`);
162+
}
163+
164+
if (considerations.length === 0) {
165+
considerations.push(
166+
'Consider non-monetary factors like career growth, team culture, and work-life balance.'
167+
);
168+
}
169+
170+
return { winner, reason, considerations };
171+
}
172+
173+
/**
174+
* Calculate job offer comparison
175+
*/
176+
export function calculateComparison(inputs: JobOfferComparisonInputs): JobOfferComparisonResult {
177+
const { currency } = inputs;
178+
179+
const offer1Calc = calculateOffer(inputs.offer1, inputs);
180+
const offer2Calc = calculateOffer(inputs.offer2, inputs);
181+
182+
const totalCompDiff = offer2Calc.totalComp - offer1Calc.totalComp;
183+
const netCompDiff = offer2Calc.netComp - offer1Calc.netComp;
184+
const percentageDiff =
185+
offer1Calc.netComp !== 0
186+
? ((offer2Calc.netComp - offer1Calc.netComp) / offer1Calc.netComp) * 100
187+
: 0;
188+
189+
const recommendation = generateRecommendation(offer1Calc, offer2Calc, inputs);
190+
191+
return {
192+
currency,
193+
offer1: offer1Calc,
194+
offer2: offer2Calc,
195+
difference: {
196+
totalComp: totalCompDiff,
197+
netComp: netCompDiff,
198+
percentageDiff,
199+
},
200+
recommendation,
201+
};
202+
}
203+
204+
/**
205+
* Format a number as currency
206+
*/
207+
export function formatCurrency(
208+
value: number,
209+
currency: Currency = 'USD',
210+
decimals: number = 0
211+
): string {
212+
return formatCurrencyByRegion(value, currency, decimals);
213+
}
214+
215+
/**
216+
* Format a number with sign (+ or -)
217+
*/
218+
export function formatDifference(value: number, currency: Currency = 'USD'): string {
219+
const sign = value >= 0 ? '+' : '';
220+
return `${sign}${formatCurrency(value, currency)}`;
221+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default } from './JobOfferComparison';
2+
export * from './types';
3+
export * from './calculations';

0 commit comments

Comments
 (0)