yiekheng 68d3de5ee2 feat(reminders): user-supplied name; auto-derived as fallback
Reminders pick up a real, user-controlled name instead of being
auto-named from the first message body. Auto-derive stays as the
fallback so empty inputs still produce something useful.

Resolution policy (single source of truth in lib/reminder-name.ts)
------------------------------------------------------------------
1. User-supplied name, trimmed, clamped to 60 chars.
2. First text-bearing message part — text body or media caption,
   trimmed, clamped to 60.
3. Literal "Reminder" (only if every part is media-without-caption
   and no name was given).

Wizard
------
- New "Name" input above the message stack on step 2 (Compose).
  Optional (label says so), maxLength 60, placeholder gives an
  example. Blank flows through the URL as an absent param.
- The name parameter passes through every subsequent step
  (when, groups, review) via the existing URL-state pattern.
- Review step gains a "Name" row at the very top showing what the
  resolver will produce. If the user left it blank, the row shows
  the auto-derived value plus a muted "(auto from message)" tag so
  they know what's happening.

Edit forms
----------
- `EditMessageForm` gains the same Name input at the top —
  consistent with the wizard's compose step.
- `EditAccountForm` / `EditWhenForm` / `EditGroupsForm` accept the
  current `name` and forward it unchanged on save. Otherwise saving
  any of those sections would re-auto-derive the name from the
  message body, silently overriding what the operator typed.

Server action
-------------
- Both `createReminderAction` and `updateReminderAction` accept an
  optional `name` field on the schema. The body collapses through
  the new `resolveReminderName` helper, replacing the inline
  `firstLabel ?? "Reminder"` slice.

Tests (+17 new in lib/reminder-name.test.ts)
--------------------------------------------
- User priority: user name wins over message body even when both
  are present; trimming.
- Auto-derive: first text part, first non-empty after skipping
  empties, media caption when present, trims around the value.
- Fallback: null/undefined/empty stack, every-part-empty, every
  part media-without-caption.
- Clamping: user-supplied long names truncate at 60; auto-derived
  long names truncate at 60; short names pass through.
- The 60-char ceiling matches what the wizard's <Input maxLength>
  enforces and what the DB column allows.

Existing tests updated to pass the new required prop (`initialName`
on EditMessageForm, `name` on EditAccountForm/EditGroupsForm SSR
fixtures, plus a couple in no-render-warnings.test.tsx).

Total: 298 web + 31 shared + 26 bot = 355 passing (was 338).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:43:22 +08:00

100 lines
2.7 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";
interface ReviewSubmitClientProps {
accountId: string;
groupIds?: string;
name?: string;
messages: MessagePart[];
scheduledAt: string;
rrule?: string;
editReminderId?: string;
timezone: string;
}
export function ReviewSubmitClient({
accountId,
groupIds,
name,
messages,
scheduledAt,
rrule,
editReminderId,
timezone,
}: ReviewSubmitClientProps) {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSchedule() {
setSubmitting(true);
setError(null);
try {
const payload = {
accountId,
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
name: name?.trim() || null,
messages,
scheduledAtIso: scheduledAt,
rrule: rrule ?? null,
timezone,
};
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);
}
}
return (
<div className="space-y-3 pt-2">
{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>
);
}