orders: restore the design the prototype had, and stop repeating the row
ci / lint-test (push) Successful in 49s
ci / lint-test (push) Successful in 49s
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.
This commit is contained in:
+199
-16
@@ -24,7 +24,7 @@ const LANES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Twenty-one years is the archive, not the working set. Default to this year.
|
// 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 }[] = [
|
const RANGES: { id: RangeKey; label: string }[] = [
|
||||||
{ id: "m0", label: "This month" },
|
{ id: "m0", label: "This month" },
|
||||||
{ id: "m1", label: "Last 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 "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 "y1": return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31`, label: String(y - 1) };
|
||||||
case "all": return { label: "2006–present" };
|
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<string, string> = {
|
|||||||
account_notice: "notice",
|
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 IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]);
|
||||||
const REVERSED = new Set(["refunded", "returned", "cancelled"]);
|
const REVERSED = new Set(["refunded", "returned", "cancelled"]);
|
||||||
|
|
||||||
@@ -79,9 +111,12 @@ function fmtMoney(amount: string | null, currency: string) {
|
|||||||
const n = Number(amount);
|
const n = Number(amount);
|
||||||
if (!Number.isFinite(n)) return null;
|
if (!Number.isFinite(n)) return null;
|
||||||
try {
|
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", {
|
return new Intl.NumberFormat("en-AU", {
|
||||||
style: "currency", currency, minimumFractionDigits: 2,
|
style: "currency", currency, currencyDisplay: "narrowSymbol",
|
||||||
}).format(n);
|
minimumFractionDigits: 2,
|
||||||
|
}).format(n).replace(/^\$/, "A$");
|
||||||
} catch {
|
} catch {
|
||||||
// 20 currencies live in the spine, including a literal '$', XLM and MANA.
|
// 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
|
// 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 }) {
|
function Manifest({ row }: { row: OrderRow }) {
|
||||||
const items = row.item_preview ?? [];
|
const all = row.item_preview ?? [];
|
||||||
|
const items = all.slice(0, 3);
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return <span className="text-xs text-zinc-500 italic">No itemised list on this receipt</span>;
|
return <span className="text-xs text-zinc-500 italic">No itemised list on this receipt</span>;
|
||||||
}
|
}
|
||||||
@@ -115,9 +167,9 @@ function Manifest({ row }: { row: OrderRow }) {
|
|||||||
return (
|
return (
|
||||||
<span className="text-[13px] text-zinc-300 leading-snug">
|
<span className="text-[13px] text-zinc-300 leading-snug">
|
||||||
{items.map((d, i) => (
|
{items.map((d, i) => (
|
||||||
<span key={i}>
|
<span key={i} title={d}>
|
||||||
{i > 0 && <span className="text-indigo-800 mx-1.5">·</span>}
|
{i > 0 && <span className="text-indigo-800 mx-1.5">·</span>}
|
||||||
{d}
|
{shorten(d)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{extra > 0 && <span className="font-mono text-xs text-indigo-500 ml-1.5">+{extra}</span>}
|
{extra > 0 && <span className="font-mono text-xs text-indigo-500 ml-1.5">+{extra}</span>}
|
||||||
@@ -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 <div className="h-[54px]" />;
|
||||||
|
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 (
|
||||||
|
<div className="flex items-end gap-[3px]">
|
||||||
|
{years.map(({ year, n }) => {
|
||||||
|
const on = year >= lo && year <= hi;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
onClick={() => onPick(year)}
|
||||||
|
title={`${year} — ${n.toLocaleString()} orders`}
|
||||||
|
aria-label={`${year}, ${n} orders`}
|
||||||
|
aria-pressed={on}
|
||||||
|
className="flex-1 min-w-0 flex flex-col items-center gap-1.5 group"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ height: `${Math.max(2, Math.round((44 * n) / max))}px` }}
|
||||||
|
className={`w-full rounded-[1px] transition-colors ${
|
||||||
|
on ? "bg-indigo-400" : "bg-indigo-800 group-hover:bg-indigo-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className={`font-mono text-[9.5px] tabular-nums ${on ? "text-indigo-300" : "text-zinc-500 group-hover:text-zinc-300"}`}>
|
||||||
|
{String(year).slice(2)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ row, expanded, onToggle }: { row: OrderRow; expanded: boolean; onToggle: () => void }) {
|
||||||
const reversed = REVERSED.has(row.status);
|
const reversed = REVERSED.has(row.status);
|
||||||
const gross = Number(row.order_total ?? NaN);
|
const gross = Number(row.order_total ?? NaN);
|
||||||
const refund = Number(row.refunded_amount ?? NaN);
|
const refund = Number(row.refunded_amount ?? NaN);
|
||||||
@@ -149,11 +257,22 @@ function Row({ row }: { row: OrderRow }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 align-top">
|
<td className="p-3 align-top">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{/* 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 && (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? "Hide items" : `Show all ${row.line_item_count} items`}
|
||||||
|
className="text-zinc-500 hover:text-indigo-400 font-mono text-[10px] w-3 shrink-0"
|
||||||
|
>{expanded ? "▾" : "▸"}</button>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/orders/${encodeURIComponent(row.entity_key)}`}
|
href={`/orders/${encodeURIComponent(row.entity_key)}`}
|
||||||
className="text-[13.5px] text-zinc-50 hover:text-indigo-300"
|
className="text-[13.5px] text-zinc-50 hover:text-indigo-300"
|
||||||
>
|
>
|
||||||
{row.display_name}
|
{tidyMerchant(row.display_name)}
|
||||||
</Link>
|
</Link>
|
||||||
{row.content_class && KIND_LABEL[row.content_class] && (
|
{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">
|
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-400 border border-zinc-700 rounded-sm px-1.5">
|
||||||
@@ -175,7 +294,10 @@ function Row({ row }: { row: OrderRow }) {
|
|||||||
>no ref</span>
|
>no ref</span>
|
||||||
)}
|
)}
|
||||||
{row.source_trust === "untrusted_external" && (
|
{row.source_trust === "untrusted_external" && (
|
||||||
<span title="Content from an unverified sender" className="text-zinc-500 text-xs">✉</span>
|
<span
|
||||||
|
title="Derived from an unverified sender — content is shown as provenance, not fact"
|
||||||
|
className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-800 rounded-sm px-1.5"
|
||||||
|
>unverified</span>
|
||||||
)}
|
)}
|
||||||
{row.txn_count > 0 && (
|
{row.txn_count > 0 && (
|
||||||
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-700 rounded-sm px-1.5">
|
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-700 rounded-sm px-1.5">
|
||||||
@@ -183,10 +305,26 @@ function Row({ row }: { row: OrderRow }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{row.canonical_name && row.canonical_name !== row.display_name && (
|
{showTitle(row) && (
|
||||||
<div className="text-[12px] text-zinc-400 mb-0.5">{row.canonical_name}</div>
|
<div className="text-[12px] text-zinc-400 mb-0.5">{row.canonical_name}</div>
|
||||||
)}
|
)}
|
||||||
<Manifest row={row} />
|
{!expanded && <Manifest row={row} />}
|
||||||
|
{expanded && (
|
||||||
|
<ul className="list-none m-0 p-0 mt-1">
|
||||||
|
{(row.item_preview ?? []).map((d, i) => (
|
||||||
|
<li key={i} className="text-[12.5px] text-zinc-300 leading-snug py-0.5 pl-3 border-l border-zinc-800">
|
||||||
|
{d}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{row.line_item_count > (row.item_preview ?? []).length && (
|
||||||
|
<li className="text-[11.5px] text-zinc-500 py-0.5 pl-3 border-l border-zinc-800">
|
||||||
|
<Link href={`/orders/${encodeURIComponent(row.entity_key)}`} className="text-indigo-400 hover:text-indigo-300">
|
||||||
|
{row.line_item_count - (row.item_preview ?? []).length} more — open the order
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 align-top"><StatusPill status={row.status} /></td>
|
<td className="p-3 align-top"><StatusPill status={row.status} /></td>
|
||||||
<td className="p-3 align-top font-mono text-[11px] text-zinc-400 tabular-nums whitespace-nowrap">
|
<td className="p-3 align-top font-mono text-[11px] text-zinc-400 tabular-nums whitespace-nowrap">
|
||||||
@@ -223,6 +361,7 @@ function OrdersContent() {
|
|||||||
const [platform, setPlatform] = useState<string>("");
|
const [platform, setPlatform] = useState<string>("");
|
||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const range = useMemo(() => rangeFor(rangeKey), [rangeKey]);
|
const range = useMemo(() => rangeFor(rangeKey), [rangeKey]);
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
@@ -249,14 +388,36 @@ function OrdersContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-[1180px] mx-auto">
|
<div className="max-w-[1180px] mx-auto">
|
||||||
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-1">
|
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-1">
|
||||||
<h2 className="text-2xl font-display text-zinc-50">Orders</h2>
|
<h2 className="text-[30px] font-display text-zinc-50 tracking-tight leading-none">
|
||||||
|
Orders<span className="text-indigo-400">.</span>
|
||||||
|
</h2>
|
||||||
<div className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
|
<div className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
|
||||||
{data ? <><span className="text-zinc-200">{data.total.toLocaleString()}</span> orders in {range.label}</> : "—"}
|
{data ? (
|
||||||
|
<>
|
||||||
|
<span className="text-zinc-200">{data.total.toLocaleString()}</span> orders in{" "}
|
||||||
|
<span className="text-zinc-200">{range.label}</span>
|
||||||
|
{" · "}{data.facets.all_time.toLocaleString()} all time
|
||||||
|
{data.facets.all_time > 0 && (
|
||||||
|
<>{" · "}<span className="text-zinc-200">
|
||||||
|
{Math.round((100 * data.facets.with_amount) / data.facets.all_time)}%
|
||||||
|
</span> with a known amount</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date range. The archive reaches 2006; nobody browses two decades. */}
|
{/* THE HERO. Twenty-one years of buying, and the date filter, are the
|
||||||
<div className="flex gap-1 flex-wrap py-3 border-y border-zinc-800 mb-4">
|
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. */}
|
||||||
|
<div className="border-y border-zinc-800 py-3.5 mb-5">
|
||||||
|
<div className="flex items-baseline justify-between mb-3">
|
||||||
|
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-zinc-500">
|
||||||
|
Ordered — {range.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
{RANGES.map((r) => (
|
{RANGES.map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r.id}
|
key={r.id}
|
||||||
@@ -270,6 +431,14 @@ function OrdersContent() {
|
|||||||
>{r.label}</button>
|
>{r.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<YearStrip
|
||||||
|
years={data?.facets.years ?? []}
|
||||||
|
activeFrom={range.from}
|
||||||
|
activeTo={range.to}
|
||||||
|
onPick={(y) => set(setRangeKey)(String(y) as RangeKey)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-0.5 flex-wrap mb-3" role="tablist" aria-label="Order lanes">
|
<div className="flex gap-0.5 flex-wrap mb-3" role="tablist" aria-label="Order lanes">
|
||||||
{LANES.map((l) => (
|
{LANES.map((l) => (
|
||||||
@@ -361,7 +530,21 @@ function OrdersContent() {
|
|||||||
No orders in {range.label} match these filters. Widen the date range or clear the search.
|
No orders in {range.label} match these filters. Widen the date range or clear the search.
|
||||||
</td></tr>
|
</td></tr>
|
||||||
)}
|
)}
|
||||||
{data?.data.map((r) => <Row key={r.entity_key} row={r} />)}
|
{data?.data.map((r) => (
|
||||||
|
<Row
|
||||||
|
key={r.entity_key}
|
||||||
|
row={r}
|
||||||
|
expanded={expanded.has(r.entity_key)}
|
||||||
|
onToggle={() =>
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+31
-3
@@ -229,7 +229,10 @@ export async function getOrderFeed(filters: OrderFilters) {
|
|||||||
CASE WHEN jsonb_typeof(f.details->'line_items') = 'array'
|
CASE WHEN jsonb_typeof(f.details->'line_items') = 'array'
|
||||||
THEN f.details->'line_items' ELSE '[]'::jsonb END)
|
THEN f.details->'line_items' ELSE '[]'::jsonb END)
|
||||||
WITH ORDINALITY t(li, n)
|
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}
|
${FROM_CLAUSE}
|
||||||
${where}
|
${where}
|
||||||
-- NULLS LAST always: eta_date is NULL on ~65% of retail and ~95% of food,
|
-- 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 {
|
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 }[];
|
lanes: { lane: string; n: number }[];
|
||||||
platforms: { platform: string; n: number }[];
|
platforms: { platform: string; n: number }[];
|
||||||
statuses: { status: string; n: number }[];
|
statuses: { status: string; n: number }[];
|
||||||
@@ -264,7 +275,19 @@ export async function getOrderFacets(filters: OrderFilters): Promise<OrderFacets
|
|||||||
const { where, params } = buildWhere(base);
|
const { where, params } = buildWhere(base);
|
||||||
const laneScoped = buildWhere({ ...base, lane: filters.lane });
|
const laneScoped = buildWhere({ ...base, lane: filters.lane });
|
||||||
|
|
||||||
const [lanes, platforms, statuses, currencies] = await Promise.all([
|
// The year strip and the all-time totals ignore the date range on purpose.
|
||||||
|
const spanFilters: OrderFilters = { hide_lifecycle_only: filters.hide_lifecycle_only };
|
||||||
|
const span = buildWhere(spanFilters);
|
||||||
|
|
||||||
|
const [years, totals, lanes, platforms, statuses, currencies] = await Promise.all([
|
||||||
|
queryRaw<{ year: number; n: number }>(
|
||||||
|
`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 }>(
|
queryRaw<{ lane: string; n: number }>(
|
||||||
`SELECT f.lane, count(*)::int AS n ${FROM_CLAUSE} ${where} GROUP BY 1 ORDER BY 2 DESC`, params),
|
`SELECT f.lane, count(*)::int AS n ${FROM_CLAUSE} ${where} GROUP BY 1 ORDER BY 2 DESC`, params),
|
||||||
queryRaw<{ platform: string; n: number }>(
|
queryRaw<{ platform: string; n: number }>(
|
||||||
@@ -277,7 +300,12 @@ export async function getOrderFacets(filters: OrderFilters): Promise<OrderFacets
|
|||||||
`SELECT f.currency, count(*)::int AS n ${FROM_CLAUSE} ${laneScoped.where}
|
`SELECT f.currency, count(*)::int AS n ${FROM_CLAUSE} ${laneScoped.where}
|
||||||
GROUP BY 1 ORDER BY 2 DESC`, laneScoped.params),
|
GROUP BY 1 ORDER BY 2 DESC`, laneScoped.params),
|
||||||
]);
|
]);
|
||||||
return { lanes, platforms, statuses, currencies };
|
return {
|
||||||
|
years,
|
||||||
|
all_time: totals[0]?.all_time ?? 0,
|
||||||
|
with_amount: totals[0]?.with_amount ?? 0,
|
||||||
|
lanes, platforms, statuses, currencies,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderLifecycleEvent {
|
export interface OrderLifecycleEvent {
|
||||||
|
|||||||
Reference in New Issue
Block a user