Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion src/components/whats-on/CreateEvent/EventForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,23 @@ jest.mock('./EventForm.styled', () => ({
UpcomingDatesEmpty: ({ children }: { children: React.ReactNode }) => <span data-testid="upcoming-dates-empty">{children}</span>,
UpcomingDatesGroup: ({ children }: { children: React.ReactNode }) => <div data-testid="upcoming-dates-group">{children}</div>,
UpcomingDatesLabel: ({ children }: { children: React.ReactNode }) => <span data-testid="upcoming-dates-label">{children}</span>,
UpcomingDatesList: ({ children }: { children: React.ReactNode }) => <ul data-testid="upcoming-dates-list">{children}</ul>
UpcomingDatesList: ({ children }: { children: React.ReactNode }) => <ul data-testid="upcoming-dates-list">{children}</ul>,
WeekdayChip: ({
children,
$active,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { children: React.ReactNode; $active: boolean }) => (
<button data-testid="weekday-chip" data-active={$active} {...props}>
{children}
</button>
),
WeekdayChipGroup: ({ children, ...props }: { children: React.ReactNode } & Record<string, unknown>) => (
<div data-testid="weekday-chip-group" {...props}>
{children}
</div>
),
WeekdayChipLabel: ({ children }: { children: React.ReactNode }) => <span data-testid="weekday-chip-label">{children}</span>,
WeekdayChipRow: ({ children }: { children: React.ReactNode }) => <div data-testid="weekday-chip-row">{children}</div>
}))

jest.mock('../EventDetailModal', () => ({
Expand Down Expand Up @@ -223,6 +239,7 @@ function createFormState(overrides = {}) {
duration: '',
repeatEnabled: false,
recurrence: 'every_week',
repeatDays: [],
repeatEndDate: '',
location: 'land',
coordX: '0',
Expand Down Expand Up @@ -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(<EventForm onCancel={mockOnCancel} onSuccess={jest.fn()} />)

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(<EventForm onCancel={mockOnCancel} onSuccess={jest.fn()} />)

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(<EventForm onCancel={mockOnCancel} onSuccess={jest.fn()} />)

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', () => {
Expand Down
65 changes: 63 additions & 2 deletions src/components/whats-on/CreateEvent/EventForm.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ({
Expand Down Expand Up @@ -664,5 +721,9 @@ export {
UpcomingDatesEmpty,
UpcomingDatesGroup,
UpcomingDatesLabel,
UpcomingDatesList
UpcomingDatesList,
WeekdayChip,
WeekdayChipGroup,
WeekdayChipLabel,
WeekdayChipRow
}
74 changes: 69 additions & 5 deletions src/components/whats-on/CreateEvent/EventForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -70,7 +77,11 @@ import {
UpcomingDatesEmpty,
UpcomingDatesGroup,
UpcomingDatesLabel,
UpcomingDatesList
UpcomingDatesList,
WeekdayChip,
WeekdayChipGroup,
WeekdayChipLabel,
WeekdayChipRow
} from './EventForm.styled'

const PREVIEW_REQUIRED_FIELDS: Array<keyof CreateEventFormState> = ['name', 'startDate', 'startTime', 'duration']
Expand All @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -343,6 +382,31 @@ function EventForm({ onCancel, onSuccess, initialEvent = null, initialCommunityI
))}
</EventSelect>
</EventFormControl>
{isWeeklyCadence && (
<WeekdayChipGroup role="group" aria-label={t('create_event.repeat_on')}>
<WeekdayChipLabel>{t('create_event.repeat_on')}</WeekdayChipLabel>
<WeekdayChipRow>
{WEEKDAY_INDICES.map(dayIndex => {
const isActive = selectedWeekdays.includes(dayIndex)
const isLocked = dayIndex === startWeekday
return (
<WeekdayChip
key={dayIndex}
type="button"
role="checkbox"
aria-checked={isActive}
aria-label={localizedWeekdayLong(dayIndex, locale)}
$active={isActive}
disabled={isLocked}
onClick={() => toggleWeekday(dayIndex)}
>
{localizedWeekdayShort(dayIndex, locale)}
</WeekdayChip>
)
})}
</WeekdayChipRow>
</WeekdayChipGroup>
)}
<EventTextField
variant="outlined"
label={t('create_event.repeat_until')}
Expand Down
19 changes: 12 additions & 7 deletions src/components/whats-on/EventDetailModal/normalizers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { EventEntry, LiveNowCard } from '../../../features/events'
import { weekdayMaskToDayIndices } from '../../../utils/recurrence'
import { utcMaskToLocalWeekdays, weekdayMaskToDayIndices } from '../../../utils/recurrence'
import { buildEventJumpInUrl, buildJumpInUrl, parseCoordinates, resolveEventRealm } from '../../../utils/whatsOnUrl'
import type { ModalEventData } from './EventDetailModal.types'

// Decode the server's WeekdayMask into a sorted day-of-week array. Returns undefined when the
// event has no per-weekday selection so getRecurrenceLabel falls through to the frequency label.
function decodeRecurrentByDay(mask: number | null | undefined): number[] | undefined {
// Decode the server's UTC WeekdayMask into a sorted day-of-week array in the VIEWER's local calendar
// (anchored on the event's start instant), so the recurrence label matches the locally-formatted
// schedule date/time shown alongside it and the create-form preview. Returns undefined when the event
// has no per-weekday selection so getRecurrenceLabel falls through to the frequency label. Without a
// start instant to anchor the conversion we fall back to the raw mask decode.
function decodeRecurrentByDay(mask: number | null | undefined, startAt: string | null | undefined): number[] | undefined {
if (mask === null || mask === undefined || mask === 0) return undefined
return weekdayMaskToDayIndices(mask)
if (!startAt) return weekdayMaskToDayIndices(mask)
const localDays = utcMaskToLocalWeekdays(mask, startAt)
return localDays.length > 0 ? localDays : undefined
}

function normalizeEventEntry(event: EventEntry): ModalEventData {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading