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>
This commit is contained in:
yiekheng 2026-05-10 13:43:22 +08:00
parent 8f2ee5df9e
commit 68d3de5ee2
23 changed files with 309 additions and 20 deletions

View File

@ -13,6 +13,7 @@ import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/lib/rate-limit";
import { pgNotifyBot } from "@/lib/notify"; import { pgNotifyBot } from "@/lib/notify";
import { validateUpdateScheduledAt } from "@/lib/reminder-update"; import { validateUpdateScheduledAt } from "@/lib/reminder-update";
import { resolveReminderName } from "@/lib/reminder-name";
async function rateLimit(key: string) { async function rateLimit(key: string) {
const h = await headers(); const h = await headers();
@ -228,6 +229,13 @@ const createReminderSchema = z
// older URL bookmarks; the refine() guarantees we end up with at // older URL bookmarks; the refine() guarantees we end up with at
// least one valid message either way. // least one valid message either way.
messages: z.array(messagePartSchema).optional(), messages: z.array(messagePartSchema).optional(),
// User-supplied label shown in the list / detail page header.
// Optional on the wire — when blank or missing the action body
// auto-derives a fallback from the first text-bearing message
// part. The reminders.name DB column is text(50), so the
// resolver clamps to 60 chars (mirrors the duplicate-action
// pattern that produces "<name> (copy)") and trims whitespace.
name: z.string().nullable().optional(),
// Legacy single-message fields. Still accepted so bookmarked // Legacy single-message fields. Still accepted so bookmarked
// /reminders/new URLs don't 400 after the migration. The action body // /reminders/new URLs don't 400 after the migration. The action body
// collapses these into `messages` before doing any work. // collapses these into `messages` before doing any work.
@ -334,10 +342,10 @@ export async function createReminderAction(
return { ok: false, error: "One or more groups don't belong to this account" }; return { ok: false, error: "One or more groups don't belong to this account" };
} }
// Pick a name from the first text-bearing part (text body or caption). // User-supplied name wins. If they didn't supply one, derive from
// Falls back to "Reminder" if every part is media-without-caption. // the first text-bearing part (text body or caption). Falls back to
const firstLabel = parts.find((p) => p.textContent?.trim())?.textContent?.trim(); // the literal "Reminder" if every part is media-without-caption.
const reminderName = (firstLabel ?? "Reminder").slice(0, 50); const reminderName = resolveReminderName(parsed.data.name, parts);
const reminderId = await db.transaction(async (tx) => { const reminderId = await db.transaction(async (tx) => {
const [rem] = await tx const [rem] = await tx
@ -446,8 +454,7 @@ export async function updateReminderAction(
return { ok: false, error: "One or more groups don't belong to this account" }; return { ok: false, error: "One or more groups don't belong to this account" };
} }
const firstLabel = parts.find((p) => p.textContent?.trim())?.textContent?.trim(); const reminderName = resolveReminderName(parsed.data.name, parts);
const reminderName = (firstLabel ?? "Reminder").slice(0, 50);
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx

View File

@ -42,6 +42,7 @@ export default async function EditAccountPage({ params }: Props) {
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()} scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule} rrule={reminder.rrule}
messages={initialMessages} messages={initialMessages}
name={reminder.name}
timezone={reminder.timezone} timezone={reminder.timezone}
accounts={allAccounts.map((a) => ({ accounts={allAccounts.map((a) => ({
id: a.id, id: a.id,

View File

@ -43,6 +43,7 @@ export default async function EditGroupsPage({ params }: Props) {
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()} scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule} rrule={reminder.rrule}
messages={initialMessages} messages={initialMessages}
name={reminder.name}
timezone={reminder.timezone} timezone={reminder.timezone}
groups={groups} groups={groups}
initialSelected={targets.map((t) => t.groupId)} initialSelected={targets.map((t) => t.groupId)}

View File

@ -59,6 +59,7 @@ export default async function EditMessagePage({ params }: Props) {
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()} scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
rrule={reminder.rrule} rrule={reminder.rrule}
timezone={reminder.timezone} timezone={reminder.timezone}
initialName={reminder.name}
initialMessages={initialMessages} initialMessages={initialMessages}
initialMediaInfo={mediaInfo} initialMediaInfo={mediaInfo}
/> />

View File

@ -41,6 +41,7 @@ export default async function EditWhenPage({ params }: Props) {
accountId={reminder.accountId} accountId={reminder.accountId}
groupIds={targets.map((t) => t.groupId)} groupIds={targets.map((t) => t.groupId)}
messages={initialMessages} messages={initialMessages}
name={reminder.name}
initialIso={(reminder.scheduledAt ?? new Date()).toISOString()} initialIso={(reminder.scheduledAt ?? new Date()).toISOString()}
initialSpec={specFromRrule(reminder.rrule)} initialSpec={specFromRrule(reminder.rrule)}
timezone={reminder.timezone} timezone={reminder.timezone}

View File

@ -11,6 +11,9 @@ interface PageProps {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
/** User-supplied reminder name. Optional server falls back to
* the first text-bearing message part when blank. */
name?: string;
/** New shape — encoded MessagePart[]. Replaces text/mediaId/caption. */ /** New shape — encoded MessagePart[]. Replaces text/mediaId/caption. */
messages?: string; messages?: string;
/** Legacy single-message fields. Still accepted; the steps fold them /** Legacy single-message fields. Still accepted; the steps fold them

View File

@ -30,6 +30,9 @@ interface EditAccountFormProps {
/** Existing message stack passed through unchanged so editing the /** Existing message stack passed through unchanged so editing the
* account doesn't drop parts 2..N. */ * account doesn't drop parts 2..N. */
messages: MessagePart[]; messages: MessagePart[];
/** Existing user-chosen name passed through so editing the
* account doesn't reset it back to the auto-derived first-line. */
name: string;
timezone: string; timezone: string;
accounts: AccountOption[]; accounts: AccountOption[];
initialAccountId: string; initialAccountId: string;
@ -40,6 +43,7 @@ export function EditAccountForm({
scheduledAtIso, scheduledAtIso,
rrule, rrule,
messages, messages,
name,
timezone, timezone,
accounts, accounts,
initialAccountId, initialAccountId,
@ -62,6 +66,7 @@ export function EditAccountForm({
// when switching accounts so the action doesn't fail validating a // when switching accounts so the action doesn't fail validating a
// mixed-account groupIds set. The user re-picks groups afterwards. // mixed-account groupIds set. The user re-picks groups afterwards.
groupIds: accountChanged ? [] : [], groupIds: accountChanged ? [] : [],
name,
messages, messages,
scheduledAtIso, scheduledAtIso,
rrule, rrule,

View File

@ -30,6 +30,8 @@ interface EditGroupsFormProps {
/** Existing message stack passed through unchanged so editing the /** Existing message stack passed through unchanged so editing the
* group selection doesn't drop parts 2..N. */ * group selection doesn't drop parts 2..N. */
messages: MessagePart[]; messages: MessagePart[];
/** Existing user-chosen name — passed through. */
name: string;
timezone: string; timezone: string;
groups: Group[]; groups: Group[];
initialSelected: string[]; initialSelected: string[];
@ -41,6 +43,7 @@ export function EditGroupsForm({
scheduledAtIso, scheduledAtIso,
rrule, rrule,
messages, messages,
name,
timezone, timezone,
groups, groups,
initialSelected, initialSelected,
@ -75,6 +78,7 @@ export function EditGroupsForm({
reminderId, reminderId,
accountId, accountId,
groupIds: Array.from(selected), groupIds: Array.from(selected),
name,
messages, messages,
scheduledAtIso, scheduledAtIso,
rrule, rrule,

View File

@ -20,6 +20,7 @@ const baseProps = {
scheduledAtIso: "2026-05-13T09:00:00.000+08:00", scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: "FREQ=DAILY", rrule: "FREQ=DAILY",
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
initialName: "",
initialMessages: [ initialMessages: [
{ kind: "text", textContent: "Hello", mediaId: null }, { kind: "text", textContent: "Hello", mediaId: null },
] satisfies MessagePart[], ] satisfies MessagePart[],

View File

@ -2,11 +2,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AlertCircleIcon, Loader2Icon, SaveIcon } from "lucide-react"; import { AlertCircleIcon, Loader2Icon, SaveIcon, TagIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { MessageStack } from "@/components/message-stack"; import { MessageStack } from "@/components/message-stack";
import { updateReminderAction } from "@/actions/reminders"; import { updateReminderAction } from "@/actions/reminders";
import type { MessagePart } from "@/lib/reminder-messages"; import type { MessagePart } from "@/lib/reminder-messages";
import { REMINDER_NAME_MAX } from "@/lib/reminder-name";
interface EditMessageFormProps { interface EditMessageFormProps {
reminderId: string; reminderId: string;
@ -15,6 +18,7 @@ interface EditMessageFormProps {
scheduledAtIso: string; scheduledAtIso: string;
rrule: string | null; rrule: string | null;
timezone: string; timezone: string;
initialName: string;
initialMessages: MessagePart[]; initialMessages: MessagePart[];
initialMediaInfo?: Record<string, { filename: string; mimeType: string }>; initialMediaInfo?: Record<string, { filename: string; mimeType: string }>;
} }
@ -32,10 +36,12 @@ export function EditMessageForm({
scheduledAtIso, scheduledAtIso,
rrule, rrule,
timezone, timezone,
initialName,
initialMessages, initialMessages,
initialMediaInfo, initialMediaInfo,
}: EditMessageFormProps) { }: EditMessageFormProps) {
const router = useRouter(); const router = useRouter();
const [name, setName] = useState(initialName);
const [messages, setMessages] = useState<MessagePart[]>(initialMessages); const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -52,6 +58,7 @@ export function EditMessageForm({
reminderId, reminderId,
accountId, accountId,
groupIds, groupIds,
name: name.trim() || null,
messages, messages,
scheduledAtIso, scheduledAtIso,
rrule, rrule,
@ -72,6 +79,25 @@ export function EditMessageForm({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="reminder-name" className="flex items-center gap-1.5">
<TagIcon className="size-3.5" />
Name
<span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="reminder-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sunday morning standup"
maxLength={REMINDER_NAME_MAX}
/>
<p className="text-xs text-muted-foreground">
Leave blank and the first line of your message will be used.
</p>
</div>
<MessageStack <MessageStack
initial={initialMessages} initial={initialMessages}
initialMediaInfo={initialMediaInfo} initialMediaInfo={initialMediaInfo}

View File

@ -40,6 +40,7 @@ const baseAccountProps = {
scheduledAtIso: "2026-05-13T09:00:00.000+08:00", scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: null as string | null, rrule: null as string | null,
messages: STACK, messages: STACK,
name: "Custom name",
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
accounts: [ accounts: [
{ id: "acc-1", label: "Sales", status: "connected", phoneNumber: "60123" }, { id: "acc-1", label: "Sales", status: "connected", phoneNumber: "60123" },
@ -54,6 +55,7 @@ const baseGroupsProps = {
scheduledAtIso: "2026-05-13T09:00:00.000+08:00", scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: null as string | null, rrule: null as string | null,
messages: STACK, messages: STACK,
name: "Custom name",
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
groups: [ groups: [
{ id: "g-1", name: "Team", participantCount: 5, isArchived: false }, { id: "g-1", name: "Team", participantCount: 5, isArchived: false },

View File

@ -26,6 +26,8 @@ interface EditWhenFormProps {
/** Existing message stack passed through unchanged so editing the /** Existing message stack passed through unchanged so editing the
* schedule doesn't drop parts 2..N. */ * schedule doesn't drop parts 2..N. */
messages: MessagePart[]; messages: MessagePart[];
/** Existing user-chosen name — passed through. */
name: string;
initialIso: string; initialIso: string;
initialSpec: RecurrenceSpec; initialSpec: RecurrenceSpec;
timezone: string; timezone: string;
@ -36,6 +38,7 @@ export function EditWhenForm({
accountId, accountId,
groupIds, groupIds,
messages, messages,
name,
initialIso, initialIso,
initialSpec, initialSpec,
timezone, timezone,
@ -96,6 +99,7 @@ export function EditWhenForm({
reminderId, reminderId,
accountId, accountId,
groupIds, groupIds,
name,
messages, messages,
scheduledAtIso, scheduledAtIso,
rrule, rrule,

View File

@ -2,13 +2,13 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AlertCircleIcon } from "lucide-react"; import { AlertCircleIcon, TagIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { MessageStack } from "@/components/message-stack"; import { MessageStack } from "@/components/message-stack";
import { import { encodeMessages, type MessagePart } from "@/lib/reminder-messages";
encodeMessages, import { REMINDER_NAME_MAX } from "@/lib/reminder-name";
type MessagePart,
} from "@/lib/reminder-messages";
interface PassThroughParams { interface PassThroughParams {
scheduledAt?: string; scheduledAt?: string;
@ -19,6 +19,7 @@ interface PassThroughParams {
interface ComposeFormClientProps { interface ComposeFormClientProps {
accountId: string; accountId: string;
groupIds: string; groupIds: string;
initialName: string;
initialMessages: MessagePart[]; initialMessages: MessagePart[];
/** Resolved {filename, mimeType} per mediaId so reload can show file /** Resolved {filename, mimeType} per mediaId so reload can show file
* metadata without a fresh upload. */ * metadata without a fresh upload. */
@ -29,11 +30,13 @@ interface ComposeFormClientProps {
export function ComposeFormClient({ export function ComposeFormClient({
accountId, accountId,
groupIds, groupIds,
initialName,
initialMessages, initialMessages,
initialMediaInfo, initialMediaInfo,
passThroughParams, passThroughParams,
}: ComposeFormClientProps) { }: ComposeFormClientProps) {
const router = useRouter(); const router = useRouter();
const [name, setName] = useState(initialName);
const [messages, setMessages] = useState<MessagePart[]>(initialMessages); const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -45,6 +48,8 @@ export function ComposeFormClient({
const sp = new URLSearchParams({ step: "3", accountId }); const sp = new URLSearchParams({ step: "3", accountId });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
sp.set("messages", encodeMessages(messages)); sp.set("messages", encodeMessages(messages));
const trimmedName = name.trim();
if (trimmedName) sp.set("name", trimmedName);
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule); if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
@ -54,6 +59,28 @@ export function ComposeFormClient({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Name sits above the message stack so the user names the
reminder before composing. Optional: blank falls back to the
first text part on the server side. */}
<div className="space-y-1.5">
<Label htmlFor="reminder-name" className="flex items-center gap-1.5">
<TagIcon className="size-3.5" />
Name
<span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="reminder-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sunday morning standup"
maxLength={REMINDER_NAME_MAX}
/>
<p className="text-xs text-muted-foreground">
Leave blank and the first line of your message will be used.
</p>
</div>
<MessageStack <MessageStack
initial={initialMessages} initial={initialMessages}
initialMediaInfo={initialMediaInfo} initialMediaInfo={initialMediaInfo}

View File

@ -15,6 +15,8 @@ interface Group {
} }
interface PassThroughParams { interface PassThroughParams {
/** User-supplied reminder name (passes through unchanged). */
name?: string;
/** Encoded MessagePart[] from the compose step. */ /** Encoded MessagePart[] from the compose step. */
messages?: string; messages?: string;
scheduledAt?: string; scheduledAt?: string;
@ -67,6 +69,7 @@ export function GroupsFormClient({
if (selected.size > 0) { if (selected.size > 0) {
sp.set("groupIds", Array.from(selected).join(",")); sp.set("groupIds", Array.from(selected).join(","));
} }
if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt); if (passThroughParams.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule); if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);

View File

@ -11,6 +11,7 @@ import type { MessagePart } from "@/lib/reminder-messages";
interface ReviewSubmitClientProps { interface ReviewSubmitClientProps {
accountId: string; accountId: string;
groupIds?: string; groupIds?: string;
name?: string;
messages: MessagePart[]; messages: MessagePart[];
scheduledAt: string; scheduledAt: string;
rrule?: string; rrule?: string;
@ -21,6 +22,7 @@ interface ReviewSubmitClientProps {
export function ReviewSubmitClient({ export function ReviewSubmitClient({
accountId, accountId,
groupIds, groupIds,
name,
messages, messages,
scheduledAt, scheduledAt,
rrule, rrule,
@ -39,6 +41,7 @@ export function ReviewSubmitClient({
const payload = { const payload = {
accountId, accountId,
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [], groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
name: name?.trim() || null,
messages, messages,
scheduledAtIso: scheduledAt, scheduledAtIso: scheduledAt,
rrule: rrule ?? null, rrule: rrule ?? null,

View File

@ -14,6 +14,8 @@ interface StepComposeParams {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
/** User-supplied reminder name. Blank falls back to first text part. */
name?: string;
/** New shape: encoded MessagePart[] JSON. */ /** New shape: encoded MessagePart[] JSON. */
messages?: string; messages?: string;
/** Legacy single-message fields — accepted as fallback. */ /** Legacy single-message fields — accepted as fallback. */
@ -81,6 +83,7 @@ export async function StepCompose({ params }: StepComposeProps) {
<ComposeFormClient <ComposeFormClient
accountId={accountId} accountId={accountId}
groupIds={params.groupIds ?? ""} groupIds={params.groupIds ?? ""}
initialName={params.name ?? ""}
initialMessages={initialMessages} initialMessages={initialMessages}
initialMediaInfo={mediaInfo} initialMediaInfo={mediaInfo}
passThroughParams={{ passThroughParams={{

View File

@ -12,6 +12,8 @@ interface StepGroupsParams {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
/** User-supplied reminder name (passes through). */
name?: string;
/** Encoded MessagePart[]. */ /** Encoded MessagePart[]. */
messages?: string; messages?: string;
scheduledAt?: string; scheduledAt?: string;
@ -69,6 +71,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
: []; : [];
const backParams = new URLSearchParams({ step: "3", accountId }); const backParams = new URLSearchParams({ step: "3", accountId });
if (params.name) backParams.set("name", params.name);
if (messages) backParams.set("messages", messages); if (messages) backParams.set("messages", messages);
if (scheduledAt) backParams.set("scheduledAt", scheduledAt); if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
if (rrule) backParams.set("rrule", rrule); if (rrule) backParams.set("rrule", rrule);
@ -96,7 +99,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
groups={groups} groups={groups}
preSelected={preSelected} preSelected={preSelected}
accountId={accountId} accountId={accountId}
passThroughParams={{ messages, scheduledAt, rrule, editReminderId }} passThroughParams={{ name: params.name, messages, scheduledAt, rrule, editReminderId }}
/> />
</div> </div>
); );

View File

@ -9,6 +9,7 @@ import {
PaperclipIcon, PaperclipIcon,
SmartphoneIcon, SmartphoneIcon,
RepeatIcon, RepeatIcon,
TagIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@ -23,11 +24,13 @@ import {
legacyMessageToParts, legacyMessageToParts,
type MessagePart, type MessagePart,
} from "@/lib/reminder-messages"; } from "@/lib/reminder-messages";
import { resolveReminderName } from "@/lib/reminder-name";
interface StepReviewParams { interface StepReviewParams {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
name?: string;
messages?: string; messages?: string;
text?: string; text?: string;
mediaId?: string; mediaId?: string;
@ -55,6 +58,7 @@ function editLink(
step: number, step: number,
accountId: string, accountId: string,
groupIds: string | undefined, groupIds: string | undefined,
name: string | undefined,
messages: string | undefined, messages: string | undefined,
scheduledAt: string | undefined, scheduledAt: string | undefined,
rrule: string | undefined, rrule: string | undefined,
@ -62,6 +66,7 @@ function editLink(
): string { ): string {
const sp = new URLSearchParams({ step: String(step), accountId }); const sp = new URLSearchParams({ step: String(step), accountId });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
if (name) sp.set("name", name);
if (messages) sp.set("messages", messages); if (messages) sp.set("messages", messages);
if (scheduledAt) sp.set("scheduledAt", scheduledAt); if (scheduledAt) sp.set("scheduledAt", scheduledAt);
if (rrule) sp.set("rrule", rrule); if (rrule) sp.set("rrule", rrule);
@ -120,7 +125,7 @@ export async function StepReview({ params }: StepReviewProps) {
const formattedDate = formatScheduledAt(scheduledAt, timezone); const formattedDate = formatScheduledAt(scheduledAt, timezone);
const backHref = editLink(4, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId); const backHref = editLink(4, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -139,11 +144,29 @@ export async function StepReview({ params }: StepReviewProps) {
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
{/* Name — what the operator will see in lists / detail header.
Resolves the user-supplied name with the same fallback the
server action will apply, so the preview matches reality. */}
<ReviewRow
icon={<TagIcon className="size-4" />}
label="Name"
editHref={editLink(2, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)}
>
<span className="text-sm font-medium">
{resolveReminderName(params.name, parts)}
</span>
{!params.name?.trim() && (
<span className="ml-1.5 text-xs text-muted-foreground italic">
(auto from message)
</span>
)}
</ReviewRow>
{/* Account */} {/* Account */}
<ReviewRow <ReviewRow
icon={<SmartphoneIcon className="size-4" />} icon={<SmartphoneIcon className="size-4" />}
label="Account" label="Account"
editHref={editLink(1, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)} editHref={editLink(1, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium">{account.label}</span> <span className="text-sm font-medium">{account.label}</span>
{account.phoneNumber && ( {account.phoneNumber && (
@ -155,7 +178,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<FileTextIcon className="size-4" />} icon={<FileTextIcon className="size-4" />}
label={`Messages · ${parts.length}`} label={`Messages · ${parts.length}`}
editHref={editLink(2, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)} editHref={editLink(2, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)}
> >
<ol className="space-y-1.5"> <ol className="space-y-1.5">
{parts.map((p, i) => ( {parts.map((p, i) => (
@ -194,7 +217,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<CalendarIcon className="size-4" />} icon={<CalendarIcon className="size-4" />}
label={rrule ? "First fire" : "When"} label={rrule ? "First fire" : "When"}
editHref={editLink(3, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)} editHref={editLink(3, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium">{formattedDate}</span> <span className="text-sm font-medium">{formattedDate}</span>
</ReviewRow> </ReviewRow>
@ -204,7 +227,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<RepeatIcon className="size-4" />} icon={<RepeatIcon className="size-4" />}
label="Repeats" label="Repeats"
editHref={editLink(3, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)} editHref={editLink(3, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)}
> >
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{describeRecurrence( {describeRecurrence(
@ -219,7 +242,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewRow <ReviewRow
icon={<UsersIcon className="size-4" />} icon={<UsersIcon className="size-4" />}
label="Groups" label="Groups"
editHref={editLink(4, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)} editHref={editLink(4, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)}
> >
{selectedGroups.length > 0 ? ( {selectedGroups.length > 0 ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -243,6 +266,7 @@ export async function StepReview({ params }: StepReviewProps) {
<ReviewSubmitClient <ReviewSubmitClient
accountId={accountId} accountId={accountId}
groupIds={groupIds} groupIds={groupIds}
name={params.name}
messages={parts} messages={parts}
scheduledAt={scheduledAt} scheduledAt={scheduledAt}
rrule={rrule} rrule={rrule}

View File

@ -12,6 +12,8 @@ interface StepWhenParams {
step?: string; step?: string;
accountId?: string; accountId?: string;
groupIds?: string; groupIds?: string;
/** User-supplied reminder name (passes through unchanged). */
name?: string;
/** New shape — encoded MessagePart[]. */ /** New shape — encoded MessagePart[]. */
messages?: string; messages?: string;
/** Legacy single-message fields, accepted for back-compat. */ /** Legacy single-message fields, accepted for back-compat. */
@ -56,6 +58,7 @@ export async function StepWhen({ params }: StepWhenProps) {
const backParams = new URLSearchParams({ step: "2", accountId }); const backParams = new URLSearchParams({ step: "2", accountId });
if (groupIds) backParams.set("groupIds", groupIds); if (groupIds) backParams.set("groupIds", groupIds);
if (params.name) backParams.set("name", params.name);
if (messagesParam) backParams.set("messages", messagesParam); if (messagesParam) backParams.set("messages", messagesParam);
if (rrule) backParams.set("rrule", rrule); if (rrule) backParams.set("rrule", rrule);
if (editReminderId) backParams.set("editReminderId", editReminderId); if (editReminderId) backParams.set("editReminderId", editReminderId);
@ -84,7 +87,7 @@ export async function StepWhen({ params }: StepWhenProps) {
timezone={timezone} timezone={timezone}
initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)} initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)}
initialSpec={specFromRrule(rrule)} initialSpec={specFromRrule(rrule)}
passThroughParams={{ messages: messagesParam, editReminderId }} passThroughParams={{ name: params.name, messages: messagesParam, editReminderId }}
/> />
</div> </div>
); );

View File

@ -12,6 +12,8 @@ import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
import { RecurrencePicker } from "@/components/recurrence-picker"; import { RecurrencePicker } from "@/components/recurrence-picker";
interface PassThroughParams { interface PassThroughParams {
/** User-supplied reminder name (passes through unchanged). */
name?: string;
/** Encoded MessagePart[] from the compose step. */ /** Encoded MessagePart[] from the compose step. */
messages?: string; messages?: string;
editReminderId?: string; editReminderId?: string;
@ -66,6 +68,7 @@ export function WhenFormClient({
const sp = new URLSearchParams({ step: "4", accountId, scheduledAt }); const sp = new URLSearchParams({ step: "4", accountId, scheduledAt });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
if (rrule) sp.set("rrule", rrule); if (rrule) sp.set("rrule", rrule);
if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -108,6 +111,7 @@ export function WhenFormClient({
}); });
if (groupIds) sp.set("groupIds", groupIds); if (groupIds) sp.set("groupIds", groupIds);
if (rrule) sp.set("rrule", rrule); if (rrule) sp.set("rrule", rrule);
if (passThroughParams.name) sp.set("name", passThroughParams.name);
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages); if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId); if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import {
REMINDER_NAME_FALLBACK,
REMINDER_NAME_MAX,
resolveReminderName,
} from "./reminder-name";
import type { MessagePart } from "./reminder-messages";
const text = (s: string): MessagePart => ({
kind: "text",
textContent: s,
mediaId: null,
});
const media = (caption: string | null, id = "uuid"): MessagePart => ({
kind: "media",
textContent: caption,
mediaId: id,
});
describe("resolveReminderName", () => {
describe("user-supplied name has priority", () => {
it("uses the user name verbatim when provided", () => {
expect(resolveReminderName("Sunday standup", [text("hi")])).toBe("Sunday standup");
});
it("trims surrounding whitespace from the user name", () => {
expect(resolveReminderName(" Sunday standup ", [text("hi")])).toBe("Sunday standup");
});
it("a user name beats the message body even when both are non-empty", () => {
expect(resolveReminderName("Custom", [text("Auto-derived headline")])).toBe("Custom");
});
});
describe("auto-derive from messages when name is missing or empty", () => {
it("uses the first text part's body when name is null", () => {
expect(resolveReminderName(null, [text("Hi from message")])).toBe("Hi from message");
});
it("uses the first text part's body when name is undefined", () => {
expect(resolveReminderName(undefined, [text("Hi from message")])).toBe("Hi from message");
});
it("uses the first text part's body when name is empty / whitespace-only", () => {
expect(resolveReminderName("", [text("hi")])).toBe("hi");
expect(resolveReminderName(" ", [text("hi")])).toBe("hi");
expect(resolveReminderName("\t\n ", [text("hi")])).toBe("hi");
});
it("uses a media block's caption when the first part is media-with-caption", () => {
expect(resolveReminderName(null, [media("photo caption")])).toBe("photo caption");
});
it("skips empty-text parts and uses the first NON-empty text/caption", () => {
expect(
resolveReminderName(null, [
text(" "), // empty after trim — skip
media(null), // media without caption — skip
text("third part wins"),
text("never reached"),
]),
).toBe("third part wins");
});
it("trims whitespace around the auto-derived value", () => {
expect(resolveReminderName(null, [text(" padded ")])).toBe("padded");
});
});
describe("fallback to literal 'Reminder'", () => {
it("falls back when the message stack is missing entirely", () => {
expect(resolveReminderName(null, null)).toBe(REMINDER_NAME_FALLBACK);
expect(resolveReminderName(null, undefined)).toBe(REMINDER_NAME_FALLBACK);
});
it("falls back when the message stack is empty", () => {
expect(resolveReminderName(null, [])).toBe(REMINDER_NAME_FALLBACK);
});
it("falls back when every part is media-without-caption", () => {
expect(
resolveReminderName(null, [media(null, "uuid-1"), media(null, "uuid-2")]),
).toBe(REMINDER_NAME_FALLBACK);
});
it("falls back when every part has only whitespace text", () => {
expect(
resolveReminderName(null, [text(" "), text("\t"), media(" ")]),
).toBe(REMINDER_NAME_FALLBACK);
});
});
describe("clamping at REMINDER_NAME_MAX", () => {
const long = "a".repeat(200);
it("clamps a user-supplied name longer than the max", () => {
const out = resolveReminderName(long, []);
expect(out.length).toBe(REMINDER_NAME_MAX);
expect(out).toBe("a".repeat(REMINDER_NAME_MAX));
});
it("clamps an auto-derived name longer than the max", () => {
const out = resolveReminderName(null, [text(long)]);
expect(out.length).toBe(REMINDER_NAME_MAX);
});
it("does not clamp short names", () => {
expect(resolveReminderName("short", [])).toBe("short");
});
});
describe("REMINDER_NAME_MAX is the same constant we tell the UI", () => {
it("matches the DB column ceiling we picked (60)", () => {
// If this changes, the wizard's <Input maxLength> + the DB
// column width have to move together.
expect(REMINDER_NAME_MAX).toBe(60);
});
});
});

View File

@ -0,0 +1,42 @@
import type { MessagePart } from "./reminder-messages";
/**
* Maximum length of `reminders.name` we surface in the UI / send to
* the action. The DB column is text(60); we trim before insert so
* the database never has to reject the row.
*/
export const REMINDER_NAME_MAX = 60;
/**
* The fallback we use if neither the user nor the message parts
* contribute a usable name. Worst case for a media-only reminder
* with no captions.
*/
export const REMINDER_NAME_FALLBACK = "Reminder";
/**
* Resolve the final stored name for a reminder.
*
* Priority:
* 1. The user-supplied name, trimmed and clamped to REMINDER_NAME_MAX.
* 2. The first non-empty text content from any message part
* either a text block's body or a media block's caption.
* Trimmed and clamped.
* 3. The literal "Reminder" fallback.
*
* Pure: no DB hits, no clock reads. The action calls this for both
* create and update.
*/
export function resolveReminderName(
userSupplied: string | null | undefined,
parts: ReadonlyArray<MessagePart> | null | undefined,
): string {
const userTrimmed = userSupplied?.trim();
if (userTrimmed) return userTrimmed.slice(0, REMINDER_NAME_MAX);
const firstTextPart = parts?.find((p) => p.textContent?.trim());
const fromMessage = firstTextPart?.textContent?.trim();
if (fromMessage) return fromMessage.slice(0, REMINDER_NAME_MAX);
return REMINDER_NAME_FALLBACK;
}

View File

@ -130,6 +130,7 @@ describe("SSR render — no React errors or warnings", () => {
scheduledAtIso: "2026-05-13T09:00:00.000+08:00", scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
rrule: "FREQ=DAILY", rrule: "FREQ=DAILY",
timezone: "Asia/Kuala_Lumpur", timezone: "Asia/Kuala_Lumpur",
initialName: "",
initialMessages: [ initialMessages: [
{ kind: "text" as const, textContent: "Hello", mediaId: null }, { kind: "text" as const, textContent: "Hello", mediaId: null },
], ],
@ -180,6 +181,7 @@ describe("SSR markup — no <div> inside <button> (the bug we just fixed)", () =
scheduledAtIso="2026-05-13T09:00:00.000+08:00" scheduledAtIso="2026-05-13T09:00:00.000+08:00"
rrule={null} rrule={null}
timezone="Asia/Kuala_Lumpur" timezone="Asia/Kuala_Lumpur"
initialName=""
initialMessages={[{ kind: "text", textContent: "Hello", mediaId: null }]} initialMessages={[{ kind: "text", textContent: "Hello", mediaId: null }]}
/>, />,
); );