Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit 21975ac

Browse files
authored
feat: pomodoro 로직을 main process 로 이동 (#68)
* 뽀모도로 main process에서 동작하도록 변경 * string 대신 이벤트 객체 전달되던 버그 수정 * 휴식 대기 시 아이콘 변경되지 않도록 * 뽀모도로 관련 공유 타입 및 유틸을 최상위로 이동 * 뽀모도로 실행될때만 tick 돌도록 수정 * 창 닫혔을때 mainWindow 호출되지 않도록 * 창닫은뒤 트레이로 창생성하면 할당안되던것 수정 * 카테고리 no가 0여도 api 요청 보내도록 * 아카이브 혹은 추후를 위해 usePomodoro hook 복원
1 parent cb4ef8f commit 21975ac

File tree

28 files changed

+556
-92
lines changed

28 files changed

+556
-92
lines changed

src/main/main.ts

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import path from 'path';
44
import { machineId } from 'node-machine-id';
55
import { updateElectronApp, UpdateSourceType } from 'update-electron-app';
66

7+
import {
8+
PomodoroCycle,
9+
PomodoroEndReason,
10+
PomodoroManagerConfigWithoutCallbacks,
11+
PomodoroMode,
12+
PomodoroTime,
13+
} from '../shared/type';
14+
15+
import { PomodoroManager } from './pomodoro/manager';
16+
717
updateElectronApp({
818
updateSource: {
919
type: UpdateSourceType.ElectronPublicUpdateService,
@@ -14,11 +24,10 @@ updateElectronApp({
1424

1525
let mainWindow: BrowserWindow | null = null;
1626
let tray: Tray | null = null;
17-
let forceQuit = false;
27+
let pomodoroManager: PomodoroManager;
1828

1929
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
2030
if (require('electron-squirrel-startup')) {
21-
forceQuit = true;
2231
app.quit();
2332
}
2433

@@ -62,13 +71,8 @@ const createWindow = () => {
6271
return { action: 'allow' };
6372
});
6473

65-
// @see: https://stackoverflow.com/questions/38309240/object-has-been-destroyed-when-open-secondary-child-window-in-electron-js/39135293
66-
// 창이 닫히지 않고 숨겨지도록 설정함
67-
browserWindow.on('close', (event) => {
68-
if (!forceQuit) {
69-
event.preventDefault();
70-
browserWindow?.hide();
71-
}
74+
browserWindow.on('closed', () => {
75+
mainWindow = null;
7276
});
7377

7478
return browserWindow;
@@ -93,7 +97,7 @@ const getTrayIcon = (icon: string): NativeImage => {
9397
return image;
9498
};
9599

96-
const createTray = (mainWindow: BrowserWindow) => {
100+
const createTray = () => {
97101
const tray = new Tray(getTrayIcon('cat'));
98102
const contextMenu = Menu.buildFromTemplate([
99103
{
@@ -108,10 +112,7 @@ const createTray = (mainWindow: BrowserWindow) => {
108112
{ type: 'separator' },
109113
{
110114
label: '종료',
111-
click: () => {
112-
forceQuit = true;
113-
app.quit();
114-
},
115+
role: 'quit',
115116
},
116117
]);
117118
tray.setContextMenu(contextMenu);
@@ -130,7 +131,6 @@ app.on('ready', () => {
130131
// explicitly with Cmd + Q.
131132
app.on('window-all-closed', () => {
132133
if (process.platform !== 'darwin') {
133-
forceQuit = true;
134134
app.quit();
135135
}
136136
});
@@ -146,9 +146,7 @@ app.on('activate', () => {
146146
// In this file you can include the rest of your app's specific main process
147147
// code. You can also put them in separate files and import them here.
148148
app.whenReady().then(() => {
149-
if (mainWindow) {
150-
tray = createTray(mainWindow);
151-
}
149+
tray = createTray();
152150

153151
// event handling
154152
ipcMain.handle('get-machine-id', async () => {
@@ -187,4 +185,44 @@ app.whenReady().then(() => {
187185
mainWindow?.setSize(WindowSizeMap.normal.width, WindowSizeMap.normal.height);
188186
}
189187
});
188+
189+
// pomodoro
190+
ipcMain.handle('setup-pomodoro', (event, _config: PomodoroManagerConfigWithoutCallbacks) => {
191+
const config = {
192+
..._config,
193+
onTickPomodoro: (cycles: PomodoroCycle[], time: PomodoroTime) => {
194+
mainWindow?.webContents.send('tick-pomodoro', cycles, time);
195+
196+
const trayInfo = PomodoroManager.getTrayInfo(cycles, time);
197+
if (trayInfo) {
198+
tray?.setImage(getTrayIcon(trayInfo.icon));
199+
tray?.setTitle(trayInfo.time);
200+
}
201+
},
202+
onEndPomodoro: (cycles: PomodoroCycle[], reason: PomodoroEndReason) => {
203+
mainWindow?.webContents.send('end-pomodoro', cycles, reason);
204+
},
205+
onceExceedGoalTime: (mode: PomodoroMode) => {
206+
mainWindow?.webContents.send('once-exceed-goal-time', mode);
207+
},
208+
};
209+
210+
if (pomodoroManager) {
211+
pomodoroManager.updateConfig(config);
212+
} else {
213+
pomodoroManager = new PomodoroManager(config);
214+
}
215+
});
216+
ipcMain.handle('start-focus', () => {
217+
pomodoroManager.startFocus();
218+
});
219+
ipcMain.handle('start-rest-wait', () => {
220+
pomodoroManager.startRestWait();
221+
});
222+
ipcMain.handle('start-rest', () => {
223+
pomodoroManager.startRest();
224+
});
225+
ipcMain.handle('end-pomodoro', (event, mode) => {
226+
pomodoroManager.endPomodoro(mode);
227+
});
190228
});

src/main/pomodoro/manager.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import {
2+
PomodoroCycle,
3+
PomodoroEndReason,
4+
PomodoroManagerConfig,
5+
PomodoroMode,
6+
PomodoroTime,
7+
} from '../../shared/type';
8+
import { parse, padNumber, msToTime, getPomodoroTime } from '../../shared/util';
9+
10+
import { SimpleStorage } from './util/storage';
11+
12+
const STORAGE_KEY = {
13+
POMODORO_CYCLES: 'pomodoroCycles',
14+
POMODORO_TIME: 'pomodoroTime',
15+
POMODORO_CALLED_ONCE_FOR_EXCEED_TIME: 'pomodoroCalledOnceForExceedTime',
16+
};
17+
const DEFAULT_POMODORO_TIME: PomodoroTime = { elapsed: 0, exceeded: 0 };
18+
19+
export class PomodoroManager {
20+
storage: SimpleStorage;
21+
pomodoroCycles: PomodoroCycle[];
22+
pomodoroTime: { elapsed: number; exceeded: number };
23+
calledOnceForExceedGoalTime: boolean;
24+
tickId?: NodeJS.Timeout;
25+
26+
constructor(public config: PomodoroManagerConfig) {
27+
// setup
28+
this.storage = new SimpleStorage();
29+
this.pomodoroCycles = parse(this.storage.getItem(STORAGE_KEY.POMODORO_CYCLES), []);
30+
this.pomodoroTime = parse(
31+
this.storage.getItem(STORAGE_KEY.POMODORO_TIME),
32+
DEFAULT_POMODORO_TIME,
33+
);
34+
this.calledOnceForExceedGoalTime =
35+
(this.storage.getItem(STORAGE_KEY.POMODORO_CALLED_ONCE_FOR_EXCEED_TIME), false);
36+
37+
// run tick
38+
const isRunning = this.pomodoroCycles.length > 0;
39+
if (isRunning) {
40+
this._runTick();
41+
}
42+
}
43+
44+
_save() {
45+
this.storage.setItem(STORAGE_KEY.POMODORO_CYCLES, JSON.stringify(this.pomodoroCycles));
46+
this.storage.setItem(STORAGE_KEY.POMODORO_TIME, JSON.stringify(this.pomodoroTime));
47+
this.storage.setItem(
48+
STORAGE_KEY.POMODORO_CALLED_ONCE_FOR_EXCEED_TIME,
49+
JSON.stringify(this.calledOnceForExceedGoalTime),
50+
);
51+
}
52+
53+
_clear() {
54+
this.pomodoroCycles = [];
55+
this.pomodoroTime = DEFAULT_POMODORO_TIME;
56+
this.calledOnceForExceedGoalTime = false;
57+
58+
this.storage.removeItem(STORAGE_KEY.POMODORO_CYCLES);
59+
this.storage.removeItem(STORAGE_KEY.POMODORO_TIME);
60+
this.storage.removeItem(STORAGE_KEY.POMODORO_CALLED_ONCE_FOR_EXCEED_TIME);
61+
}
62+
63+
_tick() {
64+
const currentCycle = this.pomodoroCycles[this.pomodoroCycles.length - 1];
65+
if (!currentCycle) return;
66+
67+
const { elapsed, exceeded } = getPomodoroTime(currentCycle);
68+
this.pomodoroTime = { elapsed, exceeded };
69+
70+
if (exceeded > 0 && !this.calledOnceForExceedGoalTime) {
71+
this.config.onceExceedGoalTime?.(currentCycle.mode);
72+
this.calledOnceForExceedGoalTime = true;
73+
}
74+
75+
if (exceeded >= currentCycle.exceedMaxTime) {
76+
if (currentCycle.mode === 'focus') {
77+
this.startRestWait();
78+
}
79+
if (currentCycle.mode === 'rest-wait') {
80+
this.endPomodoro('exceed');
81+
}
82+
if (currentCycle.mode === 'rest') {
83+
this.endPomodoro('exceed');
84+
}
85+
}
86+
}
87+
88+
_runTick() {
89+
this._stopTick();
90+
91+
this.tickId = setInterval(() => {
92+
// @note: hmr로 인해 this 객체가 사라지면, 에러가 발생함.
93+
try {
94+
this._tick();
95+
this.config.onTickPomodoro?.(this.pomodoroCycles, this.pomodoroTime);
96+
} catch (error) {
97+
clearInterval(this.tickId);
98+
}
99+
}, 200);
100+
}
101+
102+
_stopTick() {
103+
this.config.onTickPomodoro?.(this.pomodoroCycles, this.pomodoroTime);
104+
clearInterval(this.tickId);
105+
}
106+
107+
updateConfig(config: Partial<PomodoroManagerConfig>) {
108+
this.config = {
109+
...this.config,
110+
...config,
111+
};
112+
}
113+
114+
destroy() {
115+
clearTimeout(this.tickId);
116+
}
117+
118+
startFocus() {
119+
const nextCycles = PomodoroManager.updateCycles(this.pomodoroCycles, {
120+
startAt: Date.now(),
121+
goalTime: this.config.focusTime,
122+
exceedMaxTime: this.config.focusExceedMaxTime,
123+
mode: 'focus',
124+
});
125+
this.pomodoroCycles = nextCycles;
126+
this.pomodoroTime = DEFAULT_POMODORO_TIME;
127+
this.calledOnceForExceedGoalTime = false;
128+
this._save();
129+
this._runTick();
130+
}
131+
132+
startRestWait() {
133+
const nextCycles = PomodoroManager.updateCycles(this.pomodoroCycles, {
134+
startAt: Date.now(),
135+
goalTime: 0,
136+
exceedMaxTime: this.config.restWaitExceedMaxTime,
137+
mode: 'rest-wait',
138+
});
139+
this.pomodoroCycles = nextCycles;
140+
this.pomodoroTime = DEFAULT_POMODORO_TIME;
141+
this.calledOnceForExceedGoalTime = false;
142+
this._save();
143+
}
144+
145+
startRest() {
146+
const nextCycles = PomodoroManager.updateCycles(this.pomodoroCycles, {
147+
startAt: Date.now(),
148+
goalTime: this.config.restTime,
149+
exceedMaxTime: this.config.restExceedMaxTime,
150+
mode: 'rest',
151+
});
152+
this.pomodoroCycles = nextCycles;
153+
this.pomodoroTime = DEFAULT_POMODORO_TIME;
154+
this.calledOnceForExceedGoalTime = false;
155+
this._save();
156+
}
157+
158+
endPomodoro(reason: PomodoroEndReason = 'manual') {
159+
const endedCycles = PomodoroManager.updateCycles(this.pomodoroCycles);
160+
this.config.onEndPomodoro(endedCycles, reason);
161+
162+
// 상위로 전달했으니 cycle 데이터 초기화
163+
this._clear();
164+
this._stopTick();
165+
}
166+
167+
static updateCycles(cycles: PomodoroCycle[], nextCycle?: PomodoroCycle): PomodoroCycle[] {
168+
const prevCycles = cycles.slice(0, -1);
169+
const lastCycle = cycles[cycles.length - 1] as PomodoroCycle | undefined;
170+
171+
if (lastCycle?.mode === nextCycle?.mode) {
172+
throw new Error('Invalid mode cycle');
173+
}
174+
175+
return [
176+
...prevCycles,
177+
lastCycle && {
178+
...lastCycle,
179+
endAt: Date.now(),
180+
},
181+
nextCycle,
182+
].filter(Boolean) as PomodoroCycle[];
183+
}
184+
185+
static getFormattedTime(goalTime: number, { elapsed, exceeded }: PomodoroTime) {
186+
const isExceed = exceeded > 0;
187+
const { minutes, seconds } = msToTime(isExceed ? exceeded : goalTime - elapsed);
188+
const time = `${padNumber(minutes)}:${padNumber(seconds)}`;
189+
return { isExceed, time };
190+
}
191+
192+
static getTrayIcon(mode: PomodoroMode, isExceed: boolean) {
193+
if (mode === 'focus') {
194+
return isExceed ? 'focus-exceed' : 'focus';
195+
}
196+
if (mode === 'rest') {
197+
return isExceed ? 'rest-exceed' : 'rest';
198+
}
199+
return '';
200+
}
201+
202+
static getTrayInfo(cycles: PomodoroCycle[], time: PomodoroTime) {
203+
const currentCycle = cycles[cycles.length - 1];
204+
if (!currentCycle) return { icon: '', time: '' };
205+
206+
const mode = currentCycle.mode;
207+
// 휴식 대기 중에는 직전 아이콘 그대로 보여주기 위해 null 반환
208+
if (mode === 'rest-wait') return null;
209+
210+
const { isExceed, time: formattedTime } = PomodoroManager.getFormattedTime(
211+
currentCycle.goalTime,
212+
time,
213+
);
214+
const trayIcon = PomodoroManager.getTrayIcon(currentCycle.mode, isExceed);
215+
return { icon: trayIcon, time: formattedTime };
216+
}
217+
}

0 commit comments

Comments
 (0)