diff --git a/config/test.exs b/config/test.exs
index 21392632..ea4eae2b 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -48,6 +48,11 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
# Run Oban jobs inline during tests
config :microwaveprop, Oban, testing: :inline
+# Skip the registration-time QRZ lookup in tests — it needs a Mox stub
+# that isn't set up across the test suite, and the worker is exercised
+# directly in its own dedicated test.
+config :microwaveprop, :enable_home_qth_lookup, false
+
# MetricsLogSuppressionTest hits GET /metrics and asserts no "Sent 200"
# log line; that path needs PromEx alive to return a 200. We can't
# blanket-disable PromEx. Instead, toggle off the Oban-queue poller
diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex
index 8c66041f..dd33c6f2 100644
--- a/lib/microwaveprop/accounts.ex
+++ b/lib/microwaveprop/accounts.ex
@@ -9,8 +9,8 @@ defmodule Microwaveprop.Accounts do
alias Microwaveprop.Accounts.UserNotifier
alias Microwaveprop.Accounts.UserToken
alias Microwaveprop.Repo
-
## Database getters
+ alias Microwaveprop.Workers.UserHomeQthLookupWorker
@doc """
Gets a user by email.
@@ -132,6 +132,47 @@ defmodule Microwaveprop.Accounts do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
+ |> maybe_enqueue_home_qth_lookup()
+ end
+
+ defp maybe_enqueue_home_qth_lookup({:ok, %User{id: id, callsign: call} = user}) when is_binary(call) do
+ if home_qth_lookup_enabled?() do
+ worker = UserHomeQthLookupWorker
+
+ if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
+ _ = %{user_id: id} |> worker.new() |> Oban.insert()
+ end
+ end
+
+ {:ok, user}
+ end
+
+ defp maybe_enqueue_home_qth_lookup(other), do: other
+
+ defp home_qth_lookup_enabled? do
+ Application.get_env(:microwaveprop, :enable_home_qth_lookup, true)
+ end
+
+ @doc """
+ Enqueue a `UserHomeQthLookupWorker` job for every user whose
+ `home_grid` is still nil. Safe to call repeatedly — the worker
+ no-ops when the user has set their QTH manually.
+ """
+ @spec backfill_missing_home_qth() :: :ok
+ def backfill_missing_home_qth do
+ worker = UserHomeQthLookupWorker
+
+ if home_qth_lookup_enabled?() and Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
+ User
+ |> where([u], is_nil(u.home_grid))
+ |> select([u], u.id)
+ |> Repo.all()
+ |> Enum.each(fn id ->
+ _ = %{user_id: id} |> worker.new() |> Oban.insert()
+ end)
+ end
+
+ :ok
end
@doc """
diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex
index d7b2b1cf..f09a4a96 100644
--- a/lib/microwaveprop/application.ex
+++ b/lib/microwaveprop/application.ex
@@ -60,6 +60,13 @@ defmodule Microwaveprop.Application do
# lifetime of the pod.
{:ok, _pid} = Task.start(fn -> Microwaveprop.Weather.warm_grid_cache_from_latest_profile() end)
+ # One-shot backfill: enqueue a QRZ-lookup job for every user with a
+ # missing home QTH. Idempotent — the worker no-ops if home_grid is
+ # already set, so re-running on every boot is safe (and useful: any
+ # users registered while the worker module was unavailable get
+ # picked up the next time the pod restarts).
+ {:ok, _pid} = Task.start(fn -> Microwaveprop.Accounts.backfill_missing_home_qth() end)
+
# Load ML model in dev/test only (Nx/Axon not compiled for prod)
if Application.get_env(:microwaveprop, :load_ml_model, false) do
Microwaveprop.Propagation.load_ml_model()
diff --git a/lib/microwaveprop/workers/user_home_qth_lookup_worker.ex b/lib/microwaveprop/workers/user_home_qth_lookup_worker.ex
new file mode 100644
index 00000000..e6a5772a
--- /dev/null
+++ b/lib/microwaveprop/workers/user_home_qth_lookup_worker.ex
@@ -0,0 +1,70 @@
+defmodule Microwaveprop.Workers.UserHomeQthLookupWorker do
+ @moduledoc """
+ Looks up a user's callsign via QRZ (with Google Maps fallback) and
+ populates their home QTH fields if they're still empty.
+
+ Enqueued automatically on `Accounts.register_user/1` and reusable for
+ backfilling existing users via:
+
+ Microwaveprop.Repo.all(
+ from u in Microwaveprop.Accounts.User, where: is_nil(u.home_grid)
+ )
+ |> Enum.each(fn user ->
+ %{user_id: user.id}
+ |> Microwaveprop.Workers.UserHomeQthLookupWorker.new()
+ |> Oban.insert()
+ end)
+ """
+
+ use Oban.Worker, queue: :weather, max_attempts: 3
+
+ alias Microwaveprop.Accounts
+ alias Microwaveprop.Accounts.User
+ alias Microwaveprop.Radio.CallsignClient
+ alias Microwaveprop.Radio.Maidenhead
+ alias Microwaveprop.Repo
+
+ require Logger
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
+ case Repo.get(User, user_id) do
+ nil ->
+ :ok
+
+ %User{home_grid: grid} when is_binary(grid) and grid != "" ->
+ # User already set their home QTH manually; don't overwrite.
+ :ok
+
+ %User{callsign: nil} ->
+ :ok
+
+ %User{callsign: callsign} = user ->
+ do_lookup(user, callsign)
+ end
+ end
+
+ defp do_lookup(user, callsign) do
+ case CallsignClient.locate(callsign) do
+ {:ok, %{lat: lat, lon: lon} = result} ->
+ grid = result[:gridsquare] || Maidenhead.from_latlon(lat, lon, 10)
+ attrs = %{"home_grid" => grid, "home_lat" => lat, "home_lon" => lon}
+
+ case Accounts.update_user_home_qth(user, attrs) do
+ {:ok, _user} ->
+ :ok
+
+ {:error, changeset} ->
+ Logger.warning(
+ "UserHomeQthLookupWorker save failed: callsign=#{inspect(callsign)} errors=#{inspect(changeset.errors)}"
+ )
+
+ :ok
+ end
+
+ {:error, reason} ->
+ Logger.info("UserHomeQthLookupWorker lookup failed for #{inspect(callsign)}: #{inspect(reason)}")
+ :ok
+ end
+ end
+end
diff --git a/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex b/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex
index 9cc1e595..e7758ecb 100644
--- a/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex
+++ b/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex
@@ -32,6 +32,17 @@
Used to anchor the rover map (drive radius, distance/bearing labels).
Enter a Maidenhead grid (e.g. EM13qc) or explicit lat/lon.
{@current_scope.user.callsign}
+ is queued — check back shortly, or set yours manually below.
+
+