From cf14aac05e1f25a063993c3696a5824324683688 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Tue, 14 Jul 2026 18:02:02 +0900 Subject: [PATCH 1/9] =?UTF-8?q?chore(web):=20orval=20API=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캘린더 연동/해제/인증 관련 엔드포인트 및 모델을 추가했습니다 - 기존 엔드포인트 파일들을 최신 스펙으로 재생성했습니다 --- .../api/generated/endpoints/auth/auth.ts | 2 +- .../api/generated/endpoints/auth/auth.zod.ts | 2 +- .../generated/endpoints/calendar/calendar.ts | 366 ++++++++++++++++++ .../endpoints/calendar/calendar.zod.ts | 70 ++++ .../api/generated/endpoints/focus/focus.ts | 4 +- .../generated/endpoints/focus/focus.zod.ts | 49 ++- .../generated/endpoints/terms/terms.zod.ts | 27 +- .../baseResponseCalendarAuthorizeResponse.ts | 14 + .../baseResponseCalendarConnectResponse.ts | 14 + .../baseResponseCalendarDisconnectResponse.ts | 14 + .../models/calendarAuthorizeResponse.ts | 11 + .../models/calendarConnectRequest.ts | 20 + .../models/calendarConnectResponse.ts | 14 + .../models/calendarDisconnectResponse.ts | 11 + .../models/focusTodoDetailResponse.ts | 1 + apps/timo-web/api/generated/models/index.ts | 8 +- 16 files changed, 579 insertions(+), 48 deletions(-) create mode 100644 apps/timo-web/api/generated/endpoints/calendar/calendar.ts create mode 100644 apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts create mode 100644 apps/timo-web/api/generated/models/baseResponseCalendarAuthorizeResponse.ts create mode 100644 apps/timo-web/api/generated/models/baseResponseCalendarConnectResponse.ts create mode 100644 apps/timo-web/api/generated/models/baseResponseCalendarDisconnectResponse.ts create mode 100644 apps/timo-web/api/generated/models/calendarAuthorizeResponse.ts create mode 100644 apps/timo-web/api/generated/models/calendarConnectRequest.ts create mode 100644 apps/timo-web/api/generated/models/calendarConnectResponse.ts create mode 100644 apps/timo-web/api/generated/models/calendarDisconnectResponse.ts 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..cc2bcfcd --- /dev/null +++ b/apps/timo-web/api/generated/endpoints/calendar/calendar.ts @@ -0,0 +1,366 @@ +/** + * 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, + CalendarConnectRequest, + ErrorDto, +} 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, + ); +}; +/** + * 구글 캘린더 연동을 시작하는 구글 인증 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..2d5fb901 --- /dev/null +++ b/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts @@ -0,0 +1,70 @@ +/** + * 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(), +}); + +/** + * 구글 캘린더 연동을 시작하는 구글 인증 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..744d1d9c 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, @@ -54,7 +54,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..af510d88 100644 --- a/apps/timo-web/api/generated/endpoints/focus/focus.zod.ts +++ b/apps/timo-web/api/generated/endpoints/focus/focus.zod.ts @@ -15,37 +15,32 @@ import * as zod from "zod"; * @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/terms/terms.zod.ts b/apps/timo-web/api/generated/endpoints/terms/terms.zod.ts index 85deff25..aa869c7d 100644 --- a/apps/timo-web/api/generated/endpoints/terms/terms.zod.ts +++ b/apps/timo-web/api/generated/endpoints/terms/terms.zod.ts @@ -8,29 +8,24 @@ import * as zod from "zod"; /** - * 서비스 이용약관 및 개인정보 처리방침을 조회합니다. - * type을 지정하지 않으면 전체 약관을 조회하고, SERVICE 또는 PRIVACY를 지정하면 해당 약관만 조회합니다. - * @summary 약관 내용 조회 + * 약관 타입과 언어 기준으로 최신 약관 1건을 조회합니다. + * @summary 약관 조건 조회 */ -export const GetTermsQueryParams = zod.object({ - type: zod.string().optional().describe("약관 타입(SERVICE, PRIVACY)"), +export const GetTermsByConditionQueryParams = zod.object({ + type: zod.string().describe("약관 타입"), + language: zod.string().describe("약관 언어"), }); -export const GetTermsResponse = zod.object({ +export const GetTermsByConditionResponse = zod.object({ status: zod.number().optional(), message: zod.string().optional(), data: zod .object({ - terms: zod - .array( - zod.object({ - termsId: zod.number().describe("약관 ID"), - type: zod.string().describe("약관 타입"), - title: zod.string().describe("약관 제목"), - content: zod.string().describe("약관 전문"), - }), - ) - .describe("약관 목록"), + type: zod.string().optional().describe("약관 타입"), + language: zod.string().optional().describe("약관 언어"), + version: zod.string().optional().describe("약관 버전"), + title: zod.string().optional().describe("약관 제목"), + content: zod.string().optional().describe("약관 전문"), }) .optional(), }); 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/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/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/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/index.ts b/apps/timo-web/api/generated/models/index.ts index b72c72dc..9969d8a3 100644 --- a/apps/timo-web/api/generated/models/index.ts +++ b/apps/timo-web/api/generated/models/index.ts @@ -12,7 +12,9 @@ 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 "./baseResponseHomeResponse"; export * from "./baseResponseListTimeBoxResponse"; export * from "./baseResponseObject"; @@ -38,6 +40,10 @@ export * from "./baseResponseUpdateLanguageResponse"; export * from "./baseResponseUpdateTimezoneResponse"; export * from "./baseResponseUserProfileResponse"; export * from "./baseResponseVoid"; +export * from "./calendarAuthorizeResponse"; +export * from "./calendarConnectRequest"; +export * from "./calendarConnectResponse"; +export * from "./calendarDisconnectResponse"; export * from "./dailyTodoResponse"; export * from "./dayCompletionResponse"; export * from "./dayResponse"; From e24e67d248342f4fc5682f9168128a20b1ab4500 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Tue, 14 Jul 2026 18:03:06 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(web):=20=EA=B5=AC=EA=B8=80=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20OAuth=20=EC=97=B0=EB=8F=99=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캘린더 OAuth 콜백 페이지를 추가했습니다 - 콜백에서 code/state로 캘린더 연동 API를 호출하고 프로필 쿼리를 갱신했습니다 - 온보딩 캘린더 연동 버튼에 authorize API 호출 및 리다이렉트 로직을 연결했습니다 - 설정 페이지 캘린더 연동 버튼에 authorize API 호출 및 리다이렉트 로직을 연결했습니다 - localStorage의 calendarConnectOrigin 값으로 OAuth 완료 후 이동 경로를 구분했습니다 --- .../_hooks/account/use-settings-profile.ts | 18 ++++--- .../_containers/CalendarCallbackContainer.tsx | 53 +++++++++++++++++++ .../[locale]/oauth/calendar/callback/page.tsx | 10 ++++ .../CalendarConnectStepContainer.tsx | 20 +++++-- 4 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx create mode 100644 apps/timo-web/app/[locale]/oauth/calendar/callback/page.tsx 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 3039444b..8f00d0e6 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,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { SettingsLanguage } from "@/app/[locale]/(main)/settings/_types/account/profile-type"; import { tagCreateDataSchema } from "@/api/common/tag-schema"; +import { authorize } from "@/api/generated/endpoints/calendar/calendar"; import { getGetMyProfileQueryKey, useUpdateLanguage, @@ -57,7 +58,7 @@ export const useSettingsProfile = () => { isDefault: tag.isDefault, })); - const handleConnectCalendar = ( + const handleConnectCalendar = async ( isCalendarConnected: boolean, handlers: ConnectCalendarHandlers, ) => { @@ -66,15 +67,20 @@ export const useSettingsProfile = () => { const confirmed = window.confirm("구글 캘린더 연동을 해제하시겠습니까?"); if (!confirmed) return; - // TODO: API - 연동 토큰 파기 - console.log("구글 캘린더 연동 토큰을 파기합니다."); + // TODO: API - 연동 토큰 파기 (다른 담당자 작업) handlers.onDisconnect(); return; } - // TODO: Google 계정 인증 및 캘린더 접근 권한 동의 팝업 호출 - console.log("Google Calendar 연동 인증 팝업을 호출합니다."); - handlers.onConnect(); + try { + const response = await authorize(); + const url = response.data?.authorizationUrl; + if (!url) return; + localStorage.setItem("calendarConnectOrigin", "settings"); + window.location.assign(url); + } catch { + // authorize 실패 시 아무 동작 없음 — 사용자가 재시도 가능 + } }; const handleCreateTag = (label: string, handlers: CreateTagHandlers) => { 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/_containers/CalendarConnectStepContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx index 3c1ee3c8..44757010 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx @@ -3,6 +3,7 @@ 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 +19,19 @@ export const CalendarConnectStepContainer = ({ onStart, }: CalendarConnectStepContainerProps) => { const t = useTranslations("Onboarding"); - const [isCalendarConnected, setIsCalendarConnected] = useState(false); + const [isCalendarConnected] = useState(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 +53,7 @@ export const CalendarConnectStepContainer = ({ { - // TODO: 실제 구글 캘린더 OAuth 연동 (백엔드 API 확정 후) - setIsCalendarConnected(true); - }} + onClick={handleGoogleConnect} /> From 0935ea7339de5810dd61ffa1f4d9c8bc9eed04dc Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 19:02:08 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(web):=20=EA=B5=AC=EA=B8=80=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=9D=BC=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20disconnect=20=EB=AA=A8=EB=8B=AC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pnpm gen:api 재생성 반영 - CalendarEventsResponse 로컬 zod 스키마 및 타입 추가 (schemas/calendar) - useCalendarEventsQuery 훅 추가 (DAY/WEEK/TWO_WEEK 필터 지원) - HomeTodoContainer: 홈 뷰 필터에 따라 WEEK/TWO_WEEK로 캘린더 이벤트 조회 후 일별 렌더링 - TodayTodoListContainer: DAY 필터로 오늘 캘린더 이벤트 조회 후 렌더링 - CalendarEventItem 컴포넌트 추가 - 구글 캘린더 연동 해제 확인 모달(SettingsCalendarDisconnectModalContainer) 적용 - 캘린더 연동/해제 실패 토스트 처리 추가 - focus 엔드포인트 응답 구조 변경 반영 (BaseResponse 래퍼 제거) Co-Authored-By: Claude Sonnet 4.6 --- .../generated/endpoints/calendar/calendar.ts | 178 ++++++++++++++++++ .../endpoints/calendar/calendar.zod.ts | 46 +++++ .../api/generated/endpoints/focus/focus.ts | 5 +- .../generated/endpoints/focus/focus.zod.ts | 5 +- .../api/generated/endpoints/todo/todo.ts | 1 + .../api/generated/endpoints/todo/todo.zod.ts | 3 + .../baseResponseCalendarEventsResponse.ts | 14 ++ .../generated/models/calendarDayResponse.ts | 13 ++ .../generated/models/calendarEventResponse.ts | 11 ++ .../models/calendarEventsResponse.ts | 14 ++ .../timo-web/api/generated/models/errorDto.ts | 1 + .../models/getCalendarEventsParams.ts | 18 ++ apps/timo-web/api/generated/models/index.ts | 5 + .../generated/models/todoCreateRequestIcon.ts | 1 + .../generated/models/todoUpdateRequestIcon.ts | 1 + .../home/_containers/HomeTodoContainer.tsx | 18 ++ .../_containers/TodayTodoListContainer.tsx | 13 ++ .../focus/_queries/use-focus-todo-query.ts | 2 +- ...ttingsCalendarDisconnectModalContainer.tsx | 60 ++++++ .../account/SettingsProfileContainer.tsx | 40 +++- .../account/use-settings-profile-labels.ts | 12 ++ .../_hooks/account/use-settings-profile.ts | 31 ++- .../settings/_types/account/profile-type.ts | 4 + .../components/calendar/CalendarEventItem.tsx | 15 ++ apps/timo-web/messages/en.json | 8 +- apps/timo-web/messages/ko.json | 8 +- .../calendar/use-calendar-events-query.ts | 32 ++++ .../calendar/calendar-events-schema.ts | 23 +++ 28 files changed, 568 insertions(+), 14 deletions(-) create mode 100644 apps/timo-web/api/generated/models/baseResponseCalendarEventsResponse.ts create mode 100644 apps/timo-web/api/generated/models/calendarDayResponse.ts create mode 100644 apps/timo-web/api/generated/models/calendarEventResponse.ts create mode 100644 apps/timo-web/api/generated/models/calendarEventsResponse.ts create mode 100644 apps/timo-web/api/generated/models/getCalendarEventsParams.ts create mode 100644 apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsCalendarDisconnectModalContainer.tsx create mode 100644 apps/timo-web/components/calendar/CalendarEventItem.tsx create mode 100644 apps/timo-web/queries/calendar/use-calendar-events-query.ts create mode 100644 apps/timo-web/schemas/calendar/calendar-events-schema.ts diff --git a/apps/timo-web/api/generated/endpoints/calendar/calendar.ts b/apps/timo-web/api/generated/endpoints/calendar/calendar.ts index cc2bcfcd..193b7863 100644 --- a/apps/timo-web/api/generated/endpoints/calendar/calendar.ts +++ b/apps/timo-web/api/generated/endpoints/calendar/calendar.ts @@ -14,8 +14,10 @@ import type { BaseResponseCalendarAuthorizeResponse, BaseResponseCalendarConnectResponse, BaseResponseCalendarDisconnectResponse, + BaseResponseCalendarEventsResponse, CalendarConnectRequest, ErrorDto, + GetCalendarEventsParams, } from "../../models"; import type { DataTag, @@ -230,6 +232,182 @@ export const useDisconnectCalendar = < 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을 발급합니다. * diff --git a/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts b/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts index 2d5fb901..f23f846e 100644 --- a/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts +++ b/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts @@ -53,6 +53,52 @@ export const DisconnectCalendarResponse = zod.object({ .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을 발급합니다. * diff --git a/apps/timo-web/api/generated/endpoints/focus/focus.ts b/apps/timo-web/api/generated/endpoints/focus/focus.ts index 744d1d9c..26989958 100644 --- a/apps/timo-web/api/generated/endpoints/focus/focus.ts +++ b/apps/timo-web/api/generated/endpoints/focus/focus.ts @@ -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입니다. 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 af510d88..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,7 +8,10 @@ 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입니다. 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/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/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/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/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 079b277c..e632f371 100644 --- a/apps/timo-web/api/generated/models/index.ts +++ b/apps/timo-web/api/generated/models/index.ts @@ -15,6 +15,7 @@ export * from "./baseResponseAuthTokenResponse"; export * from "./baseResponseCalendarAuthorizeResponse"; export * from "./baseResponseCalendarConnectResponse"; export * from "./baseResponseCalendarDisconnectResponse"; +export * from "./baseResponseCalendarEventsResponse"; export * from "./baseResponseHomeResponse"; export * from "./baseResponseListTimeBoxResponse"; export * from "./baseResponseObject"; @@ -43,7 +44,10 @@ 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"; @@ -52,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/api/generated/models/todoCreateRequestIcon.ts b/apps/timo-web/api/generated/models/todoCreateRequestIcon.ts index 03aee0eb..68fad3e3 100644 --- a/apps/timo-web/api/generated/models/todoCreateRequestIcon.ts +++ b/apps/timo-web/api/generated/models/todoCreateRequestIcon.ts @@ -10,6 +10,7 @@ export type TodoCreateRequestIcon = (typeof TodoCreateRequestIcon)[keyof typeof TodoCreateRequestIcon]; export const TodoCreateRequestIcon = { + NONE: "NONE", ICON_1: "ICON_1", ICON_2: "ICON_2", ICON_3: "ICON_3", diff --git a/apps/timo-web/api/generated/models/todoUpdateRequestIcon.ts b/apps/timo-web/api/generated/models/todoUpdateRequestIcon.ts index c290f59b..f23a7a76 100644 --- a/apps/timo-web/api/generated/models/todoUpdateRequestIcon.ts +++ b/apps/timo-web/api/generated/models/todoUpdateRequestIcon.ts @@ -10,6 +10,7 @@ export type TodoUpdateRequestIcon = (typeof TodoUpdateRequestIcon)[keyof typeof TodoUpdateRequestIcon]; export const TodoUpdateRequestIcon = { + NONE: "NONE", ICON_1: "ICON_1", ICON_2: "ICON_2", ICON_3: "ICON_3", 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 e0aae773..cff55ba4 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 { CalendarEventItem } from "@/components/calendar/CalendarEventItem"; 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] = @@ -102,6 +116,7 @@ export const HomeTodoContainer = () => { const dateKey = day.date; const todos = todosByDate[dateKey] ?? day.todos; const completedCount = todos.filter((todo) => todo.completed).length; + const calendarEvents = calendarEventsByDate.get(dateKey) ?? []; return (
{ } >
+ {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 63646197..04eb0da9 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 { CalendarEventItem } from "@/components/calendar/CalendarEventItem"; 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 { formatDate } 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 @@ -86,6 +96,9 @@ export const TodayTodoListContainer = () => { totalCount={todos.length} />
+ {calendarEvents.map((event, index) => ( + + ))} {todos.map((todo) => { const isActiveTodo = activeTimer && activeTimer.todoId === todo.todoId; 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/_containers/account/SettingsCalendarDisconnectModalContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsCalendarDisconnectModalContainer.tsx new file mode 100644 index 00000000..9699f596 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsCalendarDisconnectModalContainer.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 SettingsCalendarDisconnectModalContainerProps { + isOpen: boolean; + onClose: () => void; + onExited: () => void; + labels: SettingsProfileLabels; + onDisconnect: () => void; +} + +export const SettingsCalendarDisconnectModalContainer = ({ + isOpen, + onClose, + onExited, + labels, + onDisconnect, +}: SettingsCalendarDisconnectModalContainerProps) => { + return ( + +
+ +
+

+ {labels.calendarDisconnectConfirmTitle} +

+

+ {labels.calendarDisconnectConfirmDescription} +

+
+ + {labels.calendarDisconnectConfirmCancel} + + { + onDisconnect(); + onClose(); + }} + > + {labels.calendarDisconnectConfirmConfirm} + +
+
+ ); +}; 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 8e1f8d8c..63875dbb 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 { SettingsProfileView } from "@/app/[locale]/(main)/settings/_components/account/SettingsProfileView"; +import { SettingsCalendarDisconnectModalContainer } from "@/app/[locale]/(main)/settings/_containers/account/SettingsCalendarDisconnectModalContainer"; 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"; import { CreateTagModalContainer } from "@/components/tag/CreateTagModalContainer"; @@ -19,14 +20,39 @@ export const SettingsProfileContainer = () => { const [isCalendarConnected, setIsCalendarConnected] = useState( profileState.calendarConnected, ); + const [isCalendarConnectErrorToastOpen, setIsCalendarConnectErrorToastOpen] = + useState(false); + const [isCalendarErrorToastOpen, setIsCalendarErrorToastOpen] = + useState(false); const [isTagErrorToastOpen, setIsTagErrorToastOpen] = 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: () => setIsCalendarErrorToastOpen(true), + }) + } + /> + )); + return; + } + + profileActions.onConnectCalendar(false, { onConnect: () => setIsCalendarConnected(true), onDisconnect: () => setIsCalendarConnected(false), + onConnectError: () => setIsCalendarConnectErrorToastOpen(true), + onDisconnectError: () => setIsCalendarErrorToastOpen(true), }); }; @@ -78,6 +104,18 @@ export const SettingsProfileContainer = () => { onLogout={profileActions.onLogout} /> + setIsCalendarConnectErrorToastOpen(false)} + message={tToast("calendarConnectFailed")} + /> + + setIsCalendarErrorToastOpen(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 08c10adb..d4ab445a 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,10 @@ import { useTranslations } from "next-intl"; import type { SettingsLanguage } from "@/app/[locale]/(main)/settings/_types/account/profile-type"; +import { + authorize, + useDisconnectCalendar, +} from "@/api/generated/endpoints/calendar/calendar"; import { getGetMyProfileQueryKey, useUpdateLanguage, @@ -38,6 +42,8 @@ export interface CreateTagHandlers extends ActionErrorHandlers { export interface ConnectCalendarHandlers { onConnect: () => void; onDisconnect: () => void; + onConnectError?: () => void; + onDisconnectError?: () => void; } export const useSettingsProfile = () => { @@ -46,6 +52,7 @@ export const useSettingsProfile = () => { const { data: profile } = useMyProfileQuery(); + const { mutate: disconnectCalendar } = useDisconnectCalendar(); const { mutateAsync: updateLanguage } = useUpdateLanguage(); const queryClient = useQueryClient(); @@ -71,23 +78,31 @@ export const useSettingsProfile = () => { handlers: ConnectCalendarHandlers, ) => { if (isCalendarConnected) { - // TODO: 실제 확인 모달로 교체 - const confirmed = window.confirm("구글 캘린더 연동을 해제하시겠습니까?"); - if (!confirmed) return; - - // TODO: API - 연동 토큰 파기 (다른 담당자 작업) - handlers.onDisconnect(); + disconnectCalendar(undefined, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getGetMyProfileQueryKey(), + }); + handlers.onDisconnect(); + }, + onError: () => { + handlers.onDisconnectError?.(); + }, + }); return; } try { const response = await authorize(); const url = response.data?.authorizationUrl; - if (!url) return; + if (!url) { + handlers.onConnectError?.(); + return; + } localStorage.setItem("calendarConnectOrigin", "settings"); window.location.assign(url); } catch { - // authorize 실패 시 아무 동작 없음 — 사용자가 재시도 가능 + handlers.onConnectError?.(); } }; 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/components/calendar/CalendarEventItem.tsx b/apps/timo-web/components/calendar/CalendarEventItem.tsx new file mode 100644 index 00000000..cb6c4d8d --- /dev/null +++ b/apps/timo-web/components/calendar/CalendarEventItem.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type { CalendarEvent } from "@/schemas/calendar/calendar-events-schema"; + +interface CalendarEventItemProps { + event: CalendarEvent; +} + +export const CalendarEventItem = ({ event }: CalendarEventItemProps) => ( +
+ + {event.title} + +
+); diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 8f846765..410b1544 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -104,7 +104,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", @@ -151,6 +155,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 c616188d..f3232e8a 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -104,7 +104,11 @@ "logoutConfirmTitle": "로그아웃할까요?", "logoutConfirmDescription": "로그아웃 후에도 데이터는 그대로 유지돼요.\n다시 로그인하면 이어서 사용할 수 있어요.", "logoutConfirmCancel": "취소", - "logoutConfirmConfirm": "로그아웃" + "logoutConfirmConfirm": "로그아웃", + "calendarDisconnectConfirmTitle": "구글 캘린더 연동을 해제할까요?", + "calendarDisconnectConfirmDescription": "연동을 해제하면 동기화된 일정이 사라져요. \n정말 연동을 해제하시겠어요?", + "calendarDisconnectConfirmCancel": "돌아가기", + "calendarDisconnectConfirmConfirm": "해제하기" }, "withdrawal": { "title": "탈퇴", @@ -151,6 +155,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; From a2371aa9bb44ac67c9e5f1731529e77f09d23040 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 19:43:26 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat(web):=20=EA=B5=AC=EA=B8=80=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20UI=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeCalendarEventCard, TodayCalendarEventCard 컴포넌트를 추가했습니다 - CalendarEventItem 컴포넌트를 삭제했습니다 - 캘린더 이벤트 체크박스 상태를 컨테이너로 리프팅했습니다 - completedCount와 totalCount 계산에 캘린더 이벤트를 포함했습니다 - 캘린더 연결 해제 시 이벤트 쿼리 캐시를 제거했습니다 --- .../home/_containers/HomeTodoContainer.tsx | 32 +++++++++-- .../_containers/TodayTodoListContainer.tsx | 26 +++++++-- .../_hooks/account/use-settings-profile.ts | 4 ++ .../components/calendar/CalendarEventItem.tsx | 15 ------ .../calendar/HomeCalendarEventCard.tsx | 42 +++++++++++++++ .../calendar/TodayCalendarEventCard.tsx | 54 +++++++++++++++++++ 6 files changed, 150 insertions(+), 23 deletions(-) delete mode 100644 apps/timo-web/components/calendar/CalendarEventItem.tsx create mode 100644 apps/timo-web/components/calendar/HomeCalendarEventCard.tsx create mode 100644 apps/timo-web/components/calendar/TodayCalendarEventCard.tsx 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 cff55ba4..2cbf0844 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,7 +15,7 @@ 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 { CalendarEventItem } from "@/components/calendar/CalendarEventItem"; +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"; @@ -56,6 +56,21 @@ export const HomeTodoContainer = () => { ); const days = homeViewData.days; + const [checkedCalendarByDate, setCheckedCalendarByDate] = useState< + Map> + >(new Map()); + + const toggleCalendarEvent = (dateKey: string, index: number) => { + setCheckedCalendarByDate((prev) => { + const next = new Map(prev); + const set = new Set(next.get(dateKey) ?? []); + if (set.has(index)) set.delete(index); + else set.add(index); + next.set(dateKey, set); + return next; + }); + }; + const [pendingCompleteTodo, setPendingCompleteTodo] = useState(null); const [feedbackText, setFeedbackText] = useState(); @@ -115,8 +130,12 @@ export const HomeTodoContainer = () => { {days.map((day) => { const dateKey = day.date; const todos = todosByDate[dateKey] ?? day.todos; - const completedCount = todos.filter((todo) => todo.completed).length; const calendarEvents = calendarEventsByDate.get(dateKey) ?? []; + const checkedCalendarIndices = + checkedCalendarByDate.get(dateKey) ?? new Set(); + const completedCount = + todos.filter((todo) => todo.completed).length + + checkedCalendarIndices.size; return (
{ dayOfWeek={day.dayOfWeek} isHoliday={day.isHoliday} isToday={day.isToday} - totalCount={todos.length} + totalCount={todos.length + calendarEvents.length} completedCount={completedCount} /> @@ -147,7 +166,12 @@ export const HomeTodoContainer = () => { >
{calendarEvents.map((event, index) => ( - + toggleCalendarEvent(dateKey, index)} + /> ))} {todos.map((todo) => { const [firstSubtask] = todo.subtasks; 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 04eb0da9..65edd348 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,7 +10,7 @@ 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 { CalendarEventItem } from "@/components/calendar/CalendarEventItem"; +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"; @@ -41,6 +41,18 @@ export const TodayTodoListContainer = () => { enabled: profile.calendarConnected, }); const calendarEvents = calendarEventsData?.days[0]?.events ?? []; + const [checkedCalendarIndices, setCheckedCalendarIndices] = useState< + Set + >(new Set()); + + const toggleCalendarEvent = (index: number) => { + setCheckedCalendarIndices((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + }; const [pendingCompleteTodoId, setPendingCompleteTodoId] = useState< number | null @@ -78,7 +90,8 @@ export const TodayTodoListContainer = () => { setPendingCompleteTodoId(null); }; - const completedCount = todos.filter((todo) => todo.completed).length; + const completedCount = + todos.filter((todo) => todo.completed).length + checkedCalendarIndices.size; const plannedMinutes = activeTimer ? convertDurationToMinutes( @@ -93,11 +106,16 @@ export const TodayTodoListContainer = () => {
{calendarEvents.map((event, index) => ( - + toggleCalendarEvent(index)} + /> ))} {todos.map((todo) => { const isActiveTodo = 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 d4ab445a..15c0aa52 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 @@ -8,6 +8,7 @@ import type { SettingsLanguage } from "@/app/[locale]/(main)/settings/_types/acc import { authorize, useDisconnectCalendar, + getGetCalendarEventsQueryKey, } from "@/api/generated/endpoints/calendar/calendar"; import { getGetMyProfileQueryKey, @@ -83,6 +84,9 @@ export const useSettingsProfile = () => { queryClient.invalidateQueries({ queryKey: getGetMyProfileQueryKey(), }); + queryClient.removeQueries({ + queryKey: getGetCalendarEventsQueryKey(), + }); handlers.onDisconnect(); }, onError: () => { diff --git a/apps/timo-web/components/calendar/CalendarEventItem.tsx b/apps/timo-web/components/calendar/CalendarEventItem.tsx deleted file mode 100644 index cb6c4d8d..00000000 --- a/apps/timo-web/components/calendar/CalendarEventItem.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import type { CalendarEvent } from "@/schemas/calendar/calendar-events-schema"; - -interface CalendarEventItemProps { - event: CalendarEvent; -} - -export const CalendarEventItem = ({ event }: CalendarEventItemProps) => ( -
- - {event.title} - -
-); diff --git a/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx b/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx new file mode 100644 index 00000000..37d95665 --- /dev/null +++ b/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Checkbox } from "@repo/timo-design-system/ui"; +import { cn } from "@repo/timo-design-system/utils"; +import Image from "next/image"; + +interface HomeCalendarEventCardProps { + title: string; + checked: boolean; + onToggle: (checked: boolean) => void; +} + +export const HomeCalendarEventCard = ({ + title, + checked, + onToggle, +}: 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..41637002 --- /dev/null +++ b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Checkbox } from "@repo/timo-design-system/ui"; +import { cn } from "@repo/timo-design-system/utils"; +import Image from "next/image"; +import { useState } from "react"; + +interface TodayCalendarEventCardProps { + title: string; + checked: boolean; + onToggle: (checked: boolean) => void; +} + +export const TodayCalendarEventCard = ({ + title, + checked, + onToggle, +}: TodayCalendarEventCardProps) => { + const [isHovered, setIsHovered] = useState(false); + const isDimmed = checked && !isHovered; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+
+ +
+
+ Google Calendar +
+ + {title} + +
+
+ ); +}; From d7df7b3d5b5b8fa6f0959b21d39e0d0bbbcc6189 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 20:52:08 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor(web):=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캘린더 이벤트 key와 체크 상태를 index 대신 title 기반으로 변경했습니다 - SettingsCalendarDisconnectModal을 _components로 이동하고 Container suffix를 제거했습니다 - isCalendarErrorToastOpen을 isCalendarDisconnectErrorToastOpen으로 이름을 변경했습니다 - CalendarConnectStepContainer의 불필요한 useState를 상수로 변경했습니다 - TodayCalendarEventCard의 hover 상태를 Tailwind CSS group-hover로 변경했습니다 --- .../home/_containers/HomeTodoContainer.tsx | 22 +++---- .../_containers/TodayTodoListContainer.tsx | 22 +++---- .../SettingsCalendarDisconnectModal.tsx} | 6 +- .../account/SettingsProfileContainer.tsx | 19 +++--- .../CalendarConnectStepContainer.tsx | 3 +- .../calendar/TodayCalendarEventCard.tsx | 66 +++++++++---------- 6 files changed, 67 insertions(+), 71 deletions(-) rename apps/timo-web/app/[locale]/(main)/settings/{_containers/account/SettingsCalendarDisconnectModalContainer.tsx => _components/account/SettingsCalendarDisconnectModal.tsx} (90%) 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 2cbf0844..5fb32b6a 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 @@ -57,15 +57,15 @@ export const HomeTodoContainer = () => { const days = homeViewData.days; const [checkedCalendarByDate, setCheckedCalendarByDate] = useState< - Map> + Map> >(new Map()); - const toggleCalendarEvent = (dateKey: string, index: number) => { + const toggleCalendarEvent = (dateKey: string, title: string) => { setCheckedCalendarByDate((prev) => { const next = new Map(prev); const set = new Set(next.get(dateKey) ?? []); - if (set.has(index)) set.delete(index); - else set.add(index); + if (set.has(title)) set.delete(title); + else set.add(title); next.set(dateKey, set); return next; }); @@ -131,11 +131,11 @@ export const HomeTodoContainer = () => { const dateKey = day.date; const todos = todosByDate[dateKey] ?? day.todos; const calendarEvents = calendarEventsByDate.get(dateKey) ?? []; - const checkedCalendarIndices = - checkedCalendarByDate.get(dateKey) ?? new Set(); + const checkedCalendarTitles = + checkedCalendarByDate.get(dateKey) ?? new Set(); const completedCount = todos.filter((todo) => todo.completed).length + - checkedCalendarIndices.size; + checkedCalendarTitles.size; return (
{ } >
- {calendarEvents.map((event, index) => ( + {calendarEvents.map((event) => ( toggleCalendarEvent(dateKey, index)} + checked={checkedCalendarTitles.has(event.title)} + onToggle={() => toggleCalendarEvent(dateKey, event.title)} /> ))} {todos.map((todo) => { 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 65edd348..d1a8f90c 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 @@ -41,15 +41,15 @@ export const TodayTodoListContainer = () => { enabled: profile.calendarConnected, }); const calendarEvents = calendarEventsData?.days[0]?.events ?? []; - const [checkedCalendarIndices, setCheckedCalendarIndices] = useState< - Set + const [checkedCalendarTitles, setCheckedCalendarTitles] = useState< + Set >(new Set()); - const toggleCalendarEvent = (index: number) => { - setCheckedCalendarIndices((prev) => { + const toggleCalendarEvent = (title: string) => { + setCheckedCalendarTitles((prev) => { const next = new Set(prev); - if (next.has(index)) next.delete(index); - else next.add(index); + if (next.has(title)) next.delete(title); + else next.add(title); return next; }); }; @@ -91,7 +91,7 @@ export const TodayTodoListContainer = () => { }; const completedCount = - todos.filter((todo) => todo.completed).length + checkedCalendarIndices.size; + todos.filter((todo) => todo.completed).length + checkedCalendarTitles.size; const plannedMinutes = activeTimer ? convertDurationToMinutes( @@ -109,12 +109,12 @@ export const TodayTodoListContainer = () => { totalCount={todos.length + calendarEvents.length} />
- {calendarEvents.map((event, index) => ( + {calendarEvents.map((event) => ( toggleCalendarEvent(index)} + checked={checkedCalendarTitles.has(event.title)} + onToggle={() => toggleCalendarEvent(event.title)} /> ))} {todos.map((todo) => { diff --git a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsCalendarDisconnectModalContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx similarity index 90% rename from apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsCalendarDisconnectModalContainer.tsx rename to apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx index 9699f596..b9f3c1fa 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsCalendarDisconnectModalContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx @@ -8,7 +8,7 @@ import type { SettingsProfileLabels } from "@/app/[locale]/(main)/settings/_type import { OverlayModal } from "@/components/modal/OverlayModal"; -export interface SettingsCalendarDisconnectModalContainerProps { +export interface SettingsCalendarDisconnectModalProps { isOpen: boolean; onClose: () => void; onExited: () => void; @@ -16,13 +16,13 @@ export interface SettingsCalendarDisconnectModalContainerProps { onDisconnect: () => void; } -export const SettingsCalendarDisconnectModalContainer = ({ +export const SettingsCalendarDisconnectModal = ({ isOpen, onClose, onExited, labels, onDisconnect, -}: SettingsCalendarDisconnectModalContainerProps) => { +}: SettingsCalendarDisconnectModalProps) => { return ( { ); const [isCalendarConnectErrorToastOpen, setIsCalendarConnectErrorToastOpen] = useState(false); - const [isCalendarErrorToastOpen, setIsCalendarErrorToastOpen] = - useState(false); + const [ + isCalendarDisconnectErrorToastOpen, + setIsCalendarDisconnectErrorToastOpen, + ] = useState(false); const [isTagErrorToastOpen, setIsTagErrorToastOpen] = useState(false); const [isLanguageErrorToastOpen, setIsLanguageErrorToastOpen] = useState(false); @@ -31,7 +33,7 @@ export const SettingsProfileContainer = () => { const handleConnectCalendar = () => { if (isCalendarConnected) { overlay.open(({ isOpen, close, unmount }) => ( - { profileActions.onConnectCalendar(true, { onConnect: () => setIsCalendarConnected(true), onDisconnect: () => setIsCalendarConnected(false), - onDisconnectError: () => setIsCalendarErrorToastOpen(true), + onDisconnectError: () => + setIsCalendarDisconnectErrorToastOpen(true), }) } /> @@ -52,7 +55,7 @@ export const SettingsProfileContainer = () => { onConnect: () => setIsCalendarConnected(true), onDisconnect: () => setIsCalendarConnected(false), onConnectError: () => setIsCalendarConnectErrorToastOpen(true), - onDisconnectError: () => setIsCalendarErrorToastOpen(true), + onDisconnectError: () => setIsCalendarDisconnectErrorToastOpen(true), }); }; @@ -111,8 +114,8 @@ export const SettingsProfileContainer = () => { /> setIsCalendarErrorToastOpen(false)} + isOpen={isCalendarDisconnectErrorToastOpen} + onClose={() => setIsCalendarDisconnectErrorToastOpen(false)} message={tToast("calendarDisconnectFailed")} /> diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx index 44757010..dbd4ed35 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx @@ -1,7 +1,6 @@ "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"; @@ -19,7 +18,7 @@ export const CalendarConnectStepContainer = ({ onStart, }: CalendarConnectStepContainerProps) => { const t = useTranslations("Onboarding"); - const [isCalendarConnected] = useState(false); + const isCalendarConnected = false; const handleGoogleConnect = async () => { try { diff --git a/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx index 41637002..09a272ca 100644 --- a/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx +++ b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx @@ -3,7 +3,6 @@ import { Checkbox } from "@repo/timo-design-system/ui"; import { cn } from "@repo/timo-design-system/utils"; import Image from "next/image"; -import { useState } from "react"; interface TodayCalendarEventCardProps { title: string; @@ -15,40 +14,35 @@ export const TodayCalendarEventCard = ({ title, checked, onToggle, -}: TodayCalendarEventCardProps) => { - const [isHovered, setIsHovered] = useState(false); - const isDimmed = checked && !isHovered; - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
-
- -
-
- Google Calendar -
- - {title} - +}: TodayCalendarEventCardProps) => ( +
+
+
+ +
+
+ Google Calendar
+ + {title} +
- ); -}; +
+); From c602d143de123327c75ba49bee2238faf248809c Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 22:07:23 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix(web):=20=EC=84=A4=EC=A0=95=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B2=84=ED=8A=BC=20=EC=83=89=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PillButton에 gray-dark variant를 추가했습니다 - Integrate 상태에서는 blue, Disconnect 상태에서는 gray-dark로 변경했습니다 --- .../settings/_components/account/SettingsProfileView.tsx | 5 ++++- .../src/components/button/pill-button/PillButton.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) 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/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", }; From ebfbcdbe0abc5a6acd8ab5d30c37bbcf51eaf3a3 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 22:12:57 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix(web):=20=EB=8F=99=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20key=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이벤트 식별자를 title 단독에서 title-index composite key로 변경했습니다 - 동일 제목 일정이 여러 개일 때 각각 독립적으로 체크 상태를 관리합니다 - React 중복 key 경고를 해소했습니다 --- .../home/_containers/HomeTodoContainer.tsx | 29 ++++++++------- .../_containers/TodayTodoListContainer.tsx | 35 ++++++++++--------- 2 files changed, 35 insertions(+), 29 deletions(-) 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 5fb32b6a..33740fcd 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 @@ -60,12 +60,12 @@ export const HomeTodoContainer = () => { Map> >(new Map()); - const toggleCalendarEvent = (dateKey: string, title: string) => { + const toggleCalendarEvent = (dateKey: string, eventKey: string) => { setCheckedCalendarByDate((prev) => { const next = new Map(prev); const set = new Set(next.get(dateKey) ?? []); - if (set.has(title)) set.delete(title); - else set.add(title); + if (set.has(eventKey)) set.delete(eventKey); + else set.add(eventKey); next.set(dateKey, set); return next; }); @@ -131,11 +131,11 @@ export const HomeTodoContainer = () => { const dateKey = day.date; const todos = todosByDate[dateKey] ?? day.todos; const calendarEvents = calendarEventsByDate.get(dateKey) ?? []; - const checkedCalendarTitles = + const checkedCalendarKeys = checkedCalendarByDate.get(dateKey) ?? new Set(); const completedCount = todos.filter((todo) => todo.completed).length + - checkedCalendarTitles.size; + checkedCalendarKeys.size; return (
{ } >
- {calendarEvents.map((event) => ( - toggleCalendarEvent(dateKey, event.title)} - /> - ))} + {calendarEvents.map((event, index) => { + const eventKey = `${event.title}-${index}`; + return ( + toggleCalendarEvent(dateKey, eventKey)} + /> + ); + })} {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 d1a8f90c..41281028 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 @@ -41,15 +41,15 @@ export const TodayTodoListContainer = () => { enabled: profile.calendarConnected, }); const calendarEvents = calendarEventsData?.days[0]?.events ?? []; - const [checkedCalendarTitles, setCheckedCalendarTitles] = useState< - Set - >(new Set()); + const [checkedCalendarKeys, setCheckedCalendarKeys] = useState>( + new Set(), + ); - const toggleCalendarEvent = (title: string) => { - setCheckedCalendarTitles((prev) => { + const toggleCalendarEvent = (eventKey: string) => { + setCheckedCalendarKeys((prev) => { const next = new Set(prev); - if (next.has(title)) next.delete(title); - else next.add(title); + if (next.has(eventKey)) next.delete(eventKey); + else next.add(eventKey); return next; }); }; @@ -91,7 +91,7 @@ export const TodayTodoListContainer = () => { }; const completedCount = - todos.filter((todo) => todo.completed).length + checkedCalendarTitles.size; + todos.filter((todo) => todo.completed).length + checkedCalendarKeys.size; const plannedMinutes = activeTimer ? convertDurationToMinutes( @@ -109,14 +109,17 @@ export const TodayTodoListContainer = () => { totalCount={todos.length + calendarEvents.length} />
- {calendarEvents.map((event) => ( - toggleCalendarEvent(event.title)} - /> - ))} + {calendarEvents.map((event, index) => { + const eventKey = `${event.title}-${index}`; + return ( + toggleCalendarEvent(eventKey)} + /> + ); + })} {todos.map((todo) => { const isActiveTodo = activeTimer && activeTimer.todoId === todo.todoId; From e39ac55283660afc3c662a3435a59632f5f3035c Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Thu, 16 Jul 2026 15:36:39 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat(#187):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=EC=97=B0=EB=8F=99=20=EB=8B=A8=EA=B3=84=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../home/_containers/HomeTodoContainer.tsx | 38 ++++--------------- .../_containers/TodayTodoListContainer.tsx | 32 ++++------------ .../_components/OnboardingStepButton.tsx | 3 +- .../_containers/LifePatternStepContainer.tsx | 3 +- .../_containers/OnboardingFunnelContainer.tsx | 29 +++++++++++--- .../calendar/HomeCalendarEventCard.tsx | 21 +--------- .../calendar/TodayCalendarEventCard.tsx | 25 +----------- 7 files changed, 45 insertions(+), 106 deletions(-) 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 6cde0bf3..81d95a21 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 @@ -56,21 +56,6 @@ export const HomeTodoContainer = () => { ); const days = homeViewData.days; - const [checkedCalendarByDate, setCheckedCalendarByDate] = useState< - Map> - >(new Map()); - - const toggleCalendarEvent = (dateKey: string, eventKey: string) => { - setCheckedCalendarByDate((prev) => { - const next = new Map(prev); - const set = new Set(next.get(dateKey) ?? []); - if (set.has(eventKey)) set.delete(eventKey); - else set.add(eventKey); - next.set(dateKey, set); - return next; - }); - }; - const [pendingCompleteTodo, setPendingCompleteTodo] = useState(null); const [feedbackText, setFeedbackText] = useState(); @@ -131,11 +116,7 @@ export const HomeTodoContainer = () => { const dateKey = day.date; const todos = todosByDate[dateKey] ?? day.todos; const calendarEvents = calendarEventsByDate.get(dateKey) ?? []; - const checkedCalendarKeys = - checkedCalendarByDate.get(dateKey) ?? new Set(); - const completedCount = - todos.filter((todo) => todo.completed).length + - checkedCalendarKeys.size; + const completedCount = todos.filter((todo) => todo.completed).length; return (
{ } >
- {calendarEvents.map((event, index) => { - const eventKey = `${event.title}-${index}`; - return ( - toggleCalendarEvent(dateKey, eventKey)} - /> - ); - })} + {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 7d85ade8..e381a299 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 @@ -41,18 +41,6 @@ export const TodayTodoListContainer = () => { enabled: profile.calendarConnected, }); const calendarEvents = calendarEventsData?.days[0]?.events ?? []; - const [checkedCalendarKeys, setCheckedCalendarKeys] = useState>( - new Set(), - ); - - const toggleCalendarEvent = (eventKey: string) => { - setCheckedCalendarKeys((prev) => { - const next = new Set(prev); - if (next.has(eventKey)) next.delete(eventKey); - else next.add(eventKey); - return next; - }); - }; const [pendingCompleteTodoId, setPendingCompleteTodoId] = useState< number | null @@ -90,8 +78,7 @@ export const TodayTodoListContainer = () => { setPendingCompleteTodoId(null); }; - const completedCount = - todos.filter((todo) => todo.completed).length + checkedCalendarKeys.size; + const completedCount = todos.filter((todo) => todo.completed).length; const plannedMinutes = activeTimer ? convertDurationToMinutes( @@ -109,17 +96,12 @@ export const TodayTodoListContainer = () => { totalCount={todos.length + calendarEvents.length} />
- {calendarEvents.map((event, index) => { - const eventKey = `${event.title}-${index}`; - return ( - toggleCalendarEvent(eventKey)} - /> - ); - })} + {calendarEvents.map((event, index) => ( + + ))} {todos.map((todo) => { const isActiveTodo = activeTimer && activeTimer.todoId === todo.todoId; 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/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 index 37d95665..7c43dde2 100644 --- a/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx +++ b/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx @@ -1,27 +1,15 @@ "use client"; -import { Checkbox } from "@repo/timo-design-system/ui"; -import { cn } from "@repo/timo-design-system/utils"; import Image from "next/image"; interface HomeCalendarEventCardProps { title: string; - checked: boolean; - onToggle: (checked: boolean) => void; } export const HomeCalendarEventCard = ({ title, - checked, - onToggle, }: HomeCalendarEventCardProps) => ( -
- +
-

+

{title}

diff --git a/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx index 09a272ca..5a5d1573 100644 --- a/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx +++ b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx @@ -1,30 +1,16 @@ "use client"; -import { Checkbox } from "@repo/timo-design-system/ui"; -import { cn } from "@repo/timo-design-system/utils"; import Image from "next/image"; interface TodayCalendarEventCardProps { title: string; - checked: boolean; - onToggle: (checked: boolean) => void; } export const TodayCalendarEventCard = ({ title, - checked, - onToggle, }: TodayCalendarEventCardProps) => ( -
+
-
- -
- + {title}
From 68cbbbca1e04e5fd3a80611acfa5de77ae8bda82 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Thu, 16 Jul 2026 16:21:07 +0900 Subject: [PATCH 9/9] =?UTF-8?q?feat(#187):=20=EC=98=A8=EB=B3=B4=EB=94=A9?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=91=ED=95=98=EA=B8=B0=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=20=EC=83=81=ED=83=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../_components/OnboardingButton.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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 ( ); }