1// Automatic "Near me" store detection + keep-fresh bridge for the shopping 2// list. There is deliberately NO button and NO setting: detection runs silently 3// on page-open, on foreground (phone re-opened), and on the 10-minute tick. 4// init()'s second arg only reflects whether detecting can accomplish anything 5// (the household has items AND stores); the Account page hosts nothing but the 6// permission-recovery retry (permissionState/requestNow below). The component 7// only ever hears about a USABLE fix (OnGeoLocationAuto); every failure mode â 8// no permission, dismissed prompt, timeout, coarse fix â stays completely silent. 9window.iweGeo = (function () { 10 // In-memory once-per-page guard for the permission prompt. Primary defence is 11 // the localStorage flag below (survives across cold relaunches), but hardened 12 // browser configs (Safari "Block All Cookies" etc.) make storage setters 13 // THROW â without this flag those users would be re-prompted on every 14 // foreground and every 10-min tick, forever. 15 var asked = false; 16 17 // How good a fix has to be before we stop waiting. The server needs better 18 // than 500 m to name a store; 60 m is comfortably inside that and is what a 19 // phone's GPS settles at outdoors within a couple of seconds. 20 var GOOD_ENOUGH_M = 60; 21 // How long to keep listening for a better one. Long enough for GPS to take 22 // over from the network fix indoors, short enough that a store-detect on 23 // page-open doesn't hold the radio awake. 24 var BURST_MS = 8000; 25 26 // ONE fix, acquired properly. 27 // 28 // This used to be a single getCurrentPosition with maximumAge: 300000, and it 29 // is why store detection quietly never worked in a shop: the browser is free 30 // to answer a five-minute-old cached position, enableHighAccuracy does NOT 31 // force a fresh read when a cached one satisfies maximumAge, and the cached 32 // one is whatever coarse network fix the device happened to have. A real trip 33 // to a supermarket produced 4239 m twice â the same IP-derived position hours 34 // apart â which the server rightly refused as too coarse to name a store, so 35 // nothing was ever detected. 36 // 37 // watchPosition instead: the first callback is usually that same coarse 38 // network fix, and GPS then refines it over the next few seconds. We keep the 39 // BEST accuracy seen, stop as soon as it's good enough, and give up after 40 // BURST_MS with whatever we've got. maximumAge: 0 so the cache can't answer 41 // for us at all. 42 function locate(onFix, onFail) { 43 var best = null, watchId = null, timer = 0, done = false; 44 45 function finish() { 46 if (done) return; 47 done = true; 48 if (timer) { clearTimeout(timer); timer = 0; } 49 if (watchId !== null) { 50 try { navigator.geolocation.clearWatch(watchId); } catch (_) {} 51 watchId = null; 52 } 53 if (best) onFix(best); else if (onFail) onFail(null); 54 } 55 56 try { 57 watchId = navigator.geolocation.watchPosition( 58 function (pos) { 59 var acc = (pos.coords && pos.coords.accuracy) || 0; 60 // accuracy 0 means "unknown", not "perfect" â treat it as worst. 61 var score = acc > 0 ? acc : Number.MAX_VALUE; 62 if (!best || score < best.score) best = { pos: pos, score: score }; 63 if (score <= GOOD_ENOUGH_M) finish(); 64 }, 65 function (err) { 66 // A late error after we already have a fix isn't a failure â a phone 67 // that got GPS and then lost it still told us where it was. 68 if (best) { finish(); return; } 69 if (done) return; 70 done = true; 71 if (timer) { clearTimeout(timer); timer = 0; } 72 if (watchId !== null) { 73 try { navigator.geolocation.clearWatch(watchId); } catch (_) {} 74 watchId = null; 75 } 76 if (onFail) onFail(err); 77 }, 78 { enableHighAccuracy: true, timeout: BURST_MS, maximumAge: 0 } 79 ); 80 timer = setTimeout(finish, BURST_MS); 81 } catch (_) { 82 if (onFail) onFail(null); 83 } 84 } 85 86 return { 87 // One detection attempt, gated so it never nags: 88 // granted â locate silently (every call). 89 // denied â do nothing, ever. 90 // prompt â ask AT MOST once per DEVICE (localStorage flag), so the 91 // browser's native permission prompt is the one-time opt-in and 92 // a dismissed/ignored prompt doesn't re-appear on every 93 // foreground â including cold PWA relaunches, which iOS/Firefox
94 // do aggressively and which would otherwise reset a 95 // session-scoped guard and re-nag on every launch. A device that 96 // dismissed can still opt in later via the Account page retry. 97 // Old browsers without the Permissions API can't distinguish these states: 98 // there we attempt freely only after a past success (localStorage iweGeoOk â 99 // effectively "granted"), otherwise once per device. 100 detectAuto: function (dotnetRef) { 101 try { 102 if (!navigator.geolocation) return; 103 104 var attempt = function () { 105 locate( 106 function (best) { 107 var pos = best.pos; 108 try { localStorage.iweGeoOk = '1'; } catch (_) {} 109 // .catch: the component may have been disposed (nav away) during 110 // the async geolocation gap â drop the late callback quietly. 111 dotnetRef.invokeMethodAsync( 112 'OnGeoLocationAuto', pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy || 0) 113 .catch(function () { /* torn down mid-detect */ }); 114 }, 115 function (err) { 116 // Permanently denied â forget the past-success marker so the 117 // no-Permissions-API fallback stops re-attempting next sessions. 118 if (err && err.code === 1) { try { delete localStorage.iweGeoOk; } catch (_) {} } 119 } 120 ); 121 }; 122 var attemptOnceEver = function () { 123 if (asked) return; 124 asked = true; // page-lifetime guard â holds even when storage throws 125 try { 126 // localStorage (not sessionStorage): the guard must survive cold PWA 127 // relaunches, or the auto-prompt re-fires every time the OS kills and 128 // reopens the app. At most one auto-prompt per device, ever. 129 if (localStorage.iweGeoAsked === '1') return; 130 localStorage.iweGeoAsked = '1'; 131 } catch (_) { /* storage blocked â the in-memory flag still limits us */ } 132 attempt(); 133 }; 134 135 if (navigator.permissions && navigator.permissions.query) { 136 navigator.permissions.query({ name: 'geolocation' }).then(function (status) { 137 if (status.state === 'granted') attempt(); 138 else if (status.state === 'prompt') attemptOnceEver(); 139 // 'denied' â silent, never call. 140 }).catch(attemptOnceEver); // Permissions API quirk â degrade to once-per-device 141 } else if (localStorage.iweGeoOk === '1') { 142 attempt(); // effectively granted (a past success) 143 } else { 144 attemptOnceEver(); 145 } 146 } catch (_) { /* swallow â detection must never break the page */ } 147 }, 148 149 // For the Account page's location-recovery section: report the current 150 // geolocation permission state ('granted'/'prompt'/'denied'/'unsupported'). 151 // Where the Permissions API is missing (older Safari), fall back to the same 152 // past-success marker detectAuto uses â otherwise a working, effectively- 153 // granted device would see the "fix your location" section on every visit. 154 permissionState: function () { 155 function fallback() { 156 try { return localStorage.iweGeoOk === '1' ? 'granted' : 'prompt'; } 157 catch (_) { return 'prompt'; } 158 } 159 try { 160 if (!navigator.geolocation) return Promise.resolve('unsupported'); 161 if (!navigator.permissions || !navigator.permissions.query) return Promise.resolve(fallback()); 162 return navigator.permissions.query({ name: 'geolocation' }) 163 .then(function (s) { return s.state; }) 164 .catch(function () { return fallback(); }); 165 } catch (_) { return Promise.resolve('unsupported'); } 166 }, 167 168 // Explicit user-gesture retry (the Account-page button): attempt a fix NOW. 169 // In the 'prompt' state this raises the browser's native permission dialog; 170 // after a hard "Block" the browser refuses without prompting and we resolve 171 // 'denied' so the UI can point at browser settings. Success marks the same
172 // flags detectAuto uses, so the shopping list picks it up seamlessly. 173 requestNow: function () { 174 return new Promise(function (resolve) { 175 try { 176 if (!navigator.geolocation) { resolve('unsupported'); return; } 177 try { localStorage.iweGeoAsked = '1'; } catch (_) {} 178 // Same burst as the silent path â a "retry" that accepted a cached 179 // 4 km fix would report success while leaving detection just as 180 // broken as before. 181 locate( 182 function () { 183 try { localStorage.iweGeoOk = '1'; } catch (_) {} 184 resolve('granted'); 185 }, 186 function (err) { 187 if (err && err.code === 1) { resolve('denied'); return; } 188 // Timeout / position-unavailable is NOT "blocked in the browser": 189 // the user may have just ACCEPTED the prompt and simply gotten no 190 // fix (indoors, OS location services off). Re-check the actual 191 // permission so the UI doesn't show the wrong "blocked" help. 192 if (navigator.permissions && navigator.permissions.query) { 193 navigator.permissions.query({ name: 'geolocation' }).then(function (s) { 194 if (s.state === 'granted') { try { localStorage.iweGeoOk = '1'; } catch (_) {} resolve('granted'); } 195 else resolve(s.state === 'denied' ? 'denied' : 'error'); 196 }).catch(function () { resolve('error'); }); 197 } else { resolve('error'); } 198 } 199 ); 200 } catch (_) { resolve('error'); } 201 }); 202 } 203 }; 204})(); 205 206// Keep-fresh bridge. The Blazor component registers its dotnet ref (plus 207// whether store detection is enabled) on first render; we then refresh the 208// list â and re-detect the store â when the app returns to the foreground and 209// every 10 min while it stays open. The live HouseholdListNotifier already 210// pushes changes while the SignalR circuit is connected; this covers the gap 211// after a background/lock, where the circuit gets evicted and the page would 212// otherwise sit stale. 213window.iweHousehold = (function () { 214 var ref = null, timer = 0, lastRefresh = 0, detectOn = false; 215 216 function maybeDetect() { if (ref && detectOn) window.iweGeo.detectAuto(ref); } 217 218 function refresh() { 219 if (!ref) return; 220 var now = Date.now(); 221 if (now - lastRefresh < 3000) return; // debounce the visibilitychange/pageshow double-fire 222 lastRefresh = now; 223 // If the invoke rejects the circuit is down â do NOTHING and let Blazor's 224 // built-in auto-reconnect restore it. We deliberately don't location.reload() 225 // here: on a deploy/eviction navigator.onLine is still true, so a reload would 226 // storm (reload â no circuit â reload â¦) and fight auto-reconnect. A genuine 227 // page reload (Blazor gave up, or iOS killed the PWA) re-runs detection via 228 // OnAfterRenderAsync anyway. 229 ref.invokeMethodAsync('RefreshFromClient') 230 .then(maybeDetect) 231 .catch(function () { /* circuit down â let Blazor auto-reconnect handle it */ }); 232 } 233 234 // Only visibilitychange + pageshow â the real "app came to the foreground" 235 // signals. 'focus' fires far too eagerly on desktop (every alt-tab) and would 236 // churn the list / re-detect for no benefit. 237 function onVisible() { if (!document.hidden) refresh(); } 238 239 function teardown() { 240 if (timer) { clearInterval(timer); timer = 0; } 241 document.removeEventListener('visibilitychange', onVisible); 242 window.removeEventListener('pageshow', onVisible); 243 ref = null; 244 detectOn = false; 245 } 246 247 return { 248 init: function (dotnetRef, detectEnabled) { 249 teardown(); // idempotent â drop any prior registration 250 ref = dotnetRef; 251 detectOn = !!detectEnabled; 252 document.addEventListener('visibilitychange', onVisible); 253 window.addEventListener('pageshow', onVisible); 254 timer = setInterval(function () { 255 if (document.hidden || !ref) return; // don't poke a backgrounded circuit 256 ref.invokeMethodAsync('RefreshFromClient').then(maybeDetect).catch(function () {}); 257 }, 600000); // 10 min 258 maybeDetect(); // initial detection on open 259 }, 260 dispose: teardown, 261 // Diagnostics only â lets a live page prove the bridge actually wired 262 // (a silent-failure regression here is invisible in normal use). 263 wired: function () { return !!ref; }, 264 detecting: function () { return detectOn; } 265 }; 266})(); 267 268// Viewer-local timestamp formatting. Blazor Server renders DateTimes in the 269// SERVER's timezone and culture (prod: UTC + invariant) â hours off for the 270// user and always US-shaped. Any element carrying data-utc (an ISO-8601 UTC 271// instant) gets rewritten here with the browser's own TZ + locale. Idempotent: 272// once formatted, the marker attribute flips so re-runs skip it; Blazor 273// re-creates elements on re-render (fresh, unformatted), so the component 274// calls format() after every render. 275// Floating scroll-to-top / scroll-to-bottom arrows. Any page that renders the 276// #scroll-nav-up / #scroll-nav-down buttons calls this once after render; the 277// buttons fade in only when the viewport isn't already near the top/bottom. 278// Idempotent + global: the handler re-queries by id on every fire (so Blazor's 279// per-navigation DOM swap can't leave it pointing at a detached no
279de) and any 280// previous installation is removed first so repeated navigations don't stack. 281window.iweScrollNav = function () { 282 try { 283 if (window.__scrollNavHandler) { 284 window.removeEventListener('scroll', window.__scrollNavHandler); 285 window.removeEventListener('resize', window.__scrollNavHandler); 286 } 287 var update = function () { 288 var up = document.getElementById('scroll-nav-up'); 289 var down = document.getElementById('scroll-nav-down'); 290 if (!up || !down) return; 291 var y = window.scrollY || document.documentElement.scrollTop; 292 var max = (document.documentElement.scrollHeight || document.body.scrollHeight) - window.innerHeight; 293 up.classList.toggle('visible', y > 200); 294 down.classList.toggle('visible', y < max - 200); 295 }; 296 window.__scrollNavHandler = update; 297 window.addEventListener('scroll', update, { passive: true }); 298 window.addEventListener('resize', update); 299 update(); 300 } catch (_) { /* swallow */ } 301}; 302 303window.iweTime = { 304 // Minutes to ADD to local time to get UTC (JS convention: EDT â +240). 305 // The server uses this to compute the viewer's local midnight so the 306 // "recently bought" strip clears at local midnight, not on a rolling 24h. 307 tzOffsetMinutes: function () { 308 try { return new Date().getTimezoneOffset(); } catch (_) { return 0; } 309 }, 310 // Coarse "today / yesterday / N days ago" phrase, in the viewer's LOCAL 311 // calendar days and locale. Auto-localized via Intl.RelativeTimeFormat. 312 relDay: function (d) { 313 var now = new Date(); 314 var a = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 315 var b = new Date(d.getFullYear(), d.getMonth(), d.getDate()); 316 var days = Math.round((a - b) / 86400000); 317 try { 318 return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(-days, 'day'); 319 } catch (_) { 320 return days <= 0 ? 'today' : days === 1 ? 'yesterday' : days + ' days ago'; 321 } 322 }, 323 // Fine-grained "just now / 5 minutes ago / 2 hours ago" phrase. Distinct 324 // from relDay above, which is deliberately day-granular ("yesterday") and is 325 // what FriendsPanel wants â this one is for something that just happened and 326 // whose age matters in minutes. 327 relLive: function (d) { 328 var secs = Math.round((Date.now() - d.getTime()) / 1000); 329 // Clock skew or a stamp a second in the future: don't say "in 0 seconds". 330 if (secs < 45) return 'just now'; 331 var units = [ 332 ['minute', 60], ['hour', 3600], ['day', 86400], 333 ['week', 604800], ['month', 2629800], ['year', 31557600], 334 ]; 335 // Pick the largest unit the age fills at least once. 336 var unit = 'minute', size = 60; 337 for (var i = 0; i < units.length; i++) { 338 if (secs >= units[i][1]) { unit = units[i][0]; size = units[i][1]; } 339 } 340 var n = Math.round(secs / size); 341 try { 342 return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(-n, unit); 343 } catch (_) { 344 return n + ' ' + unit + (n === 1 ? '' : 's') + ' ago'; 345 } 346 }, 347 format: function () { 348 try { 349 document.querySelectorAll('[data-utc]').forEach(function (el) { 350 try { 351 var src = el.getAttribute('data-utc'); 352 // Re-format whenever the source value changes â not just the first 353 // time. Blazor reuses an element and swaps its data-utc in place 354 // (e.g. a Friends invite after a resend), so a permanent "done" flag 355 // would leave the old localized text stuck until a full reload. 356 // data-utc-done records the exact value we last formatted for. 357 // A LIVE relative stamp must re-render on every pass even though 358 // its source instant never changes â the whole point is that the 359 // text ages ("just now" -> "5 minutes ago"). The done-guard below 360 // exists to skip unchanged ABSOLUTE values, so live ones jump it. 361 var live = el.hasAttribute('data-rel-live'); 362 if (!live && el.getAttribute('data-utc-done') === src) return; 363 var d = new Date(src); 364 if (isNaN(d)) return; 365 if (live) { 366 el.textContent = window.iweTime.relLive(d); 367 } else if (el.hasAttribute('data-rel')) {
368 // data-rel â coarse relative phrase ("3 days ago") instead of a date. 369 el.textContent = window.iweTime.relDay(d); 370 } else { 371 // data-time24 â force 24-hour time (no AM/PM), e.g. "7/9/26, 19:00". 372 var opts = el.hasAttribute('data-time24') 373 ? { year: '2-digit', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' } 374 : { dateStyle: 'short', timeStyle: 'short' }; 375 el.textContent = d.toLocaleString(undefined, opts); 376 } 377 el.setAttribute('data-utc-done', src); 378 } catch (_) { /* leave the server-rendered fallback text */ } 379 }); 380 } catch (_) { /* swallow */ } 381 } 382}; 383 384// Keep [data-rel-live] stamps honest without a server round-trip. Blazor only 385// re-renders on an interaction, so a "just now" written at page load would 386// still read "just now" an hour later; this re-runs the formatter on a timer. 387// 30s is the coarsest interval that still flips "just now" (< 45s) promptly. 388// Installed once â the guard survives repeated script evaluation â and it only 389// walks the DOM when such an element is actually present, so pages without one 390// pay a querySelector every 30 seconds and nothing else. 391(function () { 392 if (window.__iweRelLiveTimer) return; 393 window.__iweRelLiveTimer = setInterval(function () { 394 try { 395 if (!document.querySelector('[data-rel-live]')) return; 396 window.iweTime.format(); 397 } catch (_) { /* swallow */ } 398 }, 30000); 399 // A backgrounded tab throttles timers, so the stamp can be minutes stale by 400 // the time it is looked at again. Refresh the moment it becomes visible. 401 document.addEventListener('visibilitychange', function () { 402 if (document.visibilityState !== 'visible') return; 403 try { 404 if (document.querySelector('[data-rel-live]')) window.iweTime.format(); 405 } catch (_) { /* swallow */ } 406 }); 407})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.