feat(orders): record what we thought of an order, per person

The ledger already knew we had ordered from a place; it did not know the
food was bad. Orders got repeated from places we disliked because nobody
remembered by the time the next one went in. That is what the receipt
ingestion was for (ING-9) and the last piece was missing: order_reviews
existed as a table with no API, no UI and no writes.

Four levels, not three. "Loved" and "liked" are both "would order again"
but only one is worth a detour, and "ok" is not a recommendation.

A verdict belongs to a person, not to an order. A shared meal produces
two opinions and they routinely disagree — that disagreement is the
useful part, and the old UNIQUE on transaction_id alone could not hold
it. Now UNIQUE (transaction_id, participant_id), and the default is the
signed-in user rather than the owner: Sonu authenticates through the same
Traefik OAuth as participant 4, so an owner default would have filed her
verdict under his name.

Per-item opinions key on the item DESCRIPTION, not its index. An index is
meaningless across orders; "the Pad Thai here is good" is the signal that
has to survive into the next order from the same merchant. Only the two
poles are offered — a per-item "ok" answers neither of the questions you
ask at order time.

Sharing is recorded as a real 50/50 split, not a decorative flag. The
split already IS the record that an order was shared, and two records of
one fact drift apart.

An ABSENT item_verdicts means "leave them alone"; an empty array clears
them. Without that distinction a note-only save silently wipes every
per-item opinion — the same shape as the bug that reset `settled` on
split rewrites, and just as invisible on screen. Mutation-tested: making
keepItems a no-op fails exactly one test.

