紙の2週間ヘルスログを Cloudflare D1 + R2 + Gemini で月¥15の夫婦用ヘルストラッカーに置き換えた
![]()
要旨
紙の手書き 2 週間ヘルスログ(体重・体脂肪率・運動・三食・Memo を毎日埋める A4 横レイアウト)を、Cloudflare フルスタック + Gemini 2.5 Flash + iOS Shortcuts で自動化した。
- iPhone で食事を撮って共有シート → Gemini が料理名 / kcal / PFC を JSON で返す → D1 に保存
- Apple Health から体重・体脂肪・運動を 毎日 21:00 に自動取り込み
- ダッシュボードは Astro on Cloudflare Pages、印刷は Browser Rendering で PDF 生成
- 夫婦 2 名でマルチユーザ運用 (相互非公開)、Cloudflare Access + Service Token で経路別に認証
- ランニングコスト 月額約 ¥15 (実質 Gemini 課金のみ)、Cloudflare は全部 Free 枠内
紙のフォーマットを完全に画面 / 印刷で再現 (Chart.js 不採用、SVG 直描き) しているのと、ユーザー単位のデータ分離を D1 のクエリレベルで強制してプライバシーを担保しているのがポイント。
背景: 紙の 2 週間ヘルスログを毎日埋めるのが面倒すぎる
きっかけは「紙の 2 週間ダイエット記録表」を運用していたこと。
| 上半分 | 体重グラフ (方眼、0.5kg 刻み) |
| 下半分 | 14 日 × {体脂肪率 / 運動 / 朝食 / 昼食 / 夕食 / Memo} の表 |
| 右上 | No.XX (ダイエット開始日からの通し番号) |
これを毎日手で埋めるのが面倒。特に食事の記録は
- 何を食べたかを文字で書く
- カロリーをスマホで調べる
- 走り書きで字が汚くなる
の三段苦行で、3 日でやめる。一方で 「データ自体は欲しい」(夫婦でダイエットしてる、月単位で推移を見たい、そして何より病院/医師面談で紙のフォーマットで提出する必要がある) のもあって、入力を全部自動化すれば紙ライクな UI で出力するシステム を作るのが正解だと判断した。
既存策と限界
| 案 | 限界 |
|---|---|
| 既存ヘルスアプリ (あすけん / カロミル など) | UI が縦長スマホ前提、紙レイアウトで印刷できない / マルチユーザがファミリープランで縛られる |
| Notion でデータベース化 | 食事の写真→栄養素は自動化されない、印刷 CSS が崩れる |
| 自作ローカル HTML + Chart.js | 外出先から書けない、夫婦間でデータ共有できない、印刷品質が低い (canvas 出力) |
| Google スプレッドシート | 写真からの栄養推定は無理 / Apple Health 連携が遠回り |
「自前で組む × Cloudflare フルスタック」 にすれば、認証・データ・画像・PDF 生成・配信が全部 1 アカウント に収まる。CRUD と PDF だけのアプリで、Free 枠を超えるはずもない。
全体アーキテクチャ
┌─────────────── 入力 ─────────────────┐
│ iPhone Safari + iOS Shortcuts │
│ ├─ 共有シート(食事撮影) │
│ └─ 自動化(体重/運動 毎晩21時取得) │
└───────────────┬──────────────────────┘
↓ HTTPS
┌─────────── 認証 ─────────────────────┐
│ Cloudflare Access (ユーザーごと識別) │
│ ├─ Service Token (機械, 2本) │
│ │ └─ kazuha / partner │
│ └─ Google SSO (人間, 2名) │
└───────────────┬──────────────────────┘
↓
┌─────────── API ──────────────────────┐
│ Cloudflare Workers (Hono) │
│ ├─ POST /meals (画像→Gemini→D1+R2) │
│ ├─ POST /weights, /exercises │
│ ├─ GET /api/range (週次取得) │
│ └─ GET /print/sheet (PDF出力) │
└───────────────┬──────────────────────┘
↓
┌─────────── ストレージ ───────────────┐
│ Cloudflare D1 (SQLite, user_id 分離) │
│ Cloudflare R2 (食事画像原本) │
│ Cloudflare Workers Images (HEIC変換) │
└───────────────┬──────────────────────┘
↓
┌─────────── 表示 ─────────────────────┐
│ Cloudflare Pages: Astro │
│ ├─ 日次ビュー │
│ ├─ 2週間ビュー(紙再現) │
│ └─ Chart.js は使わず SVG 直描き │
└──────────────────────────────────────┘
外部依存: Gemini 2.5 Flash (食事画像解析のみ)
Cloudflare で完結する境界は以下:
- 静的アセット:
web/dist(Astro ビルド成果物) を Worker の[assets]バインディングで配信 - API: 同じ Worker の Hono ルート
- DB: D1 (SQLite 互換)
- 画像原本: R2
- 画像の HEIC→JPEG / リサイズ: Workers Images バインディング (受信時に変換、配信は基本そのまま)
- PDF 生成: Browser Rendering バインディング
- 認証: Cloudflare Access (Zero Trust)
外部 API は Gemini だけ。
データモデル (D1)
-- ユーザー
CREATE TABLE users (
id TEXT PRIMARY KEY, -- 'kazuha', 'partner'
display_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, -- Cloudflare Access Google SSO email
diet_start_date TEXT, -- 'No.XX' 通し番号の基準日
initial_weight REAL, -- グラフ Y 軸上端
target_weight REAL, -- グラフ Y 軸下端 / 目標線
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Service Token → user_id 解決テーブル
CREATE TABLE service_tokens (
client_id TEXT PRIMARY KEY, -- Cloudflare Access Service Token Client ID
user_id TEXT NOT NULL REFERENCES users(id),
purpose TEXT NOT NULL, -- 'meal-input' / 'health-sync'
label TEXT
);
CREATE TABLE meals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id), -- ★ 必須
recorded_at TEXT NOT NULL,
meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner','snack')),
dishes_json TEXT NOT NULL, -- Gemini 構造化出力
total_kcal INTEGER,
protein_g REAL,
fat_g REAL,
carb_g REAL,
confidence REAL, -- 0.0〜1.0
image_key TEXT, -- R2 object key
raw_response TEXT, -- Gemini 生レスポンス (再解析用)
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_meals_user_recorded ON meals(user_id, recorded_at);
CREATE TABLE weights ( ... user_id NOT NULL ... );
CREATE TABLE exercises ( ... user_id NOT NULL ... );
CREATE TABLE memos ( PRIMARY KEY (user_id, date) ... );
設計判断:
- 全テーブルに
user_idを持たせる。API は冒頭でresolveUser()で current user を確定 → 以降の D1 クエリは必ずWHERE user_id = ?を付ける。これが唯一のデータ分離手段 service_tokensを DB に持つ: Token のローテーションが INSERT/UPDATE で完結する (wrangler.toml編集 + デプロイ不要)dishes_jsonを JSON 列で保存: Gemini の出力構造が将来進化するので、固定スキーマにしない。SQLite の JSON 関数で十分クエリできるraw_responseを残す: プロンプト改訂後に過去画像で再評価できる。R2 の画像と組み合わせて、いつでもモデル差し替えて再生成可能な状態を保つ- 日時は全部 JST (
Asia/Tokyo) の ISO8601 文字列
実際の日次ビュー
実装後の運用画面。データが入った状態:
![]()
上部の食事 3 件は iPhone で撮影 → 共有シート → 5 秒後に表示 されたもの。それぞれ食事写真サムネ・時刻・推定 kcal・confidence バッジ (0.9 / 1.0 / 0.9) ・料理名と個別 kcal・PFC が並ぶ。中段の高菜の油炒めは「商品パッケージのみ写ってる」ことを Gemini が自分で気付いて notes に注記 している (「画像に写っているのは商品パッケージのみで、1食分の食事全体を構成するものではない可能性があります。栄養成分表示は100gあたりの値です。」)。
下段の体重・体脂肪率は Apple Health 経由で 21:00 自動同期 された値で、source=apple_health のバッジ付き。右上の Kazuha バッジ が current user (Cloudflare Access の Google SSO で識別)。
ハードルとブレークスルー
1. Gemini の thinkingBudget=0 で 1 枚 ¥0.17 まで落ちた
最初は素直に gemini-2.5-flash を thinking 有効のまま回していた。結果:
| 設定 | 1 枚あたり | 1 枚処理時間 |
|---|---|---|
| thinking 有効 (default) | 約 ¥1〜¥2 | 15〜30 秒 |
thinkingConfig: { thinkingBudget: 0 } | 約 ¥0.17 | 約 5 秒 |
精度はむしろ thinkingBudget=0 の方が良かった (量推定が現実的に)。食事画像解析は視覚パターン認識タスクで、思考連鎖の恩恵が薄い。
月 90 枚 (1 日 3 食) を想定すると、
- thinking 有効: 月 ¥150 弱
- thinking 無効: 月 ¥15
10 倍違う。Gemini API は thinking デフォルト ON が罠、ユースケース次第で必ず無効化を検討した方がいい。
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: [...],
config: {
responseMimeType: "application/json",
temperature: 0.2,
thinkingConfig: { thinkingBudget: 0 }, // ★ コスト 10x 削減
},
});
2. workers_dev = false で Access バイパスを塞ぐ
Cloudflare Workers は default で <worker-name>.<account>.workers.dev という URL が割り当たり、ここに直接アクセスすると Cloudflare Access の保護を完全にバイパスできてしまう。カスタムドメイン (api.health.example.com 等) にだけ Access が適用される ため、*.workers.dev URL を生かしたまま運用すると認証ザルになる。
wrangler.toml:
name = "health-tracker"
# *.workers.dev URL を無効化 (Access バイパス封じ)
workers_dev = false
routes = [
{ pattern = "api.health.example.com", custom_domain = true },
{ pattern = "health.example.com", custom_domain = true },
]
これは Cloudflare の公式ドキュメントにも書かれているが、つい忘れがちな最重要設定。Access で守ってるつもりで workers.dev URL が生きてた、というのは事故。
3. Cloudflare Access JWT の実構造を調査した
Cloudflare Access のドキュメントは充実しているが、JWT の実構造 (どの claim に何が入ってるか) は実際にデコードして確かめないと分からない部分があった。実測した構造:
{
"type": "app",
"iat": 1779501217,
"exp": 1780106018,
"iss": "https://<team>.cloudflareaccess.com",
"sub": "", // ← Service Token だと空、人間 SSO だと user identity
"aud": "<application-aud-tag>",
"common_name": "<client_id>.access" // ← Service Token の client_id (人間 SSO では未設定)
}
特に重要な発見:
CF-Access-Client-Idヘッダは origin (Worker) には渡されない。代わりに上記 JWT のcommon_nameclaim から<client_id>.accessを抽出してservice_tokensテーブルで解決する- 人間 SSO の場合は
Cf-Access-Authenticated-User-Emailヘッダから email を取り、users.emailで解決する - Worker 側で
issとaudを env と一致するか検証 (別アプリ向け JWT の誤用防止)
resolveUser() ヘルパは認証経路を吸収:
export async function resolveUser(c: Context<{ Bindings: Env }>): Promise<string> {
const email = c.req.header("Cf-Access-Authenticated-User-Email");
const clientId = c.req.header("Cf-Access-Client-Id"); // edge で復元される
if (email) {
const row = await c.env.DB
.prepare("SELECT id FROM users WHERE email = ?")
.bind(email).first<{ id: string }>();
if (!row) throw new HTTPException(403, { message: "Unknown user email" });
return row.id;
}
if (clientId) {
const row = await c.env.DB
.prepare("SELECT user_id FROM service_tokens WHERE client_id = ?")
.bind(clientId).first<{ user_id: string }>();
if (!row) throw new HTTPException(403, { message: "Unknown service token" });
return row.user_id;
}
throw new HTTPException(401, { message: "Unauthenticated" });
}
このヘルパが返す userId を、以降の D1 クエリすべてに必ずバインドする。ここが破れるとデータ漏洩。
4. HEIC は受信時に Workers Images で JPEG 化して保存・配信両方を楽にする
iPhone のデフォルト画像形式は HEIC。これを R2 にそのまま保存すると三重苦:
- 1 枚 5〜6MB の容量 (HEIC は高効率コーデックだがファイル自体が小さいわけではなく、iPhone は高解像度で撮るので素のサイズが大きい)
- ダッシュボードのブラウザ表示で互換性問題 (Safari 以外で見えない)
- Gemini に送るときも変換が必要
そこで 受信時に Workers Images で JPEG 化 (最大 1600px 幅、quality 85) してから R2 に保存 することにした。容量は 約 6MB → 約 500KB まで激減し、ブラウザ表示も Gemini への送信もそのまま JPEG で完結する。
# wrangler.toml
[images]
binding = "IMG"
// POST /meals 内 (受信した HEIC/JPEG を Workers Images で JPEG 化して R2 に保存)
const upload = await c.env.IMG
.input(imageStream)
.transform({ width: 1600 })
.output({ format: "image/jpeg", quality: 85 });
await c.env.R2.put(objectKey, upload.image(), {
httpMetadata: { contentType: "image/jpeg" },
});
// その後 Gemini にも同じ JPEG バイナリを送る
await callGemini({ mimeType: "image/jpeg", data: jpegBase64, ... });
「ブラウザ表示・R2 容量削減・Gemini 送信」の 3 つの要求を、入口で 1 回 JPEG 化するだけで全部解決できる。HEIC 原本を残す案も検討したが、再評価時もリサイズ後 JPEG で精度的に十分なのと、R2 容量と Class A operation コストを考えると JPEG 一本化が現実解だった。
なお画像配信側 (GET /images/...) でも、R2 上にもし HEIC が残っていた場合 (過去データ) には自動 JPEG 化するフォールバックを入れてある。新規投稿は最初から JPEG なので Workers Images 課金は走らない。
5. meal_type の判定は Gemini じゃなくサーバ側時刻で
Gemini に「朝食 / 昼食 / 夕食 / 間食」を判定させると、画像内のメニューから推測する。ところがこれが当てにならない (朝にカレーを食べたら「dinner」と判定される等)。
設計を変えて、meal_type はサーバ側で JST 時刻から確定するようにした:
| JST 時刻 | meal_type |
|---|---|
| 0:00 - 3:59 | snack (夜食) |
| 4:00 - 10:59 | breakfast |
| 11:00 - 14:59 | lunch |
| 15:00 - 16:59 | snack (午後のおやつ) |
| 17:00 - 23:59 | dinner |
ユーザーが iOS Shortcut でメニュー指定した場合はそちらを優先。Gemini の推定値は gemini_suggested_meal_type としてレスポンスに参考保持し、ダッシュボードで「AI 推定と異なる」ときに表示できるようにした。
「AI に判定させるべき部分」と「決定論的に決められる部分」を切り分けるのは LLM アプリ設計の基本則。
6. 紙レイアウト完全再現: Chart.js 不採用 → SVG 直描き
紙の手書きフォーマットを画面でも印刷でも忠実再現するのが要件 (病院提出用のフォーマットなので、見慣れたレイアウトから外せない)。Chart.js のような canvas ベースのグラフライブラリは紙印刷の段階でラスタライズ起因のジャギが出やすい (canvas は固定解像度のビットマップなので、A4 で印刷時に拡大されると線が粗くなる)。ベクタ品質を最初から狙うために、グラフは最初から SVG 直描きで設計した。
<svg viewBox="0 0 1400 400" class="weight-chart">
<!-- 方眼背景 (5mm × 0.5kg 間隔) -->
<defs>
<pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
<path d="M 50 0 L 0 0 0 40" fill="none" stroke="#ccc" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
<!-- 体重プロット -->
<polyline
points={points.join(" ")}
fill="none"
stroke="#1c1917"
stroke-width="1.5"
/>
<!-- 軸ラベル / 目標線 / 日付 -->
...
</svg>
ベクター品質なので印刷で完全にシャープ。アニメーションも不要なので JS フレームワークも要らない。
print CSS の要点:
@page {
size: A4 landscape;
margin: 10mm;
}
@media print {
.no-print { display: none !important; }
* {
print-color-adjust: exact; /* ← これがないと背景色が消える */
-webkit-print-color-adjust: exact;
}
.sheet { page-break-after: always; }
.row, tr { page-break-inside: avoid; }
}
縦書きラベル (朝食 / 昼食 / 夕食) は writing-mode: vertical-rl を使う。
7. Browser Rendering で Astro 側に Access 認証を通す
スマホからの印刷はモバイル Safari の window.print() が信用できないので、Cloudflare Browser Rendering でサーバ側 PDF 生成 → AirPrint に流す方針。
ここで罠: Browser Rendering の puppeteer が Astro ページにアクセスしようとすると、当然 Cloudflare Access の認証画面に弾かれる。Service Account 用の Token を専用に発行して、puppeteer の HTTP リクエストヘッダに付与する:
import puppeteer from "@cloudflare/puppeteer";
app.get("/print/sheet", async (c) => {
const userId = await resolveUser(c); // ← 必須
const week = c.req.query("week");
const browser = await puppeteer.launch(c.env.MYBROWSER);
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
"CF-Access-Client-Id": c.env.RENDER_BOT_CLIENT_ID,
"CF-Access-Client-Secret": c.env.RENDER_BOT_CLIENT_SECRET,
"X-Render-User": userId, // ← Astro 側で current user 上書き
});
await page.goto(`https://health.example.com/sheet/${week}?print=1`, {
waitUntil: "networkidle0",
});
await page.emulateMediaType("print");
const pdf = await page.pdf({
format: "A4",
landscape: true,
margin: { top: "10mm", right: "10mm", bottom: "10mm", left: "10mm" },
printBackground: true,
});
await browser.close();
return new Response(pdf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="health-${userId}-${week}.pdf"`,
},
});
});
X-Render-User の信頼判定が重要: Astro 側で Cf-Access-Client-Id === RENDER_BOT_CLIENT_ID のときに限り X-Render-User を current user として採用する。それ以外のリクエストでは普通に Cf-Access-Authenticated-User-Email を優先する。これによりブラウザから直接 X-Render-User を偽装しても無視される。
Browser Rendering の Free 枠は 1 日 10 分。1 回 3〜5 秒なので月数百回まで余裕。
8. マルチユーザー: WHERE user_id = ? の漏れがゼロデー
夫婦 2 名で 相互非公開 が要件。実装上、D1 のクエリで WHERE user_id = ? が 1 本でも抜けるとデータ漏洩。
対策:
- すべての API ルート冒頭で
const userId = await resolveUser(c)を呼ぶ - D1 アクセスはこの
userIdを必ずバインドする運用 - レビュー観点として「
WHERE user_id = ?が抜けてないか」を CI の grep チェックに入れる - Astro ページのデータ取得も Worker API 経由に統一 (Astro から直接 D1 を引かない)。認証境界が Worker API に集約される
3 人目を追加する場合は users テーブルに INSERT 1 行 + Service Token を新規発行して service_tokens に追加するだけ。スキーマ変更ゼロ。
結果
| 指標 | 値 |
|---|---|
| 月額ランニングコスト | 約 ¥15 (Gemini のみ、Cloudflare は全部 Free 枠) |
| 食事 1 枚あたりの解析 | 約 ¥0.17、約 5 秒 |
| Cloudflare Workers リクエスト | 1 日数十〜100 件 (Free 枠 10 万/日に対して余裕) |
| D1 ストレージ | 数 MB (個人 2 名 × 数ヶ月で軽い) |
| R2 ストレージ | 食事画像 1 日 3 枚 × ~500KB ≒ 月 45MB (Free 枠 10GB) |
| Browser Rendering | 月 PDF 生成数十回、累計数分 (Free 枠 1 日 10 分) |
| 紙印刷品質 | A4 横、ベクター描画でシャープ |
| 入力体験 | iPhone 写真撮影 → 共有 → タップ 1 回で完了 |
教訓
- Gemini API は
thinkingBudget=0を必ず検討する。デフォルト ON で 10x のコスト差。画像認識のような視覚パターンタスクは思考連鎖の恩恵が薄い - Cloudflare Access を使うなら
workers_dev = false必須。*.workers.devURL は Access バイパスの抜け穴 - JWT の実構造はドキュメントだけで判断せず、デコードして確認する。
common_name/sub/audの実値は環境で変わる - HEIC は受信時に Workers Images で JPEG 化。ブラウザ表示・R2 容量・Gemini 送信の 3 つの要求が入口 1 回の変換で全部解決する
- LLM の判定と決定論的判定を切り分ける。meal_type のような時刻で決まるものを AI に推測させない
- 印刷品質を要件にするなら canvas (Chart.js) は使わない。SVG 直描きでベクター品質
- Browser Rendering で自社サイトにアクセスするには Service Account Token が必要。Access 適用ドメインを自分の Worker が呼ぶときの落とし穴
- マルチユーザー対応はスキーマ確定時に
user_idを入れる。あとから足すと migration が痛い - データ分離は Worker API 層に集約する。Astro から直接 DB を引かない
よくある質問
Cloudflare Free 枠で本当に収まる?
夫婦 2 名・1 日数食ペースなら確実に収まる。Workers (10 万 req/日)、D1 (5GB ストレージ)、R2 (10GB ストレージ + Class A 100 万 / 月)、Browser Rendering (1 日 10 分)、Workers Images (5 千変換 / 月) いずれも余裕。スケールアップが必要になるのは何百人レベル。
Gemini API のコストの内訳は?
実測 (2026-05-23, gemini-2.5-flash, thinkingBudget=0):
- 入力トークン: 約 979 (text 387 + image 258 + sidecar 数十)
- 出力トークン: 150〜450
- 1 枚あたり $0.0007〜$0.0014 ≒ ¥0.1〜¥0.22
- 月 90 枚 ≒ ¥15
Google Cloud Console で月額アラート $5 を設定しておくと暴走時の保険になる。
HEIC ではなく JPEG で iPhone から送ればいい?
iOS Shortcuts に「画像形式を変換」アクションはあるが、共有シートからの画像をそのまま投げる方が手数が少なく、Shortcut の構造もシンプルになる。Worker 側で受信時に JPEG 化 (+ リサイズ) すれば、ブラウザ表示・R2 容量削減・Gemini 送信が同じバイナリで成立するので 入口で 1 回まとめて変換 する方針にした。
体重を Apple Health から自動取得する Shortcut の作り方は?
要点:
- 自動化トリガー: 毎日 21:00
- Apple Health から「最新の体重」「最新の体脂肪率」を取得
- ISO8601 で時刻を付与
POST /weightsに Service Token ヘッダ付きで送信
crypto.subtle 等の高度な処理は不要、純粋にデータ転送だけなので Shortcuts 標準アクションで完結する。
Astro からの直接 D1 アクセス (Cloudflare Pages Functions 経由) ではダメ?
技術的には可能だが、認証境界を分散させたくない。Worker API に全部集約すれば「resolveUser() を呼んでない → コードレビューで弾く」が成立する。Astro 側にも D1 アクセスを許すと、レビュー観点が 2 倍になる。
Service Token は何本必要?
設計時は「ユーザー × 用途 = 4 本」で考えていたが、運用後に「同じ iPhone に meal 用と health 用の Shortcut が両方入る → 端末侵害時の影響範囲は同じ → 分割しても運用が重くなるだけ」と判断して ユーザー単位 1 本、計 2 本 に統合した。
Browser Rendering じゃなくて WeasyPrint や jsPDF は?
- WeasyPrint: Python ランタイムが必要 = Cloudflare スタックから外れる
- jsPDF / html2canvas: クライアント側 PDF 化、品質が悪い (canvas ベース)
- ブラウザ標準
window.print()→ モバイル Safari で解釈バラつき
Cloudflare 内で完結したいなら Browser Rendering 一択。
ダッシュボードの認証はどうなってる?
ブラウザアクセス時は Cloudflare Access の Google SSO で人間認証 → JWT が cookie に保存 → 以降のリクエスト全部に付与 → Worker 側で email claim から users.email で user 解決。サブドメイン全体に Access を適用しているので、ログアウト後はサインインしないと何も見えない。
写真の品質はどこまで必要?
iPhone の通常撮影 (HEIC、約 500KB〜2MB) で十分。明るい場所で撮ること、料理全体が枠に入ること、の 2 点だけ気を付ければ confidence 0.8 以上が安定して出る。暗かったり半分しか写ってないと confidence 0.5 を切るので、ダッシュボードで「要確認」マークがついて手動編集を促される設計。
紙の手書き UI を完全再現する意味ある?
病院 / 医師面談で紙のフォーマットでの提出を求められるのが主な動機。電子データを画面で見せても受け取ってもらえない場面があるので、A4 横の見慣れた手書きフォーマットを忠実再現する必要がある。「入力は自動化 / 出力は従来の紙フォーマットそのまま」が現実解だった。
制作プロセスについて
設計書 (handoff.md) 作成、データモデル設計、Gemini プロンプト試作と thinkingBudget=0 検証、Cloudflare Access の JWT 構造調査と resolveUser() 実装、Workers Images / Browser Rendering の組み込み、Astro での印刷再現 CSS と SVG 体重グラフ、iOS Shortcuts のセットアップ手順整備、マルチユーザーデータ分離のレビュー観点策定、などの調査と試行錯誤は Anthropic の Claude Code と対話しながら進めた。実機 (iPhone + Apple Watch) での Shortcut セットアップ・実食事撮影による品質確認・家族への運用説明は人間担当。