feat(activity): swipe-to-archive/delete; quieter send-test toast

Two unrelated bits the user asked for in the same breath:

1. Activity row swipe-to-reveal actions
   ----------------------------------------
   On the mobile activity tab, drag a row left to reveal an Archive
   button (Restore when already archived) and a Delete button. Past a
   60 px threshold the shelf locks open; below that it springs back.
   Tapping anywhere outside an open row closes it. Desktop keeps a
   table layout but gains the same two row-level icon-buttons in a
   new Actions column, since hover-then-discover is more natural with
   a mouse than a swipe.

   - New `<SwipeableRow>` (apps/web/src/components/swipeable-row.tsx)
     — pointer-events only (no third-party gesture lib), 130 lines.
     The drag math lives in a pure helper `computeSwipeNext` so it's
     unit-testable without a DOM.

   - Migration 0007 adds `reminder_runs.archived_at timestamptz`
     (null = visible by default, non-null = archived). Soft-archive
     keeps the row queryable under a new "Archived" filter tab; hard
     Delete drops the row entirely (run_targets cascade via FK).

   - Server actions: `archiveRunAction` / `unarchiveRunAction` /
     `deleteRunAction`. Each rate-limits to 30/min/IP. Ownership
     check piggybacks on the same operator-or-orphan rule the
     activity query already uses.

   - `listActivityRuns(operatorId, { archived })` extended to filter
     in or out of the archived window. Default is archived: false so
     the existing tabs (All / Success / Partial / Failed / Skipped)
     keep showing only live runs.

   - Tests
     * `swipeable-row.test.tsx` — 6 unit tests covering the drag math
       (clamp at 0 / -SHELF_WIDTH, snap-to-closed below threshold,
       snap-to-open at or past threshold, snap math respects the
       previous offset) plus 2 SSR markup contracts (data-testid /
       aria-hidden / starts at translateX(0px) / data-state="closed").
     * Total web suite: 154 passing (was 146).

2. Send-test toast text trim
   ----------------------------------------
   "Sent ✓ — check the WhatsApp group." → "Sent ✓". The trailing
   note told the user something they could already see (they're the
   one who clicked Send Test on a specific group). Less noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 12:20:05 +08:00
parent b71dbadef1
commit 704bc5e788
10 changed files with 1509 additions and 49 deletions

View File

