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:
parent
2e1defaef6
commit
be3f28a1e6
@ -82,7 +82,7 @@ describe("fireReminder", () => {
|
||||
getReminderMock.mockResolvedValue({
|
||||
id: "r-1",
|
||||
accountId: "acct-A",
|
||||
status: "ended",
|
||||
status: "inactive",
|
||||
targets: [],
|
||||
messages: [],
|
||||
createdBy: "op-1",
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"/);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
4
packages/db/migrations/0009_rename_ended_to_inactive.sql
Normal file
4
packages/db/migrations/0009_rename_ended_to_inactive.sql
Normal 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';
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user