feat: full timestamp on accounts list; Duplicate action on reminder detail

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 12:03:41 +08:00
parent 52126765f4
commit f681be9deb
4 changed files with 99 additions and 2 deletions

View File

@ -128,6 +128,80 @@ export async function restartReminderAction(formData: FormData): Promise<void> {
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<void> {
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(),

View File

@ -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.

View File

@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
{canPause && (
<ConfirmCard
title="Pause"
@ -90,6 +92,22 @@ export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps)
/>
)}
{/* Duplicate — always available, non-destructive */}
<ConfirmCard
title="Duplicate"
description="Make a paused copy you can edit and start"
icon={<CopyIcon className="size-4 text-sky-600 dark:text-sky-400" />}
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={<CopyIcon />}
action={duplicateReminderAction}
reminderId={reminderId}
/>
{/* Delete is always available */}
<ConfirmCard
title="Delete"

View File

@ -79,11 +79,15 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
<CalendarIcon className="size-3 shrink-0" />
<span>
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,
})}
</span>
</div>