untappd-mcp 2.3.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ import { z } from 'zod';
2
+ import { minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { FAVORITE_KINDS } from '../cache/store.js';
4
+ import { DEFAULT_FAVORITE_THRESHOLDS, FAVORITE_SOURCES, listFavorites } from '../cache/favorites.js';
5
+ import { UsernameArg, freshness, resolveUser, venueFreshness } from './cache.js';
6
+ const D = DEFAULT_FAVORITE_THRESHOLDS;
7
+ /** Every derived-favorite threshold as an optional parameter (defaults = the spec). */
8
+ export const FavoriteThresholdsArg = z
9
+ .object({
10
+ venue_min_checkins: z.number().int().min(1).optional().describe(`Venue: at least this many check-ins (default ${D.venue_min_checkins})`),
11
+ venue_min_months: z.number().int().min(1).optional().describe(`…or visits spanning at least this many distinct months (default ${D.venue_min_months})`),
12
+ brewery_min_beers: z.number().int().min(1).optional().describe(`Brewery: at least this many beers had… (default ${D.brewery_min_beers})`),
13
+ brewery_min_avg_rating: z.number().min(0).max(5).optional().describe(`…with at least this average rating (default ${D.brewery_min_avg_rating})`),
14
+ brewery_min_beers_any_rating: z.number().int().min(1).optional().describe(`…or at least this many beers regardless of rating (default ${D.brewery_min_beers_any_rating})`),
15
+ beer_min_rating: z.number().min(0).max(5).optional().describe(`Beer: rated at least this (default ${D.beer_min_rating})`),
16
+ beer_min_times_had: z.number().int().min(1).optional().describe(`…or had at least this many times (default ${D.beer_min_times_had})`),
17
+ })
18
+ .optional()
19
+ .describe('Override the derived-favorite thresholds (each optional; omitted ones keep their defaults).');
20
+ const KindArg = z.enum(FAVORITE_KINDS).describe('What the id is: a beer (bid), brewery (brewery_id) or venue (venue_id)');
21
+ const IdArg = z.number().int().positive().describe('The Untappd bid / brewery_id / venue_id');
22
+ const LOCAL_ONLY = 'Local to the cache — NEVER writes to Untappd (Untappd has no favorites API for this).';
23
+ /** untappd_favorite_add / _remove / untappd_favorites_list — the local favorites table plus derived favorites. */
24
+ export function registerFavoriteTools(server, client, cacheProvider) {
25
+ server.registerTool('untappd_favorite_add', {
26
+ title: 'Mark a beer, brewery or venue as a favorite (local)',
27
+ description: 'Add a MANUAL favorite — a beer, brewery or venue — with an optional note, to the local cache. ' +
28
+ `${LOCAL_ONLY} Re-adding an existing favorite updates its note (omit note to keep it) and keeps its ` +
29
+ 'created_at. Manual favorites show in untappd_favorites_list (source "manual") and as the `favorite` field on ' +
30
+ 'has_had, has_had_many, has_visited, brewery_beers, venue_menu, top_not_had and trip_check results; a favorite brewery also boosts its ' +
31
+ 'beers in untappd_top_not_had. Omit username for your own account.',
32
+ annotations: toolAnnotations({ title: 'Mark a beer, brewery or venue as a favorite (local)', readOnly: false, idempotent: true, openWorld: false, destructive: false }),
33
+ inputSchema: z.object({
34
+ username: UsernameArg,
35
+ kind: KindArg,
36
+ id: IdArg,
37
+ note: z.string().max(500).optional().describe('Why it is a favorite (shown as the reason)'),
38
+ }),
39
+ }, async ({ username, kind, id, note }) => {
40
+ const user = resolveUser(username, client.loginName);
41
+ const cache = cacheProvider();
42
+ const r = await cache.addFavorite(user, kind, id, note, new Date().toISOString());
43
+ const [row] = await cache.getManualFavorites(user, { kind, ids: [id] });
44
+ return minifiedResult({ ...r, username: user, kind, id, name: row?.name ?? null, local_only: true });
45
+ });
46
+ server.registerTool('untappd_favorite_remove', {
47
+ title: 'Remove a manual favorite (local)',
48
+ description: `Remove a MANUAL favorite (beer, brewery or venue) from the local cache. ${LOCAL_ONLY} Returns removed:false ` +
49
+ 'when there was none. A DERIVED favorite (from the user\'s history) cannot be removed — it is computed from ' +
50
+ 'the thresholds; change them in untappd_favorites_list instead. Omit username for your own account.',
51
+ annotations: toolAnnotations({ title: 'Remove a manual favorite (local)', readOnly: false, idempotent: true, openWorld: false, destructive: false }),
52
+ inputSchema: z.object({ username: UsernameArg, kind: KindArg, id: IdArg }),
53
+ }, async ({ username, kind, id }) => {
54
+ const user = resolveUser(username, client.loginName);
55
+ const removed = await cacheProvider().removeFavorite(user, kind, id);
56
+ return minifiedResult({ removed, username: user, kind, id, local_only: true });
57
+ });
58
+ server.registerTool('untappd_favorites_list', {
59
+ title: "List a user's favorite beers, breweries and venues",
60
+ description: "List a user's favorites from the cache only — NO API call. Each item appears ONCE with every source that " +
61
+ 'names it (sources: "derived" = qualifies from their cached history, "manual" = added with ' +
62
+ 'untappd_favorite_add, "untappd" = a brewery the user likes/follows on Untappd, mirrored by ' +
63
+ 'untappd_sync_brewery_likes — breweries only: Untappd has no followed/favorite venue list) and a reason ' +
64
+ '("liked on Untappd (since 2021-03)", "12 check-ins since 2015", "avg 4.1 across 9 beers", "manual: <note>"). ' +
65
+ 'Derived rules, each ' +
66
+ `overridable via thresholds: venue = ≥${D.venue_min_checkins} check-ins OR visits spanning ≥${D.venue_min_months} distinct ` +
67
+ `months (ranked by check-ins); brewery = ≥${D.brewery_min_beers} beers had AND avg rating ≥${D.brewery_min_avg_rating}, OR ` +
68
+ `≥${D.brewery_min_beers_any_rating} beers (ranked by count blended with rating); beer = rated ≥${D.beer_min_rating} OR had ` +
69
+ `≥${D.beer_min_times_had} times. Derived favorites are only as complete as the syncs behind them ` +
70
+ '(untappd_sync_user_venues, untappd_sync_user_beers); "untappd" ones as the last untappd_sync_brewery_likes ' +
71
+ '(freshness.brewery_likes). Filter by kind and source. Omit username for your own account.',
72
+ annotations: toolAnnotations({ title: "List a user's favorite beers, breweries and venues", readOnly: true, idempotent: true, openWorld: false }),
73
+ inputSchema: z.object({
74
+ username: UsernameArg,
75
+ kind: z.enum(FAVORITE_KINDS).optional().describe('Only this kind (default all three)'),
76
+ source: z.enum(FAVORITE_SOURCES).optional().describe('Only favorites carrying this source'),
77
+ thresholds: FavoriteThresholdsArg,
78
+ limit: z.number().int().min(1).max(500).optional().describe('Most items per kind (default 50)'),
79
+ }),
80
+ }, async ({ username, kind, source, thresholds, limit }) => {
81
+ const user = resolveUser(username, client.loginName);
82
+ const cache = cacheProvider();
83
+ const all = await listFavorites(cache, user, { kind, source, thresholds: thresholds });
84
+ const likes = await cache.breweryLikesCoverage(user);
85
+ const max = limit ?? 50;
86
+ const counts = {};
87
+ const favorites = all.filter((f) => {
88
+ counts[f.kind] = (counts[f.kind] ?? 0) + 1;
89
+ return counts[f.kind] <= max;
90
+ });
91
+ return minifiedResult({
92
+ username: user,
93
+ counts,
94
+ favorites,
95
+ thresholds: { ...DEFAULT_FAVORITE_THRESHOLDS, ...Object.fromEntries(Object.entries(thresholds ?? {}).filter(([, v]) => v !== undefined)) },
96
+ freshness: {
97
+ beers: await freshness(cache, user),
98
+ venues: await venueFreshness(cache, user),
99
+ brewery_likes: likes,
100
+ ...((source === 'untappd' || source === undefined) && (kind === undefined || kind === 'brewery')
101
+ ? {
102
+ untappd_source: likes?.complete
103
+ ? 'Untappd brewery likes are mirrored; "untappd" covers breweries only (Untappd has no favorite venue list).'
104
+ : 'Untappd brewery likes are not (fully) synced — run untappd_sync_brewery_likes; until then "untappd" favorites may be missing.',
105
+ }
106
+ : {}),
107
+ },
108
+ });
109
+ });
110
+ }
@@ -0,0 +1,104 @@
1
+ import { z } from 'zod';
2
+ import { createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { menuPick, venuePick } from '../menu/pick.js';
4
+ import { freshness, quotaFields, resolveUser, UsernameArg } from './cache.js';
5
+ import { clampToQuota } from '../quota.js';
6
+ import { parseUntappdUrl } from './resolve.js';
7
+ const StyleFilter = z
8
+ .string()
9
+ .min(1)
10
+ .optional()
11
+ .describe('Case-insensitive substring filter on the beer style or parent style for the picks (e.g. "ipa", "stout").');
12
+ const TopN = z.number().int().min(1).max(10).optional().describe('How many picks to return (default 2, max 10).');
13
+ const ApiBudget = z
14
+ .number()
15
+ .int()
16
+ .min(1)
17
+ .max(100)
18
+ .optional()
19
+ .describe('Max Untappd API calls this run, shared by the cache sync, searches and rating lookups (default 60; Untappd allows ~100/hour). ' +
20
+ 'If it runs out, the result says another_run_needed and a re-run continues from what was cached.');
21
+ /** Parse a venue argument: a bare id, a numeric string, or an untappd.com/v/<slug>/<id> URL. */
22
+ export function parseVenueArg(v) {
23
+ if (typeof v === 'number')
24
+ return v;
25
+ const t = v.trim();
26
+ if (/^\d+$/.test(t))
27
+ return Number(t);
28
+ const r = parseUntappdUrl(t);
29
+ if (r?.type === 'venue' && r.id)
30
+ return r.id;
31
+ throw createHelpfulError(`"${v}" is not an Untappd venue id or venue URL.`, {
32
+ hint: 'Pass the numeric venue id, or a URL like https://untappd.com/v/<slug>/<venue_id>. untappd_search_venue finds ids by name.',
33
+ });
34
+ }
35
+ export function registerMenuTools(server, client, cacheProvider, deps = {}) {
36
+ server.registerTool('untappd_menu_pick', {
37
+ title: "Pick beers off a tap list that a user hasn't had",
38
+ description: 'One call from a tap list to "order this": give the raw menu lines as read from a photo and get back the ' +
39
+ 'top-rated beers the user has NOT had. It (1) refreshes the user\'s distinct-beers cache — an incremental ' +
40
+ 'refresh when the cache is complete, otherwise it backfills automatically within api_budget, no separate ' +
41
+ 'sync needed; (2) resolves each line to an Untappd beer (prices/punctuation stripped, scored on brewery + ' +
42
+ 'name match and check-in volume, venue_city as tie-breaker; resolutions are cached locally so repeat venues ' +
43
+ 'are instant); (3) treats "<Brewery> SEASONAL/ROTATING" as a list of possible beers (needs_confirmation) ' +
44
+ 'and "ROTATING CIDER"-style lines as unresolved ("ask bartender"); (4) checks every beer against the cache; ' +
45
+ '(5) ranks the not-had ones by global rating. Returns picks, not_had, had, possible, unresolved, a sync ' +
46
+ 'block (what the refresh did) and freshness (coverage); if coverage is still incomplete a "not had" may be ' +
47
+ 'a false negative and another_run_needed is true. api_budget is also capped to the quota Untappd reports, ' +
48
+ 'when it reports one. Omit username for your own account.',
49
+ annotations: toolAnnotations({ title: "Pick beers off a tap list that a user hasn't had", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
50
+ inputSchema: z.object({
51
+ username: UsernameArg,
52
+ items: z
53
+ .array(z.string().min(1).max(200))
54
+ .min(1)
55
+ .max(60)
56
+ .describe('Raw menu lines as read from the menu, e.g. ["JACKALOPE FENNARIO IPA $7", "BLACK ABBEY SEASONAL", "ROTATING CIDER"].'),
57
+ style_filter: StyleFilter,
58
+ top_n: TopN,
59
+ venue_city: z.string().min(1).optional().describe('City of the venue (e.g. "Nashville") — breaks ties between same-named beers toward local breweries.'),
60
+ api_budget: ApiBudget,
61
+ refresh_resolutions: z.boolean().optional().describe('Ignore cached line→beer matches and re-search every line (default false).'),
62
+ }),
63
+ }, async ({ username, items, style_filter, top_n, venue_city, api_budget, refresh_resolutions }) => {
64
+ const user = resolveUser(username, client.loginName);
65
+ const cache = cacheProvider();
66
+ const quota = clampToQuota(client, api_budget ?? 60);
67
+ const out = await menuPick(client, cache, {
68
+ username: user,
69
+ items,
70
+ styleFilter: style_filter,
71
+ topN: top_n ?? 2,
72
+ apiBudget: quota.budget,
73
+ venueCity: venue_city,
74
+ refreshResolutions: refresh_resolutions,
75
+ }, deps);
76
+ return minifiedResult({ username: user, ...out, ...quotaFields(quota), freshness: await freshness(cache, user) });
77
+ });
78
+ server.registerTool('untappd_venue_pick', {
79
+ title: "Pick beers off a venue's Untappd menu that a user hasn't had",
80
+ description: 'Like untappd_menu_pick, but takes an Untappd venue (URL or id) instead of menu lines: it pulls the venue\'s ' +
81
+ 'FULL verified menu (walking every section page until complete, within api_budget), refreshes the user\'s ' +
82
+ 'distinct-beers cache (incremental, or an automatic backfill), checks every menu beer against it and ranks ' +
83
+ 'the not-had ones by global rating. Returns the same picks / not_had / had / sync shape plus a venue block ' +
84
+ '(menu_complete is false only if the budget ran out mid-menu). A venue with no verified menu comes back ' +
85
+ 'unresolved — use untappd_menu_pick with the tap list instead. Omit username for your own account.',
86
+ annotations: toolAnnotations({ title: "Pick beers off a venue's Untappd menu that a user hasn't had", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
87
+ inputSchema: z.object({
88
+ venue: z
89
+ .union([z.string().min(1), z.number().int().positive()])
90
+ .describe('Untappd venue URL (https://untappd.com/v/<slug>/<id>) or numeric venue id.'),
91
+ username: UsernameArg,
92
+ style_filter: StyleFilter,
93
+ top_n: TopN,
94
+ api_budget: ApiBudget,
95
+ }),
96
+ }, async ({ venue, username, style_filter, top_n, api_budget }) => {
97
+ const venueId = parseVenueArg(venue);
98
+ const user = resolveUser(username, client.loginName);
99
+ const cache = cacheProvider();
100
+ const quota = clampToQuota(client, api_budget ?? 60);
101
+ const out = await venuePick(client, cache, { username: user, venueId, styleFilter: style_filter, topN: top_n ?? 2, apiBudget: quota.budget }, deps);
102
+ return minifiedResult({ username: user, ...out, ...quotaFields(quota), freshness: await freshness(cache, user) });
103
+ });
104
+ }
@@ -0,0 +1,384 @@
1
+ import { z } from 'zod';
2
+ import { RateLimitError, createHelpfulError, messageOf, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { DEFAULT_VISIT_RADIUS_M, haversineM } from '../cache/store.js';
4
+ import { BREWERY_BEERS_TTL_MS, getBreweryBeers } from '../cache/brewery-beers.js';
5
+ import { favoriteLookup, listFavorites } from '../cache/favorites.js';
6
+ import { DEFAULT_MULTI_USER_MODE, MAX_GROUP_USERS, MULTI_USER_MODES, multiHasHad, resolveUsers, } from '../cache/multi-user.js';
7
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
8
+ import { FAVORITE_BREWERY_BOOST, UsernameArg, WISHLIST_BOOST, freshness, groupFreshness, venueFreshness, } from './cache.js';
9
+ /** Default radius of the "near a favorite" hint around a stop. */
10
+ export const NEAR_FAVORITE_RADIUS_M = 2000;
11
+ /** Default number of not-had beers listed per brewery stop. */
12
+ export const TRIP_TOP_N = 4;
13
+ /** Default brewery/beer_list calls one trip_check may spend on missing or stale lists. */
14
+ export const TRIP_DEFAULT_API_BUDGET = 5;
15
+ /** One brewery/beer_list call asks for this many beers (top of the popularity order). */
16
+ const TRIP_LIST_LIMIT = 50;
17
+ const MAX_NEAR_FAVORITES = 5;
18
+ const PlaceArg = z.object({
19
+ label: z.string().max(200).optional().describe('Your own label for this stop, echoed back'),
20
+ venue_id: z.number().int().positive().optional().describe('Untappd venue id of the stop'),
21
+ name: z
22
+ .string()
23
+ .min(1)
24
+ .max(200)
25
+ .optional()
26
+ .describe('Venue name (case-insensitive substring or every significant word); defaults to the brewery name for a brewery_id stop'),
27
+ city: z.string().min(1).max(100).optional().describe('Narrow a name match to venues whose city contains this'),
28
+ state: z.string().min(1).max(100).optional().describe('Narrow a name match to venues whose state contains this (e.g. "FL")'),
29
+ lat: z.number().min(-90).max(90).optional().describe('Latitude of the stop; needs lng'),
30
+ lng: z.number().min(-180).max(180).optional().describe('Longitude of the stop; needs lat'),
31
+ radius_m: z.number().positive().max(50_000).optional().describe(`Visit-match radius around lat/lng in metres (default ${DEFAULT_VISIT_RADIUS_M})`),
32
+ brewery_id: z
33
+ .number()
34
+ .int()
35
+ .positive()
36
+ .optional()
37
+ .describe("Untappd brewery id: adds beers-had and the top not-had beers from the brewery's (cached) beer list"),
38
+ });
39
+ const ratingOf = (r) => r.rating_score ?? 0;
40
+ const minIso = (xs) => xs.filter((x) => !!x).sort()[0] ?? null;
41
+ const maxIso = (xs) => xs.filter((x) => !!x).sort().at(-1) ?? null;
42
+ /** untappd_trip_check — a pre-trip sweep over several stops, cache first. */
43
+ export function registerTripTools(server, client, cacheProvider) {
44
+ server.registerTool('untappd_trip_check', {
45
+ title: 'Check a list of trip stops against a user’s history (cache)',
46
+ description: 'Plan a brewery/bar crawl in ONE call: for each place (venue_id, name + city/state, lat/lng, and/or brewery_id) ' +
47
+ 'return whether the user has been there (dates, total visits, matched venues incl. "(duplicate of …)" copies — ' +
48
+ 'from the cached venue history, run untappd_sync_user_venues first), and for a brewery_id: how many of its ' +
49
+ `beers they have had and the top ${TRIP_TOP_N} (top_n) NOT-had beers by Untappd global rating, optionally filtered by ` +
50
+ 'style (out-of-production beers skipped unless include_out_of_production). Has-had and visits come from the cache ' +
51
+ 'ONLY; the only API calls are brewery/beer_list fetches for a brewery whose list is not cached or is past its ' +
52
+ `${BREWERY_BEERS_TTL_MS / 86_400_000}-day TTL, at most api_budget (default ${TRIP_DEFAULT_API_BUDGET}; 0 = strictly cache-only, ` +
53
+ 'a stale list is then used and flagged) under the ~100 calls/hour guard — a missing list sets another_run_needed. ' +
54
+ 'Beer lists are global facts: Untappd\'s have_had on that endpoint is the AUTHENTICATED account\'s and is never ' +
55
+ 'used. Favorites (breweries liked on Untappd via untappd_sync_brewery_likes, derived from the cached history, or ' +
56
+ 'added with untappd_favorite_add) are flagged on matched ' +
57
+ `venues, the brewery and beers; prefer_favorites (default true) lifts wishlisted beers (+${WISHLIST_BOOST}) and a ` +
58
+ `favorite brewery's beers (+${FAVORITE_BREWERY_BOOST}); and a stop within near_favorite_radius_m (default ` +
59
+ `${NEAR_FAVORITE_RADIUS_M} m) of a favorite venue gets near_favorites + a "Near a favorite" hint. For a GROUP pass ` +
60
+ 'usernames + mode ("none_had" default / "any_not_had"). Returns has-had and venue freshness blocks.',
61
+ annotations: toolAnnotations({
62
+ title: 'Check a list of trip stops against a user’s history (cache)',
63
+ readOnly: true,
64
+ idempotent: true,
65
+ openWorld: true,
66
+ }),
67
+ inputSchema: z.object({
68
+ username: UsernameArg,
69
+ usernames: z
70
+ .array(z.string().min(1))
71
+ .min(1)
72
+ .max(MAX_GROUP_USERS)
73
+ .optional()
74
+ .describe(`Check a GROUP (1–${MAX_GROUP_USERS}) instead of one user; use instead of username`),
75
+ mode: z
76
+ .enum(MULTI_USER_MODES)
77
+ .optional()
78
+ .describe('With usernames: "none_had" (default) lists beers new to everyone; "any_not_had" beers someone still needs'),
79
+ places: z.array(PlaceArg).min(1).max(25).describe('The stops (1–25); each needs venue_id, name, lat+lng, or brewery_id'),
80
+ top_n: z.number().int().min(1).max(10).optional().describe(`Not-had beers listed per brewery stop (default ${TRIP_TOP_N})`),
81
+ style: z.string().min(1).optional().describe('Case-insensitive substring filter on the beer style (e.g. "ipa")'),
82
+ prefer_favorites: z
83
+ .boolean()
84
+ .optional()
85
+ .describe(`Boost wishlisted beers (+${WISHLIST_BOOST}) and favorite-brewery beers (+${FAVORITE_BREWERY_BOOST}) (default true)`),
86
+ near_favorite_radius_m: z
87
+ .number()
88
+ .positive()
89
+ .max(50_000)
90
+ .optional()
91
+ .describe(`Radius for the "near a favorite" hint in metres (default ${NEAR_FAVORITE_RADIUS_M})`),
92
+ include_out_of_production: z.boolean().optional().describe('Also rank beers Untappd marks out of production (default false)'),
93
+ max_matches: z.number().int().min(1).max(20).optional().describe('Most matched venues listed per user per stop (default 5)'),
94
+ api_budget: z
95
+ .number()
96
+ .int()
97
+ .min(0)
98
+ .max(25)
99
+ .optional()
100
+ .describe(`Max brewery/beer_list calls for missing or stale lists (default ${TRIP_DEFAULT_API_BUDGET}; 0 = cache only)`),
101
+ }),
102
+ }, async (args) => {
103
+ const { username, usernames, mode, places, top_n, style, prefer_favorites, near_favorite_radius_m, include_out_of_production, max_matches, api_budget } = args;
104
+ places.forEach((p, i) => {
105
+ const hasGeo = p.lat !== undefined || p.lng !== undefined;
106
+ if (hasGeo && (p.lat === undefined || p.lng === undefined)) {
107
+ throw createHelpfulError(`Place ${i + 1}: lat and lng must be given together.`, { hint: 'Pass both, or match by name / venue_id instead.' });
108
+ }
109
+ if (p.venue_id === undefined && p.name === undefined && !hasGeo && p.brewery_id === undefined) {
110
+ throw createHelpfulError(`Place ${i + 1} has nothing to match on.`, {
111
+ hint: 'Give venue_id, a name (optionally with city/state), lat + lng, or brewery_id.',
112
+ });
113
+ }
114
+ });
115
+ const group = usernames !== undefined && usernames.length > 0;
116
+ const users = resolveUsers(username, usernames, client.loginName);
117
+ const groupMode = mode ?? DEFAULT_MULTI_USER_MODE;
118
+ const cache = cacheProvider();
119
+ const topN = top_n ?? TRIP_TOP_N;
120
+ const preferFavorites = prefer_favorites ?? true;
121
+ const nearRadius = near_favorite_radius_m ?? NEAR_FAVORITE_RADIUS_M;
122
+ const maxMatches = max_matches ?? 5;
123
+ const budget = api_budget ?? TRIP_DEFAULT_API_BUDGET;
124
+ const now = Date.now();
125
+ // 1. Brewery lists: cached when fresh; a missing/stale one is fetched within the budget.
126
+ const lists = new Map();
127
+ let apiCalls = 0;
128
+ let rateStop;
129
+ for (const id of [...new Set(places.map((p) => p.brewery_id).filter((x) => x !== undefined))]) {
130
+ const cached = await cache.getBreweryBeerList(id);
131
+ const stale = cached !== undefined && now - Date.parse(cached.fetched_at) >= BREWERY_BEERS_TTL_MS;
132
+ const info = { list: cached, source: cached ? 'cache' : 'missing', stale };
133
+ lists.set(id, info);
134
+ if ((cached && !stale) || apiCalls >= budget || rateStop)
135
+ continue;
136
+ const verdict = await checkRateBudget(client, cache);
137
+ if (!verdict.ok) {
138
+ rateStop = stopFromVerdict(verdict);
139
+ continue;
140
+ }
141
+ apiCalls++;
142
+ try {
143
+ await getBreweryBeers(client, cache, id, { limit: TRIP_LIST_LIMIT, refresh: stale });
144
+ lists.set(id, { list: await cache.getBreweryBeerList(id), source: 'api', stale: false });
145
+ }
146
+ catch (e) {
147
+ if (e instanceof RateLimitError)
148
+ rateStop = stopFrom429(e);
149
+ else
150
+ info.error = messageOf(e);
151
+ }
152
+ }
153
+ // 2. Visits per place and user (cache only).
154
+ const breweryNames = new Map();
155
+ for (const id of lists.keys()) {
156
+ let name = lists.get(id).list?.brewery_name ?? null;
157
+ for (const u of users) {
158
+ if (name)
159
+ break;
160
+ name = (await cache.breweryHadStats(u, [id]))[0]?.brewery_name ?? null;
161
+ }
162
+ breweryNames.set(id, name);
163
+ }
164
+ const visits = [];
165
+ for (const p of places) {
166
+ const q = {
167
+ ...(p.venue_id !== undefined ? { venue_ids: [p.venue_id] } : {}),
168
+ name: p.name ?? (p.venue_id === undefined && p.lat === undefined && p.brewery_id !== undefined ? (breweryNames.get(p.brewery_id) ?? undefined) : undefined),
169
+ city: p.city,
170
+ state: p.state,
171
+ lat: p.lat,
172
+ lng: p.lng,
173
+ radius_m: p.radius_m,
174
+ };
175
+ if (!q.venue_ids && q.name === undefined && q.lat === undefined) {
176
+ visits.push(null); // brewery stop whose name is unknown: cannot match
177
+ continue;
178
+ }
179
+ const perUser = [];
180
+ for (const u of users)
181
+ perUser.push({ username: u, matches: await cache.findVisitedVenues(u, q) });
182
+ visits.push(perUser);
183
+ }
184
+ // 3. Favorite venues with known coordinates, for the near-a-favorite hint.
185
+ const favVenueIds = new Set();
186
+ for (const u of users)
187
+ for (const f of await listFavorites(cache, u, { kind: 'venue' }))
188
+ favVenueIds.add(f.id);
189
+ const favVenues = (await cache.getVenues([...favVenueIds])).filter((v) => v.lat !== null && v.lng !== null);
190
+ const placeVenues = await cache.getVenues(places.map((p) => p.venue_id).filter((x) => x !== undefined));
191
+ const placeVenueById = new Map(placeVenues.map((v) => [v.venue_id, v]));
192
+ // 4. Not-had candidates per brewery (cache only).
193
+ const style_ = style?.toLowerCase();
194
+ const candidates = new Map();
195
+ for (const [id, info] of lists) {
196
+ const rows = (info.list?.beers ?? []).filter((r) => (include_out_of_production || r.in_production !== false) && (style_ === undefined || (r.style?.toLowerCase().includes(style_) ?? false)));
197
+ candidates.set(id, rows);
198
+ }
199
+ const allBids = [...new Set([...candidates.values()].flat().map((r) => r.bid))];
200
+ const verdicts = await multiHasHad(cache, users, allBids, groupMode);
201
+ const wishlisted = new Set();
202
+ for (const u of users)
203
+ for (const w of await cache.getWishlist(u, allBids))
204
+ wishlisted.add(w.bid);
205
+ const favs = await favoriteLookup(cache, users, {
206
+ venue: [...visits.flatMap((v) => (v ?? []).flatMap((x) => x.matches.map((m) => m.venue_id))), ...favVenueIds, ...placeVenueById.keys()],
207
+ brewery: [...lists.keys()],
208
+ beer: allBids,
209
+ });
210
+ // 5. Assemble.
211
+ let visitedCount = 0;
212
+ let missingLists = 0;
213
+ let staleLists = 0;
214
+ const out = places.map((p, i) => {
215
+ const perUser = visits[i];
216
+ const visited = perUser ? perUser.some((x) => x.matches.length > 0) : null;
217
+ if (visited)
218
+ visitedCount++;
219
+ const matchedIds = new Set([...(perUser ?? []).flatMap((x) => x.matches.map((m) => m.venue_id)), ...(p.venue_id ? [p.venue_id] : [])]);
220
+ const res = {
221
+ ...(p.label !== undefined ? { label: p.label } : {}),
222
+ place: { ...p, label: undefined },
223
+ visited,
224
+ ...(perUser
225
+ ? {
226
+ visits: perUser.map(({ username: u, matches }) => ({
227
+ username: u,
228
+ visited: matches.length > 0,
229
+ total_visits: matches.reduce((n, m) => n + (m.total_count ?? 0), 0),
230
+ first_visit: minIso(matches.map((m) => m.first_visit)),
231
+ recent_visit: maxIso(matches.map((m) => m.recent_visit)),
232
+ match_count: matches.length,
233
+ matches: matches.slice(0, maxMatches).map((m) => ({
234
+ venue_id: m.venue_id,
235
+ name: m.name,
236
+ city: m.city,
237
+ state: m.state,
238
+ ...(m.is_duplicate ? { is_duplicate: true } : {}),
239
+ ...(m.distance_m !== undefined ? { distance_m: m.distance_m } : {}),
240
+ first_visit: m.first_visit,
241
+ recent_visit: m.recent_visit,
242
+ total_count: m.total_count,
243
+ favorite: favs.get('venue', m.venue_id),
244
+ })),
245
+ })),
246
+ }
247
+ : { visit_note: 'No venue name is known for this brewery yet — pass name (and city) to check visits.' }),
248
+ favorite_venue: [...matchedIds].some((id) => favs.isFavorite('venue', id)),
249
+ };
250
+ if (p.brewery_id !== undefined) {
251
+ const id = p.brewery_id;
252
+ const info = lists.get(id);
253
+ if (info.source === 'missing')
254
+ missingLists++;
255
+ if (info.stale)
256
+ staleLists++;
257
+ const rows = candidates.get(id);
258
+ const notHad = rows.filter((r) => verdicts.get(r.bid)?.qualifies);
259
+ const breweryFav = favs.get('brewery', id);
260
+ const boostsOf = (r) => {
261
+ if (!preferFavorites)
262
+ return [];
263
+ const b = [];
264
+ if (wishlisted.has(r.bid))
265
+ b.push('wishlist');
266
+ if (breweryFav.is_favorite)
267
+ b.push('favorite_brewery');
268
+ return b;
269
+ };
270
+ const scoreOf = (r) => ratingOf(r) + boostsOf(r).reduce((n, b) => n + (b === 'wishlist' ? WISHLIST_BOOST : FAVORITE_BREWERY_BOOST), 0);
271
+ const ranked = notHad
272
+ .slice()
273
+ .sort((a, b) => scoreOf(b) - scoreOf(a) || ratingOf(b) - ratingOf(a) || a.bid - b.bid)
274
+ .slice(0, topN)
275
+ .map((r) => {
276
+ const boosts = boostsOf(r);
277
+ const beerFav = favs.get('beer', r.bid);
278
+ const v = verdicts.get(r.bid);
279
+ return {
280
+ bid: r.bid,
281
+ name: r.beer_name,
282
+ style: r.style,
283
+ abv: r.abv,
284
+ rating: r.rating_score,
285
+ rating_count: r.rating_count,
286
+ ...(r.in_production === false ? { in_production: false } : {}),
287
+ ...(boosts.length ? { score: Number(scoreOf(r).toFixed(3)), boosted_by: boosts } : {}),
288
+ ...(wishlisted.has(r.bid) ? { wishlisted: true } : {}),
289
+ ...(beerFav.is_favorite ? { favorite: beerFav } : {}),
290
+ ...(group ? { not_had_by: v.not_had_by, had_by: v.had_by.map((x) => x.username) } : {}),
291
+ };
292
+ });
293
+ res.brewery = {
294
+ brewery_id: id,
295
+ brewery_name: breweryNames.get(id) ?? null,
296
+ source: info.source,
297
+ stale: info.stale,
298
+ fetched_at: info.list?.fetched_at ?? null,
299
+ ...(info.error ? { error: info.error } : {}),
300
+ beers_in_list: info.list?.beers.length ?? 0,
301
+ total_count: info.list?.total_count ?? null,
302
+ not_had_in_list: notHad.length,
303
+ favorite: breweryFav,
304
+ };
305
+ res.top_not_had = ranked;
306
+ }
307
+ return res;
308
+ });
309
+ // beers_had per brewery stop (async, so outside the map).
310
+ for (let i = 0; i < places.length; i++) {
311
+ const id = places[i].brewery_id;
312
+ if (id === undefined)
313
+ continue;
314
+ const brewery = out[i].brewery;
315
+ const counts = {};
316
+ for (const u of users)
317
+ counts[u] = (await cache.breweryHadStats(u, [id]))[0]?.beers ?? 0;
318
+ if (group)
319
+ brewery.beers_had_by = counts;
320
+ else
321
+ brewery.beers_had = counts[users[0]];
322
+ }
323
+ // Near-a-favorite hint: centre = the stop's lat/lng, else its first matched venue with coordinates.
324
+ for (let i = 0; i < places.length; i++) {
325
+ const p = places[i];
326
+ let centre = p.lat !== undefined && p.lng !== undefined ? { lat: p.lat, lng: p.lng } : null;
327
+ const matches = (visits[i] ?? []).flatMap((x) => x.matches);
328
+ if (!centre && p.venue_id !== undefined) {
329
+ const v = placeVenueById.get(p.venue_id);
330
+ if (v && v.lat !== null && v.lng !== null)
331
+ centre = { lat: v.lat, lng: v.lng };
332
+ }
333
+ if (!centre) {
334
+ const m = matches.find((x) => x.lat !== null && x.lng !== null);
335
+ if (m)
336
+ centre = { lat: m.lat, lng: m.lng };
337
+ }
338
+ if (!centre)
339
+ continue;
340
+ const exclude = new Set([...matches.map((m) => m.venue_id), ...(p.venue_id ? [p.venue_id] : [])]);
341
+ const near = favVenues
342
+ .filter((v) => !exclude.has(v.venue_id))
343
+ .map((v) => ({ v, d: Math.round(haversineM(centre.lat, centre.lng, v.lat, v.lng)) }))
344
+ .filter((x) => x.d <= nearRadius)
345
+ .sort((a, b) => a.d - b.d)
346
+ .slice(0, MAX_NEAR_FAVORITES);
347
+ if (near.length === 0)
348
+ continue;
349
+ out[i].near_favorites = near.map(({ v, d }) => ({
350
+ venue_id: v.venue_id,
351
+ name: v.name,
352
+ city: v.city,
353
+ distance_m: d,
354
+ favorite: favs.get('venue', v.venue_id),
355
+ }));
356
+ out[i].hint = `Near a favorite: ${near.map(({ v, d }) => `${v.name ?? `venue ${v.venue_id}`} (${d} m)`).join(', ')}`;
357
+ }
358
+ const partial = missingLists > 0 || staleLists > 0;
359
+ const venueFresh = group
360
+ ? Object.fromEntries(await Promise.all(users.map(async (u) => [u, await venueFreshness(cache, u)])))
361
+ : await venueFreshness(cache, users[0]);
362
+ return minifiedResult({
363
+ ...(group ? { usernames: users, mode: groupMode } : { username: users[0] }),
364
+ places: out,
365
+ summary: {
366
+ places: places.length,
367
+ visited: visitedCount,
368
+ prefer_favorites: preferFavorites,
369
+ near_favorite_radius_m: nearRadius,
370
+ api_calls_used: apiCalls,
371
+ ...(missingLists ? { brewery_lists_missing: missingLists } : {}),
372
+ ...(staleLists ? { brewery_lists_stale: staleLists } : {}),
373
+ partial,
374
+ another_run_needed: partial,
375
+ stopped_for_rate_limit: rateStop !== undefined,
376
+ ...(rateStop ? { rate_limit: rateStop, note: resumeAdvice(rateStop, 'untappd_trip_check') } : {}),
377
+ },
378
+ freshness: {
379
+ beers: group ? await groupFreshness(cache, users) : await freshness(cache, users[0]),
380
+ venues: group ? { by_user: venueFresh } : venueFresh,
381
+ },
382
+ });
383
+ });
384
+ }