見出し画像

Claude 3.5を使ってゆるいToDo管理アプリを作ったよ:コード全量公開中

この記事では、Claude 3.5を使って、WEBブラウザで動作するToDo管理アプリを作ってもらう流れを簡単にご紹介します。アプリはHTML単体で動作します。コード全量も配布しているので、常識の範囲内でご自由にお使いください。(目次下、および記事下部に記載。)


ゆるToDo

↑このファイルだけで動作します。(app2が記事公開当時の版です。)

↑自前のサーバにも置いておきます。(こちらは常に最新版です。)

インポートできるjsonファイル(サンプル)

イメージ

特徴

特徴としては、以下の4点です。

  1. サーバとの通信を一切行わない。(デザイン部分を除く)

  2. エクスポート機能、インポート機能を持っている。

  3. 個別に進捗状況をパーセントで管理できる。

  4. 入力した値は、各ブラウザ(端末)のローカルストレージに保存される。

設計思想としては、とにかくシンプルにすることを重視し、特に進捗状況をパーセントで管理できるようにしようと考えていました。通常、ToDoのタスクはパーセントで管理しませんが、この機能を使えば、より直感的に進捗を把握できます。例えば、「借りてきた『銀河鉄道の夜』を読む」というタスクがあれば、途中まで読んで放置している場合に50%と記録する、といった具合です。これにより、どの程度進んでいるのか一目で分かるようになります。

エクスポート機能とインポート機能も大きな特徴です。ユーザの入力データをサーバ側に保存せずに、ローカル(端末のストレージ)に保存する仕組みを採用しています。この機能はClaude 3.5の追加提案で実装しました。例えば、iPhoneのSafari(標準ブラウザ)で作成したタスクを、WindowsのChromeにインポートすることが可能です。(逆も然り)

デザインは自分の趣味です🎨
見づらい場合は、コードを書き換えてご自身の好みに合わせてください。ChatGPTやClaudeを使えば、簡単にカスタマイズできます。

スマホからの利用

スマホなどの横幅が小さい端末では、期待したレイアウトにならないため、50%まで拡大率を下げて使用する必要があります。(不具合なので、余裕があったら修正します。。) ←記事公開後に修正しました。

アップデート

・レスポンシブデザインに対応しました。(スマホレイアウト対応)
・全体の進捗の計算を変更しました。各タスクの進捗率も加味します。
・スライダーのUIを変更しました。
・各タスクの時間が0だった場合、選択した際に即時入力状態となるように変更。

2024/06/23 追記

Claude 3.5の現状の問題

やってみて以下の2点が気になりました。

  • ざっくりコードが400行を超えてくると、生成が中断される場合がある。

  • プレビューが正常に動作しない場合がある。(Artifacts機能)

時間にもよるかもしれませんが、日本時間の土曜、22:00くらいでは400行を超えたくらいから生成が中断されやすくなりました。

Claude’s response was limited as it hit the maximum length allowed at this time.
「許可された最大長に達したため、応答結果が限られたものになった。」ようです。
しかし、出力される時は出力されるので、単に開始からの時間経過で見ている可能性はあります。

これを回避するためには、部分的に生成させるようにすべきです。「(指示)〜当該箇所のコードを提示してください。全量書き直す必要はありません。」といった指示を最後に入れると良いでしょう。部分的な修正では、プレビューに反映されないので、最後のちょっとした文言の差し替え程度で使えば良いでしょう。

また、このプレビューですが、ローカルストレージに保存するように処理を加えた後から、何も表示されなくなりました。ブラウザやセキュリティの問題かもしれませんが、うまく表示されないこともあるので、その点は念頭に置いておくと良いでしょう。

エラーもワーニングも出て真っ黒。ただし、ダウンロードしてブラウザで開くと動きます。

Artifacts機能の凄さ

リアルタイムにデザインや動きを確認できるのは革新的です。

この辺を、こうしたいというのは文章で伝える必要がありますが、それだけで一応動くものをイメージ通りに作れるのは驚異的です。例えば、実際プレビューで動かしてみて、タスクの入力上限が設定されていないため、レイアウトが崩れることに気づけました。

