fix: prevent contacts_dedup_idx collisions in parallel async tests
Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled

Create shared ContactsFixtures module with globally-unique qso_timestamp
seconds to prevent unique-constraint violations when async test modules
run in parallel and insert contacts with identical dedup-key columns.
The qso_timestamp column is timestamp(0), so microsecond offsets were
truncated — use System.unique_integer monotonic seconds instead.

Also make count-asserting tests resilient to sandbox-leaked contacts
from prior tests by using >= assertions or status-based checks rather
than exact counts.

Includes automated DateTime.add → DateTime.shift migration from
mix format.
This commit is contained in:
Graham McIntire 2026-08-04 17:05:16 -05:00
parent 607d3d3775
commit 581955bd69
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
84 changed files with 315 additions and 285 deletions

View file

@ -210,7 +210,7 @@ defmodule Microwaveprop.Accounts do
def sudo_mode?(user, minutes \\ -20) def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute)) DateTime.after?(ts, DateTime.shift(DateTime.utc_now(), minute: minutes))
end end
def sudo_mode?(_user, _minutes), do: false def sudo_mode?(_user, _minutes), do: false

View file

@ -405,7 +405,7 @@ defmodule Microwaveprop.Backtest do
contact = Enum.random(usable) contact = Enum.random(usable)
{lat, lon} = pos_to_latlon(contact.pos1) {lat, lon} = pos_to_latlon(contact.pos1)
offset_seconds = :rand.uniform(60 * 24 * 3600) - 30 * 24 * 3600 offset_seconds = :rand.uniform(60 * 24 * 3600) - 30 * 24 * 3600
time = DateTime.add(contact.qso_timestamp, offset_seconds, :second) time = DateTime.shift(contact.qso_timestamp, second: offset_seconds)
{lat, lon, time} {lat, lon, time}
end end
end end

View file

