From cfc64e489c04e77d30718942f6c716e036a74803 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 14:24:18 -0600 Subject: [PATCH] feat(announcements): pass `summary` through validator and show under title in banner Adds optional `summary` field on the `Announcement` interface, wires it through the feed validator (capped at 240 chars; over-cap drops the summary but keeps the entry, with a debug log line), and renders it inline after the title in the desktop banner with an em-dash separator and slightly dimmed text so the title stays the primary affordance. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/announcements-poller.test.ts | 81 ++++++++++++++++++++++++++ src/main/announcements-poller.ts | 12 ++++ src/renderer/App.tsx | 5 +- src/shared/state/announcements.test.ts | 37 ++++++++++++ src/shared/state/announcements.ts | 1 + 5 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/main/announcements-poller.test.ts b/src/main/announcements-poller.test.ts index 6b78251e..3beb2648 100644 --- a/src/main/announcements-poller.test.ts +++ b/src/main/announcements-poller.test.ts @@ -1,4 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const logSpy = vi.fn() +vi.mock('./debug', () => ({ + log: (...args: unknown[]) => logSpy(...args), + formatErr: (err: unknown) => (err instanceof Error ? err.message : String(err)) +})) + import { fetchAnnouncementsFeed } from './announcements-poller' const originalFetch = globalThis.fetch @@ -16,6 +23,7 @@ function mockFetch(body: unknown, init: { ok?: boolean; status?: number } = {}): describe('fetchAnnouncementsFeed', () => { beforeEach(() => { globalThis.fetch = originalFetch + logSpy.mockClear() }) afterEach(() => { globalThis.fetch = originalFetch @@ -101,4 +109,77 @@ describe('fetchAnnouncementsFeed', () => { mockFetch({}, { ok: false, status: 503 }) await expect(fetchAnnouncementsFeed('http://mock')).rejects.toThrow(/HTTP 503/) }) + + it('includes summary when it is a non-empty string within the length cap', async () => { + mockFetch({ + announcements: [ + { + id: 'ok', + title: 'Hello', + href: 'https://x.example/y', + publishedAt: '2026-05-20T00:00:00Z', + summary: 'A short blurb about the post.' + } + ] + }) + const { items } = await fetchAnnouncementsFeed('http://mock') + expect(items).toHaveLength(1) + expect(items[0].summary).toBe('A short blurb about the post.') + }) + + it('ignores non-string summary and keeps the entry', async () => { + mockFetch({ + announcements: [ + { + id: 'ok', + title: 'Hello', + href: 'https://x.example/y', + publishedAt: '2026-05-20T00:00:00Z', + summary: 12345 + } + ] + }) + const { items } = await fetchAnnouncementsFeed('http://mock') + expect(items).toHaveLength(1) + expect(items[0].summary).toBeUndefined() + }) + + it('omits an empty-string summary', async () => { + mockFetch({ + announcements: [ + { + id: 'ok', + title: 'Hello', + href: 'https://x.example/y', + publishedAt: '2026-05-20T00:00:00Z', + summary: ' ' + } + ] + }) + const { items } = await fetchAnnouncementsFeed('http://mock') + expect(items).toHaveLength(1) + expect(items[0].summary).toBeUndefined() + }) + + it('drops a summary over 240 chars but keeps the entry, logging the case', async () => { + const longSummary = 'x'.repeat(300) + mockFetch({ + announcements: [ + { + id: 'ok', + title: 'Hello', + href: 'https://x.example/y', + publishedAt: '2026-05-20T00:00:00Z', + summary: longSummary + } + ] + }) + const { items } = await fetchAnnouncementsFeed('http://mock') + expect(items).toHaveLength(1) + expect(items[0].summary).toBeUndefined() + const summaryLogs = logSpy.mock.calls.filter( + (call) => call[0] === 'announcements' && String(call[1]).includes('summary') + ) + expect(summaryLogs.length).toBeGreaterThan(0) + }) }) diff --git a/src/main/announcements-poller.ts b/src/main/announcements-poller.ts index 614d3d77..d1844e03 100644 --- a/src/main/announcements-poller.ts +++ b/src/main/announcements-poller.ts @@ -5,12 +5,14 @@ import type { Announcement } from '../shared/state/announcements' const FEED_URL = 'https://harness.mikelyons.org/announcements.json' const POLL_INTERVAL_MS = 6 * 60 * 60 * 1000 const FETCH_TIMEOUT_MS = 10_000 +const MAX_SUMMARY_LEN = 240 interface RawAnnouncement { id?: unknown title?: unknown href?: unknown publishedAt?: unknown + summary?: unknown expiresAt?: unknown } @@ -41,6 +43,16 @@ function validateEntry(raw: unknown): Announcement | null { href: r.href, publishedAt: r.publishedAt } + if (typeof r.summary === 'string' && r.summary.trim()) { + if (r.summary.length > MAX_SUMMARY_LEN) { + log( + 'announcements', + `summary on ${r.id} is ${r.summary.length} chars (max ${MAX_SUMMARY_LEN}) — dropping summary, keeping entry` + ) + } else { + cleaned.summary = r.summary + } + } if (typeof r.expiresAt === 'string' && !Number.isNaN(Date.parse(r.expiresAt))) { cleaned.expiresAt = r.expiresAt } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b573cc7a..42b88fc2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1153,10 +1153,13 @@ const setQuestStep = useCallback((next: QuestStep) => { backend.openExternal(activeAnnouncement.href)} - className="underline hover:text-accent cursor-pointer no-drag" + className="font-semibold underline hover:text-accent cursor-pointer no-drag" > {activeAnnouncement.title} + {activeAnnouncement.summary && ( + — {activeAnnouncement.summary} + )}