diff --git a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts index 242bf9dcd..ec220cf7a 100644 --- a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts +++ b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts @@ -6,17 +6,23 @@ const E2E_PORT = process.env.E2E_PORT; assert(E2E_PORT, "E2E_PORT must be set by the Playwright webServer config"); const BASE_URL = `http://127.0.0.1:${E2E_PORT}`; -// A fixed instant so the "Last crawled at" label never drifts with wall-clock -// time. toAbsoluteShortDateTime formats it in UTC (see local-time.format.ts), -// and the pinned timezoneId below makes the client-side re-localisation resolve -// to the same UTC value — so the rendered label is byte-stable across runs. -const CONTENT_FETCHED_AT = "2026-03-26T14:32:00.000Z"; +// Fixed instants so the version labels never drift with wall-clock time. +// toAbsoluteShortDateTime formats them in UTC (see local-time.format.ts), and +// the pinned timezoneId below makes the client-side re-localisation resolve to +// the same UTC value — so the rendered labels are byte-stable across runs. +const CONTENT_FETCHED_AT = "2026-07-10T09:14:00.000Z"; +const CRAWL_VERSIONS = ["2026-07-10T09:14Z", "2026-06-28T22:01Z", "2026-03-26T14:32Z"]; const ARTICLE_URL = "https://example.com/crawl-bookmark-visual"; const CANONICAL_PATH = "example.com/crawl-bookmark-visual"; async function seedCrawledArticle(page: Page): Promise { const response = await page.request.post(`${BASE_URL}/e2e/seed-crawled-article`, { - data: { url: ARTICLE_URL, title: "Crawl Bookmark Visual", contentFetchedAt: CONTENT_FETCHED_AT }, + data: { + url: ARTICLE_URL, + title: "Crawl Bookmark Visual", + contentFetchedAt: CONTENT_FETCHED_AT, + crawlVersions: CRAWL_VERSIONS, + }, }); assert.equal(response.status(), 201, "seed endpoint must create the crawled article"); } @@ -25,9 +31,10 @@ async function openReaderWithBookmark(page: Page): Promise { await page.goto(`${BASE_URL}/view/${CANONICAL_PATH}`, { waitUntil: "domcontentloaded" }); await page.waitForSelector(".crawl-bookmark__tabs"); await waitForBrandFonts(page, ["Inter"]); - // The label the seed pinned; waiting on it settles the client re-localisation - // before the pixel capture so the text can't change mid-screenshot. - await expect(page.locator(".crawl-bookmark__time")).toHaveText("26 Mar '26, 14:32"); + // The newest version's label; waiting on it settles the client re-localisation + // before the pixel capture so the text can't change mid-screenshot. `.first()` + // disambiguates the several version rows for strict-mode locators. + await expect(page.locator(".crawl-bookmark__time").first()).toHaveText("10 Jul '26, 09:14"); } test.describe("Crawl bookmark visual regression", () => { @@ -63,12 +70,13 @@ test.describe("Crawl bookmark visual regression", () => { ); }); - // The handle collapses to just its grip when the disclosure closes (mobile / - // narrow viewports), so its height must not depend on the open state — a - // regression here reintroduces the short-sliver-vs-tall-capsule mismatch that - // PR #936 caused by dropping the handle's fixed height. Compared numerically - // (not via a screenshot) so it needs no per-platform PNG baseline. - test("the handle is the same height whether the capsule is open or collapsed", async ({ page }) => { + // A multi-version capsule grows when open to seat its dated tabs, and the + // handle stretches with it (verified by the alignment test above). When the + // disclosure closes (mobile / narrow viewports) the handle must still collapse + // to a stable, clickable grip at the capsule's 54px min-height — never the thin + // sliver PR #936 caused by dropping the handle's fixed height. Compared + // numerically (not via a screenshot) so it needs no per-platform PNG baseline. + test("the collapsed handle keeps its fixed grip height while the open capsule grows to fit versions", async ({ page }) => { await seedCrawledArticle(page); await openReaderWithBookmark(page); const handle = page.locator(".crawl-bookmark__handle"); @@ -76,6 +84,7 @@ test.describe("Crawl bookmark visual regression", () => { await page.locator(".crawl-bookmark").evaluate((el) => el.removeAttribute("open")); const collapsedHeight = (await handle.boundingBox())?.height; assert.ok(openHeight && collapsedHeight, "handle must have a measurable height in both states"); - assert.equal(collapsedHeight, openHeight, "collapsed handle must match the open capsule height"); + assert.ok(openHeight > collapsedHeight, "the open capsule grows taller than the collapsed grip"); + assert.equal(collapsedHeight, 54, "collapsed handle stays at the 54px min-height grip, not a sliver"); }); }); diff --git a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-dark-chromium-linux.png b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-dark-chromium-linux.png index 375fc1e59..5adcc3bf1 100644 Binary files a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-dark-chromium-linux.png and b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-dark-chromium-linux.png differ diff --git a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-light-chromium-linux.png b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-light-chromium-linux.png index a7eacc932..70075f0fe 100644 Binary files a/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-light-chromium-linux.png and b/projects/hutch/src/e2e/crawl-bookmark-visual.e2e-local.ts-snapshots/crawl-bookmark-light-chromium-linux.png differ diff --git a/projects/hutch/src/e2e/e2e-server.main.ts b/projects/hutch/src/e2e/e2e-server.main.ts index ed482d7e8..e7b2198ea 100644 --- a/projects/hutch/src/e2e/e2e-server.main.ts +++ b/projects/hutch/src/e2e/e2e-server.main.ts @@ -190,6 +190,7 @@ const SeedCrawledArticleBody = z.object({ url: z.string(), title: z.string(), contentFetchedAt: z.string(), + crawlVersions: z.array(z.string()).default([]), }) server.post('/e2e/seed-crawled-article', async (req, res) => { const parsed = SeedCrawledArticleBody.safeParse(req.body) @@ -197,7 +198,7 @@ server.post('/e2e/seed-crawled-article', async (req, res) => { res.status(400).json({ error: parsed.error.flatten() }) return } - const { url, title, contentFetchedAt } = parsed.data + const { url, title, contentFetchedAt, crawlVersions } = parsed.data const hostname = new URL(url).hostname await fixture.articleStore.saveArticleGlobally({ url, @@ -211,6 +212,7 @@ server.post('/e2e/seed-crawled-article', async (req, res) => { }) await fixture.articleCrawl.markCrawlReady({ url }) await fixture.articleStore.setContentFetchedAt({ url, at: contentFetchedAt }) + await fixture.articleStore.setCrawlVersions({ url, versions: crawlVersions }) res.status(201).json({ ok: true }) }) diff --git a/projects/hutch/src/runtime/app.ts b/projects/hutch/src/runtime/app.ts index 41ba27f7c..24b8e1940 100644 --- a/projects/hutch/src/runtime/app.ts +++ b/projects/hutch/src/runtime/app.ts @@ -388,6 +388,7 @@ function initProviders() { markSummaryPending: summaryStore.markSummaryPending, findArticleCrawlStatus: crawlStore.findArticleCrawlStatus, findArticleFreshness: articleStore.findArticleFreshness, + findArticleCrawlVersions: articleStore.findArticleCrawlVersions, markCrawlPending: crawlStore.markCrawlPending, forceMarkCrawlPending: crawlStore.forceMarkCrawlPending, refreshArticleIfStale, @@ -653,6 +654,7 @@ function initProviders() { markSummaryPending: summaryStore.markSummaryPending, findArticleCrawlStatus: crawlStore.findArticleCrawlStatus, findArticleFreshness: articleStore.findArticleFreshness, + findArticleCrawlVersions: articleStore.findArticleCrawlVersions, markCrawlPending: crawlStore.markCrawlPending, forceMarkCrawlPending: crawlStore.forceMarkCrawlPending, refreshArticleIfStale, diff --git a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts index 7807d44dd..38fb1118e 100644 --- a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts +++ b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.test.ts @@ -719,6 +719,32 @@ describe("initDynamoDbArticleStore freshness, notification state, content and ur expect(freshness).toBeNull(); }); + it("findArticleCrawlVersions maps the stored minute-id log to crawl versions, newest first", async () => { + const { client } = createFakeClient({ + GetCommand: { + default: { + Item: { crawlVersions: ["2026-07-10T09:41Z", "2026-06-28T22:01Z", "2026-03-26T14:32Z"] }, + }, + }, + }); + + const versions = await initStore(client).findArticleCrawlVersions(URL); + + expect(versions).toEqual([ + { crawledAtMinute: "2026-07-10T09:41Z" }, + { crawledAtMinute: "2026-06-28T22:01Z" }, + { crawledAtMinute: "2026-03-26T14:32Z" }, + ]); + }); + + it("findArticleCrawlVersions returns an empty list for a pre-feature row with no log", async () => { + const { client } = createFakeClient({ GetCommand: { default: { Item: undefined } } }); + + const versions = await initStore(client).findArticleCrawlVersions(URL); + + expect(versions).toEqual([]); + }); + it("findUserArticleNotificationState hydrates every timestamp the gate reads", async () => { const { client } = createFakeClient({ GetCommand: { diff --git a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts index 27351c9c3..753648f38 100644 --- a/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts +++ b/projects/hutch/src/runtime/providers/article-store/dynamodb-article-store.ts @@ -22,6 +22,7 @@ import type { DeleteArticle, FindArticleById, FindArticleByUrl, + FindArticleCrawlVersions, FindArticleFreshness, FindArticleUrlById, FindArticlesByUser, @@ -47,6 +48,10 @@ const ArticleFreshnessRow = z.object({ contentFetchedAt: dynamoField(z.string()), }); +const ArticleCrawlVersionsRow = z.object({ + crawlVersions: dynamoField(z.array(z.string())), +}); + /** `routeId` column holds the `ReaderArticleHashId.value` (32-char hex). The Zod schema rehydrates it into a `ReaderArticleHashId` instance on read. * * `savedAt` is the public-row freshness anchor: when the row is first created @@ -134,6 +139,7 @@ export function initDynamoDbArticleStore(deps: { deleteAllUserArticles: DeleteAllUserArticles; updateArticleStatus: UpdateArticleStatus; findArticleFreshness: FindArticleFreshness; + findArticleCrawlVersions: FindArticleCrawlVersions; markArticleViewed: MarkArticleViewed; markSummaryToggled: MarkSummaryToggled; markReaderViewSucceeded: MarkReaderViewSucceeded; @@ -148,6 +154,7 @@ export function initDynamoDbArticleStore(deps: { const unverifiedArticles = defineDynamoTable({ client, tableName, schema: UnverifiedArticleRow }); const articleContent = defineDynamoTable({ client, tableName, schema: ArticleContentRow }); const articleFreshness = defineDynamoTable({ client, tableName, schema: ArticleFreshnessRow }); + const articleCrawlVersions = defineDynamoTable({ client, tableName, schema: ArticleCrawlVersionsRow }); const userArticles = defineDynamoTable({ client, tableName: userArticlesTableName, @@ -487,6 +494,15 @@ export function initDynamoDbArticleStore(deps: { } } + const findArticleCrawlVersions: FindArticleCrawlVersions = async (url) => { + const articleResourceUniqueId = ArticleResourceUniqueId.parse(url); + const row = await articleCrawlVersions.get( + { url: articleResourceUniqueId.value }, + { projection: ["crawlVersions"] }, + ); + return (row?.crawlVersions ?? []).map((crawledAtMinute) => ({ crawledAtMinute })); + }; + const markArticleViewed: MarkArticleViewed = async ({ userId, url, at }) => { await stampUserArticleIfStillSaved({ userId, url, at, updateExpression: "SET viewedAt = :at" }); }; @@ -625,6 +641,7 @@ export function initDynamoDbArticleStore(deps: { deleteAllUserArticles, updateArticleStatus, findArticleFreshness, + findArticleCrawlVersions, markArticleViewed, markSummaryToggled, markReaderViewSucceeded, diff --git a/projects/hutch/src/runtime/server.ts b/projects/hutch/src/runtime/server.ts index be82666fc..f96f93925 100644 --- a/projects/hutch/src/runtime/server.ts +++ b/projects/hutch/src/runtime/server.ts @@ -74,6 +74,7 @@ import type { DeleteArticle, FindArticleById, FindArticleByUrl, + FindArticleCrawlVersions, FindArticleFreshness, FindArticleUrlById, FindArticlesByUser, @@ -237,6 +238,7 @@ interface AppDependencies { findArticleById: FindArticleById; findArticleByUrl: FindArticleByUrl; findArticleFreshness: FindArticleFreshness; + findArticleCrawlVersions: FindArticleCrawlVersions; findArticleUrlById: FindArticleUrlById; findArticlesByUser: FindArticlesByUser; countArticlesByUser: CountArticlesByUser; @@ -975,6 +977,7 @@ export function createApp(dependencies: AppDependencies): Express { findArticleById: deps.findArticleById, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, findArticleUrlById: deps.findArticleUrlById, saveArticle: deps.saveArticle, deleteArticle: deps.deleteArticle, @@ -1054,6 +1057,7 @@ export function createApp(dependencies: AppDependencies): Express { appOrigin, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, readArticleContent: deps.readArticleContent, findGeneratedSummary: deps.findGeneratedSummary, markSummaryPending: deps.markSummaryPending, @@ -1077,6 +1081,7 @@ export function createApp(dependencies: AppDependencies): Express { appOrigin, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, readArticleContent: deps.readArticleContent, findGeneratedSummary: deps.findGeneratedSummary, findArticleCrawlStatus: deps.findArticleCrawlStatus, diff --git a/projects/hutch/src/runtime/test-app.ts b/projects/hutch/src/runtime/test-app.ts index 584aac57d..5ff615802 100644 --- a/projects/hutch/src/runtime/test-app.ts +++ b/projects/hutch/src/runtime/test-app.ts @@ -123,6 +123,7 @@ function flattenFixtureToAppDependencies( findArticleById: fixture.articleStore.findArticleById, findArticleByUrl: fixture.articleStore.findArticleByUrl, findArticleFreshness: fixture.articleStore.findArticleFreshness, + findArticleCrawlVersions: fixture.articleStore.findArticleCrawlVersions, findArticleUrlById: fixture.articleStore.findArticleUrlById, findArticlesByUser: fixture.articleStore.findArticlesByUser, countArticlesByUser: fixture.articleStore.countArticlesByUser, diff --git a/projects/hutch/src/runtime/web/pages/admin/recrawl.component.ts b/projects/hutch/src/runtime/web/pages/admin/recrawl.component.ts index d64d10aa6..84a8dd11d 100644 --- a/projects/hutch/src/runtime/web/pages/admin/recrawl.component.ts +++ b/projects/hutch/src/runtime/web/pages/admin/recrawl.component.ts @@ -65,7 +65,7 @@ export interface AdminRecrawlPageInput { extensionInstallUrl?: string; appOrigin: string; recrawlFormAction?: string; - lastCrawledAt?: LocalTime; + crawlVersions?: LocalTime[]; } /** @@ -99,7 +99,7 @@ export function AdminRecrawlPage(input: AdminRecrawlPageInput): PageBody { appOrigin: input.appOrigin, topActionsHtml: actions.top.to("text/html").body, bottomActionsHtml: actions.bottom.to("text/html").body, - lastCrawledAt: input.lastCrawledAt, + crawlVersions: input.crawlVersions, }); const tierBadge = renderTierBadge(input.contentSourceTier); diff --git a/projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts b/projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts index f3d7e3f78..b2c0123ad 100644 --- a/projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts +++ b/projects/hutch/src/runtime/web/pages/admin/recrawl.page.ts @@ -6,7 +6,7 @@ import type { ForceMarkCrawlPending, MarkCrawlPending, } from "@packages/provider-contracts/article-crawl"; -import type { FindArticleByUrl, FindArticleFreshness } from "@packages/provider-contracts/article-store"; +import type { FindArticleByUrl, FindArticleCrawlVersions, FindArticleFreshness } from "@packages/provider-contracts/article-store"; import type { ReadArticleContent } from "@packages/provider-contracts/article-store"; import type { FindGeneratedSummary, @@ -29,6 +29,7 @@ export interface AdminRecrawlDependencies { appOrigin: string; findArticleByUrl: FindArticleByUrl; findArticleFreshness: FindArticleFreshness; + findArticleCrawlVersions: FindArticleCrawlVersions; readArticleContent: ReadArticleContent; findGeneratedSummary: FindGeneratedSummary; findArticleCrawlStatus: FindArticleCrawlStatus; @@ -170,7 +171,7 @@ function handleShowRecrawlPage( contentSourceTier: existing.contentSourceTier, extensionInstallUrl: extensionInstallUrlIfMissing(req), recrawlFormAction, - lastCrawledAt: state.lastCrawledAt, + crawlVersions: state.crawlVersions, }), await deps.buildBannerState(req)).to("text/html"); res.status(html.statusCode).type("html").send(html.body); }; @@ -265,6 +266,7 @@ export function initAdminRecrawlRoutes(deps: AdminRecrawlDependencies): Router { readArticleContent: deps.readArticleContent, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, appOrigin: deps.appOrigin, formatDocumentTitle: formatRecrawlDocumentTitle, summaryOpen: false, diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.page.ts b/projects/hutch/src/runtime/web/pages/queue/queue.page.ts index 0bd1da85a..36b60f625 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.page.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.page.ts @@ -27,6 +27,7 @@ import type { DeleteArticle, FindArticleById, FindArticleByUrl, + FindArticleCrawlVersions, FindArticleFreshness, FindArticleUrlById, FindArticlesByUser, @@ -181,6 +182,7 @@ interface QueueDependencies { findArticleById: FindArticleById; findArticleByUrl: FindArticleByUrl; findArticleFreshness: FindArticleFreshness; + findArticleCrawlVersions: FindArticleCrawlVersions; findArticleUrlById: FindArticleUrlById; saveArticle: SaveArticle; deleteArticle: DeleteArticle; @@ -376,6 +378,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router { readArticleContent: deps.readArticleContent, findArticleByUrl: deps.findArticleByUrl, findArticleFreshness: deps.findArticleFreshness, + findArticleCrawlVersions: deps.findArticleCrawlVersions, appOrigin: deps.appOrigin, formatDocumentTitle: formatReaderDocumentTitle, summaryOpen: false, @@ -502,7 +505,7 @@ export function initQueueRoutes(deps: QueueDependencies): Router { extensionInstallUrl: extensionInstallUrlIfMissing(req), backLink: VIEW_BACK_LINK, renderActions: deps.stickyReader, - lastCrawledAt: state.lastCrawledAt, + crawlVersions: state.crawlVersions, }), { ...(await deps.buildBannerState(req)), showExtensionSuggestionBanner, diff --git a/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts b/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts index 58b782b67..32fba87db 100644 --- a/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts +++ b/projects/hutch/src/runtime/web/pages/queue/queue.reader.route.test.ts @@ -148,11 +148,33 @@ describe("Queue routes", () => { const afterDoc = new JSDOM(after.text).window.document; const tab = afterDoc.querySelector('[data-test-crawl-bookmark-tab="canonical"]'); assert(tab, "the canonical bookmark tab must render once contentFetchedAt exists"); + expect(tab.classList.contains("crawl-bookmark__tab--current")).toBe(true); + expect(tab.getAttribute("aria-disabled")).toBe("false"); + expect(tab.querySelector(".crawl-bookmark__badge")?.textContent).toBe("current"); const time = tab.querySelector("time"); assert(time, "the bookmark tab must carry a