Skip to content

Commit 114b5b6

Browse files
Add optional full-day start time
1 parent b48dbe2 commit 114b5b6

18 files changed

Lines changed: 401 additions & 47 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "Event"
2+
ADD COLUMN "fullDayStartMinutes" INTEGER;

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ model Event {
2525
isOnlineMeeting Boolean @default(false)
2626
meetingLink String?
2727
timezone String
28+
fullDayStartMinutes Int?
2829
slotMinutes Int @default(30)
2930
meetingDurationMinutes Int @default(60)
3031
dayStartMinutes Int

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,48 @@ describe("GET /api/events/[slug]/ics", () => {
102102
expect(body).not.toContain("DTSTART;TZID=");
103103
});
104104

105+
it("returns a timed calendar file for closed full-day events with a start time", async () => {
106+
getPublicEventSnapshot.mockResolvedValue({
107+
snapshot: {
108+
slug: "class-reunion",
109+
title: "Class Reunion",
110+
eventType: "full_day",
111+
timezone: "Europe/Vienna",
112+
fullDayStartMinutes: 18 * 60,
113+
meetingDurationMinutes: 60,
114+
status: "CLOSED",
115+
finalizedSlot: {
116+
slotStart: "2026-04-01T22:00:00.000Z",
117+
slotEnd: "2026-04-02T17:00:00.000Z",
118+
dateKey: "2026-04-02",
119+
label: "Thu, Apr 2 · 18:00",
120+
localLabel: null,
121+
availableCount: 2,
122+
participantIds: ["p1", "p2"],
123+
},
124+
},
125+
});
126+
127+
const { GET } = await import("./route");
128+
const response = await GET(
129+
new Request("https://tempoll.example.com/api/events/class-reunion/ics", {
130+
headers: {
131+
"Accept-Language": "en-US",
132+
},
133+
}),
134+
{
135+
params: Promise.resolve({
136+
slug: "class-reunion",
137+
}),
138+
},
139+
);
140+
141+
const body = await response.text();
142+
expect(body).toContain("DTSTART;TZID=Europe/Vienna:20260402T180000");
143+
expect(body).toContain("DTEND;TZID=Europe/Vienna:20260402T190000");
144+
expect(body).not.toContain("VALUE=DATE");
145+
});
146+
105147
it("returns 404 when no fixed date exists", async () => {
106148
getPublicEventSnapshot.mockResolvedValue({
107149
snapshot: {

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from "next/server";
22

33
import { getPublicEventSnapshot } from "@/lib/event-service";
4+
import { buildSlotStart } from "@/lib/availability";
45
import { createI18n, getLocaleFromRequest } from "@/lib/i18n/server";
56
import { buildEventCalendarFile } from "@/lib/ics";
67
import { PUBLIC_NO_STORE_HEADERS, mergeHeaders } from "@/lib/security";
@@ -29,17 +30,36 @@ export async function GET(request: Request, { params }: Context) {
2930
);
3031
}
3132

33+
const isTimedFullDayEvent =
34+
event.snapshot.eventType === "full_day" &&
35+
event.snapshot.fullDayStartMinutes !== null &&
36+
event.snapshot.fullDayStartMinutes !== undefined;
37+
const slotStart = isTimedFullDayEvent
38+
? buildSlotStart(
39+
event.snapshot.finalizedSlot.dateKey,
40+
event.snapshot.fullDayStartMinutes ?? 0,
41+
event.snapshot.timezone,
42+
)
43+
: event.snapshot.finalizedSlot.slotStart;
44+
const slotEnd = isTimedFullDayEvent
45+
? new Date(
46+
new Date(slotStart).getTime() + event.snapshot.meetingDurationMinutes * 60 * 1000,
47+
).toISOString()
48+
: event.snapshot.finalizedSlot.slotEnd;
49+
3250
const body = buildEventCalendarFile({
3351
slug: event.snapshot.slug,
3452
title: event.snapshot.title,
3553
location: event.snapshot.location,
3654
isOnlineMeeting: event.snapshot.isOnlineMeeting,
3755
meetingLink: event.snapshot.meetingLink,
3856
timezone: event.snapshot.timezone,
39-
slotStart: event.snapshot.finalizedSlot.slotStart,
40-
slotEnd: event.snapshot.finalizedSlot.slotEnd,
57+
slotStart,
58+
slotEnd,
4159
allDayDateKey:
42-
event.snapshot.eventType === "full_day" ? event.snapshot.finalizedSlot.dateKey : null,
60+
event.snapshot.eventType === "full_day" && !isTimedFullDayEvent
61+
? event.snapshot.finalizedSlot.dateKey
62+
: null,
4363
url: buildPublicEventUrl(event.snapshot.slug),
4464
});
4565

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ describe("POST /api/events", () => {
7676
title: "Offsite Days",
7777
timezone: "Europe/Vienna",
7878
dates: ["2026-03-30", "2026-03-31"],
79+
fullDayStartMinutes: 18 * 60,
7980
dayStartMinutes: 540,
8081
dayEndMinutes: 600,
8182
slotMinutes: 30,
@@ -94,6 +95,7 @@ describe("POST /api/events", () => {
9495
isOnlineMeeting: false,
9596
timezone: "Europe/Vienna",
9697
dates: ["2026-03-30", "2026-03-31"],
98+
fullDayStartMinutes: 18 * 60,
9799
dayStartMinutes: 540,
98100
dayEndMinutes: 600,
99101
slotMinutes: 30,

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,40 @@ describe("CreateEventForm", () => {
216216
});
217217
});
218218

219+
it("submits an optional start time for full-day events", async () => {
220+
const user = userEvent.setup();
221+
const fetchMock = vi.fn().mockResolvedValue({
222+
ok: true,
223+
json: async () => ({
224+
manageKey: "manage-key-123",
225+
}),
226+
});
227+
global.fetch = fetchMock as typeof fetch;
228+
229+
renderCreateEventForm();
230+
231+
await user.type(screen.getByLabelText("Event title"), "Class reunion");
232+
await user.click(screen.getByRole("radio", { name: /Full days/ }));
233+
await user.click(screen.getByRole("combobox", { name: "Start time" }));
234+
await user.click(screen.getByRole("option", { name: "18:00" }));
235+
await user.click(screen.getByRole("button", { name: "Create event" }));
236+
237+
await waitFor(() => {
238+
expect(fetchMock).toHaveBeenCalledTimes(1);
239+
});
240+
241+
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
242+
const payload = JSON.parse(String(requestInit.body)) as {
243+
eventType: string;
244+
fullDayStartMinutes?: number;
245+
};
246+
247+
expect(payload).toMatchObject({
248+
eventType: "full_day",
249+
fullDayStartMinutes: 18 * 60,
250+
});
251+
});
252+
219253
it("submits only enabled weekdays from the selected date range", async () => {
220254
vi.useFakeTimers();
221255
vi.setSystemTime(new Date("2026-04-02T10:00:00.000Z"));

src/components/create-event-form.tsx

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const eventFieldOrder = [
6262
"timezone",
6363
"dates",
6464
"weekdays",
65+
"fullDayStartMinutes",
6566
"dayStartMinutes",
6667
"dayEndMinutes",
6768
"slotMinutes",
@@ -81,6 +82,7 @@ const eventFieldIds: Record<EventField, string> = {
8182
timezone: "timezone-trigger",
8283
dates: "date-range-trigger",
8384
weekdays: "weekday-filter",
85+
fullDayStartMinutes: "full-day-start-trigger",
8486
dayStartMinutes: "day-start-trigger",
8587
dayEndMinutes: "day-end-trigger",
8688
slotMinutes: "slot-size-trigger",
@@ -224,6 +226,7 @@ export function CreateEventForm({
224226
const [dayEndMinutes, setDayEndMinutes] = useState(
225227
defaultCreateEventDefaults.dayEndMinutes,
226228
);
229+
const [fullDayStartMinutes, setFullDayStartMinutes] = useState<number | undefined>(undefined);
227230
const [selectedWeekdays, setSelectedWeekdays] = useState<number[]>(
228231
defaultSelectedWeekdays,
229232
);
@@ -385,6 +388,7 @@ export function CreateEventForm({
385388
notificationEmail: notificationsConfigured ? notificationEmail : undefined,
386389
timezone,
387390
dates: selectedDates,
391+
fullDayStartMinutes: eventType === "full_day" ? fullDayStartMinutes : undefined,
388392
dayStartMinutes,
389393
dayEndMinutes,
390394
slotMinutes,
@@ -1027,7 +1031,64 @@ export function CreateEventForm({
10271031
</div>
10281032
</div>
10291033
</>
1030-
) : null}
1034+
) : (
1035+
<>
1036+
<Separator />
1037+
1038+
<div className="max-w-md space-y-2">
1039+
<Label htmlFor={eventFieldIds.fullDayStartMinutes}>
1040+
{messages.createEvent.fullDayStartTimeLabel}
1041+
</Label>
1042+
<p className="text-sm text-muted-foreground">
1043+
{messages.createEvent.fullDayStartTimeDescription}
1044+
</p>
1045+
<Select
1046+
value={
1047+
fullDayStartMinutes === undefined
1048+
? "none"
1049+
: String(fullDayStartMinutes)
1050+
}
1051+
onValueChange={(value) => {
1052+
setFullDayStartMinutes(value === "none" ? undefined : Number(value));
1053+
clearErrors("fullDayStartMinutes");
1054+
}}
1055+
>
1056+
<SelectTrigger
1057+
id={eventFieldIds.fullDayStartMinutes}
1058+
aria-invalid={fieldErrors.fullDayStartMinutes ? true : undefined}
1059+
aria-describedby={
1060+
fieldErrors.fullDayStartMinutes
1061+
? "full-day-start-error"
1062+
: undefined
1063+
}
1064+
className={cn(
1065+
fieldErrors.fullDayStartMinutes &&
1066+
"border-destructive focus:ring-destructive/20",
1067+
)}
1068+
>
1069+
<SelectValue placeholder={messages.createEvent.fullDayStartTimePlaceholder} />
1070+
</SelectTrigger>
1071+
<SelectContent className="max-h-80">
1072+
<SelectItem value="none">
1073+
{messages.createEvent.fullDayStartTimePlaceholder}
1074+
</SelectItem>
1075+
{timeOptions
1076+
.filter((option) => option.value < 24 * 60)
1077+
.map((option) => (
1078+
<SelectItem key={option.value} value={String(option.value)}>
1079+
{option.label}
1080+
</SelectItem>
1081+
))}
1082+
</SelectContent>
1083+
</Select>
1084+
{fieldErrors.fullDayStartMinutes ? (
1085+
<p id="full-day-start-error" className="text-sm text-destructive">
1086+
{fieldErrors.fullDayStartMinutes}
1087+
</p>
1088+
) : null}
1089+
</div>
1090+
</>
1091+
)}
10311092

10321093
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
10331094

@@ -1091,6 +1152,13 @@ export function CreateEventForm({
10911152
</dd>
10921153
</div>
10931154
</>
1155+
) : fullDayStartMinutes !== undefined ? (
1156+
<div className="flex items-center justify-between gap-4">
1157+
<dt className="text-muted-foreground">
1158+
{messages.createEvent.previewFields.startTime}
1159+
</dt>
1160+
<dd className="font-medium">{getTimeLabel(fullDayStartMinutes)}</dd>
1161+
</div>
10941162
) : null}
10951163
</dl>
10961164
</CardContent>

src/components/event-heatmap.tsx

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { Badge } from "@/components/ui/badge";
3737
import { Button } from "@/components/ui/button";
3838
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
3939
import { Label } from "@/components/ui/label";
40+
import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control";
4041
import {
4142
Select,
4243
SelectContent,
@@ -1027,29 +1028,21 @@ export function EventHeatmap({
10271028
</Select>
10281029
</div>
10291030
{showModeToggle ? (
1030-
<div className="inline-flex rounded-md border bg-muted/30 p-1">
1031-
<Button
1032-
type="button"
1033-
size="sm"
1034-
variant={mode === "edit" ? "secondary" : "ghost"}
1035-
className="h-7 px-3"
1036-
aria-pressed={mode === "edit"}
1031+
<SegmentedControl>
1032+
<SegmentedControlItem
1033+
active={mode === "edit"}
10371034
disabled={!supportsPainting}
10381035
onClick={() => onModeChange?.("edit")}
10391036
>
10401037
{messages.publicEvent.editMode}
1041-
</Button>
1042-
<Button
1043-
type="button"
1044-
size="sm"
1045-
variant={mode === "view" ? "secondary" : "ghost"}
1046-
className="h-7 px-3"
1047-
aria-pressed={mode === "view"}
1038+
</SegmentedControlItem>
1039+
<SegmentedControlItem
1040+
active={mode === "view"}
10481041
onClick={() => onModeChange?.("view")}
10491042
>
10501043
{messages.publicEvent.viewMode}
1051-
</Button>
1052-
</div>
1044+
</SegmentedControlItem>
1045+
</SegmentedControl>
10531046
) : null}
10541047
</div>
10551048
</div>

0 commit comments

Comments
 (0)