diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b1835..e758f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # OTE Reader changelog +## 0.11.0 - 2026-11-05 + +### Fixed + +- A saved event disappeared from its collection, without a word, the moment + its publisher stopped carrying it in the feed. Collections stored only a + `{feedUrl, eventId}` pointer and resolved it against the live feed on every + render, so pruning a feed — or simply unsubscribing from it — silently + emptied collections of its events, while the pointers stayed in storage + aimed at nothing. Collections now hold the event itself. + +### Added + +- Events whose feed stopped carrying them are marked rather than removed. + "Retirado del feed" appears only when the feed actually claims the absence + means something — `feed.covers` is the window a feed declares itself + complete for, and outside it, or without it, nothing is claimed. Events from + a feed you unsubscribed from are marked "Feed no suscrito". +- A cleanup in Ajustes for saved events that can no longer be followed, with + a count and a confirmation. It deliberately does not offer to remove past + events: a saved past event is the only history the reader has. + ## 0.10.1 - 2026-11-05 ### Changed diff --git a/README.md b/README.md index 61508b6..53f4c5c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ The first version is a static PWA that lets people: - discover feeds from pages that expose an OTE ``; - filter events by topic, attendance mode, language, country and open CFP; - switch between card, RSS-like and calendar views; -- save filters and events locally; +- save filters and events locally, keeping a saved event even after its feed + stops publishing it; - export single events as `.ics`. ## Relationship with `` @@ -18,6 +19,26 @@ for sharing presentation code with OTE Reader. Today it consumes a `feed` URL di OTE Reader keeps feeds and filters in memory, so the clean integration point would be a future widget API that accepts an already-filtered OTE feed object or event list. +## Saved events + +A collection holds the event, not a pointer to it. The reader keeps a copy of +each event you save, so a publisher pruning their feed cannot empty your +collection — which is what used to happen, silently. + +Whether an event's absence *means* anything is a separate question, and +`feed.covers` answers it: inside the window a feed declares itself complete +for, an absent event has been withdrawn and the card is badged as such; +outside it, or in a feed without `covers`, nothing is claimed and nothing is +said. Every other absence — the feed is loading, it failed, it returned a +truncated response — is never presented as a withdrawal. + +Nothing is ever deleted on the reader's initiative. Ajustes offers to remove +only what can no longer be followed at all: events from feeds you have +unsubscribed, and events their feed says it withdrew. Past events are not on +that list — a saved past event is the only history the reader has, since the +feed no longer carries it. To keep them out of the lists, use the "Solo +futuros" filter, which is a view rather than a deletion. + ## OTE spec version support The reader opens a document because of its **shape** — a declared diff --git a/app.js b/app.js index 42aea68..61761ae 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,7 @@ (function () { 'use strict'; - var APP_VERSION = '0.10.1'; + var APP_VERSION = '0.11.0'; var DEMO_MODE = new URLSearchParams(location.search).get('demo') === '1' || location.pathname.replace(/\/+$/, '').endsWith('/demo'); var STORAGE = DEMO_MODE ? 'ote-reader-demo-state-v1' : 'ote-reader-state-v1'; var INSTALL_DISMISSED = 'ote-reader-install-dismissed-v1'; @@ -332,6 +332,7 @@ deleteCachedFeed(feed.url); } })); + backfillBoardSnapshots(); persist(); } @@ -610,6 +611,7 @@ feed.updatedAt = result.doc.updatedAt || ''; feedCache.set(feed.url, result.doc); writeCachedFeed(feed.url, result.doc); + backfillBoardSnapshots(); } catch (error) { if (feedCache.has(feed.url)) { feed.status = 'ok'; @@ -1406,18 +1408,124 @@ return a && b && a.feedUrl === b.feedUrl && a.eventId === b.eventId; } + function refKey(ref) { + return String(ref.feedUrl) + '\n' + String(ref.eventId); + } + + // A saved event used to be a pointer — {feedUrl, eventId} resolved against + // the live feed on every render — so the day a publisher stopped carrying + // it, it left the collection without a word and the pointer stayed behind + // in storage, aimed at nothing. A collection is the reader's, not the + // publisher's, so what gets saved is the event itself. The feed is then + // free to drop it: the card stays, and whether that absence *means* + // anything is a separate question, answered below by `covers`. + function savedRef(event) { + return { + feedUrl: event._feedUrl, + eventId: event.id, + savedAt: new Date().toISOString(), + event: snapshotOf(event) + }; + } + + function snapshotOf(event) { + var copy = Object.assign({}, event); + delete copy._fromSnapshot; + delete copy._withdrawn; + delete copy._orphan; + delete copy._savedAt; + return copy; + } + function boardHasEvent(board, event) { var ref = eventRef(event); return Boolean(board && (board.eventRefs || []).some(function (item) { return sameRef(item, ref); })); } + // The live event when the feed still carries it — it may have been edited + // since, and the current truth wins — otherwise what was saved. function boardEvents(board) { if (!board) return []; - var refs = board.eventRefs || []; - return allEvents().filter(function (event) { - var ref = eventRef(event); - return refs.some(function (item) { return sameRef(item, ref); }); + var live = new Map(); + allEvents().forEach(function (event) { live.set(refKey(eventRef(event)), event); }); + return (board.eventRefs || []).map(function (ref) { + return live.get(refKey(ref)) || snapshotEvent(ref); + }).filter(Boolean); + } + + function snapshotEvent(ref) { + if (!ref || !ref.event) return null; + var event = Object.assign({}, ref.event); + event._fromSnapshot = true; + event._savedAt = ref.savedAt || ''; + if (!findFeed(ref.feedUrl)) event._orphan = true; + else if (feedWithdrew(ref)) event._withdrawn = true; + return event; + } + + // Whether a feed is actually *claiming* this event is gone. `covers` is the + // window a feed says it is complete for: inside it, an absent event has + // been withdrawn; outside it the feed asserts nothing, and a feed without + // `covers` asserts nothing at all. Every other absence — the feed is still + // loading, it failed, it returned a truncated response, the publisher + // recycled an id — is not a claim about anything and must not be shown as + // one. + function feedWithdrew(ref) { + var feed = findFeed(ref.feedUrl); + if (!feed || feed.status !== 'ok') return false; + var doc = feedCache.get(ref.feedUrl); + if (!doc || !doc.covers) return false; + return coversIncludes(doc.covers, (ref.event || {}).startDate); + } + + // ISO dates compare correctly as strings, and `to` is inclusive — the same + // convention as an all-day endDate. + function coversIncludes(covers, startDate) { + if (!startDate || (!covers.from && !covers.to)) return false; + var day = String(startDate).slice(0, 10); + if (covers.from && day < covers.from) return false; + if (covers.to && day > covers.to) return false; + return true; + } + + // Collections saved before snapshots existed are pointers. Fill them in + // from the live feed as it loads, so they stop being destructible by a + // publisher's next prune. A pointer whose event is already gone cannot be + // recovered; it stays as it is and the cleanup in Ajustes can clear it. + function backfillBoardSnapshots() { + var live = new Map(); + allEvents().forEach(function (event) { live.set(refKey(eventRef(event)), event); }); + var changed = false; + state.boards.forEach(function (board) { + (board.eventRefs || []).forEach(function (ref) { + if (ref.event) return; + var current = live.get(refKey(ref)); + if (!current) return; + ref.event = snapshotOf(current); + changed = true; + }); }); + if (changed) persist(); + } + + // Saved events that can no longer be followed, for the cleanup in Ajustes: + // the feed is no longer subscribed, the feed says it withdrew them, or they + // are pointers from before snapshots with nothing left to point at. + function staleBoardRefs() { + var live = new Map(); + allEvents().forEach(function (event) { live.set(refKey(eventRef(event)), event); }); + var orphan = []; + var withdrawn = []; + var broken = []; + state.boards.forEach(function (board) { + (board.eventRefs || []).forEach(function (ref) { + if (live.has(refKey(ref))) return; + if (!ref.event) broken.push({ board: board, ref: ref }); + else if (!findFeed(ref.feedUrl)) orphan.push({ board: board, ref: ref }); + else if (feedWithdrew(ref)) withdrawn.push({ board: board, ref: ref }); + }); + }); + return { orphan: orphan, withdrawn: withdrawn, broken: broken }; } function createBoard(name) { @@ -1716,6 +1824,11 @@ if (!event) return []; var badges = []; if (event.status === 'cancelled') badges.push({ label: 'Cancelado', tone: 'warning' }); + // Only ever shown on a saved event the feed stopped carrying. Absence + // on its own says nothing (see feedWithdrew); these are the two cases + // where it says something. + if (event._orphan) badges.push({ label: 'Feed no suscrito', tone: 'default' }); + else if (event._withdrawn) badges.push({ label: 'Retirado del feed', tone: 'warning' }); return badges; }; } @@ -1807,7 +1920,17 @@ function currentBoardEvent() { if (!boardEventContext) return null; - return allEvents().find(function (event) { return sameRef(eventRef(event), boardEventContext); }) || null; + var live = allEvents().find(function (event) { return sameRef(eventRef(event), boardEventContext); }); + if (live) return live; + // Fall back to what was saved: an event its feed no longer carries must + // still be removable from the collection holding it. + var saved = null; + state.boards.forEach(function (board) { + (board.eventRefs || []).forEach(function (ref) { + if (!saved && ref.event && sameRef(ref, boardEventContext)) saved = snapshotEvent(ref); + }); + }); + return saved; } function saveBoardPicker() { @@ -1818,7 +1941,7 @@ state.boards.forEach(function (board) { var has = board.eventRefs.some(function (item) { return sameRef(item, ref); }); var wants = checked.indexOf(board.id) !== -1; - if (wants && !has) board.eventRefs.push(ref); + if (wants && !has) board.eventRefs.push(savedRef(event)); if (!wants && has) board.eventRefs = board.eventRefs.filter(function (item) { return !sameRef(item, ref); }); }); persist(); @@ -2270,10 +2393,82 @@ function openModal(id) { var modal = $(id); + if (id === 'help-modal') renderCollectionsSettings(); if (modal.showModal) modal.showModal(); else modal.setAttribute('open', ''); } + function savedEventCount() { + return state.boards.reduce(function (sum, board) { + return sum + (board.eventRefs || []).length; + }, 0); + } + + // Deliberately not a "remove past events" button. A saved past event is the + // only history this reader has — its feed no longer carries it — and it is + // the one a reader asked for by name. Hiding past events from lists is what + // the "Solo futuros" filter is for; that is a view, not a deletion. What + // this offers to remove is only what can no longer be followed at all. + function renderCollectionsSettings() { + var summary = $('collections-summary'); + var button = $('clean-collections'); + if (!summary || !button) return; + var stale = staleBoardRefs(); + var removable = stale.orphan.length + stale.withdrawn.length + stale.broken.length; + var total = savedEventCount(); + if (!total) { + summary.textContent = 'Todavía no has guardado ningún evento.'; + button.hidden = true; + return; + } + var noun = total === 1 ? '1 evento guardado' : total + ' eventos guardados'; + if (!removable) { + summary.textContent = noun + '. Todos se pueden seguir en sus feeds.'; + button.hidden = true; + return; + } + var parts = []; + if (stale.orphan.length) { + parts.push(stale.orphan.length + (stale.orphan.length === 1 + ? ' de un feed que ya no sigues' : ' de feeds que ya no sigues')); + } + if (stale.withdrawn.length) { + parts.push(stale.withdrawn.length + (stale.withdrawn.length === 1 + ? ' retirado por su feed' : ' retirados por su feed')); + } + if (stale.broken.length) { + parts.push(stale.broken.length + (stale.broken.length === 1 + ? ' guardado antes de que se conservara su contenido' + : ' guardados antes de que se conservara su contenido')); + } + summary.textContent = noun + ', de los cuales ' + parts.join(', ') + '.'; + button.hidden = false; + button.textContent = removable === 1 + ? 'Quitar 1 evento que ya no se puede seguir' + : 'Quitar ' + removable + ' eventos que ya no se pueden seguir'; + } + + function cleanCollections() { + var stale = staleBoardRefs(); + var entries = stale.orphan.concat(stale.withdrawn, stale.broken); + if (!entries.length) return; + var question = entries.length === 1 + ? 'Quitar 1 evento de tus colecciones? No se puede deshacer.' + : 'Quitar ' + entries.length + ' eventos de tus colecciones? No se puede deshacer.'; + if (!confirm(question)) return; + entries.forEach(function (entry) { + entry.board.eventRefs = entry.board.eventRefs.filter(function (item) { + return item !== entry.ref; + }); + }); + persist(); + renderCollectionsSettings(); + render(); + showMessage(entries.length === 1 + ? 'Se quitó 1 evento de tus colecciones.' + : 'Se quitaron ' + entries.length + ' eventos de tus colecciones.', '', true); + } + function closeModal(id) { var modal = $(id); if (modal.close) modal.close(); @@ -2402,6 +2597,7 @@ }); $('install-bar-button').addEventListener('click', promptInstall); $('install-help-button').addEventListener('click', promptInstall); + $('clean-collections').addEventListener('click', cleanCollections); $('install-dismiss').addEventListener('click', function () { try { localStorage.setItem(INSTALL_DISMISSED, '1'); } catch (e) { /* storage unavailable */ } updateInstallUi(); diff --git a/index.html b/index.html index 21d0b0d..b5d759d 100644 --- a/index.html +++ b/index.html @@ -258,6 +258,14 @@

Tarjetas

Elige qué información opcional se muestra en cada evento.

+
+

Colecciones

+

Lo que guardas se queda contigo: un evento sigue en tu colección aunque su feed deje de publicarlo.

+

+ +

Descubrimiento OTE

Una web puede anunciar su feed desde el <head>:

diff --git a/package.json b/package.json index a74b5f7..b57ecea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ote-reader", - "version": "0.10.1", + "version": "0.11.0", "private": true, "type": "module", "description": "Attendee-facing PWA for subscribing to OpenTechEvents feeds.",