フリーランスとして活動していく上で、自分が稼働したプロジェクトや案件についてどのくらいの時間がかかったのか管理することってすごく大事ですよね。例えばホームページ制作を10万円で受けたとして、10時間で作るのか、100時間で作るのかで自分の時間単価は変わってくるわけで。
そして一度に一つの案件だけ動いているというのはなかなか考えにくいと思っていて、やっぱり一つの契約先であっても複数の稼働案件があるっていうことが多いと思うので、そういった時に「何に」「どのくらいの時間を割いたか」は分かっておいた方がよいと思います。
ということで、今回は自己学習も兼ねて複数プロジェクトのカウントアップタイマーを作ってみましたのでアウトプットします。
結構AIに助けてもらいながら作っていったので、自分の中での整理も兼ねています。
作成物
稼働管理タイマー
このような感じで、プロジェクト追加をクリックするとプロジェクト名と稼働時間の開始・一時停止ができるようになります。
そして終了したものについては削除をクリックするとデータが削除されるというものになっています。
使用技術
- Typescript
- Next.js
- Supabase
- TailwindCSS
をメインで利用しました。コンポーネントについては、shadcn/uiを使用しています。
稼働管理ページのコード
import { useState, useEffect } from "react";
import { CardWithForm } from "@/components/Card"
import { Button } from "@/components/ui/button";
import { supabase} from "@/lib/supabase"
Hooksのインポートと、shadcnからのインポート、あとは自作したCardWithFormコンポーネントをインポートしています。
//プロジェクトに関する状態管理
const [projects, setProjects] = useState<typeProjects[]>([]);
const [loading, setLoading] = useState(true);
新規追加、削除するプロジェクトに関するuseStateと、読み込みに関するuseStateを用意しました。
useEffect(() => {
const fetchProjects = async () => {
try{
const { data, error} = await supabase
.from("projects")
.select("*")
.order("created_at", { ascending: true})
if (error) throw error;
setProjects(data || []);
} catch(error){
console.error("Error:", error)
} finally {
setLoading(false);
}
}
fetchProjects();
const subscription = supabase
.channel("projects")
.on(
'postgres_changes' as never,
{ event: "*", schema: "public", table: "projects" },
(payload) => {
if(payload.eventType === "INSERT"){
setProjects(prev => [...prev, payload.new as typeProjects]);
} else if (payload.eventType === "DELETE"){
setProjects(prev => prev.filter(project => project.id !== (payload.old as typeProjects).id));
} else if (payload.eventType === "UPDATE"){
setProjects(prev => prev.map(project =>
project.id === (payload.new as typeProjects).id ? payload.new as typeProjects : project
));
}
}
)
.subscribe();
return () => {
subscription.unsubscribe();
}
}, []);
supabaseからデータを取得するために、非同期関数を作成してprojects関数からデータをfetchするようにしています。
そしてINSERTやDELETE、UPDATEに応じてsetProjectsできるようにしています。
次は実際にボタンをクリックした時の挙動についてです。
// プロジェクトの追加
const addProject = async () => {
try {
const newProject = {
name: `プロジェクト:${projects.length + 1}`,
project_start_time: Date.now(),
total_elapsed_time: 0,
is_running: false,
};
// 即座にUIを更新(一時的なIDを生成)
const tempProject = {
...newProject,
id: crypto.randomUUID(),
created_at: new Date().toISOString()
};
setProjects(prev => [...prev, tempProject]);
// その後でサーバーと同期
const { data, error } = await supabase
.from("projects")
.insert([newProject])
.select();
if (error) {
console.error("Error adding project:", error.message);
// エラーが発生した場合は元の状態に戻す
setProjects(prev => prev.filter(p => p.id !== tempProject.id));
return;
}
// 一時的なプロジェクトを実際のデータで置き換え
setProjects(prev => prev.map(p =>
p.id === tempProject.id ? data[0] : p
));
} catch (error) {
console.error("Error in addProject:", error);
}
};
この辺の「即座にUIへ反映」といった部分でAIにお世話になったのですが、一時的なIDを用意し、実際のプロジェクトデータへ書き換えといったことをして対応してくれたみたいですね。
const toggleTimer = async (id: string) => {
try {
const project = projects.find(p => p.id === id);
if (!project) return;
const updates = project.is_running
? {
is_running: false,
total_elapsed_time: project.total_elapsed_time + (Date.now() - project.project_start_time)
}
: {
is_running: true,
project_start_time: Date.now()
};
// 即座にUIを更新
setProjects(prev => prev.map(p =>
p.id === id ? { ...p, ...updates } : p
));
// その後でサーバーと同期
const { error } = await supabase
.from("projects")
.update(updates)
.eq("id", id);
if (error) {
console.error("Error updating project:", error.message);
// エラーが発生した場合は元の状態に戻す
const { data } = await supabase
.from("projects")
.select("*")
.order("created_at", { ascending: true });
setProjects(data || []);
}
} catch (error) {
console.error("Error in toggleTimer:", error);
}
};
これは開始・一時停止を管理する関数になっています。is_runningの値に対して状態をセットしてカウントアップ機能の実装に繋げています。
const deleteProject = async(id: string) => {
try {
// 即座にUIを更新
setProjects(prev => prev.filter(project => project.id !== id));
// その後でサーバーと同期
const { error } = await supabase
.from("projects")
.delete()
.eq("id", id);
if (error) {
console.error("Error deleting project:", error);
// エラーが発生した場合は元の状態に戻す
const { data } = await supabase
.from("projects")
.select("*")
.order("created_at", { ascending: true });
setProjects(data || []);
}
} catch (error) {
console.error("Error in deleteProject:", error);
}
};
const updateProjectName = async (id: string, newName: string) => {
try {
// 即座にUIを更新
setProjects(prev => prev.map(p =>
p.id === id ? { ...p, name: newName } : p
));
// その後でサーバーと同期
const { error } = await supabase
.from("projects")
.update({ name: newName })
.eq("id", id);
if (error) {
console.error("Error updating project name:", error);
// エラーが発生した場合は元の状態に戻す
const { data } = await supabase
.from("projects")
.select("*")
.order("created_at", { ascending: true });
setProjects(data || []);
}
} catch (error) {
console.error("Error in updateProjectName:", error);
}
};
return (
<div className="container mx-auto p-10">
<h1 className="text-xl md:text-3xl font-bold text-center">稼働時間管理</h1>
<Button
variant="destructive"
onClick={addProject}
className="font-bold mb-8"
>
プロジェクト追加
</Button>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<CardWithForm
key={project.id}
projectId={project.id}
projectName={project.name}
projectStartTime={project.project_start_time}
totalElapsedTime={project.total_elapsed_time}
isRunning={project.is_running}
toggleTimer={toggleTimer}
deleteProject={deleteProject}
updateProjectName={updateProjectName}
/>
))}
</div>
</div>
)
こんな感じで用意していました。
カードコンポーネント
import * as React from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardFooter,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
export const CardWithForm: React.FC<{
projectId: string;
projectName: string;
projectStartTime: number;
totalElapsedTime: number;
isRunning: boolean;
toggleTimer: (id: string) => void;
deleteProject: (id: string) => void;
updateProjectName: (id: string, newName: string) => void;
}> = ({ projectId, projectName, projectStartTime, totalElapsedTime, isRunning, toggleTimer, deleteProject, updateProjectName }) => {
const [elapsed, setElapsed] = useState<number>(totalElapsedTime);
const [isEditing, setIsEditing] = useState(false);
const [editedName, setEditedName] = useState(projectName);
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (isRunning) {
setElapsed(totalElapsedTime + (Date.now() - projectStartTime));
intervalId = setInterval(() => {
setElapsed(totalElapsedTime + (Date.now() - projectStartTime));
}, 1000);
} else {
setElapsed(totalElapsedTime);
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [isRunning, projectStartTime, totalElapsedTime]);
const handleNameSubmit = () => {
updateProjectName(projectId, editedName);
setIsEditing(false);
}
return (
<Card>
<CardContent>
<div className="p-4">
{isEditing ? (
<div className="flex gap-2">
<Input
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNameSubmit()}
/>
<Button onClick={handleNameSubmit}>保存</Button>
<Button onClick={() => setIsEditing(false)}>キャンセル</Button>
</div>
) : (
<div className="flex justify-between items-center">
<CardTitle>{projectName}</CardTitle>
<Button variant="ghost" onClick={() => setIsEditing(true)}>
編集
</Button>
</div>
)}
</div>
<div className="grid w-full items-center gap-4">
<div className="flex flex-col space-y-1.5">
<p>経過時間:{Math.floor(elapsed / 1000)}秒</p>
<Button onClick={() => toggleTimer(projectId)}>
{isRunning ? "一時停止" : "開始"}
</Button>
<Button onClick={() => deleteProject(projectId)}>削除</Button>
</div>
</div>
</CardContent>
</Card>
)
}
この辺はだいぶAIの力を借りたので、次自分で一から作れるかというとまだうまく作れないような気がしています。
でも今回学びになったことは、UIへの反映だったり、どんな項目を使って状態管理やタイマー管理をしたら良いのかってことはわかったので、細かな実装は調べながらいけるかなってところです。
現状はSupbase使っているもののローカル環境でのみの動作になるので、あとは実際の稼働時間を出力したりするために管理画面作ることも考えていて、そのためには認可・認証の実装をする必要が出てくるので、これはネクストステップとして今後のアウトプットかだいとしておきま