untappd-mcp 2.4.0 → 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.
@@ -17,12 +17,21 @@
17
17
  // schema/queries back any engine: `node:sqlite` is the one that ships, and a
18
18
  // different deployment can adapt another driver to the same surface. This
19
19
  // module imports nothing platform-specific.
20
+ /** Default radius for a lat/lng has-visited query. */
21
+ export const DEFAULT_VISIT_RADIUS_M = 150;
22
+ export const FAVORITE_KINDS = ['beer', 'brewery', 'venue'];
20
23
  function asDict(v) {
21
24
  return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
22
25
  }
23
26
  function num(v) {
24
27
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
25
28
  }
29
+ /** A number, or a numeric string (coordinates sometimes arrive as strings). */
30
+ function numLoose(v) {
31
+ if (typeof v === 'string' && v.trim() !== '')
32
+ return num(Number(v));
33
+ return num(v);
34
+ }
26
35
  function str(v) {
27
36
  return typeof v === 'string' && v.length > 0 ? v : null;
28
37
  }
@@ -82,6 +91,107 @@ export function mapBeerRow(username, item) {
82
91
  last_had: normaliseDate(c.recent_created_at ?? c.first_created_at),
83
92
  };
84
93
  }
94
+ /**
95
+ * Normalise a raw `/user/venues` item to the shared venue facts plus the user's
96
+ * visit summary. Dates become ISO-8601 UTC (sortable, comparable across syncs).
97
+ * Returns null without a venue id (Untappd sends `venue: []` for none).
98
+ */
99
+ export function mapUserVenueItem(_username, item) {
100
+ const c = asDict(item);
101
+ const v = asDict(c.venue);
102
+ const venue_id = num(v.venue_id);
103
+ if (venue_id === null)
104
+ return null;
105
+ const loc = asDict(v.location);
106
+ const verified = v.is_verified;
107
+ return {
108
+ venue: {
109
+ venue_id,
110
+ name: str(v.venue_name),
111
+ category: str(v.primary_category),
112
+ address: str(loc.venue_address),
113
+ city: str(loc.venue_city),
114
+ state: str(loc.venue_state),
115
+ country: str(loc.venue_country),
116
+ lat: numLoose(loc.lat),
117
+ lng: numLoose(loc.lng),
118
+ is_verified: typeof verified === 'boolean' || typeof verified === 'number' ? (verified ? 1 : 0) : null,
119
+ },
120
+ visit: {
121
+ venue_id,
122
+ first_visit: normaliseDate(c.first_visit),
123
+ first_checkin_id: num(c.first_checkin_id),
124
+ recent_visit: normaliseDate(c.recent_visit),
125
+ recent_checkin_id: num(c.recent_checkin_id),
126
+ total_count: num(c.total_count),
127
+ },
128
+ };
129
+ }
130
+ /**
131
+ * Map one brewery/beer_list item to a {@link BreweryBeerRow}. Takes only the
132
+ * global facts: the item's `has_had` / `total_user_count` are the AUTHENTICATED
133
+ * account's and are deliberately dropped. Returns null without a bid.
134
+ */
135
+ export function mapBreweryBeerItem(breweryId, item, fetchedAt, popularityRank) {
136
+ const i = asDict(item);
137
+ const b = asDict(i.beer);
138
+ const bid = num(b.bid);
139
+ if (bid === null)
140
+ return null;
141
+ const inProd = num(b.is_in_production);
142
+ return {
143
+ brewery_id: breweryId,
144
+ bid,
145
+ beer_name: str(b.beer_name),
146
+ style: str(b.beer_style),
147
+ abv: num(b.beer_abv),
148
+ ibu: num(b.beer_ibu),
149
+ rating_score: num(b.rating_score),
150
+ rating_count: num(b.rating_count),
151
+ checkin_count: num(i.total_count),
152
+ in_production: inProd === null ? null : inProd !== 0,
153
+ popularity_rank: popularityRank,
154
+ fetched_at: fetchedAt,
155
+ };
156
+ }
157
+ /** Map one user/wishlist item; null without a bid. */
158
+ export function mapWishlistItem(item) {
159
+ const i = asDict(item);
160
+ const b = asDict(i.beer);
161
+ const bid = num(b.bid);
162
+ if (bid === null)
163
+ return null;
164
+ const brw = asDict(i.brewery);
165
+ return {
166
+ bid,
167
+ beer_name: str(b.beer_name),
168
+ brewery_id: num(brw.brewery_id),
169
+ brewery_name: str(brw.brewery_name),
170
+ style: str(b.beer_style),
171
+ abv: num(b.beer_abv),
172
+ added_at: normaliseDate(i.created_at),
173
+ };
174
+ }
175
+ /** Map one user/brewerylikes item; null without a brewery_id. */
176
+ export function mapBreweryLikeItem(item) {
177
+ const i = asDict(item);
178
+ const b = asDict(i.brewery);
179
+ const id = num(b.brewery_id);
180
+ if (id === null)
181
+ return null;
182
+ const loc = asDict(b.location);
183
+ return {
184
+ brewery_id: id,
185
+ brewery_name: str(b.brewery_name),
186
+ brewery_type: str(b.brewery_type),
187
+ country: str(b.country_name),
188
+ city: str(loc.brewery_city),
189
+ state: str(loc.brewery_state),
190
+ lat: num(loc.lat),
191
+ lng: num(loc.lng),
192
+ liked_at: normaliseDate(i.created_at),
193
+ };
194
+ }
85
195
  /**
86
196
  * Normalise a beer object (from beer/info's `beer`, or a search/list item's
87
197
  * `beer` with a sibling `brewery`) to {@link BeerMeta}. `fetchedAt` stamps when
@@ -221,7 +331,179 @@ export const VERSIONED_MIGRATIONS = [
221
331
  version: 3,
222
332
  statements: ['CREATE INDEX IF NOT EXISTS idx_checkins_user_created ON checkins(username, created_at)'],
223
333
  },
334
+ {
335
+ // Venue history, list syncs, TTL caches, favorites and the API call log.
336
+ // New tables only — nothing existing is altered.
337
+ version: 4,
338
+ statements: [
339
+ // Global venue facts from user/venues (and venue/info), shared across users.
340
+ `CREATE TABLE IF NOT EXISTS venues (
341
+ venue_id INTEGER PRIMARY KEY,
342
+ name TEXT,
343
+ category TEXT,
344
+ address TEXT,
345
+ city TEXT,
346
+ state TEXT,
347
+ country TEXT,
348
+ lat REAL,
349
+ lng REAL,
350
+ is_verified INTEGER,
351
+ updated_at TEXT
352
+ )`,
353
+ `CREATE INDEX IF NOT EXISTS idx_venues_city ON venues(city COLLATE NOCASE)`,
354
+ `CREATE INDEX IF NOT EXISTS idx_venues_lat_lng ON venues(lat, lng)`,
355
+ // One row per (user, venue) from user/venues: the user's visit summary there.
356
+ `CREATE TABLE IF NOT EXISTS user_venues (
357
+ username TEXT NOT NULL,
358
+ venue_id INTEGER NOT NULL,
359
+ first_visit TEXT,
360
+ first_checkin_id INTEGER,
361
+ recent_visit TEXT,
362
+ recent_checkin_id INTEGER,
363
+ total_count INTEGER,
364
+ synced_at TEXT,
365
+ PRIMARY KEY (username, venue_id)
366
+ )`,
367
+ `CREATE INDEX IF NOT EXISTS idx_user_venues_recent ON user_venues(username, recent_visit)`,
368
+ // Resumable cursor + reported total for offset-paged per-user lists
369
+ // (list = 'venues' | 'wishlist' | …). Kept out of sync_state so each list
370
+ // owns its own bookkeeping.
371
+ `CREATE TABLE IF NOT EXISTS list_sync_state (
372
+ username TEXT NOT NULL,
373
+ list TEXT NOT NULL,
374
+ next_offset INTEGER,
375
+ total_count INTEGER,
376
+ complete INTEGER NOT NULL DEFAULT 0,
377
+ last_synced_at TEXT,
378
+ PRIMARY KEY (username, list)
379
+ )`,
380
+ // brewery/beer_list results, per brewery, with a TTL on the list as a whole.
381
+ // The response's have_had reflects the AUTHENTICATED account, so it is not stored.
382
+ // fetched_at is when the current snapshot STARTED (its TTL clock);
383
+ // ranked_count is how many beers from the top of Untappd's default
384
+ // (popularity) order are cached contiguously.
385
+ `CREATE TABLE IF NOT EXISTS brewery_beer_lists (
386
+ brewery_id INTEGER PRIMARY KEY,
387
+ brewery_name TEXT,
388
+ total_count INTEGER,
389
+ ranked_count INTEGER NOT NULL DEFAULT 0,
390
+ fetched_at TEXT NOT NULL
391
+ )`,
392
+ `CREATE TABLE IF NOT EXISTS brewery_beers (
393
+ brewery_id INTEGER NOT NULL,
394
+ bid INTEGER NOT NULL,
395
+ beer_name TEXT,
396
+ style TEXT,
397
+ abv REAL,
398
+ rating_score REAL,
399
+ rating_count INTEGER,
400
+ checkin_count INTEGER,
401
+ in_production INTEGER,
402
+ popularity_rank INTEGER,
403
+ fetched_at TEXT NOT NULL,
404
+ PRIMARY KEY (brewery_id, bid)
405
+ )`,
406
+ // Short-TTL raw response cache (venue menus / activity), keyed e.g. 'venue_menu:123'.
407
+ `CREATE TABLE IF NOT EXISTS response_cache (
408
+ key TEXT PRIMARY KEY,
409
+ kind TEXT NOT NULL,
410
+ data TEXT NOT NULL,
411
+ fetched_at TEXT NOT NULL
412
+ )`,
413
+ `CREATE TABLE IF NOT EXISTS wishlist (
414
+ username TEXT NOT NULL,
415
+ bid INTEGER NOT NULL,
416
+ beer_name TEXT,
417
+ brewery_id INTEGER,
418
+ brewery_name TEXT,
419
+ style TEXT,
420
+ abv REAL,
421
+ added_at TEXT,
422
+ synced_at TEXT NOT NULL,
423
+ PRIMARY KEY (username, bid)
424
+ )`,
425
+ // Manual favorites — local only, never written to Untappd.
426
+ `CREATE TABLE IF NOT EXISTS favorites (
427
+ username TEXT NOT NULL,
428
+ kind TEXT NOT NULL CHECK (kind IN ('beer', 'brewery', 'venue')),
429
+ id INTEGER NOT NULL,
430
+ note TEXT,
431
+ created_at TEXT NOT NULL,
432
+ PRIMARY KEY (username, kind, id)
433
+ )`,
434
+ // What a user drank at a venue, from checkin/view of the user_venues first/recent check-ins.
435
+ `CREATE TABLE IF NOT EXISTS venue_drinks (
436
+ username TEXT NOT NULL,
437
+ venue_id INTEGER NOT NULL,
438
+ checkin_id INTEGER NOT NULL,
439
+ bid INTEGER,
440
+ beer_name TEXT,
441
+ brewery_id INTEGER,
442
+ brewery_name TEXT,
443
+ created_at TEXT,
444
+ fetched_at TEXT NOT NULL,
445
+ PRIMARY KEY (username, checkin_id)
446
+ )`,
447
+ `CREATE INDEX IF NOT EXISTS idx_venue_drinks_user_venue ON venue_drinks(username, venue_id)`,
448
+ // Every API response's time/status/rate-limit headers (src/ratelimit.ts),
449
+ // shared by all processes using this file. Pruned to ~2 hours.
450
+ `CREATE TABLE IF NOT EXISTS api_calls (
451
+ at INTEGER NOT NULL,
452
+ status INTEGER NOT NULL,
453
+ remaining INTEGER,
454
+ limit_hdr INTEGER,
455
+ reset_at INTEGER,
456
+ retry_after_s INTEGER
457
+ )`,
458
+ `CREATE INDEX IF NOT EXISTS idx_api_calls_at ON api_calls(at)`,
459
+ ],
460
+ },
461
+ {
462
+ // Untappd brewery likes (user/brewerylikes, the app's "following") — the
463
+ // "untappd" favorite source. A new table only; nothing existing is altered.
464
+ version: 5,
465
+ statements: [
466
+ `CREATE TABLE IF NOT EXISTS brewery_likes (
467
+ username TEXT NOT NULL,
468
+ brewery_id INTEGER NOT NULL,
469
+ brewery_name TEXT,
470
+ brewery_type TEXT,
471
+ country TEXT,
472
+ city TEXT,
473
+ state TEXT,
474
+ lat REAL,
475
+ lng REAL,
476
+ liked_at TEXT,
477
+ synced_at TEXT NOT NULL,
478
+ PRIMARY KEY (username, brewery_id)
479
+ )`,
480
+ ],
481
+ },
482
+ {
483
+ // brewery/beer_list items carry `beer_ibu`, which the old live-only compact
484
+ // projection returned; caching it keeps untappd_brewery_beers' compact shape
485
+ // identical with and without the cache. Additive: rows cached before this
486
+ // read back ibu null until the list next refreshes (7-day TTL).
487
+ version: 6,
488
+ statements: [`ALTER TABLE brewery_beers ADD COLUMN ibu REAL`],
489
+ },
490
+ ];
491
+ /** Tables holding one user's data, cleared by {@link CheckinStoreCore.forgetUser}. */
492
+ export const PER_USER_TABLES = [
493
+ 'checkins',
494
+ 'distinct_beers',
495
+ 'sync_state',
496
+ 'user_venues',
497
+ 'list_sync_state',
498
+ 'wishlist',
499
+ 'favorites',
500
+ 'venue_drinks',
501
+ 'brewery_likes',
224
502
  ];
