Add website annotations with chart markers and CRUD management

This commit is contained in:
Mike Cao committed 2026-08-27 00:09:03 -07:00
1 parent eea124bbf8
commit 64e4cc1c3b
24 files changed
+957 -3

No files matched your search

@@ -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");
+17
View File
@@ -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
+3
View File
@@ -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",
@@ -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<ChartAnnotation[]>(() => {
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 (
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
<PageviewsChart
@@ -53,6 +89,8 @@ export function WebsiteChart({
minDate={startDate}
maxDate={endDate}
unit={unit}
annotations={annotations}
onAnnotationClick={handleAnnotationClick}
/>
</LoadingPanel>
);
@@ -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 }) {
<UnitFilter />
</Row>
<WebsiteChart websiteId={websiteId} />
<Row justifyContent="end">
<AnnotationsButton websiteId={websiteId} />
</Row>
</Panel>
<WebsitePanels websiteId={websiteId} />
<ExpandedViewModal websiteId={websiteId} />
@@ -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 (
<DialogButton icon={<Trash />} variant="quiet" title={t(labels.confirm)} width="400px">
{({ close }) => (
<ConfirmationForm
message={t.rich(messages.confirmRemove, {
target: name,
b: chunks => <b>{chunks}</b>,
})}
isLoading={isPending}
error={error}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={t(labels.delete)}
buttonVariant="danger"
/>
)}
</DialogButton>
);
}
@@ -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<Date>(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 (
<Form
error={getErrorMessage(error)}
onSubmit={handleSubmit}
defaultValues={{ note: annotation?.note || '' }}
>
<Column gap>
<Column>
<Label>{t(labels.date)}</Label>
<DatePicker value={date} locale={locale} onChange={value => value && setDate(value)} />
</Column>
<FormField
name="note"
label={t(labels.note)}
rules={{ required: t(labels.required), maxLength: 500 }}
>
<TextField asTextArea autoFocus />
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Column>
</Form>
);
}
@@ -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 (
<DialogButton
icon={<MessageSquareText />}
label={t(labels.notes)}
variant="quiet"
width="800px"
>
{({ close }) => <AnnotationsModal websiteId={websiteId} onClose={close} />}
</DialogButton>
);
}
@@ -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<View>({ 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 (
<AnnotationEditForm
websiteId={websiteId}
annotation={view.mode === 'edit' ? view.annotation : undefined}
onSave={showList}
onClose={showList}
/>
);
}
if (isLoading && !data) {
return <Loading placement="absolute" />;
}
const rows: Annotation[] = data?.data || [];
return (
<Column gap>
<Row justifyContent="end">
<Button variant="primary" onPress={() => setView({ mode: 'add' })}>
<Plus />
{t(labels.addNote)}
</Button>
</Row>
{rows.length === 0 ? (
<Empty />
) : (
<DataTable data={rows}>
<DataColumn id="date" label={t(labels.date)} width="200px">
{(row: Annotation) => (
<Button variant="quiet" onPress={() => handleSelect(row)}>
{formatAnnotationDate(
{ id: row.id, date: toLocalDate(row), label: row.note, allDay: row.allDay },
locale,
)}
</Button>
)}
</DataColumn>
<DataColumn id="note" label={t(labels.note)}>
{(row: Annotation) => <Text style={{ whiteSpace: 'pre-wrap' }}>{row.note}</Text>}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{(row: Annotation) => (
<Row>
<Button variant="quiet" onPress={() => setView({ mode: 'edit', annotation: row })}>
<IconLabel icon={<Edit />} />
</Button>
<AnnotationDeleteButton
annotationId={row.id}
websiteId={websiteId}
name={row.note}
/>
</Row>
)}
</DataColumn>
</DataTable>
)}
{data?.count > PAGE_SIZE && (
<Pager page={page} pageSize={PAGE_SIZE} count={data.count} onPageChange={setPage} />
)}
</Column>
);
}
@@ -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();
}
@@ -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,
});
});
@@ -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);
}
+74
View File
@@ -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<string>;
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<AnnotationMarker[]>([]);
const annotationsRef = useRef<ChartAnnotation[]>(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<number, AnnotationMarker>();
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 (
<Column gap="6">
<Box {...props}>
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
<canvas ref={canvas} style={{ position: 'absolute', top: 0, left: 0 }} />
{markers.length > 0 && (
<ChartAnnotationMarkers markers={markers} onClick={onAnnotationClick} />
)}
</div>
</Box>
<Legend items={legendItems} onClick={handleLegendClick} />
@@ -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;
}
@@ -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 (
<TooltipTrigger key={first.id} delay={0}>
<button
type="button"
className={styles.marker}
style={{ left: x, top: y, backgroundColor: colors.chart.views.hoverBorderColor }}
aria-label={annotations.map(({ label }) => label).join(', ')}
onClick={() => onClick?.(first)}
>
{annotations.length > 1 && <span className={styles.count}>{annotations.length}</span>}
</button>
<Tooltip placement="top">
<Column gap="3" className={styles.note}>
{annotations.map(annotation => (
<Column key={annotation.id} gap="1">
<Text size="sm" weight="bold">
{formatAnnotationDate(annotation, locale)}
</Text>
<Text size="sm">{annotation.label}</Text>
</Column>
))}
</Column>
</Tooltip>
</TooltipTrigger>
);
})}
</>
);
}
+1
View File
@@ -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';
@@ -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<string, string | number>,
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,
});
}
+3
View File
@@ -336,6 +336,9 @@ export const labels: Record<string, string> = {
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',
+13
View File
@@ -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()}`;
}
+6
View File
@@ -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(),
+64
View File
@@ -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 } });
}
+1
View File
@@ -1,3 +1,4 @@
export * from './annotation';
export * from './board';
export * from './link';
export * from './pixel';
+6
View File
@@ -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',
]);
+4
View File
@@ -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 },
});