diff --git a/src/components/whats-on/CreateEvent/EventForm.spec.tsx b/src/components/whats-on/CreateEvent/EventForm.spec.tsx
index d2bb7faf..b7699757 100644
--- a/src/components/whats-on/CreateEvent/EventForm.spec.tsx
+++ b/src/components/whats-on/CreateEvent/EventForm.spec.tsx
@@ -123,7 +123,23 @@ jest.mock('./EventForm.styled', () => ({
UpcomingDatesEmpty: ({ children }: { children: React.ReactNode }) => {children},
UpcomingDatesGroup: ({ children }: { children: React.ReactNode }) =>
{children}
,
UpcomingDatesLabel: ({ children }: { children: React.ReactNode }) => {children},
- UpcomingDatesList: ({ children }: { children: React.ReactNode }) =>
+ UpcomingDatesList: ({ children }: { children: React.ReactNode }) => ,
+ WeekdayChip: ({
+ children,
+ $active,
+ ...props
+ }: React.ButtonHTMLAttributes & { children: React.ReactNode; $active: boolean }) => (
+
+ ),
+ WeekdayChipGroup: ({ children, ...props }: { children: React.ReactNode } & Record) => (
+
+ {children}
+
+ ),
+ WeekdayChipLabel: ({ children }: { children: React.ReactNode }) => {children},
+ WeekdayChipRow: ({ children }: { children: React.ReactNode }) => {children}
}))
jest.mock('../EventDetailModal', () => ({
@@ -223,6 +239,7 @@ function createFormState(overrides = {}) {
duration: '',
repeatEnabled: false,
recurrence: 'every_week',
+ repeatDays: [],
repeatEndDate: '',
location: 'land',
coordX: '0',
@@ -837,6 +854,57 @@ describe('EventForm', () => {
expect(screen.getByTestId('upcoming-dates-label')).toBeInTheDocument()
expect(screen.getAllByTestId('upcoming-date-item').length).toBeGreaterThan(0)
})
+
+ it('should render a weekday chip for every day with the start weekday locked on', () => {
+ render()
+
+ const chips = screen.getAllByRole('checkbox')
+ expect(chips).toHaveLength(7)
+ // 2030-01-01 is a Tuesday (index 2): that chip is locked (disabled) and pre-checked.
+ expect(chips[2]).toBeDisabled()
+ expect(chips[2]).toHaveAttribute('aria-checked', 'true')
+ })
+
+ it('should toggle a non-locked weekday through setField', () => {
+ render()
+
+ const chips = screen.getAllByRole('checkbox')
+ fireEvent.click(chips[0]) // Sunday (index 0)
+
+ expect(mockSetField).toHaveBeenCalledWith('repeatDays', [0])
+ })
+ })
+
+ describe('when repeatEnabled is true with a non-weekly cadence', () => {
+ beforeEach(() => {
+ mockUseCreateEventForm.mockReturnValue({
+ form: createFormState({
+ repeatEnabled: true,
+ recurrence: 'every_month',
+ startDate: '2030-01-01',
+ startTime: '10:00',
+ repeatEndDate: '2030-06-01'
+ }),
+ errors: {},
+ mode: 'create',
+ setField: mockSetField,
+ markRequiredFields: jest.fn(),
+ handleImageSelect: mockHandleImageSelect,
+ handleImageRemove: mockHandleImageRemove,
+ handleVerticalImageSelect: jest.fn(),
+ handleVerticalImageRemove: jest.fn(),
+ isFormValid: true,
+ isSubmitting: false,
+ handleSubmit: mockHandleSubmit
+ })
+ })
+
+ it('should not render the weekday chips', () => {
+ render()
+
+ expect(screen.queryByTestId('weekday-chip-group')).not.toBeInTheDocument()
+ expect(screen.queryAllByRole('checkbox')).toHaveLength(0)
+ })
})
describe('when repeatEnabled is true but the recurrence has no upcoming dates', () => {
diff --git a/src/components/whats-on/CreateEvent/EventForm.styled.ts b/src/components/whats-on/CreateEvent/EventForm.styled.ts
index 440914bc..0b82ab21 100644
--- a/src/components/whats-on/CreateEvent/EventForm.styled.ts
+++ b/src/components/whats-on/CreateEvent/EventForm.styled.ts
@@ -299,13 +299,70 @@ const RepeatFields = styled(Box, {
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
- maxHeight: $visible ? 560 : 0,
+ maxHeight: $visible ? 680 : 0,
opacity: $visible ? 1 : 0,
overflow: 'hidden',
paddingTop: $visible ? 10 : 0,
transition: 'max-height 0.3s ease, opacity 0.3s ease, padding-top 0.3s ease'
}))
+/* ── weekday picker (weekly cadences) ───────────────────────────────── */
+
+const WeekdayChipGroup = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(0.75)
+}))
+
+const WeekdayChipLabel = styled(Typography)({
+ fontSize: 12,
+ fontWeight: 400,
+ color: labelColor,
+ letterSpacing: '0.15px',
+ lineHeight: 1.5,
+ textTransform: 'uppercase'
+})
+
+const WeekdayChipRow = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ gap: theme.spacing(1),
+ flexWrap: 'wrap'
+}))
+
+/* eslint-disable @typescript-eslint/naming-convention -- pseudo-class selectors */
+const WeekdayChip = styled('button', {
+ shouldForwardProp: prop => prop !== '$active'
+})<{ $active: boolean }>(({ $active, theme }) => ({
+ minWidth: 44,
+ height: 28,
+ padding: `0 ${theme.spacing(1.5)}`,
+ borderRadius: 14,
+ border: `1px solid ${$active ? theme.palette.primary.main : inputBorder}`,
+ background: $active ? theme.palette.primary.main : 'transparent',
+ color: $active ? theme.palette.primary.contrastText : inputText,
+ fontSize: 13,
+ fontWeight: 600,
+ fontFamily: 'inherit',
+ cursor: 'pointer',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ transition: 'background 0.15s ease, border-color 0.15s ease, color 0.15s ease',
+ '&:hover': {
+ borderColor: theme.palette.primary.main,
+ background: $active ? theme.palette.primary.dark : 'rgba(255, 255, 255, 0.04)'
+ },
+ '&:focus-visible': {
+ outline: `2px solid ${theme.palette.primary.main}`,
+ outlineOffset: 2
+ },
+ '&:disabled': {
+ cursor: 'not-allowed',
+ opacity: 0.85
+ }
+}))
+/* eslint-enable @typescript-eslint/naming-convention */
+
/* ── upcoming dates preview ─────────────────────────────────────────── */
const UpcomingDatesGroup = styled(Box)(({ theme }) => ({
@@ -664,5 +721,9 @@ export {
UpcomingDatesEmpty,
UpcomingDatesGroup,
UpcomingDatesLabel,
- UpcomingDatesList
+ UpcomingDatesList,
+ WeekdayChip,
+ WeekdayChipGroup,
+ WeekdayChipLabel,
+ WeekdayChipRow
}
diff --git a/src/components/whats-on/CreateEvent/EventForm.tsx b/src/components/whats-on/CreateEvent/EventForm.tsx
index b6793f04..d16faebc 100644
--- a/src/components/whats-on/CreateEvent/EventForm.tsx
+++ b/src/components/whats-on/CreateEvent/EventForm.tsx
@@ -17,6 +17,13 @@ import { useAuthIdentity } from '../../../hooks/useAuthIdentity'
import { useCreateEventForm } from '../../../hooks/useCreateEventForm'
import { RECURRENCE_OPTIONS, computeUpcomingOccurrences, parseDurationMs, recurrenceToApi } from '../../../hooks/useCreateEventForm.helpers'
import type { CreateEventFormState } from '../../../hooks/useCreateEventForm.types'
+import {
+ WEEKDAY_INDICES,
+ effectiveWeekdays,
+ localizedWeekdayLong,
+ localizedWeekdayShort,
+ parseStartWeekday
+} from '../../../utils/recurrence'
import { formatLocalDate, formatLocalTime, formatUtcTime, getUtcDayDelta } from '../../../utils/whatsOnTime'
import { buildEventJumpInUrl } from '../../../utils/whatsOnUrl'
import { EventDetailModal } from '../EventDetailModal'
@@ -70,7 +77,11 @@ import {
UpcomingDatesEmpty,
UpcomingDatesGroup,
UpcomingDatesLabel,
- UpcomingDatesList
+ UpcomingDatesList,
+ WeekdayChip,
+ WeekdayChipGroup,
+ WeekdayChipLabel,
+ WeekdayChipRow
} from './EventForm.styled'
const PREVIEW_REQUIRED_FIELDS: Array = ['name', 'startDate', 'startTime', 'duration']
@@ -90,6 +101,9 @@ function buildPreviewData(form: CreateEventFormState, address: string | undefine
const previewUntil = form.repeatEnabled && form.repeatEndDate ? new Date(`${form.repeatEndDate}T00:00:00`).toISOString() : null
const recurrenceApi = form.repeatEnabled ? recurrenceToApi(form.recurrence) : null
+ // For weekly cadences show the selected local weekdays in the modal label; daily/monthly carry none.
+ const previewByDay =
+ recurrenceApi?.frequency === 'WEEKLY' ? effectiveWeekdays(form.repeatDays, parseStartWeekday(form.startDate)) : undefined
return {
id: 'preview',
@@ -107,8 +121,7 @@ function buildPreviewData(form: CreateEventFormState, address: string | undefine
recurrentInterval: recurrenceApi?.interval ?? null,
recurrentCount: null,
recurrentUntil: previewUntil,
- // Single-weekday recurrence: no per-weekday mask, so the modal derives the weekday from startAt.
- recurrentByDay: undefined,
+ recurrentByDay: previewByDay,
recurrentDates: [],
totalAttendees: 0,
attending: false,
@@ -166,9 +179,23 @@ function EventForm({ onCancel, onSuccess, initialEvent = null, initialCommunityI
return t('event_time.form_utc_preview', { time })
}, [form.startDate, form.startTime, locale, t])
+ // Weekly cadences expose a weekday picker; the start date's weekday is always part of the set.
+ const isWeeklyCadence = recurrenceToApi(form.recurrence).frequency === 'WEEKLY'
+ const startWeekday = parseStartWeekday(form.startDate)
+ const selectedWeekdays = useMemo(() => effectiveWeekdays(form.repeatDays, startWeekday), [form.repeatDays, startWeekday])
+
const upcomingDates = useMemo(
- () => (form.repeatEnabled ? computeUpcomingOccurrences(form.startDate, form.startTime, form.recurrence, form.repeatEndDate) : []),
- [form.repeatEnabled, form.startDate, form.startTime, form.recurrence, form.repeatEndDate]
+ () =>
+ form.repeatEnabled
+ ? computeUpcomingOccurrences(
+ form.startDate,
+ form.startTime,
+ form.recurrence,
+ form.repeatEndDate,
+ isWeeklyCadence ? selectedWeekdays : []
+ )
+ : [],
+ [form.repeatEnabled, form.startDate, form.startTime, form.recurrence, form.repeatEndDate, isWeeklyCadence, selectedWeekdays]
)
const imageMissing = !form.imageUrl
@@ -195,6 +222,18 @@ function EventForm({ onCancel, onSuccess, initialEvent = null, initialCommunityI
setIsPreviewOpen(false)
}, [])
+ const toggleWeekday = useCallback(
+ (dayIndex: number) => {
+ // The start date's weekday is locked on — it's always part of the recurrence.
+ if (dayIndex === startWeekday) return
+ const next = form.repeatDays.includes(dayIndex)
+ ? form.repeatDays.filter(day => day !== dayIndex)
+ : [...form.repeatDays, dayIndex].sort((a, b) => a - b)
+ setField('repeatDays', next)
+ },
+ [form.repeatDays, setField, startWeekday]
+ )
+
const handleVerticalClick = useCallback(() => {
if (form.verticalImagePreviewUrl) {
handleVerticalImageRemove()
@@ -343,6 +382,31 @@ function EventForm({ onCancel, onSuccess, initialEvent = null, initialCommunityI
))}
+ {isWeeklyCadence && (
+
+ {t('create_event.repeat_on')}
+
+ {WEEKDAY_INDICES.map(dayIndex => {
+ const isActive = selectedWeekdays.includes(dayIndex)
+ const isLocked = dayIndex === startWeekday
+ return (
+ toggleWeekday(dayIndex)}
+ >
+ {localizedWeekdayShort(dayIndex, locale)}
+
+ )
+ })}
+
+
+ )}
0 ? localDays : undefined
}
function normalizeEventEntry(event: EventEntry): ModalEventData {
@@ -28,7 +33,7 @@ function normalizeEventEntry(event: EventEntry): ModalEventData {
recurrentInterval: event.recurrent_interval,
recurrentCount: event.recurrent_count,
recurrentUntil: event.recurrent_until,
- recurrentByDay: decodeRecurrentByDay(event.recurrent_weekday_mask),
+ recurrentByDay: decodeRecurrentByDay(event.recurrent_weekday_mask, event.start_at),
recurrentDates: event.recurrent_dates,
totalAttendees: event.total_attendees,
attending: event.attending,
@@ -62,7 +67,7 @@ function normalizeLiveNowCard(card: LiveNowCard): ModalEventData {
recurrentInterval: card.recurrentInterval ?? null,
recurrentCount: card.recurrentCount ?? null,
recurrentUntil: card.recurrentUntil ?? null,
- recurrentByDay: decodeRecurrentByDay(card.recurrentWeekdayMask),
+ recurrentByDay: decodeRecurrentByDay(card.recurrentWeekdayMask, card.startAt),
recurrentDates: card.recurrentDates ?? [],
totalAttendees: card.users,
attending: card.attending,
diff --git a/src/hooks/useCreateEventForm.helpers.spec.ts b/src/hooks/useCreateEventForm.helpers.spec.ts
index 9eabb8a8..f2dd8097 100644
--- a/src/hooks/useCreateEventForm.helpers.spec.ts
+++ b/src/hooks/useCreateEventForm.helpers.spec.ts
@@ -156,6 +156,29 @@ describe('eventEntryToFormState', () => {
})
})
+ describe('when hydrating repeatDays from a stored weekly event', () => {
+ // Tests run with TZ pinned to UTC (jestGlobalSetup), so local weekdays equal the stored UTC mask.
+ it('should decode the UTC weekday mask into local weekday indices', () => {
+ // mask 42 = Monday(2) | Wednesday(8) | Friday(32).
+ const formState = eventEntryToFormState(buildEvent({ recurrent: true, recurrent_frequency: 'WEEKLY', recurrent_weekday_mask: 42 }))
+
+ expect(formState.repeatDays).toEqual([1, 3, 5])
+ })
+
+ it('should fall back to the start weekday when the mask is 0', () => {
+ // start_at 2030-01-01 is a Tuesday (weekday index 2).
+ const formState = eventEntryToFormState(buildEvent({ recurrent: true, recurrent_frequency: 'WEEKLY', recurrent_weekday_mask: 0 }))
+
+ expect(formState.repeatDays).toEqual([2])
+ })
+
+ it('should leave repeatDays empty for a non-recurrent event', () => {
+ const formState = eventEntryToFormState(buildEvent({ recurrent: false }))
+
+ expect(formState.repeatDays).toEqual([])
+ })
+ })
+
describe('recurrenceToApi', () => {
it('should map every_2_weeks to a WEEKLY frequency with interval 2', () => {
expect(recurrenceToApi('every_2_weeks')).toEqual({ frequency: 'WEEKLY', interval: 2 })
@@ -186,11 +209,11 @@ describe('eventEntryToFormState', () => {
})
it('should return an empty array for an unknown recurrence option', () => {
- expect(computeUpcomingOccurrences(startDate, startTime, 'bogus', '', startMs)).toEqual([])
+ expect(computeUpcomingOccurrences(startDate, startTime, 'bogus', '', [], startMs)).toEqual([])
})
it('should project five biweekly occurrences spaced exactly 14 days apart', () => {
- const dates = computeUpcomingOccurrences(startDate, startTime, 'every_2_weeks', '', startMs)
+ const dates = computeUpcomingOccurrences(startDate, startTime, 'every_2_weeks', '', [], startMs)
expect(dates).toHaveLength(5)
expect(dates[0].getTime()).toBe(startMs)
@@ -200,7 +223,7 @@ describe('eventEntryToFormState', () => {
})
it('should project five daily occurrences spaced exactly 1 day apart', () => {
- const dates = computeUpcomingOccurrences(startDate, startTime, 'every_day', '', startMs)
+ const dates = computeUpcomingOccurrences(startDate, startTime, 'every_day', '', [], startMs)
expect(dates).toHaveLength(5)
for (let i = 1; i < dates.length; i++) {
@@ -210,7 +233,7 @@ describe('eventEntryToFormState', () => {
it('should stop generating once the end date is passed', () => {
// Weekly from Jan 1: Jan 1, Jan 8, Jan 15 land on/before Jan 20; Jan 22 is excluded.
- const dates = computeUpcomingOccurrences(startDate, startTime, 'every_week', '2030-01-20', startMs)
+ const dates = computeUpcomingOccurrences(startDate, startTime, 'every_week', '2030-01-20', [], startMs)
expect(dates).toHaveLength(3)
})
@@ -218,7 +241,7 @@ describe('eventEntryToFormState', () => {
it('should skip occurrences that already passed relative to now', () => {
const now = startMs + 15 * DAY
- const dates = computeUpcomingOccurrences(startDate, startTime, 'every_week', '', now)
+ const dates = computeUpcomingOccurrences(startDate, startTime, 'every_week', '', [], now)
expect(dates[0].getTime()).toBeGreaterThanOrEqual(now)
})
@@ -230,6 +253,7 @@ describe('eventEntryToFormState', () => {
startTime,
'every_month',
'',
+ [],
new Date(`${monthlyStart}T${startTime}`).getTime()
)
@@ -238,6 +262,26 @@ describe('eventEntryToFormState', () => {
expect(dates[1].getMonth()).toBe(1)
expect(dates[1].getDate()).toBe(28)
})
+
+ describe('when selected weekdays are provided for a weekly cadence', () => {
+ // 2030-01-07 is a Monday; project Mon/Wed/Fri (1/3/5).
+ const mondayStart = '2030-01-07'
+ const mondayStartMs = new Date(`${mondayStart}T${startTime}`).getTime()
+
+ it('should project every selected weekday each week', () => {
+ const dates = computeUpcomingOccurrences(mondayStart, startTime, 'every_week', '', [1, 3, 5], mondayStartMs)
+
+ expect(dates.map(d => d.getDate())).toEqual([7, 9, 11, 14, 16])
+ expect(dates.map(d => d.getDay())).toEqual([1, 3, 5, 1, 3])
+ })
+
+ it('should only project selected weekdays inside active interval weeks (biweekly skips the off week)', () => {
+ const dates = computeUpcomingOccurrences(mondayStart, startTime, 'every_2_weeks', '', [1, 3, 5], mondayStartMs)
+
+ // Week of Jan 7 (Mon/Wed/Fri), then skip Jan 14 week, then week of Jan 21.
+ expect(dates.map(d => d.getDate())).toEqual([7, 9, 11, 21, 23])
+ })
+ })
})
describe('when the event is recurrent and `start_at` already lies in the past (issue #474)', () => {
diff --git a/src/hooks/useCreateEventForm.helpers.ts b/src/hooks/useCreateEventForm.helpers.ts
index abe10533..3bf5d35c 100644
--- a/src/hooks/useCreateEventForm.helpers.ts
+++ b/src/hooks/useCreateEventForm.helpers.ts
@@ -1,4 +1,5 @@
import type { EventEntry, RecurrentFrequency } from '../features/events'
+import { parseStartWeekday, utcMaskToLocalWeekdays } from '../utils/recurrence'
import type { CreateEventFormState } from './useCreateEventForm.types'
const DURATION_PATTERN = /^([0-9]{1,2}):([0-5][0-9])$/
@@ -73,15 +74,24 @@ function addMonthsClamped(date: Date, months: number): Date {
return shifted
}
-// Best-effort client-side projection of the next occurrences for the recurrence preview. Mirrors the
-// single-weekday model the form submits (weekly cadences step by whole weeks from start_at, so every
-// occurrence keeps start_at's weekday). Only occurrences at/after `now` are returned, capped at
-// `count`; an empty array means "nothing upcoming" (e.g. the end date already passed).
+// Local midnight of the Monday that opens `date`'s week (WKST=Monday, matching the iCal RRule default
+// the events server uses to bucket weekly occurrences into interval-week cycles).
+function mondayOfWeek(date: Date): Date {
+ const offsetFromMonday = (date.getDay() + 6) % 7
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() - offsetFromMonday)
+}
+
+// Best-effort client-side projection of the next occurrences for the recurrence preview. Only
+// occurrences at/after `now` are returned, capped at `count`; an empty array means "nothing upcoming"
+// (e.g. the end date already passed). `selectedDays` are LOCAL weekday indices (Sun=0..Sat=6) and only
+// apply to weekly cadences — when provided, the preview projects every selected weekday within each
+// active interval-week cycle; when empty it falls back to the single start-day-of-week cadence.
function computeUpcomingOccurrences(
startDate: string,
startTime: string,
recurrence: string,
repeatEndDate: string,
+ selectedDays: number[] = [],
now: number = Date.now(),
count: number = RECURRENCE_PREVIEW_COUNT
): Date[] {
@@ -93,6 +103,32 @@ function computeUpcomingOccurrences(
const end = repeatEndDate ? new Date(`${repeatEndDate}T23:59:59`) : null
const endTs = end && !Number.isNaN(end.getTime()) ? end.getTime() : null
+ const occurrences: Date[] = []
+
+ if (frequency === 'WEEKLY' && selectedDays.length > 0) {
+ const daySet = new Set(selectedDays)
+ const startWeekMondayTs = mondayOfWeek(start).getTime()
+ // Walk forward calendar-day by calendar-day (date math, not ms addition, to stay DST-safe),
+ // keeping start_at's time-of-day, and collect days that fall on a selected weekday inside an
+ // active interval-week cycle.
+ for (let dayOffset = 0; dayOffset < MAX_PREVIEW_ITERATIONS && occurrences.length < count; dayOffset++) {
+ const candidate = new Date(
+ start.getFullYear(),
+ start.getMonth(),
+ start.getDate() + dayOffset,
+ start.getHours(),
+ start.getMinutes(),
+ start.getSeconds(),
+ start.getMilliseconds()
+ )
+ if (endTs !== null && candidate.getTime() > endTs) break
+ if (!daySet.has(candidate.getDay())) continue
+ const weeksSinceStart = Math.round((mondayOfWeek(candidate).getTime() - startWeekMondayTs) / (7 * DAY_MS))
+ if (weeksSinceStart % interval !== 0) continue
+ if (candidate.getTime() >= now) occurrences.push(candidate)
+ }
+ return occurrences
+ }
const next = (date: Date): Date => {
if (frequency === 'MONTHLY') return addMonthsClamped(date, interval)
@@ -100,7 +136,6 @@ function computeUpcomingOccurrences(
return new Date(date.getTime() + stepDays * DAY_MS)
}
- const occurrences: Date[] = []
let current = start
for (let i = 0; i < MAX_PREVIEW_ITERATIONS && occurrences.length < count; i++) {
if (endTs !== null && current.getTime() > endTs) break
@@ -128,6 +163,7 @@ const INITIAL_STATE: CreateEventFormState = {
duration: '',
repeatEnabled: false,
recurrence: DEFAULT_RECURRENCE,
+ repeatDays: [],
repeatEndDate: '',
location: 'land',
coordX: '0',
@@ -196,6 +232,13 @@ function eventEntryToFormState(event: EventEntry, now: number = Date.now()): Cre
const hasWorldName = typeof event.server === 'string' && event.server.length > 0
const isWorld = Boolean(event.world) && hasWorldName
+ // Hydrate the weekday chips from the stored UTC mask, converted back to the creator's local
+ // weekdays against the same reference instant the date/time fields use. A 0/absent mask means the
+ // server defaults to start_at's weekday, so fall back to that single local weekday.
+ const startWeekday = parseStartWeekday(start.date)
+ const decodedDays = utcMaskToLocalWeekdays(event.recurrent_weekday_mask, referenceStartAt)
+ const repeatDays = event.recurrent ? (decodedDays.length > 0 ? decodedDays : startWeekday !== null ? [startWeekday] : []) : []
+
return {
...INITIAL_STATE,
imageUrl: event.image ?? null,
@@ -209,6 +252,7 @@ function eventEntryToFormState(event: EventEntry, now: number = Date.now()): Cre
duration: durationMsToHhMm(durationMs),
repeatEnabled: Boolean(event.recurrent),
recurrence: apiToRecurrence(event.recurrent_frequency, event.recurrent_interval),
+ repeatDays,
repeatEndDate: repeatEnd.date,
location: isWorld ? 'world' : 'land',
coordX: isWorld ? '0' : String(event.x ?? 0),
diff --git a/src/hooks/useCreateEventForm.spec.tsx b/src/hooks/useCreateEventForm.spec.tsx
index ffadb02e..30f80f27 100644
--- a/src/hooks/useCreateEventForm.spec.tsx
+++ b/src/hooks/useCreateEventForm.spec.tsx
@@ -92,6 +92,13 @@ function fillValidForm(setField: ReturnType['setField
})
}
+// The default valid form starts on 2030-01-01 10:00. The submitted weekday mask must always carry the
+// start instant's own UTC weekday bit (the #560 invariant) — asserting against this keeps the tests
+// independent of the runner's timezone.
+const DEFAULT_START_AT = new Date('2030-01-01T10:00:00').toISOString()
+const DEFAULT_START_UTC_BIT = 1 << new Date(DEFAULT_START_AT).getUTCDay()
+const countBits = (mask: number): number => mask.toString(2).replace(/0/g, '').length
+
let mockImageWidth = 716
let mockImageHeight = 1814
let mockImageShouldFail = false
@@ -222,7 +229,7 @@ describe('useCreateEventForm', () => {
})
describe('when repeat is enabled on a weekly recurrence with a valid end date', () => {
- it('should send WEEKLY, interval 1 and a 0 weekday mask so the server uses the start_at weekday', async () => {
+ it('should send WEEKLY, interval 1 and a mask carrying the start weekday so the series never gains a phantom day', async () => {
const { result } = renderHook(() => useCreateEventForm())
fillValidForm(result.current.setField)
@@ -242,8 +249,8 @@ describe('useCreateEventForm', () => {
recurrent: true,
recurrent_frequency: 'WEEKLY',
recurrent_interval: 1,
- // 0 (not undefined) so the backend clears any stale mask and recurs on start_at's weekday — issue #560.
- recurrent_weekday_mask: 0,
+ // Just the start weekday when no extra days are picked — and it's start_at's UTC weekday (#560).
+ recurrent_weekday_mask: DEFAULT_START_UTC_BIT,
recurrent_until: expect.stringContaining('2030-02-01')
})
})
@@ -252,7 +259,7 @@ describe('useCreateEventForm', () => {
})
describe('when repeat is enabled on a biweekly recurrence', () => {
- it('should send WEEKLY with interval 2 and a 0 weekday mask', async () => {
+ it('should send WEEKLY with interval 2 and the start-weekday mask', async () => {
const { result } = renderHook(() => useCreateEventForm())
fillValidForm(result.current.setField)
@@ -271,13 +278,37 @@ describe('useCreateEventForm', () => {
payload: expect.objectContaining({
recurrent_frequency: 'WEEKLY',
recurrent_interval: 2,
- recurrent_weekday_mask: 0
+ recurrent_weekday_mask: DEFAULT_START_UTC_BIT
})
})
)
})
})
+ describe('when repeat is enabled on a weekly recurrence with extra weekdays selected', () => {
+ it('should send a mask with the start weekday plus each extra day', async () => {
+ const { result } = renderHook(() => useCreateEventForm())
+
+ fillValidForm(result.current.setField)
+ act(() => {
+ result.current.setField('repeatEnabled', true)
+ result.current.setField('recurrence', 'every_week')
+ // Thursday (4) — distinct from the 2030-01-01 start weekday in any timezone.
+ result.current.setField('repeatDays', [4])
+ result.current.setField('repeatEndDate', '2030-02-01')
+ })
+
+ await act(async () => {
+ await result.current.handleSubmit()
+ })
+
+ const payload = mockCreateEvent.mock.calls[0][0].payload
+ // Start weekday is always present (no phantom occurrence), plus exactly one extra day.
+ expect(payload.recurrent_weekday_mask & DEFAULT_START_UTC_BIT).toBe(DEFAULT_START_UTC_BIT)
+ expect(countBits(payload.recurrent_weekday_mask)).toBe(2)
+ })
+ })
+
describe('when repeat is enabled on a daily recurrence', () => {
it('should send DAILY, interval 1 and a 0 weekday mask', async () => {
const { result } = renderHook(() => useCreateEventForm())
diff --git a/src/hooks/useCreateEventForm.ts b/src/hooks/useCreateEventForm.ts
index bba48520..2c0740d6 100644
--- a/src/hooks/useCreateEventForm.ts
+++ b/src/hooks/useCreateEventForm.ts
@@ -8,6 +8,7 @@ import {
} from '../features/events'
import type { EventEntry } from '../features/events'
import { compressImageFile } from '../utils/imageCompression'
+import { effectiveWeekdays, localWeekdaysToUtcMask, parseStartWeekday } from '../utils/recurrence'
import { useAuthIdentity } from './useAuthIdentity'
import { INITIAL_STATE, eventEntryToFormState, parseDurationMs, recurrenceToApi } from './useCreateEventForm.helpers'
import type { CreateEventFormMode, CreateEventFormState, FormErrors, ImageErrorCode } from './useCreateEventForm.types'
@@ -370,6 +371,14 @@ function useCreateEventForm({ onSuccess, initialEvent = null, initialCommunityId
const isWorld = form.location === 'world'
const recurrenceApi = form.repeatEnabled ? recurrenceToApi(form.recurrence) : null
+ // Weekly cadences send the selected weekdays as a UTC mask (always including start_at's own
+ // weekday — see localWeekdaysToUtcMask); daily/monthly send 0 so the server uses start_at's
+ // weekday; non-recurrent events omit the field entirely.
+ const weekdayMask = !form.repeatEnabled
+ ? undefined
+ : recurrenceApi?.frequency === 'WEEKLY'
+ ? localWeekdaysToUtcMask(effectiveWeekdays(form.repeatDays, parseStartWeekday(form.startDate)), startAt)
+ : 0
/* eslint-disable @typescript-eslint/naming-convention */
const payload = {
name: form.name.trim(),
@@ -393,12 +402,11 @@ function useCreateEventForm({ onSuccess, initialEvent = null, initialCommunityId
recurrent: form.repeatEnabled || undefined,
recurrent_frequency: recurrenceApi?.frequency,
recurrent_interval: recurrenceApi?.interval,
- // NOTE: send an explicit 0 (not undefined) whenever the event is recurrent (#560). 0 makes the
- // server's RRule default BYDAY to start_at's own weekday, so a weekly/biweekly event recurs on
- // exactly that one day. Sending it explicitly also CLEARS any stale per-weekday mask on edit —
- // omitting the field on a PATCH would let the backend keep the old mask, which is how a Tuesday
- // event ended up also showing every Wednesday. Omit it only when the event isn't recurrent.
- recurrent_weekday_mask: form.repeatEnabled ? 0 : undefined,
+ // Always send the mask explicitly for recurrent events (#560): a weekly cadence carries the
+ // selected weekdays (converted to UTC, start_at's weekday guaranteed included so RRule never
+ // adds a phantom start-day occurrence); daily/monthly send 0 (server uses start_at's weekday).
+ // Sending it explicitly also clears any stale mask on a PATCH. Non-recurrent events omit it.
+ recurrent_weekday_mask: weekdayMask,
recurrent_until: form.repeatEnabled && form.repeatEndDate ? new Date(`${form.repeatEndDate}T00:00:00`).toISOString() : undefined
}
/* eslint-enable @typescript-eslint/naming-convention */
diff --git a/src/hooks/useCreateEventForm.types.ts b/src/hooks/useCreateEventForm.types.ts
index a7cab31e..54a94bfa 100644
--- a/src/hooks/useCreateEventForm.types.ts
+++ b/src/hooks/useCreateEventForm.types.ts
@@ -25,9 +25,12 @@ type CreateEventFormState = {
duration: string
repeatEnabled: boolean
// Single combined recurrence option (every_day | every_week | every_2_weeks | every_3_weeks |
- // every_4_weeks | every_month). It folds the legacy frequency + interval pair into one selector;
- // the weekly weekday is derived from startDate, so there is no separate weekday picker anymore.
+ // every_4_weeks | every_month). It folds the legacy frequency + interval pair into one selector.
recurrence: string
+ // LOCAL weekday indices (Sun=0..Sat=6) the event repeats on, for weekly cadences. The start date's
+ // weekday is always part of the recurrence even if absent here (see effectiveWeekdays); empty means
+ // "just the start day". Ignored for daily/monthly cadences.
+ repeatDays: number[]
repeatEndDate: string
location: string
coordX: string
diff --git a/src/intl/en.json b/src/intl/en.json
index 5b8b6cc4..7ed6b230 100644
--- a/src/intl/en.json
+++ b/src/intl/en.json
@@ -1228,7 +1228,8 @@
"every_3_weeks": "Every 3 Weeks",
"every_4_weeks": "Every 4 Weeks",
"upcoming_dates_label": "Upcoming dates",
- "upcoming_dates_empty": "No upcoming dates for this recurrence"
+ "upcoming_dates_empty": "No upcoming dates for this recurrence",
+ "repeat_on": "Repeat on"
},
"host_banner": {
"title": "Host a Hangout",
diff --git a/src/intl/es.json b/src/intl/es.json
index 94c42000..b2876d89 100644
--- a/src/intl/es.json
+++ b/src/intl/es.json
@@ -911,7 +911,8 @@
"every_4_weeks": "Cada 4 semanas",
"every_month": "Cada mes",
"upcoming_dates_label": "Próximas fechas",
- "upcoming_dates_empty": "No hay próximas fechas para esta recurrencia"
+ "upcoming_dates_empty": "No hay próximas fechas para esta recurrencia",
+ "repeat_on": "Repetir los"
},
"community": {
"global": {
diff --git a/src/intl/fr.json b/src/intl/fr.json
index 4e3621f1..508c918c 100644
--- a/src/intl/fr.json
+++ b/src/intl/fr.json
@@ -906,7 +906,8 @@
"every_4_weeks": "Toutes les 4 semaines",
"every_month": "Chaque mois",
"upcoming_dates_label": "Prochaines dates",
- "upcoming_dates_empty": "Aucune date à venir pour cette récurrence"
+ "upcoming_dates_empty": "Aucune date à venir pour cette récurrence",
+ "repeat_on": "Répéter le"
},
"community": {
"global": {
diff --git a/src/intl/ja.json b/src/intl/ja.json
index 10ebfa28..8c0ad71b 100644
--- a/src/intl/ja.json
+++ b/src/intl/ja.json
@@ -911,7 +911,8 @@
"every_4_weeks": "4週間ごと",
"every_month": "毎月",
"upcoming_dates_label": "次回以降の日程",
- "upcoming_dates_empty": "この繰り返し設定に該当する今後の日程はありません"
+ "upcoming_dates_empty": "この繰り返し設定に該当する今後の日程はありません",
+ "repeat_on": "繰り返す曜日"
},
"community": {
"global": {
diff --git a/src/intl/ko.json b/src/intl/ko.json
index 56c5c4ee..f61616da 100644
--- a/src/intl/ko.json
+++ b/src/intl/ko.json
@@ -911,7 +911,8 @@
"every_4_weeks": "4주마다",
"every_month": "매월",
"upcoming_dates_label": "예정된 날짜",
- "upcoming_dates_empty": "이 반복 설정에 예정된 날짜가 없습니다"
+ "upcoming_dates_empty": "이 반복 설정에 예정된 날짜가 없습니다",
+ "repeat_on": "반복 요일"
},
"community": {
"global": {
diff --git a/src/intl/zh.json b/src/intl/zh.json
index 97741210..86150ab9 100644
--- a/src/intl/zh.json
+++ b/src/intl/zh.json
@@ -911,7 +911,8 @@
"every_4_weeks": "每4周",
"every_month": "每月",
"upcoming_dates_label": "近期日期",
- "upcoming_dates_empty": "此重复设置没有近期日期"
+ "upcoming_dates_empty": "此重复设置没有近期日期",
+ "repeat_on": "重复于"
},
"community": {
"global": {
diff --git a/src/utils/recurrence.spec.ts b/src/utils/recurrence.spec.ts
new file mode 100644
index 00000000..2dec65ec
--- /dev/null
+++ b/src/utils/recurrence.spec.ts
@@ -0,0 +1,77 @@
+jest.mock('./whatsOnTime', () => ({ getUtcDayDelta: jest.fn(() => 0) }))
+
+import { effectiveWeekdays, localWeekdaysToUtcMask, utcMaskToLocalWeekdays } from './recurrence'
+import { getUtcDayDelta } from './whatsOnTime'
+
+const mockDelta = getUtcDayDelta as jest.Mock
+
+describe('recurrence weekday helpers', () => {
+ beforeEach(() => {
+ mockDelta.mockReturnValue(0)
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('effectiveWeekdays', () => {
+ it('should always include the start weekday, sorted and de-duped', () => {
+ expect(effectiveWeekdays([5, 1, 1], 3)).toEqual([1, 3, 5])
+ })
+
+ it('should keep just the start weekday when there are no extra picks', () => {
+ expect(effectiveWeekdays([], 2)).toEqual([2])
+ })
+
+ it('should return the picks untouched when the start weekday is null', () => {
+ expect(effectiveWeekdays([5, 1], null)).toEqual([1, 5])
+ })
+ })
+
+ describe('localWeekdaysToUtcMask', () => {
+ it('should build the mask directly when the start stays on the same UTC day (delta 0)', () => {
+ // Monday(1) | Wednesday(3) | Friday(5) -> bits 2 + 8 + 32 = 42.
+ expect(localWeekdaysToUtcMask([1, 3, 5], '2030-01-07T10:00:00.000Z')).toBe(42)
+ })
+
+ it('should shift weekdays forward and wrap Saturday to Sunday when UTC is the next day (delta +1)', () => {
+ mockDelta.mockReturnValue(1)
+ // local Saturday(6) -> UTC Sunday(0) -> bit 1 (forward wrap across the week boundary).
+ expect(localWeekdaysToUtcMask([6], 'irrelevant')).toBe(1)
+ })
+
+ it('should shift weekdays backward when UTC is the previous day (delta -1)', () => {
+ mockDelta.mockReturnValue(-1)
+ // local Tuesday(2) -> UTC Monday(1) -> bit 2 — exactly the Toxic Tuesday correction.
+ expect(localWeekdaysToUtcMask([2], 'irrelevant')).toBe(2)
+ })
+
+ it('should wrap Sunday back to Saturday when UTC is the previous day (delta -1)', () => {
+ mockDelta.mockReturnValue(-1)
+ // local Sunday(0) -> UTC Saturday(6) -> bit 64 (backward wrap across the week boundary).
+ expect(localWeekdaysToUtcMask([0], 'irrelevant')).toBe(64)
+ })
+
+ it('should always carry the start weekday so RRule cannot add a phantom occurrence', () => {
+ mockDelta.mockReturnValue(-1)
+ // local Tuesday(2) with delta -1 maps to UTC Monday(1) -> bit 1<<1 = 2.
+ const mask = localWeekdaysToUtcMask([2, 5], 'irrelevant')
+ const startUtcBit = 1 << 1
+ expect(mask & startUtcBit).toBe(startUtcBit)
+ })
+ })
+
+ describe('utcMaskToLocalWeekdays', () => {
+ it('should return an empty array for a 0 or absent mask', () => {
+ expect(utcMaskToLocalWeekdays(0, 'x')).toEqual([])
+ expect(utcMaskToLocalWeekdays(null, 'x')).toEqual([])
+ expect(utcMaskToLocalWeekdays(undefined, 'x')).toEqual([])
+ })
+
+ it('should round-trip with localWeekdaysToUtcMask under a non-zero delta', () => {
+ mockDelta.mockReturnValue(-1)
+ const days = [1, 3, 5]
+ expect(utcMaskToLocalWeekdays(localWeekdaysToUtcMask(days, 'x'), 'x')).toEqual(days)
+ })
+ })
+})
diff --git a/src/utils/recurrence.ts b/src/utils/recurrence.ts
index ec30cc5d..6bdc51b3 100644
--- a/src/utils/recurrence.ts
+++ b/src/utils/recurrence.ts
@@ -3,6 +3,8 @@
// (not `hooks/useCreateEventForm.helpers.ts`) because consumers outside the form
// rely on these too — the previous location implied form-only ownership.
+import { getUtcDayDelta } from './whatsOnTime'
+
const WEEKDAY_INDICES: ReadonlyArray = [0, 1, 2, 3, 4, 5, 6]
const ALL_WEEKDAYS: ReadonlyArray = WEEKDAY_INDICES
@@ -28,6 +30,42 @@ function parseStartWeekday(startDate: string): number | null {
return date.getDay()
}
+// Shift a weekday index by a whole-day delta, wrapping around the week (Sun=0..Sat=6).
+function shiftWeekday(dayIndex: number, delta: number): number {
+ return (((dayIndex + delta) % 7) + 7) % 7
+}
+
+// Merge the user's extra weekday picks with the start date's weekday, which is ALWAYS part of the
+// recurrence. Returning a sorted, de-duped set keeps the chips and the submitted mask in lockstep.
+function effectiveWeekdays(repeatDays: number[], startWeekday: number | null): number[] {
+ const days = new Set(repeatDays.filter(d => d >= 0 && d <= 6))
+ if (startWeekday !== null) days.add(startWeekday)
+ return [...days].sort((a, b) => a - b)
+}
+
+// The events API stores `recurrent_weekday_mask` and runs its RRule in UTC, while the form collects
+// weekdays in the creator's LOCAL calendar. If we sent the local weekdays verbatim, an event whose
+// start crosses the UTC day boundary would recur on the wrong day — and, worse, when start_at's own
+// UTC weekday wasn't in the mask the server tacked the start onto the series as a phantom extra day
+// (issue #560, "Toxic Tuesday"). Converting every local weekday by the start instant's UTC day delta
+// keeps occurrences on the creator's intended local days AND guarantees start_at's UTC weekday is in
+// the mask (since the start weekday is always one of `localDays`), so no phantom occurrence appears.
+function localWeekdaysToUtcMask(localDays: number[], startIso: string): number {
+ const delta = getUtcDayDelta(startIso)
+ return dayIndicesToWeekdayMask(localDays.map(day => shiftWeekday(day, delta)))
+}
+
+// Inverse of `localWeekdaysToUtcMask`, used when hydrating the edit form. Returns an empty array for
+// an absent/zero mask so the caller can fall back to the start weekday (mask 0 means "server defaults
+// to start_at's weekday", NOT "every weekday").
+function utcMaskToLocalWeekdays(mask: number | null | undefined, startIso: string): number[] {
+ if (!mask) return []
+ const delta = getUtcDayDelta(startIso)
+ return ALL_WEEKDAYS.filter(day => (mask & (1 << day)) !== 0)
+ .map(day => shiftWeekday(day, -delta))
+ .sort((a, b) => a - b)
+}
+
// Reference Sunday (UTC) — `1970-01-04` was a Sunday. Adding `dayIndex * 86_400_000` ms yields a
// UTC date that falls on the requested weekday. `timeZone: 'UTC'` on the formatter is critical —
// without it the local TZ offset can shift the formatter onto the previous/next day.
@@ -60,9 +98,12 @@ export {
ALL_WEEKDAYS,
WEEKDAY_INDICES,
dayIndicesToWeekdayMask,
+ effectiveWeekdays,
+ localWeekdaysToUtcMask,
localizedWeekdayLong,
localizedWeekdayShort,
normalizeDayIndices,
parseStartWeekday,
+ utcMaskToLocalWeekdays,
weekdayMaskToDayIndices
}