Files
DiunDashboard/.planning/phases/04-ux-improvements/04-02-PLAN.md

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-ux-improvements 02 execute 1
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
true
SRCH-01
SRCH-02
SRCH-03
SRCH-04
A11Y-01
A11Y-02
truths artifacts key_links
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)
path provides min_lines
frontend/src/components/FilterBar.tsx Search input + 3 filter/sort dropdowns 40
path provides
frontend/src/main.tsx Theme initialization from localStorage + prefers-color-scheme
path provides contains
frontend/src/App.tsx Filter state, filtered/sorted entries, FilterBar integration FilterBar
path provides contains
frontend/src/components/Header.tsx Theme toggle button with sun/moon icon toggleTheme
path provides contains
frontend/src/lib/utils.ts Shared getRegistry function export function getRegistry
from to via pattern
frontend/src/App.tsx frontend/src/components/FilterBar.tsx FilterBar component with onChange callbacks <FilterBar
from to via pattern
frontend/src/main.tsx localStorage theme init reads localStorage('theme') localStorage.getItem.*theme
from to via pattern
frontend/src/components/Header.tsx document.documentElement.classList toggleTheme toggles dark class and writes localStorage classList.toggle.*dark
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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 ```

From frontend/src/App.tsx (current entries derivation):

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:

interface HeaderProps {
  onRefresh: () => void
}

From frontend/src/components/ServiceCard.tsx (drag handle - current opacity pattern):

<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:

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

From frontend/src/main.tsx (current hardcoded dark mode):

document.documentElement.classList.add('dark')

From frontend/src/index.css (CSS vars - note: no --destructive or --card defined):

: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 ... */
}
Task 1: Theme toggle, drag handle fix, and shared getRegistry utility frontend/src/main.tsx, frontend/src/index.css, frontend/src/components/Header.tsx, frontend/src/components/ServiceCard.tsx, frontend/src/lib/utils.ts - frontend/src/main.tsx - frontend/src/index.css - frontend/src/components/Header.tsx - frontend/src/components/ServiceCard.tsx - frontend/src/lib/utils.ts 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'`).
cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit - 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 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 Task 2: FilterBar component and client-side search/filter/sort logic in App.tsx frontend/src/components/FilterBar.tsx, frontend/src/App.tsx - frontend/src/App.tsx - frontend/src/types/diun.ts - frontend/src/lib/utils.ts - frontend/src/components/TagSection.tsx 1. **Create FilterBar.tsx** (per D-06, D-07): New component placed above sections list, below stats row. Uses native `` 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. cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit && bun run build - FilterBar.tsx exists and exports `FilterBar` component - FilterBar.tsx contains `Search images` (placeholder text) - FilterBar.tsx contains ` 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 ```bash cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend npx tsc --noEmit bun run build ``` <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> After completion, create `.planning/phases/04-ux-improvements/04-02-SUMMARY.md`