Skip to main content

Problem Solving

한 세션이 진행 중일 때(pomo or break 관계없이), 다른 페이지들을 자유롭게 방문할 수 있도록 하는 것

A. 다른 페이지를 방문 후 세션이 종료되기 전에 /timer로 돌아오는 경우

문제 상황

우선 기본적으로 /timer에 렌더되는 countdown timer UI는 PatternTimer1Timer에 의해 만들어집니다. 다시 말하면, 이 component들의 timersStates2에 의해 타이머 UI가 적절한 값을 표현하게 됩니다 (몇 분 남았는지, 이번 세션이 pomo인지 break인지 등). 다른 페이지로 이동한다는 것은 이 component들이 unmount되어 state값들에 대한 접근을 잠시 잃어버리는 것을 의미합니다.

하지만 이 값들을 결국 /timer로 돌아올 때 사용해야 하므로 어디엔가 저장을 해야 합니다.

해결 방식

timersStates이 update 될 때마다(예를 들면, session의 종류가 바뀌거나 pause/resume을 할 때) indexedDB에 저장/update 해두었습니다. 그리고 나중에 사용자가 /timer로 돌아올 때 그 값을 이용해 PatternTimer와 Timer를 마운트 하였습니다.

어려웠던 점

처음에는 PatternTimerTimer에서 useEffect를 사용해 각 컴포넌트의 상태값을 설정했습니다. 하지만 이 방법은 컴포넌트가 마운트된 후에 상태가 업데이트되는 방식이라, 처음에 25분이라는 초기 상태값이 잠깐 보였다가 실제 남은 시간이 표시되는 과정이 부자연스러웠습니다. 이를 해결하기 위해 각 컴포넌트의 초기 상태값을 indexedDB에 저장된 값으로 설정하려 했습니다. 그러나 useState 함수는 비동기 콜백함수를 argument로 받지 않고3, indexedDB의 작업은 모두 비동기 방식으로 처리된다는 것이 문제였습니다. 그래서 두 컴포넌트의 공통 부모인 Main 컴포넌트에서 indexedDB에 저장된 상태값들을 받아오는 useEffect를 다음과 같이 호출했습니다.

export default function Main() {

...

//
useEffect(setStatesRelatedToTimerUsingDataFromIDB, []);

//
function setStatesRelatedToTimerUsingDataFromIDB() {
const getStatesFromIDB = async () => {
let states = await obtainStatesFromIDB("withoutSettings");
setStatesRelatedToTimer(states);
};
getStatesFromIDB();
}

...

}

그리고 그 값을 indexedDB에서 받아오기 전까지는 타이머 UI가 렌더되지 않도록 Main 함수에서 children을 조건부로 반환했습니다. 이렇게 하면, PatternTimerTimer는 prop을 이용해 실제 남은 세션 시간을 초기 상태값으로 설정할 수 있습니다. 결과적으로, 추가적인 렌더링 없이 타이머 UI를 한 번에 정확하게 렌더링할 수 있습니다.

Sequence Diagram

원본 링크 스탯으로 이동후 다시 타이머로 무브백

B. 다른 페이지에 머무르는 동안 세션이 종료되는 경우

문제 상황

타이머 UI가 보여주는 정보는 세션의 종류 (pomodoro이나 break), 현재 세션이 얼마나 남았는지 등이 있는데, 이것들은 timersStates2에 의해 결정됩니다. 다시 말하면, 한 사이클의 세션들이 매끄럽게 진행되기 위해서는 세션이 종료되었을때 timersStates를 update해줘야 합니다. 이것들은 타이머 UI가 렌더되는 /timer에서는 당연히 이뤄질 수 있지만,

  1. 다른 페이지에서 세션이 종료되는 경우에도 이것을 적절히 update할 수 있어야 합니다.
  2. 그리고 특히 /statistics에서 종료되는 경우, 통계 그래프에 종료된 pomodoro세션을 반영해야 합니다.

해결 방식

