diff --git a/public/intl/messages/en-GB.json b/public/intl/messages/en-GB.json index 03413b19d..7324a4d1a 100644 --- a/public/intl/messages/en-GB.json +++ b/public/intl/messages/en-GB.json @@ -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.", diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 34b26c98f..02a4001f6 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -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.", diff --git a/src/app/(main)/TopNav.tsx b/src/app/(main)/TopNav.tsx index 0c253842a..829d63444 100644 --- a/src/app/(main)/TopNav.tsx +++ b/src/app/(main)/TopNav.tsx @@ -46,6 +46,7 @@ export function TopNav() { paddingRight="5" width="100%" zIndex={100} + backgroundColor="surface-raised" > @@ -101,6 +102,17 @@ export function TopNav() { )} +
); } diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx index 10564f45a..56b45482d 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx @@ -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(() => { - 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 ( ); diff --git a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx index 74a09acc3..710297c20 100644 --- a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx @@ -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(null); + return ( @@ -18,13 +24,36 @@ export function WebsitePage({ websiteId }: { websiteId: string }) { - + + !isOpen && setAnnotationRange(null)} + title={null} + width="800px" + > + {({ close }) => + annotationRange && ( + { + close(); + setAnnotationRange(null); + }} + /> + ) + } + ); } diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationDeleteButton.tsx index 02aacb6c1..84afaad35 100644 --- a/src/app/(main)/websites/[websiteId]/annotations/AnnotationDeleteButton.tsx +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationDeleteButton.tsx @@ -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({ } variant="quiet" title={t(labels.confirm)} width="400px"> {({ close }) => ( {chunks}, - })} + message={t(messages.confirmDelete, { target: t(labels.note) })} isLoading={isPending} error={error} onConfirm={handleConfirm.bind(null, close)} diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationEditForm.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationEditForm.tsx index 7df122748..7dc80bcef 100644 --- a/src/app/(main)/websites/[websiteId]/annotations/AnnotationEditForm.tsx +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationEditForm.tsx @@ -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 ( + + + + + + + + + + ); +} + export function AnnotationEditForm({ annotation, websiteId, @@ -70,29 +121,51 @@ export function AnnotationEditForm({ onSubmit={handleSubmit} defaultValues={{ note: annotation?.note || '' }} > - - - - value && setDate(value)} /> - + {({ watch }) => { + const noteLength = (watch('note') || '').length; + const isOverLimit = noteLength > 500; - - - + return ( + + - - - - {t(labels.save)} - - - + + + + + + {noteLength} / 500 + + + + + + + {t(labels.save)} + + + + ); + }} ); } diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationsButton.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsButton.tsx index 9d04d5617..fa9546736 100644 --- a/src/app/(main)/websites/[websiteId]/annotations/AnnotationsButton.tsx +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsButton.tsx @@ -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 ( - {({ close }) => } + {({ close }) => ( + + )} ); } diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.test.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.test.tsx new file mode 100644 index 000000000..25661ce56 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.test.tsx @@ -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 }) => ( + + ), +})); + +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( + , + ); + + 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, + }); +}); diff --git a/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.tsx b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.tsx index 62c02b7c1..d39b1c2b1 100644 --- a/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.tsx +++ b/src/app/(main)/websites/[websiteId]/annotations/AnnotationsModal.tsx @@ -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({ 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({ {t(labels.notes)} - + {!share && ( + + )} + {rows.length === 0 ? ( ) : ( - + {(row: Annotation) => ( - - - )} - + {!share && ( + + {(row: Annotation) => ( + + + + + )} + + )} )} {data?.count > PAGE_SIZE && ( )} + {onClose && ( + + + + )} ); } diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx index bf86cfe71..a68a575b0 100644 --- a/src/components/charts/Chart.tsx +++ b/src/components/charts/Chart.tsx @@ -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; 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(); 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({
{markers.length > 0 && ( - + )}
diff --git a/src/components/charts/ChartAnnotationMarkers.module.css b/src/components/charts/ChartAnnotationMarkers.module.css index 62ea86cc1..d9b2c3749 100644 --- a/src/components/charts/ChartAnnotationMarkers.module.css +++ b/src/components/charts/ChartAnnotationMarkers.module.css @@ -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; +} diff --git a/src/components/charts/ChartAnnotationMarkers.tsx b/src/components/charts/ChartAnnotationMarkers.tsx index 4c02435da..0393c49fb 100644 --- a/src/components/charts/ChartAnnotationMarkers.tsx +++ b/src/components/charts/ChartAnnotationMarkers.tsx @@ -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 ( - - {annotations.map(annotation => ( + + {visibleAnnotations.map(annotation => ( {formatAnnotationDate(annotation, locale)} - {annotation.label} + + {annotation.label} + ))} + {annotations.length > 1 && + (onMoreClick ? ( + + ) : ( + } style={{ opacity: 0.8 }}> + {t(labels.view)} + + ))} diff --git a/src/components/common/ConfirmationForm.tsx b/src/components/common/ConfirmationForm.tsx index fd7663369..7f658862e 100644 --- a/src/components/common/ConfirmationForm.tsx +++ b/src/components/common/ConfirmationForm.tsx @@ -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 (
- {message} + {message && {message}} = { 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', diff --git a/src/lib/annotations.ts b/src/lib/annotations.ts index e22a22387..4bd75c470 100644 --- a/src/lib/annotations.ts +++ b/src/lib/annotations.ts @@ -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. diff --git a/src/lib/charts.test.ts b/src/lib/charts.test.ts index 884022b0e..049b09ef6 100644 --- a/src/lib/charts.test.ts +++ b/src/lib/charts.test.ts @@ -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); + }); +}); diff --git a/src/lib/charts.ts b/src/lib/charts.ts index 1458cff84..d71bd7195 100644 --- a/src/lib/charts.ts +++ b/src/lib/charts.ts @@ -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; +}