From c05c4b5a357a60486f13b816d7d5184b32f200ce Mon Sep 17 00:00:00 2001 From: siddharthd Date: Wed, 12 Aug 2026 12:50:03 +1000 Subject: [PATCH] orders: show the context, not just the row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/orders/[entityKey]/page.tsx | 26 +++++++++++++++++++-- src/app/orders/page.tsx | 35 +++++++++++++++++++++++++++- src/lib/order-feed.ts | 36 +++++++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/app/orders/[entityKey]/page.tsx b/src/app/orders/[entityKey]/page.tsx index 89a78da..1aa4f13 100644 --- a/src/app/orders/[entityKey]/page.tsx +++ b/src/app/orders/[entityKey]/page.tsx @@ -13,7 +13,16 @@ import { useOrderDetail } from "@/lib/hooks"; * 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 = { + courier_tracking: "delivery notice", + invoice_receipt: "invoice", + subscription: "subscription", + booking: "booking", + account_notice: "account notice", +}; + +const EVENT_LABEL: Record = { placed: "Order placed", shipped: "Dispatched", 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.canonical_name || o.merchant_name || "Order"} + {o.canonical_name || o.display_name || "Order"}

+
+ {o.content_class && KIND_LABEL[o.content_class] && ( + + {KIND_LABEL[o.content_class]} + + )} + {o.cadence_days && ( + + recurring · every ~{o.cadence_days} days + {o.order_count ? ` · ${o.order_count} orders` : ""} + + )} +
@@ -133,7 +155,7 @@ export default function OrderDetailPage({ params }: { params: Promise<{ entityKe {e.effective_at ? dateFmt.format(new Date(e.effective_at)) : "—"} - {KIND_LABEL[e.event_kind ?? ""] ?? e.event_kind ?? "Event"} + {EVENT_LABEL[e.event_kind ?? ""] ?? e.event_kind ?? "Event"} {fmtMoney(e.amount, e.currency ?? o.currency) ?? "—"} diff --git a/src/app/orders/page.tsx b/src/app/orders/page.tsx index d74bf0d..9733e05 100644 --- a/src/app/orders/page.tsx +++ b/src/app/orders/page.tsx @@ -19,6 +19,7 @@ const LANES = [ { id: "food", label: "Food" }, { id: "transport", label: "Transport" }, { id: "digital", label: "Digital" }, + { id: "services", label: "Services" }, { id: "grocery", label: "Grocery" }, ] 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 = { + courier_tracking: "delivery", + invoice_receipt: "invoice", + subscription: "subscription", + booking: "booking", + account_notice: "notice", +}; + const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]); const REVERSED = new Set(["refunded", "returned", "cancelled"]); @@ -136,8 +153,21 @@ function Row({ row }: { row: OrderRow }) { href={`/orders/${encodeURIComponent(row.entity_key)}`} className="text-[13.5px] text-zinc-50 hover:text-indigo-300" > - {row.merchant_name || row.platform || "Unknown merchant"} + {row.display_name} + {row.content_class && KIND_LABEL[row.content_class] && ( + + {KIND_LABEL[row.content_class]} + + )} + {row.cadence_days && ( + + every ~{row.cadence_days}d + + )} {row.reference_source === "message_id_fallback" && ( )}
+ {row.canonical_name && row.canonical_name !== row.display_name && ( +
{row.canonical_name}
+ )} diff --git a/src/lib/order-feed.ts b/src/lib/order-feed.ts index 182c294..45dcc85 100644 --- a/src/lib/order-feed.ts +++ b/src/lib/order-feed.ts @@ -39,7 +39,9 @@ export function canViewOrders(userId: number): boolean { 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 { entity_id: number; @@ -60,6 +62,17 @@ export interface OrderRow { refunded_amount: string | null; line_item_count: number; 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; 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.ordered_at, f.eta_date, f.delivered_at, f.currency, f.order_total, f.line_item_count, + f.content_class, f.display_name, f.cadence_days, m.canonical_name AS merchant_name, link.txn_count, link.first_txn_id, (SELECT sp.refunded_amount FROM order_spend sp @@ -316,6 +330,10 @@ export interface OrderDetail { tracking_carrier: string | null; line_items: { description?: string; quantity?: number; amount?: number }[]; is_settled_duplicate: boolean; + content_class: string | null; + display_name: string; + cadence_days: number | null; + order_count: number | null; events: OrderLifecycleEvent[]; siblings: OrderSibling[]; transactions: OrderLinkedTxn[]; @@ -342,12 +360,26 @@ export async function getOrderDetail(entityKey: string): Promise'line_items', '[]'::jsonb) AS line_items, sp.order_total AS net_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 JOIN entity_orders o ON o.entity_id = e.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_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`, [entityKey] );