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:
parent
b71dbadef1
commit
704bc5e788
@ -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");
|
||||
}
|
||||
|
||||
@ -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,52 +226,72 @@ 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 = (
|
||||
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">
|
||||
{run.reminderName}
|
||||
{run.isDeleted && (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
|
||||
(deleted)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{relativeTime(run.firedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</CardContent>
|
||||
);
|
||||
const card = (
|
||||
<Card
|
||||
size="sm"
|
||||
className={
|
||||
run.reminderId && !run.isDeleted
|
||||
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"
|
||||
: undefined
|
||||
clickable
|
||||
? "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer rounded-none border-0 ring-0"
|
||||
: "rounded-none border-0 ring-0"
|
||||
}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{run.reminderName}
|
||||
{run.isDeleted && (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
|
||||
(deleted)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{relativeTime(run.firedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<RunStatusBadge status={run.status} />
|
||||
</CardContent>
|
||||
{clickable ? (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/reminders/${run.reminderId}` as any}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
inner
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
return run.reminderId && !run.isDeleted ? (
|
||||
<Link
|
||||
|
||||
return (
|
||||
<SwipeableRow
|
||||
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"
|
||||
actions={
|
||||
<ActionShelf
|
||||
runId={run.id}
|
||||
isArchived={Boolean(run.archivedAt)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={run.id}>{body}</div>
|
||||
{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,7 +388,9 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
||||
<p className="text-sm font-medium">
|
||||
{filter === "all"
|
||||
? "No activity yet."
|
||||
: `No ${filter} runs yet.`}
|
||||
: showingArchived
|
||||
? "No archived runs."
|
||||
: `No ${filter} runs yet.`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasAny
|
||||
|
||||
@ -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",
|
||||
|
||||
78
apps/web/src/components/swipeable-row.test.tsx
Normal file
78
apps/web/src/components/swipeable-row.test.tsx
Normal 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"');
|
||||
});
|
||||
});
|
||||
141
apps/web/src/components/swipeable-row.tsx
Normal file
141
apps/web/src/components/swipeable-row.tsx
Normal 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;
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
1
packages/db/migrations/0007_overconfident_menace.sql
Normal file
1
packages/db/migrations/0007_overconfident_menace.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "reminder_runs" ADD COLUMN "archived_at" timestamp with time zone;
|
||||
1030
packages/db/migrations/meta/0007_snapshot.json
Normal file
1030
packages/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -50,6 +50,13 @@
|
||||
"when": 1778385559051,
|
||||
"tag": "0006_adorable_nehzno",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1778386591494,
|
||||
"tag": "0007_overconfident_menace",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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", {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user