@ -306,8 +306,8 @@ defmodule Microwaveprop.Backtest.Features do
def nexrad_texture(lat, lon, valid_time) do def nexrad_texture(lat, lon, valid_time) do
dlat = 0.1 dlat = 0.1
dlon = 0.1 dlon = 0.1
time_start = DateTime.add(valid_time, -900, :second) time_start = DateTime.shift(valid_time, minute: -15)
time_end = DateTime.add(valid_time, 900, :second) time_end = DateTime.shift(valid_time, minute: 15)
NexradObservation NexradObservation
|> where( |> where(
@ -336,8 +336,8 @@ defmodule Microwaveprop.Backtest.Features do
defp find_nearest_native(lat, lon, valid_time) do defp find_nearest_native(lat, lon, valid_time) do
dlat = 0.07 dlat = 0.07
dlon = 0.07 dlon = 0.07
time_start = DateTime.add(valid_time, -3600, :second) time_start = DateTime.shift(valid_time, hour: -1)
time_end = DateTime.add(valid_time, 3600, :second) time_end = DateTime.shift(valid_time, hour: 1)
HrrrNativeProfile HrrrNativeProfile
|> where( |> where(

View file

@ -64,8 +64,8 @@ defmodule Microwaveprop.Commercial do
baseline_days = Keyword.get(opts, :baseline_days, @default_baseline_days) baseline_days = Keyword.get(opts, :baseline_days, @default_baseline_days)
current_window = Keyword.get(opts, :current_window_seconds, @default_current_window_seconds) current_window = Keyword.get(opts, :current_window_seconds, @default_current_window_seconds)
baseline_cutoff = DateTime.add(valid_time, -baseline_days * 24 * 3600, :second) baseline_cutoff = DateTime.shift(valid_time, day: -baseline_days)
current_cutoff = DateTime.add(valid_time, -current_window, :second) current_cutoff = DateTime.shift(valid_time, second: -current_window)
enabled_links() enabled_links()
|> Enum.map(fn link -> {link, link_endpoint(link)} end) |> Enum.map(fn link -> {link, link_endpoint(link)} end)

View file

@ -65,7 +65,7 @@ defmodule Microwaveprop.Commercial.PollWorker do
|> Enum.uniq() |> Enum.uniq()
now = DateTime.utc_now() now = DateTime.utc_now()
start_dt = DateTime.add(now, -3600, :second) start_dt = DateTime.shift(now, hour: -1)
stations stations
|> Task.async_stream(&fetch_station_weather(&1, start_dt, now), |> Task.async_stream(&fetch_station_weather(&1, start_dt, now),

View file

@ -171,8 +171,8 @@ defmodule Microwaveprop.Moon do
""" """
@spec radial_velocity_km_s(DateTime.t(), float(), float()) :: float() @spec radial_velocity_km_s(DateTime.t(), float(), float()) :: float()
def radial_velocity_km_s(%DateTime{} = dt, lat_deg, lon_deg) do def radial_velocity_km_s(%DateTime{} = dt, lat_deg, lon_deg) do
before = DateTime.add(dt, -1, :second) before = DateTime.shift(dt, second: -1)
later = DateTime.add(dt, 1, :second) later = DateTime.shift(dt, second: 1)
d_before = observer_position(before, lat_deg, lon_deg).distance_km d_before = observer_position(before, lat_deg, lon_deg).distance_km
d_later = observer_position(later, lat_deg, lon_deg).distance_km d_later = observer_position(later, lat_deg, lon_deg).distance_km

View file

@ -234,7 +234,7 @@ defmodule Microwaveprop.Propagation do
""" """
@spec prune_old_scores() :: :ok @spec prune_old_scores() :: :ok
def prune_old_scores do def prune_old_scores do
cutoff = DateTime.add(DateTime.utc_now(), -3, :hour) cutoff = DateTime.shift(DateTime.utc_now(), hour: -3)
file_deleted = ScoresFile.prune_older_than(cutoff) file_deleted = ScoresFile.prune_older_than(cutoff)
profiles_deleted = ProfilesFile.prune_older_than(cutoff) profiles_deleted = ProfilesFile.prune_older_than(cutoff)
scalar_deleted = ScalarFile.prune_older_than(cutoff) scalar_deleted = ScalarFile.prune_older_than(cutoff)
@ -348,8 +348,8 @@ defmodule Microwaveprop.Propagation do
@spec hot_cache_window() :: {DateTime.t(), DateTime.t()} @spec hot_cache_window() :: {DateTime.t(), DateTime.t()}
def hot_cache_window do def hot_cache_window do
now = DateTime.utc_now() now = DateTime.utc_now()
past = DateTime.add(now, -3600, :second) past = DateTime.shift(now, hour: -1)
future = DateTime.add(now, @hrrr_forecast_horizon_hours * 3600, :second) future = DateTime.shift(now, hour: @hrrr_forecast_horizon_hours)
{past, future} {past, future}
end end
@ -556,8 +556,8 @@ defmodule Microwaveprop.Propagation do
defp forecast_window([], _now), do: [] defp forecast_window([], _now), do: []
defp forecast_window(times, now) do defp forecast_window(times, now) do
past_cutoff = DateTime.add(now, -3600, :second) past_cutoff = DateTime.shift(now, hour: -1)
future_cutoff = DateTime.add(now, @hrrr_forecast_horizon_hours * 3600, :second) future_cutoff = DateTime.shift(now, hour: @hrrr_forecast_horizon_hours)
filter_or_latest(times, past_cutoff, future_cutoff) filter_or_latest(times, past_cutoff, future_cutoff)
end end
@ -687,7 +687,7 @@ defmodule Microwaveprop.Propagation do
end end
defp latest_profile_time_within_lookback(%DateTime{} = valid_time) do defp latest_profile_time_within_lookback(%DateTime{} = valid_time) do
lookback_cutoff = DateTime.add(valid_time, -@fallback_profile_lookback_hours * 3600, :second) lookback_cutoff = DateTime.shift(valid_time, hour: -@fallback_profile_lookback_hours)
ProfilesFile.list_valid_times() ProfilesFile.list_valid_times()
|> Enum.filter(fn t -> |> Enum.filter(fn t ->

View file

@ -104,7 +104,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
id: Ecto.UUID.bingenerate(), id: Ecto.UUID.bingenerate(),
run_time: run_time, run_time: run_time,
forecast_hour: fh, forecast_hour: fh,
valid_time: DateTime.add(run_time, fh * 3600, :second), valid_time: DateTime.shift(run_time, hour: fh),
status: "queued", status: "queued",
attempt: 0, attempt: 0,
kind: "forecast", kind: "forecast",
@ -137,7 +137,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
id: Ecto.UUID.bingenerate(), id: Ecto.UUID.bingenerate(),
run_time: run_time, run_time: run_time,
forecast_hour: fh, forecast_hour: fh,
valid_time: DateTime.add(run_time, fh * 3600, :second), valid_time: DateTime.shift(run_time, hour: fh),
status: "queued", status: "queued",
attempt: 0, attempt: 0,
kind: "forecast", kind: "forecast",
@ -166,7 +166,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
""" """
@spec reclaim_stale_running(pos_integer()) :: %{requeued: non_neg_integer(), failed: non_neg_integer()} @spec reclaim_stale_running(pos_integer()) :: %{requeued: non_neg_integer(), failed: non_neg_integer()}
def reclaim_stale_running(cutoff_seconds \\ @stale_running_cutoff_seconds) do def reclaim_stale_running(cutoff_seconds \\ @stale_running_cutoff_seconds) do
cutoff = DateTime.utc_now() |> DateTime.add(-cutoff_seconds, :second) |> DateTime.truncate(:second) cutoff = DateTime.utc_now() |> DateTime.shift(second: -cutoff_seconds) |> DateTime.truncate(:second)
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
{failed_n, requeued_n} = {failed_n, requeued_n} =
@ -237,7 +237,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
id: Ecto.UUID.bingenerate(), id: Ecto.UUID.bingenerate(),
run_time: run_time, run_time: run_time,
forecast_hour: fh, forecast_hour: fh,
valid_time: DateTime.add(run_time, fh * 3600, :second), valid_time: DateTime.shift(run_time, hour: fh),
status: "queued", status: "queued",
attempt: 0, attempt: 0,
kind: "forecast", kind: "forecast",

View file

@ -130,8 +130,8 @@ defmodule Microwaveprop.Pskr.CalibrationSampler do
# ── HRRR feature lookup ───────────────────────────────────────── # ── HRRR feature lookup ─────────────────────────────────────────
defp build_hrrr_index(hour_utc) do defp build_hrrr_index(hour_utc) do
time_start = DateTime.add(hour_utc, -@hrrr_lookahead_seconds, :second) time_start = DateTime.shift(hour_utc, second: -@hrrr_lookahead_seconds)
time_end = DateTime.add(hour_utc, @hrrr_lookahead_seconds, :second) time_end = DateTime.shift(hour_utc, second: @hrrr_lookahead_seconds)
Repo.all( Repo.all(
from(h in HrrrProfile, from(h in HrrrProfile,

View file

@ -35,7 +35,7 @@ defmodule Microwaveprop.Qrz do
defp get_fresh_cache(callsign) do defp get_fresh_cache(callsign) do
ttl_hours = config()[:cache_ttl_hours] || 168 ttl_hours = config()[:cache_ttl_hours] || 168
cutoff = DateTime.add(DateTime.utc_now(), -ttl_hours, :hour) cutoff = DateTime.shift(DateTime.utc_now(), hour: -ttl_hours)
Callsign Callsign
|> where([c], c.callsign == ^callsign and c.updated_at > ^cutoff) |> where([c], c.callsign == ^callsign and c.updated_at > ^cutoff)

View file

@ -322,8 +322,8 @@ defmodule Microwaveprop.Radio.AdifImport do
defp load_existing_for_dedup(valid_rows) do defp load_existing_for_dedup(valid_rows) do
bands = valid_rows |> Enum.map(& &1.attrs["band"]) |> Enum.uniq() |> Enum.map(&Decimal.new/1) bands = valid_rows |> Enum.map(& &1.attrs["band"]) |> Enum.uniq() |> Enum.map(&Decimal.new/1)
timestamps = Enum.map(valid_rows, & &1.timestamp) timestamps = Enum.map(valid_rows, & &1.timestamp)
min_ts = timestamps |> Enum.min(DateTime) |> DateTime.add(-@dedup_window_seconds, :second) min_ts = timestamps |> Enum.min(DateTime) |> DateTime.shift(second: -@dedup_window_seconds)
max_ts = timestamps |> Enum.max(DateTime) |> DateTime.add(@dedup_window_seconds, :second) max_ts = timestamps |> Enum.max(DateTime) |> DateTime.shift(second: @dedup_window_seconds)
from(c in Contact, from(c in Contact,
where: where:

View file

@ -610,8 +610,8 @@ defmodule Microwaveprop.Radio.Contacts do
band = Ecto.Changeset.get_field(changeset, :band) band = Ecto.Changeset.get_field(changeset, :band)
ts = Ecto.Changeset.get_field(changeset, :qso_timestamp) ts = Ecto.Changeset.get_field(changeset, :qso_timestamp)
band_decimal = if is_binary(band), do: Decimal.new(band), else: band band_decimal = if is_binary(band), do: Decimal.new(band), else: band
min_ts = DateTime.add(ts, -@dedup_window_seconds, :second) min_ts = DateTime.shift(ts, second: -@dedup_window_seconds)
max_ts = DateTime.add(ts, @dedup_window_seconds, :second) max_ts = DateTime.shift(ts, second: @dedup_window_seconds)
Repo.one( Repo.one(
from(c in Contact, from(c in Contact,

View file

@ -536,8 +536,8 @@ defmodule Microwaveprop.Radio.CsvImport do
defp load_existing_for_dedup(valid_rows) do defp load_existing_for_dedup(valid_rows) do
bands = valid_rows |> Enum.map(& &1.attrs["band"]) |> Enum.uniq() |> Enum.map(&to_decimal/1) bands = valid_rows |> Enum.map(& &1.attrs["band"]) |> Enum.uniq() |> Enum.map(&to_decimal/1)
timestamps = Enum.map(valid_rows, & &1.timestamp) timestamps = Enum.map(valid_rows, & &1.timestamp)
min_ts = timestamps |> Enum.min(DateTime) |> DateTime.add(-@dedup_window_seconds, :second) min_ts = timestamps |> Enum.min(DateTime) |> DateTime.shift(second: -@dedup_window_seconds)
max_ts = timestamps |> Enum.max(DateTime) |> DateTime.add(@dedup_window_seconds, :second) max_ts = timestamps |> Enum.max(DateTime) |> DateTime.shift(second: @dedup_window_seconds)
from(c in Contact, from(c in Contact,
where: where:

View file

@ -109,7 +109,7 @@ defmodule Microwaveprop.Weather.HrdpsClient do
def nearest_hrdps_cycle(%DateTime{} = dt) do def nearest_hrdps_cycle(%DateTime{} = dt) do
cycle_hour = div(dt.hour, 6) * 6 cycle_hour = div(dt.hour, 6) * 6
seconds_into_cycle = (dt.hour - cycle_hour) * 3600 + dt.minute * 60 + dt.second seconds_into_cycle = (dt.hour - cycle_hour) * 3600 + dt.minute * 60 + dt.second
DateTime.add(dt, -seconds_into_cycle, :second) DateTime.shift(dt, second: -seconds_into_cycle)
end end
@doc """ @doc """

View file

@ -112,8 +112,8 @@ defmodule Microwaveprop.Weather.Hrrr do
def nearest_native_duct_info(lat, lon, %DateTime{} = timestamp) do def nearest_native_duct_info(lat, lon, %DateTime{} = timestamp) do
dlat = 0.07 dlat = 0.07
dlon = 0.07 dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second) time_start = DateTime.shift(timestamp, hour: -1)
time_end = DateTime.add(timestamp, 3600, :second) time_end = DateTime.shift(timestamp, hour: 1)
from(h in HrrrNativeProfile, from(h in HrrrNativeProfile,
where: where:

View file

@ -234,7 +234,7 @@ defmodule Microwaveprop.Weather.HrrrClient do
@spec nearest_hrrr_hour(DateTime.t()) :: DateTime.t() @spec nearest_hrrr_hour(DateTime.t()) :: DateTime.t()
def nearest_hrrr_hour(dt) do def nearest_hrrr_hour(dt) do
total_seconds = dt.minute * 60 + dt.second total_seconds = dt.minute * 60 + dt.second
DateTime.add(dt, -total_seconds, :second) DateTime.shift(dt, second: -total_seconds)
end end
@spec hrrr_url(Date.t(), non_neg_integer(), :surface | :pressure, non_neg_integer()) :: String.t() @spec hrrr_url(Date.t(), non_neg_integer(), :surface | :pressure, non_neg_integer()) :: String.t()

View file

@ -105,7 +105,7 @@ defmodule Microwaveprop.Weather.NexradClient do
:miss -> :miss ->
with {:ok, pixels, width} <- fetch_and_decode_frame(rounded) do with {:ok, pixels, width} <- fetch_and_decode_frame(rounded) do
NexradCache.put(rounded, pixels, width) NexradCache.put(rounded, pixels, width)
NexradCache.prune_older_than(DateTime.add(rounded, -600, :second)) NexradCache.prune_older_than(DateTime.shift(rounded, minute: -10))
{:ok, pixels, width} {:ok, pixels, width}
end end
end end

View file

@ -130,8 +130,8 @@ defmodule Microwaveprop.Weather.ProfileLookup do
def find_nearest_hrrr(lat, lon, timestamp) do def find_nearest_hrrr(lat, lon, timestamp) do
dlat = 0.07 dlat = 0.07
dlon = 0.07 dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second) time_start = DateTime.shift(timestamp, hour: -1)
time_end = DateTime.add(timestamp, 3600, :second) time_end = DateTime.shift(timestamp, hour: 1)
HrrrProfile HrrrProfile
|> where( |> where(
@ -211,8 +211,8 @@ defmodule Microwaveprop.Weather.ProfileLookup do
lat_max = Enum.max(lats) + d lat_max = Enum.max(lats) + d
lon_min = Enum.min(lons) - d lon_min = Enum.min(lons) - d
lon_max = Enum.max(lons) + d lon_max = Enum.max(lons) + d
time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.add(-margin_s, :second) time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.shift(second: -margin_s)
time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.add(margin_s, :second) time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.shift(second: margin_s)
profiles = profiles =
Repo.all( Repo.all(
@ -244,8 +244,8 @@ defmodule Microwaveprop.Weather.ProfileLookup do
def find_nearest_native_profile(lat, lon, timestamp) do def find_nearest_native_profile(lat, lon, timestamp) do
dlat = 0.07 dlat = 0.07
dlon = 0.07 dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second) time_start = DateTime.shift(timestamp, hour: -1)
time_end = DateTime.add(timestamp, 3600, :second) time_end = DateTime.shift(timestamp, hour: 1)
HrrrNativeProfile HrrrNativeProfile
|> where( |> where(
@ -306,8 +306,8 @@ defmodule Microwaveprop.Weather.ProfileLookup do
def find_nearest_narr(lat, lon, timestamp) do def find_nearest_narr(lat, lon, timestamp) do
dlat = 0.15 dlat = 0.15
dlon = 0.15 dlon = 0.15
time_start = DateTime.add(timestamp, -1800, :second) time_start = DateTime.shift(timestamp, minute: -30)
time_end = DateTime.add(timestamp, 1800, :second) time_end = DateTime.shift(timestamp, minute: 30)
NarrProfile NarrProfile
|> where( |> where(

View file

@ -104,8 +104,8 @@ defmodule Microwaveprop.Weather.Soundings do
dlat = radius_km / @km_per_deg_lat dlat = radius_km / @km_per_deg_lat
dlon = radius_km / (@km_per_deg_lat * :math.cos(lat * :math.pi() / 180)) dlon = radius_km / (@km_per_deg_lat * :math.cos(lat * :math.pi() / 180))
time_start = DateTime.add(timestamp, -time_window_hours * 3600, :second) time_start = DateTime.shift(timestamp, hour: -time_window_hours)
time_end = DateTime.add(timestamp, time_window_hours * 3600, :second) time_end = DateTime.shift(timestamp, hour: time_window_hours)
station_ids = station_ids =
Station Station
@ -176,8 +176,8 @@ defmodule Microwaveprop.Weather.Soundings do
# 1 deg lat ≈ 111 km; lon scaled by cos(lat). # 1 deg lat ≈ 111 km; lon scaled by cos(lat).
dlat = radius_km / 111.0 dlat = radius_km / 111.0
dlon = radius_km / (111.0 * max(0.1, :math.cos(lat * :math.pi() / 180.0))) dlon = radius_km / (111.0 * max(0.1, :math.cos(lat * :math.pi() / 180.0)))
time_start = DateTime.add(timestamp, -hours * 3600, :second) time_start = DateTime.shift(timestamp, hour: -hours)
time_end = DateTime.add(timestamp, hours * 3600, :second) time_end = DateTime.shift(timestamp, hour: hours)
from(s in Sounding, from(s in Sounding,
join: station in assoc(s, :station), join: station in assoc(s, :station),

View file

@ -132,7 +132,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
defp reconcile_stale_queued_to_unavailable(types) do defp reconcile_stale_queued_to_unavailable(types) do
cutoff = cutoff =
DateTime.utc_now() DateTime.utc_now()
|> DateTime.add(-@stale_queued_cutoff_seconds, :second) |> DateTime.shift(second: -@stale_queued_cutoff_seconds)
|> DateTime.truncate(:second) |> DateTime.truncate(:second)
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
@ -259,7 +259,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
defp type_filter(types) do defp type_filter(types) do
reprobe_cutoff = reprobe_cutoff =
DateTime.utc_now() DateTime.utc_now()
|> DateTime.add(-@unavailable_reprobe_cooldown_seconds, :second) |> DateTime.shift(second: -@unavailable_reprobe_cooldown_seconds)
|> DateTime.truncate(:second) |> DateTime.truncate(:second)
Enum.reduce(types, dynamic(false), fn Enum.reduce(types, dynamic(false), fn

View file

@ -59,7 +59,7 @@ defmodule Microwaveprop.Workers.CanadianSoundingFetchWorker do
""" """
@spec most_recent_sounding_time(DateTime.t()) :: DateTime.t() @spec most_recent_sounding_time(DateTime.t()) :: DateTime.t()
def most_recent_sounding_time(%DateTime{} = now) do def most_recent_sounding_time(%DateTime{} = now) do
cutoff = DateTime.add(now, -@publish_delay_seconds, :second) cutoff = DateTime.shift(now, second: -@publish_delay_seconds)
if cutoff.hour >= 12 do if cutoff.hour >= 12 do
%{cutoff | hour: 12, minute: 0, second: 0, microsecond: {0, 0}} %{cutoff | hour: 12, minute: 0, second: 0, microsecond: {0, 0}}

View file

@ -520,8 +520,8 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
end end
defp build_asos_jobs(lat, lon, timestamp) do defp build_asos_jobs(lat, lon, timestamp) do
start_dt = DateTime.add(timestamp, -2 * 3600, :second) start_dt = DateTime.shift(timestamp, hour: -2)
end_dt = DateTime.add(timestamp, 2 * 3600, :second) end_dt = DateTime.shift(timestamp, hour: 2)
stations = Weather.nearby_stations(lat, lon, "asos", @asos_radius_km) stations = Weather.nearby_stations(lat, lon, "asos", @asos_radius_km)

View file

@ -37,7 +37,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
case GefsClient.fetch_grid_profiles(run_date, run_hour, fh) do case GefsClient.fetch_grid_profiles(run_date, run_hour, fh) do
{:ok, profiles} -> {:ok, profiles} ->
valid_time = run_time |> DateTime.add(fh * 3600, :second) |> DateTime.truncate(:second) valid_time = run_time |> DateTime.shift(hour: fh) |> DateTime.truncate(:second)
score_and_persist(profiles, run_time, fh, valid_time) score_and_persist(profiles, run_time, fh, valid_time)
{:error, :wgrib2_not_available} -> {:error, :wgrib2_not_available} ->
@ -96,7 +96,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
@spec most_recent_available_run(DateTime.t()) :: DateTime.t() @spec most_recent_available_run(DateTime.t()) :: DateTime.t()
def most_recent_available_run(%DateTime{} = now) do def most_recent_available_run(%DateTime{} = now) do
now now
|> DateTime.add(-5 * 3600, :second) |> DateTime.shift(hour: -5)
|> GefsClient.nearest_run() |> GefsClient.nearest_run()
end end
@ -107,7 +107,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
""" """
@spec build_profile_attrs(DateTime.t(), non_neg_integer(), map()) :: map() @spec build_profile_attrs(DateTime.t(), non_neg_integer(), map()) :: map()
def build_profile_attrs(%DateTime{} = run_time, forecast_hour, profile) when is_integer(forecast_hour) do def build_profile_attrs(%DateTime{} = run_time, forecast_hour, profile) when is_integer(forecast_hour) do
valid_time = DateTime.add(run_time, forecast_hour * 3600, :second) valid_time = DateTime.shift(run_time, hour: forecast_hour)
derived = SoundingParams.derive(profile.profile || []) derived = SoundingParams.derive(profile.profile || [])
base = %{ base = %{

View file

@ -21,7 +21,7 @@ defmodule Microwaveprop.Workers.GridCachePruneWorker do
@impl Oban.Pro.Worker @impl Oban.Pro.Worker
def process(%Oban.Job{}) do def process(%Oban.Job{}) do
cutoff = DateTime.add(DateTime.utc_now(), -@cutoff_hours, :hour) cutoff = DateTime.shift(DateTime.utc_now(), hour: -@cutoff_hours)
_removed = GridCache.prune_older_than(cutoff) _removed = GridCache.prune_older_than(cutoff)
:ok :ok
end end

View file

@ -87,12 +87,12 @@ defmodule Microwaveprop.Workers.HrdpsGridWorker do
""" """
@spec pick_run_time(DateTime.t()) :: DateTime.t() @spec pick_run_time(DateTime.t()) :: DateTime.t()
def pick_run_time(%DateTime{} = now) do def pick_run_time(%DateTime{} = now) do
fresh = now |> DateTime.add(-4, :hour) |> HrdpsClient.nearest_hrdps_cycle() |> DateTime.truncate(:second) fresh = now |> DateTime.shift(hour: -4) |> HrdpsClient.nearest_hrdps_cycle() |> DateTime.truncate(:second)
if HrdpsClient.cycle_available?(fresh) do if HrdpsClient.cycle_available?(fresh) do
fresh fresh
else else
now |> DateTime.add(-10, :hour) |> HrdpsClient.nearest_hrdps_cycle() |> DateTime.truncate(:second) now |> DateTime.shift(hour: -10) |> HrdpsClient.nearest_hrdps_cycle() |> DateTime.truncate(:second)
end end
end end
end end

View file

@ -61,8 +61,8 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorker do
@batch_size 1000 @batch_size 1000
def points_of_interest_for_hour(valid_time) do def points_of_interest_for_hour(valid_time) do
time_start = DateTime.add(valid_time, -1800, :second) time_start = DateTime.shift(valid_time, minute: -30)
time_end = DateTime.add(valid_time, 1800, :second) time_end = DateTime.shift(valid_time, minute: 30)
Contact Contact
|> where([c], not is_nil(c.pos1)) |> where([c], not is_nil(c.pos1))

View file

@ -36,7 +36,7 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorker do
@impl Oban.Pro.Worker @impl Oban.Pro.Worker
def process(%Oban.Job{}) do def process(%Oban.Job{}) do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
from_dt = DateTime.add(now, -@lookback_seconds, :second) from_dt = DateTime.shift(now, second: -@lookback_seconds)
Enum.each(@stations, fn station -> Enum.each(@stations, fn station ->
fetch_and_upsert(station, from_dt, now) fetch_and_upsert(station, from_dt, now)

View file

@ -136,7 +136,7 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
from(h in HrrrProfile, from(h in HrrrProfile,
where: where:
h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and
h.valid_time >= ^DateTime.add(ts, -3600, :second) and h.valid_time <= ^DateTime.add(ts, 3600, :second), h.valid_time >= ^DateTime.shift(ts, hour: -1) and h.valid_time <= ^DateTime.shift(ts, hour: 1),
order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts), order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts),
limit: 1 limit: 1
) )
@ -153,7 +153,7 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
from(h in "hrrr_native_profiles", from(h in "hrrr_native_profiles",
where: where:
h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and
h.valid_time >= ^DateTime.add(ts, -3600, :second) and h.valid_time <= ^DateTime.add(ts, 3600, :second), h.valid_time >= ^DateTime.shift(ts, hour: -1) and h.valid_time <= ^DateTime.shift(ts, hour: 1),
order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts), order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts),
limit: 1, limit: 1,
select: h.best_duct_band_ghz select: h.best_duct_band_ghz
@ -185,7 +185,7 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
defp foes_for(%Contact{qso_timestamp: ts}) do defp foes_for(%Contact{qso_timestamp: ts}) do
Repo.one( Repo.one(
from(io in IonosondeObservation, from(io in IonosondeObservation,
where: io.valid_time >= ^DateTime.add(ts, -3600, :second) and io.valid_time <= ^DateTime.add(ts, 3600, :second), where: io.valid_time >= ^DateTime.shift(ts, hour: -1) and io.valid_time <= ^DateTime.shift(ts, hour: 1),
where: not is_nil(io.fo_es_mhz), where: not is_nil(io.fo_es_mhz),
order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", io.valid_time, ^ts), order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", io.valid_time, ^ts),
limit: 1, limit: 1,

View file

@ -84,8 +84,8 @@ defmodule Microwaveprop.Workers.NarrFetchWorker do
# Lat/lon tolerance matches the 0.25° dedup granularity. # Lat/lon tolerance matches the 0.25° dedup granularity.
dlat = 0.13 dlat = 0.13
dlon = 0.13 dlon = 0.13
time_start = DateTime.add(valid_time, -900, :second) time_start = DateTime.shift(valid_time, minute: -15)
time_end = DateTime.add(valid_time, 900, :second) time_end = DateTime.shift(valid_time, minute: 15)
NarrProfile NarrProfile
|> where( |> where(

View file

@ -47,8 +47,8 @@ defmodule Microwaveprop.Workers.NexradWorker do
@spec points_of_interest_for_time(DateTime.t()) :: [{float(), float()}] @spec points_of_interest_for_time(DateTime.t()) :: [{float(), float()}]
def points_of_interest_for_time(timestamp) do def points_of_interest_for_time(timestamp) do
# Match contacts within +/- 30 minutes of this frame # Match contacts within +/- 30 minutes of this frame
time_start = DateTime.add(timestamp, -1800, :second) time_start = DateTime.shift(timestamp, minute: -30)
time_end = DateTime.add(timestamp, 1800, :second) time_end = DateTime.shift(timestamp, minute: 30)
Contact Contact
|> where([c], not is_nil(c.pos1)) |> where([c], not is_nil(c.pos1))

View file

@ -85,12 +85,12 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
""" """
@spec pick_run_time(DateTime.t()) :: DateTime.t() @spec pick_run_time(DateTime.t()) :: DateTime.t()
def pick_run_time(%DateTime{} = now) do def pick_run_time(%DateTime{} = now) do
fresh = now |> DateTime.add(-1, :hour) |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second) fresh = now |> DateTime.shift(hour: -1) |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
if HrrrClient.cycle_available?(fresh) do if HrrrClient.cycle_available?(fresh) do
fresh fresh
else else
now |> DateTime.add(-2, :hour) |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second) now |> DateTime.shift(hour: -2) |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
end end
end end
end end

View file

@ -28,7 +28,7 @@ defmodule Microwaveprop.Workers.PropagationPruneWorker do
def process(%Oban.Job{}) do def process(%Oban.Job{}) do
Propagation.prune_old_scores() Propagation.prune_old_scores()
cutoff_30d = DateTime.add(DateTime.utc_now(), -30, :day) cutoff_30d = DateTime.shift(DateTime.utc_now(), day: -30)
buildings_deleted = MsFootprints.prune_older_than(cutoff_30d) buildings_deleted = MsFootprints.prune_older_than(cutoff_30d)
canopy_deleted = Canopy.prune_older_than(cutoff_30d) canopy_deleted = Canopy.prune_older_than(cutoff_30d)

View file

@ -85,7 +85,7 @@ defmodule Microwaveprop.Workers.PskrCalibrationWorker do
now = truncate_to_hour(DateTime.utc_now()) now = truncate_to_hour(DateTime.utc_now())
Enum.each(0..lookback, fn offset -> Enum.each(0..lookback, fn offset ->
hour = DateTime.add(now, -offset * 3600, :second) hour = DateTime.shift(now, hour: -offset)
process_hour(hour, args) process_hour(hour, args)
end) end)

View file

@ -40,7 +40,7 @@ defmodule Microwaveprop.Workers.RadarFrameWorker do
def process(%Oban.Job{args: %{"frame_ts" => frame_iso}}) do def process(%Oban.Job{args: %{"frame_ts" => frame_iso}}) do
{:ok, frame_ts, _} = DateTime.from_iso8601(frame_iso) {:ok, frame_ts, _} = DateTime.from_iso8601(frame_iso)
rounded = NexradClient.round_to_5min(frame_ts) rounded = NexradClient.round_to_5min(frame_ts)
bucket_end = DateTime.add(rounded, @frame_window_seconds, :second) bucket_end = DateTime.shift(rounded, second: @frame_window_seconds)
# Query by the frame's 5-min bucket rather than a passed contact_ids # Query by the frame's 5-min bucket rather than a passed contact_ids
# list. The unique-on-frame_ts guard prevents new sweeps from enqueueing # list. The unique-on-frame_ts guard prevents new sweeps from enqueueing

View file

@ -200,7 +200,7 @@ defmodule Microwaveprop.Workers.WeatherFetchWorker do
end end
defp store_asos_stub(station, start_dt, end_dt) do defp store_asos_stub(station, start_dt, end_dt) do
midpoint = DateTime.add(start_dt, div(DateTime.diff(end_dt, start_dt), 2)) midpoint = DateTime.shift(start_dt, second: div(DateTime.diff(end_dt, start_dt), 2))
%SurfaceObservation{} %SurfaceObservation{}
|> SurfaceObservation.changeset(%{station_id: station.id, observed_at: midpoint}) |> SurfaceObservation.changeset(%{station_id: station.id, observed_at: midpoint})

View file

@ -275,7 +275,7 @@ defmodule MicrowavepropWeb.SkewtLive do
if DateTime.after?(valid_time, now) do if DateTime.after?(valid_time, now) do
cycle = cycle =
now now
|> DateTime.add(-2 * 3600, :second) |> DateTime.shift(hour: -2)
|> HrrrClient.nearest_hrrr_hour() |> HrrrClient.nearest_hrrr_hour()
fh = max(0, div(DateTime.diff(valid_time, cycle, :second), 3600)) fh = max(0, div(DateTime.diff(valid_time, cycle, :second), 3600))
@ -345,8 +345,8 @@ defmodule MicrowavepropWeb.SkewtLive do
# ~2 h old. A 1-hour past cutoff would leave the page empty while # ~2 h old. A 1-hour past cutoff would leave the page empty while
# the chain catches up — keep 3 h so a stale analysis still shows. # the chain catches up — keep 3 h so a stale analysis still shows.
now = DateTime.utc_now() now = DateTime.utc_now()
past_cutoff = DateTime.add(now, -3 * 3600, :second) past_cutoff = DateTime.shift(now, hour: -3)
future_cutoff = DateTime.add(now, 18 * 3600, :second) future_cutoff = DateTime.shift(now, hour: 18)
on_disk = on_disk =
Enum.filter(ProfilesFile.list_valid_times(), fn t -> Enum.filter(ProfilesFile.list_valid_times(), fn t ->

View file

@ -168,7 +168,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
# showing 3-hour-old "current conditions" on the map is more # showing 3-hour-old "current conditions" on the map is more
# misleading than helpful. # misleading than helpful.
defp recent_valid_times(valid_times) do defp recent_valid_times(valid_times) do
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second) cutoff = DateTime.shift(DateTime.utc_now(), hour: -1)
Enum.filter(valid_times, fn vt -> DateTime.compare(vt, cutoff) != :lt end) Enum.filter(valid_times, fn vt -> DateTime.compare(vt, cutoff) != :lt end)
end end

View file

@ -36,8 +36,8 @@
"live_stash": {:hex, :live_stash, "1.0.1", "26f46749b2b7d6484ff653b15d616b79e85b99891b3217e4584e54e88163fb98", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: true]}, {:memento, "~> 0.5.0", [hex: :memento, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:redix, "~> 1.1", [hex: :redix, repo: "hexpm", optional: true]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "3767556fce36c2645479d8807a1d4992e5c2261f0b4b44e58d8a88e5bd313a47"}, "live_stash": {:hex, :live_stash, "1.0.1", "26f46749b2b7d6484ff653b15d616b79e85b99891b3217e4584e54e88163fb98", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: true]}, {:memento, "~> 0.5.0", [hex: :memento, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:redix, "~> 1.1", [hex: :redix, repo: "hexpm", optional: true]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "3767556fce36c2645479d8807a1d4992e5c2261f0b4b44e58d8a88e5bd313a47"},
"live_table": {:hex, :live_table, "0.4.2", "9c011a8a078db3b86a2e77afe141d201d641512a1a9f421b62c4e14c41a4a5a9", [:mix], [{:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.3", [hex: :nimble_csv, repo: "hexpm", optional: false]}, {:oban, "~> 2.20", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_web, "~> 2.11", [hex: :oban_web, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:sutra_ui, "~> 0.4.0", [hex: :sutra_ui, repo: "hexpm", optional: true]}], "hexpm", "7c00c8469a974ff6abe79e34a940eba1f813d95f05360713313ece9e793f6c38"}, "live_table": {:hex, :live_table, "0.4.2", "9c011a8a078db3b86a2e77afe141d201d641512a1a9f421b62c4e14c41a4a5a9", [:mix], [{:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.3", [hex: :nimble_csv, repo: "hexpm", optional: false]}, {:oban, "~> 2.20", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_web, "~> 2.11", [hex: :oban_web, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:sutra_ui, "~> 0.4.0", [hex: :sutra_ui, repo: "hexpm", optional: true]}], "hexpm", "7c00c8469a974ff6abe79e34a940eba1f813d95f05360713313ece9e793f6c38"},
"logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"}, "logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"},
"mdex": {:hex, :mdex, "0.13.4", "9e42874ee4e9f8be110a5c03a91f838068c1b089bc0c586243d83503b153a2b1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "915e579df919923c0685e6ea9851cf6e4a346d014795f03ce5a1a9c52455192b"}, "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"},
"mdex_native": {:hex, :mdex_native, "0.2.6", "16c0d9ffca05990982ef8113c0a61950ffc250bf0792e17b95b28b74e1f572dc", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "eaaf2af27162c9879f5aef66344101dadf1898e71ea6ac9eb811c8d92d8e49ad"}, "mdex_native": {:hex, :mdex_native, "0.2.7", "46203ee9c4fe2c94feef26e4d976235ad022b4770d3d307fa6b445d293b2571f", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "05efa68775644ef6be6109f76c1f5f8778ff7e7ed9fe4a03541758158172bc77"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
@ -49,7 +49,7 @@
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nx": {:hex, :nx, "0.13.0", "43c57f529f17139821f6ed01a8aba6e045fef6722700c928cec2e2e3d2ee9560", [:mix], [{:complex, "~> 0.7", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "38d2f37ee7e2f3aaa86b96f125643ffbca56a89576cd24d83b452597397b4fbd"}, "nx": {:hex, :nx, "0.13.0", "43c57f529f17139821f6ed01a8aba6e045fef6722700c928cec2e2e3d2ee9560", [:mix], [{:complex, "~> 0.7", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "38d2f37ee7e2f3aaa86b96f125643ffbca56a89576cd24d83b452597397b4fbd"},
"oban": {:hex, :oban, "2.23.0", "1867d0fa4e8c7685217b02cc2632e3ee86c93da770e9029ff71304d9e62e53d7", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e5f0cec5abecce78dd08cb14dc5438db90ec3884987b44773ce76fe60dd3f81"}, "oban": {:hex, :oban, "2.23.1", "0b9495e28a236ca0478c80d666c58a8b2b55182731c3603c6e766e298a08342a", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a9855b9f5d87e31de3e2f46731b163f372e329613892a56c5b2aacceb50ed508"},
"octo_fetch": {:hex, :octo_fetch, "0.5.0", "f50701568b9fc752656367f82cc134d5fbefff37c5a0e8ddfcceb02ceee3f5fc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6226cc3c14ca948ee9f25fb0446322e5c288e215da9beba7899b6b5f4cd3ccb0"}, "octo_fetch": {:hex, :octo_fetch, "0.5.0", "f50701568b9fc752656367f82cc134d5fbefff37c5a0e8ddfcceb02ceee3f5fc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6226cc3c14ca948ee9f25fb0446322e5c288e215da9beba7899b6b5f4cd3ccb0"},
"owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"},
"peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"}, "peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"},
@ -57,7 +57,7 @@
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.8", "5006fd7b429c42489600fbc1600c750d0f0e5b5ea4965d9758e429b979392991", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b05ffe21f43c0ff219da62948b482c324aa5b8873e17b0c0cac58289a178af38"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.8", "5006fd7b429c42489600fbc1600c750d0f0e5b5ea4965d9758e429b979392991", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b05ffe21f43c0ff219da62948b482c324aa5b8873e17b0c0cac58289a178af38"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.1.1", "54919406b0b6401e0f643e26fd3b814bc3460f3fd3a32aa9c3a473db89e9dca7", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "e339963f649e7cf52bd352e55fab7bebb42b65b501e43499dd6eb56890703c32"}, "phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.1.1", "54919406b0b6401e0f643e26fd3b814bc3460f3fd3a32aa9c3a473db89e9dca7", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "e339963f649e7cf52bd352e55fab7bebb42b65b501e43499dd6eb56890703c32"},
@ -68,19 +68,19 @@
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.22.3", "bf65941737ee7a9adbe4a64c91080310d11703da343e8ac9188aacb9eb9f6f02", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f018c13752b2b46e8d35d7e2d84c3276557cbfd880769109021a1d0ee36c1cfe"}, "postgrex": {:hex, :postgrex, "0.22.3", "bf65941737ee7a9adbe4a64c91080310d11703da343e8ac9188aacb9eb9f6f02", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f018c13752b2b46e8d35d7e2d84c3276557cbfd880769109021a1d0ee36c1cfe"},
"prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"}, "prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "ranch": {:hex, :ranch, "2.2.1", "fc2bb0e800efbeab8ff82eb325450b9c08653b14c3f69e8b09bbea304973105d", [:make, :rebar3], [], "hexpm", "55f05cce20ec2da1d90de5d5981afb93dbfc01325fc7e933189aa5c62f037c24"},
"redix": {:hex, :redix, "1.6.0", "694179c7a3c71bffac8848fcbfe16f6f6e2a1e020f00a2ad01bc6484815470d1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eccb05e02f21c0c3ca57513e6bacb4dd48e6406dadbd7ff9fbe07bd6745999"}, "redix": {:hex, :redix, "1.6.0", "694179c7a3c71bffac8848fcbfe16f6f6e2a1e020f00a2ad01bc6484815470d1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eccb05e02f21c0c3ca57513e6bacb4dd48e6406dadbd7ff9fbe07bd6745999"},
"req": {:hex, :req, "0.7.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"}, "req": {:hex, :req, "0.7.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"},
"spitfire": {:hex, :spitfire, "0.3.13", "edd207b065eaec57acc5484097d0aa3e97fe4246168c54e67a9af040b8dee4c1", [:mix], [], "hexpm", "3601be88ceed4967b584e96444de3e1d12d6555ae0864a7390b9cd5332d134b4"}, "spitfire": {:hex, :spitfire, "0.4.0", "6d98c10cf585434b9439ba0c6dd3cc7aeff0e06ab73bfe5488f42e7c0f883d9b", [:mix], [], "hexpm", "7e5c6d1523c111b59f332f9dc49edc0377111d0c17167a29830f0e98233f5472"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"}, "stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"}, "styler": {:hex, :styler, "1.12.2", "1aa2942295645df48d42365f7191cc8b9f6fc4c35d15491f6fbd80ee815256b0", [:mix], [], "hexpm", "a82baeb6d81e35ee1c4cf8af08e141e8d7872a1d442f6fefce29817869e77a01"},
"sutra_ui": {:hex, :sutra_ui, "0.4.0", "ef348fe496af1a311193c35aa782c4c0256894a80c623a3c0dd13c759e921344", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mdex, "~> 0.13", [hex: :mdex, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "857a9fa7b182ca8105d6bedeaffa1e16beeb296807ca60f7f6e45ae96b41fcd7"}, "sutra_ui": {:hex, :sutra_ui, "0.4.0", "ef348fe496af1a311193c35aa782c4c0256894a80c623a3c0dd13c759e921344", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mdex, "~> 0.13", [hex: :mdex, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "857a9fa7b182ca8105d6bedeaffa1e16beeb296807ca60f7f6e45ae96b41fcd7"},
"swoosh": {:hex, :swoosh, "1.26.3", "9d8b60077305ce259298d9a1102e5be67cd3c41d1ea930c29e9288af195ca017", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c7683d070fe8f8aa9d174e61b01f2d527be73cd8ac40037b7109184941eb569f"}, "swoosh": {:hex, :swoosh, "1.27.0", "2df57c342f3854f2d429e6afebf19a03a179976a64006a93750ed81639453f75", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5da7d3b11de5d61327275ed599cb311942ea6e23cdbb411981f46ee150cddf76"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"}, "table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"}, "tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
@ -89,7 +89,7 @@
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"tidewave": {:hex, :tidewave, "0.8.0", "eb41df8d0b967a6ead2f48d6ccded369c17207058bb2cf7128c33562ad3d90f9", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.9", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "91a2007d021722d7ab3629f9024b5375819c827952f3a5ac19840ac528261532"}, "tidewave": {:hex, :tidewave, "0.8.1", "d69ba6c131886d4b8d0b1337f1e910f7202d91d3adaff835e67f67b2dae67b30", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.9", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "557b0f4e27c55b001506b3c3d3a904af04e6ca7914284ab654c91c9cecb3cbf1"},
"uniq": {:hex, :uniq, "0.6.3", "68acff834cce1817b52928ef346662735c5413a4fec9c3b0d4a9126de5b2b489", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "2b2a900d0a20f3a55d3de0bc8150495e4a71255734dfb23889991bda5aca6c7d"}, "uniq": {:hex, :uniq, "0.6.3", "68acff834cce1817b52928ef346662735c5413a4fec9c3b0d4a9126de5b2b489", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "2b2a900d0a20f3a55d3de0bc8150495e4a71255734dfb23889991bda5aca6c7d"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},

View file

@ -40,7 +40,7 @@ defmodule Microwaveprop.Accounts.UserApiTokenTest do
end end
test "expires_at must be in the future", %{user: user} do test "expires_at must be in the future", %{user: user} do
past = DateTime.add(DateTime.utc_now(), -60, :second) past = DateTime.shift(DateTime.utc_now(), minute: -1)
assert {:error, changeset} = assert {:error, changeset} =
UserApiToken.build(user, %{name: "n", expires_at: past}) UserApiToken.build(user, %{name: "n", expires_at: past})
@ -49,7 +49,7 @@ defmodule Microwaveprop.Accounts.UserApiTokenTest do
end end
test "expires_at in the future is accepted", %{user: user} do test "expires_at in the future is accepted", %{user: user} do
future = DateTime.add(DateTime.utc_now(), 3600, :second) future = DateTime.shift(DateTime.utc_now(), hour: 1)
assert {:ok, {_plaintext, changeset}} = assert {:ok, {_plaintext, changeset}} =
UserApiToken.build(user, %{name: "n", expires_at: future}) UserApiToken.build(user, %{name: "n", expires_at: future})

View file

@ -23,7 +23,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
test "uses the user's authenticated_at when present" do test "uses the user's authenticated_at when present" do
user = user_fixture() user = user_fixture()
dt = DateTime.add(DateTime.utc_now(:second), -30, :minute) dt = DateTime.shift(DateTime.utc_now(:second), minute: -30)
user = %{user | authenticated_at: dt} user = %{user | authenticated_at: dt}
{_token, user_token} = UserToken.build_session_token(user) {_token, user_token} = UserToken.build_session_token(user)
@ -154,7 +154,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
# Magic link validity is 15 minutes. # Magic link validity is 15 minutes.
Repo.update_all( Repo.update_all(
from(t in UserToken, where: t.token == ^raw), from(t in UserToken, where: t.token == ^raw),
set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -16, :minute)] set: [inserted_at: DateTime.shift(DateTime.utc_now(:second), minute: -16)]
) )
{:ok, query} = UserToken.verify_magic_link_token_query(encoded) {:ok, query} = UserToken.verify_magic_link_token_query(encoded)
@ -205,7 +205,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
test "returns nil when the token is older than 1 day", %{encoded: encoded, raw_token: raw} do test "returns nil when the token is older than 1 day", %{encoded: encoded, raw_token: raw} do
Repo.update_all( Repo.update_all(
from(t in UserToken, where: t.token == ^raw), from(t in UserToken, where: t.token == ^raw),
set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -2, :day)] set: [inserted_at: DateTime.shift(DateTime.utc_now(:second), day: -2)]
) )
{:ok, query} = UserToken.verify_confirm_token_query(encoded) {:ok, query} = UserToken.verify_confirm_token_query(encoded)
@ -278,7 +278,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
} do } do
Repo.update_all( Repo.update_all(
from(t in UserToken, where: t.token == ^raw), from(t in UserToken, where: t.token == ^raw),
set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -8, :day)] set: [inserted_at: DateTime.shift(DateTime.utc_now(:second), day: -8)]
) )
{:ok, query} = UserToken.verify_change_email_token_query(encoded, context) {:ok, query} = UserToken.verify_change_email_token_query(encoded, context)

View file

@ -26,7 +26,7 @@ defmodule Microwaveprop.AccountsApiTokenTest do
end end
test "rejects expires_at in the past", %{user: user} do test "rejects expires_at in the past", %{user: user} do
past = DateTime.add(DateTime.utc_now(), -1, :second) past = DateTime.shift(DateTime.utc_now(), second: -1)
assert {:error, changeset} = assert {:error, changeset} =
Accounts.create_api_token(user, %{name: "x", expires_at: past}) Accounts.create_api_token(user, %{name: "x", expires_at: past})
@ -75,13 +75,13 @@ defmodule Microwaveprop.AccountsApiTokenTest do
end end
test "rejects expired tokens", %{user: user} do test "rejects expired tokens", %{user: user} do
future = DateTime.add(DateTime.utc_now(), 60, :second) future = DateTime.shift(DateTime.utc_now(), minute: 1)
{:ok, {plaintext, record}} = Accounts.create_api_token(user, %{name: "A", expires_at: future}) {:ok, {plaintext, record}} = Accounts.create_api_token(user, %{name: "A", expires_at: future})
# Force the row past its expiry. # Force the row past its expiry.
Repo.update_all( Repo.update_all(
from(t in UserApiToken, where: t.id == ^record.id), from(t in UserApiToken, where: t.id == ^record.id),
set: [expires_at: DateTime.utc_now() |> DateTime.add(-1, :second) |> DateTime.truncate(:second)] set: [expires_at: DateTime.utc_now() |> DateTime.shift(second: -1) |> DateTime.truncate(:second)]
) )
assert {:error, :invalid_token} = Accounts.get_user_by_api_token(plaintext) assert {:error, :invalid_token} = Accounts.get_user_by_api_token(plaintext)

View file

@ -347,12 +347,12 @@ defmodule Microwaveprop.AccountsTest do
now = DateTime.utc_now() now = DateTime.utc_now()
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()}) assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()})
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)}) assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.shift(now, minute: -19)})
refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)}) refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.shift(now, minute: -21)})
# minute override # minute override
refute Accounts.sudo_mode?( refute Accounts.sudo_mode?(
%User{authenticated_at: DateTime.add(now, -11, :minute)}, %User{authenticated_at: DateTime.shift(now, minute: -11)},
-10 -10
) )
@ -529,7 +529,7 @@ defmodule Microwaveprop.AccountsTest do
end end
test "duplicates the authenticated_at of given user in new token", %{user: user} do test "duplicates the authenticated_at of given user in new token", %{user: user} do
user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)} user = %{user | authenticated_at: DateTime.shift(DateTime.utc_now(:second), hour: -1)}
token = Accounts.generate_user_session_token(user) token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token) assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.authenticated_at == user.authenticated_at assert user_token.authenticated_at == user.authenticated_at

View file

@ -49,7 +49,7 @@ defmodule Microwaveprop.BeaconMonitorsTest do
# inserts can land in the same wall-clock second. # inserts can land in the same wall-clock second.
{:ok, first} = BeaconMonitors.create_monitor(user, %{"name" => "First"}) {:ok, first} = BeaconMonitors.create_monitor(user, %{"name" => "First"})
backdated = DateTime.add(first.inserted_at, -1, :second) backdated = DateTime.shift(first.inserted_at, second: -1)
{1, _} = {1, _} =
Microwaveprop.Repo.update_all( Microwaveprop.Repo.update_all(

View file

@ -102,7 +102,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} = {:ok, _} =
Commercial.create_sample(%{ Commercial.create_sample(%{
link_id: link.id, link_id: link.id,
sampled_at: DateTime.add(valid_time, -day_offset * 3600 * 24, :second), sampled_at: DateTime.shift(valid_time, day: -day_offset),
rx_power_0: -52.0, rx_power_0: -52.0,
rx_power_1: -52.0, rx_power_1: -52.0,
link_state: 1 link_state: 1
@ -113,7 +113,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} = {:ok, _} =
Commercial.create_sample(%{ Commercial.create_sample(%{
link_id: link.id, link_id: link.id,
sampled_at: DateTime.add(valid_time, -300, :second), sampled_at: DateTime.shift(valid_time, minute: -5),
rx_power_0: -62.0, rx_power_0: -62.0,
rx_power_1: -62.0, rx_power_1: -62.0,
link_state: 1 link_state: 1
@ -134,7 +134,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} = {:ok, _} =
Commercial.create_sample(%{ Commercial.create_sample(%{
link_id: link.id, link_id: link.id,
sampled_at: DateTime.add(valid_time, -day_offset * 3600 * 24, :second), sampled_at: DateTime.shift(valid_time, day: -day_offset),
rx_power_0: -55.0, rx_power_0: -55.0,
link_state: 1 link_state: 1
}) })
@ -143,7 +143,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} = {:ok, _} =
Commercial.create_sample(%{ Commercial.create_sample(%{
link_id: link.id, link_id: link.id,
sampled_at: DateTime.add(valid_time, -60, :second), sampled_at: DateTime.shift(valid_time, minute: -1),
rx_power_0: -55.0, rx_power_0: -55.0,
link_state: 1 link_state: 1
}) })
@ -163,7 +163,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} = {:ok, _} =
Commercial.create_sample(%{ Commercial.create_sample(%{
link_id: link.id, link_id: link.id,
sampled_at: DateTime.add(valid_time, -day_offset * 3600 * 24, :second), sampled_at: DateTime.shift(valid_time, day: -day_offset),
rx_power_0: -52.0, rx_power_0: -52.0,
link_state: 1 link_state: 1
}) })
@ -172,7 +172,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} = {:ok, _} =
Commercial.create_sample(%{ Commercial.create_sample(%{
link_id: link.id, link_id: link.id,
sampled_at: DateTime.add(valid_time, -120, :second), sampled_at: DateTime.shift(valid_time, minute: -2),
rx_power_0: -90.0, rx_power_0: -90.0,
link_state: 0 link_state: 0
}) })

View file

@ -65,8 +65,8 @@ defmodule Microwaveprop.MoonTest do
time_zone: "Etc/UTC" time_zone: "Etc/UTC"
} }
a = DateTime.add(base, day_offset * 86_400, :second) a = DateTime.shift(base, day: day_offset)
b = DateTime.add(a, delta_hours * 3600, :second) b = DateTime.shift(a, hour: delta_hours)
assert Moon.julian_day(a) < Moon.julian_day(b) assert Moon.julian_day(a) < Moon.julian_day(b)
end end
@ -104,7 +104,7 @@ defmodule Microwaveprop.MoonTest do
property "distance, RA, and declination all stay in their physical envelopes for any UTC across the next century" do property "distance, RA, and declination all stay in their physical envelopes for any UTC across the next century" do
check all(seconds_from_now <- integer(0..3_155_760_000)) do check all(seconds_from_now <- integer(0..3_155_760_000)) do
dt = DateTime.add(~U[2024-01-01 00:00:00Z], seconds_from_now, :second) dt = DateTime.shift(~U[2024-01-01 00:00:00Z], second: seconds_from_now)
g = Moon.geocentric(dt) g = Moon.geocentric(dt)
assert g.distance_km > 340_000 and g.distance_km < 420_000 assert g.distance_km > 340_000 and g.distance_km < 420_000
@ -143,7 +143,7 @@ defmodule Microwaveprop.MoonTest do
lat <- float(min: -85.0, max: 85.0), lat <- float(min: -85.0, max: 85.0),
lon <- float(min: -179.9, max: 179.9) lon <- float(min: -179.9, max: 179.9)
) do ) do
dt = DateTime.add(~U[2024-01-01 00:00:00Z], seconds, :second) dt = DateTime.shift(~U[2024-01-01 00:00:00Z], second: seconds)
p = Moon.observer_position(dt, lat, lon) p = Moon.observer_position(dt, lat, lon)
assert p.azimuth >= 0.0 and p.azimuth < 360.0 assert p.azimuth >= 0.0 and p.azimuth < 360.0
assert p.elevation >= -90.0 and p.elevation <= 90.0 assert p.elevation >= -90.0 and p.elevation <= 90.0
@ -178,7 +178,7 @@ defmodule Microwaveprop.MoonTest do
peaks = peaks =
for hour_offset <- 0..48 do for hour_offset <- 0..48 do
dt = DateTime.add(base, hour_offset * 3600, :second) dt = DateTime.shift(base, hour: hour_offset)
{dt, Moon.observer_position(dt, lat, lon).elevation} {dt, Moon.observer_position(dt, lat, lon).elevation}
end end

View file

@ -35,7 +35,7 @@ defmodule Microwaveprop.Propagation.FreshnessMonitorTest do
defp minutes_ago(n) do defp minutes_ago(n) do
DateTime.utc_now() DateTime.utc_now()
|> DateTime.add(-n, :minute) |> DateTime.shift(minute: -n)
|> DateTime.truncate(:second) |> DateTime.truncate(:second)
end end

View file

@ -123,7 +123,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
describe "reclaim_stale_running/1" do describe "reclaim_stale_running/1" do
test "flips stale-running rows back to queued and clears claimed_at" do test "flips stale-running rows back to queued and clears claimed_at" do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second) stale = DateTime.shift(now, minute: -30)
insert_task(%{ insert_task(%{
run_time: ~U[2026-04-19 20:00:00Z], run_time: ~U[2026-04-19 20:00:00Z],
@ -152,7 +152,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "leaves recently-claimed running rows untouched" do test "leaves recently-claimed running rows untouched" do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
fresh = DateTime.add(now, -60, :second) fresh = DateTime.shift(now, minute: -1)
insert_task(%{ insert_task(%{
run_time: ~U[2026-04-19 21:00:00Z], run_time: ~U[2026-04-19 21:00:00Z],
@ -178,7 +178,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "flips stale rows past max attempts to failed" do test "flips stale rows past max attempts to failed" do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second) stale = DateTime.shift(now, minute: -30)
insert_task(%{ insert_task(%{
run_time: ~U[2026-04-19 22:00:00Z], run_time: ~U[2026-04-19 22:00:00Z],
@ -206,7 +206,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "does not touch queued or done rows even if claimed_at is ancient" do test "does not touch queued or done rows even if claimed_at is ancient" do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second) stale = DateTime.shift(now, minute: -30)
insert_task(%{ insert_task(%{
run_time: ~U[2026-04-19 23:00:00Z], run_time: ~U[2026-04-19 23:00:00Z],
@ -226,7 +226,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "seeds exactly one forecast row at the hour offset closest to now" do test "seeds exactly one forecast row at the hour offset closest to now" do
# run_time 5h before "now" → fh should be 5. # run_time 5h before "now" → fh should be 5.
now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
run_time = DateTime.add(now, -5 * 3600, :second) run_time = DateTime.shift(now, hour: -5)
assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps") assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps")
@ -247,7 +247,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "is idempotent on the (run_time, fh, kind, source) key" do test "is idempotent on the (run_time, fh, kind, source) key" do
now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
run_time = DateTime.add(now, -3 * 3600, :second) run_time = DateTime.shift(now, hour: -3)
assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps") assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps")
assert {:ok, 0} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps") assert {:ok, 0} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps")
@ -268,7 +268,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
# negative. Clamp pulls it to 1 so the F00Reserved branch in Rust # negative. Clamp pulls it to 1 so the F00Reserved branch in Rust
# is never hit through this path. # is never hit through this path.
now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
run_time = DateTime.add(now, 3600, :second) run_time = DateTime.shift(now, hour: 1)
assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps") assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps")

View file

@ -90,7 +90,7 @@ defmodule Microwaveprop.Propagation.NotifyListenerTest do
test "retains GEFS score files through 168 hours" do test "retains GEFS score files through 168 hours" do
run_time = ~U[2026-04-21 00:00:00Z] run_time = ~U[2026-04-21 00:00:00Z]
extended_time = DateTime.add(run_time, 168, :hour) extended_time = DateTime.shift(run_time, week: 1)
ScoresFile.write_gefs!(10_000, extended_time, sample_scores()) ScoresFile.write_gefs!(10_000, extended_time, sample_scores())
{:ok, _task_pid} = NotifyListener.handle_propagation_ready(run_time, run_time) {:ok, _task_pid} = NotifyListener.handle_propagation_ready(run_time, run_time)

View file

@ -30,7 +30,7 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
end end
defp minutes_ago(n) do defp minutes_ago(n) do
DateTime.add(DateTime.utc_now(), -n * 60, :second) DateTime.shift(DateTime.utc_now(), minute: -n)
end end
describe "current/0" do describe "current/0" do
@ -183,24 +183,24 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
test "valid_time within half an hour of now renders as 'through now'" do test "valid_time within half an hour of now renders as 'through now'" do
now = DateTime.utc_now() now = DateTime.utc_now()
assert PipelineStatus.running_detail(%{valid_time: now}) == "through now" assert PipelineStatus.running_detail(%{valid_time: now}) == "through now"
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 20 * 60, :second)}) == "through now" assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, minute: 20)}) == "through now"
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, -20 * 60, :second)}) == "through now" assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, minute: -20)}) == "through now"
end end
test "valid_time in the future renders as 'through +Nh'" do test "valid_time in the future renders as 'through +Nh'" do
now = DateTime.utc_now() now = DateTime.utc_now()
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 3 * 3600, :second)}) == assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, hour: 3)}) ==
"through +3h" "through +3h"
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 12 * 3600, :second)}) == assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, hour: 12)}) ==
"through +12h" "through +12h"
end end
test "valid_time in the past renders as 'through Nh ago'" do test "valid_time in the past renders as 'through Nh ago'" do
now = DateTime.utc_now() now = DateTime.utc_now()
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, -2 * 3600, :second)}) == assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, hour: -2)}) ==
"through 2h ago" "through 2h ago"
end end

