How to handle datetime-local inputs in Phoenix with Ash

Tags:
  • Elixir
  • Phoenix
  • Ash

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: datetime
defp 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.