Skip to content

Commit 57234c4

Browse files
Add full-day event support (#67)
* Add full-day event support * Address PR review feedback for full-day events
1 parent 8164435 commit 57234c4

24 files changed

Lines changed: 2409 additions & 133 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CREATE TYPE "EventType" AS ENUM ('TIME_GRID', 'FULL_DAY');
2+
3+
ALTER TABLE "Event"
4+
ADD COLUMN "type" "EventType" NOT NULL DEFAULT 'TIME_GRID';

prisma/schema.prisma

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@ enum EventStatus {
1111
CLOSED
1212
}
1313

14+
enum EventType {
15+
TIME_GRID
16+
FULL_DAY
17+
}
18+
1419
model Event {
1520
id String @id @default(cuid())
1621
slug String @unique
1722
title String
23+
type EventType @default(TIME_GRID)
1824
timezone String
1925
slotMinutes Int @default(30)
2026
meetingDurationMinutes Int @default(60)

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,46 @@ describe("PUT /api/events/[slug]/availability", () => {
7272
expect(response.headers.get("cache-control")).toContain("private, no-store");
7373
});
7474

75+
it("accepts long full-day availability selections beyond 31 days", async () => {
76+
saveAvailability.mockResolvedValue({
77+
snapshot: {
78+
id: "event_1",
79+
},
80+
});
81+
const selectedSlotStarts = Array.from({ length: 1001 }, (_, index) =>
82+
new Date(Date.UTC(2026, 0, 1 + index)).toISOString(),
83+
);
84+
85+
const { PUT } = await import("./route");
86+
const response = await PUT(
87+
new Request("https://tempoll.example.com/api/events/full-day-event/availability", {
88+
method: "PUT",
89+
body: JSON.stringify({
90+
selectedSlotStarts,
91+
}),
92+
headers: {
93+
"Accept-Language": "en-US",
94+
"Content-Type": "application/json",
95+
},
96+
}),
97+
{
98+
params: Promise.resolve({
99+
slug: "full-day-event",
100+
}),
101+
},
102+
);
103+
104+
expect(saveAvailability).toHaveBeenCalledWith(
105+
"full-day-event",
106+
"en",
107+
{
108+
selectedSlotStarts,
109+
},
110+
"participant_1.secret-token",
111+
);
112+
expect(response.status).toBe(200);
113+
});
114+
75115
it("returns the domain-auth error for missing or expired participant sessions", async () => {
76116
saveAvailability.mockRejectedValue(unauthorized("participant_session_missing"));
77117

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe("GET /api/events/[slug]/ics", () => {
1616
snapshot: {
1717
slug: "team-sync",
1818
title: "Team Sync",
19+
eventType: "time_grid",
1920
timezone: "Europe/Vienna",
2021
status: "CLOSED",
2122
finalizedSlot: {
@@ -55,11 +56,52 @@ describe("GET /api/events/[slug]/ics", () => {
5556
expect(body).toContain("URL:http://localhost:3000/e/team-sync");
5657
});
5758

59+
it("returns an all-day calendar file for closed full-day events", async () => {
60+
getPublicEventSnapshot.mockResolvedValue({
61+
snapshot: {
62+
slug: "offsite-days",
63+
title: "Offsite Days",
64+
eventType: "full_day",
65+
timezone: "Europe/Vienna",
66+
status: "CLOSED",
67+
finalizedSlot: {
68+
slotStart: "2026-04-01T22:00:00.000Z",
69+
slotEnd: "2026-04-02T22:00:00.000Z",
70+
dateKey: "2026-04-02",
71+
label: "Thu, Apr 2",
72+
localLabel: null,
73+
availableCount: 2,
74+
participantIds: ["p1", "p2"],
75+
},
76+
},
77+
});
78+
79+
const { GET } = await import("./route");
80+
const response = await GET(
81+
new Request("https://tempoll.example.com/api/events/offsite-days/ics", {
82+
headers: {
83+
"Accept-Language": "en-US",
84+
},
85+
}),
86+
{
87+
params: Promise.resolve({
88+
slug: "offsite-days",
89+
}),
90+
},
91+
);
92+
93+
const body = await response.text();
94+
expect(body).toContain("DTSTART;VALUE=DATE:20260402");
95+
expect(body).toContain("DTEND;VALUE=DATE:20260403");
96+
expect(body).not.toContain("DTSTART;TZID=");
97+
});
98+
5899
it("returns 404 when no fixed date exists", async () => {
59100
getPublicEventSnapshot.mockResolvedValue({
60101
snapshot: {
61102
slug: "team-sync",
62103
title: "Team Sync",
104+
eventType: "time_grid",
63105
timezone: "Europe/Vienna",
64106
status: "OPEN",
65107
finalizedSlot: null,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export async function GET(request: Request, { params }: Context) {
3535
timezone: event.snapshot.timezone,
3636
slotStart: event.snapshot.finalizedSlot.slotStart,
3737
slotEnd: event.snapshot.finalizedSlot.slotEnd,
38+
allDayDateKey:
39+
event.snapshot.eventType === "full_day" ? event.snapshot.finalizedSlot.dateKey : null,
3840
url: buildPublicEventUrl(event.snapshot.slug),
3941
});
4042

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe("POST /api/events", () => {
5151
);
5252

5353
expect(createEvent).toHaveBeenCalledWith({
54+
eventType: "time_grid",
5455
title: "Sprint Planning",
5556
timezone: "Europe/Vienna",
5657
dates: ["2026-03-30"],
@@ -63,6 +64,41 @@ describe("POST /api/events", () => {
6364
expect(response.status).toBe(201);
6465
});
6566

67+
it("passes full-day event creation through to the service", async () => {
68+
const { POST } = await import("./route");
69+
const response = await POST(
70+
new Request("https://tempoll.example.com/api/events", {
71+
method: "POST",
72+
body: JSON.stringify({
73+
eventType: "full_day",
74+
title: "Offsite Days",
75+
timezone: "Europe/Vienna",
76+
dates: ["2026-03-30", "2026-03-31"],
77+
dayStartMinutes: 540,
78+
dayEndMinutes: 600,
79+
slotMinutes: 30,
80+
meetingDurationMinutes: 60,
81+
}),
82+
headers: {
83+
"Accept-Language": "en-US",
84+
"Content-Type": "application/json",
85+
},
86+
}),
87+
);
88+
89+
expect(createEvent).toHaveBeenCalledWith({
90+
eventType: "full_day",
91+
title: "Offsite Days",
92+
timezone: "Europe/Vienna",
93+
dates: ["2026-03-30", "2026-03-31"],
94+
dayStartMinutes: 540,
95+
dayEndMinutes: 600,
96+
slotMinutes: 30,
97+
meetingDurationMinutes: 60,
98+
});
99+
expect(response.status).toBe(201);
100+
});
101+
66102
it("returns a 429 with Retry-After when the create-event limit is exceeded", async () => {
67103
enforceRateLimit.mockImplementation(() => {
68104
throw tooManyRequests("event_create_rate_limited", 900);

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,44 @@ describe("CreateEventForm", () => {
268268
expect(fetchMock).not.toHaveBeenCalled();
269269
});
270270

271+
it("creates a full-day event without showing time-slot controls", async () => {
272+
vi.useFakeTimers();
273+
vi.setSystemTime(new Date("2026-04-02T10:00:00.000Z"));
274+
275+
const fetchMock = vi.fn().mockResolvedValue({
276+
ok: true,
277+
json: async () => ({
278+
manageKey: "manage-key-123",
279+
}),
280+
});
281+
global.fetch = fetchMock as typeof fetch;
282+
283+
renderCreateEventForm();
284+
vi.useRealTimers();
285+
const user = userEvent.setup();
286+
287+
await user.type(screen.getByLabelText("Event title"), "Offsite days");
288+
await user.click(screen.getByRole("radio", { name: /Full days/i }));
289+
290+
expect(screen.queryByRole("combobox", { name: "Daily start" })).not.toBeInTheDocument();
291+
expect(screen.queryByRole("combobox", { name: "Slot size" })).not.toBeInTheDocument();
292+
293+
await user.click(screen.getByRole("button", { name: "Create event" }));
294+
295+
await waitFor(() => {
296+
expect(fetchMock).toHaveBeenCalledTimes(1);
297+
});
298+
299+
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
300+
const payload = JSON.parse(String(requestInit.body)) as {
301+
dates: string[];
302+
eventType: string;
303+
};
304+
305+
expect(payload.eventType).toBe("full_day");
306+
expect(payload.dates).toEqual(["2026-04-03"]);
307+
});
308+
271309
it("submits the optional notification email when alerts are available", async () => {
272310
const user = userEvent.setup();
273311
const fetchMock = vi.fn().mockResolvedValue({

0 commit comments

Comments
 (0)