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