From 83465bdca70d5236984fe5044c260f40d3b6142f Mon Sep 17 00:00:00 2001 From: Anthony Baldwin <6219998+anthonybaldwin@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:32:46 -0700 Subject: [PATCH] fix(instatus): anchor the year heuristic on the entry's earliest timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Instatus Atom feed omits the year from each update's `` timestamp, so the parser reconstructed it from `` and rolled forward a year for anything landing more than 24h before it. That assumes `` is a lower bound on the entry's updates, which holds for incidents but not for scheduled maintenance: there `` is the scheduled START, while the announcement update is posted days or weeks earlier (and `` carries that time). An early announcement was therefore dated a year ahead, which made it sort last, so the entry's status came from the announcement instead of the final "Completed" update. `resolved_at` stayed null and the maintenance was counted open forever — it kept a pinned card ungreyed and inflated the presence ticker's active-incident count. Kagi's Jul 19 database maintenance (announced Jul 8) has been stuck open since Jul 19. Anchor on the earlier of `` and `` instead. Verified against the live kagi and perplexity feeds: this changes exactly one update timestamp (the stuck announcement) and drops the false open incident, with no other timestamp shifting. --- src/providers/instatus.test.ts | 44 +++++++++++++++++++++++++++++++ src/providers/instatus.ts | 47 ++++++++++++++++++++++++++-------- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/providers/instatus.test.ts b/src/providers/instatus.test.ts index 8cb7965..58678f3 100644 --- a/src/providers/instatus.test.ts +++ b/src/providers/instatus.test.ts @@ -154,6 +154,50 @@ describe("parseInstatusAtom", () => { }); }); +// A scheduled maintenance announced well before it runs: is the +// scheduled START (Jul 19) while is the announcement (Jul 8), so the +// first update legitimately predates . Anchoring the year-rollover +// heuristic on alone pushed that update into the NEXT year, which +// made it sort last and left the entry looking permanently in_progress. +const ATOM_MAINTENANCE_ANNOUNCED_EARLY = ` + + + tag:status.kagi.com,2005:Maintenance/cmrbr5ynx05vc0kp9eoawdsbu + 2026-07-19T06:00:00.000+00:00 + 2026-07-08T07:24:48.978+00:00 + + Database maintenance for Kagi Search + Type: Maintenance

+

Duration: 8 minutes

+

Jul 8, 07:24:48 GMT+0
Identified - + We plan to perform a minor upgrade at this time..

+

Jul 19, 06:00:01 GMT+0
Identified - + Maintenance is now in progress.

+

Jul 19, 06:07:55 GMT+0
Completed - + Maintenance has completed successfully..

