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
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
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);