Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 예외 케이스 : 타이머 상세 페이지 [미션 중일 때 앱을 껐다 킬 경우] 대응 #297

Merged
merged 8 commits into from
Jan 15, 2024
32 changes: 32 additions & 0 deletions src/app/home/home.hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useGetMissions } from '@/apis/mission';
import { type MissionItemType, MissionStatus } from '@/apis/schema/mission';
import { useSnackBar } from '@/components/SnackBar/SnackBarProvider';
import { ROUTER } from '@/constants/router';
import { STORAGE_KEY } from '@/constants/storage';

const INIT_SIZE = 10;

Expand All @@ -10,10 +13,39 @@ export const useMissions = () => {
const missionList = data?.content ?? [];

useRequireMission(data?.content);
useLeaveMissionCheck();

return { missionList, isLoading };
};

// 스톱워치 부분에서 예기치 못하게 종료된 미션 기록 확인
const useLeaveMissionCheck = () => {
const router = useRouter();
const { triggerSnackBar } = useSnackBar();

const checkLeaveMission = () => {
const startedMissionId = localStorage.getItem(STORAGE_KEY.STOPWATCH.MISSION_ID);

if (startedMissionId) {
triggerSnackBar({
variant: 'text-button',
message: '인증을 완료해 주세요!',
buttonText: '바로가기',
offset: 'appBar',
onButtonClick: () => {
router.push(ROUTER.MISSION.STOP_WATCH(startedMissionId));
},
});
}
};

useEffect(() => {
checkLeaveMission();
console.log('useLeaveMissionCheck: ', useLeaveMissionCheck);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 친구 테스트용이라면 제거되어도 될것 같은데 맞게 이해한 것일지 궁금하빈다~

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 아직은 있는게 좋을 것 같아요!
일단 잘 되는지 확인 테스트는, 나중에나 가능할 것 같아서요!

}, []);
};

// 미션 임시 인증만 진행, 미션 인증을 진행하지 않은 경우
const useRequireMission = (missionList?: MissionItemType[]) => {
const { triggerSnackBar } = useSnackBar();

Expand Down
21 changes: 10 additions & 11 deletions src/app/mission/[id]/stopwatch/index.hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { EVENT_LOG_CATEGORY, EVENT_LOG_NAME } from '@/constants/eventLog';
import { STORAGE_KEY } from '@/constants/storage';
import useInterval from '@/hooks/useInterval';
Expand All @@ -12,29 +12,29 @@ export const useGetCategory = () => {
};

export function useUnloadAction(time: number) {
const onSaveTime = () => {
const onSaveTime = useCallback(() => {
eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.MID_SAVE, EVENT_LOG_CATEGORY.STOPWATCH, { time });
localStorage.setItem(STORAGE_KEY.STOPWATCH.TIME, String(time));
};
}, [time]);

useVisibilityState(onSaveTime);
}

function useVisibilityState(onAction: VoidFunction) {
useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
onAction();
}
};
const onVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden') {
onAction();
}
}, [onAction]);

useEffect(() => {
document.addEventListener('visibilitychange', onVisibilityChange);

// 컴포넌트가 언마운트될 때 이벤트 리스너를 제거합니다.
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, []); // 빈 의존성 배열을 전달하여 이 훅이 컴포넌트가 마운트되거나 언마운트될 때만 실행되도록 합니다.
}, [onVisibilityChange]); // 빈 의존성 배열을 전달하여 이 훅이 컴포넌트가 마운트되거나 언마운트될 때만 실행되도록 합니다.
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의존성 배열에 onVisibilityChange 값이 들어가게된 이유가 궁금해요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

들어가지 않는다면,
좀 복잡하지만 😅 메소드 호출을 따라 뭘 부르는지 확인해보면 onVisibilityChange > onAction > onSaveTime인데,
여기 onSaveTime부분에서 time을 저장하게 되는데, 이때 onVisibilityChange를 의존성 배열에 추가하지 않는다면 time이 초기 시점인 0으로 고정되어 time이 계속 0이고, 스토리지에는 계속 0만이 저장되게 됩니다.

