Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion apps/webapp/app/components/asset-reminder/actions-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
DropdownMenuTrigger,
} from "~/components/shared/dropdown";
import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields";
import {
isRecurringReminder,
repeatValueFromRecurrence,
} from "~/modules/asset-reminder/recurrence";
import DeleteReminder from "./delete-reminder";
import SetOrEditReminderDialog from "./set-or-edit-reminder-dialog";
import When from "../when/when";
Expand All @@ -25,7 +29,13 @@ export default function ActionsDropdown({ reminder }: ActionsDropdownProps) {
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);

const now = new Date();
const isPending = now < new Date(reminder.alertDateTime);
/**
* One-shot reminders are immutable once sent. Recurring reminders stay
* editable even when the stored date briefly sits in the past (fire-to-
* fetch window) or the series ended — editing re-arms the series.
*/
const isPending =
now < new Date(reminder.alertDateTime) || isRecurringReminder(reminder);

return (
<DropdownMenu
Expand Down Expand Up @@ -70,6 +80,9 @@ export default function ActionsDropdown({ reminder }: ActionsDropdownProps) {
message: reminder.message,
alertDateTime: reminder.alertDateTime,
teamMembers: reminder.teamMembers.map((tm) => tm.id),
repeat: repeatValueFromRecurrence(reminder),
endsAt: reminder.recurrenceEndsAt,
recurrenceTimezone: reminder.recurrenceTimezone,
}}
open={isEditDialogOpen}
onClose={() => {
Expand Down
15 changes: 15 additions & 0 deletions apps/webapp/app/components/asset-reminder/reminders-table.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useState } from "react";
import type { Prisma } from "@prisma/client";
import { RepeatIcon } from "lucide-react";
import { useParams } from "react-router";
import colors from "tailwindcss/colors";
import type { ASSET_REMINDER_INCLUDE_FIELDS } from "~/modules/asset-reminder/fields";
import { describeRecurrence } from "~/modules/asset-reminder/recurrence";
import { List } from "../list";
import ReminderTeamMembers from "./reminder-team-members";
import SetOrEditReminderDialog from "./set-or-edit-reminder-dialog";
Expand Down Expand Up @@ -110,8 +112,15 @@ function ListContent({
extraProps: { isAssetReminderPage: boolean };
}) {
const now = new Date();
/**
* For an ACTIVE recurring reminder, alertDateTime is always the next
* occurrence (the worker advances it in place), so "Pending" stays
* correct; once a series ends the date stays in the past and the badge
* flips to "Reminder sent" — same rule as one-shots.
*/
const status =
now < new Date(item.alertDateTime) ? "Pending" : "Reminder sent";
const recurrenceLabel = describeRecurrence(item);

return (
<>
Expand All @@ -131,6 +140,12 @@ function ListContent({
</When>
<Td>
<DateS date={item.alertDateTime} includeTime />
{recurrenceLabel ? (
<span className="mt-0.5 flex items-center gap-1 text-xs text-gray-500">
<RepeatIcon className="size-3" aria-hidden="true" />
{recurrenceLabel}
</span>
) : null}
</Td>
<Td>
<Badge
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @vitest-environment node
/**
* Parse-level tests for the reminder form schemas. These cover the two
* multipart-form traps that would otherwise break the flows silently:
* an empty optional date input submits "" (not undefined), and a disabled
* Repeat select submits nothing at all.
*/
import {
editReminderServerSchema,
setReminderSchema,
setReminderServerSchema,
} from "./set-or-edit-reminder-dialog";

const FUTURE = "2099-07-15T09:00";

function basePayload(overrides: Record<string, unknown> = {}) {
return {
name: "Service the generator",
message: "Change oil and filters",
alertDateTime: FUTURE,
teamMembers: ["tm-1"],
...overrides,
};
}

describe("setReminderSchema", () => {
it("defaults repeat to never when the field is absent (disabled/locked select)", () => {
const parsed = setReminderSchema.parse(basePayload());
expect(parsed.repeat).toBe("never");
expect(parsed.endsAt).toBeUndefined();
});

it('treats an empty "Ends on" input ("") as no end date', () => {
const parsed = setReminderSchema.parse(
basePayload({ repeat: "monthly", endsAt: "" })
);
expect(parsed.repeat).toBe("monthly");
expect(parsed.endsAt).toBeUndefined();
});

it("accepts a recurring payload with an end date after the reminder date", () => {
const parsed = setReminderSchema.parse(
basePayload({ repeat: "quarterly", endsAt: "2099-12-31" })
);
expect(parsed.endsAt).toBeInstanceOf(Date);
});

it("accepts a SAME-DAY end date (interpreted as end-of-day server-side)", () => {
expect(() =>
setReminderSchema.parse(
basePayload({ repeat: "weekly", endsAt: "2099-07-15" })
)
).not.toThrow();
});

it("rejects an end date before the reminder date", () => {
const result = setReminderSchema.safeParse(
basePayload({ repeat: "monthly", endsAt: "2099-07-01" })
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(["endsAt"]);
}
});

it("rejects a past alertDateTime at PARSE time on the CLIENT schema", () => {
const result = setReminderSchema.safeParse(
basePayload({ alertDateTime: "2020-01-01T09:00" })
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(["alertDateTime"]);
}
});

it("does NOT run the future check on the SERVER schema (zone-resolved check happens in resolveReminderPayloadDates)", () => {
// Server-side, z.coerce.date() reads the raw string in the process zone,
// so a future-check here would wrongly reject valid times for users west
// of UTC. The authoritative check runs on the resolved instant instead.
const result = setReminderServerSchema.safeParse(
basePayload({ alertDateTime: "2020-01-01T09:00" })
);
expect(result.success).toBe(true);
});

it("ignores endsAt ordering when repeat is never", () => {
expect(() =>
setReminderSchema.parse(basePayload({ endsAt: "2000-01-01" }))
).not.toThrow();
});
});

describe("editReminderServerSchema", () => {
it("requires the reminder id and keeps the ordering refinement", () => {
const parsed = editReminderServerSchema.parse(
basePayload({ id: "reminder-1", repeat: "yearly" })
);
expect(parsed.id).toBe("reminder-1");
expect(parsed.repeat).toBe("yearly");

expect(
editReminderServerSchema.safeParse(basePayload({ repeat: "yearly" }))
.success
).toBe(false);

expect(
editReminderServerSchema.safeParse(
basePayload({ id: "r-1", repeat: "monthly", endsAt: "2099-07-01" })
).success
).toBe(false); // ordering refinement still applies
});
});
Loading
Loading