diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index a11379a..2e7e5ab 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -181,14 +181,18 @@ export default async function RemindersPage({ searchParams }: PageProps) { return (
-
+
{/* 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

-
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 }; +}