Skip to content
Merged
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
9 changes: 6 additions & 3 deletions packages/ui/src/bodies/CleanupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ import {
endTimeAfter,
endTimeSelectable,
formInstantMs,
mergeDateTime,
scheduleFieldErrors,
timeCarrier,
} from "./calendarModel"
import { InlineDateTimePicker } from "./InlineDateTimePicker"
import { ReportLinkPicker } from "./ReportLinkPicker"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -595,6 +597,7 @@ export function CleanupForm({
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: value.timezone,
}),
}
}, [value, locale])
Expand Down
10 changes: 5 additions & 5 deletions packages/ui/src/bodies/CreateCleanupBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ import {
hasValidEventEnd,
emptyCleanupForm,
isCleanupFormComplete,
mergeDateTime,
type CleanupFormSection,
type CleanupFormValue,
} from "./CleanupForm"
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions packages/ui/src/bodies/EditCleanupBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isScheduleInFutureInZone,
isScheduleUntouched,
wallClockToFormDate,
wallClockToFormTime,
} from "./calendarModel"
import { wallClockInZone } from "@civfix/shared/datetime"
import { CleanupForm, isCleanupFormComplete, type CleanupFormValue } from "./CleanupForm"
Expand Down Expand Up @@ -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,
Expand All @@ -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 ?? [],
Expand Down
9 changes: 4 additions & 5 deletions packages/ui/src/bodies/InlineDateTimePicker.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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],
)
Expand Down
5 changes: 2 additions & 3 deletions packages/ui/src/bodies/InlineDateTimePicker.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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))
}}
/>
}
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/bodies/SlotWindowPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down
89 changes: 89 additions & 0 deletions packages/ui/src/bodies/__tests__/deviceDstGapTimeCarrier.test.ts
Original file line number Diff line number Diff line change
@@ -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))")
})
})
4 changes: 2 additions & 2 deletions packages/ui/src/bodies/__tests__/feedShare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ 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()
})

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",
)
Expand Down
34 changes: 33 additions & 1 deletion packages/ui/src/bodies/calendarModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/bodies/eventWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
eventWindowInZone,
isScheduleInFutureInZone,
isScheduleUntouched,
wallClockToFormDate,
wallClockToFormTime,
} from "./calendarModel"
import { wallClockInZone } from "@civfix/shared/datetime"
import { isEventAddressComplete } from "./eventAddressField"
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/bodies/feedShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -107,6 +107,7 @@ export interface FeedShareEventDraft {
coords: { lat: number; lng: number } | null
date: Date | null
time: Date | null
timezone: string
}

export function buildEventPreviewCard(
Expand All @@ -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,
Expand Down
Loading
Loading