mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
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:
parent
996f7e1210
commit
0e13e14520
6 changed files with 28 additions and 9 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
14
src/app/util/parse-db-date-str.ts
Normal file
14
src/app/util/parse-db-date-str.ts
Normal 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);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue