ソースコード/アプリURL
ソースコード(GitHub)
アプリURL(Vercel)
※ ブラウザ上のIndexedDBを利用しているため、一部ブラウザでは機能しないことがあります。
サンプルデータ(私のこのアプリの制作中の稼働記録です)
データのインポート方法について詳細は共通ツールバーの項目から
このアプリについて
作成の経緯
この稼働以前に3ヶ月間ReactでTODOリストや名刺アプリをFEを作成、Unity1ヶ月とUE5を1ヶ月ずつゲーム作成を行っていました。
上記の稼働記録について、稼働内容の概要と時間をスプレッドシートで、メモをドキュメントで管理していました。
このやり方だとシートからある程度の概要と稼働時間は読み取れますが、詳細についてドキュメントからメモを引っ張り出す場合に手間です。
また、ちょっとした内容であれば記述を省いていることもあるので探す時間が無駄になることもあります。
そのため、上記の問題を解決する稼働管理のアプリケーションを作ることにしました。まず、上記の問題を解決する要件として、
- 日付ごとに細かい粒度のメモがたくさん作れる機能
- やったことに対してのメモ一覧を確認できる機能
上記2点を必須条件として、ついでに「稼働管理」という点に焦点を当てて、データの集計と表示機能の実装も目標としました。
環境構築
OSはMacでコードはVSCodeで管理しています。
プロジェクトは npx create-next-app@latest でNext.jsで作成しています。
使用した主なライブラリは以下です。
- MUI/recharts : UI作成に利用
- React Hook Form : フォーム管理に利用
- date-fns : 日付のフォーマットや計算に利用
- swr : データフェッチに利用
- dexie : IndexedDB操作に利用
また、途中で利用をやめたライブラリは以下です。
- aspida : API通信時の型安全の確保のため BE不要となったため使用を停止
- prisma : DB操作に利用 上記と同様の理由で使用を停止
作成期間
概要
全体時間:393.75(h)
期間:2ヶ月強(2025/03/25~2025/06/09)
稼働日数:55日間(平均稼働時間:7.1(h))
工程ごとの作成期間
期間 | 稼働日数 | 工程名 |
---|---|---|
03/24 | 1日間 | 制作計画(詳細) |
03/25~04/21 | 20日間 | FEの基本ページ作成 (詳細) |
04/22~04/28 | 5日間 | BE作成(詳細) |
04/29~05/18 | 14日間 | 追加UI作成(詳細) |
05/19~05/21 | 3日間 | BE -> dexie.js + IndexedDBへの移行(詳細) |
05/24 | 1日間 | ダークテーマ実装(詳細) |
05/25~06/09 | 12日間 | リファクタリング+実際に使ってテスト+デプロイ(詳細) |
アプリの概要
全体
- 稼働記録について日毎に稼働時間、メモを記録可能
- 稼働記録について大区分「カテゴリ」小区分「タスク」で管理
- タスクは1つのカテゴリに属する 稼働対象としてはタスクを選択する
- タスク・カテゴリについてそれぞれ集計したデータを表示可能
メインページ
- 各ページへのナビゲーションと最近の記録を表示可能
- ナビゲーション
- 日付/タスク/カテゴリページへそれぞれ移動可能
- 今日/昨日の日付ページへ移動可能
- 最近の記録
- 過去1ヶ月のカテゴリの割合を円グラフで表示
- クリックで特定のカテゴリページへ移動可能
- 過去1ヶ月の稼働タスクをテーブルで表示
- クリックで特定のタスクページへ移動可能
- 最近(~過去5週間)の日毎の稼働時間をヒートグラフで表示
- ホバー時に稼働時間が確認でき、クリックすると該当ページへ移動可能
- 過去1ヶ月のカテゴリの割合を円グラフで表示
- ナビゲーション
日付ページ
- 「1日ごと」に焦点を当ててデータを表示/集計
- 一覧ページでは日毎に集計したデータを表示
- 稼働時間の合計と最も稼働の多かったカテゴリ/タスクを確認可能
- 最も稼働の多かったタスクに関するメモのタイトル一覧を確認可能
- 詳細ページでは1日の稼働一覧の集計と表示
- 稼働記録と稼働記録のメモ一覧が確認可能
- 稼働の割合をカテゴリごとにまとめた円グラフで確認可能
タスクページ
- タスクに焦点を当ててデータを表示/集計
- 一覧ページではタスク毎のデータの表示と一括編集が可能
- タスク名/属するカテゴリ/進捗率/合計稼働時間/稼働開始/最終稼働日が確認可能
- 一括で進捗率を変更することが可能
- 詳細表示ではタスクの詳細情報の表示と編集が可能
- 稼働日の一覧とメモの一覧を確認可能
- 稼働日のページへ移動可能
- メモの編集が可能
- タスク名/属するカテゴリ/完了状態を編集可能
- 誤って作成した場合に削除可能(利用されている場合は削除不可)
- 稼働日の一覧とメモの一覧を確認可能
カテゴリページ
- カテゴリに焦点を当ててデータを表示/集計
- ページ左部分ではカテゴリ間の比較データを表示
- カテゴリごとに特定期間内の稼働時間/タスク数を折れ線グラフで表示可能
- 上位3カテゴリの値をランキング方式で表示可能
- グラフをクリックすると詳細データを表示可能
- ページ右部分では特定のカテゴリの詳細情報の表示と編集が可能
- 特定の期間のタスク稼働時間割合を円グラフで表示
- カテゴリに属するタスク一覧を表示
- カテゴリの合計稼働時間と稼働期間を表示
- カテゴリ名/タスク追加/完了状態を編集可能
- 誤って作成した場合に削除可能(利用されている場合は削除不可)
アプリの操作について
共通ツールバー
全てのページで上部に表示されるツールバーです。表示中のページのパスを読み取ってナビゲーションを表示します。クリックするとそのページへ移動することもできます。
また、左のメニューボタンを開くと設定ドロワーが開閉してデータ管理と表示設定について設定することができます。
データ管理の項目ではエクスポートによって稼働データのダウンロード、逆にインポートでダウンロードした稼働データを読み込むことができます。また、データリセットによってデータの削除が可能です。
また、データはブラウザ上のIndexedDBで管理されており、ブラウザからデータを削除するかインポート/データリセットしない限りは保持されます。
表示設定の項目ではダークテーマ/ライトテーマの切り替えが可能です。テーマ設定はlocalStrageに記録されるため、再度アクセス時にも適応されます。
メインページ
最近の稼働情報の表示と各種ページへの移動が可能です。
最近の稼働データがある場合、各グラフ・テーブルから特定のページへ移動することもできます。
日付ページ(一覧)
このページでは日付ごとの稼働情報を見ることができます。
メインカテゴリとメインタスクには最も稼働時間の長かったタスクの情報が表示されます。また、メモの欄ではそのタスクのメモタイトル(ホバー時にはその一覧)を表示することができます。
各行をクリック、または左上部の「今日を編集」「日付を指定して編集」をクリックすると詳細ページへ移動可能です。
日付を指定して編集する際にはもう少し詳細なデータを表示することもできます。
日付ページ(詳細)
このページでは特定の日付の稼働記録の表示と編集ができます。
稼働記録を追加するには[タスクを追加する]をクリックします。
カテゴリとタスクを選択(または必要であれば新規作成)して追加をすると左下のタスクに追加されます。
また、テーブルの稼働記録を選択して編集ボタンをクリックすると稼働時間やタスクの変更、メモの追加ができます。(メモの追加は左上のボタンからも可能です)
メモを追加する際にはタスクを選び(タスクの編集から入る場合は固定されます)、タイトルと本文を入力すると追加できます。
また、誤って入力中に閉じないように追加可能な状態で誤ってキャンセルボタンを押しても即座に閉じないようにポップを挟んでいます。
稼働とメモの一覧はテーブルで表示され、稼働を選択するとそれに関連するメモがハイライトされます。
また、当日の稼働をカテゴリ別に稼働時間割合の円グラフで表示したり、稼働時間合計を確認することができます。
タスクページ(一覧)
ここではタスクごとの進捗率/合計稼働時間/稼働開始/最終稼働日を確認できます。カテゴリの項目にホバーすると特定のカテゴリのみを表示したり、「表示範囲を変更する」からより細かい条件で表示対象をフィルターすることが可能です。
また、進捗とお気に入りは一括で編集することができ、右上の保存・破棄ボタンで編集内容の確定及び破棄することができます。
また、タスクをクリックして選択した状態で左上の「詳細ページへ移動」ボタンをクリックするとそのタスクの詳細ページへ移動可能です。
タスクページ(詳細)
ここでは一覧ページの情報に加えて、タスクの実施日の一覧とタスクに関するメモ一覧が確認できます。また、タスク名の編集と完了状態の移行も行えます。
日付の下のボタンをクリックすると稼働日一覧のカレンダーを表示できます。また、稼働日をクリックするとその日付ページへ移動することができます。
右下のボタンからはタスクのタスク名/カテゴリ/お気に入り状態を変更可能です。
「完了状態にする」を押すとタスクの進捗が100%となり日付ページで稼働を追加する際の選択候補から除外されます。
また、削除については誤って作成した場合を想定しており、タスクが利用されていない場合のみ削除可能となっています。
カテゴリページ
このページではカテゴリの詳細データとカテゴリ間の比較データが確認できます。
ページの右半分では特定のカテゴリの情報や、カテゴリに属するタスクの一覧と期間内のタスクの稼働時間の割合を確認できます。
また、カテゴリ選択の横の+ボタンからカテゴリの新規追加、メニューボタンからはタスク追加、完了状態への移行、カテゴリの削除が可能です。
ページ左半分では、カテゴリ間の稼働時間またはタスク数を期間を選択して比較することができます。
設定ボタンを押すことで集計期間や比較対象を変更したり、特定のカテゴリのグラフの表示/非表示を変更することができます。
制作について
制作計画
制作経緯に関しては 作成の経緯に記述しています。
まずは作りたいものの要件を元に考えて、ペイントで大まかなデザインを作成しました。こういった経験はないのでかなりアバウトですが、それでも作るもののイメージができてよかったと思います。
また、データの流れをイメージするために、あらかじめ必要そうなデータの型定義もまとめました。
そして、基本上記のデザインと型定義を元に作成しつつ、適宜必要な要素がある場合に追加する形で進行することに決めました。
FEの基本ページ作成(03/25~04/21)
まずは計画に従って各ページを作成しました。
計画からの大きな変更点として、
- メモページ(タスクの一番下)は廃止
- 代わりにダイアログで表示するように設定
加えて、デザインからの追加要素として、
- 日付一覧 日付を指定して移動するダイアログ
- 日付詳細 メモの一覧表示
- タスク一覧 一括更新機能
この3点を追加しました。
特に3番目の一括更新機能については複雑な設計で少し大変だったので下に少しメモを残します。
3番目の設計の詳細
この一括更新を行う機能では
- 子(TableBody)でRHF(react-hook-form)を用いてフォームを管理
- 親(ページ)で子から一斉に送信する
という方式をとっています。
子のデータの変更の有無についてはonDirtyChangeという関数を親から子に受け渡して、子で変更があった場合に親へ通知を行うことで実装しています。
useEffect(() => {
onDirtyChange(taskItem.id, isDirty);
// アンマウント時にはisDirtyをfalseにする
return () => (isDirty ? onDirtyChange(taskItem.id, false) : undefined);
}, [isDirty, onDirtyChange, taskItem.id]);
const [isDirtyRecord, setIsDirtyRecord] = useState<Record<number, boolean>>(
{}
);
const onDirtyChange = useCallback((id: number, isDirty: boolean) => {
setIsDirtyRecord((prev) => ({ ...prev, [id]: isDirty }));
}, []);
const isDirty = useMemo(
() => !Object.values(isDirtyRecord).every((value) => value === false),
[isDirtyRecord]
);
次に、保存と破棄の処理についてはrefを利用しています。親からrefを受け取り、子はrefに保存と破棄の処理を登録し、それを親で実行しています。
// 親に渡すメソッド
useImperativeHandle(ref, () => ({
getFormData: () => getValues(), // RHFのデータ取得
resetFormData: () => reset(), // RHFのデータを戻すメソッド
}));
親で処理を行う際はisDirtyRecordを用いて変更のあるデータのみ処理しています。
// 変更のある対象のキーを取得する関数
const getTargetKeys = useCallback(() => {
const keys = Object.keys(rowRefs.current);
const filtered = keys.filter((key) => isDirtyRecord[Number(key)] === true);
return filtered;
}, [isDirtyRecord]);
// ...
const handleSaveAll = useCallback(async () => {
const result = [];
const targetKeys = getTargetKeys(); // 変更のあるキーのみを取得
// 更新データを取得
for (const key of targetKeys) {
const ref = rowRefs.current[Number(key)];
const data = ref.current?.getFormData(); // <- ここでrefから関数よ
result.push({ id: Number(key), ...data }); // 判別ようにidを付与
}
// いずれかのデータの進捗が100%になる場合はダイアログ表示
const isAnyCompleted = result.some((v) => v.progress === 100);
if (isAnyCompleted) {
sendData.current = result; // refに送信データを一時保存
onOpenComplete();
} else {
updateAll(result);
}
}, [getTargetKeys, onOpenComplete, updateAll]);
refはReactのドキュメントで存在は知ってましたが、いまいち利用方法にピンと来てなかったこともあって実装で手間取る点もありましたが、なんとか実装することができました。
各ページを作成後、ページネーションの実装を行いました。
Next.jsのappルーターについて、学習しながら作成を並行進行していたため、以下の2点についてはページネーション実装時に修正を行なって対応しました。
- クライアントサイド/サーバーサイドのレンダー(CSR/SSR)について
- CSRで利用するコンポーネントを用いる場合は"use client"でクライアントサイドであることを明示する必要がある(フォームやMUI全般)
- 加えて、MUIであればAppRouterCacheProviderでラップする必要がある
- その他ライブラリによってSSRに対応していない場合は動的インポートしてSSRを制御する必要がある
const MainPagePieChart = dynamic(
() => import("./component/pie-chart/MainPagePieChart"),
{
ssr: false,
- ディレクトリ構成について
- appを起点として各ディレクトリがパスとなり、page.tsxが表示されるページとなる
上記を修正した後、next/navigationのuseRouterを用いてページネーションを実装しました。
BE作成(04/22~04/28)
データベース作成
データベース(DB)は無料かつ簡潔なものがよかったため、SQLiteを選択しました。
また、DB操作を行うためのDBクライアントにはPrismaを選択しました。
スキーマの型定義は計画を元に、そこから微調整して作成しました。
その後、マイグレーションを行なってDBを作成しました。
リクエスト実装
FE側において、リクエストの型安全を確保するためにaspidaを用いてAPIクライアントを作成し、リクエストボディ/クエリ/レスポンスボディの型定義を認識できるようにしました。
そして、各種リクエストには上記のクライアントを用いました。
また、GETリクエストについてはaspidaを内包しているSWRであるaspida/swrを用いました。
BE側ではprismaによるデータベース操作のロジックをlib/services内で管理して、appルーターの仕様に従ってapp/apiを起点として各エンドポイントのディレクトリにroute.tsを作成して各種リクエストを実装しました。
例:タスクの選択賜
まずaspidaの型定義を行います。
以下のファイル作成後にnpm run api:buildでapiClientを更新します。
import { TaskOption } from "@/type/Task";
import { DefineMethods } from "aspida";
export type Methods = DefineMethods<{
get: { query: { categoryId: number }; resBody: TaskOption[] };
}>;
その後、BE側にてservicesでロジックを定義、route.tsでリクエストハンドラーを作成します。
/**
* タスク選択賜一覧げっとする関数
*/
export const getTaskOptions = async (categoryId: number) => {
const data: TaskOption[] = await prisma.task.findMany({
where: {
categoryId: categoryId,
progress: { not: 100 /** 完了済みは取得しない */ },
},
select: { id: true, name: true },
});
return data;
};
import { getTaskOptions } from "@/lib/services/taskService";
import { TaskOption } from "@/type/Task";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
req: NextRequest
): Promise<NextResponse<TaskOption[]>> {
const { searchParams } = new URL(req.url);
const categoryId = Number(searchParams.get("categoryId") ?? "0");
const data = await getTaskOptions(categoryId);
if (data.length === 0)
return NextResponse.json([{ id: 0, name: "タスクがありません" }]);
return NextResponse.json(data);
}
その後、FE側でapiClientを呼び出して実装します。
const { data: categoryData, isLoading: isLoadingCategory } = useAspidaSWR(
apiClient.work_log.categories.options,
"get"
);
また、リクエスト後にUIへ反映する必要がある部分についてはSWRのmutateを用いてキャッシュを更新することでそれぞれ対応しています。
追加UI作成(04/29~05/18)
前項で最低限のFEとBEが完成しました。
この項目では追加で欲しい機能を検討し、それらの実装を行いました。
既存のUIの機能追加
- カテゴリページ:カテゴリの情報の表示機能と編集機能がなかったため、ヘッダーにこれらの機能追加
- カテゴリ選択のSelectだけ -> カテゴリの情報とカテゴリの操作メニューを追加
- カテゴリの情報(稼働時間合計/開始~実施日)が表示可能に
- カテゴリの完了/削除/属するタスクの追加が可能に
- カテゴリ選択のSelectだけ -> カテゴリの情報とカテゴリの操作メニューを追加
- 日付(詳細):タスク編集ダイアログの項目を追加
- 直感的にタスクへメモを追加できるようにメモを追加ボタンを追加
- タスクの進捗率を変更可能にする機能をMUIのSliderで実装
新規UIの作成
- メインページ:日付ごとの稼働ヒートグラフ
- タスク(テーブル)、カテゴリ(円グラフ)の表示項目はあるが、日付についての項目がなかったため追加
- タスク(一覧):表示タスクの範囲設定ダイアログ
- 細かい条件で絞るために追加
- タスク(詳細):稼働日を一覧表示できるカレンダー
- 稼働開始/最終稼働だけでなく、より詳しい稼働日の情報が欲しかったため
- 加えて、日付ページへナビゲーションする機能が欲しかったため追加
- ページ共通:タグ編集ダイアログ
- タグについて、追加ダイアログしかなかったため各種機能を持ったダイアログを作成
- 追加/削除/編集機能と、編集/削除時に利用中か判定してユーザーへ通知する機能を実装
- 共通レイアウト:設定ドロワー
- データ管理/表示設定をする機能が欲しかったので実装
- 共通レイアウトのヘッダーに配置することで全ページから開けるように実装
また、ロード中/データがない場合の表示のコンポーネント化も同時に行いました。
BE->dexie.js+IndexedDBへの移行(05/19~05/21)
データ管理について、現在のBEを用いてサーバー上で管理する形式からブラウザ上のIndexedDBを利用する方法を検討することになりました。
この理由として、DBアクセス時のユーザー認証の手間を省いて利用しやすくするのと、サーバー管理の必要性を省くためです。
加えて、インポート/エクスポート機能の実装が簡単にできそうな点も考慮しました。
そして、IndexedDBを用いて管理するにあたって、現在のBEの実装をIndexedDBを操作可能なdexie.jsを用いる手法に移行することを決定しました。
初めはSQLite+prismaの状態でそのまま移行できないか検討して、IndexedDB+SQLiteの要件を満たしそうなものとしてsql.jsを見つけたのですが、prismaの併用をする場合にFEだけで完結させるのは難しいようなので利用を見送りました。
移行するとBEは不要となるのですが、今後BEを利用する可能性がゼロではないためそのまま残しました。また、移行時を想定して現在のaspidaのapiClintと互換性のあるlocalClientを自作して移行しました。
// aspidaの場合
apiClient.work_log.categories.options,
"get",
{
query: { displayRange: "all", hideCompleted: "true" },
// localClientの場合
localClient.work_log.categories.options.get({
query: { displayRange: "all", hideCompleted: "true" },
})
ちなみに、localClientではlib/localServicesのロジックを直接呼び出しています。
categories: {
options: {
get:
({ query }: { query?: CategoryPanelQuery }) =>
() =>
getCategoryOptions(query),
},
データフェッチにおいて、aspida/swrはAPI通信でのみ利用可能なため、通常のswrに置き換えを行いました。
通常のswrであればFEで完結する場合でもdexie.jsの処理をBEに見立ててデータのキャッシュを行うことができます。
ダークテーマ実装(05/24)
MUIのThemeProviderを用いて実装しました。
基本的にはMUIのライトテーマ/ダークテーマの色を用いて、自身で設定している箇所はMUIのパレットを拡張することで実装しました。
<Typography color="text.primary">
// ...
declare module "@mui/material/styles" {
interface Palette {
gradient: {
/** グレースケール */
gray: {
/** メリハリの少ないグレースケール */
soft: string;
// ...
}
// PaletteOptionsはテーマのオプションを拡張するための型定義(color:aaa.bbbみたいに使える)
interface PaletteOptions {
/** グラデーション系 */
gradient?: {
/** グレースケール */
gray?: {
/** メリハリの少ないグレースケール */
soft?: string;
// ...
}
export const lightTheme = createTheme({
palette: {
mode: "light",
gradient: {
gray: {
soft: "linear-gradient(to right, rgb(255, 255, 255), rgb(220, 220, 220))",
// ...
また、MUI以外(今回の実装ではrechartsのみ)で作成したコンポーネントについては、useThemeでテーマを呼び出すことで対応しています。
<Pie
// ...
fill={theme.palette.recharts.pie.defaultFill}
/>
リファクタリング+実際に使ってテスト+デプロイ(05/25~06/09)
一通り実装が終わった時点で、まずリファクタリングを行いました。
- 型定義
- クエリの型定義を型定義に追加
- UI
- ダイアログのボタンの配置を統一
- パフォーマンス
- コンポーネントのメモ化
- パラメータの繰り下げ(再レンダーの制御)
- コンポーネント共通化
- テーブルを使い回し可能な形式に変更
その後実際に使ってみて再度欲しい機能の検討と、また利用中に見つかったバグをメモして修正を行いました。
要素の追加の一例として、以下を追加しました。
- 日付詳細ページで前後の日付にナビゲーションしたい -> ボタンを追加
- メモを追加する際に誤ってキャンセル押した際の対策 -> 入力中ならPopoverでワンクッション挟む
- カテゴリ間で比べる機能が欲しい -> 折れ線グラフで比較するグラフを追加
そして、機能の追加/バグ修正の区切りがついた時点でデプロイを行うことにしました。
デプロイ先はNext.jsであれば簡単かつ、無料利用可能という理由でvercelを利用しました。
build時のエラーを解消した後にデプロイして完了です。
デプロイ後に気づいた点を変更する際には、mainブランチからdevブランチを切り出し、devブランチから作業ブランチを切って実装しました。
最後に
長文になりましたが、ここまで読んで頂いてありがとうございます。あまり記事をまとめるのが得意ではないので、読みにくい部分があったらすいません。
このアプリはポートフォリオの制作と自分で使う2つの目的で作成しました。そのため、とくn欲しい機能はどんどん追加していったので、結果として多くの学びを得ることができたと思います。
また個人開発であったこともあり、今回プロジェクトルールは詳細に設定せずに大体でやっていたためその点は改善が必要だと思いました。一方でプロジェクト進行を想定してGitHubを用いてPRを作成した点についてはプロジェクト想定の経験値を得ることはできたと思います。
最後に、繰り返しになりますが、こちらの記事を読んで頂きありがとうございました。