From 9e895659db6f5ae3e75b0853398f837d10738e40 Mon Sep 17 00:00:00 2001 From: Jonny Nguyen Date: Fri, 26 Jun 2026 13:56:57 -0700 Subject: [PATCH] Enhance warmup functionality by introducing mailbox role validation for sends and replies. Implement new utility functions to check allowed warmup pairs and add corresponding tests. Update campaign stats routes and services to include a detailed warmup plan, and integrate a visual representation in the Campaign Detail page. Refactor related components and types for improved clarity and maintainability. --- apps/api/package.json | 2 +- apps/api/src/jobs/campaign-dispatcher.job.ts | 10 ++ apps/api/src/jobs/dispatcher.job.ts | 25 ++- apps/api/src/jobs/rescuer.job.ts | 37 ++++- apps/api/src/lib/mailbox-topology.test.ts | 64 ++++++++ apps/api/src/lib/mailbox-topology.ts | 22 +++ apps/api/src/routes/stats.routes.ts | 10 ++ apps/api/src/services/stats.service.ts | 62 +++++++ .../concurrent-primaries.integration.test.ts | 43 ++++- apps/web/src/components/WarmupPlanChart.tsx | 153 ++++++++++++++++++ apps/web/src/lib/types.ts | 15 +- apps/web/src/pages/CampaignDetailPage.tsx | 110 +++---------- packages/shared/src/index.ts | 22 +++ 13 files changed, 464 insertions(+), 111 deletions(-) create mode 100644 apps/api/src/lib/mailbox-topology.test.ts create mode 100644 apps/api/src/lib/mailbox-topology.ts create mode 100644 apps/web/src/components/WarmupPlanChart.tsx diff --git a/apps/api/package.json b/apps/api/package.json index 1dcf426..1cfde30 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "tsx --test src/services/*.test.ts", + "test": "tsx --test src/services/*.test.ts src/lib/*.test.ts", "test:integration": "tsx --test --test-concurrency=1 src/test/integration/*.integration.test.ts" }, "dependencies": { diff --git a/apps/api/src/jobs/campaign-dispatcher.job.ts b/apps/api/src/jobs/campaign-dispatcher.job.ts index 48be468..4bbdee3 100644 --- a/apps/api/src/jobs/campaign-dispatcher.job.ts +++ b/apps/api/src/jobs/campaign-dispatcher.job.ts @@ -2,6 +2,7 @@ import type { CampaignSatellite, Inbox } from '../generated/prisma/client'; import { CampaignStatus, InboxStatus, WarmupStatus, prisma } from '../lib/prisma'; import { withInboxGuard } from '../lib/inboxGuard'; import { logger } from '../lib/logger'; +import { isAllowedWarmupSendPair } from '../lib/mailbox-topology'; import { isWithinSendWindow } from '../lib/send-window'; import * as aiService from '../services/ai.service'; import { syncCampaignDay } from '../services/campaign.service'; @@ -84,6 +85,15 @@ async function executeCampaignSend( return; } + if (!isAllowedWarmupSendPair(satellite, primary)) { + logger.warn( + { campaignId, satellite: satellite.email, primary: primary.email }, + 'Campaign dispatcher skipped invalid warmup pair', + ); + if (jobId) await completeSendJob(jobId, false, 'Invalid mailbox roles for warmup send'); + return; + } + await withInboxGuard(satellite, async () => { const { subject, body, validationResult } = await aiService.generateInitialEmailWithValidation(primary.industry, { diff --git a/apps/api/src/jobs/dispatcher.job.ts b/apps/api/src/jobs/dispatcher.job.ts index 9004db8..3a67ef5 100644 --- a/apps/api/src/jobs/dispatcher.job.ts +++ b/apps/api/src/jobs/dispatcher.job.ts @@ -1,7 +1,8 @@ import type { Inbox } from '../generated/prisma/client'; -import { InboxStatus, WarmupStatus, prisma } from '../lib/prisma'; +import { InboxStatus, MailboxRole, WarmupStatus, prisma } from '../lib/prisma'; import { withInboxGuard } from '../lib/inboxGuard'; import { logger } from '../lib/logger'; +import { isAllowedWarmupSendPair, isPrimaryMailbox, isSatelliteMailbox } from '../lib/mailbox-topology'; import * as aiService from '../services/ai.service'; import * as emailService from '../services/email.service'; import { getSettingOrDefault } from '../services/settings.service'; @@ -64,12 +65,18 @@ export async function runDispatcherJob(): Promise { where: { status: InboxStatus.active }, }); - if (inboxes.length < 2) { - logger.warn('Dispatcher skipped: need at least 2 active inboxes'); + const satellites = inboxes.filter((inbox) => isSatelliteMailbox(inbox)); + const primaries = inboxes.filter((inbox) => isPrimaryMailbox(inbox)); + + if (satellites.length === 0 || primaries.length === 0) { + logger.warn( + { satellites: satellites.length, primaries: primaries.length }, + 'Legacy dispatcher skipped: need at least one active satellite and one active primary', + ); return; } - for (const sender of inboxes) { + for (const sender of satellites) { try { const sentToday = await getTodaySendCount(sender.id); if (sentToday >= sender.currentRamp) { @@ -77,8 +84,14 @@ export async function runDispatcherJob(): Promise { continue; } - const receivers = inboxes.filter((i) => i.id !== sender.id); - const receiver = pickRandom(receivers); + const receiver = pickRandom(primaries); + if (!isAllowedWarmupSendPair(sender, receiver)) { + logger.warn( + { sender: sender.email, receiver: receiver.email }, + 'Legacy dispatcher skipped invalid warmup pair', + ); + continue; + } await withInboxGuard(sender, async () => { const { subject, body } = await aiService.generateInitialEmail(sender.industry, { diff --git a/apps/api/src/jobs/rescuer.job.ts b/apps/api/src/jobs/rescuer.job.ts index 27bfcab..b3d4331 100644 --- a/apps/api/src/jobs/rescuer.job.ts +++ b/apps/api/src/jobs/rescuer.job.ts @@ -1,10 +1,15 @@ import { simpleParser } from 'mailparser'; import { ImapFlow } from 'imapflow'; import type { Inbox, WarmupCampaign, WarmupLog } from '../generated/prisma/client'; -import { CampaignStatus, InboxStatus, WarmupStatus } from '../lib/prisma'; +import { CampaignStatus, InboxStatus, MailboxRole, WarmupStatus } from '../lib/prisma'; import { prisma } from '../lib/prisma'; import { withInboxGuard } from '../lib/inboxGuard'; import { logger } from '../lib/logger'; +import { + isAllowedWarmupReplyPair, + isPrimaryMailbox, + isSatelliteMailbox, +} from '../lib/mailbox-topology'; import * as aiService from '../services/ai.service'; import { hasAnyCampaign } from '../services/campaign.service'; import * as emailService from '../services/email.service'; @@ -127,6 +132,20 @@ async function processMessage( const log = await findMatchingLog(pendingLogs, messageId, fromEmail, subject); if (!log) return; + if (!isAllowedWarmupReplyPair(receiver, log.sender)) { + logger.warn( + { + receiver: receiver.email, + receiverRole: receiver.role, + sender: log.sender.email, + senderRole: log.sender.role, + logId: log.id, + }, + 'Skipping warmup reply: only primary may reply to satellite mail', + ); + return; + } + const placement = options?.placement ?? 'inbox'; await client.messageFlagsAdd(String(uid), ['\\Seen', '\\Flagged'], { uid: true }); @@ -385,8 +404,9 @@ async function processCampaignPrimary(receiver: Inbox): Promise { include: { sender: true }, })) as PendingLog[]; - const filtered = pendingLogs.filter((log) => - allowedEmails.has(log.sender.email.toLowerCase()), + const filtered = pendingLogs.filter( + (log) => + allowedEmails.has(log.sender.email.toLowerCase()) && isSatelliteMailbox(log.sender), ); const campaignContext: CampaignContext = { @@ -406,11 +426,16 @@ async function processCampaignPrimary(receiver: Inbox): Promise { } async function processLegacyInbox(receiver: Inbox): Promise { + if (!isPrimaryMailbox(receiver)) { + return; + } + const pendingLogs = (await prisma.warmupLog.findMany({ where: { campaignId: null, receiverId: receiver.id, status: { in: [WarmupStatus.sent, WarmupStatus.rescued_from_spam] }, + sender: { role: MailboxRole.satellite }, }, include: { sender: true }, })) as PendingLog[]; @@ -442,11 +467,11 @@ export async function runRescuerJob(): Promise { } } } else { - const inboxes = await prisma.inbox.findMany({ - where: { status: InboxStatus.active }, + const primaries = await prisma.inbox.findMany({ + where: { status: InboxStatus.active, role: MailboxRole.primary }, }); - for (const inbox of inboxes) { + for (const inbox of primaries) { try { await processLegacyInbox(inbox); } catch (err) { diff --git a/apps/api/src/lib/mailbox-topology.test.ts b/apps/api/src/lib/mailbox-topology.test.ts new file mode 100644 index 0000000..6b05eb5 --- /dev/null +++ b/apps/api/src/lib/mailbox-topology.test.ts @@ -0,0 +1,64 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { MailboxRole } from './prisma'; +import { + isAllowedWarmupReplyPair, + isAllowedWarmupSendPair, +} from './mailbox-topology'; + +describe('mailbox-topology', () => { + it('allows satellite → primary sends only', () => { + assert.equal( + isAllowedWarmupSendPair( + { role: MailboxRole.satellite }, + { role: MailboxRole.primary }, + ), + true, + ); + assert.equal( + isAllowedWarmupSendPair( + { role: MailboxRole.satellite }, + { role: MailboxRole.satellite }, + ), + false, + ); + assert.equal( + isAllowedWarmupSendPair( + { role: MailboxRole.primary }, + { role: MailboxRole.primary }, + ), + false, + ); + assert.equal( + isAllowedWarmupSendPair( + { role: MailboxRole.unassigned }, + { role: MailboxRole.primary }, + ), + false, + ); + }); + + it('allows primary → satellite replies only', () => { + assert.equal( + isAllowedWarmupReplyPair( + { role: MailboxRole.primary }, + { role: MailboxRole.satellite }, + ), + true, + ); + assert.equal( + isAllowedWarmupReplyPair( + { role: MailboxRole.satellite }, + { role: MailboxRole.satellite }, + ), + false, + ); + assert.equal( + isAllowedWarmupReplyPair( + { role: MailboxRole.satellite }, + { role: MailboxRole.primary }, + ), + false, + ); + }); +}); diff --git a/apps/api/src/lib/mailbox-topology.ts b/apps/api/src/lib/mailbox-topology.ts new file mode 100644 index 0000000..ef5dfbf --- /dev/null +++ b/apps/api/src/lib/mailbox-topology.ts @@ -0,0 +1,22 @@ +import type { Inbox } from '../generated/prisma/client'; +import { MailboxRole } from './prisma'; + +type RoleLike = Pick; + +export function isSatelliteMailbox(mailbox: RoleLike): boolean { + return mailbox.role === MailboxRole.satellite; +} + +export function isPrimaryMailbox(mailbox: RoleLike): boolean { + return mailbox.role === MailboxRole.primary; +} + +/** Warmup sends are satellite → primary only. */ +export function isAllowedWarmupSendPair(sender: RoleLike, receiver: RoleLike): boolean { + return isSatelliteMailbox(sender) && isPrimaryMailbox(receiver); +} + +/** Warmup replies are primary → satellite only. */ +export function isAllowedWarmupReplyPair(responder: RoleLike, originalSender: RoleLike): boolean { + return isPrimaryMailbox(responder) && isSatelliteMailbox(originalSender); +} diff --git a/apps/api/src/routes/stats.routes.ts b/apps/api/src/routes/stats.routes.ts index edbc531..f31c681 100644 --- a/apps/api/src/routes/stats.routes.ts +++ b/apps/api/src/routes/stats.routes.ts @@ -5,6 +5,7 @@ import { getCampaignStatsDaily, getCampaignStatsSummary, getCampaignStatsToday, + getCampaignWarmupPlan, getWorkspaceOverview, StatsRange, } from '../services/stats.service'; @@ -31,6 +32,15 @@ router.get('/:id/stats/today', async (req: Request, res: Response, next: NextFun } }); +router.get('/:id/stats/plan', async (req: Request, res: Response, next: NextFunction) => { + try { + const plan = await getCampaignWarmupPlan(req.params.id); + res.json(plan); + } catch (err) { + handleError(err, next); + } +}); + router.get('/:id/stats/daily', async (req: Request, res: Response, next: NextFunction) => { try { const from = typeof req.query.from === 'string' ? req.query.from : undefined; diff --git a/apps/api/src/services/stats.service.ts b/apps/api/src/services/stats.service.ts index f0cf8a3..be358e9 100644 --- a/apps/api/src/services/stats.service.ts +++ b/apps/api/src/services/stats.service.ts @@ -5,6 +5,27 @@ import { CampaignError } from './campaign.service'; export type StatsRange = '7d' | '30d' | 'all'; +export type CampaignWarmupPlanDay = { + dayIndex: number; + date: string | null; + isToday: boolean; + isFuture: boolean; + planned: { sends: number; replies: number }; + actual: { + sends: number; + replies: number; + spamDetected: number; + spamRescued: number; + }; +}; + +export type CampaignWarmupPlan = { + campaignId: string; + currentDay: number; + durationDays: number; + days: CampaignWarmupPlanDay[]; +}; + export type CampaignStatsPayload = { campaignId: string; date: string; @@ -143,6 +164,47 @@ async function getCampaignOrThrow(campaignId: string) { return campaign; } +export async function getCampaignWarmupPlan(campaignId: string): Promise { + const campaign = await getCampaignOrThrow(campaignId); + const zone = campaign.timezone; + + const schedules = await prisma.dailySchedule.findMany({ + where: { campaignId, dayIndex: { gt: 0 } }, + orderBy: { dayIndex: 'asc' }, + }); + + const days: CampaignWarmupPlanDay[] = schedules.map((schedule) => { + const isToday = schedule.dayIndex === campaign.currentDay; + const isFuture = campaign.currentDay > 0 && schedule.dayIndex > campaign.currentDay; + + return { + dayIndex: schedule.dayIndex, + date: schedule.date + ? (DateTime.fromJSDate(schedule.date).setZone(zone).toISODate() ?? null) + : null, + isToday, + isFuture, + planned: { + sends: schedule.plannedSends, + replies: schedule.plannedReplies, + }, + actual: { + sends: schedule.actualSends, + replies: schedule.actualReplies, + spamDetected: schedule.spamDetected, + spamRescued: schedule.spamRescued, + }, + }; + }); + + return { + campaignId: campaign.id, + currentDay: campaign.currentDay, + durationDays: campaign.durationDays, + days, + }; +} + export async function getCampaignStatsToday(campaignId: string): Promise { const campaign = await getCampaignOrThrow(campaignId); const todayIso = DateTime.now().setZone(campaign.timezone).toISODate() ?? ''; diff --git a/apps/api/src/test/integration/concurrent-primaries.integration.test.ts b/apps/api/src/test/integration/concurrent-primaries.integration.test.ts index f9f0403..375f4b8 100644 --- a/apps/api/src/test/integration/concurrent-primaries.integration.test.ts +++ b/apps/api/src/test/integration/concurrent-primaries.integration.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { CampaignRecipe, MailboxRole } from '../../lib/prisma'; import { assignSatellite, createCampaign, startCampaign } from '../../services/campaign.service'; import { prisma } from '../../lib/prisma'; -import { getCampaignStatsToday } from '../../services/stats.service'; +import { getCampaignStatsToday, getCampaignWarmupPlan } from '../../services/stats.service'; import { cleanupCampaign, cleanupInbox, @@ -108,4 +108,45 @@ describe('integration: campaign stats flow', () => { assert.equal(stats.actual.spamDetected, 1); assert.equal(stats.planned.sends, 40); }); + + it('warmup plan returns full schedule with live actuals', async () => { + const primary = await createTestInbox({ role: MailboxRole.primary }); + const sat1 = await createTestInbox(); + const sat2 = await createTestInbox(); + inboxIds.push(primary.id, sat1.id, sat2.id); + + const campaign = await createCampaign({ + primaryMailboxId: primary.id, + name: 'Plan test', + recipe: CampaignRecipe.grow, + durationDays: 14, + }); + campaignIds.push(campaign.id); + await assignSatellite(campaign.id, sat1.id); + await assignSatellite(campaign.id, sat2.id); + const started = await startCampaign(campaign.id); + + await prisma.dailySchedule.update({ + where: { + campaignId_dayIndex: { campaignId: started.id, dayIndex: 1 }, + }, + data: { + actualSends: 3, + actualReplies: 1, + spamDetected: 1, + }, + }); + + const plan = await getCampaignWarmupPlan(started.id); + assert.equal(plan.durationDays, 14); + assert.equal(plan.days.length, 14); + assert.equal(plan.days[0]!.dayIndex, 1); + assert.equal(plan.days[0]!.planned.sends, 1); + assert.equal(plan.days[0]!.actual.sends, 3); + assert.equal(plan.days[0]!.actual.replies, 1); + assert.equal(plan.days[0]!.actual.spamDetected, 1); + assert.equal(plan.days[0]!.isToday, true); + assert.equal(plan.days[1]!.isFuture, true); + assert.equal(plan.days[1]!.actual.sends, 0); + }); }); diff --git a/apps/web/src/components/WarmupPlanChart.tsx b/apps/web/src/components/WarmupPlanChart.tsx new file mode 100644 index 0000000..21e0b55 --- /dev/null +++ b/apps/web/src/components/WarmupPlanChart.tsx @@ -0,0 +1,153 @@ +import { + Bar, + CartesianGrid, + ComposedChart, + Legend, + Line, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { CampaignWarmupPlan, CampaignWarmupPlanDay } from '@warmbox/shared'; + +type ChartRow = { + dayIndex: number; + label: string; + date: string | null; + isToday: boolean; + isFuture: boolean; + plannedSends: number; + plannedReplies: number; + actualSends: number; + actualReplies: number; + spamDetected: number; + inboxDelivered: number; +}; + +function toChartRows(plan: CampaignWarmupPlan): ChartRow[] { + return plan.days.map((day) => ({ + dayIndex: day.dayIndex, + label: `D${day.dayIndex}`, + date: day.date, + isToday: day.isToday, + isFuture: day.isFuture, + plannedSends: day.planned.sends, + plannedReplies: day.planned.replies, + actualSends: day.actual.sends, + actualReplies: day.actual.replies, + spamDetected: day.actual.spamDetected, + inboxDelivered: Math.max(0, day.actual.sends - day.actual.spamDetected), + })); +} + +function PlanTooltip({ + active, + payload, +}: { + active?: boolean; + payload?: Array<{ payload: ChartRow }>; +}) { + if (!active || !payload?.length) return null; + + const row = payload[0]!.payload; + + return ( +
+

+ Day {row.dayIndex} + {row.date ? ` · ${row.date}` : ''} +

+ {row.isToday &&

Today

} + {row.isFuture &&

Upcoming

} + +
+
+
Scheduled
+
+ {row.plannedSends} sends · {row.plannedReplies} replies +
+
+ + {!row.isFuture && ( + <> +
+
Sent
+
{row.actualSends}
+
+
+
Replied
+
{row.actualReplies}
+
+
+
In spam
+
{row.spamDetected}
+
+
+
Inbox
+
{row.inboxDelivered}
+
+ + )} +
+
+ ); +} + +type WarmupPlanChartProps = { + plan: CampaignWarmupPlan; +}; + +export function WarmupPlanChart({ plan }: WarmupPlanChartProps) { + const data = toChartRows(plan); + const todayRow = data.find((row) => row.isToday); + + if (data.length === 0) { + return ( +

+ Start the campaign to generate the warmup schedule. +

+ ); + } + + return ( +
+ + + + 20 ? Math.floor(plan.durationDays / 10) : 0} + /> + + } /> + + {todayRow && ( + + )} + + + + + + +
+ ); +} + +export type { CampaignWarmupPlanDay }; diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index d4ddb27..6663934 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -2,6 +2,8 @@ import type { CampaignRecipe, CampaignStatus, CampaignStatsToday, + CampaignWarmupPlan, + CampaignWarmupPlanDay, ComplianceReport, DnsCheckResult, InboxStatus, @@ -18,6 +20,8 @@ export type { CampaignRecipe, CampaignStatus, CampaignStatsToday, + CampaignWarmupPlan, + CampaignWarmupPlanDay, ComplianceReport, DnsCheckResult, InboxStatus, @@ -91,16 +95,7 @@ export type Campaign = { satellites: CampaignSatellite[]; }; -export type DailyStat = { - date: string; - dayIndex: number; - plannedSends: number; - plannedReplies: number; - actualSends: number; - actualReplies: number; - spamDetected: number; - spamRescued: number; -}; +export type DailyStat = CampaignStatsToday; export type WarmupLog = { id: string; diff --git a/apps/web/src/pages/CampaignDetailPage.tsx b/apps/web/src/pages/CampaignDetailPage.tsx index da20d18..d8f46ff 100644 --- a/apps/web/src/pages/CampaignDetailPage.tsx +++ b/apps/web/src/pages/CampaignDetailPage.tsx @@ -1,25 +1,14 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; -import { - LineChart, - Line, - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, -} from 'recharts'; +import { Send, MessageSquare, Shield, Inbox, Pause, Play, Loader2, Trash2 } from 'lucide-react'; import { apiGet, apiPatch, apiPost, apiDelete } from '../lib/api'; import type { Campaign, CampaignSatellite, CampaignStatsToday, + CampaignWarmupPlan, ComplianceReport, - DailyStat, PaginatedLogs, WarmupLog, } from '../lib/types'; @@ -27,6 +16,7 @@ import { PageHeader } from '../components/PageHeader'; import { StatusBadge } from '../components/StatusBadge'; import { MetricCard } from '../components/MetricCard'; import { ComplianceChecklist } from '../components/ComplianceChecklist'; +import { WarmupPlanChart } from '../components/WarmupPlanChart'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/Tabs'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card'; import { Skeleton } from '../components/ui/Skeleton'; @@ -36,7 +26,6 @@ import { Input } from '../components/ui/Input'; import { Label } from '../components/ui/Label'; import { formatDateTime, formatPercent } from '../lib/utils'; import { Badge } from '../components/ui/Badge'; -import { Send, MessageSquare, Shield, Inbox, Pause, Play, Loader2, Trash2 } from 'lucide-react'; function LogStatusBadge({ status }: { status: string }) { if (status === 'rescued_from_spam') { @@ -81,10 +70,11 @@ export function CampaignDetailPage() { enabled: !!id, }); - const { data: dailyData } = useQuery({ - queryKey: ['campaign', id, 'stats', 'daily'], - queryFn: () => apiGet<{ data: DailyStat[] }>(`/api/campaigns/${id}/stats/daily`), + const { data: warmupPlan } = useQuery({ + queryKey: ['campaign', id, 'stats', 'plan'], + queryFn: () => apiGet(`/api/campaigns/${id}/stats/plan`), enabled: !!id, + refetchInterval: 30_000, }); const { data: logs } = useQuery({ @@ -142,6 +132,7 @@ export function CampaignDetailPage() { mutationFn: () => apiPost(`/api/campaigns/${id}/pause`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['campaign', id] }); + queryClient.invalidateQueries({ queryKey: ['campaign', id, 'stats'] }); queryClient.invalidateQueries({ queryKey: ['workspace', 'overview'] }); }, }); @@ -150,6 +141,7 @@ export function CampaignDetailPage() { mutationFn: () => apiPost(`/api/campaigns/${id}/resume`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['campaign', id] }); + queryClient.invalidateQueries({ queryKey: ['campaign', id, 'stats'] }); queryClient.invalidateQueries({ queryKey: ['workspace', 'overview'] }); }, }); @@ -205,16 +197,6 @@ export function CampaignDetailPage() { !satellites?.some((s) => s.satelliteMailboxId === m.id), ) ?? []; - const chartData = - dailyData?.data.map((d) => ({ - day: d.dayIndex, - sends: d.actualSends, - replies: d.actualReplies, - planned: d.plannedSends, - inbox: Math.max(0, d.actualSends - d.spamDetected), - spam: d.spamDetected, - })) ?? []; - if (isLoading) { return (
@@ -337,69 +319,23 @@ export function CampaignDetailPage() { - Daily Performance + Warmup Plan +

+ Full {campaign.durationDays}-day schedule with live progress. Hover a day for + scheduled vs sent, replied, and spam counts. Updates every 30 seconds. +

- {chartData.length === 0 ? ( -

No daily stats yet

+ {warmupPlan ? ( + + ) : campaign.currentDay === 0 ? ( +

+ Start the campaign to see the warmup plan chart. +

) : ( -
- - - - - - - - - - - - -
- )} -
-
- - - - Inbox Placement (7d) - - - {chartData.length === 0 ? ( -

No placement data yet

- ) : ( -
- - - - - - - - - - - +
+ + Loading warmup plan…
)} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d68d77b..ca496f3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -60,6 +60,28 @@ export type CampaignStatsToday = { onTrackReasons: string[]; }; +/** One day in the full-campaign warmup plan (scheduled vs live actuals). */ +export type CampaignWarmupPlanDay = { + dayIndex: number; + date: string | null; + isToday: boolean; + isFuture: boolean; + planned: { sends: number; replies: number }; + actual: { + sends: number; + replies: number; + spamDetected: number; + spamRescued: number; + }; +}; + +export type CampaignWarmupPlan = { + campaignId: string; + currentDay: number; + durationDays: number; + days: CampaignWarmupPlanDay[]; +}; + export type WorkspaceOverview = { workspaceId: string | null; count: number;