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:
parent
52126765f4
commit
f681be9deb
@ -128,6 +128,80 @@ export async function restartReminderAction(formData: FormData): Promise<void> {
|
|||||||
revalidatePath(`/reminders/${reminderId}` as any);
|
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
|
const createReminderSchema = z
|
||||||
.object({
|
.object({
|
||||||
accountId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ vi.mock("@/actions/reminders", () => ({
|
|||||||
pauseReminderAction: vi.fn(),
|
pauseReminderAction: vi.fn(),
|
||||||
restartReminderAction: vi.fn(),
|
restartReminderAction: vi.fn(),
|
||||||
deleteReminderAction: vi.fn(),
|
deleteReminderAction: vi.fn(),
|
||||||
|
duplicateReminderAction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Make Dialog primitives transparent so we can grep the underlying tree.
|
// Make Dialog primitives transparent so we can grep the underlying tree.
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
|
CopyIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PauseIcon,
|
PauseIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
deleteReminderAction,
|
deleteReminderAction,
|
||||||
|
duplicateReminderAction,
|
||||||
pauseReminderAction,
|
pauseReminderAction,
|
||||||
restartReminderAction,
|
restartReminderAction,
|
||||||
} from "@/actions/reminders";
|
} from "@/actions/reminders";
|
||||||
@ -47,7 +49,7 @@ export function ActionsBar({ reminderId, status, isRecurring }: ActionsBarProps)
|
|||||||
const canRestart = status === "paused" || status === "ended";
|
const canRestart = status === "paused" || status === "ended";
|
||||||
|
|
||||||
return (
|
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 && (
|
{canPause && (
|
||||||
<ConfirmCard
|
<ConfirmCard
|
||||||
title="Pause"
|
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 */}
|
{/* Delete is always available */}
|
||||||
<ConfirmCard
|
<ConfirmCard
|
||||||
title="Delete"
|
title="Delete"
|
||||||
|
|||||||
@ -79,11 +79,15 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
|||||||
<CalendarIcon className="size-3 shrink-0" />
|
<CalendarIcon className="size-3 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
Last connected{" "}
|
Last connected{" "}
|
||||||
{account.lastConnectedAt.toLocaleDateString("en-MY", {
|
{account.lastConnectedAt.toLocaleString("en-MY", {
|
||||||
timeZone: "Asia/Kuala_Lumpur",
|
timeZone: "Asia/Kuala_Lumpur",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: true,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user