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 }