Skip to content

Commit

Permalink
feat: ポモドーロタイマーの機能 (#134)
Browse files Browse the repository at this point in the history
* feat: ポモドーロタイマーの実装
    - タイマー機能の実装
    - 残り時間表示の実装
    - 通知機能の実装
    - ポモドーロタイマー関連の設定項目の追加

* fix: デバッガの警告の解消

* fix: 設定項目を増やしたときにデフォルト値が反映されない不具合の修正
  • Loading branch information
Hirotaka-Hanai committed Jun 28, 2024
1 parent 8b18a17 commit 43ff9d1
Show file tree
Hide file tree
Showing 24 changed files with 823 additions and 34 deletions.
61 changes: 44 additions & 17 deletions src/main/services/UserPreferenceStoreServiceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { TYPES } from '@main/types';
import { DataSource } from './DataSource';
import { DateUtil } from '@shared/utils/DateUtil';

/**
* ユーザー設定を保存するクラス
*
* DBにユーザー設定が残っている状態で、改修により設定項目が増えると、
* その設定項目はDBから取得できず undefined になってしまう。
* そのため、defaultUserPreference にDBの値を上書きする形をとることで、
* 増えた設定項目をデフォルト値で取得できるようにしている。
*/
@injectable()
export class UserPreferenceStoreServiceImpl implements IUserPreferenceStoreService {
constructor(
Expand All @@ -16,33 +24,52 @@ export class UserPreferenceStoreServiceImpl implements IUserPreferenceStoreServi
this.dataSource.createDb(this.tableName, [{ fieldName: 'userId', unique: true }]);
}

private defaultUserPreference = {
syncGoogleCalendar: false,
calendars: [],

startHourLocal: 9,

speakEvent: false,
speakEventTimeOffset: 10,
speakEventTextTemplate: '{TITLE} まで {READ_TIME_OFFSET} 秒前です',

speakTimeSignal: false,
timeSignalInterval: 30,
timeSignalTextTemplate: '{TIME} です',

muteWhileInMeeting: true,

workingMinutes: 25,
breakMinutes: 5,
notifyAtPomodoroComplete: {
announce: true,
sendNotification: false,
template: '{SESSION}が終了しました。',
},
notifyBeforePomodoroComplete: {
announce: false,
sendNotification: true,
template: '{SESSION}終了まであと{TIME}分です。',
},
notifyBeforePomodoroCompleteTimeOffset: 10,
};

get tableName(): string {
return 'userPreference.db';
}

async get(userId: string): Promise<UserPreference | undefined> {
return await this.dataSource.get(this.tableName, { userId: userId });
return {
...this.defaultUserPreference,
...(await this.dataSource.get(this.tableName, { userId: userId })),
};
}

async create(userId: string): Promise<UserPreference> {
return {
userId: userId,

syncGoogleCalendar: false,
calendars: [],

startHourLocal: 9,

speakEvent: false,
speakEventTimeOffset: 10,
speakEventTextTemplate: '{TITLE} まで {READ_TIME_OFFSET} 秒前です',

speakTimeSignal: false,
timeSignalInterval: 30,
timeSignalTextTemplate: '{TIME} です',

muteWhileInMeeting: true,

...this.defaultUserPreference,
updated: this.dateUtil.getCurrentDate(),
};
}
Expand Down
25 changes: 15 additions & 10 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import * as menu from './components/menu';
import { SettingPage } from './pages/SettingPage';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ActivityUsagePage } from './pages/ActivityUsagePage';
import { PomodoroTimerPage } from './pages/PomodoroTimerPage';
import { PomodoroTimerContextProvider } from './components/PomodoroTimerContextProvider';

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -107,17 +109,20 @@ const App = (): JSX.Element => {
<ThemeProvider theme={theme}>
<HashRouter>
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'top', horizontal: 'center' }}>
<Box sx={{ display: 'flex' }}>
<DrawerAppBar />
<Box component="main" sx={{ width: '100%' }}>
<Toolbar />
<Routes>
<Route path={menu.MENU_TIMELINE.path} element={<TimelinePage />} />
<Route path={menu.MENU_SETTING.path} element={<SettingPage />} />
<Route path={menu.MENU_ACTIVITY_USAGE.path} element={<ActivityUsagePage />} />
</Routes>
<PomodoroTimerContextProvider>
<Box sx={{ display: 'flex' }}>
<DrawerAppBar />
<Box component="main" sx={{ width: '100%' }}>
<Toolbar />
<Routes>
<Route path={menu.MENU_TIMELINE.path} element={<TimelinePage />} />
<Route path={menu.MENU_SETTING.path} element={<SettingPage />} />
<Route path={menu.MENU_ACTIVITY_USAGE.path} element={<ActivityUsagePage />} />
<Route path={menu.MENU_POMODORO_TIMER.path} element={<PomodoroTimerPage />} />
</Routes>
</Box>
</Box>
</Box>
</PomodoroTimerContextProvider>
</SnackbarProvider>
</HashRouter>
</ThemeProvider>
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/components/DrawerAppBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const navItems: NavItem[] = [
text: menu.MENU_ACTIVITY_USAGE.name,
link: menu.MENU_ACTIVITY_USAGE.path,
},
{
text: menu.MENU_POMODORO_TIMER.name,
link: menu.MENU_POMODORO_TIMER.path,
},
];

