@@ -122,7 +122,7 @@ export const SegmentMenu = () => {
state={{ expandedSegment }}
onClick={() => {
closeDropdown()
- setModal('delete')
+ setModal({ type: 'delete-segment' })
}}
>
diff --git a/assets/js/dashboard/navigation/routeless-modals-context.tsx b/assets/js/dashboard/navigation/routeless-modals-context.tsx
index a88d5a044968..d3a90357933d 100644
--- a/assets/js/dashboard/navigation/routeless-modals-context.tsx
+++ b/assets/js/dashboard/navigation/routeless-modals-context.tsx
@@ -1,7 +1,8 @@
import React, { createContext, ReactNode, useContext, useState } from 'react'
import { RoutelessSegmentModal } from '../segments/routeless-segment-modals'
+import { RoutelessAnnotationModal } from '../annotations/routeless-annotations-modals'
-type ActiveModal = null | RoutelessSegmentModal
+type ActiveModal = null | RoutelessSegmentModal | RoutelessAnnotationModal
const routelessModalsContextDefaultValue: {
modal: ActiveModal
diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx
index 355bee142f32..ff8ccefe30ad 100644
--- a/assets/js/dashboard/router.tsx
+++ b/assets/js/dashboard/router.tsx
@@ -28,6 +28,7 @@ import { DashboardKeybinds } from './dashboard-keybinds'
import LastLoadContextProvider from './last-load-context'
import { RoutelessModalsContextProvider } from './navigation/routeless-modals-context'
import { RoutelessSegmentModals } from './segments/routeless-segment-modals'
+import { RoutelessAnnotationModals } from './annotations/routeless-annotations-modals'
const queryClient = new QueryClient({
defaultOptions: {
@@ -48,6 +49,7 @@ function DashboardElement() {
+
diff --git a/assets/js/dashboard/segments/routeless-segment-modals.tsx b/assets/js/dashboard/segments/routeless-segment-modals.tsx
index 3f8f2dc9a4ba..f0a6a8d52863 100644
--- a/assets/js/dashboard/segments/routeless-segment-modals.tsx
+++ b/assets/js/dashboard/segments/routeless-segment-modals.tsx
@@ -22,7 +22,10 @@ import { mutation } from '../api'
import { useRoutelessModalsContext } from '../navigation/routeless-modals-context'
import { useSegmentsContext } from '../filtering/segments-context'
-export type RoutelessSegmentModal = 'create' | 'update' | 'delete'
+export type RoutelessSegmentModal =
+ | { type: 'create-segment' }
+ | { type: 'update-segment' }
+ | { type: 'delete-segment' }
export const RoutelessSegmentModals = () => {
const { updateOne, addOne, removeOne } = useSegmentsContext()
@@ -153,7 +156,7 @@ export const RoutelessSegmentModals = () => {
return (
<>
- {modal === 'update' && expandedSegment && (
+ {modal?.type === 'update-segment' && expandedSegment && (
{
reset={patchSegment.reset}
/>
)}
- {modal === 'create' && (
+ {modal?.type === 'create-segment' && (
{
reset={createSegment.reset}
/>
)}
- {modal === 'delete' && expandedSegment && (
+ {modal?.type === 'delete-segment' && expandedSegment && (
{
diff --git a/assets/js/dashboard/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx
index aa0df4d4672c..5d8c25efef5f 100644
--- a/assets/js/dashboard/site-context.test.tsx
+++ b/assets/js/dashboard/site-context.test.tsx
@@ -17,6 +17,7 @@ describe('parseSiteFromDataset', () => {
data-funnels-available="true"
data-exploration-available="false"
data-site-segments-available="true"
+ data-site-annotations-available="true"
data-props-available="true"
data-revenue-goals='[{"currency":"USD","display_name":"Purchase"}]'
data-funnels='[{"id":1,"name":"From homepage to login","steps_count":3}]'
@@ -46,6 +47,7 @@ describe('parseSiteFromDataset', () => {
propsAvailable: true,
explorationAvailable: false,
siteSegmentsAvailable: true,
+ siteAnnotationsAvailable: true,
revenueGoals: [{ currency: 'USD', display_name: 'Purchase' }],
funnels: [{ id: 1, name: 'From homepage to login', steps_count: 3 }],
hasProps: true,
diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx
index 1dff059bacb9..4db8495ade54 100644
--- a/assets/js/dashboard/site-context.tsx
+++ b/assets/js/dashboard/site-context.tsx
@@ -10,6 +10,7 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
propsAvailable: dataset.propsAvailable === 'true',
explorationAvailable: dataset.explorationAvailable === 'true',
siteSegmentsAvailable: dataset.siteSegmentsAvailable === 'true',
+ siteAnnotationsAvailable: dataset.siteAnnotationsAvailable === 'true',
conversionsOptedOut: dataset.conversionsOptedOut === 'true',
funnelsOptedOut: dataset.funnelsOptedOut === 'true',
propsOptedOut: dataset.propsOptedOut === 'true',
@@ -39,6 +40,7 @@ export const siteContextDefaultValue = {
explorationAvailable: false,
propsAvailable: false,
siteSegmentsAvailable: false,
+ siteAnnotationsAvailable: false,
conversionsOptedOut: false,
funnelsOptedOut: false,
propsOptedOut: false,
diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx
index 4c48f1c23bdd..db3b38b6649d 100644
--- a/assets/js/dashboard/stats/graph/main-graph.tsx
+++ b/assets/js/dashboard/stats/graph/main-graph.tsx
@@ -1,5 +1,6 @@
import React, {
ReactNode,
+ RefObject,
useCallback,
useEffect,
useMemo,
@@ -41,6 +42,17 @@ import { Metric, getMetricLabel } from '../metrics'
import { useDashboardStateContext } from '../../dashboard-state-context'
import { hasConversionGoalFilter } from '../../util/filters'
import { Interval } from './intervals'
+import { useRoutelessModalsContext } from '../../navigation/routeless-modals-context'
+import {
+ Annotation,
+ AnnotationType,
+ AnnotationWithPinState,
+ PinPosition,
+ enrichAnnotationsWithPinState,
+ getAnnotationGranularity,
+ groupAnnotationsByTimeLabel
+} from '../../annotations/annotations'
+import { Button } from '../../components/button'
const height = 368
const marginTop = 16
@@ -66,42 +78,92 @@ type MainGraphYValues = Readonly<
type TooltipState = {
x: number
+ y: number
selectedIndex: number | null
persistent: boolean
}
+
const initialTooltipState: TooltipState = {
x: 0,
+ y: 0,
selectedIndex: null,
persistent: false
}
export const MainGraph = ({
width,
- data
+ data,
+ annotations
}: {
width: number
data: MainGraphData
+ annotations: Annotation[]
}) => {
const site = useSiteContext()
const { mode } = useTheme()
const navigate = useAppNavigate()
+
const { primaryGradient, secondaryGradient } = paletteByTheme[mode]
+
const [isTouchDevice, setIsTouchDevice] = useState(null)
+ const [pinnedAnnotationIds, setPinnedAnnotationIds] = useState<
+ Record
+ >({})
const [tooltip, setTooltip] = useState(initialTooltipState)
+ useEffect(() => {
+ setTooltip(initialTooltipState)
+ }, [width])
+
+ useEffect(() => {
+ setPinnedAnnotationIds({})
+ }, [width, data])
+
+ const tooltipRef = useRef(null)
+
const { selectedIndex } = tooltip
const panGestureStartTimeRef = useRef(null)
+
const metric = data.query.metrics[0] as Metric
const interval = data.interval
const period = data.period
+ const enrichedAnnotations = useMemo(
+ () => enrichAnnotationsWithPinState(annotations, pinnedAnnotationIds),
+ [annotations, pinnedAnnotationIds]
+ )
+
+ const annotationsByTimeLabel = useMemo(
+ () => groupAnnotationsByTimeLabel(enrichedAnnotations, interval),
+ [enrichedAnnotations, interval]
+ )
+
useEffect(() => {
setTooltip(initialTooltipState)
- }, [data])
+ }, [data, annotationsByTimeLabel])
+
+ useEffect(() => {
+ const onClickOutside = (event: MouseEvent) => {
+ if (!tooltipRef.current?.contains(event.target as Node)) {
+ setTooltip(initialTooltipState)
+ }
+ }
+ if (tooltip.persistent && isTouchDevice === false) {
+ document.addEventListener('click', onClickOutside)
+ } else {
+ document.removeEventListener('click', onClickOutside)
+ }
+ return () => {
+ document.removeEventListener('click', onClickOutside)
+ }
+ }, [tooltip.persistent, isTouchDevice])
useEffect(() => {
- const onPointerCancel = (e: PointerEvent) => {
- if (e.pointerType === 'touch') {
+ const onPointerCancel = (event: PointerEvent) => {
+ if (event.pointerType === 'touch') {
panGestureStartTimeRef.current = null
+ if (tooltipRef.current?.contains(event.target as Node)) {
+ return
+ }
setTooltip(initialTooltipState)
}
}
@@ -246,6 +308,25 @@ export const MainGraph = ({
}
}, [site, data, interval, period, primaryGradient, secondaryGradient, metric])
+ const annotationsByIndex = useMemo(
+ () =>
+ remappedData.map((datum) => {
+ const annotationsOnDatum = datum.main.isDefined
+ ? (annotationsByTimeLabel[datum.main.timeLabel] ?? [])
+ : []
+ return {
+ count: annotationsOnDatum.length,
+ pinnedCount: annotationsOnDatum.filter((a) => a.isPinned).length
+ }
+ }),
+ [remappedData, annotationsByTimeLabel]
+ )
+
+ const verticalLinesByIndex = useMemo(
+ () => annotationsByIndex.map(({ pinnedCount }) => pinnedCount >= 1),
+ [annotationsByIndex]
+ )
+
const getFormattedValue = useCallback(
(value: MetricValue) => MetricFormatterShort[metric](value),
[metric]
@@ -262,7 +343,7 @@ export const MainGraph = ({
setIsTouchDevice(true)
if (tooltip.persistent && inHoverableArea && closestPoint) {
const now = Date.now()
- // move the tooltip only when it is certain it's a y-pan
+ // move the tooltip only when it is certain it's not a y-pan
if (panGestureStartTimeRef.current === null) {
panGestureStartTimeRef.current = now
} else if (
@@ -272,6 +353,7 @@ export const MainGraph = ({
setTooltip({
selectedIndex: closestPoint.index,
x: closestPoint.x,
+ y: 0,
persistent: true
})
}
@@ -279,26 +361,34 @@ export const MainGraph = ({
return
}
setIsTouchDevice(false)
- if (!inHoverableArea || !closestPoint) {
- return setTooltip(initialTooltipState)
- }
- return setTooltip({
- selectedIndex: closestPoint.index,
- x: closestPoint.x,
- persistent: false
+ setTooltip((currentState) => {
+ const currentlyPersistent = currentState.persistent
+ if (currentlyPersistent) {
+ return currentState
+ }
+ if (!inHoverableArea || !closestPoint) {
+ return initialTooltipState
+ }
+ return {
+ persistent: false,
+ selectedIndex: closestPoint.index,
+ x: closestPoint.x,
+ y: 0,
+ type: 'series'
+ }
})
},
[tooltip.persistent]
)
const onGotPointerCapture = useCallback((event: unknown) => {
- if (event instanceof PointerEvent && event.pointerType === 'touch') {
+ if (isTouchEvent(event)) {
return setIsTouchDevice(true)
}
}, [])
const onPointerEnter = useCallback((event: unknown) => {
- if (event instanceof PointerEvent && event.pointerType === 'touch') {
+ if (isTouchEvent(event)) {
return setIsTouchDevice(true)
}
}, [])
@@ -323,6 +413,11 @@ export const MainGraph = ({
? selectedDatum.main.timeLabel
: null
+ const annotationDatetime =
+ selectedDatum && selectedDatum.main.isDefined
+ ? selectedDatum.main.timeLabel
+ : null
+
const zoomToPeriod = useCallback(
(date: string) => {
setTooltip(initialTooltipState)
@@ -340,31 +435,46 @@ export const MainGraph = ({
[navigate, interval]
)
- const onClick = useCallback>(
+ const onChartClick = useCallback>(
({ inHoverableArea, closestPoint }) => {
if (isTouchDevice) {
if (inHoverableArea && closestPoint) {
return setTooltip({
selectedIndex: closestPoint.index,
x: closestPoint.x,
+ y: 0,
persistent: true
})
}
return setTooltip(initialTooltipState)
}
+
+ if (tooltip.persistent) {
+ return
+ }
if (typeof zoomDate === 'string') {
return zoomToPeriod(zoomDate)
}
},
- [zoomDate, zoomToPeriod, isTouchDevice]
+ [isTouchDevice, zoomDate, zoomToPeriod, tooltip.persistent]
+ )
+
+ const onContextMenu = useCallback>(
+ ({ event }) => {
+ if (selectedDatum) {
+ ;(event as Event).preventDefault()
+ return setTooltip((current) => ({ ...current, persistent: true }))
+ }
+ },
+ [selectedDatum]
)
return (
- className={classNames(
- showZoomToPeriod && selectedDatum ? 'cursor-pointer' : '',
- tooltip.persistent ? 'touch-pan-y' : ''
- )}
+ className={classNames({
+ 'cursor-pointer': selectedDatum && showZoomToPeriod,
+ 'touch-pan-y': tooltip.persistent
+ })}
highlightedIndex={selectedIndex}
width={width}
height={height}
@@ -380,10 +490,40 @@ export const MainGraph = ({
onGotPointerCapture={onGotPointerCapture}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
- onClick={onClick}
+ onClick={onChartClick}
+ onContextMenu={onContextMenu}
yFormat={yFormat}
gradients={gradients}
+ annotationsByIndex={annotationsByIndex}
+ verticalLinesByIndex={verticalLinesByIndex}
>
+ {Object.values(annotationsByTimeLabel)
+ .map((annotations) => {
+ const pinnedAnnotations = annotations?.filter((a) => a.isPinned) ?? []
+ const pinPosition = pinnedAnnotations[0]?.pinPosition ?? null
+ return { pinPosition, pinnedAnnotations }
+ })
+ .filter(
+ ({ pinPosition }) =>
+ pinPosition !== null && pinPosition.selectedIndex !== selectedIndex
+ )
+ .sort((a, b) => a.pinPosition!.x - b.pinPosition!.x)
+ .map(({ pinPosition, pinnedAnnotations }) => (
+
+ setTooltip({
+ selectedIndex: pinPosition!.selectedIndex,
+ x: pinPosition!.x,
+ y: 0,
+ persistent: true
+ })
+ }
+ />
+ ))}
{!!selectedDatum && isTouchDevice !== null && (
zoomToPeriod(zoomDate)
- : undefined
- }
- />
+ tooltipRef={tooltipRef}
+ isTouchDevice={isTouchDevice}
+ >
+ {tooltip.persistent && (
+ <>
+ {!!annotationDatetime &&
+ !!annotationsByTimeLabel[annotationDatetime] && (
+
+ setPinnedAnnotationIds((current) => ({
+ ...current,
+ [annotation.id]:
+ current[annotation.id] != null
+ ? null
+ : {
+ x: tooltip.x,
+ selectedIndex: tooltip.selectedIndex ?? 0
+ }
+ }))
+ }
+ annotations={annotationsByTimeLabel[annotationDatetime]}
+ />
+ )}
+ {!!annotationDatetime && (
+
+ )}
+ {!!zoomDate && (
+
+ )}
+ >
+ )}
+ {!tooltip.persistent && (
+ <>
+ {!!annotationDatetime &&
+ !!annotationsByTimeLabel[annotationDatetime] && (
+ <>
+ {}}
+ />
+ {annotationsByTimeLabel[annotationDatetime].length == 2 &&
+ `and 1 more note`}
+ {annotationsByTimeLabel[annotationDatetime].length > 2 &&
+ `and ${annotationsByTimeLabel[annotationDatetime].length - 1} more notes`}
+ >
+ )}
+ {(!!zoomDate || !!annotationDatetime) && (
+
+ )}
+ {!!zoomDate && (
+
+ {`Click to view ${interval}`}
+
+ )}
+ {!!annotationDatetime && (
+
+ Right click for more actions
+
+ )}
+ >
+ )}
+
)}
)
}
-const MainGraphTooltip = ({
- metric,
- getFormattedValue,
+const InteractiveAnnotationsList = ({
+ annotations,
+ onPin
+}: {
+ onPin: (annotation: Annotation) => void
+ annotations: AnnotationWithPinState[]
+}) => {
+ const [expanded, setExpanded] = useState(null)
+ useEffect(() => {
+ setExpanded(null)
+ }, [annotations])
+ const { setModal } = useRoutelessModalsContext()
+
+ return (
+
+ setExpanded((current) => (current === index ? null : index))
+ }
+ onEdit={(annotation) =>
+ setModal({ type: 'update-annotation', annotation })
+ }
+ onDelete={(annotation) =>
+ setModal({ type: 'delete-annotation', annotation })
+ }
+ onPin={onPin}
+ />
+ )
+}
+
+const AnnotationsList = ({
+ annotations,
+ expandedIndex,
+ onAnnotationClick,
+ onEdit,
+ onPin,
+ onDelete
+}: {
+ annotations: AnnotationWithPinState[]
+ onEdit?: (annotation: Annotation) => void
+ onPin?: (annotation: Annotation) => void
+ onDelete?: (annotation: Annotation) => void
+ expandedIndex: number | null
+ onAnnotationClick?: (index: number) => void
+}) => {
+ return (
+
+ {annotations.map((annotation, index) => {
+ const { id, note } = annotation
+ return (
+
+
+
+ {typeof onAnnotationClick === 'function' ? (
+
+ ) : (
+
{note}
+ )}
+ {expandedIndex === index && (
+
+ {typeof onEdit === 'function' && (
+
+ )}
+ {typeof onPin === 'function' &&
+ (annotation.isPinned ? (
+
+ ) : (
+
+ ))}
+ {typeof onDelete === 'function' && (
+
+ )}
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
+const AddAnnotationButton = ({
interval,
- period,
- shouldShowDate,
- shouldShowYear,
- maxX,
- x,
- y,
- datum,
- showZoomToPeriod,
- bucketIndex,
- totalBuckets,
- persistent,
- onClick
+ timelabel
}: {
+ interval: Interval
+ timelabel: string
+}) => {
+ const { setModal } = useRoutelessModalsContext()
+
+ return (
+
+ )
+}
+
+const isTouchEvent = (event: unknown) =>
+ event instanceof PointerEvent && event.pointerType === 'touch'
+
+const mainGraphTooltipClassName =
+ 'absolute bg-gray-800 dark:bg-gray-950 py-3 px-4 rounded-md shadow shadow-gray-200 dark:shadow-gray-850 w-max max-w-[300px]'
+
+type MainGraphTooltipProps = {
metric: Metric
getFormattedValue: (value: MetricValue) => string
interval: Interval
@@ -443,8 +785,29 @@ const MainGraphTooltip = ({
totalBuckets: number
maxX: number
persistent: boolean
- onClick?: () => void
-}) => {
+ children?: ReactNode
+ tooltipRef: RefObject
+ isTouchDevice: boolean
+}
+
+const MainGraphTooltip = ({
+ metric,
+ getFormattedValue,
+ interval,
+ period,
+ shouldShowDate,
+ shouldShowYear,
+ maxX,
+ x,
+ y,
+ datum,
+ bucketIndex,
+ totalBuckets,
+ persistent,
+ children,
+ tooltipRef,
+ isTouchDevice
+}: MainGraphTooltipProps) => {
const { dashboardState } = useDashboardStateContext()
const metricLabel = getMetricLabel(metric, {
hasConversionGoalFilter: hasConversionGoalFilter(dashboardState)
@@ -452,14 +815,17 @@ const MainGraphTooltip = ({
const { main, comparison, change } = datum
return (
-
- {!!showZoomToPeriod && (
- <>
-
- {!persistent && (
-
- {`Click to view ${interval}`}
-
- )}
- {persistent && (
-
- )}
- >
- )}
+ {children}
)
}
+const PinnedAnnotationsTooltip = ({
+ x,
+ annotations,
+ maxX,
+ onClick
+}: {
+ x: number
+ maxX: number
+ annotations: AnnotationWithPinState[]
+ onClick: () => void
+}) => {
+ const ref = useRef
(null)
+ return (
+
+ {
+ e.stopPropagation()
+ onClick()
+ }}
+ >
+
+
+
+ )
+}
+
export const MainGraphContainer = React.forwardRef<
HTMLDivElement,
{ children: ReactNode }
diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx
index 97fa477dfa9c..240c7df7cfb6 100644
--- a/assets/js/dashboard/stats/graph/visitor-graph.tsx
+++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx
@@ -13,6 +13,7 @@ import { getStaleTime } from '../../hooks/api-client'
import { MainGraph, MainGraphContainer, useMainGraphWidth } from './main-graph'
import { useGraphIntervalContext } from './graph-interval-context'
import { useSetImportsIncluded } from './imports-included-context'
+import { useGetAnnotations } from '../../annotations/routeless-annotations-modals'
// height of at least one row of top stats
const DEFAULT_TOP_STATS_LOADING_HEIGHT_PX = 85
@@ -29,6 +30,7 @@ export default function VisitorGraph({
const { dashboardState } = useDashboardStateContext()
const isRealtime = dashboardState.period === DashboardPeriod.realtime
const queryClient = useQueryClient()
+ const getAnnotationsQuery = useGetAnnotations()
const { selectedInterval } = useGraphIntervalContext()
@@ -239,7 +241,11 @@ export default function VisitorGraph({
{!!mainGraphQuery.data && !!width && (
<>
{!showGraphLoader && (
-
+
)}
{showGraphLoader && }
>
diff --git a/assets/js/dashboard/util/date.js b/assets/js/dashboard/util/date.js
index 474c862bbfb5..30bde2ea52e5 100644
--- a/assets/js/dashboard/util/date.js
+++ b/assets/js/dashboard/util/date.js
@@ -1,7 +1,17 @@
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
+import updateLocale from 'dayjs/plugin/updateLocale'
dayjs.extend(utc)
+dayjs.extend(updateLocale)
+// The locale 'en' is the default (and the only one currently),
+// but its start of week is Sunday.
+// This change that the week starts on Monday unifies behavior
+// between start of week according to the backend
+// and start of week according to the frontend.
+dayjs.updateLocale('en', {
+ weekStart: 1
+})
const browserDateFormat = Intl.DateTimeFormat(navigator.language, {
hour: 'numeric'
diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx
index 1e40a191d8c2..dcf18097633e 100644
--- a/assets/test-utils/app-context-providers.tsx
+++ b/assets/test-utils/app-context-providers.tsx
@@ -34,6 +34,7 @@ export const DEFAULT_SITE: PlausibleSite = {
explorationAvailable: false,
propsAvailable: false,
siteSegmentsAvailable: false,
+ siteAnnotationsAvailable: false,
conversionsOptedOut: false,
funnelsOptedOut: false,
propsOptedOut: false,
diff --git a/lib/plausible/annotations/annotation.ex b/lib/plausible/annotations/annotation.ex
new file mode 100644
index 000000000000..1ca1d61f61ca
--- /dev/null
+++ b/lib/plausible/annotations/annotation.ex
@@ -0,0 +1,86 @@
+defmodule Plausible.Annotations.Annotation do
+ @moduledoc """
+ Schema for annotations. Annotations are notes attached to a point on the graph.
+
+ `datetime` stores a UTC moment. The local date/time shown to the user is derived
+ by converting it to the site's configured timezone at display time. If the site's
+ timezone changes, the local representation recalculates automatically — the UTC
+ moment is the ground truth.
+
+ `granularity` controls how much precision is displayed:
+ - `:date` — show only the local date (whole-day annotation).
+ Callers should supply UTC midnight of the intended local date.
+ - `:minute` — show local date and HH:MM (specific-time annotation).
+ Callers supply the exact UTC moment (natural for deployment pipelines).
+ """
+
+ 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 :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 changeset(annotation, attrs) do
+ annotation
+ |> cast(maybe_coerce_datetime(attrs), [
+ :note,
+ :site_id,
+ :type,
+ :owner_id,
+ :datetime,
+ :granularity
+ ])
+ |> validate_required([:note, :site_id, :type, :owner_id, :datetime, :granularity])
+ |> validate_length(:note, count: :bytes, min: 1, max: 255)
+ |> foreign_key_constraint(:site_id)
+ |> foreign_key_constraint(:owner_id)
+ end
+
+ # When granularity is "date" and datetime is a 10-byte bare date string
+ # (e.g. "2026-01-04"), append "T00:00:00Z" so Ecto can cast it as utc_datetime.
+ # Date.from_iso8601/1 validates it is a real calendar date (rejects "2026-13-45").
+ # Full datetime strings and all other inputs pass through untouched.
+ defp maybe_coerce_datetime(%{"granularity" => "date", "datetime" => dt} = attrs)
+ when is_binary(dt) and byte_size(dt) == 10 do
+ case Date.from_iso8601(dt) do
+ {:ok, _date} -> Map.put(attrs, "datetime", dt <> "T00:00:00Z")
+ _ -> attrs
+ end
+ end
+
+ defp maybe_coerce_datetime(attrs), do: attrs
+end
+
+defimpl Jason.Encoder, for: Plausible.Annotations.Annotation do
+ def encode(%Plausible.Annotations.Annotation{} = 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(is_nil(annotation.owner_id), do: nil, else: annotation.owner.name),
+ inserted_at: annotation.inserted_at,
+ updated_at: annotation.updated_at
+ }
+ |> Jason.Encode.map(opts)
+ end
+end
diff --git a/lib/plausible/annotations/annotations.ex b/lib/plausible/annotations/annotations.ex
new file mode 100644
index 000000000000..0b090d76f9b0
--- /dev/null
+++ b/lib/plausible/annotations/annotations.ex
@@ -0,0 +1,366 @@
+defmodule Plausible.Annotations do
+ @moduledoc """
+ Module for accessing Annotations.
+ """
+ alias Plausible.Annotations.Annotation
+ alias Plausible.Repo
+ import Ecto.Query
+
+ @roles_with_personal_annotations [:billing, :viewer, :editor, :admin, :owner, :super_admin]
+ @roles_with_maybe_site_annotations [:editor, :admin, :owner, :super_admin]
+
+ @type error_not_enough_permissions() :: {:error, :not_enough_permissions}
+ @type error_annotation_not_found() :: {:error, :annotation_not_found}
+ @type error_annotation_limit_reached() :: {:error, :annotations_limit_reached}
+ @type error_invalid_annotation() :: {:error, {:invalid_annotation, Keyword.t()}}
+ @type unknown_error() :: {:error, any()}
+
+ @max_annotations 500
+
+ @spec get_all_for_site(Plausible.Site.t(), atom(), pos_integer()) ::
+ {:error, :not_enough_permissions} | {:ok, list(Annotation.t())}
+ def get_all_for_site(%Plausible.Site{} = site, site_role, user_id) do
+ fields = [:id, :note, :type, :datetime, :granularity, :inserted_at, :updated_at]
+
+ cond do
+ site_role in [:public] ->
+ annotations =
+ Repo.all(
+ from(annotation in Annotation,
+ select: ^fields,
+ where: annotation.site_id == ^site.id,
+ where: annotation.type == :site,
+ order_by: [desc: annotation.updated_at, desc: annotation.id]
+ )
+ )
+
+ {:ok, Enum.map(annotations, &localize_annotation(&1, site.timezone))}
+
+ site_role in roles_with_personal_annotations() or
+ site_role in roles_with_maybe_site_annotations() ->
+ fields = fields ++ [:owner_id]
+
+ annotations =
+ Repo.all(
+ from(annotation in Annotation,
+ select: ^fields,
+ where: annotation.site_id == ^site.id,
+ where:
+ annotation.type == :site or
+ (annotation.type == :personal and annotation.owner_id == ^user_id),
+ order_by: [desc: annotation.updated_at, desc: annotation.id],
+ preload: [:owner]
+ )
+ )
+
+ {:ok, Enum.map(annotations, &localize_annotation(&1, site.timezone))}
+
+ true ->
+ {:error, :not_enough_permissions}
+ end
+ end
+
+ @spec get_one(pos_integer(), Plausible.Site.t(), atom(), pos_integer() | nil) ::
+ {:ok, Annotation.t()}
+ | error_not_enough_permissions()
+ | error_annotation_not_found()
+ def get_one(user_id, site, site_role, annotation_id) do
+ if site_role in roles_with_personal_annotations() do
+ case do_get_one(user_id, site.id, annotation_id) do
+ %Annotation{} = annotation -> {:ok, annotation}
+ nil -> {:error, :annotation_not_found}
+ end
+ else
+ {:error, :not_enough_permissions}
+ end
+ end
+
+ @spec insert_one(pos_integer(), Plausible.Site.t(), atom(), map()) ::
+ {:ok, Annotation.t()}
+ | error_not_enough_permissions()
+ | error_invalid_annotation()
+ | error_annotation_limit_reached()
+ | unknown_error()
+
+ def insert_one(
+ user_id,
+ %Plausible.Site{} = site,
+ site_role,
+ %{} = params
+ ) do
+ params = maybe_coerce_naive_datetime(params, site.timezone)
+
+ with :ok <- can_insert_one?(site, site_role, params),
+ %{valid?: true} = changeset <-
+ Annotation.changeset(
+ %Annotation{},
+ Map.merge(params, %{"site_id" => site.id, "owner_id" => user_id})
+ ) do
+ {:ok,
+ changeset |> Repo.insert!() |> Repo.preload(:owner) |> localize_annotation(site.timezone)}
+ else
+ %{valid?: false, errors: errors} ->
+ {:error, {:invalid_annotation, errors}}
+
+ {:error, _type} = error ->
+ error
+ end
+ end
+
+ @spec update_one(pos_integer(), Plausible.Site.t(), atom(), pos_integer(), map()) ::
+ {:ok, Annotation.t()}
+ | error_not_enough_permissions()
+ | error_invalid_annotation()
+ | unknown_error()
+
+ def update_one(
+ user_id,
+ %Plausible.Site{} = site,
+ site_role,
+ annotation_id,
+ %{} = params
+ ) do
+ params = maybe_coerce_naive_datetime(params, site.timezone)
+
+ with {:ok, annotation} <- get_one(user_id, site, site_role, annotation_id),
+ :ok <- can_update_one?(site, site_role, params, annotation.type),
+ %{valid?: true} = changeset <-
+ Annotation.changeset(
+ annotation,
+ Map.merge(params, %{"owner_id" => user_id})
+ ) do
+ Repo.update!(changeset)
+
+ {:ok,
+ Repo.reload!(annotation) |> Repo.preload(:owner) |> localize_annotation(site.timezone)}
+ else
+ %{valid?: false, errors: errors} ->
+ {:error, {:invalid_annotation, errors}}
+
+ {:error, _type} = error ->
+ error
+ end
+ end
+
+ def after_user_removed_from_site(site, user) do
+ Repo.delete_all(
+ from(annotation in Annotation,
+ where: annotation.site_id == ^site.id,
+ where: annotation.owner_id == ^user.id,
+ where: annotation.type == :personal
+ )
+ )
+
+ Repo.update_all(
+ from(annotation in Annotation,
+ where: annotation.site_id == ^site.id,
+ where: annotation.owner_id == ^user.id,
+ where: annotation.type == :site,
+ update: [set: [owner_id: nil]]
+ ),
+ []
+ )
+ end
+
+ def after_user_removed_from_team(team, user) do
+ team_sites_q =
+ from(
+ site in Plausible.Site,
+ where: site.team_id == ^team.id,
+ where: parent_as(:annotation).site_id == site.id
+ )
+
+ Repo.delete_all(
+ from(annotation in Annotation,
+ as: :annotation,
+ where: annotation.owner_id == ^user.id,
+ where: annotation.type == :personal,
+ where: exists(team_sites_q)
+ )
+ )
+
+ Repo.update_all(
+ from(annotation in Annotation,
+ as: :annotation,
+ where: annotation.owner_id == ^user.id,
+ where: annotation.type == :site,
+ where: exists(team_sites_q),
+ update: [set: [owner_id: nil]]
+ ),
+ []
+ )
+ end
+
+ def user_removed(user) do
+ Repo.delete_all(
+ from(annotation in Annotation,
+ as: :annotation,
+ where: annotation.owner_id == ^user.id,
+ where: annotation.type == :personal
+ )
+ )
+
+ # Site annotations are set to owner=null via ON DELETE SET NULL
+ end
+
+ def delete_one(user_id, %Plausible.Site{} = site, site_role, annotation_id) do
+ with {:ok, annotation} <- get_one(user_id, site, site_role, annotation_id) do
+ cond do
+ annotation.type == :site and site_role in roles_with_maybe_site_annotations() ->
+ {:ok, do_delete_one(annotation) |> localize_annotation(site.timezone)}
+
+ annotation.type == :personal and site_role in roles_with_personal_annotations() ->
+ {:ok, do_delete_one(annotation) |> localize_annotation(site.timezone)}
+
+ true ->
+ {:error, :not_enough_permissions}
+ end
+ end
+ end
+
+ @spec do_get_one(pos_integer(), pos_integer(), pos_integer() | nil) ::
+ Annotation.t() | nil
+ defp do_get_one(user_id, site_id, annotation_id)
+
+ defp do_get_one(_user_id, _site_id, nil) do
+ nil
+ end
+
+ defp do_get_one(user_id, site_id, annotation_id) do
+ query =
+ from(annotation in Annotation,
+ where: annotation.site_id == ^site_id,
+ where: annotation.id == ^annotation_id,
+ where:
+ annotation.type == :site or
+ (annotation.type == :personal and annotation.owner_id == ^user_id),
+ preload: [:owner]
+ )
+
+ Repo.one(query)
+ end
+
+ defp do_delete_one(annotation) do
+ Repo.delete!(annotation)
+ annotation
+ end
+
+ defp can_update_one?(%Plausible.Site{} = site, site_role, params, existing_annotation_type) do
+ updating_to_site_annotation? = params["type"] == "site"
+
+ cond do
+ (existing_annotation_type == :site or
+ updating_to_site_annotation?) and site_role in roles_with_maybe_site_annotations() and
+ site_annotations_available?(site) ->
+ :ok
+
+ existing_annotation_type == :personal and not updating_to_site_annotation? and
+ site_role in roles_with_personal_annotations() ->
+ :ok
+
+ true ->
+ {:error, :not_enough_permissions}
+ end
+ end
+
+ defp can_insert_one?(%Plausible.Site{} = site, site_role, params) do
+ cond do
+ count_annotations(site.id) >= @max_annotations ->
+ {:error, :annotations_limit_reached}
+
+ params["type"] == "site" and site_role in roles_with_maybe_site_annotations() and
+ site_annotations_available?(site) ->
+ :ok
+
+ params["type"] == "personal" and
+ site_role in roles_with_personal_annotations() ->
+ :ok
+
+ true ->
+ {:error, :not_enough_permissions}
+ end
+ end
+
+ defp count_annotations(site_id) do
+ from(annotation in Annotation,
+ where: annotation.site_id == ^site_id
+ )
+ |> Repo.aggregate(:count, :id)
+ end
+
+ def roles_with_personal_annotations(), do: @roles_with_personal_annotations
+ def roles_with_maybe_site_annotations(), do: @roles_with_maybe_site_annotations
+
+ def site_annotations_available?(%Plausible.Site{} = site),
+ do: Plausible.Billing.Feature.SiteSegments.check_availability(site.team) == :ok
+
+ @doc """
+ iex> serialize_first_error([{"name", {"should be at most %{count} byte(s)", [count: 255]}}])
+ "name should be at most 255 byte(s)"
+ """
+ def serialize_first_error(errors) do
+ {field, {message, opts}} = List.first(errors)
+
+ formatted_message =
+ Enum.reduce(opts, message, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+
+ "#{field} #{formatted_message}"
+ end
+
+ # 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.
+ defp localize_annotation(%Annotation{granularity: :date} = annotation, _timezone) do
+ %{annotation | datetime: DateTime.to_date(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.
+ defp localize_annotation(%Annotation{granularity: :minute} = annotation, timezone) do
+ naive_local =
+ annotation.datetime
+ |> DateTime.shift_zone!(timezone)
+ |> DateTime.to_naive()
+
+ %{annotation | datetime: naive_local}
+ 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(%{"datetime" => dt} = params, timezone)
+ when is_binary(dt) do
+ case DateTime.from_iso8601(dt) do
+ {:ok, _, _} ->
+ params
+
+ {:error, _} ->
+ case NaiveDateTime.from_iso8601(dt) do
+ {:ok, naive_dt} ->
+ utc_dt =
+ case DateTime.from_naive(naive_dt, timezone) do
+ {:ok, local_dt} -> DateTime.shift_zone!(local_dt, "Etc/UTC")
+ {:ambiguous, first, _second} -> DateTime.shift_zone!(first, "Etc/UTC")
+ {:gap, _just_before, just_after} -> DateTime.shift_zone!(just_after, "Etc/UTC")
+ end
+
+ Map.put(params, "datetime", DateTime.to_iso8601(utc_dt))
+
+ _ ->
+ params
+ end
+ end
+ end
+
+ defp maybe_coerce_naive_datetime(params, _timezone), do: params
+end
diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex
index 26460c8af3c4..b3186422561f 100644
--- a/lib/plausible/teams/memberships.ex
+++ b/lib/plausible/teams/memberships.ex
@@ -167,6 +167,7 @@ defmodule Plausible.Teams.Memberships do
Repo.delete!(guest_membership)
prune_guests(guest_membership.team_membership.team)
Plausible.Segments.after_user_removed_from_site(site, user)
+ Plausible.Annotations.after_user_removed_from_site(site, user)
end)
send_site_member_removed_email(guest_membership)
diff --git a/lib/plausible/teams/memberships/leave.ex b/lib/plausible/teams/memberships/leave.ex
index c0b94fdb5183..18436fcf6ba8 100644
--- a/lib/plausible/teams/memberships/leave.ex
+++ b/lib/plausible/teams/memberships/leave.ex
@@ -22,6 +22,11 @@ defmodule Plausible.Teams.Memberships.Leave do
team_membership.team,
team_membership.user
)
+
+ Plausible.Annotations.after_user_removed_from_team(
+ team_membership.team,
+ team_membership.user
+ )
end)
if Keyword.get(opts, :send_email?, true) do
diff --git a/lib/plausible/teams/memberships/remove.ex b/lib/plausible/teams/memberships/remove.ex
index 28bf1eedbf8a..52261f365027 100644
--- a/lib/plausible/teams/memberships/remove.ex
+++ b/lib/plausible/teams/memberships/remove.ex
@@ -23,6 +23,11 @@ defmodule Plausible.Teams.Memberships.Remove do
team_membership.team,
team_membership.user
)
+
+ Plausible.Annotations.after_user_removed_from_team(
+ team_membership.team,
+ team_membership.user
+ )
end)
if Keyword.get(opts, :send_email?, true) do
diff --git a/lib/plausible_web/controllers/api/internal/annotations_controller.ex b/lib/plausible_web/controllers/api/internal/annotations_controller.ex
new file mode 100644
index 000000000000..cf2fcb539481
--- /dev/null
+++ b/lib/plausible_web/controllers/api/internal/annotations_controller.ex
@@ -0,0 +1,140 @@
+defmodule PlausibleWeb.Api.Internal.AnnotationsController do
+ @moduledoc """
+ Internal API controller for segments.
+ """
+ use Plausible
+ use PlausibleWeb, :controller
+ use PlausibleWeb.Plugs.ErrorHandler
+ alias PlausibleWeb.Api.Helpers, as: H
+ alias Plausible.Annotations
+
+ def index(
+ %Plug.Conn{
+ assigns:
+ %{
+ site: site,
+ site_role: site_role
+ } = assigns
+ } = conn,
+ %{} = _params
+ ) do
+ user_id =
+ case assigns[:current_user] do
+ %{id: id} -> id
+ nil -> nil
+ end
+
+ case Annotations.get_all_for_site(site, site_role, user_id) do
+ {:ok, result} -> json(conn, result)
+ {:error, :not_enough_permissions} -> json(conn, "Not enough permissions to get annotations")
+ end
+ end
+
+ def create(
+ %Plug.Conn{
+ assigns: %{
+ site: site,
+ current_user: %{id: user_id},
+ site_role: site_role
+ }
+ } = conn,
+ %{} = params
+ ) do
+ case Annotations.insert_one(user_id, site, site_role, params) do
+ {:error, :not_enough_permissions} ->
+ H.not_enough_permissions(conn, "Not enough permissions to create annotation")
+
+ {:error, :annotations_limit_reached} ->
+ H.not_enough_permissions(conn, "Annotations limit reached")
+
+ {:error, {:invalid_annotation, errors}} when is_list(errors) ->
+ conn
+ |> put_status(400)
+ |> json(%{
+ error: Annotations.serialize_first_error(errors)
+ })
+
+ {:ok, segment} ->
+ json(conn, segment)
+ end
+ end
+
+ def create(%Plug.Conn{} = conn, _params), do: invalid_request(conn)
+
+ def update(
+ %Plug.Conn{
+ assigns: %{
+ site: site,
+ current_user: %{id: user_id},
+ site_role: site_role
+ }
+ } =
+ conn,
+ %{} = params
+ ) do
+ annotation_id = normalize_annotation_id_param(params["annotation_id"])
+
+ case Annotations.update_one(user_id, site, site_role, annotation_id, params) do
+ {:error, :not_enough_permissions} ->
+ H.not_enough_permissions(conn, "Not enough permissions to edit segment")
+
+ {:error, :annotation_not_found} ->
+ annotation_not_found(conn, params["annotation_id"])
+
+ {:error, {:invalid_annotation, errors}} when is_list(errors) ->
+ conn
+ |> put_status(400)
+ |> json(%{
+ error: Annotations.serialize_first_error(errors)
+ })
+
+ {:ok, segment} ->
+ json(conn, segment)
+ end
+ end
+
+ def update(%Plug.Conn{} = conn, _params), do: invalid_request(conn)
+
+ def delete(
+ %Plug.Conn{
+ assigns: %{
+ site: site,
+ current_user: %{id: user_id},
+ site_role: site_role
+ }
+ } =
+ conn,
+ %{} = params
+ ) do
+ annotation_id = normalize_annotation_id_param(params["annotation_id"])
+
+ case Annotations.delete_one(user_id, site, site_role, annotation_id) do
+ {:error, :not_enough_permissions} ->
+ H.not_enough_permissions(conn, "Not enough permissions to delete segment")
+
+ {:error, :annotation_not_found} ->
+ annotation_not_found(conn, params["annotation_id"])
+
+ {:ok, segment} ->
+ json(conn, segment)
+ end
+ end
+
+ def delete(%Plug.Conn{} = conn, _params), do: invalid_request(conn)
+
+ @spec normalize_annotation_id_param(any()) :: nil | pos_integer()
+ defp normalize_annotation_id_param(input) do
+ case Integer.parse(input) do
+ {int_value, ""} when int_value > 0 -> int_value
+ _ -> nil
+ end
+ end
+
+ defp annotation_not_found(%Plug.Conn{} = conn, annotation_id_param) do
+ H.not_found(conn, "Annotation not found with ID #{inspect(annotation_id_param)}")
+ end
+
+ defp invalid_request(%Plug.Conn{} = conn) do
+ H.bad_request(conn, "Invalid request")
+ end
+end
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index dbf2882e8adb..be795181ecf5 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -322,6 +322,14 @@ defmodule PlausibleWeb.Router do
delete "/:segment_id", SegmentsController, :delete
get "/:segment_id/shared-links", SegmentsController, :get_related_shared_links
end
+
+ scope "/:domain/annotations", PlausibleWeb.Api.Internal,
+ private: %{allow_consolidated_views: true} do
+ get "/", AnnotationsController, :index
+ post "/", AnnotationsController, :create
+ patch "/:annotation_id", AnnotationsController, :update
+ delete "/:annotation_id", AnnotationsController, :delete
+ end
end
scope "/api/v1/stats", PlausibleWeb.Api,
diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex
index 92b97958f223..f9e80ac9909d 100644
--- a/lib/plausible_web/templates/stats/stats.html.heex
+++ b/lib/plausible_web/templates/stats/stats.html.heex
@@ -26,8 +26,9 @@
data-props-available={
to_string(Plausible.Billing.Feature.Props.check_availability(@site.team) == :ok)
}
- data-site-segments-available={
- to_string(Plausible.Billing.Feature.SiteSegments.check_availability(@site.team) == :ok)
+ data-site-segments-available={to_string(Plausible.Segments.site_segments_available?(@site))}
+ data-site-annotations-available={
+ to_string(Plausible.Annotations.site_annotations_available?(@site))
}
data-revenue-goals={Jason.encode!(@revenue_goals)}
data-funnels={Jason.encode!(@funnels)}
diff --git a/test/plausible/billing/feature_test.exs b/test/plausible/billing/feature_test.exs
index 82dfab0a4e42..c74e0e4cfa77 100644
--- a/test/plausible/billing/feature_test.exs
+++ b/test/plausible/billing/feature_test.exs
@@ -1,5 +1,4 @@
defmodule Plausible.Billing.FeatureTest do
- alias Plausible.Billing.Feature.SiteSegments
use Plausible.DataCase
alias Plausible.Billing.Feature.{
diff --git a/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs b/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs
new file mode 100644
index 000000000000..6ca6d30d27a7
--- /dev/null
+++ b/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs
@@ -0,0 +1,295 @@
+defmodule PlausibleWeb.Api.Internal.AnnotationsControllerTest do
+ use PlausibleWeb.ConnCase, async: true
+ use Plausible.Repo
+
+ describe "GET /api/:domain/annotations - index" do
+ setup [:create_user, :log_in]
+
+ test "public role sees only site annotations, not personal ones", %{conn: conn} do
+ public_site = new_site(public: true)
+ site_owner = new_user()
+ insert(:annotation, site: public_site, owner: site_owner, type: :site, note: "site note")
+ insert(:annotation, site: public_site, owner: site_owner, type: :personal, note: "private")
+
+ conn = get(conn, "/api/#{public_site.domain}/annotations")
+
+ assert [result] = json_response(conn, 200)
+ assert result["type"] == "site"
+ assert result["note"] == "site note"
+ end
+
+ test "public role response has null owner info", %{conn: conn} do
+ public_site = new_site(public: true)
+ site_owner = new_user()
+ insert(:annotation, site: public_site, owner: site_owner, type: :site, note: "deploy")
+
+ conn = get(conn, "/api/#{public_site.domain}/annotations")
+
+ assert [result] = json_response(conn, 200)
+ assert result["owner_id"] == nil
+ assert result["owner_name"] == nil
+ end
+
+ test "authenticated viewer sees their own personal annotations and all site annotations",
+ %{conn: conn, user: user} do
+ site = new_site()
+ other_user = new_user()
+ add_guest(site, user: user, role: :viewer)
+ insert(:annotation, site: site, owner: user, type: :personal, note: "mine")
+ insert(:annotation, site: site, owner: other_user, type: :personal, note: "not mine")
+ insert(:annotation, site: site, owner: other_user, type: :site, note: "shared")
+
+ conn = get(conn, "/api/#{site.domain}/annotations")
+
+ assert results = json_response(conn, 200)
+ assert length(results) == 2
+ notes = Enum.map(results, & &1["note"])
+ assert "mine" in notes
+ assert "shared" in notes
+ refute "not mine" in notes
+ end
+
+ test "authenticated owner response includes owner info", %{conn: conn, user: user} do
+ site = new_site(owner: user)
+ insert(:annotation, site: site, owner: user, type: :site, note: "deploy")
+
+ conn = get(conn, "/api/#{site.domain}/annotations")
+
+ assert [result] = json_response(conn, 200)
+ assert result["owner_id"] == user.id
+ assert result["owner_name"] == user.name
+ end
+
+ test "private site returns 404 for non-member", %{conn: conn} do
+ private_site = new_site()
+
+ conn = get(conn, "/api/#{private_site.domain}/annotations")
+
+ assert json_response(conn, 404)
+ end
+ end
+
+ describe "POST /api/:domain/annotations - datetime coercion" do
+ setup [:create_user, :log_in, :create_site]
+
+ test "accepts bare date string when granularity is date",
+ %{conn: conn, site: site} do
+ response =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "date",
+ "datetime" => "2026-01-04"
+ })
+ |> json_response(200)
+
+ assert_matches ^strict_map(%{
+ "id" => ^any(:pos_integer),
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "date",
+ "datetime" => "2026-01-04",
+ "owner_id" => ^any(:pos_integer),
+ "owner_name" => ^any(:string),
+ "inserted_at" => ^any(:iso8601_naive_datetime),
+ "updated_at" => ^any(:iso8601_naive_datetime)
+ }) = response
+ end
+
+ test "accepts full datetime string when granularity is date",
+ %{conn: conn, site: site} do
+ response =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "date",
+ "datetime" => "2026-01-04T00:00:00Z"
+ })
+ |> json_response(200)
+
+ assert response["datetime"] == "2026-01-04"
+ end
+
+ test "accepts full datetime string when granularity is minute",
+ %{conn: conn, site: site} do
+ # Site is Etc/UTC by default; UTC moment is returned as naive local time
+ response =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "minute",
+ "datetime" => "2026-01-04T14:32:00Z"
+ })
+ |> json_response(200)
+
+ assert response["datetime"] == "2026-01-04T14:32:00"
+ end
+
+ test "rejects bare date string when granularity is minute",
+ %{conn: conn, site: site} do
+ conn =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "minute",
+ "datetime" => "2026-01-04"
+ })
+
+ assert %{"error" => _} = json_response(conn, 400)
+ end
+
+ test "rejects invalid calendar date when granularity is date",
+ %{conn: conn, site: site} do
+ conn =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "date",
+ "datetime" => "2026-13-45"
+ })
+
+ assert %{"error" => _} = json_response(conn, 400)
+ end
+
+ test "rejects non-date string when granularity is date",
+ %{conn: conn, site: site} do
+ conn =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "date",
+ "datetime" => "not-a-date"
+ })
+
+ assert %{"error" => _} = json_response(conn, 400)
+ end
+ end
+
+ describe "POST /api/:domain/annotations - naive local time coercion" do
+ setup [:create_user, :log_in]
+
+ test "converts naive local time to UTC and returns it back as local time",
+ %{conn: conn, user: user} do
+ # America/New_York is UTC-5 in January; input and output should be the same
+ # local time (round-trip), while the DB stores the UTC equivalent.
+ site = new_site(owner: user, timezone: "America/New_York")
+
+ response =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "minute",
+ "datetime" => "2026-01-04T14:30:00"
+ })
+ |> json_response(200)
+
+ assert response["datetime"] == "2026-01-04T14:30:00"
+ end
+
+ test "naive datetime with date granularity returns date without timezone shift",
+ %{conn: conn, user: user} do
+ site = new_site(owner: user, timezone: "America/New_York")
+
+ response =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "date",
+ "datetime" => "2026-01-04T00:00:00"
+ })
+ |> json_response(200)
+
+ assert response["datetime"] == "2026-01-04"
+ end
+
+ test "UTC datetime string is stored as UTC and returned as site local time",
+ %{conn: conn, user: user} do
+ # America/New_York is UTC-5 in January, so 14:30 UTC = 09:30 local
+ site = new_site(owner: user, timezone: "America/New_York")
+
+ response =
+ post(conn, "/api/#{site.domain}/annotations", %{
+ "note" => "deploys",
+ "type" => "personal",
+ "granularity" => "minute",
+ "datetime" => "2026-01-04T14:30:00Z"
+ })
+ |> json_response(200)
+
+ assert response["datetime"] == "2026-01-04T09:30:00"
+ end
+ end
+
+ describe "PATCH /api/:domain/annotations/:annotation_id - naive local time coercion" do
+ setup [:create_user, :log_in]
+
+ test "converts naive local time to UTC and returns it back as local time",
+ %{conn: conn, user: user} do
+ # America/New_York is UTC-4 in June (DST), so input and output are the same
+ site = new_site(owner: user, timezone: "America/New_York")
+
+ annotation =
+ insert(:annotation,
+ site: site,
+ owner: user,
+ type: :personal,
+ granularity: :minute,
+ datetime: ~U[2026-01-01 00:00:00Z]
+ )
+
+ response =
+ patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{
+ "granularity" => "minute",
+ "datetime" => "2026-06-15T10:00:00"
+ })
+ |> json_response(200)
+
+ assert response["datetime"] == "2026-06-15T10:00:00"
+ end
+ end
+
+ describe "PATCH /api/:domain/annotations/:annotation_id - datetime coercion" do
+ setup [:create_user, :log_in, :create_site]
+
+ test "accepts bare date string when granularity is date",
+ %{conn: conn, site: site, user: user} do
+ annotation =
+ insert(:annotation,
+ site: site,
+ owner: user,
+ type: :personal,
+ granularity: :date,
+ datetime: ~U[2026-01-01 00:00:00Z]
+ )
+
+ response =
+ patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{
+ "granularity" => "date",
+ "datetime" => "2026-06-15"
+ })
+ |> json_response(200)
+
+ assert response["datetime"] == "2026-06-15"
+ end
+
+ test "rejects bare date string when granularity is minute",
+ %{conn: conn, site: site, user: user} do
+ annotation =
+ insert(:annotation,
+ site: site,
+ owner: user,
+ type: :personal,
+ granularity: :minute,
+ datetime: ~U[2026-01-01 10:00:00Z]
+ )
+
+ conn =
+ patch(conn, "/api/#{site.domain}/annotations/#{annotation.id}", %{
+ "granularity" => "minute",
+ "datetime" => "2026-06-15"
+ })
+
+ assert %{"error" => _} = json_response(conn, 400)
+ end
+ end
+end
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 5396ee4475d2..3ca8fbfe8d28 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -392,6 +392,15 @@ defmodule Plausible.Factory do
}
end
+ def annotation_factory do
+ %Plausible.Annotations.Annotation{
+ note: "a test annotation",
+ type: :personal,
+ datetime: ~U[2026-01-04 00:00:00Z],
+ granularity: :date
+ }
+ end
+
defp hash_key() do
Keyword.fetch!(
Application.get_env(:plausible, PlausibleWeb.Endpoint),