+
-
-
-
-
{hasActiveFilter && (
diff --git a/apps/web/src/lib/reminder-update.test.ts b/apps/web/src/lib/reminder-update.test.ts
new file mode 100644
index 0000000..fd817a7
--- /dev/null
+++ b/apps/web/src/lib/reminder-update.test.ts
@@ -0,0 +1,137 @@
+import { describe, it, expect } from "vitest";
+import { validateUpdateScheduledAt } from "./reminder-update";
+
+const NOW = new Date("2026-05-13T10:00:00Z");
+const PAST = new Date("2026-05-01T09:00:00Z");
+const FUTURE = new Date("2026-05-20T09:00:00Z");
+const TZ = "Asia/Kuala_Lumpur";
+
+const isoOf = (d: Date) => d.toISOString();
+
+describe("validateUpdateScheduledAt", () => {
+ it("rejects malformed ISO outright", () => {
+ const r = validateUpdateScheduledAt({
+ iso: "not-a-date",
+ timezone: TZ,
+ existingStatus: "active",
+ existingScheduledAt: PAST,
+ now: NOW,
+ });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toBe("Invalid date");
+ });
+
+ it("active reminder, future timestamp → ok", () => {
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(FUTURE),
+ timezone: TZ,
+ existingStatus: "active",
+ existingScheduledAt: FUTURE,
+ now: NOW,
+ });
+ expect(r.ok).toBe(true);
+ });
+
+ it("active reminder, past timestamp that DIFFERS from existing → rejected", () => {
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(PAST),
+ timezone: TZ,
+ existingStatus: "active",
+ existingScheduledAt: new Date("2026-05-02T09:00:00Z"),
+ now: NOW,
+ });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toBe("Time is in the past");
+ });
+
+ it("paused one-off, past timestamp matching existing → ALLOWED (the bug fix)", () => {
+ // The user paused a fired reminder; now they're editing the message
+ // body via /reminders/[id]/edit/message, which submits the original
+ // (past) scheduledAt unchanged. Must not 400.
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(PAST),
+ timezone: TZ,
+ existingStatus: "paused",
+ existingScheduledAt: PAST,
+ now: NOW,
+ });
+ expect(r.ok).toBe(true);
+ if (r.ok) expect(r.scheduledAt.getTime()).toBe(PAST.getTime());
+ });
+
+ it("ended one-off, past timestamp matching existing → ALLOWED", () => {
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(PAST),
+ timezone: TZ,
+ existingStatus: "ended",
+ existingScheduledAt: PAST,
+ now: NOW,
+ });
+ expect(r.ok).toBe(true);
+ });
+
+ it("paused reminder, past timestamp that's been changed → still allowed (paused doesn't fire)", () => {
+ // Paused reminders aren't on the firing path so a stale time isn't
+ // dangerous — even if the user submits a different past time, we
+ // accept it; it'll fire only if/when the user explicitly Restarts.
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(new Date("2026-04-01T09:00:00Z")),
+ timezone: TZ,
+ existingStatus: "paused",
+ existingScheduledAt: PAST,
+ now: NOW,
+ });
+ expect(r.ok).toBe(true);
+ });
+
+ it("active reminder, past-but-equal-to-existing timestamp → allowed", () => {
+ // Edge case: the user opened the edit-message page on an active
+ // reminder that's about to fire; by the time they submit, NOW has
+ // ticked past existing.scheduledAt. Don't reject — it's the same
+ // moment the reminder already represents.
+ const existing = new Date("2026-05-13T09:59:00Z"); // 1 minute before NOW
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(existing),
+ timezone: TZ,
+ existingStatus: "active",
+ existingScheduledAt: existing,
+ now: NOW,
+ });
+ expect(r.ok).toBe(true);
+ });
+
+ it("active reminder, exact NOW → rejected (must be strictly future)", () => {
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(NOW),
+ timezone: TZ,
+ existingStatus: "active",
+ existingScheduledAt: new Date("2026-05-19T09:00:00Z"),
+ now: NOW,
+ });
+ expect(r.ok).toBe(false);
+ });
+
+ it("treats existingScheduledAt=null as 'no match' (active + past → reject)", () => {
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(PAST),
+ timezone: TZ,
+ existingStatus: "active",
+ existingScheduledAt: null,
+ now: NOW,
+ });
+ expect(r.ok).toBe(false);
+ });
+
+ it("sub-second drift (~750ms) still counts as 'same as existing'", () => {
+ const exact = new Date("2026-05-13T05:00:00.000Z");
+ const drifted = new Date("2026-05-13T05:00:00.750Z");
+ const r = validateUpdateScheduledAt({
+ iso: isoOf(drifted),
+ timezone: TZ,
+ existingStatus: "active",
+ existingScheduledAt: exact,
+ now: NOW,
+ });
+ expect(r.ok).toBe(true);
+ });
+});
diff --git a/apps/web/src/lib/reminder-update.ts b/apps/web/src/lib/reminder-update.ts
new file mode 100644
index 0000000..57c6bb8
--- /dev/null
+++ b/apps/web/src/lib/reminder-update.ts
@@ -0,0 +1,45 @@
+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 === "ended";
+ 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 };
+}