見出し画像

【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エクスポート

流れ

  1. IndexedDBから全レコード取得

  2. ヘッダー+各行をCSV文字列に

  3. 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回は、
・実際の診察での提示方法
・運用上の工夫(事前キャッシュの取り方など)
・バックアップ運用
・無料クラウドホスティング環境の制約との付き合い方
についてお届けします。

では、
また次回に!


前回の記事

次回の記事

過去のSvelte学習記事



いいなと思ったら応援しよう!