503
+ /** How long short-TTL cached responses are kept at all (longer than any TTL that can serve them). */
504
+ export const RESPONSE_CACHE_RETENTION_MS = 48 * 60 * 60 * 1000;
505
+ /** How long the persisted API call log is kept (it only needs the rolling hour). */
506
+ const API_CALL_LOG_RETENTION_MS = 2 * 60 * 60 * 1000;
225
507
  /** The schema version this code writes. */
226
508
  export const SCHEMA_VERSION = VERSIONED_MIGRATIONS.reduce((v, m) => Math.max(v, m.version), 1);
227
509
  /**
@@ -268,8 +550,17 @@ export class CheckinStoreCore {
268
550
  for (const m of VERSIONED_MIGRATIONS) {
269
551
  if (m.version <= current)
270
552
  continue;
271
- for (const stmt of m.statements)
272
- this.db.execScript(stmt);
553
+ for (const stmt of m.statements) {
554
+ try {
555
+ this.db.execScript(stmt);
556
+ }
557
+ catch (e) {
558
+ // An ADD COLUMN step is idempotent: a file whose user_version lags
559
+ // its real schema (e.g. reset by hand) already has the column.
560
+ if (!/ADD COLUMN/i.test(stmt) || !/duplicate column/i.test(String(e?.message)))
561
+ throw e;
562
+ }
563
+ }
273
564
  }
274
565
  if (current !== SCHEMA_VERSION)
275
566
  this.db.execScript(`PRAGMA user_version = ${SCHEMA_VERSION}`);
@@ -362,6 +653,197 @@ export class CheckinStoreCore {
362
653
  const row = this.db.get('SELECT COUNT(*) AS n FROM distinct_beers WHERE username = ?', [username.toLowerCase()]);
363
654
  return Number(row?.n ?? 0);
364
655
  }
656
+ /**
657
+ * Upsert user/venues items in one transaction: the shared venue facts (the
658
+ * latest copy wins) and this user's visit rows. "added"/"updated" are taken
659
+ * against the rows already cached for just this batch's venue ids.
660
+ */
661
+ upsertUserVenues(username, items, now = new Date().toISOString()) {
662
+ if (items.length === 0)
663
+ return { added: 0, updated: 0 };
664
+ const key = username.toLowerCase();
665
+ const venueSql = `
666
+ INSERT INTO venues (venue_id, name, category, address, city, state, country, lat, lng, is_verified, updated_at)
667
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
668
+ ON CONFLICT(venue_id) DO UPDATE SET
669
+ name=excluded.name, category=excluded.category, address=excluded.address, city=excluded.city,
670
+ state=excluded.state, country=excluded.country, lat=excluded.lat, lng=excluded.lng,
671
+ is_verified=excluded.is_verified, updated_at=excluded.updated_at`;
672
+ const visitSql = `
673
+ INSERT INTO user_venues
674
+ (username, venue_id, first_visit, first_checkin_id, recent_visit, recent_checkin_id, total_count, synced_at)
675
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
676
+ ON CONFLICT(username, venue_id) DO UPDATE SET
677
+ first_visit=excluded.first_visit, first_checkin_id=excluded.first_checkin_id,
678
+ recent_visit=excluded.recent_visit, recent_checkin_id=excluded.recent_checkin_id,
679
+ total_count=excluded.total_count, synced_at=excluded.synced_at`;
680
+ let added = 0;
681
+ let updated = 0;
682
+ this.db.transaction(() => {
683
+ const prior = new Map(this.getUserVenueVisits(key, items.map((i) => i.visit.venue_id)).map((r) => [r.venue_id, r]));
684
+ const seen = new Set();
685
+ for (const { venue: v, visit: u } of items) {
686
+ this.db.run(venueSql, [v.venue_id, v.name, v.category, v.address, v.city, v.state, v.country, v.lat, v.lng, v.is_verified, now]);
687
+ this.db.run(visitSql, [key, u.venue_id, u.first_visit, u.first_checkin_id, u.recent_visit, u.recent_checkin_id, u.total_count, now]);
688
+ if (seen.has(u.venue_id))
689
+ continue;
690
+ seen.add(u.venue_id);
691
+ const p = prior.get(u.venue_id);
692
+ if (!p)
693
+ added++;
694
+ else if (p.recent_visit !== u.recent_visit || p.total_count !== u.total_count)
695
+ updated++;
696
+ }
697
+ });
698
+ return { added, updated };
699
+ }
700
+ getUserVenueVisits(username, venueIds) {
701
+ const out = [];
702
+ for (const ids of chunks([...new Set(venueIds)])) {
703
+ if (ids.length === 0)
704
+ continue;
705
+ const rows = this.db.all(`SELECT venue_id, first_visit, first_checkin_id, recent_visit, recent_checkin_id, total_count
706
+ FROM user_venues WHERE username = ? AND venue_id IN (${ids.map(() => '?').join(', ')})`, [username.toLowerCase(), ...ids]);
707
+ for (const r of rows)
708
+ out.push(visitFrom(r));
709
+ }
710
+ return out;
711
+ }
712
+ userVenuesCount(username) {
713
+ return Number(this.db.get('SELECT COUNT(*) AS n FROM user_venues WHERE username = ?', [username.toLowerCase()])?.n ?? 0);
714
+ }
715
+ getVenues(venueIds) {
716
+ const out = [];
717
+ for (const c of chunks([...new Set(venueIds)])) {
718
+ if (c.length === 0)
719
+ continue;
720
+ const rows = this.db.all(`SELECT * FROM venues WHERE venue_id IN (${c.map(() => '?').join(', ')})`, c);
721
+ for (const r of rows) {
722
+ out.push({
723
+ venue_id: Number(r.venue_id),
724
+ name: str(r.name),
725
+ category: str(r.category),
726
+ address: str(r.address),
727
+ city: str(r.city),
728
+ state: str(r.state),
729
+ country: str(r.country),
730
+ lat: num(r.lat),
731
+ lng: num(r.lng),
732
+ is_verified: num(r.is_verified),
733
+ });
734
+ }
735
+ }
736
+ return out;
737
+ }
738
+ getListState(username, list) {
739
+ const r = this.db.get('SELECT * FROM list_sync_state WHERE username = ? AND list = ?', [username.toLowerCase(), list]);
740
+ if (!r)
741
+ return undefined;
742
+ return {
743
+ username: String(r.username),
744
+ list: String(r.list),
745
+ next_offset: num(r.next_offset),
746
+ total_count: num(r.total_count),
747
+ complete: Number(r.complete) === 1,
748
+ last_synced_at: str(r.last_synced_at),
749
+ };
750
+ }
751
+ /** Read-merge-write in one write transaction (see {@link setState}). */
752
+ setListState(username, list, patch) {
753
+ const key = username.toLowerCase();
754
+ this.db.transaction(() => {
755
+ const cur = this.getListState(key, list);
756
+ const pick = (k, fallback) => k in patch ? patch[k] : (cur?.[k] ?? fallback);
757
+ this.db.run(`INSERT INTO list_sync_state (username, list, next_offset, total_count, complete, last_synced_at)
758
+ VALUES (?, ?, ?, ?, ?, ?)
759
+ ON CONFLICT(username, list) DO UPDATE SET
760
+ next_offset=excluded.next_offset, total_count=excluded.total_count,
761
+ complete=excluded.complete, last_synced_at=excluded.last_synced_at`, [
762
+ key,
763
+ list,
764
+ pick('next_offset', null),
765
+ pick('total_count', null),
766
+ pick('complete', false) ? 1 : 0,
767
+ pick('last_synced_at', null),
768
+ ]);
769
+ });
770
+ }
771
+ /**
772
+ * Cached venues the user has visited that match `q` (see {@link VenueQuery}),
773
+ * most-visited first. Name matching is deliberately loose — a brewery's
774
+ * taproom is its own venue (a brewery_id is not a venue_id), and Untappd keeps
775
+ * "… (duplicate of …)" copies — so a venue matches when the name is a literal
776
+ * case-insensitive substring of it, OR when every significant word of the name
777
+ * (ignoring "co", "company", "inc", "llc", "the") is a whole word of it.
778
+ */
779
+ findVisitedVenues(username, q) {
780
+ const key = username.toLowerCase();
781
+ const select = `SELECT v.venue_id AS venue_id, v.name, v.category, v.address, v.city, v.state, v.country,
782
+ v.lat, v.lng, v.is_verified, u.first_visit, u.first_checkin_id, u.recent_visit, u.recent_checkin_id, u.total_count
783
+ FROM user_venues u JOIN venues v ON v.venue_id = u.venue_id WHERE u.username = ?`;
784
+ const found = new Map();
785
+ if (q.venue_ids && q.venue_ids.length > 0) {
786
+ for (const ids of chunks([...new Set(q.venue_ids)])) {
787
+ const rows = this.db.all(`${select} AND u.venue_id IN (${ids.map(() => '?').join(', ')})`, [key, ...ids]);
788
+ for (const r of rows)
789
+ found.set(Number(r.venue_id), visitedFrom(r));
790
+ }
791
+ }
792
+ const hasName = q.name !== undefined && q.name.trim() !== '';
793
+ const hasGeo = q.lat !== undefined && q.lng !== undefined;
794
+ if (hasName || hasGeo) {
795
+ const where = [];
796
+ const params = [key];
797
+ let tokens = [];
798
+ if (hasName) {
799
+ const name = q.name.trim();
800
+ tokens = significantTokens(name);
801
+ // Prefilter: the literal substring, or the longest significant word.
802
+ const longest = tokens.slice().sort((a, b) => b.length - a.length)[0];
803
+ if (longest) {
804
+ where.push(`(v.name ${LIKE_MATCH} OR v.name ${LIKE_MATCH})`);
805
+ params.push(`%${escapeLike(name)}%`, `%${escapeLike(longest)}%`);
806
+ }
807
+ else {
808
+ where.push(`v.name ${LIKE_MATCH}`);
809
+ params.push(`%${escapeLike(name)}%`);
810
+ }
811
+ }
812
+ if (q.city !== undefined && q.city.trim() !== '') {
813
+ where.push(`v.city ${LIKE_MATCH}`);
814
+ params.push(`%${escapeLike(q.city.trim())}%`);
815
+ }
816
+ if (q.state !== undefined && q.state.trim() !== '') {
817
+ where.push(`v.state ${LIKE_MATCH}`);
818
+ params.push(`%${escapeLike(q.state.trim())}%`);
819
+ }
820
+ const radius = q.radius_m ?? DEFAULT_VISIT_RADIUS_M;
821
+ if (hasGeo) {
822
+ // Bounding-box prefilter; the exact great-circle check runs below.
823
+ const dLat = radius / 111_320;
824
+ const dLng = radius / (111_320 * Math.max(0.01, Math.cos((q.lat * Math.PI) / 180)));
825
+ where.push('v.lat BETWEEN ? AND ? AND v.lng BETWEEN ? AND ?');
826
+ params.push(q.lat - dLat, q.lat + dLat, q.lng - dLng, q.lng + dLng);
827
+ }
828
+ const rows = this.db.all(`${select}${where.length ? ` AND ${where.join(' AND ')}` : ''}`, params);
829
+ for (const r of rows) {
830
+ const v = visitedFrom(r);
831
+ if (hasName && !nameMatches(q.name.trim(), tokens, v.name))
832
+ continue;
833
+ if (hasGeo) {
834
+ if (v.lat === null || v.lng === null)
835
+ continue;
836
+ const d = haversineM(q.lat, q.lng, v.lat, v.lng);
837
+ if (d > radius)
838
+ continue;
839
+ v.distance_m = Math.round(d);
840
+ }
841
+ const prior = found.get(v.venue_id);
842
+ found.set(v.venue_id, prior && v.distance_m === undefined ? prior : v);
843
+ }
844
+ }
845
+ return [...found.values()].sort((a, b) => (b.total_count ?? 0) - (a.total_count ?? 0) || (b.recent_visit ?? '').localeCompare(a.recent_visit ?? '') || a.venue_id - b.venue_id);
846
+ }
365
847
  /** Distinct bids appearing in the checkins table. */
