Compare commits

...

13 commits

Author SHA1 Message Date
Benno Tielen
cf29fa45b3 fix: branch
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-07-16 16:00:40 +02:00
Benno Tielen
c537fcbb81 fix: styling 2026-07-16 15:58:32 +02:00
Benno Tielen
1954ed03fc fix: z-index 2026-07-16 15:48:39 +02:00
Benno Tielen
fd4aab561b feature: pagination 2026-07-16 13:58:03 +02:00
Benno Tielen
9afd2726b2 feature: pinned blog posts 2026-07-16 13:44:36 +02:00
Benno Tielen
b099197839 feature: worship and events auth 2026-07-16 13:27:37 +02:00
Benno Tielen
fb229a76b0 feature: extended access for pages and parishes 2026-07-16 11:27:55 +02:00
Benno Tielen
f2b927a359 fix: text 2026-07-16 09:57:28 +02:00
Benno Tielen
de99fb003e fix: balance wrap text 2026-07-16 09:54:50 +02:00
Benno Tielen
ef8421e3d1 fix: updated text 2026-07-16 09:47:35 +02:00
Benno Tielen
36ecb86fcc fix: update event fetching logic 2026-07-16 09:39:52 +02:00
Benno Tielen
0703704307 fix: format date 2026-07-16 09:22:46 +02:00
Benno Tielen
fb071ff650 fix: opacity 2026-07-16 08:28:39 +02:00
32 changed files with 55880 additions and 226 deletions

View file

@ -77,4 +77,4 @@ all:
envs_dir: /opt/church-website/envs envs_dir: /opt/church-website/envs
scripts_dir: /opt/church-website/scripts scripts_dir: /opt/church-website/scripts
repo_url: "{{ vault_repo_url }}" repo_url: "{{ vault_repo_url }}"
repo_branch: staging repo_branch: chemnitz

View file

