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