From 1b34cd65d8350bd58143fc48623384073cc9ee8f Mon Sep 17 00:00:00 2001 From: siddharthd Date: Wed, 12 Aug 2026 13:28:06 +1000 Subject: [PATCH] orders: restore the design the prototype had, and stop repeating the row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilding the prototype in React quietly dropped most of what made it read well. Side by side, the live page had lost the year strip entirely — which was both the hero and the context for the date range — along with the masthead totals, the A$ prefix on amounts, and the Fraunces wordmark. The trust marker rendered as a tofu box. Restored, with the strip clickable so a bar scopes to that year. Also three content fixes, all visible the moment anyone looked at a real page. The title and the manifest were printing the same text twice on 1,964 of 4,649 itemised rows — 42% — because on a single-item order the order title IS the line item. The title now earns its line only when it says something the items do not. Food line items carry the full customisation: one Subway order runs to 450 characters naming every topping, and it swamped everything around it. Rows show a short form, cut at the first bracket where there is one, since "Footlong (Italian Herb…" is the product and the bracket is the customisation. Rows now expand, following the disclosure pattern the transactions page already uses, and the query fetches twelve items rather than three so opening one costs no round trip. Merchant names get a presentational tidy — corporate suffixes dropped, a lower-cased domain capitalised. This never merges two merchants: the estate holds amazon.com.au, Amazon.in and Amazon Services Australia as three distinct entities, and Amazon.in must stay separate because it is a different marketplace rather than a name variant. Actually unifying them is the merchant-alias bridge. --- src/app/orders/page.tsx | 239 +++++++++++++++++++++++++++++++++++----- src/lib/order-feed.ts | 34 +++++- 2 files changed, 242 insertions(+), 31 deletions(-) diff --git a/src/app/orders/page.tsx b/src/app/orders/page.tsx index 9733e05..dbddff5 100644 --- a/src/app/orders/page.tsx +++ b/src/app/orders/page.tsx @@ -24,7 +24,7 @@ const LANES = [ ] as const; // Twenty-one years is the archive, not the working set. Default to this year. -type RangeKey = "m0" | "m1" | "m3" | "y0" | "y1" | "all"; +type RangeKey = "m0" | "m1" | "m3" | "y0" | "y1" | "all" | string; const RANGES: { id: RangeKey; label: string }[] = [ { id: "m0", label: "This month" }, { id: "m1", label: "Last month" }, @@ -49,6 +49,13 @@ function rangeFor(key: RangeKey): { from?: string; to?: string; label: string } case "y0": return { from: `${y}-01-01`, to: iso(now), label: String(y) }; case "y1": return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31`, label: String(y - 1) }; case "all": return { label: "2006–present" }; + default: { + // A bar click scopes to that single year. + const yr = Number(key); + return Number.isFinite(yr) + ? { from: `${yr}-01-01`, to: `${yr}-12-31`, label: String(yr) } + : { label: "2006–present" }; + } } } @@ -68,6 +75,31 @@ const KIND_LABEL: Record = { account_notice: "notice", }; +/** + * Presentational tidy only — this NEVER merges two merchants. The estate holds + * "amazon.com.au", "Amazon.in" and "Amazon Services Australia, Inc." as three + * distinct entities, and Amazon.in must stay separate: it is a different + * marketplace, not a name variant. Actually unifying them is the merchant-alias + * bridge (ticket 176). All this does is stop the same entity from looking + * scruffy: drop the corporate suffix, and capitalise a name that arrived + * lower-cased from a domain. + */ +const CORP_SUFFIX = + /,?\s+(pty\.?\s+ltd\.?|pty\.?\s+limited|p\/l|ltd\.?|limited|inc\.?|llc|pbc|gmbh|b\.?v\.?|s\.?a\.?r\.?l\.?|oü|co\.?)$/i; + +function tidyMerchant(name: string): string { + let n = name.replace(/\s+/g, " ").trim(); + // Strip at most two trailing corporate suffixes ("Pty Ltd." then ","). + for (let i = 0; i < 2; i++) { + const stripped = n.replace(CORP_SUFFIX, "").trim().replace(/,$/, ""); + if (stripped === n || stripped.length < 3) break; + n = stripped; + } + // "amazon.com.au" reads as a machine artefact; "Amazon.com.au" reads as a name. + if (/^[a-z]/.test(n)) n = n[0].toUpperCase() + n.slice(1); + return n; +} + const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]); const REVERSED = new Set(["refunded", "returned", "cancelled"]); @@ -79,9 +111,12 @@ function fmtMoney(amount: string | null, currency: string) { const n = Number(amount); if (!Number.isFinite(n)) return null; try { + // narrowSymbol so AUD reads "A$" rather than a bare "$" — the spine holds + // 20 currencies and an unqualified dollar sign is ambiguous across them. return new Intl.NumberFormat("en-AU", { - style: "currency", currency, minimumFractionDigits: 2, - }).format(n); + style: "currency", currency, currencyDisplay: "narrowSymbol", + minimumFractionDigits: 2, + }).format(n).replace(/^\$/, "A$"); } catch { // 20 currencies live in the spine, including a literal '$', XLM and MANA. // Intl throws on those; show the number and the raw code rather than @@ -106,8 +141,25 @@ function StatusPill({ status }: { status: string }) { ); } +/** + * Food line items carry the full customisation — one Subway order runs to 450 + * characters listing every topping — and a raw dump swamps the row. Truncate + * per item; the detail page shows them whole. + */ +const ITEM_MAX = 58; +function shorten(d: string) { + const clean = d.replace(/\s+/g, " ").trim(); + if (clean.length <= ITEM_MAX) return clean; + // Cut at the first bracket if there is one — "Footlong (Italian Herb…" is + // the product; everything inside the bracket is the customisation. + const brk = clean.indexOf("("); + if (brk > 12 && brk <= ITEM_MAX) return clean.slice(0, brk).trim() + "…"; + return clean.slice(0, ITEM_MAX).trimEnd() + "…"; +} + function Manifest({ row }: { row: OrderRow }) { - const items = row.item_preview ?? []; + const all = row.item_preview ?? []; + const items = all.slice(0, 3); if (!items.length) { return No itemised list on this receipt; } @@ -115,9 +167,9 @@ function Manifest({ row }: { row: OrderRow }) { return ( {items.map((d, i) => ( - + {i > 0 && ·} - {d} + {shorten(d)} ))} {extra > 0 && +{extra}} @@ -125,7 +177,63 @@ function Manifest({ row }: { row: OrderRow }) { ); } -function Row({ row }: { row: OrderRow }) { +/** + * The title earns its own line only when it says something the manifest does + * not. On 1,964 of 4,649 itemised rows (42%) the order title IS the single + * line item — printing both rendered the same text twice. + */ +function showTitle(row: OrderRow): boolean { + const t = row.canonical_name?.replace(/\s+/g, " ").trim(); + if (!t) return false; + if (t === row.display_name) return false; + const first = (row.item_preview ?? [])[0]; + if (!first) return true; + const norm = (x: string) => x.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 28); + return norm(t) !== norm(first); +} + +function YearStrip({ + years, activeFrom, activeTo, onPick, +}: { + years: { year: number; n: number }[]; + activeFrom?: string; + activeTo?: string; + onPick: (y: number) => void; +}) { + if (!years.length) return
; + const max = Math.max(...years.map((y) => y.n)); + const lo = activeFrom ? Number(activeFrom.slice(0, 4)) : -Infinity; + const hi = activeTo ? Number(activeTo.slice(0, 4)) : Infinity; + return ( +
+ {years.map(({ year, n }) => { + const on = year >= lo && year <= hi; + return ( + + ); + })} +
+ ); +} + +function Row({ row, expanded, onToggle }: { row: OrderRow; expanded: boolean; onToggle: () => void }) { const reversed = REVERSED.has(row.status); const gross = Number(row.order_total ?? NaN); const refund = Number(row.refunded_amount ?? NaN); @@ -149,11 +257,22 @@ function Row({ row }: { row: OrderRow }) {
+ {/* Disclosure only where there is something behind it. Food items run + to 450 characters of customisation, so the row shows a short form + and the expansion carries the whole receipt. */} + {row.line_item_count > 0 && ( + + )} - {row.display_name} + {tidyMerchant(row.display_name)} {row.content_class && KIND_LABEL[row.content_class] && ( @@ -175,7 +294,10 @@ function Row({ row }: { row: OrderRow }) { >no ref )} {row.source_trust === "untrusted_external" && ( - + unverified )} {row.txn_count > 0 && ( @@ -183,10 +305,26 @@ function Row({ row }: { row: OrderRow }) { )}
- {row.canonical_name && row.canonical_name !== row.display_name && ( + {showTitle(row) && (
{row.canonical_name}
)} - + {!expanded && } + {expanded && ( +
    + {(row.item_preview ?? []).map((d, i) => ( +
  • + {d} +
  • + ))} + {row.line_item_count > (row.item_preview ?? []).length && ( +
  • + + {row.line_item_count - (row.item_preview ?? []).length} more — open the order + +
  • + )} +
+ )} @@ -223,6 +361,7 @@ function OrdersContent() { const [platform, setPlatform] = useState(""); const [status, setStatus] = useState(""); const [offset, setOffset] = useState(0); + const [expanded, setExpanded] = useState>(new Set()); const range = useMemo(() => rangeFor(rangeKey), [rangeKey]); const limit = 50; @@ -249,26 +388,56 @@ function OrdersContent() { return (
-

Orders

+

+ Orders. +

- {data ? <>{data.total.toLocaleString()} orders in {range.label} : "—"} + {data ? ( + <> + {data.total.toLocaleString()} orders in{" "} + {range.label} + {" · "}{data.facets.all_time.toLocaleString()} all time + {data.facets.all_time > 0 && ( + <>{" · "} + {Math.round((100 * data.facets.with_amount) / data.facets.all_time)}% + with a known amount + )} + + ) : "—"}
- {/* Date range. The archive reaches 2006; nobody browses two decades. */} -
- {RANGES.map((r) => ( - - ))} + {/* THE HERO. Twenty-one years of buying, and the date filter, are the + same object: the strip shows where the active range sits in the whole + run, and a bar is how you reach 2016. A spend total would be the + template answer here and would also be a lie — 16% of orders have no + amount. */} +
+
+ + Ordered — {range.label} + +
+ {RANGES.map((r) => ( + + ))} +
+
+ set(setRangeKey)(String(y) as RangeKey)} + />
@@ -361,7 +530,21 @@ function OrdersContent() { No orders in {range.label} match these filters. Widen the date range or clear the search. )} - {data?.data.map((r) => )} + {data?.data.map((r) => ( + + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(r.entity_key)) next.delete(r.entity_key); + else next.add(r.entity_key); + return next; + }) + } + /> + ))}
diff --git a/src/lib/order-feed.ts b/src/lib/order-feed.ts index 45dcc85..ed379e2 100644 --- a/src/lib/order-feed.ts +++ b/src/lib/order-feed.ts @@ -229,7 +229,10 @@ export async function getOrderFeed(filters: OrderFilters) { CASE WHEN jsonb_typeof(f.details->'line_items') = 'array' THEN f.details->'line_items' ELSE '[]'::jsonb END) WITH ORDINALITY t(li, n) - WHERE t.n <= 3) AS item_preview + -- 12, not 3: the row shows the first few and the expansion shows + -- the rest without a second round trip. Beyond 12 the detail page + -- is the right surface. + WHERE t.n <= 12) AS item_preview ${FROM_CLAUSE} ${where} -- NULLS LAST always: eta_date is NULL on ~65% of retail and ~95% of food, @@ -244,6 +247,14 @@ export async function getOrderFeed(filters: OrderFilters) { } export interface OrderFacets { + /** Orders per calendar year across the WHOLE feed — deliberately not scoped + * to the active date range, because the strip's job is to show where the + * current range sits in the twenty-one years available. */ + years: { year: number; n: number }[]; + /** Corpus totals for the masthead, so a filtered count is never mistaken + * for the whole archive. */ + all_time: number; + with_amount: number; lanes: { lane: string; n: number }[]; platforms: { platform: string; n: number }[]; statuses: { status: string; n: number }[]; @@ -264,7 +275,19 @@ export async function getOrderFacets(filters: OrderFilters): Promise( + `SELECT extract(year from f.ordered_at)::int AS year, count(*)::int AS n + ${FROM_CLAUSE} ${span.where}${span.where ? " AND" : " WHERE"} f.ordered_at IS NOT NULL + GROUP BY 1 ORDER BY 1`, span.params), + queryRaw<{ all_time: number; with_amount: number }>( + `SELECT count(*)::int AS all_time, + count(*) FILTER (WHERE f.order_total IS NOT NULL AND f.order_total > 0)::int AS with_amount + ${FROM_CLAUSE} ${span.where}`, span.params), queryRaw<{ lane: string; n: number }>( `SELECT f.lane, count(*)::int AS n ${FROM_CLAUSE} ${where} GROUP BY 1 ORDER BY 2 DESC`, params), queryRaw<{ platform: string; n: number }>( @@ -277,7 +300,12 @@ export async function getOrderFacets(filters: OrderFilters): Promise