const drawerWidth = 240;
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/src/components/PomodoroTimerContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PomodoroTimerDetails, TimerSession } from '@shared/data/PomodoroTimerDetails';
import React from 'react';

type PomodoroTimerContextType = {
pomodoroTimerDetails: PomodoroTimerDetails | null;

setTimer: (timerSession: TimerSession) => void;
startTimer: () => void;
pauseTimer: () => void;
stopTimer: () => void;
};

const pomodoroTimerContext = React.createContext<PomodoroTimerContextType>({
pomodoroTimerDetails: null,

setTimer: () => {},
startTimer: () => {},
pauseTimer: () => {},
stopTimer: () => {},
});

export default pomodoroTimerContext;
213 changes: 213 additions & 0 deletions src/renderer/src/components/PomodoroTimerContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import rendererContainer from '../inversify.config';
import { ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import AppContext from './AppContext';
import { TYPES } from '@renderer/types';
import { PomodoroTimerDetails, TimerSession, TimerState } from '@shared/data/PomodoroTimerDetails';
import PomodoroTimerContext from './PomodoroTimerContext';
import { TimerManager } from '@shared/utils/TimerManager';
import { IUserPreferenceProxy } from '@renderer/services/IUserPreferenceProxy';
import { DateUtil } from '@shared/utils/DateUtil';
import { ISpeakEventService } from '@renderer/services/ISpeakEventService';
import { INotificationService } from '@renderer/services/INotificationService';
import { PomodoroNotificationSetting } from '@shared/data/PomodoroNotificationSetting';

export const PomodoroTimerContextProvider = ({
children,
}: {
children: ReactNode;
}): JSX.Element => {
const [pomodoroTimerDetails, setPomodoroTimerDetails] = useState<PomodoroTimerDetails | null>(
null
);
const [lastUpdated, setLastUpdate] = useState<Date | null>(null);

const { userDetails } = useContext(AppContext);

const TIMER_NAME = 'pomodoroTimer';
const timer = rendererContainer.get<TimerManager>(TYPES.TimerManager).get(TIMER_NAME);

const userPreferenceProxy = rendererContainer.get<IUserPreferenceProxy>(
TYPES.UserPreferenceProxy
);

const speakEventService = rendererContainer.get<ISpeakEventService>(TYPES.SpeakEventSubscriber);
const notificationService = rendererContainer.get<INotificationService>(
TYPES.NotificationSubscriber
);

const dateUtil = rendererContainer.get<DateUtil>(TYPES.DateUtil);

const setTimer = useCallback(
async (session: TimerSession): Promise<void> => {
if (userDetails == null) {
return;
}
const userPreference = await userPreferenceProxy.getOrCreate(userDetails.userId);
const initialMinutes =
session === TimerSession.WORK ? userPreference.workingMinutes : userPreference.breakMinutes;
setPomodoroTimerDetails({
session: session,
state: TimerState.STOPPED,
currentTime: initialMinutes * 60 * 1000,
});
},
[userDetails, userPreferenceProxy]
);

useEffect(() => {
setTimer(TimerSession.WORK);
}, [setTimer]);

const getNextSession = (session: TimerSession): TimerSession =>
session === TimerSession.WORK ? TimerSession.BREAK : TimerSession.WORK;

const sendNotification = useCallback(
(setting: PomodoroNotificationSetting): void => {
if (pomodoroTimerDetails == null) {
return;
}
const session = pomodoroTimerDetails.session === TimerSession.WORK ? '作業時間' : '休憩時間';
const time = Math.ceil(pomodoroTimerDetails.currentTime / (60 * 1000));
const text = setting.template
.replace('{TIME}', time.toString())
.replace('{SESSION}', session);
console.log(text);
if (setting.announce) {
speakEventService.speak(text);
}
if (setting.sendNotification) {
notificationService.sendNotification(text, 60 * 1000);
}
},
[notificationService, pomodoroTimerDetails, speakEventService]
);

const startTimer = useCallback((): void => {
setPomodoroTimerDetails((details) =>
details != null
? {
...details,
state: TimerState.RUNNING,
}
: null
);
}, []);

const pauseTimer = useCallback((): void => {
if (pomodoroTimerDetails == null) {
return;
}
if (pomodoroTimerDetails.state != TimerState.RUNNING) {
return;
}
timer.clear();

setPomodoroTimerDetails((details) => {
if (details == null || details.state !== TimerState.RUNNING) {
return details;
}
const diffTime = lastUpdated
? dateUtil.getCurrentDate().getTime() - lastUpdated.getTime()
: 0;
return {
...details,
state: TimerState.PAUSED,
currentTime: details.currentTime - diffTime,
};
});
setLastUpdate(null);
}, [dateUtil, lastUpdated, pomodoroTimerDetails, timer]);

const stopTimer = (): void => {
if (pomodoroTimerDetails == null) {
return;
}
timer.clear();

setTimer(pomodoroTimerDetails.session);
setLastUpdate(null);
};

// 次の時間の更新のセット
useEffect(() => {
if (pomodoroTimerDetails == null) {
return;
}

if (pomodoroTimerDetails.state !== TimerState.RUNNING) {
return;
}

timer.clear();

if (pomodoroTimerDetails.currentTime > 0) {
setLastUpdate(dateUtil.getCurrentDate());

// 整数秒になるように時間を更新する
const intervalMs = pomodoroTimerDetails.currentTime % 1000 || 1000;
// 次の時間の更新をセットする
timer.addTimeout(() => {
setPomodoroTimerDetails((details) =>
details != null
? {
...details,
currentTime: details.currentTime - intervalMs,
}
: null
);
}, intervalMs);
}
}, [dateUtil, pomodoroTimerDetails, timer]);

// 通知とセッションの切り替え
useEffect(() => {
if (userDetails == null || pomodoroTimerDetails == null) {
return;
}

if (pomodoroTimerDetails.state !== TimerState.RUNNING) {
return;
}

const timerEvent = async (): Promise<void> => {
const userPreference = await userPreferenceProxy.getOrCreate(userDetails.userId);

// 残り時間が0秒になったときの処理
if (pomodoroTimerDetails.currentTime <= 0) {
sendNotification(userPreference.notifyAtPomodoroComplete);
const session = getNextSession(pomodoroTimerDetails.session);
await setTimer(session);
startTimer();
return;
}

// 残り時間n秒前の処理
const offsetMs = userPreference.notifyBeforePomodoroCompleteTimeOffset * 60 * 1000;
if (pomodoroTimerDetails.currentTime == offsetMs) {
sendNotification(userPreference.notifyBeforePomodoroComplete);
}
};
timerEvent();
}, [
pomodoroTimerDetails,
sendNotification,
setTimer,
startTimer,
userDetails,
userPreferenceProxy,
]);

return (
<PomodoroTimerContext.Provider
value={{
pomodoroTimerDetails,
setTimer,
startTimer,
pauseTimer,
stopTimer,
}}
>
{children}
</PomodoroTimerContext.Provider>
);
};
6 changes: 3 additions & 3 deletions src/renderer/src/components/activityUsage/ActivityGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ export const ActivityGraph = (): JSX.Element => {
return (
<>
<Paper variant="outlined">
<Grid container justifyContent={'center'} spacing={2} xs={12} padding={2}>
<Grid container justifyContent={'center'} spacing={2} padding={2}>
<Grid item xs={12} md={6} textAlign={'center'}>
<DateTimePicker
sx={{ width: '13rem' }}
label={'開始日時'}
value={startDate}
value={startDate ?? null}
format={'yyyy/MM/dd HH:mm'}
slotProps={{ textField: { size: 'small' } }}
onChange={handleStartDateChange}
Expand All @@ -89,7 +89,7 @@ export const ActivityGraph = (): JSX.Element => {
<DateTimePicker
sx={{ width: '13rem' }}
label={'終了日時'}
value={endDate}
value={endDate ?? null}
minDateTime={startDate}
format={'yyyy/MM/dd HH:mm'}
slotProps={{ textField: { size: 'small' } }}
Expand Down
Loading

0 comments on commit 43ff9d1

Please sign in to comment.