Skip to content

Commit b48dbe2

Browse files
Add event location and online meeting details (#70)
1 parent 565f6d0 commit b48dbe2

23 files changed

Lines changed: 496 additions & 4 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE "Event"
2+
ADD COLUMN "location" TEXT,
3+
ADD COLUMN "isOnlineMeeting" BOOLEAN NOT NULL DEFAULT false,
4+
ADD COLUMN "meetingLink" TEXT;

prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ model Event {
2121
slug String @unique
2222
title String
2323
type EventType @default(TIME_GRID)
24+
location String?
25+
isOnlineMeeting Boolean @default(false)
26+
meetingLink String?
2427
timezone String
2528
slotMinutes Int @default(30)
2629
meetingDurationMinutes Int @default(60)

src/app/api/events/[slug]/ics/route.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ describe("GET /api/events/[slug]/ics", () => {
1616
snapshot: {
1717
slug: "team-sync",
1818
title: "Team Sync",
19+
location: "Office 3.2",
20+
isOnlineMeeting: true,
21+
meetingLink: "https://meet.example.com/team-sync",
1922
eventType: "time_grid",
2023
timezone: "Europe/Vienna",
2124
status: "CLOSED",
@@ -52,6 +55,9 @@ describe("GET /api/events/[slug]/ics", () => {
5255
const body = await response.text();
5356
expect(body).toContain("BEGIN:VCALENDAR");
5457
expect(body).toContain("SUMMARY:Team Sync");
58+
expect(body).toContain("LOCATION:Online meeting");
59+
expect(body).not.toContain("Office 3.2");
60+
expect(body).toContain("Meeting link: https://meet.example.com/team-sync");
5561
expect(body).toContain("DTSTART;TZID=Europe/Vienna:20260402T090000");
5662
expect(body).toContain("URL:http://localhost:3000/e/team-sync");
5763
});

src/app/api/events/[slug]/ics/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export async function GET(request: Request, { params }: Context) {
3232
const body = buildEventCalendarFile({
3333
slug: event.snapshot.slug,
3434
title: event.snapshot.title,
35+
location: event.snapshot.location,
36+
isOnlineMeeting: event.snapshot.isOnlineMeeting,
37+
meetingLink: event.snapshot.meetingLink,
3538
timezone: event.snapshot.timezone,
3639
slotStart: event.snapshot.finalizedSlot.slotStart,
3740
slotEnd: event.snapshot.finalizedSlot.slotEnd,

src/app/api/events/route.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe("POST /api/events", () => {
2222
beforeEach(() => {
2323
vi.clearAllMocks();
2424
getClientIp.mockReturnValue("127.0.0.1");
25+
enforceRateLimit.mockImplementation(() => undefined);
2526
createEvent.mockResolvedValue({
2627
slug: "sprint-planning",
2728
manageKey: "manage-key-123",
@@ -53,6 +54,7 @@ describe("POST /api/events", () => {
5354
expect(createEvent).toHaveBeenCalledWith({
5455
eventType: "time_grid",
5556
title: "Sprint Planning",
57+
isOnlineMeeting: false,
5658
timezone: "Europe/Vienna",
5759
dates: ["2026-03-30"],
5860
dayStartMinutes: 540,
@@ -89,6 +91,7 @@ describe("POST /api/events", () => {
8991
expect(createEvent).toHaveBeenCalledWith({
9092
eventType: "full_day",
9193
title: "Offsite Days",
94+
isOnlineMeeting: false,
9295
timezone: "Europe/Vienna",
9396
dates: ["2026-03-30", "2026-03-31"],
9497
dayStartMinutes: 540,
@@ -130,4 +133,43 @@ describe("POST /api/events", () => {
130133
error: "Too many event creation attempts. Please wait a few minutes and try again.",
131134
});
132135
});
136+
137+
it("passes online meeting details through without a location", async () => {
138+
const { POST } = await import("./route");
139+
const response = await POST(
140+
new Request("https://tempoll.example.com/api/events", {
141+
method: "POST",
142+
body: JSON.stringify({
143+
title: "Hybrid Planning",
144+
location: "Office 3.2",
145+
isOnlineMeeting: true,
146+
meetingLink: "https://meet.example.com/hybrid-planning",
147+
timezone: "Europe/Vienna",
148+
dates: ["2026-03-30"],
149+
dayStartMinutes: 540,
150+
dayEndMinutes: 600,
151+
slotMinutes: 30,
152+
meetingDurationMinutes: 60,
153+
}),
154+
headers: {
155+
"Accept-Language": "en-US",
156+
"Content-Type": "application/json",
157+
},
158+
}),
159+
);
160+
161+
expect(createEvent).toHaveBeenCalledWith({
162+
eventType: "time_grid",
163+
title: "Hybrid Planning",
164+
isOnlineMeeting: true,
165+
meetingLink: "https://meet.example.com/hybrid-planning",
166+
timezone: "Europe/Vienna",
167+
dates: ["2026-03-30"],
168+
dayStartMinutes: 540,
169+
dayEndMinutes: 600,
170+
slotMinutes: 30,
171+
meetingDurationMinutes: 60,
172+
});
173+
expect(response.status).toBe(201);
174+
});
133175
});

src/components/create-event-form.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ describe("CreateEventForm", () => {
5858
renderCreateEventForm("de");
5959

6060
expect(screen.getByLabelText("Event-Titel")).toBeInTheDocument();
61+
expect(screen.getByLabelText("Ort")).toHaveAttribute(
62+
"placeholder",
63+
"Büro, Café, Raumname...",
64+
);
65+
expect(screen.getByRole("radiogroup", { name: "Event-Format" })).toBeInTheDocument();
66+
expect(screen.getByRole("radio", { name: "Online-Meeting" })).toBeInTheDocument();
6167
expect(screen.getByLabelText("Organizer-E-Mail-Benachrichtigungen")).toHaveAttribute(
6268
"placeholder",
6369
"name@beispiel.de",
@@ -354,6 +360,78 @@ describe("CreateEventForm", () => {
354360
expect(payload.notificationEmail).toBe("owner@example.com");
355361
});
356362

363+
it("submits optional location for in-person events", async () => {
364+
const user = userEvent.setup();
365+
const fetchMock = vi.fn().mockResolvedValue({
366+
ok: true,
367+
json: async () => ({
368+
manageKey: "manage-key-123",
369+
}),
370+
});
371+
global.fetch = fetchMock as typeof fetch;
372+
373+
renderCreateEventForm();
374+
375+
await user.type(screen.getByLabelText("Event title"), "Planning");
376+
await user.type(screen.getByLabelText("Location"), "Office 3.2");
377+
await user.click(screen.getByRole("button", { name: "Create event" }));
378+
379+
await waitFor(() => {
380+
expect(fetchMock).toHaveBeenCalledTimes(1);
381+
});
382+
383+
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
384+
const payload = JSON.parse(String(requestInit.body)) as {
385+
location?: string;
386+
isOnlineMeeting?: boolean;
387+
};
388+
389+
expect(payload).toMatchObject({
390+
location: "Office 3.2",
391+
isOnlineMeeting: false,
392+
});
393+
});
394+
395+
it("submits online meeting details without a location", async () => {
396+
const user = userEvent.setup();
397+
const fetchMock = vi.fn().mockResolvedValue({
398+
ok: true,
399+
json: async () => ({
400+
manageKey: "manage-key-123",
401+
}),
402+
});
403+
global.fetch = fetchMock as typeof fetch;
404+
405+
renderCreateEventForm();
406+
407+
await user.type(screen.getByLabelText("Event title"), "Hybrid planning");
408+
await user.type(screen.getByLabelText("Location"), "Office 3.2");
409+
await user.click(screen.getByRole("radio", { name: "Online meeting" }));
410+
expect(screen.queryByLabelText("Location")).not.toBeInTheDocument();
411+
await user.type(
412+
screen.getByLabelText("Meeting link"),
413+
"https://meet.example.com/hybrid-planning",
414+
);
415+
await user.click(screen.getByRole("button", { name: "Create event" }));
416+
417+
await waitFor(() => {
418+
expect(fetchMock).toHaveBeenCalledTimes(1);
419+
});
420+
421+
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
422+
const payload = JSON.parse(String(requestInit.body)) as {
423+
location?: string;
424+
isOnlineMeeting?: boolean;
425+
meetingLink?: string;
426+
};
427+
428+
expect(payload).not.toHaveProperty("location");
429+
expect(payload).toMatchObject({
430+
isOnlineMeeting: true,
431+
meetingLink: "https://meet.example.com/hybrid-planning",
432+
});
433+
});
434+
357435
it("shows an unavailable note instead of the email field when alerts are disabled", () => {
358436
renderCreateEventForm("en", {
359437
notificationsConfigured: false,

src/components/create-event-form.tsx

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import {
88
ChevronDownIcon,
99
CheckIcon,
1010
Clock3Icon,
11+
LinkIcon,
1112
Loader2Icon,
13+
MapPinIcon,
1214
SparklesIcon,
15+
VideoIcon,
1316
} from "lucide-react";
1417
import { useRouter } from "next/navigation";
1518
import { useMemo, useState, useTransition } from "react";
@@ -52,6 +55,9 @@ type CreateEventFormProps = {
5255
const eventFieldOrder = [
5356
"eventType",
5457
"title",
58+
"location",
59+
"isOnlineMeeting",
60+
"meetingLink",
5561
"notificationEmail",
5662
"timezone",
5763
"dates",
@@ -68,6 +74,9 @@ type EventFormErrors = Partial<Record<EventField, string>>;
6874
const eventFieldIds: Record<EventField, string> = {
6975
eventType: "event-type",
7076
title: "title",
77+
location: "location",
78+
isOnlineMeeting: "event-format",
79+
meetingLink: "meeting-link",
7180
notificationEmail: "notification-email",
7281
timezone: "timezone-trigger",
7382
dates: "date-range-trigger",
@@ -190,6 +199,9 @@ export function CreateEventForm({
190199
const [initialDateRange] = useState(() => getDefaultDateRange("time_grid", startOfToday()));
191200
const [eventType, setEventType] = useState<EventType>("time_grid");
192201
const [title, setTitle] = useState("");
202+
const [location, setLocation] = useState("");
203+
const [isOnlineMeeting, setIsOnlineMeeting] = useState(false);
204+
const [meetingLink, setMeetingLink] = useState("");
193205
const [notificationEmail, setNotificationEmail] = useState("");
194206
const [dateRange, setDateRange] = useState<DateRange | undefined>(initialDateRange);
195207
const [draftDateRange, setDraftDateRange] = useState<DateRange | undefined>(initialDateRange);
@@ -367,6 +379,9 @@ export function CreateEventForm({
367379
const parsed = createEventCreateSchema(messages).safeParse({
368380
eventType,
369381
title,
382+
location: isOnlineMeeting ? undefined : location,
383+
isOnlineMeeting,
384+
meetingLink: isOnlineMeeting ? meetingLink : undefined,
370385
notificationEmail: notificationsConfigured ? notificationEmail : undefined,
371386
timezone,
372387
dates: selectedDates,
@@ -448,6 +463,120 @@ export function CreateEventForm({
448463
) : null}
449464
</div>
450465

466+
<div className="space-y-4">
467+
<div className="space-y-2">
468+
<Label id={eventFieldIds.isOnlineMeeting}>
469+
{messages.createEvent.eventFormatLabel}
470+
</Label>
471+
<div
472+
role="radiogroup"
473+
aria-labelledby={eventFieldIds.isOnlineMeeting}
474+
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
475+
>
476+
<button
477+
type="button"
478+
role="radio"
479+
aria-checked={!isOnlineMeeting}
480+
className={cn(
481+
"flex h-10 items-center justify-center gap-2 rounded-md border px-3 text-sm font-medium transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
482+
!isOnlineMeeting
483+
? "border-primary bg-primary/10 text-foreground"
484+
: "border-input bg-background text-muted-foreground",
485+
)}
486+
onClick={() => {
487+
setIsOnlineMeeting(false);
488+
setMeetingLink("");
489+
clearErrors("isOnlineMeeting", "meetingLink");
490+
}}
491+
>
492+
<MapPinIcon className="size-4" aria-hidden="true" />
493+
{messages.createEvent.inPersonLabel}
494+
</button>
495+
<button
496+
type="button"
497+
role="radio"
498+
aria-checked={isOnlineMeeting}
499+
className={cn(
500+
"flex h-10 items-center justify-center gap-2 rounded-md border px-3 text-sm font-medium transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
501+
isOnlineMeeting
502+
? "border-primary bg-primary/10 text-foreground"
503+
: "border-input bg-background text-muted-foreground",
504+
)}
505+
onClick={() => {
506+
setIsOnlineMeeting(true);
507+
setLocation("");
508+
clearErrors("isOnlineMeeting", "location");
509+
}}
510+
>
511+
<VideoIcon className="size-4" aria-hidden="true" />
512+
{messages.createEvent.onlineMeetingLabel}
513+
</button>
514+
</div>
515+
</div>
516+
517+
{isOnlineMeeting ? (
518+
<div className="space-y-2">
519+
<Label htmlFor={eventFieldIds.meetingLink}>
520+
{messages.createEvent.meetingLinkLabel}
521+
</Label>
522+
<div className="relative">
523+
<LinkIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
524+
<Input
525+
id={eventFieldIds.meetingLink}
526+
type="url"
527+
inputMode="url"
528+
value={meetingLink}
529+
placeholder={messages.createEvent.meetingLinkPlaceholder}
530+
aria-invalid={fieldErrors.meetingLink ? true : undefined}
531+
aria-describedby={fieldErrors.meetingLink ? "meeting-link-error" : undefined}
532+
className={cn(
533+
"pl-9",
534+
fieldErrors.meetingLink &&
535+
"border-destructive focus-visible:ring-destructive/20",
536+
)}
537+
onChange={(event) => {
538+
setMeetingLink(event.target.value);
539+
clearErrors("meetingLink");
540+
}}
541+
/>
542+
</div>
543+
{fieldErrors.meetingLink ? (
544+
<p id="meeting-link-error" className="text-sm text-destructive">
545+
{fieldErrors.meetingLink}
546+
</p>
547+
) : null}
548+
</div>
549+
) : (
550+
<div className="space-y-2">
551+
<Label htmlFor={eventFieldIds.location}>{messages.createEvent.locationLabel}</Label>
552+
<div className="relative">
553+
<MapPinIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
554+
<Input
555+
id={eventFieldIds.location}
556+
value={location}
557+
placeholder={messages.createEvent.locationPlaceholder}
558+
aria-invalid={fieldErrors.location ? true : undefined}
559+
aria-describedby={fieldErrors.location ? "location-error" : undefined}
560+
className={cn(
561+
"pl-9",
562+
fieldErrors.location &&
563+
"border-destructive focus-visible:ring-destructive/20",
564+
)}
565+
onChange={(event) => {
566+
setLocation(event.target.value);
567+
clearErrors("location");
568+
}}
569+
/>
570+
</div>
571+
{fieldErrors.location ? (
572+
<p id="location-error" className="text-sm text-destructive">
573+
{fieldErrors.location}
574+
</p>
575+
) : null}
576+
</div>
577+
)}
578+
</div>
579+
451580
<div className="space-y-2">
452581
<Label id={eventFieldIds.eventType}>{messages.createEvent.eventTypeLabel}</Label>
453582
<div

src/components/event-heatmap.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
useState,
2222
} from "react";
2323

24+
import { EventMetaDetails } from "@/components/event-meta-details";
2425
import {
2526
buildFinalizedSlot,
2627
buildProjectedBoard,
@@ -912,6 +913,7 @@ export function EventHeatmap({
912913
timezone: viewerTimezoneOption?.label ?? viewerTimezone,
913914
})}
914915
</CardDescription>
916+
<EventMetaDetails snapshot={snapshot} className="mt-2" />
915917
</div>
916918
) : null}
917919
</div>

0 commit comments

Comments
 (0)