diff --git a/apps/dashboard/src/components/origin-post.tsx b/apps/dashboard/src/components/origin-post.tsx index f0a799f..2423a51 100644 --- a/apps/dashboard/src/components/origin-post.tsx +++ b/apps/dashboard/src/components/origin-post.tsx @@ -5,6 +5,32 @@ export interface OriginPost { url: string; imageUrl?: string | null; subreddit?: string; + postedAt?: number; +} + +export function formatPostAge(postedAt: number, now = Date.now()): string { + const elapsedSeconds = Math.max(0, Math.floor((now - postedAt) / 1000)); + if (elapsedSeconds < 60) return 'now'; + const elapsedMinutes = Math.floor(elapsedSeconds / 60); + if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`; + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) return `${elapsedHours}h ago`; + const elapsedDays = Math.floor(elapsedHours / 24); + if (elapsedDays < 30) return `${elapsedDays}d ago`; + const elapsedMonths = Math.floor(elapsedDays / 30); + if (elapsedMonths < 12) return `${elapsedMonths}mo ago`; + return `${Math.floor(elapsedMonths / 12)}y ago`; +} + +function PostOriginMeta({ post }: { post: OriginPost }) { + return ( + + Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'} + {post.postedAt ? ( + <> ยท + ) : null} + + ); } export function OriginPostCard({ post }: { post: OriginPost }) { @@ -21,7 +47,7 @@ export function OriginPostCard({ post }: { post: OriginPost }) { ) : null} - Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'} + {post.title} Open original post @@ -69,7 +95,7 @@ export function OriginPostButton({ children, post }: { children: ReactNode; post {post.imageUrl ? {`Source : null}
- {post.subreddit ? r/{post.subreddit} : null} +

{post.title}

