ブログBlog

    • Next.jsで工数管理アプリを作ってみた

    • 2025年3月3日
    • Next.js
    • React
    • TypeScript

はじめに

この記事では、Next.jsを使って作成した工数管理アプリを紹介します。

このアプリを作成するきっかけはプロジェクトでチケットの時間見積もりが正確でないことに課題を感じたことでした。

そこで、見積もり精度を高めるために「チケットにどれだけ時間がかかったのか」、「その予想完了時間に対してどれだけ乖離があったのか」を分析する必要があると考え、まずはタスクの実行ログを取るアプリを作成することにしました。

多くの機能を実装しましたが、今回はその中から2つの機能を抜粋してどのように実装したかを紹介します。

この記事をおすすめしたい人

  • TypeScriptでアプリ開発したい人
  • 工数管理の手法に悩んでいる人

開発環境

  • フレームワーク: Next.js
  • 言語: TypeScript
  • 開発期間: 約3ヶ月
  • デザインフレームワーク: Material-UI

    参考:https://mui.com/

紹介する機能

ポモドーロタイマー機能

ポモドーロテクニックは、作業に集中する時間と短い休憩を繰り返すことで、集中力を持続させる時間管理手法です。

よく見るのは集中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の開発について学ぶことができました。

皆さんのアプリ実装のモチベーションにつながれば幸いです。

今後も改善を重ねていきたいと思います。

さいごに

ソリューションウェアでは、さまざまな分野の案件を幅広く持ち合わせておりスキルアップには最適の環境です。

自身のスキル向上に悩んでいる方、エンジニアとしてもう一皮むけたいと考えている方、私たちと一緒に働きませんか?

「まずはカジュアルにお話だけ」というのも可能ですので気になる方は応募フォームよりお申込みください。

ソリューションウェア採用情報

この記事を書いた人 : ブログチーム

一覧へ戻る

開発についてのお問い合わせはこちら

お問い合わせ

JOIN OUR TEAM

積極採用しています。私たちと一緒に働きませんか?