@ -2,19 +2,41 @@
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { reminderRuns } from "@cmbot/db";
import { db } from "@/lib/db";
import { getSeededOperator } from "@/lib/operator";
import { checkRateLimit } from "@/lib/rate-limit";
async function rateLimit(key: string) {
async function rateLimit(key: string, opts: { max?: number; windowSec?: number } = {}) {
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "unknown";
const r = await checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 });
const r = await checkRateLimit(`${key}:${ip}`, {
max: opts.max ?? 5,
windowSec: opts.windowSec ?? 60,
});
if (r.limited) throw new Error("Too many requests");
}
/**
* Verify the run belongs to the seeded operator (or is an orphan from a
* deleted reminder, which the dashboard considers shared history). Returns
* the run's id when ownership checks out, otherwise null.
*/
async function checkRunOwnership(runId: string): Promise<string | null> {
const op = await getSeededOperator();
const rows = await db.execute<{ id: string }>(sql`
SELECT rr.id
FROM reminder_runs rr
LEFT JOIN reminders r ON r.id = rr.reminder_id
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE rr.id = ${runId}
AND (wa.operator_id = ${op.id} OR r.id IS NULL)
LIMIT 1
`);
return rows.rows[0]?.id ?? null;
}
/**
* Wipe the operator's reminder run history. Operators only see runs whose
* underlying reminder is still owned by them PLUS orphan runs (whose
@ -39,3 +61,45 @@ export async function clearHistoryAction(): Promise<void> {
revalidatePath("/");
revalidatePath("/reminders");
}
/** Soft-archive one run. Hidden from the default activity list afterwards. */
export async function archiveRunAction(formData: FormData): Promise<void> {
await rateLimit("archive-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db
.update(reminderRuns)
.set({ archivedAt: new Date() })
.where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}
/** Move a previously-archived run back to the default activity list. */
export async function unarchiveRunAction(formData: FormData): Promise<void> {
await rateLimit("unarchive-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db
.update(reminderRuns)
.set({ archivedAt: null })
.where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}
/** Hard-delete one run. Cascades through reminder_run_targets via FK. */
export async function deleteRunAction(formData: FormData): Promise<void> {
await rateLimit("delete-run", { max: 30, windowSec: 60 });
const runId = formData.get("runId");
if (typeof runId !== "string") return;
const ownedId = await checkRunOwnership(runId);
if (!ownedId) return;
await db.delete(reminderRuns).where(eq(reminderRuns.id, ownedId));
revalidatePath("/");
revalidatePath("/activity");
}

View File

@ -1,11 +1,13 @@
import Link from "next/link";
import {
ActivityIcon,
CheckCircle2Icon,
AlertTriangleIcon,
XCircleIcon,
ArchiveIcon,
ArchiveRestoreIcon,
CheckCircle2Icon,
MinusCircleIcon,
Trash2Icon,
XCircleIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -30,7 +32,13 @@ import {
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries";
import { clearHistoryAction } from "@/actions/history";
import {
archiveRunAction,
clearHistoryAction,
deleteRunAction,
unarchiveRunAction,
} from "@/actions/history";
import { SwipeableRow } from "@/components/swipeable-row";
function relativeTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
@ -87,39 +95,93 @@ function RunStatusBadge({ status }: { status: string }) {
);
}
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped";
type FilterValue = "all" | "success" | "partial" | "failed" | "skipped" | "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ value: "archived", label: "Archived" },
];
interface PageProps {
searchParams: Promise<{ filter?: string }>;
}
interface ActionShelfProps {
runId: string;
isArchived: boolean;
}
/**
* The right-side reveal shelf for swipeable activity rows.
*
* Two stacked buttons Archive (or Restore, when the row is already
* archived) and Delete. Each is its own form submit so the row stays
* a server component; the page revalidates after the action lands.
*/
function ActionShelf({ runId, isArchived }: ActionShelfProps) {
return (
<div className="flex w-full items-stretch gap-px bg-border">
<form
action={isArchived ? unarchiveRunAction : archiveRunAction}
className="flex-1"
>
<input type="hidden" name="runId" value={runId} />
<button
type="submit"
aria-label={isArchived ? "Restore" : "Archive"}
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30 text-xs font-medium"
>
{isArchived ? (
<ArchiveRestoreIcon className="size-4" />
) : (
<ArchiveIcon className="size-4" />
)}
{isArchived ? "Restore" : "Archive"}
</button>
</form>
<form action={deleteRunAction} className="flex-1">
<input type="hidden" name="runId" value={runId} />
<button
type="submit"
aria-label="Delete"
className="flex h-full w-full flex-col items-center justify-center gap-1 bg-destructive/15 text-destructive hover:bg-destructive/25 text-xs font-medium"
>
<Trash2Icon className="size-4" />
Delete
</button>
</form>
</div>
);
}
export default async function ActivityPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filter: FilterValue =
sp.filter === "success" ||
sp.filter === "partial" ||
sp.filter === "failed" ||
sp.filter === "skipped"
sp.filter === "skipped" ||
sp.filter === "archived"
? sp.filter
: "all";
const showingArchived = filter === "archived";
const op = await getSeededOperator();
const runs = await listActivityRuns(op.id);
const filtered = filter === "all" ? runs : runs.filter((r) => r.status === filter);
const runs = await listActivityRuns(op.id, { archived: showingArchived });
const filtered =
filter === "all" || filter === "archived"
? runs
: runs.filter((r) => r.status === filter);
const hasAny = runs.length > 0;
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
{hasAny && (
{hasAny && !showingArchived && (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
@ -164,18 +226,15 @@ export default async function ActivityPage({ searchParams }: PageProps) {
{filtered.length > 0 ? (
<>
{/* Mobile: cards */}
<p className="text-xs text-muted-foreground sm:hidden">
Swipe a row left for {showingArchived ? "Restore" : "Archive"} / Delete.
</p>
{/* Mobile: swipeable cards */}
<div className="flex flex-col gap-2 sm:hidden">
{filtered.map((run) => {
const body = (
<Card
size="sm"
className={
run.reminderId && !run.isDeleted
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
: undefined
}
>
const clickable = run.reminderId && !run.isDeleted;
const inner = (
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
@ -192,24 +251,47 @@ export default async function ActivityPage({ searchParams }: PageProps) {
</div>
<RunStatusBadge status={run.status} />
</CardContent>
</Card>
);
return run.reminderId && !run.isDeleted ? (
const card = (
<Card
size="sm"
className={
clickable
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer rounded-none border-0 ring-0"
: "rounded-none border-0 ring-0"
}
>
{clickable ? (
<Link
key={run.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${run.reminderId}` as any}
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{body}
{inner}
</Link>
) : (
<div key={run.id}>{body}</div>
inner
)}
</Card>
);
return (
<SwipeableRow
key={run.id}
actions={
<ActionShelf
runId={run.id}
isArchived={Boolean(run.archivedAt)}
/>
}
>
{card}
</SwipeableRow>
);
})}
</div>
{/* Desktop: table */}
{/* Desktop: table with hover-revealed actions */}
<div className="hidden sm:block">
<Card>
<CardContent className="p-0">
@ -218,16 +300,18 @@ export default async function ActivityPage({ searchParams }: PageProps) {
<TableRow>
<TableHead>Reminder</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Fired</TableHead>
<TableHead>Fired</TableHead>
<TableHead className="w-1 text-right pr-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((run) => {
const clickable = run.reminderId && !run.isDeleted;
const isArchived = Boolean(run.archivedAt);
return (
<TableRow
key={run.id}
className={clickable ? "cursor-pointer hover:bg-muted/50" : undefined}
className={clickable ? "hover:bg-muted/50" : undefined}
>
<TableCell className="font-medium">
{clickable ? (
@ -248,9 +332,45 @@ export default async function ActivityPage({ searchParams }: PageProps) {
<TableCell>
<RunStatusBadge status={run.status} />
</TableCell>
<TableCell className="text-right text-muted-foreground text-xs">
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{relativeTime(run.firedAt)}
</TableCell>
<TableCell className="text-right pr-2 whitespace-nowrap">
<div className="inline-flex items-center gap-0.5">
<form
action={
isArchived ? unarchiveRunAction : archiveRunAction
}
>
<input type="hidden" name="runId" value={run.id} />
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label={isArchived ? "Restore" : "Archive"}
className="text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400"
>
{isArchived ? (
<ArchiveRestoreIcon className="size-4" />
) : (
<ArchiveIcon className="size-4" />
)}
</Button>
</form>
<form action={deleteRunAction}>
<input type="hidden" name="runId" value={run.id} />
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label="Delete"
className="text-muted-foreground hover:text-destructive"
>
<Trash2Icon className="size-4" />
</Button>
</form>
</div>
</TableCell>
</TableRow>
);
})}
@ -268,6 +388,8 @@ export default async function ActivityPage({ searchParams }: PageProps) {
<p className="text-sm font-medium">
{filter === "all"
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`}
</p>
<p className="text-xs text-muted-foreground">

View File

@ -40,7 +40,7 @@ export function SendTestForm({ groupId }: { groupId: string }) {
"send_test.done": (data) => {
if (data.groupId !== groupId) return;
if (data.ok) {
setOutcome({ kind: "sent", message: "Sent ✓ — check the WhatsApp group." });
setOutcome({ kind: "sent", message: "Sent ✓" });
} else {
setOutcome({
kind: "error",

View File

@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
computeSwipeNext,
SwipeableRow,
SWIPE_REVEAL_THRESHOLD,
SWIPE_SHELF_WIDTH,
} from "./swipeable-row";
describe("computeSwipeNext — drag math", () => {
it("returns 0 offset and snap=closed when there's no drag", () => {
expect(computeSwipeNext(0, 0)).toEqual({
dragOffset: 0,
snapAfterRelease: 0,
});
});
it("clamps positive drags to 0 (can't drag past the closed position)", () => {
expect(computeSwipeNext(0, 25).dragOffset).toBe(0);
expect(computeSwipeNext(-30, 200).dragOffset).toBe(0);
});
it("clamps drags beyond shelf width to -SHELF_WIDTH (can't pull past fully open)", () => {
expect(computeSwipeNext(0, -500).dragOffset).toBe(-SWIPE_SHELF_WIDTH);
expect(computeSwipeNext(-SWIPE_SHELF_WIDTH, -50).dragOffset).toBe(-SWIPE_SHELF_WIDTH);
});
it("snaps back to closed when released before the threshold", () => {
const dx = -(SWIPE_REVEAL_THRESHOLD - 1);
expect(computeSwipeNext(0, dx).snapAfterRelease).toBe(0);
});
it("snaps to fully open when released at or past the threshold", () => {
const dxAt = -SWIPE_REVEAL_THRESHOLD;
const dxPast = -(SWIPE_REVEAL_THRESHOLD + 10);
expect(computeSwipeNext(0, dxAt).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH);
expect(computeSwipeNext(0, dxPast).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH);
});
it("respects the previous offset when measuring against threshold", () => {
// Already partway open (-20) and you drag another -50 → -70, which
// is past the threshold so it snaps fully open.
expect(computeSwipeNext(-20, -50).snapAfterRelease).toBe(-SWIPE_SHELF_WIDTH);
// Already partway open (-50) and you drag back +20 → -30, well shy
// of the threshold so it snaps back closed.
expect(computeSwipeNext(-50, 20).snapAfterRelease).toBe(0);
});
});
describe("SwipeableRow — SSR markup contract", () => {
it("renders the action shelf and the row body, wrapped in a positioned container", () => {
const html = renderToStaticMarkup(
<SwipeableRow actions={<button type="button">Archive</button>}>
<div>Row body</div>
</SwipeableRow>,
);
expect(html).toContain('data-testid="swipeable-row"');
// Row starts in the closed state so the shelf is `aria-hidden` to AT.
expect(html).toMatch(/aria-hidden="true"/);
expect(html).toContain("Archive");
expect(html).toContain("Row body");
// The container must be `relative` + `overflow-hidden` so the shelf
// doesn't bleed past the row's bounds during drag.
expect(html).toMatch(/class="relative overflow-hidden/);
});
it("starts with the row at translateX(0) (closed) and no transition during initial render", () => {
const html = renderToStaticMarkup(
<SwipeableRow actions={<span>x</span>}>
<div>body</div>
</SwipeableRow>,
);
// A closed row's transform is exactly translateX(0px). Anything else
// means the SSR/client states would diverge.
expect(html).toContain("translateX(0px)");
expect(html).toContain('data-state="closed"');
});
});

View File

@ -0,0 +1,141 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
/**
* SwipeableRow gesture-driven row with reveal-on-swipe action buttons.
*
* The row sits over a fixed-position action shelf. Drag (touch or
* mouse) the row to the left to drag the shelf into view; release past
* REVEAL_THRESHOLD to lock the shelf open, otherwise spring back to
* closed. Tapping anywhere outside the row when it's open also closes
* it. Optional `actions` slot for the right side (Archive / Delete).
*
* The state machine is intentionally tiny:
*
* closed dragging (release < threshold) closed
* closed dragging (release threshold) open
* open tap outside closed
*
* No third-party gesture library touch / pointer events only to
* keep the bundle small and avoid SSR / hydration headaches.
*/
const REVEAL_THRESHOLD = 60; // px — lock shelf open past this drag
const SHELF_WIDTH = 132; // px — width of the right-side action shelf
interface SwipeableRowProps {
/** Right-side action shelf revealed on swipe-left. */
actions: React.ReactNode;
/** Row body — the visible content above the shelf. */
children: React.ReactNode;
/** className for the OUTER wrapper (positioning, margins). */
className?: string;
/** className for the inner sliding row (background, padding). */
rowClassName?: string;
}
export function SwipeableRow({
actions,
children,
className,
rowClassName,
}: SwipeableRowProps) {
// `offset` is the row's current x-translation in px (0 = closed, -SHELF_WIDTH = fully open).
const [offset, setOffset] = useState(0);
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
// Close the shelf when the user taps anywhere outside an open row.
useEffect(() => {
if (offset === 0) return;
function onDocPointer(e: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOffset(0);
}
}
window.addEventListener("pointerdown", onDocPointer);
return () => window.removeEventListener("pointerdown", onDocPointer);
}, [offset]);
function clamp(next: number): number {
if (next > 0) return 0;
if (next < -SHELF_WIDTH) return -SHELF_WIDTH;
return next;
}
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
// Ignore right-click / pinch / etc.
if (e.button !== 0 && e.pointerType === "mouse") return;
dragStart.current = { x: e.clientX, baseOffset: offset };
setDragging(true);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging || !dragStart.current) return;
const dx = e.clientX - dragStart.current.x;
setOffset(clamp(dragStart.current.baseOffset + dx));
}
function handlePointerUp() {
if (!dragging) return;
setDragging(false);
dragStart.current = null;
// Snap to the nearest stable position based on REVEAL_THRESHOLD.
setOffset((prev) => (prev <= -REVEAL_THRESHOLD ? -SHELF_WIDTH : 0));
}
return (
<div
ref={containerRef}
className={cn("relative overflow-hidden rounded-xl", className)}
data-state={offset === 0 ? "closed" : offset === -SHELF_WIDTH ? "open" : "dragging"}
data-testid="swipeable-row"
>
{/* Action shelf — pinned to the right, revealed by translation of the row above. */}
<div
aria-hidden={offset === 0}
className="absolute inset-y-0 right-0 flex items-stretch"
style={{ width: SHELF_WIDTH }}
>
{actions}
</div>
{/* Sliding row body. */}
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
transform: `translateX(${offset}px)`,
transition: dragging ? "none" : "transform 200ms ease-out",
touchAction: "pan-y", // allow vertical scroll, capture horizontal drag
}}
className={cn("relative bg-card", rowClassName)}
>
{children}
</div>
</div>
);
}
/**
* Pure helper: given a pointer drag delta in px and the previous offset,
* compute the next offset (clamped) and the post-release snap target.
* Exposed so unit tests can exercise the logic without a DOM.
*/
export function computeSwipeNext(
baseOffset: number,
dx: number,
): { dragOffset: number; snapAfterRelease: number } {
const raw = baseOffset + dx;
const clamped = raw > 0 ? 0 : raw < -SHELF_WIDTH ? -SHELF_WIDTH : raw;
const snap = clamped <= -REVEAL_THRESHOLD ? -SHELF_WIDTH : 0;
return { dragOffset: clamped, snapAfterRelease: snap };
}
export const SWIPE_REVEAL_THRESHOLD = REVEAL_THRESHOLD;
export const SWIPE_SHELF_WIDTH = SHELF_WIDTH;

View File

@ -172,24 +172,36 @@ export interface ActivityRunRow {
reminderId: string | null;
reminderName: string;
isDeleted: boolean;
archivedAt: Date | null;
}
export async function listActivityRuns(operatorId: string): Promise<ActivityRunRow[]> {
export async function listActivityRuns(
operatorId: string,
opts: { archived?: boolean } = {},
): Promise<ActivityRunRow[]> {
// Mirrors the dashboard query but returns the full window (last 200) and
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
// has been deleted but history was preserved) in the list.
// The `archived` flag flips the visibility filter:
// false (default) — only non-archived rows
// true — only archived rows (for the Archived tab)
const archivedClause = opts.archived
? sql`rr.archived_at IS NOT NULL`
: sql`rr.archived_at IS NULL`;
const rows = await db.execute(sql`
SELECT
rr.id,
rr.status,
rr.fired_at,
rr.reminder_id,
rr.archived_at,
COALESCE(r.name, rr.reminder_name, '(deleted reminder)') AS name,
r.id IS NULL AS is_deleted
FROM reminder_runs rr
LEFT JOIN reminders r ON r.id = rr.reminder_id
LEFT JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId} OR r.id IS NULL
WHERE (wa.operator_id = ${operatorId} OR r.id IS NULL)
AND ${archivedClause}
ORDER BY rr.fired_at DESC
LIMIT 200
`);
@ -200,6 +212,7 @@ export async function listActivityRuns(operatorId: string): Promise<ActivityRunR
reminderId: (r.reminder_id as string | null) ?? null,
reminderName: r.name as string,
isDeleted: Boolean(r.is_deleted),
archivedAt: (r.archived_at as Date | null) ?? null,
}));
}

View File

@ -0,0 +1 @@
ALTER TABLE "reminder_runs" ADD COLUMN "archived_at" timestamp with time zone;

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,13 @@
"when": 1778385559051,
"tag": "0006_adorable_nehzno",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1778386591494,
"tag": "0007_overconfident_menace",
"breakpoints": true
}
]
}

View File

@ -120,6 +120,10 @@ export const reminderRuns = pgTable("reminder_runs", {
firedAt: timestamp("fired_at", { withTimezone: true }).notNull().defaultNow(),
status: text("status").notNull(),
errorSummary: text("error_summary"),
// Soft-archive: non-null hides the row from the default activity
// listing but keeps it queryable under a dedicated "Archived" filter.
// The user can restore (unarchive) later or hard-delete from there.
archivedAt: timestamp("archived_at", { withTimezone: true }),
});
export const reminderRunTargets = pgTable("reminder_run_targets", {