feat: track received image outcomes
This commit is contained in:
1 parent
50de1c180d
commit
d819a0f1e6
15 files changed
+145
-21
No files matched your search
@@ -40,7 +40,7 @@ function PerformanceTable({
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
<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>
|
||||
{rows.map((row) => (
|
||||
<tr key={rowLabel(row)}>
|
||||
@@ -50,6 +50,8 @@ function PerformanceTable({
|
||||
</th>
|
||||
<td>{row.totalResponses}</td>
|
||||
<td>{row.responseRate}%</td>
|
||||
<td>{row.totalImages}</td>
|
||||
<td>{row.imageRate}%</td>
|
||||
<td><SampleSize totalSent={row.totalSent} /></td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -67,6 +69,7 @@ function Trend({ rows }: { rows: readonly DailyTrend[] }) {
|
||||
<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>
|
||||
<strong>{row.responseRate}%</strong>
|
||||
<span>{row.totalImages} images ({row.imageRate}%)</span>
|
||||
<SampleSize totalSent={row.totalSent} />
|
||||
</li>
|
||||
))}
|
||||
@@ -82,7 +85,7 @@ function SuccessfulExamples({ examples }: { examples: readonly SuccessfulExample
|
||||
const source = example.subreddit ?? 'unknown source';
|
||||
return (
|
||||
<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}
|
||||
<blockquote>{example.originalMessage}</blockquote>
|
||||
{example.responseSnippet ? <p><strong>Reply:</strong> {example.responseSnippet}</p> : null}
|
||||
@@ -109,7 +112,7 @@ export function PerformancePage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.confidence-warning { color: var(--warning); }
|
||||
.sample-size { white-space: nowrap; color: var(--muted); }
|
||||
.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 span { display: block; height: 100%; background: var(--accent); border-radius: inherit; }
|
||||
.example-grid { display: grid; gap: .75rem; }
|
||||
|
||||
@@ -8,6 +8,7 @@ export const SentMessageSchema = z.strictObject({
|
||||
message: z.string(),
|
||||
sentAt: z.number(),
|
||||
gotResponse: z.boolean(),
|
||||
gotImage: z.boolean(),
|
||||
responseCheckedAt: z.number().nullable(),
|
||||
strategyId: z.string().nullable(),
|
||||
chatRoomId: z.string().nullable(),
|
||||
@@ -43,6 +44,7 @@ export const SuccessfulExampleSchema = z.strictObject({
|
||||
responseSnippet: z.string().nullable(),
|
||||
subreddit: z.string().nullable(),
|
||||
createdAt: z.number(),
|
||||
gotImage: z.boolean().optional(),
|
||||
});
|
||||
export const SuccessfulExamplesResponseSchema = z.array(SuccessfulExampleSchema);
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ const PerformanceFields = {
|
||||
totalSent: z.number(),
|
||||
totalResponses: z.number(),
|
||||
responseRate: z.number(),
|
||||
totalImages: z.number(),
|
||||
imageRate: z.number(),
|
||||
};
|
||||
|
||||
export const StatsResponseSchema = z.strictObject({
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('dashboard endpoint contracts', () => {
|
||||
message: 'Hello',
|
||||
sentAt: 1_700_000_000_000,
|
||||
gotResponse: false,
|
||||
gotImage: false,
|
||||
responseCheckedAt: null,
|
||||
strategyId: null,
|
||||
chatRoomId: null,
|
||||
@@ -115,13 +116,13 @@ describe('dashboard endpoint contracts', () => {
|
||||
|
||||
it('validates each analytics response shape', () => {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
expect(DailyTrendResponseSchema.safeParse([{ date: '2026-08-08', totalSent: '10' }]).success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -187,6 +187,24 @@ export async function bootstrap(
|
||||
const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, 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
|
||||
// or token-sniff flow.
|
||||
const tokenRefresher = new MatrixTokenRefresher();
|
||||
|
||||
@@ -192,6 +192,12 @@ export function runMigrations(db: Database.Database): void {
|
||||
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;
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface SentMessageRecord {
|
||||
message: string;
|
||||
sentAt: number;
|
||||
gotResponse: boolean;
|
||||
gotImage: boolean;
|
||||
responseCheckedAt: number | null;
|
||||
strategyId: string | null;
|
||||
chatRoomId: string | null;
|
||||
@@ -47,6 +48,7 @@ export interface SuccessfulMessageRecord {
|
||||
responseSnippet: string | null;
|
||||
subreddit: string | null;
|
||||
createdAt: number;
|
||||
gotImage?: boolean;
|
||||
}
|
||||
|
||||
export interface StrategyStats {
|
||||
@@ -54,6 +56,8 @@ export interface StrategyStats {
|
||||
totalSent: number;
|
||||
totalResponses: number;
|
||||
responseRate: number;
|
||||
totalImages: number;
|
||||
imageRate: number;
|
||||
}
|
||||
|
||||
export interface SubredditStats {
|
||||
@@ -61,6 +65,8 @@ export interface SubredditStats {
|
||||
totalSent: number;
|
||||
totalResponses: number;
|
||||
responseRate: number;
|
||||
totalImages: number;
|
||||
imageRate: number;
|
||||
}
|
||||
|
||||
export interface DailyTrend {
|
||||
@@ -68,6 +74,8 @@ export interface DailyTrend {
|
||||
totalSent: number;
|
||||
totalResponses: number;
|
||||
responseRate: number;
|
||||
totalImages: number;
|
||||
imageRate: number;
|
||||
}
|
||||
|
||||
export interface ActivityLogRecord {
|
||||
@@ -417,6 +425,7 @@ export class MessagesRepository {
|
||||
private getRecentStmt: Database.Statement;
|
||||
private getUncheckedStmt: Database.Statement;
|
||||
private markResponseStmt: Database.Statement;
|
||||
private markImageStmt: Database.Statement;
|
||||
private markCheckedStmt: 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 = ?
|
||||
`);
|
||||
|
||||
this.markImageStmt = db.prepare(`
|
||||
UPDATE sent_messages
|
||||
SET got_image = 1, got_response = 1, response_checked_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
this.markCheckedStmt = db.prepare(`
|
||||
UPDATE sent_messages SET response_checked_at = ? WHERE id = ?
|
||||
`);
|
||||
@@ -565,6 +580,10 @@ export class MessagesRepository {
|
||||
this.markResponseStmt.run(Date.now(), messageId);
|
||||
}
|
||||
|
||||
markGotImage(messageId: number): void {
|
||||
this.markImageStmt.run(Date.now(), messageId);
|
||||
}
|
||||
|
||||
markChecked(messageId: number): void {
|
||||
this.markCheckedStmt.run(Date.now(), messageId);
|
||||
}
|
||||
@@ -578,7 +597,8 @@ export class MessagesRepository {
|
||||
SELECT
|
||||
strategy_id,
|
||||
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
|
||||
WHERE strategy_id IS NOT NULL
|
||||
GROUP BY strategy_id
|
||||
@@ -591,6 +611,10 @@ export class MessagesRepository {
|
||||
responseRate: (row.total_sent as number) > 0
|
||||
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
|
||||
: 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
|
||||
pp.subreddit,
|
||||
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
|
||||
JOIN processed_posts pp ON sm.post_id = pp.id
|
||||
GROUP BY pp.subreddit
|
||||
@@ -613,6 +638,10 @@ export class MessagesRepository {
|
||||
responseRate: (row.total_sent as number) > 0
|
||||
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
|
||||
: 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
|
||||
date(sent_at / 1000, 'unixepoch') as day,
|
||||
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
|
||||
WHERE sent_at > ?
|
||||
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
|
||||
? Math.round(((row.total_responses as number) / (row.total_sent as number)) * 100)
|
||||
: 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,
|
||||
sentAt: row.sent_at as number,
|
||||
gotResponse: Boolean(row.got_response),
|
||||
gotImage: Boolean(row.got_image),
|
||||
responseCheckedAt: row.response_checked_at as number | null,
|
||||
strategyId: (row.strategy_id as string) || null,
|
||||
chatRoomId: (row.chat_room_id as string) ?? null,
|
||||
@@ -680,7 +715,7 @@ export class SuccessfulMessagesRepository {
|
||||
`);
|
||||
|
||||
this.getAttributedRecentStmt = db.prepare(`
|
||||
SELECT successful_messages.*
|
||||
SELECT successful_messages.*, COALESCE(sm.got_image, 0) AS got_image
|
||||
FROM successful_messages
|
||||
LEFT JOIN sent_messages sm ON sm.id = successful_messages.sent_message_id
|
||||
WHERE successful_messages.subreddit IS NOT NULL
|
||||
@@ -762,6 +797,7 @@ export class SuccessfulMessagesRepository {
|
||||
responseSnippet: row.response_snippet as string | null,
|
||||
subreddit: row.subreddit as string | null,
|
||||
createdAt: row.created_at as number,
|
||||
...(row.got_image === undefined ? {} : { gotImage: Boolean(row.got_image) }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,6 +737,9 @@ export class ChatService {
|
||||
try {
|
||||
const withMedia = await this.mediaService.attachMedia(chat, mediaMessages);
|
||||
this.mergeNewMessages(chat, withMedia);
|
||||
if (withMedia.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0)) {
|
||||
this.recordReceivedImage(chat);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`[chat] delta media attach failed for ${roomId}: ${(err as Error).message}`,
|
||||
@@ -848,6 +851,9 @@ export class ChatService {
|
||||
if (firstSelf) chat.originMessage = firstSelf.text;
|
||||
|
||||
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;
|
||||
|
||||
if (firstSelf && chat.roomId && this.messagesRepo) {
|
||||
@@ -861,6 +867,9 @@ export class ChatService {
|
||||
if (hasReply && !existing.gotResponse) {
|
||||
this.messagesRepo.markGotResponse(existing.id);
|
||||
}
|
||||
if (hasReceivedImage && !existing.gotImage) {
|
||||
this.messagesRepo.markGotImage(existing.id);
|
||||
}
|
||||
}
|
||||
} catch (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
|
||||
* supplied, fields it carries (messages, pendingReply, originMessage)
|
||||
|
||||
@@ -83,6 +83,8 @@ export class StrategySelector {
|
||||
totalSent: s?.totalSent || 0,
|
||||
totalResponses: s?.totalResponses || 0,
|
||||
responseRate: s?.responseRate || 0,
|
||||
totalImages: s?.totalImages || 0,
|
||||
imageRate: s?.imageRate || 0,
|
||||
configured: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MatrixClient } from '../src/services/chat/matrix/client.js';
|
||||
import { MediaService } from '../src/services/media.js';
|
||||
import type { MediaDownloader } from '../src/services/media.js';
|
||||
import { createMockLogger } from './mockLogger.js';
|
||||
import { authenticatedAccountAuth } from './testUtils.js';
|
||||
import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js';
|
||||
|
||||
const creds = {
|
||||
homeserver: 'https://matrix.example.test',
|
||||
@@ -79,8 +79,18 @@ describe('ChatService delta sync — media attachment', () => {
|
||||
const logger = createMockLogger();
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-delta-media-merge-'));
|
||||
const cacheFile = path.join(tmpDir, 'chat-cache.json');
|
||||
const db = createTestDb();
|
||||
const repos = createTestRepositories(db);
|
||||
try {
|
||||
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 = {
|
||||
version: 1,
|
||||
savedAt: now - 60_000,
|
||||
@@ -129,7 +139,7 @@ describe('ChatService delta sync — media attachment', () => {
|
||||
|
||||
const media = new MediaService(buildDownloader(), logger, tmpDir);
|
||||
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();
|
||||
|
||||
// refreshTopChats always runs a /sync (unlike getActiveChats, which can
|
||||
@@ -145,7 +155,15 @@ describe('ChatService delta sync — media attachment', () => {
|
||||
expect(imageMsg).toBeDefined();
|
||||
expect(imageMsg!.media).toBeDefined();
|
||||
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 {
|
||||
db.close();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('dashboard foundation', () => {
|
||||
),
|
||||
http.get('/api/messages', () => HttpResponse.json([{
|
||||
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(
|
||||
http.get('/api/analytics/by-subreddit', () =>
|
||||
HttpResponse.json([
|
||||
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30 },
|
||||
{ subreddit: 'tiny', totalSent: 3, totalResponses: 2, responseRate: 67 },
|
||||
{ subreddit: 'askuk', totalSent: 40, totalResponses: 12, responseRate: 30, totalImages: 6, imageRate: 15 },
|
||||
{ subreddit: 'tiny', totalSent: 3, totalResponses: 2, responseRate: 67, totalImages: 1, imageRate: 33 },
|
||||
]),
|
||||
),
|
||||
http.get('/api/analytics/by-strategy', () =>
|
||||
HttpResponse.json([
|
||||
{ strategyId: 'friendly', totalSent: 25, totalResponses: 10, responseRate: 40, configured: true },
|
||||
{ strategyId: 'new-test', totalSent: 2, totalResponses: 1, responseRate: 50 },
|
||||
{ strategyId: 'friendly', totalSent: 25, totalResponses: 10, responseRate: 40, totalImages: 5, imageRate: 20, configured: true },
|
||||
{ strategyId: 'new-test', totalSent: 2, totalResponses: 1, responseRate: 50, totalImages: 1, imageRate: 50 },
|
||||
]),
|
||||
),
|
||||
http.get('/api/analytics/trend', ({ request }) => {
|
||||
expect(new URL(request.url).searchParams.get('days')).toBe('30');
|
||||
return HttpResponse.json([
|
||||
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20 },
|
||||
{ date: '2026-08-08', totalSent: 8, totalResponses: 4, responseRate: 50 },
|
||||
{ date: '2026-08-07', totalSent: 10, totalResponses: 2, responseRate: 20, totalImages: 1, imageRate: 10 },
|
||||
{ date: '2026-08-08', totalSent: 8, totalResponses: 4, responseRate: 50, totalImages: 2, imageRate: 25 },
|
||||
]);
|
||||
}),
|
||||
http.get('/api/successful', () =>
|
||||
@@ -64,6 +64,7 @@ describe('PerformancePage', () => {
|
||||
responseSnippet: 'That sounds lovely!',
|
||||
subreddit: 'askuk',
|
||||
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 = 3')).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' });
|
||||
expect(within(strategyTable).getByText('n = 25')).toBeInTheDocument();
|
||||
expect(within(strategyTable).getByText('Configured')).toBeInTheDocument();
|
||||
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('That sounds lovely!');
|
||||
expect(screen.getByRole('article', { name: 'Successful example from askuk' })).toHaveTextContent('Image received');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('Database Client', () => {
|
||||
const columnNames = columns.map(c => c.name);
|
||||
expect(columnNames).toContain('got_response');
|
||||
expect(columnNames).toContain('response_checked_at');
|
||||
expect(columnNames).toContain('got_image');
|
||||
});
|
||||
|
||||
it('adds strategy_id column', () => {
|
||||
@@ -157,6 +158,7 @@ describe('Database Client', () => {
|
||||
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('chat_room_id')).toBeDefined();
|
||||
expect(columnMap.get('got_image')).toBeDefined();
|
||||
});
|
||||
|
||||
it('successful_messages has correct schema', () => {
|
||||
|
||||
@@ -199,10 +199,12 @@ describe('MessagesRepository', () => {
|
||||
expect(row!.chatRoomId).toBe('!room123:reddit.com');
|
||||
expect(row!.recipient).toBe('alice');
|
||||
expect(row!.gotResponse).toBe(false);
|
||||
expect(row!.gotImage).toBe(false);
|
||||
|
||||
messagesRepo.markGotResponse(id);
|
||||
messagesRepo.markGotImage(id);
|
||||
const updated = messagesRepo.getByChatRoomId('!room123:reddit.com');
|
||||
expect(updated!.gotResponse).toBe(true);
|
||||
expect(updated!.gotImage).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps unassociated chat history out of performance trends', () => {
|
||||
@@ -353,6 +355,7 @@ describe('MessagesRepository', () => {
|
||||
|
||||
messagesRepo.markGotResponse(1);
|
||||
messagesRepo.markGotResponse(3);
|
||||
messagesRepo.markGotImage(1);
|
||||
|
||||
const stats = messagesRepo.getStrategyStats();
|
||||
expect(stats.length).toBe(2);
|
||||
@@ -361,11 +364,15 @@ describe('MessagesRepository', () => {
|
||||
expect(variantA!.totalSent).toBe(2);
|
||||
expect(variantA!.totalResponses).toBe(1);
|
||||
expect(variantA!.responseRate).toBe(50);
|
||||
expect(variantA!.totalImages).toBe(1);
|
||||
expect(variantA!.imageRate).toBe(50);
|
||||
|
||||
const variantB = stats.find(s => s.strategyId === 'variant-b');
|
||||
expect(variantB!.totalSent).toBe(1);
|
||||
expect(variantB!.totalResponses).toBe(1);
|
||||
expect(variantB!.responseRate).toBe(100);
|
||||
expect(variantB!.totalImages).toBe(0);
|
||||
expect(variantB!.imageRate).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes messages without strategy_id from stats', () => {
|
||||
|
||||
Reference in new issue
Block a user