@ -3,21 +3,34 @@ import { fetchBlogPosts } from '@/fetch/blog'
import { BlogExcerpt } from '@/components/BlogExcerpt/BlogExcerpt' import { BlogExcerpt } from '@/components/BlogExcerpt/BlogExcerpt'
import { Section } from '@/components/Section/Section' import { Section } from '@/components/Section/Section'
import { Container } from '@/components/Container/Container' import { Container } from '@/components/Container/Container'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import { getPhoto } from '@/utils/dto/gallery' import { getPhoto } from '@/utils/dto/gallery'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function Page() { const buildHref = (page: number): string =>
const blogs = await fetchBlogPosts(false); page > 1 ? `/blog?page=${page}` : '/blog'
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ page?: string }>
}) {
const query = await searchParams
const parsedPage = parseInt(query.page ?? '1', 10)
const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1
const blogs = await fetchBlogPosts(false, page)
return ( return (
<> <>
<PageHeader <PageHeader
title={"Aktuelle Nachrichten"} title={'Aktuelle Nachrichten'}
description={"Im Blog der Pfarrei finden Sie aktuelle Nachrichten und erfahren alles über das Gemeindeleben, kommende Veranstaltungen und besondere Gottesdienste."} description={'Im Blog der Pfarrei finden Sie aktuelle Nachrichten und erfahren alles über das Gemeindeleben, kommende Veranstaltungen und besondere Gottesdienste.'}
/> />
<Section padding={"small"}> <Section padding={'small'}>
<Container> <Container>
{ {
blogs?.docs.map(blog => blogs?.docs.map(blog =>
@ -31,6 +44,23 @@ export default async function Page() {
} }
</Container> </Container>
</Section> </Section>
{(blogs?.hasPrevPage || blogs?.hasNextPage) && (
<Section padding={'small'}>
<NextPrevButtons
prev={
blogs.hasPrevPage && blogs.prevPage
? { href: buildHref(blogs.prevPage), text: 'Vorige Seite' }
: undefined
}
next={
blogs.hasNextPage && blogs.nextPage
? { href: buildHref(blogs.nextPage), text: 'Nächste Seite' }
: undefined
}
/>
</Section>
)}
</> </>
) )
} }

View file

@ -50,7 +50,7 @@ export default async function WorshipPage({searchParams}: {
<> <>
<PageHeader <PageHeader
title={"Gottesdienste"} title={"Gottesdienste"}
description={"Erleben Sie unsere Heilige Messe und feiern Sie mit uns! Auf dieser Seite finden Sie alle Termine, Details und besondere Highlights unserer Gottesdienste im Überblick. Seien Sie herzlich willkommen!"} description={"Feiern Sie mit uns den Glauben! Auf dieser Seite finden Sie alle Termine, Details und besondere Highlights unserer Gottesdienste im Überblick. Seien Sie herzlich willkommen! Für genauere Informationen klicken Sie auf den Gottesdiensttermin!"}
/> />
<Section padding={"small"} paddingBottom={"large"}> <Section padding={"small"} paddingBottom={"large"}>

View file

@ -38,6 +38,19 @@ export const Blog: CollectionConfig = {
de: 'Titel', de: 'Titel',
}, },
}, },
{
name: 'pinned',
type: 'checkbox',
required: true,
defaultValue: false,
label: {
de: 'Angepinnt',
},
admin: {
position: 'sidebar',
description: 'Angepinnte Beiträge werden im Blog und auf der Startseite zuerst angezeigt.',
},
},
{ {
type: 'tabs', type: 'tabs',
tabs: [ tabs: [
@ -117,8 +130,10 @@ export const Blog: CollectionConfig = {
], ],
}, },
], ],
defaultSort: ['-pinned', '-createdAt'],
admin: { admin: {
useAsTitle: 'title', useAsTitle: 'title',
defaultColumns: ['title', 'pinned', 'updatedAt'],
hidden: hide, hidden: hide,
livePreview: { livePreview: {
url: ({ data }) => `/api/draft?url=/blog/${data.id}`, url: ({ data }) => `/api/draft?url=/blog/${data.id}`,

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from 'payload' import { CollectionConfig } from 'payload'
import { isAdminOrEmployee } from '@/collections/access/admin' import { isAdminOrEmployee } from '@/collections/access/admin'
import { canMutateOccurrenceOfAssignedEvent } from '@/collections/access/assigned'
export const EventOccurrences: CollectionConfig = { export const EventOccurrences: CollectionConfig = {
slug: 'eventOccurrence', slug: 'eventOccurrence',
@ -85,7 +86,9 @@ export const EventOccurrences: CollectionConfig = {
access: { access: {
read: () => true, read: () => true,
create: isAdminOrEmployee(), create: isAdminOrEmployee(),
update: isAdminOrEmployee(), // Whoever may edit the parent event may cancel/uncancel its occurrences
// (used by the "Nächste Termine" table on the event edit view).
update: canMutateOccurrenceOfAssignedEvent(),
delete: isAdminOrEmployee(), delete: isAdminOrEmployee(),
}, },
} }

View file

@ -1,7 +1,16 @@
import { CollectionConfig } from 'payload' import { CollectionConfig } from 'payload'
import { Group, User } from '@/payload-types'
import { fetchEventById } from '@/fetch/events'
import { isPublishedPublic } from '@/collections/access/public' import { isPublishedPublic } from '@/collections/access/public'
import {
canCreateAssignedEvent,
canMutateAssignedEvent,
defaultToOnlyAssigned,
filterEventsToAssigned,
getAssignedIds,
hasAssignedMatch,
hideUnlessAssignedAny,
toIdArray,
unassignedAdditions,
} from '@/collections/access/assigned'
import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences' import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences'
export const Events: CollectionConfig = { export const Events: CollectionConfig = {
@ -122,17 +131,17 @@ export const Events: CollectionConfig = {
label: { label: {
de: 'Gemeinde', de: 'Gemeinde',
}, },
validate: (value, options) => { defaultValue: defaultToOnlyAssigned('parishes'),
let user = options.req.user // A Gläubiger may only ADD their own parishes — unless they are
// assigned to a group; group members may attach any parish.
if (!user) { // Connections already on the doc may always be kept.
return 'You are not allowed to do this' validate: (value: unknown, options: any) => {
const user = options.req?.user
if (!user || user.roles !== 'user') return true
if (getAssignedIds(user, 'groups').length > 0) return true
if (unassignedAdditions(user, 'parishes', value, options.previousValue).length > 0) {
return 'Sie können nur Gemeinden hinzufügen, die Ihnen zugewiesen sind.'
} }
if (user.roles === 'user' && value && value.length > 0) {
return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.'
}
return true return true
}, },
}, },
@ -144,38 +153,40 @@ export const Events: CollectionConfig = {
label: { label: {
de: 'Gruppe', de: 'Gruppe',
}, },
access: { defaultValue: defaultToOnlyAssigned('groups'),
update: ({req: { user}, data}) => { // Combined rule for parish AND group (validated once, here):
if(user && (user.roles == "admin" || user.roles =="employee")) { // a Gläubiger's event needs at least one connection, and at
return true // least one of them must be an own assignment. Additional
} // foreign connections are allowed.
validate: (value: unknown, options: any) => {
const user = options.req?.user
if(hasGroup(user, data)) { // No user = local API (jobs, scripts); unauthenticated REST
return true // writes are already blocked by the collection access.
} if (!user || user.roles !== 'user') {
return true
return false
}
},
validate: (value, options: { req: { user: any } }) => {
let user = options.req.user
if (!user) {
return 'You are not allowed to do this'
} }
if (user.roles === 'user') { const groupIds = toIdArray(value)
if(!Array.isArray(value) || value.length === 0) { const parishIds = toIdArray(options.data?.parish)
return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.'
}
if(!Array.isArray(user.groups) || user.groups.length === 0) { // Parish-assigned users may attach any group; otherwise only own groups may be added.
return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen." if (
} getAssignedIds(user, 'parishes').length === 0 &&
unassignedAdditions(user, 'groups', value, options.previousValue).length > 0
) {
return 'Sie können nur Gruppen hinzufügen, die Ihnen zugewiesen sind.'
}
if(!value.every(id => user.groups.includes(id))) { if (groupIds.length === 0 && parishIds.length === 0) {
return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen" return 'Sie müssen die Veranstaltung mit mindestens einer Gruppe oder Gemeinde verknüpfen.'
} }
if (
!hasAssignedMatch(user, groupIds, 'groups') &&
!hasAssignedMatch(user, parishIds, 'parishes')
) {
return 'Mindestens eine der ausgewählten Gruppen oder Gemeinden muss Ihnen zugewiesen sein.'
} }
return true return true
@ -369,6 +380,8 @@ export const Events: CollectionConfig = {
], ],
admin: { admin: {
useAsTitle: 'title', useAsTitle: 'title',
hidden: hideUnlessAssignedAny(['groups', 'parishes']),
baseFilter: filterEventsToAssigned(),
livePreview: { livePreview: {
url: ({ data }) => `/api/draft?url=/veranstaltungen/${data.id}`, url: ({ data }) => `/api/draft?url=/veranstaltungen/${data.id}`,
}, },
@ -380,26 +393,9 @@ export const Events: CollectionConfig = {
}, },
access: { access: {
read: isPublishedPublic(), read: isPublishedPublic(),
// admins and employees can delete, others only if they are member of the group create: canCreateAssignedEvent(),
delete: async ({ req: { user }, id }) => { update: canMutateAssignedEvent(),
if (!user) { delete: canMutateAssignedEvent(),
return false
}
if(user.roles === 'admin' || user.roles === 'employee')
return true
if(typeof id !== 'string') {
return false
}
const event = await fetchEventById(id)
if (hasGroup(user, event)) {
return true
}
return false
},
}, },
hooks: { hooks: {
afterChange: [ afterChange: [
@ -450,29 +446,3 @@ export const Events: CollectionConfig = {
}, },
} }
/**
* Check if we have
* - a user
* - data with groups
* - the user is member of one of the groups
*
* @param user
* @param data
*/
const hasGroup = (user: null | User , data: Partial<any> | undefined) => {
return user
&& user.roles === 'user'
&& data
&& Array.isArray(data.group)
&& data.group.length > 0
&& data.group.some((group: string | Group) => {
if (!Array.isArray(user.groups)) {
return false;
}
if (typeof group === "string")
return user.groups.includes(group)
else
return user.groups.includes(group.id)
})
}

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from 'payload' import { CollectionConfig } from 'payload'
import { isAdminOrEmployee } from '@/collections/access/admin' import { isAdminOrEmployee } from '@/collections/access/admin'
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
import { ParagraphBlock } from '@/collections/blocks/Paragraph' import { ParagraphBlock } from '@/collections/blocks/Paragraph'
import { GalleryBlock } from '@/collections/blocks/Gallery' import { GalleryBlock } from '@/collections/blocks/Gallery'
import { ContactformBlock } from '@/collections/blocks/Contactform' import { ContactformBlock } from '@/collections/blocks/Contactform'
@ -105,6 +106,8 @@ export const Groups: CollectionConfig = {
}, },
admin: { admin: {
useAsTitle: 'name', useAsTitle: 'name',
hidden: hideUnlessAssigned('groups'),
baseFilter: filterToAssigned('groups'),
livePreview: { livePreview: {
url: ({ data }) => `/api/draft?url=/gruppe/${data.slug}`, url: ({ data }) => `/api/draft?url=/gruppe/${data.slug}`,
}, },
@ -117,24 +120,7 @@ export const Groups: CollectionConfig = {
access: { access: {
read: isPublishedPublic(), read: isPublishedPublic(),
create: isAdminOrEmployee(), create: isAdminOrEmployee(),
update: ({ req, id }) => { update: isStaffOrAssigned('groups'),
if (!req.user) {
return false
}
if (
req.user.roles === 'user' &&
id &&
req.user.groups?.find(
(group) =>
group === id || (typeof group === 'object' && group.id === id),
) === undefined
) {
return false
}
return true
},
delete: isAdminOrEmployee(), delete: isAdminOrEmployee(),
}, },
} }

View file

@ -1,6 +1,7 @@
import { CollectionConfig } from 'payload' import { CollectionConfig } from 'payload'
import { revalidateTag } from 'next/cache' import { revalidateTag } from 'next/cache'
import { hide, isAdminOrEmployee } from '@/collections/access/admin' import { isAdminOrEmployee } from '@/collections/access/admin'
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
import { ParagraphBlock } from '@/collections/blocks/Paragraph' import { ParagraphBlock } from '@/collections/blocks/Paragraph'
import { DocumentBlock } from '@/collections/blocks/Document' import { DocumentBlock } from '@/collections/blocks/Document'
import { ContactformBlock } from '@/collections/blocks/Contactform' import { ContactformBlock } from '@/collections/blocks/Contactform'
@ -119,7 +120,8 @@ export const Pages: CollectionConfig = {
], ],
admin: { admin: {
useAsTitle: 'title', useAsTitle: 'title',
hidden: hide, hidden: hideUnlessAssigned('pages'),
baseFilter: filterToAssigned('pages'),
livePreview: { livePreview: {
url: ({ data }) => `/api/draft?url=/${data.slug}`, url: ({ data }) => `/api/draft?url=/${data.slug}`,
}, },
@ -132,7 +134,7 @@ export const Pages: CollectionConfig = {
access: { access: {
read: isPublishedPublic(), read: isPublishedPublic(),
create: isAdminOrEmployee(), create: isAdminOrEmployee(),
update: isAdminOrEmployee(), update: isStaffOrAssigned('pages'),
delete: isAdminOrEmployee(), delete: isAdminOrEmployee(),
}, },
hooks: { hooks: {

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from 'payload' import { CollectionConfig } from 'payload'
import { hide, isAdmin, isAdminOrEmployee } from '@/collections/access/admin' import { isAdmin } from '@/collections/access/admin'
import { filterToAssigned, hideUnlessAssigned, isStaffOrAssigned } from '@/collections/access/assigned'
import { ParagraphBlock } from '@/collections/blocks/Paragraph' import { ParagraphBlock } from '@/collections/blocks/Paragraph'
import { DocumentBlock } from '@/collections/blocks/Document' import { DocumentBlock } from '@/collections/blocks/Document'
import { DonationBlock } from '@/collections/blocks/Donation' import { DonationBlock } from '@/collections/blocks/Donation'
@ -181,7 +182,8 @@ export const Parish: CollectionConfig = {
], ],
admin: { admin: {
useAsTitle: 'name', useAsTitle: 'name',
hidden: hide, hidden: hideUnlessAssigned('parishes'),
baseFilter: filterToAssigned('parishes'),
livePreview: { livePreview: {
url: ({ data }) => `/api/draft?url=/gemeinde/${data.slug}`, url: ({ data }) => `/api/draft?url=/gemeinde/${data.slug}`,
}, },
@ -194,7 +196,7 @@ export const Parish: CollectionConfig = {
access: { access: {
read: isPublishedPublic(), read: isPublishedPublic(),
create: isAdmin(), create: isAdmin(),
update: isAdminOrEmployee(), update: isStaffOrAssigned('parishes'),
delete: isAdmin(), delete: isAdmin(),
}, },
} }

View file

@ -63,13 +63,33 @@ export const Users: CollectionConfig = {
{ {
name: 'groups', name: 'groups',
label: { label: {
de: 'Mitgliedschaft', de: 'Gruppen (Bearbeitungsrechte)',
}, },
type: 'relationship', type: 'relationship',
relationTo: 'group', relationTo: 'group',
hasMany: true, hasMany: true,
maxDepth: 0, maxDepth: 0,
}, },
{
name: 'parishes',
label: {
de: 'Gemeinden (Bearbeitungsrechte)',
},
type: 'relationship',
relationTo: 'parish',
hasMany: true,
maxDepth: 0,
},
{
name: 'pages',
label: {
de: 'Seiten (Bearbeitungsrechte)',
},
type: 'relationship',
relationTo: 'pages',
hasMany: true,
maxDepth: 0,
},
], ],
access: { access: {
read: isAdminOrEmployee(), read: isAdminOrEmployee(),

View file

@ -1,5 +1,11 @@
import { CollectionConfig } from 'payload' import { CollectionConfig } from 'payload'
import { hide, isAdminOrEmployee } from '@/collections/access/admin' import {
canCreateAssignedWorship,
canMutateAssignedWorship,
filterWorshipToAssigned,
hideUnlessAssigned,
resolveAssignedChurchIds,
} from '@/collections/access/assigned'
export const Worship: CollectionConfig = { export const Worship: CollectionConfig = {
slug: 'worship', slug: 'worship',
@ -35,6 +41,17 @@ export const Worship: CollectionConfig = {
type: 'relationship', type: 'relationship',
relationTo: 'church', relationTo: 'church',
required: true, required: true,
// The update access Where only constrains which docs may be targeted;
// this keeps a Gläubiger from moving a worship doc to a foreign church.
validate: async (value: unknown, options: any) => {
const user = options.req?.user
if (!user || user.roles !== 'user') return true
const churchIds = await resolveAssignedChurchIds(options.req, user)
if (!value || !churchIds.includes(String(value))) {
return 'Sie sind nur berechtigt, Gottesdienste für Kirchen Ihrer zugewiesenen Gemeinden zu verwalten.'
}
return true
},
}, },
{ {
name: 'type', name: 'type',
@ -125,12 +142,13 @@ export const Worship: CollectionConfig = {
admin: { admin: {
defaultColumns: ["date", 'location', 'type', 'celebrant'], defaultColumns: ["date", 'location', 'type', 'celebrant'],
listSearchableFields: ['date', 'location'], listSearchableFields: ['date', 'location'],
hidden: hide hidden: hideUnlessAssigned('parishes'),
baseFilter: filterWorshipToAssigned(),
}, },
access: { access: {
read: () => true, read: () => true,
create: isAdminOrEmployee(), create: canCreateAssignedWorship(),
update: isAdminOrEmployee(), update: canMutateAssignedWorship(),
delete: isAdminOrEmployee(), delete: canMutateAssignedWorship(),
}, },
} }

View file

@ -0,0 +1,233 @@
import type { Access, BaseFilter, ClientUser, PayloadRequest, Where } from 'payload'
/**
* Assignment fields on the Users collection: each holds the ids of the
* documents a Gläubiger (role 'user') is allowed to edit.
*/
export type AssignmentField = 'groups' | 'parishes' | 'pages'
/**
* Normalize a user's assignment field to an array of id strings.
* The fields use maxDepth: 0 so values are plain ids, but handle
* populated objects defensively.
*/
export const getAssignedIds = (user: unknown, field: AssignmentField): string[] => {
const value = (user as { [K in AssignmentField]?: unknown } | null | undefined)?.[field]
return toIdArray(value)
}
/** Normalize any relationship value (single or hasMany, id or populated doc) to id strings. */
export const toIdArray = (value: unknown): string[] => {
if (value == null) return []
const arr = Array.isArray(value) ? value : [value]
return arr.map((v) =>
typeof v === 'object' && v !== null ? String((v as { id: unknown }).id) : String(v),
)
}
const isStaff = (user: { roles?: unknown } | null | undefined): boolean =>
user?.roles === 'admin' || user?.roles === 'employee'
/**
* Update access: admin/employee always; role 'user' only for documents
* they are assigned to. The collection-level probe (no id) is granted
* only if the user has at least one assignment.
*/
export const isStaffOrAssigned =
(field: AssignmentField): Access =>
({ req: { user }, id }) => {
if (!user) return false
if (user.roles === 'admin' || user.roles === 'employee') return true
if (user.roles !== 'user') return false
const ids = getAssignedIds(user, field)
if (ids.length === 0) return false
if (id === undefined) return true
return ids.includes(String(id))
}
/**
* Admin base filter: staff see everything (null = unfiltered); role 'user'
* sees only assigned documents. An empty id list matches nothing.
*/
export const filterToAssigned =
(field: AssignmentField): BaseFilter =>
({ req: { user } }) => {
if (!user || user.roles !== 'user') return null
return { id: { in: getAssignedIds(user, field) } }
}
/**
* admin.hidden: hide the collection from role 'user' unless they have at
* least one assignment; never hidden for staff.
*/
export const hideUnlessAssigned =
(field: AssignmentField) =>
({ user }: { user: ClientUser }): boolean => {
if (!user || user.roles !== 'user') return false
return getAssignedIds(user, field).length === 0
}
/**
* admin.hidden: hide the collection from role 'user' unless they have at
* least one assignment in ANY of the given fields.
*/
export const hideUnlessAssignedAny =
(fields: AssignmentField[]) =>
({ user }: { user: ClientUser }): boolean => {
if (!user || user.roles !== 'user') return false
return fields.every((field) => getAssignedIds(user, field).length === 0)
}
/** True if at least one of the given ids is in the user's assignment field. */
export const hasAssignedMatch = (user: unknown, ids: unknown, field: AssignmentField): boolean => {
const assigned = getAssignedIds(user, field)
return toIdArray(ids).some((id) => assigned.includes(id))
}
/**
* defaultValue for hasMany relationship fields: when a Gläubiger has exactly
* one assignment, pre-fill it on new documents. Staff get no pre-fill.
*/
export const defaultToOnlyAssigned =
(field: AssignmentField) =>
({ user }: { user: unknown }): string[] | undefined => {
if ((user as { roles?: unknown } | null)?.roles !== 'user') return undefined
const ids = getAssignedIds(user, field)
return ids.length === 1 ? ids : undefined
}
/**
* Ids in `value` that are neither carried over from `previousValue` nor among
* the user's own assignments i.e. foreign connections the user is trying to
* ADD. Existing foreign connections (set by staff) are not flagged.
*/
export const unassignedAdditions = (
user: unknown,
field: AssignmentField,
value: unknown,
previousValue: unknown,
): string[] => {
const assigned = getAssignedIds(user, field)
const previous = toIdArray(previousValue)
return toIdArray(value).filter((id) => !previous.includes(id) && !assigned.includes(id))
}
// ---------------------------------------------------------------------------
// Worship: a worship doc has no parish field — only `location` (church).
// A Gläubiger's scope is the union of churches of their assigned parishes.
// ---------------------------------------------------------------------------
/** Resolve the user's assigned parishes to the union of their church ids. */
export const resolveAssignedChurchIds = async (
req: PayloadRequest,
user: unknown,
): Promise<string[]> => {
const parishIds = getAssignedIds(user, 'parishes')
if (parishIds.length === 0) return []
const { docs } = await req.payload.find({
collection: 'parish',
where: { id: { in: parishIds } },
depth: 0,
pagination: false,
select: { churches: true },
req,
})
return [...new Set(docs.flatMap((doc) => toIdArray((doc as { churches?: unknown }).churches)))]
}
/**
* Worship create access. The admin UI probes create permission without data;
* the actual create always carries data, where `location` is enforced (and
* additionally validated on the field for a German error message).
*/
export const canCreateAssignedWorship = (): Access =>
async ({ req, data }) => {
const user = req.user
if (!user) return false
if (isStaff(user)) return true
if (user.roles !== 'user') return false
const churchIds = await resolveAssignedChurchIds(req, user)
if (churchIds.length === 0) return false
if (data?.location === undefined) return true
return churchIds.includes(String(data.location))
}
/** Worship update/delete access: staff always; role 'user' only within their churches. */
export const canMutateAssignedWorship = (): Access =>
async ({ req }) => {
const user = req.user
if (!user) return false
if (isStaff(user)) return true
if (user.roles !== 'user') return false
const churchIds = await resolveAssignedChurchIds(req, user)
if (churchIds.length === 0) return false
return { location: { in: churchIds } } as Where
}
/** Worship admin list filter: staff unfiltered; role 'user' only their churches. */
export const filterWorshipToAssigned = (): BaseFilter =>
async ({ req }) => {
const user = req.user
if (!user || user.roles !== 'user') return null
return { location: { in: await resolveAssignedChurchIds(req, user) } }
}
// ---------------------------------------------------------------------------
// Events: connected to groups AND/OR parishes; one own match grants access.
// ---------------------------------------------------------------------------
/**
* Where matching events with at least one assigned group OR parish.
* `prefix: 'event.'` targets the parent event from the occurrence collection.
* Returns false when the user has no assignments at all.
*/
export const eventMatchWhere = (user: unknown, prefix = ''): false | Where => {
const groupIds = getAssignedIds(user, 'groups')
const parishIds = getAssignedIds(user, 'parishes')
const or: Where[] = []
if (groupIds.length > 0) or.push({ [`${prefix}group`]: { in: groupIds } })
if (parishIds.length > 0) or.push({ [`${prefix}parish`]: { in: parishIds } })
if (or.length === 0) return false
return { or }
}
/**
* Event create access: staff always; role 'user' with at least one group or
* parish assignment. Which connections the new event must carry is enforced
* by the combined validate on the `group` field.
*/
export const canCreateAssignedEvent = (): Access =>
({ req: { user } }) => {
if (!user) return false
if (isStaff(user)) return true
if (user.roles !== 'user') return false
return (
getAssignedIds(user, 'groups').length > 0 || getAssignedIds(user, 'parishes').length > 0
)
}
/** Event update/delete access: staff always; role 'user' via one-match Where. */
export const canMutateAssignedEvent = (): Access =>
({ req: { user } }) => {
if (!user) return false
if (isStaff(user)) return true
if (user.roles !== 'user') return false
return eventMatchWhere(user)
}
/** Events admin list filter: staff unfiltered; role 'user' one-match filter. */
export const filterEventsToAssigned = (): BaseFilter =>
({ req: { user } }) => {
if (!user || user.roles !== 'user') return null
const where = eventMatchWhere(user)
return where === false ? { id: { in: [] } } : where
}
/** Occurrence update access: whoever may edit the parent event may toggle its occurrences. */
export const canMutateOccurrenceOfAssignedEvent = (): Access =>
({ req: { user } }) => {
if (!user) return false
if (isStaff(user)) return true
if (user.roles !== 'user') return false
return eventMatchWhere(user, 'event.')
}

View file

@ -4,7 +4,7 @@
position: relative; position: relative;
height: 634px; height: 634px;
background-color: $shade1; background-color: $shade1;
opacity: 0.7; opacity: 1;
overflow: hidden; overflow: hidden;
} }
@ -14,7 +14,6 @@
.logo { .logo {
position: absolute; position: absolute;
z-index: 1;
bottom: 20px; bottom: 20px;
left: 30px; left: 30px;
} }
@ -24,7 +23,6 @@
color: $white; color: $white;
font-family: var(--header-font); font-family: var(--header-font);
position: absolute; position: absolute;
z-index: 1;
bottom: 50px; bottom: 50px;
right: 0; right: 0;
width: 50vw; width: 50vw;

View file

@ -29,6 +29,17 @@ export const EventWithEnd: Story = {
}, },
} }
export const MultiDayEvent: Story = {
args: {
date: '2026-07-24T18:00:00+02:00',
end: '2026-07-26T14:00:00+02:00',
title: 'Gemeindewochenende',
href: 'https://www.link_to_event.com',
location: "St. Clara",
cancelled: false,
},
}
export const EventInMarch: Story = { export const EventInMarch: Story = {
args: { args: {
date: '2024-03-24T15:00:00+01:00', date: '2024-03-24T15:00:00+01:00',

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import styles from "./styles.module.scss" import styles from "./styles.module.scss"
import classNames from 'classnames' import classNames from 'classnames'
import Link from 'next/link' import Link from 'next/link'
import { formatEventDate } from '@/utils/formatEventDate'
export type EventRowProps = { export type EventRowProps = {
/** datetime 8601 format */ /** datetime 8601 format */
@ -41,8 +42,6 @@ const shortMonth = (date: string) => {
return months[month - 1]; return months[month - 1];
} }
export const EventRow = ({date, end, title, location, cancelled, href, color = "base", showDate = true}: EventRowProps) => { export const EventRow = ({date, end, title, location, cancelled, href, color = "base", showDate = true}: EventRowProps) => {
const day = useMemo(() => date.substring(8, 10), [date]); const day = useMemo(() => date.substring(8, 10), [date]);
const dateObj = useMemo(() => new Date(date), [date]); const dateObj = useMemo(() => new Date(date), [date]);
@ -80,10 +79,7 @@ export const EventRow = ({date, end, title, location, cancelled, href, color = "
<span className={classNames({ [styles.cancelled]: cancelled })}> <span className={classNames({ [styles.cancelled]: cancelled })}>
{ showDate && { showDate &&
<> <>
{dateObj.toLocaleDateString("de-DE", { weekday: "long" })} {formatEventDate(dateObj, endObj, dayFormat)}
{dayFormat === "long" && " " + dateObj.toLocaleDateString("de-DE", { dateStyle: "short" })}, {dateObj.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" })}
{ endObj ? <> - {endObj.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" })}</> : "" }
&nbsp;Uhr
<br /> <br />
</> </>
} }

View file

@ -14,3 +14,9 @@ export const Default: Story = {
'Wie die drei Weisen aus dem Morgenland wollen wir uns immer wieder neu auf den Weg machen.', 'Wie die drei Weisen aus dem Morgenland wollen wir uns immer wieder neu auf den Weg machen.',
}, },
} }
export const Default2: Story = {
args: {
text: 'Herzlich willkommen auf der Homepage der Pfarrei Heilige Mutter Teresa. Als katholische Christen bringen wir uns in ökumenischer Verbundenheit als kreative Minderheit in Chemnitz und Umgebung ein.',
},
}

View file

@ -2,6 +2,7 @@
line-height: 168%; line-height: 168%;
font-size: 36px; font-size: 36px;
text-align: center; text-align: center;
text-wrap: balance;
} }
@media screen and (max-width: 576px) { @media screen and (max-width: 576px) {

View file

@ -5,8 +5,9 @@ import config from '@/payload.config'
* Fetches blog posts based on given criteria. * Fetches blog posts based on given criteria.
* *
* @param {boolean} displayOnFrontpage - Indicates whether to display posts on the front page. * @param {boolean} displayOnFrontpage - Indicates whether to display posts on the front page.
* @param {number} page - Page number for pagination.
*/ */
export const fetchBlogPosts = async (displayOnFrontpage: boolean) => { export const fetchBlogPosts = async (displayOnFrontpage: boolean, page: number = 1) => {
const today = new Date() const today = new Date()
today.setHours(23, 59) today.setHours(23, 59)
@ -59,15 +60,17 @@ export const fetchBlogPosts = async (displayOnFrontpage: boolean) => {
const payload = await getPayload({ config }) const payload = await getPayload({ config })
return payload.find({ return payload.find({
collection: 'blog', collection: 'blog',
sort: '-date', sort: ['-pinned', '-createdAt'],
select: { select: {
title: true, title: true,
date: true, date: true,
photo: true, photo: true,
pinned: true,
content: displayOnFrontpage ? undefined : true, content: displayOnFrontpage ? undefined : true,
}, },
where: query, where: query,
limit: 18, limit: 18,
page,
}) })
} }

View file

@ -43,7 +43,15 @@ const getUpcomingOccurrences = unstable_cache(
const query: any = { const query: any = {
and: [ and: [
{ date: { greater_than_equal: fromDate } }, // Overlap semantics: an occurrence stays "upcoming" while it is still
// running, not only before it starts. endDateTime is null for
// occurrences without an end; those fall back to the start date.
{
or: [
{ date: { greater_than_equal: fromDate } },
{ endDateTime: { greater_than_equal: fromDate } },
],
},
{ 'event._status': { equals: 'published' } }, { 'event._status': { equals: 'published' } },
], ],
} }

View file

@ -1,87 +1,7 @@
import { getPayload, PaginatedDocs } from 'payload' import { getPayload } from 'payload'
import config from '@/payload.config' import config from '@/payload.config'
import { Event } from '@/payload-types' import { Event } from '@/payload-types'
type Args = {
parishId?: string
groupId?: string
limit?: number
page?: number
fromDate?: Date
toDate?: Date
}
/**
* Fetch a list of events
*/
export async function fetchEvents(
args?: Args,
): Promise<PaginatedDocs<Event>> {
const {
parishId,
groupId,
limit = 30,
page = 0,
fromDate = new Date(),
toDate,
} = args || {}
const query: any = {
and: [
{
'_status': {
equals: 'published',
}
},
{
date: {
greater_than_equal: fromDate.toISOString(),
},
},
],
}
if (toDate) {
query.and.push({
date: {
less_than: toDate.toISOString(),
},
})
}
if (parishId) {
query.and.push({
parish: {
equals: parishId,
},
})
}
if (groupId) {
query.and.push({
group: {
equals: groupId,
},
})
}
const payload = await getPayload({ config })
return payload.find({
collection: 'event',
sort: 'date',
where: query,
select: {
location: true,
date: true,
title: true,
cancelled: true,
},
depth: 1,
limit,
page,
}) as Promise<PaginatedDocs<Event>>
}
/** /**
* Fetch a single event by ID * Fetch a single event by ID
*/ */

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.028Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.325Z';
ALTER TABLE "blog_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "_blog_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-08-15T09:00:32.384Z';
ALTER TABLE "group_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "_group_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "pages_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "_pages_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'chemnitz@pfarrei-bddmei.de';
ALTER TABLE "users_rels" ADD COLUMN "parish_id" uuid;
ALTER TABLE "users_rels" ADD COLUMN "pages_id" uuid;
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parish_fk" FOREIGN KEY ("parish_id") REFERENCES "public"."parish"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_rels_parish_id_idx" ON "users_rels" USING btree ("parish_id");
CREATE INDEX "users_rels_pages_id_idx" ON "users_rels" USING btree ("pages_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "users_rels" DROP CONSTRAINT "users_rels_parish_fk";
ALTER TABLE "users_rels" DROP CONSTRAINT "users_rels_pages_fk";
DROP INDEX "users_rels_parish_id_idx";
DROP INDEX "users_rels_pages_id_idx";
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-14T08:49:19.434Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-14T08:49:19.726Z';
ALTER TABLE "blog_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "_blog_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-11T08:49:19.782Z';
ALTER TABLE "group_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "_group_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "pages_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "_pages_v_blocks_contactform" ALTER COLUMN "email" SET DEFAULT 'kontakt@mutter-teresa-chemnitz.de';
ALTER TABLE "users_rels" DROP COLUMN "parish_id";
ALTER TABLE "users_rels" DROP COLUMN "pages_id";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-07-19T11:32:04.857Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-07-19T11:32:05.134Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-08-15T11:32:05.193Z';
ALTER TABLE "blog" ADD COLUMN "pinned" boolean DEFAULT false;
ALTER TABLE "_blog_v" ADD COLUMN "version_pinned" boolean DEFAULT false;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.028Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-07-19T09:00:32.325Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-08-15T09:00:32.384Z';
ALTER TABLE "blog" DROP COLUMN "pinned";
ALTER TABLE "_blog_v" DROP COLUMN "version_pinned";`)
}

View file

@ -51,6 +51,8 @@ import * as migration_20260605_130843_add_worship_language_other from './2026060
import * as migration_20260609_113259_parish_gallery_caption from './20260609_113259_parish_gallery_caption'; import * as migration_20260609_113259_parish_gallery_caption from './20260609_113259_parish_gallery_caption';
import * as migration_20260611_072650_add_contact_person_to_group from './20260611_072650_add_contact_person_to_group'; import * as migration_20260611_072650_add_contact_person_to_group from './20260611_072650_add_contact_person_to_group';
import * as migration_20260611_084920_add_image_with_text_block from './20260611_084920_add_image_with_text_block'; import * as migration_20260611_084920_add_image_with_text_block from './20260611_084920_add_image_with_text_block';
import * as migration_20260716_090032_add_user_parish_page_assignments from './20260716_090032_add_user_parish_page_assignments';
import * as migration_20260716_113205_add_blog_pinned from './20260716_113205_add_blog_pinned';
export const migrations = [ export const migrations = [
{ {
@ -316,6 +318,16 @@ export const migrations = [
{ {
up: migration_20260611_084920_add_image_with_text_block.up, up: migration_20260611_084920_add_image_with_text_block.up,
down: migration_20260611_084920_add_image_with_text_block.down, down: migration_20260611_084920_add_image_with_text_block.down,
name: '20260611_084920_add_image_with_text_block' name: '20260611_084920_add_image_with_text_block',
},
{
up: migration_20260716_090032_add_user_parish_page_assignments.up,
down: migration_20260716_090032_add_user_parish_page_assignments.down,
name: '20260716_090032_add_user_parish_page_assignments',
},
{
up: migration_20260716_113205_add_blog_pinned.up,
down: migration_20260716_113205_add_blog_pinned.down,
name: '20260716_113205_add_blog_pinned'
}, },
]; ];

View file

@ -123,4 +123,11 @@ export const WithOccurrences: Story = {
}, },
], ],
}, },
}
export const WithOccurrencesAndGroup: Story = {
args: {
...WithOccurrences.args,
group: 'some_group',
},
} }

View file

@ -6,7 +6,7 @@ import { Container } from '@/components/Container/Container'
import { Col } from '@/components/Flex/Col' import { Col } from '@/components/Flex/Col'
import { Pill } from '@/components/Pill/Pill' import { Pill } from '@/components/Pill/Pill'
import { useDate } from '@/hooks/useCompactDate' import { useDate } from '@/hooks/useCompactDate'
import { readableDateTime } from '@/utils/readableDate' import { formatEventDate } from '@/utils/formatEventDate'
import { TextDiv } from '@/components/Text/TextDiv' import { TextDiv } from '@/components/Text/TextDiv'
import { Button } from '@/components/Button/Button' import { Button } from '@/components/Button/Button'
import Image, { StaticImageData } from 'next/image' import Image, { StaticImageData } from 'next/image'
@ -72,7 +72,7 @@ export function EventPage(
}: EventProps }: EventProps
) { ) {
const published = useDate(createdAt) const published = useDate(createdAt)
const readableDate = readableDateTime(date, endDateTime) const readableDate = formatEventDate(new Date(date), endDateTime ? new Date(endDateTime) : undefined, "long")
const where = locationString(location); const where = locationString(location);
const contactPersonPhoto = typeof contact === "object" ? getPhoto("thumbnail", contact.photo) : undefined; const contactPersonPhoto = typeof contact === "object" ? getPhoto("thumbnail", contact.photo) : undefined;
const isRecurring = recurrenceType && recurrenceType !== 'none' const isRecurring = recurrenceType && recurrenceType !== 'none'
@ -93,12 +93,13 @@ export function EventPage(
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerText}> <div className={styles.headerText}>
<p>
{shortDescription} <div className={styles.published}>
</p>
<p className={styles.published}>
Publiziert am {published} Publiziert am {published}
</p> </div>
<div>
{shortDescription}
</div>
<div className={styles.pills}> <div className={styles.pills}>
{ isRecurring && { isRecurring &&

View file

@ -14,6 +14,7 @@
} }
.pills { .pills {
margin-top: 10px;
display: flex; display: flex;
gap: 10px; gap: 10px;
} }

View file

@ -1068,6 +1068,10 @@ export interface Blog {
id: string; id: string;
photo?: (string | null) | Media; photo?: (string | null) | Media;
title: string; title: string;
/**
* Angepinnte Beiträge werden im Blog und auf der Startseite zuerst angezeigt.
*/
pinned: boolean;
content: { content: {
excerpt: string; excerpt: string;
content: ( content: (
@ -1356,6 +1360,8 @@ export interface User {
name: string; name: string;
roles: 'user' | 'employee' | 'admin'; roles: 'user' | 'employee' | 'admin';
groups?: (string | Group)[] | null; groups?: (string | Group)[] | null;
parishes?: (string | Parish)[] | null;
pages?: (string | Page)[] | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@ -1375,7 +1381,7 @@ export interface User {
password?: string | null; password?: string | null;
} }
/** /**
* This is a collection of automatically created search results. These results are used by the global site search and will be updated automatically as documents in the CMS are created or updated. * Automatisch erzeugte Suchergebnisse. Sie werden von der Website-Suche verwendet und aktualisieren sich selbst, sobald Inhalte erstellt oder geändert werden.
* *
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "search". * via the `definition` "search".
@ -1884,6 +1890,7 @@ export interface CalendarSelect<T extends boolean = true> {
export interface BlogSelect<T extends boolean = true> { export interface BlogSelect<T extends boolean = true> {
photo?: T; photo?: T;
title?: T; title?: T;
pinned?: T;
content?: content?:
| T | T
| { | {
@ -2607,6 +2614,8 @@ export interface UsersSelect<T extends boolean = true> {
name?: T; name?: T;
roles?: T; roles?: T;
groups?: T; groups?: T;
parishes?: T;
pages?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
email?: T; email?: T;

View file

@ -47,6 +47,7 @@ import { siteConfig } from '@/config/site'
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses' import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences' import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences'
import { searchPlugin } from '@payloadcms/plugin-search' import { searchPlugin } from '@payloadcms/plugin-search'
import { hide } from '@/collections/access/admin'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@ -226,6 +227,20 @@ export default buildConfig({
// read each referenced document's `slug` to build URLs like // read each referenced document's `slug` to build URLs like
// /gemeinde/[slug] and /gruppe/[slug]. // /gemeinde/[slug] and /gruppe/[slug].
searchOverrides: { searchOverrides: {
labels: {
singular: {
de: 'Suchergebnis',
},
plural: {
de: 'Suchergebnisse',
},
},
admin: {
hidden: hide,
description: {
de: 'Automatisch erzeugte Suchergebnisse. Sie werden von der Website-Suche verwendet und aktualisieren sich selbst, sobald Inhalte erstellt oder geändert werden.',
},
},
fields: ({ defaultFields }) => fields: ({ defaultFields }) =>
defaultFields.map((field) => defaultFields.map((field) =>
'name' in field && field.name === 'doc' && field.type === 'relationship' 'name' in field && field.name === 'doc' && field.type === 'relationship'

View file

@ -0,0 +1,52 @@
import { describe, expect, test } from 'vitest'
import { formatEventDate } from './formatEventDate'
// Fri 24.07.2026 18:00 Berlin time
const start = new Date('2026-07-24T18:00:00+02:00')
const endSameDay = new Date('2026-07-24T20:00:00+02:00')
const endNextDay = new Date('2026-07-25T01:00:00+02:00')
const endSunday = new Date('2026-07-26T14:00:00+02:00')
describe('format "long"', () => {
test('start only', () => {
expect(formatEventDate(start)).toBe(`Freitag 24.07.26, 18:00 Uhr`)
})
test('same-day end renders as time range', () => {
expect(formatEventDate(start, endSameDay)).toBe(`Freitag 24.07.26, 18:00 - 20:00 Uhr`)
})
test('multi-day end renders both dates', () => {
expect(formatEventDate(start, endSunday)).toBe(
`Fr. 24.07. 18:00 Uhr - So. 26.07. 14:00 Uhr`,
)
})
test('end after midnight counts as multi-day', () => {
expect(formatEventDate(start, endNextDay)).toBe(
`Fr. 24.07. 18:00 Uhr - Sa. 25.07. 01:00 Uhr`,
)
})
})
describe('format "short"', () => {
test('start only', () => {
expect(formatEventDate(start, undefined, 'short')).toBe(`Freitag, 18:00 Uhr`)
})
test('same-day end renders as time range without date', () => {
expect(formatEventDate(start, endSameDay, 'short')).toBe(`Freitag, 18:00 - 20:00 Uhr`)
})
test('multi-day end renders compact weekday range', () => {
expect(formatEventDate(start, endSunday, 'short')).toBe(`Fr 18:00 - So 14:00 Uhr`)
})
})
describe('timezone handling', () => {
test('calendar day is compared in Europe/Berlin, not UTC', () => {
const lateStart = new Date('2026-07-24T22:30:00Z')
const earlyEnd = new Date('2026-07-25T01:00:00Z')
expect(formatEventDate(lateStart, earlyEnd)).toBe(`Samstag 25.07.26, 00:30 - 03:00 Uhr`)
})
})

View file

@ -0,0 +1,39 @@
/**
* Format an event's date and time range in a user-friendly way.
*
* format "long" (default):
* - same day: "Freitag 24.07.26, 18:00 - 20:00 Uhr"
* - multi-day: "Fr. 24.07. 18:00 Uhr - So. 26.07. 14:00 Uhr"
*
* format "short" (narrow screens):
* - same day: "Freitag, 18:00 - 20:00 Uhr"
* - multi-day: "Fr 18:00 - So 14:00 Uhr"
*/
export const formatEventDate = (start: Date, end?: Date, format: "long" | "short" = "long"): string => {
const time = (d: Date) => d.toLocaleTimeString("de-DE", { timeStyle: "short", timeZone: "Europe/Berlin" })
const calendarDay = (d: Date) => d.toLocaleDateString("de-DE", { timeZone: "Europe/Berlin" })
const weekday = (d: Date, length: "long" | "short") =>
d.toLocaleDateString("de-DE", { weekday: length, timeZone: "Europe/Berlin" })
const isMultiDay = end && calendarDay(start) !== calendarDay(end)
switch (format) {
case "short": {
if (end && isMultiDay) {
return `${weekday(start, "short")} ${time(start)} - ${weekday(end, "short")} ${time(end)} Uhr`
}
const endTime = end ? ` - ${time(end)}` : ""
return `${weekday(start, "long")}, ${time(start)}${endTime} Uhr`
}
case "long": {
if (end && isMultiDay) {
// "Fr. 24.07." — assembled manually because Intl puts a comma between weekday and date
const dayDate = (d: Date) =>
`${weekday(d, "short")}. ${d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", timeZone: "Europe/Berlin" })}`
return `${dayDate(start)} ${time(start)} Uhr - ${dayDate(end)} ${time(end)} Uhr`
}
const endTime = end ? ` - ${time(end)}` : ""
return `${weekday(start, "long")} ${start.toLocaleDateString("de-DE", { dateStyle: "short", timeZone: "Europe/Berlin" })}, ${time(start)}${endTime} Uhr`
}
}
}