DEV Community

Joe Lin for BeGoodTool.com

Posted on

The higher-paying offer can still lose — the exact assumptions in my job-offer comparator

A while back I noticed that most "which offer is better?" spreadsheets all make the same quiet mistake: they compare compensation like money is the only thing moving. But in real life, a job also eats commute time, unpaid overtime, and part of your week that never shows up in the salary number.

So when I built a side-by-side offer comparator, the interesting part wasn't the UI. It was deciding what counts as part of the job, what counts as a benefit, and what should not be smuggled into the hourly rate just because it's vaguely valuable.

The resulting Vue component is pretty straightforward, but I like that the assumptions are visible in code instead of buried in some magical "score." And a couple of those assumptions are more opinionated than they first look.

It localizes money input in a surprisingly practical way

One detail I didn't expect to care about this much: the tool doesn't just translate labels. It changes how large salary fields are entered depending on locale.

In the component, tw, cn, jp, and kr are treated as "myriad" locales, meaning the big compensation fields are displayed in units of ten thousand rather than plain thousands:

const myriadLocales = ["tw", "cn", "jp", "kr"];
const isMyriad = computed(() => myriadLocales.includes(locale.value));

const displayScaled = (offer, field) =>
  isMyriad.value
    ? Math.round((offer[field] / 10000) * 100) / 100
    : offer[field];

const setScaled = (offer, field, val) => {
  const num = Number(val) || 0;
  offer[field] = isMyriad.value ? Math.round(num * 10000) : Math.round(num);
};
Enter fullscreen mode Exit fullscreen mode

That means the underlying state always stores raw annual amounts, but the form can show 95 instead of 950000 when the audience naturally thinks in 萬/万 units. It's a small thing, but it makes the tool feel less like a translated Western salary calculator and more like something built for how people in those locales already talk about pay.

The defaults are localized too. The component seeds two example offers from presetsMyriad or presetsThousands, so the starting numbers look plausible for the current audience instead of dropping US-style sample salaries into every locale.

I like this approach because it keeps the calculation model simple. There isn't a separate Taiwanese math path and an English math path. It's the same numbers underneath; only the representation changes.

The "true hourly wage" formula is intentionally narrower than the "overall value" formula

The core calculation lives in one function, and it makes the model very explicit:

const calcMetrics = (offer) => {
  const ptoDays = Number(offer.ptoDays) || 0;
  const weeksWorked = Math.max(1, 52 - ptoDays / 5);
  const weeklyCommuteHours = ((Number(offer.commuteMinutes) || 0) * 2 * 5) / 60;
  const annualCommuteHours = weeklyCommuteHours * weeksWorked;
  const weeklyHours = Number(offer.weeklyHours) || 0;
  const annualWorkHours = weeklyHours * weeksWorked;
  const totalAnnualHours = annualWorkHours + annualCommuteHours;
  const annualCommuteCost = (Number(offer.dailyCommuteCost) || 0) * 5 * weeksWorked;
  const cashComp =
    (Number(offer.annualSalary) || 0) +
    (Number(offer.annualBonus) || 0) +
    (Number(offer.annualStockValue) || 0);
  const retirementValue =
    (Number(offer.annualSalary) || 0) *
    ((Number(offer.retirementMatchPercent) || 0) / 100);
  const healthInsuranceValue = (Number(offer.monthlyHealthInsuranceValue) || 0) * 12;
  const totalOverallValue =
    cashComp + retirementValue + healthInsuranceValue - annualCommuteCost;
  const realHourlyWage =
    totalAnnualHours > 0 ? (cashComp - annualCommuteCost) / totalAnnualHours : 0;
};
Enter fullscreen mode Exit fullscreen mode

A few important choices are hiding in there.

First, commute time is treated as part of the job. The input is one-way minutes, but the code doubles it, multiplies by 5 days, and turns it into weekly and annual hours. That's basically saying: if a job requires your body to be in a place, the travel time belongs in the cost of earning that job's money. I think that's the right call, and a lot of salary comparisons quietly dodge it.

