| defmodule Plausible.Annotations.Annotation do |
| @moduledoc """ |
| Schema for annotations. Annotations are notes attached to a point on the graph. |
| |
| Annotations can have two granularities, date or minute. |
| |
| To create date granularity annotations, only the date part must be specified for `datetime` field. |
| It's interpreted to be for that date, no matter what happens with the site timezone. |
| |
| Examples: |
| - `{"granularity": "date", "datetime": "2026-06-30", ...}`. |
| |
| To create minute granularity annotations, the full datetime needs to be specified for `datetime` field. |
| It can be set in local time, as a naive datetime string ("2026-06-29 10:00:00"), in which case it's |
| interpreted as that datetime in the site's timezone and stored as that particular moment in UTC. |
| Alternatively, it can be sent with TZ information ("2026-05-31T12:00:00Z", "2026-05-31T10:00:00-02:00"), |
| in which case it's interpreted to be that particular moment and stored as that in UTC. |
| |
| Examples: |
| - `{"granularity": "minute", "datetime": "2026-06-29 10:00:00", ...}` |
| - `{"granularity": "minute", "datetime": "2026-06-29T12:00:00Z", ...}` |
| """ |
|
|
| use Plausible |
| use Ecto.Schema |
| import Ecto.Changeset |
|
|
| @annotation_types [:personal, :site] |
| @annotation_granularities [:date, :minute] |
|
|
| @type t() :: %__MODULE__{} |
|
|
| schema "annotations" do |
| field :note, :string |
| field :type, Ecto.Enum, values: @annotation_types |
| field :date, :date, virtual: true |
| field :datetime, :utc_datetime |
| field :granularity, Ecto.Enum, values: @annotation_granularities |
|
|
| |
| |
| belongs_to :owner, Plausible.Auth.User, foreign_key: :owner_id |
| belongs_to :site, Plausible.Site |
|
|
| timestamps() |
| end |
|
|
| def create_changeset(attrs, site, owner) do |
| %__MODULE__{} |
| |> changeset(attrs, site.timezone) |
| |> put_assoc(:site, site) |
| |> put_assoc(:owner, owner) |
| end |
|
|
| def update_changeset(annotation, attrs, owner) do |
| changeset = |
| annotation |
| |> changeset(attrs, annotation.site.timezone) |
|
|
| |
| |
| |
| |
| case fetch_field!(changeset, :owner_id) do |
| nil -> changeset |> put_assoc(:owner, owner) |
| |
| _ -> changeset |> put_change(:owner_id, owner.id) |
| end |
| end |
|
|
| def changeset(annotation, attrs, site_timezone) do |
| annotation |
| |> cast(attrs, [:note, :type]) |
| |> cast(attrs, [:date, :datetime, :granularity], force_changes: true) |
| |> validate_required([:note, :type, :granularity]) |
| |> validate_length(:note, count: :bytes, min: 1, max: 255) |
| |> maybe_coerce_naive_datetime(site_timezone) |
| |> coerce_datetime() |
| end |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| defp maybe_coerce_naive_datetime(changeset, timezone) do |
| with false <- is_nil(get_change(changeset, :datetime)), |
| dt when is_binary(dt) <- changeset.params["datetime"], |
| {:error, _} <- DateTime.from_iso8601(dt), |
| {:ok, naive_dt} <- NaiveDateTime.from_iso8601(dt) do |
| case DateTime.from_naive(naive_dt, timezone) do |
| {:ok, local_dt} -> |
| force_change(changeset, :datetime, to_utc(local_dt)) |
|
|
| {:ambiguous, first, _second} -> |
| force_change(changeset, :datetime, to_utc(first)) |
|
|
| {:gap, _just_before, just_after} -> |
| force_change(changeset, :datetime, to_utc(just_after)) |
|
|
| {:error, _} -> |
| add_error( |
| changeset, |
| :datetime, |
| "cannot be parsed for the site timezone \"#{timezone}\"" |
| ) |
| end |
| else |
| _ -> changeset |
| end |
| end |
|
|
| defp to_utc(%DateTime{} = dt), do: DateTime.shift_zone!(dt, "Etc/UTC") |
|
|
| defp coerce_datetime(%{valid?: true} = changeset) do |
| granularity = get_change(changeset, :granularity) |
| datetime = get_change(changeset, :datetime) |
| date = get_change(changeset, :date) |
|
|
| case coerce_for_granularity(granularity, date, datetime) do |
| {:ok, %DateTime{} = utc_dt} -> |
| put_change(changeset, :datetime, utc_dt) |
|
|
| {:error, :not_supplied, field} -> |
| add_error(changeset, field, "must be supplied for chosen granularity") |
|
|
| {:error, :both_set} -> |
| add_error(changeset, :granularity, "expects either date or datetime to be set") |
|
|
| :skip -> |
| changeset |
| end |
| end |
|
|
| defp coerce_datetime(changeset), do: changeset |
|
|
| defp coerce_for_granularity(:date, %Date{} = date, nil), |
| do: {:ok, serialize_date_granularity_datetime(date)} |
|
|
| defp coerce_for_granularity(:minute, nil, %DateTime{} = dt), |
| do: {:ok, dt} |
|
|
| defp coerce_for_granularity(:date, nil, _), |
| do: {:error, :not_supplied, :date} |
|
|
| defp coerce_for_granularity(:minute, _, nil), |
| do: {:error, :not_supplied, :datetime} |
|
|
| defp coerce_for_granularity(granularity, _date, _datetime) |
| when granularity in [:date, :minute], |
| do: {:error, :both_set} |
|
|
| defp coerce_for_granularity(_granularity, _date, _datetime), do: :skip |
|
|
| def serialize_date_granularity_datetime(%Date{} = date), |
| do: DateTime.new!(date, ~T[00:00:00], "Etc/UTC") |
|
|
| |
| @doc false |
| def localize(annotation, timezone) |
|
|
| |
| |
| |
| def localize(%{granularity: :date} = annotation, _timezone) do |
| %{annotation | datetime: parse_date_granularity_datetime(annotation.datetime)} |
| end |
|
|
| |
| |
| def localize(%{granularity: :minute} = annotation, timezone) do |
| naive_local = |
| annotation.datetime |
| |> DateTime.shift_zone!(timezone) |
| |> DateTime.to_naive() |
|
|
| %{annotation | datetime: naive_local} |
| end |
|
|
| defp parse_date_granularity_datetime(%DateTime{} = datetime), |
| do: DateTime.to_date(datetime) |
| end |
|
|
| defimpl Jason.Encoder, for: Plausible.Annotations.Annotation do |
| def encode(annotation, opts) do |
| %{ |
| id: annotation.id, |
| note: annotation.note, |
| type: annotation.type, |
| datetime: annotation.datetime, |
| granularity: annotation.granularity, |
| owner_id: annotation.owner_id, |
| owner_name: if(annotation.owner_id, do: annotation.owner.name), |
| inserted_at: annotation.inserted_at, |
| updated_at: annotation.updated_at |
| } |
| |> Plausible.Annotations.Annotation.localize(annotation.site.timezone) |
| |> Jason.Encode.map(opts) |
| end |
| end |
|
|