From 9878fb1af230e6a78b8f6061f3a01043e367ed63 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 6 May 2026 09:11:39 +0300 Subject: [PATCH 01/22] Refactor from pointsRef to points state --- assets/js/dashboard/components/graph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/components/graph.tsx b/assets/js/dashboard/components/graph.tsx index 8a2d1ed00343..d6a30f5a2af7 100644 --- a/assets/js/dashboard/components/graph.tsx +++ b/assets/js/dashboard/components/graph.tsx @@ -349,7 +349,7 @@ function InnerGraph({ ? { index: closestIndexToPointer, x: points[closestIndexToPointer].x, - values: points[closestIndexToPointer].values + values: points[closestIndexToPointer].values, } : null, xPointer, From 874b74de9f4ad5494cda416dcee1fdfd23c1e9f2 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 6 May 2026 09:42:01 +0300 Subject: [PATCH 02/22] Fix effect deps --- assets/js/dashboard/components/graph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/components/graph.tsx b/assets/js/dashboard/components/graph.tsx index d6a30f5a2af7..8a2d1ed00343 100644 --- a/assets/js/dashboard/components/graph.tsx +++ b/assets/js/dashboard/components/graph.tsx @@ -349,7 +349,7 @@ function InnerGraph({ ? { index: closestIndexToPointer, x: points[closestIndexToPointer].x, - values: points[closestIndexToPointer].values, + values: points[closestIndexToPointer].values } : null, xPointer, From e54e5c8900a37719c4d4b24b35016ff5f88fd536 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Wed, 6 May 2026 10:14:40 +0300 Subject: [PATCH 03/22] WIP --- .../annotations/annotations-modals.tsx | 316 +++++++++++++++ .../js/dashboard/annotations/annotations.ts | 42 ++ .../routeless-annotations-modals.tsx | 159 ++++++++ .../js/dashboard/components/action-modal.tsx | 157 ++++++++ assets/js/dashboard/nav-menu/filters-bar.tsx | 2 +- .../nav-menu/segments/segment-menu.tsx | 6 +- .../navigation/routeless-modals-context.tsx | 3 +- assets/js/dashboard/router.tsx | 2 + .../segments/routeless-segment-modals.tsx | 11 +- .../js/dashboard/stats/graph/main-graph.tsx | 374 ++++++++++++++++-- assets/js/dashboard/util/date.js | 10 + lib/plausible/annotations/annotation.ex | 86 ++++ lib/plausible/annotations/annotations.ex | 356 +++++++++++++++++ lib/plausible/billing/feature.ex | 8 + lib/plausible/teams/memberships.ex | 1 + lib/plausible/teams/memberships/leave.ex | 5 + lib/plausible/teams/memberships/remove.ex | 5 + .../api/internal/annotations_controller.ex | 133 +++++++ lib/plausible_web/router.ex | 8 + test/plausible/billing/feature_test.exs | 1 - .../annotations_controller_test.exs | 228 +++++++++++ test/support/factory.ex | 9 + 22 files changed, 1882 insertions(+), 40 deletions(-) create mode 100644 assets/js/dashboard/annotations/annotations-modals.tsx create mode 100644 assets/js/dashboard/annotations/annotations.ts create mode 100644 assets/js/dashboard/annotations/routeless-annotations-modals.tsx create mode 100644 assets/js/dashboard/components/action-modal.tsx create mode 100644 lib/plausible/annotations/annotation.ex create mode 100644 lib/plausible/annotations/annotations.ex create mode 100644 lib/plausible_web/controllers/api/internal/annotations_controller.ex create mode 100644 test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs diff --git a/assets/js/dashboard/annotations/annotations-modals.tsx b/assets/js/dashboard/annotations/annotations-modals.tsx new file mode 100644 index 000000000000..09af8ee3a5f1 --- /dev/null +++ b/assets/js/dashboard/annotations/annotations-modals.tsx @@ -0,0 +1,316 @@ +import React, { ReactNode, useCallback, 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 { + ActionModal, + ButtonsRow, + FormTitle, + LabeledTextInput, + primaryNegativeButtonClassName, + SaveButton, + secondaryButtonClassName, + TypeDisabledMessage, + TypeSelector +} from '../components/action-modal' +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, + 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 granularity = initialGranularity + const datetime = initialDatetime + const type = initialType + + return ( + + Add note for {datetime} + + + { + const trimmedNote = note.trim() + const saveableNote = trimmedNote.length + ? trimmedNote + : notePlaceholder + + onSave({ + note: saveableNote, + type, + datetime, + granularity + }) + }} + /> + + + {error !== null && ( + + )} + + ) +} + +const AnnotationTypeSelector = ({ + value, + onChange +}: { + value: AnnotationType + onChange: (value: AnnotationType) => void +}) => { + const 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' + } + ] + + return ( + + value={value} + onChange={onChange} + options={options} + /> + ) +} + +const useAnnotationTypeDisabledState = ({ + siteAnnotationsAvailable, + user, + setType +}: { + siteAnnotationsAvailable: boolean + user: UserContextValue + setType: (type: AnnotationType) => void +}) => { + const [disabled, setDisabled] = useState(false) + const [disabledMessage, setDisabledMessage] = useState(null) + + const userIsOwner = user.role === Role.owner + const canSelectSiteAnnotation = [ + Role.admin, + Role.owner, + Role.editor, + 'super_admin' + ].includes(user.role) + + const onAnnotationTypeChange = useCallback( + (type: AnnotationType) => { + setType(type) + + if (type === AnnotationType.site && !canSelectSiteAnnotation) { + setDisabled(true) + setDisabledMessage( + <> + {"You don't have enough permissions to change segment to this type"} + + ) + } else if (type === AnnotationType.site && !siteAnnotationsAvailable) { + setDisabled(true) + setDisabledMessage( + <> + To use this annotation type, + {userIsOwner ? ( + + please upgrade your subscription + + ) : ( + <> + please reach out to a team owner to upgrade their subscription. + + )} + + ) + } else { + setDisabled(false) + setDisabledMessage(null) + } + }, + [setType, siteAnnotationsAvailable, userIsOwner, canSelectSiteAnnotation] + ) + + return { + disabled, + disabledMessage, + onAnnotationTypeChange + } +} + +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}"?`} + + + + + + {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 { disabled, disabledMessage, onAnnotationTypeChange } = + useAnnotationTypeDisabledState({ + siteAnnotationsAvailable, + user, + setType + }) + + return ( + + Update note + + + {disabled && } + + { + 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..e164842cd98a --- /dev/null +++ b/assets/js/dashboard/annotations/annotations.ts @@ -0,0 +1,42 @@ +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 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' +} 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..53efdc059e0c --- /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/action-modal.tsx b/assets/js/dashboard/components/action-modal.tsx new file mode 100644 index 000000000000..625024129d06 --- /dev/null +++ b/assets/js/dashboard/components/action-modal.tsx @@ -0,0 +1,157 @@ +import React, { ReactNode } from 'react' +import ModalWithRouting from '../stats/modals/modal' +import classNames from 'classnames' +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' + +export const primaryNeutralButtonClassName = 'button !px-3' + +export const secondaryButtonClassName = classNames( + 'button !px-3.5', + 'border !border-gray-300 dark:!border-gray-700 !bg-white dark:!bg-gray-700 !text-gray-800 dark:!text-gray-100 hover:!text-gray-900 hover:!shadow-sm dark:hover:!bg-gray-600 dark:hover:!text-white' +) + +export const primaryNegativeButtonClassName = classNames( + 'button !px-3.5', + 'items-center !bg-red-500 dark:!bg-red-500 hover:!bg-red-600 dark:hover:!bg-red-700 whitespace-nowrap', + 'disabled:!bg-red-400 disabled:cursor-not-allowed' +) + +export const ActionModal = ({ + children, + onClose +}: { + children: ReactNode + onClose: () => void +}) => ( + +
{children}
+
+) + +export const FormTitle = ({ + className, + children +}: { + className?: string + children?: ReactNode +}) => ( +

+ {children} +

+) + +export const ButtonsRow = ({ + className, + children +}: { + className?: string + children?: ReactNode +}) => ( +
+ {children} +
+) + +export const SaveButton = ({ + disabled, + onSave +}: { + disabled: boolean + onSave: () => void +}) => ( + +) + +export const TypeDisabledMessage = ({ + message +}: { + message: ReactNode | null +}) => { + if (!message) return null + + return ( +
+ +
{message}
+
+ ) +} + +export const LabeledTextInput = ({ + label, + id, + value, + onChange, + placeholder +}: { + label: string + id: string + value: string + onChange: (value: string) => void + placeholder: string +}) => ( + <> + + onChange(e.target.value)} + placeholder={placeholder} + id={id} + className="block px-3.5 py-2.5 w-full text-sm dark:text-gray-300 rounded-md border border-gray-300 dark:border-gray-750 dark:bg-gray-750 focus:outline-none focus:ring-3 focus:ring-indigo-500/20 dark:focus:ring-indigo-500/25 focus:border-indigo-500" + /> + +) + +export const TypeSelector = ({ + value, + onChange, + options +}: { + value: T + onChange: (value: T) => void + options: { type: T; name: string; description: string }[] +}) => ( +
+ {options.map(({ type, name, description }) => ( +
+
+ onChange(type)} + className="mt-px size-4.5 cursor-pointer text-indigo-600 dark:bg-transparent border-gray-400 dark:border-gray-600 checked:border-indigo-600 dark:checked:border-white" + /> + +
+
+ ))} +
+) 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..07ef07aea281 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/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index 4c48f1c23bdd..d197897ea4b6 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -16,7 +16,8 @@ import { is12HourClock, parseNaiveDate, formatDay, - isThisYear + isThisYear, + parseUTCDate } from '../../util/date' import classNames from 'classnames' import { ChangeArrow } from '../reports/change-arrow' @@ -41,6 +42,13 @@ 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 { useGetAnnotations } from '../../annotations/routeless-annotations-modals' +import { + Annotation, + AnnotationGranularity, + AnnotationType +} from '../../annotations/annotations' const height = 368 const marginTop = 16 @@ -64,17 +72,85 @@ type MainGraphYValues = Readonly< ] > +type AnnotationMenuState = { + x: number + y: number + selectedIndex: number | null +} + type TooltipState = { x: number selectedIndex: number | null persistent: boolean } + +const initialAnnotationMenuState: AnnotationMenuState = { + x: 0, + y: 0, + selectedIndex: null +} + const initialTooltipState: TooltipState = { x: 0, selectedIndex: null, persistent: false } +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(' ') + } + } + } +} + +const groupAnnotationsByTimeLabel = ( + annotations: Annotation[], + interval: Interval +): Record => { + return annotations.reduce>((acc, annotation) => { + const timeLabel = getAnnotationTimeLabel(annotation, interval) + return { ...acc, [timeLabel]: [...(acc[timeLabel] ?? []), annotation] } + }, {}) +} + export const MainGraph = ({ width, data @@ -83,10 +159,17 @@ export const MainGraph = ({ data: MainGraphData }) => { const site = useSiteContext() + const { setModal } = useRoutelessModalsContext() + const getAnnotationsQuery = useGetAnnotations() const { mode } = useTheme() const navigate = useAppNavigate() const { primaryGradient, secondaryGradient } = paletteByTheme[mode] const [isTouchDevice, setIsTouchDevice] = useState(null) + const [annotationMenu, setAnnotationMenu] = useState( + initialAnnotationMenuState + ) + const isAnnotating = annotationMenu.selectedIndex !== null + const [tooltip, setTooltip] = useState(initialTooltipState) const { selectedIndex } = tooltip const panGestureStartTimeRef = useRef(null) @@ -108,6 +191,15 @@ export const MainGraph = ({ document.addEventListener('pointercancel', onPointerCancel) return () => document.removeEventListener('pointercancel', onPointerCancel) }, []) + + const annotationsByTimeLabel = useMemo( + () => groupAnnotationsByTimeLabel(getAnnotationsQuery.data ?? [], interval), + [getAnnotationsQuery.data, interval] + ) + + useEffect(() => { + setAnnotationMenu(initialAnnotationMenuState) + }, [annotationsByTimeLabel]) const { remappedData, @@ -246,6 +338,17 @@ export const MainGraph = ({ } }, [site, data, interval, period, primaryGradient, secondaryGradient, metric]) + const annotationsCountByIndex = useMemo( + () => + remappedData.map((datum) => { + const annotationsOnDatum = datum.main.isDefined + ? (annotationsByTimeLabel[datum.main.timeLabel] ?? []) + : [] + return annotationsOnDatum.length + }), + [remappedData, annotationsByTimeLabel] + ) + const getFormattedValue = useCallback( (value: MetricValue) => MetricFormatterShort[metric](value), [metric] @@ -292,13 +395,13 @@ export const MainGraph = ({ ) 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) } }, []) @@ -317,12 +420,21 @@ export const MainGraph = ({ mainPeriodLengthInMonths ) const selectedDatum = selectedIndex !== null && remappedData[selectedIndex] + const annotationMenuDatum = + annotationMenu.selectedIndex !== null + ? remappedData[annotationMenu.selectedIndex] + : null const zoomDate = showZoomToPeriod && selectedDatum && selectedDatum.main.isDefined ? selectedDatum.main.timeLabel : null + const annotationDatetime = + selectedDatum && selectedDatum.main.isDefined + ? selectedDatum.main.timeLabel + : null + const zoomToPeriod = useCallback( (date: string) => { setTooltip(initialTooltipState) @@ -340,8 +452,37 @@ export const MainGraph = ({ [navigate, interval] ) - const onClick = useCallback>( - ({ inHoverableArea, closestPoint }) => { + const openAnnotationModal = useCallback( + (datetime: string) => { + setModal({ + type: 'create-annotation', + annotation: { + note: `Note on ${datetime}`, + type: AnnotationType.personal, + datetime: datetime, + granularity: getGranularity(interval) + } + }) + }, + [setModal, interval] + ) + + const onChartClick = useCallback>( + ({ inHoverableArea, closestPoint, annotationIndex, event }) => { + if (isAnnotating && annotationIndex === null) { + return setAnnotationMenu(initialAnnotationMenuState) + } + if (annotationIndex !== null && closestPoint) { + return setAnnotationMenu((current) => + current.selectedIndex === annotationIndex + ? initialAnnotationMenuState + : { + selectedIndex: annotationIndex, + x: closestPoint.x, + y: height - marginBottom + } + ) + } if (isTouchDevice) { if (inHoverableArea && closestPoint) { return setTooltip({ @@ -352,11 +493,29 @@ export const MainGraph = ({ } return setTooltip(initialTooltipState) } - if (typeof zoomDate === 'string') { + // if (closestPoint && isBottomClick({ height, yPointer, marginBottom })) { + // setAnnotationModal({ + // x: closestPoint.x, + // y: height - marginBottom, + // selectedIndex: closestPoint.index + // }) + // } + const isAltClick = event instanceof PointerEvent && event.altKey + if (annotationDatetime && isAltClick) { + return openAnnotationModal(annotationDatetime) + } + if (typeof zoomDate === 'string' && !isAltClick) { return zoomToPeriod(zoomDate) } }, - [zoomDate, zoomToPeriod, isTouchDevice] + [ + isAnnotating, + isTouchDevice, + zoomDate, + annotationDatetime, + zoomToPeriod, + openAnnotationModal + ] ) return ( @@ -380,11 +539,12 @@ export const MainGraph = ({ onGotPointerCapture={onGotPointerCapture} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} - onClick={onClick} + onClick={onChartClick} yFormat={yFormat} gradients={gradients} + annotationsCountByIndex={annotationsCountByIndex} > - {!!selectedDatum && isTouchDevice !== null && ( + {!!selectedDatum && isTouchDevice !== null && !isAnnotating && ( zoomToPeriod(zoomDate) - : undefined + > + {tooltip.persistent && ( + <> + {!!zoomDate && ( + + )} + {selectedDatum.main.isDefined && ( + + )} + + )} + {!tooltip.persistent && ( + <> + {!!zoomDate && ( +
+ {`Click to view ${interval}`} +
+ )} + {!!annotationDatetime && ( +
+ Alt + click to add note +
+ )} + + )} +
+ )} + {annotationMenu.selectedIndex !== null && ( + )} @@ -412,6 +611,121 @@ export const MainGraph = ({ ) } +const AnnotationMenu = ({ + x, + y, + maxX, + annotations +}: { + x: number + y: number + maxX: number + annotations: Annotation[] +}) => { + const [expanded, setExpanded] = useState(null) + const { setModal } = useRoutelessModalsContext() + + return ( + + + + ) +} + +const AddAnnotationButton = ({ + interval, + main +}: { + interval: Interval + main: GraphDatum['main'] & { isDefined: true } +}) => { + const { setModal } = useRoutelessModalsContext() + + return ( + + ) +} + +const isTouchEvent = (event: unknown) => + event instanceof PointerEvent && event.pointerType === 'touch' + +// const isBottomClick = ({ +// height, +// yPointer, +// marginBottom +// }: { +// height: number +// marginBottom: number +// yPointer: number +// }) => { +// if (height - yPointer <= marginBottom + 16) { +// return true +// } +// return false +// } + const MainGraphTooltip = ({ metric, getFormattedValue, @@ -423,11 +737,10 @@ const MainGraphTooltip = ({ x, y, datum, - showZoomToPeriod, bucketIndex, totalBuckets, persistent, - onClick + children }: { metric: Metric getFormattedValue: (value: MetricValue) => string @@ -443,7 +756,7 @@ const MainGraphTooltip = ({ totalBuckets: number maxX: number persistent: boolean - onClick?: () => void + children?: ReactNode }) => { const { dashboardState } = useDashboardStateContext() const metricLabel = getMetricLabel(metric, { @@ -458,7 +771,7 @@ const MainGraphTooltip = ({ maxX={maxX} className={classNames( 'absolute select-none bg-gray-800 dark:bg-gray-950 py-3 px-4 rounded-md shadow shadow-gray-200 dark:shadow-gray-850', - typeof onClick !== 'function' && 'pointer-events-none' + !persistent && 'pointer-events-none' )} >
)}
- - {!!showZoomToPeriod && ( + {!!children && ( <>
- {!persistent && ( - - {`Click to view ${interval}`} - - )} - {persistent && ( - - )} + {children} )} @@ -542,6 +844,18 @@ const MainGraphTooltip = ({ ) } +const getGranularity = (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 + } +} + export const MainGraphContainer = React.forwardRef< HTMLDivElement, { children: ReactNode } diff --git a/assets/js/dashboard/util/date.js b/assets/js/dashboard/util/date.js index 474c862bbfb5..6bd8cbe995d3 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/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..5d03e56243c8 --- /dev/null +++ b/lib/plausible/annotations/annotations.ex @@ -0,0 +1,356 @@ +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 + + def get_all_for_site(%Plausible.Site{} = site, site_role) 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, + 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, + 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.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.SiteAnnotations.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/billing/feature.ex b/lib/plausible/billing/feature.ex index 69554d638e6f..4cc657183e0b 100644 --- a/lib/plausible/billing/feature.ex +++ b/lib/plausible/billing/feature.ex @@ -69,6 +69,7 @@ defmodule Plausible.Billing.Feature do Plausible.Billing.Feature.Goals, Plausible.Billing.Feature.RevenueGoals, Plausible.Billing.Feature.SiteSegments, + Plausible.Billing.Feature.SiteAnnotations, Plausible.Billing.Feature.SitesAPI, Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SSO, @@ -203,6 +204,13 @@ defmodule Plausible.Billing.Feature.SiteSegments do display_name: "Shared Segments" end +defmodule Plausible.Billing.Feature.SiteAnnotations do + @moduledoc false + use Plausible.Billing.Feature, + name: :site_annotations, + display_name: "Shared Annotations" +end + defmodule Plausible.Billing.Feature.StatsAPI do use Plausible 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..ed422013c052 --- /dev/null +++ b/lib/plausible_web/controllers/api/internal/annotations_controller.ex @@ -0,0 +1,133 @@ +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 + } + } = conn, + %{} = _params + ) do + case Annotations.get_all_for_site(site, site_role) 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/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..1ec3a2625bcc --- /dev/null +++ b/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs @@ -0,0 +1,228 @@ +defmodule PlausibleWeb.Api.Internal.AnnotationsControllerTest do + use PlausibleWeb.ConnCase, async: true + use Plausible.Repo + + 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), From 18aa7484e7bd989894636859410899a080f2c0c7 Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Mon, 11 May 2026 08:41:42 +0300 Subject: [PATCH 04/22] WIP: 3 different tooltips --- .../annotations/annotations-modals.tsx | 21 +- .../js/dashboard/components/graph-tooltip.tsx | 10 +- assets/js/dashboard/components/graph.tsx | 105 +++++++- .../js/dashboard/stats/graph/main-graph.tsx | 229 +++++++++++------- 4 files changed, 270 insertions(+), 95 deletions(-) diff --git a/assets/js/dashboard/annotations/annotations-modals.tsx b/assets/js/dashboard/annotations/annotations-modals.tsx index 09af8ee3a5f1..680fc88ca22b 100644 --- a/assets/js/dashboard/annotations/annotations-modals.tsx +++ b/assets/js/dashboard/annotations/annotations-modals.tsx @@ -37,6 +37,8 @@ interface AnnotationModalProps { export const CreateAnnotationModal = ({ onClose, onSave, + user, + siteAnnotationsAvailable, notePlaceholder, initialDatetime, initialGranularity, @@ -54,9 +56,16 @@ export const CreateAnnotationModal = ({ }) => { const defaultNote = '' const [note, setNote] = useState(defaultNote) + const [type, setType] = useState(initialType) + const { disabled, disabledMessage, onAnnotationTypeChange } = + useAnnotationTypeDisabledState({ + siteAnnotationsAvailable, + user, + setType + }) + const granularity = initialGranularity const datetime = initialDatetime - const type = initialType return ( @@ -68,9 +77,11 @@ export const CreateAnnotationModal = ({ onChange={setNote} placeholder={notePlaceholder} /> + + {disabled && } { const trimmedNote = note.trim() const saveableNote = trimmedNote.length @@ -161,14 +172,14 @@ const useAnnotationTypeDisabledState = ({ setDisabled(true) setDisabledMessage( <> - {"You don't have enough permissions to change segment to this type"} + {"You don't have enough permissions to change note to this type"} ) } else if (type === AnnotationType.site && !siteAnnotationsAvailable) { setDisabled(true) setDisabledMessage( <> - To use this annotation type, + To use this note type, {userIsOwner ? ( please upgrade your subscription @@ -306,7 +317,7 @@ export const UpdateAnnotationModal = ({ errorMessage={ error instanceof ApiError ? error.message - : 'Something went wrong updating segment' + : 'Something went wrong updating note' } onClose={reset} /> diff --git a/assets/js/dashboard/components/graph-tooltip.tsx b/assets/js/dashboard/components/graph-tooltip.tsx index 94524ac9dc13..f496ec1cee44 100644 --- a/assets/js/dashboard/components/graph-tooltip.tsx +++ b/assets/js/dashboard/components/graph-tooltip.tsx @@ -6,6 +6,7 @@ import { } from '@headlessui/react' export const GraphTooltipWrapper = ({ + anchor, x, y, maxX, @@ -14,6 +15,7 @@ export const GraphTooltipWrapper = ({ className, transition }: { + anchor: 'topEdge' | 'bottomEdge' x: number y: number maxX: number @@ -49,6 +51,11 @@ export const GraphTooltipWrapper = ({ setMeasuredWidth(ref.current.offsetWidth) }, [children, className, minWidth]) + const extraStyleByAnchor = { + 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..c76a5bbc1e39 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_CIRCLE_RADIUS_PX = 6 const IDEAL_Y_TICK_COUNT = 5 const MAX_X_TICK_COUNT = 8 const X_TICK_LENGTH_PX = 4 @@ -35,6 +36,7 @@ type GraphProps< /** initial guess for left margin, automatically enlarged to fit y tick texts */ defaultMarginLeft: number data: Datum[] + annotationsCountByIndex: number[] yMax: number onPointerEnter: (event: unknown) => void onPointerMove: PointerHandler @@ -79,6 +81,7 @@ export function Graph({ } const highlightIndicatorGroupId = 'highlight-indicator' +const annotationsGroupId = 'annotations' function InnerGraph({ className, @@ -99,7 +102,8 @@ function InnerGraph({ yFormat, settings, gradients, - highlightedIndex + highlightedIndex, + annotationsCountByIndex }: GraphProps) { const [extraMarginLeft, setExtraMarginLeft] = useState(0) const [points, setPoints] = useState[]>([]) @@ -291,6 +295,8 @@ function InnerGraph({ } } + svg.append('g').attr('id', annotationsGroupId) + setPoints(points) // Unhide chart @@ -342,6 +348,18 @@ function InnerGraph({ const closestIndexToPointer = inHoverableArea ? getClosestIndexToPointer(xPointer, points) : null + const onAnnotationsBubble = + closestIndexToPointer !== null && + annotationsCountByIndex[closestIndexToPointer] > 0 + ? isOverAnnotationBubble({ + bubble: { + x: points[closestIndexToPointer].x, + y: yBottomEdge, + radius: ANNOTATION_CIRCLE_RADIUS_PX + }, + pointer: { x: xPointer, y: yPointer } + }) + : false onPointerMove({ inHoverableArea, closestPoint: @@ -352,6 +370,7 @@ function InnerGraph({ values: points[closestIndexToPointer].values } : null, + onAnnotationsBubble, xPointer, yPointer, event @@ -366,7 +385,13 @@ function InnerGraph({ } } } - }, [onPointerMove, isInHoverableArea, points]) + }, [ + onPointerMove, + isInHoverableArea, + points, + annotationsCountByIndex, + yBottomEdge + ]) useEffect(() => { const currentSvg = svgRef.current @@ -442,6 +467,18 @@ function InnerGraph({ const closestIndexToPointer = inHoverableArea ? getClosestIndexToPointer(xPointer, points) : null + const onAnnotationsBubble = + closestIndexToPointer !== null && + annotationsCountByIndex[closestIndexToPointer] > 0 + ? isOverAnnotationBubble({ + bubble: { + x: points[closestIndexToPointer].x, + y: yBottomEdge, + radius: ANNOTATION_CIRCLE_RADIUS_PX + }, + pointer: { x: xPointer, y: yPointer } + }) + : false onClick({ inHoverableArea, closestPoint: @@ -452,6 +489,7 @@ function InnerGraph({ values: points[closestIndexToPointer].values } : null, + onAnnotationsBubble, xPointer, yPointer, event @@ -465,7 +503,7 @@ function InnerGraph({ svg.on('click', null) } } - }, [onClick, isInHoverableArea, points]) + }, [onClick, isInHoverableArea, points, annotationsCountByIndex, yBottomEdge]) useEffect(() => { points.forEach(({ dots }, index) => @@ -503,6 +541,48 @@ 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) { + return cleanup() + } + const pointsWithAnnotations = points.map((point, index) => { + return { ...point, annotationCount: annotationsCountByIndex[index] } + }) + for (const point of pointsWithAnnotations) { + if (point.annotationCount > 0) { + svg + .select(`#${annotationsGroupId}`) + .append('g') + .call((g) => + g + .append('circle') + .attr('cx', point.x) + .attr('cy', yBottomEdge) + .attr('r', ANNOTATION_CIRCLE_RADIUS_PX) + .attr('class', annotationCircleClass) + ) + .call((g) => + g + .append('text') + .text(point.annotationCount) + .attr('dx', point.x) + .attr('dy', yBottomEdge) + .attr('text-anchor', 'middle') + .attr('alignment-baseline', 'middle') + .attr('class', annotationCountTextClass) + ) + } + } + return cleanup + }, [points, annotationsCountByIndex, yBottomEdge]) + return ( { + const vectorLength = + ((pointer.y - bubble.y) ** 2 + (pointer.x - bubble.x) ** 2) ** (1 / 2) + + const withinCircle = vectorLength <= bubble.radius + + return withinCircle +} export const getXDomain = (bucketCount: number): [number, number] => { const xMin = 0 @@ -890,6 +988,7 @@ export type PointerHandler = (opts: { xPointer: number yPointer: number closestPoint: ({ index: number } & Pick, 'x' | 'values'>) | null + onAnnotationsBubble: boolean event: unknown }) => void diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index d197897ea4b6..d4a0062bc544 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -80,8 +80,10 @@ type AnnotationMenuState = { type TooltipState = { x: number + y: number selectedIndex: number | null persistent: boolean + type: 'annotations' | 'series' } const initialAnnotationMenuState: AnnotationMenuState = { @@ -92,8 +94,10 @@ const initialAnnotationMenuState: AnnotationMenuState = { const initialTooltipState: TooltipState = { x: 0, + y: 0, selectedIndex: null, - persistent: false + persistent: false, + type: 'series' } const getAnnotationTimeLabel = ( @@ -168,7 +172,7 @@ export const MainGraph = ({ const [annotationMenu, setAnnotationMenu] = useState( initialAnnotationMenuState ) - const isAnnotating = annotationMenu.selectedIndex !== null + const isAnnotationMenuOpen = annotationMenu.selectedIndex !== null const [tooltip, setTooltip] = useState(initialTooltipState) const { selectedIndex } = tooltip @@ -191,7 +195,7 @@ export const MainGraph = ({ document.addEventListener('pointercancel', onPointerCancel) return () => document.removeEventListener('pointercancel', onPointerCancel) }, []) - + const annotationsByTimeLabel = useMemo( () => groupAnnotationsByTimeLabel(getAnnotationsQuery.data ?? [], interval), [getAnnotationsQuery.data, interval] @@ -360,7 +364,7 @@ export const MainGraph = ({ ) const onPointerMove = useCallback>( - ({ inHoverableArea, closestPoint, event }) => { + ({ inHoverableArea, closestPoint, event, onAnnotationsBubble }) => { if (event instanceof PointerEvent && event.pointerType === 'touch') { setIsTouchDevice(true) if (tooltip.persistent && inHoverableArea && closestPoint) { @@ -375,7 +379,9 @@ export const MainGraph = ({ setTooltip({ selectedIndex: closestPoint.index, x: closestPoint.x, - persistent: true + y: 0, + persistent: true, + type: 'series' }) } } @@ -385,10 +391,13 @@ export const MainGraph = ({ if (!inHoverableArea || !closestPoint) { return setTooltip(initialTooltipState) } + console.log(onAnnotationsBubble, closestPoint.index) return setTooltip({ + persistent: false, selectedIndex: closestPoint.index, x: closestPoint.x, - persistent: false + y: onAnnotationsBubble ? height - marginBottom : 0, + type: onAnnotationsBubble ? 'annotations' : 'series' }) }, [tooltip.persistent] @@ -411,8 +420,11 @@ export const MainGraph = ({ if (tooltip.persistent) { return } + if (isAnnotationMenuOpen) { + return + } setTooltip(initialTooltipState) - }, [tooltip.persistent]) + }, [isAnnotationMenuOpen, tooltip.persistent]) const showZoomToPeriod = canZoomToPeriod( interval, @@ -468,16 +480,16 @@ export const MainGraph = ({ ) const onChartClick = useCallback>( - ({ inHoverableArea, closestPoint, annotationIndex, event }) => { - if (isAnnotating && annotationIndex === null) { + ({ inHoverableArea, closestPoint, onAnnotationsBubble, event }) => { + if (isAnnotationMenuOpen && !onAnnotationsBubble) { return setAnnotationMenu(initialAnnotationMenuState) } - if (annotationIndex !== null && closestPoint) { + if (onAnnotationsBubble && closestPoint) { return setAnnotationMenu((current) => - current.selectedIndex === annotationIndex + current.selectedIndex === closestPoint.index ? initialAnnotationMenuState : { - selectedIndex: annotationIndex, + selectedIndex: closestPoint.index, x: closestPoint.x, y: height - marginBottom } @@ -488,18 +500,14 @@ export const MainGraph = ({ return setTooltip({ selectedIndex: closestPoint.index, x: closestPoint.x, - persistent: true + y: 0, + persistent: true, + type: 'series' }) } return setTooltip(initialTooltipState) } - // if (closestPoint && isBottomClick({ height, yPointer, marginBottom })) { - // setAnnotationModal({ - // x: closestPoint.x, - // y: height - marginBottom, - // selectedIndex: closestPoint.index - // }) - // } + const isAltClick = event instanceof PointerEvent && event.altKey if (annotationDatetime && isAltClick) { return openAnnotationModal(annotationDatetime) @@ -509,7 +517,7 @@ export const MainGraph = ({ } }, [ - isAnnotating, + isAnnotationMenuOpen, isTouchDevice, zoomDate, annotationDatetime, @@ -520,11 +528,14 @@ export const MainGraph = ({ return ( - className={classNames( - showZoomToPeriod && selectedDatum ? 'cursor-pointer' : '', - tooltip.persistent ? 'touch-pan-y' : '' - )} - highlightedIndex={selectedIndex} + className={classNames({ + 'cursor-pointer': + selectedDatum && (showZoomToPeriod || tooltip.type === 'annotations'), + 'touch-pan-y': tooltip.persistent + })} + highlightedIndex={ + isAnnotationMenuOpen ? annotationMenu.selectedIndex : selectedIndex + } width={width} height={height} hoverBuffer={hoverBuffer} @@ -544,60 +555,73 @@ export const MainGraph = ({ gradients={gradients} annotationsCountByIndex={annotationsCountByIndex} > - {!!selectedDatum && isTouchDevice !== null && !isAnnotating && ( - - {tooltip.persistent && ( - <> - {!!zoomDate && ( - - )} - {selectedDatum.main.isDefined && ( - - )} - - )} - {!tooltip.persistent && ( - <> - {!!zoomDate && ( -
- {`Click to view ${interval}`} -
- )} - {!!annotationDatetime && ( -
- Alt + click to add note -
- )} - - )} - - )} - {annotationMenu.selectedIndex !== null && ( + {!!selectedDatum && + isTouchDevice !== null && + !isAnnotationMenuOpen && + tooltip.type === 'series' && ( + + {tooltip.persistent && ( + <> + {!!zoomDate && ( + + )} + {selectedDatum.main.isDefined && ( + + )} + + )} + {!tooltip.persistent && ( + <> + {!!zoomDate && ( +
+ {`Click to view ${interval}`} +
+ )} + {!!annotationDatetime && ( +
+ Alt + click to add note +
+ )} + + )} +
+ )} + {!!selectedDatum && + isTouchDevice !== null && + !isAnnotationMenuOpen && + tooltip.type === 'annotations' && ( + + )} + {isAnnotationMenuOpen && ( { const [expanded, setExpanded] = useState(null) + useEffect(() => { + setExpanded(null) + }, [annotations]) const { setModal } = useRoutelessModalsContext() return (