Open Reddit post
diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index 8bf4973..2718d9a 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -229,6 +229,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta url: conversation.triggeringPost.url, imageUrl: conversation.triggeringPost.imageUrl, subreddit: conversation.triggeringPost.subreddit, + postedAt: conversation.triggeringPost.postedAt, }} /> ) : null} diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts index 22f4065..7ef769b 100644 --- a/packages/api-contracts/src/chats.ts +++ b/packages/api-contracts/src/chats.ts @@ -68,6 +68,7 @@ export const ConversationDetailResponseSchema = ActiveChatSchema.extend({ title: z.string().optional(), url: z.string(), imageUrl: z.string().nullable().optional(), + postedAt: z.number().optional(), sentAt: z.number(), }).nullable(), automation: z.strictObject({ diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts index e99ce6f..5a5c7a5 100644 --- a/packages/api-contracts/tests/contracts.test.ts +++ b/packages/api-contracts/tests/contracts.test.ts @@ -172,7 +172,8 @@ describe('dashboard endpoint contracts', () => { ...chat, triggeringPost: { id: 'post-1', subreddit: 'example', title: 'Example', - url: 'https://www.reddit.com/r/example/comments/post-1/', imageUrl: 'https://i.redd.it/example.jpg', sentAt: 1_700_000_000_000, + url: 'https://www.reddit.com/r/example/comments/post-1/', imageUrl: 'https://i.redd.it/example.jpg', + postedAt: 1_699_999_000_000, sentAt: 1_700_000_000_000, }, automation: { turnsUsed: 1, remainingTurns: 2, status: 'active', handoffReason: null }, syncInProgress: null, diff --git a/src/database/client.ts b/src/database/client.ts index 897c256..756d221 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -36,6 +36,7 @@ export function runMigrations(db: Database.Database): void { image_url TEXT, image_analysis TEXT, generated_message TEXT, + post_created_at INTEGER, processed_at INTEGER NOT NULL, message_sent INTEGER DEFAULT 0, error TEXT @@ -272,6 +273,9 @@ export function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_ai_usage_model ON ai_usage(actual_model, requested_model, created_at DESC); CREATE INDEX IF NOT EXISTS idx_ai_usage_account ON ai_usage(account_id, created_at DESC); `), + () => { + addColumnIfNotExists(db, 'processed_posts', 'post_created_at', 'INTEGER'); + }, ]; let currentVersion = db.pragma('user_version', { simple: true }) as number; diff --git a/src/database/repositories.ts b/src/database/repositories.ts index 1b2671b..ebb40a2 100644 --- a/src/database/repositories.ts +++ b/src/database/repositories.ts @@ -16,6 +16,7 @@ export interface ProcessedPostRecord { imageUrl: string | null; imageAnalysis: string | null; generatedMessage: string | null; + postCreatedAt: number | null; processedAt: number; messageSent: boolean; error: string | null; @@ -43,6 +44,7 @@ export interface SentMessageRecord { postUrl?: string; postImageUrl?: string; postImageAnalysis?: string; + postCreatedAt?: number; } export interface SuccessfulMessageRecord { @@ -418,8 +420,9 @@ export class PostsRepository { private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID, ) { this.insertStmt = db.prepare(` - INSERT INTO processed_posts (account_id, id, subreddit, author, title, post_url, image_url, processed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO processed_posts ( + account_id, id, subreddit, author, title, post_url, image_url, post_created_at, processed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE account_id = ? AND id = ?`); @@ -462,6 +465,7 @@ export class PostsRepository { title: string; postUrl?: string | null; imageUrl: string | null; + postCreatedAt?: number | null; processedAt: number; }): void { this.insertStmt.run( @@ -472,6 +476,7 @@ export class PostsRepository { post.title, post.postUrl ?? null, post.imageUrl, + post.postCreatedAt ?? null, post.processedAt ); } @@ -501,6 +506,7 @@ export class PostsRepository { imageUrl: row.image_url as string | null, imageAnalysis: row.image_analysis as string | null, generatedMessage: row.generated_message as string | null, + postCreatedAt: (row.post_created_at as number | null) ?? null, processedAt: row.processed_at as number, messageSent: Boolean(row.message_sent), error: row.error as string | null, @@ -518,6 +524,7 @@ export class PostsRepository { imageUrl: row.image_url as string | null, imageAnalysis: row.image_analysis as string | null, generatedMessage: row.generated_message as string | null, + postCreatedAt: (row.post_created_at as number | null) ?? null, processedAt: row.processed_at as number, messageSent: Boolean(row.message_sent), error: row.error as string | null, @@ -556,7 +563,7 @@ export class MessagesRepository { this.getByChatRoomIdStmt = db.prepare(` SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url, - pp.image_analysis AS post_image_analysis + pp.image_analysis AS post_image_analysis, pp.post_created_at AS post_created_at FROM sent_messages sm LEFT JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id WHERE sm.account_id = ? AND sm.chat_room_id = ? @@ -568,7 +575,7 @@ export class MessagesRepository { this.getLatestByRecipientStmt = db.prepare(` SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url, - pp.image_analysis AS post_image_analysis + pp.image_analysis AS post_image_analysis, pp.post_created_at AS post_created_at FROM sent_messages sm LEFT JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id WHERE sm.account_id = ? AND sm.recipient = ? COLLATE NOCASE @@ -578,7 +585,7 @@ export class MessagesRepository { this.getRecentStmt = db.prepare(` SELECT sm.*, pp.subreddit, pp.title AS post_title, pp.post_url, pp.image_url AS post_image_url, - pp.image_analysis AS post_image_analysis + pp.image_analysis AS post_image_analysis, pp.post_created_at AS post_created_at FROM sent_messages sm LEFT JOIN processed_posts pp ON sm.account_id = pp.account_id AND sm.post_id = pp.id WHERE sm.account_id = ? @@ -909,6 +916,7 @@ export class MessagesRepository { postUrl: (row.post_url as string) || undefined, postImageUrl: (row.post_image_url as string) || undefined, postImageAnalysis: (row.post_image_analysis as string) || undefined, + postCreatedAt: (row.post_created_at as number) || undefined, }; } } diff --git a/src/main-loop.ts b/src/main-loop.ts index 4a67924..b8ee13f 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -256,6 +256,7 @@ export async function runMainLoop(app: AppContainer): Promise { title: post.title, postUrl, imageUrl: postImageUrl, + postCreatedAt: post.created_utc * 1000, processedAt: Date.now(), }); diff --git a/src/services/monitor.ts b/src/services/monitor.ts index 49d9cbe..2d95ced 100644 --- a/src/services/monitor.ts +++ b/src/services/monitor.ts @@ -274,6 +274,7 @@ export class MonitorService { title: post.title, postUrl: this.redditApi.getPostUrl(post), imageUrl: subredditType === 'image' ? this.redditApi.getImageUrl(post) : null, + postCreatedAt: post.created_utc * 1000, processedAt: Date.now(), }); this.postsRepo.setError(post.id, reason); diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index f79d446..3ee9c2f 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -288,7 +288,8 @@ class AccountRuntimeLoop { const imageUrl = isChat ? null : redditApi.getImageUrl(post); postsRepo.insert({ id: post.id, subreddit: post.subreddit, author: post.author, title: post.title, - postUrl: redditApi.getPostUrl(post), imageUrl, processedAt: Date.now(), + postUrl: redditApi.getPostUrl(post), imageUrl, + postCreatedAt: post.created_utc * 1000, processedAt: Date.now(), }); // Seller screening is performed once by the shared MonitorService before // assignment. Do not repeat its profile request for every recipient. diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts index 28fe9f8..1c44250 100644 --- a/src/web/routes/conversations.ts +++ b/src/web/routes/conversations.ts @@ -168,6 +168,7 @@ export function createConversationsRouter(ctx: WebContext): Router { title?: string; url: string; imageUrl: string | null; + postedAt?: number; sentAt: number; } | null = null; let automatedOpener: string | null = null; @@ -188,6 +189,7 @@ export function createConversationsRouter(ctx: WebContext): Router { title: sent.postTitle, url, imageUrl: sent.postImageUrl ?? null, + ...(sent.postCreatedAt ? { postedAt: sent.postCreatedAt } : {}), sentAt: sent.sentAt, }; } diff --git a/tests/dashboard/conversations.test.tsx b/tests/dashboard/conversations.test.tsx index 4247eb4..6970f51 100644 --- a/tests/dashboard/conversations.test.tsx +++ b/tests/dashboard/conversations.test.tsx @@ -49,6 +49,7 @@ function detailFixture(chat = alice) { title: 'Looking for a chat', url: 'https://reddit.com/r/casualconversation/comments/post-1', imageUrl: 'https://i.redd.it/source-post.jpg', + postedAt: Date.now() - (2 * 60 * 60 * 1000), sentAt: 1_785_999_800_000, }, automation: { turnsUsed: 0, remainingTurns: 3, status: 'active', handoffReason: null }, @@ -142,6 +143,7 @@ describe('ConversationsPage', () => { 'src', 'https://i.redd.it/source-post.jpg', ); + expect(screen.getByText('2h ago')).toBeInTheDocument(); expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred'); await user.click(screen.getByRole('button', { name: 'Reveal original image' })); expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred'); diff --git a/tests/database.test.ts b/tests/database.test.ts index 6ebfae7..a257a2a 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -156,7 +156,7 @@ describe('Database Client', () => { DROP TABLE conversation_automation_claims; DROP TABLE ai_usage; `); - db.pragma(`user_version = ${latestVersion - 2}`); + db.pragma(`user_version = ${latestVersion - 3}`); runMigrations(db); expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all()) @@ -176,6 +176,7 @@ describe('Database Client', () => { expect(columnMap.get('subreddit')?.notnull).toBe(1); expect(columnMap.get('author')?.notnull).toBe(1); expect(columnMap.get('title')?.notnull).toBe(1); + expect(columnMap.get('post_created_at')).toBeDefined(); expect(columnMap.get('processed_at')?.notnull).toBe(1); expect(columnMap.get('message_sent')).toBeDefined(); expect(columnMap.get('error')).toBeDefined(); diff --git a/tests/repositories.test.ts b/tests/repositories.test.ts index 90624cd..9316786 100644 --- a/tests/repositories.test.ts +++ b/tests/repositories.test.ts @@ -31,6 +31,7 @@ describe('PostsRepository', () => { title: 'Test Post', postUrl: 'https://www.reddit.com/r/test/comments/post1/test_post/', imageUrl: 'http://example.com/img.jpg', + postCreatedAt: 1_700_000_000_000, processedAt: Date.now(), }); @@ -42,6 +43,7 @@ describe('PostsRepository', () => { expect(post!.title).toBe('Test Post'); expect(post!.postUrl).toBe('https://www.reddit.com/r/test/comments/post1/test_post/'); expect(post!.imageUrl).toBe('http://example.com/img.jpg'); + expect(post!.postCreatedAt).toBe(1_700_000_000_000); expect(post!.messageSent).toBe(false); expect(post!.imageAnalysis).toBeNull(); expect(post!.error).toBeNull(); @@ -314,6 +316,7 @@ describe('MessagesRepository', () => { title: 'Second post', postUrl: 'https://www.reddit.com/r/gonewild/comments/post-b/second_post/', imageUrl: null, + postCreatedAt: 1_700_000_000_000, processedAt: Date.now(), }); @@ -327,6 +330,7 @@ describe('MessagesRepository', () => { expect(latest!.subreddit).toBe('gonewild'); expect(latest!.postTitle).toBe('Second post'); expect(latest!.postUrl).toBe('https://www.reddit.com/r/gonewild/comments/post-b/second_post/'); + expect(latest!.postCreatedAt).toBe(1_700_000_000_000); expect(messagesRepo.getByChatRoomId('room-b')).toMatchObject({ postId: 'post-b', postTitle: 'Second post' }); expect(messagesRepo.getLatestByRecipient('ALICE')).toBeDefined(); diff --git a/tests/web/conversations-route.test.ts b/tests/web/conversations-route.test.ts index b9ae6b7..0a9aaac 100644 --- a/tests/web/conversations-route.test.ts +++ b/tests/web/conversations-route.test.ts @@ -296,6 +296,7 @@ describe('conversations route', () => { chatRoomId: 'room-alice', subreddit: 'example', postTitle: 'Exact source post', postUrl: 'https://www.reddit.com/r/example/comments/post-exact/exact_source_post/', postImageUrl: 'https://i.redd.it/exact-source.jpg', + postCreatedAt: 8, }; const latestRecipientOrigin = { ...roomOrigin, postId: 'wrong-latest-post', postTitle: 'Wrong post' }; const context = { @@ -318,7 +319,7 @@ describe('conversations route', () => { const body = await response.json(); expect(body.triggeringPost).toEqual({ id: 'post-exact', subreddit: 'example', title: 'Exact source post', - url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, sentAt: 9, + url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, postedAt: 8, sentAt: 9, }); expect(body.messages[0]).toMatchObject({ text: 'Hello', isAi: true }); expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();