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:
parent
ebbbdbdfb8
commit
797326e062
@ -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,18 +234,18 @@ 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 key={value} value={value} asChild>
|
<TabsTrigger
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
asChild
|
||||||
|
className="h-8 grow basis-20"
|
||||||
|
>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
|
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}>
|
||||||
{label}
|
{label}
|
||||||
@ -254,7 +253,6 @@ export default async function ActivityPage({ searchParams }: PageProps) {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{filtered.length > 0 ? (
|
{filtered.length > 0 ? (
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user