diff --git a/TUMBLR_MIGRATION_README.md b/TUMBLR_MIGRATION_README.md
index 22565f7..ca9a917 100644
--- a/TUMBLR_MIGRATION_README.md
+++ b/TUMBLR_MIGRATION_README.md
@@ -288,3 +288,46 @@ The migration script creates 19 posts = 19 API calls, well within limits.
**Last Updated:** January 31, 2026
**Maintainer:** OpenWorm Team
+
+---
+
+## Posting an Event
+
+The website's [Events page](https://openworm.org/events.html) is drawn live from this
+blog. There is nothing to edit on the website — post to Tumblr and the page updates.
+
+### Tags
+
+| Tag | Effect |
+|-----|--------|
+| `event` **or** `events` | Required. Puts the post on the Events page. Both spellings work; older posts use both. |
+| `date:2026-09-15` | Optional. The date the event **happens**. |
+| `date:2026-01-29..2026-01-30` | Optional. A multi-day event, rendered as "29-30 Jan". |
+
+A bare `2026-09-15` (no `date:` prefix) works too.
+
+### Why the date tag matters
+
+Without it the page falls back to the post's publication date — which is when the event
+was *announced*, not when it happens. For anything upcoming those point in opposite
+directions, and the event lands in the wrong section.
+
+A real example already on the blog: *"Join us in London! / November 5-6"* was posted on
+**31 October 2014**, so it renders under 31 Oct. Tagging it `date:2014-11-05..2014-11-06`
+would fix it.
+
+Malformed dates (`2026-13-99`, `2026-02-30`) are ignored and the post date is used
+instead, so a typo degrades quietly rather than inventing a date.
+
+### Sections
+
+Events dated today or later appear under **Coming up**, soonest first. Everything else
+appears under **Recently**, most recent first. Events predating the blog are kept as a
+static **Earlier events** list in `events.html` — that list is hand-maintained and is
+not affected by anything here.
+
+### Title and description
+
+The post title becomes the event title; the opening of the post body becomes the
+description, trimmed to fit the card. Lead with the essentials — a post that opens with
+"UPDATE 3 (2/11/17):" will show exactly that.
diff --git a/events.html b/events.html
index f7623d6..37583db 100644
--- a/events.html
+++ b/events.html
@@ -1,408 +1,226 @@
-
Upcoming and recent events are pulled from our Tumblr blog.
+
+
+
+
+
+
+
Loading events from the blog...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/main.js b/js/main.js
index 13c579a..9956176 100644
--- a/js/main.js
+++ b/js/main.js
@@ -21,6 +21,8 @@ $(document).on('pjax:complete', function() {
loadDonationControls();
} else if (loc === '/news.html') {
loadFullNewsFeed();
+ } else if (loc === '/events.html') {
+ loadEventsFeed();
} else if (loc === '/people.html') {
$.getScript("https://cdnjs.cloudflare.com/ajax/libs/mustache.js/2.2.0/mustache.min.js",
function() {
@@ -93,6 +95,8 @@ $(window).on('load', function() {
loadDonationControls();
} else if (loc === '/news.html') {
loadFullNewsFeed();
+ } else if (loc === '/events.html') {
+ loadEventsFeed();
} else if (loc === '/people.html') {
$.getScript("https://cdnjs.cloudflare.com/ajax/libs/mustache.js/2.2.0/mustache.min.js",
function() {
@@ -131,148 +135,403 @@ function setNavigation() {
}
-function getTumblrPostTitle(post) {
- if (post['regular-title']) return post['regular-title'];
- if (post['link-text']) return post['link-text'];
- if (post['quote-text']) return post['quote-text'].substring(0, 100);
- if (post['photo-caption']) {
- var tmp = document.createElement('div');
- tmp.innerHTML = post['photo-caption'];
- return (tmp.textContent || tmp.innerText || '').substring(0, 100);
+// ============================================================
+// Tumblr feed client - shared by index.html, news.html, events.html
+// ============================================================
+//
+// Tumblr put its legacy /api/read/json endpoint behind a bot challenge; it
+// now answers 403 for ordinary visitors, which is what silently broke the
+// news feed. Everything below uses the supported v2 API instead.
+//
+// TUMBLR_API_KEY is a public, read-only consumer key. It can only read posts
+// that are already public on the blog - it cannot post, edit, or delete.
+
+var TUMBLR_BLOG = 'openworm.tumblr.com';
+var TUMBLR_API_KEY = 'Sr3W9pREVcJrnwiZb57tj8wRuCbP9d1npEynWqotXMoN5DDj9P';
+var TUMBLR_CACHE_MIN = 15;
+
+// Posts tagged with any of these show up on the events page. Both spellings
+// are in use on the blog already, so accept both rather than silently
+// dropping half the history.
+var TUMBLR_EVENT_TAGS = ['event', 'events'];
+
+var _tumblrPending = {};
+
+function _tumblrCacheGet(key) {
+ try {
+ var raw = window.sessionStorage.getItem(key);
+ if (!raw) return null;
+ var hit = JSON.parse(raw);
+ if ((Date.now() - hit.at) > TUMBLR_CACHE_MIN * 60 * 1000) return null;
+ return hit.posts;
+ } catch (e) { return null; }
+}
+
+function _tumblrCacheSet(key, posts) {
+ try {
+ window.sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), posts: posts }));
+ } catch (e) { /* private browsing / quota - caching is optional */ }
+}
+
+// Fetch posts. opts: {tag, limit, done, fail}
+//
+// At most ONE request per distinct query is in flight at a time. Duplicate
+// callers (window load racing pjax:complete, say) attach to the request that
+// is already running instead of firing their own and fighting over the DOM.
+function tumblrPosts(opts) {
+ opts = opts || {};
+ var tag = opts.tag || '';
+ var limit = opts.limit || 20;
+ var key = 'tumblr:' + tag + ':' + limit;
+
+ var cached = _tumblrCacheGet(key);
+ if (cached) { if (opts.done) opts.done(cached); return; }
+
+ if (_tumblrPending[key]) { _tumblrPending[key].push(opts); return; }
+ _tumblrPending[key] = [opts];
+
+ var settle = function(which, arg) {
+ var waiting = _tumblrPending[key] || [];
+ delete _tumblrPending[key];
+ for (var i = 0; i < waiting.length; i++) {
+ if (waiting[i][which]) waiting[i][which](arg);
+ }
+ };
+
+ var params = { api_key: TUMBLR_API_KEY, limit: limit, filter: 'html' };
+ if (tag) params.tag = tag;
+
+ $.ajax({
+ url: 'https://api.tumblr.com/v2/blog/' + TUMBLR_BLOG + '/posts',
+ data: params,
+ dataType: 'jsonp',
+ timeout: 15000
+ }).done(function(data) {
+ var posts = (data && data.response && data.response.posts) || [];
+ _tumblrCacheSet(key, posts);
+ settle('done', posts);
+ }).fail(function(xhr, status) {
+ console.error('Tumblr fetch failed [' + key + ']:', status);
+ settle('fail', status);
+ });
+}
+
+// Fetch several tags at once and merge, newest first, de-duplicated by id.
+function tumblrPostsForTags(tags, limit, done, fail) {
+ var collected = [], seen = {}, remaining = tags.length, anyOk = false;
+
+ var finish = function() {
+ if (--remaining > 0) return;
+ if (!anyOk) { if (fail) fail('all tag queries failed'); return; }
+ collected.sort(function(a, b) { return b.timestamp - a.timestamp; });
+ done(collected);
+ };
+
+ for (var i = 0; i < tags.length; i++) {
+ tumblrPosts({
+ tag: tags[i],
+ limit: limit,
+ done: function(posts) {
+ anyOk = true;
+ for (var j = 0; j < posts.length; j++) {
+ if (!seen[posts[j].id]) { seen[posts[j].id] = true; collected.push(posts[j]); }
+ }
+ finish();
+ },
+ fail: finish
+ });
}
- if (post['regular-body']) {
- var tmp = document.createElement('div');
- tmp.innerHTML = post['regular-body'];
- var text = tmp.textContent || tmp.innerText || '';
- return text.indexOf(':') !== -1 ? text.substring(0, text.indexOf(':')) : text;
+}
+
+// ---- post field helpers (v2 shapes) ------------------------------------
+
+function tumblrStripHtml(html) {
+ var tmp = document.createElement('div');
+ tmp.innerHTML = html || '';
+ return (tmp.textContent || tmp.innerText || '').replace(/\s+/g, ' ').trim();
+}
+
+// Tumblr returns post titles as plain text, so the italics that the rest of
+// the site applies to species names are lost. Restore them at render time -
+// the repo has explicit commits ("Italicise C. elegans") establishing this as
+// house style, and the hand-written archive entries already follow it.
+function italiciseSpecies(s) {
+ if (!s || s.indexOf('') !== -1 || s.indexOf('') !== -1) return s;
+ return s.replace(/\b(C\. elegans|Caenorhabditis elegans|Drosophila)\b/g, '$1');
+}
+
+// Photo and quote posts have no title, so one has to be derived from the
+// caption. Cut at the first real sentence end rather than at a fixed width,
+// otherwise the "title" is a mid-sentence slice of the body and the two read
+// as duplicates. Abbreviations matter here: a naive split on ". " would cut
+// "C. elegans" in half on a C. elegans blog.
+function tumblrDeriveTitle(text) {
+ var line = (text || '').split('\n')[0].trim();
+ if (line.length <= 60) return line;
+ var m = /[^\s]{2,}[.!?](\s|$)/.exec(line);
+ if (m && m.index + m[0].length >= 20 && m.index + m[0].length <= 130) {
+ return line.substring(0, m.index + m[0].trimEnd().length);
+ }
+ return line.substring(0, 120);
+}
+
+function tumblrPostTitle(post) {
+ if (post.title) return post.title;
+ if (post.summary) return tumblrDeriveTitle(post.summary);
+ var text = tumblrStripHtml(post.body || post.caption || post.description || post.text);
+ if (!text) return 'Untitled post';
+ return text.indexOf(':') !== -1 && text.indexOf(':') < 80
+ ? text.substring(0, text.indexOf(':'))
+ : text.substring(0, 120);
+}
+
+function tumblrPostBody(post) {
+ var html = '';
+ if (post.type === 'photo' && post.photos && post.photos.length) {
+ var src = post.photos[0].original_size && post.photos[0].original_size.url;
+ if (src) html += '';
}
- return 'Untitled post';
+ html += post.body || post.caption || post.description || '';
+ if (!html && post.text) {
+ html = '
' + post.text + '
' + (post.source || '');
+ }
+ return html;
}
+// NPF posts carry their title as a heading block inside the body, so a naive
+// render prints the title twice - once as the item heading, once at the top of
+// the text. Drop the leading copy when it duplicates the heading we already
+// rendered.
+function stripLeadingTitle(html, title) {
+ if (!html || !title) return html;
+ var plain = tumblrStripHtml(title);
+ if (!plain) return html;
+
+ var el = document.createElement('div');
+ el.innerHTML = html;
+
+ // Compare decoded text, not raw HTML: a title containing "&" appears as
+ // "&" in the markup, so string matching on the source silently fails.
+ // Skip leading media (photo posts open with an ) to reach the caption.
+ for (var i = 0; i < el.children.length; i++) {
+ var node = el.children[i];
+ var txt = tumblrStripHtml(node.innerHTML);
+ if (!txt) continue; // img, spacer, empty node
+ if (txt === plain) {
+ node.parentNode.removeChild(node);
+ return el.innerHTML;
+ }
+ if (txt.indexOf(plain) === 0) { // title runs on into the body
+ var rest = txt.slice(plain.length).replace(/^[\s.,:;!?-]+/, '');
+ node.textContent = rest;
+ return el.innerHTML;
+ }
+ break; // first real text isn't the title
+ }
+ return el.innerHTML;
+}
+
+function tumblrFormatDate(d) {
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+// ---- event date convention ---------------------------------------------
+//
+// A post's publication date is when the event was ANNOUNCED, which is not
+// the same thing as when the event happens - and for anything upcoming they
+// point in opposite directions. So an event post may carry a date tag:
+//
+// date:2026-09-15 single day
+// date:2026-01-29..2026-01-30 a range, rendered "29-30"
+//
+// With no date tag we fall back to the post's own date, which is the right
+// answer for the historical posts that predate this convention.
+function tumblrEventDate(post) {
+ // Date.UTC silently rolls impossible values over - Date.UTC(2026, 12, 99)
+ // is April 2027, not an error - so a typo in a tag would quietly produce a
+ // confidently wrong date. Build the date, then check it reads back the way
+ // it was written; anything that does not is treated as not a date tag.
+ var toUTC = function(y, mo, d) {
+ var dt = new Date(Date.UTC(y, mo - 1, d));
+ if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;
+ return dt;
+ };
+
+ var tags = post.tags || [];
+ var re = /^(?:date:)?(\d{4})-(\d{2})-(\d{2})(?:\.\.(\d{4})-(\d{2})-(\d{2}))?$/;
+ for (var i = 0; i < tags.length; i++) {
+ var m = re.exec($.trim(tags[i]));
+ if (!m) continue;
+ var start = toUTC(+m[1], +m[2], +m[3]);
+ if (!start) continue;
+ var end = start;
+ if (m[4]) {
+ end = toUTC(+m[4], +m[5], +m[6]);
+ if (!end || end < start) end = start;
+ }
+ return { start: start, end: end, explicit: true };
+ }
+ var posted = new Date(post.timestamp * 1000);
+ return { start: posted, end: posted, explicit: false };
+}
+
+// ---- home page: short news list ----------------------------------------
+
function refreshNews() {
- // Use Tumblr v1 JSONP API (no CORS needed)
- $.ajax({
- url: 'https://openworm.tumblr.com/api/read/json',
- data: { num: 6 },
- dataType: 'jsonp',
- timeout: 30000,
- success: function(data) {
- var posts = data.posts || [];
+ tumblrPosts({
+ limit: 6,
+ done: function(posts) {
var html = '';
-
for (var i = 0; i < posts.length; i++) {
var post = posts[i];
- var title = getTumblrPostTitle(post);
- var link = post['url-with-slug'] || post['url'];
- var pubDate = new Date(post['unix-timestamp'] * 1000);
- var dateStr = pubDate.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric'
- });
-
html += '
');
}
});
}
-function getTumblrPostBody(post) {
- if (post['regular-body']) return post['regular-body'];
- if (post['photo-caption']) return post['photo-caption'];
- if (post['link-description']) return post['link-description'];
- if (post['quote-text']) return '
' + post['quote-text'] + '
' + (post['quote-source'] || '');
- return '';
-}
+// ---- news.html: full feed with sidebar ---------------------------------
function loadFullNewsFeed() {
- // Load full news feed with sidebar navigation for news.html page
- console.log('Loading full news feed with sidebar...');
-
- $.ajax({
- url: 'https://openworm.tumblr.com/api/read/json',
- data: { num: 25 },
- dataType: 'jsonp',
- timeout: 30000,
- success: function(data) {
- console.log('Feed loaded via JSONP');
-
- var posts = data.posts || [];
- console.log('Found ' + posts.length + ' posts');
-
+ tumblrPosts({
+ limit: 25,
+ done: function(posts) {
var mainHtml = '';
- var navHtml = '
News Archive
';
+ var navHtml = '
News Archive
';
for (var i = 0; i < posts.length; i++) {
- var post = posts[i];
- var title = getTumblrPostTitle(post);
- var link = post['url-with-slug'] || post['url'];
- var pubDate = new Date(post['unix-timestamp'] * 1000);
- var dateStr = pubDate.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric'
- });
-
- var description = getTumblrPostBody(post);
-
- // Create anchor ID from index
- var anchorId = 'news-' + i;
-
- // Add to sidebar nav (if nav element exists)
- navHtml += '
');
}
+ }
+ });
+}
+
+// ---- events.html: events drawn from tagged blog posts -------------------
+
+var MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+// "Jun 22, 2021" for a single day, "Jan 29-30, 2018" for a range - matching
+// how the hand-written archive entries below the feed are written.
+function tumblrFormatEventDate(when) {
+ var s = when.start, e = when.end;
+ var out = MONTH_ABBR[s.getUTCMonth()] + ' ' + s.getUTCDate();
+ if (e.getTime() !== s.getTime()) {
+ out += (e.getUTCMonth() === s.getUTCMonth())
+ ? '\u2013' + e.getUTCDate()
+ : '\u2013' + MONTH_ABBR[e.getUTCMonth()] + ' ' + e.getUTCDate();
+ }
+ return out + ', ' + s.getUTCFullYear();
+}
+
+// Render one post in the same shape as a news item and as the archived
+// events below it, so the whole page reads as one list.
+function renderEventItem(post, anchor, isLast) {
+ var dateStr = tumblrFormatEventDate(tumblrEventDate(post));
+ var border = isLast ? '' : 'border-bottom: 1px solid #eee;';
+
+ var html = '
');
+ var body = tumblrStripHtml(stripLeadingTitle(tumblrPostBody(post), tumblrPostTitle(post)));
+ if (body.length > 320) body = body.substring(0, 317).replace(/\s+\S*$/, '') + '...';
+ if (body) html += '
';
+}
+
+function loadEventsFeed() {
+ var $feed = $('#events-feed');
+ if (!$feed.length) return;
+
+ tumblrPostsForTags(TUMBLR_EVENT_TAGS, 50, function(posts) {
+ // "Today" at UTC midnight, so an event happening today still counts
+ // as upcoming rather than dropping into the past the morning of.
+ var now = new Date();
+ var today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
+
+ var upcoming = [], past = [];
+ for (var i = 0; i < posts.length; i++) {
+ (tumblrEventDate(posts[i]).end.getTime() >= today ? upcoming : past).push(posts[i]);
+ }
+ // Soonest first for what is ahead; most recent first for what is behind.
+ upcoming.sort(function(a, b) { return tumblrEventDate(a).start - tumblrEventDate(b).start; });
+ past.sort(function(a, b) { return tumblrEventDate(b).start - tumblrEventDate(a).start; });
+
+ var html = '', navHtml = '', n = 0;
+ var addGroup = function(label, list) {
+ if (!list.length) return;
+ html += _eventSectionHeading(label);
+ for (var j = 0; j < list.length; j++) {
+ var anchor = 'event-' + (n++);
+ var last = (list === past) && (j === list.length - 1);
+ html += renderEventItem(list[j], anchor, last);
+ navHtml += '