untappd-mcp 2.4.0 → 2.5.1
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +74 -2
- package/dist/bundle.js +5440 -1664
- package/dist/cache/brewery-beers.js +116 -0
- package/dist/cache/favorites.js +244 -0
- package/dist/cache/multi-user.js +61 -0
- package/dist/cache/response-cache.js +23 -0
- package/dist/cache/store.js +1203 -12
- package/dist/cache/sync-beers.js +24 -2
- package/dist/cache/sync-brewery-likes.js +125 -0
- package/dist/cache/sync-venues.js +200 -0
- package/dist/cache/sync-wishlist.js +126 -0
- package/dist/cache/sync.js +61 -12
- package/dist/cache/venue-drinks.js +101 -0
- package/dist/client.js +29 -15
- package/dist/compact.js +49 -42
- package/dist/index.js +20 -3
- package/dist/menu/pick.js +7 -1
- package/dist/ratelimit.js +236 -0
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +457 -35
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +92 -38
- package/dist/version.js +1 -1
- package/mint.yaml +5 -0
- package/package.json +1 -1
- package/server.json +9 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
package/dist/cache/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
+
}
|