mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -12,8 +12,17 @@ verbatim as the GitHub Release notes — so what you write here is what ships.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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
|
- RSS and Atom feeds now advertise the WebSub hub inside the feed body
|
||||||
(`<atom:link rel="hub">`), not just in the HTTP `Link` header. Readers like
|
(`<atom:link rel="hub">`), not just in the HTTP `Link` header. Readers like
|
||||||
FreshRSS discover the hub from the XML, so they can now subscribe and receive
|
FreshRSS discover the hub from the XML, so they can now subscribe and receive
|
||||||
|
|||||||
@@ -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", () => {
|
describe("Feed events", () => {
|
||||||
it("records FeedCreated on create and drains it once", () => {
|
it("records FeedCreated on create and drains it once", () => {
|
||||||
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
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 () => {
|
it("returns null when the feed has no config", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
expect(await repo.load(FeedId.unchecked("missing"))).toBeNull();
|
expect(await repo.load(FeedId.unchecked("missing"))).toBeNull();
|
||||||
|
|||||||
@@ -190,6 +190,19 @@ export class Feed {
|
|||||||
return [...this._metadata.emails];
|
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). */
|
/** Per-sender one-click unsubscribe links (copy). */
|
||||||
unsubscribeUrls(): Record<string, string> {
|
unsubscribeUrls(): Record<string, string> {
|
||||||
return { ...(this._metadata.unsubscribe ?? {}) };
|
return { ...(this._metadata.unsubscribe ?? {}) };
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
|
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
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 = {
|
const fullConfig: FeedConfig = {
|
||||||
title: "News",
|
title: "News",
|
||||||
@@ -16,6 +17,13 @@ const fullConfig: FeedConfig = {
|
|||||||
expires_at: 3000,
|
expires_at: 3000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const feedFrom = (metadata: FeedMetadata) =>
|
||||||
|
Feed.reconstitute(
|
||||||
|
FeedId.unchecked("a.b.42"),
|
||||||
|
fromConfigDTO(fullConfig),
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
|
||||||
describe("feed-mapper", () => {
|
describe("feed-mapper", () => {
|
||||||
it("round-trips a full config DTO through domain state unchanged", () => {
|
it("round-trips a full config DTO through domain state unchanged", () => {
|
||||||
expect(toConfigDTO(fromConfigDTO(fullConfig))).toEqual(fullConfig);
|
expect(toConfigDTO(fromConfigDTO(fullConfig))).toEqual(fullConfig);
|
||||||
@@ -32,11 +40,8 @@ describe("feed-mapper", () => {
|
|||||||
expect(state.blockedSenders).toEqual([]);
|
expect(state.blockedSenders).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("projects the feeds:list item from domain state", () => {
|
it("projects the feeds:list item from an empty feed aggregate", () => {
|
||||||
const item = toListItemDTO(
|
const item = toListItemDTO(feedFrom({ emails: [] }));
|
||||||
FeedId.unchecked("a.b.42"),
|
|
||||||
fromConfigDTO(fullConfig),
|
|
||||||
);
|
|
||||||
expect(item).toEqual({
|
expect(item).toEqual({
|
||||||
id: "a.b.42",
|
id: "a.b.42",
|
||||||
title: "News",
|
title: "News",
|
||||||
@@ -45,17 +50,33 @@ describe("feed-mapper", () => {
|
|||||||
expires_at: 3000,
|
expires_at: 3000,
|
||||||
pendingConfirmation: false,
|
pendingConfirmation: false,
|
||||||
hasNativeFeed: false,
|
hasNativeFeed: false,
|
||||||
|
emailCount: 0,
|
||||||
|
lastEmailAt: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("projects hasNativeFeed when passed", () => {
|
it("projects pendingConfirmation and hasNativeFeed from metadata", () => {
|
||||||
const item = toListItemDTO(
|
const item = toListItemDTO(
|
||||||
FeedId.unchecked("a.b.42"),
|
feedFrom({
|
||||||
fromConfigDTO(fullConfig),
|
emails: [],
|
||||||
true,
|
pendingConfirmation: true,
|
||||||
true,
|
nativeFeeds: { "n@x.com": [{ url: "https://x/rss", type: "rss" }] },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(item.pendingConfirmation).toBe(true);
|
expect(item.pendingConfirmation).toBe(true);
|
||||||
expect(item.hasNativeFeed).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FeedConfig, FeedListItem } from "../types";
|
import { FeedConfig, FeedListItem } from "../types";
|
||||||
import { FeedState } from "../domain/feed-state";
|
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
|
* 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(
|
* The Feed aggregate → the projection cached in the global `feeds:list` registry.
|
||||||
id: FeedId,
|
* Unlike the config DTO, the list item is a read-model view: it folds in the
|
||||||
state: FeedState,
|
* aggregate's metadata-derived signals (pending confirmation, native feed,
|
||||||
pendingConfirmation = false,
|
* email count/last-received) alongside the config fields, so it reads the whole
|
||||||
hasNativeFeed = false,
|
* aggregate through its intention-revealing accessors.
|
||||||
): FeedListItem {
|
*/
|
||||||
|
export function toListItemDTO(feed: Feed): FeedListItem {
|
||||||
return {
|
return {
|
||||||
id: id.value,
|
id: feed.id.value,
|
||||||
title: state.title,
|
title: feed.title,
|
||||||
description: state.description,
|
description: feed.description,
|
||||||
mailbox_id: state.mailboxId,
|
mailbox_id: feed.mailboxId.value,
|
||||||
expires_at: state.expiresAt,
|
expires_at: feed.expiresAt,
|
||||||
pendingConfirmation,
|
pendingConfirmation: feed.pendingConfirmation,
|
||||||
hasNativeFeed,
|
hasNativeFeed: feed.hasNativeFeed(),
|
||||||
|
emailCount: feed.emailCount,
|
||||||
|
lastEmailAt: feed.lastEmailAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,14 +87,7 @@ export class FeedRepository {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
||||||
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
||||||
this.upsertListEntry(
|
this.upsertListEntry(toListItemDTO(feed)),
|
||||||
toListItemDTO(
|
|
||||||
feed.id,
|
|
||||||
feed.state(),
|
|
||||||
feed.pendingConfirmation,
|
|
||||||
feed.hasNativeFeed(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
this.putInboundIndex(feed.mailboxId, feed.id),
|
this.putInboundIndex(feed.mailboxId, feed.id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -108,14 +101,7 @@ export class FeedRepository {
|
|||||||
async saveMetadata(feed: Feed): Promise<void> {
|
async saveMetadata(feed: Feed): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
||||||
this.upsertListEntry(
|
this.upsertListEntry(toListItemDTO(feed)),
|
||||||
toListItemDTO(
|
|
||||||
feed.id,
|
|
||||||
feed.state(),
|
|
||||||
feed.pendingConfirmation,
|
|
||||||
feed.hasNativeFeed(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,14 +113,7 @@ export class FeedRepository {
|
|||||||
async saveConfig(feed: Feed): Promise<void> {
|
async saveConfig(feed: Feed): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
||||||
this.upsertListEntry(
|
this.upsertListEntry(toListItemDTO(feed)),
|
||||||
toListItemDTO(
|
|
||||||
feed.id,
|
|
||||||
feed.state(),
|
|
||||||
feed.pendingConfirmation,
|
|
||||||
feed.hasNativeFeed(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
this.putInboundIndex(feed.mailboxId, feed.id),
|
this.putInboundIndex(feed.mailboxId, feed.id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1389,6 +1389,76 @@ describe("Admin Routes", () => {
|
|||||||
expect(body).toContain("pill-confirmation");
|
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 () => {
|
it("feed emails page shows confirmation-banner when pendingConfirmation is true", async () => {
|
||||||
const authCookie = await loginAndGetCookie();
|
const authCookie = await loginAndGetCookie();
|
||||||
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
|||||||
+15
-1
@@ -14,6 +14,8 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
FeedFormats,
|
FeedFormats,
|
||||||
ExpiryBadge,
|
ExpiryBadge,
|
||||||
|
LastEmail,
|
||||||
|
EmailCountBadge,
|
||||||
} from "./admin/ui";
|
} from "./admin/ui";
|
||||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
@@ -628,7 +630,7 @@ app.get("/", async (c) => {
|
|||||||
height="20"
|
height="20"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div class="feed-title-cell-text">
|
||||||
<strong class="truncate" title={titleHover}>
|
<strong class="truncate" title={titleHover}>
|
||||||
{titleDisplay}
|
{titleDisplay}
|
||||||
</strong>
|
</strong>
|
||||||
@@ -641,6 +643,10 @@ app.get("/", async (c) => {
|
|||||||
{descDisplay}
|
{descDisplay}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<LastEmail
|
||||||
|
at={feed.lastEmailAt}
|
||||||
|
count={feed.emailCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{feed.pendingConfirmation && (
|
{feed.pendingConfirmation && (
|
||||||
<ConfirmationPill feedId={feed.id} />
|
<ConfirmationPill feedId={feed.id} />
|
||||||
@@ -683,6 +689,7 @@ app.get("/", async (c) => {
|
|||||||
tabindex={-1}
|
tabindex={-1}
|
||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
|
<EmailCountBadge count={feed.emailCount} />
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -698,6 +705,7 @@ app.get("/", async (c) => {
|
|||||||
class="button button-small"
|
class="button button-small"
|
||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
|
<EmailCountBadge count={feed.emailCount} />
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -780,6 +788,10 @@ app.get("/", async (c) => {
|
|||||||
<span title={descHover}>{descDisplay}</span>
|
<span title={descHover}>{descDisplay}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<LastEmail
|
||||||
|
at={feed.lastEmailAt}
|
||||||
|
count={feed.emailCount}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom: var(--spacing-md);">
|
<div style="margin-bottom: var(--spacing-md);">
|
||||||
@@ -819,6 +831,7 @@ app.get("/", async (c) => {
|
|||||||
tabindex={-1}
|
tabindex={-1}
|
||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
|
<EmailCountBadge count={feed.emailCount} />
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -834,6 +847,7 @@ app.get("/", async (c) => {
|
|||||||
class="button button-small"
|
class="button button-small"
|
||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
|
<EmailCountBadge count={feed.emailCount} />
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -325,3 +325,38 @@ export const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => {
|
|||||||
</span>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -77,6 +77,33 @@
|
|||||||
gap: var(--spacing-sm);
|
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 {
|
.feed-description {
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export interface FeedListItem {
|
|||||||
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
||||||
pendingConfirmation?: boolean; // Projected from FeedMetadata for the dashboard
|
pendingConfirmation?: boolean; // Projected from FeedMetadata for the dashboard
|
||||||
hasNativeFeed?: boolean; // Projected from FeedMetadata for the dashboard pill
|
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)
|
// Cumulative monitoring counters (persisted as a KV singleton)
|
||||||
|
|||||||
Reference in New Issue
Block a user