mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
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:
parent
059539456d
commit
f1a3c10bcb
4 changed files with 225 additions and 63 deletions
101
src/__tests__/date-display-helper.test.ts
Normal file
101
src/__tests__/date-display-helper.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
70
src/utils/date/date-display-helper.ts
Normal file
70
src/utils/date/date-display-helper.ts
Normal 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();
|
||||
}
|
||||
Loading…
Reference in a new issue