feat(admin): show email count and last-email date per feed

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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-25 23:18:38 +02:00
parent e258206384
commit fd3ff8c40a
11 changed files with 273 additions and 51 deletions
+70
View File
@@ -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: "<p>hi</p>",
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);
+15 -1
View File
@@ -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"
/>
<div>
<div class="feed-title-cell-text">
<strong class="truncate" title={titleHover}>
{titleDisplay}
</strong>
@@ -641,6 +643,10 @@ app.get("/", async (c) => {
{descDisplay}
</div>
)}
<LastEmail
at={feed.lastEmailAt}
count={feed.emailCount}
/>
</div>
{feed.pendingConfirmation && (
<ConfirmationPill feedId={feed.id} />
@@ -683,6 +689,7 @@ app.get("/", async (c) => {
tabindex={-1}
>
Emails
<EmailCountBadge count={feed.emailCount} />
</span>
</>
) : (
@@ -698,6 +705,7 @@ app.get("/", async (c) => {
class="button button-small"
>
Emails
<EmailCountBadge count={feed.emailCount} />
</a>
</>
)}
@@ -780,6 +788,10 @@ app.get("/", async (c) => {
<span title={descHover}>{descDisplay}</span>
</p>
)}
<LastEmail
at={feed.lastEmailAt}
count={feed.emailCount}
/>
</div>
<div style="margin-bottom: var(--spacing-md);">
@@ -819,6 +831,7 @@ app.get("/", async (c) => {
tabindex={-1}
>
Emails
<EmailCountBadge count={feed.emailCount} />
</span>
</>
) : (
@@ -834,6 +847,7 @@ app.get("/", async (c) => {
class="button button-small"
>
Emails
<EmailCountBadge count={feed.emailCount} />
</a>
</>
)}
+35
View File
@@ -325,3 +325,38 @@ export const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => {
</span>
);
};
// ── 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 : <span class="button-count">{count}</span>;
// 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 <span class="feed-activity muted">No emails yet</span>;
}
if (at === undefined) return null;
return (
<span class="feed-activity muted" title={new Date(at).toLocaleString()}>
Last email {formatRelativeTime(at)}
</span>
);
};