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:
parent
8f2ee5df9e
commit
68d3de5ee2
@ -13,6 +13,7 @@ import { getSeededOperator } from "@/lib/operator";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { pgNotifyBot } from "@/lib/notify";
|
||||
import { validateUpdateScheduledAt } from "@/lib/reminder-update";
|
||||
import { resolveReminderName } from "@/lib/reminder-name";
|
||||
|
||||
async function rateLimit(key: string) {
|
||||
const h = await headers();
|
||||
@ -228,6 +229,13 @@ const createReminderSchema = z
|
||||
// older URL bookmarks; the refine() guarantees we end up with at
|
||||
// least one valid message either way.
|
||||
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
|
||||
// /reminders/new URLs don't 400 after the migration. The action body
|
||||
// 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" };
|
||||
}
|
||||
|
||||
// Pick a name from the first text-bearing part (text body or caption).
|
||||
// Falls back to "Reminder" if every part is media-without-caption.
|
||||
const firstLabel = parts.find((p) => p.textContent?.trim())?.textContent?.trim();
|
||||
const reminderName = (firstLabel ?? "Reminder").slice(0, 50);
|
||||
// User-supplied name wins. If they didn't supply one, derive from
|
||||
// the first text-bearing part (text body or caption). Falls back to
|
||||
// the literal "Reminder" if every part is media-without-caption.
|
||||
const reminderName = resolveReminderName(parsed.data.name, parts);
|
||||
|
||||
const reminderId = await db.transaction(async (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" };
|
||||
}
|
||||
|
||||
const firstLabel = parts.find((p) => p.textContent?.trim())?.textContent?.trim();
|
||||
const reminderName = (firstLabel ?? "Reminder").slice(0, 50);
|
||||
const reminderName = resolveReminderName(parsed.data.name, parts);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
|
||||
@ -42,6 +42,7 @@ export default async function EditAccountPage({ params }: Props) {
|
||||
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||
rrule={reminder.rrule}
|
||||
messages={initialMessages}
|
||||
name={reminder.name}
|
||||
timezone={reminder.timezone}
|
||||
accounts={allAccounts.map((a) => ({
|
||||
id: a.id,
|
||||
|
||||
@ -43,6 +43,7 @@ export default async function EditGroupsPage({ params }: Props) {
|
||||
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||
rrule={reminder.rrule}
|
||||
messages={initialMessages}
|
||||
name={reminder.name}
|
||||
timezone={reminder.timezone}
|
||||
groups={groups}
|
||||
initialSelected={targets.map((t) => t.groupId)}
|
||||
|
||||
@ -59,6 +59,7 @@ export default async function EditMessagePage({ params }: Props) {
|
||||
scheduledAtIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||
rrule={reminder.rrule}
|
||||
timezone={reminder.timezone}
|
||||
initialName={reminder.name}
|
||||
initialMessages={initialMessages}
|
||||
initialMediaInfo={mediaInfo}
|
||||
/>
|
||||
|
||||
@ -41,6 +41,7 @@ export default async function EditWhenPage({ params }: Props) {
|
||||
accountId={reminder.accountId}
|
||||
groupIds={targets.map((t) => t.groupId)}
|
||||
messages={initialMessages}
|
||||
name={reminder.name}
|
||||
initialIso={(reminder.scheduledAt ?? new Date()).toISOString()}
|
||||
initialSpec={specFromRrule(reminder.rrule)}
|
||||
timezone={reminder.timezone}
|
||||
|
||||
@ -11,6 +11,9 @@ interface PageProps {
|
||||
step?: string;
|
||||
accountId?: 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. */
|
||||
messages?: string;
|
||||
/** Legacy single-message fields. Still accepted; the steps fold them
|
||||
|
||||
@ -30,6 +30,9 @@ interface EditAccountFormProps {
|
||||
/** Existing message stack — passed through unchanged so editing the
|
||||
* account doesn't drop parts 2..N. */
|
||||
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;
|
||||
accounts: AccountOption[];
|
||||
initialAccountId: string;
|
||||
@ -40,6 +43,7 @@ export function EditAccountForm({
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
messages,
|
||||
name,
|
||||
timezone,
|
||||
accounts,
|
||||
initialAccountId,
|
||||
@ -62,6 +66,7 @@ export function EditAccountForm({
|
||||
// when switching accounts so the action doesn't fail validating a
|
||||
// mixed-account groupIds set. The user re-picks groups afterwards.
|
||||
groupIds: accountChanged ? [] : [],
|
||||
name,
|
||||
messages,
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
|
||||
@ -30,6 +30,8 @@ interface EditGroupsFormProps {
|
||||
/** Existing message stack — passed through unchanged so editing the
|
||||
* group selection doesn't drop parts 2..N. */
|
||||
messages: MessagePart[];
|
||||
/** Existing user-chosen name — passed through. */
|
||||
name: string;
|
||||
timezone: string;
|
||||
groups: Group[];
|
||||
initialSelected: string[];
|
||||
@ -41,6 +43,7 @@ export function EditGroupsForm({
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
messages,
|
||||
name,
|
||||
timezone,
|
||||
groups,
|
||||
initialSelected,
|
||||
@ -75,6 +78,7 @@ export function EditGroupsForm({
|
||||
reminderId,
|
||||
accountId,
|
||||
groupIds: Array.from(selected),
|
||||
name,
|
||||
messages,
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
|
||||
@ -20,6 +20,7 @@ const baseProps = {
|
||||
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
|
||||
rrule: "FREQ=DAILY",
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
initialName: "",
|
||||
initialMessages: [
|
||||
{ kind: "text", textContent: "Hello", mediaId: null },
|
||||
] satisfies MessagePart[],
|
||||
|
||||
@ -2,11 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MessageStack } from "@/components/message-stack";
|
||||
import { updateReminderAction } from "@/actions/reminders";
|
||||
import type { MessagePart } from "@/lib/reminder-messages";
|
||||
import { REMINDER_NAME_MAX } from "@/lib/reminder-name";
|
||||
|
||||
interface EditMessageFormProps {
|
||||
reminderId: string;
|
||||
@ -15,6 +18,7 @@ interface EditMessageFormProps {
|
||||
scheduledAtIso: string;
|
||||
rrule: string | null;
|
||||
timezone: string;
|
||||
initialName: string;
|
||||
initialMessages: MessagePart[];
|
||||
initialMediaInfo?: Record<string, { filename: string; mimeType: string }>;
|
||||
}
|
||||
@ -32,10 +36,12 @@ export function EditMessageForm({
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
timezone,
|
||||
initialName,
|
||||
initialMessages,
|
||||
initialMediaInfo,
|
||||
}: EditMessageFormProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(initialName);
|
||||
const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -52,6 +58,7 @@ export function EditMessageForm({
|
||||
reminderId,
|
||||
accountId,
|
||||
groupIds,
|
||||
name: name.trim() || null,
|
||||
messages,
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
@ -72,6 +79,25 @@ export function EditMessageForm({
|
||||
|
||||
return (
|
||||
<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
|
||||
initial={initialMessages}
|
||||
initialMediaInfo={initialMediaInfo}
|
||||
|
||||
@ -40,6 +40,7 @@ const baseAccountProps = {
|
||||
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
|
||||
rrule: null as string | null,
|
||||
messages: STACK,
|
||||
name: "Custom name",
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
accounts: [
|
||||
{ id: "acc-1", label: "Sales", status: "connected", phoneNumber: "60123" },
|
||||
@ -54,6 +55,7 @@ const baseGroupsProps = {
|
||||
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
|
||||
rrule: null as string | null,
|
||||
messages: STACK,
|
||||
name: "Custom name",
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
groups: [
|
||||
{ id: "g-1", name: "Team", participantCount: 5, isArchived: false },
|
||||
|
||||
@ -26,6 +26,8 @@ interface EditWhenFormProps {
|
||||
/** Existing message stack — passed through unchanged so editing the
|
||||
* schedule doesn't drop parts 2..N. */
|
||||
messages: MessagePart[];
|
||||
/** Existing user-chosen name — passed through. */
|
||||
name: string;
|
||||
initialIso: string;
|
||||
initialSpec: RecurrenceSpec;
|
||||
timezone: string;
|
||||
@ -36,6 +38,7 @@ export function EditWhenForm({
|
||||
accountId,
|
||||
groupIds,
|
||||
messages,
|
||||
name,
|
||||
initialIso,
|
||||
initialSpec,
|
||||
timezone,
|
||||
@ -96,6 +99,7 @@ export function EditWhenForm({
|
||||
reminderId,
|
||||
accountId,
|
||||
groupIds,
|
||||
name,
|
||||
messages,
|
||||
scheduledAtIso,
|
||||
rrule,
|
||||
|
||||
@ -2,13 +2,13 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { AlertCircleIcon, TagIcon } from "lucide-react";
|
||||
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 {
|
||||
encodeMessages,
|
||||
type MessagePart,
|
||||
} from "@/lib/reminder-messages";
|
||||
import { encodeMessages, type MessagePart } from "@/lib/reminder-messages";
|
||||
import { REMINDER_NAME_MAX } from "@/lib/reminder-name";
|
||||
|
||||
interface PassThroughParams {
|
||||
scheduledAt?: string;
|
||||
@ -19,6 +19,7 @@ interface PassThroughParams {
|
||||
interface ComposeFormClientProps {
|
||||
accountId: string;
|
||||
groupIds: string;
|
||||
initialName: string;
|
||||
initialMessages: MessagePart[];
|
||||
/** Resolved {filename, mimeType} per mediaId so reload can show file
|
||||
* metadata without a fresh upload. */
|
||||
@ -29,11 +30,13 @@ interface ComposeFormClientProps {
|
||||
export function ComposeFormClient({
|
||||
accountId,
|
||||
groupIds,
|
||||
initialName,
|
||||
initialMessages,
|
||||
initialMediaInfo,
|
||||
passThroughParams,
|
||||
}: ComposeFormClientProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(initialName);
|
||||
const [messages, setMessages] = useState<MessagePart[]>(initialMessages);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -45,6 +48,8 @@ export function ComposeFormClient({
|
||||
const sp = new URLSearchParams({ step: "3", accountId });
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
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.rrule) sp.set("rrule", passThroughParams.rrule);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
@ -54,6 +59,28 @@ export function ComposeFormClient({
|
||||
|
||||
return (
|
||||
<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
|
||||
initial={initialMessages}
|
||||
initialMediaInfo={initialMediaInfo}
|
||||
|
||||
@ -15,6 +15,8 @@ interface Group {
|
||||
}
|
||||
|
||||
interface PassThroughParams {
|
||||
/** User-supplied reminder name (passes through unchanged). */
|
||||
name?: string;
|
||||
/** Encoded MessagePart[] from the compose step. */
|
||||
messages?: string;
|
||||
scheduledAt?: string;
|
||||
@ -67,6 +69,7 @@ export function GroupsFormClient({
|
||||
if (selected.size > 0) {
|
||||
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.scheduledAt) sp.set("scheduledAt", passThroughParams.scheduledAt);
|
||||
if (passThroughParams.rrule) sp.set("rrule", passThroughParams.rrule);
|
||||
|
||||
@ -11,6 +11,7 @@ import type { MessagePart } from "@/lib/reminder-messages";
|
||||
interface ReviewSubmitClientProps {
|
||||
accountId: string;
|
||||
groupIds?: string;
|
||||
name?: string;
|
||||
messages: MessagePart[];
|
||||
scheduledAt: string;
|
||||
rrule?: string;
|
||||
@ -21,6 +22,7 @@ interface ReviewSubmitClientProps {
|
||||
export function ReviewSubmitClient({
|
||||
accountId,
|
||||
groupIds,
|
||||
name,
|
||||
messages,
|
||||
scheduledAt,
|
||||
rrule,
|
||||
@ -39,6 +41,7 @@ export function ReviewSubmitClient({
|
||||
const payload = {
|
||||
accountId,
|
||||
groupIds: groupIds ? groupIds.split(",").filter(Boolean) : [],
|
||||
name: name?.trim() || null,
|
||||
messages,
|
||||
scheduledAtIso: scheduledAt,
|
||||
rrule: rrule ?? null,
|
||||
|
||||
@ -14,6 +14,8 @@ interface StepComposeParams {
|
||||
step?: string;
|
||||
accountId?: string;
|
||||
groupIds?: string;
|
||||
/** User-supplied reminder name. Blank falls back to first text part. */
|
||||
name?: string;
|
||||
/** New shape: encoded MessagePart[] JSON. */
|
||||
messages?: string;
|
||||
/** Legacy single-message fields — accepted as fallback. */
|
||||
@ -81,6 +83,7 @@ export async function StepCompose({ params }: StepComposeProps) {
|
||||
<ComposeFormClient
|
||||
accountId={accountId}
|
||||
groupIds={params.groupIds ?? ""}
|
||||
initialName={params.name ?? ""}
|
||||
initialMessages={initialMessages}
|
||||
initialMediaInfo={mediaInfo}
|
||||
passThroughParams={{
|
||||
|
||||
@ -12,6 +12,8 @@ interface StepGroupsParams {
|
||||
step?: string;
|
||||
accountId?: string;
|
||||
groupIds?: string;
|
||||
/** User-supplied reminder name (passes through). */
|
||||
name?: string;
|
||||
/** Encoded MessagePart[]. */
|
||||
messages?: string;
|
||||
scheduledAt?: string;
|
||||
@ -69,6 +71,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
||||
: [];
|
||||
|
||||
const backParams = new URLSearchParams({ step: "3", accountId });
|
||||
if (params.name) backParams.set("name", params.name);
|
||||
if (messages) backParams.set("messages", messages);
|
||||
if (scheduledAt) backParams.set("scheduledAt", scheduledAt);
|
||||
if (rrule) backParams.set("rrule", rrule);
|
||||
@ -96,7 +99,7 @@ export async function StepGroups({ params }: StepGroupsProps) {
|
||||
groups={groups}
|
||||
preSelected={preSelected}
|
||||
accountId={accountId}
|
||||
passThroughParams={{ messages, scheduledAt, rrule, editReminderId }}
|
||||
passThroughParams={{ name: params.name, messages, scheduledAt, rrule, editReminderId }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
PaperclipIcon,
|
||||
SmartphoneIcon,
|
||||
RepeatIcon,
|
||||
TagIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@ -23,11 +24,13 @@ import {
|
||||
legacyMessageToParts,
|
||||
type MessagePart,
|
||||
} from "@/lib/reminder-messages";
|
||||
import { resolveReminderName } from "@/lib/reminder-name";
|
||||
|
||||
interface StepReviewParams {
|
||||
step?: string;
|
||||
accountId?: string;
|
||||
groupIds?: string;
|
||||
name?: string;
|
||||
messages?: string;
|
||||
text?: string;
|
||||
mediaId?: string;
|
||||
@ -55,6 +58,7 @@ function editLink(
|
||||
step: number,
|
||||
accountId: string,
|
||||
groupIds: string | undefined,
|
||||
name: string | undefined,
|
||||
messages: string | undefined,
|
||||
scheduledAt: string | undefined,
|
||||
rrule: string | undefined,
|
||||
@ -62,6 +66,7 @@ function editLink(
|
||||
): string {
|
||||
const sp = new URLSearchParams({ step: String(step), accountId });
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
if (name) sp.set("name", name);
|
||||
if (messages) sp.set("messages", messages);
|
||||
if (scheduledAt) sp.set("scheduledAt", scheduledAt);
|
||||
if (rrule) sp.set("rrule", rrule);
|
||||
@ -120,7 +125,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
@ -139,11 +144,29 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
</p>
|
||||
|
||||
<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 */}
|
||||
<ReviewRow
|
||||
icon={<SmartphoneIcon className="size-4" />}
|
||||
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>
|
||||
{account.phoneNumber && (
|
||||
@ -155,7 +178,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<FileTextIcon className="size-4" />}
|
||||
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">
|
||||
{parts.map((p, i) => (
|
||||
@ -194,7 +217,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<CalendarIcon className="size-4" />}
|
||||
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>
|
||||
</ReviewRow>
|
||||
@ -204,7 +227,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<RepeatIcon className="size-4" />}
|
||||
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">
|
||||
{describeRecurrence(
|
||||
@ -219,7 +242,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewRow
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
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 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@ -243,6 +266,7 @@ export async function StepReview({ params }: StepReviewProps) {
|
||||
<ReviewSubmitClient
|
||||
accountId={accountId}
|
||||
groupIds={groupIds}
|
||||
name={params.name}
|
||||
messages={parts}
|
||||
scheduledAt={scheduledAt}
|
||||
rrule={rrule}
|
||||
|
||||
@ -12,6 +12,8 @@ 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. */
|
||||
@ -56,6 +58,7 @@ export async function StepWhen({ params }: StepWhenProps) {
|
||||
|
||||
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);
|
||||
@ -84,7 +87,7 @@ export async function StepWhen({ params }: StepWhenProps) {
|
||||
timezone={timezone}
|
||||
initialDefaultIso={scheduledAt ?? defaultFirstFireIso(timezone)}
|
||||
initialSpec={specFromRrule(rrule)}
|
||||
passThroughParams={{ messages: messagesParam, editReminderId }}
|
||||
passThroughParams={{ name: params.name, messages: messagesParam, editReminderId }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -12,6 +12,8 @@ import { splitDateTime, validateScheduledAt } from "@/lib/date-picker";
|
||||
import { RecurrencePicker } from "@/components/recurrence-picker";
|
||||
|
||||
interface PassThroughParams {
|
||||
/** User-supplied reminder name (passes through unchanged). */
|
||||
name?: string;
|
||||
/** Encoded MessagePart[] from the compose step. */
|
||||
messages?: string;
|
||||
editReminderId?: string;
|
||||
@ -66,6 +68,7 @@ export function WhenFormClient({
|
||||
const sp = new URLSearchParams({ step: "4", accountId, scheduledAt });
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
if (rrule) sp.set("rrule", rrule);
|
||||
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
||||
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -108,6 +111,7 @@ export function WhenFormClient({
|
||||
});
|
||||
if (groupIds) sp.set("groupIds", groupIds);
|
||||
if (rrule) sp.set("rrule", rrule);
|
||||
if (passThroughParams.name) sp.set("name", passThroughParams.name);
|
||||
if (passThroughParams.messages) sp.set("messages", passThroughParams.messages);
|
||||
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
119
apps/web/src/lib/reminder-name.test.ts
Normal file
119
apps/web/src/lib/reminder-name.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
apps/web/src/lib/reminder-name.ts
Normal file
42
apps/web/src/lib/reminder-name.ts
Normal 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;
|
||||
}
|
||||
@ -130,6 +130,7 @@ describe("SSR render — no React errors or warnings", () => {
|
||||
scheduledAtIso: "2026-05-13T09:00:00.000+08:00",
|
||||
rrule: "FREQ=DAILY",
|
||||
timezone: "Asia/Kuala_Lumpur",
|
||||
initialName: "",
|
||||
initialMessages: [
|
||||
{ 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"
|
||||
rrule={null}
|
||||
timezone="Asia/Kuala_Lumpur"
|
||||
initialName=""
|
||||
initialMessages={[{ kind: "text", textContent: "Hello", mediaId: null }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user