우선 어떤 한 세션의 종료 시점을 계산하기 위해서는 /timer를 벗어난 순간부터 누군가는 계속 이어서 그 세션을 count down해야 하므로, index.tsx파일에 다음처럼 countDown 함수를 정의하여 export했습니다. 이것은 다른 페이지의 component가 mount되면 side effect으로 호출됩니다.

  1. Service worker script를 이용해서, indexed db에 저장된 timersStates를 update합니다. 이렇게 되면 /timer로 돌아왔을 때 indexedDB에서 가져오는 상태값은 세션 종료를 반영한 것이므로 이를 이용해서 바로 다음 세션 UI를 render할 수 있습니다.
  2. /statistics에서 pomo session이 종료될 때, 그 값을 통계 그래프에 곧바로 반영하기 위해 Pub/Sub 패턴을 사용했습니다.

어려웠던 점 - service worker가 작동하는 방식이 저의 예상과 달랐습니다.

Service worker를 사용한 계기

/timer외의 페이지는 그 페이지에서 하는 고유의 일이 있는데, 제가 해결해야 하는 것은 타이머 UI가 하는 작업이니 이것은 백그라운드에서 누군가 해줘야 한다는 생각이 있었습니다. 그래서 구글에 검색하던 도중 service worker는 리액트 앱이 돌아가는 main thread와는 다른 고유의 thread에서 돌아가기 때문에, 백그라운드 작업에 사용할 수 있다는 글을 읽었습니다. 그리고 브라우저에서 제공하는 API니까 이번 기회에 사용해봐야겠다는 생각이었습니다.

예상치 못한 제약
  • 세션 카운트 다운을 service worker script에서 setInterval()을 이용해 세션을 countdown하려 했는데, developer tools를 켜놓고 잘 돌아가는지 로그를 찍어서 확인할 때는 잘 작동했는데, developer tools를 끄고 실행하면 카운트 다운이 도중에 멈췄습니다. 이유는 서비스 워커 스크립트는 몇 가지 event에 반응하여 할당된 일을 마무리하면 바로 down되는 작동방식 때문이었습니다4.
  • 그래서 코드를 수정하여 countDown을 sw.js에서 index.tsx로 옮기고 session이 끝날 때 sw.js에 message (event)를 날려서 기존에 작성했던 것들을 활용하여 필요한 작업들을 할 수 있게 했습니다5 .
pubsub definition
// reference: https://www.youtube.com/watch?v=aynSM8llOBs

type Callback = (data: any) => void;
interface PubsubType {
events: { [index: string]: Set<(data: any) => void> };
subscribe: (evName: string, cb: Callback) => () => void;
unsubscribe: (evName: string, cb: Callback) => void;
publish: (evName: string, data: any) => void;
}
export const pubsub: PubsubType = {
events: {},

subscribe: function (evName, cb) {
if (!(evName in this.events)) {
this.events[evName] = new Set();
}
this.events[evName].add(cb);

return () => {
this.events[evName].delete(cb);
};
},

unsubscribe: function (evName, cb) {
if (evName in this.events) {
this.events[evName].delete(cb);
}
},

publish: function (evName, data: any) {
if (this.events[evName]) {
this.events[evName].forEach((f) => {
f(data);
});
}
},
};

publish()
// in client/src/sw.js
async function recordPomo(startTime, idTokenAndEmail, infoArray, sessionData) {
let body = null;

try {
const { idToken, email } = idTokenAndEmail;
const today = new Date(startTime);
let LocaleDateString = `${
today.getMonth() + 1
}/${today.getDate()}/${today.getFullYear()}`;

const final = convertMilliSecToMin(
createDataSortedByTimestamp(
infoArray,
sessionData.pause.record,
sessionData.endTime
)
.reduce(calculateDurationForEveryCategory, {
durationArr: [],
currentType: "focus",
currentOwner: "",
currentStartTime: 0,
})
.durationArr.reduce(aggregateFocusDurationOfTheSameCategory, {
c_duration_array: [],
currentCategoryName: "",
}).c_duration_array
).map((val) => {
if (val.categoryName !== "uncategorized") {
return {
userEmail: email,
duration: val.duration,
startTime: val.startTime,
date: LocaleDateString,
isDummy: false,
category: {
name: val.categoryName,
},
};
} else {
return {
userEmail: email,
duration: val.duration,
startTime: val.startTime,
date: LocaleDateString,
isDummy: false,
};
}
});

BC.postMessage({
evName: "pomoAdded", // <--------------------------------------------------------
payload: final,
});

//...
} catch (error) {
console.warn(error);
}
}

