From 68d3de5ee226bd4d6ad252e67012f940a04fc4ba Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 13:43:22 +0800 Subject: [PATCH] feat(reminders): user-supplied name; auto-derived as fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- apps/web/src/actions/reminders.ts | 19 ++- .../app/reminders/[id]/edit/account/page.tsx | 1 + .../app/reminders/[id]/edit/groups/page.tsx | 1 + .../app/reminders/[id]/edit/message/page.tsx | 1 + .../src/app/reminders/[id]/edit/when/page.tsx | 1 + apps/web/src/app/reminders/new/page.tsx | 3 + .../reminder-edit/edit-account-form.tsx | 5 + .../reminder-edit/edit-groups-form.tsx | 4 + .../reminder-edit/edit-message-form.test.tsx | 1 + .../reminder-edit/edit-message-form.tsx | 28 ++++- .../reminder-edit/edit-section-forms.test.tsx | 2 + .../reminder-edit/edit-when-form.tsx | 4 + .../reminder-wizard/compose-form-client.tsx | 37 +++++- .../reminder-wizard/groups-form-client.tsx | 3 + .../reminder-wizard/review-submit-client.tsx | 3 + .../reminder-wizard/step-compose.tsx | 3 + .../reminder-wizard/step-groups.tsx | 5 +- .../reminder-wizard/step-review.tsx | 36 +++++- .../components/reminder-wizard/step-when.tsx | 5 +- .../reminder-wizard/when-form-client.tsx | 4 + apps/web/src/lib/reminder-name.test.ts | 119 ++++++++++++++++++ apps/web/src/lib/reminder-name.ts | 42 +++++++ apps/web/src/test/no-render-warnings.test.tsx | 2 + 23 files changed, 309 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/lib/reminder-name.test.ts create mode 100644 apps/web/src/lib/reminder-name.ts diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index 21dd967..ebeb2b5 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -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 " (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 diff --git a/apps/web/src/app/reminders/[id]/edit/account/page.tsx b/apps/web/src/app/reminders/[id]/edit/account/page.tsx index 1dba732..a4a0663 100644 --- a/apps/web/src/app/reminders/[id]/edit/account/page.tsx +++ b/apps/web/src/app/reminders/[id]/edit/account/page.tsx @@ -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, diff --git a/apps/web/src/app/reminders/[id]/edit/groups/page.tsx b/apps/web/src/app/reminders/[id]/edit/groups/page.tsx index 2686199..6c4c3ef 100644 --- a/apps/web/src/app/reminders/[id]/edit/groups/page.tsx +++ b/apps/web/src/app/reminders/[id]/edit/groups/page.tsx @@ -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)} diff --git a/apps/web/src/app/reminders/[id]/edit/message/page.tsx b/apps/web/src/app/reminders/[id]/edit/message/page.tsx index 4c647d4..ca38359 100644 --- a/apps/web/src/app/reminders/[id]/edit/message/page.tsx +++ b/apps/web/src/app/reminders/[id]/edit/message/page.tsx @@ -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} /> diff --git a/apps/web/src/app/reminders/[id]/edit/when/page.tsx b/apps/web/src/app/reminders/[id]/edit/when/page.tsx index 472f1c9..76031f7 100644 --- a/apps/web/src/app/reminders/[id]/edit/when/page.tsx +++ b/apps/web/src/app/reminders/[id]/edit/when/page.tsx @@ -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} diff --git a/apps/web/src/app/reminders/new/page.tsx b/apps/web/src/app/reminders/new/page.tsx index 0184055..5cf3d2e 100644 --- a/apps/web/src/app/reminders/new/page.tsx +++ b/apps/web/src/app/reminders/new/page.tsx @@ -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 diff --git a/apps/web/src/components/reminder-edit/edit-account-form.tsx b/apps/web/src/components/reminder-edit/edit-account-form.tsx index 132852d..afa9a9b 100644 --- a/apps/web/src/components/reminder-edit/edit-account-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-account-form.tsx @@ -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, diff --git a/apps/web/src/components/reminder-edit/edit-groups-form.tsx b/apps/web/src/components/reminder-edit/edit-groups-form.tsx index 9cc5094..fc40758 100644 --- a/apps/web/src/components/reminder-edit/edit-groups-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-groups-form.tsx @@ -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, diff --git a/apps/web/src/components/reminder-edit/edit-message-form.test.tsx b/apps/web/src/components/reminder-edit/edit-message-form.test.tsx index 4e8ff6b..ffbaf48 100644 --- a/apps/web/src/components/reminder-edit/edit-message-form.test.tsx +++ b/apps/web/src/components/reminder-edit/edit-message-form.test.tsx @@ -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[], diff --git a/apps/web/src/components/reminder-edit/edit-message-form.tsx b/apps/web/src/components/reminder-edit/edit-message-form.tsx index 6fbb4cb..43a4927 100644 --- a/apps/web/src/components/reminder-edit/edit-message-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-message-form.tsx @@ -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; } @@ -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(initialMessages); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(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 (
+
+ + setName(e.target.value)} + placeholder="e.g. Sunday morning standup" + maxLength={REMINDER_NAME_MAX} + /> +

