Recent updates dialogue #89

Closed
opened 2026-06-13 18:31:29 +03:00 by skobkin · 3 comments
Owner
  • create update checker similar to update_checker.go from MeshGo
  • cache it's results for the update check period
  • return to the users in one of two formats like in /api/v1/info
  • show HTML version in similar dialog (better to extract and re-use the component) as "Site Information"

Open questions:

  • Where to put the trigger to open this dialogue? Header is already crowded
    • Using logo is a bad idea since people are used to headers leading to the "main page"
    • Adding new tab is a bad idea since it takes too much horizontal space on mobile devices
    • Adding yet another button on the right will make collision with tab buttons on mobile decides too
- create update checker similar to [`update_checker.go`](https://git.skobk.in/skobkin/meshgo/raw/branch/master/internal/app/update_checker.go) from MeshGo - cache it's results for the update check period - return to the users in one of two formats like in `/api/v1/info` - show HTML version in similar dialog (better to extract and re-use the component) as "Site Information" Open questions: - Where to put the trigger to open this dialogue? Header is already crowded - Using logo is a bad idea since people are used to headers leading to the "main page" - Adding new tab is a bad idea since it takes too much horizontal space on mobile devices - Adding yet another button on the right will make collision with tab buttons on mobile decides too
skobkin self-assigned this 2026-06-13 18:31:29 +03:00
Collaborator

Concrete shape for this — the body is well-scoped (4 bullets + 3 open trigger-placement questions), so the work is mostly picking a small, useful slice and answering the trigger question.

Two phases, two PRs

Phase Scope Outcome
1. Backend + cache Port the update_checker.go pattern from meshgo/internal/app/ into a similar internal/updates/ package; cache per update_check_period; expose via GET /api/v1/info (or a new updates key) Server-side ready; no UI yet
2. Frontend dialog Re-use the existing "Site Information" dialog component (extract it from wherever it lives today if it isn't already a shared component); render a similar dialog with version + release notes + update-available state User-visible feature

I'd suggest landing phase 1 first, even with no UI, because:

  • It exercises the cache-period behaviour, which is the only stateful piece (you don't want to hit git.skobk.in/api/v1/repos/.../releases?limit=5 on every page load).
  • The /api/v1/info shape is the wire contract for any future consumer — even a future CLI / health-check probe will want it.
  • The UI PR then becomes a pure render change with no new API surface, which is much easier to review.

The port — what to copy from meshgo/internal/app/update_checker.go

meshgo piece meshmap-lite equivalent
UpdateChecker struct holding endpoint URL, interval, current version, *http.Client Same struct shape, but pointed at git.skobk.in/api/v1/repos/skobkin/meshmap-lite/releases?draft=false&pre-release=false&limit=5
Start(ctx) goroutine that ticks on interval Identical — the only difference is the interval source (meshgo reads it from a config field; meshmap-lite has a YAML config)
LastResult() returns the cached UpdateSnapshot Identical — expose it via /api/v1/info or /api/v1/updates
Skip-if-cache-fresh check Same; pick the cache TTL from the same config key the rest of meshmap-lite uses for cache durations (looks like web.map.topology_cache_ttl per #78 — keep naming consistent)

The meshgo version is small (single file, no deps beyond net/http + encoding/json). A direct port should be ~150 LoC.

Trigger placement (the three open questions, with one answer each)

The three options you listed are all real, but they boil down to "where's the affordance on mobile vs. desktop". A small variant usually beats a new dedicated control:

  1. Header logo — bad, you ruled this out and I'd agree. Users expect logo → home.
  2. New tab — bad on mobile (horizontal real estate), but actually fine on desktop. A tab that shows "Updates" with a small badge dot when an update is available works in a desktop layout. It fails the mobile test, so I'd reject it as the only trigger, but I'd accept it as a desktop-only secondary trigger.
  3. Another button in the header right cluster — the actual right answer, with a twist: use the existing settings/info area, not a new top-level button. If there's already a "?" / "i" / "About" affordance anywhere in the header (the body mentions a "Site Information" dialog, which implies something already opens it), the "Recent updates" dialog is one entry in that affordance, not a peer of it.

That keeps the trigger placement decision out of the new feature and reuses an existing UX hook. Concrete shape:

  • If "Site Information" is currently a settings-page entry → add a "Recent updates" row beneath it.
  • If "Site Information" is currently a header icon (e.g. a ? or i) → make it a small dropdown / popover with two entries: "Site Information" and "Recent updates".
  • The popover approach is mobile-friendly because it can become a bottom-sheet via CSS media query without changing the data model.

The cache invalidation question

Meshgo's update_checker caches for interval (12h by default). For meshmap-lite I'd suggest the same, with one tweak: also invalidate when the user opens the dialog. The user opening the dialog is a strong signal they care about the current state, and a refresh-on-open costs one HTTP call. Implementation: GET /api/v1/updates?force=true skips the cache. The frontend hits the forced endpoint on dialog open; the regular polling tick still hits the cached endpoint.

What to do about the "version comparison" edge case

If the user is on a build that doesn't match any release tag (e.g. master between releases, or a fork), current_version is unknown and the comparison logic shouldn't claim "you're up to date" or "an update is available" — it should just say "current version unknown" and link to the releases page. One extra branch in the comparison; ~10 LoC.

Smallest first PR

  1. Add internal/updates/updates.go with the ported checker. Register an UpdateSnapshot endpoint on the existing router (probably internal/api/http/router.go).
  2. Update config.example.yaml with web.updates.endpoint, web.updates.interval, and web.updates.cache_ttl keys. Document them in openapi.yaml.
  3. Add a httptest for the cache (fresh entry within TTL is served from cache; expired entry re-fetches; forced entry always re-fetches).
  4. Stop. Don't touch the frontend yet — phase 2 is its own PR.

One open question to settle before phase 1 lands

Should the same UpdateSnapshot also report meshmap-lite's own server version (the running build's commit SHA or build date), so the dialog can show "you're running commit abc1234, latest is def5678"? Meshgo does this. If yes, the endpoint needs access to a version variable populated at build time via -ldflags "-X main.version=$(git rev-parse --short HEAD)". If no, the "current version" is always blank for self-hosters. I'd say yes — it's two lines of build config and makes the dialog actually useful for self-hosters.

Concrete shape for this — the body is well-scoped (4 bullets + 3 open trigger-placement questions), so the work is mostly picking a small, useful slice and answering the trigger question. **Two phases, two PRs** | Phase | Scope | Outcome | |---|---|---| | 1. Backend + cache | Port the `update_checker.go` pattern from `meshgo/internal/app/` into a similar `internal/updates/` package; cache per `update_check_period`; expose via `GET /api/v1/info` (or a new `updates` key) | Server-side ready; no UI yet | | 2. Frontend dialog | Re-use the existing "Site Information" dialog component (extract it from wherever it lives today if it isn't already a shared component); render a similar dialog with version + release notes + update-available state | User-visible feature | I'd suggest landing phase 1 first, even with no UI, because: - It exercises the cache-period behaviour, which is the only stateful piece (you don't want to hit `git.skobk.in/api/v1/repos/.../releases?limit=5` on every page load). - The `/api/v1/info` shape is the wire contract for any future consumer — even a future CLI / health-check probe will want it. - The UI PR then becomes a pure render change with no new API surface, which is much easier to review. **The port — what to copy from `meshgo/internal/app/update_checker.go`** | meshgo piece | meshmap-lite equivalent | |---|---| | `UpdateChecker` struct holding endpoint URL, interval, current version, `*http.Client` | Same struct shape, but pointed at `git.skobk.in/api/v1/repos/skobkin/meshmap-lite/releases?draft=false&pre-release=false&limit=5` | | `Start(ctx)` goroutine that ticks on `interval` | Identical — the only difference is the interval source (meshgo reads it from a config field; meshmap-lite has a YAML config) | | `LastResult()` returns the cached `UpdateSnapshot` | Identical — expose it via `/api/v1/info` or `/api/v1/updates` | | Skip-if-cache-fresh check | Same; pick the cache TTL from the same config key the rest of meshmap-lite uses for cache durations (looks like `web.map.topology_cache_ttl` per #78 — keep naming consistent) | The meshgo version is small (single file, no deps beyond `net/http` + `encoding/json`). A direct port should be ~150 LoC. **Trigger placement (the three open questions, with one answer each)** The three options you listed are all real, but they boil down to "where's the affordance on mobile vs. desktop". A small variant usually beats a new dedicated control: 1. **Header logo** — bad, you ruled this out and I'd agree. Users expect logo → home. 2. **New tab** — bad on mobile (horizontal real estate), but actually fine on desktop. A tab that shows "Updates" with a small badge dot when an update is available works in a desktop layout. It fails the mobile test, so I'd reject it as the *only* trigger, but I'd accept it as a desktop-only *secondary* trigger. 3. **Another button in the header right cluster** — the actual right answer, with a twist: **use the existing settings/info area**, not a new top-level button. If there's already a "?" / "i" / "About" affordance anywhere in the header (the body mentions a "Site Information" dialog, which implies something already opens it), the "Recent updates" dialog is one entry in that affordance, not a peer of it. That keeps the trigger placement decision *out* of the new feature and reuses an existing UX hook. Concrete shape: - If "Site Information" is currently a settings-page entry → add a "Recent updates" row beneath it. - If "Site Information" is currently a header icon (e.g. a `?` or `i`) → make it a small dropdown / popover with two entries: "Site Information" and "Recent updates". - The popover approach is mobile-friendly because it can become a bottom-sheet via CSS media query without changing the data model. **The cache invalidation question** Meshgo's update_checker caches for `interval` (12h by default). For meshmap-lite I'd suggest the same, with one tweak: **also invalidate when the user opens the dialog**. The user opening the dialog is a strong signal they care about the current state, and a refresh-on-open costs one HTTP call. Implementation: `GET /api/v1/updates?force=true` skips the cache. The frontend hits the forced endpoint on dialog open; the regular polling tick still hits the cached endpoint. **What to do about the "version comparison" edge case** If the user is on a build that doesn't match any release tag (e.g. `master` between releases, or a fork), `current_version` is unknown and the comparison logic shouldn't claim "you're up to date" or "an update is available" — it should just say "current version unknown" and link to the releases page. One extra branch in the comparison; ~10 LoC. **Smallest first PR** 1. Add `internal/updates/updates.go` with the ported checker. Register an `UpdateSnapshot` endpoint on the existing router (probably `internal/api/http/router.go`). 2. Update `config.example.yaml` with `web.updates.endpoint`, `web.updates.interval`, and `web.updates.cache_ttl` keys. Document them in `openapi.yaml`. 3. Add a `httptest` for the cache (fresh entry within TTL is served from cache; expired entry re-fetches; forced entry always re-fetches). 4. Stop. Don't touch the frontend yet — phase 2 is its own PR. **One open question to settle before phase 1 lands** Should the same `UpdateSnapshot` also report **meshmap-lite's own server version** (the running build's commit SHA or build date), so the dialog can show "you're running commit `abc1234`, latest is `def5678`"? Meshgo does this. If yes, the endpoint needs access to a `version` variable populated at build time via `-ldflags "-X main.version=$(git rev-parse --short HEAD)"`. If no, the "current version" is always blank for self-hosters. I'd say **yes** — it's two lines of build config and makes the dialog actually useful for self-hosters.
Author
Owner

Implementation plan

Context

End users of a self-hosted meshmap-lite instance have no way to learn what changed in the app since they last used it. They aren't the operator, so "a new version is out" isn't directly actionable — but they're often curious about new features and want to discover release notes when they feel like it. The goal is a subtle, opt-in "what's new" surface that mirrors the existing Site Information dialog's role and re-uses its component, but is never auto-pushed onto the user: the dialog opens only on explicit click, and the only signal of new content is a small number badge on the existing "i" button.

The backend is being designed from day one as a generic multi-source release-checker: the same component will later power Meshtastic firmware update notifications (a separate source on GitHub), so the design introduces a Source interface and per-platform implementations. The cache is centralised at the manager level (one snapshot per registered source) so we hit upstream APIs at most once per interval regardless of how many users are connected.

Approach

Backend (Go) — new internal/updatecheck/ package

File layout (per-platform sources are isolated sub-packages for context management):

internal/updatecheck/
├── types.go            # ReleaseInfo, UpdateSnapshot, SourceSpec
├── source.go           # Source interface
├── cache.go            # in-memory cache, mutex-protected (own file)
├── cache_test.go
├── manager.go          # Manager: sources + cache + ticker + current version
├── manager_test.go
└── sources/
    ├── forgejo/
    │   ├── forgejo.go        # MVP implementation, port of meshgo's checker
    │   └── forgejo_test.go
    └── github/
        └── github.go         # stub for the follow-up Meshtastic firmware
                              # feature — `errors.New("github source not implemented")`
                              # body so the package compiles + is discoverable

Types (types.go):

  • ReleaseInfo{Version, Body, HTMLURL, PublishedAt} — single release from any platform.
  • UpdateSnapshot{CurrentVersion string, Latest ReleaseInfo, Releases []ReleaseInfo, UpdateAvailable bool, CheckedAt time.Time, SourceHash string} — what the cache stores and what /api/v1/updates returns.
  • SourceSpec — config-side description of a registered source (name, current version source, label, etc.).

Interface (source.go):

type Source interface {
    Name() string
    Label() string                          // user-facing tab label, e.g. "Map" / "Meshtastic"
    FetchReleases(ctx context.Context) ([]ReleaseInfo, error)
    ReleasesPageURL() string                // "View all releases" link, e.g. https://git.skobk.in/skobkin/meshmap-lite/releases
}

The interface is intentionally tiny: it returns releases plus a couple of display facts the frontend needs. Current-version comparison, hashing, and UpdateAvailable computation are the Manager's responsibility (so a source stays a pure data fetcher with no opinion about "what counts as newer"). Failed fetches don't poison the cache — only successful fetches overwrite the snapshot. ReleasesPageURL is built by the platform adapter from base_url + repository (with a sensible default for GitHub), so the frontend never has to know which platform a source came from.

Cache (cache.go): own file, holds:

type Cache struct {
    mu    sync.RWMutex
    items map[string]UpdateSnapshot  // keyed by source name
    known map[string]bool            // whether a snapshot exists yet
}
func (c *Cache) Get(name string) (UpdateSnapshot, bool)
func (c *Cache) Set(name string, snap UpdateSnapshot)

The Manager is the only writer; HTTP handlers are readers. Snapshot is held in memory only — no SQLite persistence, no restart durability. This is exactly what the issue means by "cache its results for the update check period" and matches the meshgo reference.

Manager (manager.go):

  • Manager{sources map[string]registeredSource, cache *Cache, interval time.Duration, httpClient *http.Client, logger *slog.Logger}.
  • registeredSource{Source, CurrentVersion string} — current version can be empty when the platform has no concept of "what's installed" (e.g. Meshtastic firmware, where the user is just informed of latest).
  • Register(name string, src Source, currentVersion string) error — called at startup.
  • Start(ctx context.Context) error — spawns one goroutine per source. Each goroutine does an immediate fetch, then ticks on interval. On each successful fetch: compute SourceHash (SHA-256 of Latest.Version || "\n" || Latest.PublishedAt), compute UpdateAvailable (semver compare if both versions are valid; "current is dev" → always true; "current is empty/empty-target" → omit UpdateAvailable), call cache.Set(name, snap).
  • Snapshot(name string) (UpdateSnapshot, bool) — read-through accessor for the HTTP handler.
  • Names() []string — for /api/v1/meta to advertise available sources.

Per-platform implementations:

  • sources/forgejo/forgejo.go — port of meshgo/internal/app/update_checker.go (the same forgejoRelease JSON DTO, Accept: application/json, ?limit= from config, semver compare via golang.org/x/mod/semver). The adapter constructs the URL from base_url + /api/v1/repos/<repository>/releases?draft=false&pre-release=false&limit=<limit> and exposes ReleasesPageURL as base_url + /<repository>/releases.
  • sources/github/github.go — stub package for MVP. Single file with the Source interface implemented but FetchReleases returns errors.New("github source not implemented"). The package compiles, the registration site is reachable, and the Meshtastic firmware follow-up can flesh it out without touching the manager. ReleasesPageURL for GitHub is https://github.com/<repository>/releases (regardless of base_url, which is only used for the API endpoint).

Config (internal/config/types.go + defaults.go + validate.go)

update_check:
  enabled: true            # master switch — when false, manager is not started
  interval: 12h
  timeout: 15s
  sources:
    - name: meshmap-lite        # auto-registered with these defaults if absent (see below)
      label: Map
      type: forgejo
      base_url: 'https://git.skobk.in'
      repository: 'skobkin/meshmap-lite'
      current_version_source: buildinfo   # special key: pull from internal/buildinfo
      limit: 15
    # Future follow-up, do not enable in this issue:
    # - name: meshtastic-firmware
    #   label: Meshtastic
    #   type: github
    #   repository: 'meshtastic/firmware'
    #   current_version_source: none
    #   limit: 10

The platform adapter (forgejo, github, …) owns the URL shape. Users only set base_url + repository (+ limit); the adapter composes the API endpoint at runtime. base_url is required for forgejo; for github it defaults to https://api.github.com.

current_version_source values: buildinfo (use internal/buildinfo.Version), none (no comparison, the snapshot just shows the latest), or a literal string. Defaults: enabled=true, interval=12h, timeout=15s, limit=15.

Default registration: when update_check.enabled=true and no meshmap-lite source is declared in config, the wiring layer auto-registers a meshmap-lite source with the defaults shown above (label: Map, type: forgejo, base_url: https://git.skobk.in, repository: skobkin/meshmap-lite, current_version_source: buildinfo, limit: 15). Declaring a source with name: meshmap-lite in config (or via ENV override) replaces the default entry entirely. To turn the feature off, set update_check.enabled: false (or set update_check.sources: []).

Validation: at least one source required when enabled=true; source names must be unique; source type must match a registered sub-package.

Wiring (internal/app/app.go)

  • After config load: construct updatecheck.NewCache(), updatecheck.NewManager(...).
  • For each configured source: instantiate the right sub-package (sources/forgejo.New(...) or sources/github.New(...)) and call manager.Register(name, src, currentVersion).
  • Resolve current_version_source: buildinfo to buildinfo.Version at registration time.
  • manager.Start(ctx) alongside the MQTT client.
  • Pass the *updatecheck.Manager to the HTTP server via httpapi.Config.Updates *updatecheck.Manager.

HTTP API

  • GET /api/v1/updates?source=meshmap-lite&format=html|markdown (internal/api/http/handlers.go):
    • source defaults to the only/first registered source.
    • format=html (default) → each release's body is pre-rendered by goldmark (reuse siteinfo.RenderMarkdown).
    • format=markdown → raw markdown bodies.
    • 200: {format, source_hash, releases: [{version, published_at, html_url, body}]}.
    • 404 update_check_not_configured when update_check.enabled=false.
    • 404 update_check_source_not_found when the named source isn't registered.
    • 503 update_check_not_ready when the source has not completed its first fetch yet.
  • /api/v1/meta extension (internal/api/http/dto.go): replace the simple booleans with a update_check_sources []SourceSummary array, each entry {name, source_hash, current_version, latest_version, update_available}. Add a top-level update_check_available bool for "is the feature on at all". Omit the array (or send null) when disabled.
  • OpenAPI (internal/apidocs/assets/openapi.yaml): document the new endpoint, the ?source= and ?format= params, and the meta extension.

Frontend (TypeScript / Preact)

  1. Generalize InfoModalHtmlModal in web/src/components/InfoModal.tsx:
    • Replace the hardcoded <h2>Site information</h2> with a header slot (or tabs slot, see step 8) controlled by props.
    • The component already accepts content (pre-rendered HTML), error, loading, showUpdatedNotice, onClose, onDismiss — these stay. Add an optional title prop for non-tab use.
  2. New AppModal wrapper in web/src/components/AppModal.tsx that renders the tabbed version:
    • Header layout: [tab bar centered] [close button right]. No <h2>.
    • Tabs are built dynamically: the first tab is always Information (the static site-info flow); subsequent tabs come from meta.update_check_sources (one per source, labeled with the source's label config — e.g. Map, and in the future Meshtastic). For MVP there are 2 tabs; once the Meshtastic firmware source is added, the dialog shows 3 tabs without any further wiring.
    • Each update-source tab button shows a small number badge (same style as the header "i" badge) with that source's unread count. The Information tab is the existing flow and has no badge.
    • Active tab is controlled by a prop.
    • The body and footer are passed in as slots, so all tabs share the dialog chrome (backdrop, Escape, click-outside, full-screen on narrow viewports).
  3. Cookie helper in web/src/utils/updatesCookie.ts:
    • Per-source cookie: meshmap-lite.updates.<sourceName>.dismissed_published_at — stores the highest published_at ISO timestamp the user has marked as read (default empty).
    • Mirrors infoCookie.ts (10y Max-Age, SameSite=Lax, Path=/).
    • For MVP only meshmap-lite is registered, so the helper only ever needs to round-trip that one name — but the design accommodates future sources.
  4. API types in web/src/api/types.ts:
    interface UpdatesResponse {
      format: 'html' | 'markdown'
      source_hash: string
      releases: { version: string; published_at: string; html_url: string; body: string }[]
    }
    interface SourceSummary {
      name: string
      label: string                                    // matches the source's `label` config
      source_hash?: string
      current_version?: string
      latest_version?: string
      update_available?: boolean
      releases: { version: string; published_at: string }[]   // metadata only — no body
    }
    
    Extend Meta with update_check_available: boolean and update_check_sources?: SourceSummary[]. The meta's releases array carries just version + published_at per release — enough to render a version/date list and to compute the unread count, but without the markdown body, so the page-load payload stays light. The full bodies are fetched on-demand from api.updates(source).
  5. API client in web/src/api/client.ts — add api.updates(source, format = 'html') taking the source name.
  6. Meta store in web/src/stores/meta.ts — surface the new fields, including the per-source releases metadata.
  7. Updates store in web/src/stores/updates.ts — a small per-source map. Keyed by source name, value holds: { releases: ReleaseInfo[] (with body), loading, error }. Populated lazily when the user opens a tab. Cookie reads/writes also happen here.
  8. App.tsx wiring:
    • On meta load, if update_check_available and update_check_sources is non-empty, do not pre-fetch bodies. Just compute the unread count per source from meta.update_check_sources[*].releases + the per-source cookie, sum them for the header badge, and stash the cookie values in stores/updates.ts.
    • When the user opens a source tab, lazily call api.updates(source, 'html') and stash the full response in stores/updates.ts. Subsequent opens of the same tab use the cached response.
    • State: appModalOpen, appModalActiveTab ('information' | <sourceName>).
    • Auto-open rules (preserve current site-info behavior):
      • Site info source hash changed (or never dismissed) → open dialog on Information tab.
      • Any source unread count > 0 → no auto-open, just the badge.
    • Manual open: always opens on Information (the first tab). Update-source tabs only show unread badges — the user clicks them to read at their own pace. There is no "last viewed tab" persistence.
    • Dismiss handler is tab-aware: writing the info cookie (existing behavior) when the Information tab is active, or the source's updates cookie (meshmap-lite.updates.<sourceName>.dismissed_published_at) when a source tab is active. "×" close never marks anything as read.
    • URL fragment: #/info keeps the current behavior (opens the dialog on Information). Add #/updates/<sourceName> for explicit deep-link to a source tab (e.g. #/updates/meshmap-lite).
  9. Header "i" button in web/src/components/Header.tsx:
    • Keep the existing <a href="#/info"> anchor.
    • When the sum of unread counts across all sources > 0, render a small <span class="header-badge">{n}</span> absolutely positioned over the icon (Pico --pico-del-color or similar).
    • aria-label becomes "Site information (N new updates)" when the badge is shown, with N being the cross-source total.
    • The total is generic from day one: sum(source.unreadCount for source in meta.update_check_sources). When the second source is added, the badge automatically reflects it.
  10. Updates tab content component in web/src/components/UpdatesPanel.tsx:
    • Props: source: { name, label, releasesPageUrl }, releases (with body, from the store), dismissedPublishedAt (from cookie), onDismiss, loading, error.
    • The same component renders any source's release list — it is not hardcoded to meshmap-lite. The label shows as the panel's <h2> (or as a small caption above the version list); the "View all" link points at source.releasesPageUrl (e.g. "View all releases on Forgejo →" / "View all releases on GitHub →"), and the onDismiss callback writes the source's own cookie.
    • For each release (newest first), render a <section class="updates-release"> with <h3>version</h3>, <time> (localized), and a <div class="updates-body" dangerouslySetInnerHTML>.
    • Find the latest release whose published_at <= dismissedPublishedAt; if found and it isn't the last in the list, render a visible separator after it (<hr class="updates-separator"> plus a small caption "You last read up to vX.Y.Z" — only when the user is "behind" by at least one release).
    • Releases newer than dismissedPublishedAt get a subtle "new" visual treatment (e.g., a small "NEW" pill next to the version).
    • Footer: a "View all releases on →" link (source.releasesPageUrl) and the "Got it" button (which calls onDismiss).
  11. CSS in web/src/styles.css — new rules:
    • .app-modal-tabs, .app-modal-tab, .app-modal-tab[aria-current="page"] — tab bar layout, centered, equal-width buttons, focus rings, dark-theme parity via color-mix (consistent with the recent show-all-topology dark-theme work).
    • .header-badge — small red circle, absolutely positioned top-right of the icon, with min-width for double digits.
    • .updates-release, .updates-separator, .updates-new-pill — release list styling, narrow-viewport friendly.
    • Dark-theme overrides for all of the above.
  12. Tests:
    • Backend: port meshgo's update_checker_test.go → split into cache_test.go (Get/Set/concurrency) and manager_test.go (ticker + multi-source registration + UpdateAvailable cases). Add sources/forgejo/forgejo_test.go (HTTP fetch + DTO decode, URL construction from base_url + repository + limit). Handler tests for /api/v1/updates (200, 404 disabled, 404 source, 503 not ready, format=markdown) and the meta extension. A manager test that registers a stub Source (not the GitHub package) to verify the orchestrator's behavior is platform-agnostic.
    • Frontend: extend InfoModal.test.tsx to cover the tabbed variant; new UpdatesPanel.test.tsx for separator / "new" / "Got it" / "View all" behavior; new test for the header badge rendering (with multi-source total).

Decisions (from brainstorm)

Decision Choice
Trigger placement A single Information tab + one tab per registered update-check source (e.g. Map), inside the existing dialog. Tab bar in the header line, centered. Indicator is a number badge on the existing "i" button — no new header element.
Auto-open Information tab only (current behavior). Source tabs never auto-open.
Indicator style Number badge (e.g. "3") on the "i" button = sum of unread counts across all sources; same style of badge on each source-tab button (per-source unread).
Default manual open The dialog always opens on Information (first tab). No "last viewed tab" persistence.
Dialog content Latest N releases per source (default limit: 15, configurable). 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 →" link in each source tab's footer; URL built by the platform adapter from base_url + repository (or a GitHub default).
Lazy load of bodies Meta carries release metadata (version, published_at) for every source. The full release notes (markdown bodies) are fetched on-demand from api.updates(source) when the user opens that source's tab.
Cookies meshmap-lite.info.dismissed_source_hash (unchanged) and a new per-source meshmap-lite.updates.<name>.dismissed_published_at (10y Max-Age, SameSite=Lax).
"Got it" semantics Marks the currently active tab as read. "×" close does not mark as read.
Component reuse InfoModal is generalized to HtmlModal (adds header slot, keeps existing props). New AppModal is the tabbed variant.
Multi-source design Source interface (Name, Label, FetchReleases, ReleasesPageURL) + per-platform sub-packages. Cache is centralised at the manager level (one snapshot per source, separate cache.go file). The MVP ships only the forgejo source implementation; github is a stub package so the follow-up Meshtastic firmware checker can plug in without manager changes.
Caching In-memory only, refreshed on interval. Cache lives in cache.go (own file) and is owned by the Manager. Per-source failure doesn't poison the cache. Manager does an immediate fetch on startup so the cache is warm for the first request.
Current version per source Resolved at registration time. current_version_source: buildinfobuildinfo.Version. current_version_source: none → no comparison, the snapshot just exposes the latest.
Config shape Each source declares label, type, base_url (required for forgejo, optional for github), repository, current_version_source, limit. The platform adapter owns the URL construction — no full endpoint field.
Default registration meshmap-lite is auto-registered with sensible defaults (label: Map, type: 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. Declaring it in config (or via ENV override) replaces the default.

Files to modify

  • internal/updatecheck/ — new package (see file layout above)
  • internal/config/types.go, internal/config/defaults.go, internal/config/validate.go — new UpdateCheckConfig block with sources array
  • internal/app/app.go — wire the manager, pass to HTTP server
  • internal/api/http/server.go, internal/api/http/handlers.go, internal/api/http/dto.go, internal/api/http/routes.go — new endpoint, meta extension, route
  • internal/apidocs/assets/openapi.yaml — document the new endpoint, ?source=, ?format=, and meta fields
  • web/src/components/InfoModal.tsx → rename / generalize to HtmlModal.tsx (header slot prop)
  • web/src/components/AppModal.tsx — new tabbed wrapper
  • web/src/components/UpdatesPanel.tsx — new content component
  • web/src/components/Header.tsx — render the badge on the "i" button
  • web/src/utils/updatesCookie.ts — new per-source cookie helper (mirror of infoCookie.ts)
  • web/src/stores/updates.ts — new small store (snapshot of the latest fetched response, unread count)
  • web/src/stores/meta.ts — surface the new meta fields
  • web/src/api/types.ts, web/src/api/client.ts — types + client method
  • web/src/App.tsx — modal state, auto-open rules, dismiss handlers, URL fragments
  • web/src/styles.css — new rules for tabs, badge, updates panel
  • web/src/components/InfoModal.test.tsx → split into HtmlModal.test.tsx + AppModal.test.tsx
  • web/src/components/UpdatesPanel.test.tsx — new (generic per source, not meshmap-lite specific)
  • internal/api/http/handlers_test.go — extend with /api/v1/updates cases
  • internal/config/defaults.go — add the auto-registration of the meshmap-lite default source when the config block is empty
  • internal/config/validate.go — when enabled=true, if sources is empty, silently fall back to the default; if it has entries, the user-provided entries win. Reject duplicate names. Reject unknown type values.

Verification

  1. Unit tests: go test ./... (backend) and npm test (frontend) — all pass.
  2. Manager / cache tests: register a stub Source that returns canned releases, verify (a) the ticker triggers fetches, (b) the cache holds the last successful snapshot, (c) a failing source leaves the previous snapshot intact, (d) two sources don't share state, (e) the manager does an immediate fetch on startup so the first request after boot is served from cache.
  3. Default registration: with a config that has update_check.enabled: true and no sources block, the meshmap-lite source is registered with the documented defaults. Adding a sources: block (with or without a meshmap-lite entry) overrides; setting update_check.enabled: false (or sources: []) disables it.
  4. Manual end-to-end with a real release on git.skobk.in/skobkin/meshmap-lite:
    • Start the server with update_check.enabled=true (no explicit sources block).
    • Open the SPA → the "i" button shows a number badge equal to the count of releases newer than the cookie (or full count on first visit, when the cookie is empty).
    • Click "i" → dialog opens on the Information tab (the static flow). The Map tab shows an unread badge with the same count.
    • Click the Map tab → bodies are fetched lazily. Releases are rendered newest-first; the separator appears after the latest release that's <= cookie.published_at (skip this on first visit — no separator).
    • The "View all releases on Forgejo →" link goes to the project's releases page.
    • Click "Got it" → dialog closes, meshmap-lite.updates.meshmap-lite.dismissed_published_at cookie is written, badge clears on next load.
    • Re-open the dialog → still opens on Information (manual-open rule). Releases are no longer marked "NEW" once the Map tab is opened again.
  5. Site info flow still works: edit the configured web.info.file (or trigger a hash change), reload → dialog auto-opens on Information tab with the existing "updated since you last dismissed it" notice. The "i" badge for updates is unaffected.
  6. Multi-source readiness: with the stub github source added to the config and a real in-process test handler returning 200, the SPA's meta.update_check_sources should contain both meshmap-lite and meshtastic-firmware entries, the dialog renders three tabs (Information, Map, Meshtastic), and GET /api/v1/updates?source=meshtastic-firmware returns the stub's data without the manager having to be re-architected. (The stub GitHub FetchReleases is expected to fail for MVP; this verifies the failure-doesn't-poison-cache contract, not real GitHub behavior.)
  7. Cross-source badge: with two sources each having N unread releases, the header "i" badge shows 2N, while each tab badge shows its own N.
  8. Lazy load payload: opening the SPA on first visit only triggers GET /api/v1/meta — no GET /api/v1/updates until the user opens a source tab. Confirmed via the browser DevTools network tab.
  9. Mobile (≤980px): tab bar still readable, badge doesn't overflow, modal becomes full-screen as it does today.
  10. Disabled / no snapshot: with update_check.enabled=false, the "i" button shows no badge, meta.update_check_available is false, and /api/v1/updates returns 404 update_check_not_configured. With enabled=true but a never-completed check, the endpoint returns 503 update_check_not_ready and the "i" button shows no badge.
## Implementation plan ### Context End users of a self-hosted `meshmap-lite` instance have no way to learn what changed in the app since they last used it. They aren't the operator, so "a new version is out" isn't directly actionable — but they're often curious about new features and want to discover release notes when they feel like it. The goal is a subtle, opt-in "what's new" surface that mirrors the existing **Site Information** dialog's role and re-uses its component, but is **never** auto-pushed onto the user: the dialog opens only on explicit click, and the only signal of new content is a small number badge on the existing "i" button. The backend is being designed from day one as a **generic multi-source release-checker**: the same component will later power Meshtastic firmware update notifications (a separate source on GitHub), so the design introduces a `Source` interface and per-platform implementations. The cache is centralised at the manager level (one snapshot per registered source) so we hit upstream APIs at most once per interval regardless of how many users are connected. ### Approach #### Backend (Go) — new `internal/updatecheck/` package File layout (per-platform sources are isolated sub-packages for context management): ``` internal/updatecheck/ ├── types.go # ReleaseInfo, UpdateSnapshot, SourceSpec ├── source.go # Source interface ├── cache.go # in-memory cache, mutex-protected (own file) ├── cache_test.go ├── manager.go # Manager: sources + cache + ticker + current version ├── manager_test.go └── sources/ ├── forgejo/ │ ├── forgejo.go # MVP implementation, port of meshgo's checker │ └── forgejo_test.go └── github/ └── github.go # stub for the follow-up Meshtastic firmware # feature — `errors.New("github source not implemented")` # body so the package compiles + is discoverable ``` **Types (`types.go`):** - `ReleaseInfo{Version, Body, HTMLURL, PublishedAt}` — single release from any platform. - `UpdateSnapshot{CurrentVersion string, Latest ReleaseInfo, Releases []ReleaseInfo, UpdateAvailable bool, CheckedAt time.Time, SourceHash string}` — what the cache stores and what `/api/v1/updates` returns. - `SourceSpec` — config-side description of a registered source (name, current version source, label, etc.). **Interface (`source.go`):** ```go type Source interface { Name() string Label() string // user-facing tab label, e.g. "Map" / "Meshtastic" FetchReleases(ctx context.Context) ([]ReleaseInfo, error) ReleasesPageURL() string // "View all releases" link, e.g. https://git.skobk.in/skobkin/meshmap-lite/releases } ``` The interface is intentionally tiny: it returns releases plus a couple of display facts the frontend needs. Current-version comparison, hashing, and `UpdateAvailable` computation are the **Manager's** responsibility (so a source stays a pure data fetcher with no opinion about "what counts as newer"). Failed fetches don't poison the cache — only successful fetches overwrite the snapshot. `ReleasesPageURL` is built by the platform adapter from `base_url` + `repository` (with a sensible default for GitHub), so the frontend never has to know which platform a source came from. **Cache (`cache.go`):** own file, holds: ```go type Cache struct { mu sync.RWMutex items map[string]UpdateSnapshot // keyed by source name known map[string]bool // whether a snapshot exists yet } func (c *Cache) Get(name string) (UpdateSnapshot, bool) func (c *Cache) Set(name string, snap UpdateSnapshot) ``` The Manager is the only writer; HTTP handlers are readers. Snapshot is held in memory only — no SQLite persistence, no restart durability. This is exactly what the issue means by "cache its results for the update check period" and matches the meshgo reference. **Manager (`manager.go`):** - `Manager{sources map[string]registeredSource, cache *Cache, interval time.Duration, httpClient *http.Client, logger *slog.Logger}`. - `registeredSource{Source, CurrentVersion string}` — current version can be empty when the platform has no concept of "what's installed" (e.g. Meshtastic firmware, where the user is just informed of latest). - `Register(name string, src Source, currentVersion string) error` — called at startup. - `Start(ctx context.Context) error` — spawns one goroutine per source. Each goroutine does an immediate fetch, then ticks on `interval`. On each successful fetch: compute `SourceHash` (SHA-256 of `Latest.Version || "\n" || Latest.PublishedAt`), compute `UpdateAvailable` (semver compare if both versions are valid; "current is dev" → always `true`; "current is empty/empty-target" → omit `UpdateAvailable`), call `cache.Set(name, snap)`. - `Snapshot(name string) (UpdateSnapshot, bool)` — read-through accessor for the HTTP handler. - `Names() []string` — for `/api/v1/meta` to advertise available sources. **Per-platform implementations:** - `sources/forgejo/forgejo.go` — port of `meshgo/internal/app/update_checker.go` (the same `forgejoRelease` JSON DTO, `Accept: application/json`, `?limit=` from config, semver compare via `golang.org/x/mod/semver`). The adapter constructs the URL from `base_url + /api/v1/repos/<repository>/releases?draft=false&pre-release=false&limit=<limit>` and exposes `ReleasesPageURL` as `base_url + /<repository>/releases`. - `sources/github/github.go` — stub package for MVP. Single file with the `Source` interface implemented but `FetchReleases` returns `errors.New("github source not implemented")`. The package compiles, the registration site is reachable, and the Meshtastic firmware follow-up can flesh it out without touching the manager. `ReleasesPageURL` for GitHub is `https://github.com/<repository>/releases` (regardless of `base_url`, which is only used for the API endpoint). #### Config (`internal/config/types.go` + `defaults.go` + `validate.go`) ```yaml update_check: enabled: true # master switch — when false, manager is not started interval: 12h timeout: 15s sources: - name: meshmap-lite # auto-registered with these defaults if absent (see below) label: Map type: forgejo base_url: 'https://git.skobk.in' repository: 'skobkin/meshmap-lite' current_version_source: buildinfo # special key: pull from internal/buildinfo limit: 15 # Future follow-up, do not enable in this issue: # - name: meshtastic-firmware # label: Meshtastic # type: github # repository: 'meshtastic/firmware' # current_version_source: none # limit: 10 ``` The platform adapter (forgejo, github, …) owns the URL shape. Users only set `base_url` + `repository` (+ `limit`); the adapter composes the API endpoint at runtime. `base_url` is required for `forgejo`; for `github` it defaults to `https://api.github.com`. `current_version_source` values: `buildinfo` (use `internal/buildinfo.Version`), `none` (no comparison, the snapshot just shows the latest), or a literal string. Defaults: `enabled=true`, `interval=12h`, `timeout=15s`, `limit=15`. **Default registration**: when `update_check.enabled=true` and no `meshmap-lite` source is declared in config, the wiring layer auto-registers a `meshmap-lite` source with the defaults shown above (`label: Map`, `type: forgejo`, `base_url: https://git.skobk.in`, `repository: skobkin/meshmap-lite`, `current_version_source: buildinfo`, `limit: 15`). Declaring a source with `name: meshmap-lite` in config (or via ENV override) replaces the default entry entirely. To turn the feature off, set `update_check.enabled: false` (or set `update_check.sources: []`). Validation: at least one source required when `enabled=true`; source names must be unique; source `type` must match a registered sub-package. #### Wiring (`internal/app/app.go`) - After config load: construct `updatecheck.NewCache()`, `updatecheck.NewManager(...)`. - For each configured source: instantiate the right sub-package (`sources/forgejo.New(...)` or `sources/github.New(...)`) and call `manager.Register(name, src, currentVersion)`. - Resolve `current_version_source: buildinfo` to `buildinfo.Version` at registration time. - `manager.Start(ctx)` alongside the MQTT client. - Pass the `*updatecheck.Manager` to the HTTP server via `httpapi.Config.Updates *updatecheck.Manager`. #### HTTP API - **`GET /api/v1/updates?source=meshmap-lite&format=html|markdown`** (`internal/api/http/handlers.go`): - `source` defaults to the only/first registered source. - `format=html` (default) → each release's `body` is pre-rendered by goldmark (reuse `siteinfo.RenderMarkdown`). - `format=markdown` → raw markdown bodies. - 200: `{format, source_hash, releases: [{version, published_at, html_url, body}]}`. - 404 `update_check_not_configured` when `update_check.enabled=false`. - 404 `update_check_source_not_found` when the named source isn't registered. - 503 `update_check_not_ready` when the source has not completed its first fetch yet. - **`/api/v1/meta`** extension (`internal/api/http/dto.go`): replace the simple booleans with a `update_check_sources []SourceSummary` array, each entry `{name, source_hash, current_version, latest_version, update_available}`. Add a top-level `update_check_available bool` for "is the feature on at all". Omit the array (or send `null`) when disabled. - **OpenAPI** (`internal/apidocs/assets/openapi.yaml`): document the new endpoint, the `?source=` and `?format=` params, and the meta extension. #### Frontend (TypeScript / Preact) 7. **Generalize `InfoModal` → `HtmlModal`** in `web/src/components/InfoModal.tsx`: - Replace the hardcoded `<h2>Site information</h2>` with a `header` slot (or `tabs` slot, see step 8) controlled by props. - The component already accepts `content` (pre-rendered HTML), `error`, `loading`, `showUpdatedNotice`, `onClose`, `onDismiss` — these stay. Add an optional `title` prop for non-tab use. 8. **New `AppModal` wrapper** in `web/src/components/AppModal.tsx` that renders the tabbed version: - Header layout: `[tab bar centered] [close button right]`. No `<h2>`. - Tabs are built dynamically: the first tab is always **Information** (the static site-info flow); subsequent tabs come from `meta.update_check_sources` (one per source, labeled with the source's `label` config — e.g. **Map**, and in the future **Meshtastic**). For MVP there are 2 tabs; once the Meshtastic firmware source is added, the dialog shows 3 tabs without any further wiring. - Each update-source tab button shows a small number badge (same style as the header "i" badge) with that source's unread count. The **Information** tab is the existing flow and has no badge. - Active tab is controlled by a prop. - The body and footer are passed in as slots, so all tabs share the dialog chrome (backdrop, Escape, click-outside, full-screen on narrow viewports). 9. **Cookie helper** in `web/src/utils/updatesCookie.ts`: - Per-source cookie: `meshmap-lite.updates.<sourceName>.dismissed_published_at` — stores the highest `published_at` ISO timestamp the user has marked as read (default empty). - Mirrors `infoCookie.ts` (10y Max-Age, `SameSite=Lax`, `Path=/`). - For MVP only `meshmap-lite` is registered, so the helper only ever needs to round-trip that one name — but the design accommodates future sources. 10. **API types** in `web/src/api/types.ts`: ```ts interface UpdatesResponse { format: 'html' | 'markdown' source_hash: string releases: { version: string; published_at: string; html_url: string; body: string }[] } interface SourceSummary { name: string label: string // matches the source's `label` config source_hash?: string current_version?: string latest_version?: string update_available?: boolean releases: { version: string; published_at: string }[] // metadata only — no body } ``` Extend `Meta` with `update_check_available: boolean` and `update_check_sources?: SourceSummary[]`. The meta's `releases` array carries just `version` + `published_at` per release — enough to render a version/date list and to compute the unread count, but **without** the markdown body, so the page-load payload stays light. The full bodies are fetched on-demand from `api.updates(source)`. 11. **API client** in `web/src/api/client.ts` — add `api.updates(source, format = 'html')` taking the source name. 12. **Meta store** in `web/src/stores/meta.ts` — surface the new fields, including the per-source `releases` metadata. 13. **Updates store** in `web/src/stores/updates.ts` — a small per-source map. Keyed by source name, value holds: `{ releases: ReleaseInfo[] (with body), loading, error }`. Populated lazily when the user opens a tab. Cookie reads/writes also happen here. 14. **`App.tsx` wiring**: - On `meta` load, if `update_check_available` and `update_check_sources` is non-empty, do **not** pre-fetch bodies. Just compute the unread count per source from `meta.update_check_sources[*].releases` + the per-source cookie, sum them for the header badge, and stash the cookie values in `stores/updates.ts`. - When the user opens a source tab, lazily call `api.updates(source, 'html')` and stash the full response in `stores/updates.ts`. Subsequent opens of the same tab use the cached response. - State: `appModalOpen`, `appModalActiveTab` (`'information' | <sourceName>`). - **Auto-open rules** (preserve current site-info behavior): - Site info source hash changed (or never dismissed) → open dialog on **Information** tab. - Any source unread count > 0 → **no auto-open**, just the badge. - **Manual open**: always opens on **Information** (the first tab). Update-source tabs only show unread badges — the user clicks them to read at their own pace. There is no "last viewed tab" persistence. - Dismiss handler is tab-aware: writing the info cookie (existing behavior) when the Information tab is active, or the source's updates cookie (`meshmap-lite.updates.<sourceName>.dismissed_published_at`) when a source tab is active. "×" close never marks anything as read. - URL fragment: `#/info` keeps the current behavior (opens the dialog on Information). Add `#/updates/<sourceName>` for explicit deep-link to a source tab (e.g. `#/updates/meshmap-lite`). 15. **Header "i" button** in `web/src/components/Header.tsx`: - Keep the existing `<a href="#/info">` anchor. - When the **sum of unread counts across all sources** > 0, render a small `<span class="header-badge">{n}</span>` absolutely positioned over the icon (Pico `--pico-del-color` or similar). - `aria-label` becomes "Site information (N new updates)" when the badge is shown, with `N` being the cross-source total. - The total is generic from day one: `sum(source.unreadCount for source in meta.update_check_sources)`. When the second source is added, the badge automatically reflects it. 16. **Updates tab content component** in `web/src/components/UpdatesPanel.tsx`: - Props: `source: { name, label, releasesPageUrl }`, `releases` (with body, from the store), `dismissedPublishedAt` (from cookie), `onDismiss`, `loading`, `error`. - The same component renders any source's release list — it is **not** hardcoded to meshmap-lite. The `label` shows as the panel's `<h2>` (or as a small caption above the version list); the "View all" link points at `source.releasesPageUrl` (e.g. "View all releases on Forgejo →" / "View all releases on GitHub →"), and the `onDismiss` callback writes the source's own cookie. - For each release (newest first), render a `<section class="updates-release">` with `<h3>version</h3>`, `<time>` (localized), and a `<div class="updates-body" dangerouslySetInnerHTML>`. - Find the latest release whose `published_at <= dismissedPublishedAt`; if found and it isn't the last in the list, render a visible separator after it (`<hr class="updates-separator">` plus a small caption "You last read up to vX.Y.Z" — only when the user is "behind" by at least one release). - Releases newer than `dismissedPublishedAt` get a subtle "new" visual treatment (e.g., a small "NEW" pill next to the version). - Footer: a "View all releases on <platform> →" link (`source.releasesPageUrl`) and the "Got it" button (which calls `onDismiss`). 17. **CSS** in `web/src/styles.css` — new rules: - `.app-modal-tabs`, `.app-modal-tab`, `.app-modal-tab[aria-current="page"]` — tab bar layout, centered, equal-width buttons, focus rings, dark-theme parity via `color-mix` (consistent with the recent `show-all-topology` dark-theme work). - `.header-badge` — small red circle, absolutely positioned top-right of the icon, with min-width for double digits. - `.updates-release`, `.updates-separator`, `.updates-new-pill` — release list styling, narrow-viewport friendly. - Dark-theme overrides for all of the above. 18. **Tests**: - Backend: port meshgo's `update_checker_test.go` → split into `cache_test.go` (Get/Set/concurrency) and `manager_test.go` (ticker + multi-source registration + `UpdateAvailable` cases). Add `sources/forgejo/forgejo_test.go` (HTTP fetch + DTO decode, URL construction from `base_url` + `repository` + `limit`). Handler tests for `/api/v1/updates` (200, 404 disabled, 404 source, 503 not ready, format=markdown) and the meta extension. A `manager` test that registers a stub `Source` (not the GitHub package) to verify the orchestrator's behavior is platform-agnostic. - Frontend: extend `InfoModal.test.tsx` to cover the tabbed variant; new `UpdatesPanel.test.tsx` for separator / "new" / "Got it" / "View all" behavior; new test for the header badge rendering (with multi-source total). ### Decisions (from brainstorm) | Decision | Choice | |---|---| | Trigger placement | A single **Information** tab + one tab per registered update-check source (e.g. **Map**), inside the existing dialog. Tab bar in the header line, centered. Indicator is a number badge on the existing "i" button — no new header element. | | Auto-open | **Information** tab only (current behavior). Source tabs never auto-open. | | Indicator style | Number badge (e.g. "3") on the "i" button = sum of unread counts across all sources; same style of badge on each source-tab button (per-source unread). | | Default manual open | The dialog always opens on **Information** (first tab). No "last viewed tab" persistence. | | Dialog content | Latest N releases per source (default `limit: 15`, configurable). 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; URL built by the platform adapter from `base_url` + `repository` (or a GitHub default). | | Lazy load of bodies | Meta carries release **metadata** (version, published_at) for every source. The full release notes (markdown bodies) are fetched on-demand from `api.updates(source)` when the user opens that source's tab. | | Cookies | `meshmap-lite.info.dismissed_source_hash` (unchanged) and a new per-source `meshmap-lite.updates.<name>.dismissed_published_at` (10y Max-Age, `SameSite=Lax`). | | "Got it" semantics | Marks the **currently active** tab as read. "×" close does not mark as read. | | Component reuse | `InfoModal` is generalized to `HtmlModal` (adds header slot, keeps existing props). New `AppModal` is the tabbed variant. | | **Multi-source design** | `Source` interface (`Name`, `Label`, `FetchReleases`, `ReleasesPageURL`) + per-platform sub-packages. `Cache` is centralised at the manager level (one snapshot per source, separate `cache.go` file). The MVP ships only the `forgejo` source implementation; `github` is a stub package so the follow-up Meshtastic firmware checker can plug in without manager changes. | | **Caching** | In-memory only, refreshed on `interval`. Cache lives in `cache.go` (own file) and is owned by the Manager. Per-source failure doesn't poison the cache. Manager does an immediate fetch on startup so the cache is warm for the first request. | | **Current version per source** | Resolved at registration time. `current_version_source: buildinfo` → `buildinfo.Version`. `current_version_source: none` → no comparison, the snapshot just exposes the latest. | | **Config shape** | Each source declares `label`, `type`, `base_url` (required for forgejo, optional for github), `repository`, `current_version_source`, `limit`. The platform adapter owns the URL construction — no full `endpoint` field. | | **Default registration** | `meshmap-lite` is auto-registered with sensible defaults (`label: Map`, `type: 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. Declaring it in config (or via ENV override) replaces the default. | ### Files to modify - `internal/updatecheck/` — new package (see file layout above) - `internal/config/types.go`, `internal/config/defaults.go`, `internal/config/validate.go` — new `UpdateCheckConfig` block with `sources` array - `internal/app/app.go` — wire the manager, pass to HTTP server - `internal/api/http/server.go`, `internal/api/http/handlers.go`, `internal/api/http/dto.go`, `internal/api/http/routes.go` — new endpoint, meta extension, route - `internal/apidocs/assets/openapi.yaml` — document the new endpoint, `?source=`, `?format=`, and meta fields - `web/src/components/InfoModal.tsx` → rename / generalize to `HtmlModal.tsx` (header slot prop) - `web/src/components/AppModal.tsx` — new tabbed wrapper - `web/src/components/UpdatesPanel.tsx` — new content component - `web/src/components/Header.tsx` — render the badge on the "i" button - `web/src/utils/updatesCookie.ts` — new per-source cookie helper (mirror of `infoCookie.ts`) - `web/src/stores/updates.ts` — new small store (snapshot of the latest fetched response, unread count) - `web/src/stores/meta.ts` — surface the new meta fields - `web/src/api/types.ts`, `web/src/api/client.ts` — types + client method - `web/src/App.tsx` — modal state, auto-open rules, dismiss handlers, URL fragments - `web/src/styles.css` — new rules for tabs, badge, updates panel - `web/src/components/InfoModal.test.tsx` → split into `HtmlModal.test.tsx` + `AppModal.test.tsx` - `web/src/components/UpdatesPanel.test.tsx` — new (generic per source, not meshmap-lite specific) - `internal/api/http/handlers_test.go` — extend with `/api/v1/updates` cases - `internal/config/defaults.go` — add the auto-registration of the `meshmap-lite` default source when the config block is empty - `internal/config/validate.go` — when `enabled=true`, if `sources` is empty, silently fall back to the default; if it has entries, the user-provided entries win. Reject duplicate names. Reject unknown `type` values. ### Verification 1. **Unit tests**: `go test ./...` (backend) and `npm test` (frontend) — all pass. 2. **Manager / cache tests**: register a stub `Source` that returns canned releases, verify (a) the ticker triggers fetches, (b) the cache holds the last successful snapshot, (c) a failing source leaves the previous snapshot intact, (d) two sources don't share state, (e) the manager does an immediate fetch on startup so the first request after boot is served from cache. 3. **Default registration**: with a config that has `update_check.enabled: true` and **no** `sources` block, the `meshmap-lite` source is registered with the documented defaults. Adding a `sources:` block (with or without a `meshmap-lite` entry) overrides; setting `update_check.enabled: false` (or `sources: []`) disables it. 4. **Manual end-to-end** with a real release on `git.skobk.in/skobkin/meshmap-lite`: - Start the server with `update_check.enabled=true` (no explicit sources block). - Open the SPA → the "i" button shows a number badge equal to the count of releases newer than the cookie (or full count on first visit, when the cookie is empty). - Click "i" → dialog opens on the **Information** tab (the static flow). The **Map** tab shows an unread badge with the same count. - Click the **Map** tab → bodies are fetched lazily. Releases are rendered newest-first; the separator appears after the latest release that's `<= cookie.published_at` (skip this on first visit — no separator). - The "View all releases on Forgejo →" link goes to the project's releases page. - Click "Got it" → dialog closes, `meshmap-lite.updates.meshmap-lite.dismissed_published_at` cookie is written, badge clears on next load. - Re-open the dialog → still opens on **Information** (manual-open rule). Releases are no longer marked "NEW" once the **Map** tab is opened again. 5. **Site info flow still works**: edit the configured `web.info.file` (or trigger a hash change), reload → dialog auto-opens on **Information** tab with the existing "updated since you last dismissed it" notice. The "i" badge for updates is unaffected. 6. **Multi-source readiness**: with the stub `github` source added to the config and a real in-process test handler returning 200, the SPA's `meta.update_check_sources` should contain both `meshmap-lite` and `meshtastic-firmware` entries, the dialog renders three tabs (**Information**, **Map**, **Meshtastic**), and `GET /api/v1/updates?source=meshtastic-firmware` returns the stub's data without the manager having to be re-architected. (The stub GitHub `FetchReleases` is expected to fail for MVP; this verifies the failure-doesn't-poison-cache contract, not real GitHub behavior.) 7. **Cross-source badge**: with two sources each having N unread releases, the header "i" badge shows `2N`, while each tab badge shows its own `N`. 8. **Lazy load payload**: opening the SPA on first visit only triggers `GET /api/v1/meta` — no `GET /api/v1/updates` until the user opens a source tab. Confirmed via the browser DevTools network tab. 9. **Mobile (≤980px)**: tab bar still readable, badge doesn't overflow, modal becomes full-screen as it does today. 10. **Disabled / no snapshot**: with `update_check.enabled=false`, the "i" button shows no badge, `meta.update_check_available` is `false`, and `/api/v1/updates` returns 404 `update_check_not_configured`. With `enabled=true` but a never-completed check, the endpoint returns 503 `update_check_not_ready` and the "i" button shows no badge.
Author
Owner

Plan refinements

Follow-up to the original plan in #6077. Same overall shape — four targeted edits to make the backend library-extractable and to align the dialog copy with the new tabbed layout.

1. Source interface — drop Label(), keep it library-shaped

Label() is an app-specific concern. The platform adapter shouldn't know what the user-facing tab is called. Labels move to the Manager (populated from the label: field of each config entry at registration time) so the interface stays pure data-fetching and the whole internal/updatecheck/ package can be lifted into a Go module without dragging any meshmap-lite UI knowledge along with it.

type Source interface {
    Name() string
    FetchReleases(ctx context.Context) ([]ReleaseInfo, error)
    ReleasesPageURL() string
}

2. Manager — own the update schedule and let the app subscribe

Add a small broker modeled on internal/api/ws/hub.go (R/W mutex + map[name][]chan + non-blocking snapshot-then-fan-out):

  • Subscribe(name string) (<-chan UpdateSnapshot, func()) — buffered channel (cap 4) per source; returned func unsubscribes and closes the channel.
  • SubscribeAll() (<-chan NamedSnapshot, func()) — single firehose channel carrying {Name, Snapshot} from every source. Lets the meta handler (or a future WebSocket emitter) react once per fetch.
  • Labels() map[string]string — exposes labels keyed by source name for /api/v1/meta.
  • Register now takes a struct: Register(spec SourceSpec) error where SourceSpec = {Name, Label, Source, CurrentVersion} — keeps the registration call stable as new metadata is added.
  • registeredSource{Source, CurrentVersion string, Label string}Label is the user-facing tab name taken from config.

A slow consumer must not block the Manager's fetch loop — drop the snapshot for that subscriber and continue.

3. Dialog <h2> — "Information" (the first tab's name)

Replace the hardcoded "Site information" with just "Information" when the tabbed layout is active. The dialog is no longer site-information-only; the <h2> is now the first tab's name. The non-tabbed HtmlModal standalone use gets a title prop defaulting to "Information".

Refactor web/src/utils/infoCookie.ts to a small factory that serves both flows. The two free functions become instances of one shape; the existing cookie? arg seam used by the test is preserved.

const makeCookie = (name: string) => ({
  read: (cookie: string = document.cookie): string => { /* same lookup, parametrised */ },
  write: (value: string): void => { /* 10y Max-Age, Path=/, SameSite=Lax */ },
})
export const infoDismissedCookie  = makeCookie('meshmap-lite.info.dismissed_source_hash')
export const updatesDismissedCookie = makeCookie('meshmap-lite.updates.dismissed_published_at')

Why a flat updates cookie (meshmap-lite.updates.dismissed_published_at, not per-source) for MVP:

  • The header "i" badge is the sum across all sources — one published_at already covers the cross-source "you've seen everything up to T" case.
  • The dialog enforces "open on Information, lazy per-source bodies" — so a single high-watermark timestamp is enough to compute each source's unread count on the meta.
  • If a future per-source "Got it" is needed (e.g. a per-source-tab dismiss), extend the factory to take a name + a sub-suffix, e.g. makeCookie('meshmap-lite.updates', 'meshmap-lite').dismissed_published_at. The factory shape is intentionally easy to extend.

The separate web/src/utils/updatesCookie.ts from the original plan goes away — both cookies now live in the refactored infoCookie.ts. Existing infoCookie.test.ts keeps passing under the renamed infoDismissedCookie export; a new test covers updatesDismissedCookie.

Updated decisions table (replaces the corresponding rows in #6077)

Decision Choice
Multi-source design Source interface (Name, FetchReleases, ReleasesPageURL) + per-platform sub-packages — no UI concerns, so the package is library-extractable. Labels live in the Manager (registeredSource.Label from config), not on Source. Cache is centralised at the manager level (one snapshot per source, separate cache.go file). The MVP ships only the forgejo source implementation; github is a stub package so the follow-up Meshtastic firmware checker can plug in without manager changes.
Caching In-memory only, refreshed on interval. Cache lives in cache.go (own file) and is owned by the Manager. Per-source failure doesn't poison the cache. Manager does an immediate fetch on startup so the cache is warm for the first request.
Subscription API Manager exposes Subscribe(name) (<-chan UpdateSnapshot, func()) and SubscribeAll() (<-chan NamedSnapshot, func()) so consumers (HTTP handlers, future WebSocket push) react to new snapshots without polling the cache. Modeled on internal/api/ws/hub.go (R/W mutex + map[name][]chan + non-blocking snapshot-then-fan-out, drop failing subs).
Current version per source Resolved at registration time. current_version_source: buildinfobuildinfo.Version. current_version_source: none → no comparison, the snapshot just exposes the latest.
Config shape Each source declares label, type, base_url (required for forgejo, optional for github), repository, current_version_source, limit. The platform adapter owns the URL construction — no full endpoint field.
Default registration meshmap-lite is auto-registered with sensible defaults (label: Map, type: 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. Declaring it in config (or via ENV override) replaces the default.
Cookies meshmap-lite.info.dismissed_source_hash (unchanged) and a new flat meshmap-lite.updates.dismissed_published_at (10y Max-Age, SameSite=Lax). Both come from a single makeCookie(name) factory in web/src/utils/infoCookie.ts.
Component reuse InfoModal is generalized to HtmlModal (adds header slot, keeps existing props; new default <h2> text is "Information"). New AppModal is the tabbed variant.

Files to modify (delta vs. #6077)

  • Removed: web/src/utils/updatesCookie.ts
  • Changed: web/src/utils/infoCookie.ts — refactored to a makeCookie(name) factory exporting infoDismissedCookie (renamed existing) and updatesDismissedCookie (new, flat)
  • Changed: web/src/components/InfoModal.tsx — default <h2> text becomes "Information"; title prop added for non-tab use
  • All other files in the original list stay as described in #6077.

Verification (additions)

  1. Subscription API: a stub Source registered with the Manager delivers new snapshots to a Subscribe(name) channel within one tick, the SubscribeAll() channel reports the source name, unsubscribing closes the channel cleanly, and a slow consumer does not block the Manager's fetch loop.
  2. Library shape: go vet ./internal/updatecheck/... passes with no imports from meshmap-lite/... outside of internal/app/app.go (i.e. the package compiles in isolation once wired). Labels() returns a map keyed by source name, populated from each registered SourceSpec.Label.

Everything else in #6077 stands as-is.

## Plan refinements Follow-up to the original plan in #6077. Same overall shape — four targeted edits to make the backend library-extractable and to align the dialog copy with the new tabbed layout. ### 1. `Source` interface — drop `Label()`, keep it library-shaped `Label()` is an app-specific concern. The platform adapter shouldn't know what the user-facing tab is called. Labels move to the **Manager** (populated from the `label:` field of each config entry at registration time) so the interface stays pure data-fetching and the whole `internal/updatecheck/` package can be lifted into a Go module without dragging any `meshmap-lite` UI knowledge along with it. ```go type Source interface { Name() string FetchReleases(ctx context.Context) ([]ReleaseInfo, error) ReleasesPageURL() string } ``` ### 2. `Manager` — own the update schedule and let the app subscribe Add a small broker modeled on `internal/api/ws/hub.go` (R/W mutex + `map[name][]chan` + non-blocking snapshot-then-fan-out): - `Subscribe(name string) (<-chan UpdateSnapshot, func())` — buffered channel (cap 4) per source; returned func unsubscribes and closes the channel. - `SubscribeAll() (<-chan NamedSnapshot, func())` — single firehose channel carrying `{Name, Snapshot}` from every source. Lets the meta handler (or a future WebSocket emitter) react once per fetch. - `Labels() map[string]string` — exposes labels keyed by source name for `/api/v1/meta`. - `Register` now takes a struct: `Register(spec SourceSpec) error` where `SourceSpec = {Name, Label, Source, CurrentVersion}` — keeps the registration call stable as new metadata is added. - `registeredSource{Source, CurrentVersion string, Label string}` — `Label` is the user-facing tab name taken from config. A slow consumer must not block the Manager's fetch loop — drop the snapshot for that subscriber and continue. ### 3. Dialog `<h2>` — "Information" (the first tab's name) Replace the hardcoded "Site information" with just **"Information"** when the tabbed layout is active. The dialog is no longer site-information-only; the `<h2>` is now the first tab's name. The non-tabbed `HtmlModal` standalone use gets a `title` prop defaulting to `"Information"`. ### 4. Cookie helper — generalize to a `makeCookie(name)` factory Refactor `web/src/utils/infoCookie.ts` to a small factory that serves both flows. The two free functions become instances of one shape; the existing `cookie?` arg seam used by the test is preserved. ```ts const makeCookie = (name: string) => ({ read: (cookie: string = document.cookie): string => { /* same lookup, parametrised */ }, write: (value: string): void => { /* 10y Max-Age, Path=/, SameSite=Lax */ }, }) export const infoDismissedCookie = makeCookie('meshmap-lite.info.dismissed_source_hash') export const updatesDismissedCookie = makeCookie('meshmap-lite.updates.dismissed_published_at') ``` Why a **flat** updates cookie (`meshmap-lite.updates.dismissed_published_at`, not per-source) for MVP: - The header "i" badge is the **sum** across all sources — one `published_at` already covers the cross-source "you've seen everything up to T" case. - The dialog enforces "open on **Information**, lazy per-source bodies" — so a single high-watermark timestamp is enough to compute each source's unread count on the meta. - If a future per-source "Got it" is needed (e.g. a per-source-tab dismiss), extend the factory to take a name + a sub-suffix, e.g. `makeCookie('meshmap-lite.updates', 'meshmap-lite').dismissed_published_at`. The factory shape is intentionally easy to extend. The separate `web/src/utils/updatesCookie.ts` from the original plan goes away — both cookies now live in the refactored `infoCookie.ts`. Existing `infoCookie.test.ts` keeps passing under the renamed `infoDismissedCookie` export; a new test covers `updatesDismissedCookie`. ### Updated decisions table (replaces the corresponding rows in #6077) | Decision | Choice | |---|---| | **Multi-source design** | `Source` interface (`Name`, `FetchReleases`, `ReleasesPageURL`) + per-platform sub-packages — **no UI concerns**, so the package is library-extractable. Labels live in the **Manager** (`registeredSource.Label` from config), not on `Source`. `Cache` is centralised at the manager level (one snapshot per source, separate `cache.go` file). The MVP ships only the `forgejo` source implementation; `github` is a stub package so the follow-up Meshtastic firmware checker can plug in without manager changes. | | **Caching** | In-memory only, refreshed on `interval`. Cache lives in `cache.go` (own file) and is owned by the Manager. Per-source failure doesn't poison the cache. Manager does an immediate fetch on startup so the cache is warm for the first request. | | **Subscription API** | Manager exposes `Subscribe(name) (<-chan UpdateSnapshot, func())` and `SubscribeAll() (<-chan NamedSnapshot, func())` so consumers (HTTP handlers, future WebSocket push) react to new snapshots without polling the cache. Modeled on `internal/api/ws/hub.go` (R/W mutex + `map[name][]chan` + non-blocking snapshot-then-fan-out, drop failing subs). | | **Current version per source** | Resolved at registration time. `current_version_source: buildinfo` → `buildinfo.Version`. `current_version_source: none` → no comparison, the snapshot just exposes the latest. | | **Config shape** | Each source declares `label`, `type`, `base_url` (required for forgejo, optional for github), `repository`, `current_version_source`, `limit`. The platform adapter owns the URL construction — no full `endpoint` field. | | **Default registration** | `meshmap-lite` is auto-registered with sensible defaults (`label: Map`, `type: 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. Declaring it in config (or via ENV override) replaces the default. | | **Cookies** | `meshmap-lite.info.dismissed_source_hash` (unchanged) and a new flat `meshmap-lite.updates.dismissed_published_at` (10y Max-Age, `SameSite=Lax`). Both come from a single `makeCookie(name)` factory in `web/src/utils/infoCookie.ts`. | | **Component reuse** | `InfoModal` is generalized to `HtmlModal` (adds header slot, keeps existing props; new default `<h2>` text is **"Information"**). New `AppModal` is the tabbed variant. | ### Files to modify (delta vs. #6077) - **Removed**: `web/src/utils/updatesCookie.ts` - **Changed**: `web/src/utils/infoCookie.ts` — refactored to a `makeCookie(name)` factory exporting `infoDismissedCookie` (renamed existing) and `updatesDismissedCookie` (new, flat) - **Changed**: `web/src/components/InfoModal.tsx` — default `<h2>` text becomes **"Information"**; `title` prop added for non-tab use - All other files in the original list stay as described in #6077. ### Verification (additions) 11. **Subscription API**: a stub `Source` registered with the Manager delivers new snapshots to a `Subscribe(name)` channel within one tick, the `SubscribeAll()` channel reports the source name, unsubscribing closes the channel cleanly, and a slow consumer does not block the Manager's fetch loop. 12. **Library shape**: `go vet ./internal/updatecheck/...` passes with no imports from `meshmap-lite/...` outside of `internal/app/app.go` (i.e. the package compiles in isolation once wired). `Labels()` returns a map keyed by source name, populated from each registered `SourceSpec.Label`. Everything else in #6077 stands as-is.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#89
No description provided.