From cf14aac05e1f25a063993c3696a5824324683688 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Tue, 14 Jul 2026 18:02:02 +0900 Subject: [PATCH 01/58] =?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 02/58] =?UTF-8?q?feat(web):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20OAuth=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=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 1e6142c4bfe7fdf4994746ef0019bd6c3025d6c9 Mon Sep 17 00:00:00 2001 From: yumin-kim4757 Date: Wed, 15 Jul 2026 18:51:47 +0900 Subject: [PATCH 03/58] =?UTF-8?q?refactor(web):=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EB=A1=9C=EB=9D=A0=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=8B=A8=EC=88=9C=ED=99=94=20(#222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StatisticsCalendarContainer.tsx | 37 +------------------ .../components/lottie/LottiePlayer.tsx | 2 +- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsCalendarContainer.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsCalendarContainer.tsx index 2472c9a5..160ea7a5 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsCalendarContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsCalendarContainer.tsx @@ -14,28 +14,6 @@ interface StatisticsCalendarContainerProps { onSelectDate: (date: Date) => void; } -interface StatisticsCalendarStatusProps { - lottieSrc: string; - message: string; - role?: "status" | "alert"; -} - -const StatisticsCalendarStatus = ({ - lottieSrc, - message, - role = "status", -}: StatisticsCalendarStatusProps) => { - return ( -
- -

{message}

-
- ); -}; - export const StatisticsCalendarContainer = ({ currentMonth, displayDate, @@ -48,22 +26,11 @@ export const StatisticsCalendarContainer = ({ const calendarData = calendarQuery.data?.data; if (calendarQuery.isPending) { - return ( - - ); + return ; } if (calendarQuery.isError || !calendarData) { - return ( - - ); + return ; } return ( diff --git a/apps/timo-web/components/lottie/LottiePlayer.tsx b/apps/timo-web/components/lottie/LottiePlayer.tsx index d94d49fe..a52d4f2d 100644 --- a/apps/timo-web/components/lottie/LottiePlayer.tsx +++ b/apps/timo-web/components/lottie/LottiePlayer.tsx @@ -38,7 +38,7 @@ export const LottiePlayer = ({ }, [src]); return ( -
+
{animationData && ( Date: Wed, 15 Jul 2026 19:02:08 +0900 Subject: [PATCH 04/58] =?UTF-8?q?feat(web):=20=EA=B5=AC=EA=B8=80=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=9D=BC=EC=A0=95=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94=20disconnect=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=A0=81=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 49643503014be37ae24a55d66ac134b89e0b70aa Mon Sep 17 00:00:00 2001 From: jjangminii Date: Wed, 15 Jul 2026 19:19:24 +0900 Subject: [PATCH 05/58] =?UTF-8?q?fix(web):=20=EC=A4=91=EC=B2=A9=EB=90=9C?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=A8=20=ED=98=84=EC=83=81=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4=20(#224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OverlayModal에 Modal 컴포넌트와 동일한 스택 기반 z-index 로직을 적용했습니다 - 정적인 z-40/z-50 클래스를 acquireModalStackIndex 기반 동적 z-index로 교체했습니다 --- .../components/modal/OverlayModal.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/timo-web/components/modal/OverlayModal.tsx b/apps/timo-web/components/modal/OverlayModal.tsx index 68ac4d4b..f925faaa 100644 --- a/apps/timo-web/components/modal/OverlayModal.tsx +++ b/apps/timo-web/components/modal/OverlayModal.tsx @@ -1,6 +1,10 @@ "use client"; -import { cn, hasOpenFloatingLayer } from "@repo/timo-design-system/utils"; +import { + acquireModalStackIndex, + cn, + hasOpenFloatingLayer, +} from "@repo/timo-design-system/utils"; import FocusTrap from "focus-trap-react"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; @@ -8,6 +12,11 @@ import { createPortal } from "react-dom"; import type { ReactNode } from "react"; const EXIT_ANIMATION_DURATION = 200; +// tailwind-config theme.css의 --z-index-modal-overlay/--z-index-modal-panel과 동일한 기준값이다. +const BASE_OVERLAY_Z_INDEX = 40; +const BASE_PANEL_Z_INDEX = 50; +// 중첩된 모달의 오버레이가 그 아래 모달의 패널(z=50)보다 항상 높도록, 한 단계당 overlay/panel 차이(10)보다 큰 폭으로 올린다. +const MODAL_STACK_Z_INDEX_STEP = 20; interface OverlayModalProps { isOpen: boolean; @@ -28,6 +37,7 @@ export const OverlayModal = ({ }: OverlayModalProps) => { const [shouldRender, setShouldRender] = useState(isOpen); const [isVisible, setIsVisible] = useState(false); + const [stackIndex, setStackIndex] = useState(0); const dialogRef = useRef(null); const wasFloatingLayerOpenRef = useRef(false); @@ -43,6 +53,7 @@ export const OverlayModal = ({ return () => clearTimeout(hideTimer); } + setStackIndex(acquireModalStackIndex()); setShouldRender(true); const showFrame = requestAnimationFrame(() => setIsVisible(true)); @@ -71,13 +82,19 @@ export const OverlayModal = ({ if (!shouldRender) return null; + const overlayZIndex = + BASE_OVERLAY_Z_INDEX + stackIndex * MODAL_STACK_Z_INDEX_STEP; + const panelZIndex = + BASE_PANEL_Z_INDEX + stackIndex * MODAL_STACK_Z_INDEX_STEP; + return createPortal( <>
{ wasFloatingLayerOpenRef.current = hasOpenFloatingLayer(); }} @@ -102,8 +119,9 @@ export const OverlayModal = ({ aria-modal="true" aria-label={ariaLabel} tabIndex={-1} + style={{ zIndex: panelZIndex }} className={cn( - "fixed top-1/2 left-1/2 z-50 flex -translate-x-1/2 -translate-y-1/2 flex-col rounded-[4px] bg-white transition-all duration-200 ease-out", + "fixed top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col rounded-[4px] bg-white transition-all duration-200 ease-out", isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0", className, )} From a2371aa9bb44ac67c9e5f1731529e77f09d23040 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 19:43:26 +0900 Subject: [PATCH 06/58] =?UTF-8?q?feat(web):=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=B9=B4=EB=93=9C=20UI=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=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 07/58] =?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 42c91229a8028258e7ac89e9b3760a54f5f9a102 Mon Sep 17 00:00:00 2001 From: yumin-kim4757 Date: Wed, 15 Jul 2026 21:02:28 +0900 Subject: [PATCH 08/58] =?UTF-8?q?refactor(web):=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=ED=97=A4=EB=8D=94=20=EA=B3=A0=EC=A0=95=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20(#222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/statistics/_components/StatisticsCalendar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx index 69d2756f..9ac6028f 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx @@ -85,7 +85,7 @@ export const StatisticsCalendar = ({ return (
-
+

{formatStatisticsMonth(currentMonth, locale)} From 089784247a90773d6df162429c8484fddc06778d Mon Sep 17 00:00:00 2001 From: jjangminii Date: Wed, 15 Jul 2026 21:21:52 +0900 Subject: [PATCH 09/58] =?UTF-8?q?fix(web):=20=ED=8F=AC=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EB=A9=94=EB=AA=A8=EA=B0=80=20=EA=B8=B8?= =?UTF-8?q?=20=EB=95=8C=20=EC=B9=B4=EB=93=9C=EA=B0=80=20=EA=B0=80=EB=A1=9C?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제목·서브태스크·메모의 줄바꿈을 wrap-break-word에서 wrap-anywhere로 교체해, 공백 없는 긴 텍스트가 flex 아이템의 자동 최소 너비를 부풀려 카드가 타이머 패널 뒤로 사라지는 문제를 해결했습니다 - 메모 영역에 max-h-[40vh]와 세로 스크롤을 추가해 긴 메모가 페이지 전체 높이를 늘리지 않도록 했습니다 --- .../[locale]/(main)/focus/_components/FocusTaskItem.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/focus/_components/FocusTaskItem.tsx b/apps/timo-web/app/[locale]/(main)/focus/_components/FocusTaskItem.tsx index a29c3ed1..1002ba8f 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_components/FocusTaskItem.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_components/FocusTaskItem.tsx @@ -47,7 +47,7 @@ export const FocusTaskItem = ({
-

+

{title}

@@ -82,7 +82,7 @@ export const FocusTaskItem = ({

-
+
{subtasks.map((subtask) => (

@@ -103,7 +103,7 @@ export const FocusTaskItem = ({ ))} {memo && ( -

+

{memo}

)} From 1219b4060a6be907d6fdff7c75d4563cfc255c50 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 21:37:42 +0900 Subject: [PATCH 10/58] =?UTF-8?q?fix(web):=20=EC=98=A8=EB=B3=B4=EB=94=A9/?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20QA?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 온보딩 박스 크기를 w-108, h-153.5로 고정했습니다 - 로그인 페이지 약관 문구의 줄바꿈을 수정했습니다 --- .../onboarding/_containers/OnboardingFunnelContainer.tsx | 2 +- apps/timo-web/messages/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingFunnelContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingFunnelContainer.tsx index 864eacec..ab1411ee 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingFunnelContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/OnboardingFunnelContainer.tsx @@ -60,7 +60,7 @@ export const OnboardingFunnelContainer = () => { className="hidden shrink-0 lg:block lg:size-[350px] xl:size-[430px] 2xl:size-[500px]" ariaLabel="온보딩 애니메이션" /> -
+
diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 8f846765..d5a00349 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -160,7 +160,7 @@ "headline": "Manage your day with clear timeboxes.", "description": "Please log in to your timo account.", "connectLabel": "Log in with Google", - "termsNotice": "By continuing, you agree to the timo Terms of Service and Privacy Policy." + "termsNotice": "By continuing, you agree to the timo \nTerms of Service and Privacy Policy." }, "Error": { "title": "A problem has occurred.", From b0a25efe565ab2b0ea8794025a7992307a1ec42f Mon Sep 17 00:00:00 2001 From: jjangminii Date: Wed, 15 Jul 2026 21:45:23 +0900 Subject: [PATCH 11/58] =?UTF-8?q?fix(web):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=84=EB=B0=98=EC=9D=98=20React=20Query=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=ED=82=A4=20=EC=97=B0=EB=8F=99=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4=20(#224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home/Today 탭과 사이드바 타이머에서 재생·일시정지·재개·완료·정지 시 집중 페이지 쿼리를 함께 invalidate하도록 했습니다 - 집중 페이지에서 완료·서브태스크 변경 시 Today 탭과 할 일 수정 모달 쿼리를 함께 invalidate하도록 했습니다 - 할 일 수정 모달에서 수정·삭제 시 집중 페이지 쿼리를 함께 invalidate하도록 했습니다 --- .../home/_hooks/use-home-todos-by-date.ts | 21 +++++++++++++----- .../today/_hooks/useTodayTodoList.ts | 22 +++++++++++++++++-- .../(main)/focus/_hooks/use-focus-session.ts | 17 ++++++++++++++ .../layout/sidebar/time/TimerPanel.tsx | 14 ++++++++++++ .../detail/use-delete-todo-submit.ts | 4 ++++ .../detail/use-update-todo-submit.ts | 4 ++++ 6 files changed, 75 insertions(+), 7 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts index fff1170b..1e04b651 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts @@ -51,11 +51,25 @@ export const useHomeTodosByDate = ( const { invalidateHomeView, invalidateTimerState, invalidateTimeBoxes } = useTimerQueryInvalidation(); + const invalidateFocusTodo = () => { + queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); + }; + const { mutate: startTimer } = useStartTimer({ - mutation: { onSuccess: invalidateTimerState }, + mutation: { + onSuccess: () => { + invalidateTimerState(); + invalidateFocusTodo(); + }, + }, }); const { mutate: changeStatus } = useChangeStatus({ - mutation: { onSuccess: invalidateTimerState }, + mutation: { + onSuccess: () => { + invalidateTimerState(); + invalidateFocusTodo(); + }, + }, }); useEffect(() => { @@ -77,9 +91,6 @@ export const useHomeTodosByDate = ( })); }; - const invalidateFocusTodo = () => { - queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); - }; const invalidateHomeAndFocus = () => { invalidateHomeView(); invalidateFocusTodo(); diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts index b4b49af2..24dc4aea 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts @@ -7,6 +7,7 @@ import type { ErrorType } from "@/api/client/custom-instance"; import type { ErrorDto } from "@/api/generated/models"; import type { TodayTodo } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_types/today-type"; +import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; import { getGetTodayQueryKey } from "@/api/generated/endpoints/home/home"; import { useChangeStatus, @@ -48,11 +49,25 @@ export const useTodayTodoList = ( const { invalidateTimerState, invalidateTimeBoxes } = useTimerQueryInvalidation(); + const invalidateFocusTodo = () => { + queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); + }; + const { mutate: startTimer } = useStartTimer({ - mutation: { onSuccess: invalidateTimerState }, + mutation: { + onSuccess: () => { + invalidateTimerState(); + invalidateFocusTodo(); + }, + }, }); const { mutate: changeStatus } = useChangeStatus({ - mutation: { onSuccess: invalidateTimerState }, + mutation: { + onSuccess: () => { + invalidateTimerState(); + invalidateFocusTodo(); + }, + }, }); useEffect(() => { @@ -96,6 +111,7 @@ export const useTodayTodoList = ( invalidateTodayView(); invalidateTimeBoxes(); invalidateTodoDetail(todoId, dateKey); + invalidateFocusTodo(); }, onError: () => { setTodos(previous); @@ -124,6 +140,7 @@ export const useTodayTodoList = ( onSuccess: () => { invalidateTodayView(); invalidateTodoDetail(todoId, dateKey); + invalidateFocusTodo(); }, }, ); @@ -207,6 +224,7 @@ export const useTodayTodoList = ( onSuccess: () => { invalidateTodayView(); invalidateTodoDetail(todoId, dateKey); + invalidateFocusTodo(); }, onError: () => { setTodos(previous); diff --git a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts index 60ccb1fe..fb0b8392 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts @@ -6,6 +6,7 @@ import { useEffect, useRef } from "react"; import type { TimerSessionControlsHandle } from "@/components/timer/TimerSessionControls"; import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; +import { getGetTodayQueryKey } from "@/api/generated/endpoints/home/home"; import { useChangeStatus, useCompleteTimer, @@ -14,6 +15,7 @@ import { useStopTimer, } from "@/api/generated/endpoints/timer/timer"; import { + getGetTodoDetailQueryKey, useChangeSubtaskStatus, useChangeTodoStatus, } from "@/api/generated/endpoints/todo/todo"; @@ -45,6 +47,15 @@ export const useFocusSession = ({ useTimerQueryInvalidation(); const invalidateFocusTodo = () => queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); + const invalidateTodayView = () => + queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); + const invalidateTodoDetail = () => { + const todo = focusView.todo; + if (!todo) return; + queryClient.invalidateQueries({ + queryKey: getGetTodoDetailQueryKey(todo.todoId, { date: focusView.date }), + }); + }; const { mutate: startTimer } = useStartTimer({ mutation: { @@ -84,6 +95,7 @@ export const useFocusSession = ({ invalidateFocusTodo(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); }, onError: onMutationError, }, @@ -96,6 +108,7 @@ export const useFocusSession = ({ invalidateFocusTodo(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); }, onError: onMutationError, }, @@ -105,6 +118,8 @@ export const useFocusSession = ({ onSuccess: () => { invalidateFocusTodo(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateTodoDetail(); }, onError: onMutationError, }, @@ -114,6 +129,8 @@ export const useFocusSession = ({ onSuccess: () => { invalidateFocusTodo(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateTodoDetail(); }, onError: onMutationError, }, diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 562b0e4a..2e430ac7 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -1,7 +1,10 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; +import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; +import { getGetTodayQueryKey } from "@/api/generated/endpoints/home/home"; import { useChangeStatus, useCompleteTimer, @@ -28,9 +31,14 @@ export const TimerPanel = () => { const timerSessionControlsRef = useRef(null); const wasTimeUpRef = useRef(false); + const queryClient = useQueryClient(); const { data: activeTimer } = useActiveTimer(); const { invalidateActiveTimer, invalidateHomeView, invalidateTimeBoxes } = useTimerQueryInvalidation(); + const invalidateTodayView = () => + queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); + const invalidateFocusTodo = () => + queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); const { mutate: changeStatus } = useChangeStatus({ mutation: { @@ -56,6 +64,8 @@ export const TimerPanel = () => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateFocusTodo(); }, }, }); @@ -66,6 +76,8 @@ export const TimerPanel = () => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateFocusTodo(); }, }, }); @@ -74,6 +86,8 @@ export const TimerPanel = () => { onSuccess: () => { invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateFocusTodo(); }, }, }); diff --git a/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts b/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts index 0ad7d7da..74fffc5b 100644 --- a/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts +++ b/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts @@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { ErrorType } from "@/api/client/custom-instance"; import type { ErrorDto } from "@/api/generated/models"; +import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; import { getGetHomeQueryKey, getGetTodayQueryKey, @@ -30,6 +31,9 @@ export const useDeleteTodoSubmit = () => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); + queryClient.invalidateQueries({ + queryKey: getGetFocusTodoQueryKey(), + }); onSuccess(); }, onError, diff --git a/apps/timo-web/hooks/todo-modal/detail/use-update-todo-submit.ts b/apps/timo-web/hooks/todo-modal/detail/use-update-todo-submit.ts index b145e719..6a9379f5 100644 --- a/apps/timo-web/hooks/todo-modal/detail/use-update-todo-submit.ts +++ b/apps/timo-web/hooks/todo-modal/detail/use-update-todo-submit.ts @@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query"; import type { ErrorType } from "@/api/client/custom-instance"; import type { ErrorDto, TodoUpdateRequest } from "@/api/generated/models"; +import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; import { getGetHomeQueryKey, getGetTodayQueryKey, @@ -42,6 +43,9 @@ export const useUpdateTodoSubmit = () => { queryClient.invalidateQueries({ queryKey: getGetTodoDetailQueryKey(todoId, { date }), }); + queryClient.invalidateQueries({ + queryKey: getGetFocusTodoQueryKey(), + }); onSuccess?.(); }, onError, From fbe7f89d224f742249180278b2e5b0bcb6f4e799 Mon Sep 17 00:00:00 2001 From: yumin-kim4757 Date: Wed, 15 Jul 2026 21:54:34 +0900 Subject: [PATCH 12/58] =?UTF-8?q?fix(web):=20=EC=98=A4=EB=A5=B8=EC=AA=BD?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../statistics/_components/StatisticsSidePanel.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsSidePanel.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsSidePanel.tsx index 4037f77d..c31ce42f 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsSidePanel.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsSidePanel.tsx @@ -102,14 +102,18 @@ export const StatisticsSidePanel = (props: StatisticsSidePanelProps) => {
    - {detail.todos.map((todo) => { + {detail.todos.map((todo, index) => { const diffMinutes = todo.actualTimeMinutes - todo.estimatedTimeMinutes; + const isLastTodo = index === detail.todos.length - 1; return (
  • @@ -128,7 +132,9 @@ export const StatisticsSidePanel = (props: StatisticsSidePanelProps) => { {formatStatisticsClockText(todo.estimatedTimeMinutes)} - + {getDiffLabel(diffMinutes, t)}

    From f686f2dfd39813bf7f2e0086d3c79ac3db127e24 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 21:55:18 +0900 Subject: [PATCH 13/58] =?UTF-8?q?fix(web):=20Today=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20QA=20=EB=B0=98=EC=98=81=20(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 투두 카드 날짜 형식을 M/D에서 YY.MM.DD로 변경했습니다 - 투두 카드 툴바에서 휴지통 아이콘을 제거했습니다 --- .../(with-time-sidebar)/today/_components/TodayTodoCard.tsx | 3 --- .../today/_containers/TodayTodoListContainer.tsx | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx index 52ae7af6..8a57fa51 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx @@ -2,8 +2,6 @@ import { PlayDisabledIcon, PlayIcon, StopIcon, - TrashDisableIcon, - TrashOnIcon, } from "@repo/timo-design-system/icons"; import { Checkbox, @@ -200,7 +198,6 @@ export const TodayTodoCard = ({ }} />
- {isDimmed ? : }
); 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..e9ccfee5 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 @@ -13,7 +13,7 @@ import { useTodayQuery } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_ import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { StopCompleteModalContainer } from "@/containers/timer/StopCompleteModalContainer"; import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; -import { formatDate } from "@/utils/date/date"; +import { formatShortDateLabel } from "@/utils/date/date"; import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; @@ -116,7 +116,7 @@ export const TodayTodoListContainer = () => { timerStatus={timerStatus} isPlayHighlighted={isPlayHighlighted} toolbar={{ - date: formatDate(todo.date), + date: formatShortDateLabel(new Date(todo.date)), dateValue: new Date(todo.date), time: convertDurationToTimeText(durationSeconds), priority: todo.priority, From e1b90fc467d686a6111b22836ee64a1c126466a0 Mon Sep 17 00:00:00 2001 From: yumin-kim4757 Date: Wed, 15 Jul 2026 22:03:24 +0900 Subject: [PATCH 14/58] =?UTF-8?q?fix(web):=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=EC=99=80=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8C=A8=EB=84=90=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/statistics/_components/StatisticsSidePanel.tsx | 2 +- .../(main)/statistics/_containers/StatisticsContainer.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsSidePanel.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsSidePanel.tsx index c31ce42f..35b72c2a 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsSidePanel.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsSidePanel.tsx @@ -28,7 +28,7 @@ export type StatisticsSidePanelProps = | StatisticsDaySidePanelProps; const SIDE_PANEL_CLASS_NAME = - "border-timo-gray-500 bg-white h-full w-[304px] shrink-0 border-l text-timo-black"; + "border-timo-gray-500 bg-white h-full w-[304px] shrink-0 overflow-y-auto border-l text-timo-black"; const getDiffLabel = ( diffMinutes: number, diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx index 73dd709f..361ba886 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsContainer.tsx @@ -69,13 +69,13 @@ export const StatisticsContainer = () => { }; return ( -
-
+
+
-
+
Date: Wed, 15 Jul 2026 22:07:23 +0900 Subject: [PATCH 15/58] =?UTF-8?q?fix(web):=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=B2=84=ED=8A=BC=20=EC=83=89=EC=83=81=20?= =?UTF-8?q?=EC=88=98=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 2cbb20c260ec0fa8112b66da1a4cfd9c0390e34f Mon Sep 17 00:00:00 2001 From: jjangminii Date: Wed, 15 Jul 2026 22:10:28 +0900 Subject: [PATCH 16/58] =?UTF-8?q?refactor(web):=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EB=90=9C=20=EC=BF=BC=EB=A6=AC=20invalidate=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EA=B3=B5=EC=9C=A0=20=ED=9B=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9=ED=95=9C=EB=8B=A4=20(#224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4개 파일에 중복 정의돼 있던 invalidateTodayView/invalidateFocusTodo를 useTimerQueryInvalidation 훅으로 통합했습니다 --- .../home/_hooks/use-home-todos-by-date.ts | 13 ++++++------- .../today/_hooks/useTodayTodoList.ts | 17 ++++++----------- .../(main)/focus/_hooks/use-focus-session.ts | 15 +++++++-------- .../layout/sidebar/time/TimerPanel.tsx | 17 +++++++---------- .../hooks/timer/use-timer-query-invalidation.ts | 12 +++++++++++- 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts index 1e04b651..7d2c5ad8 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts @@ -7,7 +7,6 @@ import type { ApiError } from "@/api/error/api-error"; import type { HomeViewDay } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/home-view-type"; import type { Todo } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_types/todo-type"; -import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; import { useChangeStatus, useStartTimer, @@ -48,12 +47,12 @@ export const useHomeTodosByDate = ( const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: reorderTodo } = useReorderTodo(); const { mutate: stopTimer } = useStopTimer(); - const { invalidateHomeView, invalidateTimerState, invalidateTimeBoxes } = - useTimerQueryInvalidation(); - - const invalidateFocusTodo = () => { - queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); - }; + const { + invalidateHomeView, + invalidateTimerState, + invalidateTimeBoxes, + invalidateFocusTodo, + } = useTimerQueryInvalidation(); const { mutate: startTimer } = useStartTimer({ mutation: { diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts index 24dc4aea..5e087253 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts @@ -7,8 +7,6 @@ import type { ErrorType } from "@/api/client/custom-instance"; import type { ErrorDto } from "@/api/generated/models"; import type { TodayTodo } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_types/today-type"; -import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; -import { getGetTodayQueryKey } from "@/api/generated/endpoints/home/home"; import { useChangeStatus, useStartTimer, @@ -46,12 +44,12 @@ export const useTodayTodoList = ( const { mutate: changeTodoStatus } = useChangeTodoStatus(); const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: stopTimer } = useStopTimer(); - const { invalidateTimerState, invalidateTimeBoxes } = - useTimerQueryInvalidation(); - - const invalidateFocusTodo = () => { - queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); - }; + const { + invalidateTimerState, + invalidateTimeBoxes, + invalidateTodayView, + invalidateFocusTodo, + } = useTimerQueryInvalidation(); const { mutate: startTimer } = useStartTimer({ mutation: { @@ -83,9 +81,6 @@ export const useTodayTodoList = ( ); }; - const invalidateTodayView = () => { - queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); - }; const invalidateTodoDetail = (todoId: number, dateKey: string) => { queryClient.invalidateQueries({ queryKey: getGetTodoDetailQueryKey(todoId, { date: dateKey }), diff --git a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts index fb0b8392..e7e104df 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts @@ -5,8 +5,6 @@ import { useEffect, useRef } from "react"; import type { TimerSessionControlsHandle } from "@/components/timer/TimerSessionControls"; -import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; -import { getGetTodayQueryKey } from "@/api/generated/endpoints/home/home"; import { useChangeStatus, useCompleteTimer, @@ -43,12 +41,13 @@ export const useFocusSession = ({ const { data: focusView } = useFocusTodoQuery(); const { data: activeTimer } = useActiveTimer(); - const { invalidateActiveTimer, invalidateHomeView, invalidateTimeBoxes } = - useTimerQueryInvalidation(); - const invalidateFocusTodo = () => - queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); - const invalidateTodayView = () => - queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); + const { + invalidateActiveTimer, + invalidateHomeView, + invalidateTimeBoxes, + invalidateTodayView, + invalidateFocusTodo, + } = useTimerQueryInvalidation(); const invalidateTodoDetail = () => { const todo = focusView.todo; if (!todo) return; diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 2e430ac7..636562ac 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -1,10 +1,7 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; -import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; -import { getGetTodayQueryKey } from "@/api/generated/endpoints/home/home"; import { useChangeStatus, useCompleteTimer, @@ -31,14 +28,14 @@ export const TimerPanel = () => { const timerSessionControlsRef = useRef(null); const wasTimeUpRef = useRef(false); - const queryClient = useQueryClient(); const { data: activeTimer } = useActiveTimer(); - const { invalidateActiveTimer, invalidateHomeView, invalidateTimeBoxes } = - useTimerQueryInvalidation(); - const invalidateTodayView = () => - queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); - const invalidateFocusTodo = () => - queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); + const { + invalidateActiveTimer, + invalidateHomeView, + invalidateTimeBoxes, + invalidateTodayView, + invalidateFocusTodo, + } = useTimerQueryInvalidation(); const { mutate: changeStatus } = useChangeStatus({ mutation: { diff --git a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts index c9d009ea..38b3662a 100644 --- a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts +++ b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts @@ -2,7 +2,11 @@ import { useQueryClient } from "@tanstack/react-query"; -import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; +import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; +import { + getGetHomeQueryKey, + getGetTodayQueryKey, +} from "@/api/generated/endpoints/home/home"; import { getGetTimeBoxesQueryKey } from "@/api/generated/endpoints/time-box/time-box"; import { getGetActiveTimerQueryKey } from "@/api/generated/endpoints/timer/timer"; @@ -15,6 +19,10 @@ export const useTimerQueryInvalidation = () => { queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); const invalidateTimeBoxes = () => queryClient.invalidateQueries({ queryKey: getGetTimeBoxesQueryKey() }); + const invalidateTodayView = () => + queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); + const invalidateFocusTodo = () => + queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); const invalidateTimerState = () => { invalidateActiveTimer(); invalidateHomeView(); @@ -25,6 +33,8 @@ export const useTimerQueryInvalidation = () => { invalidateActiveTimer, invalidateHomeView, invalidateTimeBoxes, + invalidateTodayView, + invalidateFocusTodo, invalidateTimerState, }; }; From ebfbcdbe0abc5a6acd8ab5d30c37bbcf51eaf3a3 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 22:12:57 +0900 Subject: [PATCH 17/58] =?UTF-8?q?fix(web):=20=EB=8F=99=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20key=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B6=88=EC=9D=BC=EC=B9=98=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 - 이벤트 식별자를 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 b57fea881bf0c7cbc1e2034211d6cdde5fbb25ff Mon Sep 17 00:00:00 2001 From: yumin-kim4757 Date: Wed, 15 Jul 2026 22:13:29 +0900 Subject: [PATCH 18/58] =?UTF-8?q?fix(web):=20=EB=B0=B0=EA=B2=BD=EC=83=89?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20(#222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/statistics/_components/StatisticsCalendar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx index 9ac6028f..98b16dad 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx @@ -85,7 +85,7 @@ export const StatisticsCalendar = ({ return (
-
+

{formatStatisticsMonth(currentMonth, locale)} @@ -112,7 +112,7 @@ export const StatisticsCalendar = ({

-
+
{Array.from({ length: firstDayOffset }, (_, index) => (
))} From 2fea3cca277704bed88f06928f926ea1addba976 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 22:16:26 +0900 Subject: [PATCH 19/58] =?UTF-8?q?fix(web):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=8C=8C=EC=8B=B1=EC=9D=84=20parseDateKey?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=B4=20UTC=20=EC=98=A4?= =?UTF-8?q?=ED=94=84=EC=85=8B=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new Date(todo.date)는 UTC 기준으로 해석되어 KST에서 하루 밀리는 버그가 있었습니다 - parseDateKey를 사용해 로컬 시간 기준으로 파싱하도록 수정했습니다 --- .../today/_containers/TodayTodoListContainer.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 e9ccfee5..83eb0aac 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 @@ -13,7 +13,7 @@ import { useTodayQuery } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_ import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { StopCompleteModalContainer } from "@/containers/timer/StopCompleteModalContainer"; import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; -import { formatShortDateLabel } from "@/utils/date/date"; +import { formatShortDateLabel, parseDateKey } from "@/utils/date/date"; import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; @@ -116,8 +116,10 @@ export const TodayTodoListContainer = () => { timerStatus={timerStatus} isPlayHighlighted={isPlayHighlighted} toolbar={{ - date: formatShortDateLabel(new Date(todo.date)), - dateValue: new Date(todo.date), + date: formatShortDateLabel( + parseDateKey(todo.date) ?? new Date(), + ), + dateValue: parseDateKey(todo.date) ?? new Date(), time: convertDurationToTimeText(durationSeconds), priority: todo.priority, tag: todo.tag?.name, From 68bbc2d53161576eb21c2c18d2c57db626585e83 Mon Sep 17 00:00:00 2001 From: Lee Hye Won Date: Wed, 15 Jul 2026 22:41:00 +0900 Subject: [PATCH 20/58] =?UTF-8?q?fix(web):=20=ED=99=88=C2=B7=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=95=BD=EA=B4=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EC=97=AC=EB=B0=B1=20QA=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(#234)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overflow-x-auto 컨테이너의 pb 클립 이슈로 인해 상위 section에 pb-3 적용 - 스크롤 컨테이너 내부의 pb-2 제거 - 설정 약관 페이지 하단에 pb-12.5 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx | 2 +- .../app/[locale]/(main)/(with-time-sidebar)/home/page.tsx | 2 +- .../settings/_containers/terms/SettingsTermsContainer.tsx | 2 +- 3 files changed, 3 insertions(+), 3 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 e0aae773..2267cfa2 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 @@ -95,7 +95,7 @@ export const HomeTodoContainer = () => { ref={scrollRef} className={cn( "flex h-full overflow-x-auto scroll-smooth", - isWeekView ? "w-full gap-2.5" : "snap-x snap-proximity gap-5 pb-2", + isWeekView ? "w-full gap-2.5" : "snap-x snap-proximity gap-5", )} > {days.map((day) => { diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx index 7801a1d7..ee8f53db 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/page.tsx @@ -8,7 +8,7 @@ export default function HomePage() { -
+
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_containers/terms/SettingsTermsContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_containers/terms/SettingsTermsContainer.tsx index 518bb679..b93fb0eb 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_containers/terms/SettingsTermsContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_containers/terms/SettingsTermsContainer.tsx @@ -18,7 +18,7 @@ export const SettingsTermsContainer = ({ const { data: term } = useTermsQuery(type); return ( -
+
{term ? ( ) : ( From 353c878abcd520b68727b99851a57630bc2aa672 Mon Sep 17 00:00:00 2001 From: yumin-kim4757 Date: Wed, 15 Jul 2026 22:43:39 +0900 Subject: [PATCH 21/58] =?UTF-8?q?fix(web):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=ED=9B=84=20=ED=86=B5=EA=B3=84=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EA=B0=B1=EC=8B=A0=20(#222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/_hooks/use-home-todos-by-date.ts | 11 +++++++-- .../use-statistics-query-invalidation.ts | 23 +++++++++++++++++++ .../timer/use-timer-query-invalidation.ts | 4 ++++ .../create/use-create-todo-submit.ts | 3 +++ .../detail/use-delete-todo-submit.ts | 3 +++ .../detail/use-update-todo-submit.ts | 3 +++ 6 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 apps/timo-web/hooks/statistics/use-statistics-query-invalidation.ts diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts index fff1170b..b7c8340a 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts @@ -48,8 +48,12 @@ export const useHomeTodosByDate = ( const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: reorderTodo } = useReorderTodo(); const { mutate: stopTimer } = useStopTimer(); - const { invalidateHomeView, invalidateTimerState, invalidateTimeBoxes } = - useTimerQueryInvalidation(); + const { + invalidateHomeView, + invalidateStatistics, + invalidateTimerState, + invalidateTimeBoxes, + } = useTimerQueryInvalidation(); const { mutate: startTimer } = useStartTimer({ mutation: { onSuccess: invalidateTimerState }, @@ -110,6 +114,7 @@ export const useHomeTodosByDate = ( invalidateHomeAndFocus(); invalidateTimeBoxes(); invalidateTodoDetail(dateKey, todoId); + invalidateStatistics(); }, onError: () => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); @@ -138,6 +143,7 @@ export const useHomeTodosByDate = ( onSuccess: () => { invalidateHomeAndFocus(); invalidateTodoDetail(dateKey, todoId); + invalidateStatistics(); }, }, ); @@ -206,6 +212,7 @@ export const useHomeTodosByDate = ( invalidateHomeAndFocus(); invalidateTimeBoxes(); invalidateTodoDetail(dateKey, todoId); + invalidateStatistics(); }, onError: () => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); diff --git a/apps/timo-web/hooks/statistics/use-statistics-query-invalidation.ts b/apps/timo-web/hooks/statistics/use-statistics-query-invalidation.ts new file mode 100644 index 00000000..b5acb0c2 --- /dev/null +++ b/apps/timo-web/hooks/statistics/use-statistics-query-invalidation.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; + +import { + getGetCalendarQueryKey, + getGetDailyQueryKey, + getGetSummaryQueryKey, +} from "@/api/generated/endpoints/statistics/statistics"; + +export const useStatisticsQueryInvalidation = () => { + const queryClient = useQueryClient(); + + const invalidateStatistics = () => { + queryClient.invalidateQueries({ queryKey: getGetSummaryQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetDailyQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetCalendarQueryKey() }); + }; + + return { + invalidateStatistics, + }; +}; diff --git a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts index c9d009ea..e6ae32c8 100644 --- a/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts +++ b/apps/timo-web/hooks/timer/use-timer-query-invalidation.ts @@ -5,9 +5,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; import { getGetTimeBoxesQueryKey } from "@/api/generated/endpoints/time-box/time-box"; import { getGetActiveTimerQueryKey } from "@/api/generated/endpoints/timer/timer"; +import { useStatisticsQueryInvalidation } from "@/hooks/statistics/use-statistics-query-invalidation"; export const useTimerQueryInvalidation = () => { const queryClient = useQueryClient(); + const { invalidateStatistics } = useStatisticsQueryInvalidation(); const invalidateActiveTimer = () => queryClient.invalidateQueries({ queryKey: getGetActiveTimerQueryKey() }); @@ -19,11 +21,13 @@ export const useTimerQueryInvalidation = () => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateStatistics(); }; return { invalidateActiveTimer, invalidateHomeView, + invalidateStatistics, invalidateTimeBoxes, invalidateTimerState, }; diff --git a/apps/timo-web/hooks/todo-modal/create/use-create-todo-submit.ts b/apps/timo-web/hooks/todo-modal/create/use-create-todo-submit.ts index d7074344..2db717c6 100644 --- a/apps/timo-web/hooks/todo-modal/create/use-create-todo-submit.ts +++ b/apps/timo-web/hooks/todo-modal/create/use-create-todo-submit.ts @@ -8,6 +8,7 @@ import type { CreateTodoRequest } from "@/schemas/todo/todo-schema"; import { getGetHomeQueryKey } from "@/api/generated/endpoints/home/home"; import { useCreateTodo } from "@/api/generated/endpoints/todo/todo"; +import { useStatisticsQueryInvalidation } from "@/hooks/statistics/use-statistics-query-invalidation"; import { todoCreateResponseSchema } from "@/schemas/todo/todo-schema"; const buildCreateTodoRequestBody = ( @@ -30,6 +31,7 @@ export const useCreateTodoSubmit = () => { const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); const { mutate: createTodo } = useCreateTodo(); const queryClient = useQueryClient(); + const { invalidateStatistics } = useStatisticsQueryInvalidation(); const handleSubmit = (data: CreateTodoRequest) => { createTodo( @@ -44,6 +46,7 @@ export const useCreateTodoSubmit = () => { } queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); + invalidateStatistics(); }, onError: () => { setIsErrorToastOpen(true); diff --git a/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts b/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts index 0ad7d7da..1cd53a09 100644 --- a/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts +++ b/apps/timo-web/hooks/todo-modal/detail/use-delete-todo-submit.ts @@ -10,6 +10,7 @@ import { getGetTodayQueryKey, } from "@/api/generated/endpoints/home/home"; import { useDeleteTodo } from "@/api/generated/endpoints/todo/todo"; +import { useStatisticsQueryInvalidation } from "@/hooks/statistics/use-statistics-query-invalidation"; export interface DeleteTodoSubmitHandlers { onSuccess: () => void; @@ -19,6 +20,7 @@ export interface DeleteTodoSubmitHandlers { export const useDeleteTodoSubmit = () => { const { mutate: deleteTodo } = useDeleteTodo(); const queryClient = useQueryClient(); + const { invalidateStatistics } = useStatisticsQueryInvalidation(); const handleDelete = ( todoId: number, @@ -30,6 +32,7 @@ export const useDeleteTodoSubmit = () => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getGetHomeQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); + invalidateStatistics(); onSuccess(); }, onError, diff --git a/apps/timo-web/hooks/todo-modal/detail/use-update-todo-submit.ts b/apps/timo-web/hooks/todo-modal/detail/use-update-todo-submit.ts index b145e719..cd63a1de 100644 --- a/apps/timo-web/hooks/todo-modal/detail/use-update-todo-submit.ts +++ b/apps/timo-web/hooks/todo-modal/detail/use-update-todo-submit.ts @@ -13,6 +13,7 @@ import { getGetTodoDetailQueryKey, useUpdateTodo, } from "@/api/generated/endpoints/todo/todo"; +import { useStatisticsQueryInvalidation } from "@/hooks/statistics/use-statistics-query-invalidation"; export interface UpdateTodoSubmitParams { todoId: number; @@ -28,6 +29,7 @@ export interface UpdateTodoSubmitHandlers { export const useUpdateTodoSubmit = () => { const { mutate: updateTodo } = useUpdateTodo(); const queryClient = useQueryClient(); + const { invalidateStatistics } = useStatisticsQueryInvalidation(); const handleUpdate = ( { todoId, date, data }: UpdateTodoSubmitParams, @@ -42,6 +44,7 @@ export const useUpdateTodoSubmit = () => { queryClient.invalidateQueries({ queryKey: getGetTodoDetailQueryKey(todoId, { date }), }); + invalidateStatistics(); onSuccess?.(); }, onError, From f23cb1b5d27d1afe667ddce9ea45f3ec2379e54b Mon Sep 17 00:00:00 2001 From: kimminna Date: Wed, 15 Jul 2026 23:07:42 +0900 Subject: [PATCH 22/58] =?UTF-8?q?fix(ui):=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EB=8B=AB=ED=9E=90?= =?UTF-8?q?=20=EB=95=8C=EB=A7=8C=20=EB=B0=98=EC=98=81=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#238)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 태그/시간/우선순위/반복 드롭다운이 항목 클릭 즉시 닫히지 않고, 실제로 닫힐 때만 선택이 반영되도록 draft 상태와 closeOnSelect={false}를 적용했습니다 - 반복 셀렉터 화살표(chevron) 회전 방향이 반대로 되어 있던 것을 수정했습니다 - 시간 드롭다운이 열려 있는 상태에서 AI 추천 응답이 도착해도 draft 상태가 갱신되지 않아 선택 표시가 안 되던 것을 수정했습니다 --- .../calendar/date-selector/DateSelector.tsx | 39 ++++++- .../components/layout/dropdown/Dropdown.tsx | 20 ++-- .../priority-selector/PrioritySelector.tsx | 29 ++++- .../RepeatSelector.stories.tsx | 7 +- .../repeat/repeat-selector/RepeatSelector.tsx | 100 +++++++++++++++--- .../tag/tag-selector/TagSelector.tsx | 29 ++++- .../time/time-selector/TimeSelector.tsx | 69 +++++++++--- 7 files changed, 242 insertions(+), 51 deletions(-) diff --git a/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsx b/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsx index 65cc58eb..05ee73ce 100644 --- a/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsx +++ b/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsx @@ -1,3 +1,5 @@ +import { useRef, useState } from "react"; + import { Dropdown } from "../../layout/dropdown/Dropdown"; import { Calendar } from "../Calendar"; @@ -9,17 +11,50 @@ export interface DateSelectorProps { onChange?: (date: Date) => void; } +const isSameCalendarDate = (a?: Date, b?: Date) => { + if (!a || !b) return a === b; + + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +}; + export const DateSelector = ({ trigger, value, onChange, }: DateSelectorProps) => { + const [draftDate, setDraftDate] = useState(value); + const draftDateRef = useRef(value); + + const selectDraft = (date: Date) => { + draftDateRef.current = date; + setDraftDate(date); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + draftDateRef.current = value; + setDraftDate(value); + return; + } + + if ( + draftDateRef.current && + !isSameCalendarDate(draftDateRef.current, value) + ) { + onChange?.(draftDateRef.current); + } + }; + return ( - + {trigger} - + ); diff --git a/packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx b/packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx index cf8d59a6..11a8ad4f 100644 --- a/packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx +++ b/packages/timo-design-system/src/components/layout/dropdown/Dropdown.tsx @@ -59,6 +59,14 @@ const DropdownRoot = ({ const rootRef = useRef(null); const triggerRef = useRef(null); + const setOpenState = (next: boolean) => { + setIsOpen(next); + onOpenChange?.(next); + }; + + const toggle = () => setOpenState(!isOpen); + const close = () => setOpenState(false); + useEffect(() => { if (!isOpen) return; @@ -66,13 +74,13 @@ const DropdownRoot = ({ const handleOutsideClick = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) { - setIsOpen(false); + close(); } }; const handleEscapeKeydown = (event: KeyboardEvent) => { if (event.key === "Escape") { - setIsOpen(false); + close(); triggerRef.current?.focus(); } }; @@ -85,15 +93,9 @@ const DropdownRoot = ({ document.removeEventListener("mousedown", handleOutsideClick); document.removeEventListener("keydown", handleEscapeKeydown); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); - const toggle = () => { - const next = !isOpen; - setIsOpen(next); - onOpenChange?.(next); - }; - const close = () => setIsOpen(false); - return ( { + const [draftPriority, setDraftPriority] = useState(selected); + const draftPriorityRef = useRef(selected); + + const selectDraft = (priority: PriorityLevel) => { + draftPriorityRef.current = priority; + setDraftPriority(priority); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + draftPriorityRef.current = selected; + setDraftPriority(selected); + return; + } + + if (draftPriorityRef.current && draftPriorityRef.current !== selected) { + onSelect?.(draftPriorityRef.current); + } + }; + return ( - + {trigger} {PRIORITY_LEVELS.map((priority) => { - const isSelected = priority === selected; + const isSelected = priority === draftPriority; return ( onSelect?.(priority)} + onClick={() => selectDraft(priority)} + closeOnSelect={false} aria-pressed={isSelected} className={cn( "gap-2.25 py-0.5 pr-1 pl-2.75", diff --git a/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsx b/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsx index 781fb9b5..fb5570aa 100644 --- a/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsx +++ b/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsx @@ -81,12 +81,7 @@ const WeeklyDemo = (args: RepeatSelectorProps) => { ...args.weekly, weekdays: WEEKDAYS_KO, selectedWeekdayIds, - onWeekdayToggle: (id) => - setSelectedWeekdayIds((prev) => - prev.includes(id) - ? prev.filter((item) => item !== id) - : [...prev, id], - ), + onWeekdaysChange: setSelectedWeekdayIds, }} /> ); diff --git a/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx b/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx index e714f949..50b36ea2 100644 --- a/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx +++ b/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useId, useState } from "react"; +import { Fragment, useId, useRef, useState } from "react"; import { ChevronDownIcon } from "../../../icons"; import { cn } from "../../../lib"; @@ -24,7 +24,7 @@ export interface WeekdayOption { export interface RepeatWeeklyDetail { weekdays: WeekdayOption[]; selectedWeekdayIds: string[]; - onWeekdayToggle?: (id: string) => void; + onWeekdaysChange?: (weekdayIds: string[]) => void; } export interface RepeatMonthlyDetail { @@ -83,11 +83,17 @@ const RepeatFrequencyList = ({ ); }; +interface RepeatWeeklyDetailSectionProps { + weekdays: WeekdayOption[]; + selectedWeekdayIds: string[]; + onWeekdayToggle: (id: string) => void; +} + const RepeatWeeklyDetailSection = ({ weekdays, selectedWeekdayIds, onWeekdayToggle, -}: RepeatWeeklyDetail) => { +}: RepeatWeeklyDetailSectionProps) => { return (
{weekdays.map(({ id, label }) => ( @@ -138,6 +144,15 @@ const RepeatMonthlyDetailSection = ({ ); }; +const isSameWeekdaySelection = (a: string[], b: string[]) => { + if (a.length !== b.length) return false; + + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + + return sortedA.every((item, index) => item === sortedB[index]); +}; + export const RepeatSelector = ({ trigger, frequencyHeading = "반복 일정", @@ -149,21 +164,70 @@ export const RepeatSelector = ({ monthly, }: RepeatSelectorProps) => { const [isPicking, setIsPicking] = useState(true); - const [selectedFrequency, setSelectedFrequency] = + const [draftFrequency, setDraftFrequency] = useState(frequency); + const [draftWeekdayIds, setDraftWeekdayIds] = useState( + weekly.selectedWeekdayIds, + ); + const [draftRepeatDay, setDraftRepeatDay] = useState(monthly.repeatDay); + + const draftFrequencyRef = useRef(frequency); + const draftWeekdayIdsRef = useRef(weekly.selectedWeekdayIds); + const draftRepeatDayRef = useRef(monthly.repeatDay); const selectedLabel = options.find( - (option) => option.frequency === selectedFrequency, + (option) => option.frequency === draftFrequency, )?.label; const handleSelectFrequency = (value: RepeatFrequency) => { - setSelectedFrequency(value); - onFrequencyChange?.(value); + draftFrequencyRef.current = value; + setDraftFrequency(value); if (value !== "DAILY") setIsPicking(false); }; + const handleWeekdayToggle = (id: string) => { + const next = draftWeekdayIdsRef.current.includes(id) + ? draftWeekdayIdsRef.current.filter((item) => item !== id) + : [...draftWeekdayIdsRef.current, id]; + + draftWeekdayIdsRef.current = next; + setDraftWeekdayIds(next); + }; + + const handleRepeatDayChange = (value: string) => { + draftRepeatDayRef.current = value; + setDraftRepeatDay(value); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + draftFrequencyRef.current = frequency; + setDraftFrequency(frequency); + draftWeekdayIdsRef.current = weekly.selectedWeekdayIds; + setDraftWeekdayIds(weekly.selectedWeekdayIds); + draftRepeatDayRef.current = monthly.repeatDay; + setDraftRepeatDay(monthly.repeatDay); + return; + } + + if (draftFrequencyRef.current !== frequency) { + onFrequencyChange?.(draftFrequencyRef.current); + } + if ( + !isSameWeekdaySelection( + draftWeekdayIdsRef.current, + weekly.selectedWeekdayIds, + ) + ) { + weekly.onWeekdaysChange?.(draftWeekdayIdsRef.current); + } + if (draftRepeatDayRef.current !== monthly.repeatDay) { + monthly.onRepeatDayChange?.(draftRepeatDayRef.current); + } + }; + return ( - + {trigger} @@ -184,7 +248,7 @@ export const RepeatSelector = ({ @@ -193,26 +257,32 @@ export const RepeatSelector = ({ {isPicking ? ( ) : (
{detailHeading} - {selectedFrequency === "WEEKLY" && ( - + {draftFrequency === "WEEKLY" && ( + )} - {selectedFrequency === "MONTHLY" && ( + {draftFrequency === "MONTHLY" && ( )} diff --git a/packages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsx b/packages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsx index 97e144aa..61bdb7fc 100644 --- a/packages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsx +++ b/packages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsx @@ -1,3 +1,5 @@ +import { useRef, useState } from "react"; + import { PlusIcon } from "../../../icons"; import { cn } from "../../../lib"; import { Dropdown } from "../../layout/dropdown/Dropdown"; @@ -21,20 +23,41 @@ export const TagSelector = ({ onSelect, onAddClick, }: TagSelectorProps) => { + const [draftTag, setDraftTag] = useState(selected); + const draftTagRef = useRef(selected); + + const selectDraft = (tag: string) => { + draftTagRef.current = tag; + setDraftTag(tag); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + draftTagRef.current = selected; + setDraftTag(selected); + return; + } + + if (draftTagRef.current && draftTagRef.current !== selected) { + onSelect?.(draftTagRef.current); + } + }; + return ( - + {trigger} {tags.map((tag) => { - const isSelected = tag === selected; + const isSelected = tag === draftTag; return ( onSelect?.(tag)} + onClick={() => selectDraft(tag)} + closeOnSelect={false} aria-pressed={isSelected} className={cn("px-1.5 py-1", isSelected && "bg-timo-gray-500")} > diff --git a/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx b/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx index d9b156ff..b204c3e2 100644 --- a/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx +++ b/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef, useState } from "react"; + import { AiDefaultIcon, AiWhiteIcon } from "../../../icons"; import { cn } from "../../../lib"; import { Dropdown } from "../../layout/dropdown/Dropdown"; @@ -47,26 +49,66 @@ export const TimeSelector = ({ onSelect, onOpen, }: TimeSelectorProps) => { - const isAiSelected = selected === "ai"; - const [minutesText = "00", secondsText = "00"] = time.split(":"); + const [draftTime, setDraftTime] = useState(time); + const [draftSelected, setDraftSelected] = useState(selected); + const draftTimeRef = useRef(time); + const draftSelectedRef = useRef(selected); + + const isAiSelected = draftSelected === "ai"; + const [minutesText = "00", secondsText = "00"] = draftTime.split(":"); + + const selectDraftTime = (value: string) => { + draftTimeRef.current = value; + setDraftTime(value); + }; + + const selectDraft = (value: TimeSelection) => { + draftSelectedRef.current = value; + setDraftSelected(value); + }; + + useEffect(() => { + draftSelectedRef.current = selected; + setDraftSelected(selected); + }, [selected]); + + useEffect(() => { + draftTimeRef.current = time; + setDraftTime(time); + }, [time]); const handleMinutesChange = (event: ChangeEvent) => { - onTimeChange?.(`${sanitizeTimeSegment(event.target.value)}:${secondsText}`); + selectDraftTime( + `${sanitizeTimeSegment(event.target.value)}:${secondsText}`, + ); }; const handleSecondsChange = (event: ChangeEvent) => { - onTimeChange?.( + selectDraftTime( `${minutesText}:${sanitizeSecondsSegment(event.target.value)}`, ); }; + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + draftTimeRef.current = time; + setDraftTime(time); + draftSelectedRef.current = selected; + setDraftSelected(selected); + onOpen?.(); + return; + } + + if (draftSelectedRef.current && draftSelectedRef.current !== selected) { + onSelect?.(draftSelectedRef.current); + } + if (draftTimeRef.current !== time) { + onTimeChange?.(draftTimeRef.current); + } + }; + return ( - { - if (isOpen) onOpen?.(); - }} - > + {trigger} @@ -78,7 +120,7 @@ export const TimeSelector = ({ > -
- -
-
-
-

{dateNumber}

-

- {tCommon(`weekday.${dayOfWeek}`)} -

-
+ const deleteModalTriggerRef = useRef(null); + const handleRequestDelete = () => { + deleteModalTriggerRef.current?.click(); + }; -
- -
+ return ( + <> + +
+
-
-
-
- +
+
+
+

{dateNumber}

+

+ {tCommon(`weekday.${dayOfWeek}`)} +

+
+ +
+ +
-
-
- +
+
+ +
+ +
+
{}} - hasMemo={Boolean(todo.memo?.trim())} - isRepeatActive={detailTodoForm.isRepeatActive} - repeat={{ - frequencyHeading: t("repeatFrequencyHeading"), - detailHeading: tCreateModal("repeatDetailHeading"), - options: [ - { frequency: "DAILY", label: tCreateModal("repeatDaily") }, - { - frequency: "WEEKLY", - label: tCreateModal("repeatWeekly"), + > + + +
+ + -
- -
-
- + + + + + + + {tToast.rich("tagLimit", { + blue: (chunks) => ( + {chunks} + ), + })} +

+ } + /> + + + ); }; diff --git a/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx b/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx index a1543bce..383557a3 100644 --- a/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx +++ b/apps/timo-web/components/todo-modal/detail/DetailTodoTaskFields.tsx @@ -6,7 +6,6 @@ import { import { Checkbox, PlayButton } from "@repo/timo-design-system/ui"; import type { TodoDetailResponseTimerStatus } from "@/api/generated/models"; -import type { DetailTodoSubtaskInput } from "@/hooks/todo-modal/detail/use-detail-subtask-field"; import type { KeyboardEvent, MouseEvent, PointerEvent } from "react"; const resizeTextarea = (element: HTMLTextAreaElement | null) => { @@ -15,6 +14,13 @@ const resizeTextarea = (element: HTMLTextAreaElement | null) => { element.style.height = `${element.scrollHeight}px`; }; +export interface DetailTodoSubtaskInput { + id: number; + subtaskId: number | null; + completed: boolean; + value: string; +} + export interface DetailTodoTaskFieldsProps { titleValue: string; isCompleted: boolean; @@ -23,6 +29,7 @@ export interface DetailTodoTaskFieldsProps { isPlayHighlighted: boolean; subtaskInputs: DetailTodoSubtaskInput[]; onTitleChange: (value: string) => void; + onTitleEnter: () => void; onToggleCompleted: (completed: boolean) => void; onTogglePlay: () => void; onSubtaskInputChange: (id: number, value: string) => void; @@ -44,6 +51,7 @@ export const DetailTodoTaskFields = ({ isPlayHighlighted, subtaskInputs, onTitleChange, + onTitleEnter, onToggleCompleted, onTogglePlay, onSubtaskInputChange, @@ -69,16 +77,19 @@ export const DetailTodoTaskFields = ({
-