Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { FloatingFocusManager, FloatingPortal } from "@floating-ui/react";
import { Schema_Event } from "@core/types/event.types";
import { ZIndex } from "@web/common/constants/web.constants";
import { useDayDraftContext } from "@web/views/Day/context/DayDraftContext";
import { EventForm } from "@web/views/Forms/EventForm/EventForm";

export const AgendaEventForm = () => {
const {
draftEvent,
isFormOpen,
floatingProps,
closeForm,
setDraftEvent,
submitDraft,
} = useDayDraftContext();

if (!isFormOpen || !draftEvent) {
return null;
}

const { refs, x, y, strategy, context, getFloatingProps } = floatingProps;

return (
<FloatingPortal>
<FloatingFocusManager context={context}>
<div
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
zIndex: ZIndex.MAX,
}}
{...getFloatingProps()}
>
<EventForm
event={draftEvent}
onClose={closeForm}
onDelete={closeForm}
onSubmit={(event: Schema_Event) => submitDraft(event)}
setEvent={setDraftEvent}
/>
</div>
</FloatingFocusManager>
</FloatingPortal>
);
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import {
selectIsDayEventsProcessing,
selectTimedDayEvents,
} from "@web/ducks/events/selectors/event.selectors";
import { useAppSelector } from "@web/store/store.hooks";
import { AgendaEventForm } from "@web/views/Day/components/Agenda/AgendaEventForm/AgendaEventForm";
import { AgendaSkeleton } from "@web/views/Day/components/Agenda/AgendaSkeleton/AgendaSkeleton";
import { AgendaEvent } from "@web/views/Day/components/Agenda/Events/AgendaEvent/AgendaEvent";
import { EventContextMenuProvider } from "@web/views/Day/components/ContextMenu/EventContextMenuContext";
import { useDayDraftContext } from "@web/views/Day/context/DayDraftContext";
import { useDateInView } from "@web/views/Day/hooks/navigation/useDateInView";
import { getNowLinePosition } from "@web/views/Day/util/agenda/agenda.util";

const canvas = document.createElement("canvas");
Expand All @@ -18,19 +21,48 @@ export const AgendaEvents = () => {
const currentTime = new Date();
const agendaRef = useRef<HTMLDivElement>(null);
const [isRefSet, setIsRefSet] = useState(false);
const dateInView = useDateInView();
const { openFormAtPosition, isFormOpen } = useDayDraftContext();

useLayoutEffect(() => {
if (agendaRef.current) {
setIsRefSet(true);
}
}, []);

const handleGridClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only handle clicks directly on the grid surface, not on events
if (e.target !== e.currentTarget) {
return;
}

// Don't open form if it's already open
if (isFormOpen) {
return;
}

const rect = agendaRef.current?.getBoundingClientRect();
if (!rect) return;

// Calculate Y position relative to the grid
const yPosition =
e.clientY - rect.top + (agendaRef.current?.scrollTop ?? 0);

openFormAtPosition(yPosition, dateInView, e.clientX, e.clientY);
},
[dateInView, openFormAtPosition, isFormOpen],
);

return (
<EventContextMenuProvider>
<div
data-testid="calendar-surface"
className="relative ml-1 flex-1"
className="relative ml-1 flex-1 cursor-pointer"
ref={agendaRef}
onClick={handleGridClick}
role="grid"
aria-label="Calendar grid - click to create event"
>
{/* Current time indicator for events column */}
<div
Expand All @@ -54,6 +86,9 @@ export const AgendaEvents = () => {
))
)}
</div>

