code.pablomurad.com

bbs-hammer

SSH BBS with forums, chat, door games, retro terminals and a weather service, written in Go

chore: tidy comments and deploy notes
author
Pablo Murad
date
commit
075b0ca295c303e58cf713202dbb693b4e335433
parent
75b16e440675135045aeb1893b1eeb2557318d7c
changes
41 files +220 −220
@@ -96,7 +96,7 @@ EXPOSE 2222 2324 8080 CMD ["/app/srv", "srv", "config.local.yaml"]
 
 # ---------------------------------------------------------------------------
-# runtime: default target — debian slim, no compiler
+# runtime: the default target, debian slim, no compiler
 # ---------------------------------------------------------------------------
 FROM debian:bookworm-slim AS runtime
 
@@ -8,7 +8,7 @@ ``` 
 # Hammer
 
-**tools over SSH** — a BBS-style terminal you connect to with your SSH key, plus the
+Tools over SSH: a BBS-style terminal you connect to with your SSH key, plus the
 HTTP service behind it. Weather is the first tool; the platform is being built around it.
 
 Hammer is a single static Go binary. Fonts, translations, templates and page assets are
@@ -2,7 +2,7 @@ #!/usr/bin/env bash set -euo pipefail
 
 # ──────────────────────────────────────────────────────────────────────────────
-# build.sh — Build helper script for the project
+# build.sh: builds the project
 # Usage:
 #   ./build.sh build
 #   ./build.sh clean
@@ -80,7 +80,7 @@ if [[ "$copied" -eq 0 ]]; then         error "No fonts were copied into $EMBED_TARGET_DIR/fonts. Install the font packages listed above (see README) and re-run. Refusing to build without embedded fonts."
     fi
     if [[ "$copied" -lt "${#FONTS[@]}" ]]; then
-        warn "Only $copied/${#FONTS[@]} fonts copied — some scripts/glyphs will fall back or render as tofu in PNG output."
+        warn "Only $copied/${#FONTS[@]} fonts copied; some scripts/glyphs will fall back or render as tofu in PNG output."
     fi
 }
 
@@ -1,44 +1,48 @@-# Deploy Hammer on the CloudPanel VPS (Docker) — one-shot runbook
+# Deploying Hammer on the CloudPanel VPS
+
+How the `/opt/hammer` deployment was set up, so it can be done again from the
+repo. It runs in Docker (image target `games`, user `wttr`, uid 1000), not as a
+binary under systemd. The notes include the quirks this particular box has.
 
-Reproduces the working `/opt/hammer` deployment from the repo. Method is
-**Docker** (image target `games`, runs as user `wttr` uid 1000), not a
-binary+systemd. Corrected for the real quirks of this box.
+## Layout
 
-## Topology (as actually deployed)
-- **Web (weather):** `https://hammer.pablomurad.com` → CloudPanel reverse proxy
-  → **`http://127.0.0.1:8081`**. Port **8080 is taken by CloudPanel's own
-  nginx**, so the container's HTTP (8080 inside) is published on host
-  **127.0.0.1:8081**. CloudPanel terminates TLS (Let's Encrypt).
-- **SSH BBS:** `ssh -p 2323 guest@hammer.pablomurad.com`. Published directly on
-  TCP **2323** (no reverse proxy — SSH is not HTTP). The user name is cosmetic.
-  A client offering an SSH key is identified by it (saved profile, SFTP file
-  area, jobs). `bbs.allowAnonymous: true` in `config.docker.yaml` also lets
-  keyless clients in; they get defaults for that connection only, nothing is
-  saved, and they have no file area.
-- **Same port, three modes:** interactive terminal → menu; a command or a pipe
-  → NDJSON dispatcher; `sftp -P 2323` → the caller's file area
-  (`/app/cache/files`, inside the `deploy/cache` volume).
-- **Port 22** stays with the box's admin sshd — do not touch it.
+- Web (weather): `https://hammer.pablomurad.com` goes through CloudPanel's
+  reverse proxy to `http://127.0.0.1:8081`. CloudPanel's own nginx already holds
+  8080, so the container's HTTP port (8080 inside) is published on host
+  127.0.0.1:8081. CloudPanel handles TLS with Let's Encrypt.
+- SSH BBS: `ssh -p 2323 guest@hammer.pablomurad.com`, published straight on TCP
+  2323. There is no proxy in front of it, since SSH is not HTTP. The user name
+  doesn't matter. A client with an SSH key is known by that key (saved profile,
+  SFTP file area, jobs). `bbs.allowAnonymous: true` in `config.docker.yaml` lets
+  keyless clients in as well: they get the defaults for that connection, nothing
+  is saved, and they have no file area.
+- The same port does three things: an interactive terminal gets the menu, a
+  command or a pipe gets the NDJSON dispatcher, and `sftp -P 2323` gets the
+  caller's file area (`/app/cache/files`, inside the `deploy/cache` volume).
+- Port 22 is the box's admin sshd. Leave it alone.
 
-## Prerequisites
-- Docker + Docker Compose v2 on the box (`docker compose version`).
-- CloudPanel with `clpctl` available.
-- The repo cloned, e.g. `/opt/bbs-hammer` (git `master`).
-- Secrets you must supply (NOT in the repo): OpenCage token, WWO URL/key, and
-  the MaxMind GeoLite2-City database.
+## Before you start
+
+- Docker and Docker Compose v2 on the box (`docker compose version`).
+- CloudPanel, with `clpctl`.
+- The repo cloned somewhere, for example `/opt/bbs-hammer` (branch `master`).
+- The secrets, which are not in the repo: an OpenCage token, the WWO URL and
+  key, and the MaxMind GeoLite2-City database.
 
 ## Steps
 
 ### 1. Get the code
+
 ```bash
 sudo git clone https://github.com/runawaydevil/bbs-hammer.git /opt/bbs-hammer
 cd /opt/bbs-hammer
 ```
 
-### 2. Secrets — deploy/.env (MUST be owned by uid 1000)
-The container runs as `wttr` uid 1000 and reads `/app/.env`. If the file is not
-readable by uid 1000 the container crash-loops with
-`loading .env: permission denied`. Create it and chown it:
+### 2. Secrets in deploy/.env, owned by uid 1000
+
+The container runs as uid 1000 and reads `/app/.env`. If uid 1000 can't read
+it, the container restarts in a loop with `loading .env: permission denied`.
+
 ```bash
 cat > deploy/.env <<'ENV'
 OPENCAGE_TOKEN=__your_opencage_token__
@@ -50,8 +54,11 @@ sudo chown 1000:1000 deploy/.env sudo chmod 600 deploy/.env
 ```
 
