Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/mobile/src/date-time.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { CalendarEvent } from "../../../packages/domain/src/index";

/** Format a timed event in its calendar's named time zone. */
export function localDateTime(value: string, timeZone: string): { date: string; time: string } {
const instant = new Date(value);
Expand Down Expand Up @@ -40,6 +42,31 @@ export function zonedInstant(date: string, time: string, timeZone: string): stri
throw new Error("This time does not exist in the selected time zone. Choose another time.");
}

/** Instant range for an event; all-day events cover their date in the event's own zone. */
export function calendarInterval(
event: Pick<CalendarEvent, "start" | "end" | "allDay" | "timeZone">,
): { start: number; end: number } | null {
try {
const start = event.allDay ? zonedInstant(event.start, "00:00", event.timeZone) : event.start;
const end = event.allDay ? zonedInstant(event.end, "00:00", event.timeZone) : event.end;
const from = Date.parse(start);
const to = Date.parse(end);
return Number.isFinite(from) && Number.isFinite(to) ? { start: from, end: to } : null;
} catch {
return null;
}
}

/** Half-open overlap: events that only touch at a boundary are not a conflict. */
export function calendarOverlap(
left: Pick<CalendarEvent, "start" | "end" | "allDay" | "timeZone">,
right: Pick<CalendarEvent, "start" | "end" | "allDay" | "timeZone">,
): boolean {
const a = calendarInterval(left);
const b = calendarInterval(right);
return Boolean(a && b && a.start < b.end && a.end > b.start);
}

/** Only fully serialized instants may reset a date editor's local text. */
export function isCompleteInstant(value: string): boolean {
return (
Expand Down
12 changes: 6 additions & 6 deletions apps/mobile/src/details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import BrowserConsole from "./BrowserConsole";
import { browserAddress, browserSite } from "./browser-address";
import { ComputerSheet } from "./computer";
import DateTimeEditor from "./DateTimeEditor";
import { localDateTime, zonedInstant } from "./date-time";
import { calendarOverlap, localDateTime, zonedInstant } from "./date-time";
import PdfReader from "./PdfReader";
import {
Button,
Expand Down Expand Up @@ -388,10 +388,7 @@ function EventEditor({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const conflicts = (neighbors || w.events).filter(
(item) =>
item.id !== e?.id &&
Date.parse(start) < Date.parse(item.end) &&
Date.parse(end) > Date.parse(item.start),
(item) => item.id !== e?.id && calendarOverlap({ start, end, allDay, timeZone: zone }, item),
);
async function propose(remove = false) {
setBusy(true);
Expand Down Expand Up @@ -514,7 +511,10 @@ function EventEditor({
<Text style={s.heading}>This time overlaps</Text>
{conflicts.map((c) => (
<Text key={c.id} style={s.muted}>
{c.title} · {timeLabel(c.start, c.timeZone)}–{timeLabel(c.end, c.timeZone)}
{c.title} ·{" "}
{c.allDay
? "All day"
: `${timeLabel(c.start, c.timeZone)}–${timeLabel(c.end, c.timeZone)}`}
</Text>
))}
</Card>
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/src/screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,13 @@ export function CalendarScreen() {
) : events.length ? (
events.map((e, i) => (
<View key={e.id}>
{all && <Text style={[s.label, { marginTop: 16 }]}>{dateLabel(e.start)}</Text>}
{all && (
<Text style={[s.label, { marginTop: 16 }]}>
{e.allDay
? dateLabel(`${e.start}T12:00:00`)
: dateLabel(e.start, { timeZone: e.timeZone })}
</Text>
)}
<AgendaRow event={e} index={i} neighbors={events} />
<Text style={[s.small, { marginLeft: 84, marginBottom: 8 }]}>{e.timeZone}</Text>
</View>
Expand Down
79 changes: 78 additions & 1 deletion apps/mobile/test/date-time.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isCompleteInstant, localDateTime, zonedInstant } from "../src/date-time.ts";
import {
calendarInterval,
calendarOverlap,
isCompleteInstant,
localDateTime,
zonedInstant,
} from "../src/date-time.ts";

test("calendar time is rendered and entered in the selected named zone", () => {
assert.deepEqual(localDateTime("2026-09-15T17:30:00Z", "America/Los_Angeles"), {
Expand Down Expand Up @@ -38,3 +44,74 @@ test("partial native date edits never normalize from Date.parse", () => {
assert.equal(isCompleteInstant("2026-09-15T17:30:00.000Z"), true);
assert.equal(isCompleteInstant("2026-09-15T10:30:00-07:00"), true);
});

const allDay = (start: string, end: string, timeZone = "America/Los_Angeles") => ({
start,
end,
allDay: true,
timeZone,
});
const timed = (start: string, end: string, timeZone = "America/Los_Angeles") => ({
start,
end,
allDay: false,
timeZone,
});

test("an all-day event covers its date in its own time zone, not UTC", () => {
assert.deepEqual(calendarInterval(allDay("2026-09-30", "2026-10-01")), {
start: Date.parse("2026-09-30T00:00:00-07:00"),
end: Date.parse("2026-10-01T00:00:00-07:00"),
});
const tokyo = calendarInterval(allDay("2026-09-30", "2026-10-01", "Asia/Tokyo"));
assert.deepEqual(tokyo, {
start: Date.parse("2026-09-30T00:00:00+09:00"),
end: Date.parse("2026-10-01T00:00:00+09:00"),
});
});

test("a timed evening event overlaps the all-day event on the same local day", () => {
assert.equal(
calendarOverlap(
timed("2026-09-30T20:00:00-07:00", "2026-09-30T21:00:00-07:00"),
allDay("2026-09-30", "2026-10-01"),
),
true,
);
assert.equal(
calendarOverlap(
timed("2026-09-29T22:00:00-07:00", "2026-09-29T23:00:00-07:00"),
allDay("2026-09-30", "2026-10-01"),
),
false,
);
});

test("all-day overlap follows the calendar's zone across the UTC date boundary", () => {
assert.equal(
calendarOverlap(
allDay("2026-09-30", "2026-10-01", "Asia/Tokyo"),
timed("2026-09-29T23:00:00Z", "2026-09-30T00:00:00Z", "UTC"),
),
true,
);
});

test("events that only touch at a boundary do not conflict", () => {
assert.equal(
calendarOverlap(
allDay("2026-09-30", "2026-10-01"),
timed("2026-10-01T00:00:00-07:00", "2026-10-01T01:00:00-07:00"),
),
false,
);
});

test("unparseable ranges never report a conflict", () => {
assert.equal(calendarInterval(allDay("not-a-date", "2026-10-01")), null);
assert.equal(calendarInterval(allDay("2026-09-30", "2026-10-01", "Not/AZone")), null);
assert.equal(
calendarOverlap(allDay("garbage", "garbage"), allDay("2026-09-30", "2026-10-01")),
false,
);
});