+ ]]>
+
+
`; + +describe("parseInstatusAtom — maintenance announced before its scheduled start", () => { + const [maint] = parseInstatusAtom(ATOM_MAINTENANCE_ANNOUNCED_EARLY); + + test("keeps the announcement in the published year instead of rolling it forward", () => { + expect(maint.incident_updates.map((u) => u.created_at)).toEqual([ + "2026-07-08T07:24:48.000Z", + "2026-07-19T06:00:01.000Z", + "2026-07-19T06:07:55.000Z", + ]); + }); + + test("resolves on the Completed update rather than staying in_progress forever", () => { + expect(maint.status).toBe("resolved"); + expect(maint.resolved_at).toBe("2026-07-19T06:07:55.000Z"); + expect(maint.created_at).toBe("2026-07-08T07:24:48.000Z"); + }); +}); + import { mapInstatusSummary, instatusPageStatus, type InstatusSummaryJson } from "./instatus"; const SUMMARY_ACTIVE: InstatusSummaryJson = { diff --git a/src/providers/instatus.ts b/src/providers/instatus.ts index 826e292..a8fd34a 100644 --- a/src/providers/instatus.ts +++ b/src/providers/instatus.ts @@ -73,11 +73,16 @@ const MONTHS: Record = { /** * Parse an Instatus feed `` timestamp like "Jun 5, 01:40:38 GMT+0" - * (no year) into an ISO-8601 UTC string. The year is taken from `publishedIso`; - * if the resulting date lands before `publishedIso` (beyond a small grace - * window), it belongs to the following year (incident spanning a year boundary). + * (no year) into an ISO-8601 UTC string. The year is taken from `anchorIso`; + * if the resulting date lands before `anchorIso` (beyond a small grace + * window), it belongs to the following year (entry spanning a year boundary). + * + * `anchorIso` MUST be a lower bound on the entry's updates — see + * `entryAnchor()`. Anchoring on `` alone is wrong for scheduled + * maintenance, where `` is the scheduled start and the announcement + * update legitimately precedes it. */ -export function parseUpdateTimestamp(small: string, publishedIso: string): string | null { +export function parseUpdateTimestamp(small: string, anchorIso: string): string | null { const m = small.match(/([A-Za-z]{3})\s+(\d{1,2})\s*,\s+(\d{1,2}):(\d{2}):(\d{2})/); if (!m) return null; const month = MONTHS[m[1].toLowerCase()]; @@ -87,13 +92,13 @@ export function parseUpdateTimestamp(small: string, publishedIso: string): strin const min = Number(m[4]); const sec = Number(m[5]); - const published = new Date(publishedIso); - const year = Number.isNaN(published.getTime()) ? new Date(0).getUTCFullYear() : published.getUTCFullYear(); + const anchor = new Date(anchorIso); + const year = Number.isNaN(anchor.getTime()) ? new Date(0).getUTCFullYear() : anchor.getUTCFullYear(); let date = new Date(Date.UTC(year, month, day, hour, min, sec)); - // The feed omits the year, so an update that lands before `published` (with a + // The feed omits the year, so an update that lands before `anchor` (with a // ~24h grace window for rounding) must belong to the following year (Dec→Jan). - if (!Number.isNaN(published.getTime()) && date.getTime() < published.getTime() - 24 * 3600 * 1000) { + if (!Number.isNaN(anchor.getTime()) && date.getTime() < anchor.getTime() - 24 * 3600 * 1000) { date = new Date(Date.UTC(year + 1, month, day, hour, min, sec)); } if (Number.isNaN(date.getTime())) return null; @@ -122,6 +127,24 @@ function firstMatch(source: string, re: RegExp): string | undefined { return m ? m[1] : undefined; } +/** + * Lower bound for an entry's update timestamps, used to resolve the year the + * feed omits. `` is NOT that bound on its own: for a scheduled + * maintenance it is the scheduled start, while the announcement update can be + * days or weeks earlier (`` carries that announcement time). Taking + * the earlier of the two keeps early announcements in their real year — dating + * one a year ahead makes it sort last, so the entry's derived status comes from + * the announcement instead of the final "Completed" and the incident never + * looks resolved. + */ +function entryAnchor(published: string, updated: string | undefined): string { + const publishedMs = new Date(published).getTime(); + const updatedMs = updated === undefined ? NaN : new Date(updated).getTime(); + if (Number.isNaN(updatedMs)) return published; + if (Number.isNaN(publishedMs)) return updated as string; + return updatedMs < publishedMs ? (updated as string) : published; +} + type ParsedUpdate = { status: string; body: string; created_at: string }; /** @@ -130,7 +153,7 @@ type ParsedUpdate = { status: string; body: string; created_at: string }; * `STATUS -` marker. Header blocks (`Type: …`) * are skipped because their `` text ends in a colon. */ -function parseUpdateBlocks(content: string, publishedIso: string, isMaintenance: boolean): ParsedUpdate[] { +function parseUpdateBlocks(content: string, anchorIso: string, isMaintenance: boolean): ParsedUpdate[] { const updates: ParsedUpdate[] = []; const blockRe = /

\s*([\s\S]*?)<\/small>\s*\s*([^<]+)<\/strong>\s*-\s*([\s\S]*?)<\/p>/g; let m: RegExpExecArray | null; @@ -138,7 +161,7 @@ function parseUpdateBlocks(content: string, publishedIso: string, isMaintenance: const small = plainText(m[1]); const statusWord = m[2].trim(); if (statusWord.endsWith(":")) continue; // header block, not an update - const created = parseUpdateTimestamp(small, publishedIso) ?? publishedIso; + const created = parseUpdateTimestamp(small, anchorIso) ?? anchorIso; const status = isMaintenance ? canonicalMaintenanceStatus(statusWord) : canonicalIncidentStatus(statusWord); @@ -159,6 +182,8 @@ export function parseInstatusAtom(xml: string): Incident[] { const name = decodeEntities(firstMatch(entry, /([\s\S]*?)<\/title>/)?.trim() ?? "Untitled incident"); const published = firstMatch(entry, /<published>([\s\S]*?)<\/published>/)?.trim() ?? new Date(0).toISOString(); + const updated = firstMatch(entry, /<updated>([\s\S]*?)<\/updated>/)?.trim(); + const anchor = entryAnchor(published, updated); const shortlink = firstMatch(entry, /<link[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/); const contentRaw = firstMatch(entry, /<content[^>]*>([\s\S]*?)<\/content>/) ?? ""; @@ -166,7 +191,7 @@ export function parseInstatusAtom(xml: string): Incident[] { const typeField = firstMatch(content, /<strong>\s*Type:\s*<\/strong>\s*([A-Za-z]+)/); const isMaintenance = (typeField ?? "").toLowerCase() === "maintenance"; - const updates = parseUpdateBlocks(content, published, isMaintenance); + const updates = parseUpdateBlocks(content, anchor, isMaintenance); const mappedUpdates: IncidentUpdate[] = updates.map((u) => ({ id: `${id}:${u.created_at}`, status: u.status,