Add now.js: recompute "Last updated" relative phrase client-side

build/Now.hs renders the .now-stamp-relative phrase ("3 days ago") at
build time against the build machine's clock; a page served days later
from cache or a CDN would then read stale. now.js recomputes the
phrase in the browser from the <time datetime> attribute (an
unambiguous YYYY-MM-DD) against the visitor's clock, with bucket
thresholds that mirror Now.hs:relativeTime exactly so the no-JS
fallback and the recomputed value agree.

* static/js/now.js — the recomputation script.
* templates/default.html includes it via $if(now)$ so it only loads
  on the Current page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-23 12:06:49 -04:00
parent 70dda56625
commit af27479c6e
2 changed files with 76 additions and 0 deletions

75
static/js/now.js Normal file
View File

@ -0,0 +1,75 @@
/* now.js Keep the Current page's "Last updated" relative phrase
honest.
build/Now.hs renders `.now-stamp-relative` ("3 days ago") at build
time, relative to the build machine's clock. A page served days
later from cache/CDN would then lie. We recompute the phrase in the
browser from the `<time datetime>` attribute (an unambiguous
YYYY-MM-DD), against the visitor's own clock.
The bucket thresholds below mirror `relativeTime` in build/Now.hs
exactly keep the two in sync. The server-rendered text remains the
no-JS fallback and is only replaced once we've recomputed. */
(function () {
'use strict';
function relative(days) {
if (days < 0) return ''; // future / clock skew
if (days === 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 7) return days + ' days ago';
var n, unit;
if (days < 28) { n = Math.floor(days / 7); unit = 'week'; }
else if (days < 365) { n = Math.floor(days / 30); unit = 'month'; }
else { n = Math.floor(days / 365); unit = 'year'; }
return n === 1 ? ('1 ' + unit + ' ago')
: (n + ' ' + unit + 's ago');
}
function update() {
var stamp = document.querySelector('.now-stamp');
if (!stamp) return;
var timeEl = stamp.querySelector('.now-stamp-date');
if (!timeEl) return;
var iso = timeEl.getAttribute('datetime');
var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || '');
if (!m) return; // unparseable — leave the SSR fallback as-is
// Calendar-day difference, computed via UTC epoch days so DST
// transitions can't add or drop a day. "today" uses the
// visitor's *local* date components, matching what they'd
// read off a wall calendar.
var then = Date.UTC(+m[1], +m[2] - 1, +m[3]);
var local = new Date();
var today = Date.UTC(
local.getFullYear(),
local.getMonth(),
local.getDate()
);
var days = Math.round((today - then) / 86400000);
var text = relative(days);
var rel = stamp.querySelector('.now-stamp-relative');
if (!text) {
// No meaningful relative phrase (e.g. dated in the future):
// drop any stale server-rendered one rather than keep a lie.
if (rel) rel.remove();
return;
}
if (!rel) {
rel = document.createElement('span');
rel.className = 'now-stamp-relative';
stamp.appendChild(rel);
}
rel.textContent = text;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', update);
} else {
update();
}
})();

View File

@ -29,6 +29,7 @@ $partial("templates/partials/footer.html")$
<script src="/js/lightbox.js" defer></script>
$if(home)$<script src="/js/random.js" defer></script>$endif$
$if(reading)$<script src="/js/reading.js" defer></script>$endif$
$if(now)$<script src="/js/now.js" defer></script>$endif$
$if(photography)$<script src="/js/photography-modes.js" defer></script>$endif$
$if(photography-map)$<script src="/leaflet/leaflet.js" defer></script>$endif$
$if(photography-map)$<script src="/leaflet/leaflet.markercluster.js" defer></script>$endif$