diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 31a9d90..0dcac9d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -75,9 +75,14 @@ Plans: 4. A badge/counter showing pending update count is always visible; the browser tab title reflects it (e.g., "DiunDash (3)") 5. New updates arriving during active polling trigger a visible in-page toast, and updates seen for the first time since the user's last visit are visually highlighted 6. The light/dark theme toggle is available and respects system preference; the drag handle for tag reordering is always visible without hover -**Plans**: TBD +**Plans**: 3 plans **UI hint**: yes +Plans: +- [ ] 04-01-PLAN.md — Backend bulk dismiss: extend Store interface with AcknowledgeAll + AcknowledgeByTag, implement in both stores, add HTTP handlers and tests +- [ ] 04-02-PLAN.md — Frontend search/filter/sort controls, theme toggle, drag handle visibility fix +- [ ] 04-03-PLAN.md — Frontend bulk dismiss UI, update indicators (badge, tab title, toast, new-since-last-visit highlight) + ## Progress **Execution Order:** @@ -88,4 +93,4 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 | 1. Data Integrity | 0/2 | Not started | - | | 2. Backend Refactor | 2/2 | Complete | 2026-03-24 | | 3. PostgreSQL Support | 0/2 | Not started | - | -| 4. UX Improvements | 0/? | Not started | - | +| 4. UX Improvements | 0/3 | Not started | - | diff --git a/.planning/phases/04-ux-improvements/04-01-PLAN.md b/.planning/phases/04-ux-improvements/04-01-PLAN.md new file mode 100644 index 0000000..e03f459 --- /dev/null +++ b/.planning/phases/04-ux-improvements/04-01-PLAN.md @@ -0,0 +1,317 @@ +--- +phase: 04-ux-improvements +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - pkg/diunwebhook/store.go + - pkg/diunwebhook/sqlite_store.go + - pkg/diunwebhook/postgres_store.go + - pkg/diunwebhook/diunwebhook.go + - pkg/diunwebhook/diunwebhook_test.go + - pkg/diunwebhook/export_test.go + - cmd/diunwebhook/main.go +autonomous: true +requirements: + - BULK-01 + - BULK-02 + +must_haves: + truths: + - "POST /api/updates/acknowledge-all marks all unacknowledged updates and returns the count" + - "POST /api/updates/acknowledge-by-tag marks only unacknowledged updates in the given tag and returns the count" + - "Both endpoints return 200 with {count: 0} when nothing matches (not 404)" + artifacts: + - path: "pkg/diunwebhook/store.go" + provides: "Extended Store interface with AcknowledgeAll and AcknowledgeByTag" + contains: "AcknowledgeAll" + - path: "pkg/diunwebhook/sqlite_store.go" + provides: "SQLiteStore bulk acknowledge implementations" + contains: "func (s *SQLiteStore) AcknowledgeAll" + - path: "pkg/diunwebhook/postgres_store.go" + provides: "PostgresStore bulk acknowledge implementations" + contains: "func (s *PostgresStore) AcknowledgeAll" + - path: "pkg/diunwebhook/diunwebhook.go" + provides: "HTTP handlers for bulk acknowledge endpoints" + contains: "AcknowledgeAllHandler" + - path: "cmd/diunwebhook/main.go" + provides: "Route registration for new endpoints" + contains: "/api/updates/acknowledge-all" + key_links: + - from: "cmd/diunwebhook/main.go" + to: "pkg/diunwebhook/diunwebhook.go" + via: "mux.HandleFunc registration" + pattern: "HandleFunc.*acknowledge" + - from: "pkg/diunwebhook/diunwebhook.go" + to: "pkg/diunwebhook/store.go" + via: "s.store.AcknowledgeAll() and s.store.AcknowledgeByTag()" + pattern: "s\\.store\\.Acknowledge(All|ByTag)" +--- + + +Add backend support for bulk acknowledge operations: acknowledge all pending updates at once, and acknowledge all pending updates within a specific tag group. + +Purpose: Enables the frontend (Plan 03) to offer "Dismiss All" and "Dismiss Group" buttons. +Output: Two new Store interface methods, implementations for both SQLite and PostgreSQL, two new HTTP handlers, route registrations, and tests. + + + +@$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 + + + +From pkg/diunwebhook/store.go: +```go +type Store interface { + UpsertEvent(event DiunEvent) error + GetUpdates() (map[string]UpdateEntry, error) + AcknowledgeUpdate(image string) (found bool, err error) + ListTags() ([]Tag, error) + CreateTag(name string) (Tag, error) + DeleteTag(id int) (found bool, err error) + AssignTag(image string, tagID int) error + UnassignTag(image string) error + TagExists(id int) (bool, error) +} +``` + +From pkg/diunwebhook/sqlite_store.go (AcknowledgeUpdate pattern to follow): +```go +func (s *SQLiteStore) AcknowledgeUpdate(image string) (found bool, err error) { + s.mu.Lock() + defer s.mu.Unlock() + res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = datetime('now') WHERE image = ?`, image) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} +``` + +From pkg/diunwebhook/postgres_store.go (same method, PostgreSQL dialect): +```go +func (s *PostgresStore) AcknowledgeUpdate(image string) (found bool, err error) { + res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = NOW() WHERE image = $1`, image) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} +``` + +From pkg/diunwebhook/diunwebhook.go (DismissHandler pattern to follow): +```go +func (s *Server) DismissHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // ... +} +``` + +Current route registration order in cmd/diunwebhook/main.go: +```go +mux.HandleFunc("/api/updates/", srv.DismissHandler) +mux.HandleFunc("/api/updates", srv.UpdatesHandler) +``` + + + + + + + Task 1: Extend Store interface and implement AcknowledgeAll + AcknowledgeByTag in both stores + pkg/diunwebhook/store.go, pkg/diunwebhook/sqlite_store.go, pkg/diunwebhook/postgres_store.go + + - pkg/diunwebhook/store.go + - pkg/diunwebhook/sqlite_store.go + - pkg/diunwebhook/postgres_store.go + + + - Test 1: AcknowledgeAll on empty DB returns count=0, no error + - Test 2: AcknowledgeAll with 3 unacknowledged updates returns count=3; subsequent GetUpdates shows all acknowledged + - Test 3: AcknowledgeAll with 2 unacknowledged + 1 already acknowledged returns count=2 + - Test 4: AcknowledgeByTag with valid tag_id returns count of matching unacknowledged updates in that tag + - Test 5: AcknowledgeByTag with non-existent tag_id returns count=0, no error + - Test 6: AcknowledgeByTag does not affect updates in other tags or untagged updates + + + 1. Add two methods to the Store interface in `store.go` (per D-01): + ```go + AcknowledgeAll() (count int, err error) + AcknowledgeByTag(tagID int) (count int, err error) + ``` + + 2. Implement in `sqlite_store.go` (following AcknowledgeUpdate pattern with mutex): + - `AcknowledgeAll`: `s.mu.Lock()`, `s.db.Exec("UPDATE updates SET acknowledged_at = datetime('now') WHERE acknowledged_at IS NULL")`, return `int(RowsAffected())` + - `AcknowledgeByTag`: `s.mu.Lock()`, `s.db.Exec("UPDATE updates SET acknowledged_at = datetime('now') WHERE acknowledged_at IS NULL AND image IN (SELECT image FROM tag_assignments WHERE tag_id = ?)", tagID)`, return `int(RowsAffected())` + + 3. Implement in `postgres_store.go` (no mutex, use NOW() and $1 positional param): + - `AcknowledgeAll`: `s.db.Exec("UPDATE updates SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL")`, return `int(RowsAffected())` + - `AcknowledgeByTag`: `s.db.Exec("UPDATE updates SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL AND image IN (SELECT image FROM tag_assignments WHERE tag_id = $1)", tagID)`, return `int(RowsAffected())` + + + cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./... + + + - store.go contains `AcknowledgeAll() (count int, err error)` in the Store interface + - store.go contains `AcknowledgeByTag(tagID int) (count int, err error)` in the Store interface + - sqlite_store.go contains `func (s *SQLiteStore) AcknowledgeAll() (int, error)` + - sqlite_store.go contains `func (s *SQLiteStore) AcknowledgeByTag(tagID int) (int, error)` + - sqlite_store.go AcknowledgeAll contains `s.mu.Lock()` + - sqlite_store.go AcknowledgeAll contains `WHERE acknowledged_at IS NULL` + - sqlite_store.go AcknowledgeByTag contains `SELECT image FROM tag_assignments WHERE tag_id = ?` + - postgres_store.go contains `func (s *PostgresStore) AcknowledgeAll() (int, error)` + - postgres_store.go contains `func (s *PostgresStore) AcknowledgeByTag(tagID int) (int, error)` + - postgres_store.go AcknowledgeByTag contains `$1` (positional param) + - `go build ./...` exits 0 + + Store interface extended with 2 new methods; both SQLiteStore and PostgresStore compile and implement the interface + + + + Task 2: Add HTTP handlers, route registration, and tests for bulk acknowledge endpoints + pkg/diunwebhook/diunwebhook.go, pkg/diunwebhook/diunwebhook_test.go, pkg/diunwebhook/export_test.go, cmd/diunwebhook/main.go + + - pkg/diunwebhook/diunwebhook.go + - pkg/diunwebhook/diunwebhook_test.go + - pkg/diunwebhook/export_test.go + - cmd/diunwebhook/main.go + + + - Test: POST /api/updates/acknowledge-all with no updates returns 200 + {"count":0} + - Test: POST /api/updates/acknowledge-all with 2 pending updates returns 200 + {"count":2} + - Test: GET /api/updates/acknowledge-all returns 405 + - Test: POST /api/updates/acknowledge-by-tag with valid tag_id returns 200 + {"count":N} + - Test: POST /api/updates/acknowledge-by-tag with tag_id=0 returns 400 + - Test: POST /api/updates/acknowledge-by-tag with missing body returns 400 + - Test: POST /api/updates/acknowledge-by-tag with non-existent tag returns 200 + {"count":0} + - Test: GET /api/updates/acknowledge-by-tag returns 405 + + + 1. Add `AcknowledgeAllHandler` to `diunwebhook.go` (per D-02): + ```go + func (s *Server) AcknowledgeAllHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + count, err := s.store.AcknowledgeAll() + if err != nil { + log.Printf("AcknowledgeAllHandler: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"count": count}) + } + ``` + + 2. Add `AcknowledgeByTagHandler` to `diunwebhook.go` (per D-02): + ```go + func (s *Server) AcknowledgeByTagHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + var req struct { + TagID int `json:"tag_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.TagID <= 0 { + http.Error(w, "bad request: tag_id required", http.StatusBadRequest) + return + } + count, err := s.store.AcknowledgeByTag(req.TagID) + if err != nil { + log.Printf("AcknowledgeByTagHandler: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"count": count}) + } + ``` + + 3. Register routes in `main.go` -- CRITICAL: new specific paths BEFORE the existing `/api/updates/` subtree pattern: + ```go + mux.HandleFunc("/api/updates/acknowledge-all", srv.AcknowledgeAllHandler) + mux.HandleFunc("/api/updates/acknowledge-by-tag", srv.AcknowledgeByTagHandler) + mux.HandleFunc("/api/updates/", srv.DismissHandler) // existing -- must remain after + mux.HandleFunc("/api/updates", srv.UpdatesHandler) // existing + ``` + + 4. Add test helper to `export_test.go`: + ```go + func (s *Server) TestCreateTag(name string) (Tag, error) { + return s.store.CreateTag(name) + } + func (s *Server) TestAssignTag(image string, tagID int) error { + return s.store.AssignTag(image, tagID) + } + ``` + + 5. Write tests in `diunwebhook_test.go` following the existing `Test_` naming convention. Use `NewTestServer()` for each test. Setup: use `TestUpsertEvent` to create events, `TestCreateTag` + `TestAssignTag` to setup tag assignments. + + 6. Also add the Vite dev proxy for the two new endpoints in `frontend/vite.config.ts` -- NOT needed, the existing proxy config already proxies all `/api` requests to `:8080`. + + + cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestAcknowledge(All|ByTag)Handler" ./pkg/diunwebhook/ + + + - diunwebhook.go contains `func (s *Server) AcknowledgeAllHandler(` + - diunwebhook.go contains `func (s *Server) AcknowledgeByTagHandler(` + - diunwebhook.go AcknowledgeAllHandler contains `r.Method != http.MethodPost` + - diunwebhook.go AcknowledgeByTagHandler contains `http.MaxBytesReader` + - diunwebhook.go AcknowledgeByTagHandler contains `req.TagID <= 0` + - main.go contains `"/api/updates/acknowledge-all"` BEFORE `"/api/updates/"` + - main.go contains `"/api/updates/acknowledge-by-tag"` BEFORE `"/api/updates/"` + - diunwebhook_test.go contains `TestAcknowledgeAllHandler_Empty` + - diunwebhook_test.go contains `TestAcknowledgeByTagHandler` + - `go test -run "TestAcknowledge" ./pkg/diunwebhook/` exits 0 + - `go vet ./...` exits 0 + + Both bulk acknowledge endpoints respond correctly; all new tests pass; route order verified + + + + + +```bash +cd /home/jean-luc-makiola/Development/projects/DiunDashboard +go build ./... +go vet ./... +go test -v -run "TestAcknowledge" ./pkg/diunwebhook/ +go test -v ./pkg/diunwebhook/ # all existing tests still pass +``` + + + +- Store interface has 11 methods (9 existing + 2 new) +- Both SQLiteStore and PostgresStore implement all 11 methods +- POST /api/updates/acknowledge-all returns 200 + {"count": N} +- POST /api/updates/acknowledge-by-tag returns 200 + {"count": N} +- All existing tests continue to pass +- Route registration order prevents DismissHandler from shadowing new endpoints + + + +After completion, create `.planning/phases/04-ux-improvements/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-ux-improvements/04-02-PLAN.md b/.planning/phases/04-ux-improvements/04-02-PLAN.md new file mode 100644 index 0000000..ed32f46 --- /dev/null +++ b/.planning/phases/04-ux-improvements/04-02-PLAN.md @@ -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: " +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. + + + +@$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 + + +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): +```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 + + ``` + Wrap both buttons in a `
`. + + 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 `` 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 `` in the JSX between the stats grid and the loading state, wrapped in `{!loading && entries.length > 0 && (...)}`: + ```tsx + {!loading && entries.length > 0 && ( + + )} + ``` + + 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 +``` + + + +- 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 + + + +After completion, create `.planning/phases/04-ux-improvements/04-02-SUMMARY.md` + 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' + ``` + + + cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit && bun run build + + + - Toast.tsx exists and exports `Toast` component + - Toast.tsx contains `setTimeout(onDismiss, 5000)` + - Toast.tsx contains `fixed bottom-4 right-4` + - Header.tsx contains `pendingCount` in HeaderProps interface + - Header.tsx contains `onDismissAll` in HeaderProps interface + - Header.tsx contains `confirmDismissAll` state + - Header.tsx contains `Sure? Dismiss all` text for confirm state + - Header.tsx contains `Badge` import + - TagSection.tsx contains `onAcknowledgeGroup` in TagSectionProps + - TagSection.tsx contains `confirmDismissGroup` state + - TagSection.tsx contains `Dismiss Group` text + - ServiceCard.tsx contains `isNewSinceLastVisit` in ServiceCardProps + - ServiceCard.tsx contains `border-l-4 border-l-amber-500` + - App.tsx contains `acknowledgeAll` and `acknowledgeByTag` destructured from useUpdates + - App.tsx contains `document.title` assignment with `DiunDash` + - App.tsx contains `lastVisitTimestamp` in localStorage calls + - App.tsx contains ` + Bulk dismiss buttons work (dismiss-all in header with two-click confirm, dismiss-group in each tag section); pending badge shows in header; tab title reflects count; toast appears for new arrivals; new-since-last-visit items have amber left border highlight + + + + + +```bash +cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend +npx tsc --noEmit +bun run build +# Full stack verification: +cd /home/jean-luc-makiola/Development/projects/DiunDashboard +go test -v ./pkg/diunwebhook/ +go build ./... +``` + + + +- Dismiss All button in header triggers POST /api/updates/acknowledge-all +- Per-group Dismiss Group button triggers POST /api/updates/acknowledge-by-tag with correct tag_id +- Both dismiss buttons use two-click confirmation +- Pending count badge visible in header when > 0 +- Browser tab title shows "DiunDash (N)" or "DiunDash" +- Toast appears at bottom-right when polling detects new images +- Toast auto-dismisses after 5 seconds +- New-since-last-visit updates have amber left border +- Frontend builds without TypeScript errors + + + +After completion, create `.planning/phases/04-ux-improvements/04-03-SUMMARY.md` +