From af685f68869ea0bebd42ede7d893e3c26125e88e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 05:55:44 +0000 Subject: [PATCH 1/3] Fix tip rounding at every step and bar staff FOH pool participation - Round ccFees, kitchenPool, and barPool to the nearest dollar when roundToDollar:true so all displayed pool totals are whole dollars (no more $131.63 / $307.12 / $282.12 in the breakdown) - Bar staff now participate in the FOH pool split alongside FOH staff and receive fohShare + barPoolShare (previously received bar pool only) - Update FOH section header divisor to include bar staff count - Update tests to reflect new rounding values and bar behavior https://claude.ai/code/session_01XVxvLCrTE8DWaJHMGEcyrA --- src/lib/calculator.test.ts | 59 ++++++++++++++++++-------- src/lib/calculator.ts | 44 +++++++++++-------- src/routes/calculate/[id]/+page.svelte | 2 +- 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/lib/calculator.test.ts b/src/lib/calculator.test.ts index cc807f2..4bbbad2 100644 --- a/src/lib/calculator.test.ts +++ b/src/lib/calculator.test.ts @@ -55,8 +55,8 @@ describe('calculate — intermediate amounts', () => { expect(r.fohPoolCents).toBe(82025); }); - it('bartender is NOT counted in FOH staff', () => { - expect(r.fohStaffCount).toBe(5); + it('bartender IS counted in FOH pool participants', () => { + expect(r.fohStaffCount).toBe(6); // 5 FOH + 1 Bar share the FOH pool expect(r.barStaffCount).toBe(1); }); }); @@ -71,15 +71,21 @@ describe('calculate — roundToDollar: false', () => { const bar = r.distributions.filter(d => d.role === 'Bar'); const kitchen = r.distributions.filter(d => d.role === 'Kitchen'); - it('5 FOH servers split $820.25 → $164.05 each (exact, no remainder)', () => { + it('5 FOH servers split $820.25 among 6 (FOH+Bar) → $136.70 each (floor $136, 5¢ remainder to first 5)', () => { + // fohPool = 82025 cents, 6 participants: floor(82025/6) = 13670, remainder = 82025 - 13670*6 = 5 + // first 5 get 13671, last gets 13670 expect(foh).toHaveLength(5); - foh.forEach(d => expect(d.totalCents).toBe(16405)); // $164.05 + foh.forEach(d => { + expect(d.totalCents === 13671 || d.totalCents === 13670).toBe(true); + }); }); - it('bartender gets bar pool share only: $96.50', () => { + it('bartender gets FOH share + bar pool share: FOH $136.70ish + bar $96.50', () => { expect(bar).toHaveLength(1); - expect(bar[0].totalCents).toBe(9650); // $96.50 - expect(bar[0].fohShareCents).toBe(0); + // bartender gets the 6th FOH share (13670 or 13671) + bar pool 9650 + expect(bar[0].fohShareCents === 13671 || bar[0].fohShareCents === 13670).toBe(true); + expect(bar[0].barPoolShareCents).toBe(9650); + expect(bar[0].totalCents).toBe(bar[0].fohShareCents + 9650); }); it('3 kitchen cooks split $48.25 → $16.09, $16.08, $16.08 (1¢ remainder to first)', () => { @@ -107,15 +113,28 @@ describe('calculate — roundToDollar: true (default)', () => { r.distributions.forEach(d => expect(d.totalCents % 100).toBe(0)); }); - it('FOH pool rounds to $820, split 5 ways → $164 each (exact)', () => { - // Math.round(82025 / 100) = 820, 820 / 5 = 164 exact - foh.forEach(d => expect(d.totalCents).toBe(16400)); + it('FOH pool rounds to $820, split 6 ways (5 FOH + 1 Bar) → base $136, 4 extras of $137', () => { + // kitchenPool: round(4825/100)*100 = 4800; remaining = 96500-4800 = 91700 + // barPool: round(9650/100)*100 = 9700; fohPool = 91700-9700 = 82000 → $820 + // 820 / 6 = 136 base, extras = 820 - 136*6 = 820 - 816 = 4 + expect(foh).toHaveLength(5); + const fohTotals = foh.map(d => d.totalCents); + fohTotals.forEach(t => expect(t === 13600 || t === 13700).toBe(true)); + const fohSum = fohTotals.reduce((s, t) => s + t, 0); + // all 5 FOH + 1 bar share $820 pool; check FOH portion is consistent + expect(fohSum % 100).toBe(0); + }); + + it('bar pool rounds to $97, bartender gets FOH share + $97 bar', () => { + // Math.round(9650 / 100) = 97 + expect(bar[0].barPoolShareCents).toBe(9700); + expect(bar[0].fohShareCents === 13600 || bar[0].fohShareCents === 13700).toBe(true); + expect(bar[0].totalCents).toBe(bar[0].fohShareCents + 9700); }); - it('bar pool rounds to $97, bartender gets $97', () => { - // Math.round(9650 / 100) = 97 (rounds 96.50 → 97 with Math.round half-up) - expect(bar[0].totalCents).toBe(9700); - expect(bar[0].fohShareCents).toBe(0); + it('FOH + Bar pool shares sum to $820', () => { + const allFohShares = [...foh, ...bar].reduce((s, d) => s + d.fohShareCents, 0); + expect(allFohShares).toBe(82000); }); it('kitchen pool rounds to $48, 3 cooks → $16 each (exact)', () => { @@ -141,7 +160,7 @@ describe('calculate — edge cases', () => { r.distributions.forEach(d => expect(d.totalCents).toBe(0)); }); - it('multiple bartenders split bar pool equally (whole dollars)', () => { + it('multiple bartenders split bar pool and FOH pool equally (whole dollars)', () => { const r = calculate({ grossTipsCents: dollarsToCents(500), liquorSalesCents: dollarsToCents(1000), @@ -153,7 +172,13 @@ describe('calculate — edge cases', () => { }, makeRng()); const bars = r.distributions.filter(d => d.role === 'Bar'); expect(bars).toHaveLength(2); - // barPool = $100, 2 bartenders → $50 each - bars.forEach(d => expect(d.totalCents).toBe(5000)); + // fohPool = $500 - $0 kitchen - $100 bar = $400, split 2 ways → $200 each + // barPool = $100, split 2 ways → $50 each + // total per bartender = $200 + $50 = $250 + bars.forEach(d => { + expect(d.fohShareCents).toBe(20000); + expect(d.barPoolShareCents).toBe(5000); + expect(d.totalCents).toBe(25000); + }); }); }); diff --git a/src/lib/calculator.ts b/src/lib/calculator.ts index 88bf8c3..134764f 100644 --- a/src/lib/calculator.ts +++ b/src/lib/calculator.ts @@ -88,10 +88,14 @@ function distributePoolCents(poolCents: number, count: number): number[] { * Pure tip calculation engine — no side effects, no DB access. * * Staff model: - * FOH staff → share FOH pool equally - * Bar staff → share bar pool only (NOT counted in FOH pool) + * FOH staff → share FOH pool equally + * Bar staff → share FOH pool equally AND receive bar pool share * Kitchen staff → share kitchen pool equally * + * When roundToDollar is true, every computed amount (CC fees, each pool) is + * rounded to the nearest whole dollar before use, so all displayed values and + * per-person payouts are whole dollars. + * * @param rng Optional RNG; only used when roundToDollar is true. Pass a * seeded function in tests for deterministic results. */ @@ -99,26 +103,29 @@ export function calculate(input: CalculatorInput, rng: () => number = Math.rando const { grossTipsCents, liquorSalesCents, staff, config } = input; const roundToDollar = config.roundToDollar ?? true; - const ccFeesCents = Math.round(grossTipsCents * config.ccFeeRate); - const tipsAfterFeesCents = grossTipsCents - ccFeesCents; + // Round each computed amount to the nearest dollar when roundToDollar: true + const toDollars = (cents: number) => roundToDollar ? Math.round(cents / 100) * 100 : cents; - const kitchenPoolCents = Math.round(tipsAfterFeesCents * config.kitchenPct); + const ccFeesCents = toDollars(Math.round(grossTipsCents * config.ccFeeRate)); + const tipsAfterFeesCents = grossTipsCents - ccFeesCents; + const kitchenPoolCents = toDollars(Math.round(tipsAfterFeesCents * config.kitchenPct)); const remainingAfterKitchenCents = tipsAfterFeesCents - kitchenPoolCents; + const barPoolCents = toDollars(Math.round(liquorSalesCents * config.barLiquorPct)); + const fohPoolCents = remainingAfterKitchenCents - barPoolCents; - const barPoolCents = Math.round(liquorSalesCents * config.barLiquorPct); - const fohPoolCents = remainingAfterKitchenCents - barPoolCents; - - // Bar staff are NOT counted in the FOH pool — they receive bar pool share only const fohStaff = staff.filter(s => s.role === 'FOH'); const kitchenStaff = staff.filter(s => s.role === 'Kitchen'); const barStaff = staff.filter(s => s.role === 'Bar'); + // Bar staff participate in the FOH pool split alongside FOH staff + const fohPoolParticipants = [...fohStaff, ...barStaff]; + const distributions: Distribution[] = []; if (roundToDollar) { - const fohDollars = distributePoolDollars(fohPoolCents, fohStaff.length, rng); - const kitchenDollars = distributePoolDollars(kitchenPoolCents, kitchenStaff.length, rng); - const barDollars = distributePoolDollars(barPoolCents, barStaff.length, rng); + const fohDollars = distributePoolDollars(fohPoolCents, fohPoolParticipants.length, rng); + const kitchenDollars = distributePoolDollars(kitchenPoolCents, kitchenStaff.length, rng); + const barDollars = distributePoolDollars(barPoolCents, barStaff.length, rng); for (let i = 0; i < fohStaff.length; i++) { const cents = fohDollars[i] * 100; @@ -126,9 +133,10 @@ export function calculate(input: CalculatorInput, rng: () => number = Math.rando fohShareCents: cents, barPoolShareCents: 0, kitchenShareCents: 0, totalCents: cents }); } for (let i = 0; i < barStaff.length; i++) { - const cents = barDollars[i] * 100; + const fohCents = fohDollars[fohStaff.length + i] * 100; + const barCents = barDollars[i] * 100; distributions.push({ staffId: barStaff[i].id, name: barStaff[i].name, role: 'Bar', - fohShareCents: 0, barPoolShareCents: cents, kitchenShareCents: 0, totalCents: cents }); + fohShareCents: fohCents, barPoolShareCents: barCents, kitchenShareCents: 0, totalCents: fohCents + barCents }); } for (let i = 0; i < kitchenStaff.length; i++) { const cents = kitchenDollars[i] * 100; @@ -136,7 +144,7 @@ export function calculate(input: CalculatorInput, rng: () => number = Math.rando fohShareCents: 0, barPoolShareCents: 0, kitchenShareCents: cents, totalCents: cents }); } } else { - const fohCents = distributePoolCents(fohPoolCents, fohStaff.length); + const fohCents = distributePoolCents(fohPoolCents, fohPoolParticipants.length); const kitchenCents = distributePoolCents(kitchenPoolCents, kitchenStaff.length); const barCents = distributePoolCents(barPoolCents, barStaff.length); @@ -145,8 +153,10 @@ export function calculate(input: CalculatorInput, rng: () => number = Math.rando fohShareCents: fohCents[i], barPoolShareCents: 0, kitchenShareCents: 0, totalCents: fohCents[i] }); } for (let i = 0; i < barStaff.length; i++) { + const fohCents_i = fohCents[fohStaff.length + i]; + const barCents_i = barCents[i]; distributions.push({ staffId: barStaff[i].id, name: barStaff[i].name, role: 'Bar', - fohShareCents: 0, barPoolShareCents: barCents[i], kitchenShareCents: 0, totalCents: barCents[i] }); + fohShareCents: fohCents_i, barPoolShareCents: barCents_i, kitchenShareCents: 0, totalCents: fohCents_i + barCents_i }); } for (let i = 0; i < kitchenStaff.length; i++) { distributions.push({ staffId: kitchenStaff[i].id, name: kitchenStaff[i].name, role: 'Kitchen', @@ -157,7 +167,7 @@ export function calculate(input: CalculatorInput, rng: () => number = Math.rando return { grossTipsCents, ccFeesCents, tipsAfterFeesCents, kitchenPoolCents, remainingAfterKitchenCents, liquorSalesCents, barPoolCents, fohPoolCents, - fohStaffCount: fohStaff.length, kitchenStaffCount: kitchenStaff.length, barStaffCount: barStaff.length, + fohStaffCount: fohPoolParticipants.length, kitchenStaffCount: kitchenStaff.length, barStaffCount: barStaff.length, distributions, config, }; } diff --git a/src/routes/calculate/[id]/+page.svelte b/src/routes/calculate/[id]/+page.svelte index ee93019..a8249ec 100644 --- a/src/routes/calculate/[id]/+page.svelte +++ b/src/routes/calculate/[id]/+page.svelte @@ -60,7 +60,7 @@ {#if fohDists.length > 0}
-

