diff --git a/lib/events/schemas.ts b/lib/events/schemas.ts index 334dae7..f5583f8 100644 --- a/lib/events/schemas.ts +++ b/lib/events/schemas.ts @@ -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({ diff --git a/lib/render/index.ts b/lib/render/index.ts index 3e78261..81e4cec 100644 --- a/lib/render/index.ts +++ b/lib/render/index.ts @@ -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 'â„šī¸'; } @@ -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, }), }; } diff --git a/lib/render/samples.ts b/lib/render/samples.ts index d4d388e..c1ba69c 100644 --- a/lib/render/samples.ts +++ b/lib/render/samples.ts @@ -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 }, }, }, }, diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index d5a7718..e55b11e 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -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 { diff --git a/tests/render-types.test.ts b/tests/render-types.test.ts index b26d058..fa483b0 100644 --- a/tests/render-types.test.ts +++ b/tests/render-types.test.ts @@ -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', () => {