+
{/* Hidden on mobile — the top header already shows "Reminders". */}
Reminders
-
diff --git a/apps/web/src/components/accounts-list-view.tsx b/apps/web/src/components/accounts-list-view.tsx
index 2f71791..2ed8aa6 100644
--- a/apps/web/src/components/accounts-list-view.tsx
+++ b/apps/web/src/components/accounts-list-view.tsx
@@ -33,11 +33,15 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
{/* Hidden on mobile — the top header already shows "Accounts". */}
Accounts
-
+
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
-
-
- Add Account
+
+
+ Add Account
diff --git a/apps/web/src/lib/run-eta.test.ts b/apps/web/src/lib/run-eta.test.ts
new file mode 100644
index 0000000..1a93521
--- /dev/null
+++ b/apps/web/src/lib/run-eta.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from "vitest";
+import { estimateRunDuration, ASSUMED_RATE_PER_MINUTE } from "./run-eta";
+
+describe("estimateRunDuration", () => {
+ it("uses targetCount/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("falls back to ASSUMED_RATE_PER_MINUTE when ratePerMinute is omitted", () => {
+ const r = estimateRunDuration({
+ targetCount: ASSUMED_RATE_PER_MINUTE, // exactly one minute's worth at the assumed rate
+ fireAt: new Date("2026-05-13T09:00:00.000+08:00"),
+ });
+ // 40 / 40 = 1 min; +15% = 1.15 → ceil = 2 min
+ expect(r.durationMinutes).toBe(2);
+ });
+
+ it("exports a positive default rate constant", () => {
+ expect(typeof ASSUMED_RATE_PER_MINUTE).toBe("number");
+ expect(ASSUMED_RATE_PER_MINUTE).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/web/src/lib/run-eta.ts b/apps/web/src/lib/run-eta.ts
new file mode 100644
index 0000000..18881f7
--- /dev/null
+++ b/apps/web/src/lib/run-eta.ts
@@ -0,0 +1,39 @@
+/**
+ * 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;
+
+/**
+ * Pure ETA helper. Given a target count and a fire time, returns the
+ * estimated duration in whole minutes and the projected finish
+ * timestamp.
+ *
+ * Calculation:
+ * ceil((targetCount / ratePerMinute) * 1.15) minutes
+ * estimatedFinishAt = fireAt + that many minutes
+ *
+ * Floor of 1 minute when targetCount > 0 (anything non-zero takes at
+ * least a minute to feel real). Returns 0 minutes when targetCount
+ * is zero — the run is a no-op.
+ */
+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 };
+}