diff --git a/README.md b/README.md index 961a50b..3c6f2ed 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,14 @@ daily heartbeat. A monitor that died at 3am and one that's quietly working shoul same from the outside. Alerts are deduplicated by session id, so you hear about each show once. Shows added later still -get through. +get through, and so do shows that were pulled and re-listed: a show missing for three checks in a +row is forgotten, so its return alerts you again. | Notification | Priority | What it means | |---|---|---| | ` OPEN on - BOOK NOW` | max | The one you're waiting for. Tap it to book. | | ` is OPEN on (no yet)` | high | Date opened, your format hasn't shown up yet. | +| ` shows withdrawn on ` | default | Shows you were alerted about were pulled. Re-armed for their return. | | ` checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. | | ` checks recovered` | default | Back to normal. | | `Still watching ` | min | Daily heartbeat, with a line per site. | @@ -253,7 +255,7 @@ Run `npm run check` for a single noisy check. Configuration problems are reporte ## Development ```bash -npm test # 24 tests, no network needed +npm test # 35 tests, no network needed ``` The tests run the alert state machine and both site parsers against real captured API responses diff --git a/docs/design-notes.md b/docs/design-notes.md index 71268f3..593ca99 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -75,6 +75,14 @@ edge case testable without network or clock mocking, and it's where the interest alert's push succeeds. A failed notification retries on the next tick instead of being silently recorded as delivered. - **Dedupe by session id, not a boolean.** Shows added after the first alert still notify you. +- **Dedupe does not outlive the listing.** Cinemas publish early, pull the shows and re-list + them, often under the same session ids, and this really happened with the Endgame release. A + show missing for three consecutive successful checks is forgotten, so its return alerts again. + Three rather than one, so a single flaky response can't cause a duplicate max-priority push, + and failed checks don't count, since an outage says nothing about whether a show exists. If + the whole date goes dark after opening, a separate notice says the shows were withdrawn. The + re-arm itself lives in the base state, not in that notice's patch, so it happens even if the + notice fails to deliver. - **`dateOpenAlerted` rides an alert patch, never the base state.** Otherwise a failed push followed by sessions disappearing could swallow the only notification you'd ever get. - **Open-but-empty counts as closed**, defending against a `200` with no sessions. diff --git a/src/monitor.js b/src/monitor.js index 9c77355..282ca18 100644 --- a/src/monitor.js +++ b/src/monitor.js @@ -2,8 +2,18 @@ // Providers supply normalized sessions; nothing here knows which site they came from. import { localDate, localMinutes } from "./lib.js"; +/** + * Consecutive successful checks a previously announced show must be absent before it is + * forgotten. Counting checks, rather than reacting to one miss, stops a single flaky + * response from turning into a duplicate max-priority push. + */ +export const REARM_AFTER = 3; + export function defaultProviderState() { - return { dateOpenAlerted: false, sidsAlerted: {}, failures: 0, brokenAlerted: false, lastGood: null }; + return { + dateOpenAlerted: false, sidsAlerted: {}, missing: {}, closedTicks: 0, + failures: 0, brokenAlerted: false, lastGood: null, + }; } export function defaultState() { @@ -78,12 +88,53 @@ export function decideProvider(pstate, input, cfg, p) { base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, note, at: now.toISOString() }; } + // Cinemas publish early, pull the shows and re-list them, often under the same session + // ids. Dedupe must not outlive the listing, or the re-listing is silent. So a show that + // stays missing for REARM_AFTER checks is forgotten, and its return alerts again. This + // lives in the base state rather than an alert patch: re-arming must not depend on a + // notification getting through. + const present = new Set(effectiveKind === "open" ? ex.matched.map((s) => s.sid) : []); + const alerted = { ...(pstate.sidsAlerted ?? {}) }; + const missing = { ...(pstate.missing ?? {}) }; + for (const sid of Object.keys(alerted)) { + if (present.has(sid)) { + delete missing[sid]; + } else if ((missing[sid] = (missing[sid] ?? 0) + 1) >= REARM_AFTER) { + delete alerted[sid]; + delete missing[sid]; + } + } + for (const sid of Object.keys(missing)) if (!(sid in alerted)) delete missing[sid]; + base.sidsAlerted = alerted; + base.missing = missing; + + // The whole date going dark after it opened is worth saying out loud: the user may be + // mid-booking and wondering where the shows went. Counted the same way, then re-armed. + if (effectiveKind === "closed" && pstate.dateOpenAlerted) { + base.closedTicks = (pstate.closedTicks ?? 0) + 1; + if (base.closedTicks >= REARM_AFTER) { + base.dateOpenAlerted = false; + base.closedTicks = 0; + alerts.push(tag({ + title: `${cfg.targetDate} shows withdrawn on ${p.label}`, + priority: "3", + tags: "arrows_counterclockwise", + click: p.bookingUrl, + body: `${p.label} is no longer listing shows for ${cfg.targetDate}. The monitor has re-armed, so you'll get a max-priority push again the moment they come back.`, + statePatch: {}, + })); + } + } else if (pstate.closedTicks) { + base.closedTicks = 0; + } + if (effectiveKind === "open") { - const fresh = ex.matched.filter((s) => !pstate.sidsAlerted?.[s.sid]); + const fresh = ex.matched.filter((s) => !alerted[s.sid]); if (fresh.length > 0) { const lines = fresh.map(sessionLine); for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`); - const sidsAlerted = { ...pstate.sidsAlerted }; + // Built from the post-forget map, so the patch can't resurrect a forgotten id. + const sidsAlerted = { ...alerted }; for (const s of fresh) sidsAlerted[s.sid] = true; alerts.push(tag({ title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`, @@ -93,7 +144,7 @@ export function decideProvider(pstate, input, cfg, p) { body: lines.join("\n\n"), statePatch: { sidsAlerted, dateOpenAlerted: true }, })); - } else if (!pstate.dateOpenAlerted && ex.matched.length === 0) { + } else if (!base.dateOpenAlerted && ex.matched.length === 0) { alerts.push(tag({ title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`, priority: "4", diff --git a/test/monitor.test.js b/test/monitor.test.js index b92e6fe..acbbc16 100644 --- a/test/monitor.test.js +++ b/test/monitor.test.js @@ -212,3 +212,93 @@ test("clearing the note is itself a state change, so the status endpoint stops l const after = decideProvider(withNote, { kind: "closed", now: T0 }, CFG, P); assert.equal(after.baseState.lastGood.note, null, "note is dropped once the listing appears"); }); + +// ------------------------------------------------ withdrawn and re-listed shows +// +// Cinemas publish early, pull the shows, then re-list them, often under the same +// session ids. If dedupe outlives the listing, the re-listing (the moment bookings +// really open) is silent. These pin down when a vanished session is forgotten. + +const closed = (now = T0) => ({ kind: "closed", now }); +const tick = (state, input, p = P) => apply(state, decideProvider(state, input, CFG, p)); + +test("a withdrawn session that comes back after 3 missed checks alerts again", () => { + let s = tick(defaultProviderState(), openInput(EX_MATCH)); + assert.ok(s.sidsAlerted["111"], "alerted once"); + for (let i = 0; i < 3; i++) s = tick(s, closed()); + assert.equal(s.sidsAlerted["111"], undefined, "forgotten after 3 checks without it"); + + const back = decideProvider(s, openInput(EX_MATCH), CFG, P); + assert.equal(back.alerts.length, 1, "the re-listing must alert"); + assert.equal(back.alerts[0].priority, "5"); +}); + +test("a session missing for fewer than 3 checks is not re-alerted", () => { + let s = tick(defaultProviderState(), openInput(EX_MATCH)); + s = tick(s, closed()); + s = tick(s, closed()); + const back = decideProvider(s, openInput(EX_MATCH), CFG, P); + assert.equal(back.alerts.length, 0, "a brief flicker in the response is not a new listing"); + assert.deepEqual(apply(s, back).missing, {}, "and the miss counter resets once it is seen again"); +}); + +test("a fully withdrawn date re-arms after 3 closed checks and says so, once", () => { + let s = tick(defaultProviderState(), openInput(EX_MATCH)); + assert.equal(s.dateOpenAlerted, true); + s = tick(s, closed()); + s = tick(s, closed()); + const third = decideProvider(s, closed(), CFG, P); + const notice = third.alerts.find((a) => /withdrawn/i.test(a.title)); + assert.ok(notice, "the user should hear that the shows were pulled"); + assert.ok(notice.title.includes("District")); + assert.ok(Number(notice.priority) < 5, "informational, not a booking alert"); + s = apply(s, third); + assert.equal(s.dateOpenAlerted, false, "re-armed"); + + for (let i = 0; i < 5; i++) { + assert.equal(decideProvider(s, closed(), CFG, P).alerts.length, 0, "the notice is not repeated"); + s = tick(s, closed()); + } +}); + +test("the re-arm does not depend on the withdrawal notice being delivered", () => { + let s = tick(defaultProviderState(), openInput(EX_MATCH)); + s = tick(s, closed()); + s = tick(s, closed()); + const third = decideProvider(s, closed(), CFG, P); + s = third.baseState; // ntfy was down: no alert patches merged + assert.equal(s.dateOpenAlerted, false, "re-arming is not gated on the push"); + assert.equal(s.sidsAlerted["111"], undefined); + assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts[0].priority, "5", + "so a re-listing still gets through even if the notice was lost"); +}); + +test("partial withdrawal forgets only the missing session, with no withdrawal notice", () => { + const two = { ...EX_MATCH, matched: [EX_MATCH.matched[0], { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] }; + let s = tick(defaultProviderState(), openInput(two)); + for (let i = 0; i < 3; i++) { + const d = decideProvider(s, openInput(EX_MATCH), CFG, P); // 222 gone, date still open + assert.equal(d.alerts.filter((a) => /withdrawn/i.test(a.title)).length, 0); + s = apply(s, d); + } + assert.ok(s.sidsAlerted["111"], "the session still listed stays deduped"); + assert.equal(s.sidsAlerted["222"], undefined, "the pulled one is forgotten"); + + const back = decideProvider(s, openInput(two), CFG, P); + assert.equal(back.alerts.length, 1); + assert.ok(back.alerts[0].body.includes("1:15 PM"), "only the returning session is announced"); + assert.ok(!back.alerts[0].body.includes("9:00 AM")); +}); + +test("failed checks do not count as a session being missing", () => { + let s = tick(defaultProviderState(), openInput(EX_MATCH)); + for (let i = 0; i < 5; i++) s = tick(s, { kind: "error", reason: "HTTP 403", now: T0 }); + assert.ok(s.sidsAlerted["111"], "an outage says nothing about whether the show still exists"); + assert.equal(decideProvider(s, openInput(EX_MATCH), CFG, P).alerts.filter((a) => a.priority === "5").length, 0); +}); + +test("waiting on a date that never opened writes no state", () => { + let s = tick(defaultProviderState(), closed()); + const next = decideProvider(s, closed(), CFG, P); + assert.equal(JSON.stringify(apply(s, next)), JSON.stringify(s), "no counters ticking while nothing was ever alerted"); +});