diff --git a/assets/js/dashboard/annotations/annotations-modals.tsx b/assets/js/dashboard/annotations/annotations-modals.tsx new file mode 100644 index 000000000000..90e02c240e1b --- /dev/null +++ b/assets/js/dashboard/annotations/annotations-modals.tsx @@ -0,0 +1,324 @@ +import React, { ReactNode, useState } from 'react' +import { + Annotation, + ANNOTATION_TYPE_LABELS, + AnnotationPayload, + AnnotationType +} from './annotations' +import { MutationStatus } from '@tanstack/react-query' +import { ApiError } from '../api' +import { ErrorPanel } from '../components/error-panel' +import { + ModalLayout, + ModalFooter, + SaveButton +} from '../components/modal-layout' +import { + LabeledTextInput, + TypeSelector, + TypeDisabledMessage, + getOptionDisabledMessage, + OptionDisabledMessageType +} from '../components/form-elements' +import { Button } from '../components/button' +import { Role, UserContextValue } from '../user-context' + +interface ApiRequestProps { + status: MutationStatus + error?: unknown + reset: () => void +} + +interface AnnotationModalProps { + user: UserContextValue + siteAnnotationsAvailable: boolean + onClose: () => void + notePlaceholder: string +} + +export const CreateAnnotationModal = ({ + onClose, + onSave, + user, + siteAnnotationsAvailable, + notePlaceholder, + initialDatetime, + initialGranularity, + initialType, + error, + reset, + status +}: AnnotationModalProps & + ApiRequestProps & { + initialDatetime: AnnotationPayload['datetime'] + initialGranularity: AnnotationPayload['granularity'] + initialType: AnnotationPayload['type'] + } & { + onSave: (input: AnnotationPayload) => void + }) => { + const defaultNote = '' + const [note, setNote] = useState(defaultNote) + const [type, setType] = useState(initialType) + + const granularity = initialGranularity + const datetime = initialDatetime + + const disabledMessage = + type === AnnotationType.site + ? getAnnotationTypeDisabledMessage({ siteAnnotationsAvailable, user }) + : null + + return ( + + + + + + { + const trimmedNote = note.trim() + const saveableNote = trimmedNote.length + ? trimmedNote + : notePlaceholder + + onSave({ + note: saveableNote, + type, + datetime, + granularity + }) + }} + /> + + {error !== null && ( + + )} + + ) +} + +const AnnotationTypeSelector = ({ + value, + onChange, + optionDisabledMessage +}: { + value: AnnotationType + onChange: (value: AnnotationType) => void + optionDisabledMessage: OptionDisabledMessageType | null +}) => ( + <> + + idPrefix="annotation-type" + value={value} + onChange={onChange} + options={[ + { + type: AnnotationType.personal, + name: ANNOTATION_TYPE_LABELS[AnnotationType.personal], + description: 'Visible only to you' + }, + { + type: AnnotationType.site, + name: ANNOTATION_TYPE_LABELS[AnnotationType.site], + description: 'Visible to others on the site' + } + ]} + /> + {optionDisabledMessage !== null && ( + + } + /> + )} + +) + +const AnnotationTypeDisabledMessage = ({ + messageType +}: { + messageType: OptionDisabledMessageType +}): Exclude => { + switch (messageType) { + case 'no-permissions': + return "You don't have enough permissions to change note to this type" + case 'upgrade-subscription-yourself': + return ( + <> + To use this note type,{' '} + + please upgrade your subscription + + + ) + case 'upgrade-subscription-reach-out': + return ( + <> + To use this note type, please reach out to a team owner to upgrade + their subscription. + + ) + } +} + +const canSelectSiteAnnotation = (user: UserContextValue) => + [Role.admin, Role.owner, Role.editor, 'super_admin'].includes(user.role) + +const getAnnotationTypeDisabledMessage = ({ + siteAnnotationsAvailable, + user +}: { + siteAnnotationsAvailable: boolean + user: UserContextValue +}): OptionDisabledMessageType | null => + getOptionDisabledMessage({ + optionAvailable: siteAnnotationsAvailable, + userHasOptionPermissions: canSelectSiteAnnotation(user), + userCanUpgradeSubscription: user.role === Role.owner + }) + +export const DeleteAnnotationModal = ({ + annotation, + onClose, + onSave, + status, + error, + reset +}: { + onClose: () => void + onSave: (input: Pick) => void + annotation: Annotation +} & ApiRequestProps) => { + const deleteDisabled = status === 'pending' + + return ( + + Delete {ANNOTATION_TYPE_LABELS[annotation.type].toLowerCase()} + {` "${annotation.note}"?`} + + } + onClose={onClose} + > + + + + + {error !== null && ( + + )} + + ) +} + +export const UpdateAnnotationModal = ({ + onClose, + onSave, + annotation, + siteAnnotationsAvailable, + user, + notePlaceholder, + status, + error, + reset +}: AnnotationModalProps & + ApiRequestProps & { + onSave: (input: Pick) => void + annotation: Annotation + }) => { + const [note, setNote] = useState(annotation.note) + const [type, setType] = useState(annotation.type) + + const disabledMessage = + type === AnnotationType.site + ? getAnnotationTypeDisabledMessage({ siteAnnotationsAvailable, user }) + : null + + return ( + + + + + + { + const trimmedNote = note.trim() + const saveableNote = trimmedNote.length + ? trimmedNote + : notePlaceholder + onSave({ id: annotation.id, note: saveableNote, type }) + }} + /> + + {error !== null && ( + + )} + + ) +} diff --git a/assets/js/dashboard/annotations/annotations.ts b/assets/js/dashboard/annotations/annotations.ts new file mode 100644 index 000000000000..f65ecef59dcf --- /dev/null +++ b/assets/js/dashboard/annotations/annotations.ts @@ -0,0 +1,132 @@ +import { Interval } from '../stats/graph/intervals' +import { parseUTCDate } from '../util/date' + +export enum AnnotationType { + personal = 'personal', + site = 'site' +} + +/** This type signifies that the owner can't be shown. */ +// type AnnotationOwnershipHidden = { owner_id: null; owner_name: null } + +/** This type signifies that the original owner has been removed from the site. */ +type AnnotationOwnershipDangling = { owner_id: null; owner_name: null } + +type AnnotationOwnership = + | AnnotationOwnershipDangling + | { owner_id: number; owner_name: string } + +export enum AnnotationGranularity { + date = 'date', + minute = 'minute' +} + +export type PinPosition = { x: number; selectedIndex: number } + +export type AnnotationWithPinState = Annotation & { + isPinned: boolean + pinPosition: PinPosition | null +} + +export type Annotation = { + datetime: string + granularity: AnnotationGranularity + type: AnnotationType + note: string + + id: number + /** datetime in site timezone, example 2025-02-26 10:00:00 */ + inserted_at: string + /** datetime in site timezone, example 2025-02-26 10:00:00 */ + updated_at: string +} & AnnotationOwnership + +export type AnnotationPayload = Pick< + Annotation, + 'note' | 'datetime' | 'granularity' | 'type' +> + +export const ANNOTATION_TYPE_LABELS = { + [AnnotationType.personal]: 'Personal note', + [AnnotationType.site]: 'Site-wide note' +} + +export const getAnnotationTimeLabel = ( + annotation: Pick, + interval: Interval +): string => { + const dateString = annotation.datetime.substring(0, 'YYYY-MM-DD'.length) + switch (annotation.granularity) { + case AnnotationGranularity.date: { + switch (interval) { + case Interval.month: + // floors to closest start of month for the date + return parseUTCDate(dateString).startOf('month').format('YYYY-MM-DD') + case Interval.week: + // floors to closest start of week for the date + return parseUTCDate(dateString).startOf('week').format('YYYY-MM-DD') + case Interval.day: + case Interval.hour: + case Interval.minute: + // floors to date + return dateString + } + break + } + case AnnotationGranularity.minute: { + switch (interval) { + case Interval.month: + // floors to closest start of month for the date + return parseUTCDate(dateString).startOf('month').format('YYYY-MM-DD') + case Interval.week: + // floors to closest start of week for the date + return parseUTCDate(dateString).startOf('week').format('YYYY-MM-DD') + case Interval.day: + // floors to date + return dateString + case Interval.hour: { + const [dateYYYYMMDD, timeHHMMSS] = annotation.datetime.split('T') + // floors time to hour + return `${dateYYYYMMDD} ${timeHHMMSS.substring(0, 'HH'.length)}:00:00` + } + case Interval.minute: + return annotation.datetime.split('T').join(' ') + } + } + } +} + +export const groupAnnotationsByTimeLabel = < + T extends Pick +>( + annotations: T[], + interval: Interval +): Record => { + return annotations.reduce>((acc, annotation) => { + const timeLabel = getAnnotationTimeLabel(annotation, interval) + return { ...acc, [timeLabel]: [...(acc[timeLabel] ?? []), annotation] } + }, {}) +} + +export const enrichAnnotationsWithPinState = ( + annotations: Annotation[], + pinnedAnnotationIds: Record +): AnnotationWithPinState[] => + annotations.map((annotation) => { + const pinPosition = pinnedAnnotationIds[annotation.id] ?? null + return { ...annotation, isPinned: pinPosition !== null, pinPosition } + }) + +export const getAnnotationGranularity = ( + interval: Interval +): AnnotationGranularity => { + switch (interval) { + case Interval.minute: + case Interval.hour: + return AnnotationGranularity.minute + case Interval.day: + case Interval.week: + case Interval.month: + return AnnotationGranularity.date + } +} diff --git a/assets/js/dashboard/annotations/routeless-annotations-modals.tsx b/assets/js/dashboard/annotations/routeless-annotations-modals.tsx new file mode 100644 index 000000000000..edf88ceb660e --- /dev/null +++ b/assets/js/dashboard/annotations/routeless-annotations-modals.tsx @@ -0,0 +1,159 @@ +import React from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useSiteContext } from '../site-context' +import { useUserContext } from '../user-context' +import { get, mutation } from '../api' +import { useRoutelessModalsContext } from '../navigation/routeless-modals-context' +import { + CreateAnnotationModal, + DeleteAnnotationModal, + UpdateAnnotationModal +} from './annotations-modals' +import { Annotation, AnnotationPayload } from './annotations' + +export const useGetAnnotations = () => { + const site = useSiteContext() + const annotationsIndexQuery = useQuery({ + queryKey: ['annotations'], + queryFn: async () => { + const response: Annotation[] = await get( + `/api/${encodeURIComponent(site.domain)}/annotations` + ) + return response + } + }) + return annotationsIndexQuery +} + +export type RoutelessAnnotationModal = + | { type: 'create-annotation'; annotation: AnnotationPayload } + | { type: 'update-annotation'; annotation: Annotation } + | { type: 'delete-annotation'; annotation: Annotation } + +export const RoutelessAnnotationModals = () => { + const queryClient = useQueryClient() + const site = useSiteContext() + const { modal, setModal } = useRoutelessModalsContext() + const user = useUserContext() + + const patchAnnotation = useMutation({ + mutationFn: async ({ + id, + note, + type + }: Pick & Partial>) => { + const response: Annotation = await mutation( + `/api/${encodeURIComponent(site.domain)}/annotations/${id}`, + { + method: 'PATCH', + body: { + note, + type + } + } + ) + + return response + }, + onSuccess: async () => { + queryClient.invalidateQueries({ queryKey: ['annotations'] }) + setModal(null) + } + }) + + const createAnnotation = useMutation({ + mutationFn: async (payload: AnnotationPayload) => { + const response: Annotation = await mutation( + `/api/${encodeURIComponent(site.domain)}/annotations`, + { + method: 'POST', + body: payload + } + ) + return response + }, + onSuccess: async () => { + queryClient.invalidateQueries({ queryKey: ['annotations'] }) + setModal(null) + } + }) + + const deleteAnnotation = useMutation({ + mutationFn: async (data: Pick) => { + const response: Annotation = await mutation( + `/api/${encodeURIComponent(site.domain)}/annotations/${data.id}`, + { + method: 'DELETE' + } + ) + return response + }, + onSuccess: (): void => { + queryClient.invalidateQueries({ queryKey: ['annotations'] }) + setModal(null) + } + }) + + if (!user.loggedIn) { + return null + } + + return ( + <> + {modal?.type === 'delete-annotation' && ( + { + setModal(null) + deleteAnnotation.reset() + }} + onSave={({ id }) => deleteAnnotation.mutate({ id })} + status={deleteAnnotation.status} + error={deleteAnnotation.error} + reset={deleteAnnotation.reset} + /> + )} + + {modal?.type === 'update-annotation' && ( + { + setModal(null) + patchAnnotation.reset() + }} + onSave={({ id, note, type }) => + patchAnnotation.mutate({ + id, + note, + type + }) + } + status={patchAnnotation.status} + error={patchAnnotation.error} + reset={patchAnnotation.reset} + /> + )} + {modal?.type === 'create-annotation' && ( + { + setModal(null) + createAnnotation.reset() + }} + onSave={(payload) => createAnnotation.mutate(payload)} + status={createAnnotation.status} + error={createAnnotation.error} + reset={createAnnotation.reset} + /> + )} + + ) +} diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx index 94524ac9dc13..792098dbfd1a 100644 --- a/assets/js/dashboard/components/graph-tooltip.tsx +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -1,19 +1,13 @@ -import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react' +import React, { ReactNode, RefObject, useLayoutEffect, useState } from 'react' import { Transition, TransitionClasses, TransitionEvents } from '@headlessui/react' -export const GraphTooltipWrapper = ({ - x, - y, - maxX, - minWidth, - children, - className, - transition -}: { +type GraphTooltipWrapperProps = { + verticalAnchor: 'topEdge' | 'bottomEdge' + horizontalAnchor: 'start' | 'middle' x: number y: number maxX: number @@ -21,21 +15,52 @@ export const GraphTooltipWrapper = ({ children: ReactNode className?: string transition?: TransitionClasses & TransitionEvents -}) => { - const ref = useRef(null) + wrapperRef: RefObject +} - const xOffset = 12 +export const GraphTooltipWrapper = ({ + verticalAnchor, + horizontalAnchor, + x, + y, + maxX, + minWidth, + children, + className, + transition, + wrapperRef +}: GraphTooltipWrapperProps) => { + const minX = 0 + const xOffsetFromStart = 12 const [measuredWidth, setMeasuredWidth] = useState(minWidth) const leftByAlignment = { - alignedRight: x + xOffset, - alignedLeft: x - xOffset - measuredWidth, - alignedRightClamped: Math.max(0, Math.min(x, maxX - measuredWidth)) - } + start: { + alignedRight: x + xOffsetFromStart, + alignedLeft: x - xOffsetFromStart - measuredWidth, + alignedRightClamped: Math.max(0, Math.min(x, maxX - measuredWidth)) + }, + middle: { + alignedRight: x - measuredWidth / 2, + alignedLeft: x - measuredWidth / 2, + alignedRightClamped: Math.max( + minX, + Math.min(x - measuredWidth / 2, maxX - measuredWidth) + ) + } + }[horizontalAnchor] + + const canFitRight = { + start: leftByAlignment.alignedRight + measuredWidth <= maxX, + middle: x - measuredWidth / 2 >= minX && x + measuredWidth / 2 <= maxX + }[horizontalAnchor] + + const canFitLeft = { + start: leftByAlignment.alignedLeft >= minX, + middle: false + }[horizontalAnchor] - const canFitRight = leftByAlignment.alignedRight + measuredWidth <= maxX - const canFitLeft = leftByAlignment.alignedLeft >= 0 const position = canFitRight ? 'alignedRight' : canFitLeft @@ -43,21 +68,29 @@ export const GraphTooltipWrapper = ({ : 'alignedRightClamped' useLayoutEffect(() => { - if (!ref.current) { + if (!wrapperRef?.current) { return } - setMeasuredWidth(ref.current.offsetWidth) - }, [children, className, minWidth]) + const el = wrapperRef.current + const w = el.getBoundingClientRect().width + setMeasuredWidth(w) + }, [x, maxX, minWidth, className, children, wrapperRef]) + + const extraStyleByVerticalAnchor = { + topEdge: {}, + bottomEdge: { transform: 'translateY(-100%)' } + } return (
{children} diff --git a/assets/js/dashboard/components/graph.tsx b/assets/js/dashboard/components/graph.tsx index 8a2d1ed00343..d72143a64fb1 100644 --- a/assets/js/dashboard/components/graph.tsx +++ b/assets/js/dashboard/components/graph.tsx @@ -8,6 +8,7 @@ import React, { import * as d3 from 'd3' import classNames from 'classnames' +const ANNOTATION_DIAMOND_SIZE_PX = 3 const IDEAL_Y_TICK_COUNT = 5 const MAX_X_TICK_COUNT = 8 const X_TICK_LENGTH_PX = 4 @@ -35,12 +36,14 @@ type GraphProps< /** initial guess for left margin, automatically enlarged to fit y tick texts */ defaultMarginLeft: number data: Datum[] + annotationsByIndex: { count: number; pinnedCount: number }[] yMax: number onPointerEnter: (event: unknown) => void onPointerMove: PointerHandler onPointerLeave: (event: unknown) => void onGotPointerCapture: (event: unknown) => void onClick?: PointerHandler + onContextMenu?: PointerHandler yFormat: (domainValue: d3.NumberValue, index: number) => string /** * Things are drawn in the order of settings, @@ -55,6 +58,7 @@ type GraphProps< }[] children?: ReactNode highlightedIndex?: number | null + verticalLinesByIndex: boolean[] } /** @@ -79,6 +83,8 @@ export function Graph({ } const highlightIndicatorGroupId = 'highlight-indicator' +const annotationsGroupId = 'annotations' +const verticalLinesGroupId = 'vertical-lines' function InnerGraph({ className, @@ -96,10 +102,13 @@ function InnerGraph({ onGotPointerCapture, onPointerEnter, onClick, + onContextMenu, yFormat, settings, gradients, - highlightedIndex + highlightedIndex, + annotationsByIndex, + verticalLinesByIndex }: GraphProps) { const [extraMarginLeft, setExtraMarginLeft] = useState(0) const [points, setPoints] = useState[]>([]) @@ -230,6 +239,8 @@ function InnerGraph({ }) } + svg.append('g').attr('id', verticalLinesGroupId) + // must be on top of gradients, but under lines and points svg.append('g').attr('id', highlightIndicatorGroupId) @@ -291,6 +302,8 @@ function InnerGraph({ } } + svg.append('g').attr('id', annotationsGroupId) + setPoints(points) // Unhide chart @@ -366,7 +379,13 @@ function InnerGraph({ } } } - }, [onPointerMove, isInHoverableArea, points]) + }, [ + onPointerMove, + isInHoverableArea, + points, + annotationsByIndex, + yBottomEdge + ]) useEffect(() => { const currentSvg = svgRef.current @@ -465,7 +484,51 @@ function InnerGraph({ svg.on('click', null) } } - }, [onClick, isInHoverableArea, points]) + }, [onClick, isInHoverableArea, points, annotationsByIndex, yBottomEdge]) + + useEffect(() => { + const currentSvg = svgRef.current + if (currentSvg && points.length) { + const svg = d3.select(currentSvg) + if (typeof onContextMenu !== 'function') { + svg.on('contextmenu', null) + } else { + svg.on('contextmenu', (event) => { + const { xPointer, yPointer } = getPosition(event) + const inHoverableArea = isInHoverableArea(xPointer, yPointer) + const closestIndexToPointer = inHoverableArea + ? getClosestIndexToPointer(xPointer, points) + : null + onContextMenu({ + inHoverableArea, + closestPoint: + closestIndexToPointer !== null + ? { + index: closestIndexToPointer, + x: points[closestIndexToPointer].x, + values: points[closestIndexToPointer].values + } + : null, + xPointer, + yPointer, + event + }) + }) + } + } + return () => { + if (currentSvg) { + const svg = d3.select(currentSvg) + svg.on('contextmenu', null) + } + } + }, [ + onContextMenu, + isInHoverableArea, + points, + annotationsByIndex, + yBottomEdge + ]) useEffect(() => { points.forEach(({ dots }, index) => @@ -503,6 +566,72 @@ function InnerGraph({ } }, [height, highlightedIndex, marginBottom, marginTop, points]) + useEffect(() => { + if (!svgRef.current) { + return + } + const svg = d3.select(svgRef.current) + const cleanup = () => { + svg.selectAll(`#${annotationsGroupId} g`).remove() + } + if (!points.length || points.length !== annotationsByIndex.length) { + return cleanup() + } + + const pointsWithAnnotations = points.map((point, index) => { + return { ...point, annotations: annotationsByIndex[index] } + }) + + for (const point of pointsWithAnnotations) { + if (point.annotations.count > 0) { + svg + .select(`#${annotationsGroupId}`) + .append('g') + .call((g) => + g + .append('polygon') + .attr( + 'points', + `${-ANNOTATION_DIAMOND_SIZE_PX},0 0,${ANNOTATION_DIAMOND_SIZE_PX} ${ANNOTATION_DIAMOND_SIZE_PX},0 0,${-ANNOTATION_DIAMOND_SIZE_PX}` + ) + .attr('transform', `translate(${point.x}, ${yBottomEdge})`) + .attr('class', annotationCircleClass) + ) + } + } + return cleanup + }, [points, annotationsByIndex, yBottomEdge]) + + useEffect(() => { + if (!svgRef.current) return + const svg = d3.select(svgRef.current) + const cleanup = () => { + svg.select(`#${verticalLinesGroupId}`).selectAll('line').remove() + } + if (!points.length || verticalLinesByIndex.length !== points.length) { + return cleanup() + } + + const pointsWithVerticalLines = points.map((point, index) => { + return { ...point, verticalLine: verticalLinesByIndex[index] } + }) + + for (const point of pointsWithVerticalLines) { + if (point.verticalLine) { + svg + .select(`#${verticalLinesGroupId}`) + .append('line') + .attr('x1', 0) + .attr('x2', 0) + .attr('y1', marginTop) + .attr('y2', height - marginBottom) + .attr('transform', `translate(${point.x}, 0)`) + .attr('class', verticalDashedLineClass) + } + } + return cleanup + }, [points, verticalLinesByIndex, marginTop, marginBottom, height]) + return ( ({ const currentlySelectedLineClass = 'stroke-1 stroke-gray-300 dark:stroke-gray-700' +const verticalDashedLineClass = + 'stroke-1 stroke-gray-300 dark:stroke-gray-700 [stroke-dasharray:2,2]' const yTickLineClass = 'stroke-gray-150 dark:stroke-gray-800/75 group-first:stroke-gray-300 dark:group-first:stroke-gray-700' const tickTextClass = 'fill-gray-500 dark:fill-gray-400 text-xs select-none' const xTickLineClass = 'stroke-gray-300 dark:stroke-gray-700' +const annotationCircleClass = 'fill-indigo-500 stroke-indigo-500' export const getXDomain = (bucketCount: number): [number, number] => { const xMin = 0 diff --git a/assets/js/dashboard/nav-menu/filters-bar.tsx b/assets/js/dashboard/nav-menu/filters-bar.tsx index 79bf8c5f0e08..7f090282f145 100644 --- a/assets/js/dashboard/nav-menu/filters-bar.tsx +++ b/assets/js/dashboard/nav-menu/filters-bar.tsx @@ -359,7 +359,7 @@ const SaveAsSegmentAction = ({ className }: { className?: string }) => { s} - onClick={() => setModal('create')} + onClick={() => setModal({ type: 'create-segment' })} state={{ expandedSegment: null }} > Save as segment diff --git a/assets/js/dashboard/nav-menu/segments/segment-menu.tsx b/assets/js/dashboard/nav-menu/segments/segment-menu.tsx index 9ceed0b91108..8d8404c4f768 100644 --- a/assets/js/dashboard/nav-menu/segments/segment-menu.tsx +++ b/assets/js/dashboard/nav-menu/segments/segment-menu.tsx @@ -68,7 +68,7 @@ export const SegmentMenu = () => { search={(s) => s} state={{ expandedSegment }} onClick={() => { - setModal('update') + setModal({ type: 'update-segment' }) }} > Update segment @@ -106,7 +106,7 @@ export const SegmentMenu = () => { state={{ expandedSegment }} onClick={() => { closeDropdown() - setModal('create') + setModal({ type: 'create-segment' }) }} >
@@ -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),