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
This commit is contained in:
Linus Rath
2026-09-24 19:12:02 +02:00
parent 1b64c14280
commit 6a975b822c
7 changed files with 74 additions and 19 deletions
+16 -12
View File
@@ -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 })
@@ -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', () => {
+26
View File
@@ -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');
+4 -5
View File
@@ -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. */
+16
View File
@@ -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<CalendarEvent, 'due' | 'timeZone' | 'showWithoutTime'>,
): 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);
+1 -1
View File
@@ -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(
+4 -1
View File
@@ -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,