// in client/src/index.tsx
BC.addEventListener("message", async (ev) => {
const { evName, payload } = ev.data;

switch (evName) {
case "pomoAdded":
// type of the payload
// {
// userEmail: string;
// duration: number;
// startTime: number;
// date: string;
// isDummy: boolean;
// category?: {
// name: string;
// };
// }[]
pubsub.publish(evName, payload); // <--------------------------------------------
break;

// ...

default:
console.warn(`Unhandled event name: ${evName}`);
break;
}
});

subscribe()
  // in client/src/Pages/Statistics/Statistics.tsx
useEffect(() => {
countDown(localStorage.getItem("idOfSetInterval"));

const unsub = pubsub.subscribe(
"pomoAdded",
(
final: {
useEmail: string;
duration: number;
startTime: number;
date: string;
isDummy: boolean;
category?: {
name: string;
};
}[]
) => {
setStatData((prev) => {
if (!prev) {
return prev;
} else {
let today = new Date();
const todayDateString = `${
today.getMonth() + 1
}/${today.getDate()}/${today.getFullYear()}`;
let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

let cloned = [...prev];
let doesTodayStatExist =
cloned.length !== 0 &&
cloned[cloned.length - 1].date === todayDateString;

if (doesTodayStatExist) {
for (const pomoDoc of final) {
cloned[cloned.length - 1].total += pomoDoc.duration;
if (pomoDoc.category) {
cloned[cloned.length - 1].subtotalByCategory[
pomoDoc.category.name
].duration += pomoDoc.duration;
} else {
cloned[cloned.length - 1].withoutCategory += pomoDoc.duration;
}
}
}
if (!doesTodayStatExist) {
const now = new Date();
const startOfTodayTimestamp = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate()
).getTime();

let dayStat: DayStat = {
date: todayDateString,
timestamp: startOfTodayTimestamp,
dayOfWeek: days[today.getDay()],
total: 0,
subtotalByCategory: createBaseCategorySubtotal(),
withoutCategory: 0,
weekNumber: getISOWeek(Date.now()),
};

for (const pomoDoc of final) {
dayStat.total += pomoDoc.duration;
if (pomoDoc.category) {
dayStat.subtotalByCategory[pomoDoc.category.name].duration +=
pomoDoc.duration;
} else {
dayStat.withoutCategory += pomoDoc.duration;
}
}

cloned.push(dayStat);
}

setSum((prev) => {
const retVal = { ...prev };
for (const pomoDoc of final) {
retVal.today += pomoDoc.duration;
retVal.thisWeek += pomoDoc.duration;
retVal.thisMonth += pomoDoc.duration;
retVal.allTime += pomoDoc.duration;
}
return retVal;
});

return cloned;
}
});
}
);

return () => {
unsub();
};
}, []);

Sequence Diagram

원본 링크 스탯으로 이동후 세션 종료

비판적 질문들

인터넷 연결이 끊길 때에도 핵심 기능 사용할 수 있게 하기

문제 상황 및 구현하게 된 계기 - 인터넷이 끊긴 동안 사용한 결과는 서버에 저장되지 않는다

평소에 대학교 도서관에서 작업하는데, 시험기간에 사람이 많이 몰리는 시간에는 인터넷이 간헐적으로 끊겼습니다. 이때 앱을 계속 사용하더라도 서버에 데이터가 보내지지 않기 때문에 나중에 인터넷이 연결된 후 다시 사용할 때 문제가 있었습니다. 예를 들면, 인터넷이 끊긴 상태에서 타이머가 종료되면 그 집중한 시간은 기록되지 않았습니다. 또는 어떤 집중 시간 진행 도중에 인터넷이 끊기고 카테고리를 변경하는 경우 이것은 반영되지 않습니다. 이런 식으로 인터넷이 몇 번씩 끊겼다가 연결되었다가 하면 앱에 대한 사용자 경험 안정성이 근본적으로 저하되었습니다.

해결 방식 - request를 보관 후 다시 보내기

  • errController object를 정의해 이곳에 request를 보관 후 인터넷이 다시 연결되면 서버에 보냈습니다.

  • 사용자가 로그아웃하거나 브라우저 탭을 닫은 후 나중에 인터넷이 연결되는 경우

    • errController에 있는 request를 indexed db에 저장

    • 앱을 열거나 로그인하면, 그 사용자의 request들을 가져와서 다시 서버로 보냅니다.

