Compare commits

...

5 commits

Author SHA1 Message Date
Benno Tielen
7fed715d6b fix: timezone
Some checks failed
Deploy / deploy (push) Has been cancelled
2026-04-24 12:10:34 +02:00
Benno Tielen
a86eee52ce fix: quicker loading 2026-04-24 11:53:13 +02:00
Benno Tielen
24bf6d352d fix: max 4 on a row 2026-04-24 09:29:58 +02:00
Benno Tielen
7177ce08ec fix: hide event occurrences 2026-04-24 09:25:28 +02:00
Benno Tielen
48548e27f0 fix: docs 2026-04-24 09:13:39 +02:00
8 changed files with 111 additions and 43 deletions

View file

@ -172,11 +172,13 @@ docker restart postgres
Use these playbooks to deploy from your local machine — no Forgejo runner needed. Use these playbooks to deploy from your local machine — no Forgejo runner needed.
```bash ```bash
cd infra/ansible cd ansible
# Deploy both environments (git pull once, then build+deploy each sequentially) # Deploy both environments (git pull once, then build+deploy each sequentially)
ansible-playbook playbooks/deploy.yml --ask-vault-pass ansible-playbook playbooks/deploy.yml --ask-vault-pass
```
```
# Deploy staging only # Deploy staging only
ansible-playbook playbooks/deploy-staging.yml --ask-vault-pass ansible-playbook playbooks/deploy-staging.yml --ask-vault-pass

View file

