diff --git a/apps/timo-web/api/generated/endpoints/auth/auth.ts b/apps/timo-web/api/generated/endpoints/auth/auth.ts index 35839e4b..a8446944 100644 --- a/apps/timo-web/api/generated/endpoints/auth/auth.ts +++ b/apps/timo-web/api/generated/endpoints/auth/auth.ts @@ -114,7 +114,7 @@ export const useToken = , TContext = unknown>( return useMutation(getTokenMutationOptions(options), queryClient); }; /** - * 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급합니다.
+ * 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. * 재발급 성공 시 RefreshToken과 sessionId 쿠키를 갱신합니다. * @summary AccessToken 재발급 */ diff --git a/apps/timo-web/api/generated/endpoints/auth/auth.zod.ts b/apps/timo-web/api/generated/endpoints/auth/auth.zod.ts index ea762076..07495c8b 100644 --- a/apps/timo-web/api/generated/endpoints/auth/auth.zod.ts +++ b/apps/timo-web/api/generated/endpoints/auth/auth.zod.ts @@ -40,7 +40,7 @@ export const TokenResponse = zod.object({ }); /** - * 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken 재발급합니다.
+ * 쿠키로 전달된 RefreshToken과 sessionId를 검증하여 AccessToken을 재발급합니다. * 재발급 성공 시 RefreshToken과 sessionId 쿠키를 갱신합니다. * @summary AccessToken 재발급 */ diff --git a/apps/timo-web/api/generated/endpoints/calendar/calendar.ts b/apps/timo-web/api/generated/endpoints/calendar/calendar.ts new file mode 100644 index 00000000..193b7863 --- /dev/null +++ b/apps/timo-web/api/generated/endpoints/calendar/calendar.ts @@ -0,0 +1,544 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { customInstance } from "../../../client/custom-instance"; + +import type { ErrorType, BodyType } from "../../../client/custom-instance"; +import type { + BaseResponseCalendarAuthorizeResponse, + BaseResponseCalendarConnectResponse, + BaseResponseCalendarDisconnectResponse, + BaseResponseCalendarEventsResponse, + CalendarConnectRequest, + ErrorDto, + GetCalendarEventsParams, +} from "../../models"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +type SecondParameter unknown> = Parameters[1]; + +const withQueryKey = ( + query: T, + queryKey: K, +): T & { queryKey: K } => { + const result = { queryKey } as T & { queryKey: K }; + for (const key of Object.keys(query)) { + // The explicit queryKey always wins, matching the previous + // `{ ...query, queryKey }` spread where it was set last. + if (key === "queryKey") continue; + Object.defineProperty(result, key, { + enumerable: true, + configurable: true, + get: () => (query as Record)[key], + }); + } + return result; +}; + +/** + * 구글 OAuth 동의 완료 후 발급된 authorizationCode와 state로 구글 토큰을 교환하여 캘린더를 연동합니다. + * + * state는 authorize API 호출 시 발급받은 값을 그대로 전달해야 하며, 검증 후 즉시 만료됩니다. + * + * 가입 시 사용한 구글 계정과 다른 계정으로 연동을 시도하면 거부됩니다. + * @summary 구글 캘린더 연동 + */ +export const connectCalendar = ( + calendarConnectRequest: BodyType, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { + url: `/api/v1/users/calendar`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: calendarConnectRequest, + signal, + }, + options, + ); +}; + +export const getConnectCalendarMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + const mutationKey = ["connectCalendar"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { data: BodyType } + > = (props) => { + const { data } = props ?? {}; + + return connectCalendar(data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ConnectCalendarMutationResult = NonNullable< + Awaited> +>; +export type ConnectCalendarMutationBody = BodyType; +export type ConnectCalendarMutationError = ErrorType; + +/** + * @summary 구글 캘린더 연동 + */ +export const useConnectCalendar = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: BodyType }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: BodyType }, + TContext +> => { + return useMutation(getConnectCalendarMutationOptions(options), queryClient); +}; +/** + * 연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다. + * @summary 구글 캘린더 연동 해제 + */ +export const disconnectCalendar = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { url: `/api/v1/users/calendar`, method: "DELETE", signal }, + options, + ); +}; + +export const getDisconnectCalendarMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ["disconnectCalendar"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return disconnectCalendar(requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type DisconnectCalendarMutationResult = NonNullable< + Awaited> +>; + +export type DisconnectCalendarMutationError = ErrorType; + +/** + * @summary 구글 캘린더 연동 해제 + */ +export const useDisconnectCalendar = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + return useMutation( + getDisconnectCalendarMutationOptions(options), + queryClient, + ); +}; +/** + * filter(DAY/WEEK/TWO_WEEK)와 baseDate에 따라 연동된 구글 캘린더 일정을 일자별로 조회합니다. + * + * DAY: baseDate 하루 + * + * WEEK: baseDate ~ baseDate+6일 (총 7일) + * + * TWO_WEEK: baseDate-7일 ~ baseDate+7일 (총 15일) + * + * baseDate 미입력 시 오늘 날짜가 기본값으로 사용됩니다. + * 별도 저장 없이 매 요청마다 구글 API를 직접 호출하여 최신 상태를 반환합니다. + * @summary 캘린더 일정 조회 + */ +export const getCalendarEvents = ( + params: GetCalendarEventsParams, + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { url: `/api/v1/users/calendar/events`, method: "GET", params, signal }, + options, + ); +}; + +export const getGetCalendarEventsQueryKey = ( + params?: GetCalendarEventsParams, +) => { + return [ + `/api/v1/users/calendar/events`, + ...(params ? [params] : []), + ] as const; +}; + +export const getGetCalendarEventsQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getGetCalendarEventsQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => getCalendarEvents(params, requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetCalendarEventsQueryResult = NonNullable< + Awaited> +>; +export type GetCalendarEventsQueryError = ErrorType; + +export function useGetCalendarEvents< + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useGetCalendarEvents< + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useGetCalendarEvents< + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 캘린더 일정 조회 + */ + +export function useGetCalendarEvents< + TData = Awaited>, + TError = ErrorType, +>( + params: GetCalendarEventsParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getGetCalendarEventsQueryOptions(params, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + +/** + * 구글 캘린더 연동을 시작하는 구글 인증 URL을 발급합니다. + * + * 프론트는 이 응답의 authorizationUrl로 window.location.assign 등을 통해 직접 이동해야 합니다. + * @summary 구글 캘린더 연동 시작 + */ +export const authorize = ( + options?: SecondParameter, + signal?: AbortSignal, +) => { + return customInstance( + { url: `/api/v1/users/calendar/authorize`, method: "GET", signal }, + options, + ); +}; + +export const getAuthorizeQueryKey = () => { + return [`/api/v1/users/calendar/authorize`] as const; +}; + +export const getAuthorizeQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getAuthorizeQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => authorize(requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type AuthorizeQueryResult = NonNullable< + Awaited> +>; +export type AuthorizeQueryError = ErrorType; + +export function useAuthorize< + TData = Awaited>, + TError = ErrorType, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useAuthorize< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useAuthorize< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +/** + * @summary 구글 캘린더 연동 시작 + */ + +export function useAuthorize< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getAuthorizeQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} diff --git a/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts b/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts new file mode 100644 index 00000000..f23f846e --- /dev/null +++ b/apps/timo-web/api/generated/endpoints/calendar/calendar.zod.ts @@ -0,0 +1,116 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import * as zod from "zod"; + +/** + * 구글 OAuth 동의 완료 후 발급된 authorizationCode와 state로 구글 토큰을 교환하여 캘린더를 연동합니다. + * + * state는 authorize API 호출 시 발급받은 값을 그대로 전달해야 하며, 검증 후 즉시 만료됩니다. + * + * 가입 시 사용한 구글 계정과 다른 계정으로 연동을 시도하면 거부됩니다. + * @summary 구글 캘린더 연동 + */ + +export const ConnectCalendarBody = zod.object({ + authorizationCode: zod + .string() + .min(1) + .describe("구글 OAuth 동의 완료 후 발급된 authorization code"), + state: zod + .string() + .min(1) + .describe("authorize API 호출 시 발급받은 state 값 (CSRF 방어용)"), +}); + +export const ConnectCalendarResponse = zod.object({ + status: zod.number().optional(), + message: zod.string().optional(), + data: zod + .object({ + calendarConnected: zod.boolean().optional(), + calendarEmail: zod.string().optional(), + connectedAt: zod.string().optional().describe("UTC 기준 연동 시각"), + }) + .optional(), +}); + +/** + * 연동된 구글 캘린더 정보를 삭제하고 구글 토큰을 revoke합니다. + * @summary 구글 캘린더 연동 해제 + */ +export const DisconnectCalendarResponse = zod.object({ + status: zod.number().optional(), + message: zod.string().optional(), + data: zod + .object({ + calendarConnected: zod.boolean().optional(), + }) + .optional(), +}); + +/** + * filter(DAY/WEEK/TWO_WEEK)와 baseDate에 따라 연동된 구글 캘린더 일정을 일자별로 조회합니다. + * + * DAY: baseDate 하루 + * + * WEEK: baseDate ~ baseDate+6일 (총 7일) + * + * TWO_WEEK: baseDate-7일 ~ baseDate+7일 (총 15일) + * + * baseDate 미입력 시 오늘 날짜가 기본값으로 사용됩니다. + * 별도 저장 없이 매 요청마다 구글 API를 직접 호출하여 최신 상태를 반환합니다. + * @summary 캘린더 일정 조회 + */ +export const GetCalendarEventsQueryParams = zod.object({ + filter: zod.string().describe("조회 필터"), + baseDate: zod + .string() + .optional() + .describe("기준 날짜 (YYYY-MM-DD), 미입력 시 오늘"), +}); + +export const GetCalendarEventsResponse = zod.object({ + status: zod.number().optional(), + message: zod.string().optional(), + data: zod + .object({ + filter: zod.string().optional(), + baseDate: zod.iso.date().optional(), + days: zod + .array( + zod.object({ + date: zod.iso.date().optional(), + events: zod + .array( + zod.object({ + title: zod.string().optional(), + }), + ) + .optional(), + }), + ) + .optional(), + }) + .optional(), +}); + +/** + * 구글 캘린더 연동을 시작하는 구글 인증 URL을 발급합니다. + * + * 프론트는 이 응답의 authorizationUrl로 window.location.assign 등을 통해 직접 이동해야 합니다. + * @summary 구글 캘린더 연동 시작 + */ +export const AuthorizeResponse = zod.object({ + status: zod.number().optional(), + message: zod.string().optional(), + data: zod + .object({ + authorizationUrl: zod.string().optional(), + }) + .optional(), +}); diff --git a/apps/timo-web/api/generated/endpoints/focus/focus.ts b/apps/timo-web/api/generated/endpoints/focus/focus.ts index 316ee411..0c767fec 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 5be069fd..814851cc 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입니다. @@ -37,6 +40,7 @@ export const GetFocusTodoResponse = zod.object({ }) .optional(), isRepeated: zod.boolean(), + memo: zod.string().optional(), subtasks: zod.array( zod.object({ subtaskId: zod.number(), diff --git a/apps/timo-web/api/generated/endpoints/home/home.zod.ts b/apps/timo-web/api/generated/endpoints/home/home.zod.ts index 03dbf623..35356243 100644 --- a/apps/timo-web/api/generated/endpoints/home/home.zod.ts +++ b/apps/timo-web/api/generated/endpoints/home/home.zod.ts @@ -58,7 +58,7 @@ export const GetHomeResponse = zod.object({ name: zod.string(), }) .optional(), - hasMemo: zod.boolean(), + hasSubtask: zod.boolean(), isRepeated: zod.boolean(), timerStatus: zod.enum(["STOPPED", "RUNNING", "PAUSED"]), sortOrder: zod.number().optional(), @@ -107,7 +107,7 @@ export const GetTodayResponse = zod.object({ }) .optional(), isRepeated: zod.boolean(), - hasMemo: zod.boolean(), + hasSubtask: zod.boolean(), timerStatus: zod.enum(["STOPPED", "RUNNING", "PAUSED"]), sortOrder: zod.number().optional(), subtasks: zod.array( diff --git a/apps/timo-web/api/generated/endpoints/timer/timer.ts b/apps/timo-web/api/generated/endpoints/timer/timer.ts index 47dd634c..76aa765e 100644 --- a/apps/timo-web/api/generated/endpoints/timer/timer.ts +++ b/apps/timo-web/api/generated/endpoints/timer/timer.ts @@ -17,6 +17,7 @@ import type { BaseResponseTimerStartResponse, BaseResponseTimerStatusResponse, ErrorDto, + StartTimerParams, TimerActionRequest, TimerExtendRequest, } from "../../models"; @@ -58,15 +59,24 @@ const withQueryKey = ( /** * 투두의 타이머를 시작합니다. * 한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409를 반환합니다. + * + * 반복 투두는 여러 날짜에 같은 todoId로 노출되므로, 실행중 표시는 todoId가 아닌 (todoId, date) 조합으로 구분해야 합니다. + * 쿼리 파라미터 date로 타이머가 상태를 반영할 대상 날짜를 지정할 수 있으며(미래/과거 포함), 미지정 시 사용자 타임존 기준 오늘로 처리됩니다. 이후 일시정지/재개/완료/중지는 이 날짜를 그대로 사용합니다. * @summary 타이머 시작 */ export const startTimer = ( todoId: number, + params?: StartTimerParams, options?: SecondParameter, signal?: AbortSignal, ) => { return customInstance( - { url: `/api/v1/todos/${todoId}/timers/start`, method: "POST", signal }, + { + url: `/api/v1/todos/${todoId}/timers/start`, + method: "POST", + params, + signal, + }, options, ); }; @@ -78,14 +88,14 @@ export const getStartTimerMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { todoId: number }, + { todoId: number; params?: StartTimerParams }, TContext >; request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, - { todoId: number }, + { todoId: number; params?: StartTimerParams }, TContext > => { const mutationKey = ["startTimer"]; @@ -99,11 +109,11 @@ export const getStartTimerMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { todoId: number } + { todoId: number; params?: StartTimerParams } > = (props) => { - const { todoId } = props ?? {}; + const { todoId, params } = props ?? {}; - return startTimer(todoId, requestOptions); + return startTimer(todoId, params, requestOptions); }; return { mutationFn, ...mutationOptions }; @@ -123,7 +133,7 @@ export const useStartTimer = , TContext = unknown>( mutation?: UseMutationOptions< Awaited>, TError, - { todoId: number }, + { todoId: number; params?: StartTimerParams }, TContext >; request?: SecondParameter; @@ -132,7 +142,7 @@ export const useStartTimer = , TContext = unknown>( ): UseMutationResult< Awaited>, TError, - { todoId: number }, + { todoId: number; params?: StartTimerParams }, TContext > => { return useMutation(getStartTimerMutationOptions(options), queryClient); @@ -499,6 +509,9 @@ export const useCompleteTimer = < * 로그인한 사용자의 현재 실행 중(RUNNING/PAUSED)인 타이머를 단건 조회합니다. * 한 사용자당 시작 이후 완료/종료되지 않은 타이머는 최대 1개만 존재할 수 있습니다. * 실행 중인 타이머가 없으면 data: null을 반환합니다. + * + * 응답의 date는 타이머가 귀속되는 날짜(사용자 시간대 기준 시작일)입니다. + * 반복 투두는 여러 날짜에 같은 todoId로 노출되므로, 실행중 표시는 todoId가 아닌 (todoId, date) 조합으로 구분해야 합니다. * @summary 현재 실행 중인 타이머 조회 */ export const getActiveTimer = ( diff --git a/apps/timo-web/api/generated/endpoints/timer/timer.zod.ts b/apps/timo-web/api/generated/endpoints/timer/timer.zod.ts index ffc1e441..3cbf1c48 100644 --- a/apps/timo-web/api/generated/endpoints/timer/timer.zod.ts +++ b/apps/timo-web/api/generated/endpoints/timer/timer.zod.ts @@ -10,12 +10,22 @@ import * as zod from "zod"; /** * 투두의 타이머를 시작합니다. * 한 번에 한 개의 타이머만 실행 가능하며, 이미 실행/일시정지 중인 타이머가 있으면 409를 반환합니다. + * + * 반복 투두는 여러 날짜에 같은 todoId로 노출되므로, 실행중 표시는 todoId가 아닌 (todoId, date) 조합으로 구분해야 합니다. + * 쿼리 파라미터 date로 타이머가 상태를 반영할 대상 날짜를 지정할 수 있으며(미래/과거 포함), 미지정 시 사용자 타임존 기준 오늘로 처리됩니다. 이후 일시정지/재개/완료/중지는 이 날짜를 그대로 사용합니다. * @summary 타이머 시작 */ export const StartTimerParams = zod.object({ todoId: zod.number().describe("타이머를 시작할 투두 ID"), }); +export const StartTimerQueryParams = zod.object({ + date: zod.iso + .date() + .optional() + .describe("타이머가 상태를 반영할 대상 날짜(미지정 시 오늘). ISO 형식"), +}); + export const StartTimerResponse = zod.object({ status: zod.number().optional(), message: zod.string().optional(), @@ -23,6 +33,7 @@ export const StartTimerResponse = zod.object({ .object({ timerId: zod.number(), todoId: zod.number(), + date: zod.string(), status: zod.string(), plannedSeconds: zod.number(), startedAt: zod.string(), @@ -153,6 +164,9 @@ export const CompleteTimerResponse = zod.object({ * 로그인한 사용자의 현재 실행 중(RUNNING/PAUSED)인 타이머를 단건 조회합니다. * 한 사용자당 시작 이후 완료/종료되지 않은 타이머는 최대 1개만 존재할 수 있습니다. * 실행 중인 타이머가 없으면 data: null을 반환합니다. + * + * 응답의 date는 타이머가 귀속되는 날짜(사용자 시간대 기준 시작일)입니다. + * 반복 투두는 여러 날짜에 같은 todoId로 노출되므로, 실행중 표시는 todoId가 아닌 (todoId, date) 조합으로 구분해야 합니다. * @summary 현재 실행 중인 타이머 조회 */ export const GetActiveTimerResponse = zod.object({ @@ -162,6 +176,7 @@ export const GetActiveTimerResponse = zod.object({ .object({ timerId: zod.number(), todoId: zod.number(), + date: zod.string(), todoTitle: zod.string(), iconType: zod.string().optional(), status: zod.string(), diff --git a/apps/timo-web/api/generated/endpoints/todo/todo.ts b/apps/timo-web/api/generated/endpoints/todo/todo.ts index af0ab788..ba2c70bb 100644 --- a/apps/timo-web/api/generated/endpoints/todo/todo.ts +++ b/apps/timo-web/api/generated/endpoints/todo/todo.ts @@ -405,6 +405,7 @@ export const useDeleteTodo = , TContext = unknown>( * 기존 TODO의 제목, 메모, 날짜, 태그, 우선순위, 예상 소요 시간, 아이콘, 반복 규칙, 하위 태스크를 부분 수정합니다. * * 요청 body에 포함된(null이 아닌) 필드만 수정됩니다. + * 단, icon은 예외로, "NONE"을 전달하면 아이콘을 제거(빈 상태로 저장)하고, null이면 기존 아이콘을 유지합니다. * 예상 소요 시간은 durationSeconds 필드에 초 단위 정수로 전달합니다. * subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다. * subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다. diff --git a/apps/timo-web/api/generated/endpoints/todo/todo.zod.ts b/apps/timo-web/api/generated/endpoints/todo/todo.zod.ts index 80a4c9b7..7872d210 100644 --- a/apps/timo-web/api/generated/endpoints/todo/todo.zod.ts +++ b/apps/timo-web/api/generated/endpoints/todo/todo.zod.ts @@ -26,6 +26,7 @@ export const createTodoBodyMemoMax = 300; export const CreateTodoBody = zod.object({ icon: zod .enum([ + "NONE", "ICON_1", "ICON_2", "ICON_3", @@ -150,6 +151,7 @@ export const DeleteTodoResponse = zod.object({ * 기존 TODO의 제목, 메모, 날짜, 태그, 우선순위, 예상 소요 시간, 아이콘, 반복 규칙, 하위 태스크를 부분 수정합니다. * * 요청 body에 포함된(null이 아닌) 필드만 수정됩니다. + * 단, icon은 예외로, "NONE"을 전달하면 아이콘을 제거(빈 상태로 저장)하고, null이면 기존 아이콘을 유지합니다. * 예상 소요 시간은 durationSeconds 필드에 초 단위 정수로 전달합니다. * subtasks를 전달하면 하위 태스크 목록 전체가 교체됩니다. * subtaskId가 있으면 기존 태스크를 수정하고, null이면 신규 태스크로 추가하며, 전달되지 않은 기존 태스크는 삭제됩니다. @@ -169,6 +171,7 @@ export const updateTodoBodyMemoMax = 300; export const UpdateTodoBody = zod.object({ icon: zod .enum([ + "NONE", "ICON_1", "ICON_2", "ICON_3", diff --git a/apps/timo-web/api/generated/models/baseResponseCalendarAuthorizeResponse.ts b/apps/timo-web/api/generated/models/baseResponseCalendarAuthorizeResponse.ts new file mode 100644 index 00000000..b6b15f2b --- /dev/null +++ b/apps/timo-web/api/generated/models/baseResponseCalendarAuthorizeResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarAuthorizeResponse } from "./calendarAuthorizeResponse"; + +export interface BaseResponseCalendarAuthorizeResponse { + status?: number; + message?: string; + data?: CalendarAuthorizeResponse; +} diff --git a/apps/timo-web/api/generated/models/baseResponseCalendarConnectResponse.ts b/apps/timo-web/api/generated/models/baseResponseCalendarConnectResponse.ts new file mode 100644 index 00000000..34c4a292 --- /dev/null +++ b/apps/timo-web/api/generated/models/baseResponseCalendarConnectResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarConnectResponse } from "./calendarConnectResponse"; + +export interface BaseResponseCalendarConnectResponse { + status?: number; + message?: string; + data?: CalendarConnectResponse; +} diff --git a/apps/timo-web/api/generated/models/baseResponseCalendarDisconnectResponse.ts b/apps/timo-web/api/generated/models/baseResponseCalendarDisconnectResponse.ts new file mode 100644 index 00000000..70725d2b --- /dev/null +++ b/apps/timo-web/api/generated/models/baseResponseCalendarDisconnectResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarDisconnectResponse } from "./calendarDisconnectResponse"; + +export interface BaseResponseCalendarDisconnectResponse { + status?: number; + message?: string; + data?: CalendarDisconnectResponse; +} diff --git a/apps/timo-web/api/generated/models/baseResponseCalendarEventsResponse.ts b/apps/timo-web/api/generated/models/baseResponseCalendarEventsResponse.ts new file mode 100644 index 00000000..aa1df6f4 --- /dev/null +++ b/apps/timo-web/api/generated/models/baseResponseCalendarEventsResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarEventsResponse } from "./calendarEventsResponse"; + +export interface BaseResponseCalendarEventsResponse { + status?: number; + message?: string; + data?: CalendarEventsResponse; +} diff --git a/apps/timo-web/api/generated/models/calendarAuthorizeResponse.ts b/apps/timo-web/api/generated/models/calendarAuthorizeResponse.ts new file mode 100644 index 00000000..8db02c26 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarAuthorizeResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarAuthorizeResponse { + authorizationUrl?: string; +} diff --git a/apps/timo-web/api/generated/models/calendarConnectRequest.ts b/apps/timo-web/api/generated/models/calendarConnectRequest.ts new file mode 100644 index 00000000..e508cbb7 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarConnectRequest.ts @@ -0,0 +1,20 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarConnectRequest { + /** + * 구글 OAuth 동의 완료 후 발급된 authorization code + * @minLength 1 + */ + authorizationCode: string; + /** + * authorize API 호출 시 발급받은 state 값 (CSRF 방어용) + * @minLength 1 + */ + state: string; +} diff --git a/apps/timo-web/api/generated/models/calendarConnectResponse.ts b/apps/timo-web/api/generated/models/calendarConnectResponse.ts new file mode 100644 index 00000000..8149bba2 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarConnectResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarConnectResponse { + calendarConnected?: boolean; + calendarEmail?: string; + /** UTC 기준 연동 시각 */ + connectedAt?: string; +} diff --git a/apps/timo-web/api/generated/models/calendarDayResponse.ts b/apps/timo-web/api/generated/models/calendarDayResponse.ts new file mode 100644 index 00000000..71ee8954 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarDayResponse.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarEventResponse } from "./calendarEventResponse"; + +export interface CalendarDayResponse { + date?: string; + events?: CalendarEventResponse[]; +} diff --git a/apps/timo-web/api/generated/models/calendarDisconnectResponse.ts b/apps/timo-web/api/generated/models/calendarDisconnectResponse.ts new file mode 100644 index 00000000..3242b372 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarDisconnectResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarDisconnectResponse { + calendarConnected?: boolean; +} diff --git a/apps/timo-web/api/generated/models/calendarEventResponse.ts b/apps/timo-web/api/generated/models/calendarEventResponse.ts new file mode 100644 index 00000000..3b38c566 --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarEventResponse.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface CalendarEventResponse { + title?: string; +} diff --git a/apps/timo-web/api/generated/models/calendarEventsResponse.ts b/apps/timo-web/api/generated/models/calendarEventsResponse.ts new file mode 100644 index 00000000..38deaece --- /dev/null +++ b/apps/timo-web/api/generated/models/calendarEventsResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ +import type { CalendarDayResponse } from "./calendarDayResponse"; + +export interface CalendarEventsResponse { + filter?: string; + baseDate?: string; + days?: CalendarDayResponse[]; +} diff --git a/apps/timo-web/api/generated/models/errorDto.ts b/apps/timo-web/api/generated/models/errorDto.ts index 2a483e30..d7f45820 100644 --- a/apps/timo-web/api/generated/models/errorDto.ts +++ b/apps/timo-web/api/generated/models/errorDto.ts @@ -12,4 +12,5 @@ export interface ErrorDto { errorCode?: string; message?: string; path?: string; + traceId?: string; } diff --git a/apps/timo-web/api/generated/models/focusTodoDetailResponse.ts b/apps/timo-web/api/generated/models/focusTodoDetailResponse.ts index 91605162..ad4d8b76 100644 --- a/apps/timo-web/api/generated/models/focusTodoDetailResponse.ts +++ b/apps/timo-web/api/generated/models/focusTodoDetailResponse.ts @@ -17,5 +17,6 @@ export interface FocusTodoDetailResponse { priority?: string; tag?: TagResponse; isRepeated: boolean; + memo?: string; subtasks: SubtaskResponse[]; } diff --git a/apps/timo-web/api/generated/models/getCalendarEventsParams.ts b/apps/timo-web/api/generated/models/getCalendarEventsParams.ts new file mode 100644 index 00000000..40e155a3 --- /dev/null +++ b/apps/timo-web/api/generated/models/getCalendarEventsParams.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v8.20.0 🍺 + * Do not edit manually. + * Timo API + * Timo 서버 API 명세서 + * OpenAPI spec version: v1 + */ + +export interface GetCalendarEventsParams { + /** + * 조회 필터 + */ + filter: string; + /** + * 기준 날짜 (YYYY-MM-DD), 미입력 시 오늘 + */ + baseDate?: string; +} diff --git a/apps/timo-web/api/generated/models/index.ts b/apps/timo-web/api/generated/models/index.ts index ba4eace1..e4e610e7 100644 --- a/apps/timo-web/api/generated/models/index.ts +++ b/apps/timo-web/api/generated/models/index.ts @@ -12,6 +12,10 @@ export * from "./authTokenResponse"; export * from "./baseResponse"; export * from "./baseResponseAuthReissueResponse"; export * from "./baseResponseAuthTokenResponse"; +export * from "./baseResponseCalendarAuthorizeResponse"; +export * from "./baseResponseCalendarConnectResponse"; +export * from "./baseResponseCalendarDisconnectResponse"; +export * from "./baseResponseCalendarEventsResponse"; export * from "./baseResponseFocusTodoResponse"; export * from "./baseResponseHomeResponse"; export * from "./baseResponseListTimeBoxResponse"; @@ -38,6 +42,13 @@ export * from "./baseResponseUpdateLanguageResponse"; export * from "./baseResponseUpdateTimezoneResponse"; export * from "./baseResponseUserProfileResponse"; export * from "./baseResponseVoid"; +export * from "./calendarAuthorizeResponse"; +export * from "./calendarConnectRequest"; +export * from "./calendarConnectResponse"; +export * from "./calendarDayResponse"; +export * from "./calendarDisconnectResponse"; +export * from "./calendarEventResponse"; +export * from "./calendarEventsResponse"; export * from "./dailyTodoResponse"; export * from "./dayCompletionResponse"; export * from "./dayResponse"; @@ -46,6 +57,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"; @@ -61,6 +73,7 @@ export * from "./recommendDurationRequest"; export * from "./recommendDurationResponse"; export * from "./repeatResponse"; export * from "./repeatResponseWeekdaysItem"; +export * from "./startTimerParams"; export * from "./statisticsCalendarResponse"; export * from "./statisticsDailyResponse"; export * from "./statisticsSummaryResponse"; diff --git a/apps/timo-web/api/generated/models/startTimerParams.ts b/apps/timo-web/api/generated/models/startTimerParams.ts new file mode 100644 index 00000000..3d5e484c --- /dev/null +++ b/apps/timo-web/api/generated/models/startTimerParams.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 StartTimerParams { + /** + * 타이머가 상태를 반영할 대상 날짜(미지정 시 오늘). ISO 형식 + */ + date?: string; +} diff --git a/apps/timo-web/api/generated/models/timerActiveResponse.ts b/apps/timo-web/api/generated/models/timerActiveResponse.ts index 1002b981..b0f76183 100644 --- a/apps/timo-web/api/generated/models/timerActiveResponse.ts +++ b/apps/timo-web/api/generated/models/timerActiveResponse.ts @@ -9,6 +9,7 @@ export interface TimerActiveResponse { timerId: number; todoId: number; + date: string; todoTitle: string; iconType?: string; status: string; diff --git a/apps/timo-web/api/generated/models/timerStartResponse.ts b/apps/timo-web/api/generated/models/timerStartResponse.ts index aa2cad40..7c736fa9 100644 --- a/apps/timo-web/api/generated/models/timerStartResponse.ts +++ b/apps/timo-web/api/generated/models/timerStartResponse.ts @@ -9,6 +9,7 @@ export interface TimerStartResponse { timerId: number; todoId: number; + date: string; status: string; plannedSeconds: number; startedAt: string; diff --git a/apps/timo-web/api/generated/models/todayTodoResponse.ts b/apps/timo-web/api/generated/models/todayTodoResponse.ts index b81b03b1..416cb136 100644 --- a/apps/timo-web/api/generated/models/todayTodoResponse.ts +++ b/apps/timo-web/api/generated/models/todayTodoResponse.ts @@ -19,7 +19,7 @@ export interface TodayTodoResponse { priority?: string; tag?: TagResponse; isRepeated: boolean; - hasMemo: boolean; + hasSubtask: boolean; timerStatus: TodayTodoResponseTimerStatus; sortOrder?: number; subtasks: SubtaskResponse[]; 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/todoResponse.ts b/apps/timo-web/api/generated/models/todoResponse.ts index 4992e5f1..a0ccd634 100644 --- a/apps/timo-web/api/generated/models/todoResponse.ts +++ b/apps/timo-web/api/generated/models/todoResponse.ts @@ -17,7 +17,7 @@ export interface TodoResponse { durationSeconds?: number; priority?: string; tag?: TagResponse; - hasMemo: boolean; + hasSubtask: boolean; isRepeated: boolean; timerStatus: TodoResponseTimerStatus; sortOrder?: number; 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/_components/todo-card/HomeTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsx index 3e98a59b..81ab97a3 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_components/todo-card/HomeTodoCard.tsx @@ -17,6 +17,7 @@ import { } from "@repo/timo-design-system/ui"; import { cn } from "@repo/timo-design-system/utils"; import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; import type { TodoPriorityTypes, @@ -26,27 +27,17 @@ import type { KeyboardEvent, MouseEvent, PointerEvent } from "react"; import { convertDurationToTimeText } from "@/utils/todo/todo-time"; -type PriorityLabelKeyTypes = "urgent" | "high" | "medium" | "low"; - -const PRIORITY_LABEL_KEY: Record = { - VERY_HIGH: "urgent", - HIGH: "high", - MEDIUM: "medium", - LOW: "low", -}; - const isInteractiveElement = (target: EventTarget | null) => - target instanceof HTMLElement && - Boolean(target.closest("button, input, label")); + target instanceof Element && Boolean(target.closest("button, input, label")); export interface HomeTodoCardProps { todoId: number; title: string; isCompleted: boolean; durationSeconds: number; - priority: TodoPriorityTypes; + priority?: TodoPriorityTypes; tagName?: string; - hasMemo: boolean; + hasSubtask: boolean; isRepeated: boolean; timerStatus: TodoTimerStatusTypes; isPlayHighlighted: boolean; @@ -65,7 +56,7 @@ export const HomeTodoCard = ({ durationSeconds, priority, tagName, - hasMemo, + hasSubtask, isRepeated, timerStatus, isPlayHighlighted, @@ -86,9 +77,16 @@ export const HomeTodoCard = ({ transition, }; + const [isHovered, setIsHovered] = useState(false); + + useEffect(() => { + if (isCompleted) setIsHovered(false); + }, [isCompleted]); + + const isDimmed = isCompleted && !isHovered; const isRunning = timerStatus === "RUNNING"; - const priorityLabel = tCommon(`priority.${PRIORITY_LABEL_KEY[priority]}`); + const priorityLabel = priority ? tCommon(`priority.${priority}`) : undefined; const handleCardClick = (event: MouseEvent) => { if (isInteractiveElement(event.target)) return; @@ -123,7 +121,7 @@ export const HomeTodoCard = ({

{title} @@ -165,9 +163,11 @@ export const HomeTodoCard = ({ tabIndex={onClickTodo ? 0 : attributes.tabIndex} onClick={onClickTodo ? handleCardClick : undefined} onKeyDown={onClickTodo ? handleCardKeyDown : undefined} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} className={cn( "border-timo-gray-500 flex w-full shrink-0 flex-col items-start gap-2 overflow-hidden rounded-[4px] border border-solid px-3.5 py-3", - isCompleted ? "bg-timo-gray-200" : "bg-white", + isDimmed ? "bg-timo-gray-200" : "bg-white", onClickTodo && "cursor-pointer", )} > @@ -189,19 +189,23 @@ export const HomeTodoCard = ({ )}

- + {priority && ( +
+ +
+ )} {tagName && } - {hasMemo && - (isCompleted ? ( + {hasSubtask && + (isDimmed ? ( ) : ( ))} {isRepeated && - (isCompleted ? ( + (isDimmed ? ( ) : ( @@ -210,7 +214,7 @@ export const HomeTodoCard = ({ {convertDurationToTimeText(durationSeconds)} 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..efa73bef 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/HomeTodoContainer.tsx @@ -15,10 +15,13 @@ import { import { useHomeTodosByDate } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date"; import { useHomeViewMode } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-view-mode"; import { useHomeViewQuery } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_queries/use-home-view-query"; +import { HomeCalendarEventCard } from "@/components/calendar/HomeCalendarEventCard"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { StopCompleteModalContainer } from "@/containers/timer/StopCompleteModalContainer"; import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; import { DndSortableListProvider } from "@/providers/dnd/DndSortableListProvider"; +import { useMyProfileQuery } from "@/queries/auth/use-my-profile-query"; +import { useCalendarEventsQuery } from "@/queries/calendar/use-calendar-events-query"; import { formatDateKey } from "@/utils/date/date"; import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { getDefaultTagLabelKey } from "@/utils/todo/tag-label"; @@ -37,9 +40,20 @@ export const HomeTodoContainer = () => { const scrollRef = useHomeTodayScrollRef(); const filter: HomeViewFilter = isWeekView ? "WEEK" : "DEFAULT"; + const calendarFilter = isWeekView ? "WEEK" : "TWO_WEEK"; const baseDate = formatDateKey(referenceDate); const { data: homeViewData } = useHomeViewQuery({ filter, baseDate }); + const { data: profile } = useMyProfileQuery(); + const { data: calendarEventsData } = useCalendarEventsQuery({ + filter: calendarFilter, + baseDate, + enabled: profile.calendarConnected, + }); + + const calendarEventsByDate = new Map( + (calendarEventsData?.days ?? []).map((day) => [day.date, day.events]), + ); const days = homeViewData.days; const [pendingCompleteTodo, setPendingCompleteTodo] = @@ -64,6 +78,8 @@ export const HomeTodoContainer = () => { onStopFeedback: setFeedbackText, onPlayError: (message) => setPlayErrorMessage(message ?? tToast("timerStartFailed")), + onUpdateError: (message) => + setPlayErrorMessage(message ?? tToast("todoUpdateFailed")), }); const handleConfirmPendingComplete = () => { @@ -82,9 +98,7 @@ export const HomeTodoContainer = () => { }, [isWeekView, scrollRef]); const plannedMinutes = activeTimer - ? convertDurationToMinutes( - activeTimer.plannedSeconds + activeTimer.extendedSeconds, - ) + ? convertDurationToMinutes(activeTimer.plannedSeconds) : 0; const actualMinutes = activeTimer ? convertDurationToMinutes(activeTimer.elapsedSeconds) @@ -95,12 +109,13 @@ 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) => { const dateKey = day.date; const todos = todosByDate[dateKey] ?? day.todos; + const calendarEvents = calendarEventsByDate.get(dateKey) ?? []; const completedCount = todos.filter((todo) => todo.completed).length; return ( @@ -119,7 +134,7 @@ export const HomeTodoContainer = () => { dayOfWeek={day.dayOfWeek} isHoliday={day.isHoliday} isToday={day.isToday} - totalCount={todos.length} + totalCount={todos.length + calendarEvents.length} completedCount={completedCount} /> @@ -131,10 +146,18 @@ export const HomeTodoContainer = () => { } >
+ {calendarEvents.map((event, index) => ( + + ))} {todos.map((todo) => { const [firstSubtask] = todo.subtasks; const isActiveTodo = - activeTimer && activeTimer.todoId === todo.todoId; + activeTimer && + activeTimer.todoId === todo.todoId && + activeTimer.date === dateKey; const durationSeconds = isActiveTodo ? activeTimer.plannedSeconds + activeTimer.extendedSeconds : todo.durationSeconds; @@ -174,7 +197,7 @@ export const HomeTodoContainer = () => { durationSeconds={durationSeconds} priority={todo.priority} tagName={todoTagName} - hasMemo={todo.hasMemo} + hasSubtask={todo.hasSubtask} isRepeated={todo.isRepeated} timerStatus={timerStatus} isPlayHighlighted={isPlayHighlighted} diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer.tsx index e83a64d1..ef832d76 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer.tsx @@ -3,18 +3,33 @@ import { useTranslations } from "next-intl"; import { useState } from "react"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; +import { MAX_CUSTOM_TAG_COUNT } from "@/schemas/tag/tag-schema"; -export const TagLimitToastContainer = () => { +interface TagLimitToastContainerProps { + count?: number; + onClose?: () => void; +} + +export const TagLimitToastContainer = ({ + count = MAX_CUSTOM_TAG_COUNT, + onClose, +}: TagLimitToastContainerProps) => { const [isOpen, setIsOpen] = useState(true); const t = useTranslations("Toast"); + const handleClose = () => { + setIsOpen(false); + onClose?.(); + }; + return ( setIsOpen(false)} + onClose={handleClose} message={

{t.rich("tagLimit", { + count, blue: (chunks) => ( {chunks} ), 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..2372754d 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 @@ -3,11 +3,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; +import type { ErrorType } from "@/api/client/custom-instance"; import type { ApiError } from "@/api/error/api-error"; +import type { ErrorDto } from "@/api/generated/models"; 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, @@ -29,6 +30,7 @@ export interface UseHomeTodosByDateOptions { onTimerAlreadyRunning: () => void; onStopFeedback: (feedbackText: string | undefined) => void; onPlayError: (message: string | undefined) => void; + onUpdateError: (message: string | undefined) => void; } export const useHomeTodosByDate = ( @@ -38,25 +40,47 @@ export const useHomeTodosByDate = ( onTimerAlreadyRunning, onStopFeedback, onPlayError, + onUpdateError, }: UseHomeTodosByDateOptions, ) => { const [todosByDate, setTodosByDate] = useState>({}); const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); const queryClient = useQueryClient(); - const { data: activeTimer } = useActiveTimer(); + const { data: activeTimer, isFetching: isActiveTimerFetching } = + useActiveTimer(); const { mutate: changeTodoStatus } = useChangeTodoStatus(); const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: reorderTodo } = useReorderTodo(); const { mutate: stopTimer } = useStopTimer(); - const { invalidateHomeView, invalidateTimerState, invalidateTimeBoxes } = - useTimerQueryInvalidation(); + const { + invalidateHomeView, + invalidateStatistics, + invalidateTimerState, + invalidateTimeBoxes, + invalidateFocusTodo, + } = useTimerQueryInvalidation(); - const { mutate: startTimer } = useStartTimer({ - mutation: { onSuccess: invalidateTimerState }, - }); - const { mutate: changeStatus } = useChangeStatus({ - mutation: { onSuccess: invalidateTimerState }, - }); + const { mutate: startTimer, isPending: isStartTimerPending } = + useStartTimer({ + mutation: { + onSuccess: () => { + invalidateTimerState(); + invalidateFocusTodo(); + }, + }, + }); + const { mutate: changeStatus, isPending: isChangeStatusPending } = + useChangeStatus({ + mutation: { + onSuccess: () => { + invalidateTimerState(); + invalidateFocusTodo(); + }, + }, + }); + + const isTimerActionPending = + isStartTimerPending || isChangeStatusPending || isActiveTimerFetching; useEffect(() => { setTodosByDate( @@ -77,9 +101,6 @@ export const useHomeTodosByDate = ( })); }; - const invalidateFocusTodo = () => { - queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); - }; const invalidateHomeAndFocus = () => { invalidateHomeView(); invalidateFocusTodo(); @@ -95,7 +116,11 @@ export const useHomeTodosByDate = ( todoId: number, completed: boolean, ) => { - if (completed && activeTimer?.todoId === todoId) { + if ( + completed && + activeTimer?.todoId === todoId && + activeTimer.date === dateKey + ) { onNeedStopConfirm(dateKey, todoId); return; } @@ -110,9 +135,11 @@ export const useHomeTodosByDate = ( invalidateHomeAndFocus(); invalidateTimeBoxes(); invalidateTodoDetail(dateKey, todoId); + invalidateStatistics(); }, - onError: () => { + onError: (error: ErrorType) => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); + onUpdateError(error.response?.data.message); }, }, ); @@ -138,6 +165,7 @@ export const useHomeTodosByDate = ( onSuccess: () => { invalidateHomeAndFocus(); invalidateTodoDetail(dateKey, todoId); + invalidateStatistics(); }, }, ); @@ -147,6 +175,8 @@ export const useHomeTodosByDate = ( }; const handleTogglePlay = (dateKey: string, todoId: number) => { + if (isTimerActionPending) return; + const willRun = todosByDate[dateKey]?.find((todo) => todo.todoId === todoId) ?.timerStatus !== "RUNNING"; @@ -155,7 +185,11 @@ export const useHomeTodosByDate = ( openTimerPanel(); } - if (activeTimer && activeTimer.todoId === todoId) { + if ( + activeTimer && + activeTimer.todoId === todoId && + activeTimer.date === dateKey + ) { changeStatus( { timerId: activeTimer.timerId, @@ -175,7 +209,7 @@ export const useHomeTodosByDate = ( } startTimer( - { todoId }, + { todoId, params: { date: dateKey } }, { onSuccess: () => invalidateTodoDetail(dateKey, todoId), onError: (error: ApiError) => { @@ -206,9 +240,11 @@ export const useHomeTodosByDate = ( invalidateHomeAndFocus(); invalidateTimeBoxes(); invalidateTodoDetail(dateKey, todoId); + invalidateStatistics(); }, - onError: () => { + onError: (error: ErrorType) => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); + onUpdateError(error.response?.data.message); }, }, ); @@ -240,8 +276,9 @@ export const useHomeTodosByDate = ( { todoId: movedTodo.todoId, data: { newIndex: toIndex, date: dateKey } }, { onSuccess: invalidateHomeAndFocus, - onError: () => { + onError: (error: ErrorType) => { setTodosByDate((prev) => ({ ...prev, [dateKey]: previous })); + onUpdateError(error.response?.data.message); }, }, ); @@ -250,6 +287,7 @@ export const useHomeTodosByDate = ( return { todosByDate, activeTimer, + isTimerActionPending, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts index 9e3abf30..73708040 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_mocks/todo-mock.ts @@ -15,7 +15,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ durationSeconds: 7200, priority: "HIGH", tag: { tagId: 3, name: "work" }, - hasMemo: false, + hasSubtask: false, isRepeated: true, timerStatus: "RUNNING", sortOrder: 0, @@ -28,7 +28,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ durationSeconds: 5400, priority: "VERY_HIGH", tag: { tagId: 1, name: "assignment" }, - hasMemo: true, + hasSubtask: true, isRepeated: false, timerStatus: "STOPPED", sortOrder: 1, @@ -47,7 +47,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ durationSeconds: 1800, priority: "LOW", tag: { tagId: 4, name: "exercise" }, - hasMemo: false, + hasSubtask: false, isRepeated: true, timerStatus: "STOPPED", sortOrder: 2, @@ -60,7 +60,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ durationSeconds: 1800, priority: "MEDIUM", tag: { tagId: 5, name: "additional" }, - hasMemo: true, + hasSubtask: true, isRepeated: false, timerStatus: "STOPPED", sortOrder: 3, @@ -73,7 +73,7 @@ const TODO_TEMPLATES: TodoTemplate[] = [ durationSeconds: 3600, priority: "MEDIUM", tag: { tagId: 2, name: "dailyLife" }, - hasMemo: false, + hasSubtask: false, isRepeated: false, timerStatus: "STOPPED", sortOrder: 4, 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)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_components/TodayTodoCard.tsx index 52ae7af6..381cd1b3 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, @@ -40,7 +38,7 @@ export interface TodayTodoCardToolbar { time: string; priority?: PriorityLevel; tag?: string; - hasMemo: boolean; + hasSubtask: boolean; hasRepeat: boolean; } @@ -185,7 +183,7 @@ export const TodayTodoCard = ({ tagLabel={toolbar.tag ?? "태그"} tags={[]} selectedTag={toolbar.tag} - hasMemo={toolbar.hasMemo} + hasMemo={toolbar.hasSubtask} isRepeatActive={toolbar.hasRepeat} repeat={{ detailHeading: "상세 설정", @@ -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..c878be44 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,10 +10,13 @@ import { TodayDateHeaderContainer } from "@/app/[locale]/(main)/(with-time-sideb import { TodayTodoCardContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_containers/TodayTodoCardContainer"; import { useTodayTodoList } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList"; import { useTodayQuery } from "@/app/[locale]/(main)/(with-time-sidebar)/today/_queries/use-today-query"; +import { TodayCalendarEventCard } from "@/components/calendar/TodayCalendarEventCard"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { StopCompleteModalContainer } from "@/containers/timer/StopCompleteModalContainer"; import { DetailTodoModalContainer } from "@/containers/todo-modal/detail/DetailTodoModalContainer"; -import { formatDate } from "@/utils/date/date"; +import { useMyProfileQuery } from "@/queries/auth/use-my-profile-query"; +import { useCalendarEventsQuery } from "@/queries/calendar/use-calendar-events-query"; +import { formatShortDateLabel, parseDateKey } from "@/utils/date/date"; import { convertDurationToMinutes } from "@/utils/duration/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; @@ -31,6 +34,13 @@ const renderTodoIcon = (icon: string | undefined) => { export const TodayTodoListContainer = () => { const tToast = useTranslations("Toast"); const { data } = useTodayQuery(); + const { data: profile } = useMyProfileQuery(); + const { data: calendarEventsData } = useCalendarEventsQuery({ + filter: "DAY", + baseDate: data.date.slice(0, 10), + enabled: profile.calendarConnected, + }); + const calendarEvents = calendarEventsData?.days[0]?.events ?? []; const [pendingCompleteTodoId, setPendingCompleteTodoId] = useState< number | null @@ -59,6 +69,8 @@ export const TodayTodoListContainer = () => { onStopFeedback: setFeedbackText, onPlayError: (message) => setPlayErrorMessage(message ?? tToast("timerStartFailed")), + onUpdateError: (message) => + setPlayErrorMessage(message ?? tToast("todoUpdateFailed")), }); const handleConfirmPendingComplete = () => { @@ -71,9 +83,7 @@ export const TodayTodoListContainer = () => { const completedCount = todos.filter((todo) => todo.completed).length; const plannedMinutes = activeTimer - ? convertDurationToMinutes( - activeTimer.plannedSeconds + activeTimer.extendedSeconds, - ) + ? convertDurationToMinutes(activeTimer.plannedSeconds) : 0; const actualMinutes = activeTimer ? convertDurationToMinutes(activeTimer.elapsedSeconds) @@ -83,12 +93,20 @@ export const TodayTodoListContainer = () => {
+ {calendarEvents.map((event, index) => ( + + ))} {todos.map((todo) => { const isActiveTodo = - activeTimer && activeTimer.todoId === todo.todoId; + activeTimer && + activeTimer.todoId === todo.todoId && + activeTimer.date === todo.date; const durationSeconds = isActiveTodo ? activeTimer.plannedSeconds + activeTimer.extendedSeconds : todo.durationSeconds; @@ -116,12 +134,14 @@ export const TodayTodoListContainer = () => { timerStatus={timerStatus} isPlayHighlighted={isPlayHighlighted} toolbar={{ - date: formatDate(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, - hasMemo: todo.hasMemo, + hasSubtask: todo.hasSubtask, hasRepeat: todo.isRepeated, }} subTodos={todo.subtasks.map((s) => ({ 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..99b4cf8b 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,7 +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 { getGetTodayQueryKey } from "@/api/generated/endpoints/home/home"; import { useChangeStatus, useStartTimer, @@ -27,6 +26,7 @@ export interface UseTodayTodoListOptions { onTimerAlreadyRunning: () => void; onStopFeedback: (feedbackText: string | undefined) => void; onPlayError: (message: string | undefined) => void; + onUpdateError: (message: string | undefined) => void; } export const useTodayTodoList = ( @@ -36,25 +36,46 @@ export const useTodayTodoList = ( onTimerAlreadyRunning, onStopFeedback, onPlayError, + onUpdateError, }: UseTodayTodoListOptions, ) => { const [todos, setTodos] = useState(initialTodos); const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); const queryClient = useQueryClient(); - const { data: activeTimer } = useActiveTimer(); + const { data: activeTimer, isFetching: isActiveTimerFetching } = + useActiveTimer(); const { mutate: changeTodoStatus } = useChangeTodoStatus(); const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: stopTimer } = useStopTimer(); - const { invalidateTimerState, invalidateTimeBoxes } = - useTimerQueryInvalidation(); + const { + invalidateTimerState, + invalidateTimeBoxes, + invalidateTodayView, + invalidateFocusTodo, + } = useTimerQueryInvalidation(); - const { mutate: startTimer } = useStartTimer({ - mutation: { onSuccess: invalidateTimerState }, - }); - const { mutate: changeStatus } = useChangeStatus({ - mutation: { onSuccess: invalidateTimerState }, + const { mutate: startTimer, isPending: isStartTimerPending } = useStartTimer({ + mutation: { + onSuccess: () => { + invalidateTimerState(); + invalidateFocusTodo(); + }, + }, }); + const { mutate: changeStatus, isPending: isChangeStatusPending } = + useChangeStatus({ + mutation: { + onSuccess: () => { + invalidateTimerState(); + invalidateFocusTodo(); + }, + }, + }); + + const isTimerActionPending = + isStartTimerPending || isChangeStatusPending || isActiveTimerFetching; + useEffect(() => { setTodos(initialTodos); }, [initialTodos]); @@ -68,9 +89,6 @@ export const useTodayTodoList = ( ); }; - const invalidateTodayView = () => { - queryClient.invalidateQueries({ queryKey: getGetTodayQueryKey() }); - }; const invalidateTodoDetail = (todoId: number, dateKey: string) => { queryClient.invalidateQueries({ queryKey: getGetTodoDetailQueryKey(todoId, { date: dateKey }), @@ -78,14 +96,18 @@ export const useTodayTodoList = ( }; const handleToggleCompleted = (todoId: number, completed: boolean) => { - if (completed && activeTimer?.todoId === todoId) { + const dateKey = todos.find((todo) => todo.todoId === todoId)?.date; + if (!dateKey) return; + + if ( + completed && + activeTimer?.todoId === todoId && + activeTimer.date === dateKey + ) { onNeedStopConfirm(todoId); return; } - const dateKey = todos.find((todo) => todo.todoId === todoId)?.date; - if (!dateKey) return; - const previous = todos; updateTodo(todoId, (todo) => ({ ...todo, completed })); @@ -96,9 +118,11 @@ export const useTodayTodoList = ( invalidateTodayView(); invalidateTimeBoxes(); invalidateTodoDetail(todoId, dateKey); + invalidateFocusTodo(); }, - onError: () => { + onError: (error: ErrorType) => { setTodos(previous); + onUpdateError(error.response?.data.message); }, }, ); @@ -124,6 +148,7 @@ export const useTodayTodoList = ( onSuccess: () => { invalidateTodayView(); invalidateTodoDetail(todoId, dateKey); + invalidateFocusTodo(); }, }, ); @@ -133,6 +158,8 @@ export const useTodayTodoList = ( }; const handlePlay = (todoId: number) => { + if (isTimerActionPending) return; + const dateKey = todos.find((todo) => todo.todoId === todoId)?.date; if (!dateKey) return; @@ -143,7 +170,11 @@ export const useTodayTodoList = ( openTimerPanel(); } - if (activeTimer && activeTimer.todoId === todoId) { + if ( + activeTimer && + activeTimer.todoId === todoId && + activeTimer.date === dateKey + ) { changeStatus( { timerId: activeTimer.timerId, @@ -163,7 +194,7 @@ export const useTodayTodoList = ( } startTimer( - { todoId }, + { todoId, params: { date: dateKey } }, { onSuccess: () => invalidateTodoDetail(todoId, dateKey), onError: (error: ErrorType) => { @@ -207,9 +238,11 @@ export const useTodayTodoList = ( onSuccess: () => { invalidateTodayView(); invalidateTodoDetail(todoId, dateKey); + invalidateFocusTodo(); }, - onError: () => { + onError: (error: ErrorType) => { setTodos(previous); + onUpdateError(error.response?.data.message); }, }, ); @@ -218,6 +251,7 @@ export const useTodayTodoList = ( return { todos, activeTimer, + isTimerActionPending, handlePlay, handleToggleCompleted, handleDelete, diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock.ts index 41ebd086..c7344633 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_mocks/today-todo-mock.ts @@ -18,7 +18,7 @@ export interface TodoMock { durationSeconds: number; priority: "URGENT" | "HIGH" | "MEDIUM" | "LOW"; tag: TodoTag | null; - hasMemo: boolean; + hasSubtask: boolean; isRepeated: boolean; timerStatus: "RUNNING" | "PAUSED" | "STOPPED"; sortOrder: number; @@ -35,7 +35,7 @@ export const todayTodoMocks: TodoMock[] = [ durationSeconds: 36000, priority: "URGENT", tag: { tagId: 1, name: "작업" }, - hasMemo: true, + hasSubtask: true, isRepeated: false, timerStatus: "STOPPED", sortOrder: 0, @@ -53,7 +53,7 @@ export const todayTodoMocks: TodoMock[] = [ durationSeconds: 3600, priority: "MEDIUM", tag: { tagId: 2, name: "완료" }, - hasMemo: false, + hasSubtask: false, isRepeated: false, timerStatus: "STOPPED", sortOrder: 1, @@ -68,7 +68,7 @@ export const todayTodoMocks: TodoMock[] = [ durationSeconds: 1800, priority: "HIGH", tag: null, - hasMemo: true, + hasSubtask: true, isRepeated: true, timerStatus: "STOPPED", sortOrder: 2, @@ -83,7 +83,7 @@ export const todayTodoMocks: TodoMock[] = [ durationSeconds: 7200, priority: "HIGH", tag: { tagId: 3, name: "개발" }, - hasMemo: false, + hasSubtask: false, isRepeated: false, timerStatus: "STOPPED", sortOrder: 3, @@ -101,7 +101,7 @@ export const todayTodoMocks: TodoMock[] = [ durationSeconds: 2700, priority: "MEDIUM", tag: { tagId: 4, name: "회의" }, - hasMemo: true, + hasSubtask: true, isRepeated: true, timerStatus: "STOPPED", sortOrder: 4, @@ -116,7 +116,7 @@ export const todayTodoMocks: TodoMock[] = [ durationSeconds: 5400, priority: "URGENT", tag: { tagId: 1, name: "작업" }, - hasMemo: false, + hasSubtask: false, isRepeated: false, timerStatus: "STOPPED", sortOrder: 5, @@ -131,7 +131,7 @@ export const todayTodoMocks: TodoMock[] = [ durationSeconds: 4500, priority: "LOW", tag: { tagId: 3, name: "개발" }, - hasMemo: false, + hasSubtask: false, isRepeated: false, timerStatus: "STOPPED", sortOrder: 6, @@ -149,7 +149,7 @@ export const todayTodoMocks: TodoMock[] = [ durationSeconds: 9000, priority: "HIGH", tag: { tagId: 3, name: "개발" }, - hasMemo: true, + hasSubtask: true, isRepeated: false, timerStatus: "STOPPED", sortOrder: 7, 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}

)} diff --git a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx index 39feabbb..ff5cf58e 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -121,7 +121,7 @@ export const FocusSessionContainer = () => { ref={focusSessionState.timerSessionControlsRef} isRunning={focusSessionState.isRunning} onTogglePlay={focusSessionActions.onTogglePlay} - plannedMinutes={focusSessionState.plannedMinutes} + plannedMinutes={focusSessionState.basePlannedMinutes} actualMinutes={focusSessionState.actualMinutes} feedbackText={feedbackText} isTimeUp={focusSessionState.isTimeUp} 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..066e8575 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,7 +5,6 @@ import { useEffect, useRef } from "react"; import type { TimerSessionControlsHandle } from "@/components/timer/TimerSessionControls"; -import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; import { useChangeStatus, useCompleteTimer, @@ -14,6 +13,7 @@ import { useStopTimer, } from "@/api/generated/endpoints/timer/timer"; import { + getGetTodoDetailQueryKey, useChangeSubtaskStatus, useChangeTodoStatus, } from "@/api/generated/endpoints/todo/todo"; @@ -41,10 +41,20 @@ export const useFocusSession = ({ const { data: focusView } = useFocusTodoQuery(); const { data: activeTimer } = useActiveTimer(); - const { invalidateActiveTimer, invalidateHomeView, invalidateTimeBoxes } = - useTimerQueryInvalidation(); - const invalidateFocusTodo = () => - queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); + const { + invalidateActiveTimer, + invalidateHomeView, + invalidateTimeBoxes, + invalidateTodayView, + invalidateFocusTodo, + } = useTimerQueryInvalidation(); + 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 +94,8 @@ export const useFocusSession = ({ invalidateFocusTodo(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateTodoDetail(); }, onError: onMutationError, }, @@ -96,6 +108,8 @@ export const useFocusSession = ({ invalidateFocusTodo(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateTodoDetail(); }, onError: onMutationError, }, @@ -105,6 +119,8 @@ export const useFocusSession = ({ onSuccess: () => { invalidateFocusTodo(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateTodoDetail(); }, onError: onMutationError, }, @@ -114,6 +130,8 @@ export const useFocusSession = ({ onSuccess: () => { invalidateFocusTodo(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateTodoDetail(); }, onError: onMutationError, }, @@ -121,7 +139,10 @@ export const useFocusSession = ({ const todo = focusView.todo; const timer = - activeTimer && todo && activeTimer.todoId === todo.todoId + activeTimer && + todo && + activeTimer.todoId === todo.todoId && + activeTimer.date === focusView.date ? activeTimer : undefined; const isRunning = timer?.status === "RUNNING"; @@ -157,7 +178,9 @@ export const useFocusSession = ({ stopTimer, changeTodoStatus, onNoTimer: () => { - if (todo) startTimer({ todoId: todo.todoId }); + if (todo) { + startTimer({ todoId: todo.todoId, params: { date: focusView.date } }); + } }, }); @@ -215,6 +238,10 @@ export const useFocusSession = ({ ) : 0; const plannedMinutes = convertDurationToMinutes(plannedSeconds); + // 완료 모달의 "계획"은 연장 시간을 제외한 순수 계획 시간만 보여줘야 한다 + const basePlannedMinutes = convertDurationToMinutes( + timer ? timer.plannedSeconds : (todo?.durationSeconds ?? 0), + ); const actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); return { @@ -232,6 +259,7 @@ export const useFocusSession = ({ isOvertime, overtimeProgress, plannedMinutes, + basePlannedMinutes, actualMinutes, }, focusSessionActions: { diff --git a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx new file mode 100644 index 00000000..b9f3c1fa --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal.tsx @@ -0,0 +1,60 @@ +"use client"; + +import timoTimerLogo from "@repo/timo-design-system/assets/images/logo/timo-timer.svg"; +import { ModalButton } from "@repo/timo-design-system/ui"; +import Image from "next/image"; + +import type { SettingsProfileLabels } from "@/app/[locale]/(main)/settings/_types/account/profile-type"; + +import { OverlayModal } from "@/components/modal/OverlayModal"; + +export interface SettingsCalendarDisconnectModalProps { + isOpen: boolean; + onClose: () => void; + onExited: () => void; + labels: SettingsProfileLabels; + onDisconnect: () => void; +} + +export const SettingsCalendarDisconnectModal = ({ + isOpen, + onClose, + onExited, + labels, + onDisconnect, +}: SettingsCalendarDisconnectModalProps) => { + return ( + +
+ +
+

+ {labels.calendarDisconnectConfirmTitle} +

+

+ {labels.calendarDisconnectConfirmDescription} +

+
+ + {labels.calendarDisconnectConfirmCancel} + + { + onDisconnect(); + onClose(); + }} + > + {labels.calendarDisconnectConfirmConfirm} + +
+
+ ); +}; diff --git a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx index e42a4451..a5e8f4a4 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_components/account/SettingsProfileView.tsx @@ -101,7 +101,10 @@ export const SettingsProfileView = ({
- + {isCalendarConnected ? labels.disconnect : labels.connect}
diff --git a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx index 8e1f8d8c..d05bfab9 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/settings/_containers/account/SettingsProfileContainer.tsx @@ -4,11 +4,14 @@ import { useTranslations } from "next-intl"; import { overlay } from "overlay-kit"; import { useState } from "react"; +import { TagLimitToastContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer"; +import { SettingsCalendarDisconnectModal } from "@/app/[locale]/(main)/settings/_components/account/SettingsCalendarDisconnectModal"; import { SettingsProfileView } from "@/app/[locale]/(main)/settings/_components/account/SettingsProfileView"; import { useSettingsProfile } from "@/app/[locale]/(main)/settings/_hooks/account/use-settings-profile"; import { useSettingsProfileLabels } from "@/app/[locale]/(main)/settings/_hooks/account/use-settings-profile-labels"; import { CreateTagModalContainer } from "@/components/tag/CreateTagModalContainer"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; +import { MAX_CUSTOM_TAG_COUNT } from "@/schemas/tag/tag-schema"; export const SettingsProfileContainer = () => { const tToast = useTranslations("Toast"); @@ -19,14 +22,43 @@ export const SettingsProfileContainer = () => { const [isCalendarConnected, setIsCalendarConnected] = useState( profileState.calendarConnected, ); + const [isCalendarConnectErrorToastOpen, setIsCalendarConnectErrorToastOpen] = + useState(false); + const [ + isCalendarDisconnectErrorToastOpen, + setIsCalendarDisconnectErrorToastOpen, + ] = useState(false); const [isTagErrorToastOpen, setIsTagErrorToastOpen] = useState(false); + const [isTagLimitToastOpen, setIsTagLimitToastOpen] = useState(false); const [isLanguageErrorToastOpen, setIsLanguageErrorToastOpen] = useState(false); const handleConnectCalendar = () => { - profileActions.onConnectCalendar(isCalendarConnected, { + if (isCalendarConnected) { + overlay.open(({ isOpen, close, unmount }) => ( + + profileActions.onConnectCalendar(true, { + onConnect: () => setIsCalendarConnected(true), + onDisconnect: () => setIsCalendarConnected(false), + onDisconnectError: () => + setIsCalendarDisconnectErrorToastOpen(true), + }) + } + /> + )); + return; + } + + profileActions.onConnectCalendar(false, { onConnect: () => setIsCalendarConnected(true), onDisconnect: () => setIsCalendarConnected(false), + onConnectError: () => setIsCalendarConnectErrorToastOpen(true), + onDisconnectError: () => setIsCalendarDisconnectErrorToastOpen(true), }); }; @@ -37,6 +69,15 @@ export const SettingsProfileContainer = () => { }; const handleAddTag = () => { + const customTagCount = profileState.tags.filter( + (tag) => !tag.isDefault, + ).length; + + if (customTagCount >= MAX_CUSTOM_TAG_COUNT) { + setIsTagLimitToastOpen(true); + return; + } + const existingLabels = profileState.tags.map((tag) => tag.label); overlay.open(({ isOpen, close, unmount }) => ( @@ -78,6 +119,22 @@ export const SettingsProfileContainer = () => { onLogout={profileActions.onLogout} /> + {isTagLimitToastOpen && ( + setIsTagLimitToastOpen(false)} /> + )} + + setIsCalendarConnectErrorToastOpen(false)} + message={tToast("calendarConnectFailed")} + /> + + setIsCalendarDisconnectErrorToastOpen(false)} + message={tToast("calendarDisconnectFailed")} + /> + setIsTagErrorToastOpen(false)} 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 ? ( ) : ( 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 d39378c0..43104439 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_hooks/account/use-settings-profile.ts @@ -5,6 +5,11 @@ import { useTranslations } from "next-intl"; import type { SettingsLanguage } from "@/app/[locale]/(main)/settings/_types/account/profile-type"; +import { + authorize, + useDisconnectCalendar, + getGetCalendarEventsQueryKey, +} from "@/api/generated/endpoints/calendar/calendar"; import { getGetMyProfileQueryKey, useUpdateLanguage, @@ -16,8 +21,11 @@ import { useMyProfileQuery } from "@/queries/auth/use-my-profile-query"; import { useCreateTagMutation } from "@/queries/tag/use-create-tag-mutation"; import { useDeleteTagMutation } from "@/queries/tag/use-delete-tag-mutation"; import { useTagsQuery } from "@/queries/tag/use-tags-query"; -import { tagCreateDataSchema } from "@/schemas/tag/tag-schema"; -import { getDefaultTagLabelKey } from "@/utils/todo/tag-label"; +import { + MAX_CUSTOM_TAG_COUNT, + tagCreateDataSchema, +} from "@/schemas/tag/tag-schema"; +import { getDefaultTagLabelKey, isDefaultTagId } from "@/utils/todo/tag-label"; const LANGUAGE_REQUEST_MAP: Record< SettingsLanguage, @@ -38,6 +46,8 @@ export interface CreateTagHandlers extends ActionErrorHandlers { export interface ConnectCalendarHandlers { onConnect: () => void; onDisconnect: () => void; + onConnectError?: () => void; + onDisconnectError?: () => void; } export const useSettingsProfile = () => { @@ -46,6 +56,7 @@ export const useSettingsProfile = () => { const { data: profile } = useMyProfileQuery(); + const { mutate: disconnectCalendar } = useDisconnectCalendar(); const { mutateAsync: updateLanguage } = useUpdateLanguage(); const queryClient = useQueryClient(); @@ -55,38 +66,60 @@ export const useSettingsProfile = () => { const { mutate: logoutMutate, isPending: isLoggingOut } = useLogoutMutation(); const tagItems = (tagsQuery.data?.tags ?? []).map((tag) => { - const labelKey = tag.isDefault - ? getDefaultTagLabelKey(tag.tagId) - : undefined; + const isDefault = isDefaultTagId(tag.tagId); + const labelKey = isDefault ? getDefaultTagLabelKey(tag.tagId) : undefined; return { id: tag.tagId, label: labelKey ? tCommon(`tag.${labelKey}`) : tag.name, - isDefault: tag.isDefault, + isDefault, }; }); - const handleConnectCalendar = ( + const handleConnectCalendar = async ( isCalendarConnected: boolean, handlers: ConnectCalendarHandlers, ) => { if (isCalendarConnected) { - // TODO: 실제 확인 모달로 교체 - const confirmed = window.confirm("구글 캘린더 연동을 해제하시겠습니까?"); - if (!confirmed) return; - - // TODO: API - 연동 토큰 파기 - console.log("구글 캘린더 연동 토큰을 파기합니다."); - handlers.onDisconnect(); + disconnectCalendar(undefined, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: getGetMyProfileQueryKey(), + }); + queryClient.removeQueries({ + queryKey: getGetCalendarEventsQueryKey(), + }); + handlers.onDisconnect(); + }, + onError: () => { + handlers.onDisconnectError?.(); + }, + }); return; } - // TODO: Google 계정 인증 및 캘린더 접근 권한 동의 팝업 호출 - console.log("Google Calendar 연동 인증 팝업을 호출합니다."); - handlers.onConnect(); + try { + const response = await authorize(); + const url = response.data?.authorizationUrl; + if (!url) { + handlers.onConnectError?.(); + return; + } + localStorage.setItem("calendarConnectOrigin", "settings"); + window.location.assign(url); + } catch { + handlers.onConnectError?.(); + } }; const handleCreateTag = (label: string, handlers: CreateTagHandlers) => { + const customTagCount = tagItems.filter((tag) => !tag.isDefault).length; + + if (customTagCount >= MAX_CUSTOM_TAG_COUNT) { + handlers.onError(); + return; + } + createTag( { name: label }, { diff --git a/apps/timo-web/app/[locale]/(main)/settings/_types/account/profile-type.ts b/apps/timo-web/app/[locale]/(main)/settings/_types/account/profile-type.ts index 0d16762e..a03dbfdd 100644 --- a/apps/timo-web/app/[locale]/(main)/settings/_types/account/profile-type.ts +++ b/apps/timo-web/app/[locale]/(main)/settings/_types/account/profile-type.ts @@ -29,5 +29,9 @@ export interface SettingsProfileLabels { logoutConfirmDescription: string; logoutConfirmCancel: string; logoutConfirmConfirm: string; + calendarDisconnectConfirmTitle: string; + calendarDisconnectConfirmDescription: string; + calendarDisconnectConfirmCancel: string; + calendarDisconnectConfirmConfirm: string; removeTag: (tag: string) => string; } diff --git a/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx b/apps/timo-web/app/[locale]/(main)/statistics/_components/StatisticsCalendar.tsx index 69d2756f..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) => (
))} 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..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, @@ -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)}

    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..10dfd4b9 100644 --- a/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsCalendarContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/statistics/_containers/StatisticsCalendarContainer.tsx @@ -1,10 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; - import { StatisticsCalendar } from "@/app/[locale]/(main)/statistics/_components/StatisticsCalendar"; import { useStatisticsCalendarQuery } from "@/app/[locale]/(main)/statistics/_queries/use-statistics-query"; -import { LottiePlayer } from "@/components/lottie/LottiePlayer"; import { formatDateKey } from "@/utils/date/date"; interface StatisticsCalendarContainerProps { @@ -14,56 +11,18 @@ 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, selectedDate, onSelectDate, }: StatisticsCalendarContainerProps) => { - const t = useTranslations("Statistics.calendar"); const yearMonth = formatDateKey(currentMonth).slice(0, 7); const calendarQuery = useStatisticsCalendarQuery(yearMonth); const calendarData = calendarQuery.data?.data; - if (calendarQuery.isPending) { - return ( - - ); - } - - if (calendarQuery.isError || !calendarData) { - return ( - - ); + if (calendarQuery.isPending || calendarQuery.isError || !calendarData) { + return null; } return ( 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 ( -
    -
    +
    +
    -
    +
    = { en: "en_US", ko: "ko_KR", @@ -67,11 +66,20 @@ export async function generateMetadata({ alternateLocale: routing.locales .filter((otherLocale) => otherLocale !== locale) .map((otherLocale) => OG_LOCALE[otherLocale]), + images: [ + { + url: "/og.png", + width: 4800, + height: 2520, + alt: title, + }, + ], }, twitter: { - card: "summary", + card: "summary_large_image", title, description, + images: ["/og.png"], }, robots: { index: true, diff --git a/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx b/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx new file mode 100644 index 00000000..3bcfd3c9 --- /dev/null +++ b/apps/timo-web/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useRef } from "react"; + +import { useConnectCalendar } from "@/api/generated/endpoints/calendar/calendar"; +import { getGetMyProfileQueryKey } from "@/api/generated/endpoints/user/user"; +import { ROUTES } from "@/constants/routes"; +import { useRouter } from "@/i18n/navigation"; + +export const CalendarCallbackContainer = () => { + const searchParams = useSearchParams(); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + const router = useRouter(); + const queryClient = useQueryClient(); + const { mutate: connectCalendar } = useConnectCalendar(); + const hasRequested = useRef(false); + + useEffect(() => { + const origin = localStorage.getItem("calendarConnectOrigin"); + const redirectTarget = + origin === "settings" ? ROUTES.SETTINGS : ROUTES.HOME; + + if (error || !code || !state) { + router.replace(redirectTarget); + return; + } + if (hasRequested.current) return; + hasRequested.current = true; + + connectCalendar( + { data: { authorizationCode: code, state } }, + { + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: getGetMyProfileQueryKey(), + }); + localStorage.removeItem("calendarConnectOrigin"); + router.replace(redirectTarget); + }, + onError: () => { + router.replace(redirectTarget); + }, + }, + ); + }, [code, state, error, connectCalendar, queryClient, router]); + + return null; +}; diff --git a/apps/timo-web/app/[locale]/oauth/calendar/callback/page.tsx b/apps/timo-web/app/[locale]/oauth/calendar/callback/page.tsx new file mode 100644 index 00000000..0fecca10 --- /dev/null +++ b/apps/timo-web/app/[locale]/oauth/calendar/callback/page.tsx @@ -0,0 +1,10 @@ +import { CalendarCallbackContainer } from "@/app/[locale]/oauth/calendar/callback/_containers/CalendarCallbackContainer"; +import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; + +export default function CalendarCallbackPage() { + return ( + + + + ); +} diff --git a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx index fc491756..9c6a6a14 100644 --- a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingButton.tsx @@ -34,13 +34,34 @@ export const OnboardingButton = ({ } if (variant === "start") { + // TODO: 원래 start 버튼 (항상 활성 스타일) — CalendarConnect 단계 재활성화 시 아래 주석 해제 후 isActive 분기 제거 + // return ( + // + // ); return ( ); } diff --git a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx index afda1b95..ae23dac2 100644 --- a/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_components/OnboardingStepButton.tsx @@ -4,7 +4,8 @@ interface OnboardingStepButtonProps { step: 1 | 2 | 3 | 4; } -const STEPS = [1, 2, 3, 4] as const; +// TODO: 구글 캘린더 연동 단계 재활성화 시 4 추가 +const STEPS = [1, 2, 3] as const; export const OnboardingStepButton = ({ step }: OnboardingStepButtonProps) => { return ( diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx index 3c1ee3c8..dbd4ed35 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/CalendarConnectStepContainer.tsx @@ -1,8 +1,8 @@ "use client"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { authorize } from "@/api/generated/endpoints/calendar/calendar"; import { OnboardingButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingButtonContainer"; import { OnboardingGoogleButtonContainer } from "@/app/[locale]/onboarding/_containers/OnboardingGoogleButtonContainer"; @@ -18,7 +18,19 @@ export const CalendarConnectStepContainer = ({ onStart, }: CalendarConnectStepContainerProps) => { const t = useTranslations("Onboarding"); - const [isCalendarConnected, setIsCalendarConnected] = useState(false); + const isCalendarConnected = false; + + const handleGoogleConnect = async () => { + try { + const response = await authorize(); + const url = response.data?.authorizationUrl; + if (!url) return; + localStorage.setItem("calendarConnectOrigin", "onboarding"); + window.location.assign(url); + } catch { + // authorize 실패 시 아무 동작 없음 — 사용자가 재시도 가능 + } + }; return ( <> @@ -40,10 +52,7 @@ export const CalendarConnectStepContainer = ({ { - // TODO: 실제 구글 캘린더 OAuth 연동 (백엔드 API 확정 후) - setIsCalendarConnected(true); - }} + onClick={handleGoogleConnect} />
    diff --git a/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx b/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx index 2b8f8394..77dd2918 100644 --- a/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx +++ b/apps/timo-web/app/[locale]/onboarding/_containers/LifePatternStepContainer.tsx @@ -86,8 +86,9 @@ export const LifePatternStepContainer = ({
    + {/* TODO: 구글 캘린더 연동 단계 재활성화 시 variant="next"로 복구 */} { className="hidden shrink-0 lg:block lg:size-[350px] xl:size-[430px] 2xl:size-[500px]" ariaLabel="온보딩 애니메이션" /> -
    +
    @@ -114,12 +114,29 @@ export const OnboardingFunnelContainer = () => { !answers.bedTime ) return; - history.push("CalendarConnect", { - language: answers.language, - predictionAccuracy: answers.predictionAccuracy, - wakeUpTime: answers.wakeUpTime, - bedTime: answers.bedTime, - }); + // TODO: 구글 캘린더 연동 단계 재활성화 시 아래 completeOnboarding 블록을 제거하고 + // history.push("CalendarConnect", { language: answers.language, predictionAccuracy: answers.predictionAccuracy, wakeUpTime: answers.wakeUpTime, bedTime: answers.bedTime }) 로 복구 + completeOnboarding( + { + data: { + language: ONBOARDING_LANGUAGE_MAP[answers.language], + predictionAccuracy: answers.predictionAccuracy, + wakeUpTime: answers.wakeUpTime, + bedTime: answers.bedTime, + }, + }, + { + onSuccess: () => { + setOnboardingCompleted(true); + router.replace(ROUTES.HOME, { + locale: answers.language, + }); + }, + onError: () => { + setIsErrorToastOpen(true); + }, + }, + ); }} /> )} diff --git a/apps/timo-web/app/robots.ts b/apps/timo-web/app/robots.ts new file mode 100644 index 00000000..010dd897 --- /dev/null +++ b/apps/timo-web/app/robots.ts @@ -0,0 +1,30 @@ +import type { MetadataRoute } from "next"; + +import { ROUTES } from "@/constants/routes"; +import { SITE_URL } from "@/constants/site"; +import { routing } from "@/i18n/routing"; + +const PRIVATE_ROUTES = [ + ROUTES.ONBOARDING, + ROUTES.HOME, + ROUTES.TODAY, + ROUTES.FOCUS, + ROUTES.STATISTICS, + ROUTES.SETTINGS, +] as const; + +const disallow = routing.locales.flatMap((locale) => [ + ...PRIVATE_ROUTES.map((route) => `/${locale}${route}`), + `/${locale}/oauth`, +]); + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + disallow, + }, + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/apps/timo-web/app/sitemap.ts b/apps/timo-web/app/sitemap.ts new file mode 100644 index 00000000..d040237d --- /dev/null +++ b/apps/timo-web/app/sitemap.ts @@ -0,0 +1,24 @@ +import type { MetadataRoute } from "next"; + +import { ROUTES } from "@/constants/routes"; +import { SITE_URL } from "@/constants/site"; +import { routing } from "@/i18n/routing"; + +const PUBLIC_ROUTES = ["", ROUTES.LOGIN, ROUTES.POLICY] as const; + +export default function sitemap(): MetadataRoute.Sitemap { + const lastModified = new Date(); + + return PUBLIC_ROUTES.map((route) => ({ + url: `${SITE_URL}/${routing.defaultLocale}${route}`, + lastModified, + alternates: { + languages: Object.fromEntries( + routing.locales.map((locale) => [ + locale, + `${SITE_URL}/${locale}${route}`, + ]), + ), + }, + })); +} diff --git a/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx b/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx new file mode 100644 index 00000000..7c43dde2 --- /dev/null +++ b/apps/timo-web/components/calendar/HomeCalendarEventCard.tsx @@ -0,0 +1,25 @@ +"use client"; + +import Image from "next/image"; + +interface HomeCalendarEventCardProps { + title: string; +} + +export const HomeCalendarEventCard = ({ + title, +}: HomeCalendarEventCardProps) => ( +
    +
    + Google Calendar +
    +

    + {title} +

    +
    +); diff --git a/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx new file mode 100644 index 00000000..5a5d1573 --- /dev/null +++ b/apps/timo-web/components/calendar/TodayCalendarEventCard.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Image from "next/image"; + +interface TodayCalendarEventCardProps { + title: string; +} + +export const TodayCalendarEventCard = ({ + title, +}: TodayCalendarEventCardProps) => ( +
    +
    +
    + Google Calendar +
    + + {title} + +
    +
    +); diff --git a/apps/timo-web/components/layout/sidebar/time/TimeSidebar.tsx b/apps/timo-web/components/layout/sidebar/time/TimeSidebar.tsx index 3c46d9d3..861238f9 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimeSidebar.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimeSidebar.tsx @@ -2,7 +2,7 @@ import { TogglePanel } from "@repo/timo-design-system/ui"; import { cn } from "@repo/timo-design-system/utils"; -import { useId } from "react"; +import { useEffect, useId, useRef } from "react"; import { TIME_SIDEBAR_COLLAPSED_WIDTH_CLASS_NAME, @@ -32,6 +32,20 @@ export const TimeSidebar = ({ const activeTab = useTimeSidebarStore((state) => state.activeTab); const setActiveTab = useTimeSidebarStore((state) => state.setActiveTab); + const timeboxScrollRef = useRef(null); + + useEffect(() => { + if (!isOpen || activeTab !== "timebox") return; + const container = timeboxScrollRef.current; + if (!container) return; + const now = new Date(); + const offset = (now.getHours() + now.getMinutes() / 60) * 50; + container.scrollTo({ + top: Math.max(0, offset - container.clientHeight / 2), + behavior: "smooth", + }); + }, [activeTab, isOpen]); + const timeboxPanelId = `${id}-timebox-panel`; const timerPanelId = `${id}-timer-panel`; @@ -73,6 +87,7 @@ export const TimeSidebar = ({
    { style={{ top: startOffset, height }} > {showLabel && ( -
    +
    { const wasTimeUpRef = useRef(false); const { data: activeTimer } = useActiveTimer(); - const { invalidateActiveTimer, invalidateHomeView, invalidateTimeBoxes } = - useTimerQueryInvalidation(); + const { + invalidateActiveTimer, + invalidateHomeView, + invalidateTimeBoxes, + invalidateTodayView, + invalidateFocusTodo, + invalidateTodoDetail, + } = useTimerQueryInvalidation(); const { mutate: changeStatus } = useChangeStatus({ mutation: { @@ -56,6 +62,9 @@ export const TimerPanel = () => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateFocusTodo(); + if (response.data?.todoId) invalidateTodoDetail(response.data.todoId); }, }, }); @@ -66,6 +75,9 @@ export const TimerPanel = () => { invalidateActiveTimer(); invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateFocusTodo(); + if (response.data?.todoId) invalidateTodoDetail(response.data.todoId); }, }, }); @@ -74,6 +86,8 @@ export const TimerPanel = () => { onSuccess: () => { invalidateHomeView(); invalidateTimeBoxes(); + invalidateTodayView(); + invalidateFocusTodo(); }, }, }); @@ -143,6 +157,10 @@ export const TimerPanel = () => { ) : 0; const plannedMinutes = convertDurationToMinutes(plannedSeconds); + // 완료 모달의 "계획"은 연장 시간을 제외한 순수 계획 시간만 보여줘야 한다 + const basePlannedMinutes = convertDurationToMinutes( + activeTimer?.plannedSeconds ?? 0, + ); const actualMinutes = convertDurationToMinutes( activeTimer?.elapsedSeconds ?? 0, ); @@ -163,7 +181,7 @@ export const TimerPanel = () => { ref={timerSessionControlsRef} isRunning={isRunning} onTogglePlay={handleTogglePlay} - plannedMinutes={plannedMinutes} + plannedMinutes={basePlannedMinutes} actualMinutes={actualMinutes} feedbackText={feedbackText} isTimeUp={isTimeUp} 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 && ( { 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(); }} @@ -97,18 +114,41 @@ export const OverlayModal = ({ }} >
    - {children} + {/* + 배경 클릭으로 모달을 닫는 영역이다. 포인터 전용 UX이며 키보드 + 사용자는 이미 Escape로 동일하게 닫을 수 있어(위 handleEscape), + 여기서는 jsx-a11y의 인터랙션 규칙을 의도적으로 예외 처리한다. + */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} +
    { + wasFloatingLayerOpenRef.current = hasOpenFloatingLayer(); + }} + onClick={(event) => { + if (event.target !== event.currentTarget) return; + if (wasFloatingLayerOpenRef.current) return; + onClose(); + }} + > +
    + {children} +
    +
    , diff --git a/apps/timo-web/components/todo-modal/common/TodoIconField.tsx b/apps/timo-web/components/todo-modal/common/TodoIconField.tsx index 057f87d9..cb0da86e 100644 --- a/apps/timo-web/components/todo-modal/common/TodoIconField.tsx +++ b/apps/timo-web/components/todo-modal/common/TodoIconField.tsx @@ -1,9 +1,12 @@ import { ChevronUpIcon } from "@repo/timo-design-system/icons"; import { IconGraphic, IconSelector } from "@repo/timo-design-system/ui"; import { cn } from "@repo/timo-design-system/utils"; +import { useEffect, useState } from "react"; import type { TodoIconValue } from "@repo/timo-design-system/ui"; +const PANEL_EXIT_ANIMATION_DURATION = 160; + export interface TodoIconFieldProps { icon: TodoIconValue | null; isIconPanelOpen: boolean; @@ -23,14 +26,32 @@ export const TodoIconField = ({ onSelectIcon, onRemoveIcon, }: TodoIconFieldProps) => { + const [shouldRenderPanel, setShouldRenderPanel] = useState(isIconPanelOpen); + const [isPanelVisible, setIsPanelVisible] = useState(false); + + useEffect(() => { + if (isIconPanelOpen) { + setShouldRenderPanel(true); + const showFrame = requestAnimationFrame(() => setIsPanelVisible(true)); + return () => cancelAnimationFrame(showFrame); + } + + setIsPanelVisible(false); + const hideTimer = setTimeout( + () => setShouldRenderPanel(false), + PANEL_EXIT_ANIMATION_DURATION, + ); + return () => clearTimeout(hideTimer); + }, [isIconPanelOpen]); + return (
    - {!isIconPanelOpen && icon ? ( + {!isIconPanelOpen && !shouldRenderPanel && icon ? ( @@ -39,7 +60,7 @@ export const TodoIconField = ({ type="button" onClick={onTogglePanel} aria-expanded={isIconPanelOpen} - className="bg-timo-gray-200 flex items-center gap-1 rounded-[4px] px-1.5 py-0.5" + className="bg-timo-gray-200 flex items-center gap-1 rounded-[4px] px-1.5 py-0.5 transition-opacity duration-150 ease-out" > )} - {isIconPanelOpen && ( - + {shouldRenderPanel && ( +
    + +
    )}
    ); diff --git a/apps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsx b/apps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsx index fcd580a0..2af5b173 100644 --- a/apps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsx +++ b/apps/timo-web/components/todo-modal/create/CreateTodoModalContent.tsx @@ -9,6 +9,7 @@ import { useController, useForm } from "react-hook-form"; import type { CreateTodoRequest } from "@/schemas/todo/todo-schema"; import type { PriorityLevel } from "@repo/timo-design-system/ui"; +import { TagLimitToastContainer } from "@/app/[locale]/(main)/(with-time-sidebar)/home/_containers/toast/TagLimitToastContainer"; import { OverlayModal } from "@/components/modal/OverlayModal"; import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { TodoIconField } from "@/components/todo-modal/common/TodoIconField"; @@ -128,6 +129,7 @@ export const CreateTodoModalContent = ({ titleValue={titleField.title} titlePlaceholder={t("createModal.titlePlaceholder")} onTitleChange={titleField.handleTitleChange} + onTitleEnter={subtaskField.focusFirstInput} subtaskInputs={subtaskField.subtaskInputs} subtaskPlaceholder={t("createModal.subtaskPlaceholder")} registerSubtaskInputRef={subtaskField.registerInputRef} @@ -162,10 +164,10 @@ export const CreateTodoModalContent = ({ onTimeOpen={timeField.handleTimeSelectorOpen} priority={priorityField.value ?? undefined} priorityLabels={{ - VERY_HIGH: tCommon("priority.urgent"), - HIGH: tCommon("priority.high"), - MEDIUM: tCommon("priority.medium"), - LOW: tCommon("priority.low"), + VERY_HIGH: tCommon("priority.VERY_HIGH"), + HIGH: tCommon("priority.HIGH"), + MEDIUM: tCommon("priority.MEDIUM"), + LOW: tCommon("priority.LOW"), }} onSelectPriority={(level: PriorityLevel) => priorityField.onChange(level) @@ -193,7 +195,7 @@ export const CreateTodoModalContent = ({ weekly: { weekdays: repeatField.weekdays, selectedWeekdayIds: repeatField.repeatWeekdays, - onWeekdayToggle: repeatField.handleWeekdayToggle, + onWeekdaysChange: repeatField.handleWeekdaysChange, }, monthly: { repeatDayLabel: t("createModal.repeatDayUnit"), @@ -213,19 +215,9 @@ export const CreateTodoModalContent = ({
    - - {tToast.rich("tagLimit", { - blue: (chunks) => ( - {chunks} - ), - })} -

    - } - /> + {tagField.isTagLimitToastOpen && ( + + )} void; + onTitleEnter: () => void; subtaskInputs: SubtaskInputEntry[]; subtaskPlaceholder: string; registerSubtaskInputRef: ( @@ -29,12 +31,15 @@ export const CreateTodoTaskFields = ({ titleValue, titlePlaceholder, onTitleChange, + onTitleEnter, subtaskInputs, subtaskPlaceholder, registerSubtaskInputRef, onSubtaskInputChange, onSubtaskInputKeyDown, }: CreateTodoTaskFieldsProps) => { + const titleInputId = useId(); + return (
    @@ -43,16 +48,23 @@ export const CreateTodoTaskFields = ({ onChange={() => {}} className="cursor-default" /> -