데이터 합치기

  • patch request는 resource를 변경하는 것이므로 하나로 통합할 수 있습니다.

    • 예를 들면, /categories 에대한 patch request는 Categories collection에 있는 한 카테고리 object를 수정하는 것이고,

    • 다시 말하면 그것의 property를 수정하는 것입니다.

    • 같은 object를 수정하는 request들은 하나로 통합해서 한번 보냅니다.

    • 여러 개의 category object를 수정하게 되는 경우를 위해 batch URL을 정의

어려웠던 점

category object를 식별하는 방식이 name과 userEmail의 조합의 유일성을 이용하는 것인데,

이름을 바꾸는 patch request때문에 이것을 이용할 수 없었습니다.

그래서 F.E 한정으로 사용하는 _uuid를 batch request의 경우에 한해서 식별자로 사용했습니다.

  • 흐름
    • interceptor에서 /categories로 보내지는 patch는 interceptor에서 바로 errController에 등록하지 않고 error를 throw
    • axios patch를 호출한 함수에서 그 error를 catch한 후
    • _uuid를 넣어서 errController에 등록.

Service Worker에서 request 보내기

  • axios대신 fetch API를 이용하므로 fetch 호출을 try catch block에 넣어서 error를 잡아냅니다.

  • Error가 TypError이면 BroadcastChannel API를 이용해서 main thread에 데이터를 보냅니다.

  • event handler가 그 데이터를 받아서 errController에 저장합니다.

비판적 질문들

Horizontal Timeline 만들기

문제 상황 및 구현하게 된 계기

집중과 휴식 세션들을 어떻게 진행했는지 직관적인 feedback이 필요했고, 이를 위해 timeline을 만들기로 했습니다. 제 앱의 use case에 맞는 적절한 horizontal timeline UI를 제공하는 라이브러리를 찾기 어려웠고 이 기회에 CSS를 연습해보자는 생각으로 직접 구현하게 되었습니다.

Design and Implementation

  • 1분을 나타내는 px을 정해서 Timeline component의 width를 24시간만큼의 크기로 설정
  • 오른쪽으로 갈수록 시간이 흘러간다고 가정하고 Timeline의 가장 왼쪽 끝을 하루의 시작인 12:00 am으로 정함
  • 완료된 세션은 타임라인 가장 왼쪽 끝으로부터 얼마나 떨어트려 놓을지 계산하여 적절한 위치를 잡음
    • absolute position과 left CSS property를 이용
  • 타임라인을 좌우로 움직이기 위해 마우스 스크롤과 드래그에 대해 Timeline component의 left 값을 변화시킴
    • session들은 모두 Timeline에 대해 absolutely positioned 되었기 때문에 그 위에서 함께 움직임

어려웠던 점들

윈도우 사이즈 변화에 responsive하게 반응
  • 아래 그림처럼, visual viewport의 넓이가 x보다 커지면, 빨간색 부분처럼 빈 부분이 생김
  • window resize event handler에서 visual viewport width가 x보다 커지면 timeline의 right CSS property를 0으로 설정했습니다. timeline-design

Before


before


After


after

Responsive관련 css가 작동하지 않아서 Web API를 이용
  • 사용하게 된 계기

    • media query를 사용하기 위해 inline style을 이용한 Timeline component를 styled component로 바꿨습니다.
    • 그런데 기존의 drag와 wheel event handler들이 ref를 이용해 Timeline element의 style object에 직접 접근하는데, 관련 코드들이 Styled Component로 바꿨을 때 잘 작동하지 않았습니다.
    • 디버깅하기에는 너무 복잡해 보여서 기존의 inline 스타일을 유지하면서 CSS media query 없이 responsive design을 적용할 방법을 찾아봤습니다.
  • 전반적인 원리

    • window object의 matchMedia() method는 mediaQuery을 argument로 받고, MediaQueryList object를 반환합니다.
    • 이때, window는 현재 앱이 놓여있는 tab에 관한 것이므로, MediaQueryList이 갖게 되는 정보는 이 tab이 mediaQuery을 만족하는지 등에 관한 것입니다.
    • 그 정보와 MediaQueryList object가 제공하는 몇 가지 기능들을 이용하면, 앱을 여러 media에 대해 responsive하게 만들 수 있습니다.
  • 바꿔야 하는 것: 몇 pixel을 가지고 24시간을 나타낼 지.

    • 예를 들면, 작은 device일수록, 작은 pixel을 이용해 24시간을 나타냅니다.
    • 이것이 결정되면 비례식으로 1초, 1분, 1시간이 몇 pixel에 해당하는지 계산하여, Scale component를 적절히 render할 수 있습니다.
