diff --git a/config/runtime.exs b/config/runtime.exs
index 45b20da5..5d2a6780 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -80,7 +80,6 @@ if config_env() == :prod do
]}
]
- config :microwaveprop, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :microwaveprop, srtm_tiles_dir: "/srtm"
config :microwaveprop, hrrr_base_url: System.get_env("HRRR_BASE_URL", "http://skippy.w5isp.com:8080")
diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex
index c112c6c4..f9498200 100644
--- a/lib/microwaveprop/application.ex
+++ b/lib/microwaveprop/application.ex
@@ -12,7 +12,6 @@ defmodule Microwaveprop.Application do
children = [
MicrowavepropWeb.Telemetry,
Microwaveprop.Repo,
- {DNSCluster, query: Application.get_env(:microwaveprop, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Microwaveprop.PubSub},
{Oban, Application.fetch_env!(:microwaveprop, Oban)},
# Start to serve requests, typically the last entry
diff --git a/lib/microwaveprop/markdown.ex b/lib/microwaveprop/markdown.ex
new file mode 100644
index 00000000..151ef7ce
--- /dev/null
+++ b/lib/microwaveprop/markdown.ex
@@ -0,0 +1,213 @@
+defmodule Microwaveprop.Markdown do
+ @moduledoc """
+ Minimal Markdown-to-HTML converter. Handles the subset used by algo.md:
+ headings, paragraphs, fenced code blocks, tables, lists, inline formatting,
+ links, and horizontal rules. Derived from Earmark's approach but without
+ the parser combinator dependency.
+ """
+
+ @doc "Converts a Markdown string to HTML."
+ def to_html!(markdown) do
+ markdown
+ |> String.replace("\r\n", "\n")
+ |> String.split("\n")
+ |> parse_blocks([])
+ |> Enum.map_join("\n", &render_block/1)
+ end
+
+ # ── Block parsing ────────────────────────────────────────────────
+
+ defp parse_blocks([], acc), do: Enum.reverse(acc)
+
+ # Fenced code block
+ defp parse_blocks(["```" <> lang | rest], acc) do
+ {code_lines, remaining} = take_until_fence(rest, [])
+ lang = String.trim(lang)
+ lang_attr = if lang != "", do: " class=\"language-#{lang}\"", else: ""
+ parse_blocks(remaining, [{:code, lang_attr, code_lines} | acc])
+ end
+
+ # Horizontal rule
+ defp parse_blocks([line | rest], acc) when line in ["---", "***", "___"] do
+ parse_blocks(rest, [{:hr} | acc])
+ end
+
+ # Heading
+ defp parse_blocks(["#" <> _ = line | rest], acc) do
+ {level, text} = parse_heading(line)
+ parse_blocks(rest, [{:heading, level, text} | acc])
+ end
+
+ # Table (starts with |)
+ defp parse_blocks(["|" <> _ = line | rest], acc) do
+ {table_lines, remaining} = take_table([line | rest], [])
+ parse_blocks(remaining, [{:table, table_lines} | acc])
+ end
+
+ # Unordered list
+ defp parse_blocks(["- " <> item | rest], acc) do
+ {items, remaining} = take_list(rest, [item])
+ parse_blocks(remaining, [{:ul, items} | acc])
+ end
+
+ # Ordered list
+ defp parse_blocks([line | rest], acc) do
+ if Regex.match?(~r/^\d+\.\s/, line) do
+ item = Regex.replace(~r/^\d+\.\s+/, line, "")
+ {items, remaining} = take_ordered_list(rest, [item])
+ parse_blocks(remaining, [{:ol, items} | acc])
+ else
+ case String.trim(line) do
+ "" ->
+ parse_blocks(rest, acc)
+
+ trimmed ->
+ {para_lines, remaining} = take_paragraph(rest, [trimmed])
+ parse_blocks(remaining, [{:paragraph, para_lines} | acc])
+ end
+ end
+ end
+
+ defp take_until_fence([], acc), do: {Enum.reverse(acc), []}
+
+ defp take_until_fence(["```" <> _ | rest], acc), do: {Enum.reverse(acc), rest}
+
+ defp take_until_fence([line | rest], acc), do: take_until_fence(rest, [line | acc])
+
+ defp parse_heading(line) do
+ {hashes, text} = split_heading(line, 0)
+ {min(hashes, 6), String.trim(text)}
+ end
+
+ defp split_heading("#" <> rest, n), do: split_heading(rest, n + 1)
+ defp split_heading(rest, n), do: {n, rest}
+
+ defp take_table(["|" <> _ = line | rest], acc), do: take_table(rest, [line | acc])
+ defp take_table(rest, acc), do: {Enum.reverse(acc), rest}
+
+ defp take_list(["- " <> item | rest], acc), do: take_list(rest, [item | acc])
+ defp take_list([" " <> cont | rest], [prev | items]), do: take_list(rest, [prev <> " " <> String.trim(cont) | items])
+ defp take_list(rest, acc), do: {Enum.reverse(acc), rest}
+
+ defp take_ordered_list([line | rest], acc) do
+ if Regex.match?(~r/^\d+\.\s/, line) do
+ item = Regex.replace(~r/^\d+\.\s+/, line, "")
+ take_ordered_list(rest, [item | acc])
+ else
+ {Enum.reverse(acc), [line | rest]}
+ end
+ end
+
+ defp take_ordered_list([], acc), do: {Enum.reverse(acc), []}
+
+ defp take_paragraph([], acc), do: {Enum.reverse(acc), []}
+ defp take_paragraph(["" | rest], acc), do: {Enum.reverse(acc), rest}
+ defp take_paragraph(["#" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
+ defp take_paragraph(["|" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
+ defp take_paragraph(["- " <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
+ defp take_paragraph(["```" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest}
+ defp take_paragraph(["---" | _] = rest, acc), do: {Enum.reverse(acc), rest}
+
+ defp take_paragraph([line | rest], acc) do
+ if Regex.match?(~r/^\d+\.\s/, line) do
+ {Enum.reverse(acc), [line | rest]}
+ else
+ take_paragraph(rest, [String.trim(line) | acc])
+ end
+ end
+
+ # ── Block rendering ──────────────────────────────────────────────
+
+ defp render_block({:heading, level, text}) do
+ "
#{lines |> Enum.map_join(" ", &inline/1)}
" + end + + defp render_block({:code, lang_attr, lines}) do + escaped = lines |> Enum.map_join("\n", &escape_html/1) + "#{escaped}"
+ end
+
+ defp render_block({:hr}) do
+ "\\1")
+ end
+
+ defp replace_bold(text) do
+ text
+ |> then(&Regex.replace(~r/\*\*(.+?)\*\*/, &1, "\\1"))
+ |> then(&Regex.replace(~r/__(.+?)__/, &1, "\\1"))
+ end
+
+ defp replace_italic(text) do
+ text
+ |> then(&Regex.replace(~r/\*(.+?)\*/, &1, "\\1"))
+ |> then(&Regex.replace(~r/_(.+?)_/, &1, "\\1"))
+ end
+
+ defp replace_links(text) do
+ Regex.replace(~r/\[([^\]]+)\]\(([^)]+)\)/, text, "\\1")
+ end
+end
diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex
index 556795d7..a2844c02 100644
--- a/lib/microwaveprop/radio/csv_import.ex
+++ b/lib/microwaveprop/radio/csv_import.ex
@@ -86,43 +86,71 @@ defmodule Microwaveprop.Radio.CsvImport do
end
end
- @timestamp_formats [
- "{ISO:Extended:Z}",
- "{ISO:Extended}",
- "{YYYY}-{0M}-{0D}T{h24}:{0m}:{0s}",
- "{YYYY}-{0M}-{0D}T{h24}:{0m}",
- "{YYYY}-{0M}-{0D} {h24}:{0m}:{0s}",
- "{YYYY}-{0M}-{0D} {h24}:{0m}",
- "{YYYY}-{0M}-{0D}",
- "{M}/{D}/{YYYY} {h12}:{m} {AM}",
- "{0M}/{0D}/{YYYY} {h12}:{0m} {AM}",
- "{M}/{D}/{YYYY} {h24}:{m}:{s}",
- "{M}/{D}/{YYYY} {h24}:{m}",
- "{0M}/{0D}/{YYYY} {h24}:{0m}:{0s}",
- "{0M}/{0D}/{YYYY} {h24}:{0m}",
- "{M}-{D}-{YYYY} {h24}:{m}",
- "{0M}-{0D}-{YYYY} {h24}:{0m}:{0s}",
- "{0M}-{0D}-{YYYY} {h24}:{0m}",
- "{YYYY}{0M}{0D}T{h24}{0m}{0s}",
- "{YYYY}{0M}{0D} {h24}{0m}"
- ]
+ # Regex patterns for timestamp parsing, ordered most-specific first.
+ # Accepts many hand-entered formats. All times assumed UTC.
+ # Each returns {year, month, day, hour, minute, second} or nil.
+ defp timestamp_parsers do
+ [
+ # ISO-ish: 2024-06-15T14:30:00Z, 2024-06-15 14:30, 2024/06/15 14:30:00, etc.
+ # Accepts T or space separator, optional seconds, optional timezone suffix
+ {~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$"i,
+ fn
+ [_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
+ [_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"}
+ end},
+ # Date only: 2024-06-15, 2024/06/15
+ {~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$",
+ fn [_, y, m, d] -> {y, m, d, "00", "00", "00"} end},
+ # US with AM/PM: 6/15/2024 2:30 PM, 6-15-2024 2:30PM, 06.15.2024 02:30 am
+ {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$"i,
+ fn
+ [_, m, d, y, h, mi, s, ampm] when byte_size(s) > 0 ->
+ {y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, s}
+
+ [_, m, d, y, h, mi, _, ampm] ->
+ {y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, "00"}
+ end},
+ # US 24h: 6/15/2024 14:30, 06-15-2024 14:30:00, 6.15.2024 14:30
+ {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$",
+ fn
+ [_, m, d, y, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
+ [_, m, d, y, h, mi | _] -> {y, m, d, h, mi, "00"}
+ end},
+ # US date only: 6/15/2024, 06-15-2024
+ {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$",
+ fn [_, m, d, y] -> {y, m, d, "00", "00", "00"} end},
+ # Compact: 20240615T143000, 20240615 1430, 20240615T1430
+ {~r"^(\d{4})(\d{2})(\d{2})[T\s]?(\d{2})(\d{2})(\d{2})?$",
+ fn
+ [_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
+ [_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"}
+ end}
+ ]
+ end
@doc """
- Parses various date/time formats into ISO 8601 UTC strings using Timex.
+ Parses various date/time formats into ISO 8601 UTC strings.
All times are assumed UTC.
"""
def normalize_timestamp(raw) do
trimmed = String.trim(raw)
- @timestamp_formats
- |> Enum.find_value(fn fmt ->
- case Timex.parse(trimmed, fmt) do
- {:ok, parsed} ->
- dt = Timex.to_datetime(parsed, "Etc/UTC")
- {:ok, DateTime.to_iso8601(dt)}
+ # Try ISO 8601 with Elixir's built-in parser first
+ case DateTime.from_iso8601(trimmed) do
+ {:ok, dt, _} ->
+ {:ok, DateTime.to_iso8601(dt)}
- _ ->
- nil
+ _ ->
+ try_regex_parsers(trimmed)
+ end
+ end
+
+ defp try_regex_parsers(trimmed) do
+ timestamp_parsers()
+ |> Enum.find_value(fn {regex, extractor} ->
+ case Regex.run(regex, trimmed) do
+ nil -> nil
+ match -> format_iso(extractor.(match))
end
end)
|> case do
@@ -131,6 +159,21 @@ defmodule Microwaveprop.Radio.CsvImport do
end
end
+ defp parse_12h(12, "AM"), do: 0
+ defp parse_12h(12, "PM"), do: 12
+ defp parse_12h(h, "PM"), do: h + 12
+ defp parse_12h(h, "AM"), do: h
+
+ defp format_iso({y, m, d, h, mi, s}) do
+ y = String.pad_leading(to_string(y), 4, "0")
+ m = String.pad_leading(to_string(m), 2, "0")
+ d = String.pad_leading(to_string(d), 2, "0")
+ h = String.pad_leading(to_string(h), 2, "0")
+ mi = String.pad_leading(to_string(mi), 2, "0")
+ s = String.pad_leading(to_string(s), 2, "0")
+ {:ok, "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"}
+ end
+
defp changeset_error_strings(changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
diff --git a/lib/microwaveprop_web/live/algo_live.ex b/lib/microwaveprop_web/live/algo_live.ex
index 6e105fe0..b9bbaf45 100644
--- a/lib/microwaveprop_web/live/algo_live.ex
+++ b/lib/microwaveprop_web/live/algo_live.ex
@@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.AlgoLive do
use MicrowavepropWeb, :live_view
@external_resource "algo.md"
- @algo_html "algo.md" |> File.read!() |> Earmark.as_html!()
+ @algo_html "algo.md" |> File.read!() |> Microwaveprop.Markdown.to_html!()
@impl true
def mount(_params, _session, socket) do
diff --git a/mix.exs b/mix.exs
index e0139e0d..077cf73f 100644
--- a/mix.exs
+++ b/mix.exs
@@ -49,6 +49,7 @@ defmodule Microwaveprop.MixProject do
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0"},
{:lazy_html, ">= 0.1.0", only: :test},
+ {:stream_data, "~> 1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
@@ -60,18 +61,15 @@ defmodule Microwaveprop.MixProject do
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"},
{:jason, "~> 1.2"},
- {:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"},
{:styler, "~> 1.11", only: [:dev, :test], runtime: false},
{:oban, "~> 2.19"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
- {:earmark, "~> 1.4"},
{:nx, "~> 0.9", only: [:dev, :test]},
{:axon, "~> 0.7", only: [:dev, :test]},
{:exla, "~> 0.9", only: [:dev, :test]},
{:polaris, "~> 0.1", only: [:dev, :test]},
{:tidewave, "~> 0.5", only: :dev},
- {:timex, "~> 3.7"}
]
end
diff --git a/mix.lock b/mix.lock
index 61a31b40..84375cc5 100644
--- a/mix.lock
+++ b/mix.lock
@@ -10,8 +10,6 @@
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
- "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
- "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
@@ -51,6 +49,7 @@
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.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", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [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", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
+ "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},
"swoosh": {:hex, :swoosh, "1.24.0", "4df9645aeeef925a2eb10f7a588a6a09ddd6d370c5dfbd3e821b699c574bdf57", [: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", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.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", "6ddd84550800468d0e2c15a8aaff924a64c014ed6cff90318077efd1672b8b3b"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
@@ -59,7 +58,6 @@
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [: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.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
- "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"},
"tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
diff --git a/test/microwaveprop/radio/csv_import_test.exs b/test/microwaveprop/radio/csv_import_test.exs
index 24a1b260..1045abad 100644
--- a/test/microwaveprop/radio/csv_import_test.exs
+++ b/test/microwaveprop/radio/csv_import_test.exs
@@ -297,5 +297,124 @@ defmodule Microwaveprop.Radio.CsvImportTest do
test "returns error for empty string" do
assert {:error, _} = CsvImport.normalize_timestamp("")
end
+
+ test "parses US date with dots" do
+ assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06.15.2024 14:30")
+ end
+
+ test "parses ISO with slashes" do
+ assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024/06/15 14:30")
+ end
+
+ test "parses US date only" do
+ assert {:ok, "2024-06-15T00:00:00Z"} = CsvImport.normalize_timestamp("6/15/2024")
+ end
+ end
+
+ describe "normalize_timestamp/1 property tests" do
+ use ExUnitProperties
+
+ property "ISO 8601 format always parses successfully" do
+ check all year <- integer(2000..2030),
+ month <- integer(1..12),
+ day <- integer(1..28),
+ hour <- integer(0..23),
+ minute <- integer(0..59),
+ second <- integer(0..59) do
+ y = String.pad_leading(to_string(year), 4, "0")
+ m = String.pad_leading(to_string(month), 2, "0")
+ d = String.pad_leading(to_string(day), 2, "0")
+ h = String.pad_leading(to_string(hour), 2, "0")
+ mi = String.pad_leading(to_string(minute), 2, "0")
+ s = String.pad_leading(to_string(second), 2, "0")
+
+ iso = "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"
+ assert {:ok, ^iso} = CsvImport.normalize_timestamp(iso)
+ end
+ end
+
+ property "US date format with 24h time always parses" do
+ check all year <- integer(2000..2030),
+ month <- integer(1..12),
+ day <- integer(1..28),
+ hour <- integer(0..23),
+ minute <- integer(0..59),
+ sep <- member_of(["/", "-", "."]) do
+ input = "#{month}#{sep}#{day}#{sep}#{year} #{hour}:#{String.pad_leading(to_string(minute), 2, "0")}"
+ assert {:ok, result} = CsvImport.normalize_timestamp(input)
+ assert String.ends_with?(result, "Z")
+ {:ok, dt, _} = DateTime.from_iso8601(result)
+ assert dt.year == year
+ assert dt.month == month
+ assert dt.day == day
+ end
+ end
+
+ property "US date with AM/PM parses correctly" do
+ check all year <- integer(2000..2030),
+ month <- integer(1..12),
+ day <- integer(1..28),
+ hour_12 <- integer(1..12),
+ minute <- integer(0..59),
+ ampm <- member_of(["AM", "PM", "am", "pm"]) do
+ input = "#{month}/#{day}/#{year} #{hour_12}:#{String.pad_leading(to_string(minute), 2, "0")} #{ampm}"
+ assert {:ok, result} = CsvImport.normalize_timestamp(input)
+ {:ok, dt, _} = DateTime.from_iso8601(result)
+
+ expected_hour =
+ case {hour_12, String.upcase(ampm)} do
+ {12, "AM"} -> 0
+ {12, "PM"} -> 12
+ {h, "PM"} -> h + 12
+ {h, "AM"} -> h
+ end
+
+ assert dt.hour == expected_hour
+ assert dt.minute == minute
+ end
+ end
+
+ property "all parsed results are valid ISO 8601 UTC datetimes" do
+ formats =
+ StreamData.member_of([
+ fn y, m, d, h, mi -> "#{y}-#{m}-#{d}T#{h}:#{mi}:00Z" end,
+ fn y, m, d, h, mi -> "#{y}-#{m}-#{d} #{h}:#{mi}" end,
+ fn y, m, d, _h, _mi -> "#{y}-#{m}-#{d}" end,
+ fn y, m, d, h, mi -> "#{m}/#{d}/#{y} #{h}:#{mi}" end
+ ])
+
+ check all year <- integer(2000..2030),
+ month <- integer(1..12),
+ day <- integer(1..28),
+ hour <- integer(0..23),
+ minute <- integer(0..59),
+ fmt <- formats do
+ y = String.pad_leading(to_string(year), 4, "0")
+ m = String.pad_leading(to_string(month), 2, "0")
+ d = String.pad_leading(to_string(day), 2, "0")
+ h = String.pad_leading(to_string(hour), 2, "0")
+ mi = String.pad_leading(to_string(minute), 2, "0")
+
+ input = fmt.(y, m, d, h, mi)
+
+ case CsvImport.normalize_timestamp(input) do
+ {:ok, result} ->
+ assert String.ends_with?(result, "Z")
+ assert {:ok, _, _} = DateTime.from_iso8601(result)
+
+ {:error, _} ->
+ :ok
+ end
+ end
+ end
+
+ property "garbage input never returns ok" do
+ check all garbage <- string(:alphanumeric, min_length: 1, max_length: 5) do
+ case CsvImport.normalize_timestamp(garbage) do
+ {:ok, _} -> flunk("Parsed garbage: #{inspect(garbage)}")
+ {:error, _} -> :ok
+ end
+ end
+ end
end
end