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 aa84a616..370bcd0c 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/focus/_containers/FocusSessionContainer.tsx @@ -1,7 +1,8 @@ "use client"; import { TimerOnIcon } from "@repo/timo-design-system/icons"; -import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useRef, useState } from "react"; import type { FocusTask } from "@/app/[locale]/(main)/focus/_types/task-type"; @@ -11,22 +12,38 @@ import { focusTaskMock } from "@/app/[locale]/(main)/focus/_mocks/task-mock"; import { convertDateToBadgeText, convertDateToDayNumberText, - convertDateToDayOfWeekText, + convertDateToDayOfWeekKey, } from "@/app/[locale]/(main)/focus/_utils/date"; +import { convertDurationToMinutes } from "@/app/[locale]/(main)/focus/_utils/duration"; import { Timer } from "@/components/timer/Timer"; -import { TimerControls } from "@/components/timer/TimerControls"; +import { + TimerSessionControls, + type TimerSessionControlsHandle, +} from "@/components/timer/TimerSessionControls"; import { convertDurationToTimeText } from "@/utils/convert-duration-to-time-text"; +const SECONDS_PER_MINUTE = 60; + export const FocusSessionContainer = () => { const [task, setTask] = useState(focusTaskMock); const today = new Date(); + const timerSessionControlsRef = useRef(null); + const tWeekday = useTranslations("Common.weekday"); const handleTogglePlay = () => { // TODO: API setTask((prev) => ({ ...prev, isRunning: !prev.isRunning })); }; - const handleEnd = () => { + const handleExtendTimer = (minutes: number) => { + // TODO: API + setTask((prev) => ({ + ...prev, + durationSeconds: prev.durationSeconds + minutes * SECONDS_PER_MINUTE, + })); + }; + + const handleCompleteTimer = () => { // TODO: API setTask((prev) => ({ ...prev, @@ -39,9 +56,12 @@ export const FocusSessionContainer = () => { })); }; - const handleAddTime = () => {}; - const handleToggleCompleted = (completed: boolean) => { + if (completed && task.isRunning) { + timerSessionControlsRef.current?.openStopModal(); + return; + } + // TODO: API setTask((prev) => ({ ...prev, @@ -64,13 +84,15 @@ export const FocusSessionContainer = () => { })); }; + const plannedMinutes = convertDurationToMinutes(task.durationSeconds); + return (
{ size="lg" /> -
diff --git a/apps/timo-web/app/[locale]/(main)/focus/_utils/date.ts b/apps/timo-web/app/[locale]/(main)/focus/_utils/date.ts index 525c0776..b91da57f 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_utils/date.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_utils/date.ts @@ -1,19 +1,21 @@ -const DAY_OF_WEEK_LABELS = [ - "일요일", - "월요일", - "화요일", - "수요일", - "목요일", - "금요일", - "토요일", +export type WeekdayKey = "SUN" | "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT"; + +const WEEKDAY_KEYS: WeekdayKey[] = [ + "SUN", + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", ]; export const convertDateToDayNumberText = (date: Date): string => { return `${date.getDate()}`; }; -export const convertDateToDayOfWeekText = (date: Date): string => { - return DAY_OF_WEEK_LABELS[date.getDay()] ?? ""; +export const convertDateToDayOfWeekKey = (date: Date): WeekdayKey => { + return WEEKDAY_KEYS[date.getDay()] ?? "SUN"; }; export const convertDateToBadgeText = (date: Date): string => { diff --git a/apps/timo-web/app/[locale]/(main)/focus/_utils/duration.ts b/apps/timo-web/app/[locale]/(main)/focus/_utils/duration.ts index 5097d596..a38b7733 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_utils/duration.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_utils/duration.ts @@ -1,12 +1,4 @@ -const SECONDS_PER_HOUR = 3600; - const SECONDS_PER_MINUTE = 60; -export const convertDurationToTimeText = (durationSeconds: number): string => { - const hours = Math.floor(durationSeconds / SECONDS_PER_HOUR); - const minutes = Math.floor( - (durationSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE, - ); - - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; -}; +export const convertDurationToMinutes = (durationSeconds: number): number => + Math.round(durationSeconds / SECONDS_PER_MINUTE); diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 344dcdee..80f1df9b 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -4,17 +4,19 @@ import { TimerOnIcon } from "@repo/timo-design-system/icons"; import { useState } from "react"; import { Timer } from "@/components/timer/Timer"; -import { TimerControls } from "@/components/timer/TimerControls"; +import { TimerSessionControls } from "@/components/timer/TimerSessionControls"; type TimerStatus = "RUNNING" | "PAUSED"; +const PLANNED_MINUTES = 12; + export const TimerPanel = () => { const [status, setStatus] = useState("PAUSED"); const handleTogglePlay = () => setStatus((prev) => (prev === "RUNNING" ? "PAUSED" : "RUNNING")); - const handleEnd = () => setStatus("PAUSED"); - const handleAddTime = () => {}; + const handleExtendTimer = () => {}; + const handleCompleteTimer = () => setStatus("PAUSED"); return (
@@ -26,11 +28,13 @@ export const TimerPanel = () => { size="sm" /> -
); diff --git a/apps/timo-web/components/timer/TimerCompleteModalPanel.tsx b/apps/timo-web/components/timer/TimerCompleteModalPanel.tsx new file mode 100644 index 00000000..001eb800 --- /dev/null +++ b/apps/timo-web/components/timer/TimerCompleteModalPanel.tsx @@ -0,0 +1,59 @@ +import { Modal } from "@repo/timo-design-system/ui"; +import { useTranslations } from "next-intl"; + +import { formatDurationLabel } from "@/components/timer/durationLabel"; + +export interface TimerCompleteModalPanelProps { + plannedMinutes: number; + actualMinutes: number; + feedbackText?: string; + onComplete: () => void; +} + +export const TimerCompleteModalPanel = ({ + plannedMinutes, + actualMinutes, + feedbackText, + onComplete, +}: TimerCompleteModalPanelProps) => { + const t = useTranslations("Focus.completeModal"); + const tDuration = useTranslations("Focus.duration"); + const hourUnit = tDuration("hourUnit"); + const minuteUnit = tDuration("minuteUnit"); + + return ( + <> + {t("title")} +

+ {feedbackText ?? t("defaultFeedback")} +

+ +
+ +
+
+ + {t("plannedLabel")} + + + {formatDurationLabel(plannedMinutes, hourUnit, minuteUnit)} + +
+
+ + {t("actualLabel")} + + + {formatDurationLabel(actualMinutes, hourUnit, minuteUnit)} + +
+
+ + + + {t("completeButton")} + + + + ); +}; diff --git a/apps/timo-web/components/timer/TimerControlButton.tsx b/apps/timo-web/components/timer/TimerControlButton.tsx index 776aa2c8..10197e89 100644 --- a/apps/timo-web/components/timer/TimerControlButton.tsx +++ b/apps/timo-web/components/timer/TimerControlButton.tsx @@ -30,7 +30,7 @@ export const TimerControlButton = ({ onClick={onClick} disabled={disabled} className={cn( - "group flex size-14.5 items-center justify-center rounded-full", + "group focus-visible:border-timo-blue-300 flex size-14.5 items-center justify-center rounded-full border-2 border-transparent outline-none", isForcedActive ? "bg-timo-blue-50" : activeIcon diff --git a/apps/timo-web/components/timer/TimerControls.tsx b/apps/timo-web/components/timer/TimerControls.tsx index 69c18ff2..3d4183fd 100644 --- a/apps/timo-web/components/timer/TimerControls.tsx +++ b/apps/timo-web/components/timer/TimerControls.tsx @@ -6,46 +6,65 @@ import { PlusIcon, StopIcon, } from "@repo/timo-design-system/icons"; +import { Modal } from "@repo/timo-design-system/ui"; +import { useTranslations } from "next-intl"; import { TimerControlButton } from "@/components/timer/TimerControlButton"; +const MODAL_TRIGGER_CLASS = + "group bg-timo-gray-300 active:bg-timo-blue-50 focus-visible:border-timo-blue-300 flex size-14.5 items-center justify-center rounded-full border-2 border-transparent outline-none"; + export interface TimerControlsProps { isRunning: boolean; onTogglePlay: () => void; - onEnd: () => void; - onAddTime: () => void; + onOpenEndModal: () => void; + onOpenExtendModal: () => void; } export const TimerControls = ({ isRunning, onTogglePlay, - onEnd, - onAddTime, + onOpenEndModal, + onOpenExtendModal, }: TimerControlsProps) => { + const t = useTranslations("Focus.controls"); + return (
- } - activeIcon={} - label="종료" - onClick={onEnd} - /> + + + + + + + + : } - label={isRunning ? "일시정지" : "재생"} + label={isRunning ? t("pause") : t("play")} variant={isRunning ? "active" : "default"} onClick={onTogglePlay} /> - } - activeIcon={} - label="시간 추가" - onClick={onAddTime} - /> + + + + + + + +
); }; diff --git a/apps/timo-web/components/timer/TimerEndModalPanel.tsx b/apps/timo-web/components/timer/TimerEndModalPanel.tsx new file mode 100644 index 00000000..1434fc5b --- /dev/null +++ b/apps/timo-web/components/timer/TimerEndModalPanel.tsx @@ -0,0 +1,97 @@ +import { Modal, ModalButton } from "@repo/timo-design-system/ui"; +import { useTranslations } from "next-intl"; + +const TimerEndIcon = () => ( + //TODO: SVG를 컴포넌트 머지 시 변경 예정 + + + + + + + + + + +); + +export interface TimerEndModalPanelProps { + onContinue: () => void; + onComplete: () => void; +} + +export const TimerEndModalPanel = ({ + onContinue, + onComplete, +}: TimerEndModalPanelProps) => { + const t = useTranslations("Focus.endModal"); + + return ( + <> + + + + {t("title")} + {t("description")} + + + {t("continueButton")} + + + {t("completeButton")} + + + + ); +}; diff --git a/apps/timo-web/components/timer/TimerExtendModalPanel.tsx b/apps/timo-web/components/timer/TimerExtendModalPanel.tsx new file mode 100644 index 00000000..348957a6 --- /dev/null +++ b/apps/timo-web/components/timer/TimerExtendModalPanel.tsx @@ -0,0 +1,126 @@ +import { Modal } from "@repo/timo-design-system/ui"; +import { cn } from "@repo/timo-design-system/utils"; +import { useTranslations } from "next-intl"; + +export type ExtendTimePreset = 10 | 30 | 60 | "custom"; + +type ExtendPresetLabelKey = + | "presetTenMin" + | "presetThirtyMin" + | "presetOneHour"; + +const PRESET_OPTIONS: { + preset: ExtendTimePreset; + labelKey: ExtendPresetLabelKey; +}[] = [ + { preset: 10, labelKey: "presetTenMin" }, + { preset: 30, labelKey: "presetThirtyMin" }, + { preset: 60, labelKey: "presetOneHour" }, +]; + +const MAX_CUSTOM_MINUTES = 720; + +const CHIP_FOCUS_BORDER_CLASS = + "border-2 border-transparent focus-visible:border-timo-blue-300 outline-none"; + +export interface TimerExtendModalPanelProps { + selectedPreset: ExtendTimePreset | null; + customMinutes: string; + onSelectPreset: (preset: ExtendTimePreset) => void; + onCustomMinutesChange: (value: string) => void; + onClose: () => void; + onSubmit: () => void; + canSubmit: boolean; +} + +export const TimerExtendModalPanel = ({ + selectedPreset, + customMinutes, + onSelectPreset, + onCustomMinutesChange, + onClose, + onSubmit, + canSubmit, +}: TimerExtendModalPanelProps) => { + const t = useTranslations("Focus.extendModal"); + const isCustomSelected = selectedPreset === "custom"; + + return ( + <> + {t("title")} + +
+ {PRESET_OPTIONS.map(({ preset, labelKey }) => ( + + ))} + + {isCustomSelected ? ( +
+ { + const digitsOnly = e.target.value.replace(/\D/g, ""); + const clamped = + digitsOnly === "" + ? "" + : String(Math.min(Number(digitsOnly), MAX_CUSTOM_MINUTES)); + + onCustomMinutesChange(clamped); + }} + aria-label={t("customInputLabel")} + style={{ width: `${Math.max(customMinutes.length, 1)}ch` }} + className="typo-headline-r-14 text-timo-black shrink-0 text-center outline-none" + /> + + {t("customUnitSuffix")} + +
+ ) : ( + + )} +
+ +
+ + {t("closeButton")} + + + {t("submitButton")} + +
+ + ); +}; diff --git a/apps/timo-web/components/timer/TimerSessionControls.tsx b/apps/timo-web/components/timer/TimerSessionControls.tsx new file mode 100644 index 00000000..59518f4a --- /dev/null +++ b/apps/timo-web/components/timer/TimerSessionControls.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Modal } from "@repo/timo-design-system/ui"; +import { forwardRef, useImperativeHandle, useRef, useState } from "react"; + +import { TimerCompleteModalPanel } from "@/components/timer/TimerCompleteModalPanel"; +import { TimerControls } from "@/components/timer/TimerControls"; +import { TimerEndModalPanel } from "@/components/timer/TimerEndModalPanel"; +import { + TimerExtendModalPanel, + type ExtendTimePreset, +} from "@/components/timer/TimerExtendModalPanel"; +import { TimerStopModalPanel } from "@/components/timer/TimerStopModalPanel"; + +type TimerModalStep = "end" | "stop" | "extend" | "complete"; + +export interface TimerSessionControlsHandle { + openStopModal: () => void; +} + +export interface TimerSessionControlsProps { + isRunning: boolean; + onTogglePlay: () => void; + plannedMinutes: number; + actualMinutes: number; + feedbackText?: string; + onExtend: (minutes: number) => void; + onComplete: () => void; +} + +export const TimerSessionControls = forwardRef< + TimerSessionControlsHandle, + TimerSessionControlsProps +>(function TimerSessionControls( + { + isRunning, + onTogglePlay, + plannedMinutes, + actualMinutes, + feedbackText, + onExtend, + onComplete, + }, + ref, +) { + const [step, setStep] = useState("end"); + const stopModalTriggerRef = useRef(null); + + useImperativeHandle(ref, () => ({ + openStopModal: () => { + setStep("stop"); + stopModalTriggerRef.current?.click(); + }, + })); + const [selectedPreset, setSelectedPreset] = useState( + null, + ); + const [customMinutes, setCustomMinutes] = useState(""); + + const resetExtendSelection = () => { + setSelectedPreset(null); + setCustomMinutes(""); + }; + + const handleSubmitExtend = () => { + const minutes = + selectedPreset === "custom" + ? Number(customMinutes) + : (selectedPreset ?? 0); + + if (minutes > 0) onExtend(minutes); + + resetExtendSelection(); + }; + + const canSubmitExtend = + selectedPreset !== null && + (selectedPreset !== "custom" || Number(customMinutes) > 0); + + return ( + + setStep(isRunning ? "stop" : "end")} + onOpenExtendModal={() => { + resetExtendSelection(); + setStep("extend"); + }} + /> + + ); +}); diff --git a/apps/timo-web/components/timer/TimerStopModalPanel.tsx b/apps/timo-web/components/timer/TimerStopModalPanel.tsx new file mode 100644 index 00000000..a11283e9 --- /dev/null +++ b/apps/timo-web/components/timer/TimerStopModalPanel.tsx @@ -0,0 +1,104 @@ +import { Modal, ModalButton } from "@repo/timo-design-system/ui"; +import { useTranslations } from "next-intl"; + +import { formatDurationLabel } from "@/components/timer/durationLabel"; + +const TimerStopIcon = () => ( + //TODO: SVG를 컴포넌트 머지 시 변경 예정 + + + + + + + + + + +); + +export interface TimerStopModalPanelProps { + minutes: number; + onSwitch: () => void; +} + +export const TimerStopModalPanel = ({ + minutes, + onSwitch, +}: TimerStopModalPanelProps) => { + const t = useTranslations("Focus.stopModal"); + const tDuration = useTranslations("Focus.duration"); + const durationLabel = formatDurationLabel( + minutes, + tDuration("hourUnit"), + tDuration("minuteUnit"), + ); + + return ( + <> + + + + {t("title")} + + {t.rich("description", { + minutes: durationLabel, + blue: (chunks) => ( + {chunks} + ), + })} + + + + {t("continueButton")} + + + {t("switchButton")} + + + + ); +}; diff --git a/apps/timo-web/components/timer/durationLabel.ts b/apps/timo-web/components/timer/durationLabel.ts new file mode 100644 index 00000000..bd431720 --- /dev/null +++ b/apps/timo-web/components/timer/durationLabel.ts @@ -0,0 +1,15 @@ +const MINUTES_PER_HOUR = 60; + +export const formatDurationLabel = ( + totalMinutes: number, + hourUnit: string, + minuteUnit: string, +): string => { + const hours = Math.floor(totalMinutes / MINUTES_PER_HOUR); + const minutes = totalMinutes % MINUTES_PER_HOUR; + + if (hours === 0) return `${minutes}${minuteUnit}`; + if (minutes === 0) return `${hours}${hourUnit}`; + + return `${hours}${hourUnit} ${minutes}${minuteUnit}`; +}; diff --git a/apps/timo-web/messages/en.json b/apps/timo-web/messages/en.json index 5084507c..e3569511 100644 --- a/apps/timo-web/messages/en.json +++ b/apps/timo-web/messages/en.json @@ -66,5 +66,47 @@ "login": "Continue with your Google account", "connectCalendar": "Connect Google Calendar" } + }, + "Focus": { + "controls": { + "end": "End", + "pause": "Pause", + "play": "Play", + "extend": "Add Time" + }, + "endModal": { + "title": "Timer Finished!", + "description": "Please select the next step.", + "continueButton": "Continue", + "completeButton": "Exit" + }, + "stopModal": { + "title": "Shall we stop now?", + "description": "The {minutes} completed so far will be recorded in the timebox.", + "continueButton": "Continue", + "switchButton": "Switch" + }, + "completeModal": { + "title": "The task has been completed!", + "defaultFeedback": "You've completed this task. Great work!", + "plannedLabel": "Plan", + "actualLabel": "Actual", + "completeButton": "Complete" + }, + "extendModal": { + "title": "How much time would you like to add?", + "presetTenMin": "+10M", + "presetThirtyMin": "+30M", + "presetOneHour": "+1H", + "customPreset": "Enter", + "customInputLabel": "Custom minutes input", + "customUnitSuffix": "M", + "closeButton": "Close", + "submitButton": "Add Time" + }, + "duration": { + "hourUnit": "h", + "minuteUnit": "m" + } } } diff --git a/apps/timo-web/messages/ko.json b/apps/timo-web/messages/ko.json index ac381cae..048d92d0 100644 --- a/apps/timo-web/messages/ko.json +++ b/apps/timo-web/messages/ko.json @@ -66,5 +66,47 @@ "login": "Google 계정으로 계속하기", "connectCalendar": "Google 캘린더 연결하기" } + }, + "Focus": { + "controls": { + "end": "종료", + "pause": "일시정지", + "play": "재생", + "extend": "시간 추가" + }, + "endModal": { + "title": "타이머 종료!", + "description": "다음 단계를 선택하세요.", + "continueButton": "계속하기", + "completeButton": "완료하기" + }, + "stopModal": { + "title": "지금 멈출까요?", + "description": "지금까지 수행한 {minutes}이 타임박스에 기록됩니다.", + "continueButton": "계속 진행", + "switchButton": "전환하기" + }, + "completeModal": { + "title": "태스크가 완료되었어요!", + "defaultFeedback": "이번 작업을 완료했어요. 수고하셨어요!", + "plannedLabel": "계획", + "actualLabel": "실제", + "completeButton": "완료하기" + }, + "extendModal": { + "title": "시간을 얼마나 추가할까요?", + "presetTenMin": "+10분", + "presetThirtyMin": "+30분", + "presetOneHour": "+1시간", + "customPreset": "직접 입력", + "customInputLabel": "직접 입력 시간(분)", + "customUnitSuffix": "분", + "closeButton": "닫기", + "submitButton": "시간 추가하기" + }, + "duration": { + "hourUnit": "시간", + "minuteUnit": "분" + } } } diff --git a/packages/timo-design-system/src/components/button/modal-button/ModalButton.tsx b/packages/timo-design-system/src/components/button/modal-button/ModalButton.tsx index 055c948b..22cb7e31 100644 --- a/packages/timo-design-system/src/components/button/modal-button/ModalButton.tsx +++ b/packages/timo-design-system/src/components/button/modal-button/ModalButton.tsx @@ -5,8 +5,9 @@ import type { ButtonHTMLAttributes } from "react"; export type ModalButtonVariantTypes = "fill" | "border"; const MODAL_BUTTON_VARIANT: Record = { - fill: "bg-timo-blue-300 text-white", - border: "border border-timo-gray-500 text-timo-black", + fill: "bg-timo-blue-300 text-white focus-visible:ring-timo-blue-300 focus-visible:ring-2 focus-visible:ring-offset-2", + border: + "border border-timo-gray-500 text-timo-black focus-visible:border-timo-blue-300", }; export interface ModalButtonProps extends ButtonHTMLAttributes { @@ -23,7 +24,7 @@ export const ModalButton = ({