diff --git a/MANUAL.md b/MANUAL.md index 24cddab..2ac0def 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -48,6 +48,13 @@ The normal loop is: The local Tend runtime owns feed state. Codex Desktop owns the agent threads and connector access. +### Email dates + +Email cards show the received date and time from their saved source metadata beneath the title, including older cards with that evidence. +Times use your browser's timezone and include the timezone label. +Cards citing several email threads show each matched thread's subject and date. +Collection and card creation times are never substituted for an email date. + ## Creating And Connecting A Feed ### Create The Feed diff --git a/docs/pr-images/email-date-after.png b/docs/pr-images/email-date-after.png new file mode 100644 index 0000000..b92538f Binary files /dev/null and b/docs/pr-images/email-date-after.png differ diff --git a/docs/pr-images/email-date-before.png b/docs/pr-images/email-date-before.png new file mode 100644 index 0000000..3df4c2f Binary files /dev/null and b/docs/pr-images/email-date-before.png differ diff --git a/server/store.ts b/server/store.ts index 77b962a..b5d95df 100644 --- a/server/store.ts +++ b/server/store.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { appendFile, mkdir, readdir, readFile, rename, stat } from "node:fs/promises"; import path from "node:path"; +import { sourceEmailDates } from "../shared/emailTimestamp"; import type { AgentPresence, AgentPresenceLiveness, @@ -450,6 +451,21 @@ export class AttentionStore { this.readDrainState(feedId), ]); cards.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + const emailRunIds = new Set(cards.flatMap((card) => card.sourceRunIds ?? [])); + const emailSnapshots = new Map(await Promise.all(runs.filter((run) => emailRunIds.has(run.id)).map(async (run) => { + try { + const snapshots = await this.readSourceSnapshots(run); + return [run.id, snapshots.map((snapshot) => snapshot.value)] as const; + } catch { + console.warn(`Could not load email dates for source run ${run.id}.`); + return [run.id, []] as const; + } + }))); + for (const card of cards) { + const dates = sourceEmailDates(card, (card.sourceRunIds ?? []).flatMap((id) => emailSnapshots.get(id) ?? [])); + if (dates.length) card.emailDates = dates; + else delete card.emailDates; + } cards.forEach(projectReadingPresentation); runs.sort((a, b) => (a.completedAt ?? "").localeCompare(b.completedAt ?? "") || a.id.localeCompare(b.id)); routineActions.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); @@ -665,7 +681,8 @@ export class AttentionStore { card.updatedAt = card.reading && existing && now <= existing.updatedAt ? new Date(Date.parse(existing.updatedAt) + 1).toISOString() : now; - await this.cards.write(card); + const { emailDates: _emailDates, ...stored } = card; + await this.cards.write(stored); } async removeCard(feedId: string, cardId: string): Promise { diff --git a/shared/emailTimestamp.ts b/shared/emailTimestamp.ts new file mode 100644 index 0000000..da6eef3 --- /dev/null +++ b/shared/emailTimestamp.ts @@ -0,0 +1,42 @@ +import type { Card } from "./types"; + +export function isEmailTimestamp(value: unknown): value is string { + return typeof value === "string" + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) + && Number.isFinite(Date.parse(value)); +} + +function record(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + +export function sourceEmailDates(card: Card, snapshots: unknown[]): NonNullable { + const references = JSON.stringify([card.id, card.blocks]); + const emails = new Map[number]>(); + for (const snapshot of snapshots) { + const source = record(snapshot); + if (!source) continue; + const threadId = source.threadId ?? (Array.isArray(source.messages) ? source.id : undefined); + if (typeof threadId !== "string" || !threadId) continue; + if (card.sourceMailbox && source.sourceMailbox && source.sourceMailbox !== card.sourceMailbox) continue; + const escapedId = threadId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (!new RegExp(`(?= Date.parse(receivedAt)) continue; + emails.set(threadId, { + threadId, receivedAt, + ...(typeof source.subject === "string" ? { subject: source.subject } : {}), + }); + } + } + return [...emails.values()].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt)); +} diff --git a/shared/types.ts b/shared/types.ts index 4b6b6b6..05ff3fb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -454,6 +454,7 @@ export interface Card { eyebrow: string; why: string; sourceMailbox?: string; + emailDates?: Array<{ threadId: string; receivedAt: string; subject?: string }>; sourceRunIds?: string[]; contextInfluence?: CardContextInfluence; reading?: CardReading; diff --git a/src/feed/CardView.tsx b/src/feed/CardView.tsx index 0f2abb9..781cdf4 100644 --- a/src/feed/CardView.tsx +++ b/src/feed/CardView.tsx @@ -7,6 +7,7 @@ import type { Card, CardAction, CardBlock, FeedView, WorkItemView } from "../typ import { DetachedLink } from "../ui/DetachedLink"; import { FormattedText } from "../ui/FormattedText"; import { ReadingIdentity } from "./ReadingIdentity"; +import { EmailTimestamp } from "./EmailTimestamp"; import { ReadingPreferenceFooter } from "./ReadingPreferenceFooter"; import { visibleCardActions } from "./selectors"; @@ -460,6 +461,7 @@ function ReadingCardView({ card, active, reaction, group, preference, onVersion,

