これまでの2本で、「todo作成→Cloud Tasksで非同期分析→DB更新」の機能自体は完成していた。ただ動作確認は毎回curlとDBを直接見て確認してて、ちょっと面倒だった。
今回はその確認作業をブラウザでポチポチ完結できるように、簡易的なUIを1ページ追加する。ついでに、今までAPIとして存在してなかった「編集(タイトル・カテゴリ・優先度・所要時間)」「完了(done)切り替え」もこの機会に実装する。新しいnpmパッケージは増やさない。
設計方針
-
GET /uiという新しいルートを1本追加するだけ - HTML/CSS/JSは全部1つの文字列にして
c.html()で返す(別ファイルの静的配信やビルドツールは使わない) - 一覧は3秒おきに
/api/todosを再取得(ポーリング)。Cloud Tasks経由の分析が終わってcategory/priority/estimated_minutesが後から埋まっていく様子が、画面をリロードしなくてもそのまま見える - 既存のAPIには
GET/POST/DELETEしかなく、更新系がなかったのでPATCH /api/todos/:idを新設する - 編集対象は
title/doneに加えて、分析結果であるcategory/priority/estimated_minutesも含める(分析が間違ってた時に手で直せるように) -
categoryは仕事/買い物/家事/健康/学習/その他、priorityは高/中/低という決まった値しか取らない(src/classify.tsのプロンプトで指定してる語彙と揃える)ので、UI側は自由入力のテキストボックスではなく<select>にする
1. src/todos.ts にupdate()関数を追加
export type Todo = {
id: number
title: string
done: boolean
category: string,
priority: string,
estimated_minutes: BigInteger
}
export async function getAll(): Promise<Todo[]> {
...
}
export async function getById(id: number): Promise<Todo | undefined> {
...
}
+export type UpdatableFields = {
+ title?: string
+ done?: boolean
+ category?: string
+ priority?: string
+ estimated_minutes?: number
+}
+
+export async function update(
+ id: number,
+ fields: UpdatableFields
+): Promise<Todo | undefined> {
+ const sets: string[] = []
+ const values: (string | boolean | number)[] = []
+ let i = 1
+
+ if (fields.title !== undefined) {
+ sets.push(`title = $${i++}`)
+ values.push(fields.title)
+ }
+ if (fields.done !== undefined) {
+ sets.push(`done = $${i++}`)
+ values.push(fields.done)
+ }
+ if (fields.category !== undefined) {
+ sets.push(`category = $${i++}`)
+ values.push(fields.category)
+ }
+ if (fields.priority !== undefined) {
+ sets.push(`priority = $${i++}`)
+ values.push(fields.priority)
+ }
+ if (fields.estimated_minutes !== undefined) {
+ sets.push(`estimated_minutes = $${i++}`)
+ values.push(fields.estimated_minutes)
+ }
+
+ if (sets.length === 0) {
+ return getById(id)
+ }
+
+ values.push(id as unknown as string)
+ const result = await pool.query<Todo>(
+ `UPDATE todos SET ${sets.join(", ")} WHERE id = $${i}
+ RETURNING id, title, done, category, priority, estimated_minutes`,
+ values
+ )
+ return result.rows[0]
+}
+
export async function create(title: string): Promise<Todo> {
...
}
title/done/category/priority/estimated_minutes、渡された項目だけをSET句に組み立てる形になっている。何も渡されなかった場合はgetByIdにフォールバックする。値の妥当性チェック(categoryが決められた6種類の中か等)はここでは行っていない。UI側は<select>しか出さないので不正な値は基本入らないが、直接APIを叩けば任意の文字列を入れられてしまう点は注意(気をつけたことの項で後述)。
2. src/index.ts にルートを追加
UI表示用のGET /uiと、更新用のPATCH /api/todos/:idを両方追加する。
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
-import { getAll, getById, create, remove } from "./todos.js"
+import { getAll, getById, create, update, remove } from "./todos.js"
+import type { UpdatableFields } from "./todos.js"
import { ensureSchema, pool } from './db.js'
import { analyzeTodo } from './classify.js'
+import { uiHtml } from './ui.js'
const app = new Hono()
app.get('/', (c) => {
return c.json({ message: "Todo API is running" })
})
+app.get('/ui', (c) => {
+ return c.html(uiHtml)
+})
+
app.get("/api/todos", async (c) => {
const todos = await getAll()
return c.json(todos)
})
app.get("/api/todos/:id", async (c) => {
...
})
app.post("/api/todos", async (c) => {
...
})
+app.patch("/api/todos/:id", async (c) => {
+ const id = Number(c.req.param("id"))
+ const body = await c.req.json<UpdatableFields>()
+ const todo = await update(id, body)
+ if (!todo) {
+ return c.json({ error: "not found" }, 404)
+ }
+ return c.json(todo)
+})
+
app.post("/internal/tasks/analyze-todo", async (c) => {
...
});
3. src/ui.ts を新規作成(カテゴリ・優先度・所要時間も編集可能に)
// src/ui.ts
export const uiHtml = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>todo-api 確認用UI</title>
<style>
body { font-family: sans-serif; max-width: 680px; margin: 40px auto; padding: 0 16px; background: #0f1117; color: #e8ecf5; }
h1 { font-size: 20px; }
form#add-form { display: flex; gap: 8px; margin-bottom: 24px; }
input[type=text], select, input[type=number] {
padding: 8px; border-radius: 6px; border: 1px solid #4c8dff; background: #1b2230; color: #e8ecf5;
}
input#title { flex: 1; }
button { padding: 8px 16px; border-radius: 6px; border: none; background: #4c8dff; color: white; cursor: pointer; }
ul { list-style: none; padding: 0; }
li { padding: 12px; margin-bottom: 8px; background: #1b2230; border-radius: 8px; }
li.done .title-text { text-decoration: line-through; color: #6b7280; }
.row { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.main { display: flex; align-items: center; gap: 10px; flex: 1; }
.meta { font-size: 12px; color: #9aa5b8; }
.pending { color: #f5a623; }
.actions { display: flex; gap: 6px; }
.edit { background: #f5a623; padding: 4px 10px; font-size: 12px; }
.del { background: #e0596a; padding: 4px 10px; font-size: 12px; }
.save { background: #34d399; padding: 4px 10px; font-size: 12px; }
.cancel { background: #6b7280; padding: 4px 10px; font-size: 12px; }
.edit-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; align-items: center; }
.edit-form input[type=text] { flex: 1; min-width: 160px; }
.edit-form input[type=number] { width: 90px; }
</style>
</head>
<body>
<h1>todo-api 確認用UI</h1>
<form id="add-form">
<input type="text" id="new-title" placeholder="やることを入力" required>
<button type="submit">追加</button>
</form>
<ul id="list"></ul>
<script>
const CATEGORIES = ["仕事", "買い物", "家事", "健康", "学習", "その他"];
const PRIORITIES = ["高", "中", "低"];
function options(values, selected) {
return values.map(v => \`<option value="\${v}" \${v === selected ? 'selected' : ''}>\${v}</option>\`).join('');
}
async function fetchTodos() {
const res = await fetch('/api/todos');
const todos = await res.json();
renderList(todos);
}
function renderList(todos) {
const list = document.getElementById('list');
list.innerHTML = todos.map(t => \`
<li class="\${t.done ? 'done' : ''}" data-id="\${t.id}">
<div class="row">
<div class="main">
<input type="checkbox" class="done-checkbox" \${t.done ? 'checked' : ''}>
<div>
<div class="title-text">\${t.title}</div>
<div class="meta">
\${t.category
? \`category: \${t.category} / priority: \${t.priority} / \${t.estimated_minutes}分\`
: '<span class="pending">分析中...</span>'}
</div>
</div>
</div>
<div class="actions">
<button class="edit">編集</button>
<button class="del">削除</button>
</div>
</div>
</li>
\`).join('');
list.querySelectorAll('li').forEach(li => {
const id = li.dataset.id;
li.querySelector('.done-checkbox').addEventListener('change', async (e) => {
await patchTodo(id, { done: e.target.checked });
});
li.querySelector('.del').addEventListener('click', async () => {
await fetch('/api/todos/' + id, { method: 'DELETE' });
fetchTodos();
});
li.querySelector('.edit').addEventListener('click', () => {
openEditForm(li, todos.find(t => String(t.id) === id));
});
});
}
function openEditForm(li, todo) {
// 既にeditフォームが開いていたら二重に開かない
if (li.querySelector('.edit-form')) return;
const form = document.createElement('div');
form.className = 'edit-form';
form.innerHTML = \`
<input type="text" class="f-title" value="\${todo.title}">
<select class="f-category">\${options(CATEGORIES, todo.category)}</select>
<select class="f-priority">\${options(PRIORITIES, todo.priority)}</select>
<input type="number" class="f-minutes" min="1" value="\${todo.estimated_minutes ?? 30}">
<button class="save">保存</button>
<button class="cancel" type="button">キャンセル</button>
\`;
li.appendChild(form);
form.querySelector('.cancel').addEventListener('click', () => form.remove());
form.querySelector('.save').addEventListener('click', async () => {
await patchTodo(todo.id, {
title: form.querySelector('.f-title').value,
category: form.querySelector('.f-category').value,
priority: form.querySelector('.f-priority').value,
estimated_minutes: Number(form.querySelector('.f-minutes').value),
});
fetchTodos();
});
}
async function patchTodo(id, fields) {
await fetch('/api/todos/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
});
}
document.getElementById('add-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('new-title');
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: input.value }),
});
input.value = '';
fetchTodos();
});
fetchTodos();
setInterval(fetchTodos, 3000);
</script>
</body>
</html>`
編集ボタンを押すと、その行の下に「タイトル入力欄・カテゴリ選択・優先度選択・所要時間(分)入力・保存/キャンセル」の編集フォームが展開される。カテゴリと優先度は<select>なので、classify.tsが使う語彙(仕事/買い物/家事/健康/学習/その他、高/中/低)以外は選べない作りにしてある。
注意点: 3秒おきのポーリングでrenderListが丸ごと再描画されるため、編集フォームを開いたまま放置すると次のポーリングでフォームが消えてしまう。編集は手早く保存かキャンセルする前提の作り(個人の確認用途なので今回はこれで割り切っている)。
4. 手元で確認する
npm run dev
ブラウザで http://localhost:8080/ui を開いて、以下を確認する。
- todoを追加 → 「分析中...」から
category等が後で埋まるか
- チェックボックスをクリック → 取り消し線がついて完了状態になるか
- 編集ボタン → タイトル・カテゴリ・優先度・所要時間の編集フォームが開き、保存すると一覧に反映されるか
- 削除ボタン → 一覧から消えるか
5. デプロイする
前回terraform applyへの一本化を終えてるので、デプロイ手順は変わらずgit pushだけで完結する。
git add src/ui.ts src/index.ts src/todos.ts
git commit -m "feat: 確認用UIと編集・完了機能を追加"
git push
GitHub Actionsがdocker build/push → terraform applyまで一気にやってくれるので、終わったら
https://todo-api-<プロジェクト番号>.asia-northeast1.run.app/ui
にアクセスして、本番環境でも同じように動くか確認する。
気をつけたこと
-
/uiは認証なしで誰でも見れる状態になる。今回はCloud Runが--allow-unauthenticatedなので、/internal/tasks/analyze-todoのようにシークレットで守る必要はないにしても、本番の実データがそのまま誰でも見える・編集できる状態になる。個人の学習用todoデータしか入ってないので今回は許容してるが、機微な内容を扱うなら/uiとPATCH/DELETEだけBasic認証をかける、IP制限をかける、などの対策が要る -
PATCHエンドポイント自体はcategory/priorityに任意の文字列を受け付けてしまう(バリデーションをかけていない)。UIは<select>で守っているが、直接curlなどで叩けば決められた語彙以外の値も入ってしまう。今回は個人用途なので割り切っているが、公開APIとして育てるならサーバー側でもCATEGORIES/PRIORITIESの許可リストと突き合わせるチェックを入れるべき - ポーリング間隔を短くしすぎるとCloud Runへのリクエスト数が増えて地味にコストに響く。3秒はローカル確認用としては短すぎるかもしれないので、本番運用するなら10秒程度に緩めてもいい
まとめ
- 新しい依存や別プロセスを立てずに、Honoに数ルート足すだけで動作確認・簡易編集用の画面は作れる
- ポーリングで「非同期処理が後から結果を反映する」様子がそのまま見えるのは、Cloud Tasksのような非同期ワーカー構成のデモとして分かりやすい
- カテゴリ・優先度のような「決まった語彙しか取らない値」は、UIでは
<select>に倒して不正な値を防ぎつつ、本来はサーバー側にも同じ許可リストでのバリデーションを入れておくのが望ましい - 認証なしの画面が「閲覧」だけでなく「編集・削除」まで誰でもできる状態になった点は、前より一段リスクが上がっているので意識しておく
次は前回の記事の最後で触れた、「terraform planの結果をPRコメントに出して、レビューしてからapplyする」という2段階フローの話をやる予定。