見栄えがよくない。
「プロンプト:各タスクの文字入力が制限ないので、3行表示、4行表示となって見栄えが悪いので、最大常に2行表示として、それを超える場合...で省略することは可能でしょうか。」
これだけで、上記のように修正できました。

ほか、細かいところでは、進捗状況を表すバーが中央初期値でしたが、一番左で0%を初期値とすること。チェックを入れた場合(タスク完了時)はバーを非活性として操作できないようにし、100%とするように、という指示も一発でやってくれました😍
同じ条件で試してないですが、ChatGPT-4なら追加で何往復かやりとりが発生すると思いますし、いちいちファイルをダウンロードしないといけないのも手間です。その点、Claude 3.5は素晴らしいです。

まとめ

今回作ったものは、おおよそ1時間で作れました。通常、これだけの規模のものであれば、Claude 3.5の見積もりでは、10-15営業日、かつ、フリーランスでは50万から150万円、開発会社では200万から400万かかるそうなので、かなりコスパ良いですね。もっとも、フロントエンドの開発だけではなく、サーバーサイドの実装となるとこうも容易くはできませんし、こんなシンプルな要件で終わることもないと思います。しかし、プロトタイプとしては十分です。

この規模のアプリケーション開発の工数とコストを概算すると、以下のようになります:

工数:企画・要件定義: 1-2日
デザイン: 2-3日
フロントエンド開発: 5-7日
テストと修正: 2-3日
デプロイメントと最終確認: 1日

合計: 約2-3週間 (10-15営業日)

コスト: 開発者の経験や地域によって大きく異なりますが、おおよその目安は以下の通りです:フリーランス開発者の場合:
日本の平均的な単価: 5万円〜10万円/日
総額: 50万円〜150万円
開発会社に依頼する場合:
小規模な会社: 100万円〜200万円
中規模〜大規模な会社: 200万円〜400万円以上

注意点:これはあくまで概算であり、実際のコストは要件の詳細、開発者のスキルレベル、地域、会社の規模などによって大きく変動します。
この見積もりには、継続的なメンテナンスやサポート、サーバー費用は含まれていません。
機能の追加や変更、デザインの複雑さによっては、工数とコストが増加する可能性があります。

現在のアプリケーションは比較的シンプルで、高度なバックエンド機能やデータベース連携がないため、上記の見積もりの下限に近い範囲で開発可能かもしれません。ただし、プロジェクトの具体的な要件や品質基準によっては、さらに時間とコストがかかる可能性があります。

Claude 3.5の概算の見積もり(完成後に聞いています)

余談

容易にアイデアが形になるのは楽しいですね!ただし、公開するにあたっては間違いのない実装かどうかはよく確認する必要があります。コードレビューしたり、テストしたり、そういうのはまだ必要です。(ここに工数はかかる。)

ちなみに、ChatGPT-4にレビューさせたところ、バリデーションチェックが不十分で、負の値が入力できると言われました。確かに上下ボタンからは負の値は入力できませんが、キーボードから入力した場合、マイナスを許容していました😳
ほか、jsonファイルのインポートがあるのであれば、エラーハンドリングを強化するべき、であったり、1つのファイルに詰め込みすぎているのでは(分割せい)、というフィードバックももらえました。(未対応)

Claude(anthropic)に期待することとしては、今後プレビュー機能から「この辺」となぞると、リアルタイムで修正されたり、他の記事にも書きましたが、こちらでコードを直接弄れるアップデートがあっても良さそうですね。

コード全量

