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

+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',