Wizard When-step and the per-section Edit-when page now gate the
HourSelect behind a checkbox. The control reads "[ ] Pause sending
by (optional)" by default — checking it reveals the hour picker;
unchecking hides it again.
The off-state is encoded as deliveryWindowEndHour=24 (next-day
midnight) so the bot's existing windowEndAt helper produces an end
that's always in the future for any reminder fired the same day,
making the gate effectively never trip. This avoids a NULL-allowing
schema migration while still giving the operator a clean "no
deadline" mode.
Existing reminders:
• Stored 24 → checkbox starts UNCHECKED, picker hidden.
• Stored anything else → checkbox starts CHECKED, picker shows
the saved value.
• Unsupplied (legacy rows) → checkbox starts UNCHECKED.
RunEtaPill picks up an optional `windowEndAt` prop. When omitted —
the no-deadline path — it renders a neutral grey pill with just the
ETA, skipping the green "Fits before deadline" / amber "Likely to
pause" comparison that wouldn't be meaningful without a deadline.
Tests:
* when-form-deadline.test.tsx (4) — fresh / 24 / real-hour /
optional-hint paths.
* run-eta-pill.test.tsx (+1) — neutral pill when windowEndAt is
undefined.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
132 lines
4.0 KiB
TypeScript
132 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { CalendarCheckIcon, AlertCircleIcon, Loader2Icon } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { createReminderAction, updateReminderAction } from "@/actions/reminders";
|
|
import { cn } from "@/lib/utils";
|
|
import type { MessagePart } from "@/lib/reminder-messages";
|
|
import { RunEtaPill } from "./run-eta-pill";
|
|
// Subpath import — the barrel `@cmbot/shared` pulls in rrule.ts which
|
|
// uses `node:module`'s createRequire and breaks the client bundle.
|
|
import { windowEndAt } from "@cmbot/shared/delivery-window";
|
|
|
|
interface ReviewSubmitClientProps {
|
|
accountId: string;
|
|
groupIds?: string;
|
|
name?: string;
|
|
messages: MessagePart[];
|
|
scheduledAt: string;
|
|
rrule?: string;
|
|
editReminderId?: string;
|
|
timezone: string;
|
|
deliveryEndHour?: number;
|
|
}
|
|
|
|
export function ReviewSubmitClient({
|
|
accountId,
|
|
groupIds,
|
|
name,
|
|
messages,
|
|
scheduledAt,
|
|
rrule,
|
|
editReminderId,
|
|
timezone,
|
|
deliveryEndHour,
|
|
}: ReviewSubmitClientProps) {
|
|
const router = useRouter();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function handleSchedule() {
|
|
const trimmedName = name?.trim();
|
|
if (!trimmedName) {
|
|
// The wizard's compose step now blocks Continue when the name is
|
|
// blank, so the only way to land here without one is a stale
|
|
// bookmarked URL. Bounce the operator back to step 2 with a
|
|
// clear error rather than letting the server reject it.
|
|
setError("Give the reminder a name (back on the Message step).");
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload = {
|
|
accountId,
|
|
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
|
|
name: trimmedName,
|
|
messages,
|
|
scheduledAtIso: scheduledAt,
|
|
rrule: rrule ?? null,
|
|
timezone,
|
|
deliveryWindowEndHour: deliveryEndHour,
|
|
};
|
|
const result = editReminderId
|
|
? await updateReminderAction({ ...payload, reminderId: editReminderId })
|
|
: await createReminderAction(payload);
|
|
|
|
if (result.ok) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
router.push(`/reminders/${result.reminderId}` as any);
|
|
} else {
|
|
setError(result.error);
|
|
setSubmitting(false);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "An unexpected error occurred.");
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const groupCount = groupIds ? groupIds.split(",").filter(Boolean).length : 0;
|
|
const fireAt = new Date(scheduledAt);
|
|
// Treat hour=24 (or unset) as "no deadline". The pill goes neutral.
|
|
const hasDeadline =
|
|
deliveryEndHour !== undefined && deliveryEndHour !== 24;
|
|
const wEnd = hasDeadline
|
|
? windowEndAt(timezone, deliveryEndHour!, fireAt)
|
|
: undefined;
|
|
|
|
return (
|
|
<div className="space-y-3 pt-2">
|
|
<RunEtaPill
|
|
targetCount={groupCount}
|
|
fireAt={fireAt}
|
|
windowEndAt={wEnd}
|
|
timezone={timezone}
|
|
/>
|
|
|
|
{error && (
|
|
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
|
|
<AlertCircleIcon className="size-4 shrink-0 mt-0.5" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
onClick={handleSchedule}
|
|
disabled={submitting}
|
|
className={cn("gap-2", submitting && "cursor-wait")}
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2Icon className="size-4 animate-spin" />
|
|
{editReminderId ? "Saving…" : "Scheduling…"}
|
|
</>
|
|
) : (
|
|
<>
|
|
<CalendarCheckIcon className="size-4" />
|
|
{editReminderId ? "Save changes" : "Schedule Reminder"}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|