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.
@@ -16,6 +16,27 @@ function resolveUser(username, loginName) {
16
16
  }
17
17
  return encodeURIComponent(u);
18
18
  }
19
+ /**
20
+ * user/venues sort values. Untappd silently IGNORES a value it doesn't know
21
+ * (the old 'name' / 'checkin' / 'highest_rated' never sorted anything), so the
22
+ * friendly aliases map onto the real keys. There is no rating sort for venues:
23
+ * 'highest_rated' is kept only so pre-existing callers don't start failing
24
+ * validation, as a DEPRECATED alias of highest_checkin.
25
+ */
26
+ export const USER_VENUE_SORT_KEYS = {
27
+ date: 'date',
28
+ recent: 'recent',
29
+ date_asc: 'date_asc',
30
+ venue_name_asc: 'venue_name_asc',
31
+ venue_name_desc: 'venue_name_desc',
32
+ highest_checkin: 'highest_checkin',
33
+ lowest_checkin: 'lowest_checkin',
34
+ name: 'venue_name_asc',
35
+ checkin: 'highest_checkin',
36
+ /** @deprecated Untappd has no venue rating sort; kept for old callers. */
37
+ highest_rated: 'highest_checkin',
38
+ };
39
+ const USER_VENUE_SORTS = Object.keys(USER_VENUE_SORT_KEYS);
19
40
  export function registerUserTools(server, client) {
20
41
  server.registerTool('untappd_user_info', {
21
42
  title: 'Get Untappd user profile',
@@ -34,7 +55,9 @@ export function registerUserTools(server, client) {
34
55
  server.registerTool('untappd_user_checkins', {
35
56
  title: 'Get Untappd user check-ins',
36
57
  description: "Get a user's recent check-ins (most recent first): the beer, rating, comment, venue, and toasts/comments. " +
37
- 'Page backwards with max_id (the pagination.max_id from a prior call). Omit username for your own. Read-only.',
58
+ 'Page backwards with max_id (the pagination.max_id from a prior call). Only your OWN history pages fully: for ' +
59
+ 'any other user Untappd returns just the ~50 most recent check-ins and ignores max_id — use untappd_user_beers ' +
60
+ '(has had) or untappd_user_venues (has been) for their full history. Omit username for your own. Read-only.',
38
61
  annotations: toolAnnotations({ title: 'Get Untappd user check-ins', readOnly: true, idempotent: true, openWorld: true }),
39
62
  inputSchema: z.object({
40
63
  username: UsernameArg,
@@ -114,20 +137,28 @@ export function registerUserTools(server, client) {
114
137
  });
115
138
  server.registerTool('untappd_user_venues', {
116
139
  title: 'Get venues a user has checked in at',
117
- description: "Get the venues a user has checked in at, most recent first, with per-venue check-in counts. Supports " +
118
- 'sorting and paging. Omit username for your own account. Read-only.',
140
+ description: "Get the venues a user has checked in at, most recent first, with per-venue first/recent visit and " +
141
+ 'check-in counts. Pages fully for any public/friend account (unlike user check-ins, which stop at ~50 for ' +
142
+ 'other users). Untappd returns at most 25 venues per page whatever limit you ask for — advance by the ' +
143
+ "returned pagination offset. Omit username for your own account. Read-only.",
119
144
  annotations: toolAnnotations({ title: 'Get venues a user has checked in at', readOnly: true, idempotent: true, openWorld: true }),
120
145
  inputSchema: z.object({
121
146
  username: UsernameArg,
122
- limit: z.number().int().min(1).max(50).optional().describe('Max venues (1–50, default 25)'),
147
+ limit: z.number().int().min(1).max(50).optional().describe('Max venues (1–50; Untappd caps a page at 25)'),
123
148
  offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
124
149
  sort: z
125
- .enum(['date', 'name', 'checkin', 'highest_rated'])
150
+ .enum(USER_VENUE_SORTS)
126
151
  .optional()
127
- .describe('Sort order (default most recent)'),
152
+ .describe('Sort order (default most recent). Untappd keys: date, recent, date_asc, venue_name_asc, venue_name_desc, ' +
153
+ 'highest_checkin, lowest_checkin. Aliases: name = venue_name_asc, checkin = highest_checkin. ' +
154
+ 'highest_rated is deprecated (Untappd has no venue rating sort) and is sent as highest_checkin.'),
128
155
  }),
129
156
  }, async ({ username, limit, offset, sort }) => {
130
- const data = await client.get(`/user/venues/${resolveUser(username, client.loginName)}`, { limit, offset, sort });
157
+ const data = await client.get(`/user/venues/${resolveUser(username, client.loginName)}`, {
158
+ limit,
159
+ offset,
160
+ sort: sort === undefined ? undefined : USER_VENUE_SORT_KEYS[sort],
161
+ });
131
162
  return minifiedResult(data);
132
163
  });
133
164
  server.registerTool('untappd_pending_friends', {
@@ -1,7 +1,19 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
3
3
  import { compactCheckins, UNTAPPD_VIEWS, upstreamCompact } from '../compact.js';
4
- export function registerVenueTools(server, client) {
4
+ import { walkVenueMenu } from '../menu/venue-menu.js';
5
+ import { cachedResponse, VENUE_RESPONSE_TTL_MS } from '../cache/response-cache.js';
6
+ import { optionalCache } from './cache-optional.js';
7
+ import { favoriteLookup } from '../cache/favorites.js';
8
+ const TTL_HOURS = VENUE_RESPONSE_TTL_MS / 3_600_000;
9
+ const RefreshArg = z
10
+ .boolean()
11
+ .optional()
12
+ .describe(`Bypass the ${TTL_HOURS}-hour local cache and call Untappd (default false)`);
13
+ export function registerVenueTools(server, client, cacheProvider) {
14
+ const cacheOrNone = optionalCache(cacheProvider);
15
+ /** The `cache` block a cached result carries (only when a cache is in use). */
16
+ const cacheBlock = (r) => r.expires_at === null ? {} : { cache: { hit: r.hit, fetched_at: r.fetched_at, expires_at: r.expires_at } };
5
17
  server.registerTool('untappd_search_venue', {
6
18
  title: 'Search Untappd venues',
7
19
  description: 'Search Untappd for venues (bars, breweries, restaurants) by name. Returns matches with their venue id, ' +
@@ -36,10 +48,15 @@ export function registerVenueTools(server, client) {
36
48
  'spans multiple sections — e.g. a 23-beer wall that comes back with 2 items. This tool forwards the ' +
37
49
  'section_limit / section_offset paging params venue/info echoes back but never receives, walks sections up to a ' +
38
50
  'per-call max_pages budget (respecting the ~100 calls/hour limit — it does NOT loop to completion in one call), and ' +
39
- 'flattens to [{bid, name, brewery, style, abv, price, serving_type, menu, section}]. Like the sync tools it is ' +
51
+ 'flattens to [{bid, name, brewery, brewery_id, style, abv, price, serving_type, menu, section}]. Like the sync tools it is ' +
40
52
  'resumable: when the budget runs out before full coverage it returns another_run_needed:true plus next_section_offset ' +
41
53
  'to pass back on the next call. truncated:true means the upstream returned no more sections short of total_count ' +
42
- '(e.g. it ignored the paging params) — not resumable. Get an id from untappd_search_venue. Read-only.',
54
+ '(e.g. it ignored the paging params) — not resumable. Results are cached locally for ' + TTL_HOURS + ' hours per ' +
55
+ 'venue + paging params (cache.hit, fetched_at, expires_at) so repeated trip planning does not re-spend calls; ' +
56
+ 'refresh:true re-fetches. With the cache, the result also carries favorite {is_favorite, sources, reason} for the ' +
57
+ 'venue, and favorite / brewery_favorite on each beer that is one (derived from username\'s history or added ' +
58
+ 'with untappd_favorite_add; default your account) — applied on read, so never stale. Get an id from ' +
59
+ 'untappd_search_venue. Read-only.',
43
60
  annotations: toolAnnotations({ title: "Get a venue's verified beer menu (section-paged)", readOnly: true, idempotent: true, openWorld: true }),
44
61
  inputSchema: z.object({
45
62
  venue_id: z.number().int().positive().describe('Untappd venue id'),
@@ -48,119 +65,72 @@ export function registerVenueTools(server, client) {
48
65
  section_offset: z.number().int().min(0).optional().describe('Section offset to start from; pass a prior next_section_offset to resume (default 0).'),
49
66
  max_pages: z.number().int().min(1).max(10).optional().describe('API calls to spend THIS run — page budget, not page size (default 3). Resume with next_section_offset if another_run_needed.'),
50
67
  sort: z.string().optional().describe("Menu sort key (e.g. 'publish_order', 'highest_rated'). Optional."),
68
+ refresh: RefreshArg,
69
+ username: z
70
+ .string()
71
+ .min(1)
72
+ .optional()
73
+ .describe('Whose favorites to mark (cache only; default your configured account)'),
51
74
  }),
52
- }, async ({ venue_id, menu_id, section_limit, section_offset, max_pages, sort }) => {
53
- // venue/info pages its MENUS with limit/offset, but caps each menu's SECTION
54
- // list — echoing `section_limit`/`section_offset` back as accepted params
55
- // (the response even carries a `section_offset ` key with a stray trailing
56
- // space; the real param name is the clean one the web menu UI sends). We
57
- // forward them and walk sections, deduping by menu+section+bid so overlapping
58
- // or param-ignoring pages can't double-count. Per CLAUDE.md's rate-limit
59
- // design this spends at most `max_pages` API calls per run and hands the
60
- // caller next_section_offset to resume, rather than looping to completion.
61
- //
62
- // Termination is driven by SECTION availability, not a running beer count:
63
- // a page that returns fewer sections than we asked for is the end of the
64
- // list. That's what makes resuming correct — this tool is stateless across
65
- // calls, so on a resumed tail (section_offset > 0) `beers.length` covers
66
- // only this run and can never equal the whole-menu total, which would
67
- // otherwise mislabel a finished tail as truncated.
68
- const pageSize = section_limit ?? 50;
69
- const budget = max_pages ?? 3;
70
- const startOffset = section_offset ?? 0;
71
- let offset = startOffset;
72
- const seen = new Set();
73
- const beers = [];
74
- let totalCount = 0;
75
- let sawMenu = false;
76
- let pagesFetched = 0;
77
- let reachedEnd = false; // hit full coverage, or the section list ran out
78
- for (let page = 0; page < budget; page++) {
79
- const data = await client.get(`/venue/info/${venue_id}`, {
80
- section_limit: pageSize,
81
- section_offset: offset,
82
- menu_id,
75
+ }, async ({ venue_id, menu_id, section_limit, section_offset, max_pages, sort, refresh, username }) => {
76
+ const key = `venue_menu:${venue_id}:${JSON.stringify([menu_id ?? null, section_limit ?? null, section_offset ?? 0, max_pages ?? 3, sort ?? null])}`;
77
+ const cache = cacheOrNone();
78
+ const r = await cachedResponse(cache, key, 'venue_menu', () => fetchMenu(), { refresh });
79
+ // Favorites are applied on read (not cached), so a change shows even on a cache hit.
80
+ const user = username ?? client.loginName;
81
+ if (!cache || !user)
82
+ return minifiedResult({ ...r.data, ...cacheBlock(r) });
83
+ const beers = (Array.isArray(r.data.beers) ? r.data.beers : []);
84
+ const ids = (k) => beers.map((b) => b[k]).filter((x) => typeof x === 'number');
85
+ const favs = await favoriteLookup(cache, [user], { venue: [venue_id], beer: ids('bid'), brewery: ids('brewery_id') });
86
+ return minifiedResult({
87
+ ...r.data,
88
+ favorite: favs.get('venue', venue_id),
89
+ beers: beers.map((b) => {
90
+ const beerFav = favs.get('beer', b.bid);
91
+ const breweryFav = favs.get('brewery', b.brewery_id);
92
+ return {
93
+ ...b,
94
+ ...(beerFav.is_favorite ? { favorite: beerFav } : {}),
95
+ ...(breweryFav.is_favorite ? { brewery_favorite: breweryFav } : {}),
96
+ };
97
+ }),
98
+ ...cacheBlock(r),
99
+ });
100
+ async function fetchMenu() {
101
+ // Per CLAUDE.md's rate-limit design this spends at most `max_pages` API
102
+ // calls per run and hands the caller next_section_offset to resume, rather
103
+ // than looping to completion. The walk itself lives in walkVenueMenu.
104
+ const startOffset = section_offset ?? 0;
105
+ const walk = await walkVenueMenu(client, venue_id, {
106
+ menuId: menu_id,
107
+ sectionLimit: section_limit,
108
+ sectionOffset: startOffset,
109
+ maxPages: max_pages ?? 3,
83
110
  sort,
84
111
  });
85
- pagesFetched++;
86
- const vb = data?.venue?.verfied_beers;
87
- if (!vb) {
88
- reachedEnd = true; // no menu payload — nothing more to page
89
- break;
90
- }
91
- sawMenu = true;
92
- let added = 0;
93
- let matchedItemCount = 0;
94
- let sectionsReturned = 0;
95
- for (const wrap of vb.items ?? []) {
96
- const menu = wrap?.menu;
97
- if (!menu)
98
- continue;
99
- if (menu_id && menu.menu_id !== menu_id)
100
- continue;
101
- if (typeof menu.total_item_count === 'number')
102
- matchedItemCount += menu.total_item_count;
103
- const sections = menu.sections?.items ?? [];
104
- for (const section of sections) {
105
- sectionsReturned++;
106
- for (const it of section.items ?? []) {
107
- const beer = it?.beer;
108
- if (!beer || typeof beer.bid !== 'number')
109
- continue;
110
- const key = `${String(menu.menu_id)}:${String(section.section_id)}:${beer.bid}`;
111
- if (seen.has(key))
112
- continue;
113
- seen.add(key);
114
- const sectionName = section.section_name;
115
- beers.push({
116
- bid: beer.bid,
117
- name: beer.beer_name,
118
- brewery: it.brewery?.brewery_name,
119
- style: beer.beer_style,
120
- abv: beer.beer_abv,
121
- price: it.price?.value,
122
- serving_type: it.serving_type,
123
- menu: menu.menu_name,
124
- section: typeof sectionName === 'string' ? sectionName.trim() : sectionName,
125
- });
126
- added++;
127
- }
128
- }
129
- }
130
- // Coverage target: with a menu_id filter, aim for THAT menu's own item
131
- // count — verfied_beers.total_count spans every menu, so a single-menu
132
- // slice could never reach it (it would burn the whole page budget and
133
- // wrongly report a shortfall). Without a filter, the venue-wide total.
134
- totalCount = menu_id ? matchedItemCount : typeof vb.total_count === 'number' ? vb.total_count : totalCount;
135
- offset += pageSize;
136
- if (totalCount > 0 && beers.length >= totalCount) {
137
- reachedEnd = true; // full coverage from this walk
138
- break;
112
+ const { totalCount, sawMenu, pagesFetched, reachedEnd } = walk;
113
+ const beers = walk.beers.map(({ rawBeer: _b, rawBrewery: w, ...b }) => typeof w?.brewery_id === 'number' ? { ...b, brewery_id: w.brewery_id } : b);
114
+ if (!sawMenu) {
115
+ return { venue_id, total_count: 0, returned: 0, pages_fetched: pagesFetched, another_run_needed: false, truncated: false, beers: [], note: 'No verified menu on this venue.' };
139
116
  }
140
- if (sectionsReturned < pageSize || added === 0) {
141
- reachedEnd = true; // a short (or all-duplicate) page is the end of the section list
142
- break;
143
- }
144
- }
145
- if (!sawMenu) {
146
- return minifiedResult({ venue_id, total_count: 0, returned: 0, pages_fetched: pagesFetched, another_run_needed: false, truncated: false, beers: [], note: 'No verified menu on this venue.' });
117
+ const covered = totalCount > 0 && beers.length >= totalCount;
118
+ const another_run_needed = !reachedEnd; // stopped only because the page budget ran out mid-list
119
+ // Only a walk that started at the top of the list can judge a genuine
120
+ // shortfall as truncated; a resumed tail legitimately returns fewer than
121
+ // the whole-menu total_count and is not truncated.
122
+ const truncated = reachedEnd && !covered && startOffset === 0 && totalCount > 0;
123
+ return {
124
+ venue_id,
125
+ total_count: totalCount,
126
+ returned: beers.length,
127
+ pages_fetched: pagesFetched,
128
+ another_run_needed,
129
+ ...(another_run_needed ? { next_section_offset: walk.nextOffset } : {}),
130
+ truncated,
131
+ beers,
132
+ };
147
133
  }
148
- const covered = totalCount > 0 && beers.length >= totalCount;
149
- const another_run_needed = !reachedEnd; // stopped only because the page budget ran out mid-list
150
- // Only a walk that started at the top of the list can judge a genuine
151
- // shortfall as truncated; a resumed tail legitimately returns fewer than
152
- // the whole-menu total_count and is not truncated.
153
- const truncated = reachedEnd && !covered && startOffset === 0 && totalCount > 0;
154
- return minifiedResult({
155
- venue_id,
156
- total_count: totalCount,
157
- returned: beers.length,
158
- pages_fetched: pagesFetched,
159
- another_run_needed,
160
- ...(another_run_needed ? { next_section_offset: offset } : {}),
161
- truncated,
162
- beers,
163
- });
164
134
  });
165
135
  server.registerTool('untappd_venue_by_foursquare', {
166
136
  title: 'Look up an Untappd venue by Foursquare id',
@@ -177,17 +147,23 @@ export function registerVenueTools(server, client) {
177
147
  server.registerTool('untappd_venue_activity', {
178
148
  title: 'Get recent check-ins at a venue',
179
149
  description: 'Get the recent public check-ins at a venue by its id — who was there, what they drank, and their ratings. ' +
180
- 'Page backwards with max_id (the pagination.max_id from a prior call). Read-only.',
150
+ 'Page backwards with max_id (the pagination.max_id from a prior call). Cached locally for ' + TTL_HOURS + ' hours ' +
151
+ 'per venue + limit + max_id (cache.hit, fetched_at, expires_at); refresh:true re-fetches. Read-only.',
181
152
  annotations: toolAnnotations({ title: 'Get recent check-ins at a venue', readOnly: true, idempotent: false, openWorld: true }),
182
153
  inputSchema: z.object({
183
154
  venue_id: z.number().int().positive().describe('Untappd venue id'),
184
155
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
185
156
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
157
+ refresh: RefreshArg,
186
158
  view: viewParam(UNTAPPD_VIEWS, { note: 'compact projects each check-in to {id, user, beer, brewery, venue, rating, comment, toast/comment counts}; "full" returns Untappd\'s whole ~5 KB record.' }),
187
159
  }),
188
- }, async ({ venue_id, limit, max_id, view }) => {
189
- const data = await client.get(`/venue/checkins/${venue_id}`, { limit, max_id });
160
+ }, async ({ venue_id, limit, max_id, refresh, view }) => {
161
+ // The RAW response is cached, so either view can be served from one entry.
162
+ const key = `venue_activity:${venue_id}:${limit ?? ''}:${max_id ?? ''}`;
163
+ const r = await cachedResponse(cacheOrNone(), key, 'venue_activity', () => client.get(`/venue/checkins/${venue_id}`, { limit, max_id }), { refresh });
190
164
  const v = resolveView(view, UNTAPPD_VIEWS);
191
- return viewResult(v, v === 'compact' ? compactCheckins(data) : data);
165
+ const shaped = v === 'compact' ? compactCheckins(r.data) : r.data;
166
+ const extra = cacheBlock(r);
167
+ return viewResult(v, Object.keys(extra).length && shaped && typeof shaped === 'object' ? { ...shaped, ...extra } : shaped);
192
168
  });
193
169
  }
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@
3
3
  // json's `extra-files`), and `versionSyncTest` guards that it stays equal to
4
4
  // package.json. Import VERSION wherever the version is needed rather than
5
5
  // re-declaring it.
6
- export const VERSION = '2.3.2'; // x-release-please-version
6
+ export const VERSION = '2.5.0'; // x-release-please-version
package/mint.yaml CHANGED
@@ -5,6 +5,18 @@ summary: >-
5
5
  Search Untappd beers, breweries, and venues; read profiles, check-ins,
6
6
  wishlists, and your friend feed; and post check-ins, toasts, and comments
7
7
  to your account
8
+ examples:
9
+ - prompt: >-
10
+ What's trending on Untappd right now? Split it into the big macro beers
11
+ and the smaller craft ones.
12
+ note: Read-only; uses the trending beers list.
13
+ - prompt: >-
14
+ Look up Tree House Brewing on Untappd and list their highest-rated beers.
15
+ note: Read-only; brewery search, then its beer list sorted by rating.
16
+ - prompt: >-
17
+ Here's the tap list at the bar I'm at. Which two of these have I not had
18
+ yet, ranked by rating?
19
+ note: Checks your synced check-in history; nothing is posted.
8
20
  env:
9
21
  - name: UNTAPPD_USERNAME
10
22
  required: true
@@ -31,6 +43,11 @@ env:
31
43
  help: >-
32
44
  IANA timezone (e.g. America/New_York) that check-ins are stamped with
33
45
  when a call passes no timezone. Defaults to the server's own zone.
46
+ - name: UNTAPPD_RATE_LIMIT_PER_HOUR
47
+ required: false
48
+ help: >-
49
+ Hourly Untappd API call limit the syncs' stop-before-429 guard enforces
50
+ (default 100, Untappd's documented per-token limit).
34
51
  - name: UNTAPPD_PHOTO_DIR
35
52
  required: false
36
53
  help: >-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "2.3.2",
3
+ "version": "2.5.0",
4
4
  "mcpName": "io.github.chrischall/untappd-mcp",
5
5
  "description": "Untappd MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -58,5 +58,9 @@
58
58
  "typescript": "^7.0.2",
59
59
  "vite": "^8.2.2",
60
60
  "vitest": "^5.0.0"
61
+ },
62
+ "allowScripts": {
63
+ "esbuild": false,
64
+ "fsevents": false
61
65
  }
62
66
  }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/untappd-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.3.2",
9
+ "version": "2.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "2.3.2",
14
+ "version": "2.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -79,6 +79,13 @@
79
79
  "format": "string",
80
80
  "isSecret": false
81
81
  },
82
+ {
83
+ "name": "UNTAPPD_RATE_LIMIT_PER_HOUR",
84
+ "description": "Hourly Untappd API call limit the syncs' stop-before-429 guard enforces (default 100, Untappd's documented per-token limit). Only change it if Untappd gives your token a different limit.",
85
+ "isRequired": false,
86
+ "format": "string",
87
+ "isSecret": false
88
+ },
82
89
  {
83
90
  "name": "UNTAPPD_PHOTO_DIR",
84
91
  "description": "The directory untappd_checkin's photo_path must be inside (several allowed, separated by :). Defaults to ~/Downloads/untappd-mcp — put a photo there to attach it. Any other file is refused, so a prompt-injected shout or comment can't publish an arbitrary image from your disk.",
@@ -92,6 +99,13 @@
92
99
  "isRequired": false,
93
100
  "format": "string",
94
101
  "isSecret": false
102
+ },
103
+ {
104
+ "name": "UNTAPPD_CACHE_DIR",
105
+ "description": "Directory for the cache instead of ~/.untappd-mcp (the file is checkins.db inside it). UNTAPPD_CACHE_DB wins if both are set. Local/stdio only.",
106
+ "isRequired": false,
107
+ "format": "string",
108
+ "isSecret": false
95
109
  }
96
110
  ]
97
111
  }
@@ -205,6 +205,36 @@ Query tools (has-had ones consult BOTH sources — a hit in either = had):
205
205
  capped at `api_budget` per run (partial/another_run_needed when exceeded).
206
206
  - `untappd_cache_query` — filter cached check-ins by brewery, style, `min_rating`,
207
207
  venue, and date range.
208
+ - **Groups:** `untappd_cache_has_had_many` and `untappd_top_not_had` take
209
+ `usernames` (instead of `username`) plus `mode`: `none_had` (default — new to
210
+ everyone) or `any_not_had` (at least one of them still needs it). Each beer
211
+ lists `had_by` / `not_had_by`; freshness is reported per user.
212
+ - `untappd_sync_venue_drinks` — opt-in, costs one call per check-in: fetches the
213
+ beer from each cached venue's first/recent visit (`checkin/view`), budgeted by
214
+ `max_calls`; `untappd_cache_has_visited` then shows them as `drinks`. Partial
215
+ by design (two check-ins per venue at most).
216
+ - **Favorites** (local, no API): `untappd_favorites_list` (filter `kind`
217
+ beer|brewery|venue, `source` derived|manual|untappd, override `thresholds`),
218
+ `untappd_favorite_add` / `untappd_favorite_remove` (manual, with a note —
219
+ never written to Untappd). Derived defaults: venue ≥5 check-ins or ≥3 distinct
220
+ months; brewery ≥5 beers with avg ≥3.75, or ≥15 beers; beer rated ≥4.25 or
221
+ had ≥3 times. has_had / has_had_many / has_visited / brewery_beers / venue_menu /
222
+ top_not_had results carry `favorite` {is_favorite, sources, reason};
223
+ `untappd_top_not_had` boosts wishlisted (+0.5) and favorite-brewery (+0.25)
224
+ beers unless `prefer_favorites: false`. `untappd_sync_brewery_likes` mirrors
225
+ the breweries a user likes/follows on Untappd (resumable, rate-guarded); they
226
+ carry the source `untappd` (merged with derived/manual) and count for the boost.
227
+ Untappd has no followed-venue list, so venue favorites are derived + manual.
228
+ `brewery_info`'s `follow_status` is the signed-in account's, never another user's.
229
+ - `untappd_trip_check` — a whole trip in one call: per stop (`venue_id`, `name`
230
+ + `city`/`state`, `lat`/`lng`, and/or `brewery_id`) → visited with dates,
231
+ beers had from that brewery, and the top N (default 4) not-had beers by global
232
+ rating (`style` filter). Cache only, except `brewery/beer_list` for a missing or
233
+ >7-day-old list (`api_budget`, default 5; 0 = cache only). Flags favorite
234
+ venues/breweries, boosts wishlist/favorite-brewery beers (`prefer_favorites`),
235
+ and hints when a favorite venue is within `near_favorite_radius_m` (2 km).
236
+ Sync first: `untappd_sync_user_venues`, `untappd_sync_user_beers`,
237
+ `untappd_sync_wishlist`, `untappd_sync_brewery_likes`.
208
238
 
209
239
  **Retention.** The cache keeps what you sync until you remove it — including
210
240
  friends' dated check-ins, comments and venues. `untappd_cache_forget` (confirmation-gated)