diff --git a/apps/timo-web/app/[locale]/(main)/focus/_components/FocusEmptyTaskItem.tsx b/apps/timo-web/app/[locale]/(main)/focus/_components/FocusEmptyTaskItem.tsx new file mode 100644 index 00000000..cdb087b4 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/focus/_components/FocusEmptyTaskItem.tsx @@ -0,0 +1,68 @@ +import { + CalendarDisableIcon, + ClockDisableIcon, + PlayDisabledIcon, +} from "@repo/timo-design-system/icons"; +import { Checkbox, PlayButton } from "@repo/timo-design-system/ui"; +import { useTranslations } from "next-intl"; + +export interface FocusEmptyTaskItemProps { + dayNumber: string; + dayOfWeek: string; + dateText: string; +} + +export const FocusEmptyTaskItem = ({ + dayNumber, + dayOfWeek, + dateText, +}: FocusEmptyTaskItemProps) => { + const t = useTranslations("Focus"); + + return ( +
+
+

{dayNumber}

+

{dayOfWeek}

+
+ +
+ +

+ {t("empty")} +

+
+ +
+
+
+ + + {dateText} + +
+ +
+ + + 00:00 + +
+
+ + + + +
+ +
+
+ ); +}; 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 5ec1b43f..960bc9b6 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -1,14 +1,27 @@ "use client"; import { TimerOnIcon } from "@repo/timo-design-system/icons"; +import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; -import type { FocusTask } from "@/app/[locale]/(main)/focus/_types/task-type"; - +import { getGetFocusTodoQueryKey } from "@/api/generated/endpoints/focus/focus"; +import { + useChangeStatus, + useCompleteTimer, + useExtendTimer, + useStartTimer, + getGetActiveTimerQueryKey, +} from "@/api/generated/endpoints/timer/timer"; +import { + useChangeSubtaskStatus, + useChangeTodoStatus, +} from "@/api/generated/endpoints/todo/todo"; +import { FocusEmptyTaskItem } from "@/app/[locale]/(main)/focus/_components/FocusEmptyTaskItem"; import { FocusTaskItem } from "@/app/[locale]/(main)/focus/_components/FocusTaskItem"; import { FocusHeaderContainer } from "@/app/[locale]/(main)/focus/_containers/FocusHeaderContainer"; -import { focusTaskMock } from "@/app/[locale]/(main)/focus/_mocks/task-mock"; +import { useActiveTimer } from "@/app/[locale]/(main)/focus/_queries/use-active-timer"; +import { useFocusTodo } from "@/app/[locale]/(main)/focus/_queries/use-focus-todo"; import { convertDateToBadgeText, convertDateToDayNumberText, @@ -19,86 +32,192 @@ import { TimerSessionControls, type TimerSessionControlsHandle, } from "@/components/timer/TimerSessionControls"; -import { SECONDS_PER_MINUTE } from "@/constants/time"; +import { AnimatedToast } from "@/components/toast/AnimatedToast"; import { convertDurationToMinutes } from "@/utils/convert-duration-to-minutes"; import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; +import { formatDurationLabel } from "@/utils/format-duration-label"; export const FocusSessionContainer = () => { - const [task, setTask] = useState(focusTaskMock); - const today = new Date(); + const queryClient = useQueryClient(); const timerSessionControlsRef = useRef(null); const tWeekday = useTranslations("Common.weekday"); + const tDuration = useTranslations("Focus.duration"); + const tToast = useTranslations("Toast"); + const [feedbackText, setFeedbackText] = useState(); + const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); + + const { data: focusView } = useFocusTodo(); + const { data: activeTimer } = useActiveTimer(); + + const invalidateActiveTimer = () => + queryClient.invalidateQueries({ queryKey: getGetActiveTimerQueryKey() }); + const invalidateFocusTodo = () => + queryClient.invalidateQueries({ queryKey: getGetFocusTodoQueryKey() }); + const handleMutationError = () => setIsErrorToastOpen(true); + + const { mutate: startTimer } = useStartTimer({ + mutation: { + onSuccess: invalidateActiveTimer, + onError: handleMutationError, + }, + }); + const { mutate: changeStatus } = useChangeStatus({ + mutation: { + onSuccess: invalidateActiveTimer, + onError: handleMutationError, + }, + }); + const { mutate: extendTimer } = useExtendTimer({ + mutation: { + onSuccess: invalidateActiveTimer, + onError: handleMutationError, + }, + }); + const { mutate: completeTimer } = useCompleteTimer({ + mutation: { + onSuccess: (response) => { + setFeedbackText(response.data?.aiFeedback ?? undefined); + invalidateActiveTimer(); + invalidateFocusTodo(); + }, + onError: handleMutationError, + }, + }); + const { mutate: changeTodoStatus } = useChangeTodoStatus({ + mutation: { onSuccess: invalidateFocusTodo, onError: handleMutationError }, + }); + const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus({ + mutation: { onSuccess: invalidateFocusTodo, onError: handleMutationError }, + }); + + const todo = focusView.todo; + const timer = + activeTimer && todo && activeTimer.todoId === todo.todoId + ? activeTimer + : undefined; + const isRunning = timer?.status === "RUNNING"; const handleTogglePlay = () => { - // TODO: API - setTask((prev) => ({ ...prev, isRunning: !prev.isRunning })); + if (!todo) return; + + if (!timer) { + startTimer({ todoId: todo.todoId }); + return; + } + + changeStatus({ + timerId: timer.timerId, + data: { action: isRunning ? "PAUSE" : "RESUME" }, + }); }; const handleExtendTimer = (minutes: number) => { - // TODO: API - setTask((prev) => ({ - ...prev, - durationSeconds: prev.durationSeconds + minutes * SECONDS_PER_MINUTE, - })); + if (!timer) return; + + extendTimer({ timerId: timer.timerId, data: { extendMinutes: minutes } }); }; const handleCompleteTimer = () => { - // TODO: API - setTask((prev) => ({ - ...prev, - isRunning: false, - completed: true, - subtasks: prev.subtasks.map((subtask) => ({ - ...subtask, - completed: true, - })), - })); + if (!timer) return; + + completeTimer({ timerId: timer.timerId }); }; const handleToggleCompleted = (completed: boolean) => { - if (completed && task.isRunning) { + if (!todo) return; + + if (completed && isRunning) { timerSessionControlsRef.current?.openStopModal(); return; } - // TODO: API - setTask((prev) => ({ - ...prev, - completed, - isRunning: completed ? false : prev.isRunning, - subtasks: prev.subtasks.map((subtask) => ({ ...subtask, completed })), - })); + changeTodoStatus({ + todoId: todo.todoId, + data: { isCompleted: completed, date: focusView.date }, + }); }; const handleToggleSubtaskCompleted = ( subtaskId: number, completed: boolean, ) => { - // TODO: API - setTask((prev) => ({ - ...prev, - subtasks: prev.subtasks.map((subtask) => - subtask.subtaskId === subtaskId ? { ...subtask, completed } : subtask, - ), - })); + if (!todo) return; + + changeSubtaskStatus({ + todoId: todo.todoId, + subtaskId, + data: { isCompleted: completed }, + }); }; - const plannedMinutes = convertDurationToMinutes(task.durationSeconds); + const today = new Date(focusView.date); + const dateText = convertDateToBadgeText(today); + + if (!focusView.hasTodo || !todo) { + return ( +
+ setIsErrorToastOpen(false)} + message={tToast("focusActionFailed")} + /> +
+ + +
+ +
+
+ + + {}} + plannedMinutes={0} + actualMinutes={0} + onExtend={() => {}} + onComplete={() => {}} + disabled + /> +
+
+
+ ); + } + + const plannedSeconds = timer + ? timer.plannedSeconds + timer.extendedSeconds + : (todo.durationSeconds ?? 0); + const remainingSeconds = timer ? timer.remainingSeconds : plannedSeconds; + const progress = + plannedSeconds > 0 + ? ((plannedSeconds - remainingSeconds) / plannedSeconds) * 100 + : 0; + const plannedMinutes = convertDurationToMinutes(plannedSeconds); + const actualMinutes = convertDurationToMinutes(timer?.elapsedSeconds ?? 0); return (
+ setIsErrorToastOpen(false)} + message={tToast("focusActionFailed")} + />
{
} - time="10:30" - plannedLabel="12M" - progress={70} + time={convertDurationToTimeText(remainingSeconds)} + plannedLabel={formatDurationLabel( + plannedMinutes, + tDuration("hourUnit"), + tDuration("minuteUnit"), + )} + progress={progress} size="lg" /> diff --git a/apps/timo-web/app/[locale]/(main)/focus/_mocks/task-mock.ts b/apps/timo-web/app/[locale]/(main)/focus/_mocks/task-mock.ts deleted file mode 100644 index b9927a4b..00000000 --- a/apps/timo-web/app/[locale]/(main)/focus/_mocks/task-mock.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { FocusTask } from "@/app/[locale]/(main)/focus/_types/task-type"; - -export const focusTaskMock: FocusTask = { - taskId: 1, - title: "앱잼 1차 과제 제출", - completed: false, - scheduledDate: new Date(2026, 6, 1), - durationSeconds: 7200, - isRunning: false, - subtasks: [ - { subtaskId: 1, content: "기획서 작성하기", completed: false }, - { subtaskId: 2, content: "와이어프레임 그리기", completed: true }, - ], - memo: "회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기회의 전까지 초안 공유하기", -}; diff --git a/apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts b/apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts new file mode 100644 index 00000000..8ed8920a --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/focus/_queries/use-active-timer.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { + getActiveTimer, + getGetActiveTimerQueryKey, +} from "@/api/generated/endpoints/timer/timer"; +import { activeTimerSchema } from "@/app/[locale]/(main)/focus/_types/timer-type"; + +export const useActiveTimer = () => + useQuery({ + queryKey: getGetActiveTimerQueryKey(), + queryFn: ({ signal }) => getActiveTimer(undefined, signal), + select: ({ data }) => (data ? activeTimerSchema.parse(data) : undefined), + refetchInterval: (query) => + query.state.data?.data?.status === "RUNNING" ? 1000 : false, + }); diff --git a/apps/timo-web/app/[locale]/(main)/focus/_queries/use-focus-todo.ts b/apps/timo-web/app/[locale]/(main)/focus/_queries/use-focus-todo.ts new file mode 100644 index 00000000..bb81c876 --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/focus/_queries/use-focus-todo.ts @@ -0,0 +1,16 @@ +"use client"; + +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { + getFocusTodo, + getGetFocusTodoQueryKey, +} from "@/api/generated/endpoints/focus/focus"; +import { focusViewSchema } from "@/app/[locale]/(main)/focus/_types/task-type"; + +export const useFocusTodo = () => + useSuspenseQuery({ + queryKey: getGetFocusTodoQueryKey(), + queryFn: ({ signal }) => getFocusTodo(undefined, signal), + select: ({ data }) => focusViewSchema.parse(data), + }); diff --git a/apps/timo-web/app/[locale]/(main)/focus/_types/task-type.ts b/apps/timo-web/app/[locale]/(main)/focus/_types/task-type.ts index 06ccb449..ee828c6d 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_types/task-type.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_types/task-type.ts @@ -1,16 +1,25 @@ -export interface FocusTaskSubtask { - subtaskId: number; - content: string; - completed: boolean; -} +import { z } from "zod"; -export interface FocusTask { - taskId: number; - title: string; - completed: boolean; - scheduledDate: Date; - durationSeconds: number; - isRunning: boolean; - subtasks: FocusTaskSubtask[]; - memo?: string; -} +export const focusTaskSubtaskSchema = z.object({ + subtaskId: z.number(), + content: z.string(), + completed: z.boolean(), +}); + +export const focusTaskSchema = z.object({ + todoId: z.number(), + title: z.string(), + completed: z.boolean(), + durationSeconds: z.number().optional(), + subtasks: z.array(focusTaskSubtaskSchema), +}); + +export const focusViewSchema = z.object({ + date: z.iso.date(), + hasTodo: z.boolean(), + todo: focusTaskSchema.nullish(), +}); + +export type FocusTaskSubtask = z.infer; +export type FocusTask = z.infer; +export type FocusView = z.infer; diff --git a/apps/timo-web/app/[locale]/(main)/focus/_types/timer-type.ts b/apps/timo-web/app/[locale]/(main)/focus/_types/timer-type.ts new file mode 100644 index 00000000..c85587bd --- /dev/null +++ b/apps/timo-web/app/[locale]/(main)/focus/_types/timer-type.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const activeTimerSchema = z.object({ + timerId: z.number(), + todoId: z.number(), + status: z.enum(["RUNNING", "PAUSED"]), + plannedSeconds: z.number(), + extendedSeconds: z.number(), + elapsedSeconds: z.number(), + remainingSeconds: z.number(), +}); + +export type ActiveTimer = z.infer; diff --git a/apps/timo-web/app/[locale]/(main)/focus/page.tsx b/apps/timo-web/app/[locale]/(main)/focus/page.tsx index 86c4e9b8..c9d745cf 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/page.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/page.tsx @@ -1,5 +1,10 @@ import { FocusSessionContainer } from "@/app/[locale]/(main)/focus/_containers/FocusSessionContainer"; +import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; export default function FocusPage() { - return ; + return ( + + + + ); } diff --git a/apps/timo-web/components/timer/Timer.tsx b/apps/timo-web/components/timer/Timer.tsx index 1c67b82b..254f869d 100644 --- a/apps/timo-web/components/timer/Timer.tsx +++ b/apps/timo-web/components/timer/Timer.tsx @@ -6,7 +6,7 @@ export type TimerSize = "sm" | "lg"; export type IconType = ReactNode; export interface TimerProps { - icon: IconType; + icon?: IconType; time: string; plannedLabel: string; progress: number; @@ -92,10 +92,12 @@ export const Timer = ({
- {/*TODO: 아이콘 핸드오프시 변경 예정 */} - - {icon} - + {icon && ( + // TODO: 아이콘 핸드오프시 변경 예정 + + {icon} + + )}

void; onOpenEndModal: () => void; onOpenExtendModal: () => void; + disabled?: boolean; } export const TimerControls = ({ @@ -26,6 +27,7 @@ export const TimerControls = ({ onTogglePlay, onOpenEndModal, onOpenExtendModal, + disabled = false, }: TimerControlsProps) => { const t = useTranslations("Focus.controls"); @@ -34,6 +36,7 @@ export const TimerControls = ({ @@ -51,11 +54,13 @@ export const TimerControls = ({ label={isRunning ? t("pause") : t("play")} variant={isRunning ? "active" : "default"} onClick={onTogglePlay} + disabled={disabled} /> diff --git a/apps/timo-web/components/timer/TimerSessionControls.tsx b/apps/timo-web/components/timer/TimerSessionControls.tsx index 59518f4a..5a8cdf25 100644 --- a/apps/timo-web/components/timer/TimerSessionControls.tsx +++ b/apps/timo-web/components/timer/TimerSessionControls.tsx @@ -26,6 +26,7 @@ export interface TimerSessionControlsProps { feedbackText?: string; onExtend: (minutes: number) => void; onComplete: () => void; + disabled?: boolean; } export const TimerSessionControls = forwardRef< @@ -40,6 +41,7 @@ export const TimerSessionControls = forwardRef< feedbackText, onExtend, onComplete, + disabled = false, }, ref, ) { @@ -87,6 +89,7 @@ export const TimerSessionControls = forwardRef< resetExtendSelection(); setStep("extend"); }} + disabled={disabled} /> void; children?: ReactNode; + ariaLabel?: string; } const VARIANT_MAP: Record< @@ -32,6 +33,7 @@ export const PlayButton = ({ disabled = false, onClick, children, + ariaLabel, }: PlayButtonProps) => { return (