{card.title}

+

{card.why}

@@ -545,6 +547,7 @@ export function CardView({
{card.eyebrow}

{card.title}

+

diff --git a/src/feed/EmailTimestamp.tsx b/src/feed/EmailTimestamp.tsx new file mode 100644 index 0000000..46b8094 --- /dev/null +++ b/src/feed/EmailTimestamp.tsx @@ -0,0 +1,17 @@ +import { isEmailTimestamp } from "../../shared/emailTimestamp"; +import type { Card } from "../../shared/types"; + +export function EmailTimestamp({ card }: { card: Card }) { + const dates = card.emailDates?.filter((email) => isEmailTimestamp(email.receivedAt)) ?? []; + if (!dates.length) return null; + const format = new Intl.DateTimeFormat(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", timeZoneName: "short", + }); + return
{dates.map((email) => ( +
+ {dates.length > 1 ? `${email.subject ?? "Email"}: ` : "Email received "} + +
+ ))}
; +} diff --git a/src/styles.css b/src/styles.css index e31aa8b..3f67eb7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -57,6 +57,7 @@ kbd { font: 11px/1 var(--mono); color: var(--ink-3); } .attention-card.has-context-influence .card-rule { background: #b68552; } .attention-card.has-context-influence.is-active .card-rule { background: var(--blue); } .card-head { display: flex; align-items: flex-start; gap: 12px; } +.email-timestamp { margin-top: 6px; color: var(--ink-2); font-size: 12px; } .kind-dot { flex: 0 0 auto; width: 10px; height: 10px; margin-top: 7px; border-radius: 50%; background: #82909b; } .kind-dot.proposal { background: #b98649; } .eyebrow { margin-bottom: 5px; color: var(--ink-3); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } diff --git a/test/card-render.test.tsx b/test/card-render.test.tsx index f5513f7..c751ef5 100644 --- a/test/card-render.test.tsx +++ b/test/card-render.test.tsx @@ -1485,3 +1485,10 @@ test("engagement distinguishes source expansion, collapse and links without pres expect(engagementClickTarget(ui.getByRole("link", { name: "Fixture transcript" }))).toBe("source_link"); expect(engagementClickTarget(ui.getByRole("heading", { name: readingCard().title }))).toBe("card"); }); + +test("reading cards keep the source email timestamp visible outside Sources", () => { + const card = readingCard({ emailDates: [{ threadId: "abc123", receivedAt: "2026-07-15T11:47:00+02:00" }] }); + const html = renderToStaticMarkup( {}} onChanged={() => {}} onAction={() => {}} onReturnToReview={() => {}} />); + expect(html).toContain('dateTime="2026-07-15T11:47:00+02:00"'); + expect(html.indexOf("Email received")).toBeLessThan(html.indexOf("")); +}); diff --git a/test/domain.test.ts b/test/domain.test.ts index e79bc39..09fdac2 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -4073,3 +4073,25 @@ describe("local card dismissal (Tend-only, no source cleanup)", () => { expect((await store.readCard("inbox", legacy.id)).actions).toEqual(legacy.actions); }); }); + +test("feed reads project saved email dates without rewriting old cards", async () => { + const { store, domain } = await setup(); + const snapshot = { + kind: "gmail_thread_metadata", threadId: "abc123", sourceMailbox: "owner@example.com", + subject: "Workshop", receivedAt: "2026-07-15T11:47:00+02:00", + }; + const run = await domain.recordSourceRun("inbox", "gmail-inbox", [snapshot], [], {}); + await domain.recordSweepBatch("inbox", [run]); + const original = await domain.upsertCard("inbox", { + id: "email-abc123", title: "Confirm the attendee list", why: "The organizer needs a reply.", + sourceRunIds: [run], + blocks: [{ id: "source", type: "receipt", text: "Thread ID: abc123." }], + }); + const view = await store.readFeed("inbox"); + expect(view.cards.find((card) => card.id === original.id)?.emailDates).toEqual([ + { threadId: snapshot.threadId, receivedAt: snapshot.receivedAt, subject: snapshot.subject }, + ]); + expect(await store.readCard("inbox", original.id)).toEqual(original); + await store.writeCard(view.cards.find((card) => card.id === original.id)!); + expect(await store.readCard("inbox", original.id)).not.toHaveProperty("emailDates"); +}); diff --git a/test/email-timestamp.test.tsx b/test/email-timestamp.test.tsx new file mode 100644 index 0000000..9cc1dee --- /dev/null +++ b/test/email-timestamp.test.tsx @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { sourceEmailDates } from "../shared/emailTimestamp"; +import type { Card } from "../shared/types"; +import { CardView } from "../src/feed/CardView"; + +const card: Card = { + id: "mail-abc123", feedId: "inbox", kind: "attention", status: "to_review_new", + title: "Confirm the attendee list", eyebrow: "Workshop", why: "The organizer needs a reply.", + sourceMailbox: "owner@example.com", sourceRunIds: ["run-example"], + blocks: [{ id: "source", type: "receipt", text: "Thread ID: abc123." }], + readyForPass: 1, createdAt: "2026-09-16T12:00:00Z", updatedAt: "2026-09-16T12:00:00Z", history: [], +}; +const source = { + kind: "gmail_thread_metadata", threadId: "abc123", sourceMailbox: "owner@example.com", + receivedAt: "2026-07-15T11:47:00+02:00", subject: "Attendee list", +}; + +test("old cards recover email dates from their exact source thread and mailbox", () => { + expect(sourceEmailDates(card, [ + { ...source, threadId: "abc1234" }, + { ...source, sourceMailbox: "someone@example.com" }, + { ...source, receivedAt: "2026-07-15" }, + { ...source, receivedAt: "2026-07-15T11:47:00" }, + { ...source, receivedAt: "invalid" }, + { kind: "collection_receipt", collectedAt: card.createdAt }, + ])).toEqual([]); + expect(sourceEmailDates(card, [{ ...source, kind: undefined }])).toEqual([ + { threadId: source.threadId, receivedAt: source.receivedAt, subject: source.subject }, + ]); + expect(card).not.toHaveProperty("emailDates"); +}); + +test("source dates are deduplicated per thread and ordered newest first", () => { + const second = { ...source, threadId: "def456", receivedAt: "2026-07-16T12:00:00Z" }; + const referenced = { ...card, blocks: [{ id: "source", type: "receipt" as const, text: "Thread IDs: abc123 and def456." }] }; + const dates = sourceEmailDates(referenced, [source, second, source, { ...source, receivedAt: "2026-07-14T00:00:00Z" }]); + expect(dates.map((date) => date.threadId)).toEqual(["def456", "abc123"]); + expect(dates[1].receivedAt).toBe(source.receivedAt); +}); + +function render(card: Card) { + return renderToStaticMarkup( {}} onChanged={() => {}} onAction={() => {}} onReturnToReview={() => {}} />); +} + +test("the card header formats the source instant in the viewer's timezone", () => { + const dates = sourceEmailDates(card, [source]); + const html = render({ ...card, emailDates: dates }); + const expected = new Intl.DateTimeFormat(undefined, { + year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short", + }).format(new Date(source.receivedAt)); + expect(html).toContain(`dateTime="${source.receivedAt}"`); + expect(html).toContain(expected); + expect(html.indexOf("Email received")).toBeLessThan(html.indexOf("")); + expect(html).not.toContain(card.createdAt); + expect(render(card)).not.toContain(" { + const legacy = { ...card, id: "gmail-thread-thread-reviewed", sourceMailbox: undefined, + blocks: [{ id: "source", type: "evidence" as const, items: [{ label: "Email", href: "https://mail.google.com/mail/u/0/#inbox/thread-reviewed" }] }], + }; + const instant = "2026-07-15T09:47:00.000Z"; + expect(sourceEmailDates(legacy, [{ id: "thread-reviewed", history_id: "history-1", messages: [ + { id: "message-1", internalDate: String(Date.parse(instant)) }, + ] }])).toEqual([{ threadId: "thread-reviewed", receivedAt: instant }]); + expect(sourceEmailDates(legacy, [{ ...source, threadId: "thread-review" }])).toEqual([]); +});