Skip to content

Commit 6a0b6c3

Browse files
committed
feat(ui): Move checkInTimeline underscan to the left
Prior to this change, check-in timelines displayed a "underscan" area on the right side of the timeline container. This is the area that remains after we found the optimal rollup + bucket size + minimum underscan size. This area was visually indicated by a hashed gray indicator with a tooltip indicating that this area was "outside" of the selected time range (period). We received various feedback that the existence of this area is confusing. However, we can't just eliminate this area. You simply cannot divide 60 into 100 (as an example), so we need to constrain the size of the timeline that we render the selected period. We can however, instead of having the underscan at the end, simply place the underscan at the start and mvoe the starting gridline marker to where the actual period starts, and eschew the indication that there is underscan. There is an important note here. Previously when the underscan was at the end, there was no need to account for the fact that the size of the underscan area **will not always evenly fit the bucket sizes**. Since the underscan area is the remaining area in the timeline container, there's no way to guarantee that the buckets evenly fit into this area. In the old implementation we simply would not query the last bucket and it would never overflow out of the container. Now that the underscan is on the left, we need to account for the fact that the buckets may not be aligned to the actual size of the underscan. So we compute an additional underscanStartOffset that we'll use to move the ticks to the left by. This ensures the first bucket starts aligned at the pixel value that represents that bucket start time, allowing all following buckets to be correctly aligned. Before <img alt="clipboard.png" width="968" src="https://i.imgur.com/nirhIrF.png" /> After <img alt="clipboard.png" width="970" src="https://i.imgur.com/uOVIdLJ.png" /> Notice how the underscan has moved to the left side
1 parent c68bc4e commit 6a0b6c3

File tree

9 files changed

+109
-144
lines changed

9 files changed

+109
-144
lines changed

Diff for: static/app/components/checkInTimeline/checkInTooltip.spec.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {JobTickData, TimeWindowConfig} from './types';
88

99
const tickConfig: TimeWindowConfig = {
1010
start: new Date('2023-06-15T11:00:00Z'),
11+
periodStart: new Date('2023-06-15T11:00:00Z'),
1112
end: new Date('2023-06-15T12:00:00Z'),
1213
dateLabelFormat: getFormat({timeOnly: true, seconds: true}),
1314
elapsedMinutes: 60,
@@ -23,9 +24,9 @@ const tickConfig: TimeWindowConfig = {
2324
interval: 0,
2425
timelineUnderscanWidth: 0,
2526
totalBuckets: 0,
26-
underscanPeriod: 0,
27+
underscanBuckets: 0,
28+
underscanStartOffset: 0,
2729
},
28-
showUnderscanHelp: false,
2930
};
3031

