diff --git a/prisma/migrations/25_add_annotation/migration.sql b/prisma/migrations/25_add_annotation/migration.sql new file mode 100644 index 000000000..abfa045b6 --- /dev/null +++ b/prisma/migrations/25_add_annotation/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "annotation" ( + "annotation_id" UUID NOT NULL, + "website_id" UUID NOT NULL, + "user_id" UUID, + "date" TIMESTAMPTZ(6) NOT NULL, + "all_day" BOOLEAN NOT NULL DEFAULT true, + "note" VARCHAR(500) NOT NULL, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "annotation_pkey" PRIMARY KEY ("annotation_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "annotation_annotation_id_key" ON "annotation"("annotation_id"); + +-- CreateIndex +CREATE INDEX "annotation_website_id_date_idx" ON "annotation"("website_id", "date"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 97436ca16..113c67e99 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -97,6 +97,7 @@ model Website { user User? @relation("user", fields: [userId], references: [id]) createUser User? @relation("createUser", fields: [createdBy], references: [id]) team Team? @relation(fields: [teamId], references: [id]) + annotations Annotation[] eventData EventData[] reports Report[] revenue Revenue[] @@ -269,6 +270,22 @@ model Report { @@map("report") } +model Annotation { + id String @id() @map("annotation_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid + userId String? @map("user_id") @db.Uuid + date DateTime @db.Timestamptz(6) + allDay Boolean @default(true) @map("all_day") + note String @db.VarChar(500) + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + + website Website @relation(fields: [websiteId], references: [id]) + + @@index([websiteId, date]) + @@map("annotation") +} + model Segment { id String @id() @map("segment_id") @db.Uuid websiteId String @map("website_id") @db.Uuid diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 0ed5102da..023f53b8c 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -133,6 +133,7 @@ "filters-enabled": "Filters enabled", "property-filter": "Property Filter", "activity-by-property": "Activity by property", + "add-note": "Add note", "first-click": "First click", "first-seen": "First seen", "funnel": "Funnel", @@ -361,6 +362,8 @@ "arpu": "ARPU", "uniqueCustomers": "Unique Customers", "customers": "Customers", + "note": "Note", + "notes": "Notes", "orders": "Orders", "saved": "Saved", "unknown": "Unknown", diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx index 896c733a8..10564f45a 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx @@ -1,8 +1,16 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; +import type { ChartAnnotation } from '@/components/charts/ChartAnnotationMarkers'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useDateRange, useTimezone } from '@/components/hooks'; +import { + useDateParameters, + useDateRange, + useNavigation, + useTimezone, + useWebsiteAnnotationsQuery, +} from '@/components/hooks'; import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery'; import { PageviewsChart } from '@/components/metrics/PageviewsChart'; +import { getAnnotationDateRangeValue } from '@/lib/annotations'; export function WebsiteChart({ websiteId, @@ -11,9 +19,16 @@ export function WebsiteChart({ websiteId: string; compareMode?: boolean; }) { - const { timezone } = useTimezone(); + const { timezone, localFromUtc } = useTimezone(); const { dateRange, dateCompare } = useDateRange({ timezone: timezone }); const { startDate, endDate, unit, value } = dateRange; + const { startAt, endAt } = useDateParameters(); + const { router, updateParams } = useNavigation(); + const { data: annotationData } = useWebsiteAnnotationsQuery(websiteId, { + startAt, + endAt, + pageSize: 1000, + }); const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({ websiteId, compare: compareMode ? dateCompare?.compare : undefined, @@ -45,6 +60,27 @@ export function WebsiteChart({ }; }, [data, startDate, endDate, unit]); + const annotations = useMemo(() => { + return (annotationData?.data || []).map(({ id, date, note, allDay }) => ({ + id, + date: localFromUtc(new Date(date)), + label: note, + allDay, + })); + }, [annotationData, timezone]); + + const handleAnnotationClick = useCallback( + (annotation: ChartAnnotation) => { + router.push( + updateParams({ + date: getAnnotationDateRangeValue(annotation.date, annotation.allDay !== false), + offset: undefined, + }), + ); + }, + [router, updateParams], + ); + return ( ); diff --git a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx index 5412421e5..74a09acc3 100644 --- a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx @@ -3,6 +3,7 @@ import { Column, Row } from '@umami/react-zen'; import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; import { Panel } from '@/components/common/Panel'; import { UnitFilter } from '@/components/input/UnitFilter'; +import { AnnotationsButton } from './annotations/AnnotationsButton'; import { WebsiteChart } from './WebsiteChart'; import { WebsiteControls } from './WebsiteControls'; import { WebsiteMetricsBar } from './WebsiteMetricsBar'; @@ -18,6 +19,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) { + + + diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationDeleteButton.tsx new file mode 100644 index 000000000..02aacb6c1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationDeleteButton.tsx @@ -0,0 +1,50 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; + +export function AnnotationDeleteButton({ + annotationId, + websiteId, + name, + onSave, +}: { + annotationId: string; + websiteId: string; + name: string; + onSave?: () => void; +}) { + const { t, labels, messages } = useMessages(); + const { mutateAsync, isPending, error, touch } = useDeleteQuery( + `/websites/${websiteId}/annotations/${annotationId}`, + ); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('annotations'); + onSave?.(); + close(); + }, + }); + }; + + return ( + } variant="quiet" title={t(labels.confirm)} width="400px"> + {({ close }) => ( + {chunks}, + })} + isLoading={isPending} + error={error} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={t(labels.delete)} + buttonVariant="danger" + /> + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationEditForm.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationEditForm.tsx new file mode 100644 index 000000000..7df122748 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationEditForm.tsx @@ -0,0 +1,98 @@ +import { + Button, + Column, + DatePicker, + Form, + FormButtons, + FormField, + FormSubmitButton, + Label, + TextField, +} from '@umami/react-zen'; +import { startOfDay } from 'date-fns'; +import { useMemo, useState } from 'react'; +import { useLocale, useMessages, useTimezone, useUpdateQuery } from '@/components/hooks'; + +export interface Annotation { + id: string; + websiteId: string; + date: string; + allDay: boolean; + note: string; +} + +export function AnnotationEditForm({ + annotation, + websiteId, + onSave, + onClose, +}: { + annotation?: Annotation; + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { t, labels, messages, getErrorMessage } = useMessages(); + const { locale } = useLocale(); + const { localToUtc, localFromUtc } = useTimezone(); + + const initialDate = useMemo( + () => (annotation ? localFromUtc(new Date(annotation.date)) : new Date()), + [annotation], + ); + const [date, setDate] = useState(startOfDay(initialDate)); + + const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery( + `/websites/${websiteId}/annotations${annotation ? `/${annotation.id}` : ''}`, + ); + + const handleSubmit = async ({ note }: { note: string }) => { + await mutateAsync( + { + date: localToUtc(startOfDay(date)).toISOString(), + allDay: true, + note, + }, + { + onSuccess: async () => { + toast(t(messages.saved)); + touch('annotations'); + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + return ( +
+ + + + value && setDate(value)} /> + + + + + + + + + + {t(labels.save)} + + + +
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationsButton.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsButton.tsx new file mode 100644 index 000000000..3236b6d88 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsButton.tsx @@ -0,0 +1,24 @@ +import { useMessages, useShare } from '@/components/hooks'; +import { MessageSquareText } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { AnnotationsModal } from './AnnotationsModal'; + +export function AnnotationsButton({ websiteId }: { websiteId: string }) { + const { t, labels } = useMessages(); + const share = useShare(); + + if (share) { + return null; + } + + return ( + } + label={t(labels.notes)} + variant="quiet" + width="800px" + > + {({ close }) => } + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.tsx new file mode 100644 index 000000000..5ea5179d6 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.tsx @@ -0,0 +1,118 @@ +import { Button, Column, DataColumn, DataTable, Loading, Row, Text } from '@umami/react-zen'; +import { useState } from 'react'; +import { formatAnnotationDate } from '@/components/charts/ChartAnnotationMarkers'; +import { Empty } from '@/components/common/Empty'; +import { IconLabel } from '@/components/common/IconLabel'; +import { Pager } from '@/components/common/Pager'; +import { + useLocale, + useMessages, + useNavigation, + useTimezone, + useWebsiteAnnotationsQuery, +} from '@/components/hooks'; +import { Edit, Plus } from '@/components/icons'; +import { getAnnotationDateRangeValue } from '@/lib/annotations'; +import { AnnotationDeleteButton } from './AnnotationDeleteButton'; +import { type Annotation, AnnotationEditForm } from './AnnotationEditForm'; + +const PAGE_SIZE = 20; + +type View = { mode: 'list' } | { mode: 'add' } | { mode: 'edit'; annotation: Annotation }; + +export function AnnotationsModal({ + websiteId, + onClose, +}: { + websiteId: string; + onClose?: () => void; +}) { + const { t, labels } = useMessages(); + const { locale } = useLocale(); + const { localFromUtc } = useTimezone(); + const { router, updateParams } = useNavigation(); + const [view, setView] = useState({ mode: 'list' }); + const [page, setPage] = useState(1); + const { data, isLoading } = useWebsiteAnnotationsQuery(websiteId, { + page, + pageSize: PAGE_SIZE, + }); + + const showList = () => setView({ mode: 'list' }); + + const toLocalDate = (annotation: Annotation) => localFromUtc(new Date(annotation.date)); + + const handleSelect = (annotation: Annotation) => { + router.push( + updateParams({ + date: getAnnotationDateRangeValue(toLocalDate(annotation), annotation.allDay), + offset: undefined, + }), + ); + onClose?.(); + }; + + if (view.mode !== 'list') { + return ( + + ); + } + + if (isLoading && !data) { + return ; + } + + const rows: Annotation[] = data?.data || []; + + return ( + + + + + {rows.length === 0 ? ( + + ) : ( + + + {(row: Annotation) => ( + + )} + + + {(row: Annotation) => {row.note}} + + + {(row: Annotation) => ( + + + + + )} + + + )} + {data?.count > PAGE_SIZE && ( + + )} + + ); +} diff --git a/src/app/api/websites/[websiteId]/annotations/[annotationId]/route.ts b/src/app/api/websites/[websiteId]/annotations/[annotationId]/route.ts new file mode 100644 index 000000000..fd2773357 --- /dev/null +++ b/src/app/api/websites/[websiteId]/annotations/[annotationId]/route.ts @@ -0,0 +1,85 @@ +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { annotationSchema } from '@/lib/schema'; +import { canUpdateWebsite, canViewSharedWebsiteFilters } from '@/permissions'; +import { deleteAnnotation, getWebsiteAnnotation, updateAnnotation } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; annotationId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, annotationId } = await params; + + if (!(await canViewSharedWebsiteFilters(auth, websiteId))) { + return unauthorized(); + } + + const annotation = await getWebsiteAnnotation(websiteId, annotationId); + + if (!annotation) { + return notFound(); + } + + return json(annotation); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string; annotationId: string }> }, +) { + const { auth, body, error } = await parseRequest(request, annotationSchema); + + if (error) { + return error(); + } + + const { websiteId, annotationId } = await params; + const { date, allDay, note } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const annotation = await getWebsiteAnnotation(websiteId, annotationId); + + if (!annotation) { + return notFound(); + } + + const result = await updateAnnotation(annotationId, { date, allDay, note }); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ websiteId: string; annotationId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, annotationId } = await params; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const annotation = await getWebsiteAnnotation(websiteId, annotationId); + + if (!annotation) { + return notFound(); + } + + await deleteAnnotation(annotationId); + + return ok(); +} diff --git a/src/app/api/websites/[websiteId]/annotations/route.test.ts b/src/app/api/websites/[websiteId]/annotations/route.test.ts new file mode 100644 index 000000000..27e331953 --- /dev/null +++ b/src/app/api/websites/[websiteId]/annotations/route.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { parseRequest } from '@/lib/request'; +import { canUpdateWebsite, canViewSharedWebsiteFilters } from '@/permissions'; +import { createAnnotation, getWebsiteAnnotations } from '@/queries/prisma'; +import { GET, POST } from './route'; + +vi.mock('@/lib/request', () => ({ + parseRequest: vi.fn(), +})); + +vi.mock('@/permissions', () => ({ + canUpdateWebsite: vi.fn(), + canViewSharedWebsiteFilters: vi.fn(), +})); + +vi.mock('@/queries/prisma', () => ({ + createAnnotation: vi.fn(), + getWebsiteAnnotations: vi.fn(), +})); + +const parseRequestMock = vi.mocked(parseRequest); +const canUpdateWebsiteMock = vi.mocked(canUpdateWebsite); +const canViewSharedWebsiteFiltersMock = vi.mocked(canViewSharedWebsiteFilters); +const createAnnotationMock = vi.mocked(createAnnotation); +const getWebsiteAnnotationsMock = vi.mocked(getWebsiteAnnotations); + +const params = Promise.resolve({ websiteId: 'website-1' }); + +beforeEach(() => { + parseRequestMock.mockReset(); + canUpdateWebsiteMock.mockReset(); + canViewSharedWebsiteFiltersMock.mockReset(); + createAnnotationMock.mockReset(); + getWebsiteAnnotationsMock.mockReset(); +}); + +test('POST validates the note and stores the annotation with the creating user', async () => { + const date = new Date('2026-08-01T00:00:00.000Z'); + + parseRequestMock.mockResolvedValue({ + auth: { user: { id: 'user-1' } }, + body: { date, allDay: false, note: 'Deployed v2' }, + error: undefined, + } as any); + canUpdateWebsiteMock.mockResolvedValue(true); + createAnnotationMock.mockResolvedValue({ id: 'annotation-1' } as any); + + const response = await POST( + new Request('http://localhost/api/websites/website-1/annotations', { method: 'POST' }), + { params }, + ); + const schema = parseRequestMock.mock.calls[0][1] as { + safeParse: (value: unknown) => { success: boolean; data?: any }; + }; + + expect(schema.safeParse({ date: '2026-08-01', note: '' }).success).toBe(false); + expect(schema.safeParse({ date: '2026-08-01', note: 'x'.repeat(501) }).success).toBe(false); + expect(schema.safeParse({ date: 'not-a-date', note: 'ok' }).success).toBe(false); + + const parsed = schema.safeParse({ date: '2026-08-01', note: 'ok' }); + expect(parsed.success).toBe(true); + expect(parsed.data.allDay).toBe(true); + + expect(createAnnotationMock).toHaveBeenCalledWith({ + id: expect.any(String), + websiteId: 'website-1', + userId: 'user-1', + date, + allDay: false, + note: 'Deployed v2', + }); + expect(response.status).toBe(200); +}); + +test('POST rejects users without update permission', async () => { + parseRequestMock.mockResolvedValue({ + auth: { user: { id: 'user-1' } }, + body: { date: new Date(), allDay: true, note: 'nope' }, + error: undefined, + } as any); + canUpdateWebsiteMock.mockResolvedValue(false); + + const response = await POST( + new Request('http://localhost/api/websites/website-1/annotations', { method: 'POST' }), + { params }, + ); + + expect(createAnnotationMock).not.toHaveBeenCalled(); + expect(response.status).toBe(401); +}); + +test('GET passes the date window through to the query only when both bounds are present', async () => { + parseRequestMock.mockResolvedValue({ + auth: { user: { id: 'user-1' } }, + query: { startAt: 1000, endAt: 2000, search: 'deploy', page: 2, pageSize: 20 }, + error: undefined, + } as any); + canViewSharedWebsiteFiltersMock.mockResolvedValue(true); + getWebsiteAnnotationsMock.mockResolvedValue({ data: [], count: 0 } as any); + + await GET(new Request('http://localhost/api/websites/website-1/annotations'), { params }); + + expect(getWebsiteAnnotationsMock).toHaveBeenCalledWith('website-1', { + search: 'deploy', + page: 2, + pageSize: 20, + startDate: new Date(1000), + endDate: new Date(2000), + }); + + parseRequestMock.mockResolvedValue({ + auth: { user: { id: 'user-1' } }, + query: { page: 1 }, + error: undefined, + } as any); + + await GET(new Request('http://localhost/api/websites/website-1/annotations'), { params }); + + expect(getWebsiteAnnotationsMock).toHaveBeenLastCalledWith('website-1', { + search: undefined, + page: 1, + pageSize: undefined, + }); +}); diff --git a/src/app/api/websites/[websiteId]/annotations/route.ts b/src/app/api/websites/[websiteId]/annotations/route.ts new file mode 100644 index 000000000..6393d41df --- /dev/null +++ b/src/app/api/websites/[websiteId]/annotations/route.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { annotationSchema, pagingParams, searchParams } from '@/lib/schema'; +import { canUpdateWebsite, canViewSharedWebsiteFilters } from '@/permissions'; +import { createAnnotation, getWebsiteAnnotations } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().optional(), + endAt: z.coerce.number().optional(), + ...searchParams, + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { startAt, endAt, search, page, pageSize } = query; + + if (!(await canViewSharedWebsiteFilters(auth, websiteId))) { + return unauthorized(); + } + + const annotations = await getWebsiteAnnotations(websiteId, { + search, + page, + pageSize, + ...(startAt && endAt && { startDate: new Date(startAt), endDate: new Date(endAt) }), + }); + + return json(annotations); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, body, error } = await parseRequest(request, annotationSchema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { date, allDay, note } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await createAnnotation({ + id: uuid(), + websiteId, + userId: auth.user.id, + date, + allDay, + note, + }); + + return json(result); +} diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx index d3775bf40..bf86cfe71 100644 --- a/src/components/charts/Chart.tsx +++ b/src/components/charts/Chart.tsx @@ -6,6 +6,11 @@ import ChartJS, { type UpdateMode, } from 'chart.js/auto'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { + type AnnotationMarker, + type ChartAnnotation, + ChartAnnotationMarkers, +} from '@/components/charts/ChartAnnotationMarkers'; import { Legend } from '@/components/metrics/Legend'; import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants'; @@ -20,6 +25,21 @@ export interface ChartProps extends BoxProps { onTooltip?: (model: any) => void; hiddenLabels?: Set; onLegendClick?: (label: string, willBeHidden: boolean) => void; + annotations?: ChartAnnotation[]; + onAnnotationClick?: (annotation: ChartAnnotation) => void; +} + +function isSameMarkers(a: AnnotationMarker[], b: AnnotationMarker[]) { + return ( + a.length === b.length && + a.every( + (m, i) => + m.x === b[i].x && + m.y === b[i].y && + m.annotations.length === b[i].annotations.length && + m.annotations.every((annotation, j) => annotation === b[i].annotations[j]), + ) + ); } export function Chart({ @@ -31,11 +51,56 @@ export function Chart({ chartOptions, hiddenLabels, onLegendClick, + annotations, + onAnnotationClick, ...props }: ChartProps) { const canvas = useRef(null); const chart = useRef(null); const [legendItems, setLegendItems] = useState([]); + const [markers, setMarkers] = useState([]); + const annotationsRef = useRef(annotations); + annotationsRef.current = annotations; + + // Computes pixel positions for annotation markers whenever the chart lays out (update / resize) + const annotationPlugin = useMemo( + () => ({ + id: 'annotationMarkers', + afterLayout: (instance: any) => { + const list = annotationsRef.current; + const { chartArea, scales } = instance; + + if (!list?.length || !chartArea || !scales?.x) { + setMarkers(prev => (prev.length ? [] : prev)); + return; + } + + // Group annotations that land on the same pixel column into a single marker + const groups = new Map(); + + for (const annotation of list) { + const x = Math.round(scales.x.getPixelForValue(annotation.date.getTime())); + + if (!Number.isFinite(x) || x < chartArea.left || x > chartArea.right) { + continue; + } + + const group = groups.get(x); + + if (group) { + group.annotations.push(annotation); + } else { + groups.set(x, { annotations: [annotation], x, y: chartArea.bottom }); + } + } + + const next = [...groups.values()]; + + setMarkers(prev => (isSameMarkers(prev, next) ? prev : next)); + }, + }), + [], + ); const options = useMemo(() => { return { @@ -99,6 +164,7 @@ export function Chart({ type, data: chartData, options, + plugins: [annotationPlugin], }); setLegendItems(chart.current.legend.legendItems); @@ -140,11 +206,19 @@ export function Chart({ } }, [chartData, options, updateMode, hiddenLabels]); + // Re-run layout when annotations change so marker positions are recalculated + useEffect(() => { + chart.current?.update('none'); + }, [annotations]); + return (
+ {markers.length > 0 && ( + + )}
diff --git a/src/components/charts/ChartAnnotationMarkers.module.css b/src/components/charts/ChartAnnotationMarkers.module.css new file mode 100644 index 000000000..62ea86cc1 --- /dev/null +++ b/src/components/charts/ChartAnnotationMarkers.module.css @@ -0,0 +1,42 @@ +.marker { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--base-color-1, #fff); + padding: 0; + cursor: pointer; + transform: translate(-50%, -50%); + z-index: 1; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 0.1s ease-in-out; +} + +.marker:hover, +.marker:focus-visible { + transform: translate(-50%, -50%) scale(1.3); + outline: none; +} + +.count { + position: absolute; + top: -8px; + right: -8px; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + font-size: 9px; + font-weight: 600; + line-height: 14px; + color: #fff; + background: inherit; + border: 1px solid var(--base-color-1, #fff); + pointer-events: none; +} + +.note { + max-width: 300px; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/src/components/charts/ChartAnnotationMarkers.tsx b/src/components/charts/ChartAnnotationMarkers.tsx new file mode 100644 index 000000000..4c02435da --- /dev/null +++ b/src/components/charts/ChartAnnotationMarkers.tsx @@ -0,0 +1,69 @@ +import { Column, Text, Tooltip, TooltipTrigger, useTheme } from '@umami/react-zen'; +import { useMemo } from 'react'; +import { useLocale } from '@/components/hooks'; +import { getThemeColors } from '@/lib/colors'; +import { formatDate } from '@/lib/date'; +import styles from './ChartAnnotationMarkers.module.css'; + +export interface ChartAnnotation { + id: string; + date: Date; + label: string; + allDay?: boolean; +} + +export interface AnnotationMarker { + annotations: ChartAnnotation[]; + x: number; + y: number; +} + +export function formatAnnotationDate(annotation: ChartAnnotation, locale: string) { + return formatDate(annotation.date, annotation.allDay === false ? 'PP p' : 'PP', locale); +} + +export function ChartAnnotationMarkers({ + markers, + onClick, +}: { + markers: AnnotationMarker[]; + onClick?: (annotation: ChartAnnotation) => void; +}) { + const { theme } = useTheme(); + const { locale } = useLocale(); + const { colors } = useMemo(() => getThemeColors(theme), [theme]); + + return ( + <> + {markers.map(({ annotations, x, y }) => { + const [first] = annotations; + + return ( + + + + + {annotations.map(annotation => ( + + + {formatAnnotationDate(annotation, locale)} + + {annotation.label} + + ))} + + + + ); + })} + + ); +} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index bff1d0f95..b4a4db3c5 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -75,6 +75,7 @@ export * from './queries/useWebsiteMetricsQuery'; export * from './queries/useWebsitePageviewsQuery'; export * from './queries/useWebsiteListChartsQuery'; export * from './queries/useWebsiteQuery'; +export * from './queries/useWebsiteAnnotationsQuery'; export * from './queries/useWebsiteSegmentQuery'; export * from './queries/useWebsiteSegmentsQuery'; export * from './queries/useWebsiteSessionQuery'; diff --git a/src/components/hooks/queries/useWebsiteAnnotationsQuery.ts b/src/components/hooks/queries/useWebsiteAnnotationsQuery.ts new file mode 100644 index 000000000..bd517d3eb --- /dev/null +++ b/src/components/hooks/queries/useWebsiteAnnotationsQuery.ts @@ -0,0 +1,21 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteAnnotationsQuery( + websiteId: string, + params?: Record, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`annotations`); + + return useQuery({ + queryKey: ['website:annotations', { websiteId, modified, ...params }], + queryFn: () => get(`/websites/${websiteId}/annotations`, params), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/messages.ts b/src/components/messages.ts index b717200d4..35e2f7bcb 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -336,6 +336,9 @@ export const labels: Record = { addBoard: 'label.add-board', selectComponent: 'label.select-component', addLink: 'label.add-link', + addNote: 'label.add-note', + notes: 'label.notes', + note: 'label.note', addPixel: 'label.add-pixel', maximize: 'label.maximize', remaining: 'label.remaining', diff --git a/src/lib/annotations.ts b/src/lib/annotations.ts new file mode 100644 index 000000000..e22a22387 --- /dev/null +++ b/src/lib/annotations.ts @@ -0,0 +1,13 @@ +import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns'; + +/** + * Returns the `date` URL parameter value that filters the website view + * to the day (all-day annotation) or hour (timed annotation) of an annotation. + */ +export function getAnnotationDateRangeValue(date: Date, allDay: boolean) { + const [startDate, endDate] = allDay + ? [startOfDay(date), endOfDay(date)] + : [startOfHour(date), endOfHour(date)]; + + return `range:${startDate.getTime()}:${endDate.getTime()}`; +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts index a58623850..3a4e98de6 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -78,6 +78,12 @@ export const replayParams = { minDuration: z.coerce.number().int().nonnegative().optional(), }; +export const annotationSchema = z.object({ + date: z.coerce.date(), + allDay: z.boolean().optional().default(true), + note: z.string().min(1).max(500), +}); + export const pagingParams = { page: z.coerce.number().int().positive().optional(), pageSize: z.coerce.number().int().positive().optional(), diff --git a/src/queries/prisma/annotation.ts b/src/queries/prisma/annotation.ts new file mode 100644 index 000000000..6d23f4cbb --- /dev/null +++ b/src/queries/prisma/annotation.ts @@ -0,0 +1,64 @@ +import type { Prisma } from '@/generated/prisma/client'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export async function getAnnotation(annotationId: string) { + return prisma.client.annotation.findUnique({ + where: { + id: annotationId, + }, + }); +} + +export async function getWebsiteAnnotation(websiteId: string, annotationId: string) { + return prisma.client.annotation.findFirst({ + where: { id: annotationId, websiteId }, + }); +} + +export async function getWebsiteAnnotations( + websiteId: string, + filters: QueryFilters & { startDate?: Date; endDate?: Date } = {}, +) { + const { search, startDate, endDate } = filters; + const { getSearchParameters, pagedQuery } = prisma; + + const where: Prisma.AnnotationWhereInput = { + websiteId, + ...(startDate && + endDate && { + date: { + gte: startDate, + lte: endDate, + }, + }), + ...getSearchParameters(search, [ + { + note: 'contains', + }, + ]), + }; + + return pagedQuery( + 'annotation', + { + where, + orderBy: { + date: 'desc', + }, + }, + filters, + ); +} + +export async function createAnnotation(data: Prisma.AnnotationUncheckedCreateInput) { + return prisma.client.annotation.create({ data }); +} + +export async function updateAnnotation(annotationId: string, data: Prisma.AnnotationUpdateInput) { + return prisma.client.annotation.update({ where: { id: annotationId }, data }); +} + +export async function deleteAnnotation(annotationId: string) { + return prisma.client.annotation.delete({ where: { id: annotationId } }); +} diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts index ad6c50ea6..1db320166 100644 --- a/src/queries/prisma/index.ts +++ b/src/queries/prisma/index.ts @@ -1,3 +1,4 @@ +export * from './annotation'; export * from './board'; export * from './link'; export * from './pixel'; diff --git a/src/queries/prisma/website.test.ts b/src/queries/prisma/website.test.ts index a1a993174..345b82bea 100644 --- a/src/queries/prisma/website.test.ts +++ b/src/queries/prisma/website.test.ts @@ -83,6 +83,11 @@ function createDeleteTx(calls: string[]) { calls.push('segment'); }), }, + annotation: { + deleteMany: vi.fn(async () => { + calls.push('annotation'); + }), + }, share: { deleteMany: vi.fn(async () => { calls.push('share'); @@ -149,6 +154,7 @@ describe('website delete dependencies', () => { 'session', 'report', 'segment', + 'annotation', 'share', 'websiteDelete', ]); diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts index 612aca09a..60a342138 100644 --- a/src/queries/prisma/website.ts +++ b/src/queries/prisma/website.ts @@ -237,6 +237,10 @@ export async function deleteWebsite(websiteId: string) { where: { websiteId }, }); + await tx.annotation.deleteMany({ + where: { websiteId }, + }); + await tx.share.deleteMany({ where: { entityId: websiteId }, });