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: ポモドーロタイマーの機能 #134

Merged
merged 16 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions src/main/services/UserPreferenceStoreServiceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,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 {
Copy link
Contributor

Choose a reason for hiding this comment

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

なるほど、デフォルト値の埋め込みは、確かに、こうしておかないとダメですね。

なぜ、こういう実装になっているか、クラス全体のドキュメントコメントのところに、説明を書いておいてもらってもいいですか?

Nit:
実装済コードがそうなのだけど、全般的にコメントが書けていないので、まとめて書くという作業も必要になりそうです。これは、別のタスクで対応します。

...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
1 change: 1 addition & 0 deletions src/renderer/src/components/menu.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const MENU_TIMELINE = { path: '/', name: 'タイムライン' };
export const MENU_SETTING = { path: '/setting', name: '設定' };
export const MENU_ACTIVITY_USAGE = { path: '/activity_usage', name: 'アプリ使用時間' };
export const MENU_POMODORO_TIMER = { path: '/pomodoro_timer', name: 'ポモドーロタイマー' };
Loading
Loading