From b918f5ee47d6709675eb3122a84e7af168119d2d Mon Sep 17 00:00:00 2001 From: Michael Gutensohn <19628643+michaelthatsit@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:54:19 -0700 Subject: [PATCH 1/2] Add round-up per person toggle New toggle rounds each person's share up to the next whole dollar; the tip absorbs the rounding difference so the group always covers the bill. Off by default, keeping the current behavior. --- e2e/tips.spec.ts | 27 +++++++++++++++++++++++++++ src/app-main.ts | 29 +++++++++++++++++++++++++---- src/tips-math.ts | 8 ++++++++ test/tips-math.test.ts | 12 +++++++++++- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/e2e/tips.spec.ts b/e2e/tips.spec.ts index 4a0abdb..6f238c5 100644 --- a/e2e/tips.spec.ts +++ b/e2e/tips.spec.ts @@ -33,6 +33,33 @@ test("per person rounds up so the total is covered", async ({ page }) => { await expect(page.locator("#per-person")).toContainText(/4\.02/); }); +test("round up per person absorbs the difference into the tip", async ({ page }) => { + await page.goto("/"); + await page.locator("#bill").fill("100"); + await page.locator(".seg button", { hasText: "15%" }).click(); + // tip $15, total $115, 3 people -> share $38.33 rounds up to $39.00 each + await page.locator("#round-up").check(); + await page.locator("#people-plus").click(); + await page.locator("#people-plus").click(); + await expect(page.locator("#people")).toHaveText("3"); + await expect(page.locator("#per-person")).toContainText(/39\.00/); + // new total $117, so the tip absorbs the $2.00 rounding -> $17.00 + await expect(page.locator("#total")).toContainText(/117\.00/); + await expect(page.locator("#tip")).toContainText(/17\.00/); +}); + +test("round up toggle off keeps the plain split", async ({ page }) => { + await page.goto("/"); + await page.locator("#bill").fill("100"); + await page.locator(".seg button", { hasText: "15%" }).click(); + await page.locator("#people-plus").click(); + await page.locator("#people-plus").click(); + // no rounding: 11500/3 -> ceil -> $38.34 + await expect(page.locator("#per-person")).toContainText(/38\.34/); + await expect(page.locator("#tip")).toContainText(/15\.00/); + await expect(page.locator("#total")).toContainText(/115\.00/); +}); + test("empty bill shows dashes", async ({ page }) => { await page.goto("/"); await expect(page.locator("#tip")).toHaveText("—"); diff --git a/src/app-main.ts b/src/app-main.ts index f51f483..6c9e42a 100644 --- a/src/app-main.ts +++ b/src/app-main.ts @@ -1,6 +1,6 @@ import { LitElement, html, css, nothing } from "https://kit.thatsit.app/lit.js"; import { sharedStyles } from "https://kit.thatsit.app/kit.js"; -import { PRESET_TIPS, clampPeople, formatMoney, parseAmount, parsePercent, perPersonCents, tipCents } from "./tips-math.ts"; +import { PRESET_TIPS, clampPeople, formatMoney, parseAmount, parsePercent, perPersonCents, perPersonRoundedUpCents, tipCents } from "./tips-math.ts"; export class AppMain extends LitElement { static properties = { @@ -8,6 +8,7 @@ export class AppMain extends LitElement { tipChoice: { state: true }, customTip: { state: true }, people: { state: true }, + roundUp: { state: true }, }; // Layout only here: no colors, fonts or border-radius. static styles = [ @@ -19,6 +20,7 @@ export class AppMain extends LitElement { .result { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; } .result output { font-variant-numeric: tabular-nums; } .people-count { min-width: 2ch; text-align: center; } + .toggle { display: flex; align-items: center; gap: 8px; } `, ]; @@ -26,6 +28,7 @@ export class AppMain extends LitElement { tipChoice = "20"; customTip = ""; people = 1; + roundUp = false; private setPeople(delta: number) { this.people = clampPeople(this.people + delta); @@ -35,9 +38,22 @@ export class AppMain extends LitElement { const billCents = parseAmount(this.bill); const percent = this.tipChoice === "custom" ? parsePercent(this.customTip) : Number(this.tipChoice); const ready = billCents !== null && percent !== null; - const tip = ready ? tipCents(billCents!, percent!) : 0; - const total = ready ? billCents! + tip : 0; - const perPerson = ready ? perPersonCents(total, this.people) : 0; + let tip = 0; + let total = 0; + let perPerson = 0; + if (ready) { + const baseTip = tipCents(billCents!, percent!); + if (this.roundUp) { + // Round the per-person share up to a whole dollar; the tip absorbs the difference. + perPerson = perPersonRoundedUpCents(billCents! + baseTip, this.people); + total = perPerson * this.people; + tip = total - billCents!; + } else { + tip = baseTip; + total = billCents! + tip; + perPerson = perPersonCents(total, this.people); + } + } const show = ready ? formatMoney : () => "—"; return html` @@ -70,6 +86,11 @@ export class AppMain extends LitElement { ${this.people} +

