PageSourceSearch

https://pupwife.com/scripts/dependencies/siteChangelog.js

js pupwife.com collected 2026-09-30 15:05:13 UTC 21,359 bytes, 642 lines download raw bytes

1/**
2 * Homepage Changelog widget and modal.
3 */
4(function (global) {
5	'use strict';
6
7	var STORAGE_KEY = 'pupwife_changelog_seen_revision';
8	var COLLAPSED_DAYS_STORAGE_KEY = 'pupwife_changelog_collapsed_days';
9	var COLLAPSED_SECTIONS_STORAGE_KEY = 'pupwife_changelog_collapsed_sections';
10	var CATEGORY_META = {
11		new: { label: 'New', icon: 'fa-plus' },
12		updated: { label: 'Updated', icon: 'fa-rotate' },
13		fixed: { label: 'Fixed', icon: 'fa-wrench' },
14		polished: { label: 'Polished', icon: 'fa-wand-magic-sparkles' },
15		removed: { label: 'Removed', icon: 'fa-trash-can' }
16	};
17	var openButton = null;
18	var modal = null;
19	var lastFocused = null;
20	var todoActivityRef = null;
21	var todoActivityHandler = null;
22	var todoEvents = [];
23
24	function getData(opts) {
25		opts = opts || {};
26		var base = global.PUPWIFE_CHANGELOG || { revision: '', latestDate: '', entries: [] };
27		if (opts.includeTodoActivity === false) return base;
28		if (!todoEvents.length) return base;
29
30		var entries = (base.entries || []).map(function (entry) {
31			return {
32				date: entry.date,
33				pages: (entry.pages || []).map(function (page) {
34					return {
35						name: page.name,
36						href: page.href,
37						icon: page.icon,
38						groups: (page.groups || []).map(function (group) {
39							return {
40								type: group.type,
41								items: (group.items || []).slice()
42							};
43						})
44					};
45				})
46			};
47		});
48		var byDate = {};
49		entries.forEach(function (entry) {
50			byDate[entry.date] = entry;
51		});
52
53		todoEvents.forEach(function (event) {
54			var dateKey = localDateKey(event.timestamp);
55			if (!dateKey) return;
56			if (!byDate[dateKey]) {
57				byDate[dateKey] = { date: dateKey, pages: [] };
58				entries.push(byDate[dateKey]);
59			}
60			var todoPage = byDate[dateKey].pages.find(function (page) {
61				return page && page.href === 'todo.html';
62			});
63			if (!todoPage) {
64				todoPage = {
65					name: 'TO-DO',
66					href: 'todo.html',
67					icon: 'fa-list-check',
68					groups: [{ type: 'updated', items: [] }]
69				};
70				byDate[dateKey].pages.push(todoPage);
71			}
72			var activityGroup = todoPage.groups.find(function (group) {
73				return group && group.type === 'updated';
74			});
75			if (!activityGroup) {
76				activityGroup = { type: 'updated', items: [] };
77				todoPage.groups.push(activityGroup);
78			}
79			var text = event.type === 'restored'
80				? '"' + event.title + '" was restored to ' + (event.destination === 'doing' ? 'Doing' : 'To do') + ' by ' + event.actor + '.'
81				: '"' + event.title + '" was completed by ' + event.actor + '.';
82			activityGroup.items.push({ text: text });
83		});
84
85		entries.sort(function (left, right) {
86			return String(right.date).localeCompare(String(left.date));
87		});
88		var newestEvent = todoEvents[0];
89		return {
90			generatedAt: base.generatedAt,
91			revision: (base.revision || '') + ':todo:' + (newestEvent.timestamp || ''),
92			latestDate: entries[0] ? entries[0].date : base.latestDate,
93			entries: entries
94		};
95	}
96
97	function localDateKey(timestamp) {
98		if (!timestamp) return '';
99		var date = new Date(timestamp);
100		if (Number.isNaN(date.getTime())) return '';
101		return [
102			date.getFullYear(),
103			String(date.getMonth() + 1).padStart(2, '0'),
104			String(date.getDate()).padStart(2, '0')
105		].join('-');
106	}
107
108	function refreshRenderedData() {
109		renderPreview();
110		syncUnreadState();
111		if (modal) {
112			var entries = modal.querySelector('[data-changelog-entries]');
113			if (entries) renderEntries(entries);
114		}
115	}
116
117	function subscribeTodoActivity() {
118		if (typeof firebase === 'undefined' || !firebase.database) return;
119		todoActivityRef = firebase.database().ref('todo/activity').orderByChild('timestamp').limitToLast(40);
120		todoActivityHandler = todoActivityRef.on('value', function (snapshot) {
121			var nextEvents = [];
122			snapshot.forEach(function (child) {
123				var value = child.val();
124				if (!value || !value.timestamp || !value.title || !value.actor) return;
125				nextEvents.push({
126					id: child.key,
127					type: value.type,
128					title: String(value.title).substring(0, 140),
129					actor: String(value.actor).substring(0, 20),
130					destination: value.destination === 'doing' ? 'doing' : 'todo',
131					timestamp: Number(value.timestamp)
132				});
133			});
134			todoEvents = nextEvents.sort(function (left, right) {
135				return right.timestamp - left.timestamp;
136			});
137			refreshRenderedData();
138		}, function (error) {
139			if (global.__PUPWIFE_DEBUG__) console.warn('[SiteChangelog] TO-DO activity unavailable:', error);
140		});
141	}
142
143	function formatDate(isoDate) {
144		if (!isoDate) return '';
145		var parts = isoDate.split('-').map(Number);
146		if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) return isoDate;
147		try {
148			return new Intl.DateTimeFormat(undefined, {
149				month: 'long',
150				day: 'numeric',
151				year: 'numeric'
152			}).format(new Date(parts[0], parts[1] - 1, parts[2]));
153		} catch (error) {
154			return isoDate;
155		}
156	}
157
158	function makeIcon(name) {
159		var icon = document.createElement('i');
160		icon.className = 'fa-solid ' + name;
161		icon.setAttribute('aria-hidden', 'true');
162		return icon;
163	}
164
165	function hasUnreadUpdates(opts) {
166		var revision = getData(opts).revision;
167		if (!revision) return false;
168		try {
169			return localStorage.getItem(STORAGE_KEY) !== revision;
170		} catch (error) {
171			return false;
172		}
173	}
174
175	function markSeen(opts) {
176		var revision = getData(opts).revision;
177		if (revision) {
178			try {
179				localStorage.setItem(STORAGE_KEY, revision);
180			} catch (error) {
181				/* The Changelog still works when storage is unavailable. */
182			}
183		}
184		syncUnreadState();
185	}
186
187	function getCollapsedDays() {
188		try {
189			var saved = JSON.parse(localStorage.getItem(COLLAPSED_DAYS_STORAGE_KEY) || '{}');
190			return saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {};
191		} catch (error) {
192			return {};
193		}
194	}
195
196	function saveCollapsedDays(days) {
197		try {
198			localStorage.setItem(COLLAPSED_DAYS_STORAGE_KEY, JSON.stringify(days));
199		} catch (error) {
200			/* Collapsing still works for this visit when storage is unavailable. */
201		}
202	}
203
204	function setDayCollapsed(date, collapsed) {
205		var days = getCollapsedDays();
206		if (collapsed) days[date] = true;
207		else delete days[date];
208		saveCollapsedDays(days);
209	}
210
211	function getCollapsedSections() {
212		try {
213			var saved = JSON.parse(localStorage.getItem(COLLAPSED_SECTIONS_STORAGE_KEY) || '{}');
214			return saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : {};
215		} catch (error) {
216			return {};
217		}
218	}
219
220	function saveCollapsedSections(sections) {
221		try {
222			localStorage.setItem(COLLAPSED_SECTIONS_STORAGE_KEY, JSON.stringify(sections));
223		} catch (error) {
224			/* Collapsing still works for this visit when storage is unavailable. */
225		}
226	}
227
228	function setSectionCollapsed(key, collapsed) {
229		var sections = getCollapsedSections();
230		if (collapsed) sections[key] = true;
231		else delete sections[key];
232		saveCollapsedSections(sections);
233	}
234
235	function syncUnreadState(targetButton, opts) {
236		var btn = targetButton || openButton;
237		if (!btn) return;
238		var unread = hasUnreadUpdates(opts);
239		var badge = btn.querySelector('[data-changelog-badge]');
240		if (badge) badge.hidden = !unread;
241		btn.classList.toggle('db-changelog-open--unread', unread);
242		btn.classList.toggle('citrus-widget-changelog__panel--unread', unread);
243	}
244
245	function renderPreview(targetButton, opts) {
246		var btn = targetButton || openButton;
247		if (!btn) return;
248		var data = getData(opts);
249		var latest = data.entries && data.entries[0];
250		var dateEl = btn.querySelector('[data-changelog-preview-date]');
251		var summaryEl = btn.querySelector('[data-changelog-preview-summary]');
252		var chipsEl = btn.querySelector('[data-changelog-preview-pages]');
253		var chipClass = btn.classList.contains('citrus-widget-changelog__panel')
254			? 'citrus-widget-changelog__chip'
255			: 'db-changelog-chip';
256		var chipMoreClass = chipClass + (chipClass.indexOf('citrus-') === 0 ? ' citrus-widget-changelog__chip--more' : ' db-changelog-chip--more');
257
258		if (!latest) {
259			if (dateEl) dateEl.textContent = 'No entries yet';
260			if (summaryEl) summaryEl.textContent = 'Check back after the next bit of tinkering.';
261			return;
262		}
263
264		var pages = latest.pages || [];
265		if (dateEl) dateEl.textContent = formatDate(latest.date);
266		if (summaryEl) {
267			summaryEl.textContent = pages.length + (pages.length === 1 ? ' page changed' : ' pages changed');
268		}
269		if (chipsEl) {
270			chipsEl.replaceChildren();
271			if (!pages.length) {
272				chipsEl.hidden = true;
273			} else {
274				chipsEl.hidden = false;
275			}
276			pages.slice(0, 3).forEach(function (page) {
277				var chip = document.createElement('span');
278				chip.className = chipClass;
279				chip.textContent = page.name;
280				chipsEl.appendChild(chip);
281			});
282			if (pages.length > 3) {
283				var more = document.createElement('span');
284				more.className = chipMoreClass;
285				more.textContent = '+' + (pages.length - 3);
286				chipsEl.appendChild(more);
287			}
288		}
289	}
290
291	function createItem(item) {
292		var li = document.createElement('li');
293		li.className = 'site-changelog-item';
294		if (item.href) {
295			var link = document.createElement('a');
296			link.className = 'site-changelog-item__link';
297			link.href = item.href;
298			link.textContent = item.text;
299			link.appendChild(makeIcon('fa-arrow-right'));
300			li.appendChild(link);
301		} else {
302			li.textContent = item.text;
303		}
304		return li;
305	}
306
307	function createPage(page, date, index, collapsedSections) {
308		var section = document.createElement('section');
309		section.className = 'site-changelog-page';
310
311		var sectionKey = date + ':' + (page.href || page.name);
312		var contentId = 'site-changelog-section-' + date + '-' + index;
313		var collapsed = collapsedSections[sectionKey] === true;
314		var header = document.createElement('div');
315		header.className = 'site-changelog-page__header';
316
317		var title = document.createElement('h3');
318		title.className = 'site-changelog-page__title';
319		var titleContent = page.href ? document.createElement('a') : document.createElement('span');
320		titleContent.className = 'site-changelog-page__link';
321		if (page.href) titleContent.href = page.href;
322		titleContent.appendChild(makeIcon(page.icon || 'fa-file-lines'));
323		var label = document.createElement('span');
324		label.textContent = page.name;
325		titleContent.appendChild(label);
326		if (page.href) titleContent.appendChild(makeIcon('fa-arrow-up-right-from-square'));
327		title.appendChild(titleContent);
328		header.appendChild(title);
329
330		var toggle = document.createElement('button');
331		toggle.type = 'button';
332		toggle.className = 'site-changelog-page__toggle';
333		toggle.dataset.changelogSectionToggle = sectionKey;
334		toggle.dataset.changelogSectionLabel = page.name;
335		toggle.setAttribute('aria-expanded', String(!collapsed));
336		toggle.setAttribute('aria-controls', contentId);
337		toggle.setAttribute('aria-label', (collapsed ? 'Expand ' : 'Collapse ') + page.name + ' section');
338		var chevron = makeIcon('fa-chevron-down');
339		chevron.classList.add('site-changelog-page__chevron');
340		toggle.appendChild(chevron);
341		header.appendChild(toggle);
342		section.appendChild(header);
343
344		var content = document.createElement('div');
345		content.className = 'site-changelog-page__content';
346		content.id = contentId;
347		content.hidden = collapsed;
348
349		(page.groups || []).forEach(function (group) {
350			var meta = CATEGORY_META[group.type] || { label: group.type, icon: 'fa-circle' };
351			var groupEl = document.createElement('div');
352			groupEl.className = 'site-changelog-group site-changelog-group--' + group.type;
353
354			var heading = document.createElement('h4');
355			heading.className = 'site-changelog-group__title';
356			heading.appendChild(makeIcon(meta.icon));
357			var headingLabel = document.createElement('span');
358			headingLabel.textContent = meta.label;
359			heading.appendChild(headingLabel);
360			groupEl.appendChild(heading);
361
362			var list = document.createElement('ul');
363			list.className = 'site-changelog-list';
364			(group.items || []).forEach(function (item) {
365				list.appendChild(createItem(item));
366			});
367			groupEl.appendChild(list);
368			content.appendChild(groupEl);
369		});
370		section.appendChild(content);
371
372		return section;
373	}
374
375	function renderEntries(container, opts) {
376		opts = opts || {};
377		var entries = getData(opts).entries || [];
378		var collapsedDays = getCollapsedDays();
379		var collapsedSections = getCollapsedSections();
380		var latestDate = entries[0] ? entries[0].date : '';
381		if (latestDate && hasUnreadUpdates(opts) && collapsedDays[latestDate]) {
382			delete collapsedDays[latestDate];
383			saveCollapsedDays(collapsedDays);
384		}
385		if (latestDate && hasUnreadUpdates(opts)) {
386			Object.keys(collapsedSections).forEach(function (key) {
387				if (key.indexOf(latestDate + ':') === 0) delete collapsedSections[key];
388			});
389			saveCollapsedSections(collapsedSections);
390		}
391		container.replaceChildren();
392		if (!entries.length) {
393			var empty = document.createElement('p');
394			empty.className = 'site-changelog-empty';
395			empty.textContent = 'No changelog entries yet. Check back soon!';
396			container.appendChild(empty);
397			return;
398		}
399
400		entries.forEach(function (entry) {
401			var article = document.createElement('article');
402			article.className = 'site-changelog-day';
403
404			var contentId = 'site-changelog-day-' + entry.date;
405			var collapsed = collapsedDays[entry.date] === true;
406			var toggle = document.createElement('button');
407			toggle.type = 'button';
408			toggle.className = 'site-changelog-day__toggle';
409			toggle.dataset.changelogDayToggle = entry.date;
410			toggle.setAttribute('aria-expanded', String(!collapsed));
411			toggle.setAttribute('aria-controls', contentId);
412
413			var date = document.createElement('time');
414			date.className = 'site-changelog-day__date';
415			date.dateTime = entry.date;
416			date.appendChild(makeIcon('fa-calendar-day'));
417			var dateLabel = document.createElement('span');
418			dateLabel.textContent = formatDate(entry.date);
419			date.appendChild(dateLabel);
420			toggle.appendChild(date);
421			var chevron = makeIcon('fa-chevron-down');
422			chevron.classList.add('site-changelog-day__chevron');
423			toggle.appendChild(chevron);
424			article.appendChild(toggle);
425
426			var pages = document.createElement('div');
427			pages.className = 'site-changelog-day__pages';
428			pages.id = contentId;
429			pages.hidden = collapsed;
430			(entry.pages || []).forEach(function (page, index) {
431				pages.appendChild(createPage(page, entry.date, index, collapsedSections));
432			});
433			article.appendChild(pages);
434			container.appendChild(article);
435		});
436	}
437
438	function createModal() {
439		var root = document.createElement('div');
440		root.className = 'site-changelog-modal';
441		root.hidden = true;
442		root.innerHTML =
443			'<div class="site-changelog-modal__backdrop" data-changelog-close></div>' +
444			'<section class="site-changelog-modal__panel" role="dialog" aria-modal="true" aria-labelledby="site-changelog-title" aria-describedby="site-changelog-intro">' +
445				'<header class="site-changelog-modal__header">' +
446					'<div>' +
447						'<p class="site-changelog-modal__eyebrow"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> what changed?</p>' +
448						'<h2 id="site-changelog-title">Changelog</h2>' +
449						'<p id="site-changelog-intro">Little notes about what I have been working on around the site.</p>' +
450					'</div>' +
451					'<button type="button" class="site-changelog-close" data-changelog-close aria-label="Close Changelog">' +
452						'<i class="fa-solid fa-xmark" aria-hidden="true"></i>' +
453					'</button>' +
454				'</header>' +
455				'<div class="site-changelog-modal__body" data-changelog-entries></div>' +
456			'</section>';
457		document.body.appendChild(root);
458		renderEntries(root.querySelector('[data-changelog-entries]'));
459		return root;
460	}
461
462	function getFocusableElements() {
463		if (!modal) return [];
464		return Array.prototype.slice.call(
465			modal.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not
465([tabindex="-1"])')
466		).filter(function (element) {
467			return !element.hidden && element.getClientRects().length > 0;
468		});
469	}
470
471	function closeModal() {
472		if (!modal || modal.hidden) return;
473		modal.hidden = true;
474		document.body.classList.remove('site-changelog-modal-open');
475		if (lastFocused && document.contains(lastFocused)) lastFocused.focus();
476		lastFocused = null;
477	}
478
479	function handleModalKeydown(event) {
480		if (!modal || modal.hidden) return;
481		if (event.key === 'Escape') {
482			event.preventDefault();
483			closeModal();
484			return;
485		}
486		if (event.key !== 'Tab') return;
487		var focusable = getFocusableElements();
488		if (!focusable.length) return;
489		var first = focusable[0];
490		var last = focusable[focusable.length - 1];
491		if (event.shiftKey && document.activeElement === first) {
492			event.preventDefault();
493			last.focus();
494		} else if (!event.shiftKey && document.activeElement === last) {
495			event.preventDefault();
496			first.focus();
497		}
498	}
499
500	function handleEntriesClick(event) {
501		var target = event.target instanceof Element ? event.target : event.target.parentElement;
502		if (!target) return false;
503		var dayToggle = target.closest('[data-changelog-day-toggle]');
504		if (dayToggle) {
505			var dayContent = document.getElementById(dayToggle.getAttribute('aria-controls'));
506			var willCollapse = dayToggle.getAttribute('aria-expanded') === 'true';
507			dayToggle.setAttribute('aria-expanded', String(!willCollapse));
508			if (dayContent) dayContent.hidden = willCollapse;
509			setDayCollapsed(dayToggle.dataset.changelogDayToggle, willCollapse);
510			return true;
511		}
512		var sectionToggle = target.closest('[data-changelog-section-toggle]');
513		if (sectionToggle) {
514			var sectionContent = document.getElementById(sectionToggle.getAttribute('aria-controls'));
515			var willCollapseSection = sectionToggle.getAttribute('aria-expanded') === 'true';
516			sectionToggle.setAttribute('aria-expanded', String(!willCollapseSection));
517			sectionToggle.setAttribute(
518				'aria-label',
519				(willCollapseSection ? 'Expand ' : 'Collapse ') +
520					sectionToggle.dataset.changelogSectionLabel +
521					' section'
522			);
523			if (sectionContent) sectionContent.hidden = willCollapseSection;
524			setSectionCollapsed(sectionToggle.dataset.changelogSectionToggle, willCollapseSection);
525			return true;
526		}
527		return false;
528	}
529
530	function handleModalClick(event) {
531		if (handleEntriesClick(event)) return;
532		var target = event.target instanceof Element ? event.target : event.target.parentElement;
533		if (!target) return;
534		if (target.closest('[data-changelog-close]')) {
535			closeModal();
536			return;
537		}
538		if (target.closest('a[href]')) closeModal();
539	}
540
541	var DATA_OPTS_STATIC = { includeTodoActivity: false };
542	/** @type {WeakMap<Element, { onClick: function(Event): void }>} */
543	var scopeControllers = new WeakMap();
544
545	function bindEntriesInteractions(scopeEl) {
546		if (!scopeEl) return function () {};
547		function onClick(ev) {
548			handleEntriesClick(ev);
549		}
550		scopeEl.addEventListener('click', onClick);
551		return function () {
552			scopeEl.removeEventListener('click', onClick);
553		};
554	}
555
556	function mountInScope(scopeEl, opts) {
557		opts = opts || {};
558		if (!scopeEl) return function () {};
559		destroyInScope(scopeEl);
560		var entriesHost = scopeEl.querySelector('[data-changelog-entries]');
561		if (!entriesHost) return function () {};
562		var dataOpts = opts.includeTodoActivity === true ? undefined : DATA_OPTS_STATIC;
563		renderEntries(entriesHost, dataOpts);
564		var unbind = bindEntriesInteractions(scopeEl);
565		if (opts.markSeen !== false) markSeen(dataOpts);
566		scopeControllers.set(scopeEl, { unbind: unbind });
567		return unbind;
568	}
569
570	function destroyInScope(scopeEl) {
571		if (!scopeEl) return;
572		var ctrl = scopeControllers.get(scopeEl);
573		if (ctrl && typeof ctrl.unbind === 'function') ctrl.unbind();
574		scopeControllers.delete(scopeEl);
575	}
576
577	function openModal() {
578		if (!modal) modal = createModal();
579		lastFocused = document.activeElement;
580		modal.hidden = false;
581		document.body.classList.add('site-changelog-modal-open');
582		markSeen();
583		var closeButton = modal.querySelector('.site-changelog-close');
584		if (closeButton) closeButton.focus();
585	}
586
587	function destroy() {
588		if (openButton) openButton.removeEventListener('click', openModal);
589		document.removeEventListener('keydown', handleModalKeydown);
590		if (todoActivityRef && todoActivityHandler) {
591			todoActivityRef.off('value', todoActivityHandler);
592		}
593		if (modal) {
594			modal.removeEventListener('click', handleModalClick);
595			modal.remove();
596		}
597		document.body.classList.remove('site-changelog-modal-open');
598		openButton = null;
599		modal = null;
600		lastFocused = null;
601		todoActivityRef = null;
602		todoActivityHandler = null;
603		todoEvents = [];
604	}
605
606	function init() {
607		destroy();
608		openButton = document.querySelector('[data-changelog-open]');
609		if (!openButton) return;
610		renderPreview();
611		syncUnreadState();
612		openButton.addEventListener('click', openModal);
613		document.addEventListener('keydown', handleModalKeydown);
614		modal = createModal();
615		modal.addEventListener('click', handleModalClick);
616		subscribeTodoActivity();
617	}
618
619	global.SiteChangelog = {
620		init: init,
621		destroy: destroy,
622		open: openModal,
623		close: closeModal,
624		getData: getData,
625		formatDate: formatDate,
626		hasUnread: hasUnreadUpdates,
627		markSeen: markSeen,
628		renderPreview: renderPreview,
629		renderEntries: renderEntries,
630		bindEntriesInteractions: bindEntriesInteractions,
631		mountInScope: mountInScope,
632		destroyInScope: destroyInScope,
633		syncUnread: syncUnreadState,
634		DATA_OPTS_STATIC: DATA_OPTS_STATIC
635	};
636
637	if (document.readyState === 'loading') {
638		document.addEventListener('DOMContentLoaded', init, { once: true });
639	} else {
640		init();
641	}
642})(typeof window !== 'undefined' ? window : this);

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.