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>
46 lines
1.9 KiB
TypeScript
46 lines
1.9 KiB
TypeScript
import { DateTime } from "luxon";
|
|
|
|
/**
|
|
* Resolve and validate the scheduledAt for `updateReminderAction`.
|
|
*
|
|
* Why this exists: the action is invoked from four edit forms
|
|
* (account / message / groups / when). Three of those don't change
|
|
* the time — they pass the original `scheduledAt` straight through.
|
|
* For a paused/ended one-off whose original time is now in the past
|
|
* (the operator paused it after a fire and is editing the message),
|
|
* a strict "must be future" check rejects the edit even though the
|
|
* operator only wanted to fix a typo. So:
|
|
*
|
|
* - Reject malformed ISO outright.
|
|
* - Allow past timestamps when:
|
|
* * the reminder's existing status is `paused` or `ended` —
|
|
* it won't fire in those states regardless, AND/OR
|
|
* * the submitted timestamp matches the existing one (within
|
|
* a second of rounding), i.e. the form is passing it through
|
|
* unchanged.
|
|
* - Otherwise reject past timestamps so an active reminder can't
|
|
* be scheduled into a moment that's already gone.
|
|
*
|
|
* Pure function — takes `now` as input so tests can pin the clock.
|
|
*/
|
|
export function validateUpdateScheduledAt(args: {
|
|
iso: string;
|
|
timezone: string;
|
|
existingStatus: string;
|
|
existingScheduledAt: Date | null;
|
|
now: Date;
|
|
}): { ok: true; scheduledAt: Date } | { ok: false; error: string } {
|
|
const dt = DateTime.fromISO(args.iso, { zone: args.timezone }).toJSDate();
|
|
if (Number.isNaN(dt.getTime())) {
|
|
return { ok: false, error: "Invalid date" };
|
|
}
|
|
const isPaused = args.existingStatus === "paused" || args.existingStatus === "inactive";
|
|
const sameAsExisting =
|
|
args.existingScheduledAt !== null &&
|
|
Math.abs(args.existingScheduledAt.getTime() - dt.getTime()) < 1000;
|
|
if (dt.getTime() <= args.now.getTime() && !isPaused && !sameAsExisting) {
|
|
return { ok: false, error: "Time is in the past" };
|
|
}
|
|
return { ok: true, scheduledAt: dt };
|
|
}
|