feat(sprint7): Phase 2.5 — Club Event Calendar

- Flyway V14: club_events + event_rsvps tables with reminder_sent tracking
- Enums: EventType, RsvpStatus, RecurrenceRule + extend AuditEventType/NotificationType
- Entities: ClubEvent (extends AbstractTenantEntity), EventRsvp (unique event+member)
- Repositories: ClubEventRepository, EventRsvpRepository with date-range and status queries
- EventService: CRUD, RSVP with maxAttendees enforcement (409 if full), iCal RFC 5545 generation, recurring event virtual expansion, notifications on create/cancel, auto-post to Info Board
- EventReminderScheduler: hourly check, 24h reminder to ACCEPTED/MAYBE attendees
- EventController: admin CRUD (MANAGE_INFO_BOARD permission), portal upcoming events, RSVP endpoint, iCal download (text/calendar), attendee list
- Frontend: events.ts service (React Query hooks matching apiClient pattern), admin calendar page (month grid with event dots, create dialog, event cards), portal events page (RSVP buttons, capacity display)
- Navigation: added Kalender with Calendar icon
- i18n: events.* keys in de.json and en.json
- UI: added @radix-ui/react-switch + Switch component
This commit is contained in:
Patrick Plate
2026-06-13 20:16:56 +02:00
parent 4aa27cd4f9
commit 05fd679c4d
27 changed files with 2044 additions and 1 deletions
@@ -0,0 +1,437 @@
"use client"
import { useState } from "react"
import {
getEventTypeColor,
getEventTypeLabel,
getIcalUrl,
useCancelEventMutation,
useCreateEventMutation,
useEventsQuery,
} from "@/services/events"
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
startOfMonth,
startOfWeek,
subMonths,
} from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import {
Calendar,
ChevronLeft,
ChevronRight,
Download,
MapPin,
Plus,
Trash2,
Users,
} from "lucide-react"
import type { ClubEvent, EventType, RecurrenceRule } from "@/services/events"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
export default function CalendarPage() {
const t = useTranslations("events")
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const monthStart = startOfMonth(currentMonth)
const monthEnd = endOfMonth(currentMonth)
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 })
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 })
const from = monthStart.toISOString()
const to = monthEnd.toISOString()
const { data: events = [] as ClubEvent[], isLoading } = useEventsQuery(
from,
to
)
const cancelEvent = useCancelEventMutation()
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd })
const weekDays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
const getEventsForDay = (day: Date): ClubEvent[] =>
events.filter((event: ClubEvent) => isSameDay(new Date(event.startAt), day))
const selectedDayEvents: ClubEvent[] = selectedDate
? events.filter((event: ClubEvent) =>
isSameDay(new Date(event.startAt), selectedDate)
)
: []
const upcomingEvents = events
.filter((e: ClubEvent) => new Date(e.startAt) >= new Date())
.sort(
(a: ClubEvent, b: ClubEvent) =>
new Date(a.startAt).getTime() - new Date(b.startAt).getTime()
)
.slice(0, 5)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("createEvent")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("createEvent")}</DialogTitle>
</DialogHeader>
<CreateEventForm onSuccess={() => setShowCreateDialog(false)} />
</DialogContent>
</Dialog>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Calendar Grid */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<CardTitle className="text-lg">
{format(currentMonth, "MMMM yyyy", { locale: de })}
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
{/* Week day headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{weekDays.map((day) => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2"
>
{day}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-1">
{days.map((day) => {
const dayEvents = getEventsForDay(day)
const isCurrentMonth = isSameMonth(day, currentMonth)
const isSelected = selectedDate && isSameDay(day, selectedDate)
const isToday = isSameDay(day, new Date())
return (
<button
key={day.toISOString()}
onClick={() => setSelectedDate(day)}
className={`
relative flex flex-col items-center justify-start p-1 h-16 rounded-md text-sm transition-colors
${!isCurrentMonth ? "text-muted-foreground/40" : ""}
${isSelected ? "bg-primary/10 ring-1 ring-primary" : "hover:bg-muted"}
${isToday ? "font-bold" : ""}
`}
>
<span
className={
isToday
? "bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center text-xs"
: ""
}
>
{format(day, "d")}
</span>
{dayEvents.length > 0 && (
<div className="flex gap-0.5 mt-1 flex-wrap justify-center">
{dayEvents.slice(0, 3).map((event: ClubEvent) => (
<div
key={event.id}
className={`w-1.5 h-1.5 rounded-full ${getEventTypeColor(event.eventType)}`}
/>
))}
</div>
)}
</button>
)
})}
</div>
</CardContent>
</Card>
{/* Sidebar: Selected day events or upcoming */}
<div className="space-y-4">
{selectedDate ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
{format(selectedDate, "EEEE, d. MMMM", { locale: de })}
</CardTitle>
</CardHeader>
<CardContent>
{selectedDayEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("noEventsOnDay")}
</p>
) : (
<div className="space-y-3">
{selectedDayEvents.map((event: ClubEvent) => (
<EventCard
key={event.id}
event={event}
onCancel={() => cancelEvent.mutate(event.id)}
/>
))}
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Calendar className="h-4 w-4" />
{t("upcomingEvents")}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="text-sm text-muted-foreground">Laden...</p>
) : upcomingEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("noUpcomingEvents")}
</p>
) : (
<div className="space-y-3">
{upcomingEvents.map((event: ClubEvent) => (
<EventCard
key={event.id}
event={event}
onCancel={() => cancelEvent.mutate(event.id)}
/>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}
function EventCard({
event,
onCancel,
}: {
event: ClubEvent
onCancel: () => void
}) {
const t = useTranslations("events")
const accepted = event.attendeeCounts?.ACCEPTED ?? 0
const maybe = event.attendeeCounts?.MAYBE ?? 0
return (
<div className="rounded-lg border p-3 space-y-2">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{getEventTypeLabel(event.eventType)}
</Badge>
{event.recurring && (
<Badge variant="outline" className="text-xs">
🔁
</Badge>
)}
</div>
<h4 className="font-medium mt-1">{event.title}</h4>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-0.5">
<p>
📅{" "}
{format(new Date(event.startAt), "dd.MM.yyyy HH:mm", { locale: de })}
</p>
{event.location && (
<p className="flex items-center gap-1">
<MapPin className="h-3 w-3" /> {event.location}
</p>
)}
<p className="flex items-center gap-1">
<Users className="h-3 w-3" /> {accepted} Zusagen, {maybe} Vielleicht
{event.maxAttendees && ` / max. ${event.maxAttendees}`}
</p>
</div>
<div className="flex gap-1 pt-1">
<a href={getIcalUrl(event.id)} download className="inline-flex">
<Button variant="ghost" size="sm" className="h-7 text-xs">
<Download className="h-3 w-3 mr-1" /> iCal
</Button>
</a>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive"
onClick={onCancel}
>
<Trash2 className="h-3 w-3 mr-1" /> {t("cancel")}
</Button>
</div>
</div>
)
}
function CreateEventForm({ onSuccess }: { onSuccess: () => void }) {
const t = useTranslations("events")
const createEvent = useCreateEventMutation()
const [recurring, setRecurring] = useState(false)
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createEvent.mutate(
{
title: formData.get("title") as string,
description: (formData.get("description") as string) || undefined,
eventType: formData.get("eventType") as EventType,
startAt: new Date(formData.get("startAt") as string).toISOString(),
endAt: formData.get("endAt")
? new Date(formData.get("endAt") as string).toISOString()
: undefined,
location: (formData.get("location") as string) || undefined,
maxAttendees: formData.get("maxAttendees")
? Number(formData.get("maxAttendees"))
: undefined,
recurring,
recurrenceRule: recurring
? (formData.get("recurrenceRule") as RecurrenceRule)
: undefined,
recurrenceEndDate:
recurring && formData.get("recurrenceEndDate")
? (formData.get("recurrenceEndDate") as string)
: undefined,
postToInfoBoard: true,
},
{ onSuccess }
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{t("form.title")}</Label>
<Input id="title" name="title" required maxLength={200} />
</div>
<div className="space-y-2">
<Label htmlFor="eventType">{t("form.type")}</Label>
<Select name="eventType" defaultValue="MEETING">
<option value="MEETING">Mitgliederversammlung</option>
<option value="HARVEST_FESTIVAL">Erntefest</option>
<option value="BOARD_MEETING">Vorstandssitzung</option>
<option value="WORKSHOP">Workshop</option>
<option value="OTHER">Sonstiges</option>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="startAt">{t("form.start")}</Label>
<Input id="startAt" name="startAt" type="datetime-local" required />
</div>
<div className="space-y-2">
<Label htmlFor="endAt">{t("form.end")}</Label>
<Input id="endAt" name="endAt" type="datetime-local" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="location">{t("form.location")}</Label>
<Input id="location" name="location" maxLength={300} />
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("form.description")}</Label>
<Textarea id="description" name="description" rows={3} />
</div>
<div className="space-y-2">
<Label htmlFor="maxAttendees">{t("form.maxAttendees")}</Label>
<Input id="maxAttendees" name="maxAttendees" type="number" min={1} />
</div>
<div className="flex items-center gap-3">
<Switch
id="recurring"
checked={recurring}
onCheckedChange={setRecurring}
/>
<Label htmlFor="recurring">{t("form.recurring")}</Label>
</div>
{recurring && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="recurrenceRule">{t("form.recurrenceRule")}</Label>
<Select name="recurrenceRule" defaultValue="WEEKLY">
<option value="WEEKLY">Wöchentlich</option>
<option value="BIWEEKLY">Alle 2 Wochen</option>
<option value="MONTHLY">Monatlich</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="recurrenceEndDate">{t("form.recurrenceEnd")}</Label>
<Input
id="recurrenceEndDate"
name="recurrenceEndDate"
type="date"
/>
</div>
</div>
)}
<Button type="submit" className="w-full" disabled={createEvent.isPending}>
{createEvent.isPending ? "Erstelle..." : t("createEvent")}
</Button>
</form>
)
}
@@ -0,0 +1,143 @@
"use client"
import {
getEventTypeLabel,
getIcalUrl,
usePortalRsvpMutation,
useUpcomingEventsQuery,
} from "@/services/events"
import { format } from "date-fns"
import { de } from "date-fns/locale"
import { useTranslations } from "next-intl"
import {
Calendar,
Check,
Download,
HelpCircle,
MapPin,
Users,
X,
} from "lucide-react"
import type { ClubEvent, RsvpStatus } from "@/services/events"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
export default function PortalEventsPage() {
const t = useTranslations("events")
const { data: events = [], isLoading } = useUpcomingEventsQuery()
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Calendar className="h-6 w-6" />
{t("portalTitle")}
</h1>
<p className="text-muted-foreground">{t("portalDescription")}</p>
</div>
{isLoading ? (
<p className="text-muted-foreground">Laden...</p>
) : events.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{t("noUpcomingEvents")}
</CardContent>
</Card>
) : (
<div className="space-y-4">
{events.map((event: ClubEvent) => (
<PortalEventCard key={event.id} event={event} />
))}
</div>
)}
</div>
)
}
function PortalEventCard({ event }: { event: ClubEvent }) {
const t = useTranslations("events")
const rsvpMutation = usePortalRsvpMutation(event.id)
const accepted = event.attendeeCounts?.ACCEPTED ?? 0
const maybe = event.attendeeCounts?.MAYBE ?? 0
const isFull = event.maxAttendees != null && accepted >= event.maxAttendees
const handleRsvp = (status: RsvpStatus) => {
rsvpMutation.mutate(status)
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<Badge variant="secondary" className="mb-2">
{getEventTypeLabel(event.eventType)}
</Badge>
<CardTitle className="text-lg">{event.title}</CardTitle>
</div>
{isFull && <Badge variant="destructive">{t("full")}</Badge>}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-1">
<p>
📅{" "}
{format(new Date(event.startAt), "EEEE, d. MMMM yyyy · HH:mm", {
locale: de,
})}{" "}
Uhr
{event.endAt && ` ${format(new Date(event.endAt), "HH:mm")} Uhr`}
</p>
{event.location && (
<p className="flex items-center gap-1">
<MapPin className="h-4 w-4" /> {event.location}
</p>
)}
<p className="flex items-center gap-1">
<Users className="h-4 w-4" /> {accepted} Zusagen, {maybe} Vielleicht
{event.maxAttendees && ` (max. ${event.maxAttendees})`}
</p>
</div>
{event.description && <p className="text-sm">{event.description}</p>}
{/* RSVP Buttons */}
<div className="flex flex-wrap gap-2 pt-2">
<Button
size="sm"
variant={event.myRsvpStatus === "ACCEPTED" ? "default" : "outline"}
onClick={() => handleRsvp("ACCEPTED")}
disabled={isFull && event.myRsvpStatus !== "ACCEPTED"}
>
<Check className="h-4 w-4 mr-1" /> {t("rsvp.accept")}
</Button>
<Button
size="sm"
variant={event.myRsvpStatus === "MAYBE" ? "default" : "outline"}
onClick={() => handleRsvp("MAYBE")}
>
<HelpCircle className="h-4 w-4 mr-1" /> {t("rsvp.maybe")}
</Button>
<Button
size="sm"
variant={
event.myRsvpStatus === "DECLINED" ? "destructive" : "outline"
}
onClick={() => handleRsvp("DECLINED")}
>
<X className="h-4 w-4 mr-1" /> {t("rsvp.decline")}
</Button>
<a href={getIcalUrl(event.id)} download>
<Button size="sm" variant="ghost">
<Download className="h-4 w-4 mr-1" /> iCal
</Button>
</a>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -39,6 +39,11 @@ export const navigationsData: NavigationType[] = [
href: "/info-board",
iconName: "Megaphone",
},
{
title: "Kalender",
href: "/calendar",
iconName: "Calendar",
},
{
title: "Personal",
href: "/settings/staff",
+200
View File
@@ -0,0 +1,200 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export type EventType =
| "MEETING"
| "HARVEST_FESTIVAL"
| "BOARD_MEETING"
| "WORKSHOP"
| "OTHER"
export type RsvpStatus = "ACCEPTED" | "DECLINED" | "MAYBE"
export type RecurrenceRule = "WEEKLY" | "BIWEEKLY" | "MONTHLY"
export interface ClubEvent {
id: string
title: string
description: string | null
eventType: EventType
startAt: string
endAt: string | null
location: string | null
maxAttendees: number | null
recurring: boolean
recurrenceRule: RecurrenceRule | null
recurrenceEndDate: string | null
createdBy: string
createdAt: string
attendeeCounts: Record<RsvpStatus, number>
myRsvpStatus: RsvpStatus | null
}
export interface CreateEventRequest {
title: string
description?: string
eventType: EventType
startAt: string
endAt?: string
location?: string
maxAttendees?: number
recurring: boolean
recurrenceRule?: RecurrenceRule
recurrenceEndDate?: string
postToInfoBoard?: boolean
}
export interface UpdateEventRequest {
title: string
description?: string
eventType: EventType
startAt: string
endAt?: string
location?: string
maxAttendees?: number
recurring: boolean
recurrenceRule?: RecurrenceRule
recurrenceEndDate?: string
}
export interface RsvpResponse {
memberId: string
memberName: string
status: RsvpStatus
respondedAt: string
}
// --- Query Hooks ---
export function useEventsQuery(from: string, to: string) {
return useQuery({
queryKey: ["events", from, to],
queryFn: () =>
apiClient<ClubEvent[]>(`/events?from=${from}&to=${to}`),
enabled: !!from && !!to,
})
}
export function useEventQuery(eventId: string | undefined) {
return useQuery({
queryKey: ["events", eventId],
queryFn: () => apiClient<ClubEvent>(`/events/${eventId}`),
enabled: !!eventId,
})
}
export function useUpcomingEventsQuery() {
return useQuery({
queryKey: ["events", "upcoming"],
queryFn: () => apiClient<ClubEvent[]>("/portal/events"),
})
}
export function useEventAttendeesQuery(eventId: string | undefined) {
return useQuery({
queryKey: ["events", eventId, "attendees"],
queryFn: () => apiClient<RsvpResponse[]>(`/events/${eventId}/attendees`),
enabled: !!eventId,
})
}
// --- Mutation Hooks ---
export function useCreateEventMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateEventRequest) =>
apiClient<ClubEvent>("/events", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
export function useUpdateEventMutation(eventId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateEventRequest) =>
apiClient<ClubEvent>(`/events/${eventId}`, { method: "PUT", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
export function useCancelEventMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (eventId: string) =>
apiClient<void>(`/events/${eventId}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
export function useRsvpMutation(eventId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (status: RsvpStatus) =>
apiClient<{ status: RsvpStatus; respondedAt: string }>(
`/events/${eventId}/rsvp`,
{ method: "POST", body: { status } }
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
export function usePortalRsvpMutation(eventId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (status: RsvpStatus) =>
apiClient<{ status: RsvpStatus; respondedAt: string }>(
`/portal/events/${eventId}/rsvp`,
{ method: "POST", body: { status } }
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["events"] })
},
})
}
// --- Helpers ---
export function getEventTypeLabel(type: EventType): string {
const labels: Record<EventType, string> = {
MEETING: "Mitgliederversammlung",
HARVEST_FESTIVAL: "Erntefest",
BOARD_MEETING: "Vorstandssitzung",
WORKSHOP: "Workshop",
OTHER: "Sonstiges",
}
return labels[type]
}
export function getEventTypeColor(type: EventType): string {
const colors: Record<EventType, string> = {
MEETING: "bg-blue-500",
HARVEST_FESTIVAL: "bg-green-500",
BOARD_MEETING: "bg-purple-500",
WORKSHOP: "bg-amber-500",
OTHER: "bg-gray-500",
}
return colors[type]
}
export function getRsvpStatusLabel(status: RsvpStatus): string {
const labels: Record<RsvpStatus, string> = {
ACCEPTED: "Zusage",
DECLINED: "Absage",
MAYBE: "Vielleicht",
}
return labels[status]
}
export function getIcalUrl(eventId: string): string {
return `/api/backend/events/${eventId}/ical`
}