feat: track received image outcomes

This commit is contained in:
jjbrunton committed 2026-08-08 18:53:11 +01:00
1 parent 50de1c180d
commit d819a0f1e6
15 files changed
+145 -21

No files matched your search

@@ -40,7 +40,7 @@ function PerformanceTable({
return ( return (
<div className="table-scroll"> <div className="table-scroll">
<table aria-label={label} className="performance-table"> <table aria-label={label} className="performance-table">
<thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Rate</th><th scope="col">Sample</th></tr></thead> <thead><tr><th scope="col">Name</th><th scope="col">Responses</th><th scope="col">Response rate</th><th scope="col">Images</th><th scope="col">Image rate</th><th scope="col">Sample</th></tr></thead>
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr key={rowLabel(row)}> <tr key={rowLabel(row)}>
@@ -50,6 +50,8 @@ function PerformanceTable({
</th> </th>
<td>{row.totalResponses}</td> <td>{row.totalResponses}</td>
<td>{row.responseRate}%</td> <td>{row.responseRate}%</td>
<td>{row.totalImages}</td>
<td>{row.imageRate}%</td>
<td><SampleSize totalSent={row.totalSent} /></td> <td><SampleSize totalSent={row.totalSent} /></td>
</tr> </tr>
))} ))}
@@ -67,6 +69,7 @@ function Trend({ rows }: { rows: readonly DailyTrend[] }) {
<time dateTime={row.date}>{row.date}</time> <time dateTime={row.date}>{row.date}</time>
<span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span> <span className="trend-track" aria-hidden="true"><span style={{ width: `${Math.max(2, Math.min(100, row.responseRate))}%` }} /></span>
<strong>{row.responseRate}%</strong> <strong>{row.responseRate}%</strong>
<span>{row.totalImages} images ({row.imageRate}%)</span>
<SampleSize totalSent={row.totalSent} /> <SampleSize totalSent={row.totalSent} />
</li> </li>
))} ))}
@@ -82,7 +85,7 @@ function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample
const source = example.subreddit ?? 'unknown source'; const source = example.subreddit ?? 'unknown source';
return ( return (
<article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}> <article aria-label={`Successful example from ${source}`} className="successful-example" key={example.id}>
<div><span className="pill success">Response received</span><small>{source}</small></div> <div><span className="pill success">Response received</span>{example.gotImage ? <span className="pill success">Image received</span> : null}<small>{source}</small></div>
{example.postTitle ? <h3>{example.postTitle}</h3> : null} {example.postTitle ? <h3>{example.postTitle}</h3> : null}
<blockquote>{example.originalMessage}</blockquote> <blockquote>{example.originalMessage}</blockquote>
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null} {example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
@@ -109,7 +112,7 @@ export function PerformancePage() {
return ( return (
<div className="page performance-page"> <div className="page performance-page">
<header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response outcomes without hiding the sample sizes behind the percentages.</p></div></header> <header className="page-header"><div><span className="eyebrow">Decision support</span><h1>Performance</h1><p>Compare response and received-image outcomes without hiding the sample sizes behind the percentages.</p></div></header>
<section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section> <section aria-labelledby="trend-heading" className="panel feature-panel"><div className="panel-heading"><div><span className="eyebrow">Last 30 days</span><h2 id="trend-heading">Response trend</h2></div></div><Trend rows={trend.data ?? []} /></section>
<div className="performance-grid"> <div className="performance-grid">
<section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section> <section aria-labelledby="subreddit-heading" className="panel feature-panel"><div className="panel-heading"><h2 id="subreddit-heading">By subreddit</h2></div><PerformanceTable label="Subreddit performance" rows={subreddits.data ?? []} rowLabel={(row) => 'subreddit' in row ? row.subreddit : row.strategyId} /></section>
@@ -10,7 +10,7 @@
.confidence-warning { color: var(--warning); } .confidence-warning { color: var(--warning); }
.sample-size { white-space: nowrap; color: var(--muted); } .sample-size { white-space: nowrap; color: var(--muted); }
.trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; } .trend-list { display: grid; gap: .65rem; padding: 0; margin: 0; list-style: none; }
.trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 6rem; align-items: center; gap: .75rem; } .trend-list li { display: grid; grid-template-columns: 6.5rem minmax(5rem, 1fr) 3rem 8rem 6rem; align-items: center; gap: .75rem; }
.trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; } .trend-track { height: .5rem; overflow: hidden; background: var(--border); border-radius: 999px; }
.trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; } .trend-track span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
.example-grid { display: grid; gap: .75rem; } .example-grid { display: grid; gap: .75rem; }
+2
View File
@@ -8,6 +8,7 @@ export const SentMessageSchema = z.strictObject({
message: z.string(), message: z.string(),
sentAt: z.number(), sentAt: z.number(),
gotResponse: z.boolean(), gotResponse: z.boolean(),
gotImage: z.boolean(),
responseCheckedAt: z.number().nullable(), responseCheckedAt: z.number().nullable(),
strategyId: z.string().nullable(), strategyId: z.string().nullable(),
chatRoomId: z.string().nullable(), chatRoomId: z.string().nullable(),
@@ -43,6 +44,7 @@ export const SuccessfulExampleSchema = z.strictObject({
responseSnippet: z.string().nullable(), responseSnippet: z.string().nullable(),
subreddit: z.string().nullable(), subreddit: z.string().nullable(),
createdAt: z.number(), createdAt: z.number(),
gotImage: z.boolean().optional(),
}); });
export const SuccessfulExamplesResponseSchema = z.array(SuccessfulExampleSchema); export const SuccessfulExamplesResponseSchema = z.array(SuccessfulExampleSchema);
+2
View File
@@ -5,6 +5,8 @@ const PerformanceFields = {
totalSent: z.number(), totalSent: z.number(),
totalResponses: z.number(), totalResponses: z.number(),
responseRate: z.number(), responseRate: z.number(),
totalImages: z.number(),
imageRate: z.number(),
}; };
export const StatsResponseSchema = z.strictObject({ export const StatsResponseSchema = z.strictObject({
@@ -86,6 +86,7 @@ describe('dashboard endpoint contracts', () => {
message: 'Hello', message: 'Hello',
sentAt: 1_700_000_000_000, sentAt: 1_700_000_000_000,
gotResponse: false, gotResponse: false,
gotImage: false,
responseCheckedAt: null, responseCheckedAt: null,
strategyId: null, strategyId: null,
chatRoomId: null, chatRoomId: null,
@@ -115,13 +116,13 @@ describe('dashboard endpoint contracts', () => {
it('validates each analytics response shape', () => { it('validates each analytics response shape', () => {
expect(SubredditPerformanceResponseSchema.safeParse([{ expect(SubredditPerformanceResponseSchema.safeParse([{
subreddit: 'example', totalSent: 10, totalResponses: 4, responseRate: 40, subreddit: 'example', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20,
}]).success).toBe(true); }]).success).toBe(true);
expect(StrategyPerformanceResponseSchema.safeParse([{ expect(StrategyPerformanceResponseSchema.safeParse([{
strategyId: 'direct', totalSent: 10, totalResponses: 4, responseRate: 40, configured: true, strategyId: 'direct', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20, configured: true,
}]).success).toBe(true); }]).success).toBe(true);
expect(DailyTrendResponseSchema.safeParse([{ expect(DailyTrendResponseSchema.safeParse([{
date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40, date: '2026-08-08', totalSent: 10, totalResponses: 4, responseRate: 40, totalImages: 2, imageRate: 20,
}]).success).toBe(true); }]).success).toBe(true);
expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: '10' }]).success).toBe(false); expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: '10' }]).success).toBe(false);
}); });
+18
View File
@@ -187,6 +187,24 @@ export async function bootstrap(
const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger); const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger);
const mediaService = new MediaService(mediaDownloader, logger); const mediaService = new MediaService(mediaDownloader, logger);
const indexedMedia = await mediaService.getMediaItems();
const receivedImageRooms = new Set(
indexedMedia
.filter((item) => item.sender.toLowerCase() !== 'me' && item.chatRoomId)
.map((item) => item.chatRoomId as string),
);
let backfilledImageOutcomes = 0;
for (const roomId of receivedImageRooms) {
const message = messagesRepo.getByChatRoomId(roomId);
const hasPerformanceContext = Boolean(message?.postId || message?.strategyId || message?.subreddit);
if (!message || !hasPerformanceContext || message.gotImage) continue;
messagesRepo.markGotImage(message.id);
backfilledImageOutcomes += 1;
}
if (backfilledImageOutcomes > 0) {
logger.info(`Backfilled ${backfilledImageOutcomes} received-image performance outcome(s) from the media index`);
}
// A 401 waits for a new extension dump instead of opening a browser login // A 401 waits for a new extension dump instead of opening a browser login
// or token-sniff flow. // or token-sniff flow.
const tokenRefresher = new MatrixTokenRefresher(); const tokenRefresher = new MatrixTokenRefresher();
+6
View File
@@ -192,6 +192,12 @@ export function runMigrations(db: Database.Database): void {
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
); );
`), `),
() => {
addColumnIfNotExists(db, 'sent_messages', 'got_image', 'INTEGER NOT NULL DEFAULT 0');
db.exec(`
CREATE INDEX IF NOT EXISTS idx_sent_messages_image ON sent_messages(got_image);
`);
},
]; ];
let currentVersion = db.pragma('user_version', { simple: true }) as number; let currentVersion = db.pragma('user_version', { simple: true }) as number;
+40 -4
View File
@@ -28,6 +28,7 @@ export interface SentMessageRecord {
message: string; message: string;
sentAt: number; sentAt: number;
gotResponse: boolean; gotResponse: boolean;
gotImage: boolean;
responseCheckedAt: number | null; responseCheckedAt: number | null;
strategyId: string | null; strategyId: string | null;
chatRoomId: string | null; chatRoomId: string | null;
@@ -47,6 +48,7 @@ export interface SuccessfulMessageRecord {
responseSnippet: string | null; responseSnippet: string | null;
subreddit: string | null; subreddit: string | null;
createdAt: number; createdAt: number;
gotImage?: boolean;
} }
export interface StrategyStats { export interface StrategyStats {
@@ -54,6 +56,8 @@ export interface StrategyStats {
totalSent: number; totalSent: number;
totalResponses: number; totalResponses: number;
responseRate: number; responseRate: number;
totalImages: number;
imageRate: number;
} }
export interface SubredditStats { export interface SubredditStats {
@@ -61,6 +65,8 @@ export interface SubredditStats {
totalSent: number; totalSent: number;
totalResponses: number; totalResponses: number;
responseRate: number; responseRate: number;
totalImages: number;
imageRate: number;
} }
export interface DailyTrend { export interface DailyTrend {
@@ -68,6 +74,8 @@ export interface DailyTrend {
totalSent: number; totalSent: number;
totalResponses: number; totalResponses: number;
responseRate: number; responseRate: number;
totalImages: number;
imageRate: number;
} }
export interface ActivityLogRecord { export interface ActivityLogRecord {
@@ -417,6 +425,7 @@ export class MessagesRepository {
private getRecentStmt: Database.Statement; private getRecentStmt: Database.Statement;
private getUncheckedStmt: Database.Statement; private getUncheckedStmt: Database.Statement;
private markResponseStmt: Database.Statement; private markResponseStmt: Database.Statement;
private markImageStmt: Database.Statement;
private markCheckedStmt: Database.Statement; private markCheckedStmt: Database.Statement;
private hasSentToStmt: Database.Statement; private hasSentToStmt: Database.Statement;
@@ -475,6 +484,12 @@ export class MessagesRepository {
UPDATE sent_messages SET got_response = 1, response_checked_at = ? WHERE id = ? UPDATE sent_messages SET got_response = 1, response_checked_at = ? WHERE id = ?
`); `);
this.markImageStmt = db.prepare(`
UPDATE sent_messages
SET got_image = 1, got_response = 1, response_checked_at = ?
WHERE id = ?
`);
this.markCheckedStmt = db.prepare(` this.markCheckedStmt = db.prepare(`
UPDATE sent_messages SET response_checked_at = ? WHERE id = ? UPDATE sent_messages SET response_checked_at = ? WHERE id = ?
`); `);
@@ -565,6 +580,10 @@ export class MessagesRepository {
this.markResponseStmt.run(Date.now(), messageId); this.markResponseStmt.run(Date.now(), messageId);
} }
markGotImage(messageId: number): void {
this.markImageStmt.run(Date.now(), messageId);
}
markChecked(messageId: number): void { markChecked(messageId: number): void {
this.markCheckedStmt.run(Date.now(), messageId); this.markCheckedStmt.run(Date.now(), messageId);
} }
@@ -578,7 +597,8 @@ export class MessagesRepository {
SELECT SELECT
strategy_id, strategy_id,
COUNT(*) as total_sent, COUNT(*) as total_sent,
SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses,
SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images
FROM sent_messages FROM sent_messages
WHERE strategy_id IS NOT NULL WHERE strategy_id IS NOT NULL
GROUP BY strategy_id GROUP BY strategy_id
@@ -591,6 +611,10 @@ export class MessagesRepository {
responseRate: (row.total_sent as number) > 0 responseRate: (row.total_sent as number) > 0
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
: 0, : 0,
totalImages: row.total_images as number,
imageRate: (row.total_sent as number) > 0
? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100)
: 0,
})); }));
} }
@@ -599,7 +623,8 @@ export class MessagesRepository {
SELECT SELECT
pp.subreddit, pp.subreddit,
COUNT(*) as total_sent, COUNT(*) as total_sent,
SUM(CASE WHEN sm.got_response = 1 THEN 1 ELSE 0 END) as total_responses SUM(CASE WHEN sm.got_response = 1 THEN 1 ELSE 0 END) as total_responses,
SUM(CASE WHEN sm.got_image = 1 THEN 1 ELSE 0 END) as total_images
FROM sent_messages sm FROM sent_messages sm
JOIN processed_posts pp ON sm.post_id = pp.id JOIN processed_posts pp ON sm.post_id = pp.id
GROUP BY pp.subreddit GROUP BY pp.subreddit
@@ -613,6 +638,10 @@ export class MessagesRepository {
responseRate: (row.total_sent as number) > 0 responseRate: (row.total_sent as number) > 0
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
: 0, : 0,
totalImages: row.total_images as number,
imageRate: (row.total_sent as number) > 0
? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100)
: 0,
})); }));
} }
@@ -622,7 +651,8 @@ export class MessagesRepository {
SELECT SELECT
date(sent_at / 1000, 'unixepoch') as day, date(sent_at / 1000, 'unixepoch') as day,
COUNT(*) as total_sent, COUNT(*) as total_sent,
SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses SUM(CASE WHEN got_response = 1 THEN 1 ELSE 0 END) as total_responses,
SUM(CASE WHEN got_image = 1 THEN 1 ELSE 0 END) as total_images
FROM sent_messages FROM sent_messages
WHERE sent_at > ? WHERE sent_at > ?
AND (post_id IS NOT NULL OR strategy_id IS NOT NULL) AND (post_id IS NOT NULL OR strategy_id IS NOT NULL)
@@ -637,6 +667,10 @@ export class MessagesRepository {
responseRate: (row.total_sent as number) > 0 responseRate: (row.total_sent as number) > 0
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100) ? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
: 0, : 0,
totalImages: row.total_images as number,
imageRate: (row.total_sent as number) > 0
? Math.round(((row.total_images as number) / (row.total_sent as number)) * 100)
: 0,
})); }));
} }
@@ -649,6 +683,7 @@ export class MessagesRepository {
message: row.message as string, message: row.message as string,
sentAt: row.sent_at as number, sentAt: row.sent_at as number,
gotResponse: Boolean(row.got_response), gotResponse: Boolean(row.got_response),
gotImage: Boolean(row.got_image),
responseCheckedAt: row.response_checked_at as number | null, responseCheckedAt: row.response_checked_at as number | null,
strategyId: (row.strategy_id as string) || null, strategyId: (row.strategy_id as string) || null,
chatRoomId: (row.chat_room_id as string) ?? null, chatRoomId: (row.chat_room_id as string) ?? null,
@@ -680,7 +715,7 @@ export class SuccessfulMessagesRepository {
`); `);
this.getAttributedRecentStmt = db.prepare(` this.getAttributedRecentStmt = db.prepare(`
SELECT successful_messages.* SELECT successful_messages.*, COALESCE(sm.got_image, 0) AS got_image
FROM successful_messages FROM successful_messages
LEFT JOIN sent_messages sm ON sm.id = successful_messages.sent_message_id LEFT JOIN sent_messages sm ON sm.id = successful_messages.sent_message_id
WHERE successful_messages.subreddit IS NOT NULL WHERE successful_messages.subreddit IS NOT NULL
@@ -762,6 +797,7 @@ export class SuccessfulMessagesRepository {
responseSnippet: row.response_snippet as string | null, responseSnippet: row.response_snippet as string | null,
subreddit: row.subreddit as string | null, subreddit: row.subreddit as string | null,
createdAt: row.created_at as number, createdAt: row.created_at as number,
...(row.got_image === undefined ? {} : { gotImage: Boolean(row.got_image) }),
}; };
} }
} }
+23
View File
@@ -737,6 +737,9 @@ export class ChatService {
try { try {
const withMedia = await this.mediaService.attachMedia(chat, mediaMessages); const withMedia = await this.mediaService.attachMedia(chat, mediaMessages);
this.mergeNewMessages(chat, withMedia); this.mergeNewMessages(chat, withMedia);
if (withMedia.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0)) {
this.recordReceivedImage(chat);
}
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(
`[chat] delta media attach failed for ${roomId}: ${(err as Error).message}`, `[chat] delta media attach failed for ${roomId}: ${(err as Error).message}`,
@@ -848,6 +851,9 @@ export class ChatService {
if (firstSelf) chat.originMessage = firstSelf.text; if (firstSelf) chat.originMessage = firstSelf.text;
const hasReply = chat.messages.some((m) => !m.isSelf); const hasReply = chat.messages.some((m) => !m.isSelf);
const hasReceivedImage = chat.messages.some(
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
);
let performanceMessage: SentMessageRecord | undefined; let performanceMessage: SentMessageRecord | undefined;
if (firstSelf && chat.roomId && this.messagesRepo) { if (firstSelf && chat.roomId && this.messagesRepo) {
@@ -861,6 +867,9 @@ export class ChatService {
if (hasReply && !existing.gotResponse) { if (hasReply && !existing.gotResponse) {
this.messagesRepo.markGotResponse(existing.id); this.messagesRepo.markGotResponse(existing.id);
} }
if (hasReceivedImage && !existing.gotImage) {
this.messagesRepo.markGotImage(existing.id);
}
} }
} catch (err) { } catch (err) {
this.logger.warn('[chat] failed to associate reply with performance context:', err); this.logger.warn('[chat] failed to associate reply with performance context:', err);
@@ -897,6 +906,20 @@ export class ChatService {
} }
} }
private recordReceivedImage(chat: ActiveChat): void {
if (!chat.roomId || !this.messagesRepo) return;
try {
const existing = this.messagesRepo.getByChatRoomId(chat.roomId);
const hasPerformanceContext = Boolean(
existing?.postId || existing?.strategyId || existing?.subreddit,
);
if (!existing || !hasPerformanceContext) return;
if (!existing.gotImage) this.messagesRepo.markGotImage(existing.id);
} catch (err) {
this.logger.warn('[chat] failed to associate received image with performance context:', err);
}
}
/** /**
* Project a Matrix room (from /sync) into an ActiveChat. If `prev` is * Project a Matrix room (from /sync) into an ActiveChat. If `prev` is
* supplied, fields it carries (messages, pendingReply, originMessage) * supplied, fields it carries (messages, pendingReply, originMessage)
+2
View File
@@ -83,6 +83,8 @@ export class StrategySelector {
totalSent: s?.totalSent || 0, totalSent: s?.totalSent || 0,
totalResponses: s?.totalResponses || 0, totalResponses: s?.totalResponses || 0,
responseRate: s?.responseRate || 0, responseRate: s?.responseRate || 0,
totalImages: s?.totalImages || 0,
imageRate: s?.imageRate || 0,
configured: true, configured: true,
}); });
} }
+20 -2
View File
@@ -8,7 +8,7 @@ import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { MediaService } from '../src/services/media.js'; import { MediaService } from '../src/services/media.js';
import type { MediaDownloader } from '../src/services/media.js'; import type { MediaDownloader } from '../src/services/media.js';
import { createMockLogger } from './mockLogger.js'; import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth } from './testUtils.js'; import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js';
const creds = { const creds = {
homeserver: 'https://matrix.example.test', homeserver: 'https://matrix.example.test',
@@ -79,8 +79,18 @@ describe('ChatService delta sync — media attachment', () => {
const logger = createMockLogger(); const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-delta-media-merge-')); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-delta-media-merge-'));
const cacheFile = path.join(tmpDir, 'chat-cache.json'); const cacheFile = path.join(tmpDir, 'chat-cache.json');
const db = createTestDb();
const repos = createTestRepositories(db);
try { try {
const now = Date.now(); const now = Date.now();
repos.postsRepo.insert({
id: 'source-post', subreddit: 'example', author: 'alice', title: 'Source',
imageUrl: null, processedAt: now - 10_000,
});
repos.messagesRepo.insert({
postId: 'source-post', recipient: 'alice', subject: 'alice', message: 'hello',
sentAt: now - 9_000, strategyId: 'direct', chatRoomId: '!room1:example.test',
});
const existing = { const existing = {
version: 1, version: 1,
savedAt: now - 60_000, savedAt: now - 60_000,
@@ -129,7 +139,7 @@ describe('ChatService delta sync — media attachment', () => {
const media = new MediaService(buildDownloader(), logger, tmpDir); const media = new MediaService(buildDownloader(), logger, tmpDir);
const cache = new ChatCache(logger, cacheFile); const cache = new ChatCache(logger, cacheFile);
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth); const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache, repos.messagesRepo, undefined, authenticatedAccountAuth);
await svc.loadCache(); await svc.loadCache();
// refreshTopChats always runs a /sync (unlike getActiveChats, which can // refreshTopChats always runs a /sync (unlike getActiveChats, which can
@@ -145,7 +155,15 @@ describe('ChatService delta sync — media attachment', () => {
expect(imageMsg).toBeDefined(); expect(imageMsg).toBeDefined();
expect(imageMsg!.media).toBeDefined(); expect(imageMsg!.media).toBeDefined();
expect(imageMsg!.media!.length).toBe(1); expect(imageMsg!.media!.length).toBe(1);
const performanceMessage = repos.messagesRepo.getByChatRoomId('!room1:example.test');
expect(performanceMessage).toMatchObject({ gotResponse: true, gotImage: true });
expect(repos.messagesRepo.getStrategyStats()).toEqual([
expect.objectContaining({
strategyId: 'direct', totalResponses: 1, totalImages: 1, responseRate: 100, imageRate: 100,
}),
]);
} finally { } finally {
db.close();
await fs.rm(tmpDir, { recursive: true, force: true }); await fs.rm(tmpDir, { recursive: true, force: true });
} }
}); });
+1 -1
View File
@@ -155,7 +155,7 @@ describe('dashboard foundation', () => {
), ),
http.get('/api/messages', () => HttpResponse.json([{ http.get('/api/messages', () => HttpResponse.json([{
id: 1, postId: 'post-1', recipient: 'alice', subject: 'Hello', message: 'A recent opener', id: 1, postId: 'post-1', recipient: 'alice', subject: 'Hello', message: 'A recent opener',
sentAt: Date.now(), gotResponse: true, responseCheckedAt: Date.now(), strategyId: null, chatRoomId: null, sentAt: Date.now(), gotResponse: true, gotImage: false, responseCheckedAt: Date.now(), strategyId: null, chatRoomId: null,
}])), }])),
); );
@@ -36,21 +36,21 @@ describe('PerformancePage', () => {
server.use( server.use(
http.get('/api/analytics/by-subreddit', () => http.get('/api/analytics/by-subreddit', () =>
HttpResponse.json([ HttpResponse.json([
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30 }, { subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30, totalImages: 6, imageRate: 15 },
{ subreddit: 'tiny', totalSent: 3, totalResponses: 2, responseRate: 67 }, { subreddit: 'tiny', totalSent: 3, totalResponses: 2, responseRate: 67, totalImages: 1, imageRate: 33 },
]), ]),
), ),
http.get('/api/analytics/by-strategy', () => http.get('/api/analytics/by-strategy', () =>
HttpResponse.json([ HttpResponse.json([
{ strategyId: 'friendly', totalSent: 25, totalResponses: 10, responseRate: 40, configured: true }, { strategyId: 'friendly', totalSent: 25, totalResponses: 10, responseRate: 40, totalImages: 5, imageRate: 20, configured: true },
{ strategyId: 'new-test', totalSent: 2, totalResponses: 1, responseRate: 50 }, { strategyId: 'new-test', totalSent: 2, totalResponses: 1, responseRate: 50, totalImages: 1, imageRate: 50 },
]), ]),
), ),
http.get('/api/analytics/trend', ({ request }) => { http.get('/api/analytics/trend', ({ request }) => {
expect(new URL(request.url).searchParams.get('days')).toBe('30'); expect(new URL(request.url).searchParams.get('days')).toBe('30');
return HttpResponse.json([ return HttpResponse.json([
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20 }, { date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20, totalImages: 1, imageRate: 10 },
{ date: '2026-08-08', totalSent: 8, totalResponses: 4, responseRate: 50 }, { date: '2026-08-08', totalSent: 8, totalResponses: 4, responseRate: 50, totalImages: 2, imageRate: 25 },
]); ]);
}), }),
http.get('/api/successful', () => http.get('/api/successful', () =>
@@ -64,6 +64,7 @@ describe('PerformancePage', () => {
responseSnippet: 'That sounds lovely!', responseSnippet: 'That sounds lovely!',
subreddit: 'askuk', subreddit: 'askuk',
createdAt: Date.parse('2026-08-08T10:00:00Z'), createdAt: Date.parse('2026-08-08T10:00:00Z'),
gotImage: true,
}, },
]), ]),
), ),
@@ -76,12 +77,15 @@ describe('PerformancePage', () => {
expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument(); expect(within(subredditTable).getByText('n = 40')).toBeInTheDocument();
expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument(); expect(within(subredditTable).getByText('n = 3')).toBeInTheDocument();
expect(within(subredditTable).getByText('Low confidence')).toBeInTheDocument(); expect(within(subredditTable).getByText('Low confidence')).toBeInTheDocument();
expect(within(subredditTable).getByRole('columnheader', { name: 'Images' })).toBeInTheDocument();
expect(within(subredditTable).getByText('15%')).toBeInTheDocument();
const strategyTable = screen.getByRole('table', { name: 'Strategy performance' }); const strategyTable = screen.getByRole('table', { name: 'Strategy performance' });
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument(); expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
expect(within(strategyTable).getByText('Configured')).toBeInTheDocument(); expect(within(strategyTable).getByText('Configured')).toBeInTheDocument();
expect(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%'); expect(screen.getByRole('list', { name: '30 day response trend' })).toHaveTextContent('50%');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener'); expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('A thoughtful opener');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!'); expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('That sounds lovely!');
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('Image received');
}); });
}); });
+2
View File
@@ -81,6 +81,7 @@ describe('Database Client', () => {
const columnNames = columns.map(c => c.name); const columnNames = columns.map(c => c.name);
expect(columnNames).toContain('got_response'); expect(columnNames).toContain('got_response');
expect(columnNames).toContain('response_checked_at'); expect(columnNames).toContain('response_checked_at');
expect(columnNames).toContain('got_image');
}); });
it('adds strategy_id column', () => { it('adds strategy_id column', () => {
@@ -157,6 +158,7 @@ describe('Database Client', () => {
expect(columnMap.get('sent_at')?.notnull).toBe(1); expect(columnMap.get('sent_at')?.notnull).toBe(1);
expect(columnMap.get('post_id')?.notnull).toBe(0); // nullable — chat rows have no post expect(columnMap.get('post_id')?.notnull).toBe(0); // nullable — chat rows have no post
expect(columnMap.get('chat_room_id')).toBeDefined(); expect(columnMap.get('chat_room_id')).toBeDefined();
expect(columnMap.get('got_image')).toBeDefined();
}); });
it('successful_messages has correct schema', () => { it('successful_messages has correct schema', () => {
+8 -1
View File
@@ -199,10 +199,12 @@ describe('MessagesRepository', () => {
expect(row!.chatRoomId).toBe('!room123:reddit.com'); expect(row!.chatRoomId).toBe('!room123:reddit.com');
expect(row!.recipient).toBe('alice'); expect(row!.recipient).toBe('alice');
expect(row!.gotResponse).toBe(false); expect(row!.gotResponse).toBe(false);
expect(row!.gotImage).toBe(false);
messagesRepo.markGotResponse(id); messagesRepo.markGotImage(id);
const updated = messagesRepo.getByChatRoomId('!room123:reddit.com'); const updated = messagesRepo.getByChatRoomId('!room123:reddit.com');
expect(updated!.gotResponse).toBe(true); expect(updated!.gotResponse).toBe(true);
expect(updated!.gotImage).toBe(true);
}); });
it('keeps unassociated chat history out of performance trends', () => { it('keeps unassociated chat history out of performance trends', () => {
@@ -353,6 +355,7 @@ describe('MessagesRepository', () => {
messagesRepo.markGotResponse(1); messagesRepo.markGotResponse(1);
messagesRepo.markGotResponse(3); messagesRepo.markGotResponse(3);
messagesRepo.markGotImage(1);
const stats = messagesRepo.getStrategyStats(); const stats = messagesRepo.getStrategyStats();
expect(stats.length).toBe(2); expect(stats.length).toBe(2);
@@ -361,11 +364,15 @@ describe('MessagesRepository', () => {
expect(variantA!.totalSent).toBe(2); expect(variantA!.totalSent).toBe(2);
expect(variantA!.totalResponses).toBe(1); expect(variantA!.totalResponses).toBe(1);
expect(variantA!.responseRate).toBe(50); expect(variantA!.responseRate).toBe(50);
expect(variantA!.totalImages).toBe(1);
expect(variantA!.imageRate).toBe(50);
const variantB = stats.find(s => s.strategyId === 'variant-b'); const variantB = stats.find(s => s.strategyId === 'variant-b');
expect(variantB!.totalSent).toBe(1); expect(variantB!.totalSent).toBe(1);
expect(variantB!.totalResponses).toBe(1); expect(variantB!.totalResponses).toBe(1);
expect(variantB!.responseRate).toBe(100); expect(variantB!.responseRate).toBe(100);
expect(variantB!.totalImages).toBe(0);
expect(variantB!.imageRate).toBe(0);
}); });
it('excludes messages without strategy_id from stats', () => { it('excludes messages without strategy_id from stats', () => {