1/* clock.js - ONE ticker for the whole app. 2 A single setInterval walks every registered clock and writes into existing text nodes. 3 No per-card timer, and no innerHTML on a tick: re-rendering a card every second would 4 destroy focus, restart animations and thrash the layout. 5 6 Time is always computed locally from the IANA zone. The API's `time.iso` is a snapshot 7 of when the file was generated, not a clock source, so it is never used here. */ 8(function (global) { 9 'use strict'; 10 11 var WTW = global.WTW = global.WTW || {}; 12 var doc = global.document; 13 14 /* Pick the display locale once. navigator.language can be undefined or a weird tag, 15 so fall back explicitly rather than letting Intl choose per call. */ 16 var LOCALE = (function () { 17 var tag = global.navigator && global.navigator.language; 18 if (typeof tag !== 'string' || tag.length < 2) { return 'en-GB'; } 19 try { new Intl.DateTimeFormat(tag); return tag; } catch (err) { return 'en-GB'; } 20 })(); 21 22 var entries = []; 23 var timer = null; 24 var hour12 = false; 25 var formatters = Object.create(null); 26 var supported = Object.create(null); 27 28 function zoneSupported(timezone) { 29 if (!timezone) { return false; } 30 if (timezone in supported) { return supported[timezone]; } 31 var ok = false; 32 try { 33 new Intl.DateTimeFormat('en-GB', { timeZone: timezone }).format(new Date()); 34 ok = true; 35 } catch (err) { ok = false; } 36 supported[timezone] = ok; 37 return ok; 38 } 39 40 /* hour12 is passed explicitly, together with the matching hourCycle. Relying on the 41 locale default would make the 12/24-hour toggle a no-op in some locales. */ 42 function timeFormatter(timezone) { 43 var key = 't|' + timezone + '|' + hour12; 44 if (!formatters[key]) { 45 formatters[key] = new Intl.DateTimeFormat(LOCALE, { 46 timeZone: timezone, 47 hour: '2-digit', minute: '2-digit', second: '2-digit', 48 hour12: hour12, hourCycle: hour12 ? 'h12' : 'h23' 49 }); 50 } 51 return formatters[key]; 52 } 53 54 function dateFormatter(timezone) { 55 var key = 'd|' + timezone; 56 if (!formatters[key]) { 57 formatters[key] = new Intl.DateTimeFormat(LOCALE, { 58 timeZone: timezone, weekday: 'long', day: 'numeric', month: 'long' 59 }); 60 } 61 return formatters[key]; 62 } 63 64 function pad(n) { return (n < 10 ? '0' : '') + n; } 65 66 /* Degraded path: the zone is unknown to this browser's Intl, so shift UTC by the 67 offset the API reported. Correct until that zone next changes its offset. */ 68 function offsetParts(offsetSeconds, date) { 69 var shifted = new Date(date.getTime() + (offsetSeconds || 0) * 1000); 70 var h = shifted.getUTCHours(); 71 var display = h; 72 var period = ''; 73 if (hour12) { 74 period = h < 12 ? 'AM' : 'PM'; 75 display = h % 12 === 0 ? 12 : h % 12; 76 } 77 return { 78 hour: pad(display), minute: pad(shifted.getUTCMinutes()), second: pad(shifted.getUTCSeconds()), 79 period: period, hour24: h 80 }; 81 } 82 83 function intlParts(timezone, date) { 84 var parts = timeFormatter(timezone).formatToParts(date); 85 var out = { hour: '00', minute: '00', second: '00', period: '', hour24: 0 }; 86 for (var i = 0; i < parts.length; i++) { 87 var p = parts[i]; 88 if (p.type === 'hour') { out.hour = p.value; } 89 else if (p.type === 'minute') { out.minute = p.value; } 90 else if (p.type === 'second') { out.second = p.value; } 91 else if (p.type === 'dayPeriod') { out.period = p.value; } 92 } 93 out.hour24 = hour12 ? to24(out.hour, out.period) : parseInt(out.hour, 10) || 0; 94 return out; 95 } 96 97 function to24(hourText, period) { 98 var h = parseInt(hourText, 10) || 0; 99 var isPm = /p/i.test(period || ''); 100 if (h === 12) { return isPm ? 12 : 0; } 101 return isPm ? h + 12 : h; 102 } 103 104 function span(className, text) { 105 var el = doc.createElement('span'); 106 if (className) { el.className = className; } 107 el.textContent = text || ''; 108 return el; 109 } 110 111 function tick() { 112 var now = new Date(); 113 for (var i = 0; i < entries.length; i++) { 114 render(entries[i], now); 115 } 116 } 117 118 function render(entry, now) { 119 var parts = entry.degraded ? offsetParts(entry.offsetSeconds, now) : intlParts(entry.timezone, now); 120 121 if (entry.secEl.nodeValue !== parts.second) { entry.secEl.nodeValue = parts.second; } 122 123 /* Only touch the hour/minute nodes when they actually change: they sit inside the
124 aria-live region, so a needless write would re-announce the same minute. */ 125 var hm = parts.hour + ':' + parts.minute + '|' + parts.period; 126 if (entry.lastHM !== hm) { 127 entry.lastHM = hm; 128 entry.hourEl.nodeValue = parts.hour; 129 entry.minuteEl.nodeValue = parts.minute; 130 if (entry.periodEl) { entry.periodEl.textContent = parts.period ? ' ' + parts.period : ''; } 131 132 if (entry.dateEl) { 133 var dateText = entry.degraded 134 ? new Date(now.getTime() + (entry.offsetSeconds || 0) * 1000).toUTCString().slice(0, 16) 135 : dateFormatter(entry.timezone).format(now); 136 if (entry.dateEl.textContent !== dateText) { entry.dateEl.textContent = dateText; } 137 } 138 139 var phase = WTW.weather.phaseForHour(parts.hour24); 140 if (phase !== entry.lastPhase) { 141 entry.lastPhase = phase; 142 if (entry.onPhase) { entry.onPhase(phase); } 143 } 144 } 145 } 146 147 function start() { 148 if (timer === null) { timer = global.setInterval(tick, 1000); } 149 } 150 151 function stop() { 152 if (timer !== null) { global.clearInterval(timer); timer = null; } 153 } 154 155 var clock = { 156 LOCALE: LOCALE, 157 zoneSupported: zoneSupported, 158 159 /* container: the element carrying aria-live="polite". We build its inner spans so 160 this module owns the text nodes it has to update. */ 161 register: function (container, options) { 162 var opts = options || {}; 163 var degraded = !zoneSupported(opts.timezone); 164 165 var hmEl = span('clock__hm', ''); 166 var hourNode = doc.createTextNode('00'); 167 var minuteNode = doc.createTextNode('00'); 168 hmEl.appendChild(hourNode); 169 hmEl.appendChild(span('clock__colon', ':')); 170 hmEl.appendChild(minuteNode); 171 172 var secEl = span('clock__sec', ''); 173 secEl.setAttribute('aria-hidden', 'true'); 174 var secNode = doc.createTextNode('00'); 175 secEl.appendChild(doc.createTextNode(':')); 176 secEl.appendChild(secNode); 177 178 container.textContent = ''; 179 container.appendChild(hmEl); 180 container.appendChild(secEl); 181 182 /* AM/PM goes after the seconds so the line reads "11:00:34 AM". It stays outside 183 the aria-hidden seconds so it is still announced with the minute. */ 184 var periodEl = null; 185 if (hour12) { periodEl = span('clock__suffix', ''); container.appendChild(periodEl); } 186 187 var entry = { 188 timezone: opts.timezone, 189 offsetSeconds: opts.offsetSeconds || 0, 190 degraded: degraded, 191 hourEl: hourNode, 192 minuteEl: minuteNode, 193 periodEl: periodEl, 194 secEl: secNode, 195 dateEl: opts.dateEl || null, 196 onPhase: opts.onPhase || null, 197 lastHM: null, 198 lastPhase: null 199 }; 200 201 entries.push(entry); 202 render(entry, new Date()); 203 start(); 204 return { 205 degraded: degraded, 206 destroy: function () { 207 var i = entries.indexOf(entry); 208 if (i >= 0) { entries.splice(i, 1); } 209 if (entries.length === 0) { stop(); } 210 } 211 }; 212 }, 213 214 clear: function () { entries.length = 0; stop(); }, 215 216 /* Changing the 12/24-hour setting rebuilds every clock's markup, so ui.js re-renders 217 rather than calling this mid-flight. Kept for the Phase 2 settings panel. */ 218 setHour12: function (value) { 219 hour12 = value === true; 220 formatters = Object.create(null); 221 }, 222 223 isHour12: function () { return hour12; }, 224 225 /* Exposed for the detail view and (Phase 3) the planner. */ 226 hourIn: function (timezone, date, offsetSeconds) { 227 var d = date || new Date(); 228 return zoneSupported(timezone) ? intlParts(timezone, d).hour24 : offsetParts(offsetSeconds, d).hour24; 229 }, 230 231 /* The zone's offset ON A GIVEN DATE, computed by Intl. This is the only correct way 232 to ask the question: the API's utc_offset_seconds is today's offset and would be 233 wrong on the other side of a DST boundary. Returns seconds, or null if the browser 234 does not know the zone. */ 235 offsetAt: function (timezone, date) { 236 if (!zoneSupported(timezone)) { return null; } 237 var key = 'o|' + timezone; 238 if (!formatters[key]) { 239 formatters[key] = new Intl.DateTimeFormat('en-GB', { 240 timeZone: timezone, timeZoneName: 'longOffset' 241 }); 242 } 243 var parts = formatters[key].formatToParts(date || new Date()); 244 var name = ''; 245 for (var i = 0; i < parts.length; i++) { 246 if (parts[i].type === 'timeZoneName') { name = parts[i].value; } 247 } 248 /* "GMT" alone means UTC;
248 otherwise "GMT+05:45" / "GMT-03:30". */ 249 var m = /GMT([+-])(\d{1,2}):?(\d{2})?/.exec(name); 250 if (!m) { return /GMT/.test(name) ? 0 : null; } 251 var seconds = (parseInt(m[2], 10) * 3600) + (parseInt(m[3] || '0', 10) * 60); 252 return m[1] === '-' ? -seconds : seconds; 253 }, 254 255 /* The viewer's own zone, for the DST comparison. Undefined on very old engines. */ 256 userTimeZone: function () { 257 try { 258 var tz = new Intl.DateTimeFormat().resolvedOptions().timeZone; 259 return typeof tz === 'string' && tz ? tz : null; 260 } catch (err) { return null; } 261 }, 262 263 longDate: function (timezone, date) { 264 if (!zoneSupported(timezone)) { return null; } 265 var key = 'l|' + timezone; 266 if (!formatters[key]) { 267 formatters[key] = new Intl.DateTimeFormat(LOCALE, { 268 timeZone: timezone, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' 269 }); 270 } 271 return formatters[key].format(date || new Date()); 272 } 273 }; 274 275 WTW.clock = clock; 276})(typeof window !== 'undefined' ? window : globalThis);
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.