diff --git a/apps/timo-web/api/generated/endpoints/auth/auth.ts b/apps/timo-web/api/generated/endpoints/auth/auth.ts index 35839e4b..a8446944 100644 --- a/apps/timo-web/api/generated/endpoints/auth/auth.ts +++ b/apps/timo-web/api/generated/endpoints/auth/auth.ts @@ -114,7 +114,7 @@ export const useToken = , TContext = unknown>( return useMutation(getTokenMutationOptions(options), queryClient); }; /** - * 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급합니다.
+ * 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. * 재발급 성공 시 RefreshToken과 sessionId 쿠키를 갱신합니다. * @summary AccessToken 재발급 */ diff --git a/apps/timo-web/api/generated/endpoints/auth/auth.zod.ts b/apps/timo-web/api/generated/endpoints/auth/auth.zod.ts index ea762076..07495c8b 100644 --- a/apps/timo-web/api/generated/endpoints/auth/auth.zod.ts +++ b/apps/timo-web/api/generated/endpoints/auth/auth.zod.ts @@ -40,7 +40,7 @@ export const TokenResponse = zod.object({ }); /** - * 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급합니다.
+ * 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. * 재발급 성공 시 RefreshToken과 sessionId 쿠키를 갱신합니다. * @summary AccessToken 재발급 */ diff --git a/apps/timo-web/api/generated/endpoints/calendar/calendar.ts b/apps/timo-web/api/generated/endpoints/calendar/calendar.ts new file mode 100644 index 00000000..193b7863 --- /dev/null +++ b/apps/timo-web/api/generated/endpoints/calendar/calendar.ts @@ -0,0 +1,544 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { customInstance } from "../../../client/custom-instance"; + +import type { ErrorType, BodyType } from "../../../client/custom-instance"; +import type { + BaseResponseCalendarAuthorizeResponse, + BaseResponseCalendarConnectResponse, + BaseResponseCalendarDisconnectResponse, + BaseResponseCalendarEventsResponse, + CalendarConnectRequest, + ErrorDto, + GetCalendarEventsParams, +} from "../../models"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +type SecondParameter unknown> = Parameters[1]; + +const withQueryKey = ( + query: T, + queryKey: K, +): T & { queryKey: K } => { + const result = { queryKey } as T & { queryKey: K }; + for (const key of Object.keys(query)) { + // The explicit queryKey always wins, matching the previous + // `{ ...query, queryKey }` spread where it was set last. + if (key === "queryKey") continue; + Object.defineProperty(result, key, { + enumerable: true, + configurable: true, + get: () => (query as Record)[key], + }); + } + return result; +}; + +/** + * 구글 OAuth 동의 완료 후 발급된 authorizationCode와 state로 구글 토큰을 교환하여 캘린더를 연동합니다. + * + * state는 authorize API 호출 시 발급받은 값을 그대로 전달해야 하며, 검증 후 즉시 만료됩니다. + * + * 가입 시 사용한 구글 계정과 다른 계정으로 연동을 시도하면 거부됩니다. + * @summary 구글 캘린더 연동 + */ +export const connectCalendar = ( + calendarConnectRequest: BodyType, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { + url: `/api/v1/users/calendar`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: calendarConnectRequest, + signal, + }, + options, + ); +}; + +export const getConnectCalendarMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["connectCalendar"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return connectCalendar(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ConnectCalendarMutationResult = NonNullable< + Awaited> +>; +export type ConnectCalendarMutationBody = BodyType; +export type ConnectCalendarMutationError = ErrorType; + +/** + * @summary 구글 캘린더 연동 + */ +export const useConnectCalendar = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getConnectCalendarMutationOptions(options), queryClient); +}; +/** + * 연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다. + * @summary 구글 캘린더 연동 해제 + */ +export const disconnectCalendar = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { url: `/api/v1/users/calendar`, method: "DELETE", signal }, + options, + ); +}; + +export const getDisconnectCalendarMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ["disconnectCalendar"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return disconnectCalendar(requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DisconnectCalendarMutationResult = NonNullable< + Awaited> +>; + +export type DisconnectCalendarMutationError = ErrorType; + +/** + * @summary 구글 캘린더 연동 해제 + */ +export const useDisconnectCalendar = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + return useMutation( + getDisconnectCalendarMutationOptions(options), + queryClient, + ); +}; +/** + * filter(DAY/WEEK/TWO_WEEK)와 baseDate에 따라 연동된 구글 캘린더 일정을 일자별로 조회합니다. + * + * DAY: baseDate 하루 + * + * WEEK: baseDate ~ baseDate+6일 (총 7일) + * + * TWO_WEEK: baseDate-7일 ~ baseDate+7일 (총 15일) + * + * baseDate 미입력 시 오늘 날짜가 기본값으로 사용됩니다. + * 별도 저장 없이 매 요청마다 구글 API를 직접 호출하여 최신 상태를 반환합니다. + * @summary 캘린더 일정 조회 + */ +export const getCalendarEvents = ( + params: GetCalendarEventsParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { url: `/api/v1/users/calendar/events`, method: "GET", params, signal }, + options, + ); +}; + +export const getGetCalendarEventsQueryKey = ( + params?: GetCalendarEventsParams, +) => { + return [ + `/api/v1/users/calendar/events`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetCalendarEventsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getGetCalendarEventsQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => getCalendarEvents(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetCalendarEventsQueryResult = NonNullable< + Awaited> +>; +export type GetCalendarEventsQueryError = ErrorType; + +export function useGetCalendarEvents< + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetCalendarEvents< + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetCalendarEvents< + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 캘린더 일정 조회 + */ + +export function useGetCalendarEvents< + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getGetCalendarEventsQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * 구글 캘린더 연동을 시작하는 구글 인증 URL을 발급합니다. + * + * 프론트는 이 응답의 authorizationUrl로 window.location.assign 등을 통해 직접 이동해야 합니다. + * @summary 구글 캘린더 연동 시작 + */ +export const authorize = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { url: `/api/v1/users/calendar/authorize`, method: "GET", signal }, + options, + ); +}; + +export const getAuthorizeQueryKey = () => { + return [`/api/v1/users/calendar/authorize`] as const; +}; + +export const getAuthorizeQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getAuthorizeQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => authorize(requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type AuthorizeQueryResult = NonNullable< + Awaited> +>; +export type AuthorizeQueryError = ErrorType; + +export function useAuthorize< + TData = Awaited>, + TError = ErrorType, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useAuthorize< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useAuthorize< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 구글 캘린더 연동 시작 + */ + +export function useAuthorize< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getAuthorizeQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} diff --git a/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts b/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts new file mode 100644 index 00000000..f23f846e --- /dev/null +++ b/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts @@ -0,0 +1,116 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import * as zod from "zod"; + +/** + * 구글 OAuth 동의 완료 후 발급된 authorizationCode와 state로 구글 토큰을 교환하여 캘린더를 연동합니다. + * + * state는 authorize API 호출 시 발급받은 값을 그대로 전달해야 하며, 검증 후 즉시 만료됩니다. + * + * 가입 시 사용한 구글 계정과 다른 계정으로 연동을 시도하면 거부됩니다. + * @summary 구글 캘린더 연동 + */ + +export const ConnectCalendarBody = zod.object({ + authorizationCode: zod + .string() + .min(1) + .describe("구글 OAuth 동의 완료 후 발급된 authorization code"), + state: zod + .string() + .min(1) + .describe("authorize API 호출 시 발급받은 state 값 (CSRF 방어용)"), +}); + +export const ConnectCalendarResponse = zod.object({ + status: zod.number().optional(), + message: zod.string().optional(), + data: zod + .object({ + calendarConnected: zod.boolean().optional(), + calendarEmail: zod.string().optional(), + connectedAt: zod.string().optional().describe("UTC 기준 연동 시각"), + }) + .optional(), +}); + +/** + * 연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다. + * @summary 구글 캘린더 연동 해제 + */ +export const DisconnectCalendarResponse = zod.object({ + status: zod.number().optional(), + message: zod.string().optional(), + data: zod + .object({ + calendarConnected: zod.boolean().optional(), + }) + .optional(), +}); + +/** + * filter(DAY/WEEK/TWO_WEEK)와 baseDate에 따라 연동된 구글 캘린더 일정을 일자별로 조회합니다. + * + * DAY: baseDate 하루 + * + * WEEK: baseDate ~ baseDate+6일 (총 7일) + * + * TWO_WEEK: baseDate-7일 ~ baseDate+7일 (총 15일) + * + * baseDate 미입력 시 오늘 날짜가 기본값으로 사용됩니다. + * 별도 저장 없이 매 요청마다 구글 API를 직접 호출하여 최신 상태를 반환합니다. + * @summary 캘린더 일정 조회 + */ +export const GetCalendarEventsQueryParams = zod.object({ + filter: zod.string().describe("조회 필터"), + baseDate: zod + .string() + .optional() + .describe("기준 날짜 (YYYY-MM-DD), 미입력 시 오늘"), +}); + +export const GetCalendarEventsResponse = zod.object({ + status: zod.number().optional(), + message: zod.string().optional(), + data: zod + .object({ + filter: zod.string().optional(), + baseDate: zod.iso.date().optional(), + days: zod + .array( + zod.object({ + date: zod.iso.date().optional(), + events: zod + .array( + zod.object({ + title: zod.string().optional(), + }), + ) + .optional(), + }), + ) + .optional(), + }) + .optional(), +}); + +/** + * 구글 캘린더 연동을 시작하는 구글 인증 URL을 발급합니다. + * + * 프론트는 이 응답의 authorizationUrl로 window.location.assign 등을 통해 직접 이동해야 합니다. + * @summary 구글 캘린더 연동 시작 + */ +export const AuthorizeResponse = zod.object({ + status: zod.number().optional(), + message: zod.string().optional(), + data: zod + .object({ + authorizationUrl: zod.string().optional(), + }) + .optional(), +}); diff --git a/apps/timo-web/api/generated/endpoints/focus/focus.ts b/apps/timo-web/api/generated/endpoints/focus/focus.ts index 316ee411..26989958 100644 --- a/apps/timo-web/api/generated/endpoints/focus/focus.ts +++ b/apps/timo-web/api/generated/endpoints/focus/focus.ts @@ -10,7 +10,7 @@ import { useQuery } from "@tanstack/react-query"; import { customInstance } from "../../../client/custom-instance"; import type { ErrorType } from "../../../client/custom-instance"; -import type { BaseResponseFocusTodoResponse, ErrorDto } from "../../models"; +import type { ErrorDto, FocusTodoResponse } from "../../models"; import type { DataTag, DefinedInitialDataOptions, @@ -44,7 +44,10 @@ const withQueryKey = ( }; /** - * 오늘 TODO 중 미완료인 최상단 TODO 하나를 조회합니다. (sortOrder가 가장 작은 미완료 TODO) + * 집중 모드에 노출할 TODO 하나를 다음 우선순위로 조회합니다. + * 1. 실행/일시정지 중인 타이머가 있으면, 날짜와 무관하게 해당 TODO를 최우선으로 반환합니다. + * (이 경우 응답의 date는 오늘이 아니라 타이머가 시작된 날짜일 수 있습니다.) + * 2. 활성 타이머가 없으면 오늘 TODO 중 미완료인 최상단 TODO 하나를 반환합니다. (sortOrder가 가장 작은 미완료 TODO) * 오늘 TODO가 하나도 없으면 "오늘의 TODO가 없습니다.", * 오늘 TODO를 모두 완료했으면 "오늘의 TODO를 모두 완료했습니다." 메시지를 반환하며, * 두 경우 모두 hasTodo는 false, todo는 null입니다. @@ -54,7 +57,7 @@ export const getFocusTodo = ( options?: SecondParameter, signal?: AbortSignal, ) => { - return customInstance( + return customInstance( { url: `/api/v1/focus`, method: "GET", signal }, options, ); diff --git a/apps/timo-web/api/generated/endpoints/focus/focus.zod.ts b/apps/timo-web/api/generated/endpoints/focus/focus.zod.ts index 5be069fd..d403820a 100644 --- a/apps/timo-web/api/generated/endpoints/focus/focus.zod.ts +++ b/apps/timo-web/api/generated/endpoints/focus/focus.zod.ts @@ -8,44 +8,42 @@ import * as zod from "zod"; /** - * 오늘 TODO 중 미완료인 최상단 TODO 하나를 조회합니다. (sortOrder가 가장 작은 미완료 TODO) + * 집중 모드에 노출할 TODO 하나를 다음 우선순위로 조회합니다. + * 1. 실행/일시정지 중인 타이머가 있으면, 날짜와 무관하게 해당 TODO를 최우선으로 반환합니다. + * (이 경우 응답의 date는 오늘이 아니라 타이머가 시작된 날짜일 수 있습니다.) + * 2. 활성 타이머가 없으면 오늘 TODO 중 미완료인 최상단 TODO 하나를 반환합니다. (sortOrder가 가장 작은 미완료 TODO) * 오늘 TODO가 하나도 없으면 "오늘의 TODO가 없습니다.", * 오늘 TODO를 모두 완료했으면 "오늘의 TODO를 모두 완료했습니다." 메시지를 반환하며, * 두 경우 모두 hasTodo는 false, todo는 null입니다. * @summary 집중 모드 TODO 조회 */ export const GetFocusTodoResponse = zod.object({ - status: zod.number().optional(), - message: zod.string().optional(), - data: zod + date: zod.iso.date(), + dayOfWeek: zod.enum(["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]), + hasTodo: zod.boolean(), + todo: zod .object({ - date: zod.iso.date(), - dayOfWeek: zod.enum(["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]), - hasTodo: zod.boolean(), - todo: zod + todoId: zod.number(), + icon: zod.string().optional(), + title: zod.string(), + completed: zod.boolean(), + durationSeconds: zod.number().optional(), + priority: zod.string().optional(), + tag: zod .object({ - todoId: zod.number(), - icon: zod.string().optional(), - title: zod.string(), - completed: zod.boolean(), - durationSeconds: zod.number().optional(), - priority: zod.string().optional(), - tag: zod - .object({ - tagId: zod.number(), - name: zod.string(), - }) - .optional(), - isRepeated: zod.boolean(), - subtasks: zod.array( - zod.object({ - subtaskId: zod.number(), - content: zod.string(), - completed: zod.boolean(), - }), - ), + tagId: zod.number(), + name: zod.string(), }) .optional(), + isRepeated: zod.boolean(), + memo: zod.string().optional(), + subtasks: zod.array( + zod.object({ + subtaskId: zod.number(), + content: zod.string(), + completed: zod.boolean(), + }), + ), }) .optional(), }); diff --git a/apps/timo-web/api/generated/endpoints/todo/todo.ts b/apps/timo-web/api/generated/endpoints/todo/todo.ts index af0ab788..ba2c70bb 100644 --- a/apps/timo-web/api/generated/endpoints/todo/todo.ts +++ b/apps/timo-web/api/generated/endpoints/todo/todo.ts @@ -405,6 +405,7 @@ export const useDeleteTodo = , TContext = unknown>( * 기존 TODO의 제목, 메모, 날짜, 태그, 우선순위, 예상 소요 시간, 아이콘, 반복 규칙, 하위 태스크를 부분 수정합니다. * * 요청 body에 포함된(null이 아닌) 필드만 수정됩니다. + * 단, icon은 예외로, "NONE"을 전달하면 아이콘을 제거(빈 상태로 저장)하고, null이면 기존 아이콘을 유지합니다. * 예상 소요 시간은 durationSeconds 필드에 초 단위 정수로 전달합니다. * subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다. * subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다. diff --git a/apps/timo-web/api/generated/endpoints/todo/todo.zod.ts b/apps/timo-web/api/generated/endpoints/todo/todo.zod.ts index 80a4c9b7..7872d210 100644 --- a/apps/timo-web/api/generated/endpoints/todo/todo.zod.ts +++ b/apps/timo-web/api/generated/endpoints/todo/todo.zod.ts @@ -26,6 +26,7 @@ export const createTodoBodyMemoMax = 300; export const CreateTodoBody = zod.object({ icon: zod .enum([ + "NONE", "ICON_1", "ICON_2", "ICON_3", @@ -150,6 +151,7 @@ export const DeleteTodoResponse = zod.object({ * 기존 TODO의 제목, 메모, 날짜, 태그, 우선순위, 예상 소요 시간, 아이콘, 반복 규칙, 하위 태스크를 부분 수정합니다. * * 요청 body에 포함된(null이 아닌) 필드만 수정됩니다. + * 단, icon은 예외로, "NONE"을 전달하면 아이콘을 제거(빈 상태로 저장)하고, null이면 기존 아이콘을 유지합니다. * 예상 소요 시간은 durationSeconds 필드에 초 단위 정수로 전달합니다. * subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다. * subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다. @@ -169,6 +171,7 @@ export const updateTodoBodyMemoMax = 300; export const UpdateTodoBody = zod.object({ icon: zod .enum([ + "NONE", "ICON_1", "ICON_2", "ICON_3", diff --git a/apps/timo-web/api/generated/models/baseResponseCalendarAuthorizeResponse.ts b/apps/timo-web/api/generated/models/baseResponseCalendarAuthorizeResponse.ts new file mode 100644 index 00000000..b6b15f2b --- /dev/null +++ b/apps/timo-web/api/generated/models/baseResponseCalendarAuthorizeResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarAuthorizeResponse } from "./calendarAuthorizeResponse"; + +export interface BaseResponseCalendarAuthorizeResponse { + status?: number; + message?: string; + data?: CalendarAuthorizeResponse; +} diff --git a/apps/timo-web/api/generated/models/baseResponseCalendarConnectResponse.ts b/apps/timo-web/api/generated/models/baseResponseCalendarConnectResponse.ts new file mode 100644 index 00000000..34c4a292 --- /dev/null +++ b/apps/timo-web/api/generated/models/baseResponseCalendarConnectResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarConnectResponse } from "./calendarConnectResponse"; + +export interface BaseResponseCalendarConnectResponse { + status?: number; + message?: string; + data?: CalendarConnectResponse; +} diff --git a/apps/timo-web/api/generated/models/baseResponseCalendarDisconnectResponse.ts b/apps/timo-web/api/generated/models/baseResponseCalendarDisconnectResponse.ts new file mode 100644 index 00000000..70725d2b --- /dev/null +++ b/apps/timo-web/api/generated/models/baseResponseCalendarDisconnectResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarDisconnectResponse } from "./calendarDisconnectResponse"; + +export interface BaseResponseCalendarDisconnectResponse { + status?: number; + message?: string; + data?: CalendarDisconnectResponse; +} diff --git a/apps/timo-web/api/generated/models/baseResponseCalendarEventsResponse.ts b/apps/timo-web/api/generated/models/baseResponseCalendarEventsResponse.ts new file mode 100644 index 00000000..aa1df6f4 --- /dev/null +++ b/apps/timo-web/api/generated/models/baseResponseCalendarEventsResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarEventsResponse } from "./calendarEventsResponse"; + +export interface BaseResponseCalendarEventsResponse { + status?: number; + message?: string; + data?: CalendarEventsResponse; +} diff --git a/apps/timo-web/api/generated/models/calendarAuthorizeResponse.ts b/apps/timo-web/api/generated/models/calendarAuthorizeResponse.ts new file mode 100644 index 00000000..8db02c26 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarAuthorizeResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarAuthorizeResponse { + authorizationUrl?: string; +} diff --git a/apps/timo-web/api/generated/models/calendarConnectRequest.ts b/apps/timo-web/api/generated/models/calendarConnectRequest.ts new file mode 100644 index 00000000..e508cbb7 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarConnectRequest.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarConnectRequest { + /** + * 구글 OAuth 동의 완료 후 발급된 authorization code + * @minLength 1 + */ + authorizationCode: string; + /** + * authorize API 호출 시 발급받은 state 값 (CSRF 방어용) + * @minLength 1 + */ + state: string; +} diff --git a/apps/timo-web/api/generated/models/calendarConnectResponse.ts b/apps/timo-web/api/generated/models/calendarConnectResponse.ts new file mode 100644 index 00000000..8149bba2 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarConnectResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarConnectResponse { + calendarConnected?: boolean; + calendarEmail?: string; + /** UTC 기준 연동 시각 */ + connectedAt?: string; +} diff --git a/apps/timo-web/api/generated/models/calendarDayResponse.ts b/apps/timo-web/api/generated/models/calendarDayResponse.ts new file mode 100644 index 00000000..71ee8954 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarDayResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarEventResponse } from "./calendarEventResponse"; + +export interface CalendarDayResponse { + date?: string; + events?: CalendarEventResponse[]; +} diff --git a/apps/timo-web/api/generated/models/calendarDisconnectResponse.ts b/apps/timo-web/api/generated/models/calendarDisconnectResponse.ts new file mode 100644 index 00000000..3242b372 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarDisconnectResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarDisconnectResponse { + calendarConnected?: boolean; +} diff --git a/apps/timo-web/api/generated/models/calendarEventResponse.ts b/apps/timo-web/api/generated/models/calendarEventResponse.ts new file mode 100644 index 00000000..3b38c566 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarEventResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarEventResponse { + title?: string; +} diff --git a/apps/timo-web/api/generated/models/calendarEventsResponse.ts b/apps/timo-web/api/generated/models/calendarEventsResponse.ts new file mode 100644 index 00000000..38deaece --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarEventsResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarDayResponse } from "./calendarDayResponse"; + +export interface CalendarEventsResponse { + filter?: string; + baseDate?: string; + days?: CalendarDayResponse[]; +} diff --git a/apps/timo-web/api/generated/models/errorDto.ts b/apps/timo-web/api/generated/models/errorDto.ts index 2a483e30..d7f45820 100644 --- a/apps/timo-web/api/generated/models/errorDto.ts +++ b/apps/timo-web/api/generated/models/errorDto.ts @@ -12,4 +12,5 @@ export interface ErrorDto { errorCode?: string; message?: string; path?: string; + traceId?: string; } diff --git a/apps/timo-web/api/generated/models/focusTodoDetailResponse.ts b/apps/timo-web/api/generated/models/focusTodoDetailResponse.ts index 91605162..ad4d8b76 100644 --- a/apps/timo-web/api/generated/models/focusTodoDetailResponse.ts +++ b/apps/timo-web/api/generated/models/focusTodoDetailResponse.ts @@ -17,5 +17,6 @@ export interface FocusTodoDetailResponse { priority?: string; tag?: TagResponse; isRepeated: boolean; + memo?: string; subtasks: SubtaskResponse[]; } diff --git a/apps/timo-web/api/generated/models/getCalendarEventsParams.ts b/apps/timo-web/api/generated/models/getCalendarEventsParams.ts new file mode 100644 index 00000000..40e155a3 --- /dev/null +++ b/apps/timo-web/api/generated/models/getCalendarEventsParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface GetCalendarEventsParams { + /** + * 조회 필터 + */ + filter: string; + /** + * 기준 날짜 (YYYY-MM-DD), 미입력 시 오늘 + */ + baseDate?: string; +} diff --git a/apps/timo-web/api/generated/models/index.ts b/apps/timo-web/api/generated/models/index.ts index ba4eace1..e632f371 100644 --- a/apps/timo-web/api/generated/models/index.ts +++ b/apps/timo-web/api/generated/models/index.ts @@ -12,7 +12,10 @@ export * from "./authTokenResponse"; export * from "./baseResponse"; export * from "./baseResponseAuthReissueResponse"; export * from "./baseResponseAuthTokenResponse"; -export * from "./baseResponseFocusTodoResponse"; +export * from "./baseResponseCalendarAuthorizeResponse"; +export * from "./baseResponseCalendarConnectResponse"; +export * from "./baseResponseCalendarDisconnectResponse"; +export * from "./baseResponseCalendarEventsResponse"; export * from "./baseResponseHomeResponse"; export * from "./baseResponseListTimeBoxResponse"; export * from "./baseResponseObject"; @@ -38,6 +41,13 @@ export * from "./baseResponseUpdateLanguageResponse"; export * from "./baseResponseUpdateTimezoneResponse"; export * from "./baseResponseUserProfileResponse"; export * from "./baseResponseVoid"; +export * from "./calendarAuthorizeResponse"; +export * from "./calendarConnectRequest"; +export * from "./calendarConnectResponse"; +export * from "./calendarDayResponse"; +export * from "./calendarDisconnectResponse"; +export * from "./calendarEventResponse"; +export * from "./calendarEventsResponse"; export * from "./dailyTodoResponse"; export * from "./dayCompletionResponse"; export * from "./dayResponse"; @@ -46,6 +56,7 @@ export * from "./errorDto"; export * from "./focusTodoDetailResponse"; export * from "./focusTodoResponse"; export * from "./focusTodoResponseDayOfWeek"; +export * from "./getCalendarEventsParams"; export * from "./getCalendarParams"; export * from "./getDailyParams"; export * from "./getHomeParams"; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx index 28b6c677..208eba98 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx @@ -15,10 +15,13 @@ import { import { useHomeTodosByDate } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date"; import { useHomeViewMode } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode"; import { useHomeViewQuery } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view-query"; +import { HomeCalendarEventCard } from "@/components/calendar/HomeCalendarEventCard"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { StopCompleteModalContainer } from "@/containers/timer/StopCompleteModalContainer"; import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; +import { useMyProfileQuery } from "@/queries/auth/use-my-profile-query"; +import { useCalendarEventsQuery } from "@/queries/calendar/use-calendar-events-query"; import { formatDateKey } from "@/utils/date/date"; import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { getDefaultTagLabelKey } from "@/utils/todo/tag-label"; @@ -37,9 +40,20 @@ export const HomeTodoContainer = () => { const scrollRef = useHomeTodayScrollRef(); const filter: HomeViewFilter = isWeekView ? "WEEK" : "DEFAULT"; + const calendarFilter = isWeekView ? "WEEK" : "TWO_WEEK"; const baseDate = formatDateKey(referenceDate); const { data: homeViewData } = useHomeViewQuery({ filter, baseDate }); + const { data: profile } = useMyProfileQuery(); + const { data: calendarEventsData } = useCalendarEventsQuery({ + filter: calendarFilter, + baseDate, + enabled: profile.calendarConnected, + }); + + const calendarEventsByDate = new Map( + (calendarEventsData?.days ?? []).map((day) => [day.date, day.events]), + ); const days = homeViewData.days; const [pendingCompleteTodo, setPendingCompleteTodo] = @@ -103,6 +117,7 @@ export const HomeTodoContainer = () => { {days.map((day) => { const dateKey = day.date; const todos = todosByDate[dateKey] ?? day.todos; + const calendarEvents = calendarEventsByDate.get(dateKey) ?? []; const completedCount = todos.filter((todo) => todo.completed).length; return ( @@ -121,7 +136,7 @@ export const HomeTodoContainer = () => { dayOfWeek={day.dayOfWeek} isHoliday={day.isHoliday} isToday={day.isToday} - totalCount={todos.length} + totalCount={todos.length + calendarEvents.length} completedCount={completedCount} /> @@ -133,6 +148,12 @@ export const HomeTodoContainer = () => { } >
+ {calendarEvents.map((event, index) => ( + + ))} {todos.map((todo) => { const [firstSubtask] = todo.subtasks; const isActiveTodo = diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx index 988f923a..7f568744 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoListContainer.tsx @@ -10,9 +10,12 @@ import { TodayDateHeaderContainer } from "@/app/[locale]/(main)/(with-time-sideb import { TodayTodoCardContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer"; import { useTodayTodoList } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList"; import { useTodayQuery } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_queries/use-today-query"; +import { TodayCalendarEventCard } from "@/components/calendar/TodayCalendarEventCard"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { StopCompleteModalContainer } from "@/containers/timer/StopCompleteModalContainer"; import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; +import { useMyProfileQuery } from "@/queries/auth/use-my-profile-query"; +import { useCalendarEventsQuery } from "@/queries/calendar/use-calendar-events-query"; import { formatShortDateLabel, parseDateKey } from "@/utils/date/date"; import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; @@ -31,6 +34,13 @@ const renderTodoIcon = (icon: string | undefined) => { export const TodayTodoListContainer = () => { const tToast = useTranslations("Toast"); const { data } = useTodayQuery(); + const { data: profile } = useMyProfileQuery(); + const { data: calendarEventsData } = useCalendarEventsQuery({ + filter: "DAY", + baseDate: data.date.slice(0, 10), + enabled: profile.calendarConnected, + }); + const calendarEvents = calendarEventsData?.days[0]?.events ?? []; const [pendingCompleteTodoId, setPendingCompleteTodoId] = useState< number | null @@ -85,9 +95,15 @@ export const TodayTodoListContainer = () => {
+ {calendarEvents.map((event, index) => ( + + ))} {todos.map((todo) => { const isActiveTodo = activeTimer && diff --git a/apps/timo-web/app/[locale]/(main)/focus/_queries/use-focus-todo-query.ts b/apps/timo-web/app/[locale]/(main)/focus/_queries/use-focus-todo-query.ts index d0329ca0..5e4d597e 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_queries/use-focus-todo-query.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_queries/use-focus-todo-query.ts @@ -12,5 +12,5 @@ export const useFocusTodoQuery = () => useSuspenseQuery({ queryKey: getGetFocusTodoQueryKey(), queryFn: ({ signal }) => getFocusTodo(undefined, signal), - select: ({ data }) => focusViewSchema.parse(data), + select: (data) => focusViewSchema.parse(data), }); diff --git a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx new file mode 100644 index 00000000..b9f3c1fa --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx @@ -0,0 +1,60 @@ +"use client"; + +import timoTimerLogo from "@repo/timo-design-system/assets/images/logo/timo-timer.svg"; +import { ModalButton } from "@repo/timo-design-system/ui"; +import Image from "next/image"; + +import type { SettingsProfileLabels } from "@/app/[locale]/(main)/settings/_types/account/profile-type"; + +import { OverlayModal } from "@/components/modal/OverlayModal"; + +export interface SettingsCalendarDisconnectModalProps { + isOpen: boolean; + onClose: () => void; + onExited: () => void; + labels: SettingsProfileLabels; + onDisconnect: () => void; +} + +export const SettingsCalendarDisconnectModal = ({ + isOpen, + onClose, + onExited, + labels, + onDisconnect, +}: SettingsCalendarDisconnectModalProps) => { + return ( + +
+ +
+

+ {labels.calendarDisconnectConfirmTitle} +

+

+ {labels.calendarDisconnectConfirmDescription} +

+
+ + {labels.calendarDisconnectConfirmCancel} + + { + onDisconnect(); + onClose(); + }} + > + {labels.calendarDisconnectConfirmConfirm} + +
+
+ ); +}; diff --git a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx index e42a4451..a5e8f4a4 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx @@ -101,7 +101,10 @@ export const SettingsProfileView = ({
- + {isCalendarConnected ? labels.disconnect : labels.connect}
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx index 607386ae..d05bfab9 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx @@ -5,6 +5,7 @@ import { overlay } from "overlay-kit"; import { useState } from "react"; import { TagLimitToastContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer"; +import { SettingsCalendarDisconnectModal } from "@/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal"; import { SettingsProfileView } from "@/app/[locale]/(main)/settings/_components/account/SettingsProfileView"; import { useSettingsProfile } from "@/app/[locale]/(main)/settings/_hooks/account/use-settings-profile"; import { useSettingsProfileLabels } from "@/app/[locale]/(main)/settings/_hooks/account/use-settings-profile-labels"; @@ -21,15 +22,43 @@ export const SettingsProfileContainer = () => { const [isCalendarConnected, setIsCalendarConnected] = useState( profileState.calendarConnected, ); + const [isCalendarConnectErrorToastOpen, setIsCalendarConnectErrorToastOpen] = + useState(false); + const [ + isCalendarDisconnectErrorToastOpen, + setIsCalendarDisconnectErrorToastOpen, + ] = useState(false); const [isTagErrorToastOpen, setIsTagErrorToastOpen] = useState(false); const [isTagLimitToastOpen, setIsTagLimitToastOpen] = useState(false); const [isLanguageErrorToastOpen, setIsLanguageErrorToastOpen] = useState(false); const handleConnectCalendar = () => { - profileActions.onConnectCalendar(isCalendarConnected, { + if (isCalendarConnected) { + overlay.open(({ isOpen, close, unmount }) => ( + + profileActions.onConnectCalendar(true, { + onConnect: () => setIsCalendarConnected(true), + onDisconnect: () => setIsCalendarConnected(false), + onDisconnectError: () => + setIsCalendarDisconnectErrorToastOpen(true), + }) + } + /> + )); + return; + } + + profileActions.onConnectCalendar(false, { onConnect: () => setIsCalendarConnected(true), onDisconnect: () => setIsCalendarConnected(false), + onConnectError: () => setIsCalendarConnectErrorToastOpen(true), + onDisconnectError: () => setIsCalendarDisconnectErrorToastOpen(true), }); }; @@ -94,6 +123,18 @@ export const SettingsProfileContainer = () => { setIsTagLimitToastOpen(false)} /> )} + setIsCalendarConnectErrorToastOpen(false)} + message={tToast("calendarConnectFailed")} + /> + + setIsCalendarDisconnectErrorToastOpen(false)} + message={tToast("calendarDisconnectFailed")} + /> + setIsTagErrorToastOpen(false)} diff --git a/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile-labels.ts b/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile-labels.ts index 3715772a..ff7e913c 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile-labels.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile-labels.ts @@ -32,6 +32,18 @@ export const useSettingsProfileLabels = (): SettingsProfileLabels => { logoutConfirmDescription: tSettings("profile.logoutConfirmDescription"), logoutConfirmCancel: tSettings("profile.logoutConfirmCancel"), logoutConfirmConfirm: tSettings("profile.logoutConfirmConfirm"), + calendarDisconnectConfirmTitle: tSettings( + "profile.calendarDisconnectConfirmTitle", + ), + calendarDisconnectConfirmDescription: tSettings( + "profile.calendarDisconnectConfirmDescription", + ), + calendarDisconnectConfirmCancel: tSettings( + "profile.calendarDisconnectConfirmCancel", + ), + calendarDisconnectConfirmConfirm: tSettings( + "profile.calendarDisconnectConfirmConfirm", + ), removeTag: (tag: string) => tSettings("profile.removeTag", { tag }), }; }; diff --git a/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts b/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts index 8ba2db38..cc2e08b9 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts @@ -5,6 +5,11 @@ import { useTranslations } from "next-intl"; import type { SettingsLanguage } from "@/app/[locale]/(main)/settings/_types/account/profile-type"; +import { + authorize, + useDisconnectCalendar, + getGetCalendarEventsQueryKey, +} from "@/api/generated/endpoints/calendar/calendar"; import { getGetMyProfileQueryKey, useUpdateLanguage, @@ -41,6 +46,8 @@ export interface CreateTagHandlers extends ActionErrorHandlers { export interface ConnectCalendarHandlers { onConnect: () => void; onDisconnect: () => void; + onConnectError?: () => void; + onDisconnectError?: () => void; } export const useSettingsProfile = () => { @@ -49,6 +56,7 @@ export const useSettingsProfile = () => { const { data: profile } = useMyProfileQuery(); + const { mutate: disconnectCalendar } = useDisconnectCalendar(); const { mutateAsync: updateLanguage } = useUpdateLanguage(); const queryClient = useQueryClient(); @@ -69,24 +77,40 @@ export const useSettingsProfile = () => { }; }); - const handleConnectCalendar = ( + const handleConnectCalendar = async ( isCalendarConnected: boolean, handlers: ConnectCalendarHandlers, ) => { if (isCalendarConnected) { - // TODO: 실제 확인 모달로 교체 - const confirmed = window.confirm("구글 캘린더 연동을 해제하시겠습니까?"); - if (!confirmed) return; - - // TODO: API - 연동 토큰 파기 - console.log("구글 캘린더 연동 토큰을 파기합니다."); - handlers.onDisconnect(); + disconnectCalendar(undefined, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getGetMyProfileQueryKey(), + }); + queryClient.removeQueries({ + queryKey: getGetCalendarEventsQueryKey(), + }); + handlers.onDisconnect(); + }, + onError: () => { + handlers.onDisconnectError?.(); + }, + }); return; } - // TODO: Google 계정 인증 및 캘린더 접근 권한 동의 팝업 호출 - console.log("Google Calendar 연동 인증 팝업을 호출합니다."); - handlers.onConnect(); + try { + const response = await authorize(); + const url = response.data?.authorizationUrl; + if (!url) { + handlers.onConnectError?.(); + return; + } + localStorage.setItem("calendarConnectOrigin", "settings"); + window.location.assign(url); + } catch { + handlers.onConnectError?.(); + } }; const handleCreateTag = (label: string, handlers: CreateTagHandlers) => { diff --git a/apps/timo-web/app/[locale]/(main)/settings/_types/account/profile-type.ts b/apps/timo-web/app/[locale]/(main)/settings/_types/account/profile-type.ts index 0d16762e..a03dbfdd 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_types/account/profile-type.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_types/account/profile-type.ts @@ -29,5 +29,9 @@ export interface SettingsProfileLabels { logoutConfirmDescription: string; logoutConfirmCancel: string; logoutConfirmConfirm: string; + calendarDisconnectConfirmTitle: string; + calendarDisconnectConfirmDescription: string; + calendarDisconnectConfirmCancel: string; + calendarDisconnectConfirmConfirm: string; removeTag: (tag: string) => string; } diff --git a/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx b/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx new file mode 100644 index 00000000..3bcfd3c9 --- /dev/null +++ b/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useRef } from "react"; + +import { useConnectCalendar } from "@/api/generated/endpoints/calendar/calendar"; +import { getGetMyProfileQueryKey } from "@/api/generated/endpoints/user/user"; +import { ROUTES } from "@/constants/routes"; +import { useRouter } from "@/i18n/navigation"; + +export const CalendarCallbackContainer = () => { + const searchParams = useSearchParams(); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + const router = useRouter(); + const queryClient = useQueryClient(); + const { mutate: connectCalendar } = useConnectCalendar(); + const hasRequested = useRef(false); + + useEffect(() => { + const origin = localStorage.getItem("calendarConnectOrigin"); + const redirectTarget = + origin === "settings" ? ROUTES.SETTINGS : ROUTES.HOME; + + if (error || !code || !state) { + router.replace(redirectTarget); + return; + } + if (hasRequested.current) return; + hasRequested.current = true; + + connectCalendar( + { data: { authorizationCode: code, state } }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: getGetMyProfileQueryKey(), + }); + localStorage.removeItem("calendarConnectOrigin"); + router.replace(redirectTarget); + }, + onError: () => { + router.replace(redirectTarget); + }, + }, + ); + }, [code, state, error, connectCalendar, queryClient, router]); + + return null; +}; diff --git a/apps/timo-web/app/[locale]/oauth/calendar/callback/page.tsx b/apps/timo-web/app/[locale]/oauth/calendar/callback/page.tsx new file mode 100644 index 00000000..0fecca10 --- /dev/null +++ b/apps/timo-web/app/[locale]/oauth/calendar/callback/page.tsx @@ -0,0 +1,10 @@ +import { CalendarCallbackContainer } from "@/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer"; +import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; + +export default function CalendarCallbackPage() { + return ( + + + + ); +} diff --git a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx index fc491756..9c6a6a14 100644 --- a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx @@ -34,13 +34,34 @@ export const OnboardingButton = ({ } if (variant === "start") { + // TODO: 원래 start 버튼 (항상 활성 스타일) — CalendarConnect 단계 재활성화 시 아래 주석 해제 후 isActive 분기 제거 + // return ( + // + // ); return ( ); } diff --git a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx index afda1b95..ae23dac2 100644 --- a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx @@ -4,7 +4,8 @@ interface OnboardingStepButtonProps { step: 1 | 2 | 3 | 4; } -const STEPS = [1, 2, 3, 4] as const; +// TODO: 구글 캘린더 연동 단계 재활성화 시 4 추가 +const STEPS = [1, 2, 3] as const; export const OnboardingStepButton = ({ step }: OnboardingStepButtonProps) => { return ( diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx index 3c1ee3c8..dbd4ed35 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx @@ -1,8 +1,8 @@ "use client"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { authorize } from "@/api/generated/endpoints/calendar/calendar"; import { OnboardingButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingButtonContainer"; import { OnboardingGoogleButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer"; @@ -18,7 +18,19 @@ export const CalendarConnectStepContainer = ({ onStart, }: CalendarConnectStepContainerProps) => { const t = useTranslations("Onboarding"); - const [isCalendarConnected, setIsCalendarConnected] = useState(false); + const isCalendarConnected = false; + + const handleGoogleConnect = async () => { + try { + const response = await authorize(); + const url = response.data?.authorizationUrl; + if (!url) return; + localStorage.setItem("calendarConnectOrigin", "onboarding"); + window.location.assign(url); + } catch { + // authorize 실패 시 아무 동작 없음 — 사용자가 재시도 가능 + } + }; return ( <> @@ -40,10 +52,7 @@ export const CalendarConnectStepContainer = ({ { - // TODO: 실제 구글 캘린더 OAuth 연동 (백엔드 API 확정 후) - setIsCalendarConnected(true); - }} + onClick={handleGoogleConnect} />
diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx index 2b8f8394..77dd2918 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx @@ -86,8 +86,9 @@ export const LifePatternStepContainer = ({
+ {/* TODO: 구글 캘린더 연동 단계 재활성화 시 variant="next"로 복구 */} { !answers.bedTime ) return; - history.push("CalendarConnect", { - language: answers.language, - predictionAccuracy: answers.predictionAccuracy, - wakeUpTime: answers.wakeUpTime, - bedTime: answers.bedTime, - }); + // TODO: 구글 캘린더 연동 단계 재활성화 시 아래 completeOnboarding 블록을 제거하고 + // history.push("CalendarConnect", { language: answers.language, predictionAccuracy: answers.predictionAccuracy, wakeUpTime: answers.wakeUpTime, bedTime: answers.bedTime }) 로 복구 + completeOnboarding( + { + data: { + language: ONBOARDING_LANGUAGE_MAP[answers.language], + predictionAccuracy: answers.predictionAccuracy, + wakeUpTime: answers.wakeUpTime, + bedTime: answers.bedTime, + }, + }, + { + onSuccess: () => { + setOnboardingCompleted(true); + router.replace(ROUTES.HOME, { + locale: answers.language, + }); + }, + onError: () => { + setIsErrorToastOpen(true); + }, + }, + ); }} /> )} diff --git a/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx b/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx new file mode 100644 index 00000000..7c43dde2 --- /dev/null +++ b/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Image from "next/image"; + +interface HomeCalendarEventCardProps { + title: string; +} + +export const HomeCalendarEventCard = ({ + title, +}: HomeCalendarEventCardProps) => ( +
+
+ Google Calendar +
+

+ {title} +

+
+); diff --git a/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx new file mode 100644 index 00000000..5a5d1573 --- /dev/null +++ b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Image from "next/image"; + +interface TodayCalendarEventCardProps { + title: string; +} + +export const TodayCalendarEventCard = ({ + title, +}: TodayCalendarEventCardProps) => ( +
+
+
+ Google Calendar +
+ + {title} + +
+
+); diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 1ee3cae7..180de03d 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -108,7 +108,11 @@ "logoutConfirmTitle": "Do you want to log out?", "logoutConfirmDescription": "Your data stays saved after logging out.\nSign back in anytime to pick up where you left off.", "logoutConfirmCancel": "Cancel", - "logoutConfirmConfirm": "Log out" + "logoutConfirmConfirm": "Log out", + "calendarDisconnectConfirmTitle": "Would you like to disconnect Google Calendar integration?", + "calendarDisconnectConfirmDescription": "If you disconnect, the synced schedules will disappear. \nDo you really want to disconnect?", + "calendarDisconnectConfirmCancel": "Go back", + "calendarDisconnectConfirmConfirm": "Disconnect" }, "withdrawal": { "title": "Withdraw", @@ -155,6 +159,8 @@ "tagActionFailed": "Failed to update the tag. Please try again.", "focusActionFailed": "Failed to process the request. Please try again.", "languageChangeFailed": "Failed to change the language. Please try again.", + "calendarConnectFailed": "Failed to integrate Google Calendar. Please try again.", + "calendarDisconnectFailed": "Failed to disconnect the calendar. Please try again.", "timerAlreadyRunning": "Another to-do's timer is already running. Please stop it first.", "timerStartFailed": "This to-do has no estimated duration set." }, diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json index 0e82016e..7e48164f 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -108,7 +108,11 @@ "logoutConfirmTitle": "로그아웃할까요?", "logoutConfirmDescription": "로그아웃 후에도 데이터는 그대로 유지돼요.\n다시 로그인하면 이어서 사용할 수 있어요.", "logoutConfirmCancel": "취소", - "logoutConfirmConfirm": "로그아웃" + "logoutConfirmConfirm": "로그아웃", + "calendarDisconnectConfirmTitle": "구글 캘린더 연동을 해제할까요?", + "calendarDisconnectConfirmDescription": "연동을 해제하면 동기화된 일정이 사라져요. \n정말 연동을 해제하시겠어요?", + "calendarDisconnectConfirmCancel": "돌아가기", + "calendarDisconnectConfirmConfirm": "해제하기" }, "withdrawal": { "title": "탈퇴", @@ -155,6 +159,8 @@ "tagActionFailed": "태그 처리에 실패했어요. 다시 시도해 주세요.", "focusActionFailed": "요청을 처리하지 못했어요. 다시 시도해 주세요.", "languageChangeFailed": "언어 변경에 실패했어요. 다시 시도해 주세요.", + "calendarConnectFailed": "구글 캘린더 연동에 실패했어요. 다시 시도해 주세요.", + "calendarDisconnectFailed": "캘린더 연동 해제에 실패했어요. 다시 시도해 주세요.", "timerAlreadyRunning": "다른 투두의 타이머가 이미 실행 중이에요. 먼저 종료해 주세요.", "timerStartFailed": "예상 소요 시간이 설정되지 않은 투두입니다." }, diff --git a/apps/timo-web/queries/calendar/use-calendar-events-query.ts b/apps/timo-web/queries/calendar/use-calendar-events-query.ts new file mode 100644 index 00000000..a0efaed2 --- /dev/null +++ b/apps/timo-web/queries/calendar/use-calendar-events-query.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { + getCalendarEvents, + getGetCalendarEventsQueryKey, +} from "@/api/generated/endpoints/calendar/calendar"; +import { + calendarEventsDataSchema, + type CalendarFilter, +} from "@/schemas/calendar/calendar-events-schema"; + +export interface CalendarEventsQueryParams { + filter: CalendarFilter; + baseDate: string; + enabled?: boolean; +} + +export const useCalendarEventsQuery = ({ + filter, + baseDate, + enabled = true, +}: CalendarEventsQueryParams) => + useQuery({ + queryKey: getGetCalendarEventsQueryKey({ filter, baseDate }), + queryFn: ({ signal }) => + getCalendarEvents({ filter, baseDate }, undefined, signal), + select: ({ data }) => calendarEventsDataSchema.parse(data), + enabled, + staleTime: 0, + }); diff --git a/apps/timo-web/schemas/calendar/calendar-events-schema.ts b/apps/timo-web/schemas/calendar/calendar-events-schema.ts new file mode 100644 index 00000000..109f23e0 --- /dev/null +++ b/apps/timo-web/schemas/calendar/calendar-events-schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const calendarFilterSchema = z.enum(["DAY", "WEEK", "TWO_WEEK"]); + +export const calendarEventSchema = z.object({ + title: z.string(), +}); + +export const calendarDaySchema = z.object({ + date: z.string(), + events: z.array(calendarEventSchema), +}); + +export const calendarEventsDataSchema = z.object({ + filter: calendarFilterSchema, + baseDate: z.string(), + days: z.array(calendarDaySchema), +}); + +export type CalendarFilter = z.infer; +export type CalendarEvent = z.infer; +export type CalendarDay = z.infer; +export type CalendarEventsData = z.infer; diff --git a/packages/timo-design-system/src/components/button/pill-button/PillButton.tsx b/packages/timo-design-system/src/components/button/pill-button/PillButton.tsx index 73b61259..7e6f129b 100644 --- a/packages/timo-design-system/src/components/button/pill-button/PillButton.tsx +++ b/packages/timo-design-system/src/components/button/pill-button/PillButton.tsx @@ -2,10 +2,11 @@ import { cn } from "../../../lib"; import type { ReactNode } from "react"; -export type PillButtonVariant = "gray" | "blue"; +export type PillButtonVariant = "gray" | "gray-dark" | "blue"; const PILL_BUTTON_VARIANT: Record = { gray: "bg-timo-gray-300 text-timo-gray-900", + "gray-dark": "bg-timo-gray-700 text-white", blue: "bg-timo-blue-300 text-white", };