Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions assets/js/dashboard/annotations/annotation-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,31 @@ const VerticalBar = () => (
)

export const AnnotationItemRow = ({ children }: { children: ReactNode }) => (
<div className="group flex flex-row gap-x-2">
<div className="relative group flex flex-row gap-x-2">
<VerticalBar />
{children}
</div>
)

export const AnnotationAuthorshipLine = ({
annotation
annotation,
showDateLabel
}: {
annotation: Annotation
showDateLabel: boolean
}) => (
<div className="flex items-baseline text-xs text-gray-300 pr-8">
<div
data-testid="annotation-attribution"
className="flex items-baseline text-xs text-gray-300 pr-8"
>
<span className="truncate min-w-0">
{getAnnotationAuthorship(annotation)}
</span>
<span className="whitespace-nowrap shrink-0">
{` • ${getAttributionDateLabel(annotation)}`}
</span>
{showDateLabel && (
<span className="whitespace-nowrap shrink-0">
&nbsp;{`• ${getAttributionDateLabel(annotation)}`}
</span>
)}
</div>
)

Expand Down
72 changes: 59 additions & 13 deletions assets/js/dashboard/annotations/annotations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {
canEditAnnotation,
getAnnotationAuthorship,
getAnnotationGranularity,
getAnnotationTimeLabel,
groupAnnotationsByTimeLabel
getAnnotationDatetimeGroup,
groupAnnotationsByDatetime,
allAnnotationsAreFromThisExactDatetime
} from './annotations'
import { Interval } from '../stats/graph/intervals'
import { Role, UserContextValue } from '../user-context'
Expand Down Expand Up @@ -199,7 +200,7 @@ describe(`${getAnnotationGranularity.name}`, () => {
})
})