366
848
  checkinsBeerCount(username) {
367
849
  const row = this.db.get('SELECT COUNT(DISTINCT bid) AS n FROM checkins WHERE username = ? AND bid IS NOT NULL', [username.toLowerCase()]);
@@ -598,22 +1080,46 @@ export class CheckinStoreCore {
598
1080
  const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
599
1081
  return this.db.all(`SELECT * FROM checkins WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`, [...params, limit]);
600
1082
  }
601
- /** Delete one user's cached rows (case-insensitive) in a single transaction. */
1083
+ /**
1084
+ * Delete one user's cached rows (case-insensitive) from every per-user table
1085
+ * in a single transaction. Shared tables (beer_meta, venues, brewery lists,
1086
+ * response cache, the API call log) hold no personal history and are kept.
1087
+ */
602
1088
  forgetUser(username) {
603
1089
  const key = username.toLowerCase();
604
- const count = (table) => Number(this.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE username = ?`, [key])?.n ?? 0);
605
- const removed = {
606
- checkins: count('checkins'),
607
- distinct_beers: count('distinct_beers'),
608
- sync_state: count('sync_state'),
609
- };
1090
+ const removed = {};
610
1091
  this.db.transaction(() => {
611
- this.db.run('DELETE FROM checkins WHERE username = ?', [key]);
612
- this.db.run('DELETE FROM distinct_beers WHERE username = ?', [key]);
613
- this.db.run('DELETE FROM sync_state WHERE username = ?', [key]);
1092
+ for (const table of PER_USER_TABLES) {
1093
+ removed[table] = Number(this.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE username = ?`, [key])?.n ?? 0);
1094
+ this.db.run(`DELETE FROM ${table} WHERE username = ?`, [key]);
1095
+ }
614
1096
  });
