Files
DiunDashboard/.planning/phases/04-ux-improvements/04-RESEARCH.md

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

bun add sonner

Then add <Toaster /> to App.tsx root and call toast() from anywhere.


Architecture Patterns

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-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)

-- 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

  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)