eleven / messenger Public

web: expenditure renders as ledger lines with a per-currency total (#771)

Rows like the subscriptions list, a Total at the bottom — per currency
only (usd and eur never sum without FX) with the mixed-periods caveat
stated inline: the tally is a scale read, not an invoice.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5277262d0d6b0a92eb9ced41b8b599253e45d531 parent a09faa3
1 file changed, +40 −13
  • web/status/books.html +40 −13
diff --git a/web/status/books.html b/web/status/books.html
index 41f47ab..91cd551 100644
--- a/web/status/books.html
+++ b/web/status/books.html
@@ -69,6 +69,12 @@
.bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.bars li { display: grid; grid-template-columns: minmax(90px, 160px) 1fr 3.5em; align-items: center; gap: 12px; padding: 10px 16px; }
.bars li + li { border-top: 1px solid var(--grid); }
+ .ledger { list-style: none; padding: 0; display: grid; gap: 0; }
+ .ledger li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 9px 2px; border-bottom: 1px solid var(--border); font-size: 15px; }
+ .ledger .amt { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }
+ .ledger-total { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 11px 2px 0; font-weight: 700; }
+ .ledger-total .amt { font-variant-numeric: tabular-nums; white-space: nowrap; }
+ .ledger-total .caveat { font-weight: 400; font-size: 13px; color: var(--ink-2); }
.bars .tier { font-size: 14px; font-weight: 600; text-transform: capitalize; overflow-wrap: anywhere; }
.bars .track { height: 12px; }
.bars .bar { height: 12px; min-width: 2px; background: var(--series); border-radius: 0 4px 4px 0; }
@@ -124,7 +130,8 @@
<h2 id="h-expenditure">Expenditure</h2>
<p class="note explain"><strong>Itemized by design:</strong> one line per
provider, in that provider’s own billing currency — never converted.</p>
- <div class="tiles" id="exp-tiles"></div>
+ <ul class="ledger" id="exp-lines"></ul>
+ <div class="ledger-total" id="exp-total"></div>
</section>
</div>
@@ -239,8 +246,13 @@ const EXPENSE_LABELS = {
function renderExpenses(exp) {
const sec = $("s-expenditure");
const providers = (exp && exp.providers) || [];
- const tiles = $("exp-tiles");
- tiles.replaceChildren();
+ const lines = $("exp-lines");
+ lines.replaceChildren();
+ // Totals are PER CURRENCY only — usd and eur never sum into one number
+ // (no FX), and the caveat owns the other honesty gap: the lines mix
+ // billing periods (month-to-date, monthly, yearly, to-date), so the tally
+ // is a scale read, not an invoice.
+ const totals = {};
for (const p of providers) {
// Exactly one amount key per provider: metered month-to-date, flat
// monthly, flat yearly, or cumulative to-date — each shown as billed
@@ -248,19 +260,34 @@ function renderExpenses(exp) {
// is labelled to date, not smeared across months).
const cents = [p.month_to_date_cents, p.monthly_cents, p.yearly_cents, p.to_date_cents].find((v) => typeof v === "number");
if (typeof cents !== "number") continue;
- const tile = document.createElement("div");
- tile.className = "tile";
- const label = document.createElement("div");
- label.className = "label";
+ const cur = (p.currency || "eur").toUpperCase();
+ totals[cur] = (totals[cur] || 0) + cents;
+ const li = document.createElement("li");
+ const label = document.createElement("span");
label.textContent = EXPENSE_LABELS[p.provider] || p.provider;
- const value = document.createElement("div");
- value.className = "value";
- const fmt = new Intl.NumberFormat("en-IE", { style: "currency", currency: (p.currency || "eur").toUpperCase() });
+ const value = document.createElement("span");
+ value.className = "amt";
+ const fmt = new Intl.NumberFormat("en-IE", { style: "currency", currency: cur });
value.textContent = fmt.format(cents / 100);
- tile.append(label, value);
- tiles.append(tile);
+ li.append(label, value);
+ lines.append(li);
+ }
+ const totalEl = $("exp-total");
+ totalEl.replaceChildren();
+ if (lines.childElementCount > 0) {
+ const label = document.createElement("span");
+ label.textContent = "Total";
+ const caveat = document.createElement("span");
+ caveat.className = "caveat";
+ caveat.textContent = "across mixed billing periods";
+ const amt = document.createElement("span");
+ amt.className = "amt";
+ amt.textContent = Object.entries(totals).map(([c, v]) =>
+ new Intl.NumberFormat("en-IE", { style: "currency", currency: c }).format(v / 100)).join(" + ");
+ label.append(document.createTextNode(" "), caveat);
+ totalEl.append(label, amt);
}
- sec.hidden = tiles.childElementCount === 0;
+ sec.hidden = lines.childElementCount === 0;
}
async function refresh() {