628 lines
33 KiB
Markdown
628 lines
33 KiB
Markdown
# Phase 4: UX Improvements - Research
|
|
|
|
**Researched:** 2026-03-24
|
|
**Domain:** React SPA (search/filter, toast, theme, drag UX) + Go HTTP handlers (bulk acknowledge endpoints)
|
|
**Confidence:** HIGH — all findings are based on direct inspection of the live codebase. No third-party library unknowns; every feature maps to patterns already present in the project.
|
|
|
|
---
|
|
|
|
<user_constraints>
|
|
## User Constraints (from CONTEXT.md)
|
|
|
|
### Locked Decisions
|
|
|
|
**Bulk dismiss (BULK-01, BULK-02)**
|
|
- D-01: Add two new Store methods: `AcknowledgeAll() (count int, err error)` and `AcknowledgeByTag(tagID int) (count int, err error)` — consistent with existing `AcknowledgeUpdate(image)` pattern
|
|
- D-02: Two new API endpoints: `POST /api/updates/acknowledge-all` and `POST /api/updates/acknowledge-by-tag` (with `tag_id` in body) — returning the count of dismissed items
|
|
- D-03: UI placement: "Dismiss All" button in the header/stats area; "Dismiss Group" button in each TagSection header next to the existing delete button
|
|
- D-04: Confirmation: modal/dialog confirmation for dismiss-all (high-impact action); inline confirm pattern (matching existing tag delete) for per-group dismiss
|
|
|
|
**Search and filter (SRCH-01 through SRCH-04)**
|
|
- D-05: Client-side filtering only — all data is already in memory from polling, no new API endpoints needed
|
|
- D-06: Filter bar placed above the sections list, below the stats row
|
|
- D-07: Controls: text search input (filters by image name), status dropdown (all/pending/acknowledged), tag dropdown (all/specific tag/untagged), sort dropdown (date/name/registry)
|
|
- D-08: Filters do not persist across page reloads — reset on each visit
|
|
|
|
**New-update indicators (INDIC-01 through INDIC-04)**
|
|
- D-09: Pending update badge/counter displayed in the Header component next to the "Diun Dashboard" title — always visible
|
|
- D-10: Browser tab title reflects pending count: `"DiunDash (N)"` when N > 0, `"DiunDash"` when zero
|
|
- D-11: Toast notification when new updates arrive during polling — auto-dismiss after 5 seconds with manual dismiss button; non-stacking (latest update replaces previous toast)
|
|
- D-12: "New since last visit" detection via localStorage timestamp — store `lastVisitTimestamp` on page unload; updates with `received_at` after that timestamp get a visual highlight
|
|
- D-13: Highlight style: subtle left border accent (`border-l-4 border-amber-500`) on ServiceCard for new-since-last-visit items
|
|
|
|
**Accessibility and theme (A11Y-01, A11Y-02)**
|
|
- D-14: Light/dark theme toggle placed in the Header bar next to the refresh button — icon button (sun/moon)
|
|
- D-15: Theme preference persisted in localStorage; on first visit, respects `prefers-color-scheme` media query; removes the hardcoded `classList.add('dark')` from `main.tsx`
|
|
- D-16: Drag handle on ServiceCard always visible at reduced opacity (`opacity-40`), full opacity on hover — removes the current `opacity-0 group-hover:opacity-100` pattern
|
|
|
|
### Claude's Discretion
|
|
- Toast component implementation (custom or shadcn/ui Sonner)
|
|
- Exact filter bar layout and responsive breakpoints
|
|
- Animation/transition details for theme switching
|
|
- Whether to show a count in the per-group dismiss button (e.g., "Dismiss 3")
|
|
- Sort order default (most recent first vs alphabetical)
|
|
|
|
### Deferred Ideas (OUT OF SCOPE)
|
|
None — discussion stayed within phase scope.
|
|
</user_constraints>
|
|
|
|
---
|
|
|
|
<phase_requirements>
|
|
## Phase Requirements
|
|
|
|
| ID | Description | Research Support |
|
|
|----|-------------|------------------|
|
|
| BULK-01 | User can acknowledge all pending updates at once with a single action | New `AcknowledgeAll` Store method + `POST /api/updates/acknowledge-all` handler; optimistic update in useUpdates follows existing acknowledge pattern |
|
|
| BULK-02 | User can acknowledge all pending updates within a specific tag/group | New `AcknowledgeByTag` Store method + `POST /api/updates/acknowledge-by-tag` handler; TagSection receives `onAcknowledgeGroup` callback prop |
|
|
| SRCH-01 | User can search updates by image name (text search) | Client-side filter on `entries` array in App.tsx; filter state via useState; case-insensitive substring match on image key |
|
|
| SRCH-02 | User can filter updates by status (pending vs acknowledged) | Client-side filter on `entry.acknowledged` boolean already present in UpdateEntry type |
|
|
| SRCH-03 | User can filter updates by tag/group | Client-side filter on `entry.tag?.id` against tag dropdown value; "untagged" = null tag |
|
|
| SRCH-04 | User can sort updates by date, image name, or registry | Client-side sort on `entries` array before grouping; `received_at` (string ISO 8601 sortable), image key (string), registry extracted by existing `getRegistry` helper in ServiceCard |
|
|
| INDIC-01 | Dashboard shows a badge/counter of pending (unacknowledged) updates | `pending` count already computed in App.tsx; Badge component already exists; wire into Header props |
|
|
| INDIC-02 | Browser tab title includes pending update count | `document.title` side effect in useUpdates or App.tsx useEffect watching pending count |
|
|
| INDIC-03 | In-page toast notification appears when new updates arrive during polling | Detect new images in fetchUpdates by comparing prev vs new keys; toast state in useUpdates hook; custom toast component or Radix-based |
|
|
| INDIC-04 | Updates that arrived since the user's last visit are visually highlighted | localStorage `lastVisitTimestamp` written on `beforeunload`; read at mount; compare `entry.received_at` ISO string; add `isNewSinceLastVisit` boolean to derived state |
|
|
| A11Y-01 | Light/dark theme toggle with system preference detection | Tailwind `darkMode: ['class']` already configured; toggle adds/removes `dark` class; localStorage + `prefers-color-scheme` media query init replaces hardcoded `classList.add('dark')` in main.tsx |
|
|
| A11Y-02 | Drag handle for tag reordering is always visible (not hover-only) | Change `opacity-0 group-hover:opacity-100` to `opacity-40 hover:opacity-100` on the grip button in ServiceCard.tsx |
|
|
</phase_requirements>
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Phase 4 adds UX features across the entire stack. The backend requires two new SQL operations (`UPDATE ... WHERE acknowledged_at IS NULL` for all rows, and the same filtered by tag join) and two new HTTP handlers following the exact pattern already used for `DismissHandler`. No schema changes, no migrations, no new tables.
|
|
|
|
The frontend work is pure React/TypeScript. All features are enabled by the existing stack: client-side filter/sort, toast via a lightweight component, theme via the already-configured Tailwind `darkMode: ['class']` strategy, localStorage for persistence of theme preference and last-visit timestamp, and a one-line opacity change for the drag handle. No new npm packages are strictly required. The one discretionary choice is the toast implementation: a small custom component avoids a new dependency; `sonner` (shadcn/ui's recommended toast) is an option if polish justifies the dependency.
|
|
|
|
**Primary recommendation:** Implement everything with existing dependencies. Use a custom toast component (30 lines of Tailwind CSS) rather than installing sonner. Use native `<select>` elements for filter dropdowns styled with Tailwind rather than installing a headless select library. Both keep the bundle lean and avoid Radix package additions that would require new peer dependency management.
|
|
|
|
---
|
|
|
|
## Standard Stack
|
|
|
|
### Core (already installed — no new packages required)
|
|
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| React | ^19.0.0 | UI framework | Project constraint |
|
|
| TypeScript | ^5.7.2 | Type safety | Project constraint |
|
|
| Tailwind CSS | ^3.4.17 | Styling | Project constraint |
|
|
| shadcn/ui (Badge, Button) | in-repo | UI primitives | Already present; reuse for badge and buttons |
|
|
| Lucide React | ^0.469.0 | Icons | Already present; Sun/Moon icons for theme toggle |
|
|
| class-variance-authority | ^0.7.1 | Variant management | Already used in Button/Badge |
|
|
| clsx + tailwind-merge via `cn()` | in-repo | Conditional classes | Already used project-wide |
|
|
|
|
### Potentially New (discretionary)
|
|
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| sonner | ^1.7.x | Toast notifications (shadcn/ui recommended) | Only if custom toast feels too raw; adds ~15KB |
|
|
| @radix-ui/react-dialog | ^1.x | Accessible modal for dismiss-all confirmation | Only if a custom dialog is not acceptable; adds Radix peer dep |
|
|
| @radix-ui/react-select | ^2.x | Accessible filter dropdowns | Only if native `<select>` is unacceptable for design reasons |
|
|
|
|
**Version verification:** The above new packages are NOT currently in `package.json`. Before adding any, run `bun add <package>` to pull the latest version from the registry. Do not assume training-data version numbers.
|
|
|
|
**Recommendation:** Use native HTML `<select>` for filter dropdowns (Tailwind-styled). Use a custom inline dialog (confirm pattern already used for tag delete) or a small `<dialog>` element for dismiss-all. Use a custom toast component. This avoids any new package installs.
|
|
|
|
**If sonner is chosen:**
|
|
```bash
|
|
bun add sonner
|
|
```
|
|
Then add `<Toaster />` to `App.tsx` root and call `toast()` from anywhere.
|
|
|
|
---
|
|
|
|
## Architecture Patterns
|
|
|
|
### Recommended Project Structure After Phase 4
|
|
|
|
```
|
|
frontend/src/
|
|
├── components/
|
|
│ ├── Header.tsx # add: pending badge, theme toggle, dismiss-all button
|
|
│ ├── TagSection.tsx # add: per-group dismiss button + inline confirm
|
|
│ ├── ServiceCard.tsx # change: drag handle opacity, new-visit highlight
|
|
│ ├── FilterBar.tsx # NEW: search input + 3 dropdowns
|
|
│ ├── Toast.tsx # NEW: simple toast notification component
|
|
│ └── ui/ # existing shadcn primitives (unchanged)
|
|
├── hooks/
|
|
│ └── useUpdates.ts # extend: bulk dismiss callbacks, toast detection, tab title
|
|
├── App.tsx # extend: filter state, filtered/sorted entries, Toast mount
|
|
└── main.tsx # change: theme init logic
|
|
|
|
pkg/diunwebhook/
|
|
├── store.go # add: AcknowledgeAll, AcknowledgeByTag to interface
|
|
├── sqlite_store.go # implement: AcknowledgeAll, AcknowledgeByTag
|
|
├── postgres_store.go # implement: AcknowledgeAll, AcknowledgeByTag
|
|
└── diunwebhook.go # add: AcknowledgeAllHandler, AcknowledgeByTagHandler
|
|
|
|
cmd/diunwebhook/
|
|
└── main.go # add: 2 route registrations
|
|
```
|
|
|
|
### Pattern 1: New Store Method Implementation
|
|
|
|
The two new Store methods follow the exact `AcknowledgeUpdate` pattern. Confirmed by reading `sqlite_store.go` and `postgres_store.go`.
|
|
|
|
**SQLite:**
|
|
```go
|
|
// AcknowledgeAll marks all unacknowledged updates as acknowledged.
|
|
// Returns the count of rows updated.
|
|
func (s *SQLiteStore) AcknowledgeAll() (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = datetime('now') WHERE acknowledged_at IS NULL`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return int(n), nil
|
|
}
|
|
|
|
// AcknowledgeByTag marks all unacknowledged updates for images in the given tag as acknowledged.
|
|
func (s *SQLiteStore) AcknowledgeByTag(tagID int) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
res, err := 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)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return int(n), nil
|
|
}
|
|
```
|
|
|
|
**PostgreSQL (positional params, NOW() instead of datetime('now')):**
|
|
```go
|
|
func (s *PostgresStore) AcknowledgeAll() (int, error) {
|
|
res, err := s.db.Exec(`UPDATE updates SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return int(n), nil
|
|
}
|
|
|
|
func (s *PostgresStore) AcknowledgeByTag(tagID int) (int, error) {
|
|
res, err := 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)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return int(n), nil
|
|
}
|
|
```
|
|
|
|
### Pattern 2: New HTTP Handlers
|
|
|
|
Follow `DismissHandler` exactly: POST method check, body size limit, JSON decode, store call, JSON response with count.
|
|
|
|
```go
|
|
// AcknowledgeAllHandler handles POST /api/updates/acknowledge-all
|
|
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}) //nolint:errcheck
|
|
}
|
|
|
|
// AcknowledgeByTagHandler handles POST /api/updates/acknowledge-by-tag
|
|
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}) //nolint:errcheck
|
|
}
|
|
```
|
|
|
|
**Route registration in main.go** — note the specific path order matters in `net/http`'s default mux. `/api/updates/acknowledge-all` must be registered before `/api/updates/` to avoid the catch-all stripping:
|
|
```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 the above
|
|
mux.HandleFunc("/api/updates", srv.UpdatesHandler)
|
|
```
|
|
|
|
### Pattern 3: Client-Side Filter and Sort
|
|
|
|
Filter state lives in `App.tsx` (no global state library — project constraint). Filtering happens on the computed `entries` array before grouping into `taggedSections` and `untaggedRows`.
|
|
|
|
```typescript
|
|
// In App.tsx — filter state
|
|
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')
|
|
|
|
// Derived: filtered + sorted entries
|
|
const filteredEntries = useMemo(() => {
|
|
let result = Object.entries(updates)
|
|
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 // date-desc
|
|
}
|
|
})
|
|
return result
|
|
}, [updates, search, statusFilter, tagFilter, sortOrder])
|
|
```
|
|
|
|
`getRegistry` already exists in `ServiceCard.tsx` — move it to a shared utility or duplicate in `App.tsx`.
|
|
|
|
### Pattern 4: Toast Detection in useUpdates
|
|
|
|
Detect new arrivals by comparing the keys of the previous poll result against the current result. New keys = new images arrived.
|
|
|
|
```typescript
|
|
// In useUpdates.ts — track previous keys
|
|
const prevKeysRef = useRef<Set<string>>(new Set())
|
|
const [newArrivals, setNewArrivals] = useState<string[]>([])
|
|
|
|
const fetchUpdates = useCallback(async () => {
|
|
// ... existing fetch logic ...
|
|
const data: UpdatesMap = await res.json()
|
|
const newKeys = Object.keys(data).filter(k => !prevKeysRef.current.has(k))
|
|
if (newKeys.length > 0 && prevKeysRef.current.size > 0) {
|
|
// Only fire toast after initial load (size > 0 guard)
|
|
setNewArrivals(newKeys)
|
|
}
|
|
prevKeysRef.current = new Set(Object.keys(data))
|
|
setUpdates(data)
|
|
// ...
|
|
}, [])
|
|
```
|
|
|
|
Non-stacking: `newArrivals` state is replaced (not appended) each poll, so the toast always shows the latest batch.
|
|
|
|
### Pattern 5: Theme Toggle
|
|
|
|
The project already has `darkMode: ['class']` in `tailwind.config.ts` and CSS variables for both `:root` (light) and `.dark` (dark) in `index.css`. The only change is in `main.tsx` — replace the hardcoded `classList.add('dark')` with an initializer that reads localStorage and falls back to `prefers-color-scheme`.
|
|
|
|
```typescript
|
|
// main.tsx — replace classList.add('dark') with:
|
|
const stored = localStorage.getItem('theme')
|
|
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
document.documentElement.classList.add('dark')
|
|
}
|
|
```
|
|
|
|
Toggle function (in Header or a custom hook):
|
|
```typescript
|
|
function toggleTheme() {
|
|
const isDark = document.documentElement.classList.toggle('dark')
|
|
localStorage.setItem('theme', isDark ? 'dark' : 'light')
|
|
}
|
|
```
|
|
|
|
### Pattern 6: Last-Visit Highlight
|
|
|
|
```typescript
|
|
// In App.tsx (or useUpdates.ts) — read at mount
|
|
const lastVisitRef = useRef<string | null>(
|
|
localStorage.getItem('lastVisitTimestamp')
|
|
)
|
|
// Write on unload
|
|
useEffect(() => {
|
|
const handler = () => localStorage.setItem('lastVisitTimestamp', new Date().toISOString())
|
|
window.addEventListener('beforeunload', handler)
|
|
return () => window.removeEventListener('beforeunload', handler)
|
|
}, [])
|
|
|
|
// Usage in ServiceCard or when building rows:
|
|
const isNewSinceLastVisit = lastVisitRef.current
|
|
? entry.received_at > lastVisitRef.current
|
|
: false
|
|
```
|
|
|
|
In `ServiceCard.tsx`:
|
|
```tsx
|
|
<div className={cn(
|
|
'group p-4 rounded-xl border border-border bg-card ...',
|
|
isNewSinceLastVisit && 'border-l-4 border-l-amber-500',
|
|
isDragging && 'opacity-30',
|
|
)}>
|
|
```
|
|
|
|
Note: `isNewSinceLastVisit` must be passed as a prop to ServiceCard since the ref lives in App/useUpdates.
|
|
|
|
### Pattern 7: Tab Title
|
|
|
|
```typescript
|
|
// In App.tsx or useUpdates.ts — side effect watching pending count
|
|
useEffect(() => {
|
|
document.title = pending > 0 ? `DiunDash (${pending})` : 'DiunDash'
|
|
}, [pending])
|
|
```
|
|
|
|
`pending` is already computed in `App.tsx`.
|
|
|
|
### Pattern 8: Dismiss-All Confirmation Modal
|
|
|
|
The project has no existing modal component. The simplest approach consistent with the inline confirm pattern already used for tag delete is a two-click confirm pattern on the "Dismiss All" button itself — same UX as the "Delete" button in `TagSection.tsx`. This avoids adding a dialog library.
|
|
|
|
If a modal is preferred (D-04 says "modal/dialog confirmation"), the lightest option is the HTML `<dialog>` element with no external dependencies:
|
|
|
|
```tsx
|
|
// Simple inline confirm state (matches TagSection pattern exactly)
|
|
const [confirmDismissAll, setConfirmDismissAll] = useState(false)
|
|
|
|
<Button
|
|
variant={confirmDismissAll ? 'destructive' : 'outline'}
|
|
size="sm"
|
|
onClick={() => {
|
|
if (!confirmDismissAll) { setConfirmDismissAll(true); return }
|
|
onDismissAll()
|
|
setConfirmDismissAll(false)
|
|
}}
|
|
onBlur={() => setConfirmDismissAll(false)}
|
|
>
|
|
{confirmDismissAll ? 'Sure? Dismiss all' : 'Dismiss All'}
|
|
</Button>
|
|
```
|
|
|
|
This matches the exact two-click confirm pattern already shipping in `TagSection.tsx` for tag deletion. Use this unless the user explicitly requires a modal overlay.
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **Route registration order:** In `net/http`'s default mux, registering `/api/updates/` before `/api/updates/acknowledge-all` means the handler for the more specific path is never reached. Always register specific paths before catch-alls.
|
|
- **Filtering after grouping:** Do not filter within each `TagSection` separately — filter `entries` before grouping, then re-derive `taggedSections` and `untaggedRows` from filtered entries. Otherwise the tag group counts shown in section headers will be wrong.
|
|
- **Mutating `updates` object for bulk dismiss optimistic update:** Use the functional `setUpdates(prev => ...)` form and create a new object with `Object.fromEntries(Object.entries(prev).map(...))` to avoid mutating in place — same pattern as the existing `acknowledge` callback.
|
|
- **Hardcoded `classList.add('dark')` left in place:** If main.tsx is not updated, the theme toggle will fight with the initialization and users will see a flash or be unable to switch to light mode.
|
|
- **Toast stacking:** If toast state is accumulated into an array rather than replaced, multiple rapid polls accumulate toasts. D-11 says non-stacking — always replace, never append.
|
|
- **beforeunload timestamp written before any data loads:** The first visit will write a `lastVisitTimestamp` of "now", making every update appear highlighted. The guard is: only highlight items where `received_at > lastVisitTimestamp` AND `lastVisitTimestamp` existed before this page load (i.e., use the `useRef` initialized from localStorage at mount, not the live localStorage value).
|
|
|
|
---
|
|
|
|
## Don't Hand-Roll
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| Case-insensitive image name search | Custom fuzzy matcher | `string.toLowerCase().includes(query.toLowerCase())` | The data set is small (dozens of images); simple substring match is sufficient |
|
|
| Toast notification system | Multiple-file toast context/provider | Single `Toast.tsx` component with useState in App | Project has no global state; keep toast state local |
|
|
| SQL bulk update | Row-by-row loop over `AcknowledgeUpdate` | Single `UPDATE ... WHERE acknowledged_at IS NULL` | One round-trip vs N; transactional; simpler |
|
|
| Theme persistence | Cookie or server-side preference | localStorage + `prefers-color-scheme` | Client-only SPA; localStorage is sufficient and already used for `lastVisitTimestamp` |
|
|
| Filter URL serialization | Query string encode/decode | Transient state in useState | D-08 explicitly locks: filters reset on reload |
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: Route Ordering in net/http ServeMux
|
|
|
|
**What goes wrong:** `mux.HandleFunc("/api/updates/", srv.DismissHandler)` is a subtree pattern that matches ALL paths starting with `/api/updates/`. If registered before `/api/updates/acknowledge-all`, the new endpoints are never reached.
|
|
|
|
**Why it happens:** Go's `http.ServeMux` matches subtree patterns (`/path/` with trailing slash) before exact patterns only when the subtree is registered first. More specific paths win only if registered first.
|
|
|
|
**How to avoid:** Register `/api/updates/acknowledge-all` and `/api/updates/acknowledge-by-tag` before `/api/updates/` in `main.go`. Verify the order matches the current pattern where `/api/updates` (no slash, exact) is registered after `/api/updates/` (subtree).
|
|
|
|
**Warning signs:** HTTP 204 or 404 returned by AcknowledgeAllHandler with no log output means the DismissHandler is handling the request.
|
|
|
|
### Pitfall 2: CSS Variable Missing for destructive in dark mode
|
|
|
|
**What goes wrong:** The `--destructive` and `--destructive-foreground` CSS variables are used by `buttonVariants` in `button.tsx` but are NOT defined in `index.css`. If a destructive-variant button is added (e.g., for dismiss-all confirm), it will render with no background.
|
|
|
|
**Why it happens:** The existing code never uses `variant="destructive"` — the two-click confirm in `TagSection.tsx` uses custom Tailwind classes (`text-destructive hover:bg-destructive/10`) rather than the Button component. So the missing CSS var was never noticed.
|
|
|
|
**How to avoid:** Either (a) add `--destructive` and `--destructive-foreground` to both `:root` and `.dark` in `index.css`, or (b) continue using inline Tailwind classes for the confirm state rather than the Button destructive variant.
|
|
|
|
A suitable value for `:root`: `--destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%;`
|
|
For `.dark`: `--destructive: 0 62.8% 30.6%; --destructive-foreground: 0 85.7% 97.3%;`
|
|
|
|
### Pitfall 3: ServiceCard receives isNewSinceLastVisit as a Prop
|
|
|
|
**What goes wrong:** The `lastVisitRef` value is available in `App.tsx` at mount time, but `ServiceCard` currently receives only `image`, `entry`, and `onAcknowledge`. If the highlight logic is added inside ServiceCard reading from localStorage directly, every card reads localStorage independently — which is fine but couples the component to a side effect.
|
|
|
|
**Why it happens:** Convenience — it seems simpler to read localStorage in the card.
|
|
|
|
**How to avoid:** Compute `isNewSinceLastVisit` at the point where rows are built in `App.tsx` and pass it as a prop to `ServiceCard`. This keeps the component pure and the logic testable.
|
|
|
|
### Pitfall 4: Tab Title Not Reset When All Dismissed
|
|
|
|
**What goes wrong:** `document.title` is set to `"DiunDash (N)"` but if `pending` reaches 0 after a bulk dismiss, the title must be reset to `"DiunDash"`.
|
|
|
|
**Why it happens:** The `useEffect` dependency only fires if `pending` changes, so if `pending` was already 0 and stays 0, the title is never set at all.
|
|
|
|
**How to avoid:** The `useEffect` watching `pending` handles this correctly as long as it runs on mount (initial render with pending=0 will set title to "DiunDash"). Ensure the effect has `[pending]` in its dependency array, not `[pending > 0]`.
|
|
|
|
### Pitfall 5: AcknowledgeByTag Does Not Verify Tag Exists
|
|
|
|
**What goes wrong:** If `tag_id` in the request body refers to a deleted tag, the query silently updates 0 rows and returns count=0. This is acceptable behavior (idempotent), but the test should verify it returns 200 with count:0 rather than 404.
|
|
|
|
**Why it happens:** Inconsistency with `DismissHandler` which returns 404 when no row is found. Bulk operations should not 404 on empty result sets — they're batch operations.
|
|
|
|
**How to avoid:** Document and test the 200+count:0 response explicitly. Do NOT add a `TagExists` check before the bulk update (it adds a round-trip and a TOCTOU race).
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### AcknowledgeAll SQL (SQLite)
|
|
```sql
|
|
-- Source: direct analysis of existing sqlite_store.go patterns
|
|
UPDATE updates SET acknowledged_at = datetime('now') WHERE acknowledged_at IS NULL
|
|
```
|
|
|
|
### AcknowledgeAll SQL (PostgreSQL)
|
|
```sql
|
|
UPDATE updates SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL
|
|
```
|
|
|
|
### AcknowledgeByTag SQL (SQLite)
|
|
```sql
|
|
UPDATE updates SET acknowledged_at = datetime('now')
|
|
WHERE acknowledged_at IS NULL
|
|
AND image IN (SELECT image FROM tag_assignments WHERE tag_id = ?)
|
|
```
|
|
|
|
### Bulk Dismiss Optimistic Update (TypeScript)
|
|
```typescript
|
|
// Source: pattern derived from existing acknowledge callback in useUpdates.ts
|
|
const acknowledgeAll = useCallback(async () => {
|
|
// Optimistic
|
|
setUpdates(prev =>
|
|
Object.fromEntries(
|
|
Object.entries(prev).map(([img, entry]) => [img, { ...entry, acknowledged: true }])
|
|
)
|
|
)
|
|
try {
|
|
await fetch('/api/updates/acknowledge-all', { method: 'POST' })
|
|
} catch (e) {
|
|
console.error('acknowledgeAll failed:', e)
|
|
fetchUpdates() // re-sync on failure
|
|
}
|
|
}, [fetchUpdates])
|
|
|
|
const acknowledgeByTag = useCallback(async (tagID: number) => {
|
|
setUpdates(prev =>
|
|
Object.fromEntries(
|
|
Object.entries(prev).map(([img, entry]) => [
|
|
img,
|
|
entry.tag?.id === tagID ? { ...entry, acknowledged: true } : entry,
|
|
])
|
|
)
|
|
)
|
|
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])
|
|
```
|
|
|
|
### Theme Init (main.tsx replacement)
|
|
```typescript
|
|
// Source: Tailwind CSS darkMode: ['class'] documentation pattern
|
|
const stored = localStorage.getItem('theme')
|
|
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
document.documentElement.classList.add('dark')
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Environment Availability
|
|
|
|
Step 2.6: SKIPPED for new tool dependencies — this phase adds no external tools, services, CLIs, or databases beyond what is already confirmed operational from Phase 3. Bun is available (v1.3.10, verified above). The Go compiler is not accessible in this shell environment but CI uses the Gitea Actions runner with the custom Docker image that includes Go 1.26.
|
|
|
|
---
|
|
|
|
## Project Constraints (from CLAUDE.md)
|
|
|
|
The following directives must be respected by the planner:
|
|
|
|
| Constraint | Impact on This Phase |
|
|
|------------|----------------------|
|
|
| No CGO — pure Go SQLite driver `modernc.org/sqlite` | No impact; new methods use existing `database/sql` patterns |
|
|
| Go 1.26, no third-party router | New handlers follow `net/http` stdlib pattern exactly |
|
|
| `gofmt` enforced in CI | All new Go files must be `gofmt`-clean before commit |
|
|
| `go vet` runs in CI | No unsafe patterns |
|
|
| TypeScript `strict: true`, `noUnusedLocals`, `noUnusedParameters` | Filter state, toast state, and new props must have types; no unused imports |
|
|
| No ESLint/Prettier for frontend | No linting enforcement, but follow project style (2-space indent, single quotes, no semicolons) |
|
|
| Handler naming: `<Noun>Handler` | New handlers: `AcknowledgeAllHandler`, `AcknowledgeByTagHandler` |
|
|
| Test function naming: `Test<FunctionName>_<Scenario>` | e.g., `TestAcknowledgeAllHandler_Empty`, `TestAcknowledgeByTagHandler_NotFound` |
|
|
| External test package `package diunwebhook_test` | New tests use `NewTestServer()` from `export_test.go` |
|
|
| Error messages lowercase | `"bad request"`, `"internal error"` — matches existing style |
|
|
| `log.Printf` with handler name prefix | `"AcknowledgeAllHandler: ..."` |
|
|
| Single `diunwebhook.go` file for handler logic | New handlers go in `diunwebhook.go` alongside existing handlers |
|
|
| Backward compatible — existing SQLite DBs | No schema changes in this phase (confirmed: no migrations needed) |
|
|
| GSD workflow enforcement | All work enters via GSD execute-phase |
|
|
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
1. **Dismiss-All: inline confirm vs modal overlay**
|
|
- What we know: D-04 says "modal/dialog confirmation for dismiss-all"; the inline two-click pattern is simpler and consistent with existing tag delete UX
|
|
- What's unclear: Whether "modal" means a literal overlay dialog or just a confirmation step
|
|
- Recommendation: Use the inline two-click confirm (matches existing pattern, zero new dependencies). The planner can escalate to a proper `<dialog>` element if the user reviews the plan and wants a modal overlay.
|
|
|
|
2. **getRegistry helper duplication**
|
|
- What we know: `getRegistry` function lives in `ServiceCard.tsx` (not exported); sort-by-registry in `App.tsx` needs the same logic
|
|
- What's unclear: Whether to move `getRegistry` to `lib/serviceIcons.ts` or `lib/utils.ts` or duplicate it
|
|
- Recommendation: Move to `frontend/src/lib/utils.ts` or `frontend/src/lib/serviceIcons.ts` and re-import in ServiceCard. This is a small refactor but cleaner than duplication. The planner should include this as a sub-task.
|
|
|
|
3. **Toast: custom vs sonner**
|
|
- What we know: No toast library is installed; shadcn/ui recommends sonner; a custom component is ~30 lines
|
|
- What's unclear: How polished the toast needs to look
|
|
- Recommendation: Custom component. If the user requests sonner, it is `bun add sonner` plus a `<Toaster />` in App.tsx root.
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- Direct codebase inspection: `pkg/diunwebhook/store.go`, `sqlite_store.go`, `postgres_store.go`, `diunwebhook.go`, `server.go` (does not exist yet — all handlers are in `diunwebhook.go`)
|
|
- Direct codebase inspection: `frontend/src/App.tsx`, `useUpdates.ts`, `Header.tsx`, `TagSection.tsx`, `ServiceCard.tsx`, `main.tsx`, `tailwind.config.ts`, `index.css`
|
|
- Direct codebase inspection: `frontend/package.json` — confirmed no sonner, dialog, or select Radix packages installed
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- Tailwind CSS `darkMode: ['class']` pattern — well-established, matches existing project configuration
|
|
- `localStorage` + `prefers-color-scheme` theme init pattern — standard web platform API, no library required
|
|
- HTML5 `beforeunload` event for last-visit timestamp — standard, widely supported
|
|
|
|
### Tertiary (LOW confidence — none)
|
|
No findings rely solely on unverified web search.
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH — direct package.json inspection, no assumptions
|
|
- Architecture: HIGH — derived from reading every file the phase touches
|
|
- Pitfalls: HIGH — route ordering and CSS var gaps verified directly in the source; others are logic-level
|
|
- SQL patterns: HIGH — derived from existing store implementations in the same codebase
|
|
|
|
**Research date:** 2026-03-24
|
|
**Valid until:** 2026-04-24 (stable stack; 30-day validity)
|