1/* local-corner.js for alpha-lock, built 2026-10-02 by gotham-ops/local-corner/build.mjs. Do not hand-edit. */ 2(function () { 3 var CONFIG = {"id":"local-corner","mode":"fill","tz":"America/New_York","weather":{"url":"https://api.weather.gov/gridpoints/OKX/39,58/forecast/hourly","rule":"lock-cold","tz":"America/New_York","place":"White Plains","lat":41.034,"lon":-73.7629}}; 4/* 5 * Local corner: a small, dependency-free block that adds local value to a client homepage. 6 * Built per site by gotham-ops/local-corner/build.mjs, which wraps this file with that site's CONFIG. 7 * Owner's request 2026-10-01. Do not hand-edit the built copy in a client repo; edit the site JSON 8 * under gotham-ops/local-corner/sites/ and rebuild. 9 * 10 * Two modes: 11 * fill - the block is already in the page HTML (static sites). This script only adds the live 12 * weather line and marks the current season. If it never runs, the page is complete. 13 * inject - the page is a hydrated Next.js (React 19) export, so the block is inserted only after 14 * React has hydrated <main> (same pattern as the fleet's holiday notices). It re-inserts 15 * itself if React re-renders, and removes itself on client-side navigation away from "/". 16 * 17 * Weather comes from the US National Weather Service (api.weather.gov, public domain, CORS open, 18 * cached 1h by NWS). One request per visit, cached 30 min in localStorage when available. 19 */ 20 "use strict"; 21 22 var ID = CONFIG.id || "local-corner"; 23 var CACHE_MS = 30 * 60 * 1000; 24 25 // ---------- helpers ---------- 26 function nowParts() { 27 var tz = (CONFIG.weather && CONFIG.weather.tz) || CONFIG.tz || undefined; 28 try { 29 var f = new Intl.DateTimeFormat("en-US", { timeZone: tz, month: "numeric", hour: "numeric", hour12: false }); 30 var p = f.formatToParts(new Date()); 31 var m = 0, h = 0; 32 for (var i = 0; i < p.length; i++) { 33 if (p[i].type === "month") m = parseInt(p[i].value, 10); 34 if (p[i].type === "hour") h = parseInt(p[i].value, 10) % 24; 35 } 36 if (m) return { month: m, hour: h }; 37 } catch (e) {} 38 var d = new Date(); 39 return { month: d.getMonth() + 1, hour: d.getHours() }; 40 } 41 42 function hourLabel(iso) { 43 try { 44 return new Intl.DateTimeFormat("en-US", { timeZone: CONFIG.weather.tz, hour: "numeric" }).format(new Date(iso)); 45 } catch (e) { 46 return ""; 47 } 48 } 49 50 function dayKey(d) { 51 try { 52 return new Intl.DateTimeFormat("en-CA", { timeZone: CONFIG.weather.tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(d); 53 } catch (e) { 54 return d.toDateString(); 55 } 56 } 57 58 // "today" / "tomorrow" for an hourly period, in the site's own time zone. 59 function dayWord(iso) { 60 var t = new Date(iso); 61 if (dayKey(t) === dayKey(new Date())) return "today"; 62 if (dayKey(t) === dayKey(new Date(Date.now() + 86400000))) return "tomorrow"; 63 return ""; 64 } 65 66 function rainWords(pop) { 67 return pop >= 60 ? "Rain is likely (" + pop + "% chance)" : "Rain is possible (up to " + pop + "% chance)"; 68 } 69 70 function cToF(c) { 71 return c == null ? null : Math.round(c * 9 / 5 + 32); 72 } 73 74 function num(v) { 75 if (v == null) return null; 76 if (typeof v === "number") return v; 77 if (typeof v === "object" && "value" in v) return v.value == null ? null : Number(v.value); 78 return Number(v); 79 } 80 81 function windMph(s) { 82 var m = String(s || "").match(/(\d+)(?:\s*to\s*(\d+))?/); 83 if (!m) return null; 84 return parseInt(m[2] || m[1], 10); 85 } 86 87 function cacheGet(key) { 88 try { 89 var raw = window.localStorage && localStorage.getItem(key); 90 if (!raw) return null; 91 var o = JSON.parse(raw); 92 if (!o || Date.now() - o.t > CACHE_MS) return null; 93 return o.v; 94 } catch (e) { 95 return null; 96 } 97 } 98 99 function cacheSet(key, v) { 100 try { 101 if (window.localStorage) localStorage.setItem(key, JSON.stringify({ t: Date.now(), v: v })); 102 } catch (e) {} 103 } 104 105 function fetchJson(url, ms) { 106 return new Promise(function (resolve, reject) { 107 var done = false; 108 var ctrl = window.AbortController ? new AbortController() : null; 109 var timer = setTimeout(function () { 110 if (done) return; 111 done = true; 112 if (ctrl) ctrl.abort(); 113 reject(new Error("timeout")); 114 }, ms); 115 fetch(url, { headers: { Accept: "application/geo+json" }, signal: ctrl ? ctrl.signal : undefined }) 116 .then(function (r) { 117 if (!r.ok) throw new Error("http " + r.status); 118 return r.json(); 119 }) 120 .then(function (j) { 121 if (done) return; 122 done = true; 123 clearTimeout(timer); 124 resolve(j); 125 }) 126 .catch(function (e) { 127 if (done) return; 128 done = true; 129 clearTimeout(timer); 130 reject(e); 131 }); 132 }); 133 } 134 135 // Compact hourly series: [{t, f, dewF, rh, pop, wind, day, sf}] 136 function loadHours() { 137 var url = CONFIG.weather.url; 138 var key = "lc:" + url; 139 var hit = cacheGet(key); 140 if (hit) return Promise.resolve(hit); 141 return fetchJson(url, 8000).then(function (j) { 142 var periods = (j && j.properties && j.properties.periods) || []; 143 var now = Date.now(); 144 var out = []; 145 for (var i = 0; i < periods.length && out.length < 36; i++) { 146 var p = periods[i]; 147 if (Date.parse(p.endTime) <= now) continue; 148 out.push({ 149 t: p.startTime, 150 f: num(p.temperature), 151 dewF: cToF(num(p.dewpoint)), 152 rh: num(p.relativeHumidity),
153 pop: num(p.probabilityOfPrecipitation) || 0, 154 wind: windMph(p.windSpeed), 155 day: !!p.isDaytime, 156 sf: p.shortForecast || "" 157 }); 158 } 159 if (!out.length) throw new Error("no periods"); 160 cacheSet(key, out); 161 return out; 162 }); 163 } 164 165 function minOf(hs, k) { 166 var m = null; 167 for (var i = 0; i < hs.length; i++) if (hs[i][k] != null && (m == null || hs[i][k] < m)) m = hs[i][k]; 168 return m; 169 } 170 function maxOf(hs, k) { 171 var m = null; 172 for (var i = 0; i < hs.length; i++) if (hs[i][k] != null && (m == null || hs[i][k] > m)) m = hs[i][k]; 173 return m; 174 } 175 function avgOf(hs, k) { 176 var s = 0, n = 0; 177 for (var i = 0; i < hs.length; i++) if (hs[i][k] != null) { s += hs[i][k]; n++; } 178 return n ? Math.round(s / n) : null; 179 } 180 function frozenPrecip(hs) { 181 for (var i = 0; i < hs.length; i++) { 182 if (hs[i].f != null && hs[i].f <= 34 && hs[i].pop >= 40) return true; 183 if (/snow|sleet|freezing|ice/i.test(hs[i].sf) && hs[i].pop >= 30) return true; 184 } 185 return false; 186 } 187 188 // ---------- rules: each returns {level: "good"|"caution"|"poor", text} ---------- 189 var RULES = { 190 // Exterior painting: most exterior latex paints want 50F+ for the whole application and drying 191 // window, air temperature at least 5F above the dew point, and no rain while the film sets. 192 "paint-exterior": function (hs) { 193 var next = hs.slice(0, 24); 194 var best = null, run = []; 195 for (var i = 0; i < next.length; i++) { 196 var h = next[i]; 197 var ok = h.day && h.f >= 50 && h.f <= 90 && h.pop < 30 && (h.dewF == null || h.f - h.dewF >= 5); 198 if (ok) { 199 run.push(h); 200 if (!best || run.length > best.length) best = run.slice(); 201 } else run = []; 202 } 203 if (best && best.length >= 4) { 204 return { 205 level: "good", 206 text: "Good window for exterior painting " + dayWord(best[0].t) + " from " + hourLabel(best[0].t) + " to " + 207 hourLabel(new Date(Date.parse(best[best.length - 1].t) + 3600000).toISOString()) + 208 " (" + minOf(best, "f") + " to " + maxOf(best, "f") + "°F, low rain chance). Many exterior latex paints need 50°F and up while they dry; check the label, some are rated lower." 209 }; 210 } 211 var days = next.filter(function (h) { return h.day; }); 212 var hi = maxOf(days.length ? days : next, "f"); 213 if (hi != null && hi < 50) return { level: "poor", text: "Too cold for most exterior paints today (high near " + hi + "°F). Interior work is the better plan; many exterior latex paints need 50°F and up while they dry." }; 214 var dpop = maxOf(days.length ? days : next, "pop"); 215 if (dpop >= 30) return { level: "poor", text: rainWords(dpop) + " in the daytime hours, so exterior paint may not have time to set. A good day for interior rooms instead." }; 216 return { level: "caution", text: "Conditions are borderline for exterior paint today (dew or short dry spells). Interior work is the safer choice." }; 217 }, 218 219 // Garage doors: torsion springs are under the most stress in hard cold. 220 "garage-cold": function (hs) { 221 var low = minOf(hs.slice(0, 24), "f"); 222 if (low == null) return null; 223 if (low <= 10) return { level: "poor", text: "Deep cold in the next 24 hours (low near " + low + "°F). Torsion springs are most likely to snap in hard cold. If the door feels heavy or a spring shows a gap, stop using the opener." }; 224 if (low <= 32) return { level: "caution", text: "Freezing temperatures in the next 24 hours (low near " + low + "°F). Spring breaks climb with the first hard freezes, so a quick look at the springs and cables now is worth it." }; 225 return { level: "good", text: "No freeze in the next 24 hours (low near " + low + "°F). A good day for a balance check: lift the door halfway by hand with the opener disengaged; it should stay put." }; 226 }, 227 228 // Locks: ice in the cylinder and keys snapped by force. 229 "lock-cold": function (hs) { 230 var next = hs.slice(0, 24); 231 var low = minOf(next, "f"); 232 if (low == null) return null; 233 if (low <= 32 && frozenPrecip(next)) return { level: "poor", text: "Freezing temperatures with precipitation in the next 24 hours (low near " + low + "°F). Locks and car doors can ice up: use a lock de-icer and never pour hot water into a lock, it refreezes deeper." }; 234 if (low <= 32) return { level: "caution", text: "Below freezing in the next 24 hours (low near " + low + "°F). A frozen lock needs de-icer and patience, not force; forcing a key is how keys snap." }; 235 return { level: "good", text: "No freeze in the next 24 hours (low near " + low + "°F). If a key feels stiff, a dry lubricant such as graphite keeps a lock turning through the winter; oil-based sprays attract grit." }; 236 }, 237 238 // Flooring: wood moves with humidity, vinyl plank far less so. 239 "flooring-humidity": function (hs) { 240 var rh = avgOf(hs.slice(0, 12), "rh"); 241 if (rh == null) return null; 242 if (rh >= 65) return { level: "caution", text: "Humid today (relative humidity around " + rh + "%). Solid hardwood needs extra acclimation time in damp air; luxury vinyl plank is far less sensitive to humidity." }; 243 if (rh <= 30) return { level: "caution", text: "Dry air today (relative humidity around " + rh + "%). Wood floors shrink in dry air, which is when seasonal gaps show; a humidifier helps protect hardwood." }; 244 return { level: "good", text: "Moderate humidity today (around " + rh + "%), good conditions for flooring work and for acclimating new wood." }; 245 }, 246 247 // Drywall: joint compound dries by evaporation, slowly when it is cold or humid. 248 "drywall-drying": function (hs) { 249 var next = hs.slice(0, 12); 250 var rh = avgOf(next, "rh"); 251 var t = avgOf(next, "f"); 252 if (rh == null) return null; 253 var cold = t != null && t < 50 ? " In unheated spaces such as garages, the cold slows drying further." : ""; 254 if (rh >= 70) return { level: "caution", text: "Humid air today (around " + rh + "% relative humidity). Joint compound dries more slowly in damp air, so allow extra time between coats, keep air moving, or use a setting-type compound that cures chemically." + cold }; 255 if (rh <= 35) return { level: "good", text: "Dry air today (around " + rh + "% relative humidity), so joint compound dries quickly between coats." + cold }; 256 return { level: "good", text: "Moderate humidity today (around " + rh + "%), normal drying time for joint compound between coats." + cold }; 257 }, 258 259 // Exterior doors: sealants and caulk want dry weather and roughly 40F and up.
260 "door-exterior": function (hs) { 261 var days = hs.slice(0, 24).filter(function (h) { return h.day; }).slice(0, 8); 262 if (!days.length) days = hs.slice(0, 8); 263 var lo = minOf(days, "f"), pop = maxOf(days, "pop"); 264 if (lo == null) return null; 265 if (lo >= 40 && pop < 30) return { level: "good", text: "Good conditions for exterior door work today (" + lo + "°F and up, low rain chance). Most exterior sealants need about 40°F and dry weather to cure." }; 266 if (lo < 40) return { level: "caution", text: "Cold for exterior sealants today (daytime low near " + lo + "°F). Most need about 40°F to cure; interior door work is unaffected." }; 267 return { level: "caution", text: rainWords(pop) + " in the daytime hours, which is hard on fresh exterior sealant. Interior door work is unaffected." }; 268 } 269 }; 270 271 // ---------- render ---------- 272 function el(tag, cls, text) { 273 var e = document.createElement(tag); 274 if (cls) e.className = cls; 275 if (text != null) e.appendChild(document.createTextNode(text)); 276 return e; 277 } 278 279 function renderWeather(root) { 280 if (!CONFIG.weather || !window.fetch || !window.Promise) return; 281 var slot = root.querySelector("[data-lc-weather]"); 282 if (!slot || slot.getAttribute("data-lc-live") === "1") return; 283 slot.setAttribute("data-lc-live", "1"); 284 loadHours().then(function (hs) { 285 var rule = RULES[CONFIG.weather.rule]; 286 var res = rule && rule(hs); 287 if (!res || !res.text) return; 288 var now = hs[0]; 289 var p = el("p", "lc-live lc-" + res.level); 290 var lead = el("strong", null, "Today in " + CONFIG.weather.place + ": "); 291 292 p.appendChild(lead); 293 p.appendChild(document.createTextNode("now " + now.f + "°F, " + (now.sf || "").toLowerCase() + ". " + res.text)); 294 var src = el("p", "lc-src"); 295 src.appendChild(document.createTextNode("Live forecast from the ")); 296 var a = el("a", null, "National Weather Service"); 297 a.href = "https://forecast.weather.gov/MapClick.php?lat=" + CONFIG.weather.lat + "&lon=" + CONFIG.weather.lon; 298 a.rel = "noopener"; 299 a.target = "_blank"; 300 src.appendChild(a); 301 src.appendChild(document.createTextNode(", refreshed when you load this page.")); 302 while (slot.firstChild) slot.removeChild(slot.firstChild); 303 slot.appendChild(p); 304 slot.appendChild(src); 305 }, function () { /* keep the static fallback text */ }); 306 } 307 308 function markSeason(root) { 309 var m = nowParts().month; 310 var items = root.querySelectorAll("[data-lc-months]"); 311 for (var i = 0; i < items.length; i++) { 312 var months = String(items[i].getAttribute("data-lc-months")).split(","); 313 var on = false; 314 for (var k = 0; k < months.length; k++) if (parseInt(months[k], 10) === m) on = true; 315 if (on) { 316 items[i].classList.add("lc-now"); 317 items[i].setAttribute("aria-current", "true"); 318 } else { 319 items[i].classList.remove("lc-now"); 320 items[i].removeAttribute("aria-current"); 321 } 322 } 323 } 324 325 function enhance(root) { 326 markSeason(root); 327 renderWeather(root); 328 } 329 330 // ---------- fill mode ---------- 331 function fillMode() { 332 var root = document.getElementById(ID); 333 if (root) enhance(root); 334 } 335 336 // ---------- inject mode ---------- 337 function onHome() { 338 var p = window.location.pathname; 339 return p === "/" || p === "/index.html"; 340 } 341 342 function hydrated(node) { 343 var keys = Object.keys(node); 344 for (var i = 0; i < keys.length; i++) if (keys[i].indexOf("__reactFiber$") === 0) return true; 345 return false; 346 } 347 348 function buildBlock() { 349 var tpl = document.createElement("div"); 350 tpl.innerHTML = CONFIG.html; 351 return tpl.firstElementChild; 352 } 353 354 function host() { 355 var main = document.querySelector("main"); 356 if (!main) return null; 357 var sel = CONFIG.insert && CONFIG.insert.before; 358 var ref = sel ? main.querySelector(sel) : null; 359 return { parent: ref ? ref.parentNode : main, ref: ref }; 360 } 361 362 function place() { 363 var existing = document.getElementById(ID); 364 if (!onHome()) { 365 if (existing && existing.parentNode) existing.parentNode.removeChild(existing); 366 return; 367 } 368 if (existing) return; 369 var h = host(); 370 if (!h) return; 371 var block = buildBlock(); 372 if (!block) return; 373 h.parent.insertBefore(block, h.ref); 374 enhance(block); 375 } 376 377 var queued = false;
378 function schedule() { 379 if (queued) return; 380 queued = true; 381 setTimeout(function () { 382 queued = false; 383 place(); 384 }, 0); 385 } 386 387 function go() { 388 place(); 389 if (window.MutationObserver && document.body) { 390 new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true }); 391 } 392 } 393 394 function injectMode() { 395 var t0 = Date.now(); 396 (function tick() { 397 var main = document.querySelector("main"); 398 if (main && hydrated(main)) return go(); 399 var waited = Date.now() - t0; 400 var booted = !!(window.next && window.next.version); 401 if (!booted && waited > 8000 && document.readyState === "complete") return go(); 402 if (waited > 30000) return go(); 403 setTimeout(tick, 120); 404 })(); 405 } 406 407 var started = false; 408 function begin() { 409 if (started) return; 410 started = true; 411 if (CONFIG.mode === "inject") injectMode(); 412 else fillMode(); 413 } 414 415 if (document.readyState === "complete" || document.readyState === "interactive") { 416 begin(); 417 } else { 418 document.addEventListener("DOMContentLoaded", begin); 419 window.addEventListener("load", begin); 420 } 421 422})();
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.