orders: show the context, not just the row
ci / lint-test (push) Successful in 44s

The first cut rendered entity_orders and nothing else, and it read like a table
dump: a courier tracking notice and a subscription payment both presented as
retail purchases, merchants shown as sender-domain slugs, and no indication that
the same coffee roaster had been billing fortnightly for two years.

Rows now carry what the ingestion layer already knew. A kind badge from
content_class distinguishes a delivery notice, an invoice, a booking and a
subscription from an actual purchase — NULL stays unbadged rather than being
labelled a purchase, because 48% of rows predate the interpretation index and
"unknown" is not "order". display_name shows the resolved merchant (98% of rows)
instead of the slug. A recurrence badge marks merchants billing on a cadence,
which is derived from the gaps between orders rather than stated anywhere in the
mail. And the order's own title now appears when it differs from the merchant,
so the manifest is not the only detail on the row.

Adds the services lane for bookings, invoices and subscriptions — things with no
goods and no delivery, where the retail columns are dead space.
This commit is contained in:
2026-08-12 12:50:03 +10:00
parent b26d526e83
commit c05c4b5a35
3 changed files with 92 additions and 5 deletions
+24 -2
View File
@@ -13,7 +13,16 @@ import { useOrderDetail } from "@/lib/hooks";
* Do not build a tracking widget against a field nothing populates. * Do not build a tracking widget against a field nothing populates.
*/ */
/** What the classifier thought the source document was — see the list page. */
const KIND_LABEL: Record<string, string> = { const KIND_LABEL: Record<string, string> = {
courier_tracking: "delivery notice",
invoice_receipt: "invoice",
subscription: "subscription",
booking: "booking",
account_notice: "account notice",
};
const EVENT_LABEL: Record<string, string> = {
placed: "Order placed", placed: "Order placed",
shipped: "Dispatched", shipped: "Dispatched",
out_for_delivery: "Out for delivery", out_for_delivery: "Out for delivery",
@@ -91,8 +100,21 @@ export default function OrderDetailPage({ params }: { params: Promise<{ entityKe
{o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}</>} {o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}</>}
</div> </div>
<h2 className="font-display text-[27px] leading-tight text-zinc-50 my-3 max-w-[26ch] text-balance"> <h2 className="font-display text-[27px] leading-tight text-zinc-50 my-3 max-w-[26ch] text-balance">
{o.canonical_name || o.merchant_name || "Order"} {o.canonical_name || o.display_name || "Order"}
</h2> </h2>
<div className="flex gap-2 flex-wrap mb-3">
{o.content_class && KIND_LABEL[o.content_class] && (
<span className="font-mono text-[10px] uppercase tracking-wider text-zinc-400 border border-zinc-700 rounded-sm px-2 py-0.5">
{KIND_LABEL[o.content_class]}
</span>
)}
{o.cadence_days && (
<span className="font-mono text-[10px] uppercase tracking-wider text-indigo-400 border border-indigo-800 rounded-sm px-2 py-0.5">
recurring · every ~{o.cadence_days} days
{o.order_count ? ` · ${o.order_count} orders` : ""}
</span>
)}
</div>
<div className="flex gap-6 flex-wrap font-mono text-[11.5px] text-zinc-400 tabular-nums"> <div className="flex gap-6 flex-wrap font-mono text-[11.5px] text-zinc-400 tabular-nums">
<div> <div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5"> <span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">
@@ -133,7 +155,7 @@ export default function OrderDetailPage({ params }: { params: Promise<{ entityKe
{e.effective_at ? dateFmt.format(new Date(e.effective_at)) : "—"} {e.effective_at ? dateFmt.format(new Date(e.effective_at)) : "—"}
</span> </span>
<span className="text-[13px] text-zinc-100"> <span className="text-[13px] text-zinc-100">
{KIND_LABEL[e.event_kind ?? ""] ?? e.event_kind ?? "Event"} {EVENT_LABEL[e.event_kind ?? ""] ?? e.event_kind ?? "Event"}
</span> </span>
<span className="font-mono text-[11.5px] text-zinc-500 tabular-nums"> <span className="font-mono text-[11.5px] text-zinc-500 tabular-nums">
{fmtMoney(e.amount, e.currency ?? o.currency) ?? "—"} {fmtMoney(e.amount, e.currency ?? o.currency) ?? "—"}
+34 -1
View File
@@ -19,6 +19,7 @@ const LANES = [
{ id: "food", label: "Food" }, { id: "food", label: "Food" },
{ id: "transport", label: "Transport" }, { id: "transport", label: "Transport" },
{ id: "digital", label: "Digital" }, { id: "digital", label: "Digital" },
{ id: "services", label: "Services" },
{ id: "grocery", label: "Grocery" }, { id: "grocery", label: "Grocery" },
] as const; ] as const;
@@ -51,6 +52,22 @@ function rangeFor(key: RangeKey): { from?: string; to?: string; label: string }
} }
} }
/**
* What the source document actually was. The classifier already knew — 188
* orders came from courier_tracking documents and 147 from invoice_receipt —
* and the first cut of this page showed all of them as retail purchases.
* NULL means the interpretation index never saw it (48% of rows, the Takeout
* backfill), which is "unknown", not "purchase" — so it gets no badge at all
* rather than a confident wrong one.
*/
const KIND_LABEL: Record<string, string> = {
courier_tracking: "delivery",
invoice_receipt: "invoice",
subscription: "subscription",
booking: "booking",
account_notice: "notice",
};
const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]); const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]);
const REVERSED = new Set(["refunded", "returned", "cancelled"]); const REVERSED = new Set(["refunded", "returned", "cancelled"]);
@@ -136,8 +153,21 @@ function Row({ row }: { row: OrderRow }) {
href={`/orders/${encodeURIComponent(row.entity_key)}`} href={`/orders/${encodeURIComponent(row.entity_key)}`}
className="text-[13.5px] text-zinc-50 hover:text-indigo-300" className="text-[13.5px] text-zinc-50 hover:text-indigo-300"
> >
{row.merchant_name || row.platform || "Unknown merchant"} {row.display_name}
</Link> </Link>
{row.content_class && KIND_LABEL[row.content_class] && (
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-400 border border-zinc-700 rounded-sm px-1.5">
{KIND_LABEL[row.content_class]}
</span>
)}
{row.cadence_days && (
<span
title={`Orders from this merchant arrive on a regular cadence — derived from the gaps between them, not stated anywhere in the mail.`}
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-400 border border-indigo-800 rounded-sm px-1.5"
>
every ~{row.cadence_days}d
</span>
)}
{row.reference_source === "message_id_fallback" && ( {row.reference_source === "message_id_fallback" && (
<span <span
title="No order reference in this mail — it cannot merge with its lifecycle siblings, so the same purchase may appear twice." title="No order reference in this mail — it cannot merge with its lifecycle siblings, so the same purchase may appear twice."
@@ -153,6 +183,9 @@ function Row({ row }: { row: OrderRow }) {
</span> </span>
)} )}
</div> </div>
{row.canonical_name && row.canonical_name !== row.display_name && (
<div className="text-[12px] text-zinc-400 mb-0.5">{row.canonical_name}</div>
)}
<Manifest row={row} /> <Manifest row={row} />
</td> </td>
<td className="p-3 align-top"><StatusPill status={row.status} /></td> <td className="p-3 align-top"><StatusPill status={row.status} /></td>
+34 -2
View File
@@ -39,7 +39,9 @@ export function canViewOrders(userId: number): boolean {
return ORDER_VIEWERS.includes(userId); return ORDER_VIEWERS.includes(userId);
} }
export type OrderLane = "retail" | "food" | "grocery" | "digital" | "transport"; /** Bookings, invoices and subscriptions get their own lane: they carry no
* goods and no delivery, so the retail columns are dead space on them. */
export type OrderLane = "retail" | "food" | "grocery" | "digital" | "transport" | "services";
export interface OrderRow { export interface OrderRow {
entity_id: number; entity_id: number;
@@ -60,6 +62,17 @@ export interface OrderRow {
refunded_amount: string | null; refunded_amount: string | null;
line_item_count: number; line_item_count: number;
item_preview: string[] | null; item_preview: string[] | null;
/** What the classifier thought the source document was. NULL on 48% of rows
* (the Takeout backfill predates the interpretation index) — treat NULL as
* "unknown", never as "purchase". */
content_class: string | null;
/** COALESCE(resolved merchant, interpretation counterparty, platform slug).
* 95% resolve to a real name; the rest honestly show the slug rather than
* guessing one from the sender domain — see ticket 176. */
display_name: string;
/** Merchant orders on a regular cadence: the subscription signal, derived
* not extracted. NULL unless the gaps are tight relative to their mean. */
cadence_days: number | null;
txn_count: number; txn_count: number;
first_txn_id: number | null; first_txn_id: number | null;
} }
@@ -206,6 +219,7 @@ export async function getOrderFeed(filters: OrderFilters) {
f.order_reference, f.reference_source, f.source_trust, f.order_reference, f.reference_source, f.source_trust,
f.ordered_at, f.eta_date, f.delivered_at, f.ordered_at, f.eta_date, f.delivered_at,
f.currency, f.order_total, f.line_item_count, f.currency, f.order_total, f.line_item_count,
f.content_class, f.display_name, f.cadence_days,
m.canonical_name AS merchant_name, m.canonical_name AS merchant_name,
link.txn_count, link.first_txn_id, link.txn_count, link.first_txn_id,
(SELECT sp.refunded_amount FROM order_spend sp (SELECT sp.refunded_amount FROM order_spend sp
@@ -316,6 +330,10 @@ export interface OrderDetail {
tracking_carrier: string | null; tracking_carrier: string | null;
line_items: { description?: string; quantity?: number; amount?: number }[]; line_items: { description?: string; quantity?: number; amount?: number }[];
is_settled_duplicate: boolean; is_settled_duplicate: boolean;
content_class: string | null;
display_name: string;
cadence_days: number | null;
order_count: number | null;
events: OrderLifecycleEvent[]; events: OrderLifecycleEvent[];
siblings: OrderSibling[]; siblings: OrderSibling[];
transactions: OrderLinkedTxn[]; transactions: OrderLinkedTxn[];
@@ -342,12 +360,26 @@ export async function getOrderDetail(entityKey: string): Promise<OrderDetail | n
COALESCE(o.details->'line_items', '[]'::jsonb) AS line_items, COALESCE(o.details->'line_items', '[]'::jsonb) AS line_items,
sp.order_total AS net_total, sp.order_total AS net_total,
sp.gross_total, sp.gross_total,
sp.refunded_amount sp.refunded_amount,
ctx.content_class,
COALESCE(me2.canonical_name, ctx.counterparty, o.platform) AS display_name,
rec.cadence_days,
rec.order_count
FROM entities e FROM entities e
JOIN entity_orders o ON o.entity_id = e.id JOIN entity_orders o ON o.entity_id = e.id
LEFT JOIN entities m ON m.id = o.merchant_entity_id LEFT JOIN entities m ON m.id = o.merchant_entity_id
LEFT JOIN entities me2 ON me2.id = o.merchant_entity_id
LEFT JOIN order_platforms p ON p.slug = o.platform LEFT JOIN order_platforms p ON p.slug = o.platform
LEFT JOIN order_spend sp ON sp.entity_id = o.entity_id LEFT JOIN order_spend sp ON sp.entity_id = o.entity_id
LEFT JOIN order_merchant_cadence rec ON rec.merchant_entity_id = o.merchant_entity_id
LEFT JOIN LATERAL (
SELECT di.content_class, di.counterparty
FROM extracted_facts ef
JOIN document_interpretations di ON di.source_document_id = ef.source_document_id
WHERE ef.fact_type = 'order_event'
AND ef.payload->>'_order_entity_key' = e.entity_key
ORDER BY (di.counterparty IS NULL), ef.effective_at LIMIT 1
) ctx ON true
WHERE e.entity_key = $1`, WHERE e.entity_key = $1`,
[entityKey] [entityKey]
); );