diff --git a/packages/ui/src/bodies/CleanupForm.tsx b/packages/ui/src/bodies/CleanupForm.tsx index 7cfff066..673f4fd0 100644 --- a/packages/ui/src/bodies/CleanupForm.tsx +++ b/packages/ui/src/bodies/CleanupForm.tsx @@ -55,8 +55,8 @@ import { endTimeAfter, endTimeSelectable, formInstantMs, - mergeDateTime, scheduleFieldErrors, + timeCarrier, } from "./calendarModel" import { InlineDateTimePicker } from "./InlineDateTimePicker" import { ReportLinkPicker } from "./ReportLinkPicker" @@ -498,8 +498,10 @@ export function CleanupForm({ (date: Date) => { const before = value.date && value.time ? formInstantMs(value.date, value.time, value.timezone) : null - const time = value.time ? mergeDateTime(date, value.time) : value.time - const endTime = value.endTime ? mergeDateTime(date, value.endTime) : value.endTime + const time = value.time ? timeCarrier(date, value.time.getHours(), value.time.getMinutes()) : value.time + const endTime = value.endTime + ? timeCarrier(date, value.endTime.getHours(), value.endTime.getMinutes()) + : value.endTime const after = time ? formInstantMs(date, time, value.timezone) : null patch({ date, @@ -595,6 +597,7 @@ export function CleanupForm({ day: "numeric", hour: "numeric", minute: "2-digit", + timeZone: value.timezone, }), } }, [value, locale]) diff --git a/packages/ui/src/bodies/CreateCleanupBody.tsx b/packages/ui/src/bodies/CreateCleanupBody.tsx index 87ac46fb..d7b78516 100644 --- a/packages/ui/src/bodies/CreateCleanupBody.tsx +++ b/packages/ui/src/bodies/CreateCleanupBody.tsx @@ -61,7 +61,6 @@ import { hasValidEventEnd, emptyCleanupForm, isCleanupFormComplete, - mergeDateTime, type CleanupFormSection, type CleanupFormValue, } from "./CleanupForm" @@ -222,18 +221,19 @@ function ReviewSummary({ const label = useReverseLabel(value.coords) const empty = t("wizard.empty") + const startMs = + value.date && value.time ? formInstantMs(value.date, value.time, value.timezone) : null const whenText = - value.date && value.time - ? mergeDateTime(value.date, value.time).toLocaleString(locale, { + startMs !== null + ? new Date(startMs).toLocaleString(locale, { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + timeZone: value.timezone, }) : empty - const startMs = - value.date && value.time ? formInstantMs(value.date, value.time, value.timezone) : null const endMs = value.date && value.time && value.endTime ? formEndInstantMs(value.date, value.time, value.endTime, value.timezone) diff --git a/packages/ui/src/bodies/EditCleanupBody.tsx b/packages/ui/src/bodies/EditCleanupBody.tsx index 19f9cfe2..01098bbb 100644 --- a/packages/ui/src/bodies/EditCleanupBody.tsx +++ b/packages/ui/src/bodies/EditCleanupBody.tsx @@ -22,6 +22,7 @@ import { isScheduleInFutureInZone, isScheduleUntouched, wallClockToFormDate, + wallClockToFormTime, } from "./calendarModel" import { wallClockInZone } from "@civfix/shared/datetime" import { CleanupForm, isCleanupFormComplete, type CleanupFormValue } from "./CleanupForm" @@ -49,7 +50,7 @@ function saveErrorMessage(err: unknown, t: Translate): string { function formFromCleanup(cleanup: CleanupDTO): CleanupFormValue { const timezone = cleanup.timezone ?? viewerTimeZone() - const when = wallClockToFormDate(wallClockInZone(Date.parse(cleanup.scheduledAt), timezone)) + const start = wallClockInZone(Date.parse(cleanup.scheduledAt), timezone) return { organizationId: cleanup.organization?.id ?? null, title: cleanup.title, @@ -64,8 +65,8 @@ function formFromCleanup(cleanup: CleanupDTO): CleanupFormValue { ? geocodePointKey({ lat: cleanup.lat, lng: cleanup.lng }) : null, coords: cleanup.lat != null && cleanup.lng != null ? { lat: cleanup.lat, lng: cleanup.lng } : null, - date: when, - time: when, + date: wallClockToFormDate(start), + time: wallClockToFormTime(start), endTime: seededEndTime(cleanup, timezone), timezone, bring: cleanup.bring ?? [], diff --git a/packages/ui/src/bodies/InlineDateTimePicker.native.tsx b/packages/ui/src/bodies/InlineDateTimePicker.native.tsx index a4625d2d..da14d0ca 100644 --- a/packages/ui/src/bodies/InlineDateTimePicker.native.tsx +++ b/packages/ui/src/bodies/InlineDateTimePicker.native.tsx @@ -8,7 +8,7 @@ import { useTheme } from "../theme" import { Icon, iconMap } from "../typography" import { useLocale } from "../i18n" import { DateTimeFieldRow, InlineDateTimePickerLayout } from "./DateTimeFieldRow" -import { uses24HourClock } from "./calendarModel" +import { timeCarrier, uses24HourClock } from "./calendarModel" import { TIME_PICKER_MINUTE_INTERVAL, type DateFieldRowProps, @@ -102,14 +102,13 @@ export function TimeFieldRow({ }: TimeFieldRowProps) { const th = useTheme() const { locale } = useLocale() - const current = value ?? day ?? new Date() + const base = day ?? new Date() + const current = value ?? timeCarrier(base, base.getHours(), base.getMinutes()) const commit = useCallback( (event: DateTimePickerEvent, picked?: Date) => { if (event.type === "dismissed" || picked === undefined) return - const next = new Date(day ?? value ?? picked) - next.setHours(picked.getHours(), picked.getMinutes(), 0, 0) - onChange(next) + onChange(timeCarrier(day ?? value ?? picked, picked.getHours(), picked.getMinutes())) }, [day, onChange, value], ) diff --git a/packages/ui/src/bodies/InlineDateTimePicker.web.tsx b/packages/ui/src/bodies/InlineDateTimePicker.web.tsx index 28408881..7e008634 100644 --- a/packages/ui/src/bodies/InlineDateTimePicker.web.tsx +++ b/packages/ui/src/bodies/InlineDateTimePicker.web.tsx @@ -8,6 +8,7 @@ import { type InlineDateTimePickerProps, type TimeFieldRowProps, } from "./InlineDateTimePicker.types" +import { timeCarrier } from "./calendarModel" function pad(value: number): string { return String(value).padStart(2, "0") @@ -152,9 +153,7 @@ export function TimeFieldRow({ onChange={(event) => { const parsed = parseTimeInput(event.target.value) if (parsed === null) return - const next = new Date(day ?? value ?? new Date()) - next.setHours(parsed.hours, parsed.minutes, 0, 0) - onChange(next) + onChange(timeCarrier(day ?? value ?? new Date(), parsed.hours, parsed.minutes)) }} /> } diff --git a/packages/ui/src/bodies/SlotWindowPicker.tsx b/packages/ui/src/bodies/SlotWindowPicker.tsx index 635b0a37..6028c92d 100644 --- a/packages/ui/src/bodies/SlotWindowPicker.tsx +++ b/packages/ui/src/bodies/SlotWindowPicker.tsx @@ -3,7 +3,7 @@ import { View } from "react-native" import { timeLabel, wallClockInZone, wallClockToInstantMs } from "@civfix/shared/datetime" import { makeThemedStyles } from "../theme" import { useLocale, useT } from "../i18n" -import { addWallClockDays, wallClockToFormDate } from "./calendarModel" +import { addWallClockDays, wallClockToFormTime } from "./calendarModel" import { MIN_SLOT_DURATION_MS } from "./eventSlotsForm" import { TimeFieldRow } from "./InlineDateTimePicker" import { TIME_PICKER_MINUTE_INTERVAL } from "./InlineDateTimePicker.types" @@ -19,7 +19,7 @@ export interface SlotWindowPickerProps { } function carrierOf(instant: Date, timeZone: string): Date { - return wallClockToFormDate(wallClockInZone(instant.getTime(), timeZone)) + return wallClockToFormTime(wallClockInZone(instant.getTime(), timeZone)) } export function clockOnEventDay(clock: Date, eventStart: Date, timeZone: string): number | null { diff --git a/packages/ui/src/bodies/__tests__/deviceDstGapTimeCarrier.test.ts b/packages/ui/src/bodies/__tests__/deviceDstGapTimeCarrier.test.ts new file mode 100644 index 00000000..8ca8fe6d --- /dev/null +++ b/packages/ui/src/bodies/__tests__/deviceDstGapTimeCarrier.test.ts @@ -0,0 +1,89 @@ +process.env.TZ = "America/Los_Angeles" + +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" +import { + formInstantMs, + formWallClock, + timeCarrier, + wallClockToFormDate, + wallClockToFormTime, +} from "../calendarModel" +import { buildEventPreviewCard } from "../feedShare" + +const code = (rel: string) => readFileSync(new URL(rel, import.meta.url), "utf8") + +// 2026-03-08 is the US spring-forward day: on this (Los Angeles) device 02:00-02:59 does not exist, while an +// event in Phoenix (no DST) runs at 02:30 that night like any other. +const gapDay = () => new Date(2026, 2, 8, 12) +const PHOENIX = "America/Phoenix" +const PHOENIX_0230_UTC = Date.UTC(2026, 2, 8, 9, 30) +const organizer = { id: "u-1", displayName: "Host", handle: null, avatarUrl: null } as never + +describe("a clock inside the DEVICE's DST gap survives the event form", () => { + it("the device really has the gap: a Date on that day cannot hold 02:30", () => { + const naive = new Date(2026, 2, 8, 2, 30) + expect([naive.getHours(), naive.getMinutes()]).toEqual([3, 30]) + }) + + it("keeps 02:30 as the picked time and saves 02:30 in the event's zone", () => { + const time = timeCarrier(gapDay(), 2, 30) + expect([time.getHours(), time.getMinutes()]).toEqual([2, 30]) + expect(formWallClock(gapDay(), time)).toEqual({ year: 2026, month: 3, day: 8, hours: 2, minutes: 30 }) + expect(formInstantMs(gapDay(), time, PHOENIX)).toBe(PHOENIX_0230_UTC) + }) + + it("keeps the carrier on the picked day when that day has no offset change", () => { + const time = timeCarrier(new Date(2026, 5, 10, 12), 2, 30) + expect(time.getTime()).toBe(new Date(2026, 5, 10, 2, 30).getTime()) + }) + + it("loads a saved 02:30 event back as 02:30, on its own day", () => { + const wallClock = { year: 2026, month: 3, day: 8, hours: 2, minutes: 30 } + expect(formWallClock(wallClockToFormDate(wallClock), wallClockToFormTime(wallClock))).toEqual(wallClock) + }) + + it("previews the share card at the event-zone instant, not the device-shifted one", () => { + const card = buildEventPreviewCard( + { + title: "Night market", + eventKind: "cleanup", + coords: null, + date: gapDay(), + time: timeCarrier(gapDay(), 2, 30), + timezone: PHOENIX, + }, + organizer, + ) + expect(card?.scheduledAt).toBe(new Date(PHOENIX_0230_UTC).toISOString()) + }) + + it("also holds where the device's gap starts at midnight (Santiago, 2026-09-06 00:00 -> 01:00)", () => { + process.env.TZ = "America/Santiago" + try { + const day = new Date(2026, 8, 6, 12) + expect(new Date(2026, 8, 6, 0, 30).getHours()).toBe(1) + const time = timeCarrier(day, 0, 30) + expect([time.getHours(), time.getMinutes()]).toEqual([0, 30]) + expect(formInstantMs(day, time, "America/Lima")).toBe(Date.UTC(2026, 8, 6, 5, 30)) + } finally { + process.env.TZ = "America/Los_Angeles" + } + }) + + it("is how both pickers, the date change and the edit, duplicate, wizard and slot seeds build a time", () => { + const native = code("../InlineDateTimePicker.native.tsx") + expect(code("../InlineDateTimePicker.web.tsx")).toContain( + "onChange(timeCarrier(day ?? value ?? new Date(), parsed.hours, parsed.minutes))", + ) + expect(native).toContain( + "onChange(timeCarrier(day ?? value ?? picked, picked.getHours(), picked.getMinutes()))", + ) + expect(native).toContain("const current = value ?? timeCarrier(base, base.getHours(), base.getMinutes())") + expect(code("../CleanupForm.tsx")).toContain("timeCarrier(date, value.time.getHours(), value.time.getMinutes())") + expect(code("../host/dashboard/DuplicateEventSheet.tsx")).toContain("setTime(wallClockToFormTime(wallClock))") + expect(code("../EditCleanupBody.tsx")).toContain("time: wallClockToFormTime(start)") + expect(code("../eventWizard.ts")).toContain("wallClockToFormTime(wallClockInZone(endMs, timeZone))") + expect(code("../SlotWindowPicker.tsx")).toContain("wallClockToFormTime(wallClockInZone(instant.getTime(), timeZone))") + }) +}) diff --git a/packages/ui/src/bodies/__tests__/feedShare.test.ts b/packages/ui/src/bodies/__tests__/feedShare.test.ts index e7fe5a6b..b2506122 100644 --- a/packages/ui/src/bodies/__tests__/feedShare.test.ts +++ b/packages/ui/src/bodies/__tests__/feedShare.test.ts @@ -97,7 +97,7 @@ describe("buildEventPreviewCard", () => { const time = new Date("2026-08-01T00:00:00.000Z") it("returns null until title + date + time all exist", () => { - const base = { eventKind: "cleanup" as const, coords: { lat: 1, lng: 2 } } + const base = { eventKind: "cleanup" as const, coords: { lat: 1, lng: 2 }, timezone: "UTC" } expect(buildEventPreviewCard({ ...base, title: "", date, time }, me)).toBeNull() expect(buildEventPreviewCard({ ...base, title: "Beach", date: null, time }, me)).toBeNull() expect(buildEventPreviewCard({ ...base, title: "Beach", date, time: null }, me)).toBeNull() @@ -105,7 +105,7 @@ describe("buildEventPreviewCard", () => { it("builds a LinkedEventRef once the three fields exist", () => { const card = buildEventPreviewCard( - { title: " Beach cleanup ", eventKind: "cleanup", coords: { lat: 1, lng: 2 }, date, time }, + { title: " Beach cleanup ", eventKind: "cleanup", coords: { lat: 1, lng: 2 }, date, time, timezone: "UTC" }, me, "2026-07-25T00:00:00.000Z", ) diff --git a/packages/ui/src/bodies/calendarModel.ts b/packages/ui/src/bodies/calendarModel.ts index 614f254f..dc898f3a 100644 --- a/packages/ui/src/bodies/calendarModel.ts +++ b/packages/ui/src/bodies/calendarModel.ts @@ -98,6 +98,30 @@ export function timeSlots(locale: string): TimeSlot[] { export const PAST_SCHEDULE_GRACE_MS = 60_000 +// Probed at instants 12 h either side of local noon, never at a local midnight: in zones whose gap starts at +// 00:00 (Santiago, Havana, Beirut) midnight itself does not exist and would read the new offset. +function clockAnchorDay(day: Date): Date { + const noon = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 12) + const halfDayMs = 12 * 3_600_000 + const offsetChanges = + new Date(noon.getTime() - halfDayMs).getTimezoneOffset() !== + new Date(noon.getTime() + halfDayMs).getTimezoneOffset() + if (offsetChanges) noon.setDate(noon.getDate() - 1) + return noon +} + +/** + * The form's time field: a device-local Date whose hours and minutes are the event-zone clock; the day + * comes from the date field. A Date on the DEVICE's own DST-change day cannot hold a clock in its + * spring-forward gap (setHours moves 02:30 to 03:30, and that clock would be saved), so on such a day + * the carrier sits on the day before, which never changes offset too. + */ +export function timeCarrier(day: Date, hours: number, minutes: number): Date { + const carrier = clockAnchorDay(day) + carrier.setHours(hours, minutes, 0, 0) + return carrier +} + export function mergeDateTime(date: Date, time: Date): Date { const merged = new Date(date) merged.setHours(time.getHours(), time.getMinutes(), 0, 0) @@ -241,6 +265,14 @@ export function wallClockToFormDate(wallClock: WallClock): Date { ) } +export function wallClockToFormTime(wallClock: WallClock): Date { + return timeCarrier( + new Date(wallClock.year, wallClock.month - 1, wallClock.day, 12), + wallClock.hours, + wallClock.minutes, + ) +} + export function addWallClockDays(wallClock: WallClock, days: number): WallClock { const anchor = new Date(Date.UTC(wallClock.year, wallClock.month - 1, wallClock.day, 12)) const moved = new Date(anchor.getTime() + days * DAY_MS) @@ -284,7 +316,7 @@ export function todayInZone(timeZone: string, now: number = Date.now()): Date { } export function nowClockInZone(timeZone: string, now: number = Date.now()): Date { - return wallClockToFormDate(wallClockInZone(now, timeZone)) + return wallClockToFormTime(wallClockInZone(now, timeZone)) } export function eventWindowInZone( diff --git a/packages/ui/src/bodies/eventWizard.ts b/packages/ui/src/bodies/eventWizard.ts index 2bc72e5c..4eef3cb8 100644 --- a/packages/ui/src/bodies/eventWizard.ts +++ b/packages/ui/src/bodies/eventWizard.ts @@ -3,7 +3,7 @@ import { eventWindowInZone, isScheduleInFutureInZone, isScheduleUntouched, - wallClockToFormDate, + wallClockToFormTime, } from "./calendarModel" import { wallClockInZone } from "@civfix/shared/datetime" import { isEventAddressComplete } from "./eventAddressField" @@ -36,7 +36,7 @@ export function seededEndTime(cleanup: EventScheduleSource, timeZone?: string): if (Number.isNaN(endMs)) return new Date(Number.NaN) return timeZone === undefined ? new Date(endMs) - : wallClockToFormDate(wallClockInZone(endMs, timeZone)) + : wallClockToFormTime(wallClockInZone(endMs, timeZone)) } export function eventWindowUntouched( diff --git a/packages/ui/src/bodies/feedShare.ts b/packages/ui/src/bodies/feedShare.ts index e9e38146..6f4b55a3 100644 --- a/packages/ui/src/bodies/feedShare.ts +++ b/packages/ui/src/bodies/feedShare.ts @@ -9,7 +9,7 @@ import type { ReportType, } from "@civfix/shared" import { appErrorCode } from "./errorCode" -import { mergeDateTime } from "./calendarModel" +import { formInstantMs } from "./calendarModel" import type { LinkedReportCardData } from "./LinkedReportCard" export interface FeedShareAuthUser { @@ -107,6 +107,7 @@ export interface FeedShareEventDraft { coords: { lat: number; lng: number } | null date: Date | null time: Date | null + timezone: string } export function buildEventPreviewCard( @@ -116,13 +117,14 @@ export function buildEventPreviewCard( ): LinkedEventRef | null { const title = draft.title.trim() if (!title || !draft.date || !draft.time) return null - const scheduled = mergeDateTime(draft.date, draft.time) + const scheduledMs = formInstantMs(draft.date, draft.time, draft.timezone) + if (scheduledMs === null) return null return { id: "draft", title, eventKind: draft.eventKind, status: "upcoming", - scheduledAt: scheduled.toISOString(), + scheduledAt: new Date(scheduledMs).toISOString(), lat: draft.coords?.lat ?? 0, lng: draft.coords?.lng ?? 0, going: 1, diff --git a/packages/ui/src/bodies/host/dashboard/DuplicateEventSheet.tsx b/packages/ui/src/bodies/host/dashboard/DuplicateEventSheet.tsx index f31c0747..89b57a37 100644 --- a/packages/ui/src/bodies/host/dashboard/DuplicateEventSheet.tsx +++ b/packages/ui/src/bodies/host/dashboard/DuplicateEventSheet.tsx @@ -8,7 +8,7 @@ import { useNavStore } from "../../../nav" import { useDuplicateCleanup } from "../../../data/hooks/cleanups" import { InlineDateTimePicker } from "../../InlineDateTimePicker" import { TimezoneField } from "../../TimezoneField" -import { formInstantMs, mergeDateTime, wallClockToFormDate } from "../../calendarModel" +import { formInstantMs, timeCarrier, wallClockToFormDate, wallClockToFormTime } from "../../calendarModel" import { appErrorCode } from "../../errorCode" import { duplicateErrorKey, duplicateReady, nextDuplicateStart } from "./dashboardModel" @@ -31,11 +31,9 @@ export function DuplicateEventSheet({ event, onClose }: DuplicateEventSheetProps useEffect(() => { if (!event) return - const seed = wallClockToFormDate( - nextDuplicateStart(event.startsAt, event.timezone, new Date()).wallClock, - ) - setDate(seed) - setTime(seed) + const { wallClock } = nextDuplicateStart(event.startsAt, event.timezone, new Date()) + setDate(wallClockToFormDate(wallClock)) + setTime(wallClockToFormTime(wallClock)) setErrorText(null) duplicate.reset() }, [event]) @@ -106,7 +104,7 @@ export function DuplicateEventSheet({ event, onClose }: DuplicateEventSheetProps timeZone={timeZone} onDateChange={(next) => { setDate(next) - setTime((current) => (current ? mergeDateTime(next, current) : current)) + setTime((current) => (current ? timeCarrier(next, current.getHours(), current.getMinutes()) : current)) }} onTimeChange={setTime} />