-### 3. GeoLite2 database — download with `curl -L` (follows MaxMind's 302)
-MaxMind 302-redirects to R2 storage; without `-L` you save the redirect HTML.
+### 3. The GeoLite2 database
+
+MaxMind answers with a 302 to its R2 storage. Without `curl -L` you save the
+redirect page instead of the database.
+
 ```bash
 KEY=__your_maxmind_license_key__
 sudo mkdir -p deploy/data
@@ -61,33 +68,41 @@ tar -xzf /tmp/geo.tar.gz -C /tmp sudo cp /tmp/GeoLite2-City_*/GeoLite2-City.mmdb deploy/data/GeoLite2-City.mmdb
 ```
 
-### 4. Runtime dirs must be writable by uid 1000
-The container writes the SSH host key, profiles DB, caches and NES game saves
-into mounted volumes. Create and chown them (data stays read-only):
+### 4. Folders uid 1000 can write
+
+The container writes the SSH host key, the profiles database, caches and NES
+saves into mounted volumes. Create them and hand them over (data stays
+read-only):
+
 ```bash
 sudo mkdir -p deploy/{cache,etc,log,data,games-saves,games-roms}
 sudo chown -R 1000:1000 deploy/cache deploy/etc deploy/log deploy/data deploy/games-saves deploy/games-roms
 ```
-The image target is `games`: the runtime plus nesdoor, its emulator core and the
-free homebrew from `games/catalog.yaml`, fetched and hash-checked at build time.
-At startup Hammer copies those games into `deploy/games-roms`, each one once.
-To add a game, copy its `.nes` file into `deploy/games-roms`; it shows up in the
-list straight away, with no restart. Delete a file to take a game off the list;
-a catalog game you delete is not copied back.
-Building it needs outbound HTTPS to GitHub and the Wayback Machine. To deploy
-without games, set `target: runtime` in the compose file: the `bbs.games` block
-then finds no door, logs that games are off, and the menu stays as before.
 
-### 5. Build + run
+The `games` target is the runtime plus nesdoor, its emulator core and the free
+homebrew listed in `games/catalog.yaml`, fetched and hash-checked at build
+time. On start Hammer copies those games into `deploy/games-roms`, each one
+once. To add a game, drop its `.nes` file in `deploy/games-roms`; it appears in
+the list right away, no restart needed. Delete a file to take a game off the
+list; a catalog game you delete isn't copied back.
+
+The build needs outbound HTTPS to GitHub and archive.org. To deploy without
+games, set `target: runtime` in the compose file: `bbs.games` then finds no
+door, logs that games are off, and the menu is as it was.
+
+### 5. Build and run
+
 ```bash
 docker compose -f deploy/docker-compose.prod.yml up -d --build
-docker compose -f deploy/docker-compose.prod.yml logs -f   # watch for a clean start
+docker compose -f deploy/docker-compose.prod.yml logs -f   # watch it start
 ```
-A clean start shows no `permission denied`, no `font ... file does not exist`,
-and the SSH listener up on 2323. The host key is generated on first boot into
-`deploy/etc/` and persists across restarts.
+
+A good start has no `permission denied`, no `font ... file does not exist`,
+and the SSH listener up on 2323. The host key is made on the first boot, in
+`deploy/etc/`, and kept from then on.
+
+### 6. CloudPanel reverse proxy and TLS
 
-### 6. CloudPanel reverse proxy + TLS (clpctl)
 ```bash
 clpctl site:add:reverse-proxy \
   --domainName='hammer.pablomurad.com' \
@@ -97,17 +112,21 @@ --siteUserPassword='__choose_a_password__' 
 clpctl lets-encrypt:install:certificate --domainName='hammer.pablomurad.com'
 ```
-Ensure a DNS A record `hammer.pablomurad.com` → this box's IP exists first.
+
+The DNS A record for `hammer.pablomurad.com` has to point at this box first.
 
 ### 7. Firewall
+
 ```bash
-sudo ufw allow 2323/tcp     # SSH BBS (direct)
+sudo ufw allow 2323/tcp     # SSH BBS
 sudo ufw allow 2324/tcp     # telnet BBS (keyless, read-only)
 ```
 
-### 7b. Browser terminal through CloudPanel
-`bbs.web` serves a WebSocket at `/:terminal/ws`. In the CloudPanel site's vhost
-(Sites → hammer.pablomurad.com → Vhost), add a location before `location /`:
+### 8. The browser terminal through CloudPanel
+
+`bbs.web` serves a WebSocket at `/:terminal/ws`. In the site's vhost
+(Sites, hammer.pablomurad.com, Vhost), add this before `location /`:
+
 ```nginx
 location = /:terminal/ws {
     proxy_pass http://127.0.0.1:8081;
@@ -122,50 +141,55 @@ proxy_read_timeout 600s;     proxy_send_timeout 600s;
 }
 ```
-8081 is loopback-only (bound to 127.0.0.1 in compose) — nothing to open. Port
-22 (admin sshd) is left untouched.
 
-### 8. Verify
+8081 is bound to 127.0.0.1 in the compose file, so there is nothing to open for
+it.
+
+### 9. Check it
+
 ```bash
-curl -sS https://hammer.pablomurad.com/London | head    # web weather via TLS
+curl -sS https://hammer.pablomurad.com/London | head    # weather over TLS
 ssh -p 2323 guest@hammer.pablomurad.com                 # BBS splash
 ssh -p 2323 hammer.pablomurad.com hash input=hello      # dispatcher: one JSON line
 echo put /etc/hostname | sftp -b - -P 2323 hammer.pablomurad.com   # file area (needs a key)
 ```