3132
describe('CheckInTooltip', function () {

Diff for: static/app/components/checkInTimeline/gridLines.tsx

+34-74
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,21 @@ import moment from 'moment-timezone';
66

77
import {updateDateTime} from 'sentry/actionCreators/pageFilters';
88
import {DateTime} from 'sentry/components/dateTime';
9-
import {t} from 'sentry/locale';
109
import {space} from 'sentry/styles/space';
1110
import useRouter from 'sentry/utils/useRouter';
1211

13-
import QuestionTooltip from '../questionTooltip';
14-
1512
import {type CursorOffsets, useTimelineCursor} from './timelineCursor';
1613
import {useTimelineZoom} from './timelineZoom';
1714
import type {TimeWindowConfig} from './types';
1815

16+
/**
17+
* The number of pixels the underscan must be larger than to render the first
18+
* grid marker line. When the underscan is very small we don't want to render
19+
* the first marker line since it will be very close to the left side and can
20+
* look strange.
21+
*/
22+
const UNDERSCAN_MARKER_LINE_THRESHOLD = 10;
23+
1924
type LabelPosition = 'left-top' | 'center-bottom';
2025

2126
interface TimeMarker {
@@ -50,37 +55,44 @@ function alignDateToBoundary(date: moment.Moment, minuteInterval: number) {
5055
}
5156

5257
function getTimeMarkersFromConfig(config: TimeWindowConfig) {
53-
const {start, end, elapsedMinutes, intervals, dateTimeProps, timelineWidth} = config;
58+
const {periodStart, end, elapsedMinutes, intervals, dateTimeProps, timelineWidth} =
59+
config;
5460

5561
const {referenceMarkerInterval, minimumMarkerInterval, normalMarkerInterval} =
5662
intervals;
5763

64+
// Markers start after the underscan on the left
65+
const startOffset = config.rollupConfig.timelineUnderscanWidth;
66+
5867
const msPerPixel = (elapsedMinutes * 60 * 1000) / timelineWidth;
5968

6069
// The first marker will always be the starting time. This always renders the
6170
// full date and time
6271
const markers: TimeMarker[] = [
6372
{
64-
date: start,
65-
position: 0,
73+
date: periodStart,
74+
position: startOffset,
6675
dateTimeProps: {timeZone: true},
6776
},
6877
];
6978

7079
// The mark after the first mark will be aligned to a boundary to make it
7180
// easier to understand the rest of the marks
72-
const currentMark = alignDateToBoundary(moment(start), normalMarkerInterval);
81+
const currentMark = alignDateToBoundary(moment(periodStart), normalMarkerInterval);
7382

7483
// The first label is larger since we include the date, time, and timezone.
7584

76-
while (currentMark.isBefore(moment(start).add(referenceMarkerInterval, 'minutes'))) {
85+
while (
86+
currentMark.isBefore(moment(periodStart).add(referenceMarkerInterval, 'minutes'))
87+
) {
7788
currentMark.add(normalMarkerInterval, 'minute');
7889
}
7990

8091
// Generate time markers which represent location of grid lines/time labels.
8192
// Stop adding markers once there's no more room for more markers
8293
while (moment(currentMark).add(minimumMarkerInterval, 'minutes').isBefore(end)) {
83-
const position = (currentMark.valueOf() - start.valueOf()) / msPerPixel;
94+
const position =
95+
startOffset + (currentMark.valueOf() - periodStart.valueOf()) / msPerPixel;
8496
markers.push({date: currentMark.toDate(), position, dateTimeProps});
8597
currentMark.add(normalMarkerInterval, 'minutes');
8698
}
@@ -113,23 +125,6 @@ export function GridLineLabels({
113125
<TimeLabel date={date} {...dateTimeProps} />
114126
</TimeLabelContainer>
115127
))}
116-
{timeWindowConfig.showUnderscanHelp && (
117-
<TimeLabelContainer
118-
left={
119-
labelPosition === 'left-top'
120-
? timeWindowConfig.timelineWidth
121-
: timeWindowConfig.timelineWidth - 12
122-
}
123-
labelPosition={labelPosition}
124-
>
125-
<QuestionTooltip
126-
size="xs"
127-
title={t(
128-
'This area of the timeline is outside of your selected time range to allow for accurate rendering of markers.'
129-
)}
130-
/>
131-
</TimeLabelContainer>
132-
)}
133128
</LabelsContainer>
134129
);
135130
}
@@ -176,13 +171,17 @@ export function GridLineOverlay({
176171
labelPosition = 'left-top',
177172
}: GridLineOverlayProps) {
178173
const router = useRouter();
179-
const {start, timelineWidth, dateLabelFormat, rollupConfig} = timeWindowConfig;
174+
const {periodStart, timelineWidth, dateLabelFormat, rollupConfig} = timeWindowConfig;
175+
const {timelineUnderscanWidth} = rollupConfig;
180176

181177
const msPerPixel = (timeWindowConfig.elapsedMinutes * 60 * 1000) / timelineWidth;
182178

179+
// XXX: The dateFromPosition is aligned to the periodStart, which is relative
180+
// to the pixel value after the timelineUnderscanWidth.
183181
const dateFromPosition = useCallback(
184-
(position: number) => moment(start.getTime() + msPerPixel * position),
185-
[msPerPixel, start]
182+
(position: number) =>
183+
moment(periodStart.getTime() + msPerPixel * (position - timelineUnderscanWidth)),
184+
[msPerPixel, periodStart, timelineUnderscanWidth]
186185
);
187186

188187
const makeCursorLabel = useCallback(
@@ -217,33 +216,21 @@ export function GridLineOverlay({
217216
});
218217

219218
const overlayRef = mergeRefs(cursorContainerRef, selectionContainerRef);
220-
const markers = getTimeMarkersFromConfig(timeWindowConfig);
221-
222-
// Skip first gridline, this will be represented as a border on the
223-
// LabelsContainer
224-
markers.shift();
219+
const gridLine = getTimeMarkersFromConfig(timeWindowConfig);
225220

226-
if (timeWindowConfig.showUnderscanHelp) {
227-
markers.push({
228-
date: timeWindowConfig.end,
229-
position: timeWindowConfig.timelineWidth,
230-
dateTimeProps: {},
231-
});
221+
// Skip rendering of the first grid line marker when the underscan width is
222+
// below the threshold to be displayed
223+
if (timelineUnderscanWidth < UNDERSCAN_MARKER_LINE_THRESHOLD) {
224+
gridLine.shift();
232225
}
233226

234227
return (
235228
<Overlay aria-hidden ref={overlayRef} className={className}>
236229
{timelineCursor}
237230
{timelineSelector}
238231
{additionalUi}
239-
<Underscan
240-
labelPosition={labelPosition}
241-
style={{
242-
width: rollupConfig.timelineUnderscanWidth - 1,
243-
}}
244-
/>
245232
<GridLineContainer>
246-
{markers.map(({date, position}) => (
233+
{gridLine.map(({date, position}) => (
247234
<Gridline key={date.getTime()} left={position} labelPosition={labelPosition} />
248235
))}
249236
</GridLineContainer>
@@ -349,30 +336,3 @@ const TimeLabel = styled(DateTime)`
349336
color: ${p => p.theme.subText};
350337
pointer-events: none;
351338
`;
352-
353-
const Underscan = styled('div')<{labelPosition: LabelPosition}>`
354-
position: absolute;
355-
right: 0;
356-
background-size: 3px 3px;
357-
background-image: linear-gradient(
358-
45deg,
359-
${p => p.theme.translucentBorder} 25%,
360-
transparent 25%,
361-
transparent 50%,
362-
${p => p.theme.translucentBorder} 50%,
363-
${p => p.theme.translucentBorder} 75%,
364-
transparent 75%,
365-
transparent
366-
);
367-
${p =>
368-
p.labelPosition === 'left-top' &&
369-
css`
370-
height: calc(100% - 51px);
371-
margin-top: 51px;
372-
`}
373-
${p =>
374-
p.labelPosition === 'center-bottom' &&
375-
css`
376-
height: 100%;
377-
`}
378-
`;

Diff for: static/app/components/checkInTimeline/types.tsx

+17-10
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,16 @@ export interface RollupConfig {
3939
*/
4040
totalBuckets: number;
4141
/**
42-
* When there is an underscan we also will likely want to query the
43-
* additional time range for that underscan, this is the additional period of
44-
* time that the underscan represents in seconds.
42+
* How many total buckets are part of the underscan area
4543
*/
46-
underscanPeriod: number;
44+
underscanBuckets: number;
45+
/**
46+
* The negative pixel offset that must be applied to all ticks when the
47+
* underscan width cannot evenly fit each bucket. This happens because the
48+
* underscan is the "remaining" size of the timeine container and thus will
49+
* not always be an even multiple of the pixel bucket size.
50+
*/
51+
underscanStartOffset: number;
4752
}
4853

4954
export interface TimeWindowConfig {
@@ -68,16 +73,18 @@ export interface TimeWindowConfig {
6873
*/
6974
intervals: MarkerIntervals;
7075
/**
71-
* Configures how check-ins are bucketed into the timeline
76+
* The start of the window excluding the underscan period.
7277
*/
73-
rollupConfig: RollupConfig;
78+
periodStart: Date;
7479
/**
75-
* When true the underscan help indicator should be rendered after the date
76-
* time markers.
80+
* Configures how check-ins are bucketed into the timeline
7781
*/
78-
showUnderscanHelp: boolean;
82+
rollupConfig: RollupConfig;
7983
/**
80-
* The start of the window
84+
* The start of the window.
85+
*
86+
* NOTE that this includes the underscan period. The periodStart value is
87+
* what the selected period is actually configured for.
8188
*/
8289
start: Date;
8390
/**

Diff for: static/app/components/checkInTimeline/utils/getConfigFromTimeRange.spec.tsx

+26-16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import moment from 'moment-timezone';
2+
13
import {getFormat} from 'sentry/utils/dates';
24

35
import {getConfigFromTimeRange} from './getConfigFromTimeRange';
@@ -10,6 +12,7 @@ describe('getConfigFromTimeRange', function () {
1012
const end = new Date('2023-06-15T11:05:00Z');
1113
const config = getConfigFromTimeRange(start, end, timelineWidth);
1214
expect(config).toEqual({
15+
periodStart: start,
1316
start,
1417
end,
1518
dateLabelFormat: getFormat({timeOnly: true, seconds: true}),
@@ -20,9 +23,8 @@ describe('getConfigFromTimeRange', function () {
2023
timelineUnderscanWidth: 0,
2124
totalBuckets: 20,
2225
underscanBuckets: 0,
23-
underscanPeriod: 0,
26+
underscanStartOffset: 0,
2427
},
25-
showUnderscanHelp: false,
2628
intervals: {
2729
normalMarkerInterval: 1,
2830
minimumMarkerInterval: 0.625,
@@ -38,7 +40,10 @@ describe('getConfigFromTimeRange', function () {
3840
const end = new Date('2023-06-16T11:05:00Z');
3941
const config = getConfigFromTimeRange(start, end, timelineWidth);
4042
expect(config).toEqual({
41-
start,
43+
periodStart: start,
44+
start: moment(start)
45+
.subtract(60 * 154, 'seconds')
46+
.toDate(),
4247
end,
4348
dateLabelFormat: getFormat(),
4449
elapsedMinutes: 1445,
@@ -48,9 +53,8 @@ describe('getConfigFromTimeRange', function () {
4853
timelineUnderscanWidth: 77,
4954
totalBuckets: 1446,
5055
underscanBuckets: 154,
51-
underscanPeriod: 9240,
56+
underscanStartOffset: 0,
5257
},
53-
showUnderscanHelp: false,
5458
intervals: {
5559
normalMarkerInterval: 240,
5660
minimumMarkerInterval: 219.8478561549101,
@@ -66,7 +70,10 @@ describe('getConfigFromTimeRange', function () {
6670
const end = new Date('2023-06-15T23:00:00Z');
6771
const config = getConfigFromTimeRange(start, end, timelineWidth);
6872
expect(config).toEqual({
69-
start,
73+
periodStart: start,
74+
start: moment(start)
75+
.subtract(900 * 2, 'seconds')
76+
.toDate(),
7077
end,
7178
dateLabelFormat: getFormat({timeOnly: true}),
7279
elapsedMinutes: 900,
@@ -75,15 +82,14 @@ describe('getConfigFromTimeRange', function () {
7582
interval: 900,
7683
timelineUnderscanWidth: 20,
7784
totalBuckets: 60,
78-
underscanBuckets: 1,
79-
underscanPeriod: 900,
85+
underscanBuckets: 2,
86+
underscanStartOffset: 6,
8087
},
8188
intervals: {
8289
normalMarkerInterval: 120,
8390
minimumMarkerInterval: 115.38461538461537,
8491
referenceMarkerInterval: 132.69230769230768,
8592
},
86-
showUnderscanHelp: false,
8793
dateTimeProps: {timeOnly: true},
8894
timelineWidth: 780,
8995
});
@@ -94,7 +100,10 @@ describe('getConfigFromTimeRange', function () {
94100
const end = new Date('2023-06-15T11:00:00Z');
95101
const config = getConfigFromTimeRange(start, end, timelineWidth);
96102
expect(config).toEqual({
97-
start,
103+
periodStart: start,
104+
start: moment(start)
105+
.subtract(1800 * 112, 'seconds')
106+
.toDate(),
98107
end,
99108
dateLabelFormat: getFormat(),
100109
// 31 elapsed days
@@ -105,15 +114,14 @@ describe('getConfigFromTimeRange', function () {
105114
timelineUnderscanWidth: 56,
106115
totalBuckets: 1488,
107116
underscanBuckets: 112,
108-
underscanPeriod: 201600,
117+
underscanStartOffset: 0,
109118
},
110119
// 5 days in between each time label
111120
intervals: {
112121
normalMarkerInterval: 5 * 24 * 60,
113122
minimumMarkerInterval: 6000,
114123
referenceMarkerInterval: 6900,
115124
},
116-
showUnderscanHelp: false,
117125
dateTimeProps: {dateOnly: true},
118126
timelineWidth: 744,
119127
});
@@ -124,7 +132,10 @@ describe('getConfigFromTimeRange', function () {
124132
const end = new Date('2023-05-15T10:00:00Z');
125133
const config = getConfigFromTimeRange(start, end, timelineWidth);
126134
expect(config).toEqual({
127-
start,
135+
periodStart: start,
136+
start: moment(start)
137+
.subtract(900 * 2, 'seconds')
138+
.toDate(),
128139
end,
129140
dateLabelFormat: getFormat(),
130141
// 14 hours
@@ -134,10 +145,9 @@ describe('getConfigFromTimeRange', function () {
134145
interval: 900,
135146
timelineUnderscanWidth: 16,
136147
totalBuckets: 56,
137-
underscanBuckets: 1,
138-
underscanPeriod: 900,
148+
underscanBuckets: 2,
149+
underscanStartOffset: 12,
139150
},
140-
showUnderscanHelp: false,
141151
intervals: {
142152
normalMarkerInterval: 120,
143153
minimumMarkerInterval: 117.85714285714285,

0 commit comments

Comments
 (0)