
Published
A working solution to a tedious problem that I couldn't wrap my head around too easily
Recently I've been working on Fantapippa.it, a fun new take on Fantacalcio. A good chunk of it is a rewrite of FantaAsta.com.
A bit of background first. Italian fantasy football, or soccer depending on where you're from, doesn't use a draft like American sports and doesn't have fixed prices like Premier League fantasy football. The most common way of building a team is through a live auction around a table with friends. Now that the game is fully digital, you can also do it through a variety of market types: sealed bid auction, eBay style auction, etc.
With that said, one of the screens in Fantapippa lets the admin of a league create such markets. Just like real-life transfer windows in football, markets have a start and end datetime.

A screen to create a sealed bid auction
At the time of writing, there is a native HTML input for date and time:
<input type="datetime-local" />
Aside from UX considerations which are not relevant for this
post, my main problem with this input is that its value is a
naive datetime string like 2025-06-24T10:20
. Whether a user
enters 10:20 in Japan or in Mexico, your server will receive the
same value.
The common wisdom around this problem is to include the user timezone in the form in a hidden field.
<input type="hidden" value="{user_timezone}" /><input type="datetime-local" />
Here's a useful post by Andrew Timberlake on how to include the timezone in a Phoenix form.
Ok, we're done, great! Not so fast. What follows is a list of roadblocks I hit and how I solved them.
My main goal is to only deal with timezones in the presentation layer so all the business logic can be written assuming UTC timestamps. Users should be able to input their local time in inputs, though.
Note that timezones can change. Storing a UTC timestamp for future dates doesn't guarantee that in the future it will correspond to the same point in time it corresponds to today.
Setting default values
A digression on why I want default values here
When zero-config is not possible, I think default values are the second best thing to point users in the right direction.
For example, when creating a new market I think it makes sense that it starts in one hour and ends one day later.
Compare an empty input and pre-filled one. The friction in filling the input is smaller if you only need to change the day or the hour as opposed to the full datetime.
How to set default values
Let's say I want to prefill the input for the starting datetime so it shows the time in one hour.
I can naively generate the datetime with
DateTime.utc_now/1
on the server, add one hour and set it in the input, but as you
can see this doesn't even show anything.
<input type="datetime-local" value="2025-07-05T14:28:32Z" required/>
- No value at all!
Since the input is not timezone aware, we can't pass the Z
that
makes this a UTC timestamp. If we naively make this a naive
datetime (pun intended), most users will see a time that doesn't
make sense to them.
<input type="datetime-local" value="2025-07-05T14:28:32Z" value="2025-07-05T14:28:32" required/>
- This is not the user's local time
- The input now also shows seconds
Solution in Phoenix
In app.js
we can add the timezone to the connection parameters
of the live socket like so:
const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, },});
This means in our LiveView we can get this value with
get_connect_params/1
and use it to apply the correct timezone shift.
def mount(_params, _session, socket) do timezone = get_connect_params(socket)["timezone"]
default_value_for_form = DateTime.utc_now() |> DateTime.shift_zone!(timezone) |> DateTime.add(1, :hour) ...end
Formatting values for inputs
In the end I settled on this utility function that can be used in HEEx templates when a datetime is a value in a form.
DateTime.shift_zone!/3
is idempotent, so it's safe to use with datetimes that already
have a timezone applied.
We can then format the datetime: as we've seen above, we must always set the value to a naive datetime in the user's timezone.
defp for_form(datetime, timezone) do datetime |> DateTime.shift_zone!(timezone) # I don't want to include seconds in forms |> Calendar.strftime("%Y-%m-%dT%H:%M")end
def render(assigns) do ~H""" <.input field={@form[:ends_at]} value={for_form(@form[:ends_at].value, @form[:timezone].value)} type="datetime-local" label="Ends at" > """end
At this point we can correctly prefill the input for our users. Next, we need to handle values a user sends to our server.
Parsing incoming values
I have written another utility function for this.
Once again the key is to apply the timezone shift to incoming values.
Another small annoyance with datetime-local
inputs is that they
send values in the format 2018-06-14T00:00
. Without third party
libraries, NaiveDateTime
can parse ISO timestamps, but we can't
specify different formats. So we have to do some awful string
manipulation here.
defp from_form("" = datetime, _timezone), do: datetimedefp from_form(%DateTime{} = datetime, _timezone), do: datetime
defp from_form(datetime, timezone) do # by default, values are not precise to the second (datetime <> ":00") |> NaiveDateTime.from_iso8601!() |> DateTime.from_naive!(timezone)end
This should be used on all incoming timestamps before passing them from the presentation layer onwards.
Putting it all together with Ash
I really like
AshPhoenix.Form
.
It makes form validation and submission a breeze. Unfortunately,
it assumes that incoming timestamps are ready to be parsed. There
is no built-in handling of datetimes and timezones from HTML
elements.
However, with the building blocks we have prepared earlier, we can easily hook into the lifetime of our form and do it ourselves.
AshPhoenix.Form
accepts a
transform_params
function to post-process the form parameters before they are used
for our changesets.
In transform_params!/3
we use from_form/2
to parse incoming
values and we put them back in the params map. From there on, we
have a DateTime
with the correct timezone applied and we can
write code without worries.
Lastly, when rendering we always use for_form/2
so users see
the values they expect.
def transform_params!(_form, %{} = params, _validate_or_submit) do timezone = params["timezone"] starts_at = from_form(params["starts_at"], timezone)
params |> Map.put("starts_at", starts_at)end
def mount(socket) do form = AshPhoenix.Form.for_create( Core.Domain.Resource, :my_action, transform_params: &transform_params!/3, ) |> to_form()
socket |> assign(:form, form)end
def render(assigns) do ~H""" <.input field={@form[:starts_at]} value={for_form(@form[:starts_at].value, @form[:timezone].value)} type="datetime-local" required > """end
Conclusion
Getting this to work properly took me a while, but I'm pretty happy with the final result all things considered.