-
Next.jsで工数管理アプリを作ってみた
- 2025年3月3日
- Next.js
- React
- TypeScript

はじめに
この記事では、Next.jsを使って作成した工数管理アプリを紹介します。
このアプリを作成するきっかけはプロジェクトでチケットの時間見積もりが正確でないことに課題を感じたことでした。
そこで、見積もり精度を高めるために「チケットにどれだけ時間がかかったのか」、「その予想完了時間に対してどれだけ乖離があったのか」を分析する必要があると考え、まずはタスクの実行ログを取るアプリを作成することにしました。
多くの機能を実装しましたが、今回はその中から2つの機能を抜粋してどのように実装したかを紹介します。
この記事をおすすめしたい人
- TypeScriptでアプリ開発したい人
- 工数管理の手法に悩んでいる人
開発環境
- フレームワーク: Next.js
- 言語: TypeScript
- 開発期間: 約3ヶ月
- デザインフレームワーク: Material-UI
紹介する機能
ポモドーロタイマー機能
ポモドーロテクニックは、作業に集中する時間と短い休憩を繰り返すことで、集中力を持続させる時間管理手法です。
よく見るのは集中25分、休憩5分を1シーケンスとしてそれを繰り返すパターンです。
今回はそれを管理するためのタイマー機能を実装しました。
通知機能
タスク追加ボタンを押してタスクを登録します。その後タスクごとに期限を設けることができます。(タスクの更新と削除が可能です)
タスクの期限が迫ったらアプリから通知が来るように通知機能を実装しました。
以下のように通知の許可をすると通知パネルが表示されます。
- 権限の許可

- 通知パネル

