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:
parent
c4d4f1dda7
commit
082a70db06
@ -21,16 +21,19 @@
|
|||||||
| `apps/bot/src/scheduler/per-key-mutex.test.ts` (new) | unit tests |
|
| `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.ts` (new) | per-account token bucket |
|
||||||
| `apps/bot/src/scheduler/rate-limiter.test.ts` (new) | fake-clock unit tests |
|
| `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 |
|
| `packages/shared/src/delivery-window.ts` (new) | pure window-end calculator (shared bot+web) |
|
||||||
| `apps/bot/src/scheduler/delivery-window.test.ts` (new) | unit tests |
|
| `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.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/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/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-wizard/when-form-client.tsx` | "Delivery hours" inputs |
|
||||||
| `apps/web/src/components/reminder-edit/edit-when-form.tsx` | same |
|
| `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)
|
## Task 5: Delivery window helper (TDD)
|
||||||
|
|
||||||
**Files:**
|
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.
|
||||||
- Create: `apps/bot/src/scheduler/delivery-window.test.ts`
|
|
||||||
- Create: `apps/bot/src/scheduler/delivery-window.ts`
|
|
||||||
|
|
||||||
- [ ] **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
|
```ts
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
@ -720,12 +726,12 @@ describe("windowEndAt", () => {
|
|||||||
- [ ] **Step 2: Run the failing test**
|
- [ ] **Step 2: Run the failing test**
|
||||||
|
|
||||||
```bash
|
```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'".
|
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
|
```ts
|
||||||
import { DateTime } from "luxon";
|
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
|
```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.
|
Expected: PASS, 6 tests.
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add apps/bot/src/scheduler/delivery-window.ts apps/bot/src/scheduler/delivery-window.test.ts
|
git add packages/shared/src/delivery-window.ts packages/shared/src/delivery-window.test.ts packages/shared/src/index.ts
|
||||||
git commit -m "feat(bot): pure delivery-window end calculator
|
git commit -m "feat(shared): pure delivery-window end calculator
|
||||||
|
|
||||||
windowEndAt(timezone, endHour, fireAt) returns the end-of-window for
|
windowEndAt(timezone, endHour, fireAt) returns the end-of-window for
|
||||||
the day fireAt is on. If fireAt is already past, the result is a
|
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 { pgNotifyWeb } from "../ipc/notify.js";
|
||||||
import { accountMutex } from "./per-key-mutex.js";
|
import { accountMutex } from "./per-key-mutex.js";
|
||||||
import { accountRateLimiter } from "./rate-limiter.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";
|
import { MediaUploadCache } from "./media-upload-cache.js";
|
||||||
|
|
||||||
export type FireReminderPayload = {
|
export type FireReminderPayload = {
|
||||||
@ -1155,7 +1169,10 @@ async function fireReminderInner(
|
|||||||
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
|
const mediaById = new Map(mediaRows.map((m) => [m.id, m]));
|
||||||
|
|
||||||
// Pre-create run_targets rows so progress is observable mid-run.
|
// 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(
|
await db.insert(reminderRunTargets).values(
|
||||||
reminder.targets.map((t) => ({
|
reminder.targets.map((t) => ({
|
||||||
runId,
|
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
|
// 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
|
// (e.g. cron miss-fired late) this is in the past — every iteration
|
||||||
// will trip the gate and the run resolves as failed.
|
// will trip the gate and the run resolves as failed.
|
||||||
@ -1211,22 +1259,14 @@ async function fireReminderInner(
|
|||||||
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
|
const groupConcurrency = pLimit(env.BOT_GROUP_CONCURRENCY);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
reminder.targets.map((target) =>
|
targetsToProcess.map((target) =>
|
||||||
groupConcurrency(async () => {
|
groupConcurrency(async () => {
|
||||||
// Window-end gate — even if we already started other groups,
|
// Window-end gate. CRITICAL: leave the row as `pending` (NOT
|
||||||
// any not-yet-started one stops here.
|
// `skipped`) so the run can be resumed later. The run as a
|
||||||
|
// whole flips to `paused` after this loop.
|
||||||
if (Date.now() >= windowEnd.getTime()) {
|
if (Date.now() >= windowEnd.getTime()) {
|
||||||
windowClosed = true;
|
windowClosed = true;
|
||||||
await db
|
// Don't touch the row — it's already `pending`. Just count.
|
||||||
.update(reminderRunTargets)
|
|
||||||
.set({ status: "skipped", error: "delivery window closed" })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderRunTargets.runId, runId),
|
|
||||||
eq(reminderRunTargets.groupId, target.groupId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
skippedCount++;
|
|
||||||
return;
|
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;
|
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;
|
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";
|
status = "success";
|
||||||
} else if (sentCount > 0) {
|
} else if (totalSent > 0) {
|
||||||
status = "partial";
|
status = "partial";
|
||||||
errorSummary = windowClosed
|
errorSummary = `${totalSent} of ${total} groups delivered (${totalFailed} failed, ${skippedCount} skipped).`;
|
||||||
? `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).`;
|
|
||||||
} else {
|
} else {
|
||||||
status = "failed";
|
status = "failed";
|
||||||
errorSummary = windowClosed
|
errorSummary = `All ${total} sends failed.`;
|
||||||
? `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.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@ -1332,7 +1396,11 @@ async function fireReminderInner(
|
|||||||
status,
|
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") {
|
if (reminder.scheduleKind === "one_off") {
|
||||||
await db
|
await db
|
||||||
.update(reminders)
|
.update(reminders)
|
||||||
@ -1356,6 +1424,12 @@ async function fireReminderInner(
|
|||||||
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
|
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, {
|
await writeAuditLog(db, {
|
||||||
operatorId: reminder.createdBy,
|
operatorId: reminder.createdBy,
|
||||||
@ -1606,7 +1680,8 @@ In the JSX, just before the existing `<RecurrencePicker>` block, add a new "Deli
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
The bot stops sending after the end hour. Long fan-outs that don't
|
The bot stops sending after the end hour. Long fan-outs that don't
|
||||||
finish in this window get reported as partial.
|
finish in this window are paused — you can resume them from the
|
||||||
|
Activity tab.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
@ -1691,9 +1766,9 @@ initialDeliveryStartHour?: number;
|
|||||||
initialDeliveryEndHour?: 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
|
```ts
|
||||||
export function reminderFiredToNotification(event: {
|
export function reminderFiredToNotification(event: {
|
||||||
@ -1708,16 +1783,22 @@ export function reminderFiredToNotification(event: {
|
|||||||
const headline =
|
const headline =
|
||||||
event.status === "success"
|
event.status === "success"
|
||||||
? "Reminder sent"
|
? "Reminder sent"
|
||||||
|
: event.status === "paused"
|
||||||
|
? "Reminder paused"
|
||||||
: event.status === "partial"
|
: event.status === "partial"
|
||||||
? "Reminder partly sent"
|
? "Reminder partly sent"
|
||||||
: "Reminder failed";
|
: "Reminder failed";
|
||||||
let body =
|
let body =
|
||||||
event.status === "success"
|
event.status === "success"
|
||||||
? "All groups received the message."
|
? "All groups received the message."
|
||||||
|
: event.status === "paused"
|
||||||
|
? "Delivery window closed before all groups got the message."
|
||||||
: event.status === "partial"
|
: event.status === "partial"
|
||||||
? "Some groups received the message; others failed. See activity."
|
? "Some groups received the message; others failed. See activity."
|
||||||
: "No groups received the message. 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.`;
|
body = `${event.sent} of ${event.total} groups delivered. See activity for details.`;
|
||||||
}
|
}
|
||||||
return {
|
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**
|
- [ ] **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):
|
Append to `apps/web/src/lib/notifications.test.ts` (inside the existing `describe("reminderFiredToNotification mapping", () => {...})` block):
|
||||||
|
|
||||||
```ts
|
```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", () => {
|
it("partial with sent/total renders 'X of Y groups delivered' instead of the generic body", () => {
|
||||||
const args = reminderFiredToNotification({
|
const args = reminderFiredToNotification({
|
||||||
type: "reminder.fired",
|
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
|
accept them on the create/update Zod schemas, validate
|
||||||
0 <= start < end <= 24, and persist to the new reminders columns.
|
0 <= start < end <= 24, and persist to the new reminders columns.
|
||||||
|
|
||||||
Notification body for partial-status reminders now reads
|
Notification body for paused-status now reads
|
||||||
'412 of 1000 groups delivered. See activity for details.' when the
|
'412 of 1000 groups delivered. Tap to resume or cancel.'; partial
|
||||||
SSE event carries sent/total counts."
|
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)
|
## 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`.
|
- [ ] **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 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:**
|
- [ ] **Final test sweep:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -1826,4 +2545,4 @@ After all 8 tasks land:
|
|||||||
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/shared test -- --run
|
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).
|
||||||
|
|||||||
@ -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
|
too would mean holding messages from a 4am cron miss-fire until
|
||||||
6am, which is a v2 conversation.
|
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`)
|
## Run loop changes (`fire-reminder.ts`)
|
||||||
|
|
||||||
Up-front, once per run:
|
Up-front, once per run:
|
||||||
@ -182,22 +210,36 @@ UI surfaces of paused runs:
|
|||||||
Success/Partial/Failed/Skipped/Archived filters. Resume button
|
Success/Partial/Failed/Skipped/Archived filters. Resume button
|
||||||
inline on each paused row.
|
inline on each paused row.
|
||||||
- Reminder detail page's run history shows the same Resume button on
|
- 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
|
- The `reminder.fired` SSE event for status=paused triggers a
|
||||||
notification with title "Reminder paused" and body
|
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
|
## Notification body
|
||||||
|
|
||||||
The existing `reminder.fired` SSE event already carries `{ status }`.
|
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.
|
- `success` → unchanged.
|
||||||
- `partial` → body mentions delivered/total counts when present.
|
- `partial` → body mentions delivered/total counts when present.
|
||||||
- `paused` → headline `"Reminder paused"`, body
|
- `paused` → headline `"Reminder paused"`, body
|
||||||
`"X of Y groups delivered. Resume from the Activity tab."` Click
|
`"X of Y groups delivered. Tap to resume or cancel."` Click
|
||||||
takes the operator to the reminder's detail page where the Resume
|
takes the operator to the reminder's detail page where the
|
||||||
button lives.
|
Resume / Cancel banner lives.
|
||||||
- `failed` → unchanged.
|
- `failed` → unchanged.
|
||||||
- `skipped` → still filtered (bookkeeping noise).
|
- `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/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/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/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/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/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-wizard/when-form-client.tsx` | "Delivery hours" inputs | <40 |
|
||||||
| `apps/web/src/components/reminder-edit/edit-when-form.tsx` | same | <30 |
|
| `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
|
## Tests
|
||||||
|
|
||||||
@ -232,8 +277,18 @@ The notification mapper extends:
|
|||||||
- `media-upload-cache.test.ts` — mock socket: `prepare` called once
|
- `media-upload-cache.test.ts` — mock socket: `prepare` called once
|
||||||
per unique mediaId regardless of how many groups consume it.
|
per unique mediaId regardless of how many groups consume it.
|
||||||
- `fire-reminder.test.ts` (extend) — window-end gate marks remaining
|
- `fire-reminder.test.ts` (extend) — window-end gate marks remaining
|
||||||
targets `skipped`; partial-status error_summary includes account /
|
targets `skipped` (failed-from-the-start path) or leaves them
|
||||||
delivered / total context.
|
`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)
|
## 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).
|
pause mid-fan-out isn't wired).
|
||||||
- **Retry-failed-targets** action (paused-resume only re-attempts
|
- **Retry-failed-targets** action (paused-resume only re-attempts
|
||||||
`pending` rows; `failed` rows stay failed).
|
`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.
|
- **Multi-account auto-split** of a single reminder.
|
||||||
- **Adaptive rate limiting** (auto-back-off on WA rate-limit response
|
- **Adaptive rate limiting** (auto-back-off on WA rate-limit response
|
||||||
codes; today the operator tunes the env var).
|
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
|
- 1000-group reminder with one image, established account: completes
|
||||||
in roughly 30–50 minutes, comfortably inside a 6am–6pm window.
|
in roughly 30–50 minutes, comfortably inside a 6am–6pm 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
|
- Two reminders on different accounts firing within seconds of each
|
||||||
other: both progress simultaneously, neither blocks the other.
|
other: both progress simultaneously, neither blocks the other.
|
||||||
- A run that hits the window end mid-fan-out: stops cleanly, marks
|
- A run that hits the window end mid-fan-out: stops cleanly, marks
|
||||||
the run `paused`, leaves un-started targets as `pending`, surfaces
|
the run `paused`, leaves un-started targets as `pending`, surfaces
|
||||||
the paused-status notification with delivered/total counts.
|
the paused-status notification with delivered/total counts.
|
||||||
- The operator clicks **Resume** on a paused run — fan-out continues
|
- The detail page surfaces a Resume / Cancel banner for the paused
|
||||||
from the unsent targets, respecting the same per-account rate
|
run. **Resume** re-enqueues; if it pauses again, the banner
|
||||||
limit + window. If it again can't finish, it pauses again with an
|
re-appears with an updated count. **Cancel run** flips remaining
|
||||||
updated count.
|
targets to `skipped` and resolves the run `partial`; banner
|
||||||
|
disappears.
|
||||||
- A run that hits the window end BEFORE any send (fired too late):
|
- A run that hits the window end BEFORE any send (fired too late):
|
||||||
resolves `failed`, no resume offered.
|
resolves `failed`, no resume offered.
|
||||||
- 355 existing tests still pass; ≈30 new tests cover the new helpers
|
- 355 existing tests still pass; ≈40 new tests cover the new helpers,
|
||||||
and the paused/resume flow.
|
the paused/resume flow, the ETA preview, and the banner.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user