Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Recurrence controls for the set/edit reminder dialog: the Repeat select
* (locked with an upgrade nudge when the workspace tier lacks recurrence)
* and the optional "Ends on" date input.
*
* Extracted from SetOrEditReminderDialog to keep the dialog component lean;
* all form state (zorm field names, error fallbacks) is threaded in as props.
*
* @see {@link file://./set-or-edit-reminder-dialog.tsx}
*/
import Input from "~/components/forms/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/forms/select";
import { Button } from "~/components/shared/button";
import {
REMINDER_REPEAT_PRESETS,
type ReminderRepeatValue,
} from "~/modules/asset-reminder/recurrence";

type ReminderRecurrenceFieldsProps = {
/** Whether the workspace tier includes recurring reminders. */
canUseRecurringReminders: boolean;
/** Form-submission disabled state (useDisabled). */
disabled: boolean;
/** Controlled Repeat selection. */
repeat: ReminderRepeatValue;
onRepeatChange: (value: ReminderRepeatValue) => void;
/** The stored cadence, round-tripped via hidden inputs when locked. */
initialRepeat: ReminderRepeatValue;
/** Stored end date (yyyy-MM-dd in the series' own timezone), if any. */
endsAtDefault?: string;
/** zorm field names. */
repeatFieldName: string;
endsAtFieldName: string;
/** Combined client/server error message for the endsAt field, if any. */
endsAtError?: string;
};

function RepeatOptions() {
return (
<SelectContent>
<SelectItem value="never">Never</SelectItem>
{Object.entries(REMINDER_REPEAT_PRESETS).map(([value, preset]) => (
<SelectItem key={value} value={value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
);
}

export default function ReminderRecurrenceFields({
canUseRecurringReminders,
disabled,
repeat,
onRepeatChange,
initialRepeat,
endsAtDefault,
repeatFieldName,
endsAtFieldName,
endsAtError,
}: ReminderRecurrenceFieldsProps) {
return (
<>
<div className="mb-4">
<label
htmlFor="reminder-repeat-trigger"
className={`mb-[6px] block text-sm font-medium ${
canUseRecurringReminders ? "text-gray-700" : "text-gray-500"
}`}
>
Repeat
</label>
{canUseRecurringReminders ? (
<Select
name={repeatFieldName}
value={repeat}
onValueChange={(value) =>
onRepeatChange(value as ReminderRepeatValue)
}
disabled={disabled}
>
<SelectTrigger id="reminder-repeat-trigger">
<SelectValue placeholder="Never" />
</SelectTrigger>
<RepeatOptions />
</Select>
) : (
<>
{/* why hidden inputs: a disabled control submits nothing —
carry the STORED cadence through so plain edits by
downgraded workspaces neither throw nor strip it */}
<input type="hidden" name="repeat" value={initialRepeat} />
{endsAtDefault ? (
<input type="hidden" name="endsAt" value={endsAtDefault} />
) : null}
<Select value={initialRepeat} disabled>
<SelectTrigger id="reminder-repeat-trigger">
<SelectValue placeholder="Never" />
</SelectTrigger>
<RepeatOptions />
</Select>
</>
)}
{canUseRecurringReminders ? (
<p className="mt-1 text-gray-500">
Automatically send this reminder again on a schedule.
</p>
) : (
<>
<p className="mt-1 text-gray-500">
Recurring reminders are a premium feature.{" "}
<Button
variant="link"
className="inline text-sm"
to="/account-details/subscription"
>
Upgrade your plan
</Button>{" "}
to send reminders on a schedule.
</p>
{/* why: the Ends-on input doesn't render in the locked state,
but its stored value still round-trips through hidden inputs
and can fail validation (e.g. moving the reminder date past
the series' end date) — surface that error here or the
submit blocks with no visible cause. role="alert" because
there is no associated rendered input for screen readers. */}
{endsAtError ? (
<p role="alert" className="mt-1 text-sm text-error-500">
{endsAtError} (this reminder's end date is fixed on your current
plan)
</p>
) : null}
</>
)}
</div>

{canUseRecurringReminders && repeat !== "never" ? (
<div>
<Input
defaultValue={endsAtDefault}
type="date"
name={endsAtFieldName}
error={endsAtError}
label="Ends on (optional)"
disabled={disabled}
className="mb-2"
/>
<p className="text-gray-500">
Leave empty to repeat until the reminder is deleted.
</p>
</div>
) : null}
</>
);
}
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,126 @@
// @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 on an earlier CALENDAR DAY than the reminder (client)", () => {
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("accepts a same-day end date with a LATE-evening reminder time (calendar compare, not instant compare)", () => {
// The old instant-based check (endsAt UTC-midnight + 24h vs local
// datetime) rejected this shape for users west of UTC.
expect(() =>
setReminderSchema.parse(
basePayload({
repeat: "weekly",
alertDateTime: "2099-07-15T23:30",
endsAt: "2099-07-15",
})
)
).not.toThrow();
});

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("runs NO date refinements on the SERVER schemas (zone-resolved checks happen in resolveReminderPayloadDates)", () => {
// Server-side, z.coerce.date() reads raw strings in the process zone, so
// both the future check and the endsAt ordering check would misfire for
// users in other zones. Both run on the resolved instants instead.
expect(
setReminderServerSchema.safeParse(
basePayload({ alertDateTime: "2020-01-01T09:00" })
).success
).toBe(true);
expect(
setReminderServerSchema.safeParse(
basePayload({ repeat: "monthly", endsAt: "2099-07-01" })
).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", () => {
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);
});
});
Loading
Loading