Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
359 changes: 148 additions & 211 deletions admin-ui/__specs__/gardener-page.spec.tsx

Large diffs are not rendered by default.

389 changes: 363 additions & 26 deletions admin-ui/__specs__/gardener-pagination.spec.tsx

Large diffs are not rendered by default.

394 changes: 372 additions & 22 deletions admin-ui/__specs__/gardener-tabs.spec.tsx

Large diffs are not rendered by default.

19 changes: 2 additions & 17 deletions admin-ui/app/(authed)/catalog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { PurgeUnavailableAction } from "./PurgeUnavailableAction";
import { YearFilterControl } from "./YearFilterControl";
import { FacetFilterControl } from "./FacetFilterControl";
import { MoodFilterControl } from "./MoodFilterControl";
import { Pager } from "@/components/ui/pager";
import { Tooltip } from "@/components/ui/tooltip";
import type { AdminMediaDto, BulkFilter, Pagination } from "./types";

Expand Down Expand Up @@ -627,23 +628,7 @@ export default async function CatalogPage({ searchParams }: CatalogPageProps): P
clearFiltersHref="/catalog"
/>

{pagination.pages > 1 && (
<nav aria-label="Pagination" className="mt-4 flex items-center gap-3 text-[0.82rem] text-mute">
{currentPage > 1 && (
<Link href={buildPageUrl(sp, currentPage - 1)} className="text-accent hover:underline">
Previous
</Link>
)}
<span>
Page {currentPage} of {pagination.pages}
</span>
{currentPage < pagination.pages && (
<Link href={buildPageUrl(sp, currentPage + 1)} className="text-accent hover:underline">
Next
</Link>
)}
</nav>
)}
<Pager page={currentPage} pages={pagination.pages} hrefFor={(page) => buildPageUrl(sp, page)} />
</main>
);
}
43 changes: 43 additions & 0 deletions admin-ui/app/(authed)/gardener/GardenerPageSizePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { GardenerKind } from "@/lib/gardener-api";
import { buildGardenerHref, GARDENER_PAGE_SIZES, type GardenerPageSize } from "./gardener-paging";

interface GardenerPageSizePickerProps {
kind: GardenerKind;
limit: GardenerPageSize;
}

/**
* Rows-per-page picker (SPEC F153.10 rider 2026-08-31; STORY-382 AC3-AC4): plain anchors for each
* of {@link GARDENER_PAGE_SIZES}, the same "no client JS" pager idiom the catalog's own Previous/
* Next links use β€” text links, never icon-only (T378 law). Picking a size always resets to page 1
* (`buildGardenerHref` never carries a `page` param). Chip-scale radius and a 40px touch target
* (T387 review LOW-4 β€” design-aesthetic's chip/badge sizing, matching `TabStrip`'s own `min-h-10`).
*/
export function GardenerPageSizePicker({ kind, limit }: GardenerPageSizePickerProps): ReactNode {
return (
<div className="mt-3 flex items-center gap-2 text-[0.8rem] text-mute">
<span id="gardener-page-size-label">Rows per page</span>
<div role="group" aria-labelledby="gardener-page-size-label" className="flex items-center gap-1">
{GARDENER_PAGE_SIZES.map((size) => {
const active = size === limit;
return (
<Link
key={size}
href={buildGardenerHref(kind, size)}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-10 items-center rounded-[3px] px-2",
active ? "font-semibold text-accent" : "hover:text-ink"
)}
>
{size}
</Link>
);
})}
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion admin-ui/app/(authed)/gardener/GardenerRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface GardenerRowProps {
kind: GardenerKind;
finding: GardenerFindingDto;
/** Re-fetch trigger β€” called after any successful verb (SPEC F153.10: re-fetch, never a local
* patch β€” see GardenerView's own remarks). */
* patch β€” see GardenerSection's own remarks; PLAN T387 wires this to `router.refresh()`). */
onChanged: () => void;
/** A near-duplicate group's own "Keep this one" button (DuplicateGroupCard's slot) β€” absent for
* every other kind, and for a duplicate group's own row when rendered standalone would never
Expand Down
81 changes: 49 additions & 32 deletions admin-ui/app/(authed)/gardener/GardenerSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"use client";

import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
import { PurgeUnavailableAction } from "../_components/PurgeUnavailableAction";
import {
GARDENER_KIND_EMPTY_LABELS,
Expand All @@ -13,57 +16,71 @@ interface GardenerSectionProps {
kind: GardenerKind;
group: GardenerGroupDto;
/** `GET /api/status`'s own per-kind OPEN total (SPEC F153.9) β€” `null` when the status fetch
* itself failed, in which case the header falls back to this page's own row count. */
* itself failed, in which case the header falls back to {@link total}, EXCEPT for
* `near_duplicate` (T387 review LOW-2, RULED): `openCount` is a ROW count, but `near_duplicate`'s
* own `total` is a GROUP count (STORY-382 AC6/AC8) β€” falling back to it there would silently swap
* units, showing a group count as though it were an open-row count. That one kind suppresses the
* header count entirely instead (honest beats unit-swapped); every other kind's `total` is
* row-scoped, same unit as `openCount`, so the fallback stays correct there. The old "Showing
* first N of M" flat-paging caveat this header used to carry when the two disagreed is GONE
* (SPEC F153.10 rider 2026-08-31) β€” a real pager (`Pager`) replaces it. */
openCount: number | null;
onChanged: () => void;
/** The active tab's own exact total (the kind-scoped `GardenerFindingsResponse.total`, STORY-382
* AC6/AC8) β€” the header's fallback source when status failed, and what gates the dead_file Purge
* trigger below: a beyond-end page can legitimately render zero rows while the kind itself still
* has dead files to purge, so gating on `total` (not this page's own row count) stays correct. */
total: number;
}

