From 420fba88be4f78b7816afd0660c26afcf18c7553 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 12:00:18 +0200 Subject: [PATCH 1/3] feat(feed-directory): last_result demotion and chrome Support catalog_version 2 only with required last_result; demote empty/error in sort, show ambient indicators, and warn before subscribe on failing rows. --- AGENTS.md | 2 +- CONTEXT.md | 20 +++++- .../adapters/catalog-api.test.ts | 64 +++++++++++++++++-- .../feed-directory/adapters/catalog-api.ts | 43 +++++++++++-- .../feed-directory/app/FeedDirectoryApp.ts | 30 ++++++++- .../app/directory-state.test.ts | 1 + .../feed-directory/domain/filters.test.ts | 35 +++++++++- .../feed-directory/domain/filters.ts | 3 + .../feed-directory/domain/last-result.test.ts | 27 ++++++++ .../feed-directory/domain/last-result.ts | 25 ++++++++ .../feed-directory/domain/opml.test.ts | 1 + src/components/feed-directory/domain/types.ts | 10 +++ .../feed-directory/feed-directory.css | 27 ++++++++ src/components/feed-directory/ui/render.ts | 23 ++++++- 14 files changed, 288 insertions(+), 23 deletions(-) create mode 100644 src/components/feed-directory/domain/last-result.test.ts create mode 100644 src/components/feed-directory/domain/last-result.ts diff --git a/AGENTS.md b/AGENTS.md index 1d783489..2443448d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ If a cross-repo behavior changed but upstream is not updated yet, document the g - The browse UI is a **thin client**: fetch catalog JSON from the active instance, render rows client-side, build RSS links from each entry's `path`. - Do not reintroduce `bin/data-update`, `src/data/configs.json`, or a `html2rss-configs` gem dependency in this repo. -- Wire shape v1 is defined in `html2rss-web` request specs and OpenAPI (`catalog_version`, `parameters.schema`, `parameters.defaults`). +- Wire shape v2 is defined in `html2rss-web` request specs and OpenAPI (`catalog_version: 2`, required `last_result`, `meta.starters`). The browse client supports **`[2]` only** and fails closed on v1. - When the instance is unreachable or returns `404` with `catalog_disabled`, show an error state — no static fallback list. - **Wire parsing only in** `src/components/feed-directory/adapters/catalog-api.ts`. Domain modules must not parse API envelopes or wire rows. - See `CONTEXT.md` for glossary (`FeedDirectoryEntry`, catalog seam, instance persistence contract). diff --git a/CONTEXT.md b/CONTEXT.md index dbe04297..98010c75 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,16 +18,30 @@ Normalized domain type for one catalog row after wire parsing. Required fields o | `title`, `summary`, `topics` | Directory metadata from YAML | | `channelUrl`, `language` | Channel metadata | | `parameterSchema`, `parameterDefaults` | Dynamic feed parameters | +| `lastResult` | Instance last-known scrape outcome (see below) | + +## LastResult + +Required ambient signal from catalog_version **2**. Closed set of `state` values only — do not invent green/yellow/red domain enums; map state to UI chrome in `ui/`. + +| `state` | Meaning | Browse UX | +| --------- | ------------------------------------------------------------ | -------------------------------------------- | +| `ok` | Last directory-defaults scrape succeeded | Ambient “Last scrape ok” indicator | +| `empty` | Last scrape returned no items | Demote in sort; warn before subscribe; badge | +| `error` | Last scrape failed | Demote in sort; warn before subscribe; badge | +| `unknown` | Never scraped with directory defaults on this process (cold) | Neutral — no badge; sorts with non-failing | + +Wire fields: `code` (string \| null), `at` (ISO timestamp \| null). Missing or invalid `last_result` on a row fails closed (row dropped). ## Catalog seam The boundary between the instance API and domain logic: -- **Wire:** `GET /api/v1/configs` envelope (`success`, `data.configs`, `meta.catalog_version`) -- **Adapter:** `adapters/catalog-api.ts` — fetch, envelope validation, row validation, version gate (supported: `[1]`) +- **Wire:** `GET /api/v1/configs` envelope (`success`, `data.configs`, `meta.catalog_version`, `meta.starters`) +- **Adapter:** `adapters/catalog-api.ts` — fetch, envelope validation, row validation, version gate (supported: **`[2]` only**; v1 fail closed) - **Domain:** `FeedDirectoryEntry[]` consumed by filters, OPML build, and render -Wire parsing must stay in `adapters/catalog-api.ts` only. +Wire parsing must stay in `adapters/catalog-api.ts` only. `meta.starters` is parsed for forward compatibility; browse does not render a featured strip today. ## Instance persistence contract diff --git a/src/components/feed-directory/adapters/catalog-api.test.ts b/src/components/feed-directory/adapters/catalog-api.test.ts index 883d6959..8b4d936c 100644 --- a/src/components/feed-directory/adapters/catalog-api.test.ts +++ b/src/components/feed-directory/adapters/catalog-api.test.ts @@ -17,6 +17,7 @@ const validEnvelope = { channel: { url: 'https://www.anthropic.com/news', language: 'en' }, directory: { title: 'Anthropic — News', summary: 'Announcements.', topics: ['news'] }, parameters: { schema: {}, defaults: {} }, + last_result: { state: 'ok', code: null, at: '2026-08-29T08:00:00Z' }, }, { id: 'bbc.co.uk/available_episodes', @@ -24,11 +25,39 @@ const validEnvelope = { channel: { url: 'https://www.bbc.co.uk/programmes/%s/episodes/player', language: 'en-GB' }, directory: { title: 'BBC Sounds — Programme episodes', summary: 'Episodes.', topics: ['media'] }, parameters: { schema: { id: { type: 'string' } }, defaults: { id: 'b006wkfp' } }, + last_result: { state: 'unknown', code: null, at: null }, + }, + { + id: 'example.com/broken-scrape', + path: '/example.com/broken-scrape.rss', + channel: { url: 'https://example.com/broken', language: 'en' }, + directory: { title: 'Broken', summary: '', topics: [] }, + parameters: { schema: {}, defaults: {} }, + last_result: { state: 'error', code: 'EXTRACTION_EMPTY', at: '2026-08-29T09:00:00Z' }, }, { id: 'broken' }, + { + id: 'missing.last/result', + path: '/missing.last/result.rss', + channel: { url: 'https://missing.example/', language: 'en' }, + directory: { title: 'Missing last_result', summary: '', topics: [] }, + parameters: { schema: {}, defaults: {} }, + }, + { + id: 'invalid.last/result', + path: '/invalid.last/result.rss', + channel: { url: 'https://invalid.example/', language: 'en' }, + directory: { title: 'Invalid last_result', summary: '', topics: [] }, + parameters: { schema: {}, defaults: {} }, + last_result: { state: 'green', code: null, at: null }, + }, ], }, - meta: { total: 2, catalog_version: 1 }, + meta: { + total: 3, + catalog_version: 2, + starters: ['anthropic.com/news', 'bbc.co.uk/available_episodes'], + }, }; function mockFetch(response: Partial & Pick): typeof fetch { @@ -36,7 +65,7 @@ function mockFetch(response: Partial & Pick): type } describe('fetchCatalogResponse', () => { - it('maps valid envelope rows and drops invalid ones', async () => { + it('maps valid v2 envelope rows and drops invalid ones', async () => { const fetchImpl = mockFetch({ ok: true, status: 200, @@ -45,16 +74,27 @@ describe('fetchCatalogResponse', () => { const { entries, meta } = await fetchCatalogResponse('https://example.test/', fetchImpl); - expect(entries).toHaveLength(2); + expect(entries).toHaveLength(3); expect(entries[0]).toMatchObject({ id: 'anthropic.com/news', siteKey: 'anthropic.com', title: 'Anthropic — News', topics: ['news'], language: 'en', + lastResult: { state: 'ok', code: null, at: '2026-08-29T08:00:00Z' }, }); expect(entries[1]?.parameterDefaults).toEqual({ id: 'b006wkfp' }); - expect(meta).toEqual({ total: 2, catalogVersion: 1 }); + expect(entries[1]?.lastResult).toEqual({ state: 'unknown', code: null, at: null }); + expect(entries[2]?.lastResult).toEqual({ + state: 'error', + code: 'EXTRACTION_EMPTY', + at: '2026-08-29T09:00:00Z', + }); + expect(meta).toEqual({ + total: 3, + catalogVersion: 2, + starters: ['anthropic.com/news', 'bbc.co.uk/available_episodes'], + }); }); it('throws disabled on 404', async () => { @@ -75,13 +115,27 @@ describe('fetchCatalogResponse', () => { ); }); + it('throws unsupported version for catalog_version 1 (fail closed)', async () => { + const fetchImpl = mockFetch({ + ok: true, + status: 200, + json: async () => ({ + ...validEnvelope, + meta: { total: 3, catalog_version: 1, starters: [] }, + }), + } as Response); + await expect(fetchCatalogResponse('https://example.test/', fetchImpl)).rejects.toBeInstanceOf( + CatalogUnsupportedVersionError + ); + }); + it('throws unsupported version when catalog_version is not supported', async () => { const fetchImpl = mockFetch({ ok: true, status: 200, json: async () => ({ ...validEnvelope, - meta: { total: 2, catalog_version: 99 }, + meta: { total: 3, catalog_version: 99, starters: [] }, }), } as Response); await expect(fetchCatalogResponse('https://example.test/', fetchImpl)).rejects.toBeInstanceOf( diff --git a/src/components/feed-directory/adapters/catalog-api.ts b/src/components/feed-directory/adapters/catalog-api.ts index 585290b6..8a441773 100644 --- a/src/components/feed-directory/adapters/catalog-api.ts +++ b/src/components/feed-directory/adapters/catalog-api.ts @@ -1,7 +1,8 @@ import { siteKeyFromId } from '../domain/entry'; -import type { CatalogLoadError, FeedDirectoryEntry } from '../domain/types'; +import { isLastResultState } from '../domain/last-result'; +import type { CatalogLoadError, FeedDirectoryEntry, LastResult } from '../domain/types'; -const SUPPORTED_CATALOG_VERSIONS = [1] as const; +const SUPPORTED_CATALOG_VERSIONS = [2] as const; interface CatalogWireEntry { id?: unknown; @@ -9,12 +10,13 @@ interface CatalogWireEntry { channel?: { url?: unknown; language?: unknown }; directory?: { title?: unknown; summary?: unknown; topics?: unknown }; parameters?: { schema?: unknown; defaults?: unknown }; + last_result?: unknown; } interface CatalogEnvelope { success?: unknown; data?: { configs?: unknown }; - meta?: { total?: unknown; catalog_version?: unknown }; + meta?: { total?: unknown; catalog_version?: unknown; starters?: unknown }; } export class CatalogDisabledError extends Error { @@ -80,6 +82,24 @@ function parseParameterDefaults(value: unknown): Readonly return defaults; } +/** Fail closed: missing or invalid last_result rejects the row. */ +function parseLastResult(value: unknown): LastResult | null { + if (!isRecord(value)) return null; + if (!isLastResultState(value.state)) return null; + + const code = value.code; + if (!(code === null || typeof code === 'string')) return null; + + const at = value.at; + if (!(at === null || typeof at === 'string')) return null; + + return { + state: value.state, + code: code === null || code.trim() === '' ? null : code, + at: at === null || at.trim() === '' ? null : at, + }; +} + function parseCatalogEntries(configs: unknown): FeedDirectoryEntry[] { if (!Array.isArray(configs)) return []; @@ -90,7 +110,8 @@ function parseCatalogEntries(configs: unknown): FeedDirectoryEntry[] { const id = asString(wire.id); const path = asString(wire.path); const channelUrl = asString(wire.channel?.url); - if (!id || !path || !channelUrl) continue; + const lastResult = parseLastResult(wire.last_result); + if (!id || !path || !channelUrl || !lastResult) continue; entries.push({ id, @@ -103,6 +124,7 @@ function parseCatalogEntries(configs: unknown): FeedDirectoryEntry[] { language: asString(wire.channel?.language) ?? '', parameterSchema: parseParameterSchema(wire.parameters?.schema), parameterDefaults: parseParameterDefaults(wire.parameters?.defaults), + lastResult, }); } @@ -120,9 +142,15 @@ function parseCatalogVersion(meta: CatalogEnvelope['meta']): number { return version; } +export interface CatalogMeta { + total: number; + catalogVersion: number; + starters: readonly string[]; +} + function parseCatalogEnvelope(payload: unknown): { entries: FeedDirectoryEntry[]; - meta: { total: number; catalogVersion: number }; + meta: CatalogMeta; } { if (!isRecord(payload)) { throw new CatalogInvalidEnvelopeError(); @@ -137,17 +165,18 @@ function parseCatalogEnvelope(payload: unknown): { const catalogVersion = parseCatalogVersion(envelope.meta); const totalRaw = envelope.meta?.total; const total = typeof totalRaw === 'number' && Number.isFinite(totalRaw) ? totalRaw : entries.length; + const starters = parseStringArray(envelope.meta?.starters); return { entries, - meta: { total, catalogVersion }, + meta: { total, catalogVersion, starters }, }; } export async function fetchCatalogResponse( instanceUrl: string, fetchImpl: typeof fetch = fetch -): Promise<{ entries: FeedDirectoryEntry[]; meta: { total: number; catalogVersion: number } }> { +): Promise<{ entries: FeedDirectoryEntry[]; meta: CatalogMeta }> { const catalogUrl = new URL('/api/v1/configs', instanceUrl).toString(); let response: Response; diff --git a/src/components/feed-directory/app/FeedDirectoryApp.ts b/src/components/feed-directory/app/FeedDirectoryApp.ts index 52c8d350..6396ae49 100644 --- a/src/components/feed-directory/app/FeedDirectoryApp.ts +++ b/src/components/feed-directory/app/FeedDirectoryApp.ts @@ -8,9 +8,11 @@ import { } from '../adapters/browser-storage'; import { downloadOpml } from '../adapters/browser-download'; import { buildFeedUrl } from '../domain/feed-url'; +import { isFailingLastResult } from '../domain/last-result'; import { buildOpmlDocument } from '../domain/opml'; import { normalizeFilterLanguage } from '../domain/language'; import { debounce } from '../lib/debounce'; +import type { FeedDirectoryEntry } from '../domain/types'; import { renderFeedDirectory } from '../ui/render'; import { applyFilterPatch, @@ -182,6 +184,15 @@ export class FeedDirectoryApp { this.render(); break; } + case 'open-feed': { + const entryId = actionEl.dataset.entryId; + const entry = this.findEntry(entryId); + if (!entry) return; + if (isFailingLastResult(entry.lastResult) && !this.confirmFailingSubscribe(entry)) { + event.preventDefault(); + } + break; + } case 'copy-feed': void this.copyFeed(actionEl.dataset.entryId); break; @@ -193,6 +204,19 @@ export class FeedDirectoryApp { } } + private findEntry(entryId: string | undefined): FeedDirectoryEntry | undefined { + if (!entryId) return undefined; + return this.state.entries.find((item) => item.id === entryId); + } + + private confirmFailingSubscribe(entry: FeedDirectoryEntry): boolean { + const detail = + entry.lastResult.state === 'empty' + ? 'The last known scrape on this instance returned no items.' + : 'The last known scrape on this instance failed.'; + return window.confirm(`${detail} Feeds that recently failed often fail again. Subscribe anyway?`); + } + private async applyInstance(): Promise { const normalized = normalizeInstanceUrl(this.state.instanceDraft); if (!normalized) { @@ -217,9 +241,9 @@ export class FeedDirectoryApp { } private async copyFeed(entryId: string | undefined): Promise { - if (!entryId) return; - const entry = this.state.entries.find((item) => item.id === entryId); - if (!entry) return; + const entry = this.findEntry(entryId); + if (!entry || !entryId) return; + if (isFailingLastResult(entry.lastResult) && !this.confirmFailingSubscribe(entry)) return; const url = buildFeedUrl(this.state.instanceUrl, entry, this.state.parametersById[entryId] ?? {}); try { diff --git a/src/components/feed-directory/app/directory-state.test.ts b/src/components/feed-directory/app/directory-state.test.ts index 19adab01..0fdf744a 100644 --- a/src/components/feed-directory/app/directory-state.test.ts +++ b/src/components/feed-directory/app/directory-state.test.ts @@ -14,6 +14,7 @@ const entry = (id: string, title: string): FeedDirectoryEntry => ({ language: '', parameterSchema: {}, parameterDefaults: {}, + lastResult: { state: 'unknown', code: null, at: null }, }); describe('applyFilterPatch', () => { diff --git a/src/components/feed-directory/domain/filters.test.ts b/src/components/feed-directory/domain/filters.test.ts index 36e11857..b554af91 100644 --- a/src/components/feed-directory/domain/filters.test.ts +++ b/src/components/feed-directory/domain/filters.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_FILTER_STATE, extractFacets, filterEntries, fuzzyMatch, sortEntries } from './filters'; -import type { FeedDirectoryEntry } from './types'; +import type { FeedDirectoryEntry, LastResult } from './types'; + +const unknownResult: LastResult = { state: 'unknown', code: null, at: null }; const baseEntry = ( overrides: Partial & Pick @@ -14,6 +16,7 @@ const baseEntry = ( language: overrides.language ?? '', parameterSchema: overrides.parameterSchema ?? {}, parameterDefaults: overrides.parameterDefaults ?? {}, + lastResult: overrides.lastResult ?? unknownResult, ...overrides, }); @@ -63,6 +66,36 @@ describe('sortEntries', () => { 'z.example/feed', ]); }); + + it('demotes empty and error below ok and unknown', () => { + const error = baseEntry({ + id: 'a.example/error', + title: 'Alpha error', + lastResult: { state: 'error', code: 'X', at: null }, + }); + const empty = baseEntry({ + id: 'b.example/empty', + title: 'Beta empty', + lastResult: { state: 'empty', code: null, at: null }, + }); + const unknown = baseEntry({ + id: 'c.example/unknown', + title: 'Charlie unknown', + lastResult: { state: 'unknown', code: null, at: null }, + }); + const ok = baseEntry({ + id: 'd.example/ok', + title: 'Delta ok', + lastResult: { state: 'ok', code: null, at: '2026-08-29T08:00:00Z' }, + }); + + expect(sortEntries([error, empty, unknown, ok], 'title').map((entry) => entry.id)).toEqual([ + 'd.example/ok', + 'c.example/unknown', + 'b.example/empty', + 'a.example/error', + ]); + }); }); describe('extractFacets', () => { diff --git a/src/components/feed-directory/domain/filters.ts b/src/components/feed-directory/domain/filters.ts index e7f1cb1b..cf2aa826 100644 --- a/src/components/feed-directory/domain/filters.ts +++ b/src/components/feed-directory/domain/filters.ts @@ -1,5 +1,6 @@ import type { CatalogFacets, FeedDirectoryEntry, FilterState, SortKey } from './types'; import { baseLanguageCode, languageMatches } from './language'; +import { lastResultSortRank } from './last-result'; export const PAGE_SIZE = 25; @@ -81,6 +82,8 @@ export function filterEntries(entries: FeedDirectoryEntry[], filters: FilterStat export function sortEntries(entries: FeedDirectoryEntry[], sort: SortKey): FeedDirectoryEntry[] { const sorted = [...entries]; sorted.sort((a, b) => { + const byResult = lastResultSortRank(a.lastResult.state) - lastResultSortRank(b.lastResult.state); + if (byResult !== 0) return byResult; if (sort === 'site') { return a.siteKey.localeCompare(b.siteKey); } diff --git a/src/components/feed-directory/domain/last-result.test.ts b/src/components/feed-directory/domain/last-result.test.ts new file mode 100644 index 00000000..7cc97e08 --- /dev/null +++ b/src/components/feed-directory/domain/last-result.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { isFailingLastResult, isLastResultState, lastResultSortRank } from './last-result'; + +describe('lastResultSortRank', () => { + it('orders ok, unknown, empty, error', () => { + expect(lastResultSortRank('ok')).toBeLessThan(lastResultSortRank('unknown')); + expect(lastResultSortRank('unknown')).toBeLessThan(lastResultSortRank('empty')); + expect(lastResultSortRank('empty')).toBeLessThan(lastResultSortRank('error')); + }); +}); + +describe('isFailingLastResult', () => { + it('treats empty and error as failing', () => { + expect(isFailingLastResult({ state: 'empty', code: null, at: null })).toBe(true); + expect(isFailingLastResult({ state: 'error', code: 'X', at: null })).toBe(true); + expect(isFailingLastResult({ state: 'ok', code: null, at: null })).toBe(false); + expect(isFailingLastResult({ state: 'unknown', code: null, at: null })).toBe(false); + }); +}); + +describe('isLastResultState', () => { + it('accepts only the closed set', () => { + expect(isLastResultState('ok')).toBe(true); + expect(isLastResultState('green')).toBe(false); + expect(isLastResultState(null)).toBe(false); + }); +}); diff --git a/src/components/feed-directory/domain/last-result.ts b/src/components/feed-directory/domain/last-result.ts new file mode 100644 index 00000000..0d304f05 --- /dev/null +++ b/src/components/feed-directory/domain/last-result.ts @@ -0,0 +1,25 @@ +import type { LastResult, LastResultState } from './types'; + +const LAST_RESULT_STATES = new Set(['ok', 'empty', 'error', 'unknown']); + +/** Lower rank sorts first. Demotes empty/error below ok/unknown. */ +export function lastResultSortRank(state: LastResultState): number { + switch (state) { + case 'ok': + return 0; + case 'unknown': + return 1; + case 'empty': + return 2; + case 'error': + return 3; + } +} + +export function isFailingLastResult(lastResult: LastResult): boolean { + return lastResult.state === 'empty' || lastResult.state === 'error'; +} + +export function isLastResultState(value: unknown): value is LastResultState { + return typeof value === 'string' && LAST_RESULT_STATES.has(value as LastResultState); +} diff --git a/src/components/feed-directory/domain/opml.test.ts b/src/components/feed-directory/domain/opml.test.ts index 07b29ac2..e1fd1a0d 100644 --- a/src/components/feed-directory/domain/opml.test.ts +++ b/src/components/feed-directory/domain/opml.test.ts @@ -13,6 +13,7 @@ const entry: FeedDirectoryEntry = { language: 'en', parameterSchema: {}, parameterDefaults: {}, + lastResult: { state: 'unknown', code: null, at: null }, }; describe('buildOpmlDocument', () => { diff --git a/src/components/feed-directory/domain/types.ts b/src/components/feed-directory/domain/types.ts index ba106299..307d7f69 100644 --- a/src/components/feed-directory/domain/types.ts +++ b/src/components/feed-directory/domain/types.ts @@ -1,3 +1,12 @@ +/** Closed set from catalog wire `last_result.state` (catalog_version 2). */ +export type LastResultState = 'ok' | 'empty' | 'error' | 'unknown'; + +export interface LastResult { + state: LastResultState; + code: string | null; + at: string | null; +} + export interface FeedDirectoryEntry { id: string; path: string; @@ -9,6 +18,7 @@ export interface FeedDirectoryEntry { language: string; parameterSchema: Readonly>; parameterDefaults: Readonly>; + lastResult: LastResult; } export type SortKey = 'title' | 'site'; diff --git a/src/components/feed-directory/feed-directory.css b/src/components/feed-directory/feed-directory.css index a4db4bbc..9c6ffcdf 100644 --- a/src/components/feed-directory/feed-directory.css +++ b/src/components/feed-directory/feed-directory.css @@ -399,6 +399,33 @@ white-space: nowrap; } +.fd-result { + display: inline-flex; + align-items: center; + padding: var(--fd-pad-badge); + border-radius: var(--fd-radius-sm); + font-size: var(--sl-text-xs); + line-height: 1.35; + white-space: nowrap; + border: 1px solid var(--fd-border-muted); + color: var(--fd-muted); +} + +.fd-result-ok { + border-color: color-mix(in srgb, var(--fd-success) 35%, var(--fd-border)); + color: var(--fd-success); +} + +.fd-result-empty, +.fd-result-error { + border-color: color-mix(in srgb, var(--fd-danger) 30%, var(--fd-border)); + color: var(--fd-danger); +} + +.fd-row-failing .fd-feed-title { + color: color-mix(in srgb, var(--fd-text) 82%, var(--fd-muted)); +} + .fd-detail { padding: 0.75rem 0 0.25rem; } diff --git a/src/components/feed-directory/ui/render.ts b/src/components/feed-directory/ui/render.ts index 47828421..b5504192 100644 --- a/src/components/feed-directory/ui/render.ts +++ b/src/components/feed-directory/ui/render.ts @@ -2,9 +2,23 @@ import { escapeHtml } from '../lib/escape'; import { buildFeedUrl, formatInstanceLabel } from '../domain/feed-url'; import { hasActiveFilters, PAGE_SIZE } from '../domain/filters'; import { displayLanguage, normalizeFilterLanguage } from '../domain/language'; -import type { CatalogFacets, FeedDirectoryEntry, FilterState } from '../domain/types'; +import { isFailingLastResult } from '../domain/last-result'; +import type { CatalogFacets, FeedDirectoryEntry, LastResult } from '../domain/types'; import type { FeedDirectoryViewModel } from '../app/view-model'; +function renderLastResultIndicator(lastResult: LastResult): string { + switch (lastResult.state) { + case 'ok': + return `Last scrape ok`; + case 'empty': + return `Last scrape empty`; + case 'error': + return `Last scrape failed`; + case 'unknown': + return ''; + } +} + function renderTopicChips(facets: CatalogFacets, selected: string[]): string { if (facets.topics.length === 0) { return `

