diff --git a/apps/web/src/actions/history.ts b/apps/web/src/actions/history.ts index 3d9065e..8670927 100644 --- a/apps/web/src/actions/history.ts +++ b/apps/web/src/actions/history.ts @@ -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 { + 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 { revalidatePath("/"); revalidatePath("/reminders"); } + +/** Soft-archive one run. Hidden from the default activity list afterwards. */ +export async function archiveRunAction(formData: FormData): Promise { + 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 { + 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 { + 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"); +} diff --git a/apps/web/src/app/activity/page.tsx b/apps/web/src/app/activity/page.tsx index 0a43d93..c742a05 100644 --- a/apps/web/src/app/activity/page.tsx +++ b/apps/web/src/app/activity/page.tsx @@ -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 ( +
+
+ + +
+
+ + +
+
+ ); +} + 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 (

Activity

- {hasAny && ( + {hasAny && !showingArchived && ( + +
+ + +
+
+ ); })} @@ -268,7 +388,9 @@ export default async function ActivityPage({ searchParams }: PageProps) {

{filter === "all" ? "No activity yet." - : `No ${filter} runs yet.`} + : showingArchived + ? "No archived runs." + : `No ${filter} runs yet.`}

{hasAny diff --git a/apps/web/src/components/send-test-form.tsx b/apps/web/src/components/send-test-form.tsx index 9659a31..6a5de32 100644 --- a/apps/web/src/components/send-test-form.tsx +++ b/apps/web/src/components/send-test-form.tsx @@ -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", diff --git a/apps/web/src/components/swipeable-row.test.tsx b/apps/web/src/components/swipeable-row.test.tsx new file mode 100644 index 0000000..c1c4be2 --- /dev/null +++ b/apps/web/src/components/swipeable-row.test.tsx @@ -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( + Archive}> +

Row body
+ , + ); + 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( + x}> +
body
+
, + ); + // 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"'); + }); +}); diff --git a/apps/web/src/components/swipeable-row.tsx b/apps/web/src/components/swipeable-row.tsx new file mode 100644 index 0000000..16b2e1b --- /dev/null +++ b/apps/web/src/components/swipeable-row.tsx @@ -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(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) { + // 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) { + 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 ( +
+ {/* Action shelf — pinned to the right, revealed by translation of the row above. */} +
+ {actions} +
+ + {/* Sliding row body. */} +
+ {children} +
+
+ ); +} + +/** + * 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; diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 6e172d0..ddd6cd3 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -172,24 +172,36 @@ export interface ActivityRunRow { reminderId: string | null; reminderName: string; isDeleted: boolean; + archivedAt: Date | null; } -export async function listActivityRuns(operatorId: string): Promise { +export async function listActivityRuns( + operatorId: string, + opts: { archived?: boolean } = {}, +): Promise { // 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