refactor(web,bot,db): rename reminder status 'ended' → 'inactive'

The 'ended' label read like a terminal failure state ("the reminder
gave up") when in practice it just means "this reminder isn't going
to fire on its own — restart it if you want it back". 'inactive' is
the more accurate read.

* SQL migration 0009 backfills existing rows.
* Bot fire-reminder writes 'inactive' on one-off completion / no
  further occurrences.
* Web actions, queries, filters, and reminder lifecycle gates updated.
* Dashboard counter card label "Active / Paused / Ended / Total"
  becomes "Active / Paused / Inactive / Total".
* Reminders list filter tab "Ended" becomes "Inactive".
* Status pill style key renamed to match.
* Tests updated alongside the runtime changes.

Also: the "Pause sending by" deadline opt-in now renders as a
visible card-shaped row with hover state + Set/Off label on the
right, so the toggle is discoverable instead of a tiny native
checkbox tucked next to the label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 16:32:53 +08:00
parent 2e1defaef6
commit be3f28a1e6
18 changed files with 52 additions and 35 deletions

View File

@ -82,7 +82,7 @@ describe("fireReminder", () => {
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "ended",
status: "inactive",
targets: [],
messages: [],
createdBy: "op-1",

View File

@ -394,7 +394,7 @@ async function fireReminderInner(
if (reminder.scheduleKind === "one_off") {
await db
.update(reminders)
.set({ status: "ended", updatedAt: new Date() })
.set({ status: "inactive", updatedAt: new Date() })
.where(eq(reminders.id, reminder.id));
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
@ -417,7 +417,7 @@ async function fireReminderInner(
}
} else {
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending");
await db.update(reminders).set({ status: "ended" }).where(eq(reminders.id, reminder.id));
await db.update(reminders).set({ status: "inactive" }).where(eq(reminders.id, reminder.id));
}
}
}

View File

@ -206,6 +206,6 @@ describe("cancelReminderRunAction", () => {
await cancelReminderRunAction({ runId: PAUSED_RUN.id });
const calls = setSpy.mock.calls;
const lastPayload = calls[calls.length - 1]?.[0] as Record<string, unknown>;
expect(lastPayload.status).toBe("ended");
expect(lastPayload.status).toBe("inactive");
});
});

View File

