From f681be9deb19a9346877caeb2c578b746ccc8ffd Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 12:03:41 +0800 Subject: [PATCH] feat: full timestamp on accounts list; Duplicate action on reminder detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small additions: 1. Accounts list "Last connected" line: bumped from a date-only string ("10 May 2026") to a full timestamp with day, month, year, hour (12-hour with AM/PM), minute, second — same KL timezone, en-MY locale. Useful for diagnosing recent disconnects vs old ones at a glance. 2. New \`duplicateReminderAction\` server action plus a fourth card on the reminder detail's ActionsBar (Pause / Restart / Duplicate / Delete). The action copies the source reminder's account, groups, message parts, and schedule (rrule unchanged). The new row starts \`paused\` so it doesn't fire on top of the original — operator tweaks the schedule from the detail page and Restarts when ready. Name is suffixed with " (copy)" (capped at 60 chars). ActionsBar grid bumped from 3-column to 4-column at lg, with a 2x2 fallback at sm so it doesn't get cramped on narrower screens. Test mock for actions-bar.test.tsx widened to include the new action. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/actions/reminders.ts | 74 +++++++++++++++++++ .../app/reminders/[id]/actions-bar.test.tsx | 1 + .../src/app/reminders/[id]/actions-bar.tsx | 20 ++++- .../web/src/components/accounts-list-view.tsx | 6 +- 4 files changed, 99 insertions(+), 2 deletions(-) diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index ac4f9f1..de188ec 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -128,6 +128,80 @@ export async function restartReminderAction(formData: FormData): Promise { revalidatePath(`/reminders/${reminderId}` as any); } +/** + * Duplicate a reminder. Creates a new reminder with the same account, + * groups, and message parts. The copy starts \`paused\` and inherits + * the source's scheduledAt / rrule unchanged — the user can edit the + * schedule from the detail page and Restart when ready. + */ +export async function duplicateReminderAction(formData: FormData): Promise { + await rateLimit("duplicate-reminder"); + const reminderId = formData.get("reminderId"); + if (typeof reminderId !== "string") return; + const op = await getSeededOperator(); + + const source = await db.query.reminders.findFirst({ + where: (r, { eq }) => eq(r.id, reminderId), + }); + if (!source) return; + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => + and(eq(a.id, source.accountId), eq(a.operatorId, op.id)), + }); + if (!account) return; + + const sourceTargets = await db.query.reminderTargets.findMany({ + where: (t, { eq }) => eq(t.reminderId, reminderId), + }); + const sourceMessages = await db.query.reminderMessages.findMany({ + where: (m, { eq }) => eq(m.reminderId, reminderId), + }); + + const newId = await db.transaction(async (tx) => { + const [rem] = await tx + .insert(reminders) + .values({ + accountId: source.accountId, + name: `${source.name} (copy)`.slice(0, 60), + scheduleKind: source.scheduleKind, + scheduledAt: source.scheduledAt, + rrule: source.rrule, + timezone: source.timezone, + // Start paused so the copy doesn't fire on top of the original + // — the user picks a new time / reactivates from the detail page. + status: "paused", + createdBy: op.id, + }) + .returning({ id: reminders.id }); + + if (sourceTargets.length > 0) { + await tx.insert(reminderTargets).values( + sourceTargets.map((t) => ({ + reminderId: rem!.id, + groupId: t.groupId, + position: t.position, + })), + ); + } + if (sourceMessages.length > 0) { + await tx.insert(reminderMessages).values( + sourceMessages.map((m) => ({ + reminderId: rem!.id, + position: m.position, + kind: m.kind, + textContent: m.textContent, + mediaId: m.mediaId, + })), + ); + } + return rem!.id; + }); + + revalidatePath("/reminders"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect(`/reminders/${newId}` as any); +} + const createReminderSchema = z .object({ accountId: z.string().uuid(), diff --git a/apps/web/src/app/reminders/[id]/actions-bar.test.tsx b/apps/web/src/app/reminders/[id]/actions-bar.test.tsx index 4c287df..b453360 100644 --- a/apps/web/src/app/reminders/[id]/actions-bar.test.tsx +++ b/apps/web/src/app/reminders/[id]/actions-bar.test.tsx @@ -6,6 +6,7 @@ vi.mock("@/actions/reminders", () => ({ pauseReminderAction: vi.fn(), restartReminderAction: vi.fn(), deleteReminderAction: vi.fn(), + duplicateReminderAction: vi.fn(), })); // Make Dialog primitives transparent so we can grep the underlying tree. diff --git a/apps/web/src/app/reminders/[id]/actions-bar.tsx b/apps/web/src/app/reminders/[id]/actions-bar.tsx index c5111bd..2cf9e20 100644 --- a/apps/web/src/app/reminders/[id]/actions-bar.tsx +++ b/apps/web/src/app/reminders/[id]/actions-bar.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { AlertCircleIcon, + CopyIcon, Loader2Icon, PauseIcon, PlayIcon, @@ -21,6 +22,7 @@ import { } from "@/components/ui/dialog"; import { deleteReminderAction, + duplicateReminderAction, pauseReminderAction, restartReminderAction, } from "@/actions/reminders"; @@ -47,7 +49,7 @@ export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps) const canRestart = status === "paused" || status === "ended"; return ( -
+
{canPause && ( )} + {/* Duplicate — always available, non-destructive */} + } + accentBg="bg-sky-500/10" + accentRing="hover:ring-sky-500/30" + dialogTitle="Duplicate this reminder?" + dialogBody="A paused copy is created with the same account, groups, message and schedule. Edit it and Restart when you're ready." + confirmLabel="Yes, duplicate" + confirmVariant="default" + confirmIcon={} + action={duplicateReminderAction} + reminderId={reminderId} + /> + {/* Delete is always available */} Last connected{" "} - {account.lastConnectedAt.toLocaleDateString("en-MY", { + {account.lastConnectedAt.toLocaleString("en-MY", { timeZone: "Asia/Kuala_Lumpur", year: "numeric", month: "short", day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, })}