From 75a2362ec4406d8def9d2b0b4694b5e19ab20e46 Mon Sep 17 00:00:00 2001 From: SleepingOff Date: Tue, 24 Feb 2026 02:22:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=8A=A4=EC=BC=80=EC=A4=84/?= =?UTF-8?q?=ED=88=AC=EB=91=90=20=EC=88=98=EC=A0=95=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EB=82=99=EA=B4=80=EC=A0=81=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.config.ts | 2 +- app/[teamId]/calendar/_layout.tsx | 9 +- assets/icons/bell.tsx | 30 ++-- features/calendar/hooks/useSchedules.ts | 176 +++++++++++++++++++- features/calendar/hooks/useTodos.ts | 136 ++++++++++++++- package.json | 2 +- shared/ui/templates/CalendarDetailModal.tsx | 40 +++-- shared/ui/templates/CalendarModal.tsx | 25 +-- shared/ui/templates/ScheduleListModal.tsx | 2 +- 9 files changed, 373 insertions(+), 49 deletions(-) diff --git a/app.config.ts b/app.config.ts index 70440be..6fc714f 100644 --- a/app.config.ts +++ b/app.config.ts @@ -15,7 +15,7 @@ if (process.env.GOOGLE_SERVICES_JSON) { export default { name: "네모네모", slug: "frontend", - version: "1.0.1", + version: "1.0.2", orientation: "portrait", icon: "./assets/images/icon.png", scheme: "frontend", diff --git a/app/[teamId]/calendar/_layout.tsx b/app/[teamId]/calendar/_layout.tsx index b46968b..f359dc1 100644 --- a/app/[teamId]/calendar/_layout.tsx +++ b/app/[teamId]/calendar/_layout.tsx @@ -1,6 +1,7 @@ import GroupIcon from "@/assets/icons/group"; import SquaredPlusIcon from "@/assets/icons/squaredPlus"; import TeamSetting from "@/assets/icons/teamSetting"; +import BellIcon from "@/assets/icons/bell"; import { useLatestNotice, useNoticeMutations, @@ -25,7 +26,6 @@ import NemoText from "@/shared/ui/atoms/NemoText"; import Tabs, { TabsText } from "@/shared/ui/molecules/Tabs"; import ModalEditableField from "@/shared/ui/organisms/ModalEditableField"; import SideModal from "@/shared/ui/templates/SideModal"; -import { Feather } from "@expo/vector-icons"; import { Image as ExpoImage } from "expo-image"; import { Slot, @@ -296,12 +296,7 @@ export default function CalendarTodosScreen() { - + diff --git a/assets/icons/bell.tsx b/assets/icons/bell.tsx index aaa5ab7..e805e1b 100644 --- a/assets/icons/bell.tsx +++ b/assets/icons/bell.tsx @@ -8,24 +8,30 @@ interface MainIconProps extends SvgProps { } const BellIcon = ({ - size = 24, + size = 20, color = globalGray700, ...props }: MainIconProps) => ( - + ); diff --git a/features/calendar/hooks/useSchedules.ts b/features/calendar/hooks/useSchedules.ts index 462925e..8c92eb3 100644 --- a/features/calendar/hooks/useSchedules.ts +++ b/features/calendar/hooks/useSchedules.ts @@ -1,5 +1,6 @@ import { QueryClient, + QueryKey, useMutation, useQuery, useQueryClient, @@ -97,6 +98,16 @@ const shouldIncludeOccurrence = ( candidate <= paramsEnd && candidate <= repeatEnd; +const isScheduleOverlappingRange = ( + schedule: SchedulesResponse, + paramsStart: Date, + paramsEnd: Date +) => { + const scheduleStart = new Date(schedule.startAt); + const scheduleEnd = new Date(schedule.endAt); + return scheduleStart <= paramsEnd && scheduleEnd >= paramsStart; +}; + const generateRepeatOccurrences = ( schedule: SchedulesResponse, params: ScheduleQueryParams @@ -192,8 +203,21 @@ const expandSchedulesWithRepeats = ( schedules: SchedulesResponse[], params: ScheduleQueryParams ) => { + const queryStart = new Date(params.start); + const queryEnd = new Date(params.end); + return schedules - .flatMap((schedule) => [schedule, ...generateRepeatOccurrences(schedule, params)]) + .flatMap((schedule) => { + const normalizedType = (schedule.repeatType ?? "NONE").toUpperCase(); + if (normalizedType === "NONE" || !schedule.repeatEndDate) { + return [schedule]; + } + + return generateRepeatOccurrences(schedule, params); + }) + .filter((schedule) => + isScheduleOverlappingRange(schedule, queryStart, queryEnd) + ) .sort( (a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime() @@ -243,6 +267,112 @@ type DeleteSchedulePayload = { teamId?: number | null; }; +type ScheduleCacheSnapshot = Array<[QueryKey, SchedulesResponse[] | undefined]>; + +const snapshotScheduleCaches = (queryClient: QueryClient) => ({ + team: queryClient.getQueriesData({ + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("schedules"), + }), + me: queryClient.getQueriesData({ + queryKey: ["me", "schedules"], + exact: false, + }), +}); + +const restoreScheduleCaches = ( + queryClient: QueryClient, + snapshot?: { team: ScheduleCacheSnapshot; me: ScheduleCacheSnapshot } +) => { + if (!snapshot) return; + + [...snapshot.team, ...snapshot.me].forEach(([key, data]) => { + queryClient.setQueryData(key, data); + }); +}; + +const patchScheduleInCache = ( + data: SchedulesResponse[] | undefined, + scheduleId: number, + body: ScheduleRequest +) => { + if (!data) return data; + + return data.map((schedule) => + schedule.id === scheduleId || schedule.parentScheduleId === scheduleId + ? { + ...schedule, + title: body.title, + description: body.description, + startAt: body.startAt, + endAt: body.endAt, + isAllDay: body.isAllDay, + place: body.place, + url: body.url, + repeatType: body.repeatType, + repeatInterval: body.repeatInterval, + repeatWeekDays: body.repeatWeekDays, + repeatUseDate: body.repeatUseDate, + repeatEndDate: body.repeatEndDate, + positionIds: body.positionIds, + attendeeMemberIds: body.attendeeMemberIds, + notificationMinutes: body.notificationMinutes, + } + : schedule + ); +}; + +const removeScheduleFromCache = ( + data: SchedulesResponse[] | undefined, + scheduleId: number +) => { + if (!data) return data; + return data.filter( + (schedule) => + schedule.id !== scheduleId && schedule.parentScheduleId !== scheduleId + ); +}; + +const optimisticallyUpdateScheduleCaches = ( + queryClient: QueryClient, + scheduleId: number, + body: ScheduleRequest +) => { + queryClient.setQueriesData( + { + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("schedules"), + }, + (old) => patchScheduleInCache(old, scheduleId, body) + ); + + queryClient.setQueriesData( + { queryKey: ["me", "schedules"], exact: false }, + (old) => patchScheduleInCache(old, scheduleId, body) + ); +}; + +const optimisticallyDeleteScheduleCaches = ( + queryClient: QueryClient, + scheduleId: number +) => { + queryClient.setQueriesData( + { + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("schedules"), + }, + (old) => removeScheduleFromCache(old, scheduleId) + ); + + queryClient.setQueriesData( + { queryKey: ["me", "schedules"], exact: false }, + (old) => removeScheduleFromCache(old, scheduleId) + ); +}; + function invalidateScheduleQueries( queryClient: QueryClient, teamId?: number | null @@ -278,7 +408,29 @@ export function useScheduleMutations() { const updateSchedule = useMutation({ mutationFn: (payload: UpdateSchedulePayload) => changeSchedule(payload.scheduleId, payload.body), - onSuccess: (_, variables) => { + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("schedules"), + }); + await queryClient.cancelQueries({ + queryKey: ["me", "schedules"], + exact: false, + }); + + const snapshot = snapshotScheduleCaches(queryClient); + optimisticallyUpdateScheduleCaches( + queryClient, + variables.scheduleId, + variables.body + ); + return { snapshot }; + }, + onError: (_, __, context) => { + restoreScheduleCaches(queryClient, context?.snapshot); + }, + onSettled: (_, __, variables) => { invalidateScheduleQueries(queryClient, variables.body.teamId); }, }); @@ -286,7 +438,25 @@ export function useScheduleMutations() { const deleteScheduleMutation = useMutation({ mutationFn: (payload: DeleteSchedulePayload) => deleteScheduleRequest(payload.scheduleId), - onSuccess: (_, variables) => { + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("schedules"), + }); + await queryClient.cancelQueries({ + queryKey: ["me", "schedules"], + exact: false, + }); + + const snapshot = snapshotScheduleCaches(queryClient); + optimisticallyDeleteScheduleCaches(queryClient, variables.scheduleId); + return { snapshot }; + }, + onError: (_, __, context) => { + restoreScheduleCaches(queryClient, context?.snapshot); + }, + onSettled: (_, __, variables) => { invalidateScheduleQueries(queryClient, variables.teamId); }, }); diff --git a/features/calendar/hooks/useTodos.ts b/features/calendar/hooks/useTodos.ts index 53e0aa8..4d0f528 100644 --- a/features/calendar/hooks/useTodos.ts +++ b/features/calendar/hooks/useTodos.ts @@ -1,5 +1,6 @@ import { QueryClient, + QueryKey, useMutation, useQuery, useQueryClient, @@ -71,6 +72,101 @@ type DeleteTodoPayload = { teamId?: number | null; }; +type TodoCacheSnapshot = Array<[QueryKey, TodoResponse[] | undefined]>; + +const snapshotTodoCaches = (queryClient: QueryClient) => ({ + team: queryClient.getQueriesData({ + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("todos"), + }), + me: queryClient.getQueriesData({ + queryKey: ["me", "todos"], + exact: false, + }), +}); + +const restoreTodoCaches = ( + queryClient: QueryClient, + snapshot?: { team: TodoCacheSnapshot; me: TodoCacheSnapshot } +) => { + if (!snapshot) return; + + [...snapshot.team, ...snapshot.me].forEach(([key, data]) => { + queryClient.setQueryData(key, data); + }); +}; + +const patchTodoInCache = ( + data: TodoResponse[] | undefined, + todoId: number, + body: TodoRequest +) => { + if (!data) return data; + + return data.map((todo) => + todo.id === todoId + ? { + ...todo, + title: body.title, + description: body.description, + status: body.status, + endAt: body.endAt, + place: body.place, + url: body.url, + positionIds: body.positionIds, + } + : todo + ); +}; + +const removeTodoFromCache = ( + data: TodoResponse[] | undefined, + todoId: number +) => { + if (!data) return data; + return data.filter((todo) => todo.id !== todoId); +}; + +const optimisticallyUpdateTodoCaches = ( + queryClient: QueryClient, + todoId: number, + body: TodoRequest +) => { + queryClient.setQueriesData( + { + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("todos"), + }, + (old) => patchTodoInCache(old, todoId, body) + ); + + queryClient.setQueriesData( + { queryKey: ["me", "todos"], exact: false }, + (old) => patchTodoInCache(old, todoId, body) + ); +}; + +const optimisticallyDeleteTodoCaches = ( + queryClient: QueryClient, + todoId: number +) => { + queryClient.setQueriesData( + { + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("todos"), + }, + (old) => removeTodoFromCache(old, todoId) + ); + + queryClient.setQueriesData( + { queryKey: ["me", "todos"], exact: false }, + (old) => removeTodoFromCache(old, todoId) + ); +}; + function invalidateTodoQueries( queryClient: QueryClient, teamId?: number | null @@ -106,7 +202,25 @@ export function useTodoMutations() { const updateTodo = useMutation({ mutationFn: (payload: UpdateTodoPayload) => changeTodo(payload.todoId, payload.body), - onSuccess: (_, variables) => { + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("todos"), + }); + await queryClient.cancelQueries({ + queryKey: ["me", "todos"], + exact: false, + }); + + const snapshot = snapshotTodoCaches(queryClient); + optimisticallyUpdateTodoCaches(queryClient, variables.todoId, variables.body); + return { snapshot }; + }, + onError: (_, __, context) => { + restoreTodoCaches(queryClient, context?.snapshot); + }, + onSettled: (_, __, variables) => { invalidateTodoQueries(queryClient, variables.body.teamId); }, }); @@ -122,7 +236,25 @@ export function useTodoMutations() { const deleteTodo = useMutation({ mutationFn: (payload: DeleteTodoPayload) => deleteTodoRequest(payload.todoId), - onSuccess: (_, variables) => { + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: ["teams"], + exact: false, + predicate: (query) => query.queryKey.includes("todos"), + }); + await queryClient.cancelQueries({ + queryKey: ["me", "todos"], + exact: false, + }); + + const snapshot = snapshotTodoCaches(queryClient); + optimisticallyDeleteTodoCaches(queryClient, variables.todoId); + return { snapshot }; + }, + onError: (_, __, context) => { + restoreTodoCaches(queryClient, context?.snapshot); + }, + onSettled: (_, __, variables) => { invalidateTodoQueries(queryClient, variables.teamId); }, }); diff --git a/package.json b/package.json index 58f3bba..c421b32 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nemonemo", "main": "expo-router/entry", - "version": "1.0.1", + "version": "1.0.2", "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", diff --git a/shared/ui/templates/CalendarDetailModal.tsx b/shared/ui/templates/CalendarDetailModal.tsx index 910fc8e..92ddd71 100644 --- a/shared/ui/templates/CalendarDetailModal.tsx +++ b/shared/ui/templates/CalendarDetailModal.tsx @@ -14,7 +14,7 @@ import { } from "@/shared/hooks/useCalendarForm"; import { Feather, Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams } from "expo-router"; -import { useMemo, useReducer, useRef, useState } from "react"; +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import { Animated, Modal, PanResponder, StyleSheet, View } from "react-native"; import { globalGray700 } from ".."; import NemoText from "../atoms/NemoText"; @@ -43,22 +43,42 @@ const CalendarDetailModal = ({ onPatch, }: CalendarDetailModalProps) => { const { teamId } = useLocalSearchParams<{ teamId: string }>(); - const positions = usePositions(parseInt(teamId)).data; + const parsedTeamId = Number(teamId); + const safeTeamId = + Number.isFinite(parsedTeamId) && parsedTeamId > 0 + ? parsedTeamId + : data.teamId; + const positions = usePositions(safeTeamId).data; const [isOpenEditModal, setIsOpenEditModal] = useState(false); - const personQuery = useTeamMembers(parseInt(teamId)); - const members = personQuery.data?.members?.map((member) => - toMemberChip(member) + const personQuery = useTeamMembers(safeTeamId); + const members = useMemo( + () => personQuery.data?.members?.map((member) => toMemberChip(member)) ?? [], + [personQuery.data?.members] ); + const positionChips = useMemo(() => positions ?? [], [positions]); const initialState = createInitialState({ - teamId: Number(teamId), + teamId: safeTeamId, data, type, - persons: members ?? [], - positions: positions ?? [], + persons: members, + positions: positionChips, selectedDate, }); const [state, dispatch] = useReducer(reducer, initialState); + useEffect(() => { + dispatch({ + type: "RESET", + payload: createInitialState({ + teamId: safeTeamId, + data, + type, + persons: members, + positions: positionChips, + selectedDate, + }), + }); + }, [safeTeamId, data, type, members, positionChips, selectedDate]); const { title } = state; const { deleteSchedule } = useScheduleMutations(); const { deleteTodo } = useTodoMutations(); @@ -153,7 +173,7 @@ const CalendarDetailModal = ({ readonly: true, state, dispatch, - teamId: parseInt(teamId), + teamId: safeTeamId, }} > {type == "schedule" ? ( @@ -166,7 +186,7 @@ const CalendarDetailModal = ({ {isOpenEditModal && ( setIsOpenEditModal(false)} diff --git a/shared/ui/templates/CalendarModal.tsx b/shared/ui/templates/CalendarModal.tsx index f338de7..ded725b 100644 --- a/shared/ui/templates/CalendarModal.tsx +++ b/shared/ui/templates/CalendarModal.tsx @@ -12,7 +12,7 @@ import { InitialCalendarState, } from "@/shared/hooks/useCalendarForm"; import { AntDesign, EvilIcons } from "@expo/vector-icons"; -import { useEffect, useReducer, useRef, useState } from "react"; +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; import { Animated, FlatList, @@ -158,24 +158,25 @@ const CalendarModal = ({ confirmModal, closeModal, }: CalendarModalProps) => { + const safeTeamId = Number.isFinite(teamId) && teamId > 0 ? teamId : null; const [selectedTeamId, setSelectedTeamId] = useState( - data?.teamId ?? teamId + data?.teamId ?? safeTeamId ); const positionQuery = usePositions(selectedTeamId); const personQuery = useTeamMembers(selectedTeamId); - const members = personQuery.data?.members?.map((member) => - toMemberChip(member) + const members = useMemo( + () => personQuery.data?.members?.map((member) => toMemberChip(member)) ?? [], + [personQuery.data?.members] ); - const isReady = - selectedTeamId && personQuery.isSuccess && positionQuery.isSuccess; + const positions = useMemo(() => positionQuery.data ?? [], [positionQuery.data]); const initialState = createInitialState({ - teamId, + teamId: safeTeamId ?? data?.teamId ?? 0, data, type, - persons: members ?? [], - positions: positionQuery.data ?? [], + persons: members, + positions, selectedDate, }); const segmentTexts = [ @@ -217,12 +218,12 @@ const CalendarModal = ({ teamId: selectedTeamId, data, type, - positions: positionQuery.data ?? [], - persons: members ?? [], + positions, + persons: members, selectedDate, }), }); - }, [selectedTeamId, isReady]); + }, [selectedTeamId, data, type, positions, members, selectedDate]); const [isTitleWritten, setIsTitleWritten] = useState(true); diff --git a/shared/ui/templates/ScheduleListModal.tsx b/shared/ui/templates/ScheduleListModal.tsx index c3db66b..2f8bb09 100644 --- a/shared/ui/templates/ScheduleListModal.tsx +++ b/shared/ui/templates/ScheduleListModal.tsx @@ -210,7 +210,7 @@ const ScheduleListModal = ({ 0, 0, 0, - -1 + 0 ).toISOString(); const end = new Date( selectedDate.getFullYear(), From 78ce387d2c4e5853537cd6e9ccc4e1cf655d1dd3 Mon Sep 17 00:00:00 2001 From: SleepingOff Date: Tue, 24 Feb 2026 02:26:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=95=88=EB=93=9C=EB=A1=9C=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EC=97=90=EC=84=9C=20=EB=82=A0=EC=A7=9C=20=EC=97=AC?= =?UTF-8?q?=EB=B0=B1=20=EA=B9=A8=EC=A7=90=20=ED=98=84=EC=83=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/ui/molecules/CalendarHeader.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/ui/molecules/CalendarHeader.tsx b/shared/ui/molecules/CalendarHeader.tsx index 31b9c3a..097e809 100644 --- a/shared/ui/molecules/CalendarHeader.tsx +++ b/shared/ui/molecules/CalendarHeader.tsx @@ -1,4 +1,4 @@ -import { StyleSheet, Text } from "react-native"; +import { StyleSheet, View } from "react-native"; import Selector from "./Selector"; interface CalendarHeaderProps { @@ -9,18 +9,18 @@ interface CalendarHeaderProps { const CalendarHeader = ({ year, month, goMonth }: CalendarHeaderProps) => { return ( - + goMonth(dir)} /> - + ); }; const style = StyleSheet.create({ container: { - textAlign: "center", + alignItems: "center", marginBottom: 14, }, });