※使っているライブラリ等のMITライセンスを継承しています。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ゆるToDo</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap');
        body {
            font-family: 'Orbitron', sans-serif;
            background-color: #0a0a0a;
            color: #00ff00;
        }
        .cyber-glow {
            text-shadow: 0 0 5px #00ff00, 0 0 10px #00ff00;
        }
        .cyber-input {
            background-color: rgba(0, 255, 0, 0.1);
            border: 1px solid #00ff00;
            color: #00ff00;
        }
        .cyber-button {
            background-color: rgba(0, 255, 0, 0.2);
            border: 1px solid #00ff00;
            color: #00ff00;
            transition: all 0.3s ease;
        }
        .cyber-button:hover {
            background-color: rgba(0, 255, 0, 0.4);
            box-shadow: 0 0 10px #00ff00;
        }
        .cyber-list-item {
            background-color: rgba(0, 255, 0, 0.05);
            border: 1px solid rgba(0, 255, 0, 0.2);
        }
        .cyber-stats {
            background-color: rgba(0, 255, 0, 0.1);
            border: 1px solid #00ff00;
            box-shadow: 0 0 5px #00ff00;
        }
        .progress-bar {
            -webkit-appearance: none;
            appearance: none;
            width: 100%;
            height: 10px;
            background: rgba(0, 255, 0, 0.1);
            outline: none;
            transition: 0.2s;
        }
        .progress-bar::-webkit-slider-thumb {
            -webkit-appearance: none;
            appearance: none;
            width: 20px;
            height: 20px;
            background: #00ff00;
            cursor: pointer;
            border-radius: 50%;
            box-shadow: 0 0 5px #00ff00;
        }
        .progress-bar::-moz-range-thumb {
            width: 20px;
            height: 20px;
            background: #00ff00;
            cursor: pointer;
            border-radius: 50%;
            box-shadow: 0 0 5px #00ff00;
        }
        .progress-bar:disabled {
            opacity: 0.5;
        }
        .progress-bar:disabled::-webkit-slider-thumb {
            display: none;
        }
        .progress-bar:disabled::-moz-range-thumb {
            display: none;
        }
        .task-text {
            display: -webkit-box;
            -webkit-line-clamp: 2;
            -webkit-box-orient: vertical;
            overflow: hidden;
            text-overflow: ellipsis;
            max-height: 3em;
            line-height: 1.5em;
        }
        .cyber-checkbox {
            appearance: none;
            -webkit-appearance: none;
            width: 1.5em;
            height: 1.5em;
            border: 2px solid rgba(0, 255, 0, 0.3);
            border-radius: 50%;
            outline: none;
            cursor: pointer;
            transition: all 0.3s ease;
        }

        .cyber-checkbox:checked {
            background-color: #00ff00;
            box-shadow: 0 0 5px #00ff00, 0 0 10px #00ff00;
        }

        .cyber-checkbox:hover {
            border-color: #00ff00;
            box-shadow: 0 0 5px #00ff00;
        }
    </style>
