33 KiB
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)andAcknowledgeByTag(tagID int) (count int, err error)— consistent with existingAcknowledgeUpdate(image)pattern - D-02: Two new API endpoints:
POST /api/updates/acknowledge-allandPOST /api/updates/acknowledge-by-tag(withtag_idin 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
lastVisitTimestampon page unload; updates withreceived_atafter 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-schememedia query; removes the hardcodedclassList.add('dark')frommain.tsx - D-16: Drag handle on ServiceCard always visible at reduced opacity (
opacity-40), full opacity on hover — removes the currentopacity-0 group-hover:opacity-100pattern
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:
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:
// 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')):
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.
// 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:
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.
// 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.
// 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.
// 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):
function toggleTheme() {
const isDark = document.documentElement.classList.toggle('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
Pattern 6: Last-Visit Highlight
// 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:
<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
// 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:
// 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-allmeans 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
TagSectionseparately — filterentriesbefore grouping, then re-derivetaggedSectionsanduntaggedRowsfrom filtered entries. Otherwise the tag group counts shown in section headers will be wrong. - Mutating
updatesobject for bulk dismiss optimistic update: Use the functionalsetUpdates(prev => ...)form and create a new object withObject.fromEntries(Object.entries(prev).map(...))to avoid mutating in place — same pattern as the existingacknowledgecallback. - 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
lastVisitTimestampof "now", making every update appear highlighted. The guard is: only highlight items wherereceived_at > lastVisitTimestampANDlastVisitTimestampexisted before this page load (i.e., use theuseRefinitialized 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)
-- Source: direct analysis of existing sqlite_store.go patterns
UPDATE updates SET acknowledged_at = datetime('now') WHERE acknowledged_at IS NULL
AcknowledgeAll SQL (PostgreSQL)
UPDATE updates SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL
AcknowledgeByTag SQL (SQLite)
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)
// 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)
// 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
-
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.
-
getRegistry helper duplication
- What we know:
getRegistryfunction lives inServiceCard.tsx(not exported); sort-by-registry inApp.tsxneeds the same logic - What's unclear: Whether to move
getRegistrytolib/serviceIcons.tsorlib/utils.tsor duplicate it - Recommendation: Move to
frontend/src/lib/utils.tsorfrontend/src/lib/serviceIcons.tsand re-import in ServiceCard. This is a small refactor but cleaner than duplication. The planner should include this as a sub-task.
- What we know:
-
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 sonnerplus 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 indiunwebhook.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-schemetheme init pattern — standard web platform API, no library required- HTML5
beforeunloadevent 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)