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
19 changes: 14 additions & 5 deletions apps/calendar-web/src/common/utils/datetime/web.date.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,18 +33,26 @@ 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)",
);

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),
Expand Down
17 changes: 12 additions & 5 deletions apps/calendar-web/src/common/utils/grid/grid.util.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 11 additions & 3 deletions apps/calendar-web/src/events/grid-event-draft.adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
Expand Down
9 changes: 7 additions & 2 deletions apps/calendar-web/src/events/grid-event-draft.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions apps/calendar-web/src/events/queries/event.view-model.dst.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
13 changes: 11 additions & 2 deletions apps/calendar-web/src/events/queries/event.view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion apps/calendar-web/src/grid/components/CalendarTimeColumn.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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],
Expand Down
16 changes: 13 additions & 3 deletions apps/calendar-web/src/grid/components/TimedGrid.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -59,10 +61,13 @@ export const TimedGrid: FC<TimedGridProps> = ({
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 (
<ScrollableRegion
Expand All @@ -76,14 +81,15 @@ export const TimedGrid: FC<TimedGridProps> = ({
>
<CalendarTimeColumn at={labelDay.toDate()} />
<div
className="absolute top-0 left-(--calendar-grid-margin-left) grid h-[calc(24*100%/var(--calendar-visible-hours))] w-[calc(100%-var(--calendar-grid-margin-left))] grid-cols-[repeat(var(--calendar-column-count),minmax(var(--calendar-column-min-width),1fr))]"
className="absolute top-0 left-(--calendar-grid-margin-left) grid h-[calc(var(--calendar-day-hour-slots)*100%/var(--calendar-visible-hours))] w-[calc(100%-var(--calendar-grid-margin-left))] grid-cols-[repeat(var(--calendar-column-count),minmax(var(--calendar-column-min-width),1fr))]"
id={columnsId}
ref={timedColumnsRef}
style={
{
"--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
}
Expand All @@ -92,6 +98,7 @@ export const TimedGrid: FC<TimedGridProps> = ({
<CalendarNowLine
columnCount={visibleDates.length}
columnIndex={columnIndex}
effectiveTimeZone={effectiveTimeZone}
key={visibleDates[columnIndex]?.key}
/>
))}
Expand Down Expand Up @@ -135,11 +142,12 @@ export const TimedGrid: FC<TimedGridProps> = ({
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) => (
<div
className={`relative flex ${TIMED_HOUR_SLOT_HEIGHT_CLASS} w-full items-start border-border border-b`}
key={dayTime}
Expand All @@ -156,12 +164,14 @@ export const TimedGrid: FC<TimedGridProps> = ({
const CalendarNowLine = ({
columnCount,
columnIndex,
effectiveTimeZone,
}: {
columnCount: number;
columnIndex: number;
effectiveTimeZone: string;
}) => {
useMinuteTick();
const percentOfDay = getCurrentPercentOfDay();
const percentOfDay = getCurrentPercentOfDay(effectiveTimeZone);
const pulseNowLine = useNowCueStore(selectNowCueActive);

return (
Expand Down
Loading
Loading