0
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?

todo分析の結果をブラウザで確認・編集できる簡易UIを作る

0
Posted at

これまでの2本で、「todo作成→Cloud Tasksで非同期分析→DB更新」の機能自体は完成していた。ただ動作確認は毎回curlとDBを直接見て確認してて、ちょっと面倒だった。

今回はその確認作業をブラウザでポチポチ完結できるように、簡易的なUIを1ページ追加する。ついでに、今までAPIとして存在してなかった「編集(タイトル・カテゴリ・優先度・所要時間)」「完了(done)切り替え」もこの機会に実装する。新しいnpmパッケージは増やさない。

スクリーンショット 2026-09-27 221428.png

設計方針

  • 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等が後で埋まるか

スクリーンショット 2026-09-27 221326.png

  • チェックボックスをクリック → 取り消し線がついて完了状態になるか
  • 編集ボタン → タイトル・カテゴリ・優先度・所要時間の編集フォームが開き、保存すると一覧に反映されるか

スクリーンショット 2026-09-27 221343.png

スクリーンショット 2026-09-27 221348.png

  • 削除ボタン → 一覧から消えるか

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段階フローの話をやる予定。

0
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
0
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?