From 6a975b822c30aabaff47f4b51ec5cda4cecd1b48 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:42:07 +0200 Subject: [PATCH] fix: show a task's due time in local time like events A task's `due` is wall time in the task's `timeZone`, but the tasks sheet, the calendar chips and task reminders read it as device time, so a task due at 17:00 Europe/Berlin showed and fired at 17:00 wherever the phone was. Events already go through `utcStart`. - resolve the due in its zone with a new `getTaskDueDate` and use it for the list, sorting, overdue, the chips and reminder times - save an edited timed due with the effective time zone, as the event editor does, so the converted local time isn't read back in the task's old zone --- src/components/calendar/TasksSheet.tsx | 28 +++++++++++-------- .../calendar-alert-scheduler.test.ts | 7 +++++ src/lib/__tests__/calendar-utils.test.ts | 26 +++++++++++++++++ src/lib/calendar-alert-scheduler.ts | 9 +++--- src/lib/calendar-utils.ts | 16 +++++++++++ src/lib/recurrence-expansion.ts | 2 +- src/screens/CalendarScreen.tsx | 5 +++- 7 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 7ef913f4..1f91ce64 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -24,11 +24,12 @@ import { Trash2, X, } from 'lucide-react-native'; -import { format, isBefore, isToday, parseISO, startOfDay } from 'date-fns'; +import { format, isBefore, isToday, startOfDay } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { getCalendarColor, timePattern, type TimeFormat } from '../../lib/calendar-utils'; +import { getCalendarColor, getTaskDueDate, timePattern, type TimeFormat } from '../../lib/calendar-utils'; +import { getEffectiveTimeZone } from '../../lib/calendar-timezone'; import { isWritableCalendar } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { useSheetDrag } from '../../lib/use-sheet-drag'; @@ -74,9 +75,9 @@ function isCompleted(task: CalendarEvent): boolean { } function isOverdue(task: CalendarEvent): boolean { - if (!task.due || isCompleted(task)) return false; - const d = parseISO(task.due); - if (isNaN(d.getTime())) return false; + if (isCompleted(task)) return false; + const d = getTaskDueDate(task); + if (!d) return false; return isBefore(d, startOfDay(new Date())) && !isToday(d); } @@ -89,8 +90,8 @@ function compareTasks(a: CalendarEvent, b: CalendarEvent): number { const ao = isOverdue(a); const bo = isOverdue(b); if (ao !== bo) return ao ? -1 : 1; - const ad = a.due ? parseISO(a.due).getTime() : Infinity; - const bd = b.due ? parseISO(b.due).getTime() : Infinity; + const ad = getTaskDueDate(a)?.getTime() ?? Infinity; + const bd = getTaskDueDate(b)?.getTime() ?? Infinity; if (ad !== bd) return ad - bd; const ap = a.priority || 10; const bp = b.priority || 10; @@ -113,12 +114,11 @@ function emptyEditor(calendarId: string): EditorState { } function editorFromTask(task: CalendarEvent, fallbackCalendarId: string): EditorState { - const due = task.due ? parseISO(task.due) : null; return { id: task.id, title: task.title || '', description: task.description || '', - due: due && !isNaN(due.getTime()) ? due : null, + due: getTaskDueDate(task), withTime: !!task.due && !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due), priority: priorityToLevel(task.priority), calendarId: Object.keys(task.calendarIds || {})[0] || fallbackCalendarId, @@ -236,9 +236,14 @@ export function TasksSheet({ if (editor.withTime) { data.due = format(editor.due, "yyyy-MM-dd'T'HH:mm:ss"); data.showWithoutTime = false; + // The editor shows the due in local time; label it with the zone + // it was entered in, like the event editor, or the task's old zone + // would shift it. + data.timeZone = getEffectiveTimeZone(); } else { data.due = format(editor.due, "yyyy-MM-dd'T'00:00:00"); data.showWithoutTime = true; + data.timeZone = null; } } else if (editor.id) { data.due = null; @@ -260,9 +265,8 @@ export function TasksSheet({ }; const dueLabel = (task: CalendarEvent): string | null => { - if (!task.due) return null; - const d = parseISO(task.due); - if (isNaN(d.getTime())) return null; + const d = getTaskDueDate(task); + if (!d || !task.due) return null; const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due); return hasTime ? format(d, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale }) diff --git a/src/lib/__tests__/calendar-alert-scheduler.test.ts b/src/lib/__tests__/calendar-alert-scheduler.test.ts index 2601145a..3e7928fd 100644 --- a/src/lib/__tests__/calendar-alert-scheduler.test.ts +++ b/src/lib/__tests__/calendar-alert-scheduler.test.ts @@ -56,6 +56,13 @@ describe('parseAlertOffset / computeFireTime', () => { ); expect(computeTaskFireTime({ ...task, due: null }, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBeNull(); }); + + it('reads a zoned task due in its own time zone', () => { + const task = { ...event, due: '2026-07-01T17:00:00', timeZone: 'Europe/Berlin', alerts: undefined }; + expect(computeTaskFireTime(task, { '@type': 'OffsetTrigger', offset: '-PT1H' })).toBe( + new Date('2026-07-01T14:00:00Z').getTime(), + ); + }); }); describe('getEffectiveAlerts', () => { diff --git a/src/lib/__tests__/calendar-utils.test.ts b/src/lib/__tests__/calendar-utils.test.ts index 56ffb506..2522c8f9 100644 --- a/src/lib/__tests__/calendar-utils.test.ts +++ b/src/lib/__tests__/calendar-utils.test.ts @@ -9,6 +9,7 @@ import { getCalendarColor, getEventStartDate, getEventEndDate, + getTaskDueDate, timePattern, layoutOverlappingEvents, CALENDAR_COLOR_PALETTE, @@ -72,6 +73,31 @@ describe('getEventStartDate / getEventEndDate malformed utc fallback', () => { }); }); +describe('getTaskDueDate', () => { + it('reads a timed due as wall time in the task zone', () => { + const d = getTaskDueDate({ due: '2026-07-01T17:00:00', timeZone: 'Europe/Berlin' }); + expect(d?.toISOString()).toBe('2026-07-01T15:00:00.000Z'); + const winter = getTaskDueDate({ due: '2026-01-15T17:00:00', timeZone: 'Europe/Berlin' }); + expect(winter?.toISOString()).toBe('2026-01-15T16:00:00.000Z'); + }); + + it('keeps floating, date-only and all-day dues in the device zone', () => { + expect(getTaskDueDate({ due: '2026-07-01T17:00:00' })?.getTime()) + .toBe(new Date('2026-07-01T17:00:00').getTime()); + expect(getTaskDueDate({ due: '2026-07-01', timeZone: 'Europe/Berlin' })?.getTime()) + .toBe(new Date('2026-07-01T00:00:00').getTime()); + expect(getTaskDueDate({ due: '2026-07-01T00:00:00', timeZone: 'Europe/Berlin', showWithoutTime: true })?.getTime()) + .toBe(new Date('2026-07-01T00:00:00').getTime()); + }); + + it('falls back to wall time for an unknown zone and returns null without a valid due', () => { + expect(getTaskDueDate({ due: '2026-07-01T17:00:00', timeZone: 'Mars/Olympus' })?.getTime()) + .toBe(new Date('2026-07-01T17:00:00').getTime()); + expect(getTaskDueDate({ due: null })).toBeNull(); + expect(getTaskDueDate({ due: 'garbage', timeZone: 'Europe/Berlin' })).toBeNull(); + }); +}); + describe('timePattern', () => { it('maps 12h to an am/pm pattern and 24h (or undefined) to HH:mm', () => { expect(timePattern('12h')).toBe('h:mm a'); diff --git a/src/lib/calendar-alert-scheduler.ts b/src/lib/calendar-alert-scheduler.ts index 7125abb3..4d6d2203 100644 --- a/src/lib/calendar-alert-scheduler.ts +++ b/src/lib/calendar-alert-scheduler.ts @@ -1,6 +1,6 @@ import { parseISO } from 'date-fns'; import type { Alert, Calendar, CalendarEvent } from '../api/types'; -import { parseDuration } from './calendar-utils'; +import { getTaskDueDate, parseDuration } from './calendar-utils'; // Pure alert maths for calendar reminders. Port of the webmail's // lib/calendar-alerts.ts (computeFireTime / getEffectiveAlerts / @@ -71,10 +71,9 @@ export function computeTaskFireTime(task: CalendarEvent, trigger: Alert['trigger if (trigger.offset === undefined) return null; const offsetMs = parseAlertOffset(trigger.offset); if (offsetMs === null) return null; - if (!task.due) return null; - const baseTime = parseISO(task.due).getTime(); - if (Number.isNaN(baseTime)) return null; - return baseTime + offsetMs; + const due = getTaskDueDate(task); + if (!due) return null; + return due.getTime() + offsetMs; } /** The event's own alerts, or the calendar defaults when `useDefaultAlerts` is set. */ diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index e2550ffc..78d58c26 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -7,6 +7,7 @@ import { } from 'date-fns'; import type { Calendar, CalendarEvent } from '../api/types'; import { colors } from '../theme/tokens'; +import { zonedWallTimeToUtc } from './recurrence-expansion'; // ─── Duration ──────────────────────────────────────────── // Parse an ISO 8601 duration ("PT1H30M", "P2D", "PT45M") to milliseconds. @@ -45,6 +46,21 @@ export function getEventStartDate( return parseISO(event.start); } +// ─── Task due ──────────────────────────────────────────── +// A task's `due` is a wall-clock time in its `timeZone` (a CalDAV DUE with a +// TZID keeps it). Resolve it to the instant, as utcStart does for events, so +// a 17:00 Europe/Berlin due shows at the viewer's local time. Date-only and +// floating dues stay in the device zone. +export function getTaskDueDate( + task: Pick, +): Date | null { + if (!task.due) return null; + const wall = parseISO(task.due); + if (isNaN(wall.getTime())) return null; + if (task.showWithoutTime || !task.timeZone || /^\d{4}-\d{2}-\d{2}$/.test(task.due)) return wall; + return zonedWallTimeToUtc(wall, task.timeZone) ?? wall; +} + export function getEventEndDate(event: CalendarEvent): Date { if (!event.showWithoutTime && event.utcEnd) { const utc = parseISO(event.utcEnd); diff --git a/src/lib/recurrence-expansion.ts b/src/lib/recurrence-expansion.ts index 31b643ac..f7404c9c 100644 --- a/src/lib/recurrence-expansion.ts +++ b/src/lib/recurrence-expansion.ts @@ -175,7 +175,7 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number * and return the corresponding UTC instant. Two fixup iterations converge * for all real offsets, including across DST transitions. */ -function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { +export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { const formatter = getTzFormatter(timeZone); if (!formatter) return null; const wallAsUtc = Date.UTC( diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 874ac05f..918bb00b 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -63,6 +63,7 @@ import { eventsOnDayFromIndex, getEventStartDate, getPrimaryCalendarId, + getTaskDueDate, pickUnusedCalendarColor, sharedCalendarColorKey, type EventDayIndex, @@ -279,7 +280,9 @@ export default function CalendarScreen() { start: dateOnly ? `${task.due}T00:00:00` : task.due, showWithoutTime: allDay, duration: allDay ? 'P1D' : 'PT30M', - utcStart: undefined, + // The due is wall time in the task's zone; place the chip at the + // instant, like events. + utcStart: allDay ? undefined : getTaskDueDate(task)?.toISOString(), utcEnd: undefined, title: `☐ ${task.title || ''}`.trim(), recurrenceRules: undefined,