fix(analytics): make the displayed numbers mean what they say
ci / lint-test (push) Failing after 44s

Six metric-integrity defects from the UI/IA review, plus two found while
verifying the review's own claims against the code.

The reconciled-row exclusion existed only in queries.ts. Every analytics
route counted the superseded manual rows as spend — 48 rows, $4,474.79 of
double count, invisible precisely because the transaction list looked
right. It is now one fragment both sides import.

The spend-pace chart computed its own totals in the browser: gross
amounts, debits only, no personal share, no refunds, fees, interest or
itemised loan repayments. On live data it ended July at $4,747.31 under a
headline reading $3,597.10 — and its own baseline line was drawn from the
split-adjusted monthly totals, so the two series in one chart disagreed
with each other. Both now come from /api/analytics/daily, built from the
same fragments as the headline.

Fees aggregated every statement ever imported with no date filter, under
a heading with no period, so a lifetime figure read as a current one and
grew forever. Now bounded, labelled, and selectable.

Comparisons no longer measure a month in progress against complete ones:
the in-progress month is out of every baseline, and a selected current
month is compared through the same day.

Two the review did not catch:

- Every analytics window was a day early. toISOString() on a
  local-midnight Date converts backwards through UTC. Surfaced only once
  fees started reporting the range it had used.
- /monthly rounded per category, /daily per category-day, so the pace
  chart ended a few cents off the headline above it.

Shared currency needed amending rather than applying. Reading s.currency
would have labelled every order row AUD, since an order receipt has no
statement and carries its own currency — the opposite convention from a
foreign charge on an AUD statement, where amount IS AUD. NATIVE_CURRENCY's
COALESCE order keeps the two apart. Balances also now count rows whose AUD
value is genuinely unknown instead of netting a foreign figure against AUD
ones. Latent today: no foreign transaction is currently split.

Tag-filtered balance cards no longer claim "owes you". With a filter on,
payments are deliberately not subtracted, so the figure is a split total
and settling against it would record a payment for a debt that never was.

Split-coverage warnings deliberately omitted (user decision).
This commit is contained in:
2026-07-27 17:10:27 +10:00
parent 5ee5ee24cf
commit a4ab543a6c
14 changed files with 643 additions and 90 deletions
+48 -11
View File
@@ -22,8 +22,12 @@ function formatDate(d: string) {
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(n: number, type?: string) {
const formatted = `$${Number(n).toFixed(2)}`;
function formatAmount(n: number, type?: string, currency?: string) {
// A bare "$" on a non-AUD row was the visible half of the problem: the row
// read as dollars while the participant balances converted to AUD, so the
// two disagreed on screen with nothing to explain why.
const value = Number(n).toFixed(2);
const formatted = !currency || currency === "AUD" ? `$${value}` : `${currency} ${value}`;
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
}
@@ -298,7 +302,17 @@ export default function SharedPage() {
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
}
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
const { data: allTags = [] } = useTags();
const { data: me } = useCurrentUser();
// Names the tag scope when one is active. Non-empty means the cards below are
// split totals rather than payable balances.
const tagScopeLabel =
realTagIds.length === 0
? null
: realTagIds.length === 1
? (allTags.find((t) => String(t.id) === realTagIds[0])?.name ?? "this tag")
: `${realTagIds.length} tags`;
const [addingParticipant, setAddingParticipant] = useState(false);
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
const [showHistory, setShowHistory] = useState<number | null>(null);
@@ -351,23 +365,37 @@ export default function SharedPage() {
<div>
<p className="font-medium">{b.name}</p>
<p className="text-xs text-zinc-500">
{settled ? "all square" : theyOweMe ? `owes you` : "you owe"}
{/* With a tag filter on, payments are deliberately not
subtracted — so this is a split total, not a payable
balance, and must not claim to be one. */}
{tagScopeLabel
? `split total in ${tagScopeLabel}`
: settled ? "all square" : theyOweMe ? "owes you" : "you owe"}
</p>
</div>
<div className="text-right">
<p className={`text-lg font-semibold ${settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
<p className={`text-lg font-semibold ${tagScopeLabel ? "text-zinc-300" : settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
${net.toFixed(2)}
</p>
{b.unconverted_count > 0 && (
<p className="text-[11px] text-amber-500/80 mt-0.5">
approx · {b.unconverted_count} unconverted
</p>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
Record Payment
</button>
{/* Settling against a tag-scoped total would record a payment
for a figure that never was the debt. */}
{!tagScopeLabel && (
<button
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
Record Payment
</button>
)}
<button
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
@@ -440,7 +468,16 @@ export default function SharedPage() {
)}
</td>
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
{formatAmount(tx.amount, tx.transaction_type)}
{formatAmount(tx.amount, tx.transaction_type, tx.currency)}
{tx.currency !== "AUD" && (
// Splits settle on the AUD figure, so show it next to the
// native one rather than leaving the two to differ silently.
<span className="block text-xs font-normal text-zinc-500">
{tx.amount_unconverted
? "AUD value unknown"
: `${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">