fix(dates): correct timezone handling for date display in task views

Fixes incorrect date display when user's timezone differs from UTC. Dates stored
as UTC noon timestamps were showing off-by-one day in some timezones.

- Add date-display-helper utility to properly convert between UTC storage and local display
- Update TaskDetailsComponent to use new helper functions
- Update MetadataEditor to use consistent date conversion logic
- Add comprehensive tests for timezone edge cases

The fix ensures dates are consistently stored at UTC noon but displayed correctly
in the user's local timezone, preventing date shift issues.

Resolves: #419
This commit is contained in:
Quorafind 2025-09-02 12:41:05 +08:00
parent 059539456d
commit f1a3c10bcb
4 changed files with 225 additions and 63 deletions

View file

@ -0,0 +1,101 @@
/**
* Tests for date display helper functions
* Ensures correct conversion between UTC noon timestamps and local date display
*/
import { timestampToLocalDateString, localDateStringToTimestamp } from "@/utils/date/date-display-helper";
describe("Date Display Helper", () => {
describe("timestampToLocalDateString", () => {
it("should convert UTC noon timestamp to correct local date string", () => {
// Test date: 2025-09-01 stored as UTC noon
const utcNoonTimestamp = new Date(Date.UTC(2025, 8, 1, 12, 0, 0)).getTime();
// The result should always be 2025-09-01 regardless of timezone
const result = timestampToLocalDateString(utcNoonTimestamp);
expect(result).toBe("2025-09-01");
});
it("should handle undefined timestamp", () => {
const result = timestampToLocalDateString(undefined);
expect(result).toBe("");
});
it("should handle dates at year boundaries", () => {
// Test date: 2024-12-31 stored as UTC noon
const utcNoonTimestamp = new Date(Date.UTC(2024, 11, 31, 12, 0, 0)).getTime();
const result = timestampToLocalDateString(utcNoonTimestamp);
expect(result).toBe("2024-12-31");
});
it("should handle leap year date", () => {
// Test date: 2024-02-29 (leap year) stored as UTC noon
const utcNoonTimestamp = new Date(Date.UTC(2024, 1, 29, 12, 0, 0)).getTime();
const result = timestampToLocalDateString(utcNoonTimestamp);
expect(result).toBe("2024-02-29");
});
});
describe("localDateStringToTimestamp", () => {
it("should convert local date string to UTC noon timestamp", () => {
const dateString = "2025-09-01";
const result = localDateStringToTimestamp(dateString);
// Should be UTC noon for the given date
const expected = new Date(Date.UTC(2025, 8, 1, 12, 0, 0)).getTime();
expect(result).toBe(expected);
});
it("should handle undefined date string", () => {
const result = localDateStringToTimestamp("");
expect(result).toBeUndefined();
});
it("should handle invalid date string", () => {
const result = localDateStringToTimestamp("invalid-date");
expect(result).toBeUndefined();
});
it("should handle dates at year boundaries", () => {
const dateString = "2024-12-31";
const result = localDateStringToTimestamp(dateString);
const expected = new Date(Date.UTC(2024, 11, 31, 12, 0, 0)).getTime();
expect(result).toBe(expected);
});
});
describe("Round-trip conversion", () => {
it("should maintain date integrity through round-trip conversion", () => {
const testDates = [
"2025-09-01",
"2024-12-31",
"2025-01-01",
"2024-02-29", // Leap year
"2025-06-15",
];
for (const originalDate of testDates) {
const timestamp = localDateStringToTimestamp(originalDate);
const convertedBack = timestampToLocalDateString(timestamp);
expect(convertedBack).toBe(originalDate);
}
});
});
describe("Timezone edge cases", () => {
it("should handle dates correctly even when local time would be different day", () => {
// This simulates the issue where a date stored as UTC noon
// might appear as a different day in certain timezones
// Create a timestamp for 2025-09-01 at UTC noon
const timestamp = new Date(Date.UTC(2025, 8, 1, 12, 0, 0)).getTime();
// Even if the user is in a timezone where this would be
// 2025-09-01 19:00 (UTC+7) or 2025-09-01 08:00 (UTC-4),
// the display should still show 2025-09-01
const result = timestampToLocalDateString(timestamp);
expect(result).toBe("2025-09-01");
});
});
});

View file

