docs(04-ux-improvements): create phase plan

This commit is contained in:
2026-03-24 09:41:36 +01:00
parent 19d9724c9c
commit 81fc110224
4 changed files with 1293 additions and 2 deletions

View File

@@ -0,0 +1,411 @@
---
phase: 04-ux-improvements
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- frontend/src/main.tsx
- frontend/src/index.css
- frontend/src/components/ServiceCard.tsx
- frontend/src/components/FilterBar.tsx
- frontend/src/components/Header.tsx
- frontend/src/App.tsx
- frontend/src/lib/utils.ts
autonomous: true
requirements:
- SRCH-01
- SRCH-02
- SRCH-03
- SRCH-04
- A11Y-01
- A11Y-02
must_haves:
truths:
- "User can search updates by image name and results filter instantly"
- "User can filter updates by status (all/pending/acknowledged)"
- "User can filter updates by tag (all/specific tag/untagged)"
- "User can sort updates by date, name, or registry"
- "User can toggle between light and dark themes"
- "Theme preference persists across page reloads via localStorage"
- "System prefers-color-scheme is respected on first visit"
- "Drag handle is always visible on ServiceCard (not hover-only)"
artifacts:
- path: "frontend/src/components/FilterBar.tsx"
provides: "Search input + 3 filter/sort dropdowns"
min_lines: 40
- path: "frontend/src/main.tsx"
provides: "Theme initialization from localStorage + prefers-color-scheme"
- path: "frontend/src/App.tsx"
provides: "Filter state, filtered/sorted entries, FilterBar integration"
contains: "FilterBar"
- path: "frontend/src/components/Header.tsx"
provides: "Theme toggle button with sun/moon icon"
contains: "toggleTheme"
- path: "frontend/src/lib/utils.ts"
provides: "Shared getRegistry function"
contains: "export function getRegistry"
key_links:
- from: "frontend/src/App.tsx"
to: "frontend/src/components/FilterBar.tsx"
via: "FilterBar component with onChange callbacks"
pattern: "<FilterBar"
- from: "frontend/src/main.tsx"
to: "localStorage"
via: "theme init reads localStorage('theme')"
pattern: "localStorage.getItem.*theme"
- from: "frontend/src/components/Header.tsx"
to: "document.documentElement.classList"
via: "toggleTheme toggles dark class and writes localStorage"
pattern: "classList.toggle.*dark"
---
<objective>
Add client-side search/filter/sort controls, light/dark theme toggle, and fix the hover-only drag handle to be always visible.
Purpose: Makes the dashboard usable at scale (finding specific images) and accessible (theme choice, visible drag handles).
Output: New FilterBar component, theme toggle in Header, updated ServiceCard drag handle, filter logic in App.tsx.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-ux-improvements/04-CONTEXT.md
@.planning/phases/04-ux-improvements/04-RESEARCH.md
<interfaces>
From frontend/src/types/diun.ts:
```typescript
export interface Tag {
id: number
name: string
}
export interface UpdateEntry {
event: DiunEvent
received_at: string
acknowledged: boolean
tag: Tag | null
}
export type UpdatesMap = Record<string, UpdateEntry>
```
From frontend/src/App.tsx (current entries derivation):
```typescript
const entries = Object.entries(updates)
const taggedSections = tags.map(tag => ({
tag,
rows: entries
.filter(([, e]) => e.tag?.id === tag.id)
.map(([image, entry]) => ({ image, entry })),
}))
const untaggedRows = entries
.filter(([, e]) => !e.tag)
.map(([image, entry]) => ({ image, entry }))
```
From frontend/src/components/Header.tsx:
```typescript
interface HeaderProps {
onRefresh: () => void
}
```
From frontend/src/components/ServiceCard.tsx (drag handle - current opacity pattern):
```tsx
<button
{...attributes}
{...listeners}
className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing shrink-0 touch-none"
>
```
From frontend/src/lib/utils.ts:
```typescript
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
```
From frontend/src/main.tsx (current hardcoded dark mode):
```typescript
document.documentElement.classList.add('dark')
```
From frontend/src/index.css (CSS vars - note: no --destructive or --card defined):
```css
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* ... light theme vars ... */
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
/* ... dark theme vars ... */
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Theme toggle, drag handle fix, and shared getRegistry utility</name>
<files>frontend/src/main.tsx, frontend/src/index.css, frontend/src/components/Header.tsx, frontend/src/components/ServiceCard.tsx, frontend/src/lib/utils.ts</files>
<read_first>
- frontend/src/main.tsx
- frontend/src/index.css
- frontend/src/components/Header.tsx
- frontend/src/components/ServiceCard.tsx
- frontend/src/lib/utils.ts
</read_first>
<action>
1. **main.tsx** (per D-15): Replace `document.documentElement.classList.add('dark')` with theme initialization:
```typescript
const stored = localStorage.getItem('theme')
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
}
```
2. **index.css**: Add `--destructive` and `--destructive-foreground` CSS variables to both `:root` and `.dark` blocks (needed for destructive button variant used in Plan 03). Also add `--card` and `--card-foreground` if missing:
In `:root` block, add:
```css
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
```
In `.dark` block, add:
```css
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
```
3. **Header.tsx** (per D-14): Add theme toggle button. Import `Sun, Moon` from `lucide-react`. Add a `toggleTheme` function:
```typescript
function toggleTheme() {
const isDark = document.documentElement.classList.toggle('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
```
Add a second Button next to the refresh button:
```tsx
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
title="Toggle theme"
>
<Sun className="h-4 w-4 hidden dark:block" />
<Moon className="h-4 w-4 block dark:hidden" />
</Button>
```
Wrap both buttons in a `<div className="flex items-center gap-1">`.
4. **ServiceCard.tsx** (per D-16): Change the drag handle button's className from `opacity-0 group-hover:opacity-100` to `opacity-40 hover:opacity-100`. The full className becomes:
```
text-muted-foreground opacity-40 hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing shrink-0 touch-none
```
5. **lib/utils.ts**: Extract `getRegistry` function from ServiceCard.tsx and add it as a named export in utils.ts:
```typescript
export function getRegistry(image: string): string {
const parts = image.split('/')
if (parts.length === 1) return 'Docker Hub'
const first = parts[0]
if (!first.includes('.') && !first.includes(':') && first !== 'localhost') return 'Docker Hub'
if (first === 'ghcr.io') return 'GitHub'
if (first === 'gcr.io') return 'GCR'
return first
}
```
Then in ServiceCard.tsx, remove the local `getRegistry` function and add `import { getRegistry } from '@/lib/utils'` (alongside the existing `cn` import: `import { cn, getRegistry } from '@/lib/utils'`).
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- main.tsx contains `localStorage.getItem('theme')` and `prefers-color-scheme`
- main.tsx does NOT contain `classList.add('dark')` as a standalone statement (only inside the conditional)
- index.css `:root` block contains `--destructive: 0 84.2% 60.2%`
- index.css `.dark` block contains `--destructive: 0 62.8% 30.6%`
- Header.tsx contains `import` with `Sun` and `Moon`
- Header.tsx contains `toggleTheme`
- Header.tsx contains `localStorage.setItem('theme'`
- ServiceCard.tsx drag handle button contains `opacity-40 hover:opacity-100`
- ServiceCard.tsx does NOT contain `opacity-0 group-hover:opacity-100` on the drag handle
- lib/utils.ts contains `export function getRegistry`
- ServiceCard.tsx contains `import` with `getRegistry` from `@/lib/utils`
- `npx tsc --noEmit` exits 0
</acceptance_criteria>
<done>Theme toggle works (sun/moon icon in header, persists to localStorage, respects system preference on first visit); drag handle always visible at 40% opacity; getRegistry is a shared utility</done>
</task>
<task type="auto">
<name>Task 2: FilterBar component and client-side search/filter/sort logic in App.tsx</name>
<files>frontend/src/components/FilterBar.tsx, frontend/src/App.tsx</files>
<read_first>
- frontend/src/App.tsx
- frontend/src/types/diun.ts
- frontend/src/lib/utils.ts
- frontend/src/components/TagSection.tsx
</read_first>
<action>
1. **Create FilterBar.tsx** (per D-06, D-07): New component placed above sections list, below stats row. Uses native `<select>` elements styled with Tailwind (no Radix Select dependency). Props interface:
```typescript
interface FilterBarProps {
search: string
onSearchChange: (value: string) => void
statusFilter: 'all' | 'pending' | 'acknowledged'
onStatusFilterChange: (value: 'all' | 'pending' | 'acknowledged') => void
tagFilter: 'all' | 'untagged' | number
onTagFilterChange: (value: 'all' | 'untagged' | number) => void
sortOrder: 'date-desc' | 'date-asc' | 'name' | 'registry'
onSortOrderChange: (value: 'date-desc' | 'date-asc' | 'name' | 'registry') => void
tags: Tag[]
}
```
Layout: flex row with wrap, gap-3. Responsive: on small screens wraps to multiple rows.
- Search input: `<input type="text" placeholder="Search images..." />` with magnifying glass icon (import `Search` from lucide-react). Full width on mobile, `w-64` on desktop.
- Status select: options "All Status", "Pending", "Acknowledged"
- Tag select: options "All Tags", "Untagged", then one option per tag (tag.name, value=tag.id)
- Sort select: options "Newest First" (date-desc), "Oldest First" (date-asc), "Name A-Z" (name), "Registry" (registry)
Style all selects with: `h-9 rounded-md border border-border bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50`
Tag select onChange handler must parse value: `"all"` and `"untagged"` stay as strings, numeric values become `parseInt(value, 10)`.
2. **App.tsx** (per D-05, D-08): Add filter state and filtering logic.
Add imports:
```typescript
import { useMemo } from 'react'
import { FilterBar } from '@/components/FilterBar'
import { getRegistry } from '@/lib/utils'
```
Add filter state (per D-08 -- no persistence, resets on reload):
```typescript
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'acknowledged'>('all')
const [tagFilter, setTagFilter] = useState<'all' | 'untagged' | number>('all')
const [sortOrder, setSortOrder] = useState<'date-desc' | 'date-asc' | 'name' | 'registry'>('date-desc')
```
Replace the direct `entries` usage with a `filteredEntries` useMemo:
```typescript
const filteredEntries = useMemo(() => {
let result = Object.entries(updates) as [string, UpdateEntry][]
if (search) {
const q = search.toLowerCase()
result = result.filter(([image]) => image.toLowerCase().includes(q))
}
if (statusFilter === 'pending') result = result.filter(([, e]) => !e.acknowledged)
if (statusFilter === 'acknowledged') result = result.filter(([, e]) => e.acknowledged)
if (tagFilter === 'untagged') result = result.filter(([, e]) => !e.tag)
if (typeof tagFilter === 'number') result = result.filter(([, e]) => e.tag?.id === tagFilter)
result.sort(([ia, ea], [ib, eb]) => {
switch (sortOrder) {
case 'date-asc': return ea.received_at < eb.received_at ? -1 : 1
case 'name': return ia.localeCompare(ib)
case 'registry': return getRegistry(ia).localeCompare(getRegistry(ib))
default: return ea.received_at > eb.received_at ? -1 : 1
}
})
return result
}, [updates, search, statusFilter, tagFilter, sortOrder])
```
Update stats to use `entries` (unfiltered) for total counts but `filteredEntries` for display. The `pending` and `acknowledgedCount` and `lastReceived` remain computed from the unfiltered `entries` (dashboard stats always show global counts).
Update `taggedSections` and `untaggedRows` derivation to use `filteredEntries` instead of `entries`:
```typescript
const taggedSections = tags.map(tag => ({
tag,
rows: filteredEntries
.filter(([, e]) => e.tag?.id === tag.id)
.map(([image, entry]) => ({ image, entry })),
}))
const untaggedRows = filteredEntries
.filter(([, e]) => !e.tag)
.map(([image, entry]) => ({ image, entry }))
```
Add `<FilterBar>` in the JSX between the stats grid and the loading state, wrapped in `{!loading && entries.length > 0 && (...)}`:
```tsx
{!loading && entries.length > 0 && (
<FilterBar
search={search}
onSearchChange={setSearch}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
tagFilter={tagFilter}
onTagFilterChange={setTagFilter}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
tags={tags}
/>
)}
```
Import `UpdateEntry` type if needed for the `as` cast.
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit && bun run build</automated>
</verify>
<acceptance_criteria>
- FilterBar.tsx exists and exports `FilterBar` component
- FilterBar.tsx contains `Search images` (placeholder text)
- FilterBar.tsx contains `<select` elements (native selects, not Radix)
- FilterBar.tsx contains `All Status` and `Pending` and `Acknowledged` as option labels
- FilterBar.tsx contains `Newest First` and `Name A-Z` as option labels
- App.tsx contains `import { FilterBar }` from `@/components/FilterBar`
- App.tsx contains `const [search, setSearch] = useState`
- App.tsx contains `const [statusFilter, setStatusFilter] = useState`
- App.tsx contains `const [sortOrder, setSortOrder] = useState`
- App.tsx contains `useMemo` for filteredEntries
- App.tsx contains `<FilterBar` JSX element
- App.tsx taggedSections uses `filteredEntries` (not raw `entries`)
- `bun run build` exits 0
</acceptance_criteria>
<done>FilterBar renders above sections; searching by image name filters instantly; status/tag/sort dropdowns work; default sort is newest-first; filters reset on page reload</done>
</task>
</tasks>
<verification>
```bash
cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend
npx tsc --noEmit
bun run build
```
</verification>
<success_criteria>
- FilterBar component renders search input and 3 dropdowns
- Filtering by image name is case-insensitive substring match
- Status filter shows only pending or acknowledged updates
- Tag filter shows only updates in a specific tag or untagged
- Sort order changes entry display order
- Theme toggle button visible in header
- Theme persists in localStorage
- First visit respects prefers-color-scheme
- Drag handle visible at 40% opacity without hover
- Frontend builds without errors
</success_criteria>
<output>
After completion, create `.planning/phases/04-ux-improvements/04-02-SUMMARY.md`
</output>