ポモドーロタイマー機能の実装
react-timer-hookでタイマー実装
「React Timer」と検索するとreact-timer-hookというモジュールでカウントダウンタイマーが簡単に実装ができるようでした。
詳しい使い方は下記をご確認ください。
参考: https://www.npmjs.com/package/react-timer-hook
これを使用して以下のようにタイマーを実装し、集中時間と休憩時間それぞれのタイマーを下記のように実装することができました。
app/components/Timer.tsx
import { useEffect, useState } from "react";
import { useTimer } from "react-timer-hook";
import useSound from "use-sound";
import { Button, InputLabel, TextField } from "@mui/material";
// @ts-ignore
import operationEndSound from "./../assets/operation_end.mp3";
// @ts-ignore
import restEndSound from "./../assets/rest_end.mp3";
import styles from "../css/Timer.module.css";
const Timer = ({
onTimerUpdate,
}: {
onTimerUpdate: (number: number) => void;
}) => {
const [operatingMinutes, setOperatingMinutes] = useState<number>(25);
const [restMinutes, setRestMinutes] = useState<number>(5);
const [isResting, setIsResting] = useState(false);
const [operationSoundPlay] = useSound(operationEndSound, { volume: 0.3 });
const [restSoundPlay] = useSound(restEndSound, { volume: 1 });
const [isInputHidden, setIsInputHidden] = useState(false);
const settingDateObject = (min: number) => {
const date = new Date();
date.setSeconds(date.getSeconds() + min * 60);
return date;
};
// ここでタイマーが0になったときになる音を設定
const soundPlay = () => {
isResting
? (() => {
restSoundPlay();
restart(settingDateObject(restMinutes), false);
})()
: (() => {
operationSoundPlay();
restart(settingDateObject(operatingMinutes), false);
})();
};
const {
totalSeconds,
seconds,
minutes,
isRunning,
start,
pause,
resume,
restart,
} = useTimer({
expiryTimestamp: isResting
? settingDateObject(restMinutes)
: settingDateObject(operatingMinutes),
onExpire: soundPlay,
});
// userEffect類は省略
useEffect(() => {
pause();
}, []);
useEffect(() => {
!isResting && restart(settingDateObject(operatingMinutes), false);
}, [operatingMinutes]);
useEffect(() => {
isResting && restart(settingDateObject(restMinutes), false);
}, [restMinutes]);
useEffect(() => {
isResting || onTimerUpdate(totalSeconds);
}, [totalSeconds]);
useEffect(() => {
isResting
? restart(settingDateObject(restMinutes), false)
: restart(settingDateObject(operatingMinutes), false);
}, [isResting]);
const switchSetting = async () => {
setIsResting(!isResting);
};
const selectedRestart = (isAutoStart: boolean) => {
isResting
? restart(settingDateObject(restMinutes), isAutoStart)
: restart(settingDateObject(operatingMinutes), isAutoStart);
};
return (
<div className={isResting ? styles.restTimer : styles.operatingTimer}>
<h1>ポモドーロタイマー</h1>
<div style={{ fontSize: "100px" }}>
<span>{minutes}</span>:<span>{seconds}</span>
</div>
<Button
sx={{ marginBottom: "10px" }}
variant="contained"
size={"small"}
color="inherit"
onClick={() => {
setIsInputHidden(!isInputHidden);
}}
>
Hidden
</Button>
{isInputHidden || (
<div style={{ backgroundColor: "white", padding: "20px" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<InputLabel>作業時間</InputLabel>
<TextField
type="number"
value={operatingMinutes}
color="primary"
onChange={(event) => {
setOperatingMinutes(Number(event.target.value));
}}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<InputLabel>休憩時間</InputLabel>
<TextField
type="number"
value={restMinutes}
color="primary"
onChange={(event) => {
setRestMinutes(Number(event.target.value));
}}
/>
</div>
</div>
)}
<div
style={{
backgroundColor: "white",
display: "flex",
justifyContent: "space-around",
padding: "20px",
}}
>
<Button variant="contained" size={"small"} onClick={start}>
Start
</Button>
<Button
variant="contained"
size={"small"}
color="error"
onClick={pause}
>
Pause
</Button>
<Button
variant="contained"
size={"small"}
color="success"
onClick={() => {
switchSetting();
selectedRestart(false);
}}
>
Switching
</Button>
<Button
variant="contained"
size={"small"}
color="secondary"
onClick={() => selectedRestart(false)}
>
Reset
</Button>
</div>
</div>
);
};
export default Timer;
上記のコードでタイマーが動くようになったのですが、画面が不活性になったタイミングでタイマーが止まる現象が発生しました。
画面が不活性の時にタイマーが止まる不具合の解消
アプリのウインドウを離れて別のウインドウで作業をしているとタイマーが止まってしまうという現象が発生してしまいました。
“リソース節約のため、ブラウザ側が裏側でよしなに実行を遅らせたり、停止させたりしていた”(下記の参考記事から抜粋)とのことです
参考:https://www.kageori.com/2024/05/setintervalweb-worker.html
通常のコードはブラウザ上のメインスレッドで実行されますが、WebWorkerを使用することでバックグラウンドスレッドで実行されるようになります。
バックグラウンドスレッドでカウントさせることでタイマーが勝手に停止することを防ぐことが可能です。
参考:https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Using_web_workers
WebWorkerの実装
app/libs/secondsTimer.tsx
let interval: any;
let i = 0;
// メインスレッドからスタートのメッセージがきたらタイマースタート
self.onmessage = ({ data }) => {
if (data === "start") {
if (!interval) {
interval = setInterval(() => {
i++;
// 1秒ごとにメインスレッドにカウントを送信
postMessage(i);
}, 1000);
}
} else if (data === "stop") {
if (interval) {
clearInterval(interval); // インターバルを停止
interval = undefined; // intervalをリセット
postMessage(0);
}
}
};
Timerの実装
app/components/Timer.tsx
// Reactのコンポーネントの外でWorkerを作成
let worker = new Worker(new URL("../../libs/secondsTimer.ts", import.meta.url));
const Timer = ({
onTimerUpdate,
}: {
onTimerUpdate: (number: number) => void;
}) => {
useEffect(() => {
isInitialRender.current = false;
if (isStarting) {
// Worker内のタイマーをスタートさせる
worker.postMessage("start");
}
}, []);
useEffect(() => {
if (!isStarting && sumTime >= 0) {
timer.current = setInterval(() => {
notifySoundPlay();
}, 10000);
} else {
clearInterval(timer.current);
}
// Workerのタイマーをストップさせる
worker.postMessage(["stop", sumTime]);
localStorage.setItem("isStarting", `${isStarting}`);
}, [isStarting]);
worker.onmessage = (e) => {
// workerからタイマー秒数が返ってくるのでlocalStrageに保存
localStorage.setItem("sumTime", Number(e.data));
addSum.current(e.data);
};
タイマーの配置を自由に変更できる機能を追加
ポモドーロタイマーウィンドウをブラウザの中に固定するのではなく、ウィンドウの配置を自由に変更できるよう実装しました。
virtual-windowというモジュールで実装することができました!(詳細は以下リンクをご確認ください)
参考:https://www.npmjs.com/package/@react-libraries/virtual-window
通知機能の実装
チケットのデッドラインが近い場合に通知パネルを出すようにしました。
JavaScriptのNotification APIを使って簡単に実装可能です。
参考にしたのは以下のドキュメントです。
参考: https://developer.mozilla.org/ja/docs/Web/API/Notification
通知機能の実装
app/hooks/useTaskNotification.ts
import { useEffect } from "react";
import { useSelector } from "react-redux";
import { getNotifyAbleTickets } from "../stores/ticketSlice";
import dayjs from "dayjs";
import notifySound from "../assets/notify2.mp3";
import useSound from "use-sound";
const useTicketNotification = () => {
const notifyAbleTickets = useSelector(getNotifyAbleTickets);
const [notifySoundPlay] = useSound(notifySound, { volume: 100 / 100 });
useEffect(() => {
// ここで通知権限を許可するかポップアップをだしている。
Notification.requestPermission();
}, []);
useEffect(() => {
setInterval(
() => {
notifyAbleTickets.forEach((ticket) => {
const diff = dayjs().diff(ticket.deadline, "d");
if (!ticket?.isNotified && diff === 0) {
new Notification("👀締め切り当日", { body: ticket.title }).onshow =
() => {
notifySoundPlay();
};
} else if (!ticket?.isNotified && diff === 1) {
new Notification("🔴締め切り1日前", { body: ticket.title }).onshow =
() => {
notifySoundPlay();
};
} else if (!ticket?.isNotified && diff === 2) {
new Notification("❗️締め切り2日前", { body: ticket.title }).onshow =
() => {
notifySoundPlay();
};
} else if (!ticket?.isNotified && diff > 2) {
new Notification("❌大幅な遅れ(2日以上)", {
body: ticket.title,
}).onshow = () => {
notifySoundPlay();
};
}
});
},
// 1hに1回通知される設定
1000 * 60 * 60,
);
}, [notifyAbleTickets]);
};
export default useTicketNotification;
所感
ブラウザで動くJavaScriptエンジンには「Notification」がWeb APIとしてビルトインされているため、importが必要なくそのまま使用できます。
一方、サーバーで動くNode.jsエンジンには「Notification」がビルトインされていないため使用できないようでした。
ブラウザとサーバーでJavaScriptエンジンが提供する機能が異なる点は知っていましたが、通知機能の実装を通じてエンジンの違いをより身近に感じられました。エンジンごとの違いを整理してみるのも面白いかと思います。
ポモドーロタイマーを活用してタスクの実績時間を記録する仕組みを取り入れることで、工数管理という目的はある程度達成できたと感じています。
しかし、記録するだけでは不十分であり、次のステップとして実績時間を分析する機能が必要だと考えています。分析機能があって初めて現状の課題を見える化できるようになるからです。
現状に満足せずさらにUXを向上させるために、今後は分析機能の実装に取り組みたいと思います。
まとめ
タスク管理アプリの実装を通して、Next.jsの開発について学ぶことができました。
皆さんのアプリ実装のモチベーションにつながれば幸いです。
今後も改善を重ねていきたいと思います。
さいごに
ソリューションウェアでは、さまざまな分野の案件を幅広く持ち合わせておりスキルアップには最適の環境です。
自身のスキル向上に悩んでいる方、エンジニアとしてもう一皮むけたいと考えている方、私たちと一緒に働きませんか?
「まずはカジュアルにお話だけ」というのも可能ですので気になる方は応募フォームよりお申込みください。
この記事を書いた人 : ブログチーム
AWS bluebird css CSV docker docker compose electron ES6 es2015 Git Heroku ITコンサルティング JavaScript justinmind less MongoDB Node.js php PostgreSQL Private Space Promise React react-router reactjs Salesforce scss Selenium Builder selenium IDE Selenium WebDriver stylus TypeScript VirtualBox VisualStudioCode vue vuejs webpack システム開発プロジェクト セキュリティ ワイヤーフレーム 上流工程 卒FIT 帳票 要件定義 設計 電力小売業界