@ -65,7 +65,7 @@ export const EventOccurrences: CollectionConfig = {
], ],
admin: { admin: {
defaultColumns: ['date', 'event', 'cancelled'], defaultColumns: ['date', 'event', 'cancelled'],
hidden: false, hidden: true,
}, },
access: { access: {
read: () => true, read: () => true,

View file

@ -1,12 +1,24 @@
import Image from 'next/image'
import type { CSSProperties } from 'react'
import { Logo } from '@/components/Logo/Logo' import { Logo } from '@/components/Logo/Logo'
import type { Media } from '@/payload-types'
import styles from "./styles.module.scss" import styles from "./styles.module.scss"
const backgroundSizeToObjectFit: Record<
'cover' | 'contain' | 'auto',
CSSProperties['objectFit']
> = {
cover: 'cover',
contain: 'contain',
auto: 'none',
}
export interface BannerProps { export interface BannerProps {
textLine1?: string | null textLine1?: string | null
textLine2?: string | null textLine2?: string | null
textLine3?: string | null textLine3?: string | null
backgroundColor?: string | null backgroundColor?: string | null
backgroundImage?: string | null backgroundImage?: Media | null
backgroundPosition?: backgroundPosition?:
| 'center center' | 'center center'
| 'top center' | 'top center'
@ -30,15 +42,27 @@ export const Banner = ({
backgroundPosition = 'center center', backgroundPosition = 'center center',
backgroundSize = 'cover', backgroundSize = 'cover',
}: BannerProps) => { }: BannerProps) => {
const bannerStyle: React.CSSProperties = { const bannerStyle: CSSProperties = {
...(backgroundColor && { backgroundColor }), ...(backgroundColor && { backgroundColor }),
...(backgroundImage && { backgroundImage: `url(${backgroundImage})` }),
...(backgroundPosition && { backgroundPosition }),
...(backgroundSize && { backgroundSize }),
} }
return ( return (
<div className={styles.banner} style={bannerStyle}> <div className={styles.banner} style={bannerStyle}>
{backgroundImage?.url && (
<Image
src={backgroundImage.url}
alt={backgroundImage.alt ?? ''}
fill
priority
sizes="100vw"
unoptimized
className={styles.backgroundImage}
style={{
objectFit: backgroundSizeToObjectFit[backgroundSize ?? 'cover'],
objectPosition: backgroundPosition ?? 'center center',
}}
/>
)}
<div className={styles.logo}> <div className={styles.logo}>
<Logo color={"#ffffff33"} height={200} /> <Logo color={"#ffffff33"} height={200} />
</div> </div>

View file

@ -4,14 +4,17 @@
position: relative; position: relative;
height: 634px; height: 634px;
background-color: $shade1; background-color: $shade1;
background-image: url("banner2.jpg");
background-size: cover;
background-position: center center;
opacity: 0.7; opacity: 0.7;
overflow: hidden;
}
.backgroundImage {
z-index: 0;
} }
.logo { .logo {
position: absolute; position: absolute;
z-index: 1;
bottom: 20px; bottom: 20px;
left: 30px; left: 30px;
} }
@ -21,6 +24,7 @@
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

@ -8,5 +8,5 @@
.container { .container {
margin: 0 auto; margin: 0 auto;
max-width: 1200px max-width: 1000px;
} }

View file

@ -148,10 +148,9 @@ export function Blocks({ content }: BlocksProps) {
} }
if (item.blockType === 'banner') { if (item.blockType === 'banner') {
const bannerImageUrl = const backgroundImage =
typeof item.backgroundImage === 'object' && typeof item.backgroundImage === 'object'
item.backgroundImage?.url ? item.backgroundImage
? item.backgroundImage.url
: undefined : undefined
return ( return (
<Banner <Banner
@ -160,7 +159,7 @@ export function Blocks({ content }: BlocksProps) {
textLine2={item.textLine2} textLine2={item.textLine2}
textLine3={item.textLine3} textLine3={item.textLine3}
backgroundColor={item.backgroundColor} backgroundColor={item.backgroundColor}
backgroundImage={bannerImageUrl} backgroundImage={backgroundImage}
backgroundPosition={item.backgroundPosition} backgroundPosition={item.backgroundPosition}
backgroundSize={item.backgroundSize} backgroundSize={item.backgroundSize}
/> />

View file

@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { describeRecurrence } from './recurrenceDescription' import { describeRecurrence } from './recurrenceDescription'
// Reference: 2026-04-13 local time = Monday, 2nd Monday of April 2026. // All test dates fall inside EU DST (Mar 29 Oct 25, 2026), so Berlin is UTC+2.
const tuesdayFirstOfMonth = new Date(2026, 3, 7, 18, 30).toISOString() // 2026-04-07 is a Tuesday, 1st of month // We spell the UTC instant explicitly so tests are timezone-independent.
const monday2ndOfMonth = new Date(2026, 3, 13, 18, 30).toISOString() // 2026-04-13 const tuesdayFirstOfMonth = '2026-04-07T16:30:00.000Z' // Tue 2026-04-07 18:30 Berlin
const sunday13thOfMonth = new Date(2026, 8, 13, 10, 0).toISOString() // 2026-09-13 is a Sunday const monday2ndOfMonth = '2026-04-13T16:30:00.000Z' // Mon 2026-04-13 18:30 Berlin
const monday5thOfMonth = new Date(2026, 2, 30, 20, 0).toISOString() // 2026-03-30 const sunday13thOfMonth = '2026-09-13T08:00:00.000Z' // Sun 2026-09-13 10:00 Berlin
const monday5thOfMonth = '2026-03-30T18:00:00.000Z' // Mon 2026-03-30 20:00 Berlin
describe('describeRecurrence', () => { describe('describeRecurrence', () => {
it('returns undefined for none / missing recurrenceType', () => { it('returns undefined for none / missing recurrenceType', () => {
@ -14,7 +15,7 @@ describe('describeRecurrence', () => {
}) })
it('describes daily with time from event.date', () => { it('describes daily with time from event.date', () => {
const dateAt10 = new Date(2026, 3, 13, 10, 0).toISOString() const dateAt10 = '2026-04-13T08:00:00.000Z' // 10:00 Berlin
expect(describeRecurrence({ recurrenceType: 'daily', date: dateAt10 })).toBe( expect(describeRecurrence({ recurrenceType: 'daily', date: dateAt10 })).toBe(
'Täglich um 10:00 Uhr', 'Täglich um 10:00 Uhr',
) )
@ -85,7 +86,7 @@ describe('describeRecurrence', () => {
}) })
it('pads minutes correctly (08:05, not 8:5)', () => { it('pads minutes correctly (08:05, not 8:5)', () => {
const dateEarly = new Date(2026, 3, 13, 8, 5).toISOString() const dateEarly = '2026-04-13T06:05:00.000Z' // 08:05 Berlin
expect(describeRecurrence({ recurrenceType: 'daily', date: dateEarly })).toBe( expect(describeRecurrence({ recurrenceType: 'daily', date: dateEarly })).toBe(
'Täglich um 08:05 Uhr', 'Täglich um 08:05 Uhr',
) )

View file

@ -28,18 +28,55 @@ const WEEK_OF_MONTH_LABEL: Record<string, string> = {
last: 'letzten', last: 'letzten',
} }
// Match the clamp in eventRecurrence.ts so the label on the Event page const BERLIN_TZ = 'Europe/Berlin'
// agrees with the rule the generator actually produces.
const weekOfMonthN = (d: Date): number => Math.min(Math.ceil(d.getDate() / 7), 4)
const formatTime = (d: Date): string => { const WEEKDAY_EN_TO_INDEX: Record<string, number> = {
const hh = String(d.getHours()).padStart(2, '0') Sunday: 0,
const mm = String(d.getMinutes()).padStart(2, '0') Monday: 1,
return `${hh}:${mm}` Tuesday: 2,
Wednesday: 3,
Thursday: 4,
Friday: 5,
Saturday: 6,
} }
const withTime = (description: string, d: Date | null): string => const berlinFormatter = new Intl.DateTimeFormat('en-GB', {
d ? `${description} um ${formatTime(d)} Uhr` : description timeZone: BERLIN_TZ,
hour: '2-digit',
minute: '2-digit',
day: 'numeric',
weekday: 'long',
hourCycle: 'h23',
})
type BerlinParts = {
hour: string
minute: string
day: number
weekdayIndex: number
}
const getBerlinParts = (d: Date): BerlinParts => {
const parts = berlinFormatter.formatToParts(d)
const get = (type: string) =>
parts.find((p) => p.type === type)?.value ?? ''
return {
hour: get('hour'),
minute: get('minute'),
day: Number(get('day')),
weekdayIndex: WEEKDAY_EN_TO_INDEX[get('weekday')] ?? 0,
}
}
// Match the clamp in eventRecurrence.ts so the label on the Event page
// agrees with the rule the generator actually produces.
const weekOfMonthN = (dayOfMonth: number): number =>
Math.min(Math.ceil(dayOfMonth / 7), 4)
const formatTime = (p: BerlinParts): string => `${p.hour}:${p.minute}`
const withTime = (description: string, p: BerlinParts | null): string =>
p ? `${description} um ${formatTime(p)} Uhr` : description
type RecurrenceRule = NonNullable<Event['recurrenceRules']>[number] type RecurrenceRule = NonNullable<Event['recurrenceRules']>[number]
@ -78,33 +115,34 @@ export const describeRecurrence = (
const parsed = event.date ? new Date(event.date) : null const parsed = event.date ? new Date(event.date) : null
const d = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null const d = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null
const p = d ? getBerlinParts(d) : null
switch (type) { switch (type) {
case 'daily': case 'daily':
return withTime('Täglich', d) return withTime('Täglich', p)
case 'weekly': case 'weekly':
return withTime(d ? `Jeden ${WEEKDAYS_DE[d.getDay()]}` : 'Wöchentlich', d) return withTime(p ? `Jeden ${WEEKDAYS_DE[p.weekdayIndex]}` : 'Wöchentlich', p)
case 'biweekly': case 'biweekly':
return withTime( return withTime(
d ? `Alle 2 Wochen, ${WEEKDAYS_DE[d.getDay()]}` : 'Alle 2 Wochen', p ? `Alle 2 Wochen, ${WEEKDAYS_DE[p.weekdayIndex]}` : 'Alle 2 Wochen',
d, p,
) )
case 'monthlyByDate': case 'monthlyByDate':
return withTime(d ? `Monatlich am ${d.getDate()}.` : 'Monatlich', d) return withTime(p ? `Monatlich am ${p.day}.` : 'Monatlich', p)
case 'monthlyByWeekday': case 'monthlyByWeekday':
return withTime( return withTime(
d p
? `Monatlich am ${weekOfMonthN(d)}. ${WEEKDAYS_DE[d.getDay()]}` ? `Monatlich am ${weekOfMonthN(p.day)}. ${WEEKDAYS_DE[p.weekdayIndex]}`
: 'Monatlich', : 'Monatlich',
d, p,
) )
case 'custom': { case 'custom': {
const rules = event.recurrenceRules ?? [] const rules = event.recurrenceRules ?? []
const parts = rules const parts = rules
.map(describeCustomRule) .map(describeCustomRule)
.filter((p): p is string => Boolean(p)) .filter((part): part is string => Boolean(part))
if (parts.length === 0) return withTime('Benutzerdefiniert', d) if (parts.length === 0) return withTime('Benutzerdefiniert', p)
return withTime(parts.join(' · '), d) return withTime(parts.join(' · '), p)
} }
default: default:
return undefined return undefined