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 })();