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 =
| "all"
| "success"
| "paused"
| "partial"
| "failed"
| "skipped"
| "archived";
type FilterValue = "all" | "success" | "paused" | "failed" | "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" },
{ value: "paused", label: "Paused" },
{ value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ 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 {
searchParams: Promise<{ filter?: string }>;
}
@ -185,9 +186,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const filter: FilterValue =
sp.filter === "success" ||
sp.filter === "paused" ||
sp.filter === "partial" ||
sp.filter === "failed" ||
sp.filter === "skipped" ||
sp.filter === "archived"
? sp.filter
: "all";
@ -198,7 +197,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const filtered =
filter === "all" || filter === "archived"
? runs
: runs.filter((r) => r.status === filter);
: runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
const hasAny = runs.length > 0;
return (
@ -235,26 +234,25 @@ export default async function ActivityPage({ searchParams }: PageProps) {
) : undefined
}
>
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
packed into a phone-width row left every label squeezed to
~50px. Wrap the list in an overflow-x scroller so each tab
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. */}
{/* Filter tabs span the full row and wrap onto a second line when the
viewport can't fit them all. Each trigger has a small basis so they
share space evenly while still keeping a readable label on mobile. */}
<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>
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
{label}
</Link>
</TabsTrigger>
))}
</TabsList>
</div>
<TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger
key={value}
value={value}
asChild
className="h-8 grow basis-20"
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
{label}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{filtered.length > 0 ? (

View File

@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
id="filter-account"
value={initial.accountId}
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>
{accounts.map((a) => (

View File

@ -187,11 +187,13 @@ export async function listActivityRuns(
// 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)
// false (default) — non-archived, non-skipped rows (skipped runs
// 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
? sql`rr.archived_at IS NOT NULL`
: sql`rr.archived_at IS NULL`;
? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
: sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
const rows = await db.execute(sql`
SELECT
rr.id,