</head>
<body class="min-h-screen">
    <div id="root"></div>
    <script type="text/babel">
        const { useState, useEffect, useRef } = React;

        const Input = ({ className, ...props }) => (
            <input className={`cyber-input rounded px-2 py-1 ${className}`} {...props} />
        );

        const Button = ({ className, children, ...props }) => (
            <button className={`cyber-button px-3 py-1 rounded ${className}`} {...props}>{children}</button>
        );

        const Select = ({ value, onValueChange, children }) => (
            <select value={value} onChange={(e) => onValueChange(e.target.value)} className="cyber-input rounded px-2 py-1">
                {children}
            </select>
        );

        const ProgressBar = ({ value, onChange, disabled }) => (
            <div className="flex items-center space-x-2">
                <input
                    type="range"
                    min="0"
                    max="100"
                    value={value}
                    onChange={onChange}
                    disabled={disabled}
                    className="progress-bar"
                />
                <span className="text-sm">{value}%</span>
            </div>
        );

        const TodoApp = () => {
            const [todos, setTodos] = useState([]);
            const [newTodo, setNewTodo] = useState('');
            const [newEstimate, setNewEstimate] = useState('');
            const [newPriority, setNewPriority] = useState('中');
            const fileInputRef = useRef(null);

            useEffect(() => {
                const storedTodos = localStorage.getItem('todos');
                if (storedTodos) {
                    setTodos(JSON.parse(storedTodos));
                }
            }, []);

            useEffect(() => {
                localStorage.setItem('todos', JSON.stringify(todos));
            }, [todos]);

            const addTodo = () => {
                if (newTodo.trim() !== '') {
                    setTodos([...todos, { 
                        id: Date.now(), 
                        text: newTodo, 
                        completed: false,
                        estimate: parseFloat(newEstimate) || 0,
                        priority: newPriority,
                        progress: 0
                    }]);
                    setNewTodo('');
                    setNewEstimate('');
                    setNewPriority('中');
                }
            };

            const toggleTodo = (id) => {
                setTodos(todos.map(todo => 
                    todo.id === id ? { ...todo, completed: !todo.completed, progress: todo.completed ? 0 : 100 } : todo
                ));
            };

            const deleteTodo = (id) => {
                setTodos(todos.filter(todo => todo.id !== id));
            };

            const updateEstimate = (id, newEstimate) => {
                setTodos(todos.map(todo =>
                    todo.id === id ? { ...todo, estimate: parseFloat(newEstimate) || 0 } : todo
                ));
            };

            const updatePriority = (id, newPriority) => {
                setTodos(todos.map(todo =>
                    todo.id === id ? { ...todo, priority: newPriority } : todo
                ));
            };

            const updateProgress = (id, newProgress) => {
                setTodos(todos.map(todo =>
                    todo.id === id ? { ...todo, progress: newProgress } : todo
                ));
            };

            const priorityColors = {
                '高': 'text-red-500',
                '中': 'text-yellow-500',
                '低': 'text-blue-500'
            };

            const getRemainingTasksCount = () => {
                return todos.filter(todo => !todo.completed).length;
            };

            const getCompletedTasksCount = () => {
                return todos.filter(todo => todo.completed).length;
            };

            const calculateRemainingTime = () => {
                return todos
                    .filter(todo => !todo.completed)
                    .reduce((total, todo) => total + (todo.estimate || 0), 0)
                    .toFixed(1);
            };

            const calculateCompletedTime = () => {
                return todos
                    .filter(todo => todo.completed)
                    .reduce((total, todo) => total + (todo.estimate || 0), 0)
                    .toFixed(1);
            };

            const exportTodos = () => {
                const dataStr = JSON.stringify(todos);
                const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
                const exportFileDefaultName = 'todos.json';

                const linkElement = document.createElement('a');
                linkElement.setAttribute('href', dataUri);
                linkElement.setAttribute('download', exportFileDefaultName);
                linkElement.click();
            };

            const importTodos = (event) => {
                const file = event.target.files[0];
                if (file) {
                    const reader = new FileReader();
                    reader.onload = (e) => {
                        try {
                            const importedTodos = JSON.parse(e.target.result);
                            setTodos(importedTodos);
                        } catch (error) {
                            alert('無効なJSONファイルです。');
                        }
                    };
                    reader.readAsText(file);
                }
            };

            return (
                <div className="max-w-4xl mx-auto mt-10 p-6 bg-gray-900 rounded-lg shadow-lg border border-green-500">
                    <h1 className="text-3xl font-bold mb-6 text-center cyber-glow">ゆるToDo</h1>
                    <div className="flex mb-4 space-x-2">
                        <Input
                            type="text"
                            value={newTodo}
                            onChange={(e) => setNewTodo(e.target.value)}
                            placeholder="新しいタスクを入力..."
                            className="flex-grow min-w-[300px]"
                        />
                        <Input
                            type="number"
                            value={newEstimate}
                            onChange={(e) => setNewEstimate(e.target.value)}
                            placeholder="時間"
                            className="w-20"
                            min="0"
                            step="0.1"
                        />
                        <Select value={newPriority} onValueChange={setNewPriority}>
                            <option value="高">高</option>
                            <option value="中">中</option>
                            <option value="低">低</option>
                        </Select>
                        <Button onClick={addTodo} className="whitespace-nowrap">
                            + 追加
                        </Button>
                    </div>
                    <div className="cyber-stats mb-4 p-3 rounded">
                        <div className="grid grid-cols-2 gap-4">
                            <div className="bg-gray-800 p-3 rounded">
                                <h3 className="text-base font-bold mb-2 text-green-400">未完了タスク</h3>
                                <div className="flex items-center mb-1">
                                    <span className="mr-2">📋</span>
                                    <span className="font-bold text-lg">{getRemainingTasksCount()}</span>
                                </div>
                                <div className="flex items-center">
                                    <span className="mr-2">⏱</span>
                                    <span className="font-bold text-lg">{calculateRemainingTime()} h</span>
                                </div>
                            </div>
                            <div className="bg-gray-800 p-3 rounded">
                                <h3 className="text-base font-bold mb-2 text-blue-400">完了済タスク</h3>
                                <div className="flex items-center mb-1">
                                    <span className="mr-2">✅</span>
                                    <span className="font-bold text-lg">{getCompletedTasksCount()}</span>
                                </div>
                                <div className="flex items-center">
                                    <span className="mr-2">⏱</span>
                                    <span className="font-bold text-lg">{calculateCompletedTime()} h</span>
                                </div>
                            </div>
                        </div>
                        <div className="mt-3">
                            <div className="text-sm mb-1">全体の進捗</div>
                            <div className="w-full bg-gray-700 rounded-full h-2.5">
                                <div 
                                    className="bg-green-600 h-2.5 rounded-full" 
                                    style={{width: `${(getCompletedTasksCount() / (getRemainingTasksCount() + getCompletedTasksCount())) * 100}%`}}
                                ></div>
                            </div>
                        </div>
                    </div>
                    <ul className="space-y-2 mb-4">
                        {todos.map(todo => (
                            <li key={todo.id} className="cyber-list-item p-3 rounded">
                                <div className="flex items-center mb-2">
                                    <input
                                        type="checkbox"
                                        checked={todo.completed}
                                        onChange={() => toggleTodo(todo.id)}
                                        className="cyber-checkbox mr-2 flex-shrink-0"
                                    />
                                    <span 
                                        className={`task-text flex-grow mr-2 ${todo.completed ? 'line-through text-gray-500' : ''}`}
                                        title={todo.text}
                                    >
                                        {todo.text}
                                    </span>
                                    <div className="flex items-center mr-2 flex-shrink-0">
                                        <span className="mr-1">⏱</span>
                                        <Input
                                            type="number"
                                            value={todo.estimate}
                                            onChange={(e) => updateEstimate(todo.id, e.target.value)}
                                            className="w-16 text-sm"
                                            min="0"
                                            step="0.1"
                                        />
                                        <span className="ml-1 text-sm">h</span>
                                    </div>
                                    <Select 
                                        value={todo.priority} 
                                        onValueChange={(value) => updatePriority(todo.id, value)}
                                        className="flex-shrink-0 mr-2"
                                    >
                                        <option value="高">高</option>
                                        <option value="中">中</option>
                                        <option value="低">低</option>
                                    </Select>
                                    <span className={`mr-2 flex-shrink-0 ${priorityColors[todo.priority]}`}>⚑</span>
                                    <Button onClick={() => deleteTodo(todo.id)} className="text-red-500 hover:text-red-700 flex-shrink-0">
                                        ✗
                                    </Button>
                                </div>
                                <ProgressBar 
                                    value={todo.progress} 
                                    onChange={(e) => updateProgress(todo.id, parseInt(e.target.value))} 
                                    disabled={todo.completed}
                                />
                            </li>
                        ))}
                    </ul>
                    <div className="flex justify-between mb-4">
                        <Button onClick={exportTodos} className="mr-2">
                            エクスポート
                        </Button>
                        <div>
                            <input
                                type="file"
                                ref={fileInputRef}
                                style={{ display: 'none' }}
                                onChange={importTodos}
                                accept=".json"
                            />
                            <Button onClick={() => fileInputRef.current.click()}>
                                インポート
                            </Button>
                        </div>
                    </div>
                    <div className="text-center text-sm text-gray-500 mb-2">
                        &copy; 2024 ゆるToDo All Rights Reserved.
                        <br />
                        制作: たぬ | コーディング協力: Claude 3.5
                    </div>
                    <div className="text-center text-xs text-gray-400 mt-4">
                        <p>This application uses React, ReactDOM, Babel, and Tailwind CSS, which are licensed under the MIT License.</p>
                        <p>
                            <a href="https://opensource.org/licenses/MIT" className="underline hover:text-gray-300" target="_blank" rel="noopener noreferrer">
                                MIT License
                            </a>
                        </p>
                    </div>
                    <div className="text-xs text-gray-400 mt-2">
                        <p>• データはローカルストレージに保存され、外部に送信されません</p>
                        <p>• 本アプリの使用は自己責任でお願いします</p>
                    </div>
                    
                </div>
            );
        };

ReactDOM.render(<TodoApp />, document.getElementById('root'));
</script>
</body>
</html>

この記事が気に入ったらサポートをしてみませんか?