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

21 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 03 execute 2
04-01
04-02
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
true
BULK-01
BULK-02
INDIC-01
INDIC-02
INDIC-03
INDIC-04
truths artifacts key_links
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
path provides contains
frontend/src/hooks/useUpdates.ts acknowledgeAll, acknowledgeByTag callbacks; newArrivals state; tab title effect acknowledgeAll
path provides contains
frontend/src/components/Header.tsx Pending badge, dismiss-all button with confirm pendingCount
path provides contains
frontend/src/components/TagSection.tsx Per-group dismiss button onAcknowledgeGroup
path provides min_lines
frontend/src/components/Toast.tsx Custom toast notification component 20
path provides contains
frontend/src/components/ServiceCard.tsx New-since-last-visit highlight via isNewSinceLastVisit prop isNewSinceLastVisit
path provides contains
frontend/src/App.tsx Wiring: bulk callbacks, toast state, lastVisit ref, tab title, new props acknowledgeAll
from to via pattern
frontend/src/hooks/useUpdates.ts /api/updates/acknowledge-all fetch POST in acknowledgeAll callback fetch.*acknowledge-all
from to via pattern
frontend/src/hooks/useUpdates.ts /api/updates/acknowledge-by-tag fetch POST in acknowledgeByTag callback fetch.*acknowledge-by-tag
from to via pattern
frontend/src/App.tsx frontend/src/components/Header.tsx pendingCount and onDismissAll props pendingCount=|onDismissAll=
from to via pattern
frontend/src/App.tsx frontend/src/components/TagSection.tsx onAcknowledgeGroup prop onAcknowledgeGroup=
from to via pattern
frontend/src/App.tsx frontend/src/components/ServiceCard.tsx isNewSinceLastVisit prop passed through TagSection 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.

<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 @.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):

interface HeaderProps {
  onRefresh: () => void
}
// Header now has theme toggle button, refresh button

From frontend/src/hooks/useUpdates.ts:

export function useUpdates() {
  // Returns: updates, loading, error, lastRefreshed, secondsUntilRefresh, fetchUpdates, acknowledge, assignTag
}

From frontend/src/components/TagSection.tsx:

interface TagSectionProps {
  tag: Tag | null
  rows: TagSectionRow[]
  onAcknowledge: (image: string) => void
  onDeleteTag?: (id: number) => void
}

From frontend/src/components/ServiceCard.tsx:

interface ServiceCardProps {
  image: string
  entry: UpdateEntry
  onAcknowledge: (image: string) => void
}

From frontend/src/App.tsx (after Plan 02):

// 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<Set<string>>(new Set())
   const [newArrivals, setNewArrivals] = useState<string[]>([])

   // 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 (
       <div className="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg border border-border bg-card px-4 py-3 shadow-lg flex items-center gap-3">
         <p className="text-sm flex-1">{message}</p>
         <button
           onClick={onDismiss}
           className="text-muted-foreground hover:text-foreground text-xs font-medium shrink-0"
         >
           Dismiss
         </button>
       </div>
     )
   }
   ```

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 && (
     <Badge variant="secondary" className="text-xs font-bold px-2 py-0.5 bg-amber-500/15 text-amber-500 border-amber-500/25">
       {pendingCount}
     </Badge>
   )}
   ```
   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 && (
     <Button
       variant="ghost"
       size="sm"
       onClick={() => {
         if (!confirmDismissAll) { setConfirmDismissAll(true); return }
         onDismissAll()
         setConfirmDismissAll(false)
       }}
       onBlur={() => setConfirmDismissAll(false)}
       className={cn(
         'h-8 px-3 text-xs font-medium',
         confirmDismissAll
           ? 'text-destructive hover:bg-destructive/10'
           : 'text-muted-foreground hover:text-foreground'
       )}
     >
       <CheckCheck className="h-3.5 w-3.5 mr-1" />
       {confirmDismissAll ? 'Sure? Dismiss all' : 'Dismiss All'}
     </Button>
   )}
   ```
   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 && (
     <button
       onClick={() => {
         if (!confirmDismissGroup) { setConfirmDismissGroup(true); return }
         onAcknowledgeGroup(tag.id)
         setConfirmDismissGroup(false)
       }}
       onBlur={() => setConfirmDismissGroup(false)}
       className={cn(
         'flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-colors',
         confirmDismissGroup
           ? 'text-destructive hover:bg-destructive/10'
           : 'text-muted-foreground hover:text-foreground'
       )}
     >
       <CheckCheck className="h-3.5 w-3.5" />
       {confirmDismissGroup ? 'Sure?' : 'Dismiss Group'}
     </button>
   )}
   ```
   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<string | null>(
     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
   <ServiceCard
     key={image}
     image={image}
     entry={entry}
     onAcknowledge={onAcknowledge}
     isNewSinceLastVisit={isNew}
   />
   ```
   Update the destructuring in the `.map()`: `{rows.map(({ image, entry, isNew }) => (`

   g. Update Header props:
   ```tsx
   <Header onRefresh={fetchUpdates} pendingCount={pending} onDismissAll={acknowledgeAll} />
   ```

   h. Update TagSection props to include `onAcknowledgeGroup`:
   ```tsx
   <TagSection
     key={tag.id}
     tag={tag}
     rows={taggedSections_rows}
     onAcknowledge={acknowledge}
     onDeleteTag={deleteTag}
     onAcknowledgeGroup={acknowledgeByTag}
   />
   ```

   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 `<Toast message={toastMessage} onDismiss={clearNewArrivals} />` at the end of the root div, before the closing `</div>`.

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

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/04-ux-improvements/04-03-SUMMARY.md`