Skip to content

Commit 32aaf31

Browse files
authored
Fixed date time wrong bug (#548)
1 parent fbc3975 commit 32aaf31

3 files changed

Lines changed: 195 additions & 40 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { calculateDateLabels } from './date-label-calculator';
18+
19+
describe('calculateDateLabels', () => {
20+
const DAY = 24 * 60 * 60 * 1000;
21+
22+
it('should calculate correct basic UTC day boundaries', () => {
23+
// 2026-03-12T00:00:00Z is 1773273600000
24+
const leftEdgeTime = new Date('2026-03-11T12:00:00Z').getTime();
25+
const visibleDuration = DAY * 2; // 2 days visible
26+
const pixelsPerMs = 1 / 1000 / 60; // 1 pixel per minute
27+
28+
const labels = calculateDateLabels(
29+
leftEdgeTime,
30+
visibleDuration,
31+
pixelsPerMs,
32+
0,
33+
);
34+
35+
expect(labels.length).toBe(3);
36+
37+
// Boundary 1: 2026-03-11T00:00:00Z (before left edge)
38+
expect(labels[0].labelLeft).toBe('2026/03/10');
39+
expect(labels[0].labelRight).toBe('2026/03/11');
40+
expect(labels[0].offsetX).toBe(-720);
41+
42+
// Boundary 2: 2026-03-12T00:00:00Z
43+
expect(labels[1].labelLeft).toBe('2026/03/11');
44+
expect(labels[1].labelRight).toBe('2026/03/12');
45+
expect(labels[1].offsetX).toBe(720);
46+
47+
// Boundary 3: 2026-03-13T00:00:00Z
48+
expect(labels[2].labelLeft).toBe('2026/03/12');
49+
expect(labels[2].labelRight).toBe('2026/03/13');
50+
});
51+
52+
it('should calculate correct day boundaries with +9 hours timezone shift', () => {
53+
// 2026-03-12T00:00:00+09:00 is 2026-03-11T15:00:00Z
54+
const leftEdgeTime = new Date('2026-03-11T12:00:00Z').getTime(); // 21:00:00+09:00
55+
const visibleDuration = DAY * 2;
56+
const pixelsPerMs = 1;
57+
58+
// Boundary 1: 2026-03-11T00:00:00+09:00 (2026-03-10T15:00:00Z)
59+
// Boundary 2: 2026-03-12T00:00:00+09:00 (2026-03-11T15:00:00Z)
60+
const expectedOffsetTime =
61+
new Date('2026-03-11T15:00:00Z').getTime() - leftEdgeTime;
62+
63+
const labels = calculateDateLabels(
64+
leftEdgeTime,
65+
visibleDuration,
66+
pixelsPerMs,
67+
9,
68+
);
69+
70+
expect(labels[1].labelLeft).toBe('2026/03/11');
71+
expect(labels[1].labelRight).toBe('2026/03/12');
72+
expect(labels[1].offsetX).toBe(expectedOffsetTime * pixelsPerMs);
73+
});
74+
75+
it('should calculate correct day boundaries with negative timezone shift (-8 hours)', () => {
76+
// 2026-03-12T00:00:00-08:00 is 2026-03-12T08:00:00Z
77+
const leftEdgeTime = new Date('2026-03-12T04:00:00Z').getTime(); // 20:00:00-08:00
78+
const visibleDuration = DAY * 2;
79+
const pixelsPerMs = 1;
80+
81+
// Boundary 1: 2026-03-11T00:00:00-08:00 (2026-03-11T08:00:00Z)
82+
// Boundary 2: 2026-03-12T00:00:00-08:00 (2026-03-12T08:00:00Z)
83+
const expectedOffsetTime =
84+
new Date('2026-03-12T08:00:00Z').getTime() - leftEdgeTime;
85+
86+
const labels = calculateDateLabels(
87+
leftEdgeTime,
88+
visibleDuration,
89+
pixelsPerMs,
90+
-8,
91+
);
92+
93+
expect(labels[1].labelLeft).toBe('2026/03/11');
94+
expect(labels[1].labelRight).toBe('2026/03/12');
95+
expect(labels[1].offsetX).toBe(expectedOffsetTime * pixelsPerMs);
96+
});
97+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Represents a date label on the timeline ruler at a specific day boundary.
19+
*
20+
* This label indicates the transition between two days. For example, exactly at the
21+
* midnight boundary between March 11 and March 12, `labelLeft` will be 'YYYY/MM/11'
22+
* and `labelRight` will be 'YYYY/MM/12'.
23+
*/
24+
export interface DateLabel {
25+
/** The horizontal offset from the left edge of the timeline in pixels representing the boundary time. */
26+
offsetX: number;
27+
/** The date string (YYYY/MM/DD) representing the day immediately before the boundary. */
28+
labelLeft: string;
29+
/** The date string (YYYY/MM/DD) representing the day starting exactly at the boundary. */
30+
labelRight: string;
31+
}
32+
33+
/**
34+
* Calculates the positions and formatted text for date labels (e.g., "YYYY/MM/DD") based on the current
35+
* visible time range on the timeline ruler. This ensures that day boundaries crossing the view
36+
* are correctly labeled.
37+
*
38+
* The calculation mathematically aligns to the exact local time midnight boundary by factoring in
39+
* the given timezone shift, ensuring that the visual representation maps exactly to the viewer's local day.
40+
*
41+
* @param leftEdgeTime The timestamp (in UTC milliseconds) corresponding to the visible left edge of the timeline.
42+
* @param visibleDuration The total duration (in milliseconds) visible on the screen.
43+
* @param pixelsPerMs The current zoom level, expressed as pixels per millisecond.
44+
* @param timezoneShiftHours The timezone offset in hours to apply. Positive values move ahead of UTC, negative values move behind.
45+
* @returns An array of `DateLabel` objects representing the day boundaries within the visible range.
46+
*/
47+
export function calculateDateLabels(
48+
leftEdgeTime: number,
49+
visibleDuration: number,
50+
pixelsPerMs: number,
51+
timezoneShiftHours: number,
52+
): DateLabel[] {
53+
const labels: DateLabel[] = [];
54+
const DAY = 60 * 60 * 24 * 1000;
55+
const timezoneShiftInMs = timezoneShiftHours * 60 * 60 * 1000;
56+
57+
const localLeftEdgeTime = leftEdgeTime + timezoneShiftInMs;
58+
const localPrevMidnight = Math.floor(localLeftEdgeTime / DAY) * DAY - DAY;
59+
let prevDayTime = localPrevMidnight - timezoneShiftInMs;
60+
61+
while (true) {
62+
const currentDayTime = prevDayTime + DAY;
63+
if (currentDayTime > leftEdgeTime + visibleDuration) {
64+
break;
65+
}
66+
labels.push({
67+
offsetX: (currentDayTime - leftEdgeTime) * pixelsPerMs,
68+
labelLeft: toDateLabel(currentDayTime - 1, timezoneShiftHours),
69+
labelRight: toDateLabel(currentDayTime, timezoneShiftHours),
70+
});
71+
prevDayTime = currentDayTime;
72+
}
73+
return labels;
74+
}
75+
76+
/**
77+
* Formats a given timestamp into a date string (YYYY/MM/DD) according to the configured timezone shift.
78+
*
79+
* @param time The timestamp in milliseconds to format.
80+
* @param timezoneShiftHours The timezone offset in hours applied to the formatting.
81+
* @returns The formatted date string.
82+
*/
83+
function toDateLabel(time: number, timezoneShiftHours: number): string {
84+
const date = new Date(time + timezoneShiftHours * 60 * 60 * 1000);
85+
const year = date.getUTCFullYear();
86+
const month = ('' + (date.getUTCMonth() + 1)).padStart(2, '0');
87+
const day = ('' + date.getUTCDate()).padStart(2, '0');
88+
return `${year}/${month}/${day}`;
89+
}

web/src/app/timeline/components/timeline-ruler.component.ts

Lines changed: 9 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,7 @@ import { KHIIconRegistrationModule } from 'src/app/shared/module/icon-registrati
3636
import { RenderingLoopManager } from './canvas/rendering-loop-manager';
3737
import { TimelineRulerViewModel } from './timeline-ruler.viewmodel';
3838
import { generateDefaultRulerStyle } from './style-model';
39-
40-
interface DateLabel {
41-
offsetX: number;
42-
labelLeft: string;
43-
labelRight: string;
44-
}
39+
import { calculateDateLabels } from './calculator/date-label-calculator';
4540

4641
/**
4742
* Component that renders the timeline ruler, displaying time ticks and date labels.
@@ -111,29 +106,14 @@ export class TimelineRulerComponent implements AfterViewInit {
111106
*/
112107
dateLabels = computed(() => {
113108
const viewModel = this.viewModel();
114-
const leftEdgeTime = this.leftEdgeTime();
115-
const pixelsPerMs = this.pixelsPerMs();
116-
const labels: DateLabel[] = [];
117-
const DAY = 60 * 60 * 24 * 1000;
118-
const timezoneShiftInMs = this.timezoneShift() * 60 * 60 * 1000;
119-
let prevDayTime =
120-
Math.floor(leftEdgeTime / DAY) * DAY - DAY - timezoneShiftInMs;
121-
while (true) {
122-
const currentDayTime = prevDayTime + DAY;
123-
if (
124-
currentDayTime >
125-
leftEdgeTime + viewModel.tickTimeMS * viewModel.histogramBuckets.length
126-
) {
127-
break;
128-
}
129-
labels.push({
130-
offsetX: (currentDayTime - leftEdgeTime) * pixelsPerMs,
131-
labelLeft: this.toDateLabel(currentDayTime - timezoneShiftInMs - DAY),
132-
labelRight: this.toDateLabel(currentDayTime - timezoneShiftInMs),
133-
});
134-
prevDayTime = currentDayTime;
135-
}
136-
return labels;
109+
const visibleDuration =
110+
viewModel.tickTimeMS * viewModel.histogramBuckets.length;
111+
return calculateDateLabels(
112+
this.leftEdgeTime(),
113+
visibleDuration,
114+
this.pixelsPerMs(),
115+
this.timezoneShift(),
116+
);
137117
});
138118

139119
private rulerCanvasRenderer!: TimelineRulerCanvasRenderer;
@@ -230,17 +210,6 @@ export class TimelineRulerComponent implements AfterViewInit {
230210
});
231211
}
232212

233-
/**
234-
* Formats a timestamp into a date string (YYYY/MM/DD) according to the configured timezone shift.
235-
*/
236-
private toDateLabel(time: number): string {
237-
const date = new Date(time + this.timezoneShift() * 60 * 60 * 1000);
238-
const year = date.getUTCFullYear();
239-
const month = ('' + (date.getUTCMonth() + 1)).padStart(2, '0');
240-
const day = ('' + date.getUTCDate()).padStart(2, '0');
241-
return `${year}/${month}/${day}`;
242-
}
243-
244213
mouseEnter() {
245214
this.scalingMode.set(true);
246215
}

0 commit comments

Comments
 (0)