diff --git a/apps/calendar-web/src/common/utils/datetime/web.date.util.ts b/apps/calendar-web/src/common/utils/datetime/web.date.util.ts index ee80c80b80..00366b228f 100644 --- a/apps/calendar-web/src/common/utils/datetime/web.date.util.ts +++ b/apps/calendar-web/src/common/utils/datetime/web.date.util.ts @@ -10,6 +10,7 @@ import { type EventSchedule, EventScheduleSchema, } from "@core/types/event.contracts"; +import { civilHourSlotCount, startOfCivilDay } from "@core/util/date/civil-day"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { ACCEPTED_TIMES } from "@web/common/constants/web.constants"; import { type SelectOption } from "@web/common/types/component.types"; @@ -32,8 +33,11 @@ interface SelectedDates { * `currentHour` matches at `currentHour - 1`. CSS variables (not hex) so the * colors land in inline styles that resolve against the active [data-theme]. */ -export const getColorsByHour = (currentHour: number): string[] => - Array.from({ length: 24 }, (_, index) => +export const getColorsByHour = ( + currentHour: number, + slotCount = 24, +): string[] => + Array.from({ length: slotCount }, (_, index) => currentHour - 1 === index ? "var(--accent)" : "var(--text-muted)", ); @@ -41,9 +45,14 @@ export const getDayjsByTimeValue = (timeValue: string) => { return dayjs(`2000-01-01 ${timeValue}`, YMDHAM_FORMAT); }; -export const getHourLabels = (includeMidnight = false) => { - const day = dayjs().startOf("day"); - const hours = includeMidnight ? 24 : 23; +export const getHourLabels = ( + includeMidnight = false, + referenceDay: Dayjs | Date | string = dayjs(), + timeZone: string = getEffectiveTimeZone(), +) => { + const slotCount = civilHourSlotCount(referenceDay, timeZone); + const hours = includeMidnight ? slotCount : Math.max(0, slotCount - 1); + const day = startOfCivilDay(referenceDay, timeZone); return Array.from({ length: hours }, (_, index) => day.add(index + 1, "hour").format(HOURS_AM_SHORT_FORMAT), diff --git a/apps/calendar-web/src/common/utils/grid/grid.util.ts b/apps/calendar-web/src/common/utils/grid/grid.util.ts index 593f2883c1..77feb35904 100644 --- a/apps/calendar-web/src/common/utils/grid/grid.util.ts +++ b/apps/calendar-web/src/common/utils/grid/grid.util.ts @@ -1,3 +1,7 @@ +import { + civilDayLengthMinutes, + minutesFromCivilMidnight, +} from "@core/util/date/civil-day"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { GRID_EVENT_TITLE_LINE_HEIGHT_PX, @@ -35,9 +39,8 @@ export const assignEventToRow = ( return { fits, rowNum }; }; -export const getCurrentMinute = () => { - const now = dayjs().tz(getEffectiveTimeZone()); - return now.get("hours") * 60 + now.get("minutes"); +export const getCurrentMinute = (timeZone: string = getEffectiveTimeZone()) => { + return minutesFromCivilMidnight(dayjs(), timeZone); }; // Timed grid always renders exactly TIMED_VISIBLE_HOURS worth of rows in @@ -46,8 +49,12 @@ export const getCurrentMinute = () => { export const getMinuteHeight = (clientHeight: number) => clientHeight / TIMED_VISIBLE_HOURS / 60; -export const getCurrentPercentOfDay = () => { - return (getCurrentMinute() / 1440) * 100; +export const getCurrentPercentOfDay = ( + timeZone: string = getEffectiveTimeZone(), +) => { + const now = dayjs().tz(timeZone); + const civilMinutes = civilDayLengthMinutes(now, timeZone); + return (minutesFromCivilMidnight(now, timeZone) / civilMinutes) * 100; }; // Scrolls the current time to just below the viewport top, not flush with diff --git a/apps/calendar-web/src/events/grid-event-draft.adapter.test.ts b/apps/calendar-web/src/events/grid-event-draft.adapter.test.ts index ef371cf967..319fc8e5ad 100644 --- a/apps/calendar-web/src/events/grid-event-draft.adapter.test.ts +++ b/apps/calendar-web/src/events/grid-event-draft.adapter.test.ts @@ -6,7 +6,7 @@ import { } from "@core/types/calendar.contracts"; import { EventIdSchema } from "@core/types/domain-primitives"; import { type Event } from "@core/types/event.contracts"; -import dayjs from "@core/util/date/dayjs"; +import { formatInstantInTimeZone } from "@core/util/date/civil-day"; import { type GridEventDraft } from "@web/events/event-draft.types"; import { resetEffectiveTimeZoneStoreForTests, @@ -227,8 +227,16 @@ test("projects a grid draft into a grid event without a CompassEvent bridge", () const timedGrid = gridEventDraftToGridEvent(timed); expect(timedGrid.isAllDay).toBe(false); - expect(timedGrid.startDate).toBe(dayjs(timed.values.schedule.start).format()); - expect(timedGrid.endDate).toBe(dayjs(timed.values.schedule.end).format()); + if (timed.values.schedule.kind !== "timed") { + throw new Error("Expected timed schedule"); + } + const zone = timed.values.schedule.timeZone ?? "America/Denver"; + expect(timedGrid.startDate).toBe( + formatInstantInTimeZone(timed.values.schedule.start, zone), + ); + expect(timedGrid.endDate).toBe( + formatInstantInTimeZone(timed.values.schedule.end, zone), + ); expect(timedGrid.color).toBe("coral"); expect(timedGrid.isBusy).toBe(false); }); diff --git a/apps/calendar-web/src/events/grid-event-draft.adapter.ts b/apps/calendar-web/src/events/grid-event-draft.adapter.ts index ce7242ddc8..7c1ea4c719 100644 --- a/apps/calendar-web/src/events/grid-event-draft.adapter.ts +++ b/apps/calendar-web/src/events/grid-event-draft.adapter.ts @@ -16,6 +16,7 @@ import { type CreateEventInput, type RecurrenceScope, } from "@core/types/event-command.contracts"; +import { formatInstantInTimeZone } from "@core/util/date/civil-day"; import dayjs from "@core/util/date/dayjs"; import { type GridEvent } from "@web/common/types/web.event.types"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; @@ -278,6 +279,10 @@ export function gridEventDraftToGridEvent(draft: GridEventDraft): GridEvent { const color = draft.values.color ?? undefined; const colorHex = gridEventDraftColorHex(draft); const isAllDay = schedule.kind === "allDay"; + const timedZone = + schedule.kind === "timed" + ? (schedule.timeZone ?? getEffectiveTimeZone()) + : getEffectiveTimeZone(); return { _id: getGridDraftId(draft), @@ -288,10 +293,10 @@ export function gridEventDraftToGridEvent(draft: GridEventDraft): GridEvent { isAllDay, startDate: isAllDay ? toDateOnlyString(schedule.start) - : dayjs(schedule.start).format(), + : formatInstantInTimeZone(schedule.start, timedZone), endDate: isAllDay ? toDateOnlyString(schedule.end) - : dayjs(schedule.end).format(), + : formatInstantInTimeZone(schedule.end, timedZone), recurrence: legacyRecurrenceFromDraft(draft), position: gridEventDefaultPosition, calendarId: draft.values.calendarId ?? undefined, diff --git a/apps/calendar-web/src/events/queries/event.view-model.dst.test.ts b/apps/calendar-web/src/events/queries/event.view-model.dst.test.ts new file mode 100644 index 0000000000..086d9d051c --- /dev/null +++ b/apps/calendar-web/src/events/queries/event.view-model.dst.test.ts @@ -0,0 +1,40 @@ +import { act } from "react"; +import { EventScheduleSchema } from "@core/types/event.contracts"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { deriveCalendarEventViewModel } from "@web/events/queries/event.view-model"; +import { + resetEffectiveTimeZoneStoreForTests, + setPinnedTimeZone, +} from "@web/timezone/effective-timezone.store"; +import { afterEach, describe, expect, it } from "bun:test"; + +const denver = "America/Denver"; + +afterEach(() => { + act(() => resetEffectiveTimeZoneStoreForTests()); +}); + +describe("event.view-model DST display", () => { + it("re-anchors timed start/end to the schedule zone after fall-back", () => { + act(() => setPinnedTimeZone(denver)); + + const schedule = EventScheduleSchema.parse({ + end: "2026-11-05T17:00:00.000Z", + kind: "timed", + start: "2026-11-05T16:00:00.000Z", + timeZone: denver, + }); + const event = createMockEvent({ schedule }); + const viewModel = deriveCalendarEventViewModel({ + demoEventIds: [], + duplicateCopies: new Map(), + entities: { [event.id]: event }, + ids: [event.id], + }); + + expect(viewModel.timedEvents[0]?.startDate).toBe( + "2026-11-05T09:00:00-07:00", + ); + expect(viewModel.timedEvents[0]?.endDate).toBe("2026-11-05T10:00:00-07:00"); + }); +}); diff --git a/apps/calendar-web/src/events/queries/event.view-model.ts b/apps/calendar-web/src/events/queries/event.view-model.ts index b7845782a6..d2f79bd230 100644 --- a/apps/calendar-web/src/events/queries/event.view-model.ts +++ b/apps/calendar-web/src/events/queries/event.view-model.ts @@ -2,6 +2,7 @@ import { Origin } from "@core/constants/core.constants"; import { type EventId } from "@core/types/domain-primitives"; import { type Event } from "@core/types/event.contracts"; import { withColor, withColorHex } from "@core/types/event-color.contracts"; +import { formatInstantInTimeZone } from "@core/util/date/civil-day"; import { type GridEvent } from "@web/common/types/web.event.types"; import { gridEventDefaultPosition } from "@web/common/utils/event/event.util"; import { @@ -57,8 +58,16 @@ const eventToGridEvent = ( ): GridEvent => { const { schedule } = event; const isAllDay = scheduleOverride?.isAllDay ?? schedule.kind === "allDay"; - const startDate = scheduleOverride?.startDate ?? schedule.start; - const endDate = scheduleOverride?.endDate ?? schedule.end; + const startDate = + scheduleOverride?.startDate ?? + (schedule.kind === "timed" + ? formatInstantInTimeZone(schedule.start, schedule.timeZone) + : schedule.start); + const endDate = + scheduleOverride?.endDate ?? + (schedule.kind === "timed" + ? formatInstantInTimeZone(schedule.end, schedule.timeZone) + : schedule.end); const isBusy = event.content.kind === "busy"; const isProviderManaged = event.providerManaged === true; const details = event.content.kind === "details" ? event.content : undefined; diff --git a/apps/calendar-web/src/grid/components/CalendarTimeColumn.tsx b/apps/calendar-web/src/grid/components/CalendarTimeColumn.tsx index 5a776f496f..420baf5411 100644 --- a/apps/calendar-web/src/grid/components/CalendarTimeColumn.tsx +++ b/apps/calendar-web/src/grid/components/CalendarTimeColumn.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { civilHourSlotCount } from "@core/util/date/civil-day"; import { useMinuteTick } from "@web/common/hooks/useMinuteTick"; import { type CSSVariables } from "@web/common/styles/css.types"; import { getColorsByHour } from "@web/common/utils/datetime/web.date.util"; @@ -17,7 +18,14 @@ export function CalendarTimeColumn({ at }: { at: Date }) { const timeTravelZone = useTimeTravelZone(); const marginLeft = gridMarginLeftFor(timeTravelZone !== null); const currentHour = useMinuteTick().tz(effectiveTimeZone).hour(); - const colors = useMemo(() => getColorsByHour(currentHour), [currentHour]); + const slotCount = useMemo( + () => civilHourSlotCount(at, effectiveTimeZone), + [at, effectiveTimeZone], + ); + const colors = useMemo( + () => getColorsByHour(currentHour, slotCount), + [currentHour, slotCount], + ); const primaryLabels = useMemo( () => mappedHourLabels(effectiveTimeZone, effectiveTimeZone, at), [at, effectiveTimeZone], diff --git a/apps/calendar-web/src/grid/components/TimedGrid.tsx b/apps/calendar-web/src/grid/components/TimedGrid.tsx index 81e0c204fc..c0a201f166 100644 --- a/apps/calendar-web/src/grid/components/TimedGrid.tsx +++ b/apps/calendar-web/src/grid/components/TimedGrid.tsx @@ -1,6 +1,7 @@ import cn from "classnames"; import { type FC, type ReactNode, type RefCallback } from "react"; import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import { civilHourSlotCount } from "@core/util/date/civil-day"; import { type Dayjs } from "@core/util/date/dayjs"; import { CALENDAR_GRID_PH_NO_RAGECLICK_CLASS } from "@web/auth/posthog/posthog-calendar-grid.util"; import { @@ -32,6 +33,7 @@ import { selectEventJumpActiveDayKeys, useEventJumpStore, } from "@web/shortcuts/shift-hint/event-jump.store"; +import { useEffectiveTimeZone } from "@web/timezone/effective-timezone.store"; interface TimedGridProps { columnsId?: string; @@ -59,10 +61,13 @@ export const TimedGrid: FC = ({ const todayColumnIndexes = visibleDates.flatMap(({ date }, index) => date.isSame(today, "day") ? [index] : [], ); + const effectiveTimeZone = useEffectiveTimeZone(); const labelDay = visibleDates.find(({ date }) => date.isSame(today, "day"))?.date ?? visibleDates[0]?.date ?? today; + const civilHourSlots = civilHourSlotCount(labelDay, effectiveTimeZone); + const hourRowLabels = getHourLabels(true, labelDay, effectiveTimeZone); return ( = ({ >
= ({ "--calendar-column-count": visibleDates.length, "--calendar-column-min-width": `${EVENT_WIDTH_MINIMUM}px`, "--calendar-grid-margin-left": `${marginLeft}px`, + "--calendar-day-hour-slots": civilHourSlots, "--calendar-visible-hours": TIMED_VISIBLE_HOURS, } as CSSVariables } @@ -92,6 +98,7 @@ export const TimedGrid: FC = ({ ))} @@ -135,11 +142,12 @@ export const TimedGrid: FC = ({ style={ { "--calendar-grid-margin-left": `${marginLeft}px`, + "--calendar-day-hour-slots": civilHourSlots, "--calendar-visible-hours": TIMED_VISIBLE_HOURS, } as CSSVariables } > - {getHourLabels(true).map((dayTime) => ( + {hourRowLabels.map((dayTime) => (
= ({ const CalendarNowLine = ({ columnCount, columnIndex, + effectiveTimeZone, }: { columnCount: number; columnIndex: number; + effectiveTimeZone: string; }) => { useMinuteTick(); - const percentOfDay = getCurrentPercentOfDay(); + const percentOfDay = getCurrentPercentOfDay(effectiveTimeZone); const pulseNowLine = useNowCueStore(selectNowCueActive); return ( diff --git a/apps/calendar-web/src/grid/dst/grid-dst.test.ts b/apps/calendar-web/src/grid/dst/grid-dst.test.ts new file mode 100644 index 0000000000..c6b70a0dba --- /dev/null +++ b/apps/calendar-web/src/grid/dst/grid-dst.test.ts @@ -0,0 +1,235 @@ +import { ObjectId } from "bson"; +import { act } from "react"; +import { + civilDayLengthMinutes, + civilHourSlotCount, + formatInstantInTimeZone, +} from "@core/util/date/civil-day"; +import dayjs from "@core/util/date/dayjs"; +import { CompassEventRRule } from "@core/util/event/compass.event.rrule"; +import { getHourLabels } from "@web/common/utils/datetime/web.date.util"; +import { + getCurrentPercentOfDay, + getScrollToNowTop, +} from "@web/common/utils/grid/grid.util"; +import { getTimedEventPosition } from "@web/grid/layout/event.position"; +import { + resetEffectiveTimeZoneStoreForTests, + setPinnedTimeZone, +} from "@web/timezone/effective-timezone.store"; +import { mappedHourLabels } from "@web/timezone/mapped-hour-labels"; +import { + resetTimeTravelStoreForTests, + setTimeTravelZone, +} from "@web/timezone/time-travel.store"; +import { timedIntentAt } from "@web/views/Week/pointer-intent/grid-pointer-target"; +import { afterEach, describe, expect, it, setSystemTime } from "bun:test"; + +const denver = "America/Denver"; +const newYork = "America/New_York"; +const fallBackDenver = "2026-11-01"; +const springForwardDenver = "2027-03-14"; + +const measurements = { + allDayRow: null, + colWidths: [320], + hourHeight: 60, + mainGrid: { + bottom: 1800, + height: 780, + left: 0, + right: 320, + top: 0, + width: 320, + x: 0, + y: 0, + }, +}; + +afterEach(() => { + setSystemTime(); + act(() => { + resetEffectiveTimeZoneStoreForTests(); + resetTimeTravelStoreForTests(); + }); +}); + +describe("US DST civil day length", () => { + it("uses 25 hours on 2026-11-01 fall-back in Denver and New York", () => { + expect(civilHourSlotCount(fallBackDenver, denver)).toBe(25); + expect(civilHourSlotCount(fallBackDenver, newYork)).toBe(25); + }); + + it("uses 23 hours on 2027-03-14 spring-forward in Denver and New York", () => { + expect(civilHourSlotCount(springForwardDenver, denver)).toBe(23); + expect(civilHourSlotCount(springForwardDenver, newYork)).toBe(23); + }); +}); + +describe("event positioning on DST days", () => { + const visibleDates = (dayKey: string) => [ + { date: dayjs.tz(`${dayKey}T12:00`, denver), key: dayKey }, + ]; + + it("keeps 9 AM on the same row before and after fall-back", () => { + act(() => setPinnedTimeZone(denver)); + + const beforeDst = getTimedEventPosition( + { + endDate: "2026-10-29T10:00:00-06:00", + isAllDay: false, + position: { widthMultiplier: 1 }, + startDate: "2026-10-29T09:00:00-06:00", + } as never, + { + measurements, + visibleDates: visibleDates("2026-10-29"), + isDraft: false, + }, + ).top; + + const afterDst = getTimedEventPosition( + { + endDate: "2026-11-05T10:00:00-07:00", + isAllDay: false, + position: { widthMultiplier: 1 }, + startDate: "2026-11-05T09:00:00-07:00", + } as never, + { + measurements, + visibleDates: visibleDates("2026-11-05"), + isDraft: false, + }, + ).top; + + expect(beforeDst).toBe(9 * 60); + expect(afterDst).toBe(9 * 60); + }); + + it("preserves duration in minutes across spring-forward week", () => { + act(() => setPinnedTimeZone(denver)); + + const start = dayjs.tz("2027-03-14T09:00:00", denver); + const end = start.add(1, "hour"); + + const position = getTimedEventPosition( + { + endDate: end.format(), + isAllDay: false, + position: { widthMultiplier: 1 }, + startDate: start.format(), + } as never, + { + measurements, + visibleDates: visibleDates("2027-03-14"), + isDraft: false, + }, + ); + + const minutesFromMidnight = start.diff(start.startOf("day"), "minute"); + expect(position.top).toBe(minutesFromMidnight); + expect(position.height).toBeGreaterThan(0); + expect(position.height).toBe(60 - 3); + }); +}); + +describe("now line and hour labels", () => { + it("uses civil day length for the now-line on fall-back morning", () => { + act(() => setPinnedTimeZone(denver)); + setSystemTime(new Date("2026-11-01T15:00:00.000Z")); + + const percent = getCurrentPercentOfDay(denver); + const minutes = 9 * 60; + const expected = + (minutes / civilDayLengthMinutes(fallBackDenver, denver)) * 100; + expect(percent).toBeCloseTo(expected, 5); + }); + + it("renders 25 hour grid rows on fall-back and 23 on spring-forward", () => { + expect(getHourLabels(true, fallBackDenver, denver)).toHaveLength(25); + expect(getHourLabels(true, springForwardDenver, denver)).toHaveLength(23); + }); + + it("maps secondary timezone labels with DST-aware slot count", () => { + act(() => { + setPinnedTimeZone(newYork); + setTimeTravelZone(denver); + }); + + const labels = mappedHourLabels(newYork, denver, fallBackDenver); + expect(labels).toHaveLength(24); + }); +}); + +describe("grid click intent on DST days", () => { + it("maps y position with fall-back civil length", () => { + act(() => setPinnedTimeZone(denver)); + + const civilMinutes = civilDayLengthMinutes(fallBackDenver, denver); + const yForNineAm = (540 / civilMinutes) * 1500; + const grid = { + scrollHeight: 1500, + scrollTop: 0, + getBoundingClientRect: () => ({ top: 0 }), + } as unknown as HTMLElement; + + const previousGetElementById = document.getElementById.bind(document); + document.getElementById = (id: string) => + id === "mainGrid" ? grid : previousGetElementById(id); + + try { + const intent = timedIntentAt(fallBackDenver, yForNineAm); + expect(intent?.start).toContain("T09:00:00"); + } finally { + document.getElementById = previousGetElementById; + } + }); +}); + +describe("recurring events across DST", () => { + it("keeps 9 AM Denver on weekly occurrences after fall-back", () => { + act(() => setPinnedTimeZone(denver)); + + const start = dayjs.tz("2026-10-22T09:00:00", denver); + const rrule = new CompassEventRRule( + { + _id: new ObjectId(), + startDate: start.format(), + endDate: start.add(1, "hour").format(), + isAllDay: false, + recurrence: { rule: ["RRULE:FREQ=WEEKLY;COUNT=3"] }, + } as never, + { tzid: denver }, + ); + + const afterFallback = rrule + .all() + .map((occurrence) => dayjs(occurrence).tz(denver).format("HH:mm Z")); + expect(afterFallback).toEqual([ + "09:00 -06:00", + "09:00 -06:00", + "09:00 -07:00", + ]); + }); +}); + +describe("schedule display offsets", () => { + it("re-anchors stored instants to the zone offset for the civil date", () => { + const formatted = formatInstantInTimeZone( + "2026-11-05T16:00:00.000Z", + denver, + ); + expect(formatted).toBe("2026-11-05T09:00:00-07:00"); + }); +}); + +describe("scroll-to-now on spring-forward day", () => { + it("scrolls using civil minutes, not a fixed 1440", () => { + act(() => setPinnedTimeZone(denver)); + setSystemTime(new Date("2027-03-14T16:00:00.000Z")); + + const clientHeight = 780; + const scrollTop = getScrollToNowTop(clientHeight); + expect(scrollTop).toBeGreaterThan(0); + }); +}); diff --git a/apps/calendar-web/src/grid/hooks/useGridCoordinates.ts b/apps/calendar-web/src/grid/hooks/useGridCoordinates.ts index c5b415703a..dd758a7b78 100644 --- a/apps/calendar-web/src/grid/hooks/useGridCoordinates.ts +++ b/apps/calendar-web/src/grid/hooks/useGridCoordinates.ts @@ -1,4 +1,8 @@ import { type MutableRefObject } from "react"; +import { + civilDayLengthMinutes, + startOfCivilDay, +} from "@core/util/date/civil-day"; import dayjs from "@core/util/date/dayjs"; import { roundToPrev } from "@web/common/utils/round/round.util"; import { GRID_TIME_STEP } from "@web/grid/grid.constants"; @@ -7,6 +11,7 @@ import { type GridMeasurements, type GridVisibleDate, } from "@web/grid/types/grid.types"; +import { getEffectiveTimeZone } from "@web/timezone/effective-timezone.store"; export const useGridCoordinates = ( measurements: GridMeasurements, @@ -48,8 +53,12 @@ export const useGridCoordinates = ( const getDateByXY = (x: number, y: number) => { const visibleDate = visibleDates[getVisibleDateIndexByX(x)]?.date; + const timeZone = getEffectiveTimeZone(); + const dayStart = startOfCivilDay(visibleDate ?? dayjs(), timeZone); + const civilMinutes = civilDayLengthMinutes(dayStart, timeZone); + const minute = Math.min(getMinuteByY(y), civilMinutes); - return (visibleDate ?? dayjs()).add(getMinuteByY(y), "minutes"); + return dayStart.add(minute, "minute"); }; const getDateStrByXY = (x: number, y: number, format?: string) => { @@ -62,8 +71,10 @@ export const useGridCoordinates = ( }; const getYByDate = (date: string) => { - const day = dayjs(date); - return measurements.hourHeight * (day.hour() + day.minute() / 60); + const timeZone = getEffectiveTimeZone(); + const zoned = dayjs(date).tz(timeZone); + const minutes = zoned.diff(startOfCivilDay(zoned, timeZone), "minute"); + return measurements.hourHeight * (minutes / 60); }; return { diff --git a/apps/calendar-web/src/timezone/mapped-hour-labels.test.ts b/apps/calendar-web/src/timezone/mapped-hour-labels.test.ts index da42160b5b..e034749f21 100644 --- a/apps/calendar-web/src/timezone/mapped-hour-labels.test.ts +++ b/apps/calendar-web/src/timezone/mapped-hour-labels.test.ts @@ -25,6 +25,16 @@ describe("mappedHourLabels", () => { expect(labels[12]).toBe("11 AM"); }); + it("emits 24 mapped labels on a 25-hour fall-back day", () => { + const labels = mappedHourLabels( + "America/Denver", + "America/Denver", + "2026-11-01", + ); + expect(labels).toHaveLength(24); + expect(labels[0]).toBe("1 AM"); + }); + it("uses the per-instant offset when DST differs between zones", () => { // 2026-03-15: US already on EDT, UK still on GMT (5h vs the later 4h BST gap). const march = mappedHourLabels( diff --git a/apps/calendar-web/src/timezone/mapped-hour-labels.ts b/apps/calendar-web/src/timezone/mapped-hour-labels.ts index b028711bfd..d648486295 100644 --- a/apps/calendar-web/src/timezone/mapped-hour-labels.ts +++ b/apps/calendar-web/src/timezone/mapped-hour-labels.ts @@ -1,5 +1,6 @@ import { HOURS_AM_SHORT_FORMAT } from "@core/constants/date.constants"; -import dayjs from "@core/util/date/dayjs"; +import { civilHourSlotCount, startOfCivilDay } from "@core/util/date/civil-day"; +import type dayjs from "@core/util/date/dayjs"; function formatMappedHourLabel(date: dayjs.Dayjs): string { return date.minute() === 0 @@ -17,8 +18,9 @@ export function mappedHourLabels( displayTimeZone: string, at: Date | string, ): string[] { - const day = dayjs(at).tz(effectiveTimeZone).startOf("day"); - return Array.from({ length: 23 }, (_, index) => + const labelCount = Math.max(0, civilHourSlotCount(at, effectiveTimeZone) - 1); + const day = startOfCivilDay(at, effectiveTimeZone); + return Array.from({ length: labelCount }, (_, index) => formatMappedHourLabel(day.add(index + 1, "hour").tz(displayTimeZone)), ); } diff --git a/apps/calendar-web/src/views/Week/pointer-intent/grid-pointer-target.ts b/apps/calendar-web/src/views/Week/pointer-intent/grid-pointer-target.ts index 47c5d52be2..3a94a91f14 100644 --- a/apps/calendar-web/src/views/Week/pointer-intent/grid-pointer-target.ts +++ b/apps/calendar-web/src/views/Week/pointer-intent/grid-pointer-target.ts @@ -1,4 +1,5 @@ import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants"; +import { civilDayLengthMinutes } from "@core/util/date/civil-day"; import dayjs from "@core/util/date/dayjs"; import { DATA_TIMED_GRID_ROW, @@ -75,9 +76,11 @@ export const timedIntentAt = ( if (!grid) return null; const rect = grid.getBoundingClientRect(); const relative = grid.scrollTop + clientY - rect.top; - const rawMinute = (relative / grid.scrollHeight) * 1440; + const timeZone = getEffectiveTimeZone(); + const civilMinutes = civilDayLengthMinutes(date, timeZone); + const rawMinute = (relative / grid.scrollHeight) * civilMinutes; const minute = Math.min( - 24 * 60 - GRID_TIME_STEP, + civilMinutes - GRID_TIME_STEP, Math.max(0, Math.round(rawMinute / GRID_TIME_STEP) * GRID_TIME_STEP), ); const hour = Math.floor(minute / 60); @@ -89,9 +92,7 @@ export const timedIntentAt = ( hour: "numeric", minute: "2-digit", }).format(new Date(2000, 0, 1, hour, minutes)); - const start = dayjs - .tz(`${date}T${hh}:${mm}`, getEffectiveTimeZone()) - .format(); + const start = dayjs.tz(`${date}T${hh}:${mm}`, timeZone).format(); return { date, kind: "timed", start, timeKey, timeLabel }; }; diff --git a/packages/core/src/util/date/civil-day.test.ts b/packages/core/src/util/date/civil-day.test.ts new file mode 100644 index 0000000000..2daf85e765 --- /dev/null +++ b/packages/core/src/util/date/civil-day.test.ts @@ -0,0 +1,61 @@ +import { + civilDayLengthMinutes, + civilHourSlotCount, + formatInstantInTimeZone, + minutesFromCivilMidnight, +} from "./civil-day"; +import { describe, expect, it } from "bun:test"; + +const denver = "America/Denver"; +const newYork = "America/New_York"; + +describe("civilDayLengthMinutes", () => { + it("counts 1500 minutes on US fall-back 2026-11-01 in Denver", () => { + expect(civilDayLengthMinutes("2026-11-01", denver)).toBe(1500); + expect(civilHourSlotCount("2026-11-01", denver)).toBe(25); + }); + + it("counts 1380 minutes on US spring-forward 2027-03-14 in Denver", () => { + expect(civilDayLengthMinutes("2027-03-14", denver)).toBe(1380); + expect(civilHourSlotCount("2027-03-14", denver)).toBe(23); + }); + + it("matches New York fall-back and spring-forward boundaries", () => { + expect(civilDayLengthMinutes("2026-11-01", newYork)).toBe(1500); + expect(civilDayLengthMinutes("2027-03-14", newYork)).toBe(1380); + }); + + it("counts 1440 minutes on a non-transition day", () => { + expect(civilDayLengthMinutes("2026-08-20", denver)).toBe(1440); + }); +}); + +describe("minutesFromCivilMidnight", () => { + it("places 9 AM on the expected minute index regardless of offset suffix", () => { + expect(minutesFromCivilMidnight("2026-11-05T09:00:00-07:00", denver)).toBe( + 540, + ); + expect(minutesFromCivilMidnight("2026-11-05T16:00:00.000Z", denver)).toBe( + 540, + ); + }); + + it("separates the two 1:30 AM instants on fall-back day", () => { + expect(minutesFromCivilMidnight("2026-11-01T01:30:00-06:00", denver)).toBe( + 90, + ); + expect(minutesFromCivilMidnight("2026-11-01T01:30:00-07:00", denver)).toBe( + 150, + ); + }); +}); + +describe("formatInstantInTimeZone", () => { + it("re-anchors to the zone offset for the civil date", () => { + const formatted = formatInstantInTimeZone( + "2026-11-05T16:00:00.000Z", + denver, + ); + expect(formatted).toBe("2026-11-05T09:00:00-07:00"); + }); +}); diff --git a/packages/core/src/util/date/civil-day.ts b/packages/core/src/util/date/civil-day.ts new file mode 100644 index 0000000000..ed5063fe07 --- /dev/null +++ b/packages/core/src/util/date/civil-day.ts @@ -0,0 +1,49 @@ +import dayjs, { type Dayjs } from "@core/util/date/dayjs"; + +/** Midnight at the start of `day` in `timeZone` (calendar date from `day`). */ +export function startOfCivilDay( + day: Dayjs | Date | string, + timeZone: string, +): Dayjs { + const key = dayjs(day).format("YYYY-MM-DD"); + return dayjs.tz(key, timeZone).startOf("day"); +} + +/** Minutes from civil midnight to the next civil midnight (1380, 1440, or 1500). */ +export function civilDayLengthMinutes( + day: Dayjs | Date | string, + timeZone: string, +): number { + const start = startOfCivilDay(day, timeZone); + const nextKey = start.add(1, "day").format("YYYY-MM-DD"); + const nextMidnight = dayjs.tz(nextKey, timeZone).startOf("day"); + return nextMidnight.diff(start, "minute"); +} + +/** Hour-sized slots in a civil day (23 on spring-forward, 25 on fall-back). */ +export function civilHourSlotCount( + day: Dayjs | Date | string, + timeZone: string, +): number { + return civilDayLengthMinutes(day, timeZone) / 60; +} + +/** Minutes from civil midnight for `instant` in `timeZone`. */ +export function minutesFromCivilMidnight( + instant: Dayjs | Date | string, + timeZone: string, +): number { + const zoned = dayjs(instant).tz(timeZone); + return zoned.diff(startOfCivilDay(zoned, timeZone), "minute"); +} + +/** + * RFC3339 offset string for an absolute instant in `timeZone`, with the + * offset that applies on that civil date (handles stale offsets in `iso`). + */ +export function formatInstantInTimeZone( + iso: string | Date, + timeZone: string, +): string { + return dayjs(iso).tz(timeZone).format(); +}