Topics appear after the catalog loads.

`; @@ -54,6 +68,8 @@ function renderFeedRow(entry: FeedDirectoryEntry, vm: FeedDirectoryViewModel): s const hasParameters = Object.keys(entry.parameterSchema).length > 0; const expanded = vm.expandedEntryId === entry.id; const copied = vm.copiedEntryId === entry.id; + const failing = isFailingLastResult(entry.lastResult); + const resultIndicator = renderLastResultIndicator(entry.lastResult); const topicBadges = entry.topics.length > 0 @@ -64,7 +80,7 @@ function renderFeedRow(entry: FeedDirectoryEntry, vm: FeedDirectoryViewModel): s ? `${escapeHtml(entry.siteKey)}` : `${escapeHtml(entry.siteKey)}`; - return ` + return `

${escapeHtml(entry.title)}

@@ -73,12 +89,13 @@ function renderFeedRow(entry: FeedDirectoryEntry, vm: FeedDirectoryViewModel): s ${domainMarkup} ${language !== '—' ? `${escapeHtml(language)}` : ''} ${topicBadges} + ${resultIndicator}
- RSS + RSS ${entry.channelUrl ? `Source` : ''} ${hasParameters ? `` : ''} From 327a8e4ec2eaa510b46ff21798db71934b6e8d6b Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 12:00:18 +0200 Subject: [PATCH 2/3] docs: describe catalog last_result Clarify that Feed Directory scrape hints are instance last-known signals; cold instances show no badge and this is not /health. --- .../docs/web-application/guides/use-the-feed-directory.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/content/docs/web-application/guides/use-the-feed-directory.mdx b/src/content/docs/web-application/guides/use-the-feed-directory.mdx index 0f1c0fa7..c2468822 100644 --- a/src/content/docs/web-application/guides/use-the-feed-directory.mdx +++ b/src/content/docs/web-application/guides/use-the-feed-directory.mdx @@ -29,6 +29,10 @@ If you see a config file named `phys.org/weekly.yml`, you can access it at: Just replace `localhost:4000` with your own `html2rss-web` address. +## Last-known scrape signal + +The Feed Directory may show a small last-scrape hint on each feed. That signal is the **last known** directory-defaults scrape outcome on the **selected instance** (process-local), not a live health check and not `/health`. A cold instance (feeds never scraped yet) shows **no** badge — that is normal. Empty or failed last scrapes are still listed; they sort lower and ask for confirmation before you subscribe. + ## When to Move On Use a custom config when: From adc8d560e7bf95a5d88f573a3044685fe6a72965 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 13:48:44 +0200 Subject: [PATCH 3/3] fix(feed-directory): warn on failing OPML and honest count copy Align bulk export with subscribe warnings and drop "ready-to-use" when the catalog can include empty/error last scrapes. --- .../adapters/catalog-api.test.ts | 19 ++++++++++++++++--- .../feed-directory/app/FeedDirectoryApp.ts | 12 ++++++++++++ .../feed-directory/ui/render.test.ts | 14 ++++++++++++++ src/components/feed-directory/ui/render.ts | 4 ++-- 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 src/components/feed-directory/ui/render.test.ts diff --git a/src/components/feed-directory/adapters/catalog-api.test.ts b/src/components/feed-directory/adapters/catalog-api.test.ts index 8b4d936c..fedf0151 100644 --- a/src/components/feed-directory/adapters/catalog-api.test.ts +++ b/src/components/feed-directory/adapters/catalog-api.test.ts @@ -35,6 +35,14 @@ const validEnvelope = { parameters: { schema: {}, defaults: {} }, last_result: { state: 'error', code: 'EXTRACTION_EMPTY', at: '2026-08-29T09:00:00Z' }, }, + { + id: 'example.com/empty-scrape', + path: '/example.com/empty-scrape.rss', + channel: { url: 'https://example.com/empty', language: 'en' }, + directory: { title: 'Empty', summary: '', topics: [] }, + parameters: { schema: {}, defaults: {} }, + last_result: { state: 'empty', code: 'EXTRACTION_EMPTY', at: '2026-08-29T09:30:00Z' }, + }, { id: 'broken' }, { id: 'missing.last/result', @@ -54,7 +62,7 @@ const validEnvelope = { ], }, meta: { - total: 3, + total: 4, catalog_version: 2, starters: ['anthropic.com/news', 'bbc.co.uk/available_episodes'], }, @@ -74,7 +82,7 @@ describe('fetchCatalogResponse', () => { const { entries, meta } = await fetchCatalogResponse('https://example.test/', fetchImpl); - expect(entries).toHaveLength(3); + expect(entries).toHaveLength(4); expect(entries[0]).toMatchObject({ id: 'anthropic.com/news', siteKey: 'anthropic.com', @@ -90,8 +98,13 @@ describe('fetchCatalogResponse', () => { code: 'EXTRACTION_EMPTY', at: '2026-08-29T09:00:00Z', }); + expect(entries[3]?.lastResult).toEqual({ + state: 'empty', + code: 'EXTRACTION_EMPTY', + at: '2026-08-29T09:30:00Z', + }); expect(meta).toEqual({ - total: 3, + total: 4, catalogVersion: 2, starters: ['anthropic.com/news', 'bbc.co.uk/available_episodes'], }); diff --git a/src/components/feed-directory/app/FeedDirectoryApp.ts b/src/components/feed-directory/app/FeedDirectoryApp.ts index 6396ae49..153a82b2 100644 --- a/src/components/feed-directory/app/FeedDirectoryApp.ts +++ b/src/components/feed-directory/app/FeedDirectoryApp.ts @@ -266,6 +266,18 @@ export class FeedDirectoryApp { private exportOpml(): void { const { filteredEntries } = selectPagedEntries(this.state); if (filteredEntries.length === 0) return; + + const failingCount = filteredEntries.filter((entry) => isFailingLastResult(entry.lastResult)).length; + if (failingCount > 0) { + const detail = + failingCount === 1 + ? '1 feed in this export had an empty or failed last scrape on this instance.' + : `${failingCount} feeds in this export had an empty or failed last scrape on this instance.`; + if (!window.confirm(`${detail} Feeds that recently failed often fail again. Export anyway?`)) { + return; + } + } + const opml = buildOpmlDocument(this.state.instanceUrl, filteredEntries, this.state.parametersById); downloadOpml(opml); } diff --git a/src/components/feed-directory/ui/render.test.ts b/src/components/feed-directory/ui/render.test.ts new file mode 100644 index 00000000..1cfe9dbe --- /dev/null +++ b/src/components/feed-directory/ui/render.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { renderLastResultIndicator } from './render'; + +describe('renderLastResultIndicator', () => { + it('renders no badge for unknown (cold)', () => { + expect(renderLastResultIndicator({ state: 'unknown', code: null, at: null })).toBe(''); + }); + + it('renders ambient badges for ok, empty, and error', () => { + expect(renderLastResultIndicator({ state: 'ok', code: null, at: null })).toContain('fd-result-ok'); + expect(renderLastResultIndicator({ state: 'empty', code: null, at: null })).toContain('fd-result-empty'); + expect(renderLastResultIndicator({ state: 'error', code: 'X', at: null })).toContain('fd-result-error'); + }); +}); diff --git a/src/components/feed-directory/ui/render.ts b/src/components/feed-directory/ui/render.ts index b5504192..3e1a8b90 100644 --- a/src/components/feed-directory/ui/render.ts +++ b/src/components/feed-directory/ui/render.ts @@ -6,7 +6,7 @@ import { isFailingLastResult } from '../domain/last-result'; import type { CatalogFacets, FeedDirectoryEntry, LastResult } from '../domain/types'; import type { FeedDirectoryViewModel } from '../app/view-model'; -function renderLastResultIndicator(lastResult: LastResult): string { +export function renderLastResultIndicator(lastResult: LastResult): string { switch (lastResult.state) { case 'ok': return `Last scrape ok`; @@ -185,7 +185,7 @@ export function renderFeedDirectory(vm: FeedDirectoryViewModel): string { const activeFilters = hasActiveFilters(vm.filters); const resultLabel = activeFilters ? `${vm.filteredTotal} matching feed${vm.filteredTotal === 1 ? '' : 's'}` - : `${vm.catalogTotal} ready-to-use feed${vm.catalogTotal === 1 ? '' : 's'}`; + : `${vm.catalogTotal} feed${vm.catalogTotal === 1 ? '' : 's'}`; const feedback = vm.instanceFeedback; const feedbackClass = feedback?.tone ? ` fd-feedback-${feedback.tone}` : '';