feat(web): collapse Skipped→Archived, Partial→Paused+Failed; full-width filter rows

- Activity filter tabs drop Partial and Skipped; Partial runs now appear
  under both Paused and Failed (anything that didn't fully succeed),
  Skipped runs surface under Archived (history the operator chose not
  to send). Five tabs left: All / Success / Paused / Failed / Archived.
- listActivityRuns flips skipped runs out of the default list and into
  the archived view at the SQL layer so pagination stays correct.
- Tabs row spans the full width and wraps onto a second row when the
  viewport can't fit them. Account-filter select also span full width
  on every breakpoint instead of capping at sm:max-w-xs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 18:26:34 +08:00
parent ebbbdbdfb8
commit 797326e062
3 changed files with 37 additions and 37 deletions

View File

@ -106,24 +106,25 @@ function RunStatusBadge({ status }: { status: string }) {
); );
} }
type FilterValue = type FilterValue = "all" | "success" | "paused" | "failed" | "archived";
| "all"
| "success"
| "paused"
| "partial"
| "failed"
| "skipped"
| "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" }, { value: "all", label: "All" },
{ value: "success", label: "Success" }, { value: "success", label: "Success" },
{ value: "paused", label: "Paused" }, { value: "paused", label: "Paused" },
{ value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" }, { value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ value: "archived", label: "Archived" }, { value: "archived", label: "Archived" },
]; ];
// Partial runs (some recipients ok, some failed) surface under BOTH the
// Paused and Failed tabs — the operator wants to see anything that didn't
// fully succeed on either page. Skipped runs collapse into Archived since
// they're effectively "history that the operator chose not to send".
const FILTER_STATUSES: Record<Exclude<FilterValue, "all" | "archived">, string[]> = {
success: ["success"],
paused: ["paused", "partial"],
failed: ["failed", "partial"],
};
interface PageProps { interface PageProps {
searchParams: Promise<{ filter?: string }>; searchParams: Promise<{ filter?: string }>;
} }
@ -185,9 +186,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const filter: FilterValue = const filter: FilterValue =
sp.filter === "success" || sp.filter === "success" ||
sp.filter === "paused" || sp.filter === "paused" ||
sp.filter === "partial" ||
sp.filter === "failed" || sp.filter === "failed" ||
sp.filter === "skipped" ||
sp.filter === "archived" sp.filter === "archived"
? sp.filter ? sp.filter
: "all"; : "all";
@ -198,7 +197,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const filtered = const filtered =
filter === "all" || filter === "archived" filter === "all" || filter === "archived"
? runs ? runs
: runs.filter((r) => r.status === filter); : runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
const hasAny = runs.length > 0; const hasAny = runs.length > 0;
return ( return (
@ -235,26 +234,25 @@ export default async function ActivityPage({ searchParams }: PageProps) {
) : undefined ) : undefined
} }
> >
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived) {/* Filter tabs span the full row and wrap onto a second line when the
packed into a phone-width row left every label squeezed to viewport can't fit them all. Each trigger has a small basis so they
~50px. Wrap the list in an overflow-x scroller so each tab share space evenly while still keeping a readable label on mobile. */}
keeps a readable label + comfortable touch target on mobile;
on desktop the row fits naturally and no scroll bar appears.
Negative margins extend the scroller to the page edges so the
first/last tabs don't look clipped against the container. */}
<Tabs value={filter}> <Tabs value={filter}>
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
<TabsList> {FILTER_TABS.map(({ value, label }) => (
{FILTER_TABS.map(({ value, label }) => ( <TabsTrigger
<TabsTrigger key={value} value={value} asChild> key={value}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} value={value}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}> asChild
{label} className="h-8 grow basis-20"
</Link> >
</TabsTrigger> {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
))} <Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
</TabsList> {label}
</div> </Link>
</TabsTrigger>
))}
</TabsList>
</Tabs> </Tabs>
{filtered.length > 0 ? ( {filtered.length > 0 ? (

View File

@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
id="filter-account" id="filter-account"
value={initial.accountId} value={initial.accountId}
onChange={(e) => setParam("accountId", e.target.value)} onChange={(e) => setParam("accountId", e.target.value)}
className="h-8 w-full sm:max-w-xs rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<option value="">All accounts</option> <option value="">All accounts</option>
{accounts.map((a) => ( {accounts.map((a) => (

View File

@ -187,11 +187,13 @@ export async function listActivityRuns(
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder // exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
// has been deleted but history was preserved) in the list. // has been deleted but history was preserved) in the list.
// The `archived` flag flips the visibility filter: // The `archived` flag flips the visibility filter:
// false (default) — only non-archived rows // false (default) — non-archived, non-skipped rows (skipped runs
// true — only archived rows (for the Archived tab) // belong to the Archived tab now)
// true — archived rows OR skipped rows (they're treated
// as "history" rather than active outcomes)
const archivedClause = opts.archived const archivedClause = opts.archived
? sql`rr.archived_at IS NOT NULL` ? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
: sql`rr.archived_at IS NULL`; : sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
const rows = await db.execute(sql` const rows = await db.execute(sql`
SELECT SELECT
rr.id, rr.id,