diff --git a/assets/js/dashboard/annotations/annotation-list-items.tsx b/assets/js/dashboard/annotations/annotation-list-items.tsx index 03ebe3feb8e8..dc5a3c04437e 100644 --- a/assets/js/dashboard/annotations/annotation-list-items.tsx +++ b/assets/js/dashboard/annotations/annotation-list-items.tsx @@ -21,24 +21,31 @@ const VerticalBar = () => ( ) export const AnnotationItemRow = ({ children }: { children: ReactNode }) => ( -
+
{children}
) export const AnnotationAuthorshipLine = ({ - annotation + annotation, + showDateLabel }: { annotation: Annotation + showDateLabel: boolean }) => ( -
+
{getAnnotationAuthorship(annotation)} - - {` • ${getAttributionDateLabel(annotation)}`} - + {showDateLabel && ( + +  {`• ${getAttributionDateLabel(annotation)}`} + + )}
) diff --git a/assets/js/dashboard/annotations/annotations.test.ts b/assets/js/dashboard/annotations/annotations.test.ts index 0b36eb0b2a1b..086c6f08e16d 100644 --- a/assets/js/dashboard/annotations/annotations.test.ts +++ b/assets/js/dashboard/annotations/annotations.test.ts @@ -5,8 +5,9 @@ import { canEditAnnotation, getAnnotationAuthorship, getAnnotationGranularity, - getAnnotationTimeLabel, - groupAnnotationsByTimeLabel + getAnnotationDatetimeGroup, + groupAnnotationsByDatetime, + allAnnotationsAreFromThisExactDatetime } from './annotations' import { Interval } from '../stats/graph/intervals' import { Role, UserContextValue } from '../user-context' @@ -199,7 +200,7 @@ describe(`${getAnnotationGranularity.name}`, () => { }) }) -describe(`${getAnnotationTimeLabel.name}`, () => { +describe(`${getAnnotationDatetimeGroup.name}`, () => { // 2025-02-26 is a Wednesday const dateAnnotation = { datetime: '2025-02-26', @@ -215,7 +216,9 @@ describe(`${getAnnotationTimeLabel.name}`, () => { ])( `date-granularity annotation on ${dateAnnotation.datetime} bucketed to %s yields %s`, (interval, expected) => { - expect(getAnnotationTimeLabel(dateAnnotation, interval)).toBe(expected) + expect(getAnnotationDatetimeGroup(dateAnnotation, interval)).toBe( + expected + ) } ) @@ -227,17 +230,19 @@ describe(`${getAnnotationTimeLabel.name}`, () => { [Interval.month, '2025-02-01'], [Interval.week, '2025-02-24'], [Interval.day, '2025-02-26'], - [Interval.hour, '2025-02-26 10:00:00'], - [Interval.minute, '2025-02-26 10:30:00'] + [Interval.hour, '2025-02-26T10:00:00'], + [Interval.minute, '2025-02-26T10:30:00'] ])( `minute granularity annotation with datetime ${minuteAnnotation.datetime} bucketed to %s yields %s`, (interval, expected) => { - expect(getAnnotationTimeLabel(minuteAnnotation, interval)).toBe(expected) + expect(getAnnotationDatetimeGroup(minuteAnnotation, interval)).toBe( + expected + ) } ) }) -describe(`${groupAnnotationsByTimeLabel.name}`, () => { +describe(`${groupAnnotationsByDatetime.name}`, () => { const dateGranularity = AnnotationGranularity.date const annotations = [ { id: 1, datetime: '2025-02-24 00:00:00', granularity: dateGranularity }, // Mon @@ -246,7 +251,7 @@ describe(`${groupAnnotationsByTimeLabel.name}`, () => { ] it('groups annotations by day when the interval is day', () => { - const grouped = groupAnnotationsByTimeLabel(annotations, Interval.day) + const grouped = groupAnnotationsByDatetime(annotations, Interval.day) expect(Object.keys(grouped).sort()).toEqual([ '2025-02-24', @@ -259,7 +264,7 @@ describe(`${groupAnnotationsByTimeLabel.name}`, () => { }) it('collapses annotations from the same week into one bucket', () => { - const grouped = groupAnnotationsByTimeLabel(annotations, Interval.week) + const grouped = groupAnnotationsByDatetime(annotations, Interval.week) expect(Object.keys(grouped).sort()).toEqual(['2025-02-24', '2025-03-03']) expect(grouped['2025-02-24']!.map((a) => a.id)).toEqual([1, 2]) @@ -267,7 +272,7 @@ describe(`${groupAnnotationsByTimeLabel.name}`, () => { }) it('collapses annotations from the same month into one bucket', () => { - const grouped = groupAnnotationsByTimeLabel(annotations, Interval.month) + const grouped = groupAnnotationsByDatetime(annotations, Interval.month) expect(Object.keys(grouped).sort()).toEqual(['2025-02-01', '2025-03-01']) expect(grouped['2025-02-01']!.map((a) => a.id)).toEqual([1, 2]) @@ -281,17 +286,58 @@ describe(`${groupAnnotationsByTimeLabel.name}`, () => { { id: 12, datetime: '2025-02-26 10:00:00', granularity: dateGranularity } ] - const grouped = groupAnnotationsByTimeLabel(sameDay, Interval.day) + const grouped = groupAnnotationsByDatetime(sameDay, Interval.day) expect(grouped['2025-02-26']!.map((a) => a.id)).toEqual([10, 11, 12]) }) it('returns an empty object when there are no annotations', () => { expect( - groupAnnotationsByTimeLabel( + groupAnnotationsByDatetime( [] as { datetime: string; granularity: AnnotationGranularity }[], Interval.day ) ).toEqual({}) }) }) + +describe(`${allAnnotationsAreFromThisExactDatetime.name}`, () => { + it('returns true when every annotation shares the given datetime', () => { + expect( + allAnnotationsAreFromThisExactDatetime( + [ + { datetime: '2025-02-26T10:30:00' }, + { datetime: '2025-02-26T10:30:00' } + ], + '2025-02-26T10:30:00' + ) + ).toBe(true) + }) + + it('returns true when every annotation shares the given date', () => { + expect( + allAnnotationsAreFromThisExactDatetime( + [{ datetime: '2026-07-20' }, { datetime: '2026-07-20' }], + '2026-07-20' + ) + ).toBe(true) + }) + + it('returns false when some annotation has a different datetime', () => { + expect( + allAnnotationsAreFromThisExactDatetime( + [ + { datetime: '2025-02-26T10:30:00' }, + { datetime: '2025-02-26T10:31:00' } + ], + '2025-02-26T10:30:00' + ) + ).toBe(false) + }) + + it('returns true when there are no annotations', () => { + expect( + allAnnotationsAreFromThisExactDatetime([], '2025-02-26T10:30:00') + ).toBe(true) + }) +}) diff --git a/assets/js/dashboard/annotations/annotations.ts b/assets/js/dashboard/annotations/annotations.ts index 4ba1faf96606..b848ba056285 100644 --- a/assets/js/dashboard/annotations/annotations.ts +++ b/assets/js/dashboard/annotations/annotations.ts @@ -37,9 +37,9 @@ export type Annotation = { note: string id: number - /** datetime in site timezone, example 2025-02-26 10:00:00 */ + /** datetime in site timezone, example 2025-02-26T10:00:00 */ inserted_at: string - /** datetime in site timezone, example 2025-02-26 10:00:00 */ + /** datetime in site timezone, example 2025-02-26T10:00:00 */ updated_at: string } & AnnotationOwnership @@ -139,7 +139,7 @@ export function canAddAnnotation({ } } -export const getAnnotationTimeLabel = ( +export const getAnnotationDatetimeGroup = ( annotation: Pick, interval: Interval ): string => { @@ -175,24 +175,24 @@ export const getAnnotationTimeLabel = ( case Interval.hour: { const [dateYYYYMMDD, timeHHMMSS] = annotation.datetime.split('T') // floors time to hour - return `${dateYYYYMMDD} ${timeHHMMSS.substring(0, 'HH'.length)}:00:00` + return `${dateYYYYMMDD}T${timeHHMMSS.substring(0, 'HH'.length)}:00:00` } case Interval.minute: - return annotation.datetime.split('T').join(' ') + return annotation.datetime } } } } -export const groupAnnotationsByTimeLabel = < +export const groupAnnotationsByDatetime = < T extends Pick >( annotations: T[], interval: Interval ): Record => { return annotations.reduce>((acc, annotation) => { - const timeLabel = getAnnotationTimeLabel(annotation, interval) - return { ...acc, [timeLabel]: [...(acc[timeLabel] ?? []), annotation] } + const label = getAnnotationDatetimeGroup(annotation, interval) + return { ...acc, [label]: [...(acc[label] ?? []), annotation] } }, {}) } @@ -210,6 +210,11 @@ export const getAnnotationGranularity = ( } } +export const allAnnotationsAreFromThisExactDatetime = ( + annotations: Pick[], + datetime: string +): boolean => annotations.every((a) => a.datetime === datetime) + export const getApiFormattedPayload = ({ granularity, datetime, diff --git a/assets/js/dashboard/annotations/hover-annotations-list.tsx b/assets/js/dashboard/annotations/hover-annotations-list.tsx index 2acc232c6103..01522197d943 100644 --- a/assets/js/dashboard/annotations/hover-annotations-list.tsx +++ b/assets/js/dashboard/annotations/hover-annotations-list.tsx @@ -1,5 +1,8 @@ import React from 'react' -import { Annotation } from './annotations' +import { + Annotation, + allAnnotationsAreFromThisExactDatetime +} from './annotations' import { AnnotationAuthorshipLine, AnnotationItemRow, @@ -10,20 +13,29 @@ import { const MAX_PREVIEW = 2 export const HoverAnnotationsList = ({ + annotationDatetime, annotations }: { + annotationDatetime: string annotations: Annotation[] }) => { const preview = annotations.slice(0, MAX_PREVIEW) const extra = annotations.length - MAX_PREVIEW + const showDateLabel = !allAnnotationsAreFromThisExactDatetime( + preview, + annotationDatetime + ) return ( <> {preview.map((annotation) => ( -
- +
+
diff --git a/assets/js/dashboard/annotations/interactive-annotations-list.tsx b/assets/js/dashboard/annotations/interactive-annotations-list.tsx index 987007df4d45..fd01220e74df 100644 --- a/assets/js/dashboard/annotations/interactive-annotations-list.tsx +++ b/assets/js/dashboard/annotations/interactive-annotations-list.tsx @@ -1,5 +1,9 @@ import React, { ReactNode } from 'react' -import { Annotation, canEditAnnotation } from './annotations' +import { + Annotation, + allAnnotationsAreFromThisExactDatetime, + canEditAnnotation +} from './annotations' import { AnnotationAuthorshipLine, AnnotationItemRow, @@ -17,10 +21,12 @@ const ScrollableArea = (props: { children: ReactNode }) => ( ) export const InteractiveAnnotationsList = ({ + annotationDatetime, annotations, isTouchDevice, closeTooltip }: { + annotationDatetime: string annotations: Annotation[] isTouchDevice: boolean closeTooltip: () => void @@ -31,6 +37,10 @@ export const InteractiveAnnotationsList = ({ closeTooltip() setModal({ type: 'update-annotation', annotation }) } + const showDateLabel = !allAnnotationsAreFromThisExactDatetime( + annotations, + annotationDatetime + ) return ( @@ -39,7 +49,10 @@ export const InteractiveAnnotationsList = ({ const editable = canEditAnnotation({ type: annotation.type, user }) const content = ( <> - + {editable && !isTouchDevice && ( ) : ( -
+
{content}
)} diff --git a/assets/js/dashboard/stats/graph/main-graph-data.test.ts b/assets/js/dashboard/stats/graph/main-graph-data.test.ts index 1f81532eb644..6f4620bf7130 100644 --- a/assets/js/dashboard/stats/graph/main-graph-data.test.ts +++ b/assets/js/dashboard/stats/graph/main-graph-data.test.ts @@ -1,7 +1,8 @@ import { getChangeInPercentagePoints, getRelativeChange, - getLineSegments + getLineSegments, + normalizeGraphTimeLabel } from './main-graph-data' describe(`${getChangeInPercentagePoints.name}`, () => { @@ -109,3 +110,15 @@ describe(`${getLineSegments.name}`, () => { ]) }) }) + +describe(`${normalizeGraphTimeLabel.name}`, () => { + it('replaces the space separator with "T" for datetime labels', () => { + expect(normalizeGraphTimeLabel('2026-10-10 10:00:15')).toBe( + '2026-10-10T10:00:15' + ) + }) + + it('leaves date-only labels unchanged', () => { + expect(normalizeGraphTimeLabel('2026-10-10')).toBe('2026-10-10') + }) +}) diff --git a/assets/js/dashboard/stats/graph/main-graph-data.ts b/assets/js/dashboard/stats/graph/main-graph-data.ts index 3f34a9f73ab8..153d3dfb8eb3 100644 --- a/assets/js/dashboard/stats/graph/main-graph-data.ts +++ b/assets/js/dashboard/stats/graph/main-graph-data.ts @@ -213,3 +213,11 @@ type SeriesValue = isCurrent: boolean timeLabel: string } + +/** + * This function normalizes graph time labels in the format "2026-10-10 10:00:15" + * to the standard format "2026-10-10T10:00:15". Passes YYYY-MM-DD format date strings + * like "2026-01-01" through unchanged. + */ +export const normalizeGraphTimeLabel = (timeLabel: string) => + timeLabel.split(' ').join('T') diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index bf8708ca329d..b41de291628b 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -36,7 +36,8 @@ import { getRelativeChange, REVENUE_METRICS, getFirstAndLastTimeLabels, - MainGraphSeriesName + MainGraphSeriesName, + normalizeGraphTimeLabel } from './main-graph-data' import { Metric, getMetricLabel } from '../metrics' import { extractIntervalFromDimensions, Interval } from './intervals' @@ -46,7 +47,7 @@ import { AnnotationType, canShowAddAnnotationButton, getAnnotationGranularity, - groupAnnotationsByTimeLabel + groupAnnotationsByDatetime } from '../../annotations/annotations' import { useUserContext } from '../../user-context' import { Button } from '../../components/button' @@ -118,8 +119,8 @@ export const MainGraph = ({ const interval = extractIntervalFromDimensions(data.query.dimensions) const isRealtime = data.extraContext.isRealtime - const annotationsByTimeLabel = useMemo( - () => groupAnnotationsByTimeLabel(annotations, interval), + const annotationsByDatetime = useMemo( + () => groupAnnotationsByDatetime(annotations, interval), [annotations, interval] ) @@ -304,13 +305,15 @@ export const MainGraph = ({ () => remappedData.map((datum) => { const annotationsOnDatum = datum.main.isDefined - ? (annotationsByTimeLabel[datum.main.timeLabel] ?? []) + ? (annotationsByDatetime[ + normalizeGraphTimeLabel(datum.main.timeLabel) + ] ?? []) : [] return { count: annotationsOnDatum.length } }), - [remappedData, annotationsByTimeLabel] + [remappedData, annotationsByDatetime] ) const getFormattedValue = useCallback( @@ -398,9 +401,13 @@ export const MainGraph = ({ const annotationDatetime = selectedDatum && selectedDatum.main.isDefined - ? selectedDatum.main.timeLabel + ? normalizeGraphTimeLabel(selectedDatum.main.timeLabel) : null + const annotationsForSelectedBucket = annotationDatetime + ? annotationsByDatetime[annotationDatetime] + : undefined + const zoomToPeriod = useCallback( (date: string) => { setTooltip(initialTooltipState) @@ -507,11 +514,7 @@ export const MainGraph = ({ {tooltip.persistent ? ( ) : ( void closeTooltip: () => void @@ -564,8 +564,9 @@ const PersistentTooltipContents = ({ const hasActions = !!zoomDate || (!!annotationDatetime && canAddAnnotation) return ( <> - {!!annotations?.length && ( + {!!annotationDatetime && !!annotations?.length && ( { return ( <> - {!!annotations?.length && ( - + {!!annotationDatetime && !!annotations?.length && ( + )}
diff --git a/e2e/tests/dashboard/annotations.spec.ts b/e2e/tests/dashboard/annotations.spec.ts index aa15273f25d5..bf6f2fd3ac67 100644 --- a/e2e/tests/dashboard/annotations.spec.ts +++ b/e2e/tests/dashboard/annotations.spec.ts @@ -65,6 +65,10 @@ test('user can create annotations across granularities, edit, and delete them', const personalNoteAttribution = 'Personal note • 29 Jun 10:00' const siteNoteAttribution = `${user.name} • 29 Jun` + // when all annotations in a bucket are exactly for the date (& time) of the bucket + // the attribution is shortened because it's unambiguous + const siteNoteAttributionUnambiguous = `${user.name}` + const personalNoteAttributionUnambiguous = 'Personal note' // Asserts the tooltip's annotation list matches provided list exactly const expectTooltipAnnotations = async ( @@ -75,7 +79,9 @@ test('user can create annotations across granularities, edit, and delete them', for (const [i, { note, attribution }] of expected.entries()) { const row = rows.nth(i) await expect(row).toContainText(note) - await expect(row.getByText(attribution, { exact: true })).toBeVisible() + await expect(row.getByTestId('annotation-attribution')).toHaveText( + attribution + ) } } @@ -124,7 +130,7 @@ test('user can create annotations across granularities, edit, and delete them', await test.step('right-click Monday and zoom in via "View day"', async () => { await mondayIn7d.hover() await expectTooltipAnnotations([ - { note: siteNoteText, attribution: siteNoteAttribution } + { note: siteNoteText, attribution: siteNoteAttributionUnambiguous } ]) await mondayIn7d.click({ button: 'right' }) @@ -164,6 +170,18 @@ test('user can create annotations across granularities, edit, and delete them', ).toBeVisible() }) + await test.step('in day view, hovering the 10:00 bucket with the single note omits the duplicative datetime label from the note', async () => { + const hour10InDay = page.getByTestId('graph-dot-series-1-bucket-10') + await hour10InDay.hover() + await expect(tooltip).toBeVisible() + await expectTooltipAnnotations([ + { + note: personalNoteText, + attribution: personalNoteAttributionUnambiguous + } + ]) + }) + await test.step('zoom back out to 7d, both annotations are visible on Monday', async () => { await page.goBack({ waitUntil: 'commit' }) await setGraphInterval('Days') @@ -239,7 +257,7 @@ test('user can create annotations across granularities, edit, and delete them', await expect(deleteModalHeading).toBeHidden() await mondayIn7d.hover() await expectTooltipAnnotations([ - { note: siteNoteText, attribution: siteNoteAttribution } + { note: siteNoteText, attribution: siteNoteAttributionUnambiguous } ]) await expect(mondayMarker7d).toBeVisible() }) 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 index 0f383668d4d5..047affef7492 100644 --- a/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs +++ b/test/plausible_web/controllers/api/internal_controller/annotations_controller_test.exs @@ -495,26 +495,28 @@ defmodule PlausibleWeb.Api.Internal.AnnotationsControllerTest do assert response["datetime"] == "2026-01-04T14:32:00" end - 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") + for dt <- ["2026-01-04T14:30:00", "2026-01-04 14:30:00"] do + test "converts naive local time #{dt} 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" => "feature released", - "type" => "personal", - "granularity" => "minute", - "datetime" => "2026-01-04T14:30:00" - }) - |> json_response(200) + response = + post(conn, "/api/#{site.domain}/annotations", %{ + "note" => "feature released", + "type" => "personal", + "granularity" => "minute", + "datetime" => unquote(dt) + }) + |> json_response(200) - assert response["datetime"] == "2026-01-04T14:30:00" + assert response["datetime"] == "2026-01-04T14:30:00" - reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, response["id"]) - assert reloaded.granularity == :minute - assert reloaded.datetime == ~U[2026-01-04 19:30:00Z] + reloaded = Plausible.Repo.get!(Plausible.Annotations.Annotation, response["id"]) + assert reloaded.granularity == :minute + assert reloaded.datetime == ~U[2026-01-04 19:30:00Z] + end end test "UTC datetime string is stored as UTC and returned as site local time",