이러한 이유로 의존성 배열에 추가하였어요


export function useRecordMidTime(time: number) {
Expand All @@ -43,7 +43,6 @@ export function useRecordMidTime(time: number) {
localStorage.setItem(STORAGE_KEY.STOPWATCH.TIME_2, String(time));
};

// 카운터 속도 증가
useInterval(() => {
onSaveTime();
}, 10000);
Expand Down
25 changes: 21 additions & 4 deletions src/app/mission/[id]/stopwatch/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default function StopwatchPage() {
const category = useGetCategory();

const { step, prevStep, stepLabel, onNextStep } = useStopwatchStatus();
const { seconds, minutes, stepper, isFinished } = useStopwatch(step);
const { seconds, minutes, stepper, isFinished, isPending: isStopwatchPending } = useStopwatch(step);

const time = Number(minutes) * 60 + Number(seconds);
const logData = {
Expand All @@ -46,15 +46,24 @@ export default function StopwatchPage() {

useCustomBack(openMidOutModal);

useUnloadAction(time);
useRecordMidTime(time);
useUnloadAction(time);

const resetStopwatchStorage = () => {
localStorage.removeItem(STORAGE_KEY.STOPWATCH.MISSION_ID);
localStorage.removeItem(STORAGE_KEY.STOPWATCH.TIME);
localStorage.removeItem(STORAGE_KEY.STOPWATCH.TIME_2);
localStorage.removeItem(STORAGE_KEY.STOPWATCH.START_TIME);
};

// isError 처리 어떻게 할것인지?
const { mutate, isPending: isSubmitLoading } = useRecordTime({
onSuccess: (response) => {
const missionRecordId = String(response.missionId);
router.replace(ROUTER.RECORD.CREATE(missionRecordId));
eventLogger.logEvent('api/record-time', 'stopwatch', { missionRecordId });

resetStopwatchStorage();
},
onError: (error) => {
// TODO
Expand Down Expand Up @@ -101,6 +110,7 @@ export default function StopwatchPage() {
// 뒤로가기 버튼 눌렀을 때
const onExit = () => {
router.push(ROUTER.MISSION.DETAIL(missionId));
resetStopwatchStorage();
};

const onFinish = () => {
Expand Down Expand Up @@ -134,9 +144,16 @@ export default function StopwatchPage() {
};

const onStart = () => {
eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_START, EVENT_LOG_CATEGORY.STOPWATCH, { category });
onNextStep('progress');
// 중도 재시작
if (time > 0) {
eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_RESTART, EVENT_LOG_CATEGORY.STOPWATCH);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

중도 재시작시에는 로그만 쏜다 가 쉽게 이해가 안가서 궁금해요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에 시작하는 로직인 onNextStep('progress'); 있습니다!
중도 시작이 아닌, 시작의 경우에는 밑과 같은 플로우가 실행됩니다

  • 시작 로그를 쏘고
  • 시작한 미션의 id를 스토리지에 저장하고,
  • 미션 시작 시간을 스토리지에 저장

return;
}
// 초기시작
eventLogger.logEvent(EVENT_LOG_NAME.STOPWATCH.CLICK_START, EVENT_LOG_CATEGORY.STOPWATCH);
const startTime = new Date().toISOString();
localStorage.setItem(STORAGE_KEY.STOPWATCH.MISSION_ID, missionId);
localStorage.setItem(STORAGE_KEY.STOPWATCH.START_TIME, startTime);
};

Expand Down Expand Up @@ -166,7 +183,7 @@ export default function StopwatchPage() {
</section>
<section className={buttonContainerCss}>
{step === 'ready' && (
<Button variant="cta" size="large" type="button" onClick={onStart}>
<Button variant="cta" size="large" type="button" onClick={onStart} disabled={isStopwatchPending}>
시작
</Button>
)}
Expand Down
1 change: 1 addition & 0 deletions src/constants/eventLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const EVENT_LOG_NAME: Record<EventLogCategoryType, Record<string, string>
CLICK_CANCEL: 'click/cancel',
CLICK_STOP: 'click/stop',
CLICK_START: 'click/start',
CLICK_RESTART: 'click/restart',
MID_SAVE: 'mid-save',
MID_SAVE_2: 'mid-save-2',
CLICK_BACK: 'click/back',
Expand Down
1 change: 1 addition & 0 deletions src/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export const STORAGE_KEY = {
TIME: 'stopwatch-time', // 웹 이탈 시 체크
TIME_2: 'stopwatch-time-2', // 테스트 용
START_TIME: 'stopwatch-start-time',
MISSION_ID: 'stopwatch-mission-id',
},
};
20 changes: 15 additions & 5 deletions src/hooks/mission/stopwatch/useStopwatch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { STORAGE_KEY } from '@/constants/storage';
import { formatMMSS } from '@/utils/time';

import { type StepType } from './useStopwatchStatus';
Expand All @@ -7,13 +8,12 @@ const INIT_SECONDS = 0;
const MAX_SECONDS = 60 * 60; // max 1 hour

const DEFAULT_MS = 1000;
const TEST_MS = 1;
const TEST_MS = 50;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pooling을 위한 시간으로 이해했는데 맞을까요?!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞아요 라이브로 갈때는 DEFAULT_MS로 가게될 텐데, env에 추가하는 방법도 괜찮을 것 같네요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#308
이슈 열었습니다! 후에 추가할게요~


// 좀 더 의미론적.... useStopwatch
export default function useStopwatch(status: StepType) {
const [second, setSecond] = useState(INIT_SECONDS); // 남은 시간 (단위: 초)
const [isPending, setIsPending] = useState(true);
const [isFinished, setIsFinished] = useState(false);

const { formattedMinutes, formattedSeconds } = formatMMSS(second);

const stepper = second < 60 ? 0 : Math.floor(second / 60 / 10);
Expand All @@ -29,12 +29,22 @@ export default function useStopwatch(status: StepType) {

if (status === 'progress') {
timer = setInterval(() => {
setSecond((prev) => prev + 1);
setSecond((prev) => (prev >= MAX_SECONDS ? prev : prev + 1));
}, TEST_MS);
}

return () => clearInterval(timer);
}, [second, status]);

return { minutes: formattedMinutes, seconds: formattedSeconds, stepper, isFinished };
useEffect(() => {
// init time setting
const initSecondString = localStorage.getItem(STORAGE_KEY.STOPWATCH.TIME);
const initSeconds = Number(initSecondString);
if (initSeconds && initSeconds < MAX_SECONDS) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initSeconds가 NaN으로 나오거나 0으로 나오면 실행이 안될걸로 이해했는데 다르게 이해한 것일지, 의도된 것일지 궁금해요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞습니다 실행이 되지 않으면 second가 초기값 0으로 유지되기 때문에 문제 없습니당

setSecond(initSeconds);
}
setIsPending(false);
}, []);

return { minutes: formattedMinutes, seconds: formattedSeconds, stepper, isFinished, isPending };
}
4 changes: 2 additions & 2 deletions src/hooks/mission/stopwatch/useStopwatchStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const STOPWATCH_STATUS = {
},
} as const;

function useStopwatchStatus() {
const [step, setStep] = useState<StepType>('ready');
function useStopwatchStatus(initStatus?: StepType) {
const [step, setStep] = useState<StepType>(initStatus ?? 'ready');
const [prevStep, setPrevStep] = useState<StepType>('ready');

const stepLabel = STOPWATCH_STATUS[step];
Expand Down