diff --git a/packages/core/src/types/event.contracts.test.ts b/packages/core/src/types/event.contracts.test.ts index edfaac24dc..cf4a4f99d4 100644 --- a/packages/core/src/types/event.contracts.test.ts +++ b/packages/core/src/types/event.contracts.test.ts @@ -71,6 +71,44 @@ describe("Event Contracts", () => { expect(result.success).toBe(false); }); + + it("accepts an optional conference link on details content", () => { + const result = EventContentSchema.safeParse({ + kind: "details", + title: "x", + description: "y", + conference: { + url: "https://meet.google.com/abc-defg-hij", + label: null, + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + kind: "details", + title: "x", + description: "y", + conference: { + url: "https://meet.google.com/abc-defg-hij", + label: null, + }, + }); + } + }); + + it("omits conference when not provided", () => { + const result = EventContentSchema.safeParse({ + kind: "details", + title: "x", + description: "y", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).not.toHaveProperty("conference"); + } + }); }); describe("EventScheduleSchema (timed)", () => { diff --git a/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.test.tsx b/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.test.tsx new file mode 100644 index 0000000000..8f27f3f5f4 --- /dev/null +++ b/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.test.tsx @@ -0,0 +1,186 @@ +import userEvent from "@testing-library/user-event"; +import { EventIdSchema } from "@core/types/domain-primitives"; +import { type Event, EventScheduleSchema } from "@core/types/event.contracts"; +import dayjs from "@core/util/date/dayjs"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@web/__tests__/__mocks__/mock.render"; +import { createMockEvent } from "@web/__tests__/utils/factories/event.factory"; +import { draftActions, useDraftStore } from "@web/events/stores/draft.store"; +import { UpNextBanner } from "./UpNextBanner"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import "@testing-library/jest-dom"; + +const SOON_EVENT_ID = "aaaaaaaaaaaaaaaaaaaaaaaa"; +const LATER_EVENT_ID = "bbbbbbbbbbbbbbbbbbbbbbbb"; + +// Events are built relative to the real clock because the banner's whole job +// is comparing today's events against "now". +const timedEvent = ( + id: string, + title: string, + startsInMinutes: number, + conference?: { url: string; label: string | null }, +): Event => { + const start = dayjs().add(startsInMinutes, "minute"); + return createMockEvent({ + id: EventIdSchema.parse(id), + content: { + kind: "details", + title, + description: "", + ...(conference ? { conference } : {}), + }, + schedule: EventScheduleSchema.parse({ + kind: "timed", + start: start.format(), + end: start.add(30, "minute").format(), + timeZone: "UTC", + }), + }); +}; + +const mockWindowOpen = mock(); + +beforeEach(() => { + draftActions.discard(); + mockWindowOpen.mockClear(); + window.open = mockWindowOpen; +}); + +afterEach(() => { + cleanup(); +}); + +describe("UpNextBanner", () => { + it("shows a countdown and Open action for an event within 2 minutes", () => { + render(, { + events: [timedEvent(SOON_EVENT_ID, "Soon Event", 2)], + }); + + expect(screen.getByText("Starts in 2 minutes")).toBeInTheDocument(); + expect(screen.getByText("Soon Event")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + expect(screen.getByText("N")).toBeInTheDocument(); + }); + + it("renders nothing for an event more than 2 minutes out", () => { + render(, { + events: [timedEvent(LATER_EVENT_ID, "Later Event", 30)], + }); + + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("shows a Join action with the meeting link instead of Open", () => { + render(, { + events: [ + timedEvent(SOON_EVENT_ID, "Standup", 2, { + url: "https://meet.google.com/abc-defg-hij", + label: null, + }), + ], + }); + + const joinButton = screen.getByRole("button", { name: "Join" }); + expect(joinButton).toBeInTheDocument(); + expect(screen.getByText("V")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open" })).toBeNull(); + + fireEvent.click(joinButton); + + expect(mockWindowOpen).toHaveBeenCalledWith( + "https://meet.google.com/abc-defg-hij", + "_blank", + "noopener,noreferrer", + ); + }); + + it("pressing v opens the meeting link", async () => { + const user = userEvent.setup(); + render(, { + events: [ + timedEvent(SOON_EVENT_ID, "Standup", 2, { + url: "https://meet.google.com/abc-defg-hij", + label: null, + }), + ], + }); + + await user.keyboard("v"); + + expect(mockWindowOpen).toHaveBeenCalledWith( + "https://meet.google.com/abc-defg-hij", + "_blank", + "noopener,noreferrer", + ); + }); + + it("pressing n opens the event details form even when Join is shown", async () => { + const user = userEvent.setup(); + render(, { + events: [ + timedEvent(SOON_EVENT_ID, "Standup", 2, { + url: "https://meet.google.com/abc-defg-hij", + label: null, + }), + ], + }); + + await user.keyboard("n"); + + await waitFor(() => { + const state = useDraftStore.getState(); + expect(state.status?.isFormOpen).toBe(true); + expect(state.status?.activity).toBe("keyboardEdit"); + expect(state.gridDraft?.source?.id).toBe( + EventIdSchema.parse(SOON_EVENT_ID), + ); + }); + }); + + it("clicking Open opens the event details form", async () => { + render(, { + events: [timedEvent(SOON_EVENT_ID, "Soon Event", 2)], + }); + + fireEvent.click(screen.getByRole("button", { name: "Open" })); + + await waitFor(() => { + const state = useDraftStore.getState(); + expect(state.status?.isFormOpen).toBe(true); + expect(state.gridDraft?.source?.id).toBe( + EventIdSchema.parse(SOON_EVENT_ID), + ); + }); + }); + + it("dismissing hides the banner and it stays hidden for that event", () => { + render(, { + events: [timedEvent(SOON_EVENT_ID, "Soon Event", 2)], + }); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(screen.queryByText("Soon Event")).toBeNull(); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("pressing Escape dismisses the banner", async () => { + const user = userEvent.setup(); + render(, { + events: [timedEvent(SOON_EVENT_ID, "Soon Event", 2)], + }); + + expect(screen.getByText("Soon Event")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + expect(screen.queryByText("Soon Event")).toBeNull(); + expect(screen.queryByRole("status")).toBeNull(); + }); +}); diff --git a/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.tsx b/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.tsx new file mode 100644 index 0000000000..7afc9ea466 --- /dev/null +++ b/packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.tsx @@ -0,0 +1,82 @@ +import { type FC, useState } from "react"; +import dayjs from "@core/util/date/dayjs"; +import { Z_INDEX_FLOATING_MENU } from "@web/common/constants/web.constants"; +import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint"; +import { useAppShortcutUp } from "@web/shortcuts/useAppShortcut"; +import { formatStartsIn } from "./UpNextCard"; +import { useUpNextEvent } from "./useUpNextEvent"; + +const MINUTES_BEFORE_START = 2; + +/** + * A centered banner that surfaces the next timed event when it is about to + * start, mirroring Vimcal. N opens the event's details; when the event has a + * meeting link, the primary action switches to V for "Join" instead. + */ +export const UpNextBanner: FC = () => { + const { now, openEventDetails, upNext, conferenceUrl } = useUpNextEvent(); + const [dismissedId, setDismissedId] = useState(undefined); + + const isWithinWindow = + Boolean(upNext) && + dayjs(upNext?.startDate).diff(now, "minute", true) <= MINUTES_BEFORE_START; + const isVisible = isWithinWindow && upNext?._id !== dismissedId; + + const openConference = () => + window.open(conferenceUrl, "_blank", "noopener,noreferrer"); + + useAppShortcutUp("N", () => openEventDetails("keyboardEdit")); + useAppShortcutUp("V", openConference, { + enabled: Boolean(conferenceUrl) && isWithinWindow, + }); + // Only active while the banner itself is showing. Fires alongside any + // other Escape handling (e.g. useEscapeToCloseForm closing the event form) + // the same way every other Escape shortcut in the app does - Escape is + // never scoped to "not while typing" here, matching that convention. + useAppShortcutUp("Escape", () => setDismissedId(upNext?._id), { + enabled: isVisible, + }); + + if (!isVisible || !upNext) { + return null; + } + + const countdown = formatStartsIn(dayjs(upNext.startDate), now); + + return ( +
+
+
{countdown}
+
+ + {upNext.title} +
+
+ + +
+ ); +}; diff --git a/packages/web/src/components/Sidebar/UpNextCard/UpNextCard.test.tsx b/packages/web/src/components/Sidebar/UpNextCard/UpNextCard.test.tsx index a6c863d039..5ae5ca8773 100644 --- a/packages/web/src/components/Sidebar/UpNextCard/UpNextCard.test.tsx +++ b/packages/web/src/components/Sidebar/UpNextCard/UpNextCard.test.tsx @@ -1,4 +1,3 @@ -import userEvent from "@testing-library/user-event"; import { type Calendar, getCalendarCapabilities, @@ -24,7 +23,6 @@ import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { setCalendarVisibility } from "@web/calendars/calendar-visibility.store"; import { draftActions, useDraftStore } from "@web/events/stores/draft.store"; import { formatStartsIn, UpNextCard } from "./UpNextCard"; -import { useUpNextEventShortcut } from "./useUpNextEvent"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import "@testing-library/jest-dom"; @@ -232,42 +230,3 @@ describe("UpNextCard", () => { expect(screen.queryByRole("link", { name: "Join" })).toBeNull(); }); }); - -describe("useUpNextEventShortcut", () => { - it("opens the next event form with n", async () => { - const user = userEvent.setup(); - const ShortcutHarness = () => { - useUpNextEventShortcut(); - return null; - }; - - render(, { - events: [timedEvent(SOON_EVENT_ID, "Soon Event", 30)], - }); - - await user.keyboard("n"); - - await waitFor(() => { - const state = useDraftStore.getState(); - expect(state.status?.isFormOpen).toBe(true); - expect(state.status?.activity).toBe("keyboardEdit"); - expect(state.gridDraft?.source?.id).toBe( - EventIdSchema.parse(SOON_EVENT_ID), - ); - }); - }); - - it("does nothing when there is no upcoming event", async () => { - const user = userEvent.setup(); - const ShortcutHarness = () => { - useUpNextEventShortcut(); - return null; - }; - - render(, { events: [] }); - - await user.keyboard("n"); - - expect(useDraftStore.getState().status?.isFormOpen).toBe(false); - }); -}); diff --git a/packages/web/src/components/Sidebar/UpNextCard/useUpNextEvent.ts b/packages/web/src/components/Sidebar/UpNextCard/useUpNextEvent.ts index 2f83f8b86b..a963052f50 100644 --- a/packages/web/src/components/Sidebar/UpNextCard/useUpNextEvent.ts +++ b/packages/web/src/components/Sidebar/UpNextCard/useUpNextEvent.ts @@ -3,7 +3,6 @@ import dayjs, { type Dayjs } from "@core/util/date/dayjs"; import { editGridEventDraft } from "@web/events/grid-event-draft.adapter"; import { useDayEventViewModel } from "@web/events/queries/useDayEventsQuery"; import { draftActions } from "@web/events/stores/draft.store"; -import { useAppShortcutUp } from "@web/shortcuts/useAppShortcut"; import { dayEventQueryRange } from "@web/views/Day/hooks/events/useDayEvents"; function useMinuteTick(): Dayjs { @@ -53,11 +52,10 @@ export function useUpNextEvent() { [sourceEvent], ); - return { now, openEventDetails, upNext }; -} - -export function useUpNextEventShortcut() { - const { openEventDetails } = useUpNextEvent(); + const conferenceUrl = + sourceEvent?.content.kind === "details" + ? sourceEvent.content.conference?.url + : undefined; - useAppShortcutUp("N", () => openEventDetails("keyboardEdit")); + return { now, openEventDetails, upNext, conferenceUrl }; } diff --git a/packages/web/src/shortcuts/data/shortcuts.data.test.ts b/packages/web/src/shortcuts/data/shortcuts.data.test.ts index bb03fd6c57..cdd2f8b4c1 100644 --- a/packages/web/src/shortcuts/data/shortcuts.data.test.ts +++ b/packages/web/src/shortcuts/data/shortcuts.data.test.ts @@ -72,7 +72,7 @@ describe("shortcuts.data", () => { ]); }); - it("lists the Up Next shortcut in both views", () => { + it("lists the Up Next shortcuts in both views", () => { for (const view of ["day", "week"] as const) { const [navigate] = getShortcutMenuSections({ view, @@ -83,6 +83,10 @@ describe("shortcuts.data", () => { keys: ["n"], label: "Open Up Next event", }); + expect(navigate.shortcuts).toContainEqual({ + keys: ["v"], + label: "Join Up Next meeting", + }); } }); diff --git a/packages/web/src/shortcuts/data/shortcuts.data.ts b/packages/web/src/shortcuts/data/shortcuts.data.ts index 478e52285a..97c214a711 100644 --- a/packages/web/src/shortcuts/data/shortcuts.data.ts +++ b/packages/web/src/shortcuts/data/shortcuts.data.ts @@ -32,6 +32,7 @@ const getNavigateShortcuts = ({ return [ { keys: ["n"], label: "Open Up Next event" }, + { keys: ["v"], label: "Join Up Next meeting" }, { keys: ["j"], label: `Previous ${view}` }, { keys: ["k"], label: `Next ${view}` }, ...(view === "week" diff --git a/packages/web/src/views/Day/view/DayViewContent.tsx b/packages/web/src/views/Day/view/DayViewContent.tsx index acafd20f70..e87a81f9ac 100644 --- a/packages/web/src/views/Day/view/DayViewContent.tsx +++ b/packages/web/src/views/Day/view/DayViewContent.tsx @@ -9,7 +9,6 @@ import { DemoEventsBannerGate } from "@web/components/DemoEventsBanner/DemoEvent import { SidebarEventDetails } from "@web/components/Sidebar/EventDetails/SidebarEventDetails"; import { ResizableSidebarPanel } from "@web/components/Sidebar/ResizableSidebarPanel"; import { Sidebar } from "@web/components/Sidebar/Sidebar"; -import { useUpNextEventShortcut } from "@web/components/Sidebar/UpNextCard/useUpNextEvent"; import { useSidebarShortcuts } from "@web/components/Sidebar/useSidebarShortcuts"; import { focusFirstSidebarItem } from "@web/components/Sidebar/util/sidebarFocus.util"; import { welcomeGuideActions } from "@web/components/WelcomeModal/welcome.guide.store"; @@ -56,7 +55,6 @@ export const DayViewContent = memo(() => { onPrevious: navigateToPreviousDay, }); useDayEvents(dateInView); - useUpNextEventShortcut(); const toggleSidebar = useCallback(() => { viewActions.toggleSidebar(); diff --git a/packages/web/src/views/Root.tsx b/packages/web/src/views/Root.tsx index 4541e2e353..e27a12eafd 100644 --- a/packages/web/src/views/Root.tsx +++ b/packages/web/src/views/Root.tsx @@ -6,6 +6,7 @@ import { isMobileOS } from "@web/common/utils/device/device.util"; import { AuthenticatedLayout } from "@web/components/AuthenticatedLayout/AuthenticatedLayout"; import { GlobalShortcutsHost } from "@web/components/CompassProvider/CompassProvider"; import { MobileGate } from "@web/components/MobileGate/MobileGate"; +import { UpNextBanner } from "@web/components/Sidebar/UpNextCard/UpNextBanner"; import SSEProvider from "@web/sse/provider/SSEProvider"; import { BackendDownView } from "@web/views/BackendDown/BackendDown"; @@ -30,6 +31,7 @@ export const RootView = () => { + diff --git a/packages/web/src/views/Week/WeekView.tsx b/packages/web/src/views/Week/WeekView.tsx index d55b1998c0..5c96803489 100644 --- a/packages/web/src/views/Week/WeekView.tsx +++ b/packages/web/src/views/Week/WeekView.tsx @@ -9,7 +9,6 @@ import { DemoEventsBannerGate } from "@web/components/DemoEventsBanner/DemoEvent import { SidebarEventDetails } from "@web/components/Sidebar/EventDetails/SidebarEventDetails"; import { ResizableSidebarPanel } from "@web/components/Sidebar/ResizableSidebarPanel"; import { Sidebar } from "@web/components/Sidebar/Sidebar"; -import { useUpNextEventShortcut } from "@web/components/Sidebar/UpNextCard/useUpNextEvent"; import { useSidebarShortcuts } from "@web/components/Sidebar/useSidebarShortcuts"; import { welcomeGuideActions } from "@web/components/WelcomeModal/welcome.guide.store"; import { toDemoEventsRange } from "@web/events/demo-events.util"; @@ -43,7 +42,6 @@ import { useWeek } from "@web/views/Week/hooks/useWeek"; import { WeekInteractionCoordinator } from "@web/views/Week/interaction/WeekInteractionCoordinator"; export const WeekView = () => { - useUpNextEventShortcut(); const isSidebarOpen = useViewStore(selectIsSidebarOpen); // Event details live in the sidebar, so an open form reveals the sidebar // even when the user keeps it collapsed; their persisted preference is