From d6ee0afbda4075f170616a3db98d9f698f71141b Mon Sep 17 00:00:00 2001 From: Jared Zwick <52264361+jaredzwick@users.noreply.github.com> Date: Sun, 3 May 2026 02:14:22 -0400 Subject: [PATCH 1/2] hir-94: campaigns list + detail pages, post-create redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user could create a campaign but had no way to see it afterward — no listing, no detail view, no status, no sent/open/click counts. The GET /api/campaigns and GET /api/campaigns/:id endpoints already existed; only the UI was missing. - /dashboard/campaigns: lists campaigns with status badge, sent progress, reply count, created date. Empty-state CTA links to /dashboard/campaigns/new. - /dashboard/campaigns/[id]: shows status, send progress, opens / clicks / replies / bounces / unsubscribes / total recipients, plus optional queue stats (pending / sent / failed / bounced) when the API returns them. Includes a delete action with confirm prompt. - /dashboard/campaigns/new: on successful POST, router.push to the new campaign's detail page so the user lands somewhere meaningful. - src/lib/campaignStatus.ts: pure helpers (status badge label/tone, sent progress formatter with clamp + percent rounding) so render logic is unit-testable without mounting React. 15 vitest specs in tests/int/campaignStatus.int.spec.ts cover all known + unknown statuses, null/undefined/empty inputs, and the formatter's clamp / NaN / zero-total / rounding paths. No API route, schema, migration, or send-pipeline changes. tsc clean (error count went from 10 pre-existing on main to 0 — earlier merged PRs cleared them). Full test:int matches main baseline (only api.int.spec.ts still fails on missing PAYLOAD_SECRET, pre-existing). Co-Authored-By: Paperclip --- .../dashboard/campaigns/[id]/page.tsx | 215 ++++++++++++++++++ .../dashboard/campaigns/new/page.tsx | 9 +- .../(frontend)/dashboard/campaigns/page.tsx | 143 ++++++++++++ src/lib/campaignStatus.ts | 49 ++++ tests/int/campaignStatus.int.spec.ts | 89 ++++++++ 5 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 src/app/(frontend)/dashboard/campaigns/[id]/page.tsx create mode 100644 src/app/(frontend)/dashboard/campaigns/page.tsx create mode 100644 src/lib/campaignStatus.ts create mode 100644 tests/int/campaignStatus.int.spec.ts diff --git a/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx b/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx new file mode 100644 index 0000000..57dd9d6 --- /dev/null +++ b/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx @@ -0,0 +1,215 @@ +'use client' + +import { use, useEffect, useState } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { + describeCampaignStatus, + formatCampaignProgress, +} from '@/lib/campaignStatus' + +type CampaignDetail = { + id: string + name: string + status: string + totalRecipients: number + sentCount: number + openCount: number + clickCount: number + replyCount: number + bounceCount: number + unsubscribeCount: number + createdAt: string + updatedAt: string + queueStats?: { + pending?: number + sent?: number + failed?: number + bounced?: number + } +} + +type LoadState = + | { kind: 'loading' } + | { kind: 'ready'; campaign: CampaignDetail } + | { kind: 'error'; status?: number; message: string } + +export default function CampaignDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const router = useRouter() + const [state, setState] = useState({ kind: 'loading' }) + const [deleting, setDeleting] = useState(false) + + useEffect(() => { + let cancelled = false + fetch(`/api/campaigns/${id}`) + .then(async (res) => { + const data = await res.json().catch(() => ({})) + if (!res.ok || !data?.success) { + const msg = data?.error || `Failed (${res.status})` + throw Object.assign(new Error(msg), { status: res.status }) + } + return data.campaign as CampaignDetail + }) + .then((campaign) => { + if (!cancelled) setState({ kind: 'ready', campaign }) + }) + .catch((err) => { + if (cancelled) return + setState({ + kind: 'error', + status: err?.status, + message: err instanceof Error ? err.message : String(err), + }) + }) + return () => { + cancelled = true + } + }, [id]) + + const handleDelete = async () => { + if (state.kind !== 'ready') return + if (!confirm(`Delete "${state.campaign.name}" and all its queue entries?`)) { + return + } + setDeleting(true) + try { + const res = await fetch(`/api/campaigns/${id}`, { method: 'DELETE' }) + const data = await res.json().catch(() => ({})) + if (!res.ok || !data?.success) { + throw new Error(data?.error || `Failed (${res.status})`) + } + router.push('/dashboard/campaigns') + } catch (err) { + alert(err instanceof Error ? err.message : String(err)) + setDeleting(false) + } + } + + return ( +
+
+ + + {state.kind === 'loading' && ( +

Loading campaign…

+ )} + + {state.kind === 'error' && ( +
+

{state.message}

+ {state.status === 404 && ( +

+ Campaign may have been deleted.{' '} + + Back to list + + . +

+ )} +
+ )} + + {state.kind === 'ready' && ( + + )} +
+
+ ) +} + +function CampaignDetailBody({ + campaign, + deleting, + onDelete, +}: { + campaign: CampaignDetail + deleting: boolean + onDelete: () => void +}) { + const badge = describeCampaignStatus(campaign.status) + return ( + <> +
+
+

{campaign.name}

+

+ Created {new Date(campaign.createdAt).toLocaleString()} · Updated{' '} + {new Date(campaign.updatedAt).toLocaleString()} +

+
+ + {badge.label} + +
+ +
+

Send progress

+

+ {formatCampaignProgress({ + totalRecipients: campaign.totalRecipients, + sentCount: campaign.sentCount, + })} +

+
+ +
+ + + + + + +
+ + {campaign.queueStats && ( +
+

Queue

+
+ + + + +
+
+ )} + +
+ +
+ + ) +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/src/app/(frontend)/dashboard/campaigns/new/page.tsx b/src/app/(frontend)/dashboard/campaigns/new/page.tsx index c3f70f7..8e72441 100644 --- a/src/app/(frontend)/dashboard/campaigns/new/page.tsx +++ b/src/app/(frontend)/dashboard/campaigns/new/page.tsx @@ -1,7 +1,7 @@ 'use client' import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' -import { useSearchParams } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -34,6 +34,7 @@ type SubmitResult = | { kind: 'error'; message: string } function NewCampaignPageInner() { + const router = useRouter() const searchParams = useSearchParams() const [name, setName] = useState('') const [subject, setSubject] = useState('') @@ -114,11 +115,15 @@ function NewCampaignPageInner() { if (!res.ok || !data?.success) { throw new Error(data?.error || `Request failed (${res.status})`) } + const campaignId = data.campaign?.id ?? '' setSubmit({ kind: 'success', - campaignId: data.campaign?.id ?? '', + campaignId, queued: data.queuedEmails ?? parsed.recipients.length, }) + if (campaignId) { + router.push(`/dashboard/campaigns/${campaignId}`) + } } catch (err) { setSubmit({ kind: 'error', diff --git a/src/app/(frontend)/dashboard/campaigns/page.tsx b/src/app/(frontend)/dashboard/campaigns/page.tsx new file mode 100644 index 0000000..9695634 --- /dev/null +++ b/src/app/(frontend)/dashboard/campaigns/page.tsx @@ -0,0 +1,143 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { + describeCampaignStatus, + formatCampaignProgress, +} from '@/lib/campaignStatus' + +type CampaignSummary = { + id: string + name: string + status: string + totalRecipients: number + sentCount: number + openCount: number + clickCount: number + replyCount: number + bounceCount: number + unsubscribeCount: number + createdAt: string + updatedAt: string +} + +type LoadState = + | { kind: 'loading' } + | { kind: 'ready'; campaigns: CampaignSummary[] } + | { kind: 'error'; message: string } + +export default function CampaignsListPage() { + const [state, setState] = useState({ kind: 'loading' }) + + useEffect(() => { + let cancelled = false + fetch('/api/campaigns') + .then(async (res) => { + const data = await res.json().catch(() => ({})) + if (!res.ok || !data?.success) { + throw new Error(data?.error || `Failed (${res.status})`) + } + return data.campaigns as CampaignSummary[] + }) + .then((campaigns) => { + if (!cancelled) setState({ kind: 'ready', campaigns }) + }) + .catch((err) => { + if (cancelled) return + setState({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }) + }) + return () => { + cancelled = true + } + }, []) + + return ( +
+
+
+

Campaigns

+ + + +
+ + {state.kind === 'loading' && ( +

Loading campaigns…

+ )} + + {state.kind === 'error' && ( +

+ Couldn't load campaigns: {state.message} +

+ )} + + {state.kind === 'ready' && state.campaigns.length === 0 && ( +
+

+ You haven't created any campaigns yet. +

+ + + +
+ )} + + {state.kind === 'ready' && state.campaigns.length > 0 && ( +
+ + + + + + + + + + + + {state.campaigns.map((c) => { + const badge = describeCampaignStatus(c.status) + return ( + + + + + + + + ) + })} + +
NameStatusSentRepliesCreated
+ + {c.name} + + + + {badge.label} + + + {formatCampaignProgress({ + totalRecipients: c.totalRecipients, + sentCount: c.sentCount, + })} + {c.replyCount} + {new Date(c.createdAt).toLocaleDateString()} +
+
+ )} +
+
+ ) +} diff --git a/src/lib/campaignStatus.ts b/src/lib/campaignStatus.ts new file mode 100644 index 0000000..9fd6300 --- /dev/null +++ b/src/lib/campaignStatus.ts @@ -0,0 +1,49 @@ +export type CampaignStatus = + | 'draft' + | 'scheduled' + | 'sending' + | 'completed' + | 'paused' + +export type CampaignStatusBadge = { + label: string + tone: 'neutral' | 'info' | 'progress' | 'success' | 'warning' +} + +const KNOWN: Record = { + draft: { label: 'Draft', tone: 'neutral' }, + scheduled: { label: 'Scheduled', tone: 'info' }, + sending: { label: 'Sending', tone: 'progress' }, + completed: { label: 'Completed', tone: 'success' }, + paused: { label: 'Paused', tone: 'warning' }, +} + +export function describeCampaignStatus( + status: string | null | undefined, +): CampaignStatusBadge { + if (status && status in KNOWN) { + return KNOWN[status as CampaignStatus] + } + return { label: status ? capitalize(status) : 'Unknown', tone: 'neutral' } +} + +export function formatCampaignProgress(input: { + totalRecipients: number + sentCount: number +}): string { + const total = Math.max(0, input.totalRecipients) + const sent = clamp(input.sentCount, 0, total) + if (total === 0) return '0 / 0' + const pct = Math.round((sent / total) * 100) + return `${sent} / ${total} (${pct}%)` +} + +function clamp(value: number, min: number, max: number): number { + if (Number.isNaN(value)) return min + return Math.min(max, Math.max(min, value)) +} + +function capitalize(s: string): string { + if (!s) return s + return s[0].toUpperCase() + s.slice(1) +} diff --git a/tests/int/campaignStatus.int.spec.ts b/tests/int/campaignStatus.int.spec.ts new file mode 100644 index 0000000..7d3b0d2 --- /dev/null +++ b/tests/int/campaignStatus.int.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + describeCampaignStatus, + formatCampaignProgress, +} from '@/lib/campaignStatus' + +describe('describeCampaignStatus', () => { + it.each([ + ['draft', { label: 'Draft', tone: 'neutral' }], + ['scheduled', { label: 'Scheduled', tone: 'info' }], + ['sending', { label: 'Sending', tone: 'progress' }], + ['completed', { label: 'Completed', tone: 'success' }], + ['paused', { label: 'Paused', tone: 'warning' }], + ] as const)('maps known %s status', (input, expected) => { + expect(describeCampaignStatus(input)).toEqual(expected) + }) + + it('falls back to capitalized neutral for unknown status', () => { + expect(describeCampaignStatus('archived')).toEqual({ + label: 'Archived', + tone: 'neutral', + }) + }) + + it('handles null and undefined', () => { + expect(describeCampaignStatus(null)).toEqual({ + label: 'Unknown', + tone: 'neutral', + }) + expect(describeCampaignStatus(undefined)).toEqual({ + label: 'Unknown', + tone: 'neutral', + }) + }) + + it('handles empty string as Unknown', () => { + expect(describeCampaignStatus('')).toEqual({ + label: 'Unknown', + tone: 'neutral', + }) + }) +}) + +describe('formatCampaignProgress', () => { + it('formats with percentage', () => { + expect( + formatCampaignProgress({ totalRecipients: 100, sentCount: 25 }), + ).toBe('25 / 100 (25%)') + }) + + it('returns 0 / 0 when total is zero', () => { + expect( + formatCampaignProgress({ totalRecipients: 0, sentCount: 0 }), + ).toBe('0 / 0') + }) + + it('clamps sent above total back to total (no >100% display)', () => { + expect( + formatCampaignProgress({ totalRecipients: 10, sentCount: 25 }), + ).toBe('10 / 10 (100%)') + }) + + it('clamps negative sent count to zero', () => { + expect( + formatCampaignProgress({ totalRecipients: 10, sentCount: -3 }), + ).toBe('0 / 10 (0%)') + }) + + it('treats negative total as zero', () => { + expect( + formatCampaignProgress({ totalRecipients: -5, sentCount: 1 }), + ).toBe('0 / 0') + }) + + it('rounds percentage to nearest integer', () => { + expect( + formatCampaignProgress({ totalRecipients: 3, sentCount: 1 }), + ).toBe('1 / 3 (33%)') + expect( + formatCampaignProgress({ totalRecipients: 3, sentCount: 2 }), + ).toBe('2 / 3 (67%)') + }) + + it('handles NaN sent count as zero', () => { + expect( + formatCampaignProgress({ totalRecipients: 10, sentCount: NaN }), + ).toBe('0 / 10 (0%)') + }) +}) From e471dfec0e1257761158c7dbb4653fceb1fa7ece Mon Sep 17 00:00:00 2001 From: Jared Zwick <52264361+jaredzwick@users.noreply.github.com> Date: Sun, 3 May 2026 06:29:22 -0400 Subject: [PATCH 2/2] hir-94: per-recipient queue table on campaign detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The campaign detail page surfaced aggregate counts but a user could not see which specific recipients were pending, sent, failed, or bounced. The most common post-create question — "did my emails actually go out?" — required a database query to answer. - GET /api/campaigns/:id/queue: paginated per-recipient queue rows. Owner-checked (loads the campaign first to verify userId), then delegates to the existing getQueueEntriesByCampaign helper. Limit defaults 100, max 500; offset honored. Returns the columns a UI table needs (status / attempts / scheduled / sent / errorMessage) and omits raw send_log internals. - /dashboard/campaigns/:id detail page: new "Recipients" section renders the queue rows in a table with status badge, attempts N/M, last-activity relative time, and a one-line error summary. - src/lib/queueRowSummary.ts: pure helpers (queue-status badge label/tone, summarizeError that trims to a single line + ellipsis, formatRelativeTime that takes a fixed `now` for testability). 29 vitest specs in tests/int/queueRowSummary.int.spec.ts cover every known + unknown queue status, error truncation (empty / multi-line / CRLF / over-max with rounded ellipsis / trailing-whitespace strip / custom max), and relative-time formatting (just-now band, past + future across m/h/d/w with rounding, ISO string input, NaN/null handling). Stacked on hir-94/campaign-list-detail (PR-pending). No schema, no migrations, no send-pipeline changes. tsc clean. test:int 139 passed (only pre-existing PAYLOAD_SECRET api.int.spec.ts fails). Co-Authored-By: Paperclip --- .../dashboard/campaigns/[id]/page.tsx | 135 ++++++++++++++++++ src/app/api/campaigns/[id]/queue/route.ts | 85 +++++++++++ src/lib/queueRowSummary.ts | 92 ++++++++++++ tests/int/queueRowSummary.int.spec.ts | 115 +++++++++++++++ 4 files changed, 427 insertions(+) create mode 100644 src/app/api/campaigns/[id]/queue/route.ts create mode 100644 src/lib/queueRowSummary.ts create mode 100644 tests/int/queueRowSummary.int.spec.ts diff --git a/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx b/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx index 57dd9d6..0d26742 100644 --- a/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx +++ b/src/app/(frontend)/dashboard/campaigns/[id]/page.tsx @@ -8,6 +8,29 @@ import { describeCampaignStatus, formatCampaignProgress, } from '@/lib/campaignStatus' +import { + describeQueueStatus, + formatRelativeTime, + summarizeError, +} from '@/lib/queueRowSummary' + +type QueueEntry = { + id: string + recipientEmail: string + recipientName: string | null + status: string + attemptCount: number + maxAttempts: number + scheduledFor: string | null + lastAttemptAt: string | null + sentAt: string | null + errorMessage: string | null +} + +type QueueState = + | { kind: 'loading' } + | { kind: 'ready'; entries: QueueEntry[]; hasMore: boolean } + | { kind: 'error'; message: string } type CampaignDetail = { id: string @@ -43,6 +66,7 @@ export default function CampaignDetailPage({ const { id } = use(params) const router = useRouter() const [state, setState] = useState({ kind: 'loading' }) + const [queue, setQueue] = useState({ kind: 'loading' }) const [deleting, setDeleting] = useState(false) useEffect(() => { @@ -72,6 +96,34 @@ export default function CampaignDetailPage({ } }, [id]) + useEffect(() => { + let cancelled = false + fetch(`/api/campaigns/${id}/queue?limit=100`) + .then(async (res) => { + const data = await res.json().catch(() => ({})) + if (!res.ok || !data?.success) { + throw new Error(data?.error || `Failed (${res.status})`) + } + return { + entries: data.entries as QueueEntry[], + hasMore: Boolean(data.pagination?.hasMore), + } + }) + .then(({ entries, hasMore }) => { + if (!cancelled) setQueue({ kind: 'ready', entries, hasMore }) + }) + .catch((err) => { + if (cancelled) return + setQueue({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }) + }) + return () => { + cancelled = true + } + }, [id]) + const handleDelete = async () => { if (state.kind !== 'ready') return if (!confirm(`Delete "${state.campaign.name}" and all its queue entries?`)) { @@ -123,6 +175,7 @@ export default function CampaignDetailPage({ {state.kind === 'ready' && ( @@ -134,10 +187,12 @@ export default function CampaignDetailPage({ function CampaignDetailBody({ campaign, + queue, deleting, onDelete, }: { campaign: CampaignDetail + queue: QueueState deleting: boolean onDelete: () => void }) { @@ -191,6 +246,11 @@ function CampaignDetailBody({ )} +
+

Recipients

+ +
+