A UX Polish Sprint: 8 PRs in 48 Hours
Over a weekend I shipped eight focused pull requests to polish an admin dashboard for a task seeding platform. None of them fixed a broken feature. Every one removed friction that had accumulated while the product grew: crowded toolbars, hardcoded filter lists, search that fired on every keystroke, internal jargon on buttons, missing navigation cues. Core functionality was already solid. The dashboard was just harder to use than it needed to be.
This post walks through each PR—what changed, why it mattered, and the patterns worth reusing. The through-line is simple: polish is not a big redesign. It is a series of small, intentional improvements that respect the user's time and compound into a dramatically better experience.
The context
The admin panel had grown organically over several weeks. Operators could list tasks, filter by status, drill into batches, and run bulk actions. But the UI showed its age. Five separate filter dropdowns sat inline on the toolbar next to sort controls and a search box. Filter options were hardcoded in component files, so adding a new status meant touching multiple places and hoping string literals stayed in sync. One action button still used an internal slot code instead of the human label the rest of the app used. Task tables showed a redundant arrow column even though the whole row was clickable. Search re-filtered the list on every character, including single-letter queries that matched half the database.
None of these were bugs. All of them made daily work slower and more error-prone. A 48-hour polish sprint was the right response: ship small PRs, review quickly, and let the improvements stack.
PR 1 — Filter registry (208 lines)
The status filter chips were driven by a hardcoded statusChipOptions array. Labels, colors, icons, and matching logic lived in different files. Adding "Completed" or a virtual "Duplicate" filter meant hunting magic strings across the UI and the filter reducer.
I replaced that with a single ordered registry: LISTING_FILTER_REGISTRY. Each entry is a ListingFilterDef—key, label, icon, color, and a matches() predicate. The chip row and the filter logic both derive from the same array. Adding a filter is one registry entry; removing one is deleting a line.
interface ListingFilterDef {
key: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
matches: (task: TaskRow) => boolean;
}
const DEDUP_MARKER = "duplicate_detected";
export const LISTING_FILTER_REGISTRY: ListingFilterDef[] = [
{
key: "all",
label: "All",
icon: ListIcon,
color: "muted",
matches: () => true,
},
{
key: "completed",
label: "Completed",
icon: CheckCircleIcon,
color: "success",
matches: (task) => task.status === "completed",
},
{
key: "duplicate",
label: "Duplicate",
icon: CopyIcon,
color: "warning",
matches: (task) =>
task.errorReason?.includes(DEDUP_MARKER) ?? false,
},
// ...other status filters
];
"Duplicate" is a virtual filter: it does not map to a database status. It matches tasks whose errorReason contains a deduplication marker—useful when operators need to triage collisions without learning internal status enums. "Completed" is a straight status match. Both shipped because the registry made them trivial.
PR 2 — Search minimum length (60 lines)
Free-text search ran on every keystroke. Typing a re-filtered thousands of rows and made the list feel broken. Users had no feedback about why results looked random.
I introduced MIN_SEARCH_QUERY_LENGTH = 3. Queries shorter than three characters are treated as empty—no filter applied. An inline hint appears below the input when the field is non-empty but too short.
const MIN_SEARCH_QUERY_LENGTH = 3;
function effectiveSearchQuery(raw: string): string {
const trimmed = raw.trim();
return trimmed.length >= MIN_SEARCH_QUERY_LENGTH ? trimmed : "";
}
// In the listing toolbar component:
{searchQuery.length > 0 && searchQuery.length < MIN_SEARCH_QUERY_LENGTH && (
<p className="text-sm text-muted-foreground" role="status">
Enter at least {MIN_SEARCH_QUERY_LENGTH} characters to search.
</p>
)}
The list stops thrashing on single-character input, and users get a clear explanation instead of silence.
PR 3 — Consolidated filters panel (291 lines)
The toolbar had grown to eight dense inline controls: step, version, progress, updated, task state, plus sort field, sort direction, and clear. On a laptop screen it wrapped awkwardly and competed with the search box for attention.
Five categorical filters moved into one Filters dropdown. Inside, a DropdownMenuRadioGroup gives native single-select semantics—only one secondary filter active at a time, which matches how operators actually think ("show me tasks stuck on this step"). The trigger shows an active-count badge. Sort controls stayed as separate compact dropdowns because they are used constantly and deserve one click.
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Filters
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-2">
{activeFilterCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={secondaryFilter ?? "none"}
onValueChange={(v) => setSecondaryFilter(v === "none" ? null : v)}
>
{SECONDARY_FILTER_OPTIONS.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
Before: [Step ▼] [Version ▼] [Progress ▼] [Updated ▼] [Task state ▼] [Sort: Updated ▼] [Newest ▼] [Clear]
After: [Filters (2)] [Sort: Updated ▼] [Newest first ▼] [Clear]
Same capability, half the visual noise. Operators who live in this screen immediately noticed the calmer header.
PR 4 — Conditional task state filter
The task state filter (paused / cancelled / active) always rendered three options even when every visible task was active. Empty filters teach users that the product is unfinished.
The dropdown now mounts only when at least one loaded task has a paused or cancelled overlay state. When data refreshes and no overlay tasks remain, the filter resets to "all" and the control hides itself. No dead options, no stale filter state pointing at zero rows.
PR 5 — Back button for batch detail (10 lines)
Users could open a bulk job detail view from the jobs list but had no in-app way back except the browser chrome. Ten lines: a back button in the detail header that navigates to the list route. Obvious in hindsight; absent for weeks because nobody filed it as a bug.
PR 6 — Label cleanup (14 lines)
One row action still said "Open A1"—internal jargon for the author assignment slot. The rest of the admin panel already said "Trainer" via shared ASSIGNMENT_ROLE_LABELS. This button was the last holdout. Renamed to "Assign Trainer" with a matching aria-label. Fourteen lines, zero functional change, meaningful clarity for new operators.
PR 7 — Remove arrow column (1 line)
Task tables included a trailing arrow icon implying "click to open." The entire row was already clickable. The column added visual clutter without affordance the row did not already provide. One line deleted from the column definition.
PR 8 — Stale cache on detail route
The batch detail view could show pipeline state from a server-side cache that lagged behind the live job. That fix is documented in a separate post; it belongs in the same sprint because stale detail pages feel like broken navigation even when the list view is correct. Removing the cache from the detail route aligned what operators see with what the job runner actually did.
The compounding effect
No single PR in this sprint is impressive on a slide deck. One line here, sixty there, two hundred in the registry. Together they change how the dashboard feels:
- The toolbar is scannable instead of crowded.
- Filters are extensible without archaeology through components.
- Search explains itself instead of failing silently.
- Navigation has explicit back affordances.
- Labels match the language the rest of the product uses.
- Dead UI—empty filters, redundant arrows— is gone.
The lesson for me was pacing: polish sprints work when each PR is reviewable in minutes and shippable without a feature flag. Big redesigns get deferred; small improvements ship.
The pattern — registry-driven UI
The filter registry is the most reusable artifact from the sprint. Instead of scattering option arrays and switch statements across components, declare each option as data with rendering metadata and a matching function. The UI maps over the registry; tests can assert matches() in isolation.
When product asks for another chip—"Blocked on review," "Stuck in export," a virtual bucket keyed off metadata—you add one object to LISTING_FILTER_REGISTRY. No new prop drilling, no duplicated color maps, no risk that the chip label and the filter predicate disagree.
That pattern generalizes beyond status chips: assignment roles, bulk action types, export formats—anywhere the admin panel presents a fixed set of choices with per-option behavior. Registries trade a few dozen lines upfront for predictable extension later.
What I would do again
I would schedule polish sprints earlier, before friction becomes "how the product works." I would default new listing filters to registry entries on day one rather than hardcoded arrays. I would treat toolbar density as a metric: if inline controls exceed four, consolidate.
Users rarely ask for a redesign. They ask why search feels broken, why they need the browser back button, why a button says "A1." Eight small PRs answered those questions. That is UX engineering: respect people's time, ship the obvious fix, and let the compound effect do the rest.