---
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 (
)
}
```
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