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 { 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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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[],
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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",
|
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 }]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user