Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vitest/json/output.json
Original file line number Diff line number Diff line change
@@ -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"}]}
{"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"}]}
27 changes: 27 additions & 0 deletions e2e/tips.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("—");
Expand Down
29 changes: 25 additions & 4 deletions src/app-main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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 = {
bill: { state: true },
tipChoice: { state: true },
customTip: { state: true },
people: { state: true },
roundUp: { state: true },
};
// Layout only here: no colors, fonts or border-radius.
static styles = [
Expand All @@ -19,13 +20,15 @@ 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; }
`,
];

bill = "";
tipChoice = "20";
customTip = "";
people = 1;
roundUp = false;

private setPeople(delta: number) {
this.people = clampPeople(this.people + delta);
Expand All @@ -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`
Expand Down Expand Up @@ -70,6 +86,11 @@ export class AppMain extends LitElement {
<span id="people" class="people-count">${this.people}</span>
<button id="people-plus" class="btn ghost" aria-label="More people" @click=${() => this.setPeople(1)}>+</button>
</div>
<label class="toggle">
<input id="round-up" type="checkbox" .checked=${this.roundUp}
@change=${(e: Event) => (this.roundUp = (e.target as HTMLInputElement).checked)} />
<span>Round up per person to a whole dollar</span>
</label>
<section class="results">
<p class="result"><span class="label">tip</span> <output id="tip">${show(tip)}</output></p>
<p class="result"><span class="label">total</span> <output id="total">${show(total)}</output></p>
Expand Down
8 changes: 8 additions & 0 deletions src/tips-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
12 changes: 11 additions & 1 deletion test/tips-math.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
Expand Down
Loading