docs: consolidate windowed-fanout spec/plan with ETA + paused/resume

Folds in three rounds of requirement evolution:

* Pause/resume on window close (was stop-and-report-partial).
* ETA preview pill at compose / edit time so the operator sees
  whether their chosen window will fit before scheduling.
* Interactive paused-run banner with Resume / Cancel buttons on the
  detail page; pause notification deep-links to it.

Helper relocations:

* windowEndAt() moves to packages/shared so both bot fire-reminder
  and the web ETA pill can import the same calculator.

Plan grows from 8 to 10 tasks: adds Task 9 (run-eta + RunEtaPill,
TDD) and Task 10 (resume/cancel actions + PausedRunBanner).
Acceptance gains two paused-flow smoke tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 14:33:51 +08:00
parent c4d4f1dda7
commit 082a70db06
2 changed files with 877 additions and 93 deletions

View File

@ -21,16 +21,19 @@
| `apps/bot/src/scheduler/per-key-mutex.test.ts` (new) | unit tests |
| `apps/bot/src/scheduler/rate-limiter.ts` (new) | per-account token bucket |
| `apps/bot/src/scheduler/rate-limiter.test.ts` (new) | fake-clock unit tests |
| `apps/bot/src/scheduler/delivery-window.ts` (new) | pure window-end calculator |
| `apps/bot/src/scheduler/delivery-window.test.ts` (new) | unit tests |
| `packages/shared/src/delivery-window.ts` (new) | pure window-end calculator (shared bot+web) |
| `packages/shared/src/delivery-window.test.ts` (new) | unit tests |
| `apps/bot/src/scheduler/media-upload-cache.ts` (new) | `prepareWAMessageMedia` results, keyed by mediaId |
| `apps/bot/src/scheduler/media-upload-cache.test.ts` (new) | mock-socket unit tests |
| `apps/bot/src/scheduler/fire-reminder.ts` (rewrite) | new loop using all of the above |
| `apps/bot/src/scheduler/fire-reminder.ts` (rewrite) | new loop using all of the above; accepts optional `runId` for resume |
| `apps/bot/src/scheduler/reminder-jobs.ts` | pass `teamSize` config |
| `apps/web/src/actions/reminders.ts` | accept the two new fields on create/update |
| `apps/web/src/actions/reminders.ts` | accept the two new fields; add `resumeReminderRunAction`, `cancelReminderRunAction` |
| `apps/web/src/components/reminder-wizard/when-form-client.tsx` | "Delivery hours" inputs |
| `apps/web/src/components/reminder-edit/edit-when-form.tsx` | same |
| `apps/web/src/lib/notifications.ts` | partial-status notification body extension |
| `apps/web/src/lib/run-eta.ts` (new) | pure ETA calculator |
| `apps/web/src/components/reminder-wizard/run-eta-pill.tsx` (new) | green/amber ETA pill |
| `apps/web/src/components/reminder-detail/paused-run-banner.tsx` (new) | Resume / Cancel run banner on detail page |
| `apps/web/src/lib/notifications.ts` | paused + partial notification body extension |
---
@ -657,11 +660,14 @@ WhatsApp account."
## Task 5: Delivery window helper (TDD)
**Files:**
- Create: `apps/bot/src/scheduler/delivery-window.test.ts`
- Create: `apps/bot/src/scheduler/delivery-window.ts`
The helper lives in `packages/shared` (NOT the bot) because both bundles need it: bot's fire-reminder loop gates on it, and web's ETA pill (Task 9) compares ETA against it to flip the green/amber state.
- [ ] **Step 1: Write the failing test file at `apps/bot/src/scheduler/delivery-window.test.ts`**
**Files:**
- Create: `packages/shared/src/delivery-window.test.ts`
- Create: `packages/shared/src/delivery-window.ts`
- Modify: `packages/shared/src/index.ts` (re-export `windowEndAt`)
- [ ] **Step 1: Write the failing test file at `packages/shared/src/delivery-window.test.ts`**
```ts
import { describe, it, expect } from "vitest";
@ -720,12 +726,12 @@ describe("windowEndAt", () => {
- [ ] **Step 2: Run the failing test**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/bot test -- --run delivery-window
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run delivery-window
```
Expected: FAIL with "Cannot find module './delivery-window.js'".
- [ ] **Step 3: Implement `apps/bot/src/scheduler/delivery-window.ts`**
- [ ] **Step 3: Implement `packages/shared/src/delivery-window.ts`**
```ts
import { DateTime } from "luxon";
@ -766,19 +772,27 @@ export function windowEndAt(
}
```
- [ ] **Step 4: Run the tests**
- [ ] **Step 4: Re-export from `packages/shared/src/index.ts`**
Append:
```ts
export { windowEndAt } from "./delivery-window.js";
```
- [ ] **Step 5: Run the tests**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/bot test -- --run delivery-window
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run delivery-window
```
Expected: PASS, 6 tests.
- [ ] **Step 5: Commit**
- [ ] **Step 6: Commit**
```bash
git add apps/bot/src/scheduler/delivery-window.ts apps/bot/src/scheduler/delivery-window.test.ts
git commit -m "feat(bot): pure delivery-window end calculator
git add packages/shared/src/delivery-window.ts packages/shared/src/delivery-window.test.ts packages/shared/src/index.ts
git commit -m "feat(shared): pure delivery-window end calculator
windowEndAt(timezone, endHour, fireAt) returns the end-of-window for
the day fireAt is on. If fireAt is already past, the result is a
@ -1052,7 +1066,7 @@ import { scheduleReminderFire } from "./reminder-jobs.js";
import { pgNotifyWeb } from "../ipc/notify.js";
import { accountMutex } from "./per-key-mutex.js";
import { accountRateLimiter } from "./rate-limiter.js";
import { windowEndAt } from "./delivery-window.js";
import { windowEndAt } from "@cmbot/shared";
import { MediaUploadCache } from "./media-upload-cache.js";
export type FireReminderPayload = {
@ -1155,7 +1169,10 @@ async function fireReminderInner(
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
// Pre-create run_targets rows so progress is observable mid-run.
if (reminder.targets.length > 0) {
// On a RESUME, the rows already exist — only the original fire path
// inserts them. The resume path skips this; the loop below filters
// to only the still-pending rows.
if (!resumeRunId && reminder.targets.length > 0) {
await db.insert(reminderRunTargets).values(
reminder.targets.map((t) => ({
runId,
@ -1166,6 +1183,37 @@ async function fireReminderInner(
);
}
// On a RESUME, only the still-pending targets need attention. On
// a fresh fire, every target is pending. Either way we read the
// current run_target rows from the DB to be the source of truth
// about what's left to do.
const pendingRows = await db.query.reminderRunTargets.findMany({
where: (t, { eq, and: drizzleAnd }) =>
drizzleAnd(eq(t.runId, runId), eq(t.status, "pending")),
});
const pendingGroupIds = new Set(pendingRows.map((r) => r.groupId));
const targetsToProcess = reminder.targets.filter((t) =>
pendingGroupIds.has(t.groupId),
);
// Already-sent count from prior run (so the final tally adds to total).
const priorSentCount = resumeRunId
? (
await db.query.reminderRunTargets.findMany({
where: (t, { eq, and: drizzleAnd }) =>
drizzleAnd(eq(t.runId, runId), eq(t.status, "sent")),
})
).length
: 0;
const priorFailedCount = resumeRunId
? (
await db.query.reminderRunTargets.findMany({
where: (t, { eq, and: drizzleAnd }) =>
drizzleAnd(eq(t.runId, runId), eq(t.status, "failed")),
})
).length
: 0;
// Window-end timestamp. If the reminder fires AFTER today's end-hour
// (e.g. cron miss-fired late) this is in the past — every iteration
// will trip the gate and the run resolves as failed.
@ -1211,22 +1259,14 @@ async function fireReminderInner(
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
await Promise.all(
reminder.targets.map((target) =>
targetsToProcess.map((target) =>
groupConcurrency(async () => {
// Window-end gate — even if we already started other groups,
// any not-yet-started one stops here.
// Window-end gate. CRITICAL: leave the row as `pending` (NOT
// `skipped`) so the run can be resumed later. The run as a
// whole flips to `paused` after this loop.
if (Date.now() >= windowEnd.getTime()) {
windowClosed = true;
await db
.update(reminderRunTargets)
.set({ status: "skipped", error: "delivery window closed" })
.where(
and(
eq(reminderRunTargets.runId, runId),
eq(reminderRunTargets.groupId, target.groupId),
),
);
skippedCount++;
// Don't touch the row — it's already `pending`. Just count.
return;
}
@ -1303,21 +1343,45 @@ async function fireReminderInner(
),
);
// Final status. The four shapes:
// - paused : window closed with at least one row STILL pending.
// Resumable. Sent rows stay sent, pending stays pending.
// - success : every target sent (no failures, no pending).
// - partial : every target was attempted; some sent, some failed
// or skipped. NOT resumable; failures are real.
// - failed : zero sent. Either every send errored, or the window
// was already closed when the run began (nothing
// attempted, but no pending-with-progress to resume).
const total = reminder.targets.length;
let status: "success" | "partial" | "failed";
const totalSent = priorSentCount + sentCount;
const totalFailed = priorFailedCount + failedCount;
// Re-read pending count from the DB so the count reflects whatever
// the loop left behind (any window-skipped rows are still pending).
const remainingPending = (
await db.query.reminderRunTargets.findMany({
where: (t, { eq, and: drizzleAnd }) =>
drizzleAnd(eq(t.runId, runId), eq(t.status, "pending")),
})
).length;
let status: "success" | "partial" | "failed" | "paused";
let errorSummary: string | null = null;
if (sentCount === total) {
if (windowClosed && remainingPending > 0 && totalSent > 0) {
status = "paused";
errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}). ${totalSent} of ${total} groups delivered, ${remainingPending} still pending. Resume from the Activity tab. If this happens repeatedly, consider offloading to another paired account, or shrinking the message body / media size to fit more groups in your daily window.`;
} else if (windowClosed && totalSent === 0) {
// Window was closed before any send happened. Not paused — there's
// nothing meaningful to resume. Counts as a hard failure.
status = "failed";
errorSummary = `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}) before any group could be sent. The reminder fired too late in the day.`;
} else if (totalSent === total) {
status = "success";
} else if (sentCount > 0) {
} else if (totalSent > 0) {
status = "partial";
errorSummary = windowClosed
? `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}). ${sentCount} of ${total} groups delivered. This account is at capacity for this fan-out — consider sending the remainder from another paired account.`
: `${sentCount} of ${total} groups delivered (${failedCount} failed, ${skippedCount} skipped).`;
errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`;
} else {
status = "failed";
errorSummary = windowClosed
? `Delivery window closed at ${reminder.deliveryWindowEndHour}:00 (${reminder.timezone}) before any group could be sent. The reminder fired too late in the day.`
: `All ${total} sends failed.`;
errorSummary = `All ${total} sends failed.`;
}
await db
@ -1332,7 +1396,11 @@ async function fireReminderInner(
status,
});
// One-off reminders end after firing. Recurring reminders re-arm.
// Lifecycle bookkeeping. Skip when the run is paused — the reminder
// shouldn't end or re-arm while a resume is still possible.
const runIsTerminal = status !== "paused";
if (runIsTerminal) {
if (reminder.scheduleKind === "one_off") {
await db
.update(reminders)
@ -1356,6 +1424,12 @@ async function fireReminderInner(
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
}
}
} else {
logger.info(
{ reminderId: reminder.id, runId },
"fire-reminder: paused — leaving reminder lifecycle unchanged for resume",
);
}
await writeAuditLog(db, {
operatorId: reminder.createdBy,
@ -1606,7 +1680,8 @@ In the JSX, just before the existing `<RecurrencePicker>` block, add a new "Deli
</div>
<p className="text-xs text-muted-foreground">
The bot stops sending after the end hour. Long fan-outs that don&apos;t
finish in this window get reported as partial.
finish in this window are paused — you can resume them from the
Activity tab.
</p>
</div>
```
@ -1691,9 +1766,9 @@ initialDeliveryStartHour?: number;
initialDeliveryEndHour?: number;
```
- [ ] **Step 6: Extend the partial-status notification body in `apps/web/src/lib/notifications.ts`**
- [ ] **Step 6: Extend the notification body for paused + partial in `apps/web/src/lib/notifications.ts`**
Find `reminderFiredToNotification`. The existing function takes `{ reminderId, runId, status }`. Update the body to mention the delivered/total when status is `partial`:
Find `reminderFiredToNotification`. The existing function takes `{ reminderId, runId, status }`. Extend the event shape to carry `sent`/`total` and handle `paused` as a first-class status:
```ts
export function reminderFiredToNotification(event: {
@ -1708,16 +1783,22 @@ export function reminderFiredToNotification(event: {
const headline =
event.status === "success"
? "Reminder sent"
: event.status === "paused"
? "Reminder paused"
: event.status === "partial"
? "Reminder partly sent"
: "Reminder failed";
let body =
event.status === "success"
? "All groups received the message."
: event.status === "paused"
? "Delivery window closed before all groups got the message."
: event.status === "partial"
? "Some groups received the message; others failed. See activity."
: "No groups received the message. See activity.";
if (event.status === "partial" && event.sent !== undefined && event.total !== undefined) {
if (event.status === "paused" && event.sent !== undefined && event.total !== undefined) {
body = `${event.sent} of ${event.total} groups delivered. Tap to resume or cancel.`;
} else if (event.status === "partial" && event.sent !== undefined && event.total !== undefined) {
body = `${event.sent} of ${event.total} groups delivered. See activity for details.`;
}
return {
@ -1729,11 +1810,37 @@ export function reminderFiredToNotification(event: {
}
```
Also update the SSE event emitter (search for `reminder.fired` in `apps/bot/src/scheduler/fire-reminder.ts`) to include `sent` and `total` on the published event payload — Task 7 already wires this in the new fire-reminder. Confirm the web-side receiver passes those fields through to `reminderFiredToNotification`.
- [ ] **Step 7: Add tests for the extended notification body**
Append to `apps/web/src/lib/notifications.test.ts` (inside the existing `describe("reminderFiredToNotification mapping", () => {...})` block):
```ts
it("paused with sent/total renders 'Tap to resume or cancel'", () => {
const args = reminderFiredToNotification({
type: "reminder.fired",
reminderId: "r-1",
runId: "run-1",
status: "paused",
sent: 412,
total: 1000,
});
expect(args?.title).toBe("Reminder paused");
expect(args?.body).toBe("412 of 1000 groups delivered. Tap to resume or cancel.");
});
it("paused without sent/total falls back to a generic paused body", () => {
const args = reminderFiredToNotification({
type: "reminder.fired",
reminderId: "r-1",
runId: "run-1",
status: "paused",
});
expect(args?.title).toBe("Reminder paused");
expect(args?.body).toMatch(/Delivery window closed/);
});
it("partial with sent/total renders 'X of Y groups delivered' instead of the generic body", () => {
const args = reminderFiredToNotification({
type: "reminder.fired",
@ -1793,16 +1900,616 @@ inputs for the delivery window hours (default 6/18). Server actions
accept them on the create/update Zod schemas, validate
0 <= start < end <= 24, and persist to the new reminders columns.
Notification body for partial-status reminders now reads
'412 of 1000 groups delivered. See activity for details.' when the
SSE event carries sent/total counts."
Notification body for paused-status now reads
'412 of 1000 groups delivered. Tap to resume or cancel.'; partial
status uses the same delivered/total wording when sent/total are
present."
```
---
## Task 9: Run-ETA helper + ETA pill in wizard review (TDD)
**Files:**
- Create: `apps/web/src/lib/run-eta.ts`
- Create: `apps/web/src/lib/run-eta.test.ts`
- Create: `apps/web/src/components/reminder-wizard/run-eta-pill.tsx`
- Create: `apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx`
- Modify: `apps/web/src/components/reminder-wizard/review-submit-client.tsx`
- Modify: `apps/web/src/components/reminder-edit/edit-when-form.tsx`
- Modify: `apps/web/src/components/reminder-edit/edit-groups-form.tsx`
- [ ] **Step 1: Write the failing run-eta test**
Create `apps/web/src/lib/run-eta.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { estimateRunDuration, ASSUMED_RATE_PER_MINUTE } from "./run-eta";
describe("estimateRunDuration", () => {
it("uses target count / rate plus a 15% buffer, ceiling-rounded to whole minutes", () => {
const r = estimateRunDuration({
targetCount: 1000,
ratePerMinute: 40,
fireAt: new Date("2026-05-13T09:00:00.000+08:00"),
});
// 1000 / 40 = 25 min; +15% = 28.75 → ceil = 29
expect(r.durationMinutes).toBe(29);
expect(r.estimatedFinishAt.toISOString()).toBe(
new Date("2026-05-13T09:29:00.000+08:00").toISOString(),
);
});
it("returns a 1-minute floor for very small runs", () => {
const r = estimateRunDuration({
targetCount: 1,
ratePerMinute: 40,
fireAt: new Date("2026-05-13T09:00:00.000+08:00"),
});
expect(r.durationMinutes).toBe(1);
});
it("returns 0 minutes and finishAt = fireAt when targetCount is 0", () => {
const fireAt = new Date("2026-05-13T09:00:00.000+08:00");
const r = estimateRunDuration({ targetCount: 0, ratePerMinute: 40, fireAt });
expect(r.durationMinutes).toBe(0);
expect(r.estimatedFinishAt.toISOString()).toBe(fireAt.toISOString());
});
it("throws when ratePerMinute is 0 or negative", () => {
expect(() =>
estimateRunDuration({
targetCount: 100,
ratePerMinute: 0,
fireAt: new Date(),
}),
).toThrow();
expect(() =>
estimateRunDuration({
targetCount: 100,
ratePerMinute: -1,
fireAt: new Date(),
}),
).toThrow();
});
it("exports the configured default rate constant", () => {
expect(typeof ASSUMED_RATE_PER_MINUTE).toBe("number");
expect(ASSUMED_RATE_PER_MINUTE).toBeGreaterThan(0);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run run-eta
```
Expected: FAIL — `Cannot find module './run-eta'`.
- [ ] **Step 3: Write the helper to pass**
Create `apps/web/src/lib/run-eta.ts`:
```ts
/**
* Default per-account send rate, mirroring `BOT_MAX_SEND_PER_MINUTE`
* in the bot env. The web bundle hardcodes this — operators who tune
* the bot env are expected to redeploy web with the matching value.
*/
export const ASSUMED_RATE_PER_MINUTE = 40;
const ETA_BUFFER = 1.15;
export function estimateRunDuration(opts: {
targetCount: number;
ratePerMinute?: number;
fireAt: Date;
}): { durationMinutes: number; estimatedFinishAt: Date } {
const rate = opts.ratePerMinute ?? ASSUMED_RATE_PER_MINUTE;
if (rate <= 0) throw new Error("ratePerMinute must be > 0");
if (opts.targetCount <= 0) {
return { durationMinutes: 0, estimatedFinishAt: new Date(opts.fireAt) };
}
const raw = (opts.targetCount / rate) * ETA_BUFFER;
const durationMinutes = Math.max(1, Math.ceil(raw));
const estimatedFinishAt = new Date(
opts.fireAt.getTime() + durationMinutes * 60_000,
);
return { durationMinutes, estimatedFinishAt };
}
```
- [ ] **Step 4: Run test to verify it passes**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run run-eta
```
Expected: PASS (5/5).
- [ ] **Step 5: Write the failing pill component test**
Create `apps/web/src/components/reminder-wizard/run-eta-pill.test.tsx`:
```tsx
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { RunEtaPill } from "./run-eta-pill";
describe("RunEtaPill", () => {
it("renders green 'Fits in window' when estimatedFinishAt <= windowEndAt", () => {
const html = renderToStaticMarkup(
<RunEtaPill
targetCount={500}
fireAt={new Date("2026-05-13T09:00:00.000+08:00")}
windowEndAt={new Date("2026-05-13T18:00:00.000+08:00")}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toMatch(/Fits in window/);
expect(html).toMatch(/min/);
expect(html).not.toMatch(/Likely to pause/);
});
it("renders amber 'Likely to pause' when ETA exceeds window", () => {
const html = renderToStaticMarkup(
<RunEtaPill
targetCount={5000}
fireAt={new Date("2026-05-13T17:00:00.000+08:00")}
windowEndAt={new Date("2026-05-13T18:00:00.000+08:00")}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toMatch(/Likely to pause/);
expect(html).toMatch(/Widen the window/);
});
it("renders nothing for zero targets", () => {
const html = renderToStaticMarkup(
<RunEtaPill
targetCount={0}
fireAt={new Date("2026-05-13T09:00:00.000+08:00")}
windowEndAt={new Date("2026-05-13T18:00:00.000+08:00")}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toBe("");
});
});
```
- [ ] **Step 6: Implement the pill**
Create `apps/web/src/components/reminder-wizard/run-eta-pill.tsx`:
```tsx
import { ClockIcon, AlertTriangleIcon } from "lucide-react";
import { estimateRunDuration } from "@/lib/run-eta";
interface RunEtaPillProps {
targetCount: number;
fireAt: Date;
windowEndAt: Date;
timezone: string;
}
/**
* Visible at the wizard's review step and on the per-section edit
* pages that affect ETA (groups, when). Advisory only — does NOT
* block submission. The operator can still schedule a run that
* pauses; the pause-and-resume flow covers that case.
*/
export function RunEtaPill({
targetCount,
fireAt,
windowEndAt,
timezone,
}: RunEtaPillProps) {
if (targetCount <= 0) return null;
const { durationMinutes, estimatedFinishAt } = estimateRunDuration({
targetCount,
fireAt,
});
const fits = estimatedFinishAt.getTime() <= windowEndAt.getTime();
const finishLocal = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
}).format(estimatedFinishAt);
if (fits) {
return (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-400">
<ClockIcon className="size-3.5" />
<span>
~{durationMinutes} min · finishes ~{finishLocal} · Fits in window
</span>
</div>
);
}
return (
<div className="flex items-start gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangleIcon className="size-3.5 mt-0.5 shrink-0" />
<div className="space-y-0.5">
<div>
~{durationMinutes} min · finishes ~{finishLocal} · Likely to pause
</div>
<div className="text-[11px] opacity-80">
Widen the window or split into smaller runs.
</div>
</div>
</div>
);
}
```
- [ ] **Step 7: Run pill tests**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run run-eta-pill
```
Expected: 3/3 PASS.
- [ ] **Step 8: Wire the pill into review-submit-client**
In `apps/web/src/components/reminder-wizard/review-submit-client.tsx`, add the pill above the Schedule button. The component already has access to `groupIds`, `scheduledAt`, `timezone`, and now `deliveryEndHour` (from Task 8). Compute the windowEndAt inline:
```tsx
import { RunEtaPill } from "./run-eta-pill";
import { windowEndAt as computeWindowEndAt } from "@cmbot/shared"; // see note below
// inside the component, just before the action button:
{groupIds && scheduledAt && (() => {
const ids = groupIds.split(",").filter(Boolean);
const fireAt = new Date(scheduledAt);
const wEnd = computeWindowEndAt(timezone, deliveryEndHour ?? 18, fireAt);
return (
<RunEtaPill
targetCount={ids.length}
fireAt={fireAt}
windowEndAt={wEnd}
timezone={timezone}
/>
);
})()}
```
`computeWindowEndAt` is the helper Task 5 created in `packages/shared/src/delivery-window.ts` — both bundles import it from `@cmbot/shared`.
- [ ] **Step 9: Wire the pill into edit-groups-form and edit-when-form**
Both forms know the reminder's `targetCount`, `fireAt`, `timezone`, and `deliveryWindowEndHour`. Add the pill above their Save button:
```tsx
<RunEtaPill
targetCount={selectedGroupIds.length}
fireAt={new Date(scheduledAtIso)}
windowEndAt={computeWindowEndAt(
timezone,
deliveryEndHour,
new Date(scheduledAtIso),
)}
timezone={timezone}
/>
```
- [ ] **Step 10: Run all web tests**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/bot test -- --run
```
Expected: all green. Web ≈304 (added 8 ETA tests + 3 pill tests, partially offset by no test removals). Shared adds the windowEndAt tests that moved from bot.
- [ ] **Step 11: Commit**
```bash
git add apps/web packages/shared apps/bot
git commit -m "feat(web): ETA preview pill in wizard + edit-groups + edit-when
estimateRunDuration() computes a per-run ETA from BOT_MAX_SEND_PER_MINUTE
(hardcoded as ASSUMED_RATE_PER_MINUTE in the web bundle) plus a 15%
buffer. The RunEtaPill component shows a green 'Fits in window' or
amber 'Likely to pause' badge with a one-line suggestion. windowEndAt
moves from apps/bot/src/scheduler/delivery-window.ts to
packages/shared/src/delivery-window.ts so both bundles can import it."
```
---
## Task 10: Resume + cancel actions and PausedRunBanner
**Files:**
- Modify: `apps/web/src/actions/reminders.ts` (add `resumeReminderRunAction`, `cancelReminderRunAction`)
- Create: `apps/web/src/components/reminder-detail/paused-run-banner.tsx`
- Create: `apps/web/src/components/reminder-detail/paused-run-banner.test.tsx`
- Modify: `apps/web/src/app/reminders/[id]/page.tsx` (mount the banner)
- Modify: `apps/web/src/app/activity/page.tsx` (add Paused filter + Resume button per row)
- [ ] **Step 1: Write the failing banner test**
Create `apps/web/src/components/reminder-detail/paused-run-banner.test.tsx`:
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { PausedRunBanner } from "./paused-run-banner";
const resumeMock = vi.fn();
const cancelMock = vi.fn();
vi.mock("@/actions/reminders", () => ({
resumeReminderRunAction: (...args: unknown[]) => resumeMock(...args),
cancelReminderRunAction: (...args: unknown[]) => cancelMock(...args),
}));
describe("PausedRunBanner", () => {
beforeEach(() => {
resumeMock.mockReset();
cancelMock.mockReset();
});
it("renders 'Resume' and 'Cancel run' buttons when latest run is paused", () => {
const html = renderToStaticMarkup(
<PausedRunBanner
runId="run-1"
sent={412}
total={1000}
windowEndHour={18}
timezone="Asia/Kuala_Lumpur"
/>,
);
expect(html).toMatch(/Reminder paused/);
expect(html).toMatch(/412 of 1000/);
expect(html).toMatch(/Resume/);
expect(html).toMatch(/Cancel run/);
});
});
```
- [ ] **Step 2: Run to verify it fails**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run paused-run-banner
```
Expected: FAIL — module not found.
- [ ] **Step 3: Add resume + cancel actions to `apps/web/src/actions/reminders.ts`**
Append (near the other run-related actions):
```ts
const runIdSchema = z.object({ runId: z.string().uuid() });
export async function resumeReminderRunAction(input: { runId: string }) {
const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input);
if (!parsed.success) {
return { ok: false as const, error: "Invalid runId" };
}
const run = await db.query.reminderRuns.findFirst({
where: (r, { eq }) => eq(r.id, parsed.data.runId),
with: { reminder: { columns: { operatorId: true, id: true } } },
});
if (!run || run.reminder.operatorId !== op.id) {
return { ok: false as const, error: "Run not found" };
}
if (run.status !== "paused") {
return { ok: false as const, error: `Cannot resume a ${run.status} run` };
}
await getBoss().send("reminder.fire", {
reminderId: run.reminder.id,
runId: run.id,
});
await writeAudit(op.id, "reminder.run.resumed", { runId: run.id });
revalidatePath(`/reminders/${run.reminder.id}`);
revalidatePath(`/activity`);
return { ok: true as const };
}
export async function cancelReminderRunAction(input: { runId: string }) {
const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input);
if (!parsed.success) {
return { ok: false as const, error: "Invalid runId" };
}
const run = await db.query.reminderRuns.findFirst({
where: (r, { eq }) => eq(r.id, parsed.data.runId),
with: { reminder: { columns: { operatorId: true, id: true } } },
});
if (!run || run.reminder.operatorId !== op.id) {
return { ok: false as const, error: "Run not found" };
}
if (run.status !== "paused") {
return { ok: false as const, error: `Cannot cancel a ${run.status} run` };
}
await db.transaction(async (tx) => {
await tx
.update(reminderRunTargets)
.set({ status: "skipped", error: "canceled by operator" })
.where(
and(
eq(reminderRunTargets.runId, run.id),
eq(reminderRunTargets.status, "pending"),
),
);
await tx
.update(reminderRuns)
.set({
status: "partial",
endedAt: new Date(),
errorSummary: "Canceled by operator before all groups received the message.",
})
.where(eq(reminderRuns.id, run.id));
});
await writeAudit(op.id, "reminder.run.canceled", { runId: run.id });
revalidatePath(`/reminders/${run.reminder.id}`);
revalidatePath(`/activity`);
return { ok: true as const };
}
```
(Imports needed at top of file: `revalidatePath` from `next/cache`, `getBoss` from `@/lib/boss`, `writeAudit` from `@/lib/audit`, `reminderRuns` and `reminderRunTargets` from `@cmbot/db`, `and`/`eq` from `drizzle-orm`.)
- [ ] **Step 4: Implement PausedRunBanner**
Create `apps/web/src/components/reminder-detail/paused-run-banner.tsx`:
```tsx
"use client";
import { useState, useTransition } from "react";
import { AlertCircleIcon, PlayIcon, XIcon, Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
resumeReminderRunAction,
cancelReminderRunAction,
} from "@/actions/reminders";
interface PausedRunBannerProps {
runId: string;
sent: number;
total: number;
windowEndHour: number;
timezone: string;
}
export function PausedRunBanner({
runId,
sent,
total,
windowEndHour,
timezone,
}: PausedRunBannerProps) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const onResume = () =>
startTransition(async () => {
setError(null);
const r = await resumeReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
const onCancel = () =>
startTransition(async () => {
setError(null);
const r = await cancelReminderRunAction({ runId });
if (!r.ok) setError(r.error);
});
return (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">Reminder paused</p>
<p className="text-xs text-muted-foreground">
{sent} of {total} groups delivered. The delivery window
closed at {windowEndHour}:00 ({timezone}). Resume to send
the remaining {total - sent} groups, or cancel the run.
</p>
</div>
</div>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
<div className="flex gap-2">
<Button size="sm" onClick={onResume} disabled={pending} className="gap-2">
{pending ? <Loader2Icon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
Resume
</Button>
<Button size="sm" variant="outline" onClick={onCancel} disabled={pending} className="gap-2">
<XIcon className="size-3.5" />
Cancel run
</Button>
</div>
</div>
);
}
```
- [ ] **Step 5: Run banner test**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run paused-run-banner
```
Expected: PASS.
- [ ] **Step 6: Mount the banner on the reminder detail page**
In `apps/web/src/app/reminders/[id]/page.tsx`, after loading `reminder` and `runs`, find the latest paused run and conditionally render the banner above the section list:
```tsx
const latestPausedRun = runs.find((r) => r.status === "paused");
// in JSX, near the top of the page content:
{latestPausedRun && (
<PausedRunBanner
runId={latestPausedRun.id}
sent={latestPausedRun.sentCount ?? 0}
total={latestPausedRun.totalCount ?? 0}
windowEndHour={reminder.deliveryWindowEndHour}
timezone={reminder.timezone}
/>
)}
```
(`sentCount` and `totalCount` need to be derived in the query — adjust `getReminderWithRuns` to count run-target rows by status. If not present, add a count step there.)
- [ ] **Step 7: Add Paused filter + Resume button on Activity page**
In `apps/web/src/app/activity/page.tsx`, extend the status filter pills to include `"paused"` (amber). For each row whose status is `paused`, render a small Resume button inline (use the same `resumeReminderRunAction`).
Re-use a small client component `ResumeRunButton` that wraps the action call (mirrors the existing inline buttons elsewhere). Place it in `apps/web/src/components/activity/resume-run-button.tsx`.
- [ ] **Step 8: Run all web tests**
```bash
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/web test -- --run
```
Expected: all green.
- [ ] **Step 9: Restart web container so SSR picks up the new client components**
```bash
NO_SUDO=1 docker compose --env-file .env.development -f docker-compose.base.yml -f docker-compose.dev.yml restart web
```
- [ ] **Step 10: Commit**
```bash
git add apps/web
git commit -m "feat(web): paused-run banner with Resume / Cancel buttons
resumeReminderRunAction re-enqueues the existing run via pg-boss with
runId in the payload (Task 7's fire-reminder accepts that). Cancel
action flips remaining pending targets to skipped and resolves the
run to partial. Activity tab gets a Paused filter and inline Resume
button on each paused row."
```
---
## Acceptance check (manual)
After all 8 tasks land:
After all 10 tasks land:
- [ ] **Smoke 1.** Create a reminder for 1 group with default delivery hours and a 30-second future fire time. Verify it lands; verify the run's `error_summary` is null and status `success`.
@ -1818,6 +2525,18 @@ After all 8 tasks land:
- [ ] **Smoke 4.** Create a reminder with a 5-MB JPEG and 3 groups. Fire it. Bot logs should show only ONE `prepareWAMessageMedia` / upload entry (look for the upload size in the Baileys debug log) followed by 3 relayMessage events.
- [ ] **Smoke 5.** Wizard ETA pill: pick 5 groups + default 6/18 hours. Pill should be green ("Fits in window"). Pick 5000 groups (clone an existing one): pill should flip amber ("Likely to pause") with the "Widen the window" hint.
- [ ] **Smoke 6.** Trigger a paused run on purpose: set `BOT_MAX_SEND_PER_MINUTE=2` in `.env.development`, restart bot, fire a 10-group reminder with end hour set ~3 minutes from now. Verify:
- The run resolves `paused` (~6 groups sent).
- A "Reminder paused" notification appears (with sent/total in body).
- The detail page shows the banner with **Resume** and **Cancel run**.
- Click **Resume** → run continues from the unsent targets, eventually resolving to `success` (or `paused` again if the window closes again).
- Reset `BOT_MAX_SEND_PER_MINUTE` after the test.
- [ ] **Smoke 7.** Cancel-run: same setup as Smoke 6, but click **Cancel run** instead of Resume. Verify remaining pending targets become `skipped`, run resolves `partial` with errorSummary "Canceled by operator before all groups received the message.", banner disappears.
- [ ] **Final test sweep:**
```bash
@ -1826,4 +2545,4 @@ After all 8 tasks land:
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run
```
Expected: all green. ~380 total.
Expected: all green. ~395 total (web ≈315 with the new ETA + banner + notification tests; shared adds windowEndAt suite that moved from bot).

View File

@ -100,6 +100,34 @@ cannot finish inside the window, send what we can and stop.
too would mean holding messages from a 4am cron miss-fire until
6am, which is a v2 conversation.
## Estimated finish time (ETA preview)
The operator sets the delivery window, but they need a feel for
whether the window is *enough*. We surface an ETA at compose and
edit time so they can widen the window (or shrink the run) before
hitting Schedule.
- A pure helper `estimateRunDuration({ targetCount, ratePerMinute })`
returns `{ durationMinutes, estimatedFinishAt }` given a fire time.
Calculation: `ceil(targetCount / ratePerMinute)` minutes plus a
15% buffer for per-group setup latency, with a 1-minute floor.
- Default `ratePerMinute` reads `BOT_MAX_SEND_PER_MINUTE` (40); the
number is hard-coded into the web bundle as a constant — operators
who tune the bot env are responsible for redeploying web. The web
side does NOT read bot env directly.
- Displayed in two places:
- **Wizard Review step**, between the recipients summary and the
Schedule button:
`"~28 minutes · finishes ~10:32 (Asia/Kuala_Lumpur)"`
- **Edit Groups** and **Edit When** pages, near the save button.
- Style:
- Green pill `"Fits in window"` when `estimatedFinishAt <= windowEndAt`.
- Amber pill `"Likely to pause"` when it doesn't, with a one-line
suggestion: *"Widen the window or split into smaller runs."*
- The ETA is advisory, not a hard gate — the operator can still
schedule a run that's likely to pause; pause-and-resume covers
that case. The ETA just removes the surprise.
## Run loop changes (`fire-reminder.ts`)
Up-front, once per run:
@ -182,22 +210,36 @@ UI surfaces of paused runs:
Success/Partial/Failed/Skipped/Archived filters. Resume button
inline on each paused row.
- Reminder detail page's run history shows the same Resume button on
paused rows.
paused rows. A prominent banner at the top of the detail page
surfaces the latest paused run with two buttons side-by-side:
**Resume** (re-enqueues the run via the action above) and
**Cancel run** (marks the run `partial` so it stops appearing as
paused; pending targets flip to `skipped` with `error="canceled by
operator"`). The banner is the operator's "interactive"
resume/cancel choice referenced from the pause notification.
- The `reminder.fired` SSE event for status=paused triggers a
notification with title "Reminder paused" and body
`"X of Y groups delivered. Resume from the Activity tab."`
`"X of Y groups delivered. Tap to resume or cancel."` Clicking the
notification deep-links to the detail page where the banner lives.
Note on the Notifications API: page-side `new Notification()` does
not support inline action buttons (only service-worker push
notifications do). The "interactive" choice is therefore one
click into the detail page — fewer surfaces to keep in sync, no
service worker required.
## Notification body
The existing `reminder.fired` SSE event already carries `{ status }`.
The notification mapper extends:
We extend it to carry `sent` and `total` counts so the notification
can be specific. The notification mapper:
- `success` → unchanged.
- `partial` → body mentions delivered/total counts when present.
- `paused` → headline `"Reminder paused"`, body
`"X of Y groups delivered. Resume from the Activity tab."` Click
takes the operator to the reminder's detail page where the Resume
button lives.
`"X of Y groups delivered. Tap to resume or cancel."` Click
takes the operator to the reminder's detail page where the
Resume / Cancel banner lives.
- `failed` → unchanged.
- `skipped` → still filtered (bookkeeping noise).
@ -211,13 +253,16 @@ The notification mapper extends:
| `apps/bot/src/scheduler/rate-limiter.ts` (new) | per-account token bucket | ~60 |
| `apps/bot/src/scheduler/media-upload-cache.ts` (new) | `prepareWAMessageMedia` results, keyed by mediaId | ~50 |
| `apps/bot/src/scheduler/delivery-window.ts` (new) | pure window-end calculator | ~30 |
| `apps/bot/src/scheduler/fire-reminder.ts` (rewrite) | new loop using all of the above | ~200 |
| `apps/bot/src/scheduler/fire-reminder.ts` (rewrite) | new loop using all of the above | ~220 |
| `apps/bot/src/scheduler/reminder-jobs.ts` | `teamSize` config | <10 |
| `apps/bot/src/env.ts` | `BOT_FIRE_CONCURRENCY`, `BOT_MAX_SEND_PER_MINUTE`, `BOT_GROUP_CONCURRENCY` | <20 |
| `apps/web/src/actions/reminders.ts` | accept the two new fields | <30 |
| `apps/web/src/actions/reminders.ts` | accept the two new fields + `resumeReminderRunAction` + `cancelReminderRunAction` | ~80 |
| `apps/web/src/components/reminder-wizard/when-form-client.tsx` | "Delivery hours" inputs | <40 |
| `apps/web/src/components/reminder-edit/edit-when-form.tsx` | same | <30 |
| `apps/web/src/lib/notifications.ts` | partial-status body extension | <15 |
| `apps/web/src/lib/run-eta.ts` (new) | pure ETA calculator | ~40 |
| `apps/web/src/components/reminder-wizard/run-eta-pill.tsx` (new) | shared green/amber pill component | ~50 |
| `apps/web/src/components/reminder-detail/paused-run-banner.tsx` (new) | "Resume / Cancel run" banner | ~70 |
| `apps/web/src/lib/notifications.ts` | paused + partial body extension | <30 |
## Tests
@ -232,8 +277,18 @@ The notification mapper extends:
- `media-upload-cache.test.ts` — mock socket: `prepare` called once
per unique mediaId regardless of how many groups consume it.
- `fire-reminder.test.ts` (extend) — window-end gate marks remaining
targets `skipped`; partial-status error_summary includes account /
delivered / total context.
targets `skipped` (failed-from-the-start path) or leaves them
`pending` and resolves the run `paused` when at least one send
succeeded; resume re-attaches and only re-attempts `pending` rows.
- `run-eta.test.ts` — pure ETA helper: 1000 groups @ 40/min returns
~29 minutes (with the 15% buffer), edge cases (0 groups → 0,
rate=0 → throws, fractional minutes → rounded up).
- `notifications.test.ts` (extend) — `paused` body reads
`"X of Y groups delivered. Tap to resume or cancel."`; `partial`
body uses sent/total when present.
- `paused-run-banner.test.tsx` — banner only renders when the latest
run's status is `paused`; Resume click triggers the action;
Cancel click triggers the cancel action.
## Tuning knobs (env)
@ -262,6 +317,12 @@ default to 6/18 and can be widened (e.g. 0/24) for a specific big run.
pause mid-fan-out isn't wired).
- **Retry-failed-targets** action (paused-resume only re-attempts
`pending` rows; `failed` rows stay failed).
- **Native push action buttons** (would require a service worker +
push endpoint; v1 keeps the resume/cancel choice on the detail
page, one click away from the notification).
- **Adaptive ETA from observed rate** (today the ETA uses the
configured `BOT_MAX_SEND_PER_MINUTE`; a v2 could feed back the
actual sustained rate from prior runs).
- **Multi-account auto-split** of a single reminder.
- **Adaptive rate limiting** (auto-back-off on WA rate-limit response
codes; today the operator tunes the env var).
@ -270,16 +331,20 @@ default to 6/18 and can be widened (e.g. 0/24) for a specific big run.
- 1000-group reminder with one image, established account: completes
in roughly 3050 minutes, comfortably inside a 6am6pm window.
- Wizard Review shows ETA pill before submit. Setting an end hour
that won't fit flips the pill amber and surfaces the "Likely to
pause" hint; the operator can still proceed.
- Two reminders on different accounts firing within seconds of each
other: both progress simultaneously, neither blocks the other.
- A run that hits the window end mid-fan-out: stops cleanly, marks
the run `paused`, leaves un-started targets as `pending`, surfaces
the paused-status notification with delivered/total counts.
- The operator clicks **Resume** on a paused run — fan-out continues
from the unsent targets, respecting the same per-account rate
limit + window. If it again can't finish, it pauses again with an
updated count.
- The detail page surfaces a Resume / Cancel banner for the paused
run. **Resume** re-enqueues; if it pauses again, the banner
re-appears with an updated count. **Cancel run** flips remaining
targets to `skipped` and resolves the run `partial`; banner
disappears.
- A run that hits the window end BEFORE any send (fired too late):
resolves `failed`, no resume offered.
- 355 existing tests still pass; ≈30 new tests cover the new helpers
and the paused/resume flow.
- 355 existing tests still pass; ≈40 new tests cover the new helpers,
the paused/resume flow, the ETA preview, and the banner.