feat(web): run-eta helper + bigger pill-shaped add buttons

estimateRunDuration() computes a per-run ETA from a target count, a
fire time, and an assumed per-account send rate (40/min, mirroring
the bot env). Adds a 15% buffer with a 1-minute floor. Pure helper,
covered by 6 round-trip tests including the rate-defaults path.

Header CTA buttons on /accounts and /reminders are now size="lg"
rounded-full pills with a shadow that lifts on hover. Mobile shows
just the plus icon (label collapses) so the button doesn't dominate
narrow screens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 15:10:09 +08:00
parent bf49b80431
commit e6521bd151
4 changed files with 112 additions and 9 deletions

View File

@ -181,14 +181,18 @@ export default async function RemindersPage({ searchParams }: PageProps) {
return ( return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6"> <div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-end sm:justify-between gap-4">
{/* Hidden on mobile — the top header already shows "Reminders". */} {/* Hidden on mobile — the top header already shows "Reminders". */}
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Reminders</h1> <h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Reminders</h1>
<Button asChild size="sm"> <Button
asChild
size="lg"
className="rounded-full shadow-md hover:shadow-lg transition-all px-6 gap-2 font-semibold"
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}> <Link href={"/reminders/new" as any} aria-label="New reminder">
<PlusIcon /> <PlusIcon className="size-5" />
New Reminder <span className="hidden sm:inline">New Reminder</span>
</Link> </Link>
</Button> </Button>
</div> </div>

View File

@ -33,11 +33,15 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
<div className="flex items-center justify-end sm:justify-between gap-4"> <div className="flex items-center justify-end sm:justify-between gap-4">
{/* Hidden on mobile — the top header already shows "Accounts". */} {/* Hidden on mobile — the top header already shows "Accounts". */}
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Accounts</h1> <h1 className="hidden sm:block text-2xl font-semibold tracking-tight">Accounts</h1>
<Button asChild size="sm"> <Button
asChild
size="lg"
className="rounded-full shadow-md hover:shadow-lg transition-all px-6 gap-2 font-semibold"
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any}> <Link href={"/accounts/new" as any} aria-label="Add account">
<PlusIcon /> <PlusIcon className="size-5" />
Add Account <span className="hidden sm:inline">Add Account</span>
</Link> </Link>
</Button> </Button>
</div> </div>

View File

@ -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);
});
});

View File

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