docs(04-ux-improvements): create phase plan
This commit is contained in:
@@ -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 | - |
|
||||
|
||||
317
.planning/phases/04-ux-improvements/04-01-PLAN.md
Normal file
317
.planning/phases/04-ux-improvements/04-01-PLAN.md
Normal file
@@ -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)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
<!-- Store interface the executor must extend -->
|
||||
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)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Extend Store interface and implement AcknowledgeAll + AcknowledgeByTag in both stores</name>
|
||||
<files>pkg/diunwebhook/store.go, pkg/diunwebhook/sqlite_store.go, pkg/diunwebhook/postgres_store.go</files>
|
||||
<read_first>
|
||||
- pkg/diunwebhook/store.go
|
||||
- pkg/diunwebhook/sqlite_store.go
|
||||
- pkg/diunwebhook/postgres_store.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
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())`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./...</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>Store interface extended with 2 new methods; both SQLiteStore and PostgresStore compile and implement the interface</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Add HTTP handlers, route registration, and tests for bulk acknowledge endpoints</name>
|
||||
<files>pkg/diunwebhook/diunwebhook.go, pkg/diunwebhook/diunwebhook_test.go, pkg/diunwebhook/export_test.go, cmd/diunwebhook/main.go</files>
|
||||
<read_first>
|
||||
- pkg/diunwebhook/diunwebhook.go
|
||||
- pkg/diunwebhook/diunwebhook_test.go
|
||||
- pkg/diunwebhook/export_test.go
|
||||
- cmd/diunwebhook/main.go
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
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<Handler>_<Scenario>` 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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -run "TestAcknowledge(All|ByTag)Handler" ./pkg/diunwebhook/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>Both bulk acknowledge endpoints respond correctly; all new tests pass; route order verified</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```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
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-ux-improvements/04-01-SUMMARY.md`
|
||||
</output>
|
||||
411
.planning/phases/04-ux-improvements/04-02-PLAN.md
Normal file
411
.planning/phases/04-ux-improvements/04-02-PLAN.md
Normal file
@@ -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: "<FilterBar"
|
||||
- from: "frontend/src/main.tsx"
|
||||
to: "localStorage"
|
||||
via: "theme init reads localStorage('theme')"
|
||||
pattern: "localStorage.getItem.*theme"
|
||||
- from: "frontend/src/components/Header.tsx"
|
||||
to: "document.documentElement.classList"
|
||||
via: "toggleTheme toggles dark class and writes localStorage"
|
||||
pattern: "classList.toggle.*dark"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
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<string, UpdateEntry>
|
||||
```
|
||||
|
||||
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
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing shrink-0 touch-none"
|
||||
>
|
||||
```
|
||||
|
||||
From frontend/src/lib/utils.ts:
|
||||
```typescript
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
```
|
||||
|
||||
From frontend/src/main.tsx (current hardcoded dark mode):
|
||||
```typescript
|
||||
document.documentElement.classList.add('dark')
|
||||
```
|
||||
|
||||
From frontend/src/index.css (CSS vars - note: no --destructive or --card defined):
|
||||
```css
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
/* ... light theme vars ... */
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
/* ... dark theme vars ... */
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Theme toggle, drag handle fix, and shared getRegistry utility</name>
|
||||
<files>frontend/src/main.tsx, frontend/src/index.css, frontend/src/components/Header.tsx, frontend/src/components/ServiceCard.tsx, frontend/src/lib/utils.ts</files>
|
||||
<read_first>
|
||||
- frontend/src/main.tsx
|
||||
- frontend/src/index.css
|
||||
- frontend/src/components/Header.tsx
|
||||
- frontend/src/components/ServiceCard.tsx
|
||||
- frontend/src/lib/utils.ts
|
||||
</read_first>
|
||||
<action>
|
||||
1. **main.tsx** (per D-15): Replace `document.documentElement.classList.add('dark')` with theme initialization:
|
||||
```typescript
|
||||
const stored = localStorage.getItem('theme')
|
||||
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
```
|
||||
|
||||
2. **index.css**: Add `--destructive` and `--destructive-foreground` CSS variables to both `:root` and `.dark` blocks (needed for destructive button variant used in Plan 03). Also add `--card` and `--card-foreground` if missing:
|
||||
In `:root` block, add:
|
||||
```css
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
```
|
||||
In `.dark` block, add:
|
||||
```css
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
```
|
||||
|
||||
3. **Header.tsx** (per D-14): Add theme toggle button. Import `Sun, Moon` from `lucide-react`. Add a `toggleTheme` function:
|
||||
```typescript
|
||||
function toggleTheme() {
|
||||
const isDark = document.documentElement.classList.toggle('dark')
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light')
|
||||
}
|
||||
```
|
||||
Add a second Button next to the refresh button:
|
||||
```tsx
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleTheme}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
title="Toggle theme"
|
||||
>
|
||||
<Sun className="h-4 w-4 hidden dark:block" />
|
||||
<Moon className="h-4 w-4 block dark:hidden" />
|
||||
</Button>
|
||||
```
|
||||
Wrap both buttons in a `<div className="flex items-center gap-1">`.
|
||||
|
||||
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'`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: FilterBar component and client-side search/filter/sort logic in App.tsx</name>
|
||||
<files>frontend/src/components/FilterBar.tsx, frontend/src/App.tsx</files>
|
||||
<read_first>
|
||||
- frontend/src/App.tsx
|
||||
- frontend/src/types/diun.ts
|
||||
- frontend/src/lib/utils.ts
|
||||
- frontend/src/components/TagSection.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
1. **Create FilterBar.tsx** (per D-06, D-07): New component placed above sections list, below stats row. Uses native `<select>` elements styled with Tailwind (no Radix Select dependency). Props interface:
|
||||
```typescript
|
||||
interface FilterBarProps {
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
statusFilter: 'all' | 'pending' | 'acknowledged'
|
||||
onStatusFilterChange: (value: 'all' | 'pending' | 'acknowledged') => void
|
||||
tagFilter: 'all' | 'untagged' | number
|
||||
onTagFilterChange: (value: 'all' | 'untagged' | number) => void
|
||||
sortOrder: 'date-desc' | 'date-asc' | 'name' | 'registry'
|
||||
onSortOrderChange: (value: 'date-desc' | 'date-asc' | 'name' | 'registry') => void
|
||||
tags: Tag[]
|
||||
}
|
||||
```
|
||||
|
||||
Layout: flex row with wrap, gap-3. Responsive: on small screens wraps to multiple rows.
|
||||
- Search input: `<input type="text" placeholder="Search images..." />` 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 `<FilterBar>` in the JSX between the stats grid and the loading state, wrapped in `{!loading && entries.length > 0 && (...)}`:
|
||||
```tsx
|
||||
{!loading && entries.length > 0 && (
|
||||
<FilterBar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
tagFilter={tagFilter}
|
||||
onTagFilterChange={setTagFilter}
|
||||
sortOrder={sortOrder}
|
||||
onSortOrderChange={setSortOrder}
|
||||
tags={tags}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
Import `UpdateEntry` type if needed for the `as` cast.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit && bun run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- FilterBar.tsx exists and exports `FilterBar` component
|
||||
- FilterBar.tsx contains `Search images` (placeholder text)
|
||||
- FilterBar.tsx contains `<select` elements (native selects, not Radix)
|
||||
- FilterBar.tsx contains `All Status` and `Pending` and `Acknowledged` as option labels
|
||||
- FilterBar.tsx contains `Newest First` and `Name A-Z` as option labels
|
||||
- App.tsx contains `import { FilterBar }` from `@/components/FilterBar`
|
||||
- App.tsx contains `const [search, setSearch] = useState`
|
||||
- App.tsx contains `const [statusFilter, setStatusFilter] = useState`
|
||||
- App.tsx contains `const [sortOrder, setSortOrder] = useState`
|
||||
- App.tsx contains `useMemo` for filteredEntries
|
||||
- App.tsx contains `<FilterBar` JSX element
|
||||
- App.tsx taggedSections uses `filteredEntries` (not raw `entries`)
|
||||
- `bun run build` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend
|
||||
npx tsc --noEmit
|
||||
bun run build
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-ux-improvements/04-02-SUMMARY.md`
|
||||
</output>
|
||||
558
.planning/phases/04-ux-improvements/04-03-PLAN.md
Normal file
558
.planning/phases/04-ux-improvements/04-03-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
<!-- From Plan 01: new backend endpoints -->
|
||||
POST /api/updates/acknowledge-all -> {"count": N}
|
||||
POST /api/updates/acknowledge-by-tag (body: {"tag_id": N}) -> {"count": N}
|
||||
|
||||
<!-- From Plan 02: Header already has theme toggle, App.tsx has filter state -->
|
||||
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
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Extend useUpdates with bulk acknowledge callbacks, toast detection, and tab title effect</name>
|
||||
<files>frontend/src/hooks/useUpdates.ts</files>
|
||||
<read_first>
|
||||
- frontend/src/hooks/useUpdates.ts
|
||||
- frontend/src/types/diun.ts
|
||||
</read_first>
|
||||
<action>
|
||||
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,
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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<Set<string>>`
|
||||
- useUpdates.ts contains `const [newArrivals, setNewArrivals] = useState<string[]>`
|
||||
- useUpdates.ts contains `clearNewArrivals` in the return object
|
||||
- useUpdates.ts return object includes `acknowledgeAll` and `acknowledgeByTag`
|
||||
- `npx tsc --noEmit` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>useUpdates hook returns acknowledgeAll, acknowledgeByTag, newArrivals, and clearNewArrivals; toast detection fires on new images during polling</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Toast component, Header updates, TagSection per-group dismiss, ServiceCard highlight, and App.tsx wiring</name>
|
||||
<files>frontend/src/components/Toast.tsx, frontend/src/components/Header.tsx, frontend/src/components/TagSection.tsx, frontend/src/components/ServiceCard.tsx, frontend/src/App.tsx</files>
|
||||
<read_first>
|
||||
- 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
|
||||
</read_first>
|
||||
<action>
|
||||
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'
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard/frontend && npx tsc --noEmit && bun run build</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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 `<Toast` JSX element
|
||||
- App.tsx contains `<Header` with `pendingCount=` and `onDismissAll=` props
|
||||
- App.tsx contains `onAcknowledgeGroup=` prop on TagSection
|
||||
- TagSection.tsx TagSectionRow interface contains `isNew`
|
||||
- `bun run build` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```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 ./...
|
||||
```
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-ux-improvements/04-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user