mockDbWithPool gained queryRow. Omitting an export from the mock makes it
undefined at the call site, which fails as "not a function" and reads
like a code bug rather than a test-harness gap.
This commit is contained in:
2026-07-28 15:31:22 +10:00
parent d081d80a3f
commit 0cb46a087b
9 changed files with 1123 additions and 8 deletions
+360 -6
View File
@@ -1,6 +1,17 @@
"use client";
import { useOrderReceipt, type OrderReceipt } from "@/lib/hooks";
import { useMemo, useState } from "react";
import {
useOrderReceipt,
useOrderReview,
useParticipants,
useSetOrderReview,
useSetSplits,
type ItemOpinion,
type ItemVerdict,
type OrderReceipt,
type OrderRating,
} from "@/lib/hooks";
const PLATFORM_LABEL: Record<string, string> = {
doordash: "DoorDash",
@@ -9,11 +20,40 @@ const PLATFORM_LABEL: Record<string, string> = {
};
/**
* The receipt behind a delivery order: what was actually bought, and where it
* went. All of it was already stored at ingest and none of it was reachable —
* the row showed a merchant and a total and nothing else.
* Who records verdicts. A two-person household with one primary user: the
* owner records almost everything, and the only other consumer is Sonu (user,
* 2026-07-28). Mirrors OWNER_PARTICIPANT_ID / SECOND_CONSUMER_ID in
* `lib/order-reviews.ts` — duplicated rather than imported because that module
* pulls in the database client and this is a client component.
*/
const OWNER_PARTICIPANT_ID = 1;
const SECOND_CONSUMER_ID = 4;
const RATING_LABEL: Record<OrderRating, string> = {
loved: "Loved it",
liked: "Liked it",
ok: "OK",
never: "Never again",
};
const RATING_STYLE: Record<OrderRating, string> = {
loved: "border-emerald-600 bg-emerald-950 text-emerald-300",
liked: "border-emerald-800 bg-emerald-950/50 text-emerald-400",
ok: "border-zinc-600 bg-zinc-800 text-zinc-300",
never: "border-red-800 bg-red-950 text-red-300",
};
const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "never"];
/**
* The receipt behind a delivery order: what was actually bought, where it went,
* and what we thought of it.
*
* Read-only on purpose. This is what a provider sent, not something to edit.
* The receipt half is read-only — it is what a provider sent, not something to
* edit. The verdict half is the only part of an order that changes, and it is
* the reason the receipts are ingested at all (ING-9): the ledger already knew
* we had ordered from here, but not that it was bad, so orders got repeated
* from places we disliked because nobody remembered.
*/
export function OrderDetails({
transactionId,
@@ -26,6 +66,9 @@ export function OrderDetails({
bare?: boolean;
}) {
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
const { data: review } = useOrderReview(transactionId);
const [reviewer, setReviewer] = useState(OWNER_PARTICIPANT_ID);
if (isLoading || !receipt) return null;
const cur = receipt.currency ?? currency ?? "AUD";
@@ -33,6 +76,8 @@ export function OrderDetails({
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
const route: OrderReceipt["route"] = receipt.route ?? [];
const mine = review?.reviews.find((r) => r.participant_id === reviewer);
return (
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
<div className="flex items-baseline justify-between mb-2">
@@ -50,7 +95,7 @@ export function OrderDetails({
{items.length > 0 ? (
<ul className="space-y-1.5 mb-3">
{items.map((it, i) => (
<li key={i} className="flex gap-2 text-xs">
<li key={i} className="flex gap-2 text-xs items-start">
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty}×</span>
<span className="text-zinc-300 flex-1 min-w-0">
{it.description}
@@ -58,6 +103,14 @@ export function OrderDetails({
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
)}
</span>
<ItemVerdictToggle
transactionId={transactionId}
reviewer={reviewer}
item={it.description}
current={mine?.item_verdicts ?? []}
rating={mine?.rating ?? null}
note={mine?.note ?? null}
/>
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
</li>
))}
@@ -90,6 +143,307 @@ export function OrderDetails({
{receipt.order_reference}
</p>
)}
<OrderVerdict
transactionId={transactionId}
reviewer={reviewer}
onReviewerChange={setReviewer}
/>
</div>
);
}
/**
* Loved / never on a single line item, for the currently selected reviewer.
*
* Only the two poles are offered. A per-item "OK" is noise: the question at the
* next order is "what should I get, what should I avoid", and a middling dish
* answers neither.
*
* Every press sends the whole item array plus the current rating and note,
* because the endpoint upserts a row rather than patching fields — sending a
* partial would blank whatever it omitted.
*/
function ItemVerdictToggle({
transactionId,
reviewer,
item,
current,
rating,
note,
}: {
transactionId: number;
reviewer: number;
item: string;
current: ItemOpinion[];
rating: OrderRating | null;
note: string | null;
}) {
const save = useSetOrderReview();
const existing = current.find(
(v) => v.item.trim().toLowerCase() === item.trim().toLowerCase()
);
const toggle = (verdict: ItemVerdict) => {
const rest = current.filter(
(v) => v.item.trim().toLowerCase() !== item.trim().toLowerCase()
);
// Pressing the active verdict clears it — a mis-tap must be reversible, and
// there is no other route back to "no opinion on this dish".
const next =
existing?.verdict === verdict ? rest : [...rest, { item, verdict }];
save.mutate({
transactionId,
participantId: reviewer,
rating,
note,
itemVerdicts: next,
});
};
return (
<span className="flex gap-0.5 shrink-0">
{(["loved", "never"] as ItemVerdict[]).map((v) => (
<button
key={v}
type="button"
onClick={() => toggle(v)}
disabled={save.isPending}
title={v === "loved" ? "Loved this item" : "Never order this again"}
className={`rounded px-1 leading-none transition-opacity disabled:opacity-40 ${
existing?.verdict === v
? "opacity-100"
: "opacity-25 hover:opacity-60"
}`}
>
{v === "loved" ? "👍" : "👎"}
</button>
))}
</span>
);
}
/**
* Was this order shared? One tap, and the split is the answer.
*
* "Shared" means shared in both senses — we both ate it and we both pay for it
* — so this writes a real 50/50 `transaction_splits` row rather than a
* decorative flag (user, 2026-07-28: the split was part of the original
* requirement). There is no separate "shared" column precisely because the
* split already IS that record, and two records of one fact drift apart.
*
* Unsharing clears the splits. That is safe on an order because an ingested
* order is post-cutover by construction — the DB CHECK forbids credits orders
* before 2026-01-09 — so no settled historical obligation can be sitting on it
* to lose.
*/
function SharedToggle({
transactionId,
splits,
otherName,
}: {
transactionId: number;
splits: { participant_id: number; share_percent: string }[];
otherName: string;
}) {
const setSplits = useSetSplits();
const shared = splits.some((s) => s.participant_id === SECOND_CONSUMER_ID);
return (
<div className="mb-2 flex items-center gap-2">
<button
type="button"
disabled={setSplits.isPending}
onClick={() =>
setSplits.mutate({
transactionId,
splits: shared
? []
: [{ participant_id: SECOND_CONSUMER_ID, share_percent: 50 }],
})
}
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
shared
? "border-sky-700 bg-sky-950 text-sky-300"
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
}`}
>
{shared ? `Shared 50/50 with ${otherName}` : "Just me"}
</button>
{shared && (
<span className="text-[11px] text-zinc-600">
ask {otherName} for her verdict too
</span>
)}
</div>
);
}
/**
* The overall verdict, whose it is, and this merchant's track record.
*
* The history sits above the buttons deliberately: it is read before the next
* order, not after, and burying it under the form is how you re-order from a
* place you already rejected.
*/
function OrderVerdict({
transactionId,
reviewer,
onReviewerChange,
}: {
transactionId: number;
reviewer: number;
onReviewerChange: (id: number) => void;
}) {
const { data, isLoading } = useOrderReview(transactionId);
const { data: participants } = useParticipants();
const save = useSetOrderReview();
const [noteDraft, setNoteDraft] = useState<string | null>(null);
const reviewers = useMemo(
() =>
[OWNER_PARTICIPANT_ID, SECOND_CONSUMER_ID].map((id) => ({
id,
name:
id === OWNER_PARTICIPANT_ID
? "Me"
: participants?.find((p) => p.id === id)?.name ?? "Them",
})),
[participants]
);
// No merchant means no receipt behind this row — nothing to have a view on.
if (isLoading || !data?.merchant) return null;
const mine = data.reviews.find((r) => r.participant_id === reviewer);
const current = mine?.rating ?? null;
const noteValue = noteDraft ?? mine?.note ?? "";
const { history, warn, items } = data.merchant;
const others = data.reviews.filter((r) => r.participant_id !== reviewer && r.rating);
const set = (rating: OrderRating) =>
save.mutate({
transactionId,
participantId: reviewer,
// Pressing the active rating clears it — otherwise a mis-tap is
// permanent, and there is no other way back to "no opinion".
rating: rating === current ? null : rating,
note: noteValue.trim() || null,
});
return (
<div className="mt-4 border-t border-zinc-800 pt-3">
{warn && (
<p className="mb-2 text-xs text-red-400">
Marked &ldquo;never again&rdquo; here before.
</p>
)}
{items.length > 0 && (
<p className="mb-2 text-[11px] text-zinc-500">
{items
.filter((i) => i.loved > i.never)
.slice(0, 3)
.map((i) => `👍 ${i.item}`)
.concat(
items
.filter((i) => i.never > 0)
.slice(0, 3)
.map((i) => `👎 ${i.item}`)
)
.join(" · ")}
</p>
)}
<SharedToggle
transactionId={transactionId}
splits={data.splits}
otherName={reviewers[1].name}
/>
<div className="flex items-center gap-2 mb-2">
{reviewers.map((r) => (
<button
key={r.id}
type="button"
onClick={() => {
setNoteDraft(null); // the draft belongs to the person who typed it
onReviewerChange(r.id);
}}
className={`text-xs transition-colors ${
reviewer === r.id
? "text-zinc-200 underline underline-offset-4"
: "text-zinc-600 hover:text-zinc-400"
}`}
>
{r.name}
{data.reviews.some((v) => v.participant_id === r.id && v.rating) && " ✓"}
</button>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{RATING_ORDER.map((r) => (
<button
key={r}
type="button"
onClick={() => set(r)}
disabled={save.isPending}
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
current === r
? RATING_STYLE[r]
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
}`}
>
{RATING_LABEL[r]}
</button>
))}
</div>
<input
type="text"
value={noteValue}
placeholder="What was wrong (or right)?"
onChange={(e) => setNoteDraft(e.target.value)}
onBlur={() => {
const next = noteValue.trim() || null;
if (next !== (mine?.note ?? null)) {
save.mutate({
transactionId,
participantId: reviewer,
rating: current,
note: next,
});
}
}}
className="mt-2 w-full rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-300 placeholder:text-zinc-700 focus:border-zinc-600 focus:outline-none"
/>
{others.map((o) => (
<p key={o.participant_id} className="mt-1.5 text-[11px] text-zinc-500">
<span className="text-zinc-400">{o.participant_name}:</span>{" "}
{o.rating && RATING_LABEL[o.rating]}
{o.note && <span className="italic"> {o.note}</span>}
</p>
))}
{history.length > 0 && (
<ul className="mt-2 space-y-1">
{history.map((h) => (
<li
key={`${h.transaction_id}-${h.participant_id}`}
className="text-[11px] text-zinc-600"
>
<span className="tabular-nums">{h.transaction_date}</span>
<span className="ml-1.5 text-zinc-500">
{h.participant_name}
{h.rating ? ` · ${RATING_LABEL[h.rating]}` : ""}
</span>
{h.note && <span className="ml-1.5 italic">{h.note}</span>}
</li>
))}
</ul>
)}
</div>
);
}