diff --git a/.woodpecker.yml b/.woodpecker.yml
new file mode 100644
index 0000000..c2b3a8b
--- /dev/null
+++ b/.woodpecker.yml
@@ -0,0 +1,32 @@
+# Woodpecker CI — Forgejo at https://git.jonnyn.com/
+# Push to main → verify build → deploy on home server.
+#
+# Server prerequisites: see docs/deploy-ci-cd.md
+
+when:
+ - event: push
+ branch: main
+
+steps:
+ verify:
+ image: node:22-bookworm-slim
+ environment:
+ DATABASE_URL: file:/tmp/warmbox-ci.db
+ ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
+ NODE_ENV: test
+ commands:
+ - npm ci
+ - npx prisma generate
+ - npm run build
+ - npm test
+
+ deploy:
+ image: docker:27-cli
+ depends_on: [verify]
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - /srv/docker-configs/warmbox:/deploy
+ commands:
+ - apk add --no-cache git bash
+ - chmod +x /deploy/scripts/deploy.sh
+ - DEPLOY_DIR=/deploy DEPLOY_BRANCH=main bash /deploy/scripts/deploy.sh
diff --git a/apps/web/src/components/DailyPerformanceChart.tsx b/apps/web/src/components/DailyPerformanceChart.tsx
new file mode 100644
index 0000000..b14976d
--- /dev/null
+++ b/apps/web/src/components/DailyPerformanceChart.tsx
@@ -0,0 +1,98 @@
+import {
+ CartesianGrid,
+ Legend,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import type { DailyChartRow } from '../lib/campaign-chart-utils';
+
+function PerformanceTooltip({
+ active,
+ payload,
+}: {
+ active?: boolean;
+ payload?: Array<{ payload: DailyChartRow; name?: string; value?: number; color?: string }>;
+}) {
+ if (!active || !payload?.length) return null;
+ const row = payload[0]!.payload;
+
+ return (
+
+ No warmup emails received yet — placement appears after the first sends land in the
+ primary inbox.
+
+ );
+ }
+
+ const inboxPercent = (inboxCount / total) * 100;
+ const spamPercent = (spamCount / total) * 100;
+
+ const data: PlacementSlice[] = [
+ {
+ name: 'In Inbox',
+ value: inboxCount,
+ percent: inboxPercent,
+ color: INBOX_COLOR,
+ },
+ {
+ name: 'In Spam',
+ value: spamCount,
+ percent: spamPercent,
+ color: SPAM_COLOR,
+ },
+ ].filter((slice) => slice.value > 0);
+
+ return (
+
Day {row.dayIndex}
- {row.date ? ` · ${row.date}` : ''}
+ {row.date ? ` · ${row.label}` : ''}
{row.isToday &&
Today
}
{row.isFuture &&
Upcoming
}
@@ -74,11 +92,11 @@ function PlanTooltip({
<>
Sent
- {row.actualSends}
+ {sent}
Replied
- {row.actualReplies}
+ {row.actualReplies}
In spam
@@ -86,7 +104,7 @@ function PlanTooltip({
Inbox
- {row.inboxDelivered}
+ {row.inboxDelivered}
>
)}
@@ -114,14 +132,14 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
return (
-
+
20 ? Math.floor(plan.durationDays / 10) : 0}
/>
-
+
} />
{todayRow && (
@@ -132,22 +150,45 @@ export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
label={{ value: 'Today', position: 'top', fill: '#4f46e5', fontSize: 11 }}
/>
)}
+
+
+
+ (value > 0 ? String(value) : '')}
+ />
+
-
-
-
);
}
-
-export type { CampaignWarmupPlanDay };
diff --git a/apps/web/src/lib/campaign-chart-utils.ts b/apps/web/src/lib/campaign-chart-utils.ts
new file mode 100644
index 0000000..cd2da07
--- /dev/null
+++ b/apps/web/src/lib/campaign-chart-utils.ts
@@ -0,0 +1,41 @@
+import type { CampaignStatsToday } from '@warmbox/shared';
+
+export function formatChartDate(iso: string): string {
+ const d = new Date(`${iso}T12:00:00`);
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+}
+
+export function last7DaysIsoRange(): { from: string; to: string } {
+ const to = new Date();
+ const from = new Date();
+ from.setDate(from.getDate() - 6);
+ const toIso = to.toISOString().slice(0, 10);
+ const fromIso = from.toISOString().slice(0, 10);
+ return { from: fromIso, to: toIso };
+}
+
+export type DailyChartRow = {
+ dayIndex: number;
+ label: string;
+ date: string;
+ plannedSends: number;
+ plannedReplies: number;
+ sends: number;
+ replies: number;
+ spam: number;
+ inbox: number;
+};
+
+export function mapDailyStatToChartRow(stat: CampaignStatsToday): DailyChartRow {
+ return {
+ dayIndex: stat.dayIndex,
+ label: formatChartDate(stat.date),
+ date: stat.date,
+ plannedSends: stat.planned.sends,
+ plannedReplies: stat.planned.replies,
+ sends: stat.actual.sends,
+ replies: stat.actual.replies,
+ spam: stat.actual.spamDetected,
+ inbox: Math.max(0, stat.actual.sends - stat.actual.spamDetected),
+ };
+}
diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts
index 6663934..7c28da0 100644
--- a/apps/web/src/lib/types.ts
+++ b/apps/web/src/lib/types.ts
@@ -97,6 +97,22 @@ export type Campaign = {
export type DailyStat = CampaignStatsToday;
+export type CampaignStatsSummary = {
+ campaignId: string;
+ range: '7d' | '30d' | 'all';
+ totals: {
+ plannedSends: number;
+ plannedReplies: number;
+ sends: number;
+ replies: number;
+ spamDetected: number;
+ spamRescued: number;
+ };
+ rates: { reply: number; inbox: number };
+ onTrack: boolean;
+ days: number;
+};
+
export type WarmupLog = {
id: string;
campaignId: string | null;
diff --git a/apps/web/src/pages/CampaignDetailPage.tsx b/apps/web/src/pages/CampaignDetailPage.tsx
index 755229d..7960e95 100644
--- a/apps/web/src/pages/CampaignDetailPage.tsx
+++ b/apps/web/src/pages/CampaignDetailPage.tsx
@@ -7,6 +7,7 @@ import { invalidateCampaignCaches, invalidateCampaignListCaches } from '../lib/q
import type {
Campaign,
CampaignSatellite,
+ CampaignStatsSummary,
CampaignStatsToday,
CampaignWarmupPlan,
ComplianceReport,
@@ -18,6 +19,10 @@ import { StatusBadge } from '../components/StatusBadge';
import { MetricCard } from '../components/MetricCard';
import { ComplianceChecklist } from '../components/ComplianceChecklist';
import { WarmupPlanChart } from '../components/WarmupPlanChart';
+import { InboxVsSpamChart } from '../components/InboxVsSpamChart';
+import { DailyPerformanceChart } from '../components/DailyPerformanceChart';
+import { InboxPlacementChart } from '../components/InboxPlacementChart';
+import { last7DaysIsoRange, mapDailyStatToChartRow } from '../lib/campaign-chart-utils';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/Tabs';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
import { Skeleton } from '../components/ui/Skeleton';
@@ -78,6 +83,31 @@ export function CampaignDetailPage() {
refetchInterval: 30_000,
});
+ const { data: daily7d } = useQuery({
+ queryKey: ['campaign', id, 'stats', 'daily', '7d'],
+ queryFn: () => {
+ const { from, to } = last7DaysIsoRange();
+ return apiGet<{ data: CampaignStatsToday[] }>(
+ `/api/campaigns/${id}/stats/daily?from=${from}&to=${to}`,
+ );
+ },
+ enabled: !!id,
+ refetchInterval: 30_000,
+ });
+
+ const { data: placementSummary } = useQuery({
+ queryKey: ['campaign', id, 'stats', 'all'],
+ queryFn: () => apiGet
(`/api/campaigns/${id}/stats?range=all`),
+ enabled: !!id,
+ refetchInterval: 30_000,
+ });
+
+ const dailyChartData = daily7d?.data.map(mapDailyStatToChartRow) ?? [];
+ const inboxCount = placementSummary
+ ? Math.max(0, placementSummary.totals.sends - placementSummary.totals.spamDetected)
+ : 0;
+ const spamCount = placementSummary?.totals.spamDetected ?? 0;
+
const { data: logs } = useQuery({
queryKey: ['logs', { campaignId: id }],
queryFn: () =>
@@ -314,29 +344,71 @@ export function CampaignDetailPage() {
/>
-