Second, PTO doesn't just affect "quality of life" conceptually. It reduces weeksWorked, which means it changes both the denominator of the hourly calculation and the annual commute burden. So more paid time off doesn't only feel better; it mechanically raises the effective rate because you're giving fewer weeks of your life to the job.

Third, and this is the subtle one, the component keeps two different value models on purpose:

  • totalOverallValue includes retirement match and health benefits
  • realHourlyWage does not include those benefits

That split is easy to miss if you only look at the UI. The tool is basically saying: employer-paid benefits matter to the offer's total package, but they are not liquid compensation you earn per hour in the same way salary, bonus, and stock are. Reasonable people could argue either way here, but I actually like that the code draws a hard line instead of blending everything into one fuzzy "equivalent hourly" number.

The most useful output isn't the winner badge — it's the counterintuitive explanation

Just showing two totals isn't enough. If one offer has the higher salary but loses on real hourly wage, that's the moment people want explained.

The component has a computed insights list for exactly that case:

const higherSalaryIdx =
  Math.abs(a.cashComp - b.cashComp) < 1 ? -1 : a.cashComp > b.cashComp ? 0 : 1;

if (
  higherSalaryIdx !== -1 &&
  hourlyWinnerIndex.value !== -1 &&
  higherSalaryIdx !== hourlyWinnerIndex.value
) {
  const higherSalaryName = higherSalaryIdx === 0 ? nameA : nameB;
  const higherHourlyName = hourlyWinnerIndex.value === 0 ? nameA : nameB;
  const hoursDiff = Math.abs(a.weeklyTotalHours - b.weeklyTotalHours);
  list.push(
    t("jobOfferComparator.insightSalaryVsHourly", {
      higherSalaryName,
      higherHourlyName,
      hoursDiff: formatDecimal(hoursDiff),
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode

That is the heart of the tool for me. It doesn't just calculate; it translates the calculation into the exact kind of sentence a human would say out loud: yes, this offer pays more, but it eats enough extra time that the effective hourly rate is worse.

The rest of the insight logic does similar things for commute and PTO. If weekly commute differs by more than 0.3 hours, it calls that out. If PTO differs at all, it calls that out. If one offer wins overall, it generates a sentence with the approximate dollar gap.

There are also explicit tie thresholds:

if (Math.abs(a.totalOverallValue - b.totalOverallValue) < 1) return -1;
if (Math.abs(a.realHourlyWage - b.realHourlyWage) < 0.01) return -1;
Enter fullscreen mode Exit fullscreen mode

I appreciate this because raw floating-point math loves to produce fake drama. If two offers differ by fractions of a cent or a rounding blip, the UI doesn't pretend one is meaningfully better. That's a tiny implementation detail, but it's the kind that makes a calculator feel calmer and more trustworthy.

The model is honest, but it definitely has edges

This is the part I always want to know when I use someone else's calculator: where does the simplification start to show?

A few places in this one are very real:

  • It assumes a 5-day workweek. PTO is converted with ptoDays / 5, and commute is multiplied by 5 days every time. That's fine for a standard full-time office job, but hybrid schedules, 4-day weeks, and rotating shifts don't fit cleanly.
  • Retirement match is simplified a lot. The code calculates it as a straight percentage of salary. Real plans usually have caps, vesting rules, employee-contribution requirements, and weird edge conditions that this model intentionally ignores.
  • Benefits don't raise the hourly rate. That's not a bug; it's how the function is written. But it does mean an offer with fantastic insurance can win on total value while still looking mediocre on "true hourly wage."
  • Absurd PTO entries are clamped into something finite. Math.max(1, 52 - ptoDays / 5) guarantees at least one week worked. That's a practical guard against divide-by-zero, but it also means nonsense input doesn't fail loudly — it just gets turned into a still-computable scenario.

So I wouldn't use this as a universal labor-economics model. I would use it for the thing it's actually designed for: sanity-checking two normal offers when one recruiter is selling the headline number harder than the lived reality.

I turned this into a small free tool: Job Offer Comparison Calculator. It's the version I use when I want a quick "is the better salary actually better?" answer without rebuilding the spreadsheet again.


Available in other languages

Top comments (0)