FOH — ${formatCents(c.foh_pool_cents)} ÷ {fohDists.length}

+

FOH — ${formatCents(c.foh_pool_cents)} ÷ {fohDists.length + barDists.length}

{#each fohDists as d}
{d.name}${formatCents(d.total_cents)}
{/each} From 23b58c2670bdf718b4056c49e94d9197cb6c38f3 Mon Sep 17 00:00:00 2001 From: Brady Dibble Date: Mon, 6 Apr 2026 23:09:20 -0700 Subject: [PATCH 2/3] Update src/lib/calculator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/lib/calculator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/calculator.ts b/src/lib/calculator.ts index 134764f..b71fac1 100644 --- a/src/lib/calculator.ts +++ b/src/lib/calculator.ts @@ -167,7 +167,8 @@ export function calculate(input: CalculatorInput, rng: () => number = Math.rando return { grossTipsCents, ccFeesCents, tipsAfterFeesCents, kitchenPoolCents, remainingAfterKitchenCents, liquorSalesCents, barPoolCents, fohPoolCents, - fohStaffCount: fohPoolParticipants.length, kitchenStaffCount: kitchenStaff.length, barStaffCount: barStaff.length, + fohStaffCount: fohStaff.length, fohPoolParticipantCount: fohPoolParticipants.length, + kitchenStaffCount: kitchenStaff.length, barStaffCount: barStaff.length, distributions, config, }; } From c354b3d7615c04e05a1da2a51bea49183f852025 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:14:40 +0000 Subject: [PATCH 3/3] Address review feedback: fix interface, docs, and test exactness Agent-Logs-Url: https://github.com/bradydibble/tip-split/sessions/f6c66255-fb26-49cc-a1bd-d356c49fced3 Co-authored-by: bradydibble <12791097+bradydibble@users.noreply.github.com> --- src/lib/calculator.test.ts | 17 +++++++++-------- src/lib/calculator.ts | 3 ++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/lib/calculator.test.ts b/src/lib/calculator.test.ts index 4bbbad2..0edd03e 100644 --- a/src/lib/calculator.test.ts +++ b/src/lib/calculator.test.ts @@ -56,7 +56,8 @@ describe('calculate — intermediate amounts', () => { }); it('bartender IS counted in FOH pool participants', () => { - expect(r.fohStaffCount).toBe(6); // 5 FOH + 1 Bar share the FOH pool + expect(r.fohStaffCount).toBe(5); // only FOH staff + expect(r.fohPoolParticipantCount).toBe(6); // 5 FOH + 1 Bar share the FOH pool expect(r.barStaffCount).toBe(1); }); }); @@ -71,21 +72,21 @@ describe('calculate — roundToDollar: false', () => { const bar = r.distributions.filter(d => d.role === 'Bar'); const kitchen = r.distributions.filter(d => d.role === 'Kitchen'); - it('5 FOH servers split $820.25 among 6 (FOH+Bar) → $136.70 each (floor $136, 5¢ remainder to first 5)', () => { + it('5 FOH servers split $820.25 among 6 (FOH+Bar) → floor $136.70, 5¢ remainder to first 5', () => { // fohPool = 82025 cents, 6 participants: floor(82025/6) = 13670, remainder = 82025 - 13670*6 = 5 - // first 5 get 13671, last gets 13670 + // fohPoolParticipants = [...fohStaff, ...barStaff], so first 5 (all FOH) each get 13671¢ expect(foh).toHaveLength(5); foh.forEach(d => { - expect(d.totalCents === 13671 || d.totalCents === 13670).toBe(true); + expect(d.totalCents).toBe(13671); }); }); - it('bartender gets FOH share + bar pool share: FOH $136.70ish + bar $96.50', () => { + it('bartender gets FOH share + bar pool share: FOH $136.70 + bar $96.50', () => { expect(bar).toHaveLength(1); - // bartender gets the 6th FOH share (13670 or 13671) + bar pool 9650 - expect(bar[0].fohShareCents === 13671 || bar[0].fohShareCents === 13670).toBe(true); + // bartender is the 6th participant (index 5) — gets 13670¢ FOH share + 9650¢ bar pool + expect(bar[0].fohShareCents).toBe(13670); expect(bar[0].barPoolShareCents).toBe(9650); - expect(bar[0].totalCents).toBe(bar[0].fohShareCents + 9650); + expect(bar[0].totalCents).toBe(13670 + 9650); }); it('3 kitchen cooks split $48.25 → $16.09, $16.08, $16.08 (1¢ remainder to first)', () => { diff --git a/src/lib/calculator.ts b/src/lib/calculator.ts index b71fac1..3053956 100644 --- a/src/lib/calculator.ts +++ b/src/lib/calculator.ts @@ -24,7 +24,7 @@ export interface Distribution { staffId: number; name: string; role: StaffRole; - fohShareCents: number; // 0 for Bar and Kitchen + fohShareCents: number; // 0 for Kitchen; Bar staff also receive a FOH share (they participate in the FOH pool) barPoolShareCents: number; // 0 for FOH and Kitchen kitchenShareCents: number; // 0 for FOH and Bar totalCents: number; @@ -40,6 +40,7 @@ export interface CalculationResult { barPoolCents: number; fohPoolCents: number; fohStaffCount: number; + fohPoolParticipantCount: number; kitchenStaffCount: number; barStaffCount: number; distributions: Distribution[];