Claude 3.5を使ってゆるいToDo管理アプリを作ったよ:コード全量公開中
この記事では、Claude 3.5を使って、WEBブラウザで動作するToDo管理アプリを作ってもらう流れを簡単にご紹介します。アプリはHTML単体で動作します。コード全量も配布しているので、常識の範囲内でご自由にお使いください。(目次下、および記事下部に記載。)
ゆるToDo
↑このファイルだけで動作します。(app2が記事公開当時の版です。)
↑自前のサーバにも置いておきます。(こちらは常に最新版です。)
インポートできるjsonファイル(サンプル)
特徴
特徴としては、以下の4点です。
サーバとの通信を一切行わない。(デザイン部分を除く)
エクスポート機能、インポート機能を持っている。
個別に進捗状況をパーセントで管理できる。
入力した値は、各ブラウザ(端末)のローカルストレージに保存される。
設計思想としては、とにかくシンプルにすることを重視し、特に進捗状況をパーセントで管理できるようにしようと考えていました。通常、ToDoのタスクはパーセントで管理しませんが、この機能を使えば、より直感的に進捗を把握できます。例えば、「借りてきた『銀河鉄道の夜』を読む」というタスクがあれば、途中まで読んで放置している場合に50%と記録する、といった具合です。これにより、どの程度進んでいるのか一目で分かるようになります。
エクスポート機能とインポート機能も大きな特徴です。ユーザの入力データをサーバ側に保存せずに、ローカル(端末のストレージ)に保存する仕組みを採用しています。この機能はClaude 3.5の追加提案で実装しました。例えば、iPhoneのSafari(標準ブラウザ)で作成したタスクを、WindowsのChromeにインポートすることが可能です。(逆も然り)
デザインは自分の趣味です🎨
見づらい場合は、コードを書き換えてご自身の好みに合わせてください。ChatGPTやClaudeを使えば、簡単にカスタマイズできます。
スマホからの利用
スマホなどの横幅が小さい端末では、期待したレイアウトにならないため、50%まで拡大率を下げて使用する必要があります。(不具合なので、余裕があったら修正します。。) ←記事公開後に修正しました。
アップデート
Claude 3.5の現状の問題
やってみて以下の2点が気になりました。
ざっくりコードが400行を超えてくると、生成が中断される場合がある。
プレビューが正常に動作しない場合がある。(Artifacts機能)
時間にもよるかもしれませんが、日本時間の土曜、22:00くらいでは400行を超えたくらいから生成が中断されやすくなりました。
これを回避するためには、部分的に生成させるようにすべきです。「(指示)〜当該箇所のコードを提示してください。全量書き直す必要はありません。」といった指示を最後に入れると良いでしょう。部分的な修正では、プレビューに反映されないので、最後のちょっとした文言の差し替え程度で使えば良いでしょう。
また、このプレビューですが、ローカルストレージに保存するように処理を加えた後から、何も表示されなくなりました。ブラウザやセキュリティの問題かもしれませんが、うまく表示されないこともあるので、その点は念頭に置いておくと良いでしょう。
Artifacts機能の凄さ
リアルタイムにデザインや動きを確認できるのは革新的です。
この辺を、こうしたいというのは文章で伝える必要がありますが、それだけで一応動くものをイメージ通りに作れるのは驚異的です。例えば、実際プレビューで動かしてみて、タスクの入力上限が設定されていないため、レイアウトが崩れることに気づけました。
ほか、細かいところでは、進捗状況を表すバーが中央初期値でしたが、一番左で0%を初期値とすること。チェックを入れた場合(タスク完了時)はバーを非活性として操作できないようにし、100%とするように、という指示も一発でやってくれました😍
同じ条件で試してないですが、ChatGPT-4なら追加で何往復かやりとりが発生すると思いますし、いちいちファイルをダウンロードしないといけないのも手間です。その点、Claude 3.5は素晴らしいです。
まとめ
今回作ったものは、おおよそ1時間で作れました。通常、これだけの規模のものであれば、Claude 3.5の見積もりでは、10-15営業日、かつ、フリーランスでは50万から150万円、開発会社では200万から400万かかるそうなので、かなりコスパ良いですね。もっとも、フロントエンドの開発だけではなく、サーバーサイドの実装となるとこうも容易くはできませんし、こんなシンプルな要件で終わることもないと思います。しかし、プロトタイプとしては十分です。
余談
容易にアイデアが形になるのは楽しいですね!ただし、公開するにあたっては間違いのない実装かどうかはよく確認する必要があります。コードレビューしたり、テストしたり、そういうのはまだ必要です。(ここに工数はかかる。)
ちなみに、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">
© 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>
この記事が気に入ったらサポートをしてみませんか?