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 (
|
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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user