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

95 lines
3.3 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getSeededOperator } from "@/lib/operator";
import { WhenFormClient } from "./when-form-client";
import { specFromRrule } from "@/lib/recurrence";
import { defaultFirstFireIso } from "@/lib/date-picker";
import { decodeMessages, legacyMessageToParts } from "@/lib/reminder-messages";
interface StepWhenParams {
step?: string;
accountId?: string;
groupIds?: string;
/** User-supplied reminder name (passes through unchanged). */
name?: string;
/** New shape — encoded MessagePart[]. */
messages?: string;
/** Legacy single-message fields, accepted for back-compat. */
text?: string;
mediaId?: string;
caption?: string;
scheduledAt?: string;
rrule?: string;
editReminderId?: string;
}
interface StepWhenProps {
params: StepWhenParams;
}
export async function StepWhen({ params }: StepWhenProps) {
const { accountId, groupIds, scheduledAt, rrule, editReminderId } = params;
// Resolve the messages param once: prefer the new shape, fall back to
// the legacy fields. Either way the wizard needs at least one part to
// continue past Compose.
const messagesParam =
params.messages && decodeMessages(params.messages)
? params.messages
: (() => {
const legacy = legacyMessageToParts(params.text, params.mediaId, params.caption);
if (!legacy) return undefined;
// Re-encode the legacy fallback so subsequent steps see one
// canonical wire-format and don't have to know about both.
return new URLSearchParams({
messages: encodeURIComponent(JSON.stringify(legacy)),
}).get("messages") ?? undefined;
})();
if (!accountId || !messagesParam) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/reminders/new" as any);
}
const op = await getSeededOperator();
const timezone = op.defaultTimezone ?? "UTC";
const backParams = new URLSearchParams({ step: "2", accountId });
if (groupIds) backParams.set("groupIds", groupIds);
if (params.name) backParams.set("name", params.name);
if (messagesParam) backParams.set("messages", messagesParam);
if (rrule) backParams.set("rrule", rrule);
if (editReminderId) backParams.set("editReminderId", editReminderId);
const backHref = `/reminders/new?${backParams.toString()}`;
return (
<div className="space-y-4">
<div>
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={backHref as any}>
<ArrowLeftIcon />
Back
</Link>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Choose when to send this reminder. Times are in{" "}
<span className="font-medium text-foreground">{timezone}</span>.
</p>
<WhenFormClient
accountId={accountId}
groupIds={groupIds ?? ""}
timezone={timezone}
initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)}
initialSpec={specFromRrule(rrule)}
passThroughParams={{ name: params.name, messages: messagesParam, editReminderId }}
/>
</div>
);
}