From fd3ff8c40a601473913400c02c7abb1e5a4dbb60 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Mon, 25 May 2026 23:18:38 +0200 Subject: [PATCH] feat(admin): show email count and last-email date per feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface each feed's email count on its Emails button and a "Last email …" freshness line under the title, in both dashboard views. The values are projected into feeds:list (kept to a single KV read) via the Feed aggregate, so toListItemDTO now maps the whole aggregate through its intention-revealing accessors instead of threading scalar projections. Also fixes long titles overflowing into the Feed ID column in the table view. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 9 ++++ src/domain/feed.aggregate.test.ts | 49 ++++++++++++++++++ src/domain/feed.aggregate.ts | 13 +++++ src/infrastructure/feed-mapper.test.ts | 43 ++++++++++++---- src/infrastructure/feed-mapper.ts | 33 ++++++------ src/infrastructure/feed-repository.ts | 27 ++-------- src/routes/admin.test.ts | 70 ++++++++++++++++++++++++++ src/routes/admin.tsx | 16 +++++- src/routes/admin/ui.tsx | 35 +++++++++++++ src/styles/components.css | 27 ++++++++++ src/types/index.ts | 2 + 11 files changed, 273 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b9849..4339080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,17 @@ verbatim as the GitHub Release notes — so what you write here is what ships. ## [Unreleased] +### Added + +- The admin dashboard now shows each feed's email count on its **Emails** button + and a **"Last email …"** freshness line under the feed title, in both the list + and table views. Both values are projected into `feeds:list`, so the dashboard + stays a single KV read; they backfill on a feed's next email or save. + ### Fixed +- Admin dashboard table view: long feed titles no longer overflow into the Feed + ID column — the title/description cell now shrinks so its text ellipsises. - RSS and Atom feeds now advertise the WebSub hub inside the feed body (``), not just in the HTTP `Link` header. Readers like FreshRSS discover the hub from the XML, so they can now subscribe and receive diff --git a/src/domain/feed.aggregate.test.ts b/src/domain/feed.aggregate.test.ts index f8fb80d..75b13bb 100644 --- a/src/domain/feed.aggregate.test.ts +++ b/src/domain/feed.aggregate.test.ts @@ -200,6 +200,34 @@ describe("Feed.removeEmails", () => { }); }); +describe("Feed.emailCount / lastEmailAt", () => { + it("reports zero and undefined for an empty feed", () => { + const feed = Feed.reconstitute(FID, state(), { emails: [] }); + expect(feed.emailCount).toBe(0); + expect(feed.lastEmailAt).toBeUndefined(); + }); + + it("counts emails and reports the newest receivedAt (index head)", () => { + const feed = Feed.reconstitute(FID, state(), { + emails: [ + entry({ key: "k2", receivedAt: 2000 }), + entry({ key: "k1", receivedAt: 1000 }), + ], + }); + expect(feed.emailCount).toBe(2); + expect(feed.lastEmailAt).toBe(2000); + }); + + it("tracks the latest email after ingest", () => { + const feed = Feed.reconstitute(FID, state(), { + emails: [entry({ key: "old", receivedAt: 1000 })], + }); + feed.ingest(entry({ key: "new", receivedAt: 5000 }), { maxBytes: 10_000 }); + expect(feed.emailCount).toBe(2); + expect(feed.lastEmailAt).toBe(5000); + }); +}); + describe("Feed events", () => { it("records FeedCreated on create and drains it once", () => { const feed = Feed.create(FID, createInput(), { mailboxId: MBOX }); @@ -333,6 +361,27 @@ describe("FeedRepository.load / save round-trip", () => { ]); }); + it("projects email count and last-email timestamp into feeds:list", async () => { + const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); + const created = Feed.create(FID, createInput({ title: "Proj" }), { + mailboxId: MBOX, + }); + await repo.save(created); + + let listed = await repo.listFeeds(); + expect(listed[0].emailCount).toBe(0); + expect(listed[0].lastEmailAt).toBeUndefined(); + + created.ingest(entry({ key: "feed:opaque-feed-id:1", receivedAt: 4242 }), { + maxBytes: 1_000_000, + }); + await repo.saveMetadata(created); + + listed = await repo.listFeeds(); + expect(listed[0].emailCount).toBe(1); + expect(listed[0].lastEmailAt).toBe(4242); + }); + it("returns null when the feed has no config", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); expect(await repo.load(FeedId.unchecked("missing"))).toBeNull(); diff --git a/src/domain/feed.aggregate.ts b/src/domain/feed.aggregate.ts index 425f604..9ee4e0f 100644 --- a/src/domain/feed.aggregate.ts +++ b/src/domain/feed.aggregate.ts @@ -190,6 +190,19 @@ export class Feed { return [...this._metadata.emails]; } + /** Number of emails currently in the index. */ + get emailCount(): number { + return this._metadata.emails.length; + } + + /** + * Received timestamp (ms) of the most recent email, or undefined when the + * feed has none. The index is maintained newest-first (ingest unshifts). + */ + get lastEmailAt(): number | undefined { + return this._metadata.emails[0]?.receivedAt; + } + /** Per-sender one-click unsubscribe links (copy). */ unsubscribeUrls(): Record { return { ...(this._metadata.unsubscribe ?? {}) }; diff --git a/src/infrastructure/feed-mapper.test.ts b/src/infrastructure/feed-mapper.test.ts index 97be55f..e6791f6 100644 --- a/src/infrastructure/feed-mapper.test.ts +++ b/src/infrastructure/feed-mapper.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper"; import { FeedId } from "../domain/value-objects/feed-id"; -import type { FeedConfig } from "../types"; +import { Feed } from "../domain/feed.aggregate"; +import type { FeedConfig, FeedMetadata } from "../types"; const fullConfig: FeedConfig = { title: "News", @@ -16,6 +17,13 @@ const fullConfig: FeedConfig = { expires_at: 3000, }; +const feedFrom = (metadata: FeedMetadata) => + Feed.reconstitute( + FeedId.unchecked("a.b.42"), + fromConfigDTO(fullConfig), + metadata, + ); + describe("feed-mapper", () => { it("round-trips a full config DTO through domain state unchanged", () => { expect(toConfigDTO(fromConfigDTO(fullConfig))).toEqual(fullConfig); @@ -32,11 +40,8 @@ describe("feed-mapper", () => { expect(state.blockedSenders).toEqual([]); }); - it("projects the feeds:list item from domain state", () => { - const item = toListItemDTO( - FeedId.unchecked("a.b.42"), - fromConfigDTO(fullConfig), - ); + it("projects the feeds:list item from an empty feed aggregate", () => { + const item = toListItemDTO(feedFrom({ emails: [] })); expect(item).toEqual({ id: "a.b.42", title: "News", @@ -45,17 +50,33 @@ describe("feed-mapper", () => { expires_at: 3000, pendingConfirmation: false, hasNativeFeed: false, + emailCount: 0, + lastEmailAt: undefined, }); }); - it("projects hasNativeFeed when passed", () => { + it("projects pendingConfirmation and hasNativeFeed from metadata", () => { const item = toListItemDTO( - FeedId.unchecked("a.b.42"), - fromConfigDTO(fullConfig), - true, - true, + feedFrom({ + emails: [], + pendingConfirmation: true, + nativeFeeds: { "n@x.com": [{ url: "https://x/rss", type: "rss" }] }, + }), ); expect(item.pendingConfirmation).toBe(true); expect(item.hasNativeFeed).toBe(true); }); + + it("projects email count and the newest email's timestamp", () => { + const item = toListItemDTO( + feedFrom({ + emails: [ + { key: "k2", subject: "b", receivedAt: 1700000000000 }, + { key: "k1", subject: "a", receivedAt: 1600000000000 }, + ], + }), + ); + expect(item.emailCount).toBe(2); + expect(item.lastEmailAt).toBe(1700000000000); + }); }); diff --git a/src/infrastructure/feed-mapper.ts b/src/infrastructure/feed-mapper.ts index 76ca451..cc4fc46 100644 --- a/src/infrastructure/feed-mapper.ts +++ b/src/infrastructure/feed-mapper.ts @@ -1,6 +1,6 @@ import { FeedConfig, FeedListItem } from "../types"; import { FeedState } from "../domain/feed-state"; -import { FeedId } from "../domain/value-objects/feed-id"; +import { Feed } from "../domain/feed.aggregate"; /** * The translation seam between the Feed aggregate's domain state (camelCase) and @@ -44,20 +44,23 @@ export function toConfigDTO(state: FeedState): FeedConfig { }; } -/** Domain state → the projection cached in the global `feeds:list` registry. */ -export function toListItemDTO( - id: FeedId, - state: FeedState, - pendingConfirmation = false, - hasNativeFeed = false, -): FeedListItem { +/** + * The Feed aggregate → the projection cached in the global `feeds:list` registry. + * Unlike the config DTO, the list item is a read-model view: it folds in the + * aggregate's metadata-derived signals (pending confirmation, native feed, + * email count/last-received) alongside the config fields, so it reads the whole + * aggregate through its intention-revealing accessors. + */ +export function toListItemDTO(feed: Feed): FeedListItem { return { - id: id.value, - title: state.title, - description: state.description, - mailbox_id: state.mailboxId, - expires_at: state.expiresAt, - pendingConfirmation, - hasNativeFeed, + id: feed.id.value, + title: feed.title, + description: feed.description, + mailbox_id: feed.mailboxId.value, + expires_at: feed.expiresAt, + pendingConfirmation: feed.pendingConfirmation, + hasNativeFeed: feed.hasNativeFeed(), + emailCount: feed.emailCount, + lastEmailAt: feed.lastEmailAt, }; } diff --git a/src/infrastructure/feed-repository.ts b/src/infrastructure/feed-repository.ts index 237e112..63743bc 100644 --- a/src/infrastructure/feed-repository.ts +++ b/src/infrastructure/feed-repository.ts @@ -87,14 +87,7 @@ export class FeedRepository { await Promise.all([ this.putConfig(feed.id, toConfigDTO(feed.state())), this.putMetadata(feed.id, feed.toMetadataSnapshot()), - this.upsertListEntry( - toListItemDTO( - feed.id, - feed.state(), - feed.pendingConfirmation, - feed.hasNativeFeed(), - ), - ), + this.upsertListEntry(toListItemDTO(feed)), this.putInboundIndex(feed.mailboxId, feed.id), ]); } @@ -108,14 +101,7 @@ export class FeedRepository { async saveMetadata(feed: Feed): Promise { await Promise.all([ this.putMetadata(feed.id, feed.toMetadataSnapshot()), - this.upsertListEntry( - toListItemDTO( - feed.id, - feed.state(), - feed.pendingConfirmation, - feed.hasNativeFeed(), - ), - ), + this.upsertListEntry(toListItemDTO(feed)), ]); } @@ -127,14 +113,7 @@ export class FeedRepository { async saveConfig(feed: Feed): Promise { await Promise.all([ this.putConfig(feed.id, toConfigDTO(feed.state())), - this.upsertListEntry( - toListItemDTO( - feed.id, - feed.state(), - feed.pendingConfirmation, - feed.hasNativeFeed(), - ), - ), + this.upsertListEntry(toListItemDTO(feed)), this.putInboundIndex(feed.mailboxId, feed.id), ]); } diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 4af7590..0da90cf 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -1389,6 +1389,76 @@ describe("Admin Routes", () => { expect(body).toContain("pill-confirmation"); }); + it("dashboard shows email count badge and last-email line in both views", async () => { + const authCookie = await loginAndGetCookie(); + const repo = FeedRepository.from(mockEnv as unknown as Env); + + const feedId = FeedId.generate(); + const mailboxId = MailboxId.unchecked("count.dash.07"); + const feed = Feed.create( + feedId, + { + title: "Counted Feed", + language: "en", + allowedSenders: [], + blockedSenders: [], + }, + { mailboxId }, + ); + await repo.save(feed); + + for (let i = 0; i < 2; i++) { + const emailKey = repo.newEmailKey(feedId); + await repo.putEmail(emailKey, { + subject: `Email ${i}`, + from: "newsletter@example.com", + content: "