tip ${show(tip)}

total ${show(total)}

diff --git a/src/tips-math.ts b/src/tips-math.ts index 72aea36..880e324 100644 --- a/src/tips-math.ts +++ b/src/tips-math.ts @@ -31,6 +31,14 @@ export function perPersonCents(total: number, people: number): number { return Math.ceil(total / people); } +/** + * One person's share of a total rounded up to the next whole dollar (in cents). + * Whole-dollar shares are kept as-is (no extra cent/dollar added). + */ +export function perPersonRoundedUpCents(total: number, people: number): number { + return Math.ceil(total / (people * 100)) * 100; +} + export function clampPeople(n: number): number { if (!Number.isFinite(n) || n < 1) return 1; return Math.min(MAX_PEOPLE, Math.round(n)); diff --git a/test/tips-math.test.ts b/test/tips-math.test.ts index 252af16..abb9201 100644 --- a/test/tips-math.test.ts +++ b/test/tips-math.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { clampPeople, parseAmount, parsePercent, perPersonCents, tipCents } from "../src/tips-math.ts"; +import { clampPeople, parseAmount, parsePercent, perPersonCents, perPersonRoundedUpCents, tipCents } from "../src/tips-math.ts"; describe("parseAmount", () => { it("parses plain decimals into cents", () => { @@ -49,6 +49,16 @@ describe("perPersonCents", () => { }); }); +describe("perPersonRoundedUpCents", () => { + it("rounds the per-person share up to the next whole dollar", () => { + expect(perPersonRoundedUpCents(11500, 3)).toBe(3900); // 38.33 -> $39.00 + expect(perPersonRoundedUpCents(12000, 3)).toBe(4000); // exact, no extra + expect(perPersonRoundedUpCents(1205, 3)).toBe(500); // $4.02 -> $5.00 + expect(perPersonRoundedUpCents(1000, 1)).toBe(1000); + expect(perPersonRoundedUpCents(99, 3)).toBe(100); // tiny share -> $1.00 + }); +}); + describe("clampPeople", () => { it("keeps the count between 1 and 99", () => { expect(clampPeople(0)).toBe(1); From 1dd5870cfc7dfbb1216105cb047ed5769b92201e Mon Sep 17 00:00:00 2001 From: Michael Gutensohn <19628643+michaelthatsit@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:00:53 -0700 Subject: [PATCH 2/2] Add round-up per person toggle Adds an optional 'Round up per person to a whole dollar' toggle. When on, each person's share is rounded up to the next whole dollar and the tip absorbs the difference (total = per-person x people). Off by default, keeping existing behavior. Adds unit + e2e coverage. --- .vitest/json/output.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vitest/json/output.json b/.vitest/json/output.json index f716e54..641a78e 100644 --- a/.vitest/json/output.json +++ b/.vitest/json/output.json @@ -1 +1 @@ -{"numTotalTestSuites":1,"numPassedTestSuites":0,"numFailedTestSuites":1,"numPendingTestSuites":0,"numTotalTests":0,"numPassedTests":0,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1792963337027,"success":false,"testResults":[{"assertionResults":[],"startTime":1792963337027,"endTime":1792963337027,"status":"failed","message":"Currency code is required with currency style.","name":"/home/michael/workspace/apps/tips/test/tips-math.test.ts"}]} \ No newline at end of file +{"numTotalTestSuites":7,"numPassedTestSuites":7,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":8,"numPassedTests":8,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1792965203049,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["parseAmount"],"fullName":"parseAmount parses plain decimals into cents","status":"passed","title":"parses plain decimals into cents","duration":1.1074950000000001,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["parseAmount"],"fullName":"parseAmount rejects empty and invalid input","status":"passed","title":"rejects empty and invalid input","duration":0.16899399999999787,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["parsePercent"],"fullName":"parsePercent parses percentages","status":"passed","title":"parses percentages","duration":0.11859300000000417,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["parsePercent"],"fullName":"parsePercent rejects empty, invalid and out-of-range input","status":"passed","title":"rejects empty, invalid and out-of-range input","duration":0.0785449999999912,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["tipCents"],"fullName":"tipCents rounds to the nearest cent","status":"passed","title":"rounds to the nearest cent","duration":0.1635209999999887,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["perPersonCents"],"fullName":"perPersonCents rounds up so the total is covered","status":"passed","title":"rounds up so the total is covered","duration":0.11150500000000818,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["perPersonRoundedUpCents"],"fullName":"perPersonRoundedUpCents rounds the per-person share up to the next whole dollar","status":"passed","title":"rounds the per-person share up to the next whole dollar","duration":0.11932899999999336,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["clampPeople"],"fullName":"clampPeople keeps the count between 1 and 99","status":"passed","title":"keeps the count between 1 and 99","duration":0.14684900000000312,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1792965203159,"endTime":1792965203161.1467,"status":"passed","message":"","name":"/home/michael/workspace/apps/tips/test/tips-math.test.ts"}]} \ No newline at end of file