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
- - Timestamps must be ISO 8601 UTC, e.g.
2024-06-15T14:30:00Z
+ -
+ Timestamps in most formats accepted (e.g.
2024-06-15T14:30:00Z,
+ 6/15/2024 2:30 PM, 2024-06-15 14:30). All times assumed UTC.
+
- Grid squares should be as detailed as possible (6 characters preferred, e.g. EM12kp)
- Band in MHz (e.g. 10000, 24000)