- Add visited_regions table migration - Mark/unmark region endpoints with auto-mark parent country - Unmark country cascades to its regions; unmark last region cascades to country - Region modal with mark/unmark flow and bucket list shortcut - Viewport-based lazy loading of region GeoJSON at zoom >= 6 - i18n: add atlas.markRegionVisitedHint and atlas.confirmUnmarkRegion across all 13 locales
537 lines
26 KiB
TypeScript
537 lines
26 KiB
TypeScript
import fetch from 'node-fetch';
|
|
import { db } from '../db/database';
|
|
import { Trip, Place } from '../types';
|
|
|
|
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
|
|
|
|
let admin1GeoCache: any = null;
|
|
let admin1GeoLoading: Promise<any> | null = null;
|
|
|
|
async function loadAdmin1Geo(): Promise<any> {
|
|
if (admin1GeoCache) return admin1GeoCache;
|
|
if (admin1GeoLoading) return admin1GeoLoading;
|
|
admin1GeoLoading = fetch(
|
|
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
|
|
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
|
).then(r => r.json()).then(geo => {
|
|
admin1GeoCache = geo;
|
|
admin1GeoLoading = null;
|
|
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
|
|
return geo;
|
|
}).catch(err => {
|
|
admin1GeoLoading = null;
|
|
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
|
|
return null;
|
|
});
|
|
return admin1GeoLoading;
|
|
}
|
|
|
|
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
|
const geo = await loadAdmin1Geo();
|
|
if (!geo) return { type: 'FeatureCollection', features: [] };
|
|
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
|
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
|
return { type: 'FeatureCollection', features };
|
|
}
|
|
|
|
// ── Geocode cache ───────────────────────────────────────────────────────────
|
|
|
|
const geocodeCache = new Map<string, string | null>();
|
|
|
|
function roundKey(lat: number, lng: number): string {
|
|
return `${lat.toFixed(3)},${lng.toFixed(3)}`;
|
|
}
|
|
|
|
function cacheKey(lat: number, lng: number): string {
|
|
return roundKey(lat, lng);
|
|
}
|
|
|
|
export function getCached(lat: number, lng: number): string | null | undefined {
|
|
const key = cacheKey(lat, lng);
|
|
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
|
|
return undefined;
|
|
}
|
|
|
|
export function setCache(lat: number, lng: number, code: string | null): void {
|
|
geocodeCache.set(cacheKey(lat, lng), code);
|
|
}
|
|
|
|
// Periodically trim the cache so it doesn't grow unbounded
|
|
const CACHE_MAX = 50_000;
|
|
const CACHE_CLEANUP_MS = 10 * 60 * 1000;
|
|
setInterval(() => {
|
|
if (geocodeCache.size > CACHE_MAX) {
|
|
const keys = [...geocodeCache.keys()];
|
|
const toDelete = keys.slice(0, keys.length - CACHE_MAX);
|
|
for (const k of toDelete) geocodeCache.delete(k);
|
|
}
|
|
}, CACHE_CLEANUP_MS).unref();
|
|
|
|
// ── Bounding-box lookup tables ──────────────────────────────────────────────
|
|
|
|
export const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
|
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
|
|
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
|
|
BD:[88.0,20.7,92.7,26.6],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
|
|
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
|
|
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
|
|
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
|
|
IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],
|
|
JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KR:[126,33.2,129.6,38.6],LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],
|
|
LU:[5.7,49.4,6.5,50.2],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MA:[-13.2,27.7,-1,35.9],NL:[3.4,50.8,7.2,53.5],
|
|
NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],
|
|
PL:[14.1,49,24.1,54.9],PT:[-9.5,36.8,-6.2,42.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],SA:[34.6,16.4,55.7,32.2],
|
|
RS:[18.8,42.2,23,46.2],SK:[16.8,47.7,22.6,49.6],SI:[13.4,45.4,16.6,46.9],ZA:[16.5,-34.8,32.9,-22.1],ES:[-9.4,36,-0.2,43.8],
|
|
SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4],
|
|
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],
|
|
};
|
|
|
|
export const NAME_TO_CODE: Record<string, string> = {
|
|
'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
|
|
'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
|
|
'usa':'US','netherlands':'NL','niederlande':'NL','austria':'AT','osterreich':'AT','switzerland':'CH',
|
|
'schweiz':'CH','portugal':'PT','greece':'GR','griechenland':'GR','turkey':'TR','turkei':'TR',
|
|
'croatia':'HR','kroatien':'HR','czech republic':'CZ','tschechien':'CZ','czechia':'CZ',
|
|
'poland':'PL','polen':'PL','sweden':'SE','schweden':'SE','norway':'NO','norwegen':'NO',
|
|
'denmark':'DK','danemark':'DK','finland':'FI','finnland':'FI','belgium':'BE','belgien':'BE',
|
|
'ireland':'IE','irland':'IE','hungary':'HU','ungarn':'HU','romania':'RO','rumanien':'RO',
|
|
'bulgaria':'BG','bulgarien':'BG','japan':'JP','china':'CN','australia':'AU','australien':'AU',
|
|
'canada':'CA','kanada':'CA','mexico':'MX','mexiko':'MX','brazil':'BR','brasilien':'BR',
|
|
'argentina':'AR','argentinien':'AR','thailand':'TH','indonesia':'ID','indonesien':'ID',
|
|
'india':'IN','indien':'IN','egypt':'EG','agypten':'EG','morocco':'MA','marokko':'MA',
|
|
'south africa':'ZA','sudafrika':'ZA','new zealand':'NZ','neuseeland':'NZ','iceland':'IS','island':'IS',
|
|
'luxembourg':'LU','luxemburg':'LU','slovenia':'SI','slowenien':'SI','slovakia':'SK','slowakei':'SK',
|
|
'estonia':'EE','estland':'EE','latvia':'LV','lettland':'LV','lithuania':'LT','litauen':'LT',
|
|
'serbia':'RS','serbien':'RS','israel':'IL','russia':'RU','russland':'RU','ukraine':'UA',
|
|
'vietnam':'VN','south korea':'KR','sudkorea':'KR','philippines':'PH','philippinen':'PH',
|
|
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
|
|
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
|
|
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
|
|
};
|
|
|
|
export const CONTINENT_MAP: Record<string, string> = {
|
|
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
|
|
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
|
|
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
|
|
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
|
|
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
|
|
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
|
|
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
|
|
};
|
|
|
|
// ── Geocoding helpers ───────────────────────────────────────────────────────
|
|
|
|
export async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
|
|
const key = roundKey(lat, lng);
|
|
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
|
|
try {
|
|
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, {
|
|
headers: { 'User-Agent': 'TREK Travel Planner' },
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = await res.json() as { address?: { country_code?: string } };
|
|
const code = data.address?.country_code?.toUpperCase() || null;
|
|
geocodeCache.set(key, code);
|
|
return code;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getCountryFromCoords(lat: number, lng: number): string | null {
|
|
let bestCode: string | null = null;
|
|
let bestArea = Infinity;
|
|
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
|
|
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
|
|
const area = (maxLng - minLng) * (maxLat - minLat);
|
|
if (area < bestArea) {
|
|
bestArea = area;
|
|
bestCode = code;
|
|
}
|
|
}
|
|
}
|
|
return bestCode;
|
|
}
|
|
|
|
export function getCountryFromAddress(address: string | null): string | null {
|
|
if (!address) return null;
|
|
const parts = address.split(',').map(s => s.trim()).filter(Boolean);
|
|
if (parts.length === 0) return null;
|
|
const last = parts[parts.length - 1];
|
|
const normalized = last.toLowerCase();
|
|
if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized];
|
|
if (NAME_TO_CODE[last]) return NAME_TO_CODE[last];
|
|
if (last.length === 2 && last === last.toUpperCase()) return last;
|
|
return null;
|
|
}
|
|
|
|
// ── Resolve a place to a country code (address -> geocode -> bbox) ──────────
|
|
|
|
async function resolveCountryCode(place: Place): Promise<string | null> {
|
|
let code = getCountryFromAddress(place.address);
|
|
if (!code && place.lat && place.lng) {
|
|
code = await reverseGeocodeCountry(place.lat, place.lng);
|
|
}
|
|
if (!code && place.lat && place.lng) {
|
|
code = getCountryFromCoords(place.lat, place.lng);
|
|
}
|
|
return code;
|
|
}
|
|
|
|
function resolveCountryCodeSync(place: Place): string | null {
|
|
let code = getCountryFromAddress(place.address);
|
|
if (!code && place.lat && place.lng) {
|
|
code = getCountryFromCoords(place.lat, place.lng);
|
|
}
|
|
return code;
|
|
}
|
|
|
|
// ── Shared query: all trips the user owns or is a member of ─────────────────
|
|
|
|
function getUserTrips(userId: number): Trip[] {
|
|
return db.prepare(`
|
|
SELECT DISTINCT t.* FROM trips t
|
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
|
WHERE t.user_id = ? OR m.user_id = ?
|
|
ORDER BY t.start_date DESC
|
|
`).all(userId, userId, userId) as Trip[];
|
|
}
|
|
|
|
function getPlacesForTrips(tripIds: number[]): Place[] {
|
|
if (tripIds.length === 0) return [];
|
|
const placeholders = tripIds.map(() => '?').join(',');
|
|
return db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
|
|
}
|
|
|
|
// ── getStats ────────────────────────────────────────────────────────────────
|
|
|
|
export async function getStats(userId: number) {
|
|
const trips = getUserTrips(userId);
|
|
const tripIds = trips.map(t => t.id);
|
|
|
|
if (tripIds.length === 0) {
|
|
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
|
|
const countries = manualCountries.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }));
|
|
return { countries, trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: countries.length, totalDays: 0 } };
|
|
}
|
|
|
|
const places = getPlacesForTrips(tripIds);
|
|
|
|
interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set<number> }
|
|
const countrySet = new Map<string, CountryEntry>();
|
|
for (const place of places) {
|
|
const code = await resolveCountryCode(place);
|
|
if (code) {
|
|
if (!countrySet.has(code)) {
|
|
countrySet.set(code, { code, places: [], tripIds: new Set() });
|
|
}
|
|
countrySet.get(code)!.places.push({ id: place.id, name: place.name, lat: place.lat ?? null, lng: place.lng ?? null });
|
|
countrySet.get(code)!.tripIds.add(place.trip_id);
|
|
}
|
|
}
|
|
|
|
let totalDays = 0;
|
|
for (const trip of trips) {
|
|
if (trip.start_date && trip.end_date) {
|
|
const start = new Date(trip.start_date);
|
|
const end = new Date(trip.end_date);
|
|
const diff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
if (diff > 0) totalDays += diff;
|
|
}
|
|
}
|
|
|
|
const countries = [...countrySet.values()].map(c => {
|
|
const countryTrips = trips.filter(t => c.tripIds.has(t.id));
|
|
const dates = countryTrips.map(t => t.start_date).filter(Boolean).sort();
|
|
return {
|
|
code: c.code,
|
|
placeCount: c.places.length,
|
|
tripCount: c.tripIds.size,
|
|
firstVisit: dates[0] || null,
|
|
lastVisit: dates[dates.length - 1] || null,
|
|
};
|
|
});
|
|
|
|
const citySet = new Set<string>();
|
|
for (const place of places) {
|
|
if (place.address) {
|
|
const parts = place.address.split(',').map((s: string) => s.trim()).filter(Boolean);
|
|
let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
|
if (raw) {
|
|
const city = raw.replace(/[\d\-\u2212\u3012]+/g, '').trim().toLowerCase();
|
|
if (city) citySet.add(city);
|
|
}
|
|
}
|
|
}
|
|
const totalCities = citySet.size;
|
|
|
|
// Merge manually marked countries
|
|
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
|
|
for (const mc of manualCountries) {
|
|
if (!countries.find(c => c.code === mc.country_code)) {
|
|
countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
|
|
}
|
|
}
|
|
|
|
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
|
|
|
|
const continents: Record<string, number> = {};
|
|
countries.forEach(c => {
|
|
const cont = CONTINENT_MAP[c.code] || 'Other';
|
|
continents[cont] = (continents[cont] || 0) + 1;
|
|
});
|
|
|
|
const now = new Date().toISOString().split('T')[0];
|
|
const pastTrips = trips.filter(t => t.end_date && t.end_date <= now).sort((a, b) => b.end_date!.localeCompare(a.end_date!));
|
|
const lastTrip: { id: number; title: string; start_date?: string | null; end_date?: string | null; countryCode?: string } | null = pastTrips[0]
|
|
? { id: pastTrips[0].id, title: pastTrips[0].title, start_date: pastTrips[0].start_date, end_date: pastTrips[0].end_date }
|
|
: null;
|
|
if (lastTrip) {
|
|
const lastTripPlaces = places.filter(p => p.trip_id === lastTrip.id);
|
|
for (const p of lastTripPlaces) {
|
|
const code = resolveCountryCodeSync(p);
|
|
if (code) { lastTrip.countryCode = code; break; }
|
|
}
|
|
}
|
|
|
|
const futureTrips = trips.filter(t => t.start_date && t.start_date > now).sort((a, b) => a.start_date!.localeCompare(b.start_date!));
|
|
const nextTrip: { id: number; title: string; start_date?: string | null; daysUntil?: number } | null = futureTrips[0]
|
|
? { id: futureTrips[0].id, title: futureTrips[0].title, start_date: futureTrips[0].start_date }
|
|
: null;
|
|
if (nextTrip) {
|
|
const diff = Math.ceil((new Date(nextTrip.start_date!).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
|
|
nextTrip.daysUntil = Math.max(0, diff);
|
|
}
|
|
|
|
const tripYears = new Set(trips.filter(t => t.start_date).map(t => parseInt(t.start_date!.split('-')[0])));
|
|
let streak = 0;
|
|
const currentYear = new Date().getFullYear();
|
|
for (let y = currentYear; y >= 2000; y--) {
|
|
if (tripYears.has(y)) streak++;
|
|
else break;
|
|
}
|
|
const firstYear = tripYears.size > 0 ? Math.min(...tripYears) : null;
|
|
|
|
return {
|
|
countries,
|
|
stats: {
|
|
totalTrips: trips.length,
|
|
totalPlaces: places.length,
|
|
totalCountries: countries.length,
|
|
totalDays,
|
|
totalCities,
|
|
},
|
|
mostVisited,
|
|
continents,
|
|
lastTrip,
|
|
nextTrip,
|
|
streak,
|
|
firstYear,
|
|
tripsThisYear: trips.filter(t => t.start_date && t.start_date.startsWith(String(currentYear))).length,
|
|
};
|
|
}
|
|
|
|
// ── getCountryPlaces ────────────────────────────────────────────────────────
|
|
|
|
export function getCountryPlaces(userId: number, code: string) {
|
|
const trips = getUserTrips(userId);
|
|
const tripIds = trips.map(t => t.id);
|
|
if (tripIds.length === 0) return { places: [], trips: [], manually_marked: false };
|
|
|
|
const places = getPlacesForTrips(tripIds);
|
|
|
|
const matchingPlaces: { id: number; name: string; address: string | null; lat: number | null; lng: number | null; trip_id: number }[] = [];
|
|
const matchingTripIds = new Set<number>();
|
|
|
|
for (const place of places) {
|
|
const pCode = resolveCountryCodeSync(place);
|
|
if (pCode === code) {
|
|
matchingPlaces.push({ id: place.id, name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null, trip_id: place.trip_id });
|
|
matchingTripIds.add(place.trip_id);
|
|
}
|
|
}
|
|
|
|
const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
|
|
|
|
const isManuallyMarked = !!(db.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?').get(userId, code));
|
|
return { places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked };
|
|
}
|
|
|
|
// ── Mark / unmark country ───────────────────────────────────────────────────
|
|
|
|
export function listVisitedCountries(userId: number): { country_code: string; created_at: string }[] {
|
|
return db.prepare(
|
|
'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC'
|
|
).all(userId) as { country_code: string; created_at: string }[];
|
|
}
|
|
|
|
export function markCountryVisited(userId: number, code: string): void {
|
|
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
|
|
}
|
|
|
|
export function unmarkCountryVisited(userId: number, code: string): void {
|
|
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
|
|
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code);
|
|
}
|
|
|
|
// ── Mark / unmark region ────────────────────────────────────────────────────
|
|
|
|
export function listManuallyVisitedRegions(userId: number): { region_code: string; region_name: string; country_code: string }[] {
|
|
return db.prepare(
|
|
'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC'
|
|
).all(userId) as { region_code: string; region_name: string; country_code: string }[];
|
|
}
|
|
|
|
export function markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): void {
|
|
db.prepare('INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(userId, regionCode, regionName, countryCode);
|
|
// Auto-mark parent country if not already visited
|
|
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode);
|
|
}
|
|
|
|
export function unmarkRegionVisited(userId: number, regionCode: string): void {
|
|
const region = db.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?').get(userId, regionCode) as { country_code: string } | undefined;
|
|
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?').run(userId, regionCode);
|
|
if (region) {
|
|
const remaining = db.prepare('SELECT COUNT(*) as count FROM visited_regions WHERE user_id = ? AND country_code = ?').get(userId, region.country_code) as { count: number };
|
|
if (remaining.count === 0) {
|
|
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, region.country_code);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Sub-national region resolution ────────────────────────────────────────
|
|
|
|
interface RegionInfo { country_code: string; region_code: string; region_name: string }
|
|
|
|
const regionCache = new Map<string, RegionInfo | null>();
|
|
|
|
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
|
const key = roundKey(lat, lng);
|
|
if (regionCache.has(key)) return regionCache.get(key)!;
|
|
try {
|
|
const res = await fetch(
|
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
|
|
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
|
);
|
|
if (!res.ok) return null;
|
|
const data = await res.json() as { address?: Record<string, string> };
|
|
const countryCode = data.address?.country_code?.toUpperCase() || null;
|
|
// Try finest ISO level first (lvl6 = departments/provinces), then lvl5, then lvl4 (states/regions)
|
|
let regionCode = data.address?.['ISO3166-2-lvl6'] || data.address?.['ISO3166-2-lvl5'] || data.address?.['ISO3166-2-lvl4'] || null;
|
|
// Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility)
|
|
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
|
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
|
}
|
|
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
|
|
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
|
const info: RegionInfo = {
|
|
country_code: countryCode,
|
|
region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`,
|
|
region_name: regionName,
|
|
};
|
|
regionCache.set(key, info);
|
|
return info;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getVisitedRegions(userId: number): Promise<{ regions: Record<string, { code: string; name: string; placeCount: number }[]> }> {
|
|
const trips = getUserTrips(userId);
|
|
const tripIds = trips.map(t => t.id);
|
|
const places = getPlacesForTrips(tripIds);
|
|
|
|
// Check DB cache first
|
|
const placeIds = places.filter(p => p.lat && p.lng).map(p => p.id);
|
|
const cached = placeIds.length > 0
|
|
? db.prepare(`SELECT * FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`).all(...placeIds) as { place_id: number; country_code: string; region_code: string; region_name: string }[]
|
|
: [];
|
|
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
|
|
|
|
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
|
|
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
|
|
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
|
|
|
for (const place of uncached) {
|
|
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
|
if (info) {
|
|
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
|
cachedMap.set(place.id, { place_id: place.id, ...info });
|
|
}
|
|
// Nominatim rate limit: 1 req/sec
|
|
if (uncached.indexOf(place) < uncached.length - 1) {
|
|
await new Promise(r => setTimeout(r, 1100));
|
|
}
|
|
}
|
|
|
|
// Group by country → regions with place counts
|
|
const regionMap: Record<string, Map<string, { code: string; name: string; placeCount: number }>> = {};
|
|
for (const [, entry] of cachedMap) {
|
|
if (!regionMap[entry.country_code]) regionMap[entry.country_code] = new Map();
|
|
const existing = regionMap[entry.country_code].get(entry.region_code);
|
|
if (existing) {
|
|
existing.placeCount++;
|
|
} else {
|
|
regionMap[entry.country_code].set(entry.region_code, { code: entry.region_code, name: entry.region_name, placeCount: 1 });
|
|
}
|
|
}
|
|
|
|
const result: Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]> = {};
|
|
for (const [country, regions] of Object.entries(regionMap)) {
|
|
result[country] = [...regions.values()];
|
|
}
|
|
|
|
// Merge manually marked regions
|
|
const manualRegions = listManuallyVisitedRegions(userId);
|
|
for (const r of manualRegions) {
|
|
if (!result[r.country_code]) result[r.country_code] = [];
|
|
if (!result[r.country_code].find(x => x.code === r.region_code)) {
|
|
result[r.country_code].push({ code: r.region_code, name: r.region_name, placeCount: 0, manuallyMarked: true });
|
|
}
|
|
}
|
|
|
|
return { regions: result };
|
|
}
|
|
|
|
// ── Bucket list CRUD ────────────────────────────────────────────────────────
|
|
|
|
export function listBucketList(userId: number) {
|
|
return db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
|
}
|
|
|
|
export function createBucketItem(userId: number, data: { name: string; lat?: number | null; lng?: number | null; country_code?: string | null; notes?: string | null; target_date?: string | null }) {
|
|
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
|
|
userId, data.name.trim(), data.lat ?? null, data.lng ?? null, data.country_code ?? null, data.notes ?? null, data.target_date ?? null
|
|
);
|
|
return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
|
|
}
|
|
|
|
export function updateBucketItem(userId: number, itemId: string | number, data: { name?: string; notes?: string; lat?: number | null; lng?: number | null; country_code?: string | null; target_date?: string | null }) {
|
|
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
|
|
if (!item) return null;
|
|
db.prepare(`UPDATE bucket_list SET
|
|
name = COALESCE(?, name),
|
|
notes = CASE WHEN ? THEN ? ELSE notes END,
|
|
lat = CASE WHEN ? THEN ? ELSE lat END,
|
|
lng = CASE WHEN ? THEN ? ELSE lng END,
|
|
country_code = CASE WHEN ? THEN ? ELSE country_code END,
|
|
target_date = CASE WHEN ? THEN ? ELSE target_date END
|
|
WHERE id = ?`).run(
|
|
data.name?.trim() || null,
|
|
data.notes !== undefined ? 1 : 0, data.notes !== undefined ? (data.notes || null) : null,
|
|
data.lat !== undefined ? 1 : 0, data.lat !== undefined ? (data.lat || null) : null,
|
|
data.lng !== undefined ? 1 : 0, data.lng !== undefined ? (data.lng || null) : null,
|
|
data.country_code !== undefined ? 1 : 0, data.country_code !== undefined ? (data.country_code || null) : null,
|
|
data.target_date !== undefined ? 1 : 0, data.target_date !== undefined ? (data.target_date || null) : null,
|
|
itemId
|
|
);
|
|
return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(itemId);
|
|
}
|
|
|
|
export function deleteBucketItem(userId: number, itemId: string | number): boolean {
|
|
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
|
|
if (!item) return false;
|
|
db.prepare('DELETE FROM bucket_list WHERE id = ?').run(itemId);
|
|
return true;
|
|
}
|