diff --git a/packages/web/src/views/Day/components/Agenda/AgendaEventForm/AgendaEventForm.tsx b/packages/web/src/views/Day/components/Agenda/AgendaEventForm/AgendaEventForm.tsx new file mode 100644 index 000000000..4848ece93 --- /dev/null +++ b/packages/web/src/views/Day/components/Agenda/AgendaEventForm/AgendaEventForm.tsx @@ -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 ( + + +
+ submitDraft(event)} + setEvent={setDraftEvent} + /> +
+
+
+ ); +}; diff --git a/packages/web/src/views/Day/components/Agenda/Events/AgendaEvent/AgendaEvents.tsx b/packages/web/src/views/Day/components/Agenda/Events/AgendaEvent/AgendaEvents.tsx index a9fd03afa..a828e0d29 100644 --- a/packages/web/src/views/Day/components/Agenda/Events/AgendaEvent/AgendaEvents.tsx +++ b/packages/web/src/views/Day/components/Agenda/Events/AgendaEvent/AgendaEvents.tsx @@ -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"); @@ -18,6 +21,8 @@ export const AgendaEvents = () => { const currentTime = new Date(); const agendaRef = useRef(null); const [isRefSet, setIsRefSet] = useState(false); + const dateInView = useDateInView(); + const { openFormAtPosition, isFormOpen } = useDayDraftContext(); useLayoutEffect(() => { if (agendaRef.current) { @@ -25,12 +30,39 @@ export const AgendaEvents = () => { } }, []); + const handleGridClick = useCallback( + (e: React.MouseEvent) => { + // 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 (
{/* Current time indicator for events column */}
{ )) )}
+ + {/* Event creation form */} + ); }; diff --git a/packages/web/src/views/Day/context/DayDraftContext.tsx b/packages/web/src/views/Day/context/DayDraftContext.tsx new file mode 100644 index 000000000..f9d3cd5cb --- /dev/null +++ b/packages/web/src/views/Day/context/DayDraftContext.tsx @@ -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["refs"]; + x: number | null; + y: number | null; + strategy: "fixed" | "absolute"; + context: FloatingContext; + getFloatingProps: ( + props?: React.HTMLProps, + ) => Record; + }; + openFormAtPosition: ( + yPosition: number, + dateInView: Dayjs, + clickX: number, + clickY: number, + ) => Promise; + closeForm: () => void; + setDraftEvent: (event: Schema_Event | null) => void; + submitDraft: (event: Schema_Event) => void; +} + +const DayDraftContext = createContext( + 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(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 ( + + {children} + + ); +}; diff --git a/packages/web/src/views/Day/util/agenda/agenda.util.test.ts b/packages/web/src/views/Day/util/agenda/agenda.util.test.ts index 16fafdced..e21a926df 100644 --- a/packages/web/src/views/Day/util/agenda/agenda.util.test.ts +++ b/packages/web/src/views/Day/util/agenda/agenda.util.test.ts @@ -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", () => { @@ -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); + } + }); + }); }); diff --git a/packages/web/src/views/Day/util/agenda/agenda.util.ts b/packages/web/src/views/Day/util/agenda/agenda.util.ts index 152eba0a7..d938876d2 100644 --- a/packages/web/src/views/Day/util/agenda/agenda.util.ts +++ b/packages/web/src/views/Day/util/agenda/agenda.util.ts @@ -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) => @@ -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(); +}; diff --git a/packages/web/src/views/Day/util/day.test-util.tsx b/packages/web/src/views/Day/util/day.test-util.tsx index 3bf1291cb..8775fb722 100644 --- a/packages/web/src/views/Day/util/day.test-util.tsx +++ b/packages/web/src/views/Day/util/day.test-util.tsx @@ -8,6 +8,7 @@ import { ROOT_ROUTES } from "@web/common/constants/routes"; import { loadSpecificDayData, loadTodayData } from "@web/routers/loaders"; import { store as defaultStore } from "@web/store"; import { DateNavigationProvider } from "@web/views/Day/context/DateNavigationProvider"; +import { DayDraftProvider } from "@web/views/Day/context/DayDraftContext"; import { StorageInfoModalProvider } from "@web/views/Day/context/StorageInfoModalContext"; import { TaskProvider } from "@web/views/Day/context/TaskProvider"; @@ -15,7 +16,9 @@ export const TaskProviderWrapper = ({ children }: PropsWithChildren) => { return ( - {children} + + {children} + ); diff --git a/packages/web/src/views/Day/view/DayView.tsx b/packages/web/src/views/Day/view/DayView.tsx index adbf629ef..8875c776f 100644 --- a/packages/web/src/views/Day/view/DayView.tsx +++ b/packages/web/src/views/Day/view/DayView.tsx @@ -1,5 +1,6 @@ import { Outlet } from "react-router-dom"; import { DateNavigationProvider } from "@web/views/Day/context/DateNavigationProvider"; +import { DayDraftProvider } from "@web/views/Day/context/DayDraftContext"; import { StorageInfoModalProvider } from "@web/views/Day/context/StorageInfoModalContext"; import { TaskProvider } from "@web/views/Day/context/TaskProvider"; @@ -8,7 +9,9 @@ export function DayView() { - + + +