File size: 7,517 Bytes
3e21b19 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | 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
# owner ID can be null (aka note is dangling) when the original owner is deassociated from the site
# the note is dangling until another user edits it: the editor becomes the new owner
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)
# Users that can edit site annotations can edit
# 1) dangling site annotations (owner_id: nil),
# 2) site annotations owned by other users,
# in both cases becoming the new owners.
case fetch_field!(changeset, :owner_id) do
nil -> changeset |> put_assoc(:owner, owner)
# This case works around Ecto's limitations in overwriting owner association
_ -> 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
# If `datetime` is a naive ISO 8601 string (no UTC offset or Z suffix), interpret
# it as a local time in the site's timezone and convert to UTC before the changeset
# runs. This lets callers supply times in their local context without manually
# computing offsets.
#
# DST edge cases:
# - gap (spring-forward): the missing hour is resolved to just-after the gap
# - ambiguous (fall-back): the earlier of the two possibilities is used
#
# All other `datetime` values (bare dates, full UTC strings, invalid strings) pass
# through unchanged and are handled downstream by the changeset.
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")
# Used only by encoder
@doc false
def localize(annotation, timezone)
# For date granularity, the UTC date component IS the annotation date — callers
# store UTC midnight of their intended local date, so no timezone shift is needed.
# Return just the Date so the JSON response matches the bare-date input format.
def localize(%{granularity: :date} = annotation, _timezone) do
%{annotation | datetime: parse_date_granularity_datetime(annotation.datetime)}
end
# For minute granularity, shift the stored UTC moment to the site's local timezone
# and strip the offset so the response is a naive local time string.
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
|