From 591674e4e24bacfabb8f9cc54690270357c0de03 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Fri, 7 Aug 2026 07:15:00 +0100 Subject: [PATCH 1/2] Occurrence modal refactor --- e2e/pages/occurrence.page.ts | 8 +- .../layouts/OrganizerLayout/index.tsx | 7 +- .../RecurrenceScheduleDrawer.module.scss | 609 +++++++++ .../RecurrenceScheduleDrawer/index.tsx | 1169 +++++++++++++++++ .../RecurrenceScheduleModal.module.scss | 268 ---- .../RecurrenceScheduleModal/index.tsx | 885 ------------- .../routes/event/OccurrencesTab/index.tsx | 4 +- frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 408 +++--- frontend/src/locales/el.js | 2 +- frontend/src/locales/el.po | 408 +++--- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 392 +++--- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 408 +++--- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 408 +++--- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 408 +++--- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 408 +++--- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 408 +++--- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 408 +++--- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 408 +++--- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 408 +++--- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 408 +++--- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 408 +++--- frontend/src/locales/sk.js | 2 +- frontend/src/locales/sk.po | 408 +++--- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 408 +++--- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 408 +++--- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 408 +++--- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 408 +++--- 43 files changed, 6283 insertions(+), 4031 deletions(-) create mode 100644 frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/RecurrenceScheduleDrawer.module.scss create mode 100644 frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx delete mode 100644 frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleModal/RecurrenceScheduleModal.module.scss delete mode 100644 frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleModal/index.tsx diff --git a/e2e/pages/occurrence.page.ts b/e2e/pages/occurrence.page.ts index 8f01556183..b28db66bd3 100644 --- a/e2e/pages/occurrence.page.ts +++ b/e2e/pages/occurrence.page.ts @@ -1,4 +1,4 @@ -import type { Locator, Page } from '@playwright/test'; +import { expect, type Locator, type Page } from '@playwright/test'; export class OccurrencePage { constructor(private readonly page: Page) {} @@ -17,11 +17,13 @@ export class OccurrencePage { } async pickWeekday(label: string): Promise { - await this.dialog().getByRole('checkbox', { name: label, exact: true }).check({ force: true }); + const dot = this.dialog().getByRole('checkbox', { name: new RegExp(`^${label}`) }); + await dot.click(); + await expect(dot).toBeChecked(); } async chooseFixedNumberOfDates(count: number): Promise { - await this.dialog().getByText('Set number of dates').click(); + await this.dialog().getByText('For a number of dates').click(); await this.dialog().getByLabel(/^Number of dates to create/).fill(String(count)); } diff --git a/frontend/src/components/layouts/OrganizerLayout/index.tsx b/frontend/src/components/layouts/OrganizerLayout/index.tsx index 5ecea5d021..81b03e8577 100644 --- a/frontend/src/components/layouts/OrganizerLayout/index.tsx +++ b/frontend/src/components/layouts/OrganizerLayout/index.tsx @@ -88,7 +88,12 @@ const OrganizerLayout = () => { }, ] as NavItem[] : []), { label: 'Overview' }, - { link: 'dashboard', label: t`Organizer Dashboard`, icon: IconDashboard }, + { + link: 'dashboard', + label: t`Organizer Dashboard`, + icon: IconDashboard, + isActive: (isActive) => isActive || /\/manage\/organizer(\/[^/]+)?\/?$/.test(location.pathname), + }, { link: 'reports', label: t`Reports`, diff --git a/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/RecurrenceScheduleDrawer.module.scss b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/RecurrenceScheduleDrawer.module.scss new file mode 100644 index 0000000000..5ed6617c3d --- /dev/null +++ b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/RecurrenceScheduleDrawer.module.scss @@ -0,0 +1,609 @@ +@use "../../../../../styles/mixins"; + +.content { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: 0; +} + +.header { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + padding: 18px var(--mantine-spacing-lg); + border-bottom: 1px solid var(--hi-color-gray-2); + background-color: var(--mantine-color-body); + flex-shrink: 0; +} + +.headerText { + display: flex; + align-items: baseline; + gap: var(--mantine-spacing-sm); + min-width: 0; +} + +.headerTitle { + margin: 0; + font-size: 1.0625rem; + font-weight: 700; + letter-spacing: -0.01em; + white-space: nowrap; +} + +.headerActions { + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + margin-inline-start: auto; + flex-shrink: 0; +} + +.escHint { + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-text-tertiary); + + @include mixins.respond-below(md) { + display: none; + } +} + +.main { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + flex: 1; + min-height: 0; +} + +.formColumn { + overflow-y: auto; + padding: var(--mantine-spacing-lg); + outline: none; +} + +.previewColumn { + overflow-y: auto; + padding: var(--mantine-spacing-lg); + background-color: var(--hi-color-gray); + border-left: 1px solid var(--hi-color-gray-2); + display: flex; + flex-direction: column; + gap: var(--hi-spacing-md); +} + +.previewEyebrow { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--hi-color-gray-dark); +} + +.footer { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + padding: var(--mantine-spacing-md) var(--mantine-spacing-lg); + border-top: 1px solid var(--hi-color-gray-2); + background-color: var(--mantine-color-body); + flex-shrink: 0; +} + +.footerHint { + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); + min-width: 0; +} + +.footerActions { + display: flex; + gap: var(--mantine-spacing-sm); + margin-inline-start: auto; + flex-shrink: 0; +} + +.block { + padding: var(--hi-spacing-lg) 0; + + &:first-child { + padding-top: 0; + } + + & + & { + border-top: 1px solid var(--hi-color-border); + } +} + +.blockHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--hi-spacing-md); + margin-bottom: var(--hi-spacing-md); + min-height: 30px; + flex-wrap: wrap; + + &:last-child { + margin-bottom: 0; + } +} + +.blockLabel { + font-weight: 600; + font-size: var(--mantine-font-size-sm); +} + +.capacityInput { + width: 170px; +} + +.fieldHint { + margin: var(--hi-spacing-sm) 0 0; + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); +} + +.sentenceRow { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.sentenceRow :global(.mantine-InputWrapper-root), +.timeSlot :global(.mantine-InputWrapper-root), +.blockHeader :global(.mantine-InputWrapper-root) { + margin-bottom: 0; +} + +.sentenceWord { + font-size: var(--mantine-font-size-sm); + color: var(--mantine-color-text); +} + +.stepper { + display: inline-flex; + align-items: stretch; + border: 1.5px solid var(--hi-color-gray-2); + border-radius: 7px; + overflow: hidden; + + button { + width: 30px; + border: none; + background: none; + font: inherit; + font-size: var(--mantine-font-size-md); + color: var(--hi-color-gray-dark); + cursor: pointer; + padding: 4px 0; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); + + &:hover:not(:disabled) { + background-color: var(--hi-color-gray); + color: var(--hi-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + outline-offset: -2px; + } + } + + span { + display: flex; + align-items: center; + justify-content: center; + min-width: 34px; + padding: 0 4px; + font-size: var(--mantine-font-size-sm); + font-weight: 600; + border-left: 1px solid var(--hi-color-border); + border-right: 1px solid var(--hi-color-border); + } +} + +.dayDots { + display: inline-flex; + gap: 2px; + flex-wrap: wrap; +} + +.dayDot { + width: 44px; + height: 44px; + padding: 4px; + border: none; + background: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + span { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1.5px solid var(--hi-color-gray-2); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: var(--mantine-color-text); + transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); + } + + &:hover:not(.selected) span { + border-color: var(--hi-primary); + color: var(--hi-primary); + } + + &.selected span { + background-color: var(--hi-primary); + border-color: var(--hi-primary); + color: var(--mantine-color-white); + font-weight: 700; + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + outline-offset: -2px; + border-radius: 50%; + } +} + +.patternContent { + margin-top: var(--hi-spacing-md); +} + +.monthGrid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 4px; + max-width: 320px; +} + +.monthCell { + aspect-ratio: 1; + border-radius: 50%; + border: 1.5px solid var(--hi-color-gray-2); + background: none; + font: inherit; + font-size: var(--mantine-font-size-xs); + font-weight: 500; + color: var(--mantine-color-text); + cursor: pointer; + transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); + + &:hover:not(.selected) { + border-color: var(--hi-primary); + color: var(--hi-primary); + } + + &.selected { + background-color: var(--hi-primary); + border-color: var(--hi-primary); + color: var(--mantine-color-white); + font-weight: 700; + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + outline-offset: 1px; + } +} + +.inlineSelect { + width: 150px; +} + +.inlineDayInput { + width: 64px; + + input { + text-align: center; + } +} + +.inlineDateInput { + width: 150px; +} + +.inlineCountInput { + width: 90px; +} + +.timeSlot { + display: grid; + grid-template-columns: 100px 14px 100px minmax(0, 1fr) 28px; + grid-template-areas: "start sep end label remove"; + gap: var(--hi-spacing-sm); + align-items: center; +} + +.slotStart { + grid-area: start; +} + +.slotSep { + grid-area: sep; +} + +.slotEnd { + grid-area: end; +} + +.slotLabel { + grid-area: label; +} + +.slotRemove { + grid-area: remove; +} + +.timeInput { + text-align: center; +} + +.timeSeparator { + font-size: var(--mantine-font-size-sm); + color: var(--hi-color-gray-dark); + text-align: center; +} + +.endTimeWrap { + position: relative; +} + +.plusDayPill { + position: absolute; + top: -8px; + right: -6px; + z-index: 1; + padding: 1px 6px; + border-radius: 999px; + background-color: var(--hi-accent-brand-soft); + color: var(--hi-primary); + font-size: 10px; + font-weight: 600; + white-space: nowrap; + pointer-events: none; +} + +.countCard { + display: flex; + align-items: baseline; + gap: var(--hi-spacing-sm); + border-radius: var(--hi-radius-lg); + background-color: var(--mantine-color-body); + border: 1px solid var(--hi-color-gray-2); + padding: 14px 16px; +} + +.countNumber { + font-size: 24px; + font-weight: 800; + color: var(--hi-primary); + line-height: 1; +} + +.countNumberWarning { + color: var(--hi-color-warning); +} + +.countSubtitle { + font-size: 13px; + color: var(--hi-color-gray-dark); +} + +.calendarCard { + border-radius: var(--hi-radius-lg); + background-color: var(--mantine-color-body); + border: 1px solid var(--hi-color-gray-2); + padding: var(--hi-spacing-md); +} + +.calendarHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--hi-spacing-sm); + margin-bottom: var(--hi-spacing-sm); + + span { + font-size: 13px; + font-weight: 700; + text-align: center; + } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: none; + border-radius: var(--hi-radius-sm); + color: var(--hi-color-gray-dark); + cursor: pointer; + + &:hover:not(:disabled) { + background-color: var(--hi-color-gray); + color: var(--hi-primary); + } + + &:disabled { + opacity: 0.35; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid var(--mantine-color-primary-4); + } + } +} + +.calendarWeekdays { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + margin-bottom: 4px; + + span { + text-align: center; + font-size: 9.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--hi-color-text-tertiary); + } +} + +.calendarGrid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 2px; +} + +.calendarCell { + display: inline-flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + border-radius: 50%; + font-size: 11.5px; + color: var(--mantine-color-text); + + &.outside { + color: var(--hi-color-text-tertiary); + } + + &.occurrence { + background-color: var(--hi-primary); + color: var(--mantine-color-white); + font-weight: 700; + } +} + +.ruleBox { + display: flex; + align-items: flex-start; + gap: var(--hi-spacing-sm); + border-radius: var(--hi-radius-md); + background-color: var(--hi-color-success-soft); + color: var(--hi-color-success); + padding: var(--hi-spacing-md); + font-size: var(--mantine-font-size-xs); + line-height: 1.5; + + strong { + font-weight: 700; + } + + svg { + flex-shrink: 0; + margin-top: 1px; + } +} + +.ruleDot { + flex-shrink: 0; + width: 7px; + height: 7px; + margin-top: 5px; + border-radius: 50%; + background-color: var(--hi-color-success); +} + +.ruleBoxWarning { + background-color: color-mix(in srgb, var(--hi-color-warning) 14%, transparent); + color: var(--hi-color-warning); +} + +.previewFootnote { + margin: 0; + font-size: 11px; + color: var(--hi-color-text-tertiary); +} + +.previewEmpty { + display: flex; + align-items: center; + gap: var(--hi-spacing-sm); + border: 1px dashed var(--hi-color-gray-2); + border-radius: var(--hi-radius-lg); + padding: var(--hi-spacing-md); + font-size: var(--mantine-font-size-xs); + color: var(--hi-color-gray-dark); + + svg { + flex-shrink: 0; + color: var(--hi-color-text-tertiary); + } +} + +.previewBelowForm { + display: none; +} + +@include mixins.respond-below(xl) { + .main { + grid-template-columns: minmax(0, 1fr); + } + + .previewColumn { + display: none; + } + + .previewBelowForm { + display: flex; + flex-direction: column; + gap: var(--hi-spacing-md); + margin-top: var(--mantine-spacing-md); + } +} + +@include mixins.respond-below(sm) { + .header, + .footer { + padding-left: var(--mantine-spacing-md); + padding-right: var(--mantine-spacing-md); + } + + .formColumn { + padding: var(--mantine-spacing-md); + } + + .footerHint { + display: none; + } + + .footerActions { + flex: 1; + + button { + flex: 1; + } + } + + .timeSlot { + grid-template-columns: minmax(0, 1fr) 14px minmax(0, 1fr) 28px; + grid-template-areas: + "start sep end remove" + "label label label label"; + } +} diff --git a/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx new file mode 100644 index 0000000000..d21d10d895 --- /dev/null +++ b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleDrawer/index.tsx @@ -0,0 +1,1169 @@ +import React, {useEffect, useId, useMemo, useState} from "react"; +import {t} from "@lingui/macro"; +import { + ActionIcon, + Button, + CloseButton, + Drawer, + NumberInput, + SegmentedControl, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import {useForm} from "@mantine/form"; +import {modals} from "@mantine/modals"; +import {useParams} from "react-router"; +import { + IconAlertTriangle, + IconChevronLeft, + IconChevronRight, + IconPlus, + IconSparkles, + IconX, +} from "@tabler/icons-react"; +import classNames from "classnames"; + +import {GenericModalProps, RecurrenceRule, RecurrenceTimeSlot} from "../../../../../types.ts"; +import {useGenerateOccurrences} from "../../../../../mutations/useGenerateOccurrences.ts"; +import {GET_EVENT_QUERY_KEY, useGetEvent} from "../../../../../queries/useGetEvent.ts"; +import {GET_EVENT_OCCURRENCES_QUERY_KEY} from "../../../../../queries/useGetEventOccurrences.ts"; +import {useQueryClient} from "@tanstack/react-query"; +import {showError, showSuccess} from "../../../../../utilites/notifications.tsx"; +import {useFormErrorResponseHandler} from "../../../../../hooks/useFormErrorResponseHandler.tsx"; +import classes from './RecurrenceScheduleDrawer.module.scss'; + +const MAX_PREVIEW = 1200; + +const DAYS_OF_WEEK = [ + {value: 'monday', label: t`Mon`, full: t`Monday`}, + {value: 'tuesday', label: t`Tue`, full: t`Tuesday`}, + {value: 'wednesday', label: t`Wed`, full: t`Wednesday`}, + {value: 'thursday', label: t`Thu`, full: t`Thursday`}, + {value: 'friday', label: t`Fri`, full: t`Friday`}, + {value: 'saturday', label: t`Sat`, full: t`Saturday`}, + {value: 'sunday', label: t`Sun`, full: t`Sunday`}, +]; + +const FREQUENCIES = [ + {value: 'daily', label: t`Daily`}, + {value: 'weekly', label: t`Weekly`}, + {value: 'monthly', label: t`Monthly`}, + {value: 'yearly', label: t`Yearly`}, +]; + +const WEEK_POSITIONS = [ + {value: '1', label: t`First`}, + {value: '2', label: t`Second`}, + {value: '3', label: t`Third`}, + {value: '4', label: t`Fourth`}, + {value: '-1', label: t`Last`}, +]; + +const MONTHS = [ + {value: '1', label: t`January`}, + {value: '2', label: t`February`}, + {value: '3', label: t`March`}, + {value: '4', label: t`April`}, + {value: '5', label: t`May`}, + {value: '6', label: t`June`}, + {value: '7', label: t`July`}, + {value: '8', label: t`August`}, + {value: '9', label: t`September`}, + {value: '10', label: t`October`}, + {value: '11', label: t`November`}, + {value: '12', label: t`December`}, +]; + +const DAY_NUMBER_MAP: Record = { + 'sunday': 0, 'monday': 1, 'tuesday': 2, 'wednesday': 3, + 'thursday': 4, 'friday': 5, 'saturday': 6, +}; + +const frequencyUnitLabel = (frequency: string, interval: number): string => { + if (interval === 1) { + switch (frequency) { + case 'daily': return t`day`; + case 'weekly': return t`week`; + case 'monthly': return t`month`; + case 'yearly': return t`year`; + default: return ''; + } + } + switch (frequency) { + case 'daily': return t`days`; + case 'weekly': return t`weeks`; + case 'monthly': return t`months`; + case 'yearly': return t`years`; + default: return ''; + } +}; + +const getNthWeekdayOfMonth = (year: number, month: number, dayOfWeek: number, position: number): Date | null => { + if (position === -1) { + const lastDay = new Date(year, month + 1, 0); + for (let d = lastDay.getDate(); d >= 1; d--) { + const candidate = new Date(year, month, d); + if (candidate.getDay() === dayOfWeek) return candidate; + } + return null; + } + let count = 0; + for (let d = 1; d <= 31; d++) { + const candidate = new Date(year, month, d); + if (candidate.getMonth() !== month) break; + if (candidate.getDay() === dayOfWeek) { + count++; + if (count === position) return candidate; + } + } + return null; +}; + +const parseLocalDate = (value: string): Date | null => { + if (!value) return null; + const [y, m, d] = value.split('-').map(Number); + if (!y || !m || !d) return null; + return new Date(y, m - 1, d); +}; + +const computePreviewDates = (values: RecurrenceFormValues): Date[] => { + const dates: Date[] = []; + const today = parseLocalDate(values.range_start) ?? new Date(); + today.setHours(0, 0, 0, 0); + + const endDate = values.range_type === 'until' && values.range_until + ? new Date(values.range_until + 'T23:59:59') + : null; + const maxCount = values.range_type === 'count' + ? Math.min(values.range_count || 1, MAX_PREVIEW) + : MAX_PREVIEW; + + if (values.range_type === 'until' && !endDate) return dates; + + const addCandidate = (date: Date): boolean => { + if (endDate && date > endDate) return false; + if (dates.length >= maxCount) return false; + dates.push(new Date(date)); + return true; + }; + + switch (values.frequency) { + case 'daily': { + const current = new Date(today); + let safety = 0; + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + if (!addCandidate(current)) break; + current.setDate(current.getDate() + (values.interval || 1)); + safety++; + } + break; + } + case 'weekly': { + const selectedDays = values.days_of_week + .map(d => DAY_NUMBER_MAP[d]) + .filter(d => d !== undefined) + .sort((a, b) => a - b); + if (selectedDays.length === 0) break; + + const weekStart = new Date(today); + const todayDay = weekStart.getDay(); + const diff = todayDay === 0 ? -6 : 1 - todayDay; + weekStart.setDate(weekStart.getDate() + diff); + + let safety = 0; + outer: + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + for (const dayNum of selectedDays) { + const candidate = new Date(weekStart); + const offset = dayNum === 0 ? 6 : dayNum - 1; + candidate.setDate(weekStart.getDate() + offset); + if (candidate >= today) { + if (!addCandidate(candidate)) break outer; + } + } + weekStart.setDate(weekStart.getDate() + 7 * (values.interval || 1)); + safety++; + } + break; + } + case 'monthly': { + if (values.monthly_pattern === 'by_day_of_month') { + const days = values.days_of_month + .map(d => parseInt(d)) + .filter(n => !isNaN(n)) + .sort((a, b) => a - b); + if (days.length === 0) break; + + const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1); + let safety = 0; + outer2: + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + for (const day of days) { + const candidate = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day); + if (candidate.getMonth() !== currentMonth.getMonth()) continue; + if (candidate >= today) { + if (!addCandidate(candidate)) break outer2; + } + } + currentMonth.setMonth(currentMonth.getMonth() + (values.interval || 1)); + safety++; + } + } else { + const targetDay = DAY_NUMBER_MAP[values.day_of_week] ?? 1; + const position = parseInt(values.week_position) || 1; + + const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1); + let safety = 0; + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + const candidate = getNthWeekdayOfMonth( + currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, position + ); + if (candidate && candidate >= today) { + if (!addCandidate(candidate)) break; + } + currentMonth.setMonth(currentMonth.getMonth() + (values.interval || 1)); + safety++; + } + } + break; + } + case 'yearly': { + const month = parseInt(values.yearly_month) - 1; + const day = values.yearly_day; + let year = today.getFullYear(); + let safety = 0; + while (dates.length < maxCount && safety < MAX_PREVIEW + 100) { + const candidate = new Date(year, month, day); + if (candidate.getMonth() === month && candidate >= today) { + if (!addCandidate(candidate)) break; + } + if (endDate && candidate > endDate) break; + year += (values.interval || 1); + safety++; + } + break; + } + } + + return dates; +}; + +const computeEndTime = (startTime: string, durationMinutes: number): string => { + if (!startTime || !durationMinutes) return ''; + const [h, m] = startTime.split(':').map(Number); + if (isNaN(h) || isNaN(m)) return ''; + const totalMinutes = h * 60 + m + durationMinutes; + const endH = Math.floor(totalMinutes / 60) % 24; + const endM = totalMinutes % 60; + return `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`; +}; + +const computeDurationFromTimes = (startTime: string, endTime: string): number | null => { + if (!startTime || !endTime) return null; + const [sh, sm] = startTime.split(':').map(Number); + const [eh, em] = endTime.split(':').map(Number); + if (isNaN(sh) || isNaN(sm) || isNaN(eh) || isNaN(em)) return null; + let diff = (eh * 60 + em) - (sh * 60 + sm); + if (diff <= 0) diff += 24 * 60; + return diff; +}; + +const slotWrapsMidnight = (slot: TimeSlotFormValue): boolean => { + if (!slot.time || !slot.end_time) return false; + const [sh, sm] = slot.time.split(':').map(Number); + const [eh, em] = slot.end_time.split(':').map(Number); + if (isNaN(sh) || isNaN(sm) || isNaN(eh) || isNaN(em)) return false; + return (eh * 60 + em) - (sh * 60 + sm) <= 0; +}; + +const formatShortDate = (date: Date): string => { + return date.toLocaleDateString(undefined, {day: 'numeric', month: 'short'}); +}; + +interface TimeSlotFormValue { + time: string; + end_time: string; + label: string; +} + +interface RecurrenceFormValues { + frequency: string; + interval: number; + days_of_week: string[]; + time_slots: TimeSlotFormValue[]; + range_start: string; + range_type: string; + range_until: string; + range_count: number; + default_capacity: number | undefined; + monthly_pattern: string; + days_of_month: string[]; + day_of_week: string; + week_position: string; + yearly_month: string; + yearly_day: number; +} + +const formatLocalDate = (date: Date): string => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +}; + +const todayLocalDate = (): string => formatLocalDate(new Date()); + +const IntervalStepper = ({value, onChange}: { value: number; onChange: (value: number) => void }) => ( +
+ + {value || 1} + +
+); + +const DayDots = ({selected, onToggle}: { selected: string[]; onToggle: (value: string) => void }) => ( +
+ {DAYS_OF_WEEK.map(day => { + const isSelected = selected.includes(day.value); + return ( + + ); + })} +
+); + +const MonthDayGrid = ({selected, onToggle}: { selected: string[]; onToggle: (value: string) => void }) => ( +
+ {Array.from({length: 31}, (_, i) => String(i + 1)).map(day => { + const isSelected = selected.includes(day); + return ( + + ); + })} +
+); + +const monthIndexOf = (date: Date): number => date.getFullYear() * 12 + date.getMonth(); + +const MiniCalendar = ({previewDates, rangeStart}: { previewDates: Date[]; rangeStart: string }) => { + const [viewMonth, setViewMonth] = useState(null); + + const firstMonth = monthIndexOf(previewDates[0]); + const lastMonth = monthIndexOf(previewDates[previewDates.length - 1]); + + useEffect(() => { + setViewMonth(current => current === null || current < firstMonth || current > lastMonth + ? firstMonth + : current); + }, [firstMonth, lastMonth]); + + const month = viewMonth === null || viewMonth < firstMonth || viewMonth > lastMonth ? firstMonth : viewMonth; + const year = Math.floor(month / 12); + const monthOfYear = month % 12; + + const occurrenceKeys = useMemo( + () => new Set(previewDates.map(formatLocalDate)), + [previewDates] + ); + + const startDate = parseLocalDate(rangeStart); + const firstOfMonth = new Date(year, monthOfYear, 1); + const gridStart = new Date(firstOfMonth); + gridStart.setDate(gridStart.getDate() - ((gridStart.getDay() + 6) % 7)); + + const cells = Array.from({length: 42}, (_, i) => { + const date = new Date(gridStart); + date.setDate(gridStart.getDate() + i); + return date; + }); + + return ( +
+
+ + {firstOfMonth.toLocaleDateString(undefined, {month: 'long', year: 'numeric'})} + +
+
+ {DAYS_OF_WEEK.map(day => {day.label})} +
+
+ {cells.map((date, i) => { + const outside = date.getMonth() !== monthOfYear + || (startDate !== null && date < startDate); + const isOccurrence = occurrenceKeys.has(formatLocalDate(date)); + return ( + + {date.getDate()} + + ); + })} +
+
+ ); +}; + +interface RuleSentence { + bold: string; + rest: string; +} + +interface RecurrenceScheduleDrawerProps extends GenericModalProps { + onGenerationStarted: (jobUuid: string, totalCount: number) => void; +} + +export const RecurrenceScheduleDrawer = ({onClose, onGenerationStarted}: RecurrenceScheduleDrawerProps) => { + const {eventId} = useParams(); + const {data: event} = useGetEvent(eventId); + const generateMutation = useGenerateOccurrences(); + const queryClient = useQueryClient(); + const errorHandler = useFormErrorResponseHandler(); + const headingId = useId(); + const formId = useId(); + + const hasExistingRule = !!event?.recurrence_rule; + + const parseTimeSlotsFromRule = (rule: RecurrenceRule): TimeSlotFormValue[] => { + const times = rule.times_of_day; + const fallbackDuration = rule.duration_minutes || 120; + + if (!times?.length) { + return [{time: '09:00', end_time: computeEndTime('09:00', fallbackDuration), label: ''}]; + } + + return times.map((entry) => { + if (typeof entry === 'string') { + return { + time: entry, + end_time: computeEndTime(entry, fallbackDuration), + label: '', + }; + } + const duration = entry.duration_minutes || fallbackDuration; + return { + time: entry.time, + end_time: computeEndTime(entry.time, duration), + label: entry.label || '', + }; + }); + }; + + const form = useForm({ + initialValues: { + frequency: 'weekly', + interval: 1, + days_of_week: [], + time_slots: [{time: '09:00', end_time: '11:00', label: ''}], + range_start: todayLocalDate(), + range_type: 'until', + range_until: '', + range_count: 10, + default_capacity: undefined, + monthly_pattern: 'by_day_of_month', + days_of_month: ['1'], + day_of_week: 'monday', + week_position: '1', + yearly_month: String(new Date().getMonth() + 1), + yearly_day: 1, + }, + validate: { + days_of_week: (value, values) => values.frequency === 'weekly' && value.length === 0 + ? t`Pick at least one day of the week` + : null, + range_until: (value, values) => values.range_type === 'until' && !value + ? t`Pick an end date` + : null, + time_slots: (value) => value.every(s => !s.time.trim()) + ? t`Add at least one time` + : null, + days_of_month: (value, values) => values.frequency === 'monthly' + && values.monthly_pattern === 'by_day_of_month' + && value.length === 0 + ? t`Pick at least one day of the month` + : null, + }, + }); + + useEffect(() => { + if (event?.recurrence_rule) { + const rule = event.recurrence_rule; + const earliestOccurrence = event.occurrences?.length + ? event.occurrences + .map(o => o.start_date) + .filter((d): d is string => !!d) + .sort()[0] + : undefined; + const startFromRule = rule.range?.start; + const fallbackStart = earliestOccurrence + ? earliestOccurrence.slice(0, 10) + : todayLocalDate(); + + form.setValues({ + frequency: rule.frequency || 'weekly', + interval: rule.interval || 1, + days_of_week: rule.days_of_week || [], + time_slots: parseTimeSlotsFromRule(rule), + range_start: startFromRule ? startFromRule.slice(0, 10) : fallbackStart, + range_type: rule.range?.type || 'until', + range_until: rule.range?.until || '', + range_count: rule.range?.count || 10, + default_capacity: rule.default_capacity ?? undefined, + monthly_pattern: rule.monthly_pattern || 'by_day_of_month', + days_of_month: rule.days_of_month?.map(String) || ['1'], + day_of_week: rule.day_of_week || 'monday', + week_position: String(rule.week_position || 1), + yearly_month: String(rule.month || new Date().getMonth() + 1), + yearly_day: rule.days_of_month?.[0] || 1, + }); + form.resetDirty(); + } + }, [event]); + + const toggleDayOfWeek = (value: string) => { + const current = form.values.days_of_week; + form.setFieldValue('days_of_week', current.includes(value) + ? current.filter(d => d !== value) + : [...current, value]); + }; + + const toggleDayOfMonth = (value: string) => { + const current = form.values.days_of_month; + form.setFieldValue('days_of_month', current.includes(value) + ? current.filter(d => d !== value) + : [...current, value]); + }; + + const handleAddTime = () => { + const lastSlot = form.values.time_slots[form.values.time_slots.length - 1]; + const defaultStart = lastSlot?.end_time || '09:00'; + const defaultEnd = computeEndTime(defaultStart, 120); + form.setFieldValue('time_slots', [ + ...form.values.time_slots, + {time: defaultStart, end_time: defaultEnd, label: ''}, + ]); + }; + + const handleRemoveTime = (index: number) => { + const updated = form.values.time_slots.filter((_, i) => i !== index); + form.setFieldValue('time_slots', updated.length > 0 ? updated : [{time: '', end_time: '', label: ''}]); + }; + + const handleSlotChange = (index: number, field: keyof TimeSlotFormValue, value: string) => { + const updated = [...form.values.time_slots]; + updated[index] = {...updated[index], [field]: value}; + form.setFieldValue('time_slots', updated); + }; + + const previewDates = useMemo( + () => computePreviewDates(form.values), + [ + form.values.frequency, form.values.interval, form.values.days_of_week, + form.values.range_start, + form.values.range_type, form.values.range_until, form.values.range_count, + form.values.monthly_pattern, form.values.days_of_month, + form.values.day_of_week, form.values.week_position, + form.values.yearly_month, form.values.yearly_day, + ] + ); + + const validTimes = form.values.time_slots.filter(s => s.time.trim() !== ''); + const totalOccurrences = previewDates.length * Math.max(validTimes.length, 1); + const exceedsLimit = totalOccurrences > MAX_PREVIEW; + + const handleSubmit = (values: RecurrenceFormValues) => { + const filteredSlots = values.time_slots.filter(s => s.time.trim() !== ''); + + const timesOfDay: RecurrenceTimeSlot[] = filteredSlots.length > 0 + ? filteredSlots.map(s => { + const duration = computeDurationFromTimes(s.time, s.end_time); + return { + time: s.time, + ...(s.label ? {label: s.label} : {}), + ...(duration ? {duration_minutes: duration} : {}), + }; + }) + : [{time: '09:00'}]; + + const range: RecurrenceRule['range'] = values.range_type === 'until' + ? {type: 'until', until: values.range_until} + : {type: 'count', count: values.range_count}; + + if (values.range_start) { + range.start = values.range_start; + } + + const existingRule = event?.recurrence_rule; + const preservedMetadata: Partial = {}; + if (existingRule?.excluded_occurrences && existingRule.excluded_occurrences.length > 0) { + preservedMetadata.excluded_occurrences = existingRule.excluded_occurrences; + } + if (existingRule?.excluded_dates && existingRule.excluded_dates.length > 0) { + preservedMetadata.excluded_dates = existingRule.excluded_dates; + } + if (existingRule?.additional_dates && existingRule.additional_dates.length > 0) { + preservedMetadata.additional_dates = existingRule.additional_dates; + } + + const rule: RecurrenceRule = { + frequency: values.frequency as RecurrenceRule['frequency'], + interval: values.interval, + times_of_day: timesOfDay, + range, + default_capacity: values.default_capacity ?? null, + ...preservedMetadata, + }; + + if (values.frequency === 'weekly') { + rule.days_of_week = values.days_of_week; + } + + if (values.frequency === 'monthly') { + rule.monthly_pattern = values.monthly_pattern as RecurrenceRule['monthly_pattern']; + if (values.monthly_pattern === 'by_day_of_month') { + rule.days_of_month = values.days_of_month.map(d => parseInt(d)).filter(n => !isNaN(n)); + } else { + rule.day_of_week = values.day_of_week; + rule.week_position = parseInt(values.week_position); + } + } + + if (values.frequency === 'yearly') { + rule.month = parseInt(values.yearly_month); + rule.days_of_month = [values.yearly_day]; + } + + generateMutation.mutate({eventId, data: {recurrence_rule: rule}}, { + onSuccess: (response) => { + if (response.status === 'IN_PROGRESS' && response.job_uuid) { + onGenerationStarted(response.job_uuid, totalOccurrences); + onClose(); + } else if (response.status === 'FINISHED') { + showSuccess(t`Schedule created successfully`); + queryClient.invalidateQueries({queryKey: [GET_EVENT_OCCURRENCES_QUERY_KEY]}); + queryClient.invalidateQueries({queryKey: [GET_EVENT_QUERY_KEY, eventId]}); + onClose(); + } else { + showError(t`Failed to create schedule. Please try again.`); + } + }, + onError: (error: any) => { + const errors = error?.response?.data?.errors; + if (error?.response?.status === 422 && errors) { + const firstError = Object.values(errors).flat()[0] as string | undefined; + showError(firstError || t`Please check the provided information is correct`); + errorHandler(form, error); + } else { + showError(error?.response?.data?.message || t`Failed to create schedule`); + } + }, + }); + }; + + const handleClose = () => { + if (!form.isDirty()) { + onClose(); + return; + } + + modals.openConfirmModal({ + title: t`Discard changes?`, + children: ( + + {t`You have unsaved changes. Are you sure you want to discard them?`} + + ), + labels: {confirm: t`Discard`, cancel: t`Keep editing`}, + confirmProps: {color: 'red'}, + zIndex: 400, + onConfirm: onClose, + }); + }; + + const handleKeyDown = (keyboardEvent: React.KeyboardEvent) => { + if ((keyboardEvent.metaKey || keyboardEvent.ctrlKey) && keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault(); + (document.getElementById(formId) as HTMLFormElement | null)?.requestSubmit(); + } + }; + + const cadenceUnit = frequencyUnitLabel(form.values.frequency, form.values.interval); + const cadenceSummary = form.values.interval === 1 + ? t`Every ${cadenceUnit}` + : t`Every ${form.values.interval} ${cadenceUnit}`; + + const ruleSentence = useMemo((): RuleSentence | null => { + if (previewDates.length === 0) return null; + const values = form.values; + + let onPart = ''; + if (values.frequency === 'weekly') { + const dayList = DAYS_OF_WEEK + .filter(day => values.days_of_week.includes(day.value)) + .map(day => day.label) + .join(', '); + if (dayList) onPart = t`on ${dayList}`; + } else if (values.frequency === 'monthly') { + if (values.monthly_pattern === 'by_day_of_month') { + const dayList = values.days_of_month + .map(Number) + .filter(n => !isNaN(n)) + .sort((a, b) => a - b) + .join(', '); + if (dayList) onPart = t`on ${dayList}`; + } else { + const position = WEEK_POSITIONS.find(p => p.value === values.week_position)?.label ?? ''; + const day = DAYS_OF_WEEK.find(d => d.value === values.day_of_week)?.full ?? ''; + onPart = t`on the ${position} ${day}`; + } + } else if (values.frequency === 'yearly') { + const month = MONTHS.find(m => m.value === values.yearly_month)?.label ?? ''; + const day = values.yearly_day; + onPart = t`on ${month} ${day}`; + } + + const times = validTimes + .map(s => s.end_time ? `${s.time}–${s.end_time}` : s.time) + .join(` ${t`and`} `); + + const startDate = parseLocalDate(values.range_start) ?? new Date(); + const start = formatShortDate(startDate); + const range = values.range_type === 'until' && values.range_until + ? (() => { + const end = formatShortDate(parseLocalDate(values.range_until)!); + return t`from ${start} until ${end}`; + })() + : (() => { + const count = values.range_count; + return t`from ${start}, for ${count} dates`; + })(); + + return { + bold: onPart ? `${cadenceSummary} ${onPart}` : cadenceSummary, + rest: `, ${times}, ${range}.`, + }; + }, [form.values, previewDates.length, validTimes, cadenceSummary]); + + const dateCount = previewDates.length; + const timesPerDay = validTimes.length; + const max = MAX_PREVIEW; + + const countSubtitle = timesPerDay > 1 + ? t`sessions · ${dateCount} dates × ${timesPerDay} times` + : t`dates`; + + const ruleBox = ruleSentence && ( +
+ {exceedsLimit ? ( + <> + + {t`That's ${totalOccurrences} sessions — the maximum is ${max}. Shorten the range or frequency.`} + + ) : ( + <> + + {ruleSentence.bold}{ruleSentence.rest} + + )} +
+ ); + + const countLine = ( +
+ + {totalOccurrences} + + {countSubtitle} +
+ ); + + const emptyPreview = ( +
+ + {t`Pick days to see your dates.`} +
+ ); + + return ( + +
+
+

+ {hasExistingRule ? t`Edit schedule` : t`Set up your schedule`} +

+
+
+ {t`Esc to close`} + +
+
+ +
+
+
+
+
+ {t`Repeats`} + form.setFieldValue('frequency', value)} + data={FREQUENCIES} + /> +
+ +
+ {t`Every`} + form.setFieldValue('interval', value)} + /> + {cadenceUnit} + + {form.values.frequency === 'weekly' && ( + <> + {t`on`} + + + )} + + {form.values.frequency === 'monthly' && ( + <> + {t`on`} + form.setFieldValue('monthly_pattern', value)} + data={[ + {label: t`Days of the month`, value: 'by_day_of_month'}, + {label: t`A weekday pattern`, value: 'by_day_of_week'}, + ]} + /> + + )} + + {form.values.frequency === 'yearly' && ( + <> + {t`on`} + + - - {frequencyUnitLabel(form.values.frequency, form.values.interval)} - - } - rightSectionWidth={60} - {...form.getInputProps('interval')} - /> - - - {form.values.frequency === 'weekly' && ( - - - {DAYS_OF_WEEK.map(day => ( - - ))} - - - )} - - {form.values.frequency === 'monthly' && ( - - - - - - - - - {form.values.monthly_pattern === 'by_day_of_month' && ( -
- {t`Days of Month`} - - - {daysOfMonthOptions.map(day => ( - - {day} - - ))} - - -
- )} - - {form.values.monthly_pattern === 'by_day_of_week' && ( - - - - )} -
- )} - - {form.values.frequency === 'yearly' && ( - -