feat(ui): add recent updates dialogue (#89) #92

Merged
skobkin merged 5 commits from feat/issue-89-recent-updates-dialogue into master 2026-06-15 23:27:40 +03:00
Owner

Summary

Closes #89.

Adds a "Recent updates" dialog so end users can discover what changed in meshmap-lite since they last used it. The dialog is never auto-pushed: it opens only on explicit click of the existing "i" button (which now also shows a small number badge = sum of unread release counts across all registered update-check sources). The auto-open rule for the static site information flow is preserved unchanged.

The backend is designed as a generic multi-source release checker so the same component will later power Meshtastic firmware update notifications (a second source on GitHub).

Backend

  • New internal/updatecheck/ package:
    • Source interface (Name, FetchReleases, ReleasesPageURL) — library-shaped, no UI concerns.
    • Cache (own file) — in-memory, mutex-protected, owned by the Manager.
    • Manager — registers sources, runs one immediate fetch + an interval ticker per source, exposes Snapshot/Names/Labels/Subscribe/SubscribeAll.
    • ReleaseInfo, UpdateSnapshot, SourceSpec types in their own file.
    • Per-platform sub-packages: sources/forgejo (MVP, port of meshgo's checker) and sources/github (stub for the follow-up Meshtastic firmware feature — panic body so the package compiles and is discoverable).
    • Labels live in the Manager (from each SourceSpec.Label), not on Source — keeps the platform adapter a pure data fetcher. ReleasesPageURL is built by the adapter from base_url + /<repo>/releases.
  • New GET /api/v1/updates?source=<name>&format=html|markdown endpoint:
    • format=html (default) pre-renders each release body via goldmark (reuses siteinfo.RenderMarkdown).
    • 200: {format, source, source_hash, releases: [{version, published_at, html_url, body}]}.
    • 404 update_check_not_configured when the feature is disabled.
    • 404 update_check_source_not_found for an unknown source name.
    • 503 update_check_not_ready when the source has not completed its first fetch yet.
  • /api/v1/meta extension: new top-level update_check_available: bool and update_check_sources?: SourceSummary[] (each entry carries version/date metadata only — no body — so the page-load payload stays light).
  • New update_check: config block (enabled, interval, timeout, sources[] with name/label/type/base_url/repository/current_version_source/limit). Auto-registers the default meshmap-lite forgejo source when enabled=true and the sources block is absent; declaring it in config (or via ENV) replaces the default.
  • OpenAPI documentation for the new endpoint, query params, and meta fields.
  • Current-version resolution: current_version_source: buildinfobuildinfo.Version at registration time; current_version_source: none → no comparison, snapshot just shows latest.
  • Failed fetches do not poison the cache — only successful fetches overwrite the snapshot.

Frontend

  • InfoModalHtmlModal generalization (renamed file: InfoModal.tsxHtmlModal.tsx): adds a title prop (default "Information") and a tabs slot for the header. Existing props (content, error, loading, showUpdatedNotice, onClose, onDismiss) and behavior are preserved.
  • New AppModal wrapper: renders the tabbed variant. Header layout is [tabs centered] [close right]. Each tab is a button; source tabs show a small number badge with that source's unread count. Active tab has aria-current="page". Tabs: first is always Information (the static site-info flow), then one per source from meta.update_check_sources (e.g. Map for MVP; will become Information + Map + Meshtastic once the firmware source is added — no further wiring needed).
  • New UpdatesPanel: per-source release list component (NOT hardcoded to meshmap-lite). Renders newest-first, shows a small NEW pill on releases newer than the user's dismissed point, and a visible <hr class="updates-separator"> + caption "You last read up to vX.Y.Z" after the latest release the user has already read. Footer has a "View all releases →" link (uses source.releasesPageUrl) and a "Got it" button.
  • infoCookie.ts refactored into a makeCookie(name) factory exporting both infoDismissedCookie (the existing cookie) and a new updatesDismissedCookie. Both keep the 10y Max-Age + Path=/ + SameSite=Lax contract.
  • web/src/api/client.ts — new api.updates(source, format = 'html') method. Lazy: bodies are fetched only when the user opens a source tab.
  • web/src/stores/meta.ts — surfaces the new meta fields. Pure helper countNewerReleases in metaSelectors.ts for unread-count logic.
  • web/src/stores/updates.ts — new per-source store, keyed by source name; caches the latest fetched response so re-opening a tab is instant.
  • web/src/App.tsx wiring: appModalOpen + appModalActiveTab state; URL fragments #/info (always opens on Information) and #/updates/<sourceName> (deep-links to that source's tab). Auto-open rule: site-info source-hash change still opens on Information; source tabs never auto-open, only the badge. Dismiss handler is tab-aware (writes the appropriate cookie); the "×" close never marks anything as read.
  • Header.tsx: small <span class="header-badge"> over the "i" icon when the cross-source total > 0. aria-label becomes "Site information (N new updates)" when shown.
  • styles.css: new rules for .app-modal-tabs, .app-modal-tab, .header-badge, .updates-release, .updates-separator, .updates-new-pill — with dark-theme overrides using color-mix (matching the pattern from the recent show-all-topology dark-theme work).

Tests

  • Backend:
    • internal/updatecheck/cache_test.go — Get/Set roundtrip, overwrite, concurrency safety.
    • internal/updatecheck/manager_test.go — registration/dedupe, ticker behavior, immediate fetch, per-source failure isolation, UpdateAvailable semantics (newer/equal/older/dev/invalid-latest), Subscribe/SubscribeAll broker.
    • internal/updatecheck/sources/forgejo/forgejo_test.go — URL construction, DTO decode, ReleasesPageURL shape.
    • internal/api/http/updates_test.go — meta extension (disabled/enabled), 404 not configured, 404 unknown source, 503 not ready, HTML by default, markdown format, default-source fallback.
  • Frontend:
    • HtmlModal.test.tsx (port of the old InfoModal test + new title prop and tabs slot coverage).
    • AppModal.test.tsx — tab switching, badge rendering, close/escape.
    • UpdatesPanel.test.tsx — empty, all-newer, mixed (separator + NEW pill), "Got it", "View all" link (present/absent), loading, error.
    • infoCookie.test.ts — both cookies, read empty, write+read roundtrip, 10y Max-Age, Path=/, SameSite=Lax.
    • client.test.tsapi.updates() 200/404/404-source/503.
    • metaSelectors.test.tscountNewerReleases (empty cookie, future cookie, mixed).

Verification (all green on feat/issue-89-recent-updates-dialogue)

Check Result
go build ./... + go build ./cmd/server
go vet ./...
go test ./... (20 packages)
gofmt -l . ✓ (no diffs)
golangci-lint run ./... ✓ (0 issues; was 10)
npx tsc --noEmit
npx eslint src ✓ (0 errors, 0 warnings)
npm test (vitest) ✓ 31 files, 215 tests
npx vite build ✓ 134.40 kB CSS, 357.84 kB JS

Decisions

Decision Choice
Trigger placement Single Information tab + one tab per registered update-check source, inside the existing dialog. Tab bar in the header, centered. Indicator = number badge on the existing "i" button.
Auto-open Information tab only (current behavior). Source tabs never auto-open.
Indicator style Number badge on "i" = sum of unread across all sources; same style on each source tab.
Default manual open Always Information (first tab). No "last viewed tab" persistence.
Dialog content Latest N releases per source (default limit: 15). Visible separator after the latest release the user has already read, with a "you last read up to vX.Y.Z" caption.
"More" button "View all releases on <platform> →" link in each source tab's footer.
Lazy load of bodies Meta carries release metadata (version, published_at) for every source. Full bodies are fetched on-demand from api.updates(source).
Cookies meshmap-lite.info.dismissed_source_hash (unchanged) + new flat meshmap-lite.updates.dismissed_published_at. Both come from a makeCookie(name) factory.
"Got it" semantics Marks the currently active tab as read. "×" close does not mark as read.
Component reuse InfoModal generalized to HtmlModal (header slot). AppModal is the tabbed variant.
Multi-source design Source interface (no UI concerns) + per-platform sub-packages. Labels live in the Manager. Cache is centralised. MVP ships only forgejo; github is a stub.
Caching In-memory only, refreshed on interval. Manager does an immediate fetch on startup.
Subscription API Subscribe(name) / SubscribeAll() on the Manager for future WebSocket push, modeled on internal/api/ws/hub.go (R/W mutex, non-blocking, drop slow consumers).
Current version per source Resolved at registration time. current_version_source: buildinfobuildinfo.Version; current_version_source: none → no comparison.
Default registration meshmap-lite auto-registered with label: Map, forgejo, base_url: https://git.skobk.in, repository: skobkin/meshmap-lite, current_version_source: buildinfo, limit: 15 when update_check.enabled=true and the source is absent from config.

🤖 Generated with Claude Code

## Summary Closes #89. Adds a "Recent updates" dialog so end users can discover what changed in `meshmap-lite` since they last used it. The dialog is **never** auto-pushed: it opens only on explicit click of the existing "i" button (which now also shows a small number badge = sum of unread release counts across all registered update-check sources). The auto-open rule for the static site information flow is preserved unchanged. The backend is designed as a **generic multi-source release checker** so the same component will later power Meshtastic firmware update notifications (a second source on GitHub). ## Backend - New `internal/updatecheck/` package: - `Source` interface (`Name`, `FetchReleases`, `ReleasesPageURL`) — **library-shaped, no UI concerns**. - `Cache` (own file) — in-memory, mutex-protected, owned by the Manager. - `Manager` — registers sources, runs one immediate fetch + an `interval` ticker per source, exposes `Snapshot`/`Names`/`Labels`/`Subscribe`/`SubscribeAll`. - `ReleaseInfo`, `UpdateSnapshot`, `SourceSpec` types in their own file. - Per-platform sub-packages: `sources/forgejo` (MVP, port of meshgo's checker) and `sources/github` (stub for the follow-up Meshtastic firmware feature — `panic` body so the package compiles and is discoverable). - Labels live in the Manager (from each `SourceSpec.Label`), not on `Source` — keeps the platform adapter a pure data fetcher. `ReleasesPageURL` is built by the adapter from `base_url + /<repo>/releases`. - New `GET /api/v1/updates?source=<name>&format=html|markdown` endpoint: - `format=html` (default) pre-renders each release body via `goldmark` (reuses `siteinfo.RenderMarkdown`). - 200: `{format, source, source_hash, releases: [{version, published_at, html_url, body}]}`. - 404 `update_check_not_configured` when the feature is disabled. - 404 `update_check_source_not_found` for an unknown source name. - 503 `update_check_not_ready` when the source has not completed its first fetch yet. - `/api/v1/meta` extension: new top-level `update_check_available: bool` and `update_check_sources?: SourceSummary[]` (each entry carries version/date metadata only — no body — so the page-load payload stays light). - New `update_check:` config block (`enabled`, `interval`, `timeout`, `sources[]` with `name/label/type/base_url/repository/current_version_source/limit`). Auto-registers the default `meshmap-lite` forgejo source when `enabled=true` and the sources block is absent; declaring it in config (or via ENV) replaces the default. - OpenAPI documentation for the new endpoint, query params, and meta fields. - Current-version resolution: `current_version_source: buildinfo` → `buildinfo.Version` at registration time; `current_version_source: none` → no comparison, snapshot just shows latest. - **Failed fetches do not poison the cache** — only successful fetches overwrite the snapshot. ## Frontend - `InfoModal` → `HtmlModal` generalization (renamed file: `InfoModal.tsx` → `HtmlModal.tsx`): adds a `title` prop (default `"Information"`) and a `tabs` slot for the header. Existing props (`content`, `error`, `loading`, `showUpdatedNotice`, `onClose`, `onDismiss`) and behavior are preserved. - New `AppModal` wrapper: renders the tabbed variant. Header layout is `[tabs centered] [close right]`. Each tab is a button; source tabs show a small number badge with that source's unread count. Active tab has `aria-current="page"`. Tabs: first is always **Information** (the static site-info flow), then one per source from `meta.update_check_sources` (e.g. **Map** for MVP; will become **Information** + **Map** + **Meshtastic** once the firmware source is added — no further wiring needed). - New `UpdatesPanel`: per-source release list component (NOT hardcoded to meshmap-lite). Renders newest-first, shows a small **NEW** pill on releases newer than the user's dismissed point, and a visible `<hr class="updates-separator">` + caption "You last read up to vX.Y.Z" after the latest release the user has already read. Footer has a "View all releases →" link (uses `source.releasesPageUrl`) and a "Got it" button. - `infoCookie.ts` refactored into a `makeCookie(name)` factory exporting both `infoDismissedCookie` (the existing cookie) and a new `updatesDismissedCookie`. Both keep the 10y Max-Age + `Path=/` + `SameSite=Lax` contract. - `web/src/api/client.ts` — new `api.updates(source, format = 'html')` method. Lazy: bodies are fetched only when the user opens a source tab. - `web/src/stores/meta.ts` — surfaces the new meta fields. Pure helper `countNewerReleases` in `metaSelectors.ts` for unread-count logic. - `web/src/stores/updates.ts` — new per-source store, keyed by source name; caches the latest fetched response so re-opening a tab is instant. - `web/src/App.tsx` wiring: `appModalOpen` + `appModalActiveTab` state; URL fragments `#/info` (always opens on Information) and `#/updates/<sourceName>` (deep-links to that source's tab). Auto-open rule: site-info source-hash change still opens on **Information**; **source tabs never auto-open**, only the badge. Dismiss handler is tab-aware (writes the appropriate cookie); the "×" close never marks anything as read. - `Header.tsx`: small `<span class="header-badge">` over the "i" icon when the cross-source total > 0. `aria-label` becomes "Site information (N new updates)" when shown. - `styles.css`: new rules for `.app-modal-tabs`, `.app-modal-tab`, `.header-badge`, `.updates-release`, `.updates-separator`, `.updates-new-pill` — with dark-theme overrides using `color-mix` (matching the pattern from the recent `show-all-topology` dark-theme work). ## Tests - Backend: - `internal/updatecheck/cache_test.go` — Get/Set roundtrip, overwrite, concurrency safety. - `internal/updatecheck/manager_test.go` — registration/dedupe, ticker behavior, immediate fetch, per-source failure isolation, `UpdateAvailable` semantics (newer/equal/older/dev/invalid-latest), `Subscribe`/`SubscribeAll` broker. - `internal/updatecheck/sources/forgejo/forgejo_test.go` — URL construction, DTO decode, `ReleasesPageURL` shape. - `internal/api/http/updates_test.go` — meta extension (disabled/enabled), 404 not configured, 404 unknown source, 503 not ready, HTML by default, markdown format, default-source fallback. - Frontend: - `HtmlModal.test.tsx` (port of the old InfoModal test + new `title` prop and `tabs` slot coverage). - `AppModal.test.tsx` — tab switching, badge rendering, close/escape. - `UpdatesPanel.test.tsx` — empty, all-newer, mixed (separator + NEW pill), "Got it", "View all" link (present/absent), loading, error. - `infoCookie.test.ts` — both cookies, read empty, write+read roundtrip, 10y Max-Age, `Path=/`, `SameSite=Lax`. - `client.test.ts` — `api.updates()` 200/404/404-source/503. - `metaSelectors.test.ts` — `countNewerReleases` (empty cookie, future cookie, mixed). ## Verification (all green on `feat/issue-89-recent-updates-dialogue`) | Check | Result | |---|---| | `go build ./...` + `go build ./cmd/server` | ✓ | | `go vet ./...` | ✓ | | `go test ./...` (20 packages) | ✓ | | `gofmt -l .` | ✓ (no diffs) | | `golangci-lint run ./...` | ✓ (0 issues; was 10) | | `npx tsc --noEmit` | ✓ | | `npx eslint src` | ✓ (0 errors, 0 warnings) | | `npm test` (vitest) | ✓ 31 files, 215 tests | | `npx vite build` | ✓ 134.40 kB CSS, 357.84 kB JS | ## Decisions | Decision | Choice | |---|---| | Trigger placement | Single **Information** tab + one tab per registered update-check source, inside the existing dialog. Tab bar in the header, centered. Indicator = number badge on the existing "i" button. | | Auto-open | **Information** tab only (current behavior). Source tabs never auto-open. | | Indicator style | Number badge on "i" = sum of unread across all sources; same style on each source tab. | | Default manual open | Always **Information** (first tab). No "last viewed tab" persistence. | | Dialog content | Latest N releases per source (default `limit: 15`). Visible separator after the latest release the user has already read, with a "you last read up to vX.Y.Z" caption. | | "More" button | "View all releases on &lt;platform&gt; →" link in each source tab's footer. | | Lazy load of bodies | Meta carries release **metadata** (version, published_at) for every source. Full bodies are fetched on-demand from `api.updates(source)`. | | Cookies | `meshmap-lite.info.dismissed_source_hash` (unchanged) + new flat `meshmap-lite.updates.dismissed_published_at`. Both come from a `makeCookie(name)` factory. | | "Got it" semantics | Marks the **currently active** tab as read. "×" close does not mark as read. | | Component reuse | `InfoModal` generalized to `HtmlModal` (header slot). `AppModal` is the tabbed variant. | | Multi-source design | `Source` interface (no UI concerns) + per-platform sub-packages. Labels live in the Manager. `Cache` is centralised. MVP ships only `forgejo`; `github` is a stub. | | Caching | In-memory only, refreshed on `interval`. Manager does an immediate fetch on startup. | | Subscription API | `Subscribe(name)` / `SubscribeAll()` on the Manager for future WebSocket push, modeled on `internal/api/ws/hub.go` (R/W mutex, non-blocking, drop slow consumers). | | Current version per source | Resolved at registration time. `current_version_source: buildinfo` → `buildinfo.Version`; `current_version_source: none` → no comparison. | | Default registration | `meshmap-lite` auto-registered with `label: Map`, forgejo, `base_url: https://git.skobk.in`, `repository: skobkin/meshmap-lite`, `current_version_source: buildinfo`, `limit: 15` when `update_check.enabled=true` and the source is absent from config. | 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(ui): add recent updates dialogue (#89)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
ca8aef3abb
Backend: new internal/updatecheck/ package (Source interface + Cache +
Manager + per-platform forgejo/github sub-packages) and a generic
GET /api/v1/updates endpoint with a Source/format query, plus meta
extension exposing update_check_sources. Auto-registers the default
meshmap-lite forgejo source when update_check.enabled=true and no
sources are declared.

Frontend: generalize InfoModal into HtmlModal; add a tabbed AppModal
wrapper, a per-source UpdatesPanel (NEW pill, separator after the
release the user has already read, 'View all' link, 'Got it' dismiss),
a makeCookie(infoCookie) factory exporting both infoDismissedCookie and
updatesDismissedCookie, an updates store with lazy api.updates() calls,
and a header badge summing unread counts across all sources. URL
fragments: #/info opens on Information, #/updates/<source> deep-links
to a source tab.

All Go and npm checks pass (go build, vet, test, gofmt, golangci-lint;
tsc, eslint, vitest 215/215, vite build).
fix(updatecheck): take write lock in publish to prevent concurrent map write
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
f3a38b5f1f
Manager.publish held subMu.RLock() but reassigned m.subs and m.all, so
two fetch goroutines (one per registered source) racing on the same
map tripped Go's runtime concurrent-map-write detector. Switch the
function to a write lock and explain the invariant in a comment.

Add TestPublishIsConcurrencySafe, a deterministic regression that
spawns N goroutines hammering publish across M sources and fails the
test process if the lock is ever weakened again. Also harden the
shared stubSource test double with a small RWMutex around its hook
field so TestFailedFetchDoesNotPoisonCache can swap the hook while a
Manager goroutine is in flight (caught by go test -race).

Verified:
  go test -race -count=1 ./internal/updatecheck/   ok
  go test -count=5 ./internal/updatecheck/        ok
  go test ./...                                   ok
  golangci-lint run ./...                         0 issues
  gofmt -l .                                      clean
fix(updates): address review findings
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
8de3cfe84b
fix(web): reposition header unread badge to overlay the i icon
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
b475f725f8
fix(web): align info modal tabs and unread badge
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
8865c37d40
skobkin merged commit 8865c37d40 into master 2026-06-15 23:27:40 +03:00
skobkin deleted branch feat/issue-89-recent-updates-dialogue 2026-06-15 23:27:40 +03:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
skobkin/meshmap-lite!92
No description provided.