hi

", + receivedAt: Date.now(), + headers: {}, + }); + feed.ingest( + { key: emailKey, subject: `Email ${i}`, receivedAt: Date.now() }, + { maxBytes: 1_000_000 }, + ); + } + await repo.saveMetadata(feed); + + for (const view of ["table", "list"]) { + const res = await request(`/admin?view=${view}`, { + headers: { Cookie: authCookie }, + }); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain('class="button-count">2<'); + expect(body).toContain("Last email"); + } + }); + + it("dashboard shows 'No emails yet' for a feed with zero emails", async () => { + const authCookie = await loginAndGetCookie(); + const repo = FeedRepository.from(mockEnv as unknown as Env); + + const feedId = FeedId.generate(); + const feed = Feed.create( + feedId, + { + title: "Empty Feed", + language: "en", + allowedSenders: [], + blockedSenders: [], + }, + { mailboxId: MailboxId.unchecked("empty.dash.08") }, + ); + await repo.save(feed); + + const res = await request("/admin?view=list", { + headers: { Cookie: authCookie }, + }); + const body = await res.text(); + expect(body).toContain("No emails yet"); + expect(body).toContain('class="button-count">0<'); + }); + it("feed emails page shows confirmation-banner when pendingConfirmation is true", async () => { const authCookie = await loginAndGetCookie(); const repo = FeedRepository.from(mockEnv as unknown as Env); diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 087d589..dbfdebd 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -14,6 +14,8 @@ import { CheckIcon, FeedFormats, ExpiryBadge, + LastEmail, + EmailCountBadge, } from "./admin/ui"; import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "../domain/value-objects/feed-id"; @@ -628,7 +630,7 @@ app.get("/", async (c) => { height="20" loading="lazy" /> -
+
{titleDisplay} @@ -641,6 +643,10 @@ app.get("/", async (c) => { {descDisplay}
)} +
{feed.pendingConfirmation && ( @@ -683,6 +689,7 @@ app.get("/", async (c) => { tabindex={-1} > Emails + ) : ( @@ -698,6 +705,7 @@ app.get("/", async (c) => { class="button button-small" > Emails + )} @@ -780,6 +788,10 @@ app.get("/", async (c) => { {descDisplay}

)} +
@@ -819,6 +831,7 @@ app.get("/", async (c) => { tabindex={-1} > Emails + ) : ( @@ -834,6 +847,7 @@ app.get("/", async (c) => { class="button button-small" > Emails + )} diff --git a/src/routes/admin/ui.tsx b/src/routes/admin/ui.tsx index 43ad489..4ba428d 100644 --- a/src/routes/admin/ui.tsx +++ b/src/routes/admin/ui.tsx @@ -325,3 +325,38 @@ export const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => { ); }; + +// ── Email activity ────────────────────────────────────────────────────────────── + +function formatRelativeTime(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60_000) return "just now"; + const m = Math.floor(diff / 60_000); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo}mo ago`; + return `${Math.floor(mo / 12)}y ago`; +} + +// Count badge rendered inside the "Emails" button. Omitted for legacy feeds +// whose count hasn't been projected into feeds:list yet (backfills on next save). +export const EmailCountBadge = ({ count }: { count?: number }) => + count === undefined ? null : {count}; + +// Muted "last email" freshness line for the feed title block. Shows "No emails +// yet" for empty feeds; renders nothing when the timestamp isn't projected yet. +export const LastEmail = ({ at, count }: { at?: number; count?: number }) => { + if (count === 0) { + return No emails yet; + } + if (at === undefined) return null; + return ( + + Last email {formatRelativeTime(at)} + + ); +}; diff --git a/src/styles/components.css b/src/styles/components.css index d4f76e2..ffdc374 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -77,6 +77,33 @@ gap: var(--spacing-sm); } +/* Let the title/description text shrink so .truncate ellipsizes instead of + overflowing into the next column. Flex items default to min-width:auto. */ +.feed-title-cell-text { + flex: 1; + min-width: 0; +} + +/* "Last email …" freshness line under the feed title. */ +.feed-activity { + display: block; + margin-top: 4px; + font-size: var(--font-size-sm); +} + +/* Count badge inside the "Emails" button (always on the orange primary button, + incl. its faded disabled variant, so a light-on-dark badge fits both modes). */ +.button-count { + display: inline-block; + margin-left: 6px; + padding: 0 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.22); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + line-height: 1.5; +} + .feed-description { font-size: var(--font-size-md); color: var(--color-text-secondary); diff --git a/src/types/index.ts b/src/types/index.ts index 5f6a4e6..b2385cf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -111,6 +111,8 @@ export interface FeedListItem { expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads pendingConfirmation?: boolean; // Projected from FeedMetadata for the dashboard hasNativeFeed?: boolean; // Projected from FeedMetadata for the dashboard pill + emailCount?: number; // Projected email index size (dashboard "Emails" count) + lastEmailAt?: number; // Projected receivedAt (ms) of the most recent email } // Cumulative monitoring counters (persisted as a KV singleton)