describe(`${getAnnotationTimeLabel.name}`, () => {
describe(`${getAnnotationDatetimeGroup.name}`, () => {
// 2025-02-26 is a Wednesday
const dateAnnotation = {
datetime: '2025-02-26',
Expand All @@ -215,7 +216,9 @@ describe(`${getAnnotationTimeLabel.name}`, () => {
])(
`date-granularity annotation on ${dateAnnotation.datetime} bucketed to %s yields %s`,
(interval, expected) => {
expect(getAnnotationTimeLabel(dateAnnotation, interval)).toBe(expected)
expect(getAnnotationDatetimeGroup(dateAnnotation, interval)).toBe(
expected
)
}
)

Expand All @@ -227,17 +230,19 @@ describe(`${getAnnotationTimeLabel.name}`, () => {
[Interval.month, '2025-02-01'],
[Interval.week, '2025-02-24'],
[Interval.day, '2025-02-26'],
[Interval.hour, '2025-02-26 10:00:00'],
[Interval.minute, '2025-02-26 10:30:00']
[Interval.hour, '2025-02-26T10:00:00'],
[Interval.minute, '2025-02-26T10:30:00']
])(
`minute granularity annotation with datetime ${minuteAnnotation.datetime} bucketed to %s yields %s`,
(interval, expected) => {
expect(getAnnotationTimeLabel(minuteAnnotation, interval)).toBe(expected)
expect(getAnnotationDatetimeGroup(minuteAnnotation, interval)).toBe(
expected
)
}
)
})

describe(`${groupAnnotationsByTimeLabel.name}`, () => {
describe(`${groupAnnotationsByDatetime.name}`, () => {
const dateGranularity = AnnotationGranularity.date
const annotations = [
{ id: 1, datetime: '2025-02-24 00:00:00', granularity: dateGranularity }, // Mon
Expand All @@ -246,7 +251,7 @@ describe(`${groupAnnotationsByTimeLabel.name}`, () => {
]

it('groups annotations by day when the interval is day', () => {
const grouped = groupAnnotationsByTimeLabel(annotations, Interval.day)
const grouped = groupAnnotationsByDatetime(annotations, Interval.day)

expect(Object.keys(grouped).sort()).toEqual([
'2025-02-24',
Expand All @@ -259,15 +264,15 @@ describe(`${groupAnnotationsByTimeLabel.name}`, () => {
})

it('collapses annotations from the same week into one bucket', () => {
const grouped = groupAnnotationsByTimeLabel(annotations, Interval.week)
const grouped = groupAnnotationsByDatetime(annotations, Interval.week)

expect(Object.keys(grouped).sort()).toEqual(['2025-02-24', '2025-03-03'])
expect(grouped['2025-02-24']!.map((a) => a.id)).toEqual([1, 2])
expect(grouped['2025-03-03']!.map((a) => a.id)).toEqual([3])
})

it('collapses annotations from the same month into one bucket', () => {
const grouped = groupAnnotationsByTimeLabel(annotations, Interval.month)
const grouped = groupAnnotationsByDatetime(annotations, Interval.month)

expect(Object.keys(grouped).sort()).toEqual(['2025-02-01', '2025-03-01'])
expect(grouped['2025-02-01']!.map((a) => a.id)).toEqual([1, 2])
Expand All @@ -281,17 +286,58 @@ describe(`${groupAnnotationsByTimeLabel.name}`, () => {
{ id: 12, datetime: '2025-02-26 10:00:00', granularity: dateGranularity }
]

const grouped = groupAnnotationsByTimeLabel(sameDay, Interval.day)
const grouped = groupAnnotationsByDatetime(sameDay, Interval.day)

expect(grouped['2025-02-26']!.map((a) => a.id)).toEqual([10, 11, 12])
})

it('returns an empty object when there are no annotations', () => {
expect(
groupAnnotationsByTimeLabel(
groupAnnotationsByDatetime(
[] as { datetime: string; granularity: AnnotationGranularity }[],
Interval.day
)
).toEqual({})
})
})

describe(`${allAnnotationsAreFromThisExactDatetime.name}`, () => {
it('returns true when every annotation shares the given datetime', () => {
expect(
allAnnotationsAreFromThisExactDatetime(
[
{ datetime: '2025-02-26T10:30:00' },
{ datetime: '2025-02-26T10:30:00' }
],
'2025-02-26T10:30:00'
)
).toBe(true)
})

it('returns true when every annotation shares the given date', () => {
expect(
allAnnotationsAreFromThisExactDatetime(
[{ datetime: '2026-07-20' }, { datetime: '2026-07-20' }],
'2026-07-20'
)
).toBe(true)
})

it('returns false when some annotation has a different datetime', () => {
expect(
allAnnotationsAreFromThisExactDatetime(
[
{ datetime: '2025-02-26T10:30:00' },
{ datetime: '2025-02-26T10:31:00' }
],
'2025-02-26T10:30:00'
)
).toBe(false)
})

it('returns true when there are no annotations', () => {
expect(
allAnnotationsAreFromThisExactDatetime([], '2025-02-26T10:30:00')
).toBe(true)
})
})
21 changes: 13 additions & 8 deletions assets/js/dashboard/annotations/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ export type Annotation = {
note: string

id: number
/** datetime in site timezone, example 2025-02-26 10:00:00 */
/** datetime in site timezone, example 2025-02-26T10:00:00 */
inserted_at: string
/** datetime in site timezone, example 2025-02-26 10:00:00 */
/** datetime in site timezone, example 2025-02-26T10:00:00 */
updated_at: string
} & AnnotationOwnership

Expand Down Expand Up @@ -139,7 +139,7 @@ export function canAddAnnotation({
}
}

export const getAnnotationTimeLabel = (
export const getAnnotationDatetimeGroup = (
annotation: Pick<Annotation, 'datetime' | 'granularity'>,
interval: Interval
): string => {
Expand Down Expand Up @@ -175,24 +175,24 @@ export const getAnnotationTimeLabel = (
case Interval.hour: {
const [dateYYYYMMDD, timeHHMMSS] = annotation.datetime.split('T')
// floors time to hour
return `${dateYYYYMMDD} ${timeHHMMSS.substring(0, 'HH'.length)}:00:00`
return `${dateYYYYMMDD}T${timeHHMMSS.substring(0, 'HH'.length)}:00:00`
}
case Interval.minute:
return annotation.datetime.split('T').join(' ')
return annotation.datetime
}
}
}
}

export const groupAnnotationsByTimeLabel = <
export const groupAnnotationsByDatetime = <
T extends Pick<Annotation, 'datetime' | 'granularity'>
>(
annotations: T[],
interval: Interval
): Record<string, T[] | undefined> => {
return annotations.reduce<Record<string, T[]>>((acc, annotation) => {
const timeLabel = getAnnotationTimeLabel(annotation, interval)
return { ...acc, [timeLabel]: [...(acc[timeLabel] ?? []), annotation] }
const label = getAnnotationDatetimeGroup(annotation, interval)
return { ...acc, [label]: [...(acc[label] ?? []), annotation] }
}, {})
}

Expand All @@ -210,6 +210,11 @@ export const getAnnotationGranularity = (
}
}

export const allAnnotationsAreFromThisExactDatetime = (
annotations: Pick<Annotation, 'datetime'>[],
datetime: string
): boolean => annotations.every((a) => a.datetime === datetime)

export const getApiFormattedPayload = ({
granularity,
datetime,
Expand Down
18 changes: 15 additions & 3 deletions assets/js/dashboard/annotations/hover-annotations-list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import React from 'react'
import { Annotation } from './annotations'
import {
Annotation,
allAnnotationsAreFromThisExactDatetime
} from './annotations'
import {
AnnotationAuthorshipLine,
AnnotationItemRow,
Expand All @@ -10,20 +13,29 @@ import {
const MAX_PREVIEW = 2

export const HoverAnnotationsList = ({
annotationDatetime,
annotations
}: {
annotationDatetime: string
annotations: Annotation[]
}) => {
const preview = annotations.slice(0, MAX_PREVIEW)
const extra = annotations.length - MAX_PREVIEW
const showDateLabel = !allAnnotationsAreFromThisExactDatetime(
preview,
annotationDatetime
)

return (
<>
<AnnotationsListContainer>
{preview.map((annotation) => (
<AnnotationItemRow key={annotation.id}>
<div className="relative flex flex-col gap-y-px w-full max-w-64">
<AnnotationAuthorshipLine annotation={annotation} />
<div className="flex flex-col gap-y-px w-full max-w-64">
<AnnotationAuthorshipLine
annotation={annotation}
showDateLabel={showDateLabel}
/>
<AnnotationNote note={annotation.note} clamp />
</div>
</AnnotationItemRow>
Expand Down
21 changes: 17 additions & 4 deletions assets/js/dashboard/annotations/interactive-annotations-list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import React, { ReactNode } from 'react'
import { Annotation, canEditAnnotation } from './annotations'
import {
Annotation,
allAnnotationsAreFromThisExactDatetime,
canEditAnnotation
} from './annotations'
import {
AnnotationAuthorshipLine,
AnnotationItemRow,
Expand All @@ -17,10 +21,12 @@ const ScrollableArea = (props: { children: ReactNode }) => (
)

export const InteractiveAnnotationsList = ({
annotationDatetime,
annotations,
isTouchDevice,
closeTooltip
}: {
annotationDatetime: string
annotations: Annotation[]
isTouchDevice: boolean
closeTooltip: () => void
Expand All @@ -31,6 +37,10 @@ export const InteractiveAnnotationsList = ({
closeTooltip()
setModal({ type: 'update-annotation', annotation })
}
const showDateLabel = !allAnnotationsAreFromThisExactDatetime(
annotations,
annotationDatetime
)

return (
<ScrollableArea>
Expand All @@ -39,7 +49,10 @@ export const InteractiveAnnotationsList = ({
const editable = canEditAnnotation({ type: annotation.type, user })
const content = (
<>
<AnnotationAuthorshipLine annotation={annotation} />
<AnnotationAuthorshipLine
annotation={annotation}
showDateLabel={showDateLabel}
/>
<AnnotationNote note={annotation.note} />
{editable && !isTouchDevice && (
<button
Expand All @@ -56,13 +69,13 @@ export const InteractiveAnnotationsList = ({
<AnnotationItemRow key={annotation.id}>
{editable && isTouchDevice ? (
<button
className="relative flex flex-col gap-y-px w-full max-w-64 text-left focus:outline-none"
className="flex flex-col gap-y-px w-full max-w-64 text-left focus:outline-none"
onClick={() => openEdit(annotation)}
>
{content}
</button>
) : (
<div className="relative flex flex-col gap-y-px w-full max-w-64">
<div className="flex flex-col gap-y-px w-full max-w-64">
{content}
</div>
)}
Expand Down
15 changes: 14 additions & 1 deletion assets/js/dashboard/stats/graph/main-graph-data.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
getChangeInPercentagePoints,
getRelativeChange,
getLineSegments
getLineSegments,
normalizeGraphTimeLabel
} from './main-graph-data'

describe(`${getChangeInPercentagePoints.name}`, () => {
Expand Down Expand Up @@ -109,3 +110,15 @@ describe(`${getLineSegments.name}`, () => {
])
})
})

describe(`${normalizeGraphTimeLabel.name}`, () => {
it('replaces the space separator with "T" for datetime labels', () => {
expect(normalizeGraphTimeLabel('2026-10-10 10:00:15')).toBe(
'2026-10-10T10:00:15'
)
})

it('leaves date-only labels unchanged', () => {
expect(normalizeGraphTimeLabel('2026-10-10')).toBe('2026-10-10')
})
})
8 changes: 8 additions & 0 deletions assets/js/dashboard/stats/graph/main-graph-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,11 @@ type SeriesValue =
isCurrent: boolean
timeLabel: string
}

/**
* This function normalizes graph time labels in the format "2026-10-10 10:00:15"
* to the standard format "2026-10-10T10:00:15". Passes YYYY-MM-DD format date strings
* like "2026-01-01" through unchanged.
*/
export const normalizeGraphTimeLabel = (timeLabel: string) =>
timeLabel.split(' ').join('T')
Loading
Loading