Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 129 additions & 2 deletions shared/src/ranking.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { applyComparison, applyFoodComparison, kFactorFor, rankDiningHalls, rankDishes, rankFoods } from "./ranking.ts";
import {
applyComparison,
applyFoodComparison,
distinctLoggedDishes,
kFactorFor,
pickPostLogComparisonPair,
rankDiningHalls,
rankDishes,
rankFoods,
} from "./ranking.ts";
import { DINING_HALLS } from "./umassDining.ts";
import type { RankedDish, RankedFood } from "./types.ts";
import type { LogEntry, RankedDish, RankedFood } from "./types.ts";

function entry(overrides: Partial<LogEntry>): LogEntry {
return {
id: "1",
loggedAt: "2026-08-17T12:00:00.000Z",
source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 },
servings: 1,
nutrition: {
servingSize: "1 serving",
calories: 100,
caloriesFromFat: 10,
totalFatG: 5,
satFatG: 1,
transFatG: 0,
cholesterolMg: 0,
sodiumMg: 0,
totalCarbG: 10,
dietaryFiberG: 1,
sugarsG: 1,
proteinG: 20,
},
...overrides,
};
}

test("applyComparison creates both dishes at default rating 1500 on their first comparison, then updates them", () => {
const result = applyComparison([], { dishName: "Black Beans", hallTid: 3 }, { dishName: "Fried Plantain", hallTid: 3 });
Expand Down Expand Up @@ -165,3 +198,97 @@ test("rankFoods sorts highest rating first", () => {
["High", "Mid", "Low"],
);
});

// --- #67: post-log comparison prompt pair-selection --------------------------------------------

test("distinctLoggedDishes de-duplicates repeated logs of the same dish+hall, preserving first-seen order", () => {
const entries = [
entry({ id: "1", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } }),
entry({ id: "2", source: { type: "umass-menu", dishName: "Beans", hallTid: 1 } }),
entry({ id: "3", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } }), // repeat
];
assert.deepEqual(distinctLoggedDishes(entries), [
{ dishName: "Chicken", hallTid: 1 },
{ dishName: "Beans", hallTid: 1 },
]);
});

test("distinctLoggedDishes treats the same dish name at different halls as distinct entries", () => {
const entries = [
entry({ id: "1", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } }),
entry({ id: "2", source: { type: "umass-menu", dishName: "Chicken", hallTid: 2 } }),
];
assert.deepEqual(distinctLoggedDishes(entries), [
{ dishName: "Chicken", hallTid: 1 },
{ dishName: "Chicken", hallTid: 2 },
]);
});

test("distinctLoggedDishes ignores non-umass-menu (e.g. barcode-logged) entries", () => {
const entries = [entry({ id: "1", source: { type: "off", barcode: "012345", productName: "Granola Bar" } })];
assert.deepEqual(distinctLoggedDishes(entries), []);
});

test("pickPostLogComparisonPair returns null when justLogged is the only distinct dish logged so far", () => {
const entries = [entry({ id: "1", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } })];
const pair = pickPostLogComparisonPair(entries, { dishName: "Chicken", hallTid: 1 }, []);
assert.equal(pair, null);
});

test("pickPostLogComparisonPair returns null when there are no logged dishes at all", () => {
const pair = pickPostLogComparisonPair([], { dishName: "Chicken", hallTid: 1 }, []);
assert.equal(pair, null);
});

test("pickPostLogComparisonPair pairs justLogged with the only other distinct logged dish", () => {
const entries = [
entry({ id: "1", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } }),
entry({ id: "2", source: { type: "umass-menu", dishName: "Beans", hallTid: 1 } }),
];
const pair = pickPostLogComparisonPair(entries, { dishName: "Beans", hallTid: 1 }, []);
assert.deepEqual(pair, [
{ dishName: "Beans", hallTid: 1 },
{ dishName: "Chicken", hallTid: 1 },
]);
});

test("pickPostLogComparisonPair never offers justLogged as its own opponent, even if it was logged repeatedly", () => {
const entries = [
entry({ id: "1", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } }),
entry({ id: "2", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } }),
];
const pair = pickPostLogComparisonPair(entries, { dishName: "Chicken", hallTid: 1 }, []);
assert.equal(pair, null);
});