const msToPx = useRef<number>(PIXEL.PER_SEC.IN_FHD / 1000);
const fullWidthOfTimeline = useRef<number>(PIXEL.PER_HR.IN_FHD * 24);
export const PIXEL = {
PER_SEC: {
IN_MOBILE: 16 / 300,
IN_TABLET: 16 / 225,
IN_FHD: 8 / 60,
IN_QHD: 16 / 90,
IN_UHD: 4 / 15,
},
PER_MIN: {
IN_MOBILE: 16 / 5,
IN_TABLET: 64 / 15,
IN_FHD: 8,
IN_QHD: 32 / 3,
IN_UHD: 16,
},
PER_HR: {
IN_MOBILE: 192, // 576 <-> 3h
IN_TABLET: 256, // 768 <-> 3h
IN_FHD: 480, // 1920 <-> 4h
IN_QHD: 640, // 2560 <-> 4h
IN_UHD: 960, // 3840 <-> 4h
},
};
  • 처음 마운트 및 update되어 re-render되는 경우
    • 예를 들면, mobile, tablet, fhd를 target으로 하는 range에 대한 MediaQueryList를 각각 만들면,
    • 각 object의 matches property는 현재 tab이 자신들을 만족하고 있는지를 boolean 값으로 나타냅니다.
    • 따라서 component가 render되면 이 property들을 확인해서 어떤 pixel값을 적용해야 할지 결정합니다.
if (mobileRange.matches) {
msToPx.current = PIXEL.PER_SEC.IN_MOBILE / 1000;
fullWidthOfTimeline.current = PIXEL.PER_HR.IN_MOBILE * 24;
} else if (tabletRange.matches) {
msToPx.current = PIXEL.PER_SEC.IN_TABLET / 1000;
fullWidthOfTimeline.current = PIXEL.PER_HR.IN_TABLET * 24;
} else if (fhdRange.matches) {
msToPx.current = PIXEL.PER_SEC.IN_FHD / 1000;
fullWidthOfTimeline.current = PIXEL.PER_HR.IN_FHD * 24;
} else if (qhdRange.matches) {
msToPx.current = PIXEL.PER_SEC.IN_QHD / 1000;
fullWidthOfTimeline.current = PIXEL.PER_HR.IN_QHD * 24;
} else if (uhdRange.matches) {
msToPx.current = PIXEL.PER_SEC.IN_UHD / 1000;
fullWidthOfTimeline.current = PIXEL.PER_HR.IN_UHD * 24;
}
  • 마운트 후 윈도우 사이즈가 변화하는 경우
    • 예를 들면 mobile range를 나타내는 mediaQuery을 이용해서 만든 MediaQueryList가 있다고 할 때,
    • 양쪽 중 어느 쪽으로든 그 범위를 벗어나면, change event가 발생하고 이는 MediaQueryList에 전달됩니다.
    • 그러므로 event handler를 component내에서 정의하여 어느 range로 변화하는지를 포착해 적절한 msToPx값을 설정할 수 있도록 합니다.
const handleTabletRangeAtDetailedArea = useCallback(
(ev: MediaQueryListEvent) => {
if (ev.matches) {
console.log("TABLET IN DETAIL_AREA");
divRef.current &&
(divRef.current.style.width = PIXEL.PER_HR.IN_TABLET * 24 + "px");
msToPx.current = PIXEL.PER_SEC.IN_TABLET / 1000;
}
},
[]
);

mobileRange.addEventListener("change", handleMobileRangeAtDetailedArea);
tabletRange.addEventListener("change", handleTabletRangeAtDetailedArea);
fhdRange.addEventListener("change", handleFHD_RangeAtDetailedArea);
qhdRange.addEventListener("change", handleQHD_RangeAtDetailedArea);
uhdRange.addEventListener("change", handleUHD_RangeAtDetailedArea);

Before


before


After


after

비판적 질문들



Footnotes

  1. The component name has now been renamed to TimerController.

  2. type TimersStatesType = TimerStateType & PatternTimerStatesType - Github source code link 2

  3. function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>]

  4. https://w3c.github.io/ServiceWorker/#service-worker-lifetime

  5. index.tsx, sw.js