fix(schedule): fix timezone issues when parsing ISO date strings

Created parseDbDateStr utility to parse 'YYYY-MM-DD' strings as local
dates instead of UTC. Using new Date('2026-01-12') parses as UTC
midnight, which becomes the previous day when converted to timezones
like Europe/Berlin (UTC+1) or America/Los_Angeles (UTC-8).

Fixed in:
- ScheduleService.getDayClass
- ScheduleMonthComponent.referenceMonth
- ScheduleWeekComponent._formatDateLabel
- Test expectations in multiple spec files

All tests now pass in both Europe/Berlin and America/Los_Angeles timezones.
This commit is contained in:
Johannes Millan 2026-01-21 11:14:25 +01:00
parent 996f7e1210
commit 0e13e14520
6 changed files with 28 additions and 9 deletions

View file

@ -2,6 +2,7 @@ import { createScheduleDays } from './create-schedule-days';
import { DEFAULT_TASK, TaskWithoutReminder } from '../../tasks/task.model';
import { PlannerDayMap } from '../../planner/planner.model';
import { BlockedBlockByDayMap } from '../schedule.model';
import { parseDbDateStr } from '../../../util/parse-db-date-str';
// Helper function to create test tasks with required properties
const createTestTask = (
@ -386,7 +387,7 @@ describe('createScheduleDays - Task Filtering', () => {
});
// Viewing two days in next week
const secondDayNextWeek = new Date(nextWeekStr);
const secondDayNextWeek = parseDbDateStr(nextWeekStr);
secondDayNextWeek.setDate(secondDayNextWeek.getDate() + 1);
const secondDayStr = secondDayNextWeek.toISOString().split('T')[0];
@ -451,7 +452,7 @@ describe('createScheduleDays - Task Filtering', () => {
describe('Tasks with dueWithTime', () => {
it('should appear when viewing a week that includes the dueWithTime', () => {
// Arrange
const nextWeekDate = new Date(nextWeekStr);
const nextWeekDate = parseDbDateStr(nextWeekStr);
nextWeekDate.setHours(14, 0, 0, 0); // 2 PM next week
const taskWithDueTime = createTestTask('task13', 'Task With Due Time', {
dueWithTime: nextWeekDate.getTime(),
@ -482,7 +483,7 @@ describe('createScheduleDays - Task Filtering', () => {
it('should NOT appear when dueWithTime is before the viewed week', () => {
// Arrange
const todayDate = new Date(todayStr);
const todayDate = parseDbDateStr(todayStr);
todayDate.setHours(14, 0, 0, 0);
const taskWithDueTime = createTestTask('task14', 'Task With Due Time Today', {
dueWithTime: todayDate.getTime(),

View file

@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScheduleMonthComponent } from './schedule-month.component';
import { ScheduleService } from '../schedule.service';
import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service';
import { parseDbDateStr } from '../../../util/parse-db-date-str';
describe('ScheduleMonthComponent', () => {
let component: ScheduleMonthComponent;
@ -120,7 +121,7 @@ describe('ScheduleMonthComponent', () => {
// Assert
// Middle index = floor(35/2) = 17
const middleDay = new Date(days[17]);
const middleDay = parseDbDateStr(days[17]);
expect(result.getFullYear()).toBe(middleDay.getFullYear());
expect(result.getMonth()).toBe(middleDay.getMonth());
expect(result.getDate()).toBe(middleDay.getDate());
@ -143,7 +144,7 @@ describe('ScheduleMonthComponent', () => {
// Assert
// Middle index = floor(42/2) = 21
const middleDay = new Date(days[21]);
const middleDay = parseDbDateStr(days[21]);
expect(result.getFullYear()).toBe(middleDay.getFullYear());
expect(result.getMonth()).toBe(middleDay.getMonth());
});
@ -190,7 +191,7 @@ describe('ScheduleMonthComponent', () => {
it('should handle "other-month" class for padding days', () => {
// Arrange
mockScheduleService.getDayClass.and.callFake((day: string, ref?: Date) => {
const dayDate = new Date(day);
const dayDate = parseDbDateStr(day);
if (ref && dayDate.getMonth() !== ref.getMonth()) {
return 'other-month';
}

View file

@ -12,6 +12,7 @@ import { T } from '../../../t.const';
import { ScheduleService } from '../schedule.service';
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service';
import { parseDbDateStr } from 'src/app/util/parse-db-date-str';
@Component({
selector: 'schedule-month',
@ -59,7 +60,7 @@ export class ScheduleMonthComponent {
// Use the middle day as reference (around day 14-15 of the month)
// This ensures we get a day that's actually in the target month
const middleIndex = Math.floor(days.length / 2);
return new Date(days[middleIndex]);
return parseDbDateStr(days[middleIndex]);
});
T: typeof T = T;

View file

@ -28,6 +28,7 @@ import { DRAG_DELAY_FOR_TOUCH } from '../../../app.constants';
import { MatTooltip } from '@angular/material/tooltip';
import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service';
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
import { parseDbDateStr } from '../../../util/parse-db-date-str';
import { formatMonthDay } from '../../../util/format-month-day.util';
import { ScheduleWeekDragService } from './schedule-week-drag.service';
import { calculatePlaceholderForGridMove } from './schedule-week-placeholder.util';
@ -331,7 +332,7 @@ export class ScheduleWeekComponent implements OnInit, AfterViewInit, OnDestroy {
if (!dayStr) {
return '';
}
const date = new Date(dayStr);
const date = parseDbDateStr(dayStr);
if (Number.isNaN(date.getTime())) {
return dayStr;
}

View file

@ -23,6 +23,7 @@ import { CalendarIntegrationService } from '../calendar-integration/calendar-int
import { toSignal } from '@angular/core/rxjs-interop';
import { TaskService } from '../tasks/task.service';
import { startWith } from 'rxjs/operators';
import { parseDbDateStr } from '../../util/parse-db-date-str';
@Injectable({
providedIn: 'root',
@ -272,7 +273,7 @@ export class ScheduleService {
}
getDayClass(day: string, referenceMonth?: Date): string {
const dayDate = new Date(day);
const dayDate = parseDbDateStr(day);
const today = new Date();
// If referenceMonth is provided, use it to determine "current month"

View file

@ -0,0 +1,14 @@
// Parse 'YYYY-MM-DD' string as a local date (not UTC)
//
// ⚠️ Important: new Date('2026-01-12') parses as UTC midnight, which becomes
// the previous day when converted to local timezone (e.g., Europe/Berlin UTC+1).
// This function parses the string as a local date to avoid timezone issues.
//
// Examples:
// - new Date('2026-01-12') in Europe/Berlin → 2026-01-11 23:00 (UTC midnight)
// - parseDbDateStr('2026-01-12') → 2026-01-12 00:00 (local midnight)
export const parseDbDateStr = (dateStr: string): Date => {
const [year, month, day] = dateStr.split('-').map(Number);
return new Date(year, month - 1, day);
};