Compare commits

...

4 commits

Author SHA1 Message Date
Benno Tielen
e96d04c317 fix: pdf in new tab
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-06-24 07:52:49 +02:00
Benno Tielen
42d5f92b2b fix: padding 2026-06-24 07:46:38 +02:00
Benno Tielen
498d310c72 fix: display full menu on mobile 2026-06-24 07:44:54 +02:00
Benno Tielen
2766dafd32 fix: recurring masses 2026-06-24 07:32:07 +02:00
9 changed files with 246 additions and 105 deletions

View file

@ -255,6 +255,41 @@ The Forgejo Actions workflow (`.forgejo/workflows/deploy.yml`) triggers on push
---
## Countdown Static Site (Caddy)
`deploy-countdown.yml` provisions a **fresh standalone VPS** that serves only the
static `countdown.html` (repo root). It installs Caddy, uploads the page, and lets
Caddy obtain + auto-renew the TLS certificate for `hl-mutter-teresa-chemnitz.de`
and `www.hl-mutter-teresa-chemnitz.de`.
```bash
cd infra/ansible
# 1. Point the inventory at the VPS and confirm the domains
# edit inventory/countdown/hosts.yml -> ansible_host, countdown_domain, countdown_aliases
# 2. Make sure DNS A/AAAA records for both names point at the VPS first
# (Caddy's ACME challenge needs them resolving), then run with the root
# password (--ask-pass needs `sshpass` installed locally):
ansible-playbook playbooks/deploy-countdown.yml -i inventory/countdown/hosts.yml --ask-pass
```
> Authenticating with the root password? Install `sshpass` first
> (`brew install hudochenkov/sshpass/sshpass` on macOS, `apt install sshpass` on
> Debian/Ubuntu). Once you've added an SSH key to the box you can drop
> `--ask-pass` and set `ansible_ssh_private_key_file` in the inventory instead.
**What it does:**
1. Installs Caddy + ufw (allows 22/80/443)
2. Uploads `countdown.html` to `/var/www/countdown/index.html`
3. Deploys a Caddyfile serving both domains; Caddy handles HTTPS automatically
**Re-running** is the normal way to publish content changes — it re-uploads the
HTML and reloads Caddy. The certificate is managed entirely by Caddy.
---
## Production Setup
1. Copy and edit the production inventory:

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from 'payload'
import { hide, isAdminOrEmployee } from '@/collections/access/admin'
import { regenerateMassesForChurch } from '@/jobs/generateRecurringMasses'
export const Churches: CollectionConfig = {
slug: 'church',
@ -214,16 +215,23 @@ export const Churches: CollectionConfig = {
hooks: {
afterChange: [
async ({ doc, req }) => {
if (!doc.recurringSchedule?.length) return
try {
await req.payload.jobs.queue({
task: 'generateRecurringMasses',
input: { churchId: doc.id },
// Materialize Worship docs synchronously so they appear right
// after saving — same approach as Events/eventOccurrence. This
// wipes the church's future auto-generated masses and rebuilds
// them from the current schedule, so changing (or clearing) the
// schedule never leaves stale/double masses behind. The weekly
// job (generateRecurringMasses) still backfills the rolling
// window across all churches via its cron schedule.
await regenerateMassesForChurch({
church: doc,
payload: req.payload,
req,
})
} catch (err) {
req.payload.logger.error(
{ err, churchId: doc.id },
'Failed to queue generateRecurringMasses job',
'Failed to regenerate recurring masses',
)
}
},

View file

@ -24,7 +24,12 @@ type ItemGroupProps = {
const Item = ({href, title, description, icon, onClick}: ItemProps) => {
return (
<Link href={href} className={styles.item} onClick={onClick}>
<Link
href={href}
className={styles.item}
onClick={onClick}
target={href.includes("/api") ? "_blank" : undefined}
>
{icon &&
<div className={styles.itemIcon}>
</div>

View file

@ -87,7 +87,7 @@
}
.groupTitle {
margin: 0;
margin: 10px 0;
color: $dark-text;
}
@ -103,7 +103,7 @@
.item {
flex: 1 1 40%;
height: 100px;
height: 80px;
box-sizing: border-box;
padding: 10px;
background-color: $shade3;

View file

@ -71,6 +71,7 @@ const MegaMenuItem = ({text, quote, source, groups, onItemClick}: MegaMenuItemPr
<Link
href={""}
className={styles.menuLink}
onClick={() => setIsActive(!isActive)}
>
{text} <CollapsibleArrow direction={isActive ? "UP" : "DOWN"} stroke={1.5} />
</Link>

View file

@ -77,7 +77,7 @@
}
.megaMenuActive {
max-height: 1000px;
max-height: 800px;
}
.search {
@ -131,6 +131,10 @@
position: inherit;
}
.megaMenuActive {
max-height: 1500px;
}
.navMobile {
display: flex;
align-items: center;

View file

@ -1,4 +1,5 @@
import type { TaskConfig } from 'payload'
import type { PayloadRequest, TaskConfig } from 'payload'
import type { Payload } from 'payload'
import {
combineDateAndTime,
@ -18,8 +19,9 @@ import {
* documents and can be edited by hand (cancel, change celebrant, etc.).
*
* Triggers:
* - `afterChange` hook on Church (see collections/Churches.ts) queues
* a run whenever a schedule is saved
* - `afterChange` hook on Church (see collections/Churches.ts) calls
* `regenerateMassesForChurch` directly so generated Worship docs appear
* immediately on save same pattern as Events/eventOccurrence.
* - Own `schedule` entry below runs weekly to keep the rolling window
* populated
* - Manual: call `payload.jobs.queue({ task: 'generateRecurringMasses' })`
@ -27,9 +29,17 @@ import {
// How far into the future we materialize documents on each run.
// The weekly cron keeps this rolling window populated.
const DEFAULT_WEEKS_AHEAD = 2
const DEFAULT_WEEKS_AHEAD = 4
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000
// Most recent Monday at local midnight (Monday itself if `d` is a Monday).
const startOfLastMonday = (d: Date): Date => {
const date = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const daysSinceMonday = (date.getDay() + 6) % 7 // 0=Sun..6=Sat → Mon=0
date.setDate(date.getDate() - daysSinceMonday)
return date
}
type GenerateRecurringMassesInput = {
weeksAhead?: number
churchId?: string
@ -37,9 +47,161 @@ type GenerateRecurringMassesInput = {
type GenerateRecurringMassesOutput = {
created: number
deleted: number
skipped: number
}
type ChurchLike = {
id: string
recurringSchedule?: unknown[] | null
}
/**
* Materializes a single Church's `recurringSchedule` into real `Worship`
* documents for the rolling [last Monday, horizon] window.
*
* Wipe-and-regenerate: every future auto-generated (`generated: true`)
* Worship doc for this church is deleted and rebuilt from the current
* schedule, so a changed/cleared schedule never leaves stale or double
* masses behind. Hand-created Worship docs are never touched. Manual
* cancellations on generated docs are snapshotted by date and re-applied
* so they survive the regen (mirrors Events/eventOccurrence).
*
* Exposed both as a direct function (called from the Church `afterChange`
* hook so edits reflect immediately) and via the Jobs Queue task below
* (weekly cron backfill across all churches).
*/
export const regenerateMassesForChurch = async ({
church,
payload,
req,
weeksAhead = DEFAULT_WEEKS_AHEAD,
now = new Date(),
}: {
church: ChurchLike
payload: Payload
req?: PayloadRequest
weeksAhead?: number
now?: Date
}): Promise<GenerateRecurringMassesOutput> => {
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
// Start the window at the most recent Monday (local midnight) rather than
// `now`, so masses earlier in the current week are still materialized.
const windowStart = startOfLastMonday(now)
let created = 0
let skipped = 0
// Snapshot manual cancellations on generated docs so they survive the
// wipe/regen. Matched by ISO timestamp because regen rebuilds the same
// (church, date) slots from the schedule.
const existing = await payload.find({
collection: 'worship',
where: {
and: [
{ location: { equals: church.id } },
{ generated: { equals: true } },
{ date: { greater_than_equal: windowStart.toISOString() } },
],
},
depth: 0,
limit: 1000,
pagination: false,
req,
})
const cancelledDates = new Set(
existing.docs
.filter((doc) => doc.cancelled === true && typeof doc.date === 'string')
.map((doc) => new Date(doc.date as string).toISOString()),
)
// Wipe future auto-generated docs, then regenerate from the current
// schedule. This is what keeps a changed schedule from leaving stale
// (double) masses behind. Hand-created Worship docs (generated !== true)
// are never touched.
const wipe = await payload.delete({
collection: 'worship',
where: {
and: [
{ location: { equals: church.id } },
{ generated: { equals: true } },
{ date: { greater_than_equal: windowStart.toISOString() } },
],
},
req,
})
const deleted = wipe.docs.length
const schedule = church.recurringSchedule
if (!Array.isArray(schedule) || schedule.length === 0) {
return { created, deleted, skipped }
}
for (const rawEntry of schedule) {
const entry = rawEntry as ScheduleEntry & {
time?: string | Date | null
type?: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER'
defaultCelebrant?: string | null
defaultTitle?: string | null
defaultDescription?: string | null
}
// Guard: required fields may be missing if an editor saved a
// half-filled row. Skip rather than crash the whole run.
if (!entry.time || !entry.type) {
skipped += 1
continue
}
// Resolve the entry's recurrence pattern (weekly / biweekly /
// monthly Nth weekday) into concrete calendar dates in the
// [now, horizon] window. Returns dates at midnight only — we
// combine with the time-of-day below.
const occurrenceDates = generateOccurrenceDates(entry, windowStart, horizon)
if (occurrenceDates.length === 0) {
skipped += 1
continue
}
const timeSource = new Date(entry.time)
for (const occurrenceDate of occurrenceDates) {
// Build the real Worship.date as (calendar date) + (HH:mm from
// the schedule) using local-time components. This is the step
// that keeps DST transitions from shifting the wall-clock hour.
const date = combineDateAndTime(occurrenceDate, timeSource)
// Skip anything before the window start (last Monday).
if (date.getTime() < windowStart.getTime()) {
skipped += 1
continue
}
// `generated: true` marks this as auto-created so a later run (or the
// wipe above) can target it without touching manual rows. Re-apply any
// cancellation the editor had set on this slot before the wipe.
await payload.create({
collection: 'worship',
data: {
date: date.toISOString(),
location: church.id,
type: entry.type,
cancelled: cancelledDates.has(date.toISOString()),
title: entry.defaultTitle || undefined,
celebrant: entry.defaultCelebrant || undefined,
description: entry.defaultDescription || undefined,
generated: true,
},
req,
})
created += 1
}
}
return { created, deleted, skipped }
}
export const generateRecurringMassesTask: TaskConfig<{
input: GenerateRecurringMassesInput
output: GenerateRecurringMassesOutput
@ -60,6 +222,7 @@ export const generateRecurringMassesTask: TaskConfig<{
],
outputSchema: [
{ name: 'created', type: 'number' },
{ name: 'deleted', type: 'number' },
{ name: 'skipped', type: 'number' },
],
// Weekly cron to keep the rolling window populated even if nobody
@ -75,10 +238,9 @@ export const generateRecurringMassesTask: TaskConfig<{
const { payload } = req
const weeksAhead = input?.weeksAhead ?? DEFAULT_WEEKS_AHEAD
const now = new Date()
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
// Scope to a single church when invoked from the Church afterChange
// hook, otherwise process every church in one run.
// Scope to a single church when invoked with a churchId, otherwise
// process every church in one run (weekly cron backfill).
const churchesResult = await payload.find({
collection: 'church',
depth: 0,
@ -90,108 +252,33 @@ export const generateRecurringMassesTask: TaskConfig<{
})
let created = 0
let deleted = 0
let skipped = 0
for (const church of churchesResult.docs) {
// Cast needed because payload-types only exposes recurringSchedule
// on the full Church interface and payload.find returns a looser
// shape at depth: 0.
const schedule = (church as { recurringSchedule?: unknown[] })
.recurringSchedule
if (!Array.isArray(schedule) || schedule.length === 0) continue
for (const rawEntry of schedule) {
const entry = rawEntry as ScheduleEntry & {
time?: string | Date | null
type?: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER'
defaultCelebrant?: string | null
defaultTitle?: string | null
defaultDescription?: string | null
}
// Guard: required fields may be missing if an editor saved a
// half-filled row. Skip rather than crash the whole run.
if (!entry.time || !entry.type) {
skipped += 1
continue
}
// Resolve the entry's recurrence pattern (weekly / biweekly /
// monthly Nth weekday) into concrete calendar dates in the
// [now, horizon] window. Returns dates at midnight only — we
// combine with the time-of-day below.
const occurrenceDates = generateOccurrenceDates(entry, now, horizon)
if (occurrenceDates.length === 0) {
skipped += 1
continue
}
const timeSource = new Date(entry.time)
for (const occurrenceDate of occurrenceDates) {
// Build the real Worship.date as (calendar date) + (HH:mm from
// the schedule) using local-time components. This is the step
// that keeps DST transitions from shifting the wall-clock hour.
const date = combineDateAndTime(occurrenceDate, timeSource)
// `now` could land mid-week: skip any occurrence that already
// passed earlier today.
if (date.getTime() < now.getTime()) {
skipped += 1
continue
}
// Append-only: if *any* Worship doc already exists at this
// exact (church, timestamp) slot — whether generated by a
// previous run or entered manually — leave it alone. This is
// what protects admin edits (cancellations, celebrant changes,
// etc.) from being overwritten.
const existing = await payload.find({
collection: 'worship',
depth: 0,
limit: 1,
pagination: false,
where: {
and: [
{ location: { equals: church.id } },
{ date: { equals: date.toISOString() } },
],
},
})
if (existing.docs.length > 0) {
skipped += 1
continue
}
// `generated: true` marks this as auto-created so future
// cleanup tooling can target it without touching manual rows.
await payload.create({
collection: 'worship',
data: {
date: date.toISOString(),
location: church.id,
type: entry.type,
cancelled: false,
title: entry.defaultTitle || undefined,
celebrant: entry.defaultCelebrant || undefined,
description: entry.defaultDescription || undefined,
generated: true,
},
})
created += 1
}
}
const result = await regenerateMassesForChurch({
church: church as ChurchLike,
payload,
req,
weeksAhead,
now,
})
created += result.created
deleted += result.deleted
skipped += result.skipped
}
// Counts surface on the Payload Jobs admin page as the task output.
payload.logger.info(
{ created, skipped, weeksAhead },
{ created, deleted, skipped, weeksAhead },
'generateRecurringMasses finished',
)
return {
output: { created, skipped },
output: { created, deleted, skipped },
}
},
}

View file

@ -67,7 +67,7 @@ export const Parish = (
link={<CalendarAnnouncementButtons calendar={calendar} announcements={announcement}/>}
/>
<Section>
<Section padding={"small"} paddingBottom={"large"}>
<Container>
<Row>
<Col>

View file

@ -2940,6 +2940,7 @@ export interface TaskGenerateRecurringMasses {
};
output: {
created?: number | null;
deleted?: number | null;
skipped?: number | null;
};
}