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({ getReminderMock.mockResolvedValue({
id: "r-1", id: "r-1",
accountId: "acct-A", accountId: "acct-A",
status: "ended", status: "inactive",
targets: [], targets: [],
messages: [], messages: [],
createdBy: "op-1", createdBy: "op-1",

View File

@ -394,7 +394,7 @@ async function fireReminderInner(
if (reminder.scheduleKind === "one_off") { if (reminder.scheduleKind === "one_off") {
await db await db
.update(reminders) .update(reminders)
.set({ status: "ended", updatedAt: new Date() }) .set({ status: "inactive", updatedAt: new Date() })
.where(eq(reminders.id, reminder.id)); .where(eq(reminders.id, reminder.id));
} else if (reminder.scheduleKind === "recurring" && reminder.rrule) { } else if (reminder.scheduleKind === "recurring" && reminder.rrule) {
const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date()); const next = nextOccurrence(reminder.rrule, reminder.timezone, new Date());
@ -417,7 +417,7 @@ async function fireReminderInner(
} }
} else { } else {
logger.info({ reminderId: reminder.id }, "fire-reminder: no further occurrences, ending"); 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 }); await cancelReminderRunAction({ runId: PAUSED_RUN.id });
const calls = setSpy.mock.calls; const calls = setSpy.mock.calls;
const lastPayload = calls[calls.length - 1]?.[0] as Record<string, unknown>; 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 await tx
.update(reminders) .update(reminders)
.set({ .set({
status: reminder.scheduleKind === "recurring" ? "active" : "ended", status: reminder.scheduleKind === "recurring" ? "active" : "inactive",
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(reminders.id, reminder.id)); .where(eq(reminders.id, reminder.id));

View File

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

View File

@ -41,9 +41,9 @@ describe("ActionsBar — card visibility by status", () => {
expect(html).not.toMatch(/aria-label="Pause"/); 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( 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="Restart"/);
expect(html).toMatch(/aria-label="Delete"/); expect(html).toMatch(/aria-label="Delete"/);

View File

@ -38,7 +38,7 @@ interface ActionsBarProps {
* on desktop, stacked on mobile: * on desktop, stacked on mobile:
* *
* - Pause only when status === "active" * - Pause only when status === "active"
* - Restart when status is "paused" or "ended" * - Restart when status is "paused" or "inactive"
* - Delete always available (terminal) * - Delete always available (terminal)
* *
* Each Dialog confirms before firing the corresponding server action. * Each Dialog confirms before firing the corresponding server action.
@ -46,7 +46,7 @@ interface ActionsBarProps {
*/ */
export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) { export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) {
const canPause = status === "active"; const canPause = status === "active";
const canRestart = status === "paused" || status === "ended"; const canRestart = status === "paused" || status === "inactive";
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2"> <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> = { const STATUS_STYLES: Record<string, string> = {
active: active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "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", "bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused: paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent", "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, restartReminderAction,
} from "@/actions/reminders"; } from "@/actions/reminders";
type FilterValue = "all" | "active" | "ended" | "paused"; type FilterValue = "all" | "active" | "inactive" | "paused";
function formatWhen(date: Date | null, tz: string): string { function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—"; if (!date) return "—";
@ -48,7 +48,7 @@ function formatWhen(date: Date | null, tz: string): string {
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
active: active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent", "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", "bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused: paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent", "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 }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "active", label: "Active" }, { value: "active", label: "Active" },
{ value: "ended", label: "Ended" }, { value: "inactive", label: "Inactive" },
{ value: "paused", label: "Paused" }, { value: "paused", label: "Paused" },
]; ];
@ -127,7 +127,7 @@ interface PageProps {
export default async function RemindersPage({ searchParams }: PageProps) { export default async function RemindersPage({ searchParams }: PageProps) {
const sp = await searchParams; const sp = await searchParams;
const status: FilterValue = const status: FilterValue =
sp.filter === "active" || sp.filter === "ended" || sp.filter === "paused" sp.filter === "active" || sp.filter === "inactive" || sp.filter === "paused"
? sp.filter ? sp.filter
: "all"; : "all";
// Sort is now fixed to `created_desc`. Reordering on every status flip // 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) => { {visible.map((reminder) => {
const canPause = reminder.status === "active"; const canPause = reminder.status === "active";
const canRestart = const canRestart =
reminder.status === "paused" || reminder.status === "ended"; reminder.status === "paused" || reminder.status === "inactive";
const cardBody = ( const cardBody = (
<Link <Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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} /> <RecurrencePicker firstFire={previewDt} value={spec} onChange={setSpec} />
<div className="space-y-1.5"> <div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer select-none"> <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 <input
type="checkbox" type="checkbox"
checked={useDeadline} checked={useDeadline}
@ -179,17 +179,20 @@ export function EditWhenForm({
setUseDeadline(e.target.checked); setUseDeadline(e.target.checked);
setError(null); setError(null);
}} }}
className="size-4 rounded border-input accent-primary" className="size-5 rounded border-input accent-primary"
aria-label="Set a delivery deadline" 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" /> <ClockIcon className="size-3.5" />
Pause sending by Pause sending by
<span className="text-xs font-normal text-muted-foreground">(optional)</span> <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</span> </span>
<span className="text-xs text-muted-foreground">
{useDeadline ? "Set" : "Off"}
</span>
</label> </label>
{useDeadline && ( {useDeadline && (
<div className="flex flex-wrap items-center gap-2 pl-6"> <div className="flex flex-wrap items-center gap-2 pl-3">
<HourSelect <HourSelect
ariaPrefix="Delivery deadline" ariaPrefix="Delivery deadline"
value={deliveryEndHour} value={deliveryEndHour}

View File

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

View File

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

View File

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

View File

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

View File

@ -59,11 +59,11 @@ describe("validateUpdateScheduledAt", () => {
if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime()); 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({ const r = validateUpdateScheduledAt({
iso: isoOf(PAST), iso: isoOf(PAST),
timezone: TZ, timezone: TZ,
existingStatus: "ended", existingStatus: "inactive",
existingScheduledAt: PAST, existingScheduledAt: PAST,
now: NOW, now: NOW,
}); });

View File

@ -34,7 +34,7 @@ export function validateUpdateScheduledAt(args: {
if (Number.isNaN(dt.getTime())) { if (Number.isNaN(dt.getTime())) {
return { ok: false, error: "Invalid date" }; return { ok: false, error: "Invalid date" };
} }
const isPaused = args.existingStatus === "paused" || args.existingStatus === "ended"; const isPaused = args.existingStatus === "paused" || args.existingStatus === "inactive";
const sameAsExisting = const sameAsExisting =
args.existingScheduledAt !== null && args.existingScheduledAt !== null &&
Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000; 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, "when": 1778395584234,
"tag": "0008_greedy_matthew_murdock", "tag": "0008_greedy_matthew_murdock",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1778464000000,
"tag": "0009_rename_ended_to_inactive",
"breakpoints": true
} }
] ]
} }