format.js (10.5 KB)
1 // Pure date/text formatting helpers + email-obfuscation. 2 3 (function () { 4 var WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; 5 var MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; 6 var MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; 7 8 function ordinal(n) { 9 var s = ["th", "st", "nd", "rd"], v = n % 100; 10 return n + (s[(v - 20) % 10] || s[v] || s[0]); 11 } 12 13 // Parse "2026-06-07T14:00" as local time (no timezone applied). 14 function parseLocal(iso) { 15 var p = iso.split(/[-T:]/); 16 return new Date(p[0], p[1] - 1, p[2], p[3], p[4]); 17 } 18 19 // "2:00" / "9:30pm" - am/pm only on the end so a range reads "1:00 - 4:00pm". 20 function clockTime(d, withMeridian) { 21 var h = d.getHours(), min = d.getMinutes(); 22 var mer = h >= 12 ? "pm" : "am"; 23 h = h % 12 || 12; 24 return h + ":" + (min < 10 ? "0" + min : min) + (withMeridian ? mer : ""); 25 } 26 27 // "1:00 - 4:00pm" when start/end share a meridian; "11:00am - 1:00pm" when they 28 // differ, so a cross-noon/midnight range isn't ambiguous. forceStart keeps the start 29 // meridian even when shared, so a multiday range doesn't read as a same-day span. 30 function timeRange(start, end, forceStart) { 31 var sameMer = (start.getHours() >= 12) === (end.getHours() >= 12); 32 return clockTime(start, forceStart || !sameMer) + " - " + clockTime(end, true); 33 } 34 35 // "Sunday, June 7th" 36 function dateLabel(d) { 37 return WEEKDAYS[d.getDay()] + ", " + MONTH_NAMES[d.getMonth()] + " " + ordinal(d.getDate()); 38 } 39 40 function sameDay(a, b) { 41 return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); 42 } 43 44 // " (Today!)" / " (Tomorrow)" / "" against `now` at call time (no live refresh). 45 // A passed event takes ", <year>" instead, so the year is clear to viewers in a 46 // later year landing on the same day-of-month; the "(Ended)" marker is appended 47 // separately by the caller so it can sit at the end of a multiday range. 48 function relativeDayTag(m, d, now) { 49 now = now || new Date(); 50 if (hasPassed(m, now)) { return ", " + d.getFullYear(); } 51 if (sameDay(d, now)) { return " (Today!)"; } 52 var tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); 53 if (sameDay(d, tomorrow)) { return " (Tomorrow)"; } 54 return ""; 55 } 56 57 // An event has "passed" once its end time is in the past. 58 function hasPassed(m, now) { 59 return parseLocal(m.end) < (now || new Date()); 60 } 61 62 // Upcoming events (end not yet past), soonest start first. Caller snapshots `now`. 63 function upcomingEvents(events, now) { 64 now = now || new Date(); 65 return events.filter(function (m) { return !hasPassed(m, now); }) 66 .sort(function (a, b) { return parseLocal(a.start) - parseLocal(b.start); }); 67 } 68 69 // Up to `limit` passed events, most-recently-ended first. Caller snapshots `now`. 70 function pastEvents(events, now, limit) { 71 now = now || new Date(); 72 return events.filter(function (m) { return hasPassed(m, now); }) 73 .sort(function (a, b) { return parseLocal(b.end) - parseLocal(a.end); }) 74 .slice(0, limit == null ? 10 : limit); 75 } 76 77 // "June 7th" - short date for the intro table's date column. withYear appends ", 2025" 78 // so past events read unambiguously in a later year. 79 function shortDate(m, withYear) { 80 var d = parseLocal(m.start); 81 return MONTH_NAMES[d.getMonth()] + " " + ordinal(d.getDate()) + (withYear ? ", " + d.getFullYear() : ""); 82 } 83 84 // "Sunday, June 7th from 1:00 - 4:00pm" 85 function dateLine(m) { 86 var start = parseLocal(m.start), end = parseLocal(m.end); 87 return dateLabel(start) + " from " + timeRange(start, end); 88 } 89 90 // "Sunday, June 7th at 1:00pm" - start only (flyer tear-tabs). 91 function startLine(m) { 92 var start = parseLocal(m.start); 93 return dateLabel(start) + " at " + clockTime(start, true); 94 } 95 96 // Detail bullets, one per <li>. Same-day events get separate date + time bullets. 97 // Upcoming multiday events collapse into one "date time - date time" bullet so each 98 // endpoint carries its day; once past, they split into a date-range line + time-range 99 // line ("(Ended)" trailing), letting each time pair with its date. The year shows on 100 // the end date only, or on both dates when the range spans two years. 101 // Blank fields are dropped. address is intentionally not shown (kept in data for 102 // later). withRelativeDay appends "(Today!)" / "(Ended)" - panel only. 103 function detailItems(m, withRelativeDay) { 104 var start = parseLocal(m.start), end = parseLocal(m.end); 105 var single = sameDay(start, end); 106 var ended = withRelativeDay && hasPassed(m) ? " (Ended)" : ""; 107 var startLabel = dateLabel(start) + (withRelativeDay ? relativeDayTag(m, start) : ""); 108 var when; 109 if (single) { 110 when = [startLabel + ended, timeRange(start, end)]; 111 } else if (ended) { 112 var sameYear = start.getFullYear() === end.getFullYear(); 113 var startYear = sameYear ? "" : ", " + start.getFullYear(); 114 when = [dateLabel(start) + startYear + " - " + dateLabel(end) + ", " + end.getFullYear() + ended, timeRange(start, end, true)]; 115 } else { 116 when = [startLabel + " " + clockTime(start, true) + " - " + dateLabel(end) + " " + clockTime(end, true)]; 117 } 118 return when.concat([m.venue, m.price, m.ageRange]).filter(function (s) { 119 return s && s.trim(); 120 }); 121 } 122 123 function easeInOut(t) { 124 return t < 0.5 ? 0.5 * Math.sqrt(2 * t) : 1 - 0.5 * Math.sqrt(2 * (1 - t)); 125 } 126 127 // ROT13 to keep RSVP addresses out of page source; each event's `rsvp` is stored rotated. 128 function rot(str) { 129 var input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; 130 var output = "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm"; 131 return str.split("").map(function (x) { 132 var i = input.indexOf(x); 133 return i > -1 ? output[i] : x; 134 }).join(""); 135 } 136 137 // RSVP mailto: decode the rotated address, prepopulate subject + body. The event URL 138 // goes on its own trailing line so mail clients auto-link it. 139 function mailtoHref(m) { 140 var url = eventUrl(m); 141 var subject = "RSVP for " + m.title + " 馃悶"; 142 var body = "I'm confirming my RSVP for the following event:\n\n" + 143 m.title + "\n" + startLine(m) + "\n\n" + 144 url + "\n\n路 路 路 路 路\n\nAny notes or comments? Add them here:\n\n\n"; 145 return "mailto:" + rot(m.rsvp) + 146 "?subject=" + encodeURIComponent(subject) + 147 "&body=" + encodeURIComponent(body); 148 } 149 150 function pad2(n) { return n < 10 ? "0" + n : "" + n; } 151 152 // "20260610T183000" - floating local time (no zone). JSON times are venue wall-clock, 153 // so calendars show them in the viewer's local time. 154 function icsLocal(d) { 155 return d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) + 156 "T" + pad2(d.getHours()) + pad2(d.getMinutes()) + "00"; 157 } 158 159 // "20260610T183000Z" - UTC stamp for DTSTAMP/UID. 160 function icsStamp(d) { 161 return d.getUTCFullYear() + pad2(d.getUTCMonth() + 1) + pad2(d.getUTCDate()) + 162 "T" + pad2(d.getUTCHours()) + pad2(d.getUTCMinutes()) + pad2(d.getUTCSeconds()) + "Z"; 163 } 164 165 // Escape a TEXT value per RFC 5545. 166 function icsEscape(str) { 167 return String(str) 168 .replace(/\\/g, "\\\\") 169 .replace(/;/g, "\\;") 170 .replace(/,/g, "\\,") 171 .replace(/\r?\n/g, "\\n"); 172 } 173 174 // Fold a content line to <=75 octets per RFC 5545. Counts UTF-8 bytes (not chars) 175 // and never splits a multibyte sequence across the fold. 176 function icsFold(line) { 177 var out = "", run = 0, limit = 75; 178 for (var i = 0; i < line.length; i++) { 179 var ch = line[i]; 180 var bytes = encodeURIComponent(ch).replace(/%[0-9A-F]{2}/gi, "x").length; 181 if (run + bytes > limit) { out += "\r\n "; run = 1; } // leading space counts as 1 182 out += ch; 183 run += bytes; 184 } 185 return out; 186 } 187 188 // Single-event iCalendar (.ics). Floating local time; venue+address as LOCATION. 189 function icsContent(m) { 190 var start = parseLocal(m.start), end = parseLocal(m.end); 191 var location = m.address ? m.venue + ", " + m.address : m.venue; 192 var uid = icsLocal(start) + "-" + Math.abs(hashStr(m.title)) + "@events"; 193 var lines = [ 194 "BEGIN:VCALENDAR", 195 "VERSION:2.0", 196 "PRODID:-//events//EN", 197 "CALSCALE:GREGORIAN", 198 "BEGIN:VEVENT", 199 "UID:" + uid, 200 "DTSTAMP:" + icsStamp(new Date()), 201 "DTSTART:" + icsLocal(start), 202 "DTEND:" + icsLocal(end), 203 "SUMMARY:" + icsEscape(m.title), 204 "LOCATION:" + icsEscape(location), 205 "URL:" + icsEscape(eventUrl(m)), 206 "DESCRIPTION:" + icsEscape(m.description), 207 "END:VEVENT", 208 "END:VCALENDAR" 209 ]; 210 return lines.map(icsFold).join("\r\n") + "\r\n"; 211 } 212 213 // Stable hash so the same event yields the same UID across downloads (calendars 214 // dedupe/update on UID). 215 function hashStr(str) { 216 var h = 0; 217 for (var i = 0; i < str.length; i++) { h = (h * 31 + str.charCodeAt(i)) | 0; } 218 return h; 219 } 220 221 // Deep-link id: title slug + date, so two same-named events on different days don't 222 // collide, e.g. "front-porch-jam-session-2026-06-12". 223 function eventSlug(m) { 224 var title = m.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "event"; 225 return title + "-" + m.start.slice(0, 10); 226 } 227 228 // Deep link to an event: current page sans query/hash + ?<slug>. 229 function eventUrl(m) { 230 var base = window.location.href.split(/[?#]/)[0]; 231 return base + "?" + encodeURIComponent(eventSlug(m)); 232 } 233 234 function isApplePlatform() { 235 return /Macintosh|iPhone|iPad|iPod/.test(navigator.userAgent); 236 } 237 238 // Driving-directions deep link (destination only, so the app fills in the origin). 239 // Prefer the street address; fall back to lat,long when blank. 240 // maps:// opens Apple Maps directly; Google Maps everywhere else. 241 function directionsHref(m) { 242 var dest = encodeURIComponent(m.address || (m.latitude + "," + m.longitude)); 243 return isApplePlatform() 244 ? "maps://?daddr=" + dest + "&dirflg=d" 245 : "https://www.google.com/maps/dir/?api=1&destination=" + dest + "&travelmode=driving"; 246 } 247 248 // Resolve the current URL's ?<slug> back to its event, or null. Inverse of eventUrl. 249 function eventFromUrl(events) { 250 var slug = decodeURIComponent(window.location.search.slice(1)); 251 if (!slug) { return null; } 252 for (var i = 0; i < events.length; i++) { 253 if (eventSlug(events[i]) === slug) { return events[i]; } 254 } 255 return null; 256 } 257 258 function icsFilename(m) { 259 return eventSlug(m) + ".ics"; 260 } 261 262 window.fmt = { 263 MONTH_ABBR: MONTH_ABBR, 264 parseLocal: parseLocal, 265 hasPassed: hasPassed, 266 upcomingEvents: upcomingEvents, 267 pastEvents: pastEvents, 268 shortDate: shortDate, 269 dateLine: dateLine, 270 startLine: startLine, 271 detailItems: detailItems, 272 easeInOut: easeInOut, 273 mailtoHref: mailtoHref, 274 icsContent: icsContent, 275 icsFilename: icsFilename, 276 eventSlug: eventSlug, 277 eventUrl: eventUrl, 278 directionsHref: directionsHref, 279 eventFromUrl: eventFromUrl 280 }; 281 })();