Annotations workflow, UI/UX polish

This commit is contained in:
Francis Cao committed 2026-09-03 12:59:56 -07:00
1 parent 26e236c95c
commit 336fbcb3c5
18 files changed
+463 -87

No files matched your search

+1
View File
@@ -443,6 +443,7 @@
"incorrect-username-password": "Incorrect username/password.",
"invalid-domain": "Invalid domain",
"invalid-value": "Invalid value",
"max-length": "Maximum length of {n} characters",
"min-password-length": "Minimum length of {n} characters",
"new-version-available": "A new version of Umami {version} is available!",
"no-data-available": "No data available.",
+1
View File
@@ -452,6 +452,7 @@
"incorrect-username-password": "Incorrect username and/or password.",
"invalid-domain": "Invalid domain. Do not include http/https.",
"invalid-value": "Invalid value",
"max-length": "Maximum length of {n} characters",
"min-password-length": "Minimum length of {n} characters",
"new-version-available": "A new version of Umami {version} is available!",
"no-data-available": "No data available.",
+12
View File
@@ -46,6 +46,7 @@ export function TopNav() {
paddingRight="5"
width="100%"
zIndex={100}
backgroundColor="surface-raised"
>
<Row alignItems="center">
<TeamsButton />
@@ -101,6 +102,17 @@ export function TopNav() {
</>
)}
</Row>
<div
style={{
position: 'absolute',
bottom: -16,
left: 0,
right: 0,
height: 16,
background: 'linear-gradient(to bottom, var(--zen-surface-raised), transparent)',
pointerEvents: 'none',
}}
/>
</Row>
);
}
@@ -1,3 +1,4 @@
import { isSameDay } from 'date-fns';
import { useCallback, useMemo } from 'react';
import type { ChartAnnotation } from '@/components/charts/ChartAnnotationMarkers';
import { LoadingPanel } from '@/components/common/LoadingPanel';
@@ -10,30 +11,37 @@ import {
} from '@/components/hooks';
import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery';
import { PageviewsChart } from '@/components/metrics/PageviewsChart';
import { getAnnotationDateRangeValue } from '@/lib/annotations';
import { type AnnotationRange, getAnnotationDateRangeValue } from '@/lib/annotations';
import { DATE_FUNCTIONS } from '@/lib/date';
export function WebsiteChart({
websiteId,
compareMode,
showAnnotations,
onAnnotationMoreClick,
}: {
websiteId: string;
compareMode?: boolean;
showAnnotations?: boolean;
onAnnotationMoreClick?: (range: AnnotationRange) => void;
}) {
const { timezone, localFromUtc } = useTimezone();
const { timezone, localFromUtc, localToUtc } = 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: annotationData } = useWebsiteAnnotationsQuery(
websiteId,
{ startAt, endAt, pageSize: 1000 },
{ enabled: !!showAnnotations && !!websiteId },
);
const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({
websiteId,
compare: compareMode ? dateCompare?.compare : undefined,
});
const { pageviews, sessions, compare } = (data || {}) as any;
const canDrillIntoAnnotation =
unit !== 'hour' && unit !== 'minute' && !isSameDay(startDate, endDate);
const chartData = useMemo(() => {
if (!data) {
@@ -61,19 +69,47 @@ 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 isSubDayUnit = unit === 'hour' || unit === 'minute';
return (annotationData?.data || [])
.filter(({ allDay }) => !isSubDayUnit || allDay === false)
.map(({ id, date, note, allDay }) => {
const annotationDate = localFromUtc(new Date(date));
return {
id,
date: annotationDate,
markerDate: DATE_FUNCTIONS[unit].start(annotationDate),
label: note,
allDay,
isClickable: canDrillIntoAnnotation,
isGroupClickable: unit === 'month',
};
});
}, [annotationData, timezone, unit, canDrillIntoAnnotation]);
const handleAnnotationClick = useCallback(
(annotation: ChartAnnotation) => {
(annotations: ChartAnnotation[]) => {
const [annotation] = annotations;
const hasOneDate = annotations.every(item => isSameDay(item.date, annotation.date));
if (hasOneDate) {
router.push(
updateParams({
date: getAnnotationDateRangeValue(annotation.date, annotation.allDay !== false),
offset: undefined,
}),
);
return;
}
const markerDate = annotation.markerDate || annotation.date;
const startDate = DATE_FUNCTIONS.month.start(markerDate);
const endDate = DATE_FUNCTIONS.month.end(markerDate);
router.push(
updateParams({
date: getAnnotationDateRangeValue(annotation.date, annotation.allDay !== false),
date: `range:${startDate.getTime()}:${endDate.getTime()}`,
offset: undefined,
}),
);
@@ -81,6 +117,19 @@ export function WebsiteChart({
[router, updateParams],
);
const handleAnnotationMoreClick = useCallback(
(annotations: ChartAnnotation[]) => {
const markerDate = annotations[0].markerDate || annotations[0].date;
const { start, end } = DATE_FUNCTIONS[unit];
onAnnotationMoreClick?.({
startAt: +localToUtc(start(markerDate)),
endAt: +localToUtc(end(markerDate)),
});
},
[localToUtc, onAnnotationMoreClick, unit],
);
return (
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
<PageviewsChart
@@ -91,6 +140,7 @@ export function WebsiteChart({
unit={unit}
annotations={annotations}
onAnnotationClick={handleAnnotationClick}
onAnnotationMoreClick={onAnnotationMoreClick ? handleAnnotationMoreClick : undefined}
/>
</LoadingPanel>
);
@@ -1,15 +1,21 @@
'use client';
import { Column, Row } from '@umami/react-zen';
import { useState } from 'react';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { Panel } from '@/components/common/Panel';
import { DialogButton } from '@/components/input/DialogButton';
import type { AnnotationRange } from '@/lib/annotations';
import { UnitFilter } from '@/components/input/UnitFilter';
import { AnnotationsButton } from './annotations/AnnotationsButton';
import { AnnotationsModal } from './annotations/AnnotationsModal';
import { WebsiteChart } from './WebsiteChart';
import { WebsiteControls } from './WebsiteControls';
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
import { WebsitePanels } from './WebsitePanels';
export function WebsitePage({ websiteId }: { websiteId: string }) {
const [annotationRange, setAnnotationRange] = useState<AnnotationRange | null>(null);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} allowBounceFilter={true} />
@@ -18,13 +24,36 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
<Row justifyContent="end">
<UnitFilter />
</Row>
<WebsiteChart websiteId={websiteId} />
<WebsiteChart
websiteId={websiteId}
showAnnotations
onAnnotationMoreClick={setAnnotationRange}
/>
<Row justifyContent="end">
<AnnotationsButton websiteId={websiteId} />
</Row>
</Panel>
<WebsitePanels websiteId={websiteId} />
<ExpandedViewModal websiteId={websiteId} />
<DialogButton
isOpen={!!annotationRange}
onOpenChange={isOpen => !isOpen && setAnnotationRange(null)}
title={null}
width="800px"
>
{({ close }) =>
annotationRange && (
<AnnotationsModal
websiteId={websiteId}
range={annotationRange}
onClose={() => {
close();
setAnnotationRange(null);
}}
/>
)
}
</DialogButton>
</Column>
);
}
@@ -6,12 +6,10 @@ 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();
@@ -33,10 +31,7 @@ export function AnnotationDeleteButton({
<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>,
})}
message={t(messages.confirmDelete, { target: t(labels.note) })}
isLoading={isPending}
error={error}
onConfirm={handleConfirm.bind(null, close)}
@@ -1,17 +1,23 @@
import {
Button,
Calendar as ZenCalendar,
Column,
DatePicker,
DialogTrigger,
Form,
FormButtons,
FormField,
FormSubmitButton,
Icon,
Label,
Popover,
Text,
TextField,
} from '@umami/react-zen';
import { startOfDay } from 'date-fns';
import { useMemo, useState } from 'react';
import { useLocale, useMessages, useTimezone, useUpdateQuery } from '@/components/hooks';
import { Calendar } from '@/components/icons';
import { formatDate } from '@/lib/date';
export interface Annotation {
id: string;
@@ -21,6 +27,51 @@ export interface Annotation {
note: string;
}
function AnnotationDatePicker({
date,
label,
locale,
onChange,
}: {
date: Date;
label: string;
locale: string;
onChange: (date: Date) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const handleChange = (value: Date) => {
onChange(value);
setIsOpen(false);
};
return (
<Column gap="1">
<Label>{label}</Label>
<DialogTrigger overlayType="popover" isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
variant="outline"
aria-label={label}
style={{ width: 'fit-content', cursor: 'pointer', flexDirection: 'row-reverse' }}
>
<Icon size="sm">
<Calendar />
</Icon>
{formatDate(date, 'PP', locale)}
</Button>
<Popover
side="bottom"
align="start"
sideOffset={8}
className="min-w-[340px] bg-surface-overlay border border-edge-muted rounded-lg shadow-lg p-4"
>
<ZenCalendar value={date} onChange={handleChange} />
</Popover>
</DialogTrigger>
</Column>
);
}
export function AnnotationEditForm({
annotation,
websiteId,
@@ -70,29 +121,51 @@ export function AnnotationEditForm({
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>
{({ watch }) => {
const noteLength = (watch('note') || '').length;
const isOverLimit = noteLength > 500;
<FormField
name="note"
label={t(labels.note)}
rules={{ required: t(labels.required), maxLength: 500 }}
>
<TextField asTextArea autoFocus />
</FormField>
return (
<Column gap>
<AnnotationDatePicker
label={t(labels.date)}
date={date}
locale={locale}
onChange={setDate}
/>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Column>
<Column gap="1">
<FormField
name="note"
label={t(labels.note)}
rules={{
required: t(labels.required),
maxLength: { value: 500, message: t(messages.maxLength, { n: '500' }) },
}}
>
<TextField asTextArea autoFocus style={{ minHeight: 128 }} />
</FormField>
<Text
size="sm"
color="muted"
align="right"
style={isOverLimit ? { color: 'var(--zen-status-error)' } : undefined}
>
{noteLength} / 500
</Text>
</Column>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{t(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{t(labels.save)}
</FormSubmitButton>
</FormButtons>
</Column>
);
}}
</Form>
);
}
@@ -1,15 +1,11 @@
import { useMessages, useShare } from '@/components/hooks';
import { useDateParameters, useMessages } 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;
}
const { startAt, endAt } = useDateParameters();
return (
<DialogButton
@@ -19,7 +15,9 @@ export function AnnotationsButton({ websiteId }: { websiteId: string }) {
variant="quiet"
width="800px"
>
{({ close }) => <AnnotationsModal websiteId={websiteId} onClose={close} />}
{({ close }) => (
<AnnotationsModal websiteId={websiteId} range={{ startAt, endAt }} onClose={close} />
)}
</DialogButton>
);
}
@@ -0,0 +1,66 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { render, screen } from '@/test/render';
import { AnnotationsModal } from './AnnotationsModal';
const mockUseWebsiteAnnotationsQuery = vi.fn();
vi.mock('@/components/charts/ChartAnnotationMarkers', () => ({
formatAnnotationDate: () => '',
}));
vi.mock('@/components/hooks', () => ({
useLocale: () => ({ locale: 'en-US' }),
useMessages: () => ({
t: (value: string) => value,
labels: {
addNote: 'Add note',
all: 'All',
current: 'Current',
date: 'Date',
dateRange: 'date range',
note: 'Note',
notes: 'Notes',
},
messages: {
noDataAvailable: 'No data available',
},
}),
useMobile: () => ({ isMobile: false }),
useNavigation: () => ({ router: { push: vi.fn() }, updateParams: vi.fn() }),
useShare: () => null,
useTimezone: () => ({ localFromUtc: (date: Date) => date }),
useWebsiteAnnotationsQuery: (...args: unknown[]) => mockUseWebsiteAnnotationsQuery(...args),
}));
vi.mock('@/components/input/FilterButtons', () => ({
FilterButtons: ({ onChange }: { onChange: (value: string) => void }) => (
<button type="button" onClick={() => onChange('all')}>
All notes
</button>
),
}));
beforeEach(() => {
mockUseWebsiteAnnotationsQuery.mockClear();
mockUseWebsiteAnnotationsQuery.mockReturnValue({ data: { data: [], count: 0 }, isLoading: false });
});
test('defaults to the supplied date range and can show all notes', async () => {
const { user } = render(
<AnnotationsModal websiteId="website-1" range={{ startAt: 1000, endAt: 2000 }} />,
);
expect(mockUseWebsiteAnnotationsQuery).toHaveBeenLastCalledWith('website-1', {
page: 1,
pageSize: 20,
startAt: 1000,
endAt: 2000,
});
await user.click(screen.getByRole('button', { name: 'All notes' }));
expect(mockUseWebsiteAnnotationsQuery).toHaveBeenLastCalledWith('website-1', {
page: 1,
pageSize: 20,
});
});
@@ -13,44 +13,61 @@ 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 { FilterButtons } from '@/components/input/FilterButtons';
import {
useLocale,
useMessages,
useMobile,
useNavigation,
useShare,
useTimezone,
useWebsiteAnnotationsQuery,
} from '@/components/hooks';
import { Edit, Plus } from '@/components/icons';
import { getAnnotationDateRangeValue } from '@/lib/annotations';
import { type AnnotationRange, getAnnotationDateRangeValue } from '@/lib/annotations';
import { AnnotationDeleteButton } from './AnnotationDeleteButton';
import { type Annotation, AnnotationEditForm } from './AnnotationEditForm';
const PAGE_SIZE = 20;
const RANGE_FILTER = 'range';
const ALL_FILTER = 'all';
type View = { mode: 'list' } | { mode: 'add' } | { mode: 'edit'; annotation: Annotation };
export function AnnotationsModal({
websiteId,
range,
onClose,
}: {
websiteId: string;
range: AnnotationRange;
onClose?: () => void;
}) {
const { t, labels } = useMessages();
const { locale } = useLocale();
const { localFromUtc } = useTimezone();
const { isMobile } = useMobile();
const share = useShare();
const { router, updateParams } = useNavigation();
const [view, setView] = useState<View>({ mode: 'list' });
const [page, setPage] = useState(1);
const [filter, setFilter] = useState(RANGE_FILTER);
const { data, isLoading } = useWebsiteAnnotationsQuery(websiteId, {
page,
pageSize: PAGE_SIZE,
...(filter === RANGE_FILTER && range),
});
const showList = () => setView({ mode: 'list' });
const toLocalDate = (annotation: Annotation) => localFromUtc(new Date(annotation.date));
const handleFilterChange = (value: string) => {
setFilter(value);
setPage(1);
};
const handleSelect = (annotation: Annotation) => {
router.push(
updateParams({
@@ -85,15 +102,25 @@ export function AnnotationsModal({
<Column gap>
<Row alignItems="center" justifyContent="space-between">
<Heading size="xl">{t(labels.notes)}</Heading>
<Button variant="primary" onPress={() => setView({ mode: 'add' })}>
<Plus />
{t(labels.addNote)}
</Button>
{!share && (
<Button variant="primary" onPress={() => setView({ mode: 'add' })}>
<Plus />
{t(labels.addNote)}
</Button>
)}
</Row>
<FilterButtons
value={filter}
onChange={handleFilterChange}
items={[
{ id: RANGE_FILTER, label: `${t(labels.current)} ${t(labels.dateRange)}` },
{ id: ALL_FILTER, label: `${t(labels.all)} ${t(labels.notes)}` },
]}
/>
{rows.length === 0 ? (
<Empty />
) : (
<DataTable data={rows}>
<DataTable data={rows} displayMode={isMobile ? 'cards' : 'table'}>
<DataColumn id="date" label={t(labels.date)} width="200px">
{(row: Annotation) => (
<Button onPress={() => handleSelect(row)}>
@@ -107,25 +134,31 @@ export function AnnotationsModal({
<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>
{!share && (
<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} />
</Row>
)}
</DataColumn>
)}
</DataTable>
)}
{data?.count > PAGE_SIZE && (
<Pager page={page} pageSize={PAGE_SIZE} count={data.count} onPageChange={setPage} />
)}
{onClose && (
<Row justifyContent="flex-end">
<Button onPress={onClose}>{t(labels.close)}</Button>
</Row>
)}
</Column>
);
}
+16 -3
View File
@@ -12,6 +12,7 @@ import {
ChartAnnotationMarkers,
} from '@/components/charts/ChartAnnotationMarkers';
import { Legend } from '@/components/metrics/Legend';
import { getChartBucketIndex } from '@/lib/charts';
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
ChartJS.defaults.font.family = 'Inter';
@@ -26,7 +27,8 @@ export interface ChartProps extends BoxProps {
hiddenLabels?: Set<string>;
onLegendClick?: (label: string, willBeHidden: boolean) => void;
annotations?: ChartAnnotation[];
onAnnotationClick?: (annotation: ChartAnnotation) => void;
onAnnotationClick?: (annotations: ChartAnnotation[]) => void;
onAnnotationMoreClick?: (annotations: ChartAnnotation[]) => void;
}
function isSameMarkers(a: AnnotationMarker[], b: AnnotationMarker[]) {
@@ -53,6 +55,7 @@ export function Chart({
onLegendClick,
annotations,
onAnnotationClick,
onAnnotationMoreClick,
...props
}: ChartProps) {
const canvas = useRef(null);
@@ -79,7 +82,13 @@ export function Chart({
const groups = new Map<number, AnnotationMarker>();
for (const annotation of list) {
const x = Math.round(scales.x.getPixelForValue(annotation.date.getTime()));
const markerDate = annotation.markerDate || annotation.date;
const bucketIndex = getChartBucketIndex(
instance.data.datasets[0]?.data || [],
markerDate,
);
const bucket = instance.getDatasetMeta(0)?.data[bucketIndex];
const x = Math.round(bucket?.x ?? scales.x.getPixelForValue(markerDate.getTime()));
if (!Number.isFinite(x) || x < chartArea.left || x > chartArea.right) {
continue;
@@ -217,7 +226,11 @@ export function Chart({
<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} />
<ChartAnnotationMarkers
markers={markers}
onClick={onAnnotationClick}
onMoreClick={onAnnotationMoreClick}
/>
)}
</div>
</Box>
@@ -37,6 +37,34 @@
.note {
max-width: 300px;
max-height: min(280px, 50vh);
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.noteFull {
display: block;
}
.noteText {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.moreButton {
align-self: center;
min-height: 0;
padding: 2px 4px;
border-radius: 4px;
color: inherit;
opacity: 0.8;
}
.moreButton:hover,
.moreButton:focus-visible {
background-color: rgba(255, 255, 255, 0.15);
opacity: 1;
}
@@ -1,15 +1,23 @@
import { Column, Text, Tooltip, TooltipTrigger, useTheme } from '@umami/react-zen';
import { useMemo } from 'react';
import { useLocale } from '@/components/hooks';
import { IconLabel } from '@/components/common/IconLabel';
import { useLocale, useMessages } from '@/components/hooks';
import { Maximize } from '@/components/icons';
import { getThemeColors } from '@/lib/colors';
import { formatDate } from '@/lib/date';
import { Button, Column, Text, Tooltip, TooltipTrigger, useTheme } from '@umami/react-zen';
import { isSameDay } from 'date-fns';
import { useMemo } from 'react';
import styles from './ChartAnnotationMarkers.module.css';
const MAX_TOOLTIP_ANNOTATIONS = 3;
export interface ChartAnnotation {
id: string;
date: Date;
markerDate?: Date;
label: string;
allDay?: boolean;
isClickable?: boolean;
isGroupClickable?: boolean;
}
export interface AnnotationMarker {
@@ -25,40 +33,80 @@ export function formatAnnotationDate(annotation: ChartAnnotation, locale: string
export function ChartAnnotationMarkers({
markers,
onClick,
onMoreClick,
}: {
markers: AnnotationMarker[];
onClick?: (annotation: ChartAnnotation) => void;
onClick?: (annotations: ChartAnnotation[]) => void;
onMoreClick?: (annotations: ChartAnnotation[]) => void;
}) {
const { theme } = useTheme();
const { locale } = useLocale();
const { t, labels } = useMessages();
const { colors } = useMemo(() => getThemeColors(theme), [theme]);
return (
<>
{markers.map(({ annotations, x, y }) => {
const [first] = annotations;
const hasOneDate = annotations.every(annotation => isSameDay(annotation.date, first.date));
const isClickable =
!!onClick &&
(hasOneDate
? first.isClickable !== false
: annotations.every(annotation => annotation.isGroupClickable === true));
const visibleAnnotations = annotations.slice(0, MAX_TOOLTIP_ANNOTATIONS);
return (
<TooltipTrigger key={first.id} delay={0}>
<button
type="button"
className={styles.marker}
style={{ left: x, top: y, backgroundColor: colors.chart.views.hoverBorderColor }}
style={{
left: x,
top: y,
cursor: isClickable ? 'pointer' : 'default',
backgroundColor: colors.chart.views.hoverBorderColor,
}}
aria-label={annotations.map(({ label }) => label).join(', ')}
onClick={() => onClick?.(first)}
onClick={isClickable ? () => onClick?.(annotations) : undefined}
>
{annotations.length > 1 && <span className={styles.count}>{annotations.length}</span>}
</button>
<Tooltip placement="top">
<Column gap="3" className={styles.note}>
{annotations.map(annotation => (
<Column
gap="3"
padding="1"
borderRadius="md"
className={styles.note}
style={{ backgroundColor: 'rgba(0,0,0,0.2)', color: 'white' }}
>
{visibleAnnotations.map(annotation => (
<Column key={annotation.id} gap="1">
<Text size="sm" weight="bold">
{formatAnnotationDate(annotation, locale)}
</Text>
<Text size="sm">{annotation.label}</Text>
<Text
size="sm"
className={annotations.length === 1 ? styles.noteFull : styles.noteText}
>
{annotation.label}
</Text>
</Column>
))}
{annotations.length > 1 &&
(onMoreClick ? (
<Button
variant="quiet"
className={styles.moreButton}
onPress={() => onMoreClick(annotations)}
>
<IconLabel icon={<Maximize />}>{t(labels.view)}</IconLabel>
</Button>
) : (
<IconLabel icon={<Maximize />} style={{ opacity: 0.8 }}>
{t(labels.view)}
</IconLabel>
))}
</Column>
</Tooltip>
</TooltipTrigger>
+2 -2
View File
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
import { useMessages } from '@/components/hooks';
export interface ConfirmationFormProps {
message: ReactNode;
message?: ReactNode;
buttonLabel?: ReactNode;
buttonVariant?: 'primary' | 'quiet' | 'danger';
isLoading?: boolean;
@@ -25,7 +25,7 @@ export function ConfirmationForm({
return (
<Form onSubmit={onConfirm} error={getErrorMessage(error)}>
<Box marginY="4">{message}</Box>
{message && <Box marginY="4">{message}</Box>}
<FormButtons>
<Button onPress={onClose}>{t(labels.cancel)}</Button>
<FormSubmitButton
+1
View File
@@ -449,6 +449,7 @@ export const messages: Record<string, string> = {
confirmDelete: 'message.confirm-delete',
confirmRemove: 'message.confirm-remove',
confirmLeave: 'message.confirm-leave',
maxLength: 'message.max-length',
minPasswordLength: 'message.min-password-length',
noTeams: 'message.no-teams',
shareUrl: 'message.share-url',
+5
View File
@@ -1,5 +1,10 @@
import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns';
export interface AnnotationRange {
startAt: number;
endAt: number;
}
/**
* Returns the `date` URL parameter value that filters the website view
* to the day (all-day annotation) or hour (timed annotation) of an annotation.
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { renderNumberLabels } from './charts';
import { getChartBucketIndex, renderNumberLabels } from './charts';
// test for renderNumberLabels
@@ -38,3 +38,11 @@ describe('renderNumberLabels', () => {
expect(renderNumberLabels(input)).toBe(expected);
});
});
describe('getChartBucketIndex', () => {
test('places an annotation in its monthly data bucket', () => {
const data = [{ x: '2026-08' }, { x: '2026-09' }];
expect(getChartBucketIndex(data, new Date('2026-08-27T07:00:00.000Z'))).toBe(0);
});
});
+15
View File
@@ -24,3 +24,18 @@ export function renderDateLabels(unit: string, locale: string) {
}
};
}
export function getChartBucketIndex(data: any[], date: Date) {
let index = -1;
for (let i = 0; i < data.length; i++) {
const timestamp = new Date(data[i]?.x ?? data[i]).getTime();
if (!Number.isFinite(timestamp)) continue;
if (timestamp > date.getTime()) break;
index = i;
}
return index;
}