mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user