Net the trip debt to one figure, and show payer, category and search on Shared
ci / lint-test (push) Successful in 48s
ci / lint-test (push) Successful in 48s
Shared view: the query already returned owner_name and effective_category, the table just never rendered them. Paid by sits next to Splits because together they are whose money went out and whose share it was. Search is client-side — this endpoint returns all 1,267 split rows in one request with no pagination, so there is nothing for a round-trip to narrow, and the sort was already client-side. It matches description, merchant, notes, category and payer, but not participant names: the dropdown does that, and "sonu" matching every row she is split on would read as broken. Trip owed collapses to one settle-up figure per person, with the breakdown beside it so the net is auditable rather than asserted. I argued against netting a few hours ago and was wrong. The claim was that the grouped-payment allocation cleared each trip against the one-directional gross, so netting would redefine that debt after the fact. The rows say otherwise: Europe's $802.75 is 56 transactions Sonu actually paid across Rome, Venice, the Dolomites, Bellagio, Lucerne and Paris on which I hold 25%, and paid_by_me is $0.00 on every row of every trip because nothing has ever been recorded going from me to her. Her side looked settled only because the allocation derived her payment split from her gross, so it lands on zero by construction. The one-directional view was hiding a live obligation, not protecting an allocation. Nets now: Auckland Sonu +$1,077.25, Europe Sonu -$802.75, Sonu + Sunny -$936.34, Europe Molina -$816.16. Also correcting an error in my own reporting: I said Auckland's mirror was $0.00. It is $428.39 — 17 Auckland rows Sonu paid that I hold a split on. Two ad-hoc verification queries mis-joined on a nullable scope column and under-reported the mirror side. The app code was never affected and the owed column is still byte-identical. The footnote now states the trap the netting exposes: a debt settled by a payment left on the household tab still reads as outstanding on the trip. Payment 5 (Molina to Sonu, $1,605.49) is exactly that case and is left alone as a data decision. 277 passing, build clean.
This commit is contained in:
+98
-10
@@ -16,6 +16,8 @@ import {
|
||||
} from "@/lib/hooks";
|
||||
import type { SharedTransactionRow } from "@/lib/queries";
|
||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS } from "@/lib/category-colors";
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
@@ -311,15 +313,37 @@ export default function SharedPage() {
|
||||
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
|
||||
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const [search, setSearch] = useState("");
|
||||
const realTagIds = tagIds.filter((id) => id !== "untagged");
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId);
|
||||
|
||||
const transactions = [...rawTransactions].sort((a, b) => {
|
||||
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||||
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||||
return sortDir === "desc" ? bv - av : av - bv;
|
||||
});
|
||||
// Filtered client-side, like the sort above and unlike the transactions page.
|
||||
// This endpoint returns every split row in one go (1,267 today) with no
|
||||
// pagination, so there is nothing for a server round-trip to narrow — and a
|
||||
// server search would have to be added to a query the balance cards share.
|
||||
//
|
||||
// Deliberately does NOT match participant names: the participant dropdown
|
||||
// already does that properly, and typing "sonu" matching every row she is
|
||||
// split on would make the box look broken. The payer IS matched, because
|
||||
// nothing else on the page filters by who paid.
|
||||
const transactions = [...rawTransactions]
|
||||
.filter((tx) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return [
|
||||
tx.description,
|
||||
tx.effective_merchant,
|
||||
tx.notes,
|
||||
tx.effective_category ? formatCategory(tx.effective_category) : null,
|
||||
tx.owner_name,
|
||||
].some((f) => f?.toLowerCase().includes(q));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||||
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||||
return sortDir === "desc" ? bv - av : av - bv;
|
||||
});
|
||||
|
||||
function toggleSort(col: SortCol) {
|
||||
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
@@ -352,6 +376,17 @@ export default function SharedPage() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-2xl font-display">Shared Expenses</h2>
|
||||
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search description, merchant, category, payer…"
|
||||
aria-label="Search split transactions"
|
||||
className="w-64 bg-zinc-800 border border-zinc-700 rounded-lg pl-8 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm pointer-events-none">⌕</span>
|
||||
</div>
|
||||
<select
|
||||
value={participantId ?? ""}
|
||||
onChange={(e) => setParticipantId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
@@ -444,17 +479,36 @@ export default function SharedPage() {
|
||||
|
||||
{/* Transaction list */}
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto">
|
||||
<div className="px-4 py-3 border-b border-zinc-800">
|
||||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">Split Transactions</h3>
|
||||
{search.trim() && !txLoading && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
{transactions.length} of {rawTransactions.length} match “{search.trim()}”
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{txLoading ? (
|
||||
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
|
||||
) : transactions.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm px-4 py-6">
|
||||
No split transactions yet. Use the Split button on any transaction.
|
||||
</p>
|
||||
// "None yet" is wrong when a search simply matched nothing, and it reads
|
||||
// as though the splits were lost.
|
||||
search.trim() ? (
|
||||
<p className="text-zinc-500 text-sm px-4 py-6">
|
||||
Nothing matches “{search.trim()}”.{" "}
|
||||
<button onClick={() => setSearch("")} className="text-zinc-400 hover:text-zinc-200 underline">
|
||||
Clear search
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-zinc-500 text-sm px-4 py-6">
|
||||
No split transactions yet. Use the Split button on any transaction.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<table className="w-full text-sm min-w-[520px]">
|
||||
<table className="w-full text-sm min-w-[760px]">
|
||||
{/* min-w raised from 520px with the Category and Paid-by columns: the
|
||||
wrapper scrolls horizontally, so a too-small minimum crushes cells
|
||||
rather than letting them scroll. */}
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th
|
||||
@@ -470,12 +524,17 @@ export default function SharedPage() {
|
||||
Imported <SortIcon col="created_at" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||||
<th
|
||||
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
|
||||
onClick={() => toggleSort("amount")}
|
||||
>
|
||||
Amount <SortIcon col="amount" />
|
||||
</th>
|
||||
{/* Paid by sits next to Splits deliberately: together they are the
|
||||
two halves of the question this page exists to answer — whose
|
||||
money went out, and whose share it was. */}
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium whitespace-nowrap">Paid by</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
@@ -496,6 +555,25 @@ export default function SharedPage() {
|
||||
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
|
||||
)}
|
||||
</td>
|
||||
{/* Category is the effective one — the override wins over the
|
||||
extracted value, the same COALESCE every other view uses,
|
||||
so a correction made elsewhere shows up here too. */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{tx.effective_category ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs text-zinc-300"
|
||||
title={formatCategory(tx.effective_category)}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: CATEGORY_COLORS[tx.effective_category] ?? "#71717a" }}
|
||||
/>
|
||||
{formatCategory(tx.effective_category)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 italic">uncategorised</span>
|
||||
)}
|
||||
</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, tx.currency)}
|
||||
{tx.currency !== "AUD" && (
|
||||
@@ -508,6 +586,16 @@ export default function SharedPage() {
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Whose money actually left. This is the effective owner —
|
||||
COALESCE(t.owner_id, s.owner_id) — so it is the account the
|
||||
spend came out of, which is what every balance on this page
|
||||
is computed from. "Me" matches the split chips rather than
|
||||
printing your own name twice in one row. */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className={`text-xs ${tx.owner_id === me?.id ? "text-zinc-400" : "text-indigo-300"}`}>
|
||||
{tx.owner_id === me?.id ? "Me" : tx.owner_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{splits.map((s) => (
|
||||
|
||||
Reference in New Issue
Block a user