615
1097
  return removed;
616
1098
  }
1099
+ /** Append API call observations to the shared log, pruning entries past retention. */
1100
+ recordApiCalls(rows) {
1101
+ if (rows.length === 0)
1102
+ return;
1103
+ this.db.transaction(() => {
1104
+ for (const r of rows) {
1105
+ this.db.run('INSERT INTO api_calls (at, status, remaining, limit_hdr, reset_at, retry_after_s) VALUES (?, ?, ?, ?, ?, ?)', [r.at, r.status, r.remaining, r.limit, r.reset_at, r.retry_after_s]);
1106
+ }
1107
+ this.db.run('DELETE FROM api_calls WHERE at < ?', [Date.now() - API_CALL_LOG_RETENTION_MS]);
1108
+ });
1109
+ }
1110
+ /** Logged API calls at or after `sinceMs`, oldest first. */
1111
+ recentApiCalls(sinceMs) {
1112
+ return this.db
1113
+ .all('SELECT * FROM api_calls WHERE at >= ? ORDER BY at, rowid', [sinceMs])
1114
+ .map((r) => ({
1115
+ at: Number(r.at),
1116
+ status: Number(r.status),
1117
+ remaining: num(r.remaining),
1118
+ limit: num(r.limit_hdr),
1119
+ reset_at: num(r.reset_at),
1120
+ retry_after_s: num(r.retry_after_s),
1121
+ }));
1122
+ }
617
1123
  getDistinctBeers(username, bids) {
618
1124
  if (bids.length === 0)
619
1125
  return [];
@@ -646,6 +1152,10 @@ export class CheckinStoreCore {
646
1152
  .all(`SELECT username FROM sync_state
647
1153
  UNION SELECT DISTINCT username FROM distinct_beers
648
1154
  UNION SELECT DISTINCT username FROM checkins
1155
+ UNION SELECT DISTINCT username FROM user_venues
1156
+ UNION SELECT username FROM list_sync_state WHERE list IN ('venues', 'wishlist', 'brewery_likes')
1157
+ UNION SELECT DISTINCT username FROM wishlist
1158
+ UNION SELECT DISTINCT username FROM brewery_likes
649
1159
  ORDER BY username`, [])
650
1160
  .map((r) => String(r.username));
651
1161
  const users = names.map((username) => {
@@ -658,6 +1168,9 @@ export class CheckinStoreCore {
658
1168
  coverage_complete: (st?.beers_complete ?? false) || (st?.backfill_complete ?? false),
659
1169
  beers_offset: st?.beers_offset ?? null,
660
1170
  last_synced_at: st?.last_synced_at ?? null,
1171
+ venues: this.venueCoverage(username),
1172
+ wishlist: this.wishlistCoverage(username),
1173
+ brewery_likes: this.breweryLikesCoverage(username),
661
1174
  };
662
1175
  });
663
1176
  return {
@@ -665,8 +1178,537 @@ export class CheckinStoreCore {
665
1178
  users,
666
1179
  resolution_cache_entries: n('SELECT COUNT(*) AS n FROM item_resolutions'),
667
1180
  beer_meta_entries: n('SELECT COUNT(*) AS n FROM beer_meta'),
1181
+ venue_entries: n('SELECT COUNT(*) AS n FROM venues'),
1182
+ brewery_lists: n('SELECT COUNT(*) AS n FROM brewery_beer_lists'),
1183
+ response_cache_entries: n('SELECT COUNT(*) AS n FROM response_cache'),
668
1184
  };
669
1185
  }
1186
+ /** The user's user/venues coverage, or null if their venue history was never synced. */
1187
+ venueCoverage(username) {
1188
+ // Literal keys: VENUES_LIST / VENUES_REFRESH_LIST in sync-venues.ts.
1189
+ const st = this.getListState(username, 'venues');
1190
+ const cached = this.userVenuesCount(username);
1191
+ if (!st && cached === 0)
1192
+ return null;
1193
+ const refresh = this.getListState(username, 'venues_refresh');
1194
+ const refreshing = (st?.complete ?? false) && !!refresh && !refresh.complete;
1195
+ const total = st?.total_count ?? null;
1196
+ const complete = (st?.complete ?? false) && !refreshing;
1197
+ return {
1198
+ cached_venues: cached,
1199
+ total_venues: total,
1200
+ complete,
1201
+ refresh_in_progress: refreshing,
1202
+ percent: total && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : complete ? 100 : null,
1203
+ next_offset: refreshing ? refresh.next_offset : (st?.next_offset ?? null),
1204
+ last_synced_at: st?.last_synced_at ?? null,
1205
+ };
1206
+ }
1207
+ getBreweryBeerList(breweryId) {
1208
+ const h = this.db.get('SELECT * FROM brewery_beer_lists WHERE brewery_id = ?', [breweryId]);
1209
+ if (!h)
1210
+ return undefined;
1211
+ const beers = this.db
1212
+ .all(`SELECT * FROM brewery_beers WHERE brewery_id = ?
1213
+ ORDER BY popularity_rank IS NULL, popularity_rank, checkin_count IS NULL, checkin_count DESC, bid`, [breweryId])
1214
+ .map((r) => ({
1215
+ brewery_id: Number(r.brewery_id),
1216
+ bid: Number(r.bid),
1217
+ beer_name: str(r.beer_name),
1218
+ style: str(r.style),
1219
+ abv: num(r.abv),
1220
+ ibu: num(r.ibu),
1221
+ rating_score: num(r.rating_score),
1222
+ rating_count: num(r.rating_count),
1223
+ checkin_count: num(r.checkin_count),
1224
+ in_production: r.in_production === null || r.in_production === undefined ? null : Number(r.in_production) !== 0,
1225
+ popularity_rank: num(r.popularity_rank),
1226
+ fetched_at: String(r.fetched_at),
1227
+ }));
1228
+ return {
1229
+ brewery_id: breweryId,
1230
+ brewery_name: str(h.brewery_name),
1231
+ total_count: num(h.total_count),
1232
+ ranked_count: Number(h.ranked_count ?? 0),
1233
+ fetched_at: String(h.fetched_at),
1234
+ beers,
1235
+ };
1236
+ }
1237
+ /**
1238
+ * Store one brewery/beer_list page in one transaction. `restart` (a stale or
1239
+ * refreshed list) drops the old rows and restarts the TTL clock; otherwise the
1240
+ * page is merged into the current snapshot, which keeps its fetched_at. A page
1241
+ * fetched in popularity order at an offset inside the ranked prefix extends it.
1242
+ */
1243
+ saveBreweryBeerPage(page) {
1244
+ this.db.transaction(() => {
1245
+ const cur = this.db.get('SELECT * FROM brewery_beer_lists WHERE brewery_id = ?', [page.brewery_id]);
1246
+ const fresh = page.restart || !cur;
1247
+ if (page.restart)
1248
+ this.db.run('DELETE FROM brewery_beers WHERE brewery_id = ?', [page.brewery_id]);
1249
+ let ranked = fresh ? 0 : Number(cur.ranked_count ?? 0);
1250
+ if (page.ranked_offset !== null && page.ranked_offset <= ranked) {
1251
+ ranked = Math.max(ranked, page.ranked_offset + page.item_count);
1252
+ }
1253
+ this.db.run(`INSERT INTO brewery_beer_lists (brewery_id, brewery_name, total_count, ranked_count, fetched_at)
1254
+ VALUES (?, ?, ?, ?, ?)
1255
+ ON CONFLICT(brewery_id) DO UPDATE SET
1256
+ brewery_name = COALESCE(excluded.brewery_name, brewery_beer_lists.brewery_name),
1257
+ total_count = COALESCE(excluded.total_count, brewery_beer_lists.total_count),
1258
+ ranked_count = excluded.ranked_count,
1259
+ fetched_at = excluded.fetched_at`, [
1260
+ page.brewery_id,
1261
+ page.brewery_name,
1262
+ page.total_count,
1263
+ ranked,
1264
+ fresh ? page.fetched_at : String(cur.fetched_at),
1265
+ ]);
1266
+ for (const r of page.rows) {
1267
+ this.db.run(`INSERT INTO brewery_beers (brewery_id, bid, beer_name, style, abv, ibu, rating_score, rating_count,
1268
+ checkin_count, in_production, popularity_rank, fetched_at)
1269
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1270
+ ON CONFLICT(brewery_id, bid) DO UPDATE SET
1271
+ beer_name = excluded.beer_name, style = excluded.style, abv = excluded.abv, ibu = excluded.ibu,
1272
+ rating_score = excluded.rating_score, rating_count = excluded.rating_count,
1273
+ checkin_count = excluded.checkin_count, in_production = excluded.in_production,
1274
+ popularity_rank = COALESCE(excluded.popularity_rank, brewery_beers.popularity_rank),
1275
+ fetched_at = excluded.fetched_at`, [
1276
+ r.brewery_id,
1277
+ r.bid,
1278
+ r.beer_name,
1279
+ r.style,
1280
+ r.abv,
1281
+ r.ibu,
1282
+ r.rating_score,
1283
+ r.rating_count,
1284
+ r.checkin_count,
1285
+ r.in_production === null ? null : r.in_production ? 1 : 0,
1286
+ r.popularity_rank,
1287
+ r.fetched_at,
1288
+ ]);
1289
+ }
1290
+ });
1291
+ }
1292
+ getCachedResponse(key) {
1293
+ const r = this.db.get('SELECT * FROM response_cache WHERE key = ?', [key]);
1294
+ if (!r)
1295
+ return undefined;
1296
+ let data;
1297
+ try {
1298
+ data = JSON.parse(String(r.data));
1299
+ }
1300
+ catch {
1301
+ return undefined;
1302
+ }
1303
+ return { key, kind: String(r.kind), data, fetched_at: String(r.fetched_at) };
1304
+ }
1305
+ putCachedResponse(key, kind, data, fetchedAt) {
1306
+ this.db.transaction(() => {
1307
+ this.db.run(`INSERT INTO response_cache (key, kind, data, fetched_at) VALUES (?, ?, ?, ?)
1308
+ ON CONFLICT(key) DO UPDATE SET kind = excluded.kind, data = excluded.data, fetched_at = excluded.fetched_at`, [key, kind, JSON.stringify(data ?? null), fetchedAt]);
1309
+ // These responses hold other people's recent check-ins; keep them only
1310
+ // as long as any TTL could still serve them.
1311
+ const cutoff = new Date(Date.parse(fetchedAt) - RESPONSE_CACHE_RETENTION_MS).toISOString();
1312
+ this.db.run('DELETE FROM response_cache WHERE fetched_at < ?', [cutoff]);
1313
+ });
1314
+ }
1315
+ upsertWishlist(username, rows, passStamp) {
1316
+ if (rows.length === 0)
1317
+ return 0;
1318
+ const key = username.toLowerCase();
1319
+ let added = 0;
1320
+ this.db.transaction(() => {
1321
+ const bids = rows.map((r) => r.bid);
1322
+ const known = new Set();
1323
+ for (const c of chunks(bids)) {
1324
+ for (const r of this.db.all(`SELECT bid FROM wishlist WHERE username = ? AND bid IN (${c.map(() => '?').join(', ')})`, [key, ...c])) {
1325
+ known.add(Number(r.bid));
1326
+ }
1327
+ }
1328
+ for (const r of rows) {
1329
+ if (!known.has(r.bid)) {
1330
+ added++;
1331
+ known.add(r.bid);
1332
+ }
1333
+ this.db.run(`INSERT INTO wishlist (username, bid, beer_name, brewery_id, brewery_name, style, abv, added_at, synced_at)
1334
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1335
+ ON CONFLICT(username, bid) DO UPDATE SET
1336
+ beer_name = excluded.beer_name, brewery_id = excluded.brewery_id, brewery_name = excluded.brewery_name,
1337
+ style = excluded.style, abv = excluded.abv, added_at = excluded.added_at, synced_at = excluded.synced_at`, [key, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.style, r.abv, r.added_at, passStamp]);
1338
+ }
1339
+ });
1340
+ return added;
1341
+ }
1342
+ pruneWishlist(username, passStamp) {
1343
+ let removed = 0;
1344
+ this.db.transaction(() => {
1345
+ const key = username.toLowerCase();
1346
+ removed = Number(this.db.get('SELECT COUNT(*) AS n FROM wishlist WHERE username = ? AND synced_at <> ?', [key, passStamp])?.n ?? 0);
1347
+ this.db.run('DELETE FROM wishlist WHERE username = ? AND synced_at <> ?', [key, passStamp]);
1348
+ });
1349
+ return removed;
1350
+ }
1351
+ wishlistCount(username) {
1352
+ return Number(this.db.get('SELECT COUNT(*) AS n FROM wishlist WHERE username = ?', [username.toLowerCase()])?.n ?? 0);
1353
+ }
1354
+ getWishlist(username, bids) {
1355
+ const key = username.toLowerCase();
1356
+ const map = (r) => ({
1357
+ bid: Number(r.bid),
1358
+ beer_name: str(r.beer_name),
1359
+ brewery_id: num(r.brewery_id),
1360
+ brewery_name: str(r.brewery_name),
1361
+ style: str(r.style),
1362
+ abv: num(r.abv),
1363
+ added_at: str(r.added_at),
1364
+ });
1365
+ if (bids === undefined) {
1366
+ return this.db.all('SELECT * FROM wishlist WHERE username = ? ORDER BY added_at DESC, bid', [key]).map(map);
1367
+ }
1368
+ const out = [];
1369
+ for (const c of chunks(bids)) {
1370
+ if (c.length === 0)
1371
+ continue;
1372
+ out.push(...this.db
1373
+ .all(`SELECT * FROM wishlist WHERE username = ? AND bid IN (${c.map(() => '?').join(', ')})`, [key, ...c])
1374
+ .map(map));
1375
+ }
1376
+ return out;
1377
+ }
1378
+ /** The user's wishlist mirror coverage, or null if it was never synced. */
1379
+ wishlistCoverage(username) {
1380
+ // Literal key: WISHLIST_LIST in sync-wishlist.ts.
1381
+ const st = this.getListState(username, 'wishlist');
1382
+ const cached = this.wishlistCount(username);
1383
+ if (!st && cached === 0)
1384
+ return null;
1385
+ return {
1386
+ cached,
1387
+ total: st?.total_count ?? null,
1388
+ complete: st?.complete ?? false,
1389
+ next_offset: st?.next_offset ?? null,
1390
+ last_synced_at: st?.last_synced_at ?? null,
1391
+ };
1392
+ }
1393
+ upsertBreweryLikes(username, rows, passStamp) {
1394
+ if (rows.length === 0)
1395
+ return 0;
1396
+ const key = username.toLowerCase();
1397
+ let added = 0;
1398
+ this.db.transaction(() => {
1399
+ const known = new Set();
1400
+ for (const c of chunks(rows.map((r) => r.brewery_id))) {
1401
+ for (const r of this.db.all(`SELECT brewery_id FROM brewery_likes WHERE username = ? AND brewery_id IN (${c.map(() => '?').join(', ')})`, [key, ...c])) {
1402
+ known.add(Number(r.brewery_id));
1403
+ }
1404
+ }
1405
+ for (const r of rows) {
1406
+ if (!known.has(r.brewery_id)) {
1407
+ added++;
1408
+ known.add(r.brewery_id);
1409
+ }
1410
+ this.db.run(`INSERT INTO brewery_likes (username, brewery_id, brewery_name, brewery_type, country, city, state, lat, lng, liked_at, synced_at)
1411
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1412
+ ON CONFLICT(username, brewery_id) DO UPDATE SET
1413
+ brewery_name = excluded.brewery_name, brewery_type = excluded.brewery_type, country = excluded.country,
1414
+ city = excluded.city, state = excluded.state, lat = excluded.lat, lng = excluded.lng,
1415
+ liked_at = excluded.liked_at, synced_at = excluded.synced_at`, [key, r.brewery_id, r.brewery_name, r.brewery_type, r.country, r.city, r.state, r.lat, r.lng, r.liked_at, passStamp]);
1416
+ }
1417
+ });
1418
+ return added;
1419
+ }
1420
+ pruneBreweryLikes(username, passStamp) {
1421
+ let removed = 0;
1422
+ this.db.transaction(() => {
1423
+ const key = username.toLowerCase();
1424
+ removed = Number(this.db.get('SELECT COUNT(*) AS n FROM brewery_likes WHERE username = ? AND synced_at <> ?', [key, passStamp])?.n ?? 0);
1425
+ this.db.run('DELETE FROM brewery_likes WHERE username = ? AND synced_at <> ?', [key, passStamp]);
1426
+ });
1427
+ return removed;
1428
+ }
1429
+ breweryLikesCount(username) {
1430
+ return Number(this.db.get('SELECT COUNT(*) AS n FROM brewery_likes WHERE username = ?', [username.toLowerCase()])?.n ?? 0);
1431
+ }
1432
+ getBreweryLikes(username, breweryIds) {
1433
+ const key = username.toLowerCase();
1434
+ const map = (r) => ({
1435
+ brewery_id: Number(r.brewery_id),
1436
+ brewery_name: str(r.brewery_name),
1437
+ brewery_type: str(r.brewery_type),
1438
+ country: str(r.country),
1439
+ city: str(r.city),
1440
+ state: str(r.state),
1441
+ lat: num(r.lat),
1442
+ lng: num(r.lng),
1443
+ liked_at: str(r.liked_at),
1444
+ });
1445
+ const order = 'ORDER BY liked_at IS NULL, liked_at DESC, brewery_id';
1446
+ if (breweryIds === undefined) {
1447
+ return this.db.all(`SELECT * FROM brewery_likes WHERE username = ? ${order}`, [key]).map(map);
1448
+ }
1449
+ const out = [];
1450
+ for (const c of chunks(breweryIds)) {
1451
+ if (c.length === 0)
1452
+ continue;
1453
+ out.push(...this.db
1454
+ .all(`SELECT * FROM brewery_likes WHERE username = ? AND brewery_id IN (${c.map(() => '?').join(', ')}) ${order}`, [key, ...c])
1455
+ .map(map));
1456
+ }
1457
+ return out;
1458
+ }
1459
+ /** The user's Untappd brewery-likes coverage, or null if it was never synced. */
1460
+ breweryLikesCoverage(username) {
1461
+ // Literal key: BREWERY_LIKES_LIST in sync-brewery-likes.ts.
1462
+ const st = this.getListState(username, 'brewery_likes');
1463
+ const cached = this.breweryLikesCount(username);
1464
+ if (!st && cached === 0)
1465
+ return null;
1466
+ const total = st?.total_count ?? null;
1467
+ const complete = st?.complete ?? false;
1468
+ return {
1469
+ cached,
1470
+ total,
1471
+ complete,
1472
+ percent: total && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : complete ? 100 : null,
1473
+ next_offset: st?.next_offset ?? null,
1474
+ last_synced_at: st?.last_synced_at ?? null,
1475
+ };
1476
+ }
1477
+ venueDrinkCandidates(username, opts = {}) {
1478
+ const key = username.toLowerCase();
1479
+ const which = opts.which ?? 'both';
1480
+ const venueFilter = (ids) => (ids.length ? ` AND u.venue_id IN (${ids.map(() => '?').join(', ')})` : '');
1481
+ const parts = [];
1482
+ if (which !== 'recent')
1483
+ parts.push({ col: 'first_checkin_id', kind: 'first' });
1484
+ if (which !== 'first')
1485
+ parts.push({ col: 'recent_checkin_id', kind: 'recent' });
1486
+ const idSets = opts.venueIds && opts.venueIds.length > 0 ? chunks([...new Set(opts.venueIds)]) : [[]];
1487
+ const rows = [];
1488
+ for (const ids of idSets) {
1489
+ for (const { col, kind } of parts) {
1490
+ rows.push(...this.db
1491
+ .all(`SELECT u.venue_id AS venue_id, u.${col} AS checkin_id, u.total_count AS total_count, u.recent_visit AS recent_visit
1492
+ FROM user_venues u
1493
+ WHERE u.username = ? AND u.${col} IS NOT NULL${venueFilter(ids)}
1494
+ AND NOT EXISTS (SELECT 1 FROM venue_drinks d WHERE d.username = u.username AND d.checkin_id = u.${col})`, [key, ...ids])
1495
+ .map((r) => ({ ...r, kind })));
1496
+ }
1497
+ }
1498
+ rows.sort((a, b) => Number(b.total_count ?? 0) - Number(a.total_count ?? 0) ||
1499
+ String(b.recent_visit ?? '').localeCompare(String(a.recent_visit ?? '')) ||
1500
+ Number(a.venue_id) - Number(b.venue_id) ||
1501
+ (a.kind === b.kind ? 0 : a.kind === 'first' ? -1 : 1));
1502
+ const seen = new Set();
1503
+ const out = [];
1504
+ for (const r of rows) {
1505
+ const id = Number(r.checkin_id);
1506
+ if (seen.has(id))
1507
+ continue;
1508
+ seen.add(id);
1509
+ out.push({ venue_id: Number(r.venue_id), checkin_id: id, kind: r.kind });
1510
+ }
1511
+ return out;
1512
+ }
1513
+ upsertVenueDrinks(username, rows, fetchedAt) {
1514
+ if (rows.length === 0)
1515
+ return;
1516
+ const key = username.toLowerCase();
1517
+ const sql = `
1518
+ INSERT INTO venue_drinks (username, venue_id, checkin_id, bid, beer_name, brewery_id, brewery_name, created_at, fetched_at)
1519
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1520
+ ON CONFLICT(username, checkin_id) DO UPDATE SET
1521
+ venue_id=excluded.venue_id, bid=excluded.bid, beer_name=excluded.beer_name, brewery_id=excluded.brewery_id,
1522
+ brewery_name=excluded.brewery_name, created_at=excluded.created_at, fetched_at=excluded.fetched_at`;
1523
+ this.db.transaction(() => {
1524
+ for (const r of rows) {
1525
+ this.db.run(sql, [key, r.venue_id, r.checkin_id, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.created_at, fetchedAt]);
1526
+ }
1527
+ });
1528
+ }
1529
+ getVenueDrinks(username, venueIds) {
1530
+ const key = username.toLowerCase();
1531
+ const out = [];
1532
+ for (const ids of chunks([...new Set(venueIds)])) {
1533
+ if (ids.length === 0)
1534
+ continue;
1535
+ const rows = this.db.all(`SELECT venue_id, checkin_id, bid, beer_name, brewery_id, brewery_name, created_at FROM venue_drinks
1536
+ WHERE username = ? AND venue_id IN (${ids.map(() => '?').join(', ')})
1537
+ AND NOT (bid IS NULL AND beer_name IS NULL)`, [key, ...ids]);
1538
+ for (const r of rows) {
1539
+ out.push({
1540
+ venue_id: Number(r.venue_id),
1541
+ checkin_id: Number(r.checkin_id),
1542
+ bid: num(r.bid),
1543
+ beer_name: str(r.beer_name),
1544
+ brewery_id: num(r.brewery_id),
1545
+ brewery_name: str(r.brewery_name),
1546
+ created_at: str(r.created_at),
1547
+ });
1548
+ }
1549
+ }
1550
+ return out.sort((a, b) => String(b.created_at ?? '').localeCompare(String(a.created_at ?? '')) || b.checkin_id - a.checkin_id);
1551
+ }
1552
+ addFavorite(username, kind, id, note, createdAt) {
1553
+ const key = username.toLowerCase();
1554
+ let out = { added: false, note: null, created_at: createdAt };
1555
+ this.db.transaction(() => {
1556
+ const cur = this.db.get('SELECT note, created_at FROM favorites WHERE username = ? AND kind = ? AND id = ?', [key, kind, id]);
1557
+ if (!cur) {
1558
+ const n = note ?? null;
1559
+ this.db.run('INSERT INTO favorites (username, kind, id, note, created_at) VALUES (?, ?, ?, ?, ?)', [key, kind, id, n, createdAt]);
1560
+ out = { added: true, note: n, created_at: createdAt };
1561
+ return;
1562
+ }
1563
+ const n = note === undefined ? str(cur.note) : note;
1564
+ this.db.run('UPDATE favorites SET note = ? WHERE username = ? AND kind = ? AND id = ?', [n, key, kind, id]);
1565
+ out = { added: false, note: n, created_at: String(cur.created_at) };
1566
+ });
1567
+ return out;
1568
+ }
1569
+ removeFavorite(username, kind, id) {
1570
+ let removed = false;
1571
+ this.db.transaction(() => {
1572
+ const key = username.toLowerCase();
1573
+ removed = !!this.db.get('SELECT 1 AS x FROM favorites WHERE username = ? AND kind = ? AND id = ?', [key, kind, id]);
1574
+ if (removed)
1575
+ this.db.run('DELETE FROM favorites WHERE username = ? AND kind = ? AND id = ?', [key, kind, id]);
1576
+ });
1577
+ return removed;
1578
+ }
1579
+ getManualFavorites(username, opts = {}) {
1580
+ const key = username.toLowerCase();
1581
+ // Best-effort display name from whatever the cache already holds.
1582
+ const nameSql = `CASE f.kind
1583
+ WHEN 'beer' THEN COALESCE(
1584
+ (SELECT beer_name FROM distinct_beers d WHERE d.username = f.username AND d.bid = f.id),
1585
+ (SELECT name FROM beer_meta m WHERE m.bid = f.id),
1586
+ (SELECT beer_name FROM checkins c WHERE c.username = f.username AND c.bid = f.id LIMIT 1),
1587
+ (SELECT beer_name FROM wishlist w WHERE w.username = f.username AND w.bid = f.id))
1588
+ WHEN 'brewery' THEN COALESCE(
1589
+ (SELECT brewery_name FROM brewery_beer_lists l WHERE l.brewery_id = f.id),
1590
+ (SELECT brewery_name FROM distinct_beers d WHERE d.username = f.username AND d.brewery_id = f.id LIMIT 1),
1591
+ (SELECT brewery FROM beer_meta m WHERE m.brewery_id = f.id LIMIT 1),
1592
+ (SELECT brewery_name FROM checkins c WHERE c.username = f.username AND c.brewery_id = f.id LIMIT 1))
1593
+ ELSE (SELECT name FROM venues v WHERE v.venue_id = f.id) END`;
1594
+ const where = ['f.username = ?'];
1595
+ const params = [key];
1596
+ if (opts.kind) {
1597
+ where.push('f.kind = ?');
1598
+ params.push(opts.kind);
1599
+ }
1600
+ const run = (extra, extraParams) => this.db
1601
+ .all(`SELECT f.kind AS kind, f.id AS id, f.note AS note, f.created_at AS created_at, ${nameSql} AS name
1602
+ FROM favorites f WHERE ${[...where, ...(extra ? [extra] : [])].join(' AND ')}`, [...params, ...extraParams])
1603
+ .map((r) => ({
1604
+ kind: String(r.kind),
1605
+ id: Number(r.id),
1606
+ note: str(r.note),
1607
+ created_at: String(r.created_at),
1608
+ name: str(r.name),
1609
+ }));
1610
+ let rows;
1611
+ if (opts.ids === undefined)
1612
+ rows = run('', []);
1613
+ else {
1614
+ rows = [];
1615
+ for (const c of chunks([...new Set(opts.ids)])) {
1616
+ if (c.length)
1617
+ rows.push(...run(`f.id IN (${c.map(() => '?').join(', ')})`, c));
1618
+ }
1619
+ }
1620
+ return rows.sort((a, b) => b.created_at.localeCompare(a.created_at) || a.kind.localeCompare(b.kind) || a.id - b.id);
1621
+ }
1622
+ /**
1623
+ * One row per distinct beer the user has had, from BOTH sources: user/beers
1624
+ * (rating = the user's rating, times = had_count) and check-ins for beers
1625
+ * user/beers doesn't hold. `best` is the max rating across both (as
1626
+ * hasHadMany), `avg_basis` the rating used for brewery averages.
1627
+ */
1628
+ perBeerSql(bidFilter) {
1629
+ return `WITH c AS (
1630
+ SELECT bid, COUNT(*) AS n, MAX(NULLIF(rating, 0)) AS best, AVG(NULLIF(rating, 0)) AS avg_r,
1631
+ MAX(beer_name) AS beer_name, MAX(brewery_id) AS brewery_id, MAX(brewery_name) AS brewery_name
1632
+ FROM checkins WHERE username = ? AND bid IS NOT NULL${bidFilter} GROUP BY bid),
1633
+ d AS (
1634
+ SELECT bid, had_count, NULLIF(rating, 0) AS rating, beer_name, brewery_id, brewery_name
1635
+ FROM distinct_beers WHERE username = ?${bidFilter}),
1636
+ ids AS (SELECT bid FROM c UNION SELECT bid FROM d)
1637
+ SELECT ids.bid AS bid,
1638
+ COALESCE(d.beer_name, c.beer_name) AS beer_name,
1639
+ COALESCE(d.brewery_id, c.brewery_id) AS brewery_id,
1640
+ COALESCE(d.brewery_name, c.brewery_name) AS brewery_name,
1641
+ CASE WHEN d.rating IS NULL THEN c.best WHEN c.best IS NULL THEN d.rating
1642
+ WHEN d.rating > c.best THEN d.rating ELSE c.best END AS best,
1643
+ COALESCE(d.rating, c.avg_r) AS avg_basis,
1644
+ COALESCE(d.had_count, c.n, 1) AS times_had
1645
+ FROM ids LEFT JOIN d ON d.bid = ids.bid LEFT JOIN c ON c.bid = ids.bid`;
1646
+ }
1647
+ breweryHadStats(username, breweryIds) {
1648
+ const key = username.toLowerCase();
1649
+ const sql = (filter) => `SELECT brewery_id, MAX(brewery_name) AS brewery_name, COUNT(*) AS beers, COUNT(avg_basis) AS rated,
1650
+ AVG(avg_basis) AS avg_rating
1651
+ FROM (${this.perBeerSql('')}) WHERE brewery_id IS NOT NULL${filter} GROUP BY brewery_id`;
1652
+ const map = (r) => ({
1653
+ brewery_id: Number(r.brewery_id),
1654
+ brewery_name: str(r.brewery_name),
1655
+ beers: Number(r.beers),
1656
+ rated_beers: Number(r.rated),
1657
+ avg_rating: num(r.avg_rating),
1658
+ });
1659
+ if (breweryIds === undefined)
1660
+ return this.db.all(sql(''), [key, key]).map(map);
1661
+ const out = [];
1662
+ for (const c of chunks([...new Set(breweryIds)])) {
1663
+ if (c.length)
1664
+ out.push(...this.db.all(sql(` AND brewery_id IN (${c.map(() => '?').join(', ')})`), [key, key, ...c]).map(map));
1665
+ }
1666
+ return out;
1667
+ }
1668
+ beerHadStats(username, opts) {
1669
+ const key = username.toLowerCase();
1670
+ const map = (r) => ({
1671
+ bid: Number(r.bid),
1672
+ beer_name: str(r.beer_name),
1673
+ brewery_id: num(r.brewery_id),
1674
+ brewery_name: str(r.brewery_name),
1675
+ rating: num(r.best),
1676
+ times_had: Number(r.times_had),
1677
+ });
1678
+ if (opts.bids !== undefined) {
1679
+ const out = [];
1680
+ for (const c of chunks([...new Set(opts.bids)])) {
1681
+ if (c.length === 0)
1682
+ continue;
1683
+ const f = ` AND bid IN (${c.map(() => '?').join(', ')})`;
1684
+ out.push(...this.db.all(this.perBeerSql(f), [key, ...c, key, ...c]).map(map));
1685
+ }
1686
+ return out;
1687
+ }
1688
+ return this.db
1689
+ .all(`SELECT * FROM (${this.perBeerSql('')}) WHERE best >= ? OR times_had >= ?`, [
1690
+ key,
1691
+ key,
1692
+ opts.minRating ?? Number.MAX_VALUE,
1693
+ opts.minTimesHad ?? Number.MAX_SAFE_INTEGER,
1694
+ ])
1695
+ .map(map);
1696
+ }
1697
+ venueVisitStats(username, opts) {
1698
+ const key = username.toLowerCase();
1699
+ const base = `SELECT u.*, v.name, v.category, v.address, v.city, v.state, v.country, v.lat, v.lng, v.is_verified
1700
+ FROM user_venues u LEFT JOIN venues v ON v.venue_id = u.venue_id
1701
+ WHERE u.username = ? AND COALESCE(u.total_count, 0) >= ?`;
1702
+ const min = opts.minCheckins ?? 0;
1703
+ if (opts.venueIds === undefined)
1704
+ return this.db.all(base, [key, min]).map(visitedFrom);
1705
+ const out = [];
1706
+ for (const c of chunks([...new Set(opts.venueIds)])) {
1707
+ if (c.length)
1708
+ out.push(...this.db.all(`${base} AND u.venue_id IN (${c.map(() => '?').join(', ')})`, [key, min, ...c]).map(visitedFrom));
1709
+ }
1710
+ return out;
1711
+ }
670
1712
  getBeerMeta(bids) {
671
1713
  if (bids.length === 0)
672
1714
  return [];
@@ -759,6 +1801,99 @@ export class LocalCacheStore {
759
1801
  async stats() {
760
1802
  return this.core.stats();
761
1803
  }
1804
+ async upsertUserVenues(username, items) {
1805
+ return this.core.upsertUserVenues(username, items);
1806
+ }
1807
+ async getUserVenueVisits(username, venueIds) {
1808
+ return this.core.getUserVenueVisits(username, venueIds);
1809
+ }
1810
+ async userVenuesCount(username) {
1811
+ return this.core.userVenuesCount(username);
1812
+ }
1813
+ async getVenues(venueIds) {
1814
+ return this.core.getVenues(venueIds);
1815
+ }
1816
+ async getListState(username, list) {
1817
+ return this.core.getListState(username, list);
1818
+ }
1819
+ async setListState(username, list, patch) {
1820
+ this.core.setListState(username, list, patch);
1821
+ }
1822
+ async findVisitedVenues(username, q) {
1823
+ return this.core.findVisitedVenues(username, q);
1824
+ }
1825
+ async getBreweryBeerList(breweryId) {
1826
+ return this.core.getBreweryBeerList(breweryId);
1827
+ }
1828
+ async saveBreweryBeerPage(page) {
1829
+ this.core.saveBreweryBeerPage(page);
1830
+ }
1831
+ async getCachedResponse(key) {
1832
+ return this.core.getCachedResponse(key);
1833
+ }
1834
+ async putCachedResponse(key, kind, data, fetchedAt) {
1835
+ this.core.putCachedResponse(key, kind, data, fetchedAt);
1836
+ }
1837
+ async upsertWishlist(username, rows, passStamp) {
1838
+ return this.core.upsertWishlist(username, rows, passStamp);
1839
+ }
1840
+ async pruneWishlist(username, passStamp) {
1841
+ return this.core.pruneWishlist(username, passStamp);
1842
+ }
1843
+ async wishlistCount(username) {
1844
+ return this.core.wishlistCount(username);
1845
+ }
1846
+ async getWishlist(username, bids) {
1847
+ return this.core.getWishlist(username, bids);
1848
+ }
1849
+ async upsertBreweryLikes(username, rows, passStamp) {
1850
+ return this.core.upsertBreweryLikes(username, rows, passStamp);
1851
+ }
1852
+ async pruneBreweryLikes(username, passStamp) {
1853
+ return this.core.pruneBreweryLikes(username, passStamp);
1854
+ }
1855
+ async breweryLikesCount(username) {
1856
+ return this.core.breweryLikesCount(username);
1857
+ }
1858
+ async getBreweryLikes(username, breweryIds) {
1859
+ return this.core.getBreweryLikes(username, breweryIds);
1860
+ }
1861
+ async breweryLikesCoverage(username) {
1862
+ return this.core.breweryLikesCoverage(username);
1863
+ }
1864
+ async venueDrinkCandidates(username, opts) {
1865
+ return this.core.venueDrinkCandidates(username, opts);
1866
+ }
1867
+ async upsertVenueDrinks(username, rows, fetchedAt) {
1868
+ this.core.upsertVenueDrinks(username, rows, fetchedAt);
1869
+ }
1870
+ async getVenueDrinks(username, venueIds) {
1871
+ return this.core.getVenueDrinks(username, venueIds);
1872
+ }
1873
+ async addFavorite(username, kind, id, note, createdAt) {
1874
+ return this.core.addFavorite(username, kind, id, note, createdAt);
1875
+ }
1876
+ async removeFavorite(username, kind, id) {
1877
+ return this.core.removeFavorite(username, kind, id);
1878
+ }
1879
+ async getManualFavorites(username, opts) {
1880
+ return this.core.getManualFavorites(username, opts);
1881
+ }
1882
+ async breweryHadStats(username, breweryIds) {
1883
+ return this.core.breweryHadStats(username, breweryIds);
1884
+ }
1885
+ async beerHadStats(username, opts) {
1886
+ return this.core.beerHadStats(username, opts);
1887
+ }
1888
+ async venueVisitStats(username, opts) {
1889
+ return this.core.venueVisitStats(username, opts);
1890
+ }
1891
+ async recordApiCalls(rows) {
1892
+ this.core.recordApiCalls(rows);
1893
+ }
1894
+ async recentApiCalls(sinceMs) {
1895
+ return this.core.recentApiCalls(sinceMs);
1896
+ }
762
1897
  }
763
1898
  // SQLite caps bound parameters per statement (999 on older builds); IN-lists
764
1899
  // are split well under that.
@@ -777,3 +1912,59 @@ const LIKE_MATCH = "LIKE ? COLLATE NOCASE ESCAPE '\\'";
777
1912
  function escapeLike(s) {
778
1913
  return s.replace(/[\\%_]/g, (c) => `\\${c}`);
779
1914
  }
1915
+ function visitFrom(r) {
1916
+ return {
1917
+ venue_id: Number(r.venue_id),
1918
+ first_visit: str(r.first_visit),
1919
+ first_checkin_id: num(r.first_checkin_id),
1920
+ recent_visit: str(r.recent_visit),
1921
+ recent_checkin_id: num(r.recent_checkin_id),
1922
+ total_count: num(r.total_count),
1923
+ };
1924
+ }
1925
+ function visitedFrom(r) {
1926
+ const name = str(r.name);
1927
+ return {
1928
+ name,
1929
+ category: str(r.category),
1930
+ address: str(r.address),
1931
+ city: str(r.city),
1932
+ state: str(r.state),
1933
+ country: str(r.country),
1934
+ lat: num(r.lat),
1935
+ lng: num(r.lng),
1936
+ is_verified: num(r.is_verified),
1937
+ ...visitFrom(r),
1938
+ is_duplicate: name !== null && /\(duplicate of\b/i.test(name),
1939
+ };
1940
+ }
1941
+ // Words that don't distinguish one venue name from another ("Green Bench
1942
+ // Brewing Co." vs "Green Bench Brewing Company").
1943
+ const NAME_STOP_WORDS = new Set(['co', 'company', 'inc', 'llc', 'ltd', 'the', 'and']);
1944
+ /** Lowercased alphanumeric words of a name, minus {@link NAME_STOP_WORDS}. */
1945
+ function significantTokens(name) {
1946
+ return wordsOf(name).filter((w) => !NAME_STOP_WORDS.has(w));
1947
+ }
1948
+ function wordsOf(s) {
1949
+ return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 0);
1950
+ }
1951
+ /** See {@link CheckinStoreCore.findVisitedVenues}: literal substring, or every significant word whole. */
1952
+ function nameMatches(query, tokens, venueName) {
1953
+ if (venueName === null)
1954
+ return false;
1955
+ if (venueName.toLowerCase().includes(query.toLowerCase()))
1956
+ return true;
1957
+ if (tokens.length === 0)
1958
+ return false;
1959
+ const words = new Set(wordsOf(venueName));
1960
+ return tokens.every((t) => words.has(t));
1961
+ }
1962
+ /** Great-circle distance in metres. */
1963
+ export function haversineM(lat1, lng1, lat2, lng2) {
1964
+ const R = 6_371_008.8;
1965
+ const rad = Math.PI / 180;
1966
+ const dLat = (lat2 - lat1) * rad;
1967
+ const dLng = (lng2 - lng1) * rad;
1968
+ const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLng / 2) ** 2;
1969
+ return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
1970
+ }