feat(orders): a fifth verdict, 'bad', between ok and never again
ci / lint-test (push) Successful in 47s
ci / lint-test (push) Successful in 47s
The jump from "OK" to "Never again" is too big and most disappointments live in the gap (user, 2026-07-28) — so a merely poor meal either flattered itself as OK or got blacklisted. Only 'never' raises the warning on a future order. A blacklist that fires for every mediocre delivery is one nobody reads, so 'bad' records the disappointment without triggering the alarm. Both set order_again = false — you would not choose either again — and that split between "would I order it" and "warn me about it" is the point of the extra level. Migration widens the CHECK; nothing is removed, so no existing row needs mapping.
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
-- A fifth verdict: "bad", between "ok" and "never".
|
||||||
|
--
|
||||||
|
-- "OK" to "Never again" is a big jump and most disappointments live in the gap
|
||||||
|
-- (user, 2026-07-28). Without it, a merely poor meal either flatters itself as
|
||||||
|
-- OK or gets blacklisted, and the blacklist is the signal that has to stay
|
||||||
|
-- sharp — `warn` remains exclusive to 'never' so it is not diluted by every
|
||||||
|
-- mediocre delivery.
|
||||||
|
--
|
||||||
|
-- Safe as a straight widening: no existing row uses a value being removed,
|
||||||
|
-- because nothing is being removed.
|
||||||
|
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
|
||||||
|
|
||||||
|
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
|
||||||
|
CHECK (rating IS NULL OR rating IN ('loved', 'liked', 'ok', 'bad', 'never'));
|
||||||
@@ -92,6 +92,25 @@ describe("order verdicts — one per person", () => {
|
|||||||
expect(body.reviews[0].order_again).toBe(false);
|
expect(body.reviews[0].order_again).toBe(false);
|
||||||
body = await (await PUT(req({ rating: "ok" }), params(txnId))).json();
|
body = await (await PUT(req({ rating: "ok" }), params(txnId))).json();
|
||||||
expect(body.reviews[0].order_again).toBe(true);
|
expect(body.reviews[0].order_again).toBe(true);
|
||||||
|
// "bad" is also a no — you would not choose it again.
|
||||||
|
body = await (await PUT(req({ rating: "bad" }), params(txnId))).json();
|
||||||
|
expect(body.reviews[0].order_again).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only `never` raises the warning, not `bad`", async () => {
|
||||||
|
// The boundary the five-level scale exists for. A blacklist that fires for
|
||||||
|
// every mediocre meal is one nobody reads, so "bad" records the
|
||||||
|
// disappointment without triggering the alarm.
|
||||||
|
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||||
|
await PUT(req({ participant_id: ownerId, rating: "bad" }), params(older));
|
||||||
|
|
||||||
|
let body = await (await GET(req(), params(txnId))).json();
|
||||||
|
expect(body.merchant.counts.bad).toBe(1);
|
||||||
|
expect(body.merchant.warn).toBe(false);
|
||||||
|
|
||||||
|
await PUT(req({ participant_id: ownerId, rating: "never" }), params(older));
|
||||||
|
body = await (await GET(req(), params(txnId))).json();
|
||||||
|
expect(body.merchant.warn).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -341,7 +341,8 @@ async function setRating(transactionId: number, participantId: number, rating: R
|
|||||||
SET rating = EXCLUDED.rating,
|
SET rating = EXCLUDED.rating,
|
||||||
order_again = EXCLUDED.order_again,
|
order_again = EXCLUDED.order_again,
|
||||||
updated_at = now()`,
|
updated_at = now()`,
|
||||||
[transactionId, participantId, rating, rating !== "never"]
|
// "bad" and "never" both mean no; only "never" warns on a future order.
|
||||||
|
[transactionId, participantId, rating, rating !== "never" && rating !== "bad"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,10 +155,12 @@ export async function PUT(
|
|||||||
)
|
)
|
||||||
.map((v) => ({ item: v.item.trim(), verdict: v.verdict }));
|
.map((v) => ({ item: v.item.trim(), verdict: v.verdict }));
|
||||||
|
|
||||||
// `order_again` is derived when the caller does not say. "never" is the only
|
// `order_again` is derived when the caller does not say. "bad" and "never"
|
||||||
// rating that answers the question on its own; "ok" is not a refusal.
|
// both answer no — you would not choose either again — but only "never"
|
||||||
|
// raises the warning on a future order, so the blacklist stays sharp.
|
||||||
const orderAgain =
|
const orderAgain =
|
||||||
body.order_again ?? (rating === null ? null : rating !== "never");
|
body.order_again ??
|
||||||
|
(rating === null ? null : rating !== "never" && rating !== "bad");
|
||||||
|
|
||||||
await queryRow(
|
await queryRow(
|
||||||
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again, note, item_verdicts)
|
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again, note, item_verdicts)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const RATING_LABEL: Record<OrderRating, string> = {
|
|||||||
loved: "Loved it",
|
loved: "Loved it",
|
||||||
liked: "Liked it",
|
liked: "Liked it",
|
||||||
ok: "OK",
|
ok: "OK",
|
||||||
|
bad: "Bad",
|
||||||
never: "Never again",
|
never: "Never again",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,10 +41,11 @@ const RATING_STYLE: Record<OrderRating, string> = {
|
|||||||
loved: "border-emerald-600 bg-emerald-950 text-emerald-300",
|
loved: "border-emerald-600 bg-emerald-950 text-emerald-300",
|
||||||
liked: "border-emerald-800 bg-emerald-950/50 text-emerald-400",
|
liked: "border-emerald-800 bg-emerald-950/50 text-emerald-400",
|
||||||
ok: "border-zinc-600 bg-zinc-800 text-zinc-300",
|
ok: "border-zinc-600 bg-zinc-800 text-zinc-300",
|
||||||
|
bad: "border-amber-800 bg-amber-950 text-amber-300",
|
||||||
never: "border-red-800 bg-red-950 text-red-300",
|
never: "border-red-800 bg-red-950 text-red-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "never"];
|
const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "bad", "never"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The receipt behind a delivery order: what was actually bought, where it went,
|
* The receipt behind a delivery order: what was actually bought, where it went,
|
||||||
|
|||||||
+1
-1
@@ -277,7 +277,7 @@ export function useOrderReceipt(transactionId: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrderRating = "loved" | "liked" | "ok" | "never";
|
export type OrderRating = "loved" | "liked" | "ok" | "bad" | "never";
|
||||||
export type ItemVerdict = "loved" | "never";
|
export type ItemVerdict = "loved" | "never";
|
||||||
|
|
||||||
export interface ItemOpinion {
|
export interface ItemOpinion {
|
||||||
|
|||||||
@@ -26,14 +26,19 @@ import { queryRaw, queryRow } from "@/lib/db";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Four levels, because three collapsed the distinction that decides a
|
* Five levels. Three collapsed the distinction that decides a re-order —
|
||||||
* re-order: "loved" and "liked" are both "would order again", but only one is
|
* "loved" and "liked" are both "would order again" but only one is worth going
|
||||||
* worth going out of your way for, and "ok" is not a recommendation at all
|
* out of your way for, and "ok" is not a recommendation at all. "bad" was added
|
||||||
* (user, 2026-07-28).
|
* because the jump from "ok" to "never again" is too big and most
|
||||||
|
* disappointments live in the gap (user, 2026-07-28).
|
||||||
|
*
|
||||||
|
* Only "never" raises the warning on a future order. A blacklist that fires for
|
||||||
|
* every mediocre meal stops being read, so "bad" records the disappointment
|
||||||
|
* without triggering the alarm.
|
||||||
*/
|
*/
|
||||||
export type Rating = "loved" | "liked" | "ok" | "never";
|
export type Rating = "loved" | "liked" | "ok" | "bad" | "never";
|
||||||
|
|
||||||
export const RATINGS: Rating[] = ["loved", "liked", "ok", "never"];
|
export const RATINGS: Rating[] = ["loved", "liked", "ok", "bad", "never"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-item opinions, keyed by the line item's description.
|
* Per-item opinions, keyed by the line item's description.
|
||||||
@@ -85,7 +90,11 @@ export interface MerchantVerdict {
|
|||||||
transaction_date: string | null;
|
transaction_date: string | null;
|
||||||
}[];
|
}[];
|
||||||
counts: Record<Rating, number>;
|
counts: Record<Rating, number>;
|
||||||
/** True when this merchant has ever been marked `never`. */
|
/**
|
||||||
|
* True when this merchant has ever been marked `never` — and ONLY `never`.
|
||||||
|
* "bad" is deliberately excluded: the warning is a blacklist, and one that
|
||||||
|
* fires for every mediocre meal is one nobody reads.
|
||||||
|
*/
|
||||||
warn: boolean;
|
warn: boolean;
|
||||||
/**
|
/**
|
||||||
* What to get and what to avoid here, pooled across every order from this
|
* What to get and what to avoid here, pooled across every order from this
|
||||||
@@ -163,7 +172,7 @@ export async function merchantVerdict(
|
|||||||
[merchant, exclude]
|
[merchant, exclude]
|
||||||
);
|
);
|
||||||
|
|
||||||
const counts: Record<Rating, number> = { loved: 0, liked: 0, ok: 0, never: 0 };
|
const counts: Record<Rating, number> = { loved: 0, liked: 0, ok: 0, bad: 0, never: 0 };
|
||||||
for (const r of rows) if (r.rating) counts[r.rating] += 1;
|
for (const r of rows) if (r.rating) counts[r.rating] += 1;
|
||||||
|
|
||||||
// Pool item opinions across orders. Case-folded because the same dish comes
|
// Pool item opinions across orders. Case-folded because the same dish comes
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const RATING_LABEL: Record<Rating, string> = {
|
|||||||
loved: "Loved it",
|
loved: "Loved it",
|
||||||
liked: "Liked it",
|
liked: "Liked it",
|
||||||
ok: "OK",
|
ok: "OK",
|
||||||
|
bad: "Bad",
|
||||||
never: "Never again",
|
never: "Never again",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user