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:
@@ -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<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",
|
||||
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))}</>}
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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)) : "—"}
|
||||
</span>
|
||||
<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 className="font-mono text-[11.5px] text-zinc-500 tabular-nums">
|
||||
{fmtMoney(e.amount, e.currency ?? o.currency) ?? "—"}
|
||||
|
||||
+34
-1
@@ -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<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 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}
|
||||
</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" && (
|
||||
<span
|
||||
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>
|
||||
)}
|
||||
</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} />
|
||||
</td>
|
||||
<td className="p-3 align-top"><StatusPill status={row.status} /></td>
|
||||
|
||||
+34
-2
@@ -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<OrderDetail | n
|
||||
COALESCE(o.details->'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]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user