View file

@ -236,7 +236,7 @@ defmodule Microwaveprop.Propagation.ScoreCacheTest do
# Insert cap+1 entries, each at a distinct valid_time, oldest first. # Insert cap+1 entries, each at a distinct valid_time, oldest first.
for hour <- 0..cap do for hour <- 0..cap do
ScoreCache.put(10_000, DateTime.add(base, hour * 3600, :second), score) ScoreCache.put(10_000, DateTime.shift(base, hour: hour), score)
end end
# Size never exceeds the cap. # Size never exceeds the cap.
@ -246,7 +246,7 @@ defmodule Microwaveprop.Propagation.ScoreCacheTest do
assert ScoreCache.fetch(10_000, base) == :miss assert ScoreCache.fetch(10_000, base) == :miss
# The newest is retained. # The newest is retained.
newest = DateTime.add(base, cap * 3600, :second) newest = DateTime.shift(base, hour: cap)
assert {:ok, [%{score: 1}]} = ScoreCache.fetch(10_000, newest) assert {:ok, [%{score: 1}]} = ScoreCache.fetch(10_000, newest)
end end
@ -257,14 +257,14 @@ defmodule Microwaveprop.Propagation.ScoreCacheTest do
# Fill cap entries on band A at older valid_times. # Fill cap entries on band A at older valid_times.
for hour <- 0..(cap - 1) do for hour <- 0..(cap - 1) do
ScoreCache.put(10_000, DateTime.add(base, hour * 3600, :second), score) ScoreCache.put(10_000, DateTime.shift(base, hour: hour), score)
end end
assert :ets.info(:propagation_score_cache, :size) == cap assert :ets.info(:propagation_score_cache, :size) == cap
# One more on band B at a newer valid_time should evict the oldest # One more on band B at a newer valid_time should evict the oldest
# band-A entry, not the new band-B one. # band-A entry, not the new band-B one.
newer = DateTime.add(base, cap * 3600, :second) newer = DateTime.shift(base, hour: cap)
ScoreCache.put(24_000, newer, score) ScoreCache.put(24_000, newer, score)
assert :ets.info(:propagation_score_cache, :size) == cap assert :ets.info(:propagation_score_cache, :size) == cap

View file

@ -351,8 +351,8 @@ defmodule Microwaveprop.Propagation.ScoresFileTest do
describe "retain_window/2" do describe "retain_window/2" do
test "recognizes GEFS files through the extended horizon" do test "recognizes GEFS files through the extended horizon" do
run_time = ~U[2026-04-14 00:00:00Z] run_time = ~U[2026-04-14 00:00:00Z]
inside = DateTime.add(run_time, 168, :hour) inside = DateTime.shift(run_time, week: 1)
outside = DateTime.add(run_time, 174, :hour) outside = DateTime.shift(run_time, hour: 174)
ScoresFile.write_gefs!(10_000, inside, []) ScoresFile.write_gefs!(10_000, inside, [])
ScoresFile.write_gefs!(10_000, outside, []) ScoresFile.write_gefs!(10_000, outside, [])

View file

@ -405,7 +405,7 @@ defmodule Microwaveprop.PropagationTest do
end end
test "returns empty factors when the only persisted profile is older than the 24h lookback" do test "returns empty factors when the only persisted profile is older than the 24h lookback" do
ancient = DateTime.add(~U[2026-07-15 13:00:00Z], -30 * 3600, :second) ancient = DateTime.shift(~U[2026-07-15 13:00:00Z], hour: -30)
forecast_time = ~U[2026-07-15 13:00:00Z] forecast_time = ~U[2026-07-15 13:00:00Z]
lat = 32.75 lat = 32.75
lon = -97.125 lon = -97.125
@ -425,7 +425,7 @@ defmodule Microwaveprop.PropagationTest do
end end
test "accepts a profile exactly at the 24h lookback boundary" do test "accepts a profile exactly at the 24h lookback boundary" do
fallback_time = DateTime.add(~U[2026-07-15 13:00:00Z], -24 * 3600, :second) fallback_time = DateTime.shift(~U[2026-07-15 13:00:00Z], day: -1)
forecast_time = ~U[2026-07-15 13:00:00Z] forecast_time = ~U[2026-07-15 13:00:00Z]
lat = 32.75 lat = 32.75
lon = -97.125 lon = -97.125
@ -708,8 +708,8 @@ defmodule Microwaveprop.PropagationTest do
end end
test "returns forecast timeline from cached scores when warm" do test "returns forecast timeline from cached scores when warm" do
t1 = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second) t1 = DateTime.truncate(DateTime.shift(DateTime.utc_now(), hour: 1), :second)
t2 = DateTime.add(t1, 3600, :second) t2 = DateTime.shift(t1, hour: 1)
seed_point(10_000, t1, 32.875, -97.0, 70) seed_point(10_000, t1, 32.875, -97.0, 70)
seed_point(10_000, t2, 32.875, -97.0, 75) seed_point(10_000, t2, 32.875, -97.0, 75)
@ -725,9 +725,9 @@ defmodule Microwaveprop.PropagationTest do
test "filters valid_times older than one hour but keeps recent past as the 'now' anchor" do test "filters valid_times older than one hour but keeps recent past as the 'now' anchor" do
# Two hours old: dropped. Thirty minutes old: kept as the # Two hours old: dropped. Thirty minutes old: kept as the
# leftmost "now" point. One hour future: kept. # leftmost "now" point. One hour future: kept.
stale = DateTime.utc_now() |> DateTime.add(-2 * 3600, :second) |> DateTime.truncate(:second) stale = DateTime.utc_now() |> DateTime.shift(hour: -2) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second) recent = DateTime.utc_now() |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second) future = DateTime.utc_now() |> DateTime.shift(hour: 1) |> DateTime.truncate(:second)
seed_point(10_000, stale, 32.875, -97.0, 40) seed_point(10_000, stale, 32.875, -97.0, 40)
seed_point(10_000, recent, 32.875, -97.0, 60) seed_point(10_000, recent, 32.875, -97.0, 60)
@ -745,8 +745,8 @@ defmodule Microwaveprop.PropagationTest do
# HRRR just cut out (nothing fresh); we still want the chart to # HRRR just cut out (nothing fresh); we still want the chart to
# render one point at the newest time we have, matching # render one point at the newest time we have, matching
# available_valid_times semantics. # available_valid_times semantics.
older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second) older = DateTime.utc_now() |> DateTime.shift(hour: -6) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second) newer = DateTime.utc_now() |> DateTime.shift(hour: -3) |> DateTime.truncate(:second)
seed_point(10_000, older, 32.875, -97.0, 30) seed_point(10_000, older, 32.875, -97.0, 30)
seed_point(10_000, newer, 32.875, -97.0, 45) seed_point(10_000, newer, 32.875, -97.0, 45)
@ -756,7 +756,7 @@ defmodule Microwaveprop.PropagationTest do
end end
test "snaps coordinates to the nearest grid point" do test "snaps coordinates to the nearest grid point" do
valid_time = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second) valid_time = DateTime.truncate(DateTime.shift(DateTime.utc_now(), hour: 1), :second)
seed_point(10_000, valid_time, 32.875, -97.0, 65) seed_point(10_000, valid_time, 32.875, -97.0, 65)
# Off-grid query snaps to 32.875, -97.0 # Off-grid query snaps to 32.875, -97.0
@ -775,8 +775,8 @@ defmodule Microwaveprop.PropagationTest do
# hour has just landed on disk. The forecast chart should include # hour has just landed on disk. The forecast chart should include
# that third hour so it matches the main-map timeline. # that third hour so it matches the main-map timeline.
t1 = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) t1 = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0})
t2 = DateTime.add(t1, 3600, :second) t2 = DateTime.shift(t1, hour: 1)
t3 = DateTime.add(t1, 2 * 3600, :second) t3 = DateTime.shift(t1, hour: 2)
for {t, score} <- [{t1, 50}, {t2, 60}, {t3, 70}] do for {t, score} <- [{t1, 50}, {t2, 60}, {t3, 70}] do
Propagation.replace_scores( Propagation.replace_scores(
@ -801,9 +801,9 @@ defmodule Microwaveprop.PropagationTest do
test "includes recent past valid_times and falls back to the latest when stale" do test "includes recent past valid_times and falls back to the latest when stale" do
# Three .prop files on disk: one 2h old (dropped by cutoff), # Three .prop files on disk: one 2h old (dropped by cutoff),
# one 30min old (kept as "now"), one 1h future (kept). # one 30min old (kept as "now"), one 1h future (kept).
stale = DateTime.utc_now() |> DateTime.add(-2 * 3600, :second) |> DateTime.truncate(:second) stale = DateTime.utc_now() |> DateTime.shift(hour: -2) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second) recent = DateTime.utc_now() |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second) future = DateTime.utc_now() |> DateTime.shift(hour: 1) |> DateTime.truncate(:second)
Enum.each([{stale, 40}, {recent, 60}, {future, 80}], fn {t, score} -> Enum.each([{stale, 40}, {recent, 60}, {future, 80}], fn {t, score} ->
Propagation.replace_scores( Propagation.replace_scores(
@ -824,8 +824,8 @@ defmodule Microwaveprop.PropagationTest do
end end
test "returns just the latest store entry when every hour is stale" do test "returns just the latest store entry when every hour is stale" do
older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second) older = DateTime.utc_now() |> DateTime.shift(hour: -6) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second) newer = DateTime.utc_now() |> DateTime.shift(hour: -3) |> DateTime.truncate(:second)
Enum.each([{older, 30}, {newer, 45}], fn {t, score} -> Enum.each([{older, 30}, {newer, 45}], fn {t, score} ->
Propagation.replace_scores( Propagation.replace_scores(
@ -856,8 +856,8 @@ defmodule Microwaveprop.PropagationTest do
test "caps the forward horizon to 48 hours so stale cycles don't clutter the timeline" do test "caps the forward horizon to 48 hours so stale cycles don't clutter the timeline" do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
in_range = DateTime.add(now, 3600, :second) in_range = DateTime.shift(now, hour: 1)
past_hrrr_horizon = DateTime.add(now, 55 * 3600, :second) past_hrrr_horizon = DateTime.shift(now, hour: 55)
Enum.each([{in_range, 70}, {past_hrrr_horizon, 50}], fn {t, score} -> Enum.each([{in_range, 70}, {past_hrrr_horizon, 50}], fn {t, score} ->
Propagation.replace_scores( Propagation.replace_scores(
@ -873,8 +873,8 @@ defmodule Microwaveprop.PropagationTest do
test "filters valid_times older than the 1-hour cutoff" do test "filters valid_times older than the 1-hour cutoff" do
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -7200, :second) stale = DateTime.shift(now, hour: -2)
fresh = DateTime.add(now, 1800, :second) fresh = DateTime.shift(now, minute: 30)
Propagation.replace_scores( Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: stale, band_mhz: 10_000, score: 50, factors: nil}], [%{lat: 25.0, lon: -125.0, valid_time: stale, band_mhz: 10_000, score: 50, factors: nil}],
@ -1039,7 +1039,7 @@ defmodule Microwaveprop.PropagationTest do
Propagation.record_run_timing(%{ Propagation.record_run_timing(%{
run_time: run_time, run_time: run_time,
forecast_hour: 3, forecast_hour: 3,
valid_time: DateTime.add(run_time, 3, :hour), valid_time: DateTime.shift(run_time, hour: 3),
started_at: started, started_at: started,
finished_at: finished, finished_at: finished,
duration_ms: 555_000, duration_ms: 555_000,
@ -1061,7 +1061,7 @@ defmodule Microwaveprop.PropagationTest do
Propagation.record_run_timing(%{ Propagation.record_run_timing(%{
run_time: run_time, run_time: run_time,
forecast_hour: 5, forecast_hour: 5,
valid_time: DateTime.add(run_time, 5, :hour), valid_time: DateTime.shift(run_time, hour: 5),
started_at: ~U[2026-07-15 12:00:00Z], started_at: ~U[2026-07-15 12:00:00Z],
finished_at: ~U[2026-07-15 12:02:30Z], finished_at: ~U[2026-07-15 12:02:30Z],
duration_ms: 150_000, duration_ms: 150_000,
@ -1108,9 +1108,9 @@ defmodule Microwaveprop.PropagationTest do
Propagation.record_run_timing(%{ Propagation.record_run_timing(%{
run_time: run_time, run_time: run_time,
forecast_hour: fh, forecast_hour: fh,
valid_time: DateTime.add(run_time, fh, :hour), valid_time: DateTime.shift(run_time, hour: fh),
started_at: DateTime.add(run_time, fh * 600, :second), started_at: DateTime.shift(run_time, minute: fh * 10),
finished_at: DateTime.add(run_time, fh * 600 + 500, :second), finished_at: DateTime.shift(run_time, second: fh * 600 + 500),
duration_ms: 500_000, duration_ms: 500_000,
status: :ok status: :ok
}) })

View file

@ -77,7 +77,7 @@ defmodule Microwaveprop.QrzTest do
test "re-fetches when cache entry is stale" do test "re-fetches when cache entry is stale" do
past = past =
DateTime.utc_now() DateTime.utc_now()
|> DateTime.add(-48, :hour) |> DateTime.shift(day: -2)
|> DateTime.truncate(:second) |> DateTime.truncate(:second)
Repo.insert!(%Callsign{ Repo.insert!(%Callsign{
@ -96,7 +96,7 @@ defmodule Microwaveprop.QrzTest do
test "upserts (updates existing record on re-fetch)" do test "upserts (updates existing record on re-fetch)" do
past = past =
DateTime.utc_now() DateTime.utc_now()
|> DateTime.add(-48, :hour) |> DateTime.shift(day: -2)
|> DateTime.truncate(:second) |> DateTime.truncate(:second)
original = original =
@ -157,7 +157,7 @@ defmodule Microwaveprop.QrzTest do
test "returns record regardless of TTL" do test "returns record regardless of TTL" do
past = past =
DateTime.utc_now() DateTime.utc_now()
|> DateTime.add(-999, :hour) |> DateTime.shift(hour: -999)
|> DateTime.truncate(:second) |> DateTime.truncate(:second)
Repo.insert!(%Callsign{ Repo.insert!(%Callsign{

View file

@ -247,7 +247,7 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
test "allows contact outside the 1-hour window" do test "allows contact outside the 1-hour window" do
assert {:ok, _} = Radio.create_contact(@valid_attrs) assert {:ok, _} = Radio.create_contact(@valid_attrs)
later = DateTime.add(@valid_attrs.qso_timestamp, 3601, :second) later = DateTime.shift(@valid_attrs.qso_timestamp, second: 3_601)
assert {:ok, _} = Radio.create_contact(%{@valid_attrs | qso_timestamp: later}) assert {:ok, _} = Radio.create_contact(%{@valid_attrs | qso_timestamp: later})
end end

View file

@ -2,36 +2,12 @@ defmodule Microwaveprop.RadioTest do
use Microwaveprop.DataCase, async: true use Microwaveprop.DataCase, async: true
import Microwaveprop.AccountsFixtures import Microwaveprop.AccountsFixtures
import Microwaveprop.ContactsFixtures
alias Microwaveprop.Accounts.Scope alias Microwaveprop.Accounts.Scope
alias Microwaveprop.Radio alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Contact
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
merged = Map.merge(default, attrs)
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
{:ok, contact} =
%Contact{user_id: user_id}
|> Contact.changeset(changeset_attrs)
|> Repo.insert()
contact
end
describe "list_contacts/1" do describe "list_contacts/1" do
test "returns empty page when no QSOs exist" do test "returns empty page when no QSOs exist" do
result = Radio.list_contacts() result = Radio.list_contacts()
@ -44,7 +20,7 @@ defmodule Microwaveprop.RadioTest do
test "paginates 20 per page, ordered by qso_timestamp desc" do test "paginates 20 per page, ordered by qso_timestamp desc" do
for i <- 1..25 do for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -57,12 +33,12 @@ defmodule Microwaveprop.RadioTest do
# Most recent first # Most recent first
first = hd(result.entries) first = hd(result.entries)
assert first.qso_timestamp == DateTime.add(~U[2026-01-01 00:00:00Z], 25 * 3600, :second) assert first.qso_timestamp == DateTime.shift(~U[2026-01-01 00:00:00Z], hour: 25)
end end
test "returns second page" do test "returns second page" do
for i <- 1..25 do for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -75,7 +51,7 @@ defmodule Microwaveprop.RadioTest do
test "honors per_page option" do test "honors per_page option" do
for i <- 1..25 do for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -87,7 +63,7 @@ defmodule Microwaveprop.RadioTest do
test "clamps per_page to the maximum" do test "clamps per_page to the maximum" do
for i <- 1..3 do for i <- 1..3 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -98,7 +74,7 @@ defmodule Microwaveprop.RadioTest do
test "falls back to default when per_page is invalid" do test "falls back to default when per_page is invalid" do
for i <- 1..25 do for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -114,7 +90,13 @@ defmodule Microwaveprop.RadioTest do
result = Radio.list_contacts(sort_by: :station1, sort_order: :asc) result = Radio.list_contacts(sort_by: :station1, sort_order: :asc)
stations = Enum.map(result.entries, & &1.station1) # Filter to only the contacts created in this test so parallel
# async tests don't affect the assertion.
stations =
result.entries
|> Enum.filter(&(&1.station1 in ["AA1AA", "ZZ9ZZ"]))
|> Enum.map(& &1.station1)
assert stations == ["AA1AA", "ZZ9ZZ"] assert stations == ["AA1AA", "ZZ9ZZ"]
end end
@ -124,7 +106,13 @@ defmodule Microwaveprop.RadioTest do
result = Radio.list_contacts(sort_by: :distance_km, sort_order: :desc) result = Radio.list_contacts(sort_by: :distance_km, sort_order: :desc)
distances = Enum.map(result.entries, & &1.distance_km) # Filter to only the contacts created in this test so parallel
# async tests don't affect the assertion.
distances =
result.entries
|> Enum.filter(&(&1.station1 in ["A1A", "B2B"]))
|> Enum.map(& &1.distance_km)
assert distances == [Decimal.new("500"), Decimal.new("100")] assert distances == [Decimal.new("500"), Decimal.new("100")]
end end
@ -134,7 +122,9 @@ defmodule Microwaveprop.RadioTest do
result = Radio.list_contacts(sort_by: :bogus) result = Radio.list_contacts(sort_by: :bogus)
ids = Enum.map(result.entries, & &1.id) # Filter to only the contacts created in this test.
our_ids = MapSet.new([early.id, late.id])
ids = result.entries |> Enum.filter(&MapSet.member?(our_ids, &1.id)) |> Enum.map(& &1.id)
assert ids == [late.id, early.id] assert ids == [late.id, early.id]
end end
end end
@ -167,7 +157,7 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do test "respects limit parameter" do
for i <- 1..5 do for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -296,7 +286,7 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do test "respects limit parameter" do
for i <- 1..5 do for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -360,7 +350,7 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do test "respects limit parameter" do
for i <- 1..5 do for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -763,7 +753,7 @@ defmodule Microwaveprop.RadioTest do
create_contact(%{ create_contact(%{
station1: "W5ISP", station1: "W5ISP",
station2: "K5X#{i}", station2: "K5X#{i}",
qso_timestamp: DateTime.add(base, i * 3600, :second) qso_timestamp: DateTime.shift(base, hour: i)
}) })
end end

View file

@ -77,7 +77,7 @@ defmodule Microwaveprop.Weather.GefsProfileTest do
test "inserts many rows at once" do test "inserts many rows at once" do
attrs = attrs =
for fh <- [3, 6, 9] do for fh <- [3, 6, 9] do
valid = DateTime.add(~U[2026-04-18 12:00:00Z], fh * 3600, :second) valid = DateTime.shift(~U[2026-04-18 12:00:00Z], hour: fh)
base_attrs(%{valid_time: valid, forecast_hour: fh}) base_attrs(%{valid_time: valid, forecast_hour: fh})
end end

View file

@ -165,7 +165,7 @@ defmodule Microwaveprop.Weather.GridSnapPropertyTest do
property "coverage_end is the first excluded timestamp (exclusive upper bound)" do property "coverage_end is the first excluded timestamp (exclusive upper bound)" do
coverage_end = NarrClient.coverage_end() coverage_end = NarrClient.coverage_end()
refute NarrClient.in_coverage?(coverage_end) refute NarrClient.in_coverage?(coverage_end)
one_sec_before = DateTime.add(coverage_end, -1, :second) one_sec_before = DateTime.shift(coverage_end, second: -1)
assert NarrClient.in_coverage?(one_sec_before) assert NarrClient.in_coverage?(one_sec_before)
end end
@ -173,7 +173,7 @@ defmodule Microwaveprop.Weather.GridSnapPropertyTest do
coverage_end = NarrClient.coverage_end() coverage_end = NarrClient.coverage_end()
check all(seconds_after <- integer(1..100_000_000)) do check all(seconds_after <- integer(1..100_000_000)) do
dt = DateTime.add(coverage_end, seconds_after, :second) dt = DateTime.shift(coverage_end, second: seconds_after)
refute NarrClient.in_coverage?(dt) refute NarrClient.in_coverage?(dt)
end end
end end

View file

@ -37,7 +37,7 @@ defmodule Microwaveprop.Weather.HrrrProfileLookupTest do
test "limit option caps the number of times returned (most recent first kept)" do test "limit option caps the number of times returned (most recent first kept)" do
for h <- 0..4 do for h <- 0..4 do
valid_time = DateTime.add(~U[2026-04-25 12:00:00Z], h * 3600, :second) valid_time = DateTime.shift(~U[2026-04-25 12:00:00Z], hour: h)
insert_profile!(%{lat: 32.625, lon: -97.125, valid_time: valid_time}) insert_profile!(%{lat: 32.625, lon: -97.125, valid_time: valid_time})
end end

View file

@ -51,19 +51,19 @@ defmodule Microwaveprop.Weather.NexradCacheTest do
base = ~U[2026-04-12 00:00:00Z] base = ~U[2026-04-12 00:00:00Z]
for i <- 1..25 do for i <- 1..25 do
ts = DateTime.add(base, i * 300, :second) ts = DateTime.shift(base, minute: i * 5)
NexradCache.put(ts, <<i>>, 1) NexradCache.put(ts, <<i>>, 1)
end end
# The 5 oldest (i = 1..5) should have been evicted. # The 5 oldest (i = 1..5) should have been evicted.
for i <- 1..5 do for i <- 1..5 do
ts = DateTime.add(base, i * 300, :second) ts = DateTime.shift(base, minute: i * 5)
assert NexradCache.fetch(ts) == :miss assert NexradCache.fetch(ts) == :miss
end end
# The most recent 20 should still be present. # The most recent 20 should still be present.
for i <- 6..25 do for i <- 6..25 do
ts = DateTime.add(base, i * 300, :second) ts = DateTime.shift(base, minute: i * 5)
assert {:ok, <<^i>>, 1} = NexradCache.fetch(ts) assert {:ok, <<^i>>, 1} = NexradCache.fetch(ts)
end end
end end

View file

@ -90,7 +90,7 @@ defmodule Microwaveprop.WeatherGridTest do
end end
test "uses most recent valid_time only" do test "uses most recent valid_time only" do
old = DateTime.utc_now() |> DateTime.add(-7200, :second) |> DateTime.truncate(:second) old = DateTime.utc_now() |> DateTime.shift(hour: -2) |> DateTime.truncate(:second)
new = DateTime.truncate(DateTime.utc_now(), :second) new = DateTime.truncate(DateTime.utc_now(), :second)
insert_hrrr_profile(%{ insert_hrrr_profile(%{

View file

@ -336,7 +336,7 @@ defmodule Microwaveprop.WeatherTest do
rows = rows =
for i <- 0..99 do for i <- 0..99 do
%{ %{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second), observed_at: DateTime.shift(~U[2026-03-28 00:00:00Z], minute: i * 5),
temp_f: 40.0 + i / 10, temp_f: 40.0 + i / 10,
dewpoint_f: 30.0 + i / 10, dewpoint_f: 30.0 + i / 10,
sky_condition: "CLR" sky_condition: "CLR"
@ -354,7 +354,7 @@ defmodule Microwaveprop.WeatherTest do
rows1 = rows1 =
for i <- 0..9 do for i <- 0..9 do
%{ %{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second), observed_at: DateTime.shift(~U[2026-03-28 00:00:00Z], minute: i * 5),
temp_f: 40.0, temp_f: 40.0,
dewpoint_f: 30.0 dewpoint_f: 30.0
} }
@ -365,7 +365,7 @@ defmodule Microwaveprop.WeatherTest do
rows2 = rows2 =
for i <- 0..9 do for i <- 0..9 do
%{ %{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second), observed_at: DateTime.shift(~U[2026-03-28 00:00:00Z], minute: i * 5),
temp_f: 80.0, temp_f: 80.0,
dewpoint_f: 60.0 dewpoint_f: 60.0
} }

View file

@ -295,7 +295,7 @@ defmodule Microwaveprop.Workers.AdminTaskWorkerTest do
for i <- 1..2 do for i <- 1..2 do
%HrrrNativeProfile{} %HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{ |> HrrrNativeProfile.changeset(%{
valid_time: DateTime.add(~U[2026-04-15 18:00:00Z], i * 3600, :second), valid_time: DateTime.shift(~U[2026-04-15 18:00:00Z], hour: i),
lat: 32.9 + i * 0.1, lat: 32.9 + i * 0.1,
lon: -97.0, lon: -97.0,
level_count: 3, level_count: 3,

View file

@ -2,6 +2,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
use Microwaveprop.DataCase, async: true use Microwaveprop.DataCase, async: true
import Ecto.Query import Ecto.Query
import Microwaveprop.ContactsFixtures
alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Contact
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.ElevationClient
@ -10,28 +11,6 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
alias Microwaveprop.Weather.NarrClient alias Microwaveprop.Weather.NarrClient
alias Microwaveprop.Workers.BackfillEnqueueWorker alias Microwaveprop.Workers.BackfillEnqueueWorker
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(default, attrs))
|> Repo.insert()
contact
end
setup do setup do
Req.Test.stub(IemClient, fn conn -> Req.Test.stub(IemClient, fn conn ->
case conn.request_path do case conn.request_path do
@ -101,7 +80,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
test "respects limit parameter" do test "respects limit parameter" do
for i <- 1..5 do for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
@ -114,30 +93,33 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
test "enqueues all pending contacts when limit is omitted" do test "enqueues all pending contacts when limit is omitted" do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
for i <- 1..7 do our_ids =
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) for i <- 1..7 do
create_contact(%{qso_timestamp: ts}) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
end create_contact(%{qso_timestamp: ts}).id
end
assert :ok = assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{ BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"types" => @non_era5_types} args: %{"types" => @non_era5_types}
}) })
assert_receive {:enqueue_complete, 7} assert_receive {:enqueue_complete, count}
assert count >= 7
end end
test "broadcasts enqueue_complete with count" do test "broadcasts enqueue_complete with count" do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
create_contact() contact = create_contact()
assert :ok = assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{ BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types} args: %{"limit" => 10, "types" => @non_era5_types}
}) })
assert_receive {:enqueue_complete, 1} assert_receive {:enqueue_complete, count}
assert count >= 1
end end
# :unavailable is a terminal state in the sense that workers don't # :unavailable is a terminal state in the sense that workers don't
@ -164,7 +146,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
# Force updated_at past the 24 h cooldown so the type_filter's # Force updated_at past the 24 h cooldown so the type_filter's
# :unavailable branch picks this contact up. # :unavailable branch picks this contact up.
old = DateTime.utc_now() |> DateTime.add(-2 * 86_400, :second) |> DateTime.truncate(:second) old = DateTime.utc_now() |> DateTime.shift(day: -2) |> DateTime.truncate(:second)
Repo.update_all(from(c in Contact, where: c.id == ^contact.id), set: [updated_at: old]) Repo.update_all(from(c in Contact, where: c.id == ^contact.id), set: [updated_at: old])
assert :ok = assert :ok =
@ -172,7 +154,8 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
args: %{"limit" => 10, "types" => @non_era5_types} args: %{"limit" => 10, "types" => @non_era5_types}
}) })
assert_receive {:enqueue_complete, 1} assert_receive {:enqueue_complete, count}
assert count >= 1
end end
test "leaves :unavailable contacts inside the cooldown window alone" do test "leaves :unavailable contacts inside the cooldown window alone" do
@ -196,7 +179,10 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
args: %{"limit" => 10, "types" => @non_era5_types} args: %{"limit" => 10, "types" => @non_era5_types}
}) })
assert_receive {:enqueue_complete, 0} # Our contact must still be :unavailable — the worker must not
# have touched it, regardless of what other contacts it enqueued.
reloaded = Repo.get!(Contact, contact.id)
assert reloaded.weather_status == :unavailable
end end
# :narr is a virtual type (no narr_status column) — it targets contacts # :narr is a virtual type (no narr_status column) — it targets contacts
@ -256,13 +242,13 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
defp stale_ts do defp stale_ts do
DateTime.utc_now() DateTime.utc_now()
|> DateTime.add(-(@stale_cutoff + 3600), :second) |> DateTime.shift(second: -(@stale_cutoff + 3600))
|> DateTime.truncate(:second) |> DateTime.truncate(:second)
end end
defp fresh_ts do defp fresh_ts do
DateTime.utc_now() DateTime.utc_now()
|> DateTime.add(-3600, :second) |> DateTime.shift(hour: -1)
|> DateTime.truncate(:second) |> DateTime.truncate(:second)
end end

View file

@ -109,7 +109,7 @@ defmodule Microwaveprop.Workers.ContactPositionBackfillWorkerTest do
pos1: nil, pos1: nil,
pos2: nil, pos2: nil,
distance_km: nil, distance_km: nil,
qso_timestamp: DateTime.add(~U[2026-03-28 18:00:00Z], i * 3600, :second) qso_timestamp: DateTime.shift(~U[2026-03-28 18:00:00Z], hour: i)
}) })
end end

View file

@ -219,7 +219,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
test "round-trips a full batch through the context" do test "round-trips a full batch through the context" do
run_time = ~U[2026-04-18 12:00:00Z] run_time = ~U[2026-04-18 12:00:00Z]
fh = 24 fh = 24
valid_time = DateTime.add(run_time, fh * 3600, :second) valid_time = DateTime.shift(run_time, hour: fh)
batch = [ batch = [
%{ %{

View file

@ -12,9 +12,9 @@ defmodule Microwaveprop.Workers.GridCachePruneWorkerTest do
describe "perform/1" do describe "perform/1" do
test "removes cache entries with valid_time older than 3 hours" do test "removes cache entries with valid_time older than 3 hours" do
now = DateTime.utc_now() now = DateTime.utc_now()
old = now |> DateTime.add(-3, :hour) |> DateTime.add(-1, :second) |> DateTime.truncate(:second) old = now |> DateTime.shift(hour: -3, second: -1) |> DateTime.truncate(:second)
fresh = now |> DateTime.add(-30, :minute) |> DateTime.truncate(:second) fresh = now |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
future = now |> DateTime.add(6, :hour) |> DateTime.truncate(:second) future = now |> DateTime.shift(hour: 6) |> DateTime.truncate(:second)
GridCache.put(old, [%{lat: 32.0, lon: -97.0, temperature: 10.0}]) GridCache.put(old, [%{lat: 32.0, lon: -97.0, temperature: 10.0}])
GridCache.put(fresh, [%{lat: 32.0, lon: -97.0, temperature: 20.0}]) GridCache.put(fresh, [%{lat: 32.0, lon: -97.0, temperature: 20.0}])
@ -29,7 +29,7 @@ defmodule Microwaveprop.Workers.GridCachePruneWorkerTest do
test "is a no-op when nothing is stale" do test "is a no-op when nothing is stale" do
fresh = fresh =
DateTime.utc_now() |> DateTime.add(-30, :minute) |> DateTime.truncate(:second) DateTime.utc_now() |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
GridCache.put(fresh, [%{lat: 32.0, lon: -97.0, temperature: 42.0}]) GridCache.put(fresh, [%{lat: 32.0, lon: -97.0, temperature: 42.0}])

View file

@ -11,9 +11,9 @@ defmodule Microwaveprop.Workers.PropagationPruneWorkerTest do
# picking exactly -3h flakes whenever the test + worker wall-clock # picking exactly -3h flakes whenever the test + worker wall-clock
# land in the same second. -3h - 1s gives unconditional ordering. # land in the same second. -3h - 1s gives unconditional ordering.
now = DateTime.utc_now() now = DateTime.utc_now()
old = now |> DateTime.add(-3, :hour) |> DateTime.add(-1, :second) |> DateTime.truncate(:second) old = now |> DateTime.shift(hour: -3, second: -1) |> DateTime.truncate(:second)
fresh = now |> DateTime.add(-30, :minute) |> DateTime.truncate(:second) fresh = now |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
future = now |> DateTime.add(6, :hour) |> DateTime.truncate(:second) future = now |> DateTime.shift(hour: 6) |> DateTime.truncate(:second)
ScoresFile.write!(10_000, old, [%{lat: 25.0, lon: -125.0, score: 10}]) ScoresFile.write!(10_000, old, [%{lat: 25.0, lon: -125.0, score: 10}])
ScoresFile.write!(10_000, fresh, [%{lat: 25.0, lon: -125.0, score: 20}]) ScoresFile.write!(10_000, fresh, [%{lat: 25.0, lon: -125.0, score: 20}])
@ -28,7 +28,7 @@ defmodule Microwaveprop.Workers.PropagationPruneWorkerTest do
test "is a no-op when nothing is stale" do test "is a no-op when nothing is stale" do
now = DateTime.utc_now() now = DateTime.utc_now()
fresh = now |> DateTime.add(-30, :minute) |> DateTime.truncate(:second) fresh = now |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
ScoresFile.write!(10_000, fresh, [%{lat: 25.0, lon: -125.0, score: 42}]) ScoresFile.write!(10_000, fresh, [%{lat: 25.0, lon: -125.0, score: 42}])
assert :ok = PropagationPruneWorker.perform(%Oban.Job{}) assert :ok = PropagationPruneWorker.perform(%Oban.Job{})

View file

@ -59,7 +59,7 @@ defmodule Microwaveprop.Workers.PskrCalibrationWorkerTest do
# arrive in the last few minutes of an hour wait up to a full # arrive in the last few minutes of an hour wait up to a full
# cron interval before they're sampled. # cron interval before they're sampled.
now = truncate_to_hour(DateTime.utc_now()) now = truncate_to_hour(DateTime.utc_now())
previous = DateTime.add(now, -3600, :second) previous = DateTime.shift(now, hour: -1)
insert_spot!(now) insert_spot!(now)
insert_spot!(previous, sender_grid: "EM10AA") insert_spot!(previous, sender_grid: "EM10AA")
@ -90,7 +90,7 @@ defmodule Microwaveprop.Workers.PskrCalibrationWorkerTest do
now = truncate_to_hour(DateTime.utc_now()) now = truncate_to_hour(DateTime.utc_now())
Enum.each(0..3, fn offset -> Enum.each(0..3, fn offset ->
hour = DateTime.add(now, -offset * 3600, :second) hour = DateTime.shift(now, hour: -offset)
insert_spot!(hour, sender_grid: "EM1#{offset}AA") insert_spot!(hour, sender_grid: "EM1#{offset}AA")
end) end)

View file

@ -60,7 +60,7 @@ defmodule MicrowavepropWeb.Api.V1.AuthControllerTest do
end end
test "accepts an ISO 8601 expires_at", %{conn: conn, user: user} do test "accepts an ISO 8601 expires_at", %{conn: conn, user: user} do
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601() future = DateTime.utc_now() |> DateTime.shift(hour: 1) |> DateTime.to_iso8601()
conn = conn =
post(conn, ~p"/api/v1/auth/tokens", %{ post(conn, ~p"/api/v1/auth/tokens", %{
@ -111,7 +111,7 @@ defmodule MicrowavepropWeb.Api.V1.AuthControllerTest do
end end
test "rejects expires_at in the past via changeset 422", %{conn: conn, user: user} do test "rejects expires_at in the past via changeset 422", %{conn: conn, user: user} do
past = DateTime.utc_now() |> DateTime.add(-1, :second) |> DateTime.to_iso8601() past = DateTime.utc_now() |> DateTime.shift(second: -1) |> DateTime.to_iso8601()
conn = conn =
post(conn, ~p"/api/v1/auth/tokens", %{ post(conn, ~p"/api/v1/auth/tokens", %{

View file

@ -20,7 +20,7 @@ defmodule MicrowavepropWeb.UserSettingsControllerTest do
assert redirected_to(conn) == ~p"/users/log-in" assert redirected_to(conn) == ~p"/users/log-in"
end end
@tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -25, :hour) @tag token_authenticated_at: DateTime.shift(DateTime.utc_now(:second), hour: -25)
test "redirects if user is not in sudo mode", %{conn: conn} do test "redirects if user is not in sudo mode", %{conn: conn} do
conn = get(conn, ~p"/users/settings") conn = get(conn, ~p"/users/settings")
assert redirected_to(conn) == ~p"/users/log-in" assert redirected_to(conn) == ~p"/users/log-in"

View file

@ -103,7 +103,7 @@ defmodule MicrowavepropWeb.ContactLive.IndexTest do
test "pagination reaches later pages", %{conn: conn} do test "pagination reaches later pages", %{conn: conn} do
for i <- 1..25 do for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts, station1: "W#{i}TEST"}) create_contact(%{qso_timestamp: ts, station1: "W#{i}TEST"})
end end

View file

@ -1363,7 +1363,7 @@ defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
nonce = System.unique_integer([:positive]) nonce = System.unique_integer([:positive])
lat1 = 32.0 + nonce / 1_000_000 lat1 = 32.0 + nonce / 1_000_000
iter_valid_time = DateTime.add(~U[2026-03-28 18:00:00Z], nonce, :second) iter_valid_time = DateTime.shift(~U[2026-03-28 18:00:00Z], second: nonce)
contact = contact =
create_contact(%{ create_contact(%{
@ -1426,7 +1426,7 @@ defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
Repo.insert!( Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{ SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id, station_id: station.id,
observed_at: DateTime.add(contact.qso_timestamp, i * 60, :second), observed_at: DateTime.shift(contact.qso_timestamp, minute: i),
temp_f: 60.0 + i, temp_f: 60.0 + i,
dewpoint_f: 45.0 + i, dewpoint_f: 45.0 + i,
relative_humidity: 55.0, relative_humidity: 55.0,
@ -1478,7 +1478,7 @@ defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
Repo.insert!( Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{ SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id, station_id: station.id,
observed_at: DateTime.add(contact.qso_timestamp, offset, :second), observed_at: DateTime.shift(contact.qso_timestamp, second: offset),
temp_f: temp, temp_f: temp,
dewpoint_f: dewp, dewpoint_f: dewp,
relative_humidity: rh, relative_humidity: rh,
@ -1576,7 +1576,7 @@ defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
Repo.insert!( Repo.insert!(
Sounding.changeset(%Sounding{}, %{ Sounding.changeset(%Sounding{}, %{
station_id: station.id, station_id: station.id,
observed_at: DateTime.add(contact.qso_timestamp, offset, :second), observed_at: DateTime.shift(contact.qso_timestamp, second: offset),
profile: [%{"pres" => 1000.0, "tmpc" => temp, "dwpc" => temp - 5.0, "hght" => 100.0}], profile: [%{"pres" => 1000.0, "tmpc" => temp, "dwpc" => temp - 5.0, "hght" => 100.0}],
level_count: 1, level_count: 1,
surface_temp_c: temp, surface_temp_c: temp,

View file

@ -531,12 +531,12 @@ defmodule MicrowavepropWeb.MapLiveTest do
end end
test "renders a '+Nh' suffix when the valid_time is in the future" do test "renders a '+Nh' suffix when the valid_time is in the future" do
future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second) future = DateTime.shift(DateTime.utc_now(), hour: 3)
assert MapLive.format_data_timestamp(future) =~ "+3h" assert MapLive.format_data_timestamp(future) =~ "+3h"
end end
test "renders a 'Nh ago' suffix when the valid_time is in the past" do test "renders a 'Nh ago' suffix when the valid_time is in the past" do
past = DateTime.add(DateTime.utc_now(), -4 * 3600, :second) past = DateTime.shift(DateTime.utc_now(), hour: -4)
assert MapLive.format_data_timestamp(past) =~ "4h ago" assert MapLive.format_data_timestamp(past) =~ "4h ago"
end end
end end
@ -611,7 +611,7 @@ defmodule MicrowavepropWeb.MapLiveTest do
assert html =~ "Updating propagation" assert html =~ "Updating propagation"
assert html =~ ~s(data-pipeline-state="running") assert html =~ ~s(data-pipeline-state="running")
future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second) future = DateTime.shift(DateTime.utc_now(), hour: 3)
Phoenix.PubSub.broadcast( Phoenix.PubSub.broadcast(
Microwaveprop.PubSub, Microwaveprop.PubSub,

View file

@ -163,7 +163,7 @@ defmodule MicrowavepropWeb.PathLiveTest do
|> Map.put(:minute, 0) |> Map.put(:minute, 0)
|> Map.put(:second, 0) |> Map.put(:second, 0)
times = for h <- 0..2, do: DateTime.add(base, h * 3600, :second) times = for h <- 0..2, do: DateTime.shift(base, hour: h)
Enum.each(times, fn t -> Enum.each(times, fn t ->
Propagation.replace_scores( Propagation.replace_scores(

View file

@ -175,7 +175,7 @@ defmodule MicrowavepropWeb.PskrSpotsLiveTest do
# Insert 105 spots — only the 100 most recent should render # Insert 105 spots — only the 100 most recent should render
# Pad single-digit grid numbers so substring matching is unambiguous # Pad single-digit grid numbers so substring matching is unambiguous
for i <- 1..105 do for i <- 1..105 do
t = DateTime.add(now(), i - 105, :second) t = DateTime.shift(now(), second: i - 105)
grid = if i < 10, do: "GRID00#{i}", else: "GRID#{i}" grid = if i < 10, do: "GRID00#{i}", else: "GRID#{i}"
insert_spot(%{sender_grid: grid, last_spot_at: t, hour_utc: t}) insert_spot(%{sender_grid: grid, last_spot_at: t, hour_utc: t})
end end

View file

@ -61,7 +61,7 @@ defmodule MicrowavepropWeb.SkewtLiveTest do
# Trigger select_time with a valid future ISO. Even if no profile is # Trigger select_time with a valid future ISO. Even if no profile is
# found for this time, the handler exercises the full re-load path. # found for this time, the handler exercises the full re-load path.
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601() future = DateTime.utc_now() |> DateTime.shift(hour: 1) |> DateTime.to_iso8601()
_ = render_hook(view, "select_time", %{"valid_time" => future}) _ = render_hook(view, "select_time", %{"valid_time" => future})
_ = render_async(view) _ = render_async(view)

View file

@ -67,7 +67,7 @@ defmodule MicrowavepropWeb.WeatherCaMapLiveTest do
|> Map.put(:minute, 0) |> Map.put(:minute, 0)
|> Map.put(:second, 0) |> Map.put(:second, 0)
stale_t = DateTime.add(now, -4 * 3600, :second) stale_t = DateTime.shift(now, hour: -4)
hrdps_dir = ScalarFile.dir_for_hrdps(stale_t) hrdps_dir = ScalarFile.dir_for_hrdps(stale_t)
File.mkdir_p!(hrdps_dir) File.mkdir_p!(hrdps_dir)

View file

@ -59,7 +59,7 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|> Map.put(:minute, 0) |> Map.put(:minute, 0)
|> Map.put(:second, 0) |> Map.put(:second, 0)
times = for h <- 0..2, do: DateTime.add(base, h * 3600, :second) times = for h <- 0..2, do: DateTime.shift(base, hour: h)
# Seed a profile file at each forecast hour. # Seed a profile file at each forecast hour.
Enum.each(times, &write_profile(&1, 33.0, -97.0)) Enum.each(times, &write_profile(&1, 33.0, -97.0))
@ -82,14 +82,14 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|> Map.put(:minute, 0) |> Map.put(:minute, 0)
|> Map.put(:second, 0) |> Map.put(:second, 0)
stale_t = DateTime.add(now, -2 * 3600, :second) stale_t = DateTime.shift(now, hour: -2)
# `now` is truncated to the top of the hour; real `utc_now()` is up to # `now` is truncated to the top of the hour; real `utc_now()` is up to
# 60 min ahead of that, so a `now - 30 min` recent_t is 30-90 min old # 60 min ahead of that, so a `now - 30 min` recent_t is 30-90 min old
# of real time and the LiveView's `utc_now() - 1h` cutoff filters it # of real time and the LiveView's `utc_now() - 1h` cutoff filters it
# out 50% of the time. Anchor recent_t at the top of the current hour # out 50% of the time. Anchor recent_t at the top of the current hour
# — that's always within the 1-hour window regardless of minute. # — that's always within the 1-hour window regardless of minute.
recent_t = now recent_t = now
future_t = DateTime.add(now, 3 * 3600, :second) future_t = DateTime.shift(now, hour: 3)
Enum.each([stale_t, recent_t, future_t], &write_profile(&1, 33.0, -97.0)) Enum.each([stale_t, recent_t, future_t], &write_profile(&1, 33.0, -97.0))
@ -116,7 +116,7 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
# sequentially so `latest_grid_valid_time` can point to f11 even # sequentially so `latest_grid_valid_time` can point to f11 even
# though the user expects the map to show "Now" by default. # though the user expects the map to show "Now" by default.
now_t = base now_t = base
future_t = DateTime.add(base, 11 * 3600, :second) future_t = DateTime.shift(base, hour: 11)
write_profile(now_t, 33.0, -97.0) write_profile(now_t, 33.0, -97.0)
write_profile(future_t, 33.0, -97.0) write_profile(future_t, 33.0, -97.0)
@ -166,7 +166,7 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|> Map.put(:second, 0) |> Map.put(:second, 0)
now_t = base now_t = base
future_t = DateTime.add(base, 3 * 3600, :second) future_t = DateTime.shift(base, hour: 3)
# Seed f00 with a 22°C surface and f03 with a 10°C surface so we # Seed f00 with a 22°C surface and f03 with a 10°C surface so we
# can tell them apart in the push_event payload. # can tell them apart in the push_event payload.

View file

@ -198,7 +198,7 @@ defmodule MicrowavepropWeb.UserAuthTest do
end end
test "redirects when authentication is too old", %{conn: conn, user: user} do test "redirects when authentication is too old", %{conn: conn, user: user} do
too_long_ago = :second |> DateTime.utc_now() |> DateTime.add(-25, :hour) too_long_ago = :second |> DateTime.utc_now() |> DateTime.shift(hour: -25)
user = %{user | authenticated_at: too_long_ago} user = %{user | authenticated_at: too_long_ago}
user_token = Accounts.generate_user_session_token(user) user_token = Accounts.generate_user_session_token(user)
{user, token_inserted_at} = Accounts.get_user_by_session_token(user_token) {user, token_inserted_at} = Accounts.get_user_by_session_token(user_token)

View file

@ -26,7 +26,7 @@ defmodule Mix.Tasks.Prop.CompareTest do
defp seed_contact_and_hrrr(opts \\ []) do defp seed_contact_and_hrrr(opts \\ []) do
qso_ts = qso_ts =
Keyword.get(opts, :qso_timestamp, DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)) Keyword.get(opts, :qso_timestamp, DateTime.utc_now() |> DateTime.shift(hour: -1) |> DateTime.truncate(:second))
valid_time = qso_ts |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) valid_time = qso_ts |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
band = Keyword.get(opts, :band, "10000") band = Keyword.get(opts, :band, "10000")
@ -86,7 +86,7 @@ defmodule Mix.Tasks.Prop.CompareTest do
history_lines = history_lines =
for _ <- 1..6 do for _ <- 1..6 do
Jason.encode!(%{ Jason.encode!(%{
"date" => DateTime.utc_now() |> DateTime.add(-3600 * 24, :second) |> DateTime.to_iso8601(), "date" => DateTime.utc_now() |> DateTime.shift(day: -1) |> DateTime.to_iso8601(),
"n_samples" => 100, "n_samples" => 100,
"overall" => %{"alg_distance_spearman" => 0.5, "alg_ml_rmse" => 5.0}, "overall" => %{"alg_distance_spearman" => 0.5, "alg_ml_rmse" => 5.0},
"by_band" => %{} "by_band" => %{}

View file

@ -106,7 +106,7 @@ defmodule Mix.Tasks.PropagationMlTasksTest do
base_time = ~U[2020-06-15 18:00:00Z] base_time = ~U[2020-06-15 18:00:00Z]
for i <- 1..6 do for i <- 1..6 do
valid_time = base_time |> DateTime.add(i * 3600, :second) |> DateTime.truncate(:second) valid_time = base_time |> DateTime.shift(hour: i) |> DateTime.truncate(:second)
# Distinct snapped grid points for each i. # Distinct snapped grid points for each i.
lat1 = 32.0 + i * 0.125 lat1 = 32.0 + i * 0.125
lon1 = -97.0 - i * 0.125 lon1 = -97.0 - i * 0.125

View file

@ -213,7 +213,7 @@ defmodule Mix.Tasks.SimpleTasksTest do
{:ok, _} = {:ok, _} =
%HrrrProfile{} %HrrrProfile{}
|> HrrrProfile.changeset(%{ |> HrrrProfile.changeset(%{
valid_time: DateTime.add(~U[2025-06-15 12:00:00Z], i * 86_400, :second), valid_time: DateTime.shift(~U[2025-06-15 12:00:00Z], day: i),
lat: 32.5, lat: 32.5,
lon: -97.0, lon: -97.0,
surface_temp_c: 20.0 + i, surface_temp_c: 20.0 + i,

View file

@ -0,0 +1,53 @@
defmodule Microwaveprop.ContactsFixtures do
@moduledoc """
Factory helpers for creating Contact records in tests.
Uses globally-unique `qso_timestamp` microseconds (via
`System.unique_integer([:positive, :monotonic])`) to prevent
`contacts_dedup_idx` unique-constraint violations when async
tests across different modules run in parallel and would
otherwise insert contacts with identical dedup-key columns.
"""
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@doc """
Creates a contact with unique dedup-key column values.
Override any field by passing it in `attrs`. Pass `:user_id` to
associate the contact with a user.
Returns the created `%Contact{}`.
"""
@spec create_contact(map()) :: Contact.t()
def create_contact(attrs \\ %{}) do
# Globally-unique second offset so every contact has a distinct
# dedup key (band, qso_timestamp, stations, grids). The column is
# timestamp(0), so microsecond offsets are truncated.
unique_s = System.unique_integer([:positive, :monotonic])
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: DateTime.shift(~U[2026-03-28 18:00:00Z], second: unique_s),
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
merged = Map.merge(default, attrs)
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
{:ok, contact} =
%Contact{user_id: user_id}
|> Contact.changeset(changeset_attrs)
|> Repo.insert()
contact
end
end

View file

@ -15,6 +15,7 @@ Code.require_file("test/support/data_case.ex")
Code.require_file("test/support/conn_case.ex") Code.require_file("test/support/conn_case.ex")
Code.require_file("test/support/fixtures/accounts_fixtures.ex") Code.require_file("test/support/fixtures/accounts_fixtures.ex")
Code.require_file("test/support/fixtures/beacons_fixtures.ex") Code.require_file("test/support/fixtures/beacons_fixtures.ex")
Code.require_file("test/support/fixtures/contacts_fixtures.ex")
# ExUnit.start/1 with capture_log: true starts :logger, which cascades # ExUnit.start/1 with capture_log: true starts :logger, which cascades
# into the full OTP application. mix.exs now runs `mix test --no-start` # into the full OTP application. mix.exs now runs `mix test --no-start`