{"id":15668407,"url":"https://github.com/lostkobrakai/tz_datetime","last_synced_at":"2025-05-06T20:04:52.568Z","repository":{"id":53590608,"uuid":"207099972","full_name":"LostKobrakai/tz_datetime","owner":"LostKobrakai","description":"Opinonated handling of datetimes and their original timezone with ecto","archived":false,"fork":false,"pushed_at":"2021-03-22T18:37:28.000Z","size":35,"stargazers_count":8,"open_issues_count":0,"forks_count":2,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-03-31T02:22:09.950Z","etag":null,"topics":["ecto","elixir","elixir-lang"],"latest_commit_sha":null,"homepage":null,"language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/LostKobrakai.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2019-09-08T11:05:27.000Z","updated_at":"2025-02-20T12:46:27.000Z","dependencies_parsed_at":"2022-09-16T00:22:41.330Z","dependency_job_id":null,"html_url":"https://github.com/LostKobrakai/tz_datetime","commit_stats":null,"previous_names":["madeitgmbh/tz_datetime"],"tags_count":4,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/LostKobrakai%2Ftz_datetime","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/LostKobrakai%2Ftz_datetime/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/LostKobrakai%2Ftz_datetime/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/LostKobrakai%2Ftz_datetime/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/LostKobrakai","download_url":"https://codeload.github.com/LostKobrakai/tz_datetime/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":252761129,"owners_count":21800124,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ecto","elixir","elixir-lang"],"created_at":"2024-10-03T14:08:32.419Z","updated_at":"2025-05-06T20:04:52.513Z","avatar_url":"https://github.com/LostKobrakai.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# TzDatetime\n\n\u003c!-- MDOC !--\u003e\nDatetime in a certain timezone with Ecto\n\nEcto natively only supports `naive_datetime`s and `utc_datetime`s, which either\nignore timezones or enforce only UTC. Both are useful for certain usecases, but\nnot sufficient when needing to store a datetime for different timezones.\n\nThis library is supposed to help for the given use case, but not in the way e.g.\n`Calecto` does it by implementing a custom `Ecto.Type`. It rather gives you tools\nto set the correct values for multiple columns on a changeset and converting the\nvalues back to a `DateTime` at a later time.\n\n## Why not use an `Ecto.Type` implementation?\n\nTimezone definitions change and do so even between storage and retrieval from a\ndatabase, which is especially problematic for points of time in the future. When a\ncalendar app stores an event at `10 o'clock` in CET a year ahead of time and the\ntimezone definition is changed e.g. to no longer do a daylight savings time the\n`utc_datetime` field in the database does no longer match the intended wall\ntime of `10 o'clock`, but results in `9 o'clock` when converted to CET. `Ecto.Type`s\nare not really well suited for dealing with that ambiguity, as values once\nstored are meant to stay valid values. `TzDatetime` uses multiple columns, which\nby themselves stay valid. The calculated `DateTime` based on those stored fields\nmight change though.\n\n## Why store the datetime in a `utc_datetime` field in the first place?\n\nThere is a simple answer: The ability to compare datetimes. Without a common timezone\nfor datetimes comparison get unnecessarily tricky. And at least\ncomparing to \"now\" is common enough to say most applications will actually need\nto compare datetimes in the db.\n\n## Usage\n\n`TzDatetime` consists of two parts:\n\n* `handle_datetime/2` for handling changes to a \"datetime\".\n* `original_datetime/2` for retrieving the original \"datetime\" in the stored timezone\n\n### Storing a \"datetime\"\n\nFor storing a \"datetime\" there are multiple fields required. \n\n```elixir\nfield :datetime, :utc_datetime\nfield :time_zone, :string\nfield :original_offset, :integer\n```\n\n`:datetime` is the datetime in UTC, `:time_zone` is the input timezone and \n`:original_offset` the offset of `:time_zone` at the time of persistance.\n\nThose three fields together allow for comparing stored datetimes – all in UTC – \nwhile still allowing detection of a change in the offset for the stored time zone \nat the time the value is read.\n\n#### NaiveDateTime as input\n\nOften the user input doesn't supply a datetime with time zone, but a string format \nlike ISO 8601. But even ISO 8601 formatted string will only include the offset, \nbut not the timezone name. Therefore the input for `handle_datetime/2` does work \nwith a `:naive_datetime` in combination with the `:time_zone` field.\n\n```elixir\nfield :input_datetime, :naive_datetime, virtual: true\nfield :time_zone, :string # As listed prev.\n```\n\n```elixir\ndef changeset(schema, params) do\n  schema\n  |\u003e cast(params, [:input_datetime, :time_zone])\n  |\u003e validate_required([:input_datetime, :time_zone])\n  |\u003e TzDatetime.handle_datetime()\nend\n```\n\nYou can customize the names for those fields by passing a keyword list\nof `[{name :: atom, custom_name :: atom}]` as second parameter to `handle_datetime/2`.\n\n#### Ambiguous dates or gaps\n\nUsing a `naive_datetime` and a separate timezone as inputs results in some\ncomplexity though. The input datetime might exist twice or might not exist in\nthe timezone. This is possible for the periods in time when a switch between\ndaylight savings time and standard time occurs.\n\nWhen the clock is turned backwards a certain naive datetime and timezone might\nresult in two possible datetimes with different `std_offset`s.\n\nWhen the clock is turned forward a certain naive datetime and timezone might\nresult in no possible datetime, where elixir will supply the last possible\ndatetime before the switch and the first possible datetime afterwards.\n\nSee `DateTime.from_naive/3` for detailed examples on those cases.\n\nThe callbacks of the `TzDatetime` behaviour allow you to handle those cases\nbased on your business domains' requirements:\n\n```elixir\n@impl TzDatetime\n@spec when_ambiguous(Ecto.Changeset.t(), DateTime.t(), DateTime.t(), TzDatetime.fields) ::\n        Ecto.Changeset.t() | DateTime.t()\ndef when_ambiguous(_changeset, dt1, _dt2, _) do\n  # Implement your business logic\n  dt1\nend\n\n@impl TzDatetime\n@spec when_gap(Ecto.Changeset.t(), DateTime.t(), DateTime.t(), TzDatetime.fields) ::\n        Ecto.Changeset.t() | DateTime.t()\ndef when_gap(changeset, _dt1, _dt2, fields) do\n  # Implement your business logic\n  add_error(changeset, fields.datetime, \"does not exist for the selected timezone\")\nend\n```\n\n`handle_datetime/2` will use the module of the changeset's data by default,\nbut you can also supply a different module using the `:module` key on the options.\n\n### Reading datetimes\n\nAs mentioned earlier the timezone definitions can change. Therefore\nthe datetime stored can diverge over time from the value originally intended.\nBy storing the offset used to convert to the utc value in the db\n`original_datetime/2` can detect if this did indeed happen or not. If a change\nis detected two datetimes are returned, one with the changed offset and one with\nthe offset as stored in the db.\n\nThis can then be used to select between:\n\n- the wall time should be kept and the utc value in the db shall be updated\n- the point in time in utc is to be kept and the stored offset shall be updated\n\nWhich option is the correct one could be infered automatically per use case or \neven by notifying users about the change and letting them deal with it \naccordingly.\n\n```elixir\n# When offset does still match\n\u003e original_datetime(schema)\n{:ok, datetime}\n\n# When offset does no longer match\n\u003e original_datetime(schema)\n{:ambiguous, datetime_using_current_offset, datetime_using_stored_offset}\n\n# Error cases:\n# E.g. when tz no longer exists\n\u003e original_datetime(schema)\n{:error, :time_zone_not_found}\n```\n\n`original_datetime/2` like `handle_datetime/2` can receive a keyword list of\nmappings for the field names for `:datetime`, `:time_zone` and `:original_offset`.\n\n## Timezone Database\n\nBy default elixir does only support `Etc/UTC` as a timezone. To use this library\nyou likely need to install an alternative `Calendar.TimeZoneDatabase` implementation.\n\n\u003c!-- MDOC !--\u003e\n\n## Installation\n\nIf [available in Hex](https://hex.pm/docs/publish), the package can be installed\nby adding `tz_datetime` to your list of dependencies in `mix.exs`:\n\n```elixir\ndef deps do\n  [\n    {:tz_datetime, \"~\u003e 0.1.2\"}\n  ]\nend\n```\n\nYou'll also need to configure elixir to use a timezone database, which supports\nall the timezones you need to use. Elixir itself does only support `Etc/UTC`. For\nother timezones look at [`tz_data`](https://hex.pm/packages/tzdata) or other\nimplementations of `Calendar.TimeZoneDatabase`.\n\nDocumentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)\nand published on [HexDocs](https://hexdocs.pm). Once published, the docs can\nbe found at [https://hexdocs.pm/tz_datetime](https://hexdocs.pm/tz_datetime).\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flostkobrakai%2Ftz_datetime","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flostkobrakai%2Ftz_datetime","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flostkobrakai%2Ftz_datetime/lists"}