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
+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>
);
};