the home site for me: also iteration 3 or 4 of my site

feat: fancy relative time

dunkirk.sh 2b7a1b73 c3e56e41

verified
+132 -71
+2
sass/css/mods.css
··· 345 345 text-decoration: none; 346 346 } 347 347 348 + 349 + 348 350 .bubble > span { 349 351 display: flex; 350 352 flex-wrap: wrap;
+127 -62
static/js/relative-time.js
··· 1 - const rtf = new Intl.RelativeTimeFormat(navigator.language, { 2 - numeric: "auto", 3 - style: "long" 4 - }); 1 + class RelativeTimeElement extends HTMLElement { 2 + static get observedAttributes() { 3 + return ['datetime', 'threshold', 'prefix', 'format']; 4 + } 5 5 6 - function formatRelativeTime(date) { 7 - const now = new Date(); 8 - const diffInMs = now - date; 9 - const diffInMins = Math.floor(diffInMs / (1000 * 60)); 10 - const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)); 11 - const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); 6 + connectedCallback() { 7 + this.update(); 8 + } 9 + 10 + disconnectedCallback() { 11 + this.stopTimer(); 12 + } 13 + 14 + attributeChangedCallback() { 15 + this.update(); 16 + } 17 + 18 + scheduleUpdate(ms) { 19 + this.stopTimer(); 20 + this.timer = setTimeout(() => this.update(), ms); 21 + } 22 + 23 + stopTimer() { 24 + if (this.timer) { 25 + clearTimeout(this.timer); 26 + this.timer = null; 27 + } 28 + } 29 + 30 + get datetime() { 31 + return this.getAttribute('datetime') || ''; 32 + } 33 + 34 + get threshold() { 35 + return this.getAttribute('threshold') || 'P30D'; 36 + } 37 + 38 + get prefix() { 39 + return this.getAttribute('prefix') || 'on'; 40 + } 41 + 42 + get format() { 43 + return this.getAttribute('format') || 'relative'; 44 + } 12 45 13 - if (diffInMins < 1) { 14 - return rtf.format(0, "minute"); 15 - } else if (diffInMins < 5) { 16 - return rtf.format(-1, "minute"); 17 - } else if (diffInMins < 60) { 18 - return rtf.format(-diffInMins, "minute"); 19 - } else if (diffInHours < 3) { 20 - return rtf.format(-diffInHours, "hour"); 21 - } else if (diffInHours < 24 && now.getDate() === date.getDate()) { 22 - const hour = date.getHours(); 23 - if (hour < 12) return "this morning"; 24 - if (hour < 17) return "this afternoon"; 25 - return "this evening"; 26 - } else if (diffInDays < 2) { 27 - return rtf.format(-1, "day"); 28 - } else if (diffInDays < 7) { 29 - return rtf.format(-diffInDays, "day"); 30 - } else { 31 - const dateFormatter = new Intl.DateTimeFormat(navigator.language, { 32 - month: 'short', 33 - day: 'numeric' 34 - }); 35 - return `on ${dateFormatter.format(date)}`; 46 + parseThreshold(iso) { 47 + const match = iso.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); 48 + if (!match) return 30 * 24 * 60 * 60 * 1000; 49 + const days = parseInt(match[1] || 0, 10); 50 + const hours = parseInt(match[2] || 0, 10); 51 + const minutes = parseInt(match[3] || 0, 10); 52 + const seconds = parseInt(match[4] || 0, 10); 53 + return ((days * 24 + hours) * 60 + minutes) * 60 * 1000 + seconds * 1000; 36 54 } 37 - } 38 55 39 - function updateTimeElements() { 40 - document.querySelectorAll("time[datetime]").forEach(el => { 41 - const datetime = el.getAttribute("datetime"); 56 + update() { 57 + const datetime = this.datetime; 42 58 if (!datetime) return; 43 59 44 60 const date = new Date(datetime); 45 61 if (isNaN(date.getTime())) return; 46 62 47 - const maxAge = el.dataset.maxAge; 48 - if (maxAge) { 49 - const diffInMs = Date.now() - date.getTime(); 50 - const maxAgeMs = parseInt(maxAge, 10) * 1000; 51 - if (diffInMs > maxAgeMs) { 52 - el.style.display = "none"; 53 - return; 54 - } 63 + const now = Date.now(); 64 + const diff = now - date.getTime(); 65 + const absDiff = Math.abs(diff); 66 + const thresholdMs = this.parseThreshold(this.threshold); 67 + 68 + if (this.format === 'datetime' || absDiff > thresholdMs) { 69 + this.textContent = this.formatDatetime(date); 70 + this.scheduleUpdate(3600000); 71 + } else { 72 + this.textContent = this.formatRelative(diff); 73 + this.scheduleUpdate(this.getNextUpdateDelay(absDiff)); 55 74 } 75 + } 56 76 57 - el.textContent = formatRelativeTime(date); 58 - el.style.display = ""; 59 - }); 60 - } 77 + getNextUpdateDelay(absDiff) { 78 + const seconds = Math.floor(absDiff / 1000); 79 + const minutes = Math.floor(seconds / 60); 80 + const hours = Math.floor(minutes / 60); 81 + const days = Math.floor(hours / 24); 61 82 62 - const observer = new MutationObserver((mutations) => { 63 - for (const mutation of mutations) { 64 - if (mutation.type === "attributes" && mutation.attributeName === "datetime") { 65 - updateTimeElements(); 66 - return; 83 + if (seconds < 60) { 84 + return 1000; 85 + } else if (minutes < 60) { 86 + return 60000; 87 + } else if (hours < 24) { 88 + return 60000 * 5; 89 + } else if (days < 7) { 90 + return 3600000; 91 + } else { 92 + return 3600000 * 6; 67 93 } 68 94 } 69 - }); 70 - observer.observe(document.documentElement, { 71 - subtree: true, 72 - attributes: true, 73 - attributeFilter: ["datetime"] 74 - }); 75 95 76 - document.addEventListener("DOMContentLoaded", updateTimeElements); 77 - setInterval(updateTimeElements, 60000); 96 + formatRelative(diff) { 97 + const rtf = new Intl.RelativeTimeFormat(navigator.language, { 98 + numeric: 'auto', 99 + style: 'long' 100 + }); 101 + 102 + const absDiff = Math.abs(diff); 103 + const sign = diff > 0 ? -1 : 1; 104 + const seconds = Math.floor(absDiff / 1000); 105 + const minutes = Math.floor(seconds / 60); 106 + const hours = Math.floor(minutes / 60); 107 + const days = Math.floor(hours / 24); 108 + const months = Math.floor(days / 30); 109 + const years = Math.floor(days / 365); 110 + 111 + if (seconds < 60) { 112 + return rtf.format(sign * seconds, 'second'); 113 + } else if (minutes < 60) { 114 + return rtf.format(sign * minutes, 'minute'); 115 + } else if (hours < 24) { 116 + return rtf.format(sign * hours, 'hour'); 117 + } else if (days < 30) { 118 + return rtf.format(sign * days, 'day'); 119 + } else if (months < 12) { 120 + return rtf.format(sign * months, 'month'); 121 + } else { 122 + return rtf.format(sign * years, 'year'); 123 + } 124 + } 125 + 126 + formatDatetime(date) { 127 + const now = new Date(); 128 + const sameYear = date.getFullYear() === now.getFullYear(); 129 + 130 + const options = { 131 + month: 'short', 132 + day: 'numeric', 133 + ...(sameYear ? {} : { year: 'numeric' }) 134 + }; 135 + 136 + const prefix = this.prefix; 137 + const formatted = new Intl.DateTimeFormat(navigator.language, options).format(date); 138 + return prefix ? `${prefix} ${formatted}` : formatted; 139 + } 140 + } 141 + 142 + customElements.define('relative-time', RelativeTimeElement);
+2 -2
templates/header.html
··· 40 40 nowPlayingTimeout = setTimeout(fetchNowPlaying, 60000); 41 41 return; 42 42 } 43 - el.innerHTML = `🎵&nbsp;<a href="${item.originUrl || '#'}" target="_blank" rel="noopener"><span class="track-name">${item.trackName}</span></a>&nbsp;-&nbsp;<span class="artist-name">${item.artists?.[0]?.artistName || 'Unknown'}</span>`; 44 - const timeUntilExpiry = expiry - now + 1000; 43 + el.innerHTML = `🎵&nbsp;<a href="${item.originUrl || '#'}" target="_blank" rel="noopener"><span class="track-name">${item.trackName}</span></a>&nbsp;-&nbsp;<span class="artist-name">${item.artists?.[0]?.artistName || 'Unknown'}</span>&nbsp;<relative-time datetime="${item.playedTime}" threshold="P1D"></relative-time>`; 44 + const timeUntilExpiry = expiry - now + 5000; 45 45 nowPlayingTimeout = setTimeout(fetchNowPlaying, timeUntilExpiry); 46 46 }) 47 47 .catch(() => {
+1 -7
templates/shortcodes/is.md
··· 1 1 <div class="bubble" style="visibility: hidden; opacity: 0;"> 2 - <span><a href="https://bsky.app/@doing.dunkirk.sh" id="verb-link">Kieran is</a> <i id="status-text"></i><span id="time-ago-wrap"><span class="time-dash"> - </span><time id="time-ago" datetime="" data-max-age="43200"></time></span></span> 2 + <span><span id="status-wrap"><a href="https://bsky.app/@doing.dunkirk.sh" id="verb-link">Kieran is</a> <i id="status-text"></i></span><span id="time-ago-wrap"><span class="time-dash"> - </span><relative-time id="time-ago" datetime="" threshold="P30D"></relative-time></span></span> 3 3 </div> 4 4 5 5 <script> ··· 31 31 document.getElementById("status-text").textContent = latestStatus; 32 32 const timeEl = document.getElementById("time-ago"); 33 33 timeEl.setAttribute("datetime", createdAt); 34 - timeEl.textContent = new Intl.DateTimeFormat(navigator.language, { 35 - month: 'short', 36 - day: 'numeric', 37 - hour: 'numeric', 38 - minute: 'numeric' 39 - }).format(createdDate); 40 34 const verbLink = document.getElementById("verb-link"); 41 35 if (diffInMins > 30) { 42 36 verbLink.textContent = "Kieran was";