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)