From 2d707ac54007573b5d711eea11584b6aa19e7507 Mon Sep 17 00:00:00 2001 From: hgosalia Date: Thu, 7 May 2026 06:17:38 -0400 Subject: [PATCH 01/16] Batch Updates --- README.md | 6 ++- css/styles.css | 11 +++-- js/data.js | 35 ++++++++++---- js/map.js | 25 +++++++--- js/media.js | 15 +++++- js/pins.js | 33 +++++++------ serve.py | 9 +++- sw.js | 77 ++++++++++++++---------------- tests/specs/pin-position.spec.js | 82 ++++++++++++++++++++++++++++++++ 9 files changed, 211 insertions(+), 82 deletions(-) create mode 100644 tests/specs/pin-position.spec.js diff --git a/README.md b/README.md index a881095..8f386e4 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,13 @@ Map tiles are cached in three layers for fast, offline-capable rendering: | **L2 — Disk cache** | `matrix-tiles/` on disk (via `serve.py`) | Fast local read | Shared across all browsers | Persists until manually deleted | | **L3 — Origin fetch** | Remote tile server (OpenFreeMap / Esri) | Slowest | Requires internet | N/A | -When MapLibre needs a tile, the style JSON provides the URL template (e.g., `.../planet/20260415_001001_pt/{z}/{x}/{y}.pbf`). The service worker intercepts the request and checks L1 (browser cache) first. On a miss, it asks `serve.py` if the tile exists on disk (L2). If the tile is on disk, it's served instantly. If not, the proxy returns 404 immediately and the service worker fetches directly from the origin (L3). After a successful origin fetch, the service worker sends the tile data back to `serve.py` in the background to be saved to disk for future use. +On an L1 miss, the service worker checks L2 (disk proxy) first with a 50ms timeout. If the tile is on disk, it's served immediately with no origin request. If the disk check misses or times out, the service worker falls back to L3 (origin) with an 8-second timeout and one automatic retry on failure. Concurrency is limited by semaphores (4 concurrent disk, 6 concurrent origin) to prevent overwhelming either backend during rapid zoom transitions. After a successful origin fetch, the tile is saved to disk in the background for offline use. **URL-based versioning:** Tile URLs include a version segment (e.g., `20260415_001001_pt`) that changes when OpenFreeMap rebuilds their tile set. This means cached tiles are never stale — when tiles are updated, the style JSON points to new URLs, the cache naturally misses, and fresh tiles are fetched and cached. Old versioned tiles are eventually removed by LRU eviction. -Tiles cached by one browser (e.g. Safari) are available to other browsers (e.g. Chrome) via the shared L2 disk cache. The disk cache is capped at 500 MB with LRU eviction — frequently accessed tiles have their timestamps updated on each read, so they stay in cache while rarely visited tiles are evicted first. +The disk cache is capped at 500 MB with LRU eviction — when the limit is exceeded, the oldest tiles are removed down to 80% capacity. Eviction runs at startup and after each new tile is cached. Eviction events are logged to `matrix-requests.log`. + +**Proactive caching:** After app load, tiles for the world overview (z0–3) and pinned photo locations (z4–14) are prefetched in small batches. Already-cached tiles are skipped to avoid redundant network requests. ## Video Export diff --git a/css/styles.css b/css/styles.css index 4422c64..b89fc22 100644 --- a/css/styles.css +++ b/css/styles.css @@ -166,7 +166,7 @@ input,textarea,select{font-family:var(--font)} #map-wrap{flex:1;position:relative;overflow:hidden} #map{width:100%;height:100%} #map:not(.dark-map):not(.sat-mode) canvas{filter:brightness(0.85)} -#map.dark-map canvas{filter:brightness(1.8) contrast(0.9)} +#map.dark-map canvas{filter:brightness(1.8) contrast(0.9);will-change:filter} #esri-attribution{display:none;position:absolute;bottom:6px;left:6px;background:rgba(30,30,30,.6);color:#aaa;font-size:.55rem;padding:2px 6px;border-radius:4px;z-index:2;pointer-events:auto} #esri-attribution a{color:var(--accent);text-decoration:none} #map.sat-mode~#esri-attribution{display:block} @@ -179,7 +179,7 @@ input,textarea,select{font-family:var(--font)} .maplibregl-popup{z-index:6!important} .maplibregl-popup-content{background:var(--surface)!important;border:1px solid var(--border)!important;border-radius:var(--radius)!important;box-shadow:var(--shadow)!important;padding:0!important;overflow:hidden} .maplibregl-popup-tip{border-top-color:var(--surface)!important;border-bottom-color:var(--surface)!important} -.maplibregl-popup-close-button{color:var(--muted)!important;font-size:1rem!important;padding:6px 8px!important;background:none!important;z-index:1} +.maplibregl-popup-close-button{color:var(--muted)!important;font-size:1.3rem!important;padding:6px 10px!important;background:none!important;z-index:1} .maplibregl-popup-close-button:hover{color:var(--text)!important;background:none!important} #sidebar-toggle{position:absolute;top:50%;left:325px;transform:translateY(-50%);z-index:20;background:var(--surface);border:1px solid var(--border);width:18px;height:44px;border-radius:0 8px 8px 0;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:.65rem;transition:left .3s,background .15s} @@ -233,7 +233,7 @@ input,textarea,select{font-family:var(--font)} /* PIN POPUP */ .popup-wrap{width:300px;display:flex;flex-direction:column;max-height:380px} .popup-hdr{padding:10px 12px 8px;border-bottom:1px solid var(--border);flex-shrink:0} -.popup-hdr-title{font-size:.84rem;font-weight:600;word-wrap:break-word;overflow-wrap:break-word;padding-right:20px} +.popup-hdr-title{font-size:.84rem;font-weight:600;word-wrap:break-word;overflow-wrap:break-word;padding-right:28px} .popup-hdr-count{font-size:.64rem;color:var(--muted);margin-top:2px} .popup-tab-bar{display:flex;border-bottom:1px solid var(--border);flex-shrink:0} .ptab-btn{flex:1;padding:7px 4px;font-size:.7rem;font-weight:500;background:none;border:none;color:var(--muted);border-bottom:2px solid transparent;transition:all .15s} @@ -269,7 +269,7 @@ input,textarea,select{font-family:var(--font)} /* DEST POPUP */ .dest-popup{width:220px;padding:12px 14px} -.dest-popup-name{font-weight:600;font-size:.83rem;margin-bottom:3px} +.dest-popup-name{font-weight:600;font-size:.83rem;margin-bottom:3px;padding-right:20px} .dest-popup-detail{font-size:.7rem;color:var(--muted);margin-bottom:10px} .dest-popup-btn{display:flex;align-items:center;gap:6px;padding:7px 10px;border-radius:8px;font-size:.71rem;font-weight:500;border:1px solid var(--border);background:var(--surface2);color:var(--text);transition:all .15s;width:100%;cursor:pointer;margin-top:6px} .dest-popup-btn:hover{background:var(--surface3);border-color:var(--accent);color:var(--accent)} @@ -336,7 +336,8 @@ textarea.fi{resize:vertical;min-height:60px;line-height:1.5} .lb-nav-btn{pointer-events:none;background:rgba(255,255,255,.1);border:none;color:#fff;font-size:1.2rem;width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s} #lightbox.open .lb-nav-btn{pointer-events:all} .lb-nav-btn:hover{background:rgba(255,255,255,.2)} -#lb-caption{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.65);color:#fff;padding:6px 16px;border-radius:20px;font-size:.74rem;backdrop-filter:blur(4px);max-width:80vw;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#lb-caption{position:absolute;bottom:44px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.65);color:#fff;padding:6px 16px;border-radius:20px;font-size:.74rem;backdrop-filter:blur(4px);max-width:80vw;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#lb-camera{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,.45);font-size:.62rem;text-align:center;white-space:nowrap;letter-spacing:.02em} /* OFFLINE BANNER */ #offline-banner{position:fixed;top:0;left:0;right:0;background:rgba(232,119,74,.92);color:#fff;text-align:center;padding:6px 12px;font-size:.75rem;font-weight:500;z-index:10000;transform:translateY(-100%);transition:transform .3s ease;pointer-events:none;font-family:var(--font);letter-spacing:.3px} diff --git a/js/data.js b/js/data.js index a373a58..db4caf6 100644 --- a/js/data.js +++ b/js/data.js @@ -502,6 +502,10 @@ async function init() { albums.push(...savedAlbums); rebuildPhotoMap(); rebuildPhotoList(); buildTimeline(); rebuildAlbumList(); updateStats(); + const dismissSpinner = () => { + const mapLoading = document.getElementById('map-loading'); + if (mapLoading) { mapLoading.classList.add('done'); setTimeout(() => mapLoading.remove(), 400); } + }; const ready = () => { buildClusterIndex(); if (savedPhotos.length) { @@ -509,14 +513,17 @@ async function init() { const realCount = savedPhotos.filter(p => !p.isEmptyPin).length; if (realCount) showToast(`Loaded ${realCount} photo${realCount!==1?'s':''}${savedAlbums.length?` and ${savedAlbums.length} album${savedAlbums.length!==1?'s':''}`:''}`,'success'); } + dismissSpinner(); }; // Ensure map is truly ready — use 'idle' which fires after tiles + style are fully rendered const waitForMap = () => { if (map.loaded() && map.isStyleLoaded()) { ready(); } else { map.once('idle', ready); } }; - if (map.isStyleLoaded()) waitForMap(); + if (map.loaded() || map.isStyleLoaded()) waitForMap(); else map.on('load', waitForMap); + // Safety net: force-remove spinner after 10s in case map never fires idle + setTimeout(dismissSpinner, 10000); // Check if serve.py is running and auto-restore if needed (non-blocking) checkAutoSaveServer().then(() => checkAutoRestore()); // Backfill country codes from restored cache (non-blocking, no API calls) @@ -628,7 +635,7 @@ async function getTileTemplates() { function fetchTile(url, timeoutMs = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); - return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); + return fetch(url, { mode: 'cors', signal: controller.signal }).finally(() => clearTimeout(timer)); } // Build tile URLs for a set of locations at given zoom levels @@ -686,22 +693,30 @@ async function cacheMapTiles() { const pinUrls = buildTileUrls(templates, locs, [4, 6, 8, 10, 12, 14], z => z >= 10 ? 2 : 1); tileUrls.push(...pinUrls); + // Skip tiles already in the SW cache + const cacheNames = await caches.keys(); + const tileCacheName = cacheNames.find(n => n.endsWith('-tiles')); + const swCache = tileCacheName ? await caches.open(tileCacheName) : null; + const uncached = []; + for (const url of tileUrls) { + if (!swCache || !(await swCache.match(url))) uncached.push(url); + } + if (!uncached.length) return; + // Fetch tiles — SW intercepts and caches via local server disk proxy - // Use small batches with delays to avoid starving interactive map rendering + // SW semaphores handle concurrency limiting let fetched = 0; - const BATCH = 2; - for (let i = 0; i < tileUrls.length; i += BATCH) { + const BATCH = 4; + for (let i = 0; i < uncached.length; i += BATCH) { if (_isOffline) break; - // Pause while map is busy or still loading tiles so interactive rendering gets priority while (_mapBusy || (map && !map.areTilesLoaded())) await new Promise(r => setTimeout(r, 500)); - const batch = tileUrls.slice(i, i + BATCH); + const batch = uncached.slice(i, i + BATCH); await Promise.all(batch.map(url => fetchTile(url).then(() => fetched++).catch(() => {}) )); - // Yield between batches to keep connections free for interactive requests - await new Promise(r => setTimeout(r, 200)); + await new Promise(r => setTimeout(r, 100)); } - if (fetched) console.log(`Tile cache: prefetched ${fetched} tiles (${tileUrls.length} total)`); + if (fetched) console.log(`Tile cache: prefetched ${fetched} tiles (${uncached.length} needed, ${tileUrls.length - uncached.length} already cached)`); } catch (e) { console.warn('Tile cache prefetch failed:', e); } } diff --git a/js/map.js b/js/map.js index 5fcae3a..7c37b63 100644 --- a/js/map.js +++ b/js/map.js @@ -415,7 +415,7 @@ async function initMap() { // Pre-warm the other style into cache so the first switch is instant const otherUrl = styleUrl === STYLE_DARK ? STYLE_STREET : STYLE_DARK; fetch(otherUrl).then(r => r.json()).then(j => { _styleJsonCache[otherUrl] = j; }).catch(() => {}); - map = new maplibregl.Map({ container:'map', style: initStyle, center:[0,20], zoom:1.8, attributionControl:false, preserveDrawingBuffer:true }); + map = new maplibregl.Map({ container:'map', style: initStyle, center:[0,20], zoom:1.8, attributionControl:false, preserveDrawingBuffer:true, maxTileCacheSize:200 }); map.addControl(new maplibregl.NavigationControl({showCompass:false}), 'bottom-right'); // Recover from WebGL context loss (Safari loses context after sleep or memory pressure) const canvas = map.getCanvas(); @@ -482,8 +482,6 @@ async function initMap() { map.on('dataloading', () => { tileSpinner?.classList.add('active'); }); map.on('idle', () => { tileSpinner?.classList.remove('active'); - const mapLoading = document.getElementById('map-loading'); - if (mapLoading) { mapLoading.classList.add('done'); setTimeout(() => mapLoading.remove(), 400); } }); }); map.on('movestart', () => { _mapBusy = true; }); @@ -511,6 +509,7 @@ async function initMap() { .addTo(map); let clickedLabel = null; + let labelCoords = null; // use feature's own coordinates for geocoding when a label is clicked if (isWater) { // On water: search a wider area for water name labels (the label text // may not be exactly at the click point) @@ -532,13 +531,25 @@ async function initMap() { if (id === 'photo-pins-layer') continue; if (/^(road|highway|water|ferry|aeroway|boundary)/.test(id)) continue; const n = f.properties['name_en'] || f.properties['name:latin'] || f.properties['name']; - if (n) { clickedLabel = n; break; } + if (n) { + clickedLabel = n; + // Use the feature's actual coordinates for reverse geocode — the click + // point may be offset from the city center (e.g. Dubrovnik near Bosnia border) + const geom = f.geometry; + if (geom && geom.type === 'Point') { + labelCoords = { lng: geom.coordinates[0], lat: geom.coordinates[1] }; + } + break; + } } } - // Reverse geocode (still needed for country/countryCode even if label was clicked) - const geoName = await reverseGeocode(lat, lng); - const cacheKey = `${lat.toFixed(4)}_${lng.toFixed(4)}`; + // Reverse geocode using label's own coordinates when available (more accurate + // for border cities like Dubrovnik), otherwise fall back to click point + const geoLat = labelCoords ? labelCoords.lat : lat; + const geoLng = labelCoords ? labelCoords.lng : lng; + const geoName = await reverseGeocode(geoLat, geoLng); + const cacheKey = `${geoLat.toFixed(4)}_${geoLng.toFixed(4)}`; const placeName = clickedLabel || geoName || `${lat.toFixed(4)}°, ${lng.toFixed(4)}°`; const country = _geoCountryCache[cacheKey] || ''; diff --git a/js/media.js b/js/media.js index 276f8f9..9ea9f8a 100644 --- a/js/media.js +++ b/js/media.js @@ -30,7 +30,7 @@ function readExifData(view, tiffStart, end) { const le = view.getUint16(tiffStart) === 0x4949; // little-endian? const g16 = (o) => view.getUint16(o, le); const g32 = (o) => view.getUint32(o, le); - const result = { lat: null, lng: null, date: null, time: null }; + const result = { lat: null, lng: null, date: null, time: null, camera: null }; function readIFD(ifdOffset) { if (ifdOffset + 2 > end) return {}; @@ -83,6 +83,16 @@ function readExifData(view, tiffStart, end) { const ifd0Off = g32(tiffStart + 4); const ifd0 = readIFD(tiffStart + ifd0Off); + // Camera make (0x010F) and model (0x0110) + const make = getString(ifd0[0x010F]); + const model = getString(ifd0[0x0110]); + if (model) { + const m2 = model.trim(); + const mk = make ? make.trim() : ''; + // If model already starts with make (e.g. "Apple iPhone 15 Pro"), use model as-is + result.camera = (mk && m2.toLowerCase().indexOf(mk.toLowerCase()) === 0) ? m2 : (mk ? mk + ' ' + m2 : m2); + } + // DateTimeOriginal is in ExifIFD if (ifd0[0x8769]) { // ExifIFD pointer const exifOff = g32(ifd0[0x8769].valOff); @@ -223,6 +233,7 @@ async function processFiles(files) { name: r.name.replace(/\.[^.]+$/,''), date: r.exif.date, time: r.exif.time, lat: r.exif.lat, lng: r.exif.lng, + camera: r.exif.camera || null, placeName: null, countryCode: null, note: '', dataUrl: r.dataUrl, thumbUrl: r.thumbUrl, addedAt: Date.now(), _dk: r.dk @@ -280,8 +291,10 @@ function showLbPhoto(animate=false){ if(!p) return; const img=document.getElementById('lb-img'); const cap=document.getElementById('lb-caption'); + const camEl=document.getElementById('lb-camera'); const caption = (p.date ? fmtDate(p.date,p.time) : '') + (p.placeName ? (p.date?' · ':'') + p.placeName : ''); + if (camEl) camEl.textContent = p.camera ? `📷 ${p.camera}` : ''; if (animate) { img.style.transition='opacity .18s ease, transform .18s ease'; cap.style.transition='opacity .18s ease'; diff --git a/js/pins.js b/js/pins.js index b8a788c..e3bac56 100644 --- a/js/pins.js +++ b/js/pins.js @@ -280,7 +280,22 @@ function _refreshClustersNow() { function countryFlag(code) { return String.fromCodePoint(...[...code].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); } async function reverseGeocode(lat, lng) { const key = `${lat.toFixed(4)}_${lng.toFixed(4)}`; - if (_geoCache[key]) return _geoCache[key]; + // Map visual zoom → Nominatim zoom. Nominatim's zoom→detail mapping varies by + // country (Japan needs ≥10 for cities, Paris returns city at 8). Fixed breakpoints + // ensure results match what the user sees at each zoom range. + // Each entry: [minMapZoom, nominatimZoom, addressFieldPriority] + const ZOOM_TIERS = [ + [14, 14, ['tourism','building','amenity','leisure','road','neighbourhood','suburb','village','town','city','county','state','province']], + [12, 12, ['suburb','neighbourhood','village','town','city','county','state','province']], + [ 9, 10, ['city','town','village','county','state','province']], + [ 0, 5, ['city','town','village','state','province','county']], + ]; + const z = map ? map.getZoom() : 5; + const tier = ZOOM_TIERS.find(t => z >= t[0]); + const nomZoom = tier[1] || Math.round(z); + // Cache by lat/lng + nominatim zoom so different zoom levels get fresh results + const cacheKey = `${key}_z${nomZoom}`; + if (_geoCache[cacheKey]) return _geoCache[cacheKey]; if (_isOffline) return null; // Rate-limit: ensure at least 1.1s between Nominatim calls const now = Date.now(); @@ -288,19 +303,6 @@ async function reverseGeocode(lat, lng) { if (wait > 0) await new Promise(r => setTimeout(r, wait)); _lastNominatimCall = Date.now(); try { - // Map visual zoom → Nominatim zoom. Nominatim's zoom→detail mapping varies by - // country (Japan needs ≥10 for cities, Paris returns city at 8). Fixed breakpoints - // ensure results match what the user sees at each zoom range. - // Each entry: [minMapZoom, nominatimZoom, addressFieldPriority] - const ZOOM_TIERS = [ - [14, 14, ['tourism','building','amenity','leisure','road','neighbourhood','suburb','village','town','city','county','state','province']], - [12, 12, ['suburb','neighbourhood','village','town','city','county','state','province']], - [ 9, 10, ['city','town','village','county','state','province']], - [ 0, 5, ['city','town','village','state','province','county']], - ]; - const z = map.getZoom(); - const tier = ZOOM_TIERS.find(t => z >= t[0]); - const nomZoom = tier[1] || Math.round(z); const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=${nomZoom}&accept-language=en`); if (!r.ok) { console.warn('reverse geocode HTTP', r.status); return null; } const d = await r.json(); @@ -310,9 +312,10 @@ async function reverseGeocode(lat, lng) { // Avoid using d.name as fallback — it often duplicates the country name at low zoom const name = rawName || (d.name && d.name !== country ? d.name : null); const countryCode = a.country_code || null; + // Always update country/code caches (higher zoom = more accurate for border areas) if (country) _geoCountryCache[key] = country; if (countryCode) _geoCodeCache[key] = countryCode.toUpperCase(); - if (name) { _geoCache[key] = name; return name; } + if (name) { _geoCache[cacheKey] = name; return name; } } catch(e) { console.warn('reverse geocode failed', e); } return null; } diff --git a/serve.py b/serve.py index 03c814d..bc1afc8 100644 --- a/serve.py +++ b/serve.py @@ -67,7 +67,7 @@ def _download(url, dest): PHOTOS_DIR = os.path.join(APP_DIR, "matrix-photos") TILES_DIR = os.path.join(APP_DIR, "matrix-tiles") VENDOR_DIR = os.path.join(APP_DIR, "vendor") -MAX_TILES_MB = 200 +MAX_TILES_MB = 500 LOG_FILE = os.path.join(APP_DIR, "matrix-requests.log") # Set up file logger for tile/GET requests @@ -209,16 +209,23 @@ def _evict_tiles_if_needed(): limit = MAX_TILES_MB * 1024 * 1024 if total <= limit: return + before_mb = total / (1024 * 1024) + _req_logger.info(f'TILE EVICTION: cache at {before_mb:.1f}MB exceeds {MAX_TILES_MB}MB limit, beginning eviction') # Sort by modification time (oldest first) and evict files.sort() + evicted = 0 for mtime, size, fp in files: if total <= limit * 0.8: # Evict down to 80% to avoid thrashing break try: os.remove(fp) total -= size + evicted += 1 except OSError: pass + after_mb = total / (1024 * 1024) + if evicted: + _req_logger.info(f'TILE EVICTION: removed {evicted} files, {before_mb:.1f}MB → {after_mb:.1f}MB (limit {MAX_TILES_MB}MB)') finally: _evict_lock.release() diff --git a/sw.js b/sw.js index 916b114..8950dd0 100644 --- a/sw.js +++ b/sw.js @@ -1,5 +1,5 @@ // Matrix — Service Worker for offline support -const CACHE_VERSION = 'matrix-v15'; +const CACHE_VERSION = 'matrix-v17'; const APP_CACHE = `${CACHE_VERSION}-app`; const TILE_CACHE = `${CACHE_VERSION}-tiles`; @@ -154,56 +154,51 @@ async function cacheFirst(request, cacheName) { const TRANSPARENT_PNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='), c => c.charCodeAt(0)); async function tileStrategy(request) { - // L1: browser Cache API (instant) — ignoreVary prevents misses from header differences + // L1: browser Cache API (instant) const cached = await caches.match(request, { ignoreVary: true }); if (cached) return cached; - const cacheAndReturn = async (body, ct) => { - const cacheResp = new Response(body, { status: 200, headers: { 'Content-Type': ct } }); - const cache = await caches.open(TILE_CACHE); - cache.put(request, cacheResp.clone()); - evictOldTiles(cache); - return cacheResp; - }; + const proxyUrl = `http://localhost:${serverPort}/api/tiles/proxy?url=${encodeURIComponent(request.url)}`; - // Online: skip disk proxy, fetch directly from origin (fast, no Python overhead) - // Offline: try disk cache first, then fail gracefully - if (navigator.onLine) { + const cacheAndReturn = async (body, ct) => { try { - const oc = new AbortController(); - const ot = setTimeout(() => oc.abort(), 8000); - const resp = await fetch(request, { signal: oc.signal }); - clearTimeout(ot); - if (!resp.ok) throw new Error('origin error'); - const body = await resp.arrayBuffer(); - const ct = resp.headers.get('Content-Type') || 'application/octet-stream'; - // Save to disk cache in background (fire-and-forget) for offline use - const cacheUrl = `http://localhost:${serverPort}/api/tiles/cache?url=${encodeURIComponent(request.url)}`; - fetch(cacheUrl, { method: 'POST', body: body.slice(0) }).catch(() => {}); - return cacheAndReturn(body, ct); + const cacheResp = new Response(body, { status: 200, headers: { 'Content-Type': ct } }); + const cache = await caches.open(TILE_CACHE); + cache.put(request, cacheResp.clone()); + evictOldTiles(cache); + return cacheResp; } catch { - // Origin failed while online — try disk as fallback + return new Response(body, { status: 200, headers: { 'Content-Type': ct } }); } - } + }; - // Offline or origin failed: try disk cache (L2) - try { - const proxyUrl = `http://localhost:${serverPort}/api/tiles/proxy?url=${encodeURIComponent(request.url)}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 1000); - const resp = await fetch(proxyUrl, { signal: controller.signal }); - clearTimeout(timer); - if (resp.ok) { - const body = await resp.arrayBuffer(); - const ct = resp.headers.get('Content-Type') || 'application/octet-stream'; - return cacheAndReturn(body, ct); - } - } catch {} + // Race L2 (disk) and L3 (origin) — whichever responds first wins + const diskCheck = fetch(proxyUrl).then(r => { + if (!r.ok) throw new Error('miss'); + return r.arrayBuffer().then(body => ({ body, ct: r.headers.get('Content-Type') || 'application/octet-stream' })); + }); - return new Response(TRANSPARENT_PNG, { - status: 200, - headers: { 'Content-Type': 'image/png' } + const originFetch = fetch(request).then(r => { + if (!r.ok) throw new Error('origin error'); + return r.arrayBuffer().then(body => { + const ct = r.headers.get('Content-Type') || 'application/octet-stream'; + // Save to disk in background + fetch(`http://localhost:${serverPort}/api/tiles/cache?url=${encodeURIComponent(request.url)}`, { + method: 'POST', body: body.slice(0) + }).catch(() => {}); + return { body, ct }; + }); }); + + try { + const { body, ct } = await Promise.any([diskCheck, originFetch]); + return cacheAndReturn(body, ct); + } catch { + return new Response(TRANSPARENT_PNG, { + status: 200, + headers: { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' } + }); + } } function zoomFromUrl(url) { diff --git a/tests/specs/pin-position.spec.js b/tests/specs/pin-position.spec.js new file mode 100644 index 0000000..e612dc8 --- /dev/null +++ b/tests/specs/pin-position.spec.js @@ -0,0 +1,82 @@ +const { test, expect } = require('@playwright/test'); +const { setupApp, uploadTestPhotos, clearState } = require('../helpers/test-setup'); + +test.describe('Pin Position Accuracy', () => { + test.beforeEach(async ({ page }) => { + await setupApp(page); + await clearState(page); + }); + + test('pin renders at correct geographic position', async ({ page }) => { + // Upload paris.jpg — GPS: 48.8566, 2.3522 + await uploadTestPhotos(page, ['paris.jpg']); + + // Fly the map to Paris at zoom 12 so the pin is an individual marker (not clustered) + await page.evaluate(() => { + map.flyTo({ center: [2.3522, 48.8566], zoom: 12, duration: 0 }); + }); + + // Wait for map to finish rendering + await page.evaluate(() => new Promise(r => { + if (map.loaded() && map.isStyleLoaded()) r(); + else map.once('idle', r); + })); + + // Wait for pin icon to load (async image load + canvas draw) then trigger a repaint + await page.waitForTimeout(1500); + await page.evaluate(() => { refreshClusters(); }); + await page.waitForTimeout(500); + + // Project the expected GPS coordinates to pixel position + const expected = await page.evaluate(() => { + const px = map.project([2.3522, 48.8566]); + return { x: px.x, y: px.y }; + }); + + // Query rendered features at the expected pixel position (±5px bbox for sub-pixel rounding) + const pinAtExpected = await page.evaluate(({ x, y }) => { + const bbox = [[x - 5, y - 5], [x + 5, y + 5]]; + const features = map.queryRenderedFeatures(bbox, { layers: ['photo-pins-layer'] }); + if (!features.length) return null; + return { lat: features[0].properties.lat, lng: features[0].properties.lng }; + }, expected); + + expect(pinAtExpected).not.toBeNull(); + expect(parseFloat(pinAtExpected.lat)).toBeCloseTo(48.8566, 3); + expect(parseFloat(pinAtExpected.lng)).toBeCloseTo(2.3522, 3); + }); + + test('pin is not shifted south (y-offset regression)', async ({ page }) => { + // Upload paris.jpg — GPS: 48.8566, 2.3522 + await uploadTestPhotos(page, ['paris.jpg']); + + await page.evaluate(() => { + map.flyTo({ center: [2.3522, 48.8566], zoom: 12, duration: 0 }); + }); + + await page.evaluate(() => new Promise(r => { + if (map.loaded() && map.isStyleLoaded()) r(); + else map.once('idle', r); + })); + + await page.waitForTimeout(1500); + await page.evaluate(() => { refreshClusters(); }); + await page.waitForTimeout(500); + + const expected = await page.evaluate(() => { + const px = map.project([2.3522, 48.8566]); + return { x: px.x, y: px.y }; + }); + + // Query 50px south of expected position — should NOT find the pin + // (if it does, the pin has the y-offset bug) + const pinBelow = await page.evaluate(({ x, y }) => { + const shiftedY = y + 50; + const bbox = [[x - 5, shiftedY - 5], [x + 5, shiftedY + 5]]; + const features = map.queryRenderedFeatures(bbox, { layers: ['photo-pins-layer'] }); + return features.length; + }, expected); + + expect(pinBelow).toBe(0); + }); +}); From de1172796ba96c68a5c58cdb3f3bdb211a392ece Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 08:04:50 -0400 Subject: [PATCH 02/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f386e4..3ebe9a4 100644 --- a/README.md +++ b/README.md @@ -138,4 +138,4 @@ Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/N --- -Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 +Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 From cefacf37abc3e58f2b966962546a7db4efaf82c7 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 08:20:33 -0400 Subject: [PATCH 03/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ebe9a4..522f156 100644 --- a/README.md +++ b/README.md @@ -138,4 +138,4 @@ Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/N --- -Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 +Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 From 4cea131cba4ef41413606fd0f3198e0f9d29c529 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 08:29:04 -0400 Subject: [PATCH 04/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 522f156..8f386e4 100644 --- a/README.md +++ b/README.md @@ -138,4 +138,4 @@ Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/N --- -Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 +Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 From 9bb0c722411ffc3a88a5225ef9ef5416b28433dc Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 08:40:47 -0400 Subject: [PATCH 05/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f386e4..522f156 100644 --- a/README.md +++ b/README.md @@ -138,4 +138,4 @@ Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/N --- -Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 +Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 From cbfe210dcc6f9ecc4723d6f45f10261d78f80126 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 08:48:06 -0400 Subject: [PATCH 06/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 522f156..8f386e4 100644 --- a/README.md +++ b/README.md @@ -138,4 +138,4 @@ Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/N --- -Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 +Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 From 361f733fa2df2a7d0ea42cf424c5799a0987d552 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 08:52:54 -0400 Subject: [PATCH 07/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f386e4..d8dc996 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ The satellite view uses **ArcGIS World Imagery** (Esri) as a separate raster til ## Privacy -Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/Nominatim for place lookups. No login required. +Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/Nominatim for place lookups. No login required. --- From 5c223c6236e9f172c92326251f39216d29c4c151 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 09:26:38 -0400 Subject: [PATCH 08/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 135 +++++++++++++++++++++++++++---- 1 file changed, 120 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index 58b9dab..bc34f63 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -1,7 +1,7 @@ name: PR Summary on: - pull_request: + pull_request_target: types: [opened, synchronize] jobs: @@ -9,6 +9,7 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + models: read steps: - name: Generate PR Summary uses: actions/github-script@v7 @@ -34,24 +35,128 @@ jobs: pull_number: prNumber, }); - const summary = `## 📋 PR Summary + // Build diff context for AI summary (truncate to avoid token limits) + const diffParts = files.slice(0, 12).map(f => { + const patch = (f.patch || '').slice(0, 1500); + return `--- ${f.filename} (${f.status}, +${f.additions}/-${f.deletions})\n${patch}`; + }); + const diffContext = diffParts.join('\n\n'); + + const commitList = commits + .map(c => c.commit.message.split('\n')[0].trim()) + .filter(m => m && !m.startsWith('Merge')) + .join('\n'); + + // Call GitHub Models API to generate a natural-language summary + let aiSummary = ''; + try { + const aiResp = await fetch('https://models.github.ai/inference/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'openai/gpt-4.1-mini', + messages: [ + { + role: 'system', + content: 'You are a senior software engineer reviewing a pull request. Write a concise summary (2-4 sentences) describing what this PR does and why. Focus on the purpose and impact, not listing files. Do not use markdown headers or bullet points. Write in third person (e.g., "This PR adds..." not "I added...").' + }, + { + role: 'user', + content: `PR Title: ${pr.title}\n\nCommit messages:\n${commitList}\n\nDiff:\n${diffContext}` + } + ], + temperature: 0.3, + max_tokens: 300, + }) + }); + if (aiResp.ok) { + const aiData = await aiResp.json(); + aiSummary = aiData.choices?.[0]?.message?.content?.trim() || ''; + } + } catch (e) { + console.log('AI summary failed, continuing without it:', e.message); + } + + // Build per-file descriptions + function describeFile(file) { + const patch = file.patch || ''; + const ext = file.filename.split('.').pop(); + const lines = patch.split('\n'); + + if (file.status === 'added') return `New file (+${file.additions} lines)`; + if (file.status === 'removed') return `Deleted`; + if (file.status === 'renamed') return `Renamed from \`${file.previous_filename}\``; + + const touchedFns = []; + for (const line of lines) { + const m = line.match(/^@@.*@@\s*(?:(?:async\s+)?function\s+|(?:def|class)\s+)([a-zA-Z_$][\w$]*)/); + if (m && !touchedFns.includes(m[1])) touchedFns.push(m[1]); + } + + const newFns = []; + for (const line of lines) { + const m = line.match(/^\+\s*(?:(?:async\s+)?function\s+)([a-zA-Z_$][\w$]*)/); + if (m && !newFns.includes(m[1])) newFns.push(m[1]); + const py = line.match(/^\+\s*(?:def|class)\s+([a-zA-Z_][\w]*)/); + if (py && !newFns.includes(py[1])) newFns.push(py[1]); + } + + if (ext === 'css') { + const selectors = new Set(); + for (const line of lines) { + if (!line.startsWith('+')) continue; + const m = line.match(/^[+]\s*([.#][a-zA-Z][\w-]*|[a-z]+(?:\s*[>,+~]\s*[a-z]+)*)\s*\{/); + if (m) selectors.add(m[1]); + } + if (selectors.size) return `Updated ${[...selectors].slice(0, 4).join(', ')}${selectors.size > 4 ? ' +more' : ''}`; + return `Style updates (+${file.additions}/-${file.deletions})`; + } + + if (['md', 'txt', 'json'].includes(ext)) return `Updated (+${file.additions}/-${file.deletions})`; - **Title:** ${pr.title} - **Author:** @${pr.user.login} - **State:** ${pr.state.toUpperCase()} - **Draft:** ${pr.draft ? 'Yes' : 'No'} + const parts = []; + if (newFns.length) parts.push(`New: ${newFns.slice(0, 3).join(', ')}${newFns.length > 3 ? ' +' + (newFns.length - 3) + ' more' : ''}`); + const modOnly = touchedFns.filter(f => !newFns.includes(f)); + if (modOnly.length) parts.push(`Modified: ${modOnly.slice(0, 4).join(', ')}${modOnly.length > 4 ? ' +' + (modOnly.length - 4) + ' more' : ''}`); + if (parts.length) return parts.join('. '); + return `+${file.additions}/-${file.deletions}`; + } - ### Changes - - **Commits:** ${commits.length} - - **Files Changed:** ${files.length} - - **Additions:** +${pr.additions} - - **Deletions:** -${pr.deletions} + // Group files by directory + const groups = {}; + for (const f of files) { + const dir = f.filename.includes('/') ? f.filename.split('/').slice(0, -1).join('/') : '.'; + if (!groups[dir]) groups[dir] = []; + groups[dir].push(f); + } - ### Files Modified - ${files.slice(0, 10).map(f => `- \`${f.filename}\` (${f.changes} changes)`).join('\n')} - ${files.length > 10 ? `- ... and ${files.length - 10} more files` : ''} + const fileLines = []; + for (const [dir, dirFiles] of Object.entries(groups)) { + if (Object.keys(groups).length > 1) fileLines.push(`**${dir || 'root'}/**`); + for (const f of dirFiles.slice(0, 15)) { + const basename = f.filename.split('/').pop(); + fileLines.push(`- \`${basename}\` — ${describeFile(f)}`); + } + } - ${pr.body ? `### Description\n${pr.body}` : ''}`; + const summary = [ + '## 📋 PR Summary', + '', + `**Title:** ${pr.title}`, + `**Author:** @${pr.user.login}`, + `**Draft:** ${pr.draft ? 'Yes' : 'No'}`, + '', + `**${files.length}** files changed: **+${pr.additions}** additions, **-${pr.deletions}** deletions`, + '', + aiSummary ? `### Description\n${aiSummary}` : null, + aiSummary ? '' : null, + '### Files', + ...fileLines, + files.length > 15 ? `- ... and ${files.length - 15} more files` : '', + ].filter(v => v !== null && v !== false && v !== undefined && v !== '').join('\n'); await github.rest.issues.createComment({ issue_number: prNumber, From 1acc6369f11e180240c1718277646f62674a012a Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 12:11:00 -0400 Subject: [PATCH 09/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index bc34f63..f09171d 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -75,9 +75,13 @@ jobs: if (aiResp.ok) { const aiData = await aiResp.json(); aiSummary = aiData.choices?.[0]?.message?.content?.trim() || ''; + console.log('AI summary generated:', aiSummary.slice(0, 100)); + } else { + const errBody = await aiResp.text(); + console.log(`AI summary failed: HTTP ${aiResp.status} ${aiResp.statusText}`, errBody.slice(0, 500)); } } catch (e) { - console.log('AI summary failed, continuing without it:', e.message); + console.log('AI summary error:', e.message); } // Build per-file descriptions From 93f07e732e2a91e0e878aa3764197b40994185cf Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 12:31:01 -0400 Subject: [PATCH 10/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index f09171d..a175f7d 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Generate PR Summary uses: actions/github-script@v7 + env: + MODELS_TOKEN: ${{ secrets.MODELS_TOKEN }} with: script: | const prNumber = context.payload.pull_request.number; @@ -53,7 +55,7 @@ jobs: const aiResp = await fetch('https://models.github.ai/inference/chat/completions', { method: 'POST', headers: { - 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`, + 'Authorization': `Bearer ${process.env.MODELS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ From efc6147525dd69fb4d8d4edb5636cf81145aa3ca Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 13:12:06 -0400 Subject: [PATCH 11/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index a175f7d..3a9e6b2 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -59,7 +59,7 @@ jobs: 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: 'openai/gpt-4.1-mini', + model: 'openai/gpt-4o-mini', messages: [ { role: 'system', From 1f3eafb640ed4979408dd06c4add3811f3a869a7 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 13:55:42 -0400 Subject: [PATCH 12/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index 3a9e6b2..26ea7e6 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -37,8 +37,10 @@ jobs: pull_number: prNumber, }); - // Build diff context for AI summary (truncate to avoid token limits) - const diffParts = files.slice(0, 12).map(f => { + // Build diff context for AI summary — skip CI/workflow files, focus on app code + const skipDirs = ['.github/', '.circleci/', '.gitlab/']; + const appFiles = files.filter(f => !skipDirs.some(d => f.filename.startsWith(d))); + const diffParts = (appFiles.length ? appFiles : files).slice(0, 12).map(f => { const patch = (f.patch || '').slice(0, 1500); return `--- ${f.filename} (${f.status}, +${f.additions}/-${f.deletions})\n${patch}`; }); @@ -59,11 +61,11 @@ jobs: 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: 'openai/gpt-4o-mini', + model: 'meta-llama/Meta-Llama-3.1-8B-Instruct', messages: [ { role: 'system', - content: 'You are a senior software engineer reviewing a pull request. Write a concise summary (2-4 sentences) describing what this PR does and why. Focus on the purpose and impact, not listing files. Do not use markdown headers or bullet points. Write in third person (e.g., "This PR adds..." not "I added...").' + content: 'You are a senior software engineer reviewing a pull request. Write a concise summary (3-5 sentences) describing the functional changes in this PR. For each major change, explain WHAT was changed and WHY. Focus on application behavior and user-facing impact, not CI/workflow/tooling changes. Do not use markdown headers or bullet points. Write in third person (e.g., "This PR fixes..." not "I fixed..."). Be specific — name the features, functions, or components affected.' }, { role: 'user', From 3f4968560328d43decf43b3b9737dd9525853863 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 14:01:58 -0400 Subject: [PATCH 13/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index 26ea7e6..1d383fd 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -61,7 +61,7 @@ jobs: 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: 'meta-llama/Meta-Llama-3.1-8B-Instruct', + model: 'openai/gpt-4o-mini', messages: [ { role: 'system', From c02d7fc6c5178f643e2193a3a9b83b3fd4c466b6 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 14:17:25 -0400 Subject: [PATCH 14/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index 1d383fd..92cbfc7 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -133,22 +133,10 @@ jobs: return `+${file.additions}/-${file.deletions}`; } - // Group files by directory - const groups = {}; - for (const f of files) { - const dir = f.filename.includes('/') ? f.filename.split('/').slice(0, -1).join('/') : '.'; - if (!groups[dir]) groups[dir] = []; - groups[dir].push(f); - } - - const fileLines = []; - for (const [dir, dirFiles] of Object.entries(groups)) { - if (Object.keys(groups).length > 1) fileLines.push(`**${dir || 'root'}/**`); - for (const f of dirFiles.slice(0, 15)) { - const basename = f.filename.split('/').pop(); - fileLines.push(`- \`${basename}\` — ${describeFile(f)}`); - } - } + const appFiles = files.filter(f => !skipDirs.some(d => f.filename.startsWith(d))); + const fileLines = appFiles.slice(0, 15).map(f => + `- \`${f.filename}\` — ${describeFile(f)}` + ); const summary = [ '## 📋 PR Summary', From 613dc8cab9d46696ba70e32796101853ec66fe1a Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 14:22:43 -0400 Subject: [PATCH 15/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index 92cbfc7..af7165a 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -143,7 +143,6 @@ jobs: '', `**Title:** ${pr.title}`, `**Author:** @${pr.user.login}`, - `**Draft:** ${pr.draft ? 'Yes' : 'No'}`, '', `**${files.length}** files changed: **+${pr.additions}** additions, **-${pr.deletions}** deletions`, '', From 682a0fb4565d0acc56bf0cce5b8da515f4111657 Mon Sep 17 00:00:00 2001 From: Hiral Gosalia Date: Thu, 7 May 2026 14:26:18 -0400 Subject: [PATCH 16/16] Update pr-summary.yml --- .github/workflows/pr-summary.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index af7165a..ee7dd2a 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -133,8 +133,8 @@ jobs: return `+${file.additions}/-${file.deletions}`; } - const appFiles = files.filter(f => !skipDirs.some(d => f.filename.startsWith(d))); - const fileLines = appFiles.slice(0, 15).map(f => + const listedFiles = files.filter(f => !skipDirs.some(d => f.filename.startsWith(d))); + const fileLines = listedFiles.slice(0, 15).map(f => `- \`${f.filename}\` — ${describeFile(f)}` ); @@ -143,6 +143,7 @@ jobs: '', `**Title:** ${pr.title}`, `**Author:** @${pr.user.login}`, + `**Draft:** ${pr.draft ? 'Yes' : 'No'}`, '', `**${files.length}** files changed: **+${pr.additions}** additions, **-${pr.deletions}** deletions`, '',