|
| 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 | +} |
0 commit comments