`.
+
+ 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 `
+
+ 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 `
+
+
+
+
+```bash
+cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend
+npx tsc --noEmit
+bun run build
+```
+
+
+
+- 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
+
+
+
diff --git a/.planning/phases/04-ux-improvements/04-03-PLAN.md b/.planning/phases/04-ux-improvements/04-03-PLAN.md
new file mode 100644
index 0000000..08a87fc
--- /dev/null
+++ b/.planning/phases/04-ux-improvements/04-03-PLAN.md
@@ -0,0 +1,558 @@
+---
+phase: 04-ux-improvements
+plan: 03
+type: execute
+wave: 2
+depends_on:
+ - 04-01
+ - 04-02
+files_modified:
+ - frontend/src/hooks/useUpdates.ts
+ - frontend/src/components/Header.tsx
+ - frontend/src/components/TagSection.tsx
+ - frontend/src/components/ServiceCard.tsx
+ - frontend/src/components/Toast.tsx
+ - frontend/src/App.tsx
+autonomous: true
+requirements:
+ - BULK-01
+ - BULK-02
+ - INDIC-01
+ - INDIC-02
+ - INDIC-03
+ - INDIC-04
+
+must_haves:
+ truths:
+ - "User can dismiss all pending updates with a Dismiss All button in the header area"
+ - "User can dismiss all pending updates within a tag group via a per-section button"
+ - "Dismiss All requires a two-click confirmation before executing"
+ - "A pending-count badge is always visible in the Header"
+ - "The browser tab title shows 'DiunDash (N)' when N > 0 and 'DiunDash' when 0"
+ - "A toast notification appears when new updates arrive during polling"
+ - "Updates received since the user's last visit have a visible amber left border highlight"
+ artifacts:
+ - path: "frontend/src/hooks/useUpdates.ts"
+ provides: "acknowledgeAll, acknowledgeByTag callbacks; newArrivals state; tab title effect"
+ contains: "acknowledgeAll"
+ - path: "frontend/src/components/Header.tsx"
+ provides: "Pending badge, dismiss-all button with confirm"
+ contains: "pendingCount"
+ - path: "frontend/src/components/TagSection.tsx"
+ provides: "Per-group dismiss button"
+ contains: "onAcknowledgeGroup"
+ - path: "frontend/src/components/Toast.tsx"
+ provides: "Custom toast notification component"
+ min_lines: 20
+ - path: "frontend/src/components/ServiceCard.tsx"
+ provides: "New-since-last-visit highlight via isNewSinceLastVisit prop"
+ contains: "isNewSinceLastVisit"
+ - path: "frontend/src/App.tsx"
+ provides: "Wiring: bulk callbacks, toast state, lastVisit ref, tab title, new props"
+ contains: "acknowledgeAll"
+ key_links:
+ - from: "frontend/src/hooks/useUpdates.ts"
+ to: "/api/updates/acknowledge-all"
+ via: "fetch POST in acknowledgeAll callback"
+ pattern: "fetch.*acknowledge-all"
+ - from: "frontend/src/hooks/useUpdates.ts"
+ to: "/api/updates/acknowledge-by-tag"
+ via: "fetch POST in acknowledgeByTag callback"
+ pattern: "fetch.*acknowledge-by-tag"
+ - from: "frontend/src/App.tsx"
+ to: "frontend/src/components/Header.tsx"
+ via: "pendingCount and onDismissAll props"
+ pattern: "pendingCount=|onDismissAll="
+ - from: "frontend/src/App.tsx"
+ to: "frontend/src/components/TagSection.tsx"
+ via: "onAcknowledgeGroup prop"
+ pattern: "onAcknowledgeGroup="
+ - from: "frontend/src/App.tsx"
+ to: "frontend/src/components/ServiceCard.tsx"
+ via: "isNewSinceLastVisit prop passed through TagSection"
+ pattern: "isNewSinceLastVisit"
+---
+
+
+Wire bulk dismiss UI (frontend) to the backend endpoints from Plan 01, add update indicators (pending badge, tab title, toast, new-since-last-visit highlight).
+
+Purpose: Completes the UX improvements by giving users bulk actions and visual awareness of new updates.
+Output: Updated useUpdates hook with bulk callbacks and toast detection, Header with badge + dismiss-all, TagSection with per-group dismiss, Toast component, ServiceCard with highlight.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.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
+@.planning/phases/04-ux-improvements/04-01-SUMMARY.md
+@.planning/phases/04-ux-improvements/04-02-SUMMARY.md
+
+
+
+POST /api/updates/acknowledge-all -> {"count": N}
+POST /api/updates/acknowledge-by-tag (body: {"tag_id": N}) -> {"count": N}
+
+
+From frontend/src/components/Header.tsx (after Plan 02):
+```typescript
+interface HeaderProps {
+ onRefresh: () => void
+}
+// Header now has theme toggle button, refresh button
+```
+
+From frontend/src/hooks/useUpdates.ts:
+```typescript
+export function useUpdates() {
+ // Returns: updates, loading, error, lastRefreshed, secondsUntilRefresh, fetchUpdates, acknowledge, assignTag
+}
+```
+
+From frontend/src/components/TagSection.tsx:
+```typescript
+interface TagSectionProps {
+ tag: Tag | null
+ rows: TagSectionRow[]
+ onAcknowledge: (image: string) => void
+ onDeleteTag?: (id: number) => void
+}
+```
+
+From frontend/src/components/ServiceCard.tsx:
+```typescript
+interface ServiceCardProps {
+ image: string
+ entry: UpdateEntry
+ onAcknowledge: (image: string) => void
+}
+```
+
+From frontend/src/App.tsx (after Plan 02):
+```typescript
+// Has: filteredEntries useMemo, FilterBar, filter state
+// Uses: useUpdates() destructured for updates, acknowledge, etc.
+// Stats: pending, acknowledgedCount computed from unfiltered entries
+```
+
+
+
+
+
+
+ Task 1: Extend useUpdates with bulk acknowledge callbacks, toast detection, and tab title effect
+ frontend/src/hooks/useUpdates.ts
+
+ - frontend/src/hooks/useUpdates.ts
+ - frontend/src/types/diun.ts
+
+
+ 1. **Add acknowledgeAll callback** (per D-01, D-02) using optimistic update pattern matching existing `acknowledge`:
+ ```typescript
+ const acknowledgeAll = useCallback(async () => {
+ setUpdates(prev =>
+ Object.fromEntries(
+ Object.entries(prev).map(([img, entry]) => [
+ img,
+ entry.acknowledged ? entry : { ...entry, acknowledged: true },
+ ])
+ ) as UpdatesMap
+ )
+ try {
+ await fetch('/api/updates/acknowledge-all', { method: 'POST' })
+ } catch (e) {
+ console.error('acknowledgeAll failed:', e)
+ fetchUpdates()
+ }
+ }, [fetchUpdates])
+ ```
+
+ 2. **Add acknowledgeByTag callback** (per D-01, D-02):
+ ```typescript
+ const acknowledgeByTag = useCallback(async (tagID: number) => {
+ setUpdates(prev =>
+ Object.fromEntries(
+ Object.entries(prev).map(([img, entry]) => [
+ img,
+ entry.tag?.id === tagID && !entry.acknowledged
+ ? { ...entry, acknowledged: true }
+ : entry,
+ ])
+ ) as UpdatesMap
+ )
+ try {
+ await fetch('/api/updates/acknowledge-by-tag', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tag_id: tagID }),
+ })
+ } catch (e) {
+ console.error('acknowledgeByTag failed:', e)
+ fetchUpdates()
+ }
+ }, [fetchUpdates])
+ ```
+
+ 3. **Add toast detection** (per D-11): Track previous update keys with a ref. After each successful fetch, compare new keys vs previous. Only fire after initial load (guard: `prevKeysRef.current.size > 0`). State is `newArrivals: string[]`, replaced (not appended) each time.
+ ```typescript
+ const prevKeysRef = useRef>(new Set())
+ const [newArrivals, setNewArrivals] = useState([])
+
+ // Inside fetchUpdates, after setUpdates(data):
+ const currentKeys = Object.keys(data)
+ const newKeys = currentKeys.filter(k => !prevKeysRef.current.has(k))
+ if (newKeys.length > 0 && prevKeysRef.current.size > 0) {
+ setNewArrivals(newKeys)
+ }
+ prevKeysRef.current = new Set(currentKeys)
+ ```
+
+ Add a `clearNewArrivals` callback:
+ ```typescript
+ const clearNewArrivals = useCallback(() => setNewArrivals([]), [])
+ ```
+
+ 4. **Update return value** to include new fields:
+ ```typescript
+ return {
+ updates, loading, error, lastRefreshed, secondsUntilRefresh,
+ fetchUpdates, acknowledge, assignTag,
+ acknowledgeAll, acknowledgeByTag,
+ newArrivals, clearNewArrivals,
+ }
+ ```
+
+
+ cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit
+
+
+ - useUpdates.ts contains `const acknowledgeAll = useCallback`
+ - useUpdates.ts contains `fetch('/api/updates/acknowledge-all'`
+ - useUpdates.ts contains `const acknowledgeByTag = useCallback`
+ - useUpdates.ts contains `fetch('/api/updates/acknowledge-by-tag'`
+ - useUpdates.ts contains `const prevKeysRef = useRef>`
+ - useUpdates.ts contains `const [newArrivals, setNewArrivals] = useState`
+ - useUpdates.ts contains `clearNewArrivals` in the return object
+ - useUpdates.ts return object includes `acknowledgeAll` and `acknowledgeByTag`
+ - `npx tsc --noEmit` exits 0
+
+ useUpdates hook returns acknowledgeAll, acknowledgeByTag, newArrivals, and clearNewArrivals; toast detection fires on new images during polling
+
+
+
+ Task 2: Toast component, Header updates, TagSection per-group dismiss, ServiceCard highlight, and App.tsx wiring
+ frontend/src/components/Toast.tsx, frontend/src/components/Header.tsx, frontend/src/components/TagSection.tsx, frontend/src/components/ServiceCard.tsx, frontend/src/App.tsx
+
+ - frontend/src/App.tsx
+ - frontend/src/components/Header.tsx
+ - frontend/src/components/TagSection.tsx
+ - frontend/src/components/ServiceCard.tsx
+ - frontend/src/hooks/useUpdates.ts
+ - frontend/src/types/diun.ts
+
+
+ 1. **Create Toast.tsx** (per D-11): Custom toast component. Auto-dismiss after 5 seconds. Non-stacking (shows latest message only). Props:
+ ```typescript
+ interface ToastProps {
+ message: string
+ onDismiss: () => void
+ }
+ ```
+ Implementation: fixed position bottom-right (`fixed bottom-4 right-4 z-50`), dark card style, shows message + X dismiss button. Uses `useEffect` with a 5-second `setTimeout` that calls `onDismiss`. Renders `null` if `message` is empty.
+ ```tsx
+ export function Toast({ message, onDismiss }: ToastProps) {
+ useEffect(() => {
+ const timer = setTimeout(onDismiss, 5000)
+ return () => clearTimeout(timer)
+ }, [message, onDismiss])
+
+ if (!message) return null
+
+ return (
+
+
{message}
+
+
+ )
+ }
+ ```
+
+ 2. **Header.tsx** (per D-03, D-04, D-09): Extend HeaderProps and add pending badge + dismiss-all button.
+ Update the interface:
+ ```typescript
+ interface HeaderProps {
+ onRefresh: () => void
+ pendingCount: number
+ onDismissAll: () => void
+ }
+ ```
+ Add `Badge` import from `@/components/ui/badge`. Add `CheckCheck` import from `lucide-react`.
+ After "Diun Dashboard" title span, add the pending badge (per D-09):
+ ```tsx
+ {pendingCount > 0 && (
+
+ {pendingCount}
+
+ )}
+ ```
+ Add dismiss-all button with two-click confirm pattern (per D-04, matching existing tag delete pattern in TagSection). Add local state `const [confirmDismissAll, setConfirmDismissAll] = useState(false)`. The button:
+ ```tsx
+ {pendingCount > 0 && (
+
+ )}
+ ```
+ Import `useState` from react and `cn` from `@/lib/utils`.
+
+ 3. **TagSection.tsx** (per D-03): Add optional `onAcknowledgeGroup` prop. Update interface:
+ ```typescript
+ interface TagSectionProps {
+ tag: Tag | null
+ rows: TagSectionRow[]
+ onAcknowledge: (image: string) => void
+ onDeleteTag?: (id: number) => void
+ onAcknowledgeGroup?: (tagId: number) => void
+ }
+ ```
+ Add a "Dismiss Group" button in the section header, next to the delete button, only when `tag !== null` and `onAcknowledgeGroup` is provided and at least one row is unacknowledged. Use two-click confirm pattern:
+ ```typescript
+ const [confirmDismissGroup, setConfirmDismissGroup] = useState(false)
+ const hasPending = rows.some(r => !r.entry.acknowledged)
+ ```
+ Button (placed before the delete button):
+ ```tsx
+ {tag && onAcknowledgeGroup && hasPending && (
+
+ )}
+ ```
+ Import `CheckCheck` from `lucide-react`.
+
+ 4. **ServiceCard.tsx** (per D-12, D-13): Add `isNewSinceLastVisit` prop. Update interface:
+ ```typescript
+ interface ServiceCardProps {
+ image: string
+ entry: UpdateEntry
+ onAcknowledge: (image: string) => void
+ isNewSinceLastVisit?: boolean
+ }
+ ```
+ Update the outer div's className to include highlight when `isNewSinceLastVisit`:
+ ```tsx
+ className={cn(
+ 'group p-4 rounded-xl border border-border bg-card hover:border-muted-foreground/30 transition-all flex flex-col justify-between gap-4',
+ isNewSinceLastVisit && 'border-l-4 border-l-amber-500',
+ isDragging && 'opacity-30',
+ )}
+ ```
+
+ 5. **App.tsx**: Wire everything together.
+
+ a. Destructure new values from useUpdates:
+ ```typescript
+ const {
+ updates, loading, error, lastRefreshed, secondsUntilRefresh,
+ fetchUpdates, acknowledge, assignTag,
+ acknowledgeAll, acknowledgeByTag,
+ newArrivals, clearNewArrivals,
+ } = useUpdates()
+ ```
+
+ b. Add tab title effect (per D-10):
+ ```typescript
+ useEffect(() => {
+ document.title = pending > 0 ? `DiunDash (${pending})` : 'DiunDash'
+ }, [pending])
+ ```
+ Add `useEffect` to the React import.
+
+ c. Add last-visit tracking (per D-12):
+ ```typescript
+ const lastVisitRef = useRef(
+ localStorage.getItem('lastVisitTimestamp')
+ )
+
+ useEffect(() => {
+ const handler = () => localStorage.setItem('lastVisitTimestamp', new Date().toISOString())
+ window.addEventListener('beforeunload', handler)
+ return () => window.removeEventListener('beforeunload', handler)
+ }, [])
+ ```
+
+ d. Compute `isNewSinceLastVisit` per entry when building rows. Create a helper:
+ ```typescript
+ function isNewSince(receivedAt: string): boolean {
+ return lastVisitRef.current ? receivedAt > lastVisitRef.current : false
+ }
+ ```
+
+ e. Update taggedSections and untaggedRows to include `isNewSinceLastVisit`:
+ ```typescript
+ const taggedSections = tags.map(tag => ({
+ tag,
+ rows: filteredEntries
+ .filter(([, e]) => e.tag?.id === tag.id)
+ .map(([image, entry]) => ({ image, entry, isNew: isNewSince(entry.received_at) })),
+ }))
+ const untaggedRows = filteredEntries
+ .filter(([, e]) => !e.tag)
+ .map(([image, entry]) => ({ image, entry, isNew: isNewSince(entry.received_at) }))
+ ```
+
+ f. Update TagSectionRow type import in TagSection.tsx or define the `isNew` property. Actually, keep `TagSectionRow` unchanged and pass `isNewSinceLastVisit` through the ServiceCard render. In TagSection.tsx, update `TagSectionRow`:
+ ```typescript
+ export interface TagSectionRow {
+ image: string
+ entry: UpdateEntry
+ isNew?: boolean
+ }
+ ```
+ And in TagSection's ServiceCard render:
+ ```tsx
+
+ ```
+ Update the destructuring in the `.map()`: `{rows.map(({ image, entry, isNew }) => (`
+
+ g. Update Header props:
+ ```tsx
+
+ ```
+
+ h. Update TagSection props to include `onAcknowledgeGroup`:
+ ```tsx
+
+ ```
+
+ i. Add toast rendering and import:
+ ```typescript
+ import { Toast } from '@/components/Toast'
+ ```
+ Compute toast message from `newArrivals`:
+ ```typescript
+ const toastMessage = newArrivals.length > 0
+ ? newArrivals.length === 1
+ ? `New update: ${newArrivals[0]}`
+ : `${newArrivals.length} new updates arrived`
+ : ''
+ ```
+ Add `` at the end of the root div, before the closing ` `.
+
+ j. Import `useEffect` if not already imported (it should be from Plan 02 adding useMemo -- check). The import line should be:
+ ```typescript
+ import React, { useState, useRef, useEffect, useMemo } from 'react'
+ ```
+