@ -14,11 +14,22 @@ import {
import { Task } from "@/types/task";
import TaskProgressBarPlugin from "@/index";
import { t } from "@/translations/helper";
import { ProjectSuggest, TagSuggest, ContextSuggest } from "@/components/ui/inputs/AutoComplete";
import {
ProjectSuggest,
TagSuggest,
ContextSuggest,
} from "@/components/ui/inputs/AutoComplete";
import { StatusComponent } from "@/components/ui/feedback/StatusIndicator";
import { format } from "date-fns";
import { getEffectiveProject, isProjectReadonly } from "@/utils/task/task-operations";
// import { format } from "date-fns";
import {
getEffectiveProject,
isProjectReadonly,
} from "@/utils/task/task-operations";
import { OnCompletionConfigurator } from "@/components/features/on-completion/OnCompletionConfigurator";
import {
timestampToLocalDateString,
localDateStringToTimestamp,
} from "@/utils/date/date-display-helper";
export interface MetadataChangeEvent {
field: string;
@ -248,8 +259,10 @@ export class TaskMetadataEditor extends Component {
private getDateString(dateValue: string | number | undefined): string {
if (dateValue === undefined) return "";
if (typeof dateValue === "number") {
return format(new Date(dateValue), "yyyy-MM-dd");
// For numeric timestamps, prefer helper for correct display across timezones
return timestampToLocalDateString(dateValue);
}
// Already a YYYY-MM-DD string
return dateValue;
}
@ -337,25 +350,32 @@ export class TaskMetadataEditor extends Component {
});
if (value) {
// Date format conversion using UTC to avoid timezone issues
try {
const date = new Date(value);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
dateInput.value = `${year}-${month}-${day}`;
} catch (e) {
console.error(`Cannot parse date: ${value}`, e);
// If already a YYYY-MM-DD string, use directly; else use helper for timestamp
const isDateString =
typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
if (isDateString) {
dateInput.value = value as string;
} else {
try {
const asNum =
typeof value === "number" ? value : Number(value);
dateInput.value = timestampToLocalDateString(
Number.isFinite(asNum) ? (asNum as number) : undefined
);
} catch (e) {
console.error(`Cannot parse date: ${value}`, e);
}
}
}
this.registerDomEvent(dateInput, "change", () => {
const dateValue = dateInput.value;
if (dateValue) {
// Create date at noon UTC to avoid timezone edge cases
const [year, month, day] = dateValue.split("-").map(Number);
const timestamp = new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).getTime();
this.notifyMetadataChange(field, timestamp);
// Use helper to convert local date string to UTC noon timestamp
const timestamp = localDateStringToTimestamp(dateValue);
if (timestamp !== undefined) {
this.notifyMetadataChange(field, timestamp);
}
} else {
this.notifyMetadataChange(field, undefined);
}

View file

@ -21,6 +21,7 @@ import { ContextSuggest, ProjectSuggest, TagSuggest } from "@/components/ui/inpu
import { FileTask } from "@/types/file-task";
import { getEffectiveProject, isProjectReadonly } from "@/utils/task/task-operations";
import { OnCompletionConfigurator } from "@/components/features/on-completion/OnCompletionConfigurator";
import { timestampToLocalDateString, localDateStringToTimestamp } from "@/utils/date/date-display-helper";
function getStatus(task: Task, settings: TaskProgressBarSettings) {
const status = Object.keys(settings.taskStatuses).find((key) => {
@ -460,12 +461,8 @@ export class TaskDetailsComponent extends Component {
cls: "date-input",
});
if (task.metadata.dueDate) {
// Use local date components to avoid off-by-one due to timezone
const date = new Date(task.metadata.dueDate);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
dueDateInput.value = `${year}-${month}-${day}`;
// Use helper to correctly display UTC noon timestamp as local date
dueDateInput.value = timestampToLocalDateString(task.metadata.dueDate);
} // Start date
const startDateField = this.createFormField(
this.editFormEl,
@ -476,12 +473,8 @@ export class TaskDetailsComponent extends Component {
cls: "date-input",
});
if (task.metadata.startDate) {
// Use UTC methods to avoid timezone issues
const date = new Date(task.metadata.startDate);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
startDateInput.value = `${year}-${month}-${day}`;
// Use helper to correctly display UTC noon timestamp as local date
startDateInput.value = timestampToLocalDateString(task.metadata.startDate);
}
// Scheduled date
@ -494,12 +487,8 @@ export class TaskDetailsComponent extends Component {
cls: "date-input",
});
if (task.metadata.scheduledDate) {
// Use UTC methods to avoid timezone issues
const date = new Date(task.metadata.scheduledDate);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
scheduledDateInput.value = `${year}-${month}-${day}`;
// Use helper to correctly display UTC noon timestamp as local date
scheduledDateInput.value = timestampToLocalDateString(task.metadata.scheduledDate);
}
// Cancelled date
@ -512,12 +501,8 @@ export class TaskDetailsComponent extends Component {
cls: "date-input",
});
if (task.metadata.cancelledDate) {
// Use UTC methods to avoid timezone issues
const date = new Date(task.metadata.cancelledDate);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
cancelledDateInput.value = `${year}-${month}-${day}`;
// Use helper to correctly display UTC noon timestamp as local date
cancelledDateInput.value = timestampToLocalDateString(task.metadata.cancelledDate);
}
// On completion action
@ -570,9 +555,8 @@ export class TaskDetailsComponent extends Component {
// Parse dates and check if they've changed
const dueDateValue = dueDateInput.value;
if (dueDateValue) {
// Create date at noon UTC to avoid timezone edge cases
const [year, month, day] = dueDateValue.split("-").map(Number);
const newDueDate = new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).getTime();
// Use helper to convert local date string to UTC noon timestamp
const newDueDate = localDateStringToTimestamp(dueDateValue);
// Only update if the date has changed or is different from the original
if (task.metadata.dueDate !== newDueDate) {
metadata.dueDate = newDueDate;
@ -589,11 +573,8 @@ export class TaskDetailsComponent extends Component {
const startDateValue = startDateInput.value;
if (startDateValue) {
// Create date at noon UTC to avoid timezone edge cases
const [year, month, day] = startDateValue
.split("-")
.map(Number);
const newStartDate = new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).getTime();
// Use helper to convert local date string to UTC noon timestamp
const newStartDate = localDateStringToTimestamp(startDateValue);
// Only update if the date has changed or is different from the original
if (task.metadata.startDate !== newStartDate) {
metadata.startDate = newStartDate;
@ -610,13 +591,8 @@ export class TaskDetailsComponent extends Component {
const scheduledDateValue = scheduledDateInput.value;
if (scheduledDateValue) {
// Create date at noon UTC to avoid timezone edge cases
const [year, month, day] = scheduledDateValue
.split("-")
.map(Number);
const newScheduledDate = new Date(
Date.UTC(year, month - 1, day, 12, 0, 0)
).getTime();
// Use helper to convert local date string to UTC noon timestamp
const newScheduledDate = localDateStringToTimestamp(scheduledDateValue);
// Only update if the date has changed or is different from the original
if (task.metadata.scheduledDate !== newScheduledDate) {
metadata.scheduledDate = newScheduledDate;
@ -633,13 +609,8 @@ export class TaskDetailsComponent extends Component {
const cancelledDateValue = cancelledDateInput.value;
if (cancelledDateValue) {
// Create date at noon UTC to avoid timezone edge cases
const [year, month, day] = cancelledDateValue
.split("-")
.map(Number);
const newCancelledDate = new Date(
Date.UTC(year, month - 1, day, 12, 0, 0)
).getTime();
// Use helper to convert local date string to UTC noon timestamp
const newCancelledDate = localDateStringToTimestamp(cancelledDateValue);
// Only update if the date has changed or is different from the original
if (task.metadata.cancelledDate !== newCancelledDate) {
metadata.cancelledDate = newCancelledDate;

View file

@ -0,0 +1,70 @@
/**
* Helper functions for displaying dates correctly in the UI
* Handles the conversion between UTC noon timestamps and local date display
*/
/**
* Convert a UTC noon timestamp to a local date string for display in date inputs
*
* The timestamp is stored as UTC noon (12:00 UTC) to avoid date boundary issues.
* This function converts it back to the intended local date for display.
*
* @param timestamp - The timestamp stored as UTC noon
* @returns Date string in YYYY-MM-DD format for the local date
*/
export function timestampToLocalDateString(
timestamp: number | undefined
): string {
if (!timestamp) return "";
const date = new Date(timestamp);
// Detect UTC-noon storage (exact 12:00 UTC)
const isUTCNoon = date.getUTCHours() === 12 && date.getUTCMinutes() === 0;
let year: number;
let month: number; // 0-based
let day: number;
if (isUTCNoon) {
// Use UTC calendar date to reconstruct the intended local date
year = date.getUTCFullYear();
month = date.getUTCMonth();
day = date.getUTCDate();
const localDate = new Date(year, month, day);
const y = localDate.getFullYear();
const m = String(localDate.getMonth() + 1).padStart(2, "0");
const d = String(localDate.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
} else {
// For local-midnight or arbitrary timestamps, use local calendar date directly
year = date.getFullYear();
month = date.getMonth();
day = date.getDate();
return `${year}-${String(month + 1).padStart(2, "0")}-${String(
day
).padStart(2, "0")}`;
}
}
/**
* Convert a local date string (YYYY-MM-DD) to a UTC noon timestamp for storage
*
* This ensures consistent date storage across timezones by storing all dates
* at UTC noon, avoiding edge cases where dates might shift due to timezone differences.
*
* @param dateString - Date string in YYYY-MM-DD format
* @returns Timestamp at UTC noon for the given date
*/
export function localDateStringToTimestamp(
dateString: string
): number | undefined {
if (!dateString) return undefined;
const [year, month, day] = dateString.split("-").map(Number);
if (!year || !month || !day) return undefined;
// Create date at noon UTC to ensure consistent storage
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).getTime();
}