From 05654289af654dea3517e3709797fa7262ebbf4f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 1 Apr 2026 15:35:35 -0500 Subject: [PATCH] Accept various date/time formats in CSV import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse US dates (6/15/2024 2:30 PM), 24h (06/15/2024 14:30), dashes (06-15-2024 14:30), compact (20240615 1430), date-only (2024-06-15), and ISO 8601 — all normalized to UTC before insert. --- lib/microwaveprop/radio/csv_import.ex | 87 +++++++++++++++++++++-- lib/microwaveprop_web/live/submit_live.ex | 5 +- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex index 7344c5f1..052b0afd 100644 --- a/lib/microwaveprop/radio/csv_import.ex +++ b/lib/microwaveprop/radio/csv_import.ex @@ -65,19 +65,94 @@ defmodule Microwaveprop.Radio.CsvImport do |> Map.new() |> Map.put("submitter_email", submitter_email) - case Radio.create_contact(attrs) do - {:ok, contact} -> - ContactWeatherEnqueueWorker.enqueue_for_contact(contact) - {:ok, contact} + case normalize_timestamp(attrs["qso_timestamp"]) do + {:ok, iso_timestamp} -> + attrs = Map.put(attrs, "qso_timestamp", iso_timestamp) - {:error, changeset} -> - {:error, changeset_error_strings(changeset)} + case Radio.create_contact(attrs) do + {:ok, contact} -> + ContactWeatherEnqueueWorker.enqueue_for_contact(contact) + {:ok, contact} + + {:error, changeset} -> + {:error, changeset_error_strings(changeset)} + end + + {:error, msg} -> + {:error, [msg]} end else {:error, ["expected #{@expected_columns} columns, got #{length(fields)}"]} end end + @doc """ + Parses various date/time formats into ISO 8601 UTC strings. + + Accepted formats: + - ISO 8601: 2024-06-15T14:30:00Z, 2024-06-15 14:30:00Z, 2024-06-15T14:30:00 + - US date: 06/15/2024 14:30, 6/15/2024 2:30 PM + - US date: 06-15-2024 14:30 + - EU date: 15/06/2024 14:30 + - Date only: 2024-06-15 (assumes 00:00 UTC) + - Compact: 20240615 1430, 20240615T143000 + """ + def normalize_timestamp(raw) do + trimmed = String.trim(raw) + + cond do + # Already ISO 8601 with timezone + match?({:ok, _, _}, DateTime.from_iso8601(trimmed)) -> + {:ok, trimmed} + + # ISO-ish without Z: 2024-06-15T14:30:00 or 2024-06-15 14:30:00 + Regex.match?(~r/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/, trimmed) -> + {:ok, trimmed <> "Z"} + + # Date only: 2024-06-15 + Regex.match?(~r/^\d{4}-\d{2}-\d{2}$/, trimmed) -> + {:ok, trimmed <> "T00:00:00Z"} + + # US format with AM/PM: 6/15/2024 2:30 PM or 06/15/2024 02:30 PM + match = Regex.run(~r"^(\d{1,2})/(\d{1,2})/(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)$"i, trimmed) -> + [_, m, d, y, h, min, ampm] = match + hour = parse_12h(String.to_integer(h), String.upcase(ampm)) + format_iso(y, m, d, hour, min) + + # US format 24h: 6/15/2024 14:30 or 06/15/2024 14:30:00 + match = Regex.run(~r"^(\d{1,2})/(\d{1,2})/(\d{4})\s+(\d{1,2}):(\d{2})(:\d{2})?$", trimmed) -> + [_, m, d, y, h, min | _] = match + format_iso(y, m, d, String.to_integer(h), min) + + # US format with dashes: 06-15-2024 14:30 + match = Regex.run(~r"^(\d{1,2})-(\d{1,2})-(\d{4})\s+(\d{1,2}):(\d{2})(:\d{2})?$", trimmed) -> + [_, m, d, y, h, min | _] = match + format_iso(y, m, d, String.to_integer(h), min) + + # Compact: 20240615 1430 or 20240615T143000 + match = Regex.run(~r"^(\d{4})(\d{2})(\d{2})[T ]?(\d{2})(\d{2})(\d{2})?$", trimmed) -> + [_, y, m, d, h, min | _] = match + format_iso(y, m, d, String.to_integer(h), min) + + true -> + {:error, "qso_timestamp could not be parsed: #{trimmed}"} + 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, hour, min) 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(hour), 2, "0") + min = String.pad_leading(to_string(min), 2, "0") + {:ok, "#{y}-#{m}-#{d}T#{h}:#{min}:00Z"} + end + defp changeset_error_strings(changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {message, opts} -> diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index de2db6a7..8e60f771 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -261,7 +261,10 @@ defmodule MicrowavepropWeb.SubmitLive do

station1, station2, grid1, grid2, band, mode, qso_timestamp