diff --git a/frontend/package.json b/frontend/package.json index 562444c3..0114d28b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.76.1", "@tanstack/react-router": "^1.109.2", "@vanilla-extract/css": "^1.17.0", "@vanilla-extract/dynamic": "^2.1.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4f040cd1..23c3c231 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tanstack/react-query': specifier: ^5.66.0 version: 5.66.0(react@19.0.0) + '@tanstack/react-query-devtools': + specifier: ^5.76.1 + version: 5.76.1(@tanstack/react-query@5.66.0(react@19.0.0))(react@19.0.0) '@tanstack/react-router': specifier: ^1.109.2 version: 1.109.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1079,6 +1082,15 @@ packages: '@tanstack/query-core@5.66.0': resolution: {integrity: sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==} + '@tanstack/query-devtools@5.76.0': + resolution: {integrity: sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ==} + + '@tanstack/react-query-devtools@5.76.1': + resolution: {integrity: sha512-LFVWgk/VtXPkerNLfYIeuGHh0Aim/k9PFGA+JxLdRaUiroQ4j4eoEqBrUpQ1Pd/KXoG4AB9vVE/M6PUQ9vwxBQ==} + peerDependencies: + '@tanstack/react-query': ^5.76.1 + react: ^18 || ^19 + '@tanstack/react-query@5.66.0': resolution: {integrity: sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw==} peerDependencies: @@ -4085,6 +4097,14 @@ snapshots: '@tanstack/query-core@5.66.0': {} + '@tanstack/query-devtools@5.76.0': {} + + '@tanstack/react-query-devtools@5.76.1(@tanstack/react-query@5.66.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@tanstack/query-devtools': 5.76.0 + '@tanstack/react-query': 5.66.0(react@19.0.0) + react: 19.0.0 + '@tanstack/react-query@5.66.0(react@19.0.0)': dependencies: '@tanstack/query-core': 5.66.0 diff --git a/frontend/src/features/my-calendar/api/index.ts b/frontend/src/features/my-calendar/api/index.ts index fb35f4c5..e9a2e3f1 100644 --- a/frontend/src/features/my-calendar/api/index.ts +++ b/frontend/src/features/my-calendar/api/index.ts @@ -1,6 +1,11 @@ import { request } from '@/utils/fetch'; -import type { DateRangeParams, PersonalEventRequest, PersonalEventResponse } from '../model'; +import type { + DateRangeParams, + PersonalEventRequest, + PersonalEventResponse, + PersonalEventSyncResponse, +} from '../model'; export const personalEventApi = { getPersonalEvent: async ( @@ -26,4 +31,8 @@ export const personalEventApi = { params: { syncWithGoogleCalendar: syncWithGoogleCalendar.toString() }, }); }, + syncPersonalEvent: async (): Promise => { + const response = await request.get('/api/v1/personal-event/sync'); + return response; + }, }; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/api/mutations.ts b/frontend/src/features/my-calendar/api/mutations.ts index f378d8ad..5db335c1 100644 --- a/frontend/src/features/my-calendar/api/mutations.ts +++ b/frontend/src/features/my-calendar/api/mutations.ts @@ -1,6 +1,12 @@ +import type { QueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; -import type { PersonalEventRequest } from '../model'; +import { addNoti } from '@/store/global/notification'; +import { EndolphinDate } from '@/utils/endolphin-date'; +import { HTTPError } from '@/utils/error'; + +import type { GooglePersonalEventDTO, PersonalEventRequest, PersonalEventResponse } from '../model'; import { personalEventApi } from '.'; import { personalEventKeys } from './keys'; @@ -52,4 +58,86 @@ export const usePersonalEventDeleteMutation = () => { }); return { mutate }; +}; + +export const usePersonalEventSyncMutation = (sunday: EndolphinDate, saturday: EndolphinDate) => { + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation({ + mutationFn: personalEventApi.syncPersonalEvent, + onSuccess: ({ events, type }) => { + if (type === 'sync') { + events?.forEach((event) => { + const start = new EndolphinDate(event.startDateTime); + const end = new EndolphinDate(event.endDateTime); + if (event.status === 'cancelled') removeEvent({ queryClient, id: event.googleEventId }); + if (event.status === 'confirmed' && end >= sunday && start <= saturday) { + updateEvent({ + queryClient, + event, + startDate: sunday.formatDateToBarString(), + endDate: saturday.formatDateToBarString(), + }); + } + }); + } + mutate(); + }, + onError: (error) => { + if (error instanceof HTTPError) { + if (error.isTimeoutError()) { + mutate(); + return; + } + addNoti({ type: 'error', title: error.message }); + } + setTimeout(mutate, 1000); + }, + }); + + useEffect(() => { + if (!isPending) mutate(); + }, []); +}; + +const updateEvent = ( + { queryClient, event, startDate, endDate }: + { + queryClient: QueryClient; + event: GooglePersonalEventDTO; + startDate: string; + endDate: string; + }, +) => { + queryClient.setQueryData(personalEventKeys.detail({ startDate, endDate }), + (oldData: PersonalEventResponse[]) => { + const isNewEvent + = !oldData || oldData.every((oldEvent) => oldEvent.googleEventId !== event.googleEventId); + if (isNewEvent) return [...oldData, event]; + return oldData.map((oldEvent) => { + if (oldEvent.googleEventId === event.googleEventId) { + return { + ...oldEvent, + startDateTime: event.startDateTime, + endDateTime: event.endDateTime, + title: event.title, + }; + } + return oldEvent; + }); + }); +}; + +const removeEvent = ( + { queryClient, id }: + { queryClient: QueryClient; id: string }, +) => { + const allEvents = queryClient.getQueriesData({ + queryKey: personalEventKeys.all, + }); + allEvents.forEach(([key, oldData]) => { + if (!oldData) return; + const newData = oldData.filter(event => event.googleEventId !== id); + if (newData.length !== oldData.length) queryClient.setQueryData(key, newData); + }); }; \ No newline at end of file diff --git a/frontend/src/features/my-calendar/model/index.ts b/frontend/src/features/my-calendar/model/index.ts index 77ca47d8..4c73c777 100644 --- a/frontend/src/features/my-calendar/model/index.ts +++ b/frontend/src/features/my-calendar/model/index.ts @@ -16,9 +16,25 @@ const PersonalEventRequest = PersonalEventDTO.omit( { id: true, googleEventId: true, calendarId: true }, ); +const GooglePersonalEventDTO = PersonalEventDTO.pick({ + googleEventId: true, + title: true, + startDateTime: true, + endDateTime: true, +}).extend({ + status: z.enum(['tentative', 'confirmed', 'cancelled']), +}); + +const PersonalEventSyncResponse = z.object({ + events: z.array(GooglePersonalEventDTO).nullable(), + type: z.enum(['timeout', 'replaced', 'sync']), +}); + export type PersonalEventDTO = z.infer; +export type GooglePersonalEventDTO = z.infer; export type PersonalEventResponse = z.infer; export type PersonalEventRequest = z.infer; +export type PersonalEventSyncResponse = z.infer; export interface DateRangeParams { startDate: string; diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/index.tsx b/frontend/src/features/my-calendar/ui/MyCalendar/index.tsx index 40f236a8..a0ab4902 100644 --- a/frontend/src/features/my-calendar/ui/MyCalendar/index.tsx +++ b/frontend/src/features/my-calendar/ui/MyCalendar/index.tsx @@ -3,7 +3,9 @@ import { useSharedCalendarContext } from '@/components/Calendar/context/SharedCa import { formatDateToWeekRange, isAllday } from '@/utils/date'; import { formatDateToBarString } from '@/utils/date/format'; import { calcSizeByDate } from '@/utils/date/position'; +import { EndolphinDate } from '@/utils/endolphin-date'; +import { usePersonalEventSyncMutation } from '../../api/mutations'; import { usePersonalEventsQuery } from '../../api/queries'; import type { PersonalEventResponse } from '../../model'; import { CalendarCard } from '../CalendarCard'; @@ -51,6 +53,8 @@ export const MyCalendar = () => { endDate: formatDateToBarString(endDate), }); + usePersonalEventSyncMutation(new EndolphinDate(startDate), new EndolphinDate(endDate)); + return ( diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index e370af94..bbf2ba88 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,4 +1,5 @@ import type { QueryClient } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createRootRouteWithContext, HeadContent, @@ -32,6 +33,7 @@ export const Route = createRootRouteWithContext()({