{/* Event creation form */}
<AgendaEventForm />
</EventContextMenuProvider>
);
};
161 changes: 161 additions & 0 deletions packages/web/src/views/Day/context/DayDraftContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { createContext, useCallback, useContext, useState } from "react";
import {
FloatingContext,
autoUpdate,
flip,
offset,
shift,
useDismiss,
useFloating,
} from "@floating-ui/react";
import { Origin, Priorities } from "@core/constants/core.constants";
import { Schema_Event } from "@core/types/event.types";
import dayjs, { Dayjs } from "@core/util/date/dayjs";
import { getUserId } from "@web/auth/auth.util";
import { createEventSlice } from "@web/ducks/events/slices/event.slice";
import { useAppDispatch } from "@web/store/store.hooks";
import { getTimeFromPosition } from "@web/views/Day/util/agenda/agenda.util";

const DEFAULT_DURATION_MINUTES = 60;

interface DayDraftContextValue {
draftEvent: Schema_Event | null;
isFormOpen: boolean;
floatingProps: {
refs: ReturnType<typeof useFloating>["refs"];
x: number | null;
y: number | null;
strategy: "fixed" | "absolute";
context: FloatingContext;
getFloatingProps: (
props?: React.HTMLProps<HTMLElement>,
) => Record<string, unknown>;
};
openFormAtPosition: (
yPosition: number,
dateInView: Dayjs,
clickX: number,
clickY: number,
) => Promise<void>;
closeForm: () => void;
setDraftEvent: (event: Schema_Event | null) => void;
submitDraft: (event: Schema_Event) => void;
}

const DayDraftContext = createContext<DayDraftContextValue | undefined>(
undefined,
);

export const useDayDraftContext = () => {
const context = useContext(DayDraftContext);
if (!context) {
throw new Error("useDayDraftContext must be used within DayDraftProvider");
}
return context;
};

export const DayDraftProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const dispatch = useAppDispatch();
const [draftEvent, setDraftEvent] = useState<Schema_Event | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);

const { refs, x, y, strategy, context } = useFloating({
placement: "right-start",
middleware: [offset(10), flip(), shift({ padding: 8 })],
open: isFormOpen,
onOpenChange: setIsFormOpen,
whileElementsMounted: autoUpdate,
});

useDismiss(context, {
escapeKey: true,
outsidePress: true,
});

const closeForm = useCallback(() => {
setIsFormOpen(false);
setDraftEvent(null);
}, []);

const openFormAtPosition = useCallback(
async (
yPosition: number,
dateInView: Dayjs,
clickX: number,
clickY: number,
) => {
const startTime = getTimeFromPosition(yPosition, dateInView);
const endTime = dayjs(startTime)
.add(DEFAULT_DURATION_MINUTES, "minutes")
.toDate();

const userId = await getUserId();

const newDraft: Schema_Event = {
title: "",
description: "",
startDate: startTime.toISOString(),
endDate: endTime.toISOString(),
isAllDay: false,
isSomeday: false,
user: userId,
priority: Priorities.UNASSIGNED,
origin: Origin.COMPASS,
};

// Set the virtual reference element where the user clicked
refs.setReference({
getBoundingClientRect: () => ({
x: clickX,
y: clickY,
top: clickY,
left: clickX,
bottom: clickY,
right: clickX,
width: 0,
height: 0,
toJSON: () => ({}),
}),
});

setDraftEvent(newDraft);
setIsFormOpen(true);
},
[refs],
);

const submitDraft = useCallback(
(event: Schema_Event) => {
dispatch(createEventSlice.actions.request(event));
closeForm();
},
[dispatch, closeForm],
);

const value: DayDraftContextValue = {
draftEvent,
isFormOpen,
floatingProps: {
refs,
x,
y,
strategy,
context,
getFloatingProps: (props) => props ?? {},
},
openFormAtPosition,
closeForm,
setDraftEvent,
submitDraft,
};

return (
<DayDraftContext.Provider value={value}>
{children}
</DayDraftContext.Provider>
);
};
68 changes: 67 additions & 1 deletion packages/web/src/views/Day/util/agenda/agenda.util.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dayjs from "@core/util/date/dayjs";
import { MINUTES_PER_SLOT, SLOT_HEIGHT } from "../../constants/day.constants";
import { getNowLinePosition } from "./agenda.util";
import { getNowLinePosition, getTimeFromPosition } from "./agenda.util";

