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
38 changes: 38 additions & 0 deletions packages/core/src/types/event.contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
186 changes: 186 additions & 0 deletions packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<UpNextBanner />, {
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(<UpNextBanner />, {
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(<UpNextBanner />, {
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(<UpNextBanner />, {
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(<UpNextBanner />, {
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(<UpNextBanner />, {
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(<UpNextBanner />, {
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(<UpNextBanner />, {
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();
});
});
82 changes: 82 additions & 0 deletions packages/web/src/components/Sidebar/UpNextCard/UpNextBanner.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(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 (
<div
className="fixed bottom-6 left-1/2 flex w-72 -translate-x-1/2 items-center gap-3 rounded border border-border bg-surface-overlay px-3 py-2 text-sm text-text shadow-lg"
role="status"
style={{ zIndex: Z_INDEX_FLOATING_MENU }}
>
<div className="min-w-0 flex-1">
<div className="text-text-muted text-xs">{countdown}</div>
<div className="flex items-center gap-1.5">
<span
aria-hidden
className="size-1.5 shrink-0 rounded-full bg-accent"
/>
<span className="truncate font-medium">{upNext.title}</span>
</div>
</div>
<button
className="c-focus-ring flex shrink-0 items-center gap-1.5 rounded bg-accent-secondary px-2 py-1 font-medium text-on-accent"
onClick={
conferenceUrl ? openConference : () => openEventDetails("gridClick")
}
type="button"
>
{conferenceUrl ? "Join" : "Open"}
<ShortcutHint>{conferenceUrl ? "V" : "N"}</ShortcutHint>
</button>
<button
aria-label="Dismiss"
className="c-focus-ring shrink-0 rounded-xs px-1 text-text-muted hover:text-text"
onClick={() => setDismissedId(upNext._id)}
type="button"
>
&times;
</button>
</div>
);
};
41 changes: 0 additions & 41 deletions packages/web/src/components/Sidebar/UpNextCard/UpNextCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import userEvent from "@testing-library/user-event";
import {
type Calendar,
getCalendarCapabilities,
Expand All @@ -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";

Expand Down Expand Up @@ -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(<ShortcutHarness />, {
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(<ShortcutHarness />, { events: [] });

await user.keyboard("n");

expect(useDraftStore.getState().status?.isFormOpen).toBe(false);
});
});
12 changes: 5 additions & 7 deletions packages/web/src/components/Sidebar/UpNextCard/useUpNextEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
}
Loading