1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.jsとSupabaseで作る稼働時間管理アプリ

Posted at

フリーランスとして活動していく上で、自分が稼働したプロジェクトや案件についてどのくらいの時間がかかったのか管理することってすごく大事ですよね。例えばホームページ制作を10万円で受けたとして、10時間で作るのか、100時間で作るのかで自分の時間単価は変わってくるわけで。
そして一度に一つの案件だけ動いているというのはなかなか考えにくいと思っていて、やっぱり一つの契約先であっても複数の稼働案件があるっていうことが多いと思うので、そういった時に「何に」「どのくらいの時間を割いたか」は分かっておいた方がよいと思います。
ということで、今回は自己学習も兼ねて複数プロジェクトのカウントアップタイマーを作ってみましたのでアウトプットします。
結構AIに助けてもらいながら作っていったので、自分の中での整理も兼ねています。

作成物

稼働管理タイマー

image.png

このような感じで、プロジェクト追加をクリックするとプロジェクト名と稼働時間の開始・一時停止ができるようになります。
そして終了したものについては削除をクリックするとデータが削除されるというものになっています。

使用技術

  • Typescript
  • Next.js
  • Supabase
  • TailwindCSS
    をメインで利用しました。コンポーネントについては、shadcn/uiを使用しています。

稼働管理ページのコード

import関連
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による入力値の管理
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できるようにしています。

次は実際にボタンをクリックした時の挙動についてです。

addProject関数
// プロジェクトの追加
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を用意し、実際のプロジェクトデータへ書き換えといったことをして対応してくれたみたいですね。

toggleTimer関数
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の値に対して状態をセットしてカウントアップ機能の実装に繋げています。

deleteProject関数
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);
    }
};
updateProjectName関数
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>
)

こんな感じで用意していました。

カードコンポーネント

components/Card.tsx
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使っているもののローカル環境でのみの動作になるので、あとは実際の稼働時間を出力したりするために管理画面作ることも考えていて、そのためには認可・認証の実装をする必要が出てくるので、これはネクストステップとして今後のアウトプットかだいとしておきま

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?