mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* fix(issues): store start_date/due_date as DATE, not timestamp (MUL-2925) These fields are calendar days (the pickers offer no time-of-day), but were stored as TIMESTAMPTZ. A client serializing local midnight via toISOString() folded its timezone into the instant, so the day shifted by the local offset (GH #3618). Migrate the columns to DATE and parse/serialize date-only "YYYY-MM-DD". ParseCalendarDate still accepts legacy RFC3339 (truncated to the UTC day) so older clients keep working. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): render start_date/due_date as timezone-stable calendar days (MUL-2925) Pickers now emit date-only "YYYY-MM-DD" (local calendar day) instead of toISOString(), and every read formats via the shared @multica/core/issues/date helpers with timeZone:"UTC" so the day never shifts with the viewer's offset. The Gantt's existing UTC bucketing is now correct. Covers web/desktop pickers, quick-set menu, list/board/detail/activity, and the mobile due-date picker. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): address date-only review — loud-fail ambiguous dates, finish display sweep (MUL-2925) Review follow-ups on #3692: - ParseCalendarDate no longer silently truncates a legacy non-midnight RFC3339 to the wrong UTC day; it accepts only YYYY-MM-DD or an exact UTC-midnight instant and rejects ambiguous ones loudly. Adds util unit tests. - migration 112 pins the TIMESTAMPTZ->DATE conversion to UTC explicitly via AT TIME ZONE 'UTC' (was session-timezone dependent); down migration too. - Convert remaining date-change display sites to formatDateOnly: inbox detail label (web) and mobile activity + inbox labels (were new Date()+local format). - CLI --start-date/--due-date help now says YYYY-MM-DD, not RFC3339. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
// Issue start_date / due_date are calendar days, not instants: the pickers
|
|
// offer no time-of-day input, so "Mar 1" must mean Mar 1 for every viewer
|
|
// regardless of timezone. They are transported as a date-only "YYYY-MM-DD"
|
|
// string. These helpers convert between that string and a Date WITHOUT letting
|
|
// the local timezone shift the day — the bug behind GH #3618 / MUL-2925 was
|
|
// serializing a local-midnight Date via toISOString() (which injects a tz) and
|
|
// reading it back through UTC day boundaries.
|
|
//
|
|
// Pure functions only (no React / DOM) so they can be shared with mobile.
|
|
|
|
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
|
|
function pad(n: number): string {
|
|
return String(n).padStart(2, "0");
|
|
}
|
|
|
|
/**
|
|
* Serialize a Date the user picked in a calendar (local midnight of the chosen
|
|
* day) to a "YYYY-MM-DD" string, using the LOCAL calendar components so the
|
|
* stored day matches the day the user clicked.
|
|
*/
|
|
export function toDateOnly(date: Date): string {
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
}
|
|
|
|
/** Today as a "YYYY-MM-DD" string in the viewer's local calendar. */
|
|
export function todayDateOnly(): string {
|
|
return toDateOnly(new Date());
|
|
}
|
|
|
|
/** "YYYY-MM-DD" of `days` from today in the viewer's local calendar. */
|
|
export function addDaysDateOnly(days: number): string {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() + days);
|
|
return toDateOnly(d);
|
|
}
|
|
|
|
/**
|
|
* Parse a date-only string into [year, month, day], tolerating a legacy full
|
|
* ISO timestamp by reading its UTC calendar day. Returns null when unparseable.
|
|
*/
|
|
function parseParts(value: string): [number, number, number] | null {
|
|
const m = DATE_ONLY.exec(value);
|
|
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return null;
|
|
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate()];
|
|
}
|
|
|
|
/**
|
|
* Date anchored at UTC midnight of the calendar day. Use for timezone-safe
|
|
* display (format with `timeZone: "UTC"`), gantt day-bucketing, and
|
|
* chronological comparison.
|
|
*/
|
|
export function dateOnlyToUTCDate(
|
|
value: string | null | undefined,
|
|
): Date | null {
|
|
if (!value) return null;
|
|
const parts = parseParts(value);
|
|
if (!parts) return null;
|
|
return new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
|
|
}
|
|
|
|
/**
|
|
* Date at LOCAL midnight of the calendar day. Use for a calendar picker's
|
|
* `selected` / `defaultMonth`, which match on the local-time day.
|
|
*/
|
|
export function dateOnlyToLocalDate(
|
|
value: string | null | undefined,
|
|
): Date | undefined {
|
|
if (!value) return undefined;
|
|
const parts = parseParts(value);
|
|
if (!parts) return undefined;
|
|
return new Date(parts[0], parts[1] - 1, parts[2]);
|
|
}
|
|
|
|
/**
|
|
* Format a calendar day for display, timezone-safely (the day never shifts with
|
|
* the viewer's timezone). Returns "" for an empty/unparseable value.
|
|
*/
|
|
export function formatDateOnly(
|
|
value: string | null | undefined,
|
|
options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" },
|
|
locale?: string,
|
|
): string {
|
|
const d = dateOnlyToUTCDate(value);
|
|
if (!d) return "";
|
|
return d.toLocaleDateString(locale, { ...options, timeZone: "UTC" });
|
|
}
|
|
|
|
/** True when the calendar day is strictly before today (viewer's local day). */
|
|
export function isPastDateOnly(value: string | null | undefined): boolean {
|
|
const d = dateOnlyToUTCDate(value);
|
|
if (!d) return false;
|
|
const today = dateOnlyToUTCDate(todayDateOnly());
|
|
return today != null && d.getTime() < today.getTime();
|
|
}
|