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:
parent
bf49b80431
commit
e6521bd151
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
56
apps/web/src/lib/run-eta.test.ts
Normal file
56
apps/web/src/lib/run-eta.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/web/src/lib/run-eta.ts
Normal file
39
apps/web/src/lib/run-eta.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user