From 142a1b6fb8a084aeeb6f0499cd02679053dde5a6 Mon Sep 17 00:00:00 2001 From: Young-do Cho Date: Fri, 6 Sep 2024 16:41:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=BD=80=EB=AA=A8=EB=8F=84=EB=A1=9C=20?= =?UTF-8?q?=EA=BB=8F=EC=BC=B0=20=EB=8C=80=EC=9D=91=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#50)=20#min?= =?UTF-8?q?or?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 뽀모도로 로직을 담은 hook 구현 * 아무것도 없을땐 setInterval 실행하지 않도록 * useInterval로 훅 단순화 * 초과했을때 hook 추가 * 끝나는거 호출할때 이유를 알려주도록 * 목표 시간 초과할때 콜백 추가 * 콜백 인자관련 설명 추가 * 시간 관련 유틸 한파일로 모으기 * 뽀모도로 로직 변경 - 새로고침에도 문제 없도록 - 껏켯에도 그대로 남아있도록 - 시간 초과 했을떄 알림이 가도록 * state로 관리하는 데이터를 카테고리 전체로 변경 * 초기화 빼먹은 곳 적용 * usePomodoro 내부 코드 정리 * 카운트다운 형태로 변경 --- src/renderer/entities/pomodoro/types.ts | 15 + .../features/pomodoro/hooks/use-pomodoro.ts | 181 ++++++++++++ src/renderer/pages/pomodoro.tsx | 277 ++++++++---------- src/renderer/shared/constants/keys.ts | 3 + src/renderer/shared/hooks/index.ts | 1 + src/renderer/shared/hooks/use-interval.ts | 20 ++ .../shared/utils/create-iso-duration.ts | 33 --- src/renderer/shared/utils/index.ts | 3 +- src/renderer/shared/utils/iso-duration.ts | 86 ++++++ .../shared/utils/parse-iso-duration.ts | 33 --- src/renderer/shared/utils/time.ts | 2 + .../widgets/pomodoro/ui/focus-screen.tsx | 20 +- .../widgets/pomodoro/ui/home-screen.tsx | 4 - .../widgets/pomodoro/ui/rest-screen.tsx | 14 +- .../widgets/pomodoro/ui/rest-wait-screen.tsx | 16 +- 15 files changed, 459 insertions(+), 249 deletions(-) create mode 100644 src/renderer/features/pomodoro/hooks/use-pomodoro.ts create mode 100644 src/renderer/shared/hooks/use-interval.ts delete mode 100644 src/renderer/shared/utils/create-iso-duration.ts create mode 100644 src/renderer/shared/utils/iso-duration.ts delete mode 100644 src/renderer/shared/utils/parse-iso-duration.ts diff --git a/src/renderer/entities/pomodoro/types.ts b/src/renderer/entities/pomodoro/types.ts index ae84b30..01ccbdf 100644 --- a/src/renderer/entities/pomodoro/types.ts +++ b/src/renderer/entities/pomodoro/types.ts @@ -9,3 +9,18 @@ export type Pomodoro = { export type PomodoroMode = 'focus' | 'rest-wait' | 'rest'; export type PomodoroNextAction = 'plus' | 'minus'; + +export type PomodoroEndReason = 'manual' | 'exceed'; + +export type PomodoroCycle = { + startAt: number; + endAt?: number; + goalTime: number; + exceedMaxTime: number; + mode: PomodoroMode; +}; + +export type PomodoroTime = { + elapsed: number; + exceeded: number; +}; diff --git a/src/renderer/features/pomodoro/hooks/use-pomodoro.ts b/src/renderer/features/pomodoro/hooks/use-pomodoro.ts new file mode 100644 index 0000000..f8d37e6 --- /dev/null +++ b/src/renderer/features/pomodoro/hooks/use-pomodoro.ts @@ -0,0 +1,181 @@ +import { useLocalStorage } from 'usehooks-ts'; + +import { PomodoroCycle, PomodoroEndReason, PomodoroMode, PomodoroTime } from '@/entities/pomodoro'; +import { LOCAL_STORAGE_KEY } from '@/shared/constants'; +import { useInterval } from '@/shared/hooks'; + +// == usePomodoro 로직에 대한 description + +// 집중 시작 - 시작 시각: Date.now(), 목표시간: 25 * 60 * 1000(25분, 형식 미정), 모드: focus, 끝난시간: Null로 로컬에 저장 + +// 집중 중 - 저장한 값과 현재 시각으로 몇분 지났는지 & 초과/미만인지 표시 +// ㄴ 이 때 초과시간이 특정 시간을 넘어가면(ex, 정해논 시간보다 초과시간이 1시간이 넘어가면) 자동 휴식으로 넘어가기 + +// 휴식 대기 - 집중 끝난시간 값 업데이트. 시작시간: Date.now(), 목표시간: 0, 모드: rest-wait, 끝난시간: null로 저장. + +// 휴식 대기 중 - 저장한 값과 현재 시각으로 몇분 지났는지 확인 +// ㄴ 이 때도 초과시간이 특정 시간을 넘어가면 자동 종료(?)하기 + +// 휴식 시작 - 휴식 대기 끝난시간 없데이트. 휴식 시간, 목표시간, 모드, 끝난시간은 null로 저장. + +// 휴식 중 - 저장한 값과 현재 시각으로 몇분 지났는지 & 초과/미만인지 표시 +// ㄴ 이 때도 초과시간이 특정 시간을 넘어가면 자동 종료하기 + +// 집중 전환 - 휴식 끝난시간 값 업데이트 & … 계속 반복 + +// 뽀모도로 종료 - 저장된 값 list를 서버에 전달(물론 전달하기 전 변환 필수). 전달 성공시 저장된 값 초기화 + +// 강제 종료 - 뽀모도로 종료 로직 실행(단, 마지막 끝난시간은 강제 종료시점으로 해서) + +export type UsePomodoroParams = { + /** 집중시간. (단위는 ms) */ + focusTime: number; + /** 집중 초과 최대시간. (단위는 ms) */ + focusExceedMaxTime: number; + /** 휴식대기 초과 최대시간. (단위는 ms) */ + restWaitExceedMaxTime: number; + /** 휴식시간. (단위는 ms) */ + restTime: number; + /** 휴식 초과 최대시간. (단위는 ms) */ + restExceedMaxTime: number; + /** 뽀모도로 종료시 실행되는 콜백 */ + onEndPomodoro: (cycles: PomodoroCycle[], reason: PomodoroEndReason) => void; + /** 목표시간 초과시 한번만 실행되는 콜백 */ + onceExceedGoalTime?: (mode: PomodoroMode) => void; +}; + +const isNotNil = (value: T): value is NonNullable => value !== null && value !== undefined; + +const updateCycles = (cycles: PomodoroCycle[], nextCycle?: PomodoroCycle): PomodoroCycle[] => { + const prevCycles = cycles.slice(0, -1); + const lastCycle = cycles[cycles.length - 1] as PomodoroCycle | undefined; + + if (lastCycle?.mode === nextCycle?.mode) { + throw new Error('Invalid mode cycle'); + } + + return [ + ...prevCycles, + lastCycle && { + ...lastCycle, + endAt: Date.now(), + }, + nextCycle, + ].filter(isNotNil); +}; + +export const getPomodoroTime = (cycle: PomodoroCycle): PomodoroTime => { + const now = cycle.endAt ?? Date.now(); + const elapsed = now - cycle.startAt; + const exceeded = elapsed - cycle.goalTime; + + return { elapsed, exceeded }; +}; + +const defaultPomodoroTime: PomodoroTime = { elapsed: 0, exceeded: 0 }; + +export const usePomodoro = ({ + focusTime, + focusExceedMaxTime, + restWaitExceedMaxTime, + restTime, + restExceedMaxTime, + onEndPomodoro, + onceExceedGoalTime, +}: UsePomodoroParams) => { + const [pomodoroCycles, setPomodoroCycles] = useLocalStorage( + LOCAL_STORAGE_KEY.POMODORO_CYCLES, + [], + ); + const [pomodoroTime, setPomodoroTime] = useLocalStorage( + LOCAL_STORAGE_KEY.POMODORO_TIME, + defaultPomodoroTime, + ); + const [calledOnceForExceedGoalTime, setCalledOnceForExceedGoalTime] = useLocalStorage( + LOCAL_STORAGE_KEY.POMODORO_CALLED_ONCE_FOR_EXCEED_TIME, + false, + ); + + const startFocus = () => { + const nextCycles = updateCycles(pomodoroCycles, { + startAt: Date.now(), + goalTime: focusTime, + exceedMaxTime: focusExceedMaxTime, + mode: 'focus', + }); + setPomodoroCycles(nextCycles); + setPomodoroTime(defaultPomodoroTime); + setCalledOnceForExceedGoalTime(false); + }; + + const startRestWait = () => { + const nextCycles = updateCycles(pomodoroCycles, { + startAt: Date.now(), + goalTime: 0, + exceedMaxTime: restWaitExceedMaxTime, + mode: 'rest-wait', + }); + setPomodoroCycles(nextCycles); + setPomodoroTime(defaultPomodoroTime); + setCalledOnceForExceedGoalTime(false); + }; + + const startRest = () => { + const nextCycles = updateCycles(pomodoroCycles, { + startAt: Date.now(), + goalTime: restTime, + exceedMaxTime: restExceedMaxTime, + mode: 'rest', + }); + setPomodoroCycles(nextCycles); + setPomodoroTime(defaultPomodoroTime); + setCalledOnceForExceedGoalTime(false); + }; + + const endPomodoro = (reason: PomodoroEndReason = 'manual') => { + const endedCycles = updateCycles(pomodoroCycles); + onEndPomodoro(endedCycles, reason); + + // 상위로 전달했으니 cycle 데이터 초기화 + setPomodoroCycles([]); + setPomodoroTime(defaultPomodoroTime); + setCalledOnceForExceedGoalTime(false); + }; + + useInterval( + () => { + const currentCycle = pomodoroCycles[pomodoroCycles.length - 1]; + if (!currentCycle) return; + + const { elapsed, exceeded } = getPomodoroTime(currentCycle); + setPomodoroTime({ elapsed, exceeded }); + + if (exceeded > 0 && !calledOnceForExceedGoalTime) { + onceExceedGoalTime?.(currentCycle.mode); + setCalledOnceForExceedGoalTime(true); + } + + if (exceeded >= currentCycle.exceedMaxTime) { + if (currentCycle.mode === 'focus') { + startRestWait(); + } + if (currentCycle.mode === 'rest-wait') { + endPomodoro('exceed'); + } + if (currentCycle.mode === 'rest') { + endPomodoro('exceed'); + } + } + }, + pomodoroCycles.length > 0 ? 250 : null, + ); + + return { + pomodoroCycles, + pomodoroTime, + startFocus, + startRestWait, + startRest, + endPomodoro, + }; +}; diff --git a/src/renderer/pages/pomodoro.tsx b/src/renderer/pages/pomodoro.tsx index 331936c..5d59f1d 100644 --- a/src/renderer/pages/pomodoro.tsx +++ b/src/renderer/pages/pomodoro.tsx @@ -1,21 +1,27 @@ import { useEffect, useState } from 'react'; -import { useLocalStorage } from 'usehooks-ts'; - import { PomodoroMode, PomodoroNextAction } from '@/entities/pomodoro'; import { useCategories, useUpdateCategory } from '@/features/category'; import { useAddPomodoro } from '@/features/pomodoro'; +import { getPomodoroTime, usePomodoro } from '@/features/pomodoro/hooks/use-pomodoro'; import { TimeoutDialog } from '@/features/pomodoro/ui/timeout-dialog'; import { useFocusNotification } from '@/features/time'; import { useUser } from '@/features/user'; -import { LOCAL_STORAGE_KEY, MINUTES_GAP } from '@/shared/constants'; -import { useDisclosure, useTimer } from '@/shared/hooks'; -import { createIsoDuration, minutesToMs, msToTime, parseIsoDuration } from '@/shared/utils'; +import { MINUTES_GAP } from '@/shared/constants'; +import { useDisclosure } from '@/shared/hooks'; +import { useToast } from '@/shared/ui'; +import { + createIsoDuration, + isoDurationToMs, + minutesToMs, + msToIsoDuration, + msToMinutes, +} from '@/shared/utils'; import { FocusScreen, HomeScreen, RestScreen, RestWaitScreen } from '@/widgets/pomodoro'; -const END_TIME_ON_FOCUS_PAGE = -minutesToMs(60); -const END_TIME_ON_REST_WAIT_PAGE = -minutesToMs(60); -const END_TIME_ON_REST_PAGE = -minutesToMs(30); +const focusExceedMaxTime = minutesToMs(60); +const restWaitExceedMaxTime = minutesToMs(60); +const restExceedMaxTime = minutesToMs(30); const timeoutMessageMap: Record< Exclude, @@ -32,129 +38,91 @@ const timeoutMessageMap: Record< }; const Pomodoro = () => { - const [selectedNextAction, setSelectedNextAction] = useState(); + const { createNotificationByMode } = useFocusNotification(); + const { toast } = useToast(); + const [selectedNextAction, setSelectedNextAction] = useState(); const [timeoutMode, setTimeoutMode] = useState | null>(null); - const [mode, setMode] = useLocalStorage(LOCAL_STORAGE_KEY.MODE, null); - - // 단위 ms - const [focusedTime, setFocusedTime] = useLocalStorage(LOCAL_STORAGE_KEY.FOCUSED_TIME, 0); + const timeoutDialogProps = useDisclosure(); const { data: categories } = useCategories(); const { data: user } = useUser(); - const { mutate: _addPomodoro } = useAddPomodoro(); const { mutate: updateCategory } = useUpdateCategory(); + const { mutate: savePomodoro } = useAddPomodoro(); - const { createNotificationByMode } = useFocusNotification(); - const timeoutDialogProps = useDisclosure(); - + const [currentCategory, setCurrentCategory] = useState(categories?.[0]); + const currentCategoryTitle = currentCategory?.title || ''; useEffect(() => { - setCurrentCategory(categories?.[0].title ?? ''); + setCurrentCategory(categories?.[0]); }, [categories]); - const [currentCategory, setCurrentCategory] = useState(categories?.[0].title ?? ''); - const categoryData = categories?.find((category) => category.title === currentCategory); - - const currentRestMinutes = - parseIsoDuration(categoryData?.restTime).hours * 60 + - parseIsoDuration(categoryData?.restTime).minutes; - const currentFocusMinutes = - parseIsoDuration(categoryData?.focusTime).hours * 60 + - parseIsoDuration(categoryData?.focusTime).minutes; - - const [initialTime, setInitialTime] = useState(minutesToMs(currentFocusMinutes)); - const [endTime, setEndTime] = useState(END_TIME_ON_FOCUS_PAGE); // 끝나는 시간 - - useEffect(() => { - setInitialTime(minutesToMs(currentFocusMinutes)); - }, [categoryData]); - - const { time, start, stop } = useTimer(initialTime, endTime, { - onStop: () => { - if (mode === 'focus') { - createNotificationByMode(user?.cat?.type ?? 'CHEESE', 'focus-end'); - setInitialTime(0); - setEndTime(END_TIME_ON_REST_WAIT_PAGE); - return; - } - if (mode === 'rest') { - createNotificationByMode(user?.cat?.type ?? 'CHEESE', 'rest-end'); - setInitialTime(minutesToMs(currentFocusMinutes)); - setEndTime(END_TIME_ON_FOCUS_PAGE); - } - }, - onFinish: () => { - if (mode === 'focus') { - // 데이터 저장 이후, - // 초기 값 변경 이후 - // 휴식 대기 화면으로 강제 이동 - setFocusedTime(minutesToMs(currentFocusMinutes) - endTime); - setInitialTime(0); - setEndTime(END_TIME_ON_REST_WAIT_PAGE); - setMode('rest-wait'); - return; - } - if (mode === 'rest-wait') { - // 데이터 저장 이후, - // 초기 값 변경 이후 - // 홈 화면으로 강제 이동 - if (categoryData?.no) { - addPomodoro(focusedTime, 0); + const currentFocusTime = isoDurationToMs(currentCategory?.focusTime); + const currentRestTime = isoDurationToMs(currentCategory?.restTime); + + const { pomodoroCycles, pomodoroTime, startFocus, startRestWait, startRest, endPomodoro } = + usePomodoro({ + focusTime: currentFocusTime, + focusExceedMaxTime, + restWaitExceedMaxTime, + restTime: currentRestTime, + restExceedMaxTime, + onceExceedGoalTime: (mode) => { + if (!user?.cat?.type) return; + // 목표시간 초과 시 알림 + if (mode === 'focus') return createNotificationByMode(user.cat.type, 'focus-end'); + if (mode === 'rest') return createNotificationByMode(user.cat.type, 'rest-end'); + }, + onEndPomodoro: (cycles, reason) => { + console.log('Pomodoro cycles:', cycles); + + let focusedTime = 0; + let restedTime = 0; + + cycles.forEach((cycle) => { + const time = getPomodoroTime(cycle); + if (cycle.mode === 'focus') + focusedTime += Math.min(time.elapsed, cycle.goalTime + cycle.exceedMaxTime); + if (cycle.mode === 'rest') + restedTime += Math.min(time.elapsed, cycle.goalTime + cycle.exceedMaxTime); + }); + + if (focusedTime < 1000 * 60) { + return toast({ message: '1분 미만의 집중 시간은 저장되지 않아요', iconName: 'clock' }); } - setInitialTime(minutesToMs(currentRestMinutes)); - setEndTime(END_TIME_ON_REST_PAGE); - setMode(null); - // @TODO: 모달 띄워주기 - setTimeoutMode('rest-wait'); - timeoutDialogProps.onOpen(); - return; - } - if (mode === 'rest') { - // 데이터 저장 이후, - // 초기 값 변경 이후 - // 홈 화면으로 강제 이동 - if (categoryData?.no) { - addPomodoro(focusedTime, minutesToMs(currentRestMinutes) - endTime); + + const lastCycleMode = cycles.at(-1)?.mode; + if (reason === 'exceed' && lastCycleMode && lastCycleMode !== 'focus') { + setTimeoutMode(lastCycleMode); + timeoutDialogProps.onOpen(); } - setInitialTime(minutesToMs(currentFocusMinutes)); - setEndTime(END_TIME_ON_FOCUS_PAGE); - setTimeoutMode('rest'); - timeoutDialogProps.onOpen(); - setMode(null); - } - }, - }); - useEffect(() => { - if (!mode) { - setInitialTime(minutesToMs(currentFocusMinutes)); - setFocusedTime(0); - setEndTime(END_TIME_ON_FOCUS_PAGE); - return; - } - start(); - }, [mode]); + if (currentCategory) { + savePomodoro({ + body: [ + { + clientFocusTimeId: Date.now().toString(), + categoryNo: currentCategory.no, + focusedTime: msToIsoDuration(focusedTime), + restedTime: msToIsoDuration(restedTime), + doneAt: new Date().toISOString(), + }, + ], + }); + } + }, + }); + const mode = pomodoroCycles.at(-1)?.mode; + const latestFocusCycle = pomodoroCycles.findLast((cycle) => cycle.mode === 'focus'); + const latestFocusTime = latestFocusCycle ? getPomodoroTime(latestFocusCycle) : null; - const addPomodoro = (focusedTime: number, restedTime: number) => { - if (categoryData?.no) { - _addPomodoro({ - body: [ - { - clientFocusTimeId: `${user?.registeredDeviceNo}-${new Date().toISOString()}`, - categoryNo: categoryData?.no, - focusedTime: createIsoDuration(msToTime(focusedTime)), - restedTime: createIsoDuration(msToTime(restedTime)), - doneAt: new Date().toISOString(), - }, - ], - }); - } - }; + const currentFocusMinutes = msToMinutes(currentFocusTime); + const currentRestMinutes = msToMinutes(currentRestTime); const updateCategoryTime = (type: 'focusTime' | 'restTime', currentMinutes: number) => { - if (!selectedNextAction || !categoryData?.no) return; + if (!selectedNextAction || !currentCategory) return; + updateCategory({ - no: categoryData?.no, + no: currentCategory.no, body: { [type]: createIsoDuration({ minutes: @@ -167,67 +135,59 @@ const Pomodoro = () => { setSelectedNextAction(undefined); }; - if (mode === 'rest') + if (mode === 'focus') return ( - { - updateCategoryTime('restTime', currentRestMinutes); - stop(); - addPomodoro(focusedTime, minutesToMs(currentRestMinutes) - time); - setMode('focus'); + { + startRestWait(); }} handleEnd={() => { - updateCategoryTime('restTime', currentRestMinutes); - stop(); - addPomodoro(focusedTime, minutesToMs(currentRestMinutes) - time); - setMode(null); + endPomodoro(); }} /> ); + if (mode === 'rest-wait') return ( { updateCategoryTime('focusTime', currentFocusMinutes); - setInitialTime(minutesToMs(currentRestMinutes)); - setEndTime(END_TIME_ON_REST_PAGE); - stop(); - setMode('rest'); + startRest(); }} handleEnd={() => { updateCategoryTime('focusTime', currentFocusMinutes); - stop(); - addPomodoro(focusedTime, 0); - setMode(null); + endPomodoro(); }} /> ); - if (mode === 'focus') + + if (mode === 'rest') return ( - { - setInitialTime(0); - setEndTime(END_TIME_ON_REST_WAIT_PAGE); - stop(); - setFocusedTime(minutesToMs(currentFocusMinutes) - time); - setMode('rest-wait'); + { + updateCategoryTime('restTime', currentRestMinutes); + startFocus(); }} handleEnd={() => { - stop(); - addPomodoro(minutesToMs(currentFocusMinutes) - time, 0); - setMode(null); + updateCategoryTime('restTime', currentRestMinutes); + endPomodoro(); }} /> ); @@ -235,12 +195,13 @@ const Pomodoro = () => { return ( <> { + setCurrentCategory(categories?.find((category) => category.title === title)); + }} + currentFocusMinutes={msToMinutes(currentFocusTime)} + currentRestMinutes={msToMinutes(currentRestTime)} /> {timeoutMode && ( void, delay: number | null) => { + const savedCallback = useRef(callback); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + function tick() { + savedCallback.current(); + } + if (delay !== null) { + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay]); +}; diff --git a/src/renderer/shared/utils/create-iso-duration.ts b/src/renderer/shared/utils/create-iso-duration.ts deleted file mode 100644 index 79fdbac..0000000 --- a/src/renderer/shared/utils/create-iso-duration.ts +++ /dev/null @@ -1,33 +0,0 @@ -type params = { - years?: number; - months?: number; - days?: number; - hours?: number; - minutes?: number; - seconds?: number; -}; - -export const createIsoDuration = ({ - years = 0, - months = 0, - days = 0, - hours = 0, - minutes = 0, - seconds = 0, -}: params): string => { - const parts: string[] = []; - if (years > 0) parts.push(`${years}Y`); - if (months > 0) parts.push(`${months}M`); - if (days > 0) parts.push(`${days}D`); - - const timeParts: string[] = []; - if (hours > 0) timeParts.push(`${hours}H`); - if (minutes > 0) timeParts.push(`${minutes}M`); - if (seconds > 0) timeParts.push(`${seconds}S`); - - if (timeParts.length > 0) { - parts.push('T' + timeParts.join('')); - } - - return parts.length > 0 ? 'P' + parts.join('') : 'PT0S'; -}; diff --git a/src/renderer/shared/utils/index.ts b/src/renderer/shared/utils/index.ts index 300f146..d28cfd2 100644 --- a/src/renderer/shared/utils/index.ts +++ b/src/renderer/shared/utils/index.ts @@ -1,8 +1,7 @@ export * from './cn'; export * from './url'; export * from './storage'; -export * from './parse-iso-duration'; export * from './icon'; export * from './string'; -export * from './create-iso-duration'; export * from './time'; +export * from './iso-duration'; diff --git a/src/renderer/shared/utils/iso-duration.ts b/src/renderer/shared/utils/iso-duration.ts new file mode 100644 index 0000000..7fe1496 --- /dev/null +++ b/src/renderer/shared/utils/iso-duration.ts @@ -0,0 +1,86 @@ +export type Duration = { + years: number; + months: number; + days: number; + hours: number; + minutes: number; + seconds: number; +}; + +const isoDurationRegex = + /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/; +const defaultDuration: Duration = { + years: 0, + months: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, +}; + +export const parseIsoDuration = (isoDuration?: string) => { + if (!isoDuration) return defaultDuration; + + const matches = isoDuration.match(isoDurationRegex); + if (!matches) return defaultDuration; + + const years = Number(matches[1]) || 0; + const months = Number(matches[2]) || 0; + const days = Number(matches[3]) || 0; + const hours = Number(matches[4]) || 0; + const minutes = Number(matches[5]) || 0; + const seconds = Number(matches[6]) || 0; + + return { + years, + months, + days, + hours, + minutes, + seconds, + }; +}; + +export const createIsoDuration = ({ + years = 0, + months = 0, + days = 0, + hours = 0, + minutes = 0, + seconds = 0, +}: Partial): string => { + const parts: string[] = []; + if (years > 0) parts.push(`${years}Y`); + if (months > 0) parts.push(`${months}M`); + if (days > 0) parts.push(`${days}D`); + + const timeParts: string[] = []; + if (hours > 0) timeParts.push(`${hours}H`); + if (minutes > 0) timeParts.push(`${minutes}M`); + if (seconds > 0) timeParts.push(`${seconds}S`); + + if (timeParts.length > 0) { + parts.push('T' + timeParts.join('')); + } + + return parts.length > 0 ? 'P' + parts.join('') : 'PT0S'; +}; + +export const msToIsoDuration = (ms: number): string => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + return createIsoDuration({ + days: days % 365, + hours: hours % 24, + minutes: minutes % 60, + seconds: seconds % 60, + }); +}; + +export const isoDurationToMs = (isoDuration?: string) => { + const { hours, minutes, seconds } = parseIsoDuration(isoDuration); + return hours * 60 * 60 * 1000 + minutes * 60 * 1000 + seconds * 1000; +}; diff --git a/src/renderer/shared/utils/parse-iso-duration.ts b/src/renderer/shared/utils/parse-iso-duration.ts deleted file mode 100644 index e71b186..0000000 --- a/src/renderer/shared/utils/parse-iso-duration.ts +++ /dev/null @@ -1,33 +0,0 @@ -const isoRegex = /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/; - -const defaultDuration = { - years: 0, - months: 0, - days: 0, - hours: 0, - minutes: 0, - seconds: 0, -}; - -export const parseIsoDuration = (isoDuration?: string) => { - if (!isoDuration) return defaultDuration; - - const matches = isoDuration.match(isoRegex); - if (!matches) return defaultDuration; - - const years = Number(matches[1]) || 0; - const months = Number(matches[2]) || 0; - const days = Number(matches[3]) || 0; - const hours = Number(matches[4]) || 0; - const minutes = Number(matches[5]) || 0; - const seconds = Number(matches[6]) || 0; - - return { - years, - months, - days, - hours, - minutes, - seconds, - }; -}; diff --git a/src/renderer/shared/utils/time.ts b/src/renderer/shared/utils/time.ts index 512f546..e58c4e8 100644 --- a/src/renderer/shared/utils/time.ts +++ b/src/renderer/shared/utils/time.ts @@ -6,3 +6,5 @@ export const msToTime = (ms: number) => { }; export const minutesToMs = (minutes: number) => minutes * 60 * 1000; + +export const msToMinutes = (ms: number) => Math.floor(ms / 1000 / 60); diff --git a/src/renderer/widgets/pomodoro/ui/focus-screen.tsx b/src/renderer/widgets/pomodoro/ui/focus-screen.tsx index b366d5a..5556bd0 100644 --- a/src/renderer/widgets/pomodoro/ui/focus-screen.tsx +++ b/src/renderer/widgets/pomodoro/ui/focus-screen.tsx @@ -7,7 +7,9 @@ import { cn, getCategoryIconName, msToTime } from '@/shared/utils'; type FocusScreenProps = { currentCategory: string; - time: number; + currentFocusTime: number; + elapsedTime: number; + exceededTime: number; handleRest: () => void; handleEnd: () => void; }; @@ -17,11 +19,17 @@ const toolTipContentMap: Record = { exceed: '이제 나랑 놀자냥!', }; -export const FocusScreen = ({ currentCategory, time, handleRest, handleEnd }: FocusScreenProps) => { - const { minutes, seconds } = msToTime(time > 0 ? time : 0); - const { minutes: exceedMinutes, seconds: exceedSeconds } = msToTime(time < 0 ? -time : 0); - - const isExceed = time < 0; +export const FocusScreen = ({ + currentCategory, + currentFocusTime, + elapsedTime, + exceededTime, + handleRest, + handleEnd, +}: FocusScreenProps) => { + const isExceed = exceededTime > 0; + const { minutes, seconds } = msToTime(currentFocusTime - elapsedTime); + const { minutes: exceedMinutes, seconds: exceedSeconds } = msToTime(exceededTime); const { data: user } = useUser(); const { RiveComponent, clickCatInput } = useRiveCat({ diff --git a/src/renderer/widgets/pomodoro/ui/home-screen.tsx b/src/renderer/widgets/pomodoro/ui/home-screen.tsx index 4ec00b8..a57916f 100644 --- a/src/renderer/widgets/pomodoro/ui/home-screen.tsx +++ b/src/renderer/widgets/pomodoro/ui/home-screen.tsx @@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom'; import { useLocalStorage } from 'usehooks-ts'; import { CatType } from '@/entities/cat'; -import { PomodoroMode } from '@/entities/pomodoro'; import { useCategories, useUpdateCategory, ChangeCategoryDrawer } from '@/features/category'; import { ChangeTimeDialog } from '@/features/time'; import { useUser } from '@/features/user'; @@ -29,7 +28,6 @@ const getTooltipMessages = (catType?: CatType) => { }; type HomeScreenProps = { - setMode: (mode: PomodoroMode) => void; startTimer: () => void; currentCategory: string; setCurrentCategory: (category: string) => void; @@ -38,7 +36,6 @@ type HomeScreenProps = { }; export const HomeScreen = ({ - setMode, startTimer, currentCategory, setCurrentCategory, @@ -144,7 +141,6 @@ export const HomeScreen = ({ className="p-[28px]" size="icon" onClick={() => { - setMode('focus'); startTimer(); }} > diff --git a/src/renderer/widgets/pomodoro/ui/rest-screen.tsx b/src/renderer/widgets/pomodoro/ui/rest-screen.tsx index f23eebe..be6ffa6 100644 --- a/src/renderer/widgets/pomodoro/ui/rest-screen.tsx +++ b/src/renderer/widgets/pomodoro/ui/rest-screen.tsx @@ -9,7 +9,9 @@ import { cn, getCategoryIconName, msToTime } from '@/shared/utils'; type RestScreenProps = { currentCategory: string; - time: number; + currentRestTime: number; + elapsedTime: number; + exceededTime: number; currentRestMinutes: number; selectedNextAction: PomodoroNextAction | undefined; setSelectedNextAction: (nextAction: PomodoroNextAction) => void; @@ -19,16 +21,18 @@ type RestScreenProps = { export const RestScreen = ({ currentCategory, - time, + currentRestTime, + elapsedTime, + exceededTime, currentRestMinutes, selectedNextAction, setSelectedNextAction, handleFocus, handleEnd, }: RestScreenProps) => { - const isExceed = time < 0; - const { minutes, seconds } = msToTime(!isExceed ? time : 0); - const { minutes: exceedMinutes, seconds: exceedSeconds } = msToTime(isExceed ? -time : 0); + const isExceed = exceededTime > 0; + const { minutes, seconds } = msToTime(currentRestTime - elapsedTime); + const { minutes: exceedMinutes, seconds: exceedSeconds } = msToTime(exceededTime); const { data: user } = useUser(); const { RiveComponent, clickCatInput } = useRiveCat({ diff --git a/src/renderer/widgets/pomodoro/ui/rest-wait-screen.tsx b/src/renderer/widgets/pomodoro/ui/rest-wait-screen.tsx index f5ad2f7..7689747 100644 --- a/src/renderer/widgets/pomodoro/ui/rest-wait-screen.tsx +++ b/src/renderer/widgets/pomodoro/ui/rest-wait-screen.tsx @@ -7,12 +7,13 @@ import completeFocusLottie from '@/shared/assets/lotties/loti_complete_focus.jso import particleLottie from '@/shared/assets/lotties/loti_particle.json?url'; import { MAX_FOCUS_MINUTES, MIN_FOCUS_MINUTES, MINUTES_GAP } from '@/shared/constants'; import { Button, Icon, SelectGroup, SelectGroupItem } from '@/shared/ui'; -import { minutesToMs, msToTime } from '@/shared/utils'; +import { msToTime } from '@/shared/utils'; type RestWaitScreenProps = { currentCategory: string; currentFocusMinutes: number; - time: number; + elapsedTime: number; + exceededTime: number; handleRest: () => void; handleEnd: () => void; selectedNextAction: PomodoroNextAction | undefined; @@ -22,18 +23,17 @@ type RestWaitScreenProps = { export const RestWaitScreen = ({ currentCategory, currentFocusMinutes, - time, // 전체 경과한 시간 + elapsedTime, + exceededTime, handleRest, handleEnd, selectedNextAction, setSelectedNextAction, }: RestWaitScreenProps) => { // 만약 전체 경과한 시간이 설정한 focusTime 보다 크면 초과 - const isExceed = time > minutesToMs(currentFocusMinutes); - const { minutes, seconds } = msToTime(isExceed ? minutesToMs(currentFocusMinutes) : time); - const { minutes: exceedMinutes, seconds: exceedSeconds } = msToTime( - isExceed ? time - minutesToMs(currentFocusMinutes) : 0, - ); + const isExceed = exceededTime > 0; + const { minutes, seconds } = msToTime(elapsedTime); + const { minutes: exceedMinutes, seconds: exceedSeconds } = msToTime(exceededTime); return (