/**
* One kind's section (SPEC F153.10, STORY-374 AC9): a header naming the kind and its open count,
* a per-kind empty state when nothing qualifies (LOW-2 β€” "Nothing here." read as generic; each
* kind now names itself), the "Showing first N of M" flat-paging caveat when this page's own row
* count for the kind is short of the status total (ORCHESTRATOR ruling 2 β€” rows are paged FLAT
* before grouping, so a page's own count for one kind can legitimately be less than that kind's
* real total), and either a flat row list (every kind but near_duplicate) or one
* {@link DuplicateGroupCard} per duplicate group (near_duplicate only β€” STORY-376 AC6).
* One kind's section β€” the tab strip's own content pane (SPEC F153.10 rider 2026-08-31; STORY-381/
* 382; PLAN T387, gh-#654/#655/#657): a header naming the kind and its open count, a per-kind empty
* state when nothing qualifies (LOW-2 β€” "Nothing here." read as generic; each kind names itself),
* and either a flat row list (every kind but near_duplicate) or one {@link DuplicateGroupCard} per
* duplicate group (near_duplicate only β€” STORY-376 AC6, STORY-383 AC4 whole-cluster rendering).
* Exactly ONE kind renders per page load now β€” the tab strip (`GardenerTabs`) owns which.
*
* This is now the page's own "use client" boundary: `page.tsx` (a Server Component) renders this
* directly, mirroring `catalog/CatalogTable.tsx`'s own split β€” a top-level client component that
* owns `useRouter()` and threads a `router.refresh()` closure down to every verb, rather than a
* closure passed in as a prop from the server (which RSC cannot serialize). `GardenerView`'s own
* client LoadState/fetch-on-mount β€” the gh-#654 defect β€” retires with this: every row verb still
* re-fetches on success, but by asking Next.js to re-render this Server Component, not by holding
* a second client-side copy of the queue. Purge stays dead_file-tab-only, now carrying the gh-#655
* verb-object label ("Purge dead tracks…"/"Purge dead tracks") β€” the old "Purge unavailable…" read
* as a status, never naming what the click actually does.
*
* T378 review LOW-5/LOW-B: the duplicate-group branch renders from `group.duplicateGroups` β€” never
* a `kind === "near_duplicate"` check alone β€” because `duplicateGroups` (not `findings.length`) is
* the actual data that branch draws from. A group with no `groupKey` is filtered out BEFORE
* `hasDuplicateGroups` is computed (not inside the render map, LOW-B's own fix) β€” Keep this one's
* whole point is "mark the OTHER members of THIS group ineligible", meaningless without a real
* group identity, and filtering only at render time left `hasDuplicateGroups` true even when every
* group had been filtered away, rendering an empty header with nothing under it. Filtering first
* means an all-null set falls through to the flat row list (the SAME fallback every non-
* near_duplicate kind renders) instead. Never reachable from the real backend today β€” a
* near_duplicate finding always carries its own `group_key` β€” but this keeps a malformed/future
* response from rendering a Keep-this-one button (or an empty shell) with no group behind it.
* T378 review LOW-5/LOW-B (carried forward verbatim): the duplicate-group branch renders from
* `group.duplicateGroups` β€” never a `kind === "near_duplicate"` check alone β€” and a group with no
* `groupKey` is filtered out BEFORE `hasDuplicateGroups` is computed, so a malformed/future
* response falls through to the flat row list instead of an empty shell with nothing under it.
*/
export function GardenerSection({ kind, group, openCount, onChanged }: GardenerSectionProps): ReactNode {
export function GardenerSection({ kind, group, openCount, total }: GardenerSectionProps): ReactNode {
const router = useRouter();
const onChanged = (): void => router.refresh();

const label = GARDENER_KIND_LABELS[kind];
const rowCount = group.findings.length;
const displayCount = openCount ?? rowCount;
const showingFewer = openCount !== null && rowCount < openCount;
// LOW-2 (RULED): near_duplicate's own `total` is a GROUP count, not a ROW count like `openCount`
// β€” falling back to it would silently swap units, so that one kind suppresses the count instead.
const displayCount: number | null = openCount ?? (kind === "near_duplicate" ? null : total);
const duplicateGroups = group.duplicateGroups.filter((duplicateGroup) => duplicateGroup.groupKey !== null);
const hasDuplicateGroups = duplicateGroups.length > 0;

return (
<section aria-label={label} className="rounded-[6px] border border-line bg-surface p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="font-display text-[1.05rem] font-semibold text-ink">
{label} <span className="text-[0.85rem] font-normal text-mute">Β· {displayCount} open</span>
{label}
{displayCount !== null && (
<span className="text-[0.85rem] font-normal text-mute"> Β· {displayCount} open</span>
)}
</h2>
{kind === "dead_file" && rowCount > 0 && (
<PurgeUnavailableAction title="Purge unavailable" triggerLabel="Purge unavailable…" onPurged={onChanged} />
{kind === "dead_file" && total > 0 && (
<PurgeUnavailableAction title="Purge dead tracks" triggerLabel="Purge dead tracks…" onPurged={onChanged} />
)}
</div>

{showingFewer && (
<p className="mt-1 text-[0.75rem] text-mute">
Showing first {rowCount} of {openCount}
</p>
)}

{rowCount === 0 && <p className="mt-3 text-[0.85rem] text-mute">{GARDENER_KIND_EMPTY_LABELS[kind]}</p>}

{rowCount > 0 && hasDuplicateGroups && (
Expand Down
42 changes: 42 additions & 0 deletions admin-ui/app/(authed)/gardener/GardenerTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { TabStrip, type TabStripTab } from "@/components/ui/tab-strip";
import {
GARDENER_KIND_LABELS,
GARDENER_KIND_ORDER,
GARDENER_OPEN_COUNT_KEY,
type GardenerKind,
type GardenerOpenCounts,
} from "@/lib/gardener-api";
import { buildGardenerHref, type GardenerPageSize } from "./gardener-paging";

interface GardenerTabsProps {
activeTab: GardenerKind;
limit: GardenerPageSize;
/** `GET /api/status`'s own per-kind OPEN totals (SPEC F153.9) β€” `null` when the status fetch
* itself failed, in which case every tab renders unbadged rather than a wrong number: the page
* fetches only the ACTIVE tab's own kind, so status is the only source for the other four. */
open: GardenerOpenCounts | null;
}

function tabLabel(kind: GardenerKind, open: GardenerOpenCounts | null): string {
const base = GARDENER_KIND_LABELS[kind];
return open === null ? base : `${base} (${open[GARDENER_OPEN_COUNT_KEY[kind]]})`;
}

/**
* The five rot-finding kind tabs (SPEC F153.10 rider 2026-08-31; STORY-381 AC1-AC3/AC7, gh-#654) β€”
* URL-driven via `?tab=`, the shared `TabStrip` markup (gh-#393's extraction), each label badged
* with that kind's own OPEN count from `/api/status` (STORY-381 AC1). `TabStrip` itself stays
* untouched (T387 scope: the count is embedded IN the label string here rather than widening the
* shared strip's own props) β€” every kind renders as its own tab regardless of count, the
* `WardrobeTabs`/`PersonaCatalogTabs` "always render every kind" ruling applied here too.
*/
export function GardenerTabs({ activeTab, limit, open }: GardenerTabsProps): ReactNode {
const tabs: TabStripTab<GardenerKind>[] = GARDENER_KIND_ORDER.map((kind) => ({
id: kind,
label: tabLabel(kind, open),
href: buildGardenerHref(kind, limit),
}));

return <TabStrip tabs={tabs} activeTab={activeTab} ariaLabel="Gardener kinds" />;
}
81 changes: 0 additions & 81 deletions admin-ui/app/(authed)/gardener/GardenerView.tsx

This file was deleted.

Loading
Loading