describe("agenda.util", () => {
describe("getNowLinePosition", () => {
Expand Down Expand Up @@ -147,4 +148,69 @@ describe("agenda.util", () => {
expect(percentage).toBeCloseTo(0.4333, 2);
});
});

describe("getTimeFromPosition", () => {
const dateInView = dayjs("2024-01-15");

it("should calculate time at midnight (position 0)", () => {
const result = getTimeFromPosition(0, dateInView);
expect(result.getHours()).toBe(0);
expect(result.getMinutes()).toBe(0);
});

it("should calculate time at 1am (position 80px)", () => {
// 1 hour = 4 slots = 80px
const result = getTimeFromPosition(80, dateInView);
expect(result.getHours()).toBe(1);
expect(result.getMinutes()).toBe(0);
});

it("should calculate time at 12pm (position 960px)", () => {
// 12 hours = 48 slots = 960px
const result = getTimeFromPosition(960, dateInView);
expect(result.getHours()).toBe(12);
expect(result.getMinutes()).toBe(0);
});

it("should calculate time at 3:15pm (position 1220px)", () => {
// 15:15 = 61 slots = 1220px
const result = getTimeFromPosition(1220, dateInView);
expect(result.getHours()).toBe(15);
expect(result.getMinutes()).toBe(15);
});

it("should snap to 15-minute slots", () => {
// Position 1230 is between 15:15 (1220) and 15:30 (1240)
// Should snap to 15:15
const result = getTimeFromPosition(1230, dateInView);
expect(result.getHours()).toBe(15);
expect(result.getMinutes()).toBe(15);
});

it("should clamp to 23:45 at extreme positions", () => {
// Very large position should clamp to 23:45
const result = getTimeFromPosition(9999, dateInView);
expect(result.getHours()).toBe(23);
expect(result.getMinutes()).toBe(45);
});

it("should use the dateInView for the date portion", () => {
const specificDate = dayjs("2024-06-20");
const result = getTimeFromPosition(480, specificDate);
expect(result.getFullYear()).toBe(2024);
expect(result.getMonth()).toBe(5); // June (0-indexed)
expect(result.getDate()).toBe(20);
});

it("should be inverse of getAgendaEventPosition for slot-aligned times", () => {
// Test round-trip: position -> time -> position
const testPositions = [0, 80, 240, 480, 960, 1200, 1280, 1800];

for (const position of testPositions) {
const time = getTimeFromPosition(position, dateInView);
const resultPosition = getNowLinePosition(time);
expect(resultPosition).toBe(position);
}
});
});
});
22 changes: 22 additions & 0 deletions packages/web/src/views/Day/util/agenda/agenda.util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Schema_Event } from "@core/types/event.types";
import dayjs, { Dayjs } from "@core/util/date/dayjs";
import { MINUTES_PER_SLOT, SLOT_HEIGHT } from "../../constants/day.constants";

export const getAgendaEventTitle = (event: Schema_Event) =>
Expand Down Expand Up @@ -27,3 +28,24 @@ export const getNowLinePosition = (date: Date) => {
const minutes = date.getMinutes();
return (hours * 4 + minutes / MINUTES_PER_SLOT) * SLOT_HEIGHT;
};

// Get time (Date) from Y position on the agenda grid, snapped to 15-minute slots
export const getTimeFromPosition = (
yPosition: number,
dateInView: Dayjs,
): Date => {
// Calculate which slot the position corresponds to
const slot = Math.floor(yPosition / SLOT_HEIGHT);
const hours = Math.floor(slot / 4);
const minutes = (slot % 4) * MINUTES_PER_SLOT;

// Clamp hours to valid range (0-23)
const clampedHours = Math.max(0, Math.min(23, hours));
const clampedMinutes = clampedHours === 23 ? Math.min(45, minutes) : minutes;

return dateInView
.startOf("day")
.hour(clampedHours)
.minute(clampedMinutes)
.toDate();
};
Loading