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
57 changes: 57 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Calculator site

# Publishes apps/calculator (the browser-only attrition calculator) to GitHub Pages.
# It is a static site: no server, no API key, nothing the visitor types leaves the browser.
on:
push:
branches: [main]
paths:
- apps/calculator/**
- packages/core/**
- .github/workflows/pages.yml
workflow_dispatch:

permissions:
contents: read

# Only one deployment at a time; a newer push replaces a queued one.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:packages
- run: pnpm --filter calculator test
- run: pnpm --filter calculator build
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: apps/calculator/dist

deploy:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
# Only this job may publish, and only to the Pages environment.
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Added

- `apps/calculator`: a browser-only attrition calculator published to GitHub Pages. It bundles `packages/core`, compares the cumulative and per-night bases side by side, and sends nothing to any server.

## [0.1.0] - 2026-10-07

First release.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

It is an open source attrition calculator, pickup tracker and alerting service for event planners, travel agencies and group housing teams. It is built with TypeScript, NestJS, Next.js and PostgreSQL, and released under the MIT license.

**Try the attrition calculator in your browser: [alihdrndm.github.io/blockpace](https://alihdrndm.github.io/blockpace/).** No sign-up and no install. It compares the cumulative and per-night bases side by side, and nothing you type leaves your browser.

![blockpace demo: a planner records a weak pickup snapshot, the block moves from On track to At risk, and the signed webhooks are delivered](docs/media/demo.gif)

## The problem

Take a three night group booking: 80, 70 and 50 rooms at $200 a night, with 20% allowed attrition. Your guests book 70, 50 and 30 rooms. Read the attrition clause as **cumulative**, measured on the total, and you owe **$2,000**. Read it **per night**, where each night must reach its own minimum, and you owe **$3,200** for exactly the same group at exactly the same hotel. That gap is decided by one line in the hotel contract, and spreadsheets get it wrong all the time, along with the damages percentage, tax and resell credit for rooms the hotel sold again. Worse, most planners only find out what they owe after the cutoff date, when the hotel has released the unbooked rooms and nothing can be done. blockpace does the attrition math exactly, tracks pickup report by pickup report, projects pickup to the cutoff and raises an alert while there is still time to fill rooms or renegotiate.
Expand Down
87 changes: 87 additions & 0 deletions apps/calculator/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hotel room block attrition calculator | blockpace</title>
<meta name="description" content="Free hotel room block attrition calculator for event planners. Compare cumulative and per-night attrition damages, with resell credit and tax. Runs in your browser; nothing is sent anywhere.">
<meta property="og:title" content="Hotel room block attrition calculator">
<meta property="og:description" content="Same pickup, $975.60 or $1,528.80 in damages, depending on one line in the contract. Check yours in your browser.">
<meta property="og:image" content="https://raw.githubusercontent.com/alihdrndm/blockpace/main/docs/media/social-preview.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231c1830'/%3E%3Crect x='7' y='12' width='7' height='7' rx='1.5' fill='%23f4b942'/%3E%3Crect x='18' y='12' width='7' height='7' rx='1.5' fill='%23ff6b6b'/%3E%3Crect x='7' y='22' width='7' height='5' rx='1.5' fill='%23f4b942'/%3E%3Crect x='18' y='22' width='7' height='5' rx='1.5' fill='%23f4b942'/%3E%3C/svg%3E">
</head>
<body>
<header class="hero">
<div class="wrap">
<p class="brand"><a href="https://github.com/alihdrndm/blockpace">blockpace</a></p>
<h1>Hotel room block attrition calculator</h1>
<p class="lede">Find out what an attrition clause will charge your group, measured on the total <em>and</em> night by night, before the hotel sends the bill.</p>
<p class="privacy">Runs entirely in your browser. Nothing you type is sent anywhere.</p>
</div>
</header>

<main class="wrap">
<section class="panel" aria-labelledby="terms-heading">
<div class="panel-head">
<h2 id="terms-heading">Contract terms</h2>
<button type="button" class="ghost" id="load-example">Load an example</button>
</div>
<div class="terms">
<label>Currency <input id="currency" value="USD" maxlength="3" autocomplete="off" spellcheck="false"></label>
<label>Allowed attrition (%) <input id="attrition" type="number" min="0" max="100" step="0.01" value="20"></label>
<label>Damages (% of rate) <input id="damages" type="number" min="0" max="100" step="0.01" value="100"></label>
<label>Tax on damages (%) <input id="tax" type="number" min="0" max="50" step="0.01" value="0"></label>
<label>Round the minimum
<select id="rounding">
<option value="ceil">Up (cautious)</option>
<option value="round">To the nearest room</option>
<option value="floor">Down</option>
</select>
</label>
<label class="check"><input id="resell" type="checkbox"> Resell credit (rooms the hotel resold reduce the shortfall)</label>
</div>
</section>

<section class="panel" aria-labelledby="nights-heading">
<div class="panel-head">
<h2 id="nights-heading">Nights</h2>
<div class="night-controls">
<label>First night <input id="first-night" type="date"></label>
<label>Number of nights <input id="night-count" type="number" min="1" max="60" value="3"></label>
</div>
</div>
<div class="table-scroll">
<table class="grid" id="nights">
<thead>
<tr>
<th scope="col">Night</th>
<th scope="col">Contracted rooms</th>
<th scope="col">Rate per night</th>
<th scope="col">Picked up</th>
<th scope="col" class="resold-col">Resold</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<button type="button" class="primary" id="calculate">Calculate damages</button>
</section>

<section aria-live="polite" id="result" class="result" hidden></section>

<section class="explain" aria-labelledby="explain-heading">
<h2 id="explain-heading">How attrition damages work</h2>
<p>A <strong>room block</strong> is a set of rooms a hotel holds for your group, night by night. The <strong>attrition clause</strong> lets your guests leave some of them unused, for example 20%. Below that <strong>minimum</strong>, every missing room night is a <strong>shortfall</strong>, charged at the room rate times the damages percentage.</p>
<p>The clause measures the minimum one of two ways. On a <strong>cumulative basis</strong>, only the total has to reach it, so a busy night makes up for a quiet one. On a <strong>per-night basis</strong>, every night has its own minimum, and a full night never cancels out an empty one. That is why the same bookings can cost very different amounts.</p>
<p class="fine">This is an estimate, and the signed contract always governs. On the cumulative basis the shortfall is charged at the average rate weighted by contracted rooms. All assumptions are listed in <a href="https://github.com/alihdrndm/blockpace/blob/main/docs/ASSUMPTIONS.md">ASSUMPTIONS.md</a>.</p>
</section>
</main>

<footer class="wrap foot">
<p>Free and open source (MIT). The full app tracks pickup reports, forecasts pickup to the cutoff date and sends alerts: <a href="https://github.com/alihdrndm/blockpace">github.com/alihdrndm/blockpace</a></p>
</footer>

<script type="module" src="/src/main.ts"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions apps/calculator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "calculator",
"version": "0.1.0",
"private": true,
"description": "Browser-only hotel room block attrition calculator, published to GitHub Pages",
"type": "module",
"engines": {
"node": ">=24 <25"
},
"scripts": {
"dev": "vite --port 3030",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage"
},
"dependencies": {
"@alihdrndm/blockpace-core": "workspace:*"
},
"author": "Ali Haider Nadeem",
"license": "MIT",
"devDependencies": {
"vite": "8.3.2"
}
}
80 changes: 80 additions & 0 deletions apps/calculator/src/compute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
calculate,
EXAMPLE,
formatMoney,
minorDigits,
toMinor,
} from "./compute.js";

describe("calculate", () => {
it("WE2: $975.60 cumulative and $1,528.80 per night", () => {
const outcome = calculate(EXAMPLE);
if (!outcome.ok) throw new Error(outcome.problems.join("; "));
expect(outcome.cumulative.damagesMinor).toBe(97560);
expect(outcome.perNight.damagesMinor).toBe(152880);
expect(formatMoney(outcome.cumulative.totalMinor, "USD")).toBe("$975.60");
expect(formatMoney(outcome.perNight.totalMinor, "USD")).toBe("$1,528.80");
expect(outcome.perNight.nights.map((n) => n.shortfallRooms)).toEqual([
0, 2, 7, 0,
]);
});

it("WE2b: resell credit lowers both bills", () => {
const nights = EXAMPLE.nights.map((n) =>
n.date === "2026-11-12" ? { ...n, resoldRooms: 3 } : n,
);
const outcome = calculate({ ...EXAMPLE, resellCredit: true, nights });
if (!outcome.ok) throw new Error(outcome.problems.join("; "));
expect(outcome.cumulative.damagesMinor).toBe(48780);
expect(outcome.perNight.damagesMinor).toBe(100320);
});

it("ignores resold rooms when resell credit is off", () => {
const nights = EXAMPLE.nights.map((n) => ({ ...n, resoldRooms: 5 }));
const outcome = calculate({ ...EXAMPLE, nights });
if (!outcome.ok) throw new Error(outcome.problems.join("; "));
expect(outcome.cumulative.damagesMinor).toBe(97560);
});

it("explains invalid input instead of throwing", () => {
const gap = {
...EXAMPLE,
nights: [EXAMPLE.nights[0], EXAMPLE.nights[2]].flatMap((n) =>
n ? [n] : [],
),
};
const notConsecutive = calculate(gap);
expect(notConsecutive.ok).toBe(false);
if (!notConsecutive.ok)
expect(notConsecutive.problems.join()).toMatch(/consecutive/);

expect(calculate({ ...EXAMPLE, allowedAttritionPct: 120 }).ok).toBe(false);
expect(calculate({ ...EXAMPLE, currency: "dollars" }).ok).toBe(false);
expect(calculate({ ...EXAMPLE, nights: [] }).ok).toBe(false);

const tooManyResold = EXAMPLE.nights.map((n) => ({
...n,
resoldRooms: 100,
}));
const resold = calculate({
...EXAMPLE,
resellCredit: true,
nights: tooManyResold,
});
expect(resold.ok).toBe(false);
});
});

describe("money helpers", () => {
it("use each currency's own minor unit", () => {
expect(minorDigits("USD")).toBe(2);
expect(minorDigits("JPY")).toBe(0);
expect(minorDigits("KWD")).toBe(3);
expect(minorDigits("not a currency")).toBe(2);
expect(toMinor(189, "USD")).toBe(18900);
expect(toMinor(189, "JPY")).toBe(189);
expect(formatMoney(18900, "USD")).toBe("$189.00");
expect(formatMoney(5, "XX")).toBe("XX 0.05");
});
});
Loading
Loading