static site frontend for mapped.at mapped.at
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add basic data caching

+177 -28
+2 -1
.gitignore
··· 1 1 .superpowers 2 - docs/superpowers 2 + docs/superpowers 3 + public/data/service.json
+1 -1
.tangled/workflows/spindle.yaml
··· 24 24 command: | 25 25 export PATH="$HOME/.nix-profile/bin:$PATH" 26 26 27 - echo "This is where a build step would go... if I had one!" 27 + bun run scripts/fetch-service-data.mjs 28 28 29 29 - name: deploy to wisp 30 30 command: |
+131 -26
public/api.js
··· 93 93 // ── fetchServiceData ─────────────────────────────────────────────── 94 94 // Fetches trails, locations, activities from the service account and returns 95 95 // URI→record lookup maps plus normalised arrays for public consumption. 96 - let _servicePromise = null; 96 + // Uses a build-time snapshot for instant load, then revalidates in background. 97 + let _cachedServiceData = null; 98 + let _serviceInflight = null; 99 + 97 100 export function fetchServiceData() { 98 - if (!_servicePromise) _servicePromise = _fetchServiceData(); 99 - return _servicePromise; 101 + if (_cachedServiceData) return Promise.resolve(_cachedServiceData); 102 + if (!_serviceInflight) _serviceInflight = _fetchServiceData(); 103 + return _serviceInflight; 100 104 } 101 105 102 - async function _fetchServiceData() { 103 - const [trailRecs, locationRecs, activityRecs] = await Promise.all([ 104 - listRecords('at.mapped.trail'), 105 - listRecords('at.mapped.location'), 106 - listRecords('at.mapped.activity'), 107 - ]); 108 - 109 - // Activity map: uri → { uri, rkey, name } 106 + // Builds the service data object from raw PDS record arrays. 107 + // Each record is { uri, value } as returned by com.atproto.repo.listRecords. 108 + function _buildFromRecs(trailRecs, locationRecs, activityRecs) { 110 109 const activityMap = new Map(); 111 110 for (const { uri, value } of activityRecs) { 112 111 const rkey = uri.split('/').pop(); 113 112 activityMap.set(uri, { uri, rkey, name: value.name }); 114 113 } 115 114 116 - // Location map: uri → { uri, rkey, name, lat, lng } 117 115 const locationMap = new Map(); 118 116 for (const { uri, value } of locationRecs) { 119 117 const rkey = uri.split('/').pop(); ··· 125 123 }); 126 124 } 127 125 128 - // Trail map: uri → { uri, rkey, name, activityType, locations, geo } 129 - // locations is an array of resolved location objects (used for the filter dropdown). 130 126 const trailMap = new Map(); 131 127 for (const { uri, value } of trailRecs) { 132 128 const rkey = uri.split('/').pop(); ··· 152 148 return { trailMap, locationMap, activityMap, trails, locations, activities }; 153 149 } 154 150 151 + async function _fetchServiceData() { 152 + // Try bundled snapshot first (generated at build time) 153 + try { 154 + const res = await fetch('/data/service.json'); 155 + if (res.ok) { 156 + const { trails, locations, activities } = await res.json(); 157 + const data = _buildFromRecs(trails, locations, activities); 158 + _cachedServiceData = data; 159 + // Revalidate from live PDS in background 160 + _revalidateServiceData(); 161 + return data; 162 + } 163 + } catch (_) { 164 + // 404 on local dev or network error — fall through to live fetch 165 + } 166 + 167 + // Live fetch (local dev or first-ever deploy) 168 + return _fetchLiveServiceData(); 169 + } 170 + 171 + async function _fetchLiveServiceData() { 172 + const [trailRecs, locationRecs, activityRecs] = await Promise.all([ 173 + listRecords('at.mapped.trail'), 174 + listRecords('at.mapped.location'), 175 + listRecords('at.mapped.activity'), 176 + ]); 177 + const data = _buildFromRecs(trailRecs, locationRecs, activityRecs); 178 + _cachedServiceData = data; 179 + return data; 180 + } 181 + 182 + async function _revalidateServiceData() { 183 + try { 184 + const prev = _cachedServiceData; 185 + const fresh = await _fetchLiveServiceData(); 186 + _serviceInflight = null; 187 + if (_serviceDataChanged(prev, fresh)) { 188 + document.dispatchEvent(new CustomEvent('mapped:servicedata')); 189 + } 190 + } catch (_) { 191 + // Silently ignore — stale data is fine 192 + } 193 + } 194 + 195 + function _serviceDataChanged(prev, next) { 196 + if (!prev) return true; 197 + return prev.trails.map(t => t.uri).join() !== next.trails.map(t => t.uri).join(); 198 + } 199 + 155 200 // ── collectPostRefs ──────────────────────────────────────────────── 156 201 // Discovers all user posts that reference the mapped.at base post via basePost. 157 202 // Returns an array of { did, rkey } unique post references. ··· 254 299 }; 255 300 } 256 301 302 + // ── Post cache helpers ───────────────────────────────────────────── 303 + const POST_CACHE_KEY = 'mapped_cache'; 304 + const POST_CACHE_TTL_MS = 60_000; 305 + 306 + function _readPostsCache() { 307 + try { 308 + const raw = localStorage.getItem(POST_CACHE_KEY); 309 + if (!raw) return null; 310 + const { posts, cachedAt } = JSON.parse(raw); 311 + if (Date.now() - cachedAt > POST_CACHE_TTL_MS) return null; 312 + // Revive Date objects (JSON serialises them as strings) 313 + return posts.map(p => ({ ...p, timestamp: new Date(p.timestamp) })); 314 + } catch (_) { 315 + return null; 316 + } 317 + } 318 + 319 + function _writePostsCache(posts) { 320 + try { 321 + localStorage.setItem(POST_CACHE_KEY, JSON.stringify({ posts, cachedAt: Date.now() })); 322 + } catch (_) { 323 + // localStorage may be unavailable (private browsing, quota exceeded) — ignore 324 + } 325 + } 326 + 257 327 // ── fetchAll ─────────────────────────────────────────────────────── 258 - // Main entry point. Fetches all mapped.at data and returns normalised arrays. 259 - // Returns a cached Promise — safe to call from multiple components simultaneously. 260 - let _promise = null; 328 + // Main entry point. Returns cached data instantly when available. 329 + // Revalidates posts in background; dispatches 'mapped:posts' on update. 330 + let _cachedResult = null; 331 + let _fetchAllInflight = null; 332 + 261 333 export function fetchAll() { 262 - if (!_promise) _promise = _fetchAll(); 263 - return _promise; 334 + if (_cachedResult) return Promise.resolve(_cachedResult); 335 + if (!_fetchAllInflight) _fetchAllInflight = _fetchAll(); 336 + return _fetchAllInflight; 264 337 } 265 338 266 339 async function _fetchAll() { 267 - // Step 1: Service account data (cached — shared with TrailsList/TabSwitcher) 268 340 const serviceData = await fetchServiceData(); 269 341 const { trails, locations, activities } = serviceData; 270 342 271 - // Step 2: Discover posts via basePost backlinks on the mapped.at base post 343 + // Serve from localStorage cache if fresh 344 + const cached = _readPostsCache(); 345 + if (cached) { 346 + _cachedResult = { trails, locations, activities, posts: cached }; 347 + _fetchAllInflight = null; 348 + _revalidatePosts(serviceData); 349 + return _cachedResult; 350 + } 351 + 352 + // Cold fetch 353 + const posts = await _fetchPosts(serviceData); 354 + _writePostsCache(posts); 355 + _cachedResult = { trails, locations, activities, posts }; 356 + return _cachedResult; 357 + } 358 + 359 + // Extracted post-fetching logic (constellation + user PDSs). 360 + async function _fetchPosts(serviceData) { 272 361 const postRefs = await collectPostRefs(); 273 362 274 - // Step 3: Resolve each unique DID (parallel) 275 363 const uniqueDids = [...new Set(postRefs.map(r => r.did))]; 276 364 const didInfoMap = new Map(); 277 365 await Promise.all(uniqueDids.map(async did => { ··· 279 367 if (info) didInfoMap.set(did, info); 280 368 })); 281 369 282 - // Step 4: Fetch each post record from its author's PDS (parallel) 283 370 const posts = (await Promise.all(postRefs.map(async ({ did, rkey }) => { 284 371 const info = didInfoMap.get(did); 285 - if (!info) return null; // DID resolution failed — skip this post 372 + if (!info) return null; 286 373 const value = await fetchPostRecord(info.pds, did, rkey); 287 374 if (!value) return null; 288 375 return _hydratePost(did, rkey, value, info.author, serviceData); 289 376 }))).filter(Boolean); 290 377 291 - // Sort posts newest first 292 378 posts.sort((a, b) => b.timestamp - a.timestamp); 379 + return posts; 380 + } 293 381 294 - return { trails, locations, activities, posts }; 382 + async function _revalidatePosts(serviceData) { 383 + try { 384 + const prevPosts = _cachedResult?.posts ?? []; 385 + const posts = await _fetchPosts(serviceData); 386 + _writePostsCache(posts); 387 + const { trails, locations, activities } = serviceData; 388 + _cachedResult = { trails, locations, activities, posts }; 389 + if (_postsChanged(prevPosts, posts)) { 390 + document.dispatchEvent(new CustomEvent('mapped:posts')); 391 + } 392 + } catch (_) { 393 + // Silently ignore — stale data is fine 394 + } 395 + } 396 + 397 + function _postsChanged(prev, next) { 398 + if (prev.length !== next.length) return true; 399 + return prev.some((p, i) => p.uri !== next[i].uri || p.timestamp.getTime() !== next[i].timestamp.getTime()); 295 400 }
+17
public/script.js
··· 515 515 connectedCallback() { 516 516 this._unsubscribe = onRouteChange(route => this._update(route)); 517 517 this._render(getRoute()); 518 + this._onServiceData = () => this._update(getRoute()); 519 + document.addEventListener('mapped:servicedata', this._onServiceData); 518 520 } 519 521 520 522 disconnectedCallback() { 521 523 this._unsubscribe?.(); 524 + document.removeEventListener('mapped:servicedata', this._onServiceData); 522 525 } 523 526 524 527 _render(route) { ··· 756 759 757 760 const observer = new MutationObserver(() => this._load(shadow)); 758 761 observer.observe(this, { attributes: true, attributeFilter: ['data-filter'] }); 762 + 763 + this._onServiceData = () => this._load(shadow); 764 + document.addEventListener('mapped:servicedata', this._onServiceData); 765 + } 766 + 767 + disconnectedCallback() { 768 + document.removeEventListener('mapped:servicedata', this._onServiceData); 759 769 } 760 770 761 771 async _load(shadow) { ··· 797 807 798 808 const observer = new MutationObserver(() => this._load(shadow)); 799 809 observer.observe(this, { attributes: true, attributeFilter: ['data-filter'] }); 810 + 811 + this._onPosts = () => this._load(shadow); 812 + document.addEventListener('mapped:posts', this._onPosts); 813 + } 814 + 815 + disconnectedCallback() { 816 + document.removeEventListener('mapped:posts', this._onPosts); 800 817 } 801 818 802 819 async _load(shadow) {
+26
scripts/fetch-service-data.mjs
··· 1 + #!/usr/bin/env node 2 + import { writeFile, mkdir } from 'node:fs/promises'; 3 + import { join, dirname } from 'node:path'; 4 + import { fileURLToPath } from 'node:url'; 5 + 6 + const MAPPED_AT_DID = 'did:plc:l5m5nuh5cvdatyn5fjxar2sh'; 7 + const MAPPED_AT_PDS = 'https://leccinum.us-west.host.bsky.network'; 8 + const OUT_PATH = join(dirname(fileURLToPath(import.meta.url)), '../public/data/service.json'); 9 + 10 + async function listRecords(collection) { 11 + const url = `${MAPPED_AT_PDS}/xrpc/com.atproto.repo.listRecords` + 12 + `?repo=${MAPPED_AT_DID}&collection=${collection}&limit=100`; 13 + const res = await fetch(url); 14 + if (!res.ok) throw new Error(`listRecords(${collection}) HTTP ${res.status}`); 15 + return (await res.json()).records ?? []; 16 + } 17 + 18 + const [trails, locations, activities] = await Promise.all([ 19 + listRecords('at.mapped.trail'), 20 + listRecords('at.mapped.location'), 21 + listRecords('at.mapped.activity'), 22 + ]); 23 + 24 + await mkdir(dirname(OUT_PATH), { recursive: true }); 25 + await writeFile(OUT_PATH, JSON.stringify({ trails, locations, activities, fetchedAt: new Date().toISOString() })); 26 + console.log(`Wrote ${OUT_PATH} (${trails.length} trails, ${locations.length} locations, ${activities.length} activities)`);