From 0d8b0d9c5fd222ea69e8a483e66dd89c203e441d Mon Sep 17 00:00:00 2001 From: j-ordep Date: Thu, 28 May 2026 22:59:40 -0300 Subject: [PATCH] feat(ui): simplify event date selection --- mobile/docs/TASKS.md | 3 + mobile/package.json | 2 +- mobile/src/screens/app/CreateEventScreen.tsx | 96 +++++++++++++++---- mobile/src/screens/app/EditScheduleScreen.tsx | 2 +- mobile/src/screens/app/ScheduleScreen.tsx | 2 +- mobile/src/utils/eventCalendarSelection.ts | 52 ++++++++++ mobile/tests/eventCalendarSelection.test.ts | 73 ++++++++++++++ 7 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 mobile/src/utils/eventCalendarSelection.ts create mode 100644 mobile/tests/eventCalendarSelection.test.ts diff --git a/mobile/docs/TASKS.md b/mobile/docs/TASKS.md index 639f160..1224f12 100644 --- a/mobile/docs/TASKS.md +++ b/mobile/docs/TASKS.md @@ -204,6 +204,8 @@ Decisao atualizada em 2026-04-07: - Ajuste de UX em 2026-04-12: - remover alerts nativos de sucesso apos confirmar presenca - o feedback principal passa a ser a atualizacao imediata do CTA e do status na propria tela + - Ajuste de UX em 2026-05-25: + - encurtar o CTA principal para `Confirmar` / `Confirmado`, evitando quebra desigual dos botoes em `ScheduleScreen` e `EditScheduleScreen` - [~] Solicitar troca (membro) - Decisao atualizada em 2026-04-10: @@ -302,6 +304,7 @@ Decisao atualizada em 2026-04-07: - `room_reservations.event_id` passa a ser o vinculo estrutural opcional entre evento e sala - `save_event_with_optional_room_reservation` salva evento + audiencia + reserva opcional em transacao - `CreateEventScreen` agora permite sala opcional com disponibilidade real por janela para evento de data unica + - `CreateEventScreen` agora mantem data unica por padrao e so habilita multiplas datas quando o usuario marca explicitamente `Permitir multiplas datas` no modal do calendario - `RoomsScreen` deixou de ser mock e passou a criar reservas independentes reais - a reconciliacao de sala em edicao protege contra limpeza indevida ao mudar e voltar janela/horario diff --git a/mobile/package.json b/mobile/package.json index 4f890b0..771c9f2 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -9,7 +9,7 @@ "web": "expo start --web", "test": "npm run test:unit", "test:build": "tsc -p tsconfig.test.json", - "test:unit": "npm run test:build && node .tests-dist/tests/eventDate.test.js && node .tests-dist/tests/eventCategory.test.js && node .tests-dist/tests/eventAudience.test.js && node .tests-dist/tests/eventPermissions.test.js && node .tests-dist/tests/eventPresentation.test.js && node .tests-dist/tests/roomReservationService.test.js && node .tests-dist/tests/eventService.test.js && node .tests-dist/tests/musicService.test.js && node .tests-dist/tests/eventFilters.test.js && node .tests-dist/tests/statusLabels.test.js && node .tests-dist/tests/formatDate.test.js && node .tests-dist/tests/scheduleParticipation.test.js && node .tests-dist/tests/scheduleRules.test.js && node .tests-dist/tests/ministryMappers.test.js && node .tests-dist/tests/scheduleCardMappers.test.js && node .tests-dist/tests/scheduleService.test.js && node .tests-dist/tests/blockedDateService.test.js && node .tests-dist/tests/ministryService.test.js && node .tests-dist/tests/profileService.test.js && node .tests-dist/tests/ministryStore.test.js && node .tests-dist/tests/scheduleStore.test.js && node .tests-dist/tests/migrationIntegrity.test.js && node .tests-dist/tests/audienceResults.test.js && node .tests-dist/tests/roomAvailability.test.js" + "test:unit": "npm run test:build && node .tests-dist/tests/eventDate.test.js && node .tests-dist/tests/eventCalendarSelection.test.js && node .tests-dist/tests/eventCategory.test.js && node .tests-dist/tests/eventAudience.test.js && node .tests-dist/tests/eventPermissions.test.js && node .tests-dist/tests/eventPresentation.test.js && node .tests-dist/tests/roomReservationService.test.js && node .tests-dist/tests/eventService.test.js && node .tests-dist/tests/musicService.test.js && node .tests-dist/tests/eventFilters.test.js && node .tests-dist/tests/statusLabels.test.js && node .tests-dist/tests/formatDate.test.js && node .tests-dist/tests/scheduleParticipation.test.js && node .tests-dist/tests/scheduleRules.test.js && node .tests-dist/tests/ministryMappers.test.js && node .tests-dist/tests/scheduleCardMappers.test.js && node .tests-dist/tests/scheduleService.test.js && node .tests-dist/tests/blockedDateService.test.js && node .tests-dist/tests/ministryService.test.js && node .tests-dist/tests/profileService.test.js && node .tests-dist/tests/ministryStore.test.js && node .tests-dist/tests/scheduleStore.test.js && node .tests-dist/tests/migrationIntegrity.test.js && node .tests-dist/tests/audienceResults.test.js && node .tests-dist/tests/roomAvailability.test.js" }, "dependencies": { "@expo/vector-icons": "^15.0.3", diff --git a/mobile/src/screens/app/CreateEventScreen.tsx b/mobile/src/screens/app/CreateEventScreen.tsx index 7f59f64..70ce18d 100644 --- a/mobile/src/screens/app/CreateEventScreen.tsx +++ b/mobile/src/screens/app/CreateEventScreen.tsx @@ -9,7 +9,7 @@ import { TouchableOpacity as RNTouchableOpacity, View, } from "react-native"; -import { Button, Chip, Divider, Switch, Text, TextInput } from "react-native-paper"; +import { Button, Checkbox, Chip, Divider, Switch, Text, TextInput } from "react-native-paper"; import { SafeAreaView } from "react-native-safe-area-context"; import { Calendar } from "react-native-calendars"; import { Calendar as CalendarIcon, Clock, Search, Sparkles, X } from "lucide-react-native"; @@ -34,6 +34,12 @@ import { } from "../../services/profileService"; import { useAuthStore } from "../../stores/useAuthStore"; import { useEventStore } from "../../stores/useEventStore"; +import { + collapseCalendarSelectionToSingleDate, + createCalendarSelectionMark, + type CalendarSelectionMark, + toggleCalendarDateSelection, +} from "../../utils/eventCalendarSelection"; import { createLocalDateTime, formatLocalDateKey, @@ -87,12 +93,7 @@ const PRESETS = [ }, ]; -type CalendarSelection = { - selected: boolean; - selectedColor: string; -}; - -function getEventDateLabel(selectedDays: Record) { +function getEventDateLabel(selectedDays: Record) { const dates = Object.keys(selectedDays); if (dates.length === 0) return "Selecione a data"; @@ -228,9 +229,10 @@ export default function CreateEventScreen({ route }: CreateEventScreenProps) { const [audiencePage, setAudiencePage] = useState(0); const [hasMoreAudience, setHasMoreAudience] = useState(false); const [showCalendar, setShowCalendar] = useState(false); - const [selectedDays, setSelectedDays] = useState>(() => { + const [allowMultipleDates, setAllowMultipleDates] = useState(false); + const [selectedDays, setSelectedDays] = useState>(() => { const todayKey = formatLocalDateKey(getNow()); - return { [todayKey]: { selected: true, selectedColor: "#000" } }; + return { [todayKey]: createCalendarSelectionMark() }; }); const [rooms, setRooms] = useState([]); const [isLoadingRooms, setIsLoadingRooms] = useState(false); @@ -241,6 +243,7 @@ export default function CreateEventScreen({ route }: CreateEventScreenProps) { const [isRoomSelectionAutoCleared, setIsRoomSelectionAutoCleared] = useState(false); const selectedDateKeys = useMemo(() => Object.keys(selectedDays), [selectedDays]); + const isMultipleDateSelectionEnabled = !isEdit && allowMultipleDates; const singleSelectedDateKey = selectedDateKeys.length === 1 ? selectedDateKeys[0] : null; const roomWindow = useMemo( @@ -267,7 +270,8 @@ export default function CreateEventScreen({ route }: CreateEventScreenProps) { setTime(`${hours}:${minutes}`); const dateKey = event.start_at.split("T")[0]; - setSelectedDays({ [dateKey]: { selected: true, selectedColor: "#000" } }); + setAllowMultipleDates(false); + setSelectedDays({ [dateKey]: createCalendarSelectionMark() }); }; useEffect(() => { @@ -528,16 +532,30 @@ export default function CreateEventScreen({ route }: CreateEventScreenProps) { }; const onDayPress = (day: { dateString: string }) => { - const dateString = day.dateString; - const nextSelectedDays = isEdit ? {} : { ...selectedDays }; + setSelectedDays((current) => + toggleCalendarDateSelection({ + selectedDays: current, + dateString: day.dateString, + allowMultipleDates: isMultipleDateSelectionEnabled, + isEdit, + }), + ); + }; - if (nextSelectedDays[dateString]) { - delete nextSelectedDays[dateString]; - } else { - nextSelectedDays[dateString] = { selected: true, selectedColor: "#000" }; - } + const handleToggleMultipleDates = () => { + if (isEdit) return; - setSelectedDays(nextSelectedDays); + setAllowMultipleDates((current) => { + const nextValue = !current; + + if (!nextValue) { + setSelectedDays((previousSelection) => + collapseCalendarSelectionToSingleDate(previousSelection), + ); + } + + return nextValue; + }); }; const handleTimeChange = (text: string) => { @@ -1407,10 +1425,18 @@ export default function CreateEventScreen({ route }: CreateEventScreenProps) { > - {isEdit ? "Alterar Data" : "Selecione as Datas"} + {isEdit + ? "Alterar Data" + : isMultipleDateSelectionEnabled + ? "Selecione as Datas" + : "Selecione a Data"} - {isEdit ? "Toque para escolher um novo dia" : "Toque para selecionar vários dias"} + {isEdit + ? "Toque para escolher um novo dia" + : isMultipleDateSelectionEnabled + ? "Toque para selecionar ou remover várias datas" + : "Por padrão, apenas uma data fica ativa por vez"} @@ -1419,6 +1445,32 @@ export default function CreateEventScreen({ route }: CreateEventScreenProps) { + {!isEdit ? ( + + + + Permitir múltiplas datas + + + Desativado por padrão para manter a criação mais simples. + + + + + ) : null} + setShowCalendar(false)} style={{ marginTop: 10, backgroundColor: "#000" }} > - Confirmar {selectedDateKeys.length} data(s) + {isMultipleDateSelectionEnabled && selectedDateKeys.length > 1 + ? `Confirmar ${selectedDateKeys.length} datas` + : "Confirmar data"} diff --git a/mobile/src/screens/app/EditScheduleScreen.tsx b/mobile/src/screens/app/EditScheduleScreen.tsx index 0178acd..0f227f6 100644 --- a/mobile/src/screens/app/EditScheduleScreen.tsx +++ b/mobile/src/screens/app/EditScheduleScreen.tsx @@ -665,7 +665,7 @@ export default function EditScheduleScreen() { isLoading={isConfirmingPresence} disabled={isOwnParticipationReadOnly || !hasPendingOwnAssignments} > - {hasPendingOwnAssignments ? "Confirmar presenca" : "Presenca confirmada"} + {hasPendingOwnAssignments ? "Confirmar" : "Confirmado"} diff --git a/mobile/src/screens/app/ScheduleScreen.tsx b/mobile/src/screens/app/ScheduleScreen.tsx index 959110e..bb4e6b7 100644 --- a/mobile/src/screens/app/ScheduleScreen.tsx +++ b/mobile/src/screens/app/ScheduleScreen.tsx @@ -263,7 +263,7 @@ export default function ScheduleScreen() { showActions={showOwnActions} swapLabel={hasPendingOwnSwapRequest ? "Cancelar troca" : "Preciso trocar"} swapVariant={hasPendingOwnSwapRequest ? "destructive" : "outline"} - confirmLabel={hasPendingOwnAssignments ? "Confirmar presenca" : "Presenca confirmada"} + confirmLabel={hasPendingOwnAssignments ? "Confirmar" : "Confirmado"} confirmDisabled={confirmDisabled} swapDisabled={swapDisabled} actionHint={actionHint} diff --git a/mobile/src/utils/eventCalendarSelection.ts b/mobile/src/utils/eventCalendarSelection.ts new file mode 100644 index 0000000..571a53a --- /dev/null +++ b/mobile/src/utils/eventCalendarSelection.ts @@ -0,0 +1,52 @@ +export type CalendarSelectionMark = { + selected: boolean; + selectedColor: string; +}; + +const DEFAULT_SELECTION_COLOR = "#000"; + +export function createCalendarSelectionMark(): CalendarSelectionMark { + return { + selected: true, + selectedColor: DEFAULT_SELECTION_COLOR, + }; +} + +export function toggleCalendarDateSelection(input: { + selectedDays: Record; + dateString: string; + allowMultipleDates: boolean; + isEdit?: boolean; +}) { + const { selectedDays, dateString, allowMultipleDates, isEdit = false } = input; + + if (!allowMultipleDates || isEdit) { + return { + [dateString]: createCalendarSelectionMark(), + }; + } + + const nextSelectedDays = { ...selectedDays }; + + if (nextSelectedDays[dateString]) { + delete nextSelectedDays[dateString]; + return nextSelectedDays; + } + + nextSelectedDays[dateString] = createCalendarSelectionMark(); + return nextSelectedDays; +} + +export function collapseCalendarSelectionToSingleDate( + selectedDays: Record, +) { + const firstDate = Object.keys(selectedDays).sort()[0]; + + if (!firstDate) { + return {}; + } + + return { + [firstDate]: createCalendarSelectionMark(), + }; +} diff --git a/mobile/tests/eventCalendarSelection.test.ts b/mobile/tests/eventCalendarSelection.test.ts new file mode 100644 index 0000000..a79dc9b --- /dev/null +++ b/mobile/tests/eventCalendarSelection.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + collapseCalendarSelectionToSingleDate, + createCalendarSelectionMark, + toggleCalendarDateSelection, +} from "../src/utils/eventCalendarSelection"; + +test("toggleCalendarDateSelection keeps a single selected date by default", () => { + const result = toggleCalendarDateSelection({ + selectedDays: { + "2026-05-23": createCalendarSelectionMark(), + }, + dateString: "2026-05-25", + allowMultipleDates: false, + }); + + assert.deepEqual(result, { + "2026-05-25": createCalendarSelectionMark(), + }); +}); + +test("toggleCalendarDateSelection adds and removes dates when multiple selection is enabled", () => { + const addedResult = toggleCalendarDateSelection({ + selectedDays: { + "2026-05-23": createCalendarSelectionMark(), + }, + dateString: "2026-05-25", + allowMultipleDates: true, + }); + + assert.deepEqual(addedResult, { + "2026-05-23": createCalendarSelectionMark(), + "2026-05-25": createCalendarSelectionMark(), + }); + + const removedResult = toggleCalendarDateSelection({ + selectedDays: addedResult, + dateString: "2026-05-23", + allowMultipleDates: true, + }); + + assert.deepEqual(removedResult, { + "2026-05-25": createCalendarSelectionMark(), + }); +}); + +test("toggleCalendarDateSelection still behaves as single selection during edit", () => { + const result = toggleCalendarDateSelection({ + selectedDays: { + "2026-05-23": createCalendarSelectionMark(), + }, + dateString: "2026-05-25", + allowMultipleDates: true, + isEdit: true, + }); + + assert.deepEqual(result, { + "2026-05-25": createCalendarSelectionMark(), + }); +}); + +test("collapseCalendarSelectionToSingleDate keeps the earliest selected date", () => { + const result = collapseCalendarSelectionToSingleDate({ + "2026-05-30": createCalendarSelectionMark(), + "2026-05-25": createCalendarSelectionMark(), + "2026-05-27": createCalendarSelectionMark(), + }); + + assert.deepEqual(result, { + "2026-05-25": createCalendarSelectionMark(), + }); +});