-The client IP the weather service sees must be the visitor's, not the Docker
-gateway: `server.trustedProxies` in `config.docker.yaml` trusts loopback and
+
+The weather service has to see the visitor's IP, not the Docker gateway's.
+`server.trustedProxies` in `config.docker.yaml` trusts loopback and
 `172.16.0.0/12` (Docker's bridge ranges), which is where CloudPanel's proxy
-arrives from inside the container. If the compose network lives elsewhere, add
-its gateway there, or visitors will all look like one address (one location,
+shows up from inside the container. If the compose network is somewhere else,
+add its gateway, or every visitor looks like the same address (one location,
 one rate-limit bucket).
 
-## Redeploy / update
+## Updating
+
 ```bash
 cd /opt/bbs-hammer && git pull
 docker compose -f deploy/docker-compose.prod.yml up -d --build
 ```
-`deploy/{cache,etc,log,data}` and `deploy/.env` are gitignored and survive the
-pull, so the host key and profiles are preserved.
+
+`deploy/{cache,etc,log,data}` and `deploy/.env` are gitignored, so they survive
+the pull and the host key and profiles stay.
+
+## Things this box taught me
 
-## Gotchas (learned on this box)
-- **8081, not 8080** — 8080 belongs to CloudPanel's nginx.
-- **`chown 1000:1000 deploy/.env`** — otherwise the container loops on
+- 8081, not 8080: CloudPanel's nginx has 8080.
+- `chown 1000:1000 deploy/.env`, or the container loops on
   `loading .env: permission denied`.
-- **`chown -R 1000:1000 deploy/{cache,etc,log}`** — otherwise it can't write the
-  host key / profiles DB.
-- **`curl -L`** for the mmdb — MaxMind 302-redirects to R2.
-- **`cap_add: NET_RAW, NET_ADMIN`** are required for the `path` tool (ping/MTR);
-  already set in the compose. Do NOT use `privileged: true`.
-- **Unknown config keys stop the service.** A `config.docker.yaml` from before the
-  OCR removal (`ocrInbox`, `ocrWorkDir`, `ocrTTL`, `ocrmypdfPath`) fails at startup;
-  use the one in the repo.
-- **Moderators:** put your key's fingerprint in `bbs.community.admins` of
-  `config.docker.yaml` (`ssh-keygen -lf ~/.ssh/id_ed25519.pub` prints it, the
-  `SHA256:...` part) and restart. Bans are stored in `hammer.db`; chat history is
-  in memory only.
-- **Rate limits:** HTTP weather is limited per visitor IP (`server.rateLimit`),
-  dispatcher calls per key (`bbs.dispatch`), and geocoder calls per UTC day for
-  everyone (`geo.dailyLookupLimit`, set under the OpenCage free tier).
-- **Never commit** `deploy/.env`, the `.mmdb`, or the CloudPanel site password.
-- Profiles saved by older builds for anonymous visitors (keyed `ip:…`) are no
-  longer read and can stay in `hammer.db` harmlessly.
+- `chown -R 1000:1000 deploy/{cache,etc,log}`, or it can't write the host key
+  and the profiles database.
+- `curl -L` for the mmdb, because of MaxMind's redirect.
+- The `path` tool (ping, MTR) needs `cap_add: NET_RAW, NET_ADMIN`, which the
+  compose file already has. Don't use `privileged: true`.
+- Unknown config keys stop the service. A `config.docker.yaml` from before the
+  OCR feature was removed (`ocrInbox`, `ocrWorkDir`, `ocrTTL`, `ocrmypdfPath`)
+  won't start; use the one in the repo.
+- Moderators go in `bbs.community.admins` in `config.docker.yaml`, as the key's
+  `SHA256:...` fingerprint (`ssh-keygen -lf ~/.ssh/id_ed25519.pub` prints it),
+  then restart. Bans are kept in `hammer.db`.
+- Rate limits: HTTP weather per visitor IP (`server.rateLimit`), dispatcher
+  calls per key (`bbs.dispatch`), and geocoder calls per UTC day for everyone
+  (`geo.dailyLookupLimit`, set under OpenCage's free tier).
+- Never commit `deploy/.env`, the `.mmdb` or the CloudPanel site password.
+- Profiles that older builds saved for anonymous visitors (keyed `ip:...`) are
+  no longer read; they can stay in `hammer.db`.
@@ -1,15 +1,15 @@-# Hammer — config baked for the production Docker container (target: runtime).
+# Hammer: config baked into the production Docker container (target: runtime).
 # The container runs `srv srv config.yaml` from WORKDIR /app as user wttr
 # (uid 1000). Secrets come from /app/.env via ${VAR} expansion. Unknown keys
 # are rejected at startup, so keep this in sync with internal/config.
 
 cache:
-  responses:            # final rendered output — fast/in-memory
+  responses:            # rendered output, kept in memory
     type: lru
     size: 50000
     ttl: 10m
     enabled: true
-  weather:              # raw upstream data — disk-backed and persistent
+  weather:              # raw upstream data, on disk
     type: disk
     dir: /app/cache/weather
     ttl: 45m
@@ -43,7 +43,7 @@ accessLog: /app/log/access.log   errorsLog: /app/log/errors.log
   interval: 300
 
-# HTTP weather — listens on 8080 inside the container; compose publishes it on
+# HTTP weather listens on 8080 inside the container; compose publishes it on
 # host 127.0.0.1:8081 (8080 belongs to CloudPanel's nginx).
 server:
   portHttp: 8080
@@ -60,7 +60,7 @@ brand:   host: hammer.pablomurad.com
   ssh: "ssh -p 2323 hammer.pablomurad.com"
 
-# SSH BBS — listens on 2323 inside the container; compose publishes 2323:2323.
+# The SSH BBS listens on 2323 inside the container; compose publishes 2323:2323.
 bbs:
   enabled: true
   port: 2323
@@ -1,14 +1,14 @@-# Hammer — production config for hammer.pablomurad.com.
+# Hammer: production config for hammer.pablomurad.com.
 # Secrets come from /hammer/.env via ${VAR} expansion. Unknown keys are
 # rejected at startup, so keep this in sync with internal/config.
 
 cache:
-  responses:            # final rendered output — keep fast/in-memory
+  responses:            # rendered output, kept in memory
     type: lru
     size: 50000
     ttl: 10m
     enabled: true
-  weather:              # raw upstream data — disk-backed and persistent
+  weather:              # raw upstream data, on disk
     type: disk
     dir: /hammer/cache/weather
     ttl: 45m
@@ -42,7 +42,7 @@ accessLog: /hammer/log/access.log   errorsLog: /hammer/log/errors.log
   interval: 300
 
-# HTTP weather service — kept on loopback-reachable 8080, fronted by nginx.
+# The HTTP weather service stays on 8080, reachable from loopback, behind nginx.
 server:
   portHttp: 8080
   # nginx on this host is the only proxy; X-Forwarded-For from anyone else is ignored.
@@ -56,7 +56,7 @@ brand:   host: hammer.pablomurad.com
   ssh: "ssh hammer.pablomurad.com"
 
-# SSH BBS — owns port 22 on this host so `ssh guest@hammer.pablomurad.com`
+# The SSH BBS owns port 22 on this host, so `ssh guest@hammer.pablomurad.com`
 # works with no -p flag. Requires CAP_NET_BIND_SERVICE (see hammer.service).
 bbs:
   enabled: true
@@ -1,4 +1,4 @@-# Hammer — production Docker deploy (CloudPanel VPS).
+# Hammer: production Docker deploy (CloudPanel VPS).
 #
 # One-shot: from the repo root, provide deploy/.env (chown 1000:1000) and
 # deploy/data/GeoLite2-City.mmdb, then:
@@ -8,7 +8,7 @@ # # HTTP weather is published on loopback 8081 (8080 is taken by CloudPanel's
 # nginx); the CloudPanel reverse proxy points at http://127.0.0.1:8081.
 # The SSH BBS is published directly on 2323. Port 22 (admin sshd) is untouched.
-# No secrets live in this file — they come from deploy/.env at runtime.
+# No secrets in this file; they come from deploy/.env at runtime.
 services:
   hammer:
     build:
@@ -1,5 +1,5 @@ [Unit]
-Description=Hammer — tools over SSH + HTTP weather
+Description=Hammer, tools over SSH and HTTP weather
 After=network-online.target
 Wants=network-online.target
 
@@ -18,7 +18,7 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE
 NoNewPrivileges=true
 
-# Hardening — the service only needs to write under cache/, log/ and etc/.
+# Hardening: the service only writes under cache/, log/ and etc/.
 ProtectSystem=strict
 ReadWritePaths=/hammer/cache /hammer/log /hammer/etc
 ProtectHome=true
@@ -16,8 +16,8 @@ "github.com/runawaydevil/hammer/internal/ui" )
 
 // The chat screen: messages scroll up, and the line being typed stays at the
-// bottom. Two goroutines write to the terminal — the session goroutine echoes
-// typing, and a receiver prints incoming messages — so every screen update
+// bottom. Two goroutines write to the terminal (the session goroutine echoes
+// typing, and a receiver prints incoming messages), so every screen update
 // happens under st.wmu, and the receiver redraws the input line after each
 // message it prints. Only the session goroutine reads input.
 
@@ -5,10 +5,9 @@ import "time" 
 // Config holds configuration for all cache layers used by the service.
 //
-// We have two (and potentially more) semantically different caches:
-//  1. Responses  → final rendered output (HTML, JSON, PNG, text, etc.) — should stay fast/in-memory
-//  2. Weather    → raw upstream data from weather backends (WWO, etc.) — can be disk-backed and persistent
-//  3. Future     → location, IP geodata, translations, etc.
+// Two caches with different jobs:
+//  1. Responses: the rendered output (HTML, JSON, PNG, text). Keep it in memory.
+//  2. Weather: raw data from the weather backends (WWO). Fine on disk, survives restarts.
 type Config struct {
 	// Responses is the cache for fully generated responses (what the client receives).
 	// This is the existing LRU cache passed to NewWeatherService.
@@ -100,8 +100,8 @@ // Set stores the completed entry and notifies waiters. func (d *DiskCacher) Set(key string, entry domain.CacheEntry) {
 	// Persist to disk
 	if err := d.writeToDisk(key, &entry); err != nil {
-		// In production you might want to log this, but don't fail the Set
-		// (the in-memory notification still happens)
+		// Losing the disk copy is not worth failing over; the waiters
+		// below still get the entry.
 	}
 
 	d.mu.Lock()
@@ -143,7 +143,7 @@ p, ok := d.inProgress[key] 	d.mu.RUnlock()
 
 	if !ok {
-		// Already completed or never started → try disk
+		// Done, or never started: try the disk
 		return d.Get(key), nil
 	}
 
@@ -99,7 +99,7 @@ return false 		}
 	}
 
-	// No valid entry and not in progress → we take ownership
+	// Nothing valid and nobody fetching: this caller fetches
 	c.inProgress[key] = struct{}{}
 	c.cache.Add(key, inProgressMarker)
 	return true
@@ -7,7 +7,7 @@ "strings" )
 
 // LoadDotEnv reads a .env file and sets any variable that is not already
-// present in the environment. A missing file is not an error — the caller
+// present in the environment. A missing file is not an error; the caller
 // may rely entirely on the real environment. Lines are KEY=VALUE; blank
 // lines and # comments are ignored, and surrounding quotes on the value are
 // stripped.
@@ -409,7 +409,7 @@ return nil, fmt.Errorf("unsupported type %q", a.Kind) }
 
 // resolveInput picks the payload: inline "input", a "file" from the caller's
-// area, or stdin in command mode — exactly one of them.
+// area, or stdin in command mode, and exactly one of them.
 func resolveInput(args map[string]any, env *Env) ([]byte, *Error) {
 	inline, hasInline := args[ArgInput].(string)
 	file, hasFile := args[ArgFile].(string)
@@ -72,7 +72,7 @@ // Expires is the absolute time after which this cache entry 	// should be considered stale and no longer served.
 	Expires time.Time
 
-	// ── Fields added for better multi-source / observability support ──
+	// The rest is for debugging and cache inspection.
 
 	// CachedAt records when this entry was stored.
 	CachedAt time.Time `json:"cached_at"`
@@ -104,7 +104,7 @@ if isEmoji(r) { 		return "Emoji"
 	}
 
-	// Box drawing, blocks, geometric shapes, powerline — very important for wttr.in
+	// Box drawing, blocks, geometric shapes and powerline glyphs: the reports use all of them
 	if (r >= 0x2500 && r <= 0x257F) || // Box Drawing
 		(r >= 0x2580 && r <= 0x259F) || // Block Elements
 		(r >= 0x25A0 && r <= 0x25FF) || // Geometric Shapes
@@ -51,7 +51,6 @@ if output.Content == nil || len(output.Content) == 0 { 		return nil, fmt.Errorf("no render output to convert to HTML")
 	}
 
-	// Convert ANSI → HTML using buildkite
 	htmlBody := fixEmojiWidth(terminal.Render(output.Content))
 
 	// Render the full document using the embedded template
@@ -27,7 +27,6 @@ 	// Convert domain options to PNG-specific options (strongly typed)
 	pngOpts := ansitopng.FromDomainOptions(query.Options)
 
-	// Call the ANSI → PNG renderer
 	pngBytes, err := ansitopng.RenderANSI(string(renderOut.Content), pngOpts)
 	if err != nil {
 		return nil, fmt.Errorf("failed to render ANSI to PNG: %w", err)
@@ -61,7 +61,7 @@ if record.Country.Names.English != "" { 		data.Country = record.Country.Names.English
 	}
 
-	// Region (first subdivision — usually state/province)
+	// Region: the first subdivision, usually the state or province
 	if len(record.Subdivisions) > 0 {
 		if record.Subdivisions[0].Names.English != "" {
 			data.Region = record.Subdivisions[0].Names.English
@@ -58,22 +58,22 @@ // Cache combines in-memory LRU, SQLite read pool, and write support.
 type Cache struct {
 	config *Config
-	// ── Persistent storage ───────────────────────────────
+	// Persistent storage
 	dbPath      string
 	godbWrite   *godb.DB // used for writes (schema + batch)
 	sqlReadPool *sql.DB  // read-only connection pool
 
-	// ── Fast in-memory cache ─────────────────────────────
+	// Fast in-memory cache
 	lru   *lru.Cache[string, *Location]
 	lruMu sync.Mutex // only for lazy init
 
-	// ── Batch writer ─────────────────────────────────────
+	// Batch writer
 	writeQueue    chan *Location
 	batchSize     int
 	flushInterval time.Duration
 	done          chan struct{} // graceful shutdown
 
-	// ── Synchronization ──────────────────────────────────
+	// Synchronization
 	writeMu       sync.Mutex
 	searcher      *Searcher
 	indexField    string
@@ -138,7 +138,7 @@ 	return c, nil
 }
 
-// ── Batch Writer ─────────────────────────────────────────────────────────────
+// Batch Writer
 
 func (c *Cache) batchWriter() {
 	ticker := time.NewTicker(c.flushInterval)
@@ -225,7 +225,7 @@ c.godbWrite.Rollback() // safety 	}
 }
 
-// Resolve returns location data — first from cache (LRU → persistent), then from external search.
+// Resolve returns location data from the cache (memory, then disk), or else from an external search.
 func (c *Cache) Resolve(location string) (*Location, error) {
 	defer log.Debugln("Resolve() finished")
 	location = normalizeLocationName(location)
@@ -383,7 +383,7 @@ } 	return os.WriteFile(c.cacheFile(addr), bytes, 0o600)
 }
 
-// ── LRU helpers ────────────────────────────────────────────────────────────────
+// LRU helpers
 func (c *Cache) initLRUOnce() {
 	c.lruMu.Lock()
 	defer c.lruMu.Unlock()
@@ -423,7 +423,7 @@ } 	c.lru.Add(key, val)
 }
 
-// ── Utility functions ──────────────────────────────────────────────────────────
+// Utility functions
 func (c *Cache) cacheFile(item string) string {
 	return path.Join(c.filesCacheDir, item)
 }
@@ -18,7 +18,7 @@ // It processes the URL path, domain name, and headers according to the specified rules. func FromRequest(r *http.Request) (*options.Options, error) {
 	opts := &options.Options{}
 
-	// Step 1: Extract components from the URL path
+	// The path, without its outer slashes, split into parts.
 	urlPath := r.URL.Path
 	// Remove leading and trailing slashes for consistency
 	urlPath = strings.Trim(urlPath, "/")
@@ -33,7 +33,7 @@ if strings.HasPrefix(urlPath, "files/") { 		opts.View = "files"
 	}
 
-	// Step 2: Process domain name for VIEW or LANG
+	// A subdomain can name the view or the language (v2.wttr.in, de.wttr.in).
 	host := r.Host
 	domainParts := strings.SplitN(host, ".", 2)
 	if len(domainParts) > 0 {
@@ -48,7 +48,7 @@ opts.View = domainPrefix 		}
 	}
 
-	// Step 3: Process the first path component for VIEW or LANG if not set by domain
+	// Otherwise the first path part can.
 	firstComponent := ""
 	if len(components) > 0 && components[0] != "" {
 		firstComponent = strings.ToLower(components[0])
@@ -59,7 +59,7 @@ opts.View = firstComponent 		}
 	}
 
