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