【IT】診察に役立つお薬記録アプリをPWAで作る:SvelteKit実装とiPhoneの壁(第2回)
―― IndexedDB設計とCSV入出力、残量計算ロジックの実装
連載は全3回
第1回:背景・PWA/Service Workerの基礎・iPhoneの制約(+URL/使い方/免責/提供環境)
第2回:IndexedDB設計/CSV入出力/残量計算ロジック/Safari対策(←いまここ)
第3回:運用と診察提示の工夫、バックアップ方法、今後の改善
はじめに
前回(第1回)では、ニトロスプレー(ニトログリセリン舌下スプレー)使用記録アプリを作るきっかけや、PWA/Service Workerの基礎、そして「iPhoneの壁」についてお話ししました。
今回は実装面に踏み込み、以下のポイントを解説します。
データを保存する IndexedDB の構造設計
診察時や機種変更で便利な CSV入出力
スプレー残量を計算する 空プッシュ補正ロジック
Safari特有のフォーム入力制約の回避
※本記事内の画面キャプチャは、実際のアプリ画面を撮影したものです。
1. IndexedDB設計
選定理由
ローカル保存:端末内に長期保存でき、サーバー不要
オフライン対応:PWAとの相性が良く、電波がなくても記録可能
構造化データ:単純なテキスト以外も管理しやすい
ストア構成
ストア名主キー用途spray_logsid(UUID)使用記録本体spray_metaspray_idスプレーごとの設定(期限や補正値)
spray_logs の例
{
"id": "uuid-v4",
"spray_id": 1,
"spray_count": 1,
"used_at": "2025-08-10T12:34:56Z",
"memo": "胸の痛みあり"
}
spray_meta の例
{
"spray_id": 1,
"expiry_date": "2025-09",
"correction_factor": 2
}
2. ログの追加・削除
// ログ追加
export async function addLog(sprayId: number, sprayCount: number, memo?: string) {
const db = await openDB('nitro-spray', 1);
await db.add('spray_logs', {
id: crypto.randomUUID(),
spray_id: sprayId,
spray_count: sprayCount,
used_at: new Date().toISOString(),
memo
});
}
// ログ削除
export async function deleteLog(id: string) {
const db = await openDB('nitro-spray', 1);
await db.delete('spray_logs', id);
}
💡 ポイント
削除後は状態ストアも更新し、UIに即反映
残量計算はスプレーIDごとに動的算出
3. CSVエクスポート
流れ
IndexedDBから全レコード取得
ヘッダー+各行をCSV文字列に
Blob化→ダウンロードリンク生成
export async function downloadCSV() {
const db = await openDB('nitro-spray', 1);
const logs = await db.getAll('spray_logs');
const header = ['ID', 'Spray ID', 'Count', 'Used At', 'Memo'];
const rows = logs.map(log => [
log.id, log.spray_id, log.spray_count, log.used_at, log.memo ?? ''
]);
const csv = [header, ...rows].map(r => r.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `spray_logs_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
}
📸 アプリ画面
「CSVダウンロード」ボタン押下前後のUI

保存されたCSVファイルをエディタで開いた画面

4. CSVインポート(復元)
export async function importCSV(file: File) {
const text = await file.text();
const [header, ...lines] = text.split('\n').map(line => line.split(','));
const db = await openDB('nitro-spray', 1);
for (const line of lines) {
if (line.length < 5) continue;
const [id, spray_id, spray_count, used_at, memo] = line;
await db.put('spray_logs', {
id,
spray_id: Number(spray_id),
spray_count: Number(spray_count),
used_at,
memo
});
}
}
💡 ポイント
ユーザーが間違って別形式を読み込んでも安全にスキップ
重複チェックを入れる場合は db.get('spray_logs', id) で事前確認
📸 アプリ画面
ファイル選択ダイアログ

復元後に一覧に反映された画面

5. 残量計算と空プッシュ補正
計算式
export function calcRemaining(logs: Log[], sprayId: number, correction: number) {
const used = logs
.filter(l => l.spray_id === sprayId)
.reduce((sum, l) => sum + (l.spray_count + correction), 0);
return Math.max(0, 100 - used);
}
💡 ポイント
1本100回を基準に残量計算
空プッシュ補正値は spray_meta に保存
残量が10回未満で警告色を表示(Tailwind条件クラスで実装)
📸 キャプチャ提案:
残量正常時と警告時の画面


6. Safari対策(<input type="time">)
iOS Safariはtype="time"のUIが制限されるため、条件分岐で代替入力を提供。
{#if isPcSafari}
<input type="text" pattern="[0-2][0-9]:[0-5][0-9]" placeholder="例:14:30" />
{:else}
<input type="time" />
{/if}
判定関数:
export const isPcSafari =
/^((?!chrome|android).)*safari/i.test(navigator.userAgent) &&
!/iphone|ipad|ipod/i.test(navigator.userAgent);
📸 アプリ画面
PC Safariでのテキスト入力画面

iPhone Safariでのtimeピッカー画面

まとめと次回予告
今回紹介した IndexedDB の構成や CSV 出力処理は、小規模なPWAアプリでも十分に使える汎用的なパターンです。
特に、
・ローカル保存(IndexedDB)での高速アクセス
・シンプルなCSV出力による医師への提示
・記録と残量管理のロジック分離
が実装のポイントになりました。
次回(第3回)は、
・実際の診察での提示方法
・運用上の工夫(事前キャッシュの取り方など)
・バックアップ運用
・無料クラウドホスティング環境の制約との付き合い方
についてお届けします。
では、
また次回に!