-	// Step 4: Extract location and format from the path
+	// Then the location and the output format.
 	location := ""
 	if len(components) > 1 && components[1] != "" {
 		location = components[1]
@@ -88,7 +88,7 @@ } 	opts.Location = strings.ReplaceAll(opts.Location, "+", " ")
 	opts.Location = strings.TrimPrefix(opts.Location, "~")
 
-	// Step 5: Set default language from header if not set by domain or URL
+	// No language yet: try Accept-Language, then English.
 	if opts.Lang == "" {
 		if lang := langFromAccept(r.Header.Get("Accept-Language")); lang != "" {
 			opts.Lang = lang
@@ -98,8 +98,8 @@ if opts.Lang == "" { 		opts.Lang = "en"
 	}
 
-	// Step 6: Set User-Agent from header. Output defaulting based on the
-	// agent is deferred to ApplyAutoFixes, where the view is known.
+	// The output that suits the client is picked in ApplyAutoFixes,
+	// once the view is known.
 	opts.Agent = r.Header.Get("User-Agent")
 
 	return opts, nil
@@ -387,7 +387,6 @@ return true }
 
 // isValidView checks if the provided string is a valid view name.
-// This is a placeholder; in a real application, you'd have a list of supported views.
 func isValidView(view string) bool {
 	validViews := []string{
 		"files", "page", "line",
@@ -29,7 +29,7 @@ // Parse implements QueryParser.Parse func (p *strictQueryParser) Parse(ctx context.Context, r *http.Request, ipOpts *options.Options) (*options.Options, error) {
 	queryString := r.URL.RawQuery
 
-	// Step 1: use the existing parser to get validated map[string]string
+	// Validate first, with the same parser the old code used.
 	rawMap, err := ParseQueryString(queryString, p.config)
 	if err != nil {
 		return nil, err
@@ -52,7 +52,7 @@ return nil, fmt.Errorf("response missing current_condition array") 	}
 	cc := full.CurrentCondition[0]
 
-	// ── Parse numeric fields with safe defaults ────────────────────────────────
+	// Parse numeric fields with safe defaults
 	tempC, _ := strconv.ParseFloat(cc.TempC, 64)
 	tempF, _ := strconv.ParseFloat(cc.TempF, 64)
 	feelsC, _ := strconv.ParseFloat(cc.FeelsLikeC, 64)
@@ -70,28 +70,27 @@ 	cloud, _ := strconv.Atoi(cc.Cloudcover)
 	visKm, _ := strconv.ParseFloat(cc.Visibility, 64)
 
-	// ── Description (prefer translated lang_xx if present) ─────────────────────
+	// Description (prefer translated lang_xx if present)
 	desc := ""
 	if len(cc.WeatherDesc) > 0 {
 		desc = cc.WeatherDesc[0].Value
 	}
 
 	// Try to find any lang_ field (lang_ru, lang_de, etc.)
-	for _, v := range cc.WeatherDesc { // actually it's []ValueItem, but we check parent
-		// Note: real code might need to look at the full map or use a lang prefix search
-		// For simplicity we take the first description (usually English or requested lang)
+	// The first non-empty description wins: English, or the language asked for.
+	for _, v := range cc.WeatherDesc {
 		if desc == "" && len(v.Value) > 0 {
 			desc = v.Value
 		}
 	}
 
-	// ── Observation time ───────────────────────────────────────────────────────
+	// Observation time
 	obsTime, err := time.Parse("2006-01-02 15:04", cc.LocalObsDateTime)
 	if err != nil {
 		obsTime = time.Now().UTC() // fallback
 	}
 
-	// ── Location fallback ──────────────────────────────────────────────────────
+	// Location fallback
 	locName := "?"
 	if len(full.Request) > 0 {
 		locName = full.Request[0].Query
@@ -110,7 +109,7 @@ FeelsLikeF:       feelsF, 		Humidity:         humidity,
 		PrecipMM:         precipMM,
 		PrecipInches:     precipInches,
-		ChanceOfRain:     0, // often comes from hourly → can be filled later if needed
+		ChanceOfRain:     0, // only the hourly data has it
 		PressureHpa:      pressure,
 		UVIndex:          uv,
 		WindKmph:         windKmph,
@@ -34,7 +34,7 @@ 	return v, nil
 }
 
-// GetStringValue — convenience wrapper for the most common case (strings & numbers)
+// GetStringValue is the common case: a string or a number, as text.
 func GetStringValue(data interface{}, jqQuery string) (string, error) {
 	val, err := GetValue(data, jqQuery)
 	if err != nil {
@@ -14,9 +14,7 @@ "github.com/runawaydevil/hammer/internal/localization" 	"github.com/runawaydevil/hammer/internal/options"
 )
 
-// ──────────────────────────────────────────────────────────────────────────────
 // Unified render function signature
-// ──────────────────────────────────────────────────────────────────────────────
 
 type RenderFunc func(ctx *RenderContext) string
 
@@ -25,7 +23,7 @@ // in the compact one-line text format (?format=... or preconfigured IDs). //
 // It implements the weather.Renderer interface.
 type OnelineRenderer struct {
-	// Currently stateless — all configuration comes from query.Options
+	// Stateless: all configuration comes from query.Options.
 	// Future fields could include:
 	// - symbolSets     map[string]map[int]string   // different emoji/icon sets
 	// - moonCalculator MoonCalculator              // injectable moon phase logic
@@ -41,9 +39,7 @@ func NewOnelineRenderer() *OnelineRenderer { 	return &OnelineRenderer{}
 }
 
-// ──────────────────────────────────────────────────────────────────────────────
 // Helpers (usually kept private in the same file)
-// ──────────────────────────────────────────────────────────────────────────────
 
 func (r *OnelineRenderer) determineFormat(opts *options.Options) string {
 	if opts == nil || opts.Format == "" {
@@ -80,10 +76,8 @@ Location *domain.Location 	Now      time.Time
 }
 
-// ──────────────────────────────────────────────────────────────────────────────
 // Central registry: letter → render function
 // This matches format_specifiers.letter from oneline.yaml
-// ──────────────────────────────────────────────────────────────────────────────
 
 var placeholderRenderers = map[rune]RenderFunc{
 	'c': RenderConditionEmoji,
@@ -110,9 +104,7 @@ 'T': RenderLocalTime, 	'Z': RenderTimezone,
 }
 
-// ──────────────────────────────────────────────────────────────────────────────
 // Main rendering loop using the map
-// ──────────────────────────────────────────────────────────────────────────────
 
 func (r *OnelineRenderer) Render(q domain.Query, localizer localization.Localizer) (domain.RenderOutput, error) {
 	if q.Weather == nil || len(*q.Weather) == 0 {
@@ -152,7 +144,7 @@ 			if fn, ok := placeholderRenderers[letter]; ok {
 				sb.WriteString(fn(ctx))
 			} else {
-				// unknown placeholder → keep literal or log
+				// unknown placeholder: keep it as written
 				sb.WriteString(string([]rune{'%', letter}))
 			}
 			continue
@@ -184,7 +176,7 @@ sb.WriteString(fn(ctx)) 				continue
 			}
 
-			// Unknown → keep %X as-is
+			// unknown: keep %X as written
 			sb.WriteByte('%')
 			sb.WriteByte(letter)
 			continue
@@ -225,9 +217,9 @@ sb.WriteByte(byte(v)) 				i += 3
 				continue
 			}
-			// invalid hex → keep literal %XX
+			// not hex: keep %XX as written
 		}
-		// lone % or invalid → keep as is
+		// a lone %: keep it
 		sb.WriteByte(s[i])
 		i++
 	}
@@ -8,9 +8,7 @@ "strconv" 	"time"
 )
 
-// ──────────────────────────────────────────────────────────────────────────────
-// All render functions — same signature
-// ──────────────────────────────────────────────────────────────────────────────
+// Every render function has this signature.
 
 func RenderConditionFullName(ctx *RenderContext) string {
 	if ctx.Options.Lang == "en" {
@@ -184,13 +182,13 @@ if ctx == nil || ctx.Data == nil || ctx.Data.ConditionCode == "" { 		return "?"
 	}
 
-	// Step 1: Get symbolic name from code (e.g. "116" → "PartlyCloudy")
+	// The symbolic name for the code ("116" is "PartlyCloudy"),
 	symbolicName, ok := WWOCodeToName[ctx.Data.ConditionCode]
 	if !ok {
 		return "?"
 	}
 
-	// Step 2: Look up the plain ASCII-style symbol
+	// then its plain ASCII symbol.
 	plainSymbol, exists := WeatherSymbolPlain[symbolicName]
 	if !exists {
 		return "?"
@@ -26,7 +26,7 @@ 	return age
 }
 
-// RenderMoonPhaseEmoji — improved to better match visual expectation + Python spirit
+// RenderMoonPhaseEmoji picks the emoji closest to how the moon looks, like the Python version.
 func RenderMoonPhaseEmoji(ctx *RenderContext) string {
 	if ctx == nil {
 		return "🌕"
@@ -53,7 +53,7 @@ return phases[idx] }
 
 // RenderMoonDay returns approximate lunar day (days since last new moon, 0–29)
-// Rounded to nearest integer — suitable for weather/terminal display
+// Rounded to a whole day, plenty for a weather report.
 func RenderMoonDay(ctx *RenderContext) string {
 	illum := suncalc.GetMoonIllumination(ctx.Now)
 
@@ -19,7 +19,7 @@ } 
 	lat := ctx.Location.Latitude
 	lon := ctx.Location.Longitude
-	date := ctx.Now.Truncate(24 * time.Hour) // midnight of "today" (UTC or local — adjust if needed)
+	date := ctx.Now.Truncate(24 * time.Hour) // midnight today, UTC
 
 	rise, _ := sunrise.SunriseSunset(
 		lat, lon,
@@ -65,7 +65,7 @@ localSet := set.In(loc) 	return localSet.Format("15:04:05")
 }
 
-// solarNoonTime — approximate as midpoint between sunrise & sunset
+// solarNoonTime is the midpoint between sunrise and sunset, near enough.
 func solarNoonTime(ctx *RenderContext) string {
 	riseStr := sunriseTime(ctx)
 	setStr := sunsetTime(ctx)
@@ -107,7 +107,7 @@ if ctx.Location.TimeZone != "" { 			var err error
 			loc, err = time.LoadLocation(ctx.Location.TimeZone)
 			if err == nil {
-				// success — we have both coords + valid tz
+				// both coordinates and a valid zone
 				return lat, lon, loc, lat != 0 || lon != 0
 			}
 			// log invalid tz name but continue with coords
@@ -130,12 +130,10 @@ 	return lat, lon, loc, ok
 }
 
-// ────────────────────────────────────────────────
 // Optional: Helper to get "today" at midnight local time
 // Useful for twilight calculations
-// ────────────────────────────────────────────────
 func getLocalMidnight(ctx *RenderContext) time.Time {
-	_, _, loc, _ := getLatLonAndTZ(ctx) // ignore ok here — UTC is safe fallback
+	_, _, loc, _ := getLatLonAndTZ(ctx) // ok ignored: UTC is a safe fallback
 	now := ctx.Now
 	if now.IsZero() {
 		now = time.Now()
@@ -150,8 +148,7 @@ if !ok { 		return "??:??:??"
 	}
 
-	// Use a proper date – here assuming "today" from context or current time
-	// In real code, prefer to take date from astronomy/weather data if available
+	// Today in the location's zone; the report has no date of its own here.
 
 	now := time.Now().In(loc)
 	utcDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) // midnight UTC
@@ -66,7 +66,7 @@ if query.Weather == nil { 		return domain.RenderOutput{Content: []byte{}}, nil
 	}
 
-	// Deserialize WeatherRaw → domain.Weather (standard pattern)
+	// WeatherRaw into domain.Weather
 	var weather domain.Weather
 	err := json.Unmarshal(*query.Weather, &weather)
 	if err != nil {
@@ -79,7 +79,7 @@ rendered := r.renderCurrent(weather.CurrentCondition[0], "current", alreadySeen) 		output.WriteString(rendered)
 	}
 
-	// Forecast — next 3 days
+	// Forecast for the next 3 days
 	for i := 0; i < 3 && i < len(weather.Weather); i++ {
 		rendered := r.renderCurrent(weather.Weather[i], fmt.Sprintf("%dd", i), alreadySeen)
 		output.WriteString(rendered)
@@ -34,7 +34,7 @@ return " " 	}
 }
 
-// drawAstronomical — port of the original Python draw_astronomical function:
+// drawAstronomical is a port of the Python draw_astronomical function:
 // a moon-phase line and a 72-hour daylight timeline aligned with the time scale.
 func drawAstronomical(loc *domain.Location, opts *options.Options) string {
 	if loc == nil || (loc.Latitude == 0 && loc.Longitude == 0) {
@@ -82,7 +82,7 @@ return moonLine.String() + "\n" + timeline.String() + "\n\n" }
 
 // parseTimeStr converts "HH:MM:SS" (or "??:??:??") into a time.Time on the same day as `ref`.
-// Falls back to `ref` on error — matches Python’s try/except behavior.
+// Falls back to `ref` on error, like the Python try/except.
 func parseTimeStr(s string, ref time.Time) time.Time {
 	if s == "" || s == "??:??:??" || s == "??:??:???" {
 		return ref
@@ -7,10 +7,10 @@ "math" 	"strings"
 )
 
-// sparkBars as []rune — correct Unicode handling
+// sparkBars is a []rune so the multi-byte glyphs index right.
 var sparkBars = []rune(" _▁▂▃▄▅▇█")
 
-// drawRainSpark — now matches Python v2 label position (TOP of the sparkline)
+// drawRainSpark draws the rain sparkline with its label on top, as Python v2 does.
 func drawRainSpark(data, chanceData []float64, height, width int) string {
 	if len(data) == 0 || width <= 0 {
 		return strings.Repeat(" ", max(width, 0)) + "\n"
@@ -80,7 +80,7 @@ // Prepend label + blank line (matches original Python behavior) 	return "\n" + maxLine + "\n" + output.String()
 }
 
-// box — exact port of Python _box
+// box is a straight port of Python's _box.
 func box(row int, value, maxValue float64, height int) string {
 	if maxValue == 0 {
 		return " "
@@ -126,7 +126,7 @@ // renderSections with proper terminal display width func renderSections(sections []section) string {
 	var b strings.Builder
 
-	// === Step 1: Calculate GLOBAL max display width per column ===
+	// The widest label and value of each column, across all sections.
 	maxLabel := make([]int, 0)
 	maxValue := make([]int, 0)
 
@@ -151,7 +151,7 @@ } 		}
 	}
 
-	// === Step 2: Render with alignment ===
+	// Then draw them aligned.
 	for _, sec := range sections {
 		if len(sec) == 0 {
 			continue
@@ -23,7 +23,7 @@ Data: &oneline.ParsedCurrentCondition{ 				ConditionCode: strconv.Itoa(code),
 			},
 			Options: opts,
-			// Location, Now, DataRaw can stay nil/zero — emoji renderer doesn't need them
+			// Location, Now and DataRaw stay zero: the emoji renderer doesn't use them
 		}
 
 		b.WriteString(oneline.RenderConditionEmoji(ctx))
@@ -78,7 +78,7 @@ chanceInterp := interpolate(chanceValues, 72) 	buf.WriteString(drawRainSpark(precipInterp, chanceInterp, 5, 72))
 	buf.WriteString("\n")
 
-	// Weather emojis — one 3-column cell per slot, 24 slots across the panel,
+	// Weather emojis: one 3-column cell per slot, 24 slots across the panel,
 	// however many forecast slots the upstream returned.
 	codes := extractAllHourlyInt(weather, func(h domain.Hourly) string { return h.WeatherCode })
 	buf.WriteString(drawWeatherEmoji(resampleInts(codes, panelSlots), opts))
@@ -17,8 +17,8 @@ // maxField bounds each handshake field read by a server.
 const maxField = 64
 
-// Login sends the client handshake on c — an empty field, then the client
-// user, the server user and "terminal/speed" — and waits for the server's
+// Login sends the client handshake on c (an empty field, then the client
+// user, the server user and "terminal/speed") and waits for the server's
 // single zero byte. After it, c is a plain byte stream.
 func Login(c net.Conn, clientUser, serverUser, terminal string, wait time.Duration) error {
 	for _, f := range []string{clientUser, serverUser, terminal} {
@@ -32,7 +32,7 @@ client := srv.Client() 	client.Timeout = 3 * time.Second
 
 	host := strings.TrimPrefix(srv.URL, "http://")
-	// probeHTTP always uses https:// — call the helper against the test
+	// probeHTTP always uses https://, so call the helper against the test
 	// server with a rewritten request by going through Diagnose+Permissive
 	// is overkill; just check status formatting via a GET here.
 	req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
@@ -22,7 +22,7 @@ const Sep = "\x00sep\x00" 
 // BoxANSI draws a box around lines that may contain ANSI SGR sequences,
 // padding each line to at least minInner cells using VisibleWidth. frame is
-// the SGR code applied to the border glyphs only — content colours are
+// the SGR code applied to the border glyphs only; content colours are
 // preserved. Pass "" for an uncoloured border.
 func BoxANSI(lines []string, minInner int, style BoxStyle, frame string) []string {
 	return TitledBoxANSI("", lines, minInner, style, frame)
@@ -4,15 +4,15 @@ // The shared terminal palette. Tools use these so their output matches the // surrounding interface instead of inventing their own colours.
 const (
 	Reset  = "\033[0m"
-	Frame  = "\033[38;5;44m"    // cyan — borders
-	Title  = "\033[1;38;5;51m"  // bold bright cyan — titles
-	Accent = "\033[38;5;250m"   // light grey — secondary text
-	Prompt = "\033[1;38;5;213m" // bold pink — prompts and keys
-	Dim    = "\033[38;5;244m"   // mid grey — hints
-	Value  = "\033[38;5;255m"   // near white — data values
-	Good   = "\033[38;5;84m"    // green — confirmations
-	Bad    = "\033[38;5;203m"   // soft red — errors
-	Warn   = "\033[1;38;5;221m" // bold yellow — destructive confirmations
+	Frame  = "\033[38;5;44m"    // cyan: borders
+	Title  = "\033[1;38;5;51m"  // bold bright cyan: titles
+	Accent = "\033[38;5;250m"   // light grey: secondary text
+	Prompt = "\033[1;38;5;213m" // bold pink: prompts and keys
+	Dim    = "\033[38;5;244m"   // mid grey: hints
+	Value  = "\033[38;5;255m"   // near white: data values
+	Good   = "\033[38;5;84m"    // green: confirmations
+	Bad    = "\033[38;5;203m"   // soft red: errors
+	Warn   = "\033[1;38;5;221m" // bold yellow: destructive confirmations
 
 	// ClearScreen erases the visible screen and homes the cursor. It leaves
 	// the scrollback alone on purpose.
@@ -53,7 +53,7 @@ return nil, errors.New("location not found") 	}
 	l.m.Unlock()
 
-	// Use the existing Resolve method — it does cache lookup + upstream query + timezone
+	// Resolve does the cache lookup, the upstream query and the time zone
 	// + caching of result
 	raw, err := l.cache.Resolve(locationName)
 	if err != nil {
@@ -90,14 +90,8 @@ if err != nil { 		return nil, fmt.Errorf("invalid longitude in cached location: %w", err)
 	}
 
-	// Country / CountryCode are not present in the existing Location type.
-	// Options:
-	//   1. Leave them empty (simplest)
-	//   2. Parse them from Fullname (heuristic, fragile)
-	//   3. Extend upstream query to return them (requires changing Nominatim parsers)
-	//   4. Use a second service (e.g. reverse geocoding with country info)
-	//
-	// → for most use-cases option 1 is acceptable
+	// Country and CountryCode stay empty: the cached Location has neither,
+	// and nothing that reads them needs them yet.
 
 	return &domain.Location{
 		Name: raw.Name, // already normalized
@@ -181,10 +181,10 @@ if entry, err := s.Cacher.WaitForCompletion(cacheKey, 12*time.Second); err == nil && entry != nil { 			s.serveFromCache(w, entry)
 			return
 		}
-		// Timeout or failure → fall through and compute ourselves
+		// timed out or failed: compute it here
 	}
 
-	// We are the leader → compute and store
+	// first in line: compute and store
 	s.computeAndStore(ctx, w, r, cacheKey, overallStart)
 }
 
@@ -233,7 +233,7 @@ } 
 // computeResponse performs location resolution, weather fetch, rendering and formatting.
 // Returns ready-to-cache domain.CacheEntry or error.
-// Does NOT write to ResponseWriter — that stays in handler.
+// It never writes the response; the handler does.
 func (s *WeatherService) computeResponse(
 	ctx context.Context, r *http.Request,
 	tracker *TimeTracker,
@@ -242,7 +242,7 @@ debugRequested := r.URL.Query().Get("debug") != "" 
 	clientIP := getClientIP(r)
 
-	// ── Determine location string ────────────────────────────────────────
+	// Determine location string
 	start := time.Now()
 	path := r.URL.Path // cleanPath(r.URL.Path) // helper: trim / and extensions
 	autoDetect := isAutoDetectPath(path)
@@ -305,7 +305,7 @@ locStr = "Berlin" 		log.Println("no location could be determined")
 	}
 
-	// ── Geocode ───────────────────────────────────────────────────────────
+	// Geocode
 	start = time.Now()
 	location, err := s.Locator.GetLocation(locStr)
 	if err != nil {
@@ -327,7 +327,7 @@ formatOut *domain.FormatOutput 		query     domain.Query
 	)
 
-	// ── Build Query ───────────────────────────────────────────────────────
+	// Build Query
 	start = time.Now()
 	query = domain.Query{
 		ClientData: &domain.ClientData{
@@ -356,7 +356,7 @@ return uplinkResponse, err 		}
 		tracker.Add(fmt.Sprintf("Upstream processing (view=%s)", opts.View), time.Since(start))
 	} else {
-		// ── Fetch + Render (shared core) ──────────────────────────────────────
+		// Fetch + Render (shared core)
 		start = time.Now()
 		query.IPData = ipData
 
@@ -377,7 +377,7 @@ } 		}
 		renderOut.Content = brand.Apply(s.Brand, renderOut.Content, opts, tagline)
 
-		// ── Format ────────────────────────────────────────────────────────────
+		// Format
 		start = time.Now()
 		formatter := s.selectFormatter(opts.Output)
 
@@ -55,18 +55,18 @@ // GetWeather returns weather data, using cache when possible. func (c *CachedWeatherClient) GetWeather(lat, lon float64, lang string) ([]byte, error) {
 	key := MakeWeatherCacheKey(lat, lon, lang)
 
-	// 1. Fast path: check cache
+	// In the cache already?
 	if entry := c.cacher.Get(key); entry != nil {
 		return entry.Body, nil
 	}
 
-	// 2. Try to become the leader (coalescing)
+	// Otherwise one caller fetches,
 	if c.cacher.SetInProgressIfNotExists(key) {
-		// We are the one fetching
+		// this one
 		return c.fetchAndCache(key, lat, lon, lang)
 	}
 
-	// 3. Another goroutine is already fetching → wait for it
+	// and the rest wait for it.
 	return c.waitForInProgress(key)
 }