test("pickPostLogComparisonPair prefers the least-compared candidate — same signal /rank's own picker uses", () => {
const entries = [
entry({ id: "1", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } }),
entry({ id: "2", source: { type: "umass-menu", dishName: "Beans", hallTid: 1 } }),
entry({ id: "3", source: { type: "umass-menu", dishName: "Rice", hallTid: 1 } }),
];
const rankedDishes: RankedDish[] = [
{ dishName: "Beans", hallTid: 1, rating: 1500, comparisonCount: 5 },
{ dishName: "Rice", hallTid: 1, rating: 1500, comparisonCount: 1 }, // least-compared candidate
];
const pair = pickPostLogComparisonPair(entries, { dishName: "Chicken", hallTid: 1 }, rankedDishes);
assert.deepEqual(pair, [
{ dishName: "Chicken", hallTid: 1 },
{ dishName: "Rice", hallTid: 1 },
]);
});

test("pickPostLogComparisonPair treats a never-compared candidate (comparisonCount 0, absent from rankedDishes) as least-compared", () => {
const entries = [
entry({ id: "1", source: { type: "umass-menu", dishName: "Chicken", hallTid: 1 } }),
entry({ id: "2", source: { type: "umass-menu", dishName: "Beans", hallTid: 1 } }),
entry({ id: "3", source: { type: "umass-menu", dishName: "Rice", hallTid: 1 } }),
];
// Beans has been compared before; Rice has never been rated at all (absent from rankedDishes).
const rankedDishes: RankedDish[] = [{ dishName: "Beans", hallTid: 1, rating: 1500, comparisonCount: 3 }];
const pair = pickPostLogComparisonPair(entries, { dishName: "Chicken", hallTid: 1 }, rankedDishes);
assert.deepEqual(pair, [
{ dishName: "Chicken", hallTid: 1 },
{ dishName: "Rice", hallTid: 1 },
]);
});
63 changes: 62 additions & 1 deletion shared/src/ranking.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DINING_HALLS } from "./umassDining.ts";
import type { RankedDish, RankedFood } from "./types.ts";
import type { LogEntry, RankedDish, RankedFood } from "./types.ts";

const DEFAULT_RATING = 1500;

Expand Down Expand Up @@ -139,3 +139,64 @@ export function rankDiningHalls(dishes: RankedDish[]): { ranked: DiningHallRank[

return { ranked, unranked };
}

// --- #67: rank-informed surfaces (post-log comparison prompt) ----------------------------------

/** A logged dish's identity — same (dishName, hallTid) pairing RankedDish uses, without the rating. */
export interface LoggedDish {
dishName: string;
hallTid: number;
}

function loggedDishKey(d: LoggedDish): string {
return dishKey(d.dishName, d.hallTid);
}

/**
* Extracts the distinct umass-menu dishes a user has logged, in first-seen order — the same
* de-duplication web/src/routes/rank/+page.svelte's `refresh()` already does inline (barcode/`off`
* entries excluded, repeats of the same dish+hall collapsed to one), factored out here so
* pickPostLogComparisonPair below doesn't re-derive it differently.
*/
export function distinctLoggedDishes(entries: LogEntry[]): LoggedDish[] {
const seen = new Set<string>();
const dishes: LoggedDish[] = [];
for (const entry of entries) {
if (entry.source.type !== "umass-menu") continue;
const dish = { dishName: entry.source.dishName, hallTid: entry.source.hallTid };
const key = loggedDishKey(dish);
if (seen.has(key)) continue;
seen.add(key);
dishes.push(dish);
}
return dishes;
}

/**
* Decides whether to offer a one-tap comparison prompt right after logging `justLogged`, and if so,
* which other previously-logged dish to pair it against (#67's "when to offer, which pair" shared
* logic). Reuses applyComparison's dish identity (dishName+hallTid) and RankedDish's
* comparisonCount — the same signal /rank's own pair-picker (`pickLeastCompared` in
* web/src/routes/rank/+page.svelte) prefers under-compared dishes with, instead of inventing a
* parallel notion of "pairable."
*
* Returns null when there's no valid opponent: `justLogged` is the only distinct dish logged so far.
* Deterministic (always the least-compared opponent) rather than randomized like /rank's sampler —
* a post-log prompt fires once per log, so there's no continuously-visible "same pair every time"
* staleness to guard against the way /rank's picker has to.
*/
export function pickPostLogComparisonPair(
entries: LogEntry[],
justLogged: LoggedDish,
rankedDishes: RankedDish[],
): [LoggedDish, LoggedDish] | null {
const justLoggedKey = loggedDishKey(justLogged);
const candidates = distinctLoggedDishes(entries).filter((d) => loggedDishKey(d) !== justLoggedKey);
if (candidates.length === 0) return null;

const comparisonCountFor = (d: LoggedDish): number =>
rankedDishes.find((r) => loggedDishKey(r) === loggedDishKey(d))?.comparisonCount ?? 0;

const opponent = candidates.reduce((least, d) => (comparisonCountFor(d) < comparisonCountFor(least) ? d : least));
return [justLogged, opponent];
}
Loading