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>
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() { |