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 (
+ <>
+
+
+
+ Send progress
+
+ {formatCampaignProgress({
+ totalRecipients: campaign.totalRecipients,
+ sentCount: campaign.sentCount,
+ })}
+
+
+
+
+
+ {campaign.queueStats && (
+
+ )}
+
+
+ >
+ )
+}
+
+function Stat({ label, value }: { label: string; value: number }) {
+ return (
+
+ )
+}
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 && (
+
+
+
+
+ | Name |
+ Status |
+ Sent |
+ Replies |
+ Created |
+
+
+
+ {state.campaigns.map((c) => {
+ const badge = describeCampaignStatus(c.status)
+ return (
+
+ |
+
+ {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({
)}
+
+
+ )
+ }
+ if (queue.kind === 'error') {
+ return (
+
+ Couldn't load recipients: {queue.message}
+
+ )
+ }
+ if (queue.entries.length === 0) {
+ return (
+ No recipients queued.
+ )
+ }
+ return (
+
+
+
+
+ | Recipient |
+ Status |
+ Attempts |
+ Last activity |
+ Error |
+
+
+
+ {queue.entries.map((row) => {
+ const badge = describeQueueStatus(row.status)
+ const lastTs = row.sentAt ?? row.lastAttemptAt ?? row.scheduledFor
+ return (
+
+ |
+ {row.recipientEmail}
+ {row.recipientName && (
+
+ {row.recipientName}
+
+ )}
+ |
+
+
+ {badge.label}
+
+ |
+
+ {row.attemptCount} / {row.maxAttempts}
+ |
+
+ {formatRelativeTime(lastTs)}
+ |
+
+ {summarizeError(row.errorMessage)}
+ |
+
+ )
+ })}
+
+
+ {queue.hasMore && (
+
+ Showing the first 100 recipients. Pagination coming soon.
+
+ )}
+
+ )
+}
+
function Stat({ label, value }: { label: string; value: number }) {
return (
diff --git a/src/app/api/campaigns/[id]/queue/route.ts b/src/app/api/campaigns/[id]/queue/route.ts
new file mode 100644
index 0000000..8af4160
--- /dev/null
+++ b/src/app/api/campaigns/[id]/queue/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { requireAuth, AuthorizationError } from '@/lib/authorization'
+import {
+ getCampaignWithStats,
+ getQueueEntriesByCampaign,
+} from '@coldflow/db'
+
+/**
+ * GET /api/campaigns/[id]/queue
+ *
+ * Per-recipient queue entries for a campaign. Owner-checked. Paginated:
+ * `?limit=N&offset=N` (defaults limit=100, offset=0; max limit=500).
+ *
+ * The campaign endpoint already returns aggregate counts; this surfaces
+ * the per-row state so users can see who is pending / sent / failed.
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { id } = await params
+ const user = await requireAuth()
+
+ // Verify the campaign exists and the requester owns it. Cheap, and
+ // means the queue endpoint can never leak rows from another tenant.
+ const campaign = await getCampaignWithStats(id)
+ if (!campaign) {
+ return NextResponse.json(
+ { success: false, error: 'Campaign not found' },
+ { status: 404 },
+ )
+ }
+ if (campaign.userId !== user.id) {
+ return NextResponse.json(
+ { success: false, error: 'Unauthorized to access this campaign' },
+ { status: 403 },
+ )
+ }
+
+ const searchParams = request.nextUrl.searchParams
+ const limit = clamp(parseInt(searchParams.get('limit') || '100'), 1, 500)
+ const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'))
+
+ const entries = await getQueueEntriesByCampaign(id, limit, offset)
+
+ return NextResponse.json({
+ success: true,
+ entries: entries.map((e) => ({
+ id: e.id,
+ recipientEmail: e.recipientEmail,
+ recipientName: e.recipientName,
+ status: e.status,
+ attemptCount: e.attemptCount,
+ maxAttempts: e.maxAttempts,
+ scheduledFor: e.scheduledFor,
+ lastAttemptAt: e.lastAttemptAt,
+ sentAt: e.sentAt,
+ errorMessage: e.errorMessage,
+ })),
+ pagination: {
+ limit,
+ offset,
+ hasMore: entries.length === limit,
+ },
+ })
+ } catch (error) {
+ console.error('Error fetching campaign queue:', error)
+ if (error instanceof AuthorizationError) {
+ return NextResponse.json(
+ { success: false, error: error.message },
+ { status: error.statusCode },
+ )
+ }
+ return NextResponse.json(
+ { success: false, error: 'Failed to fetch campaign queue' },
+ { status: 500 },
+ )
+ }
+}
+
+function clamp(value: number, min: number, max: number): number {
+ if (Number.isNaN(value)) return min
+ return Math.min(max, Math.max(min, value))
+}
diff --git a/src/lib/queueRowSummary.ts b/src/lib/queueRowSummary.ts
new file mode 100644
index 0000000..0ad7622
--- /dev/null
+++ b/src/lib/queueRowSummary.ts
@@ -0,0 +1,92 @@
+export type QueueStatus =
+ | 'pending'
+ | 'processing'
+ | 'sent'
+ | 'failed'
+ | 'bounced'
+
+export type QueueStatusBadge = {
+ label: string
+ tone: 'neutral' | 'info' | 'progress' | 'success' | 'warning' | 'error'
+}
+
+const KNOWN: Record = {
+ pending: { label: 'Pending', tone: 'neutral' },
+ processing: { label: 'Processing', tone: 'progress' },
+ sent: { label: 'Sent', tone: 'success' },
+ failed: { label: 'Failed', tone: 'error' },
+ bounced: { label: 'Bounced', tone: 'warning' },
+}
+
+export function describeQueueStatus(
+ status: string | null | undefined,
+): QueueStatusBadge {
+ if (status && status in KNOWN) {
+ return KNOWN[status as QueueStatus]
+ }
+ return { label: status ? capitalize(status) : 'Unknown', tone: 'neutral' }
+}
+
+/**
+ * Trim verbose Gmail / SMTP / OAuth error blobs to a single useful line.
+ * Caller-provided errors are surfaced in the UI table; long stack traces or
+ * full HTTP bodies make the row unreadable. Always returns a string ≤ max.
+ */
+export function summarizeError(
+ raw: string | null | undefined,
+ max = 140,
+): string {
+ if (!raw) return ''
+ const firstLine = raw.split(/\r?\n/, 1)[0]?.trim() ?? ''
+ if (firstLine.length <= max) return firstLine
+ return firstLine.slice(0, max - 1).trimEnd() + '…'
+}
+
+/**
+ * Format a duration between `from` and `to` (defaults to now) as a short,
+ * UI-friendly relative string ("2m ago", "in 3h", "just now"). Pure;
+ * tests pass a fixed `now` so no system-clock dependency.
+ */
+export function formatRelativeTime(
+ from: Date | string | null | undefined,
+ now: Date = new Date(),
+): string {
+ if (!from) return ''
+ const ts = typeof from === 'string' ? new Date(from) : from
+ if (Number.isNaN(ts.getTime())) return ''
+
+ const diffMs = ts.getTime() - now.getTime()
+ const past = diffMs < 0
+ const abs = Math.abs(diffMs)
+
+ if (abs < 45_000) return 'just now'
+
+ const units: Array<[number, string]> = [
+ [60_000, 'm'],
+ [3_600_000, 'h'],
+ [86_400_000, 'd'],
+ [604_800_000, 'w'],
+ ]
+
+ let value = 0
+ let suffix = ''
+ for (let i = 0; i < units.length; i++) {
+ const [scale, label] = units[i]
+ const next = units[i + 1]?.[0]
+ if (!next || abs < next) {
+ value = Math.round(abs / scale)
+ suffix = label
+ break
+ }
+ }
+ if (!suffix) {
+ value = Math.round(abs / 604_800_000)
+ suffix = 'w'
+ }
+ return past ? `${value}${suffix} ago` : `in ${value}${suffix}`
+}
+
+function capitalize(s: string): string {
+ if (!s) return s
+ return s[0].toUpperCase() + s.slice(1)
+}
diff --git a/tests/int/queueRowSummary.int.spec.ts b/tests/int/queueRowSummary.int.spec.ts
new file mode 100644
index 0000000..037ddaf
--- /dev/null
+++ b/tests/int/queueRowSummary.int.spec.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from 'vitest'
+import {
+ describeQueueStatus,
+ formatRelativeTime,
+ summarizeError,
+} from '@/lib/queueRowSummary'
+
+describe('describeQueueStatus', () => {
+ it.each([
+ ['pending', 'Pending', 'neutral'],
+ ['processing', 'Processing', 'progress'],
+ ['sent', 'Sent', 'success'],
+ ['failed', 'Failed', 'error'],
+ ['bounced', 'Bounced', 'warning'],
+ ])('maps %s to %s/%s', (input, label, tone) => {
+ expect(describeQueueStatus(input)).toEqual({ label, tone })
+ })
+
+ it('falls back to capitalized neutral for unknown', () => {
+ expect(describeQueueStatus('archived')).toEqual({
+ label: 'Archived',
+ tone: 'neutral',
+ })
+ })
+
+ it('handles null / undefined / empty', () => {
+ expect(describeQueueStatus(null).label).toBe('Unknown')
+ expect(describeQueueStatus(undefined).label).toBe('Unknown')
+ expect(describeQueueStatus('').label).toBe('Unknown')
+ })
+})
+
+describe('summarizeError', () => {
+ it('returns empty string for empty input', () => {
+ expect(summarizeError(null)).toBe('')
+ expect(summarizeError(undefined)).toBe('')
+ expect(summarizeError('')).toBe('')
+ })
+
+ it('returns the first line, trimmed', () => {
+ expect(summarizeError(' oh no \nstack trace\nmore\n')).toBe('oh no')
+ })
+
+ it('truncates with ellipsis when over the max', () => {
+ const long = 'x'.repeat(200)
+ const out = summarizeError(long, 50)
+ expect(out).toHaveLength(50)
+ expect(out.endsWith('…')).toBe(true)
+ })
+
+ it('honors a custom max length', () => {
+ expect(summarizeError('hello world', 5)).toBe('hell…')
+ })
+
+ it('handles CRLF line endings', () => {
+ expect(summarizeError('first\r\nsecond')).toBe('first')
+ })
+
+ it('keeps lines exactly at the max as-is (no ellipsis)', () => {
+ const exact = 'a'.repeat(140)
+ expect(summarizeError(exact)).toBe(exact)
+ })
+
+ it('trims trailing whitespace before adding the ellipsis', () => {
+ const out = summarizeError('hello world world', 10)
+ expect(out.endsWith('…')).toBe(true)
+ expect(out).not.toMatch(/ +…$/)
+ })
+})
+
+describe('formatRelativeTime', () => {
+ const now = new Date('2026-05-03T12:00:00Z')
+
+ it.each([
+ [new Date('2026-05-03T12:00:00Z'), 'just now'],
+ [new Date('2026-05-03T11:59:50Z'), 'just now'],
+ [new Date('2026-05-03T11:58:00Z'), '2m ago'],
+ [new Date('2026-05-03T11:00:00Z'), '1h ago'],
+ [new Date('2026-05-03T09:00:00Z'), '3h ago'],
+ [new Date('2026-05-02T12:00:00Z'), '1d ago'],
+ [new Date('2026-04-26T12:00:00Z'), '1w ago'],
+ [new Date('2026-04-01T12:00:00Z'), '5w ago'],
+ ])('past: %s -> %s', (from, expected) => {
+ expect(formatRelativeTime(from, now)).toBe(expected)
+ })
+
+ it.each([
+ [new Date('2026-05-03T12:02:00Z'), 'in 2m'],
+ [new Date('2026-05-03T13:00:00Z'), 'in 1h'],
+ [new Date('2026-05-04T12:00:00Z'), 'in 1d'],
+ [new Date('2026-05-10T12:00:00Z'), 'in 1w'],
+ ])('future: %s -> %s', (from, expected) => {
+ expect(formatRelativeTime(from, now)).toBe(expected)
+ })
+
+ it('accepts ISO string input', () => {
+ expect(formatRelativeTime('2026-05-03T11:00:00Z', now)).toBe('1h ago')
+ })
+
+ it('returns empty for null / undefined / NaN dates', () => {
+ expect(formatRelativeTime(null, now)).toBe('')
+ expect(formatRelativeTime(undefined, now)).toBe('')
+ expect(formatRelativeTime('not-a-date', now)).toBe('')
+ })
+
+ it('rounds to the nearest unit (not floor)', () => {
+ // 89s ago rounds to 1m, 91s ago also rounds to 2m (closer)
+ expect(
+ formatRelativeTime(new Date(now.getTime() - 89_000), now),
+ ).toBe('1m ago')
+ expect(
+ formatRelativeTime(new Date(now.getTime() - 91_000), now),
+ ).toBe('2m ago')
+ })
+})