From d7c389ff3d5827fd89f08cc9532eb930ac3ea3fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 12:29:53 +0000 Subject: [PATCH] fix: keep edit modals open when a change is rejected Event and routine modals dismissed themselves regardless of whether the write succeeded, so a rejected change looked like it silently vanished. - Add/edit event: report which two events overlap instead of a generic message, and keep the modal open so times can be corrected in place. Editing also no longer pushes the rejected times into the event row, which previously left the list showing times that were never saved. - Add routine: a duplicate name (the pre-filled "New Routine!" being the common case) closed the modal without creating anything, so the routine never appeared in the list. Keep the modal open on failure and reset the form after a successful create. Deep copy the schedule before mutating it so the previous state object stays intact. - Dark mode: the root layout applied the stored scheme during render on every pass, remounting the tab navigator and bouncing the user out of the screen they were on. Apply it once in an effect, set Appearance from the toggle itself, and drive the status bar from the reactive value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XN5J6bUa9anKhYUSfcnpiz --- apps/mobile/src/app/_layout.tsx | 23 +++---- apps/mobile/src/app/settings/index.tsx | 14 ++--- .../src/components/settings/AddEventModal.tsx | 61 ++++--------------- .../components/settings/AddRoutineModal.tsx | 27 ++++++-- .../src/components/settings/EventModal.tsx | 59 ++++-------------- apps/mobile/src/utils/eventValidation.ts | 28 +++++++++ 6 files changed, 88 insertions(+), 124 deletions(-) create mode 100644 apps/mobile/src/utils/eventValidation.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index a54a946..1174bf8 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -84,18 +84,15 @@ export default function App() { const { colorScheme, setColorScheme } = useColorScheme(); - if (storage.getString("colorScheme")) { - Appearance.setColorScheme(storage.getString("colorScheme") as ColorSchemeName); - setColorScheme(storage.getString("colorScheme") as ColorSchemeName); - } else { - if (Appearance.getColorScheme()) { - storage.set("colorScheme", Appearance.getColorScheme()); - setColorScheme(Appearance.getColorScheme()); - } else { - storage.set("colorScheme", "light"); - setColorScheme("light"); - } - } + useEffect(() => { + const savedScheme = storage.getString("colorScheme"); + const scheme = savedScheme ?? Appearance.getColorScheme() ?? "light"; + + if (!savedScheme) storage.set("colorScheme", scheme); + + Appearance.setColorScheme(scheme as ColorSchemeName); + setColorScheme(scheme as ColorSchemeName); + }, [setColorScheme]); if (storage.getString("passingPeriods") == "undefined") storage.set("passingPeriods", "true"); @@ -110,7 +107,7 @@ export default function App() { return ( <> - + { - if (colorScheme == "dark") { - setColorScheme("light"); - storage.set("colorScheme", "light"); - } else { - setColorScheme("dark"); - storage.set("colorScheme", "dark"); - } + const nextScheme = colorScheme == "dark" ? "light" : "dark"; + + storage.set("colorScheme", nextScheme); + Appearance.setColorScheme(nextScheme); + setColorScheme(nextScheme); WidgetUpdaterModule.update(); impactAsync(); diff --git a/apps/mobile/src/components/settings/AddEventModal.tsx b/apps/mobile/src/components/settings/AddEventModal.tsx index 9de971d..453226a 100644 --- a/apps/mobile/src/components/settings/AddEventModal.tsx +++ b/apps/mobile/src/components/settings/AddEventModal.tsx @@ -14,6 +14,7 @@ import { } from "react-native"; import DateTimePickerModal from "react-native-modal-datetime-picker"; +import { findEventConflict } from "@/src/utils/eventValidation"; import storage from "@/src/utils/storage"; import TextModal from "./TextModal"; @@ -43,16 +44,6 @@ function createCustomDate(timestamp: number) { return timeString; } -function createCustomTime(inputTime: string) { - const currentDate = new Date(); - - const [inputHourRaw, inputMinuteRaw] = inputTime.split(":"); - const inputMinute = inputMinuteRaw.replace(/[A-Za-z]/g, ""); // Remove any non-numeric characters - - currentDate.setHours(parseInt(inputHourRaw), parseInt(inputMinute), 0, 0); - return currentDate.getTime(); -} - function sortByStartTime(array: UnparsedEvent[]) { return array.sort((a, b) => { const startTimeA = a.startTime.split(":").map(Number); @@ -66,35 +57,6 @@ function sortByStartTime(array: UnparsedEvent[]) { }); } -function areEventsValid(events: UnparsedEvent[]) { - if (events.length <= 1) { - return true; // Single event is always valid - } - - for (let i = 0; i < events.length; i++) { - const currentEvent = events[i]; - - const startTime = createCustomTime(currentEvent.startTime); - const endTime = createCustomTime(currentEvent.endTime); - - if (startTime >= endTime) { - return false; // End time is not after start time - } - - if (i < events.length - 1) { - // Check for event overlap - const nextEvent = events[i + 1]; - const nextStartTime = createCustomTime(nextEvent.startTime); - - if (endTime > nextStartTime) { - return false; // Events overlap - } - } - } - - return true; // All events are valid -} - function addEvent( schedule: UnparsedSchedule, setSchedule: React.Dispatch>, @@ -116,15 +78,15 @@ function addEvent( newSchedule["routines"][currentRoutine]["events"], ); - if (!areEventsValid(newSchedule["routines"][currentRoutine]["events"])) { - return Alert.alert( - "Error", - "This event overlaps with another event or has an invalid start/end time.", - ); - } else { - setSchedule(newSchedule); - storage.set("currentSchedule", JSON.stringify(newSchedule)); + const conflict = findEventConflict(newSchedule["routines"][currentRoutine]["events"]); + if (conflict) { + Alert.alert("Error", conflict); + return false; } + + setSchedule(newSchedule); + storage.set("currentSchedule", JSON.stringify(newSchedule)); + return true; } export default function AddEventModal(props: { @@ -286,7 +248,7 @@ export default function AddEventModal(props: { accessibilityLabel="Finish" className="mt-3 bg-wedgewood-300 rounded shadow-xl p-4 border-2 border-wedgewood-400 active:bg-wedgewood-500 dark:active:bg-wedgewood-800 flex flex-row items-center justify-center dark:bg-wedgewood-950 dark:border-wedgewood-600" onPress={() => { - addEvent( + const added = addEvent( props.scheduleDB, props.setScheduleDB, props.currentRoutine, @@ -294,7 +256,8 @@ export default function AddEventModal(props: { createCustomDate(startTime.getTime()), createCustomDate(endTime.getTime()), ); - props.setModalVisible(false); + + if (added) props.setModalVisible(false); }} > diff --git a/apps/mobile/src/components/settings/AddRoutineModal.tsx b/apps/mobile/src/components/settings/AddRoutineModal.tsx index 18db31a..59d8254 100644 --- a/apps/mobile/src/components/settings/AddRoutineModal.tsx +++ b/apps/mobile/src/components/settings/AddRoutineModal.tsx @@ -29,14 +29,18 @@ function createNewRoutine( name: string, weekdays: number[], ) { - if (!name) return Alert.alert("Error", "You must provide a name."); - - const newSchedule = { ...schedule }; + if (!name) { + Alert.alert("Error", "You must provide a name."); + return false; + } - if (newSchedule["routines"][name]) { - return Alert.alert("Error", "Routine with same name already exists."); + if (schedule["routines"][name]) { + Alert.alert("Error", `A routine named "${name}" already exists. Pick a different name.`); + return false; } + const newSchedule = JSON.parse(JSON.stringify(schedule)) as UnparsedSchedule; + newSchedule["routines"][name] = { officialName: name, days: weekdays, @@ -55,6 +59,7 @@ function createNewRoutine( storage.set("currentSchedule", JSON.stringify(newSchedule)); Alert.alert("Success", "Successfully created a new routine."); + return true; } export default function AddRoutineModal(props: { @@ -138,7 +143,17 @@ export default function AddRoutineModal(props: { accessibilityLabel="Finish" className="mt-4 bg-wedgewood-300 rounded shadow-xl p-4 border-2 border-wedgewood-400 active:bg-wedgewood-500 dark:active:bg-wedgewood-800 flex flex-row items-center justify-center dark:bg-wedgewood-950 dark:border-wedgewood-600" onPress={() => { - createNewRoutine(props.scheduleDB, props.setScheduleDB, name, weekdays); + const created = createNewRoutine( + props.scheduleDB, + props.setScheduleDB, + name, + weekdays, + ); + + if (!created) return; + + setName("New Routine!"); + setWeekdays([]); props.setModalVisible(false); }} > diff --git a/apps/mobile/src/components/settings/EventModal.tsx b/apps/mobile/src/components/settings/EventModal.tsx index 840e49f..a663266 100644 --- a/apps/mobile/src/components/settings/EventModal.tsx +++ b/apps/mobile/src/components/settings/EventModal.tsx @@ -14,6 +14,7 @@ import { } from "react-native"; import DateTimePickerModal from "react-native-modal-datetime-picker"; +import { findEventConflict } from "@/src/utils/eventValidation"; import storage from "@/src/utils/storage"; import TextModal from "./TextModal"; @@ -56,45 +57,6 @@ function createCustomDateString(timestamp: number) { return timeString; } -function createCustomTime(inputTime: string) { - const currentDate = new Date(); - - const [inputHourRaw, inputMinuteRaw] = inputTime.split(":"); - const inputMinute = inputMinuteRaw.replace(/[A-Za-z]/g, ""); // Remove any non-numeric characters - - currentDate.setHours(parseInt(inputHourRaw), parseInt(inputMinute), 0, 0); - return currentDate.getTime(); -} - -function areEventsValid(events: UnparsedEvent[]) { - if (events.length <= 1) { - return true; // Single event is always valid - } - - for (let i = 0; i < events.length; i++) { - const currentEvent = events[i]; - - const startTime = createCustomTime(currentEvent.startTime); - const endTime = createCustomTime(currentEvent.endTime); - - if (startTime >= endTime) { - return false; // End time is not after start time - } - - if (i < events.length - 1) { - // Check for event overlap - const nextEvent = events[i + 1]; - const nextStartTime = createCustomTime(nextEvent.startTime); - - if (endTime > nextStartTime) { - return false; // Events overlap - } - } - } - - return true; // All events are valid -} - function modifyEvent( schedule: UnparsedSchedule, setSchedule: React.Dispatch>, @@ -127,15 +89,15 @@ function modifyEventTimes( newSchedule["routines"][currentRoutine]["events"], ); - if (!areEventsValid(newSchedule["routines"][currentRoutine]["events"])) { - return Alert.alert( - "Error", - "This event overlaps with another event or has an invalid start/end time.", - ); + const conflict = findEventConflict(newSchedule["routines"][currentRoutine]["events"]); + if (conflict) { + Alert.alert("Error", conflict); + return false; } setSchedule(newSchedule); storage.set("currentSchedule", JSON.stringify(newSchedule)); + return true; } function removeEvent( @@ -420,10 +382,7 @@ export default function EventModal(props: { return Alert.alert("Error", "End time must be after start time."); } - props.setStartTime(updatedStartTime); - props.setEndTime(updatedEndTime); - - modifyEventTimes( + const saved = modifyEventTimes( props.scheduleDB, props.setScheduleDB, props.currentRoutine, @@ -432,6 +391,10 @@ export default function EventModal(props: { createCustomDateString(updatedEndTime.getTime()), ); + if (!saved) return; + + props.setStartTime(updatedStartTime); + props.setEndTime(updatedEndTime); props.setModalVisible(false); }} > diff --git a/apps/mobile/src/utils/eventValidation.ts b/apps/mobile/src/utils/eventValidation.ts new file mode 100644 index 0000000..9949234 --- /dev/null +++ b/apps/mobile/src/utils/eventValidation.ts @@ -0,0 +1,28 @@ +import { UnparsedEvent } from "@scheduli/types"; + +function toTimestamp(inputTime: string) { + const date = new Date(); + + const [hourRaw, minuteRaw] = inputTime.split(":"); + const minute = minuteRaw.replace(/[A-Za-z]/g, ""); + + date.setHours(parseInt(hourRaw), parseInt(minute), 0, 0); + return date.getTime(); +} + +export function findEventConflict(events: UnparsedEvent[]) { + for (let i = 0; i < events.length; i++) { + const current = events[i]; + + if (toTimestamp(current.startTime) >= toTimestamp(current.endTime)) { + return `"${current.name}" has to end after it starts.`; + } + + const next = events[i + 1]; + if (next && toTimestamp(current.endTime) > toTimestamp(next.startTime)) { + return `"${current.name}" overlaps with "${next.name}". Change one of their times so they don't run at the same time.`; + } + } + + return null; +}