+ Leave blank and the first line of your message will be used. +

+
+ (initialMessages); const [error, setError] = useState(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 (
+ {/* 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. */} +
+ + setName(e.target.value)} + placeholder="e.g. Sunday morning standup" + maxLength={REMINDER_NAME_MAX} + /> +

+ Leave blank and the first line of your message will be used. +

+
+ 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); diff --git a/apps/web/src/components/reminder-wizard/review-submit-client.tsx b/apps/web/src/components/reminder-wizard/review-submit-client.tsx index 7691e70..2b116b3 100644 --- a/apps/web/src/components/reminder-wizard/review-submit-client.tsx +++ b/apps/web/src/components/reminder-wizard/review-submit-client.tsx @@ -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, diff --git a/apps/web/src/components/reminder-wizard/step-compose.tsx b/apps/web/src/components/reminder-wizard/step-compose.tsx index b5965ef..2476aec 100644 --- a/apps/web/src/components/reminder-wizard/step-compose.tsx +++ b/apps/web/src/components/reminder-wizard/step-compose.tsx @@ -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) {
); diff --git a/apps/web/src/components/reminder-wizard/step-review.tsx b/apps/web/src/components/reminder-wizard/step-review.tsx index 5c088a3..7b64bd3 100644 --- a/apps/web/src/components/reminder-wizard/step-review.tsx +++ b/apps/web/src/components/reminder-wizard/step-review.tsx @@ -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 (
@@ -139,11 +144,29 @@ export async function StepReview({ params }: StepReviewProps) {

+ {/* 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. */} + } + label="Name" + editHref={editLink(2, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)} + > + + {resolveReminderName(params.name, parts)} + + {!params.name?.trim() && ( + + (auto from message) + + )} + + {/* Account */} } label="Account" - editHref={editLink(1, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)} + editHref={editLink(1, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)} > {account.label} {account.phoneNumber && ( @@ -155,7 +178,7 @@ export async function StepReview({ params }: StepReviewProps) { } label={`Messages · ${parts.length}`} - editHref={editLink(2, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)} + editHref={editLink(2, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)} >
    {parts.map((p, i) => ( @@ -194,7 +217,7 @@ export async function StepReview({ params }: StepReviewProps) { } 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)} > {formattedDate} @@ -204,7 +227,7 @@ export async function StepReview({ params }: StepReviewProps) { } label="Repeats" - editHref={editLink(3, accountId, groupIds, messagesParam, scheduledAt, rrule, editReminderId)} + editHref={editLink(3, accountId, groupIds, params.name, messagesParam, scheduledAt, rrule, editReminderId)} > {describeRecurrence( @@ -219,7 +242,7 @@ export async function StepReview({ params }: StepReviewProps) { } 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 ? (
    @@ -243,6 +266,7 @@ export async function StepReview({ params }: StepReviewProps) {
    ); diff --git a/apps/web/src/components/reminder-wizard/when-form-client.tsx b/apps/web/src/components/reminder-wizard/when-form-client.tsx index 6e89657..4e13a46 100644 --- a/apps/web/src/components/reminder-wizard/when-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/when-form-client.tsx @@ -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 diff --git a/apps/web/src/lib/reminder-name.test.ts b/apps/web/src/lib/reminder-name.test.ts new file mode 100644 index 0000000..145afd8 --- /dev/null +++ b/apps/web/src/lib/reminder-name.test.ts @@ -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 + the DB + // column width have to move together. + expect(REMINDER_NAME_MAX).toBe(60); + }); + }); +}); diff --git a/apps/web/src/lib/reminder-name.ts b/apps/web/src/lib/reminder-name.ts new file mode 100644 index 0000000..0918232 --- /dev/null +++ b/apps/web/src/lib/reminder-name.ts @@ -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 | 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; +} diff --git a/apps/web/src/test/no-render-warnings.test.tsx b/apps/web/src/test/no-render-warnings.test.tsx index 74c0415..62835a6 100644 --- a/apps/web/src/test/no-render-warnings.test.tsx +++ b/apps/web/src/test/no-render-warnings.test.tsx @@ -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
    inside