@ -660,7 +660,7 @@ export async function cancelReminderRunAction(input: {
await tx
.update(reminders)
.set({
status: reminder.scheduleKind === "recurring" ? "active" : "ended",
status: reminder.scheduleKind === "recurring" ? "active" : "inactive",
updatedAt: new Date(),
})
.where(eq(reminders.id, reminder.id));

View File

@ -182,9 +182,9 @@ export default async function DashboardPage() {
/>
<StatCard
title="Reminders"
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.endedReminders} / ${stats.totalReminders}`}
value={`${stats.activeReminders} / ${stats.pausedReminders} / ${stats.inactiveReminders} / ${stats.totalReminders}`}
icon={BellIcon}
description="Active / Paused / Ended / Total"
description="Active / Paused / Inactive / Total"
href="/reminders"
/>
</div>

View File

@ -41,9 +41,9 @@ describe("ActionsBar — card visibility by status", () => {
expect(html).not.toMatch(/aria-label="Pause"/);
});
it("ended: shows Restart and Delete (no Pause)", () => {
it("inactive: shows Restart and Delete (no Pause)", () => {
const html = renderToStaticMarkup(
<ActionsBar reminderId="r-1" status="ended" isRecurring={false} />,
<ActionsBar reminderId="r-1" status="inactive" isRecurring={false} />,
);
expect(html).toMatch(/aria-label="Restart"/);
expect(html).toMatch(/aria-label="Delete"/);

View File

@ -38,7 +38,7 @@ interface ActionsBarProps {
* on desktop, stacked on mobile:
*
* - Pause only when status === "active"
* - Restart when status is "paused" or "ended"
* - Restart when status is "paused" or "inactive"
* - Delete always available (terminal)
*
* Each Dialog confirms before firing the corresponding server action.
@ -46,7 +46,7 @@ interface ActionsBarProps {
*/
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
const canPause = status === "active";
const canRestart = status === "paused" || status === "ended";
const canRestart = status === "paused" || status === "inactive";
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">

View File

@ -48,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
const STATUS_STYLES: Record<string, string> = {
active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
ended:
inactive:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",

View File

@ -32,7 +32,7 @@ import {
restartReminderAction,
} from "@/actions/reminders";
type FilterValue = "all" | "active" | "ended" | "paused";
type FilterValue = "all" | "active" | "inactive" | "paused";
function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—";
@ -48,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
const STATUS_STYLES: Record<string, string> = {
active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
ended:
inactive:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
@ -104,7 +104,7 @@ function StatusPill({ status }: { status: string }) {
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "ended", label: "Ended" },
{ value: "inactive", label: "Inactive" },
{ value: "paused", label: "Paused" },
];
@ -127,7 +127,7 @@ interface PageProps {
export default async function RemindersPage({ searchParams }: PageProps) {
const sp = await searchParams;
const status: FilterValue =
sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused"
sp.filter === "active" || sp.filter === "inactive" || sp.filter === "paused"
? sp.filter
: "all";
// Sort is now fixed to `created_desc`. Reordering on every status flip
@ -225,7 +225,7 @@ export default async function RemindersPage({ searchParams }: PageProps) {
{visible.map((reminder) => {
const canPause = reminder.status === "active";
const canRestart =
reminder.status === "paused" || reminder.status === "ended";
reminder.status === "paused" || reminder.status === "inactive";
const cardBody = (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -170,8 +170,8 @@ export function EditWhenForm({
<RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
<div className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer select-none">
<div className="space-y-2">
<label className="flex items-center gap-3 rounded-lg border border-input bg-card px-3 py-2.5 cursor-pointer select-none transition-colors hover:bg-accent/40">
<input
type="checkbox"
checked={useDeadline}
@ -179,17 +179,20 @@ export function EditWhenForm({
setUseDeadline(e.target.checked);
setError(null);
}}
className="size-4 rounded border-input accent-primary"
className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline"
/>
<span className="text-sm font-medium flex items-center gap-1.5">
<span className="flex-1 flex items-center gap-1.5 text-sm font-medium">
<ClockIcon className="size-3.5" />
Pause sending by
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
</span>
<span className="text-xs text-muted-foreground">
{useDeadline ? "Set" : "Off"}
</span>
</label>
{useDeadline && (
<div className="flex flex-wrap items-center gap-2 pl-6">
<div className="flex flex-wrap items-center gap-2 pl-3">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}

View File

@ -181,8 +181,8 @@ export function WhenFormClient({
deadline are paused so the operator can resume them later.
The whole control is opt-in: tick the box to surface the hour
picker, untick to remove the deadline entirely. */}
<div className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer select-none">
<div className="space-y-2">
<label className="flex items-center gap-3 rounded-lg border border-input bg-card px-3 py-2.5 cursor-pointer select-none transition-colors hover:bg-accent/40">
<input
type="checkbox"
checked={useDeadline}
@ -190,17 +190,20 @@ export function WhenFormClient({
setUseDeadline(e.target.checked);
setError(null);
}}
className="size-4 rounded border-input accent-primary"
className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline"
/>
<span className="text-sm font-medium flex items-center gap-1.5">
<span className="flex-1 flex items-center gap-1.5 text-sm font-medium">
<ClockIcon className="size-3.5" />
Pause sending by
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
</span>
<span className="text-xs text-muted-foreground">
{useDeadline ? "Set" : "Off"}
</span>
</label>
{useDeadline && (
<div className="flex flex-wrap items-center gap-2 pl-6">
<div className="flex flex-wrap items-center gap-2 pl-3">
<HourSelect
ariaPrefix="Delivery deadline"
value={deliveryEndHour}

View File

@ -34,7 +34,7 @@ export async function getDashboardStats(operatorId: string) {
totalAccounts: accounts.length,
activeReminders: allReminders.filter((r) => r.status === "active").length,
pausedReminders: allReminders.filter((r) => r.status === "paused").length,
endedReminders: allReminders.filter((r) => r.status === "ended").length,
inactiveReminders: allReminders.filter((r) => r.status === "inactive").length,
totalReminders: allReminders.length,
recentRuns: recentRuns.rows as Array<{
id: string;

View File

@ -73,7 +73,7 @@ describe("applyReminderFilter — account / group filters", () => {
});
it("status='all' or unset includes every status", () => {
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "ended" })];
const rows = [mk({ id: "a", status: "active" }), mk({ id: "b", status: "inactive" })];
expect(applyReminderFilter(rows, { status: "all" }).map((r) => r.id)).toEqual(["a", "b"]);
expect(applyReminderFilter(rows, {}).map((r) => r.id)).toEqual(["a", "b"]);
});
@ -81,7 +81,7 @@ describe("applyReminderFilter — account / group filters", () => {
it("status filters to the matching value", () => {
const rows = [
mk({ id: "a", status: "active" }),
mk({ id: "b", status: "ended" }),
mk({ id: "b", status: "inactive" }),
mk({ id: "c", status: "paused" }),
];
expect(applyReminderFilter(rows, { status: "paused" }).map((r) => r.id)).toEqual(["c"]);
@ -152,7 +152,7 @@ describe("applyReminderFilter — combined", () => {
mk({ id: "match", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
mk({ id: "wrong-acc", name: "Daily ping", accountId: "acc-2", groupIds: ["g-1"], status: "active", ...base }),
mk({ id: "wrong-group", name: "Daily ping", accountId: "acc-1", groupIds: ["g-9"], status: "active", ...base }),
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "ended", ...base }),
mk({ id: "wrong-status", name: "Daily ping", accountId: "acc-1", groupIds: ["g-1"], status: "inactive", ...base }),
mk({ id: "wrong-q", name: "Lunch", accountId: "acc-1", groupIds: ["g-1"], status: "active", ...base }),
];
expect(

View File

@ -26,7 +26,7 @@ export interface ReminderFilter {
q?: string;
accountId?: string;
groupId?: string;
status?: string; // "all" | "active" | "ended" | "paused"
status?: string; // "all" | "active" | "inactive" | "paused"
sort?: SortKey;
}

View File

@ -59,11 +59,11 @@ describe("validateUpdateScheduledAt", () => {
if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime());
});
it("ended one-off, past timestamp matching existing → ALLOWED", () => {
it("inactive one-off, past timestamp matching existing → ALLOWED", () => {
const r = validateUpdateScheduledAt({
iso: isoOf(PAST),
timezone: TZ,
existingStatus: "ended",
existingStatus: "inactive",
existingScheduledAt: PAST,
now: NOW,
});

View File

@ -34,7 +34,7 @@ export function validateUpdateScheduledAt(args: {
if (Number.isNaN(dt.getTime())) {
return { ok: false, error: "Invalid date" };
}
const isPaused = args.existingStatus === "paused" || args.existingStatus === "ended";
const isPaused = args.existingStatus === "paused" || args.existingStatus === "inactive";
const sameAsExisting =
args.existingScheduledAt !== null &&
Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000;

View File

@ -0,0 +1,4 @@
-- Rename the reminders.status enum value 'ended' → 'inactive'.
-- The column is plain text (no DB-level enum), so this is purely a
-- data migration. Code path renames in the same commit.
UPDATE reminders SET status = 'inactive' WHERE status = 'ended';

View File

@ -64,6 +64,13 @@
"when": 1778395584234,
"tag": "0008_greedy_matthew_murdock",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1778464000000,
"tag": "0009_rename_ended_to_inactive",
"breakpoints": true
}
]
}