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] );