Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/events/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const linkSchema = z.object({ label: z.string(), url: z.string() });
// Per-type payloads. Kept permissive where the renderer tolerates missing fields.
const payloads = {
error: z.object({ message: z.string(), route: z.string().optional(), stack: z.string().optional(), source: z.string().optional() }),
seo_report: z.object({ siteLabel: z.string(), clicks: z.number(), impressions: z.number(), topQueries: z.array(z.string()).optional(), subSections: z.array(subSectionSchema).optional() }),
seo_report: z.object({ siteLabel: z.string(), clicks: z.number(), impressions: z.number(), topQueries: z.array(z.string()).optional(), product: z.object({ signups24h: z.number().optional(), paidConversions7d: z.number().optional(), totalUsers: z.number().optional(), paidUsers: z.number().optional(), mrrUsd: z.number().optional() }).optional(), budget: z.object({ spentUsd: z.number(), capUsd: z.number() }).optional(), subSections: z.array(subSectionSchema).optional() }),
signup: z.object({ email: z.string(), name: z.string().optional(), kind: z.enum(['signup', 'waitlist']).optional() }),
subscription: z.object({ email: z.string(), kind: z.enum(['new', 'upgrade', 'downgrade', 'cancel', 'expired', 'payment_failed', 'refund', 'addon', 'addon_cancel']), plan: z.string().optional(), amount: z.number().optional(), interval: z.string().optional(), endsAt: z.string().optional(), source: z.string().optional(), variant: z.string().optional(), subscriptionId: z.string().optional() }),
feedback: z.object({
Expand Down
42 changes: 34 additions & 8 deletions lib/render/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function pickEmoji(ev: ParsedEvent): string {
case 'infra': return ev.severity === 'error' ? '🔴' : '📡';
case 'booking': return '📅';
case 'contact': return '✉️';
case 'seo_report': return '🔎';
case 'seo_report': return '📈';
case 'generic': return (p.emoji as string) ?? 'ℹ️';
default: return 'ℹ️';
}
Expand Down Expand Up @@ -229,16 +229,42 @@ export function renderEvent(ev: ParsedEvent, ctx?: RenderContext): Rendered {
}

case 'seo_report': {
const ctr = p.impressions > 0 ? ((p.clicks / p.impressions) * 100).toFixed(2) : '0.00';
let body = `${p.clicks} clicks · ${p.impressions} impressions · ${ctr}% CTR`;
if (Array.isArray(p.topQueries) && p.topQueries.length) {
body += `\n${p.topQueries.slice(0, 5).map((x: string) => `• ${x}`).join('\n')}`;
const ctr = p.impressions > 0 ? ((p.clicks / p.impressions) * 100).toFixed(1) : '0.0';
// Standard digest layout, rendered centrally so every site is identical:
// Search (7d) · Product (24h/7d, when sent) · then any caller subSections.
const subSections: { header: string; lines: string[] }[] = [
{
header: 'Search · last 7 days',
lines: [
`impressions: *${p.impressions}* clicks: *${p.clicks}* CTR: *${ctr}%*`,
...(Array.isArray(p.topQueries) && p.topQueries.length
? p.topQueries.slice(0, 5).map((x: string) => `• ${x}`)
: []),
],
},
];
const pr = p.product;
if (pr) {
const lines: string[] = [];
if (pr.signups24h != null) lines.push(`signups (24h): *${pr.signups24h}*`);
if (pr.paidConversions7d != null) lines.push(`paid conversions (7d): *${pr.paidConversions7d}*`);
if (pr.totalUsers != null) lines.push(`total users: *${pr.totalUsers}*`);
if (pr.paidUsers != null) lines.push(`paid users: *${pr.paidUsers}*`);
if (pr.mrrUsd != null) lines.push(`MRR: *$${pr.mrrUsd.toFixed(2)}*`);
if (lines.length) subSections.push({ header: 'Product · last 24h / 7d', lines });
}
if (Array.isArray(p.subSections)) subSections.push(...p.subSections);
// Budget footer: explicit footerNote wins; otherwise derive the standard
// line from the structured budget numbers. siteLabel is appended as source.
const budgetNote = p.budget
? `Anthropic budget: $${Number(p.budget.spentUsd).toFixed(2)} of $${Number(p.budget.capUsd).toFixed(2)} this month`
: undefined;
return {
text: `${p.siteLabel} — ${p.clicks}c/${p.impressions}i`,
text: `SEO digest · ${p.siteLabel} — ${p.clicks}c/${p.impressions}i`,
blocks: richMessage({
...base, emoji, title: String(p.siteLabel), subject: 'search digest', body,
subSections: Array.isArray(p.subSections) ? p.subSections : undefined,
...base, emoji, title: 'SEO digest', subject: String(p.siteLabel),
subSections,
footerNote: base.footerNote ?? budgetNote,
}),
};
}
Expand Down
13 changes: 8 additions & 5 deletions lib/render/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,19 @@ export const SAMPLE_ENTRIES = [
type: 'seo_report',
envelope: {
type: 'seo_report',
footerNote: 'Anthropic budget: $2.43 of $25.00 this month',
payload: {
siteLabel: 'decrevel.dev',
clicks: 312,
impressions: 8420,
topQueries: ['matt decrevel', 'agentic workflows', 'next.js notification service'],
subSections: [
{ header: 'Top movers', lines: ['• "loop notifications" +42 clicks', '• "drizzle neon" +18 clicks'] },
{ header: 'Health', lines: ['• 0 crawl errors', '• 3 new pages indexed'] },
],
product: {
signups24h: 4,
paidConversions7d: 1,
totalUsers: 128,
paidUsers: 12,
mrrUsd: 96,
},
budget: { spentUsd: 2.43, capUsd: 25 },
},
},
},
Expand Down
17 changes: 17 additions & 0 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,28 @@ export interface ContactPayload {
message: string;
source?: string;
}
/** Product metrics for an SEO digest — rendered as a standard "Product" section. */
export interface SeoProductMetrics {
signups24h?: number;
paidConversions7d?: number;
totalUsers?: number;
paidUsers?: number;
mrrUsd?: number;
}
/** Agent spend for the month → rendered as the standard budget footer. */
export interface SeoBudget {
spentUsd: number;
capUsd: number;
}
export interface SeoReportPayload {
siteLabel: string;
clicks: number;
impressions: number;
topQueries?: string[];
/** Raw product numbers; Loop formats them into the standard Product section. */
product?: SeoProductMetrics;
/** Month-to-date agent spend; Loop formats the standard budget footer. */
budget?: SeoBudget;
subSections?: LoopSubSection[];
}
export interface GenericPayload {
Expand Down
16 changes: 12 additions & 4 deletions tests/render-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,18 @@ describe('renderEvent — per-type baseline snapshots', () => {
});

it('seo_report', () => {
const { text, blocks } = renderEvent(ev({ type: 'seo_report', category: 'seo', payload: { siteLabel: 'demo.dev', clicks: 10, impressions: 100 } }), { siteLabel: 'demo.dev' });
expect(text).toBe('demo.dev — 10c/100i');
expect(JSON.stringify(blocks)).toContain('🔎');
expect(JSON.stringify(blocks)).toContain('10.00% CTR');
const { text, blocks } = renderEvent(ev({ type: 'seo_report', category: 'seo', payload: { siteLabel: 'demo.dev', clicks: 10, impressions: 100, product: { signups24h: 3, mrrUsd: 50 }, budget: { spentUsd: 2, capUsd: 25 } } }), { siteLabel: 'demo.dev' });
expect(text).toBe('SEO digest · demo.dev — 10c/100i');
const json = JSON.stringify(blocks);
expect(json).toContain('📈');
expect(json).toContain('*SEO digest*');
expect(json).toContain('CTR: *10.0%*');
expect(json).toContain('Search · last 7 days');
// structured product → standard Product section + budget footer
expect(json).toContain('Product · last 24h / 7d');
expect(json).toContain('signups (24h): *3*');
expect(json).toContain('MRR: *$50.00*');
expect(json).toContain('Anthropic budget: $2.00 of $25.00 this month');
});

it('generic', () => {
Expand Down
Loading