はじめに ─ Service Worker だけではオフライン学習にならなかった
前回(Phase 1+2)で Service Worker を入れ、オフラインでも白いエラー画面ではなくフォールバックページが出るようにしました。ただ、実際にオフラインで使うと 1 問目で詰みます。「次の問題へ」を押すたびにサーバーへ問題を取りに行くからです。
そこで、問題データそのものをクライアント側(IndexedDB)に持たせるのが Phase 3、オフラインで解いた回答を端末にためて、オンライン復帰時に同期するのが Phase 4 です。
本記事はこの Phase 3+4 を扱います。対象は「で、オフラインで実際に何ができるの?」と思った方と、既存のサーバー駆動 UI を壊さずにオフライン機能を足したい Rails エンジニア。IndexedDB 入門ではなく、設計判断を中心に書きます。
1. 設計方針 ── 既存を流用し、オフライン専用経路だけ足す
1-1. 既存の学習機能をそのまま使わなかった理由
このアプリには元々「学習セッション(study_session)」という中心機能があります。「次に出すべき問題」をサーバー側で毎回計算する作りで、ユーザの正答率や苦手分野を見て出題を最適化するロジックが Rails 側に乗っています。
# 既存の StudySessionsController#show のイメージ(汎用化)
def show
@session = current_user.study_sessions.find(params[:id])
@question = Question.weak_for_user(current_user) # サーバー側で都度計算
# ...
endなので「次の問題へ」を押すたびに サーバー往復が前提になっています。これをそのままオフラインで動かそうとすると、出題最適化のロジックごとクライアント側に移植することになり、実質、フロントを丸ごと作り直す大改修です。影響範囲が大きく、既存の UX を壊すリスクも高い ── 正直、ここに手を入れるのは避けたいところでした。
そこで方針を切り替えました。既存フローには一切触らず、オフライン専用の別経路を新しく足す。オフライン練習は「ランダムに出して解く軽い復習」と割り切り、出題最適化が要る本格的な学習は従来どおりサーバー駆動のセッションに任せる。要は 2 つの経路を住み分けさせる、という判断です。詳しくは §3-1 で書きます。
1-2. 全体像 ── 何を新しく作り、何は既存を流用するか
実装に入る前に、Phase 3+4 で何を新しく作り、何は既存のまま流用するかを一望しておきます。「既存を壊さない」方針なので、サーバー側はほとんど流用で済み、新規追加はオフライン専用の部品に絞られるのが見てとれます。
| 層 | やること | 新規 / 既存 |
|---|---|---|
| ルーティング | questions#index を JSON でも返す | 既存流用(ルート追加なし) |
| ルーティング | offline_practice / offline_attempts(member 2 本) | 新規 |
| サーバー | QuestionsController に JSON 用メソッド | 既存に追加 |
| サーバー | OfflinePracticeController(show / bulk_create) | 新規 |
| クライアント・保存 | db.js / question_store.js(IndexedDB) | 新規 |
| クライアント・同期 | offline_sync_controller.js(裏で自動 sync) | 新規 |
| クライアント・練習 UI | offline_practice_controller.js / show.html.erb | 新規 |
これを 2 つの Phase に割り振ります。Phase 3 はデータの土台(問題を JSON で渡す口 → IndexedDB に保存 → 裏で自動同期)、Phase 4 はページ本体(/exams/sap/offline_practice という新しい練習ページ + 回答キュー + 一括同期)です。§1 で触れた「オフライン専用の別経路」の正体が、この offline_practice ページになります。
2. Phase 3 ── 問題データを IndexedDB に持たせる
Phase 3 では、まず データの土台だけを作ります。画面への組み込みは Phase 4 に回して、ここでは「サーバーから問題データを JSON でもらう → IndexedDB に保存 → オフラインでも読み出せる」ところまでを用意します。
2-1. サーバー側:questions コントローラに JSON 用メソッドを追加
まず、問題データを JSON で配信するエンドポイントが要ります。/api/v1/... のような独立 namespace ではなく、既存の QuestionsController#index に respond_to で JSON を足すだけにしました。URL は /exams/sap/questions.json です。
# app/controllers/questions_controller.rb
def index
@questions = @exam.questions.order(:position)
respond_to do |format|
format.html
format.json { render json: questions_json_payload(@questions) }
end
end
private
def questions_json_payload(questions)
locale = I18n.locale.to_s
{
exam: { id: @exam.id, slug: @exam.slug, name: @exam.name },
locale: locale,
generated_at: Time.current.iso8601,
questions: questions.includes(:tags).map { |q|
{
id: q.id,
position: q.position,
domain: q.domain,
body: q.localized_body(locale),
choices: q.localized_choices(locale),
correct_answers: q.correct_answers,
source_explanation: q.localized_explanation(locale),
tags: q.tags.map(&:name)
}
}
}
endここで意識したのは次の 3 点です。
includes(:tags)で N+1 を回避 ── 全問をループしてタグを引くと N+1 になる。一括取得しておくcorrect_answers(正解)を含める ── オフラインでローカル採点するために必要。正解は「回答後に表示される情報」なので機密ではない、という整理(FAQ 参照)- 単一 locale で送る ── このアプリは 3 言語対応だが、3 言語 × 全問を一度に送るとペイロードが重い。ユーザが locale を切り替える頻度は低いので、現在の locale 分だけ送り、切り替え時に再 sync する。
metaストアにlocaleを記録しておく
2-2. クライアント側:問題を IndexedDB に保存する処理を自作する
IndexedDB のラッパーは idb や Dexie を使うのが一般的ですが、今回は 入れませんでした。扱うストアは questions と meta の 2 つだけで、自前でも 100 行未満。それ以上に、importmap-rails 環境で npm 依存を増やすとビルド構成に影響するのが大きく、この規模なら自前ラッパーの方が見通しが良いと判断しました。
importmap に pin_all_from "app/javascript/offline", under: "offline" を足して、offline/question_store として import できるようにします。スキーマは questions ストア(keyPath: "id")と meta ストア(keyPath: "key")の 2 つです。
// app/javascript/offline/question_store.js(Phase 3 時点)
const DB_NAME = "app"
const DB_VERSION = 1
const QUESTIONS_STORE = "questions"
const META_STORE = "meta"
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = (event) => {
const db = event.target.result
const store = db.createObjectStore(QUESTIONS_STORE, { keyPath: "id" })
store.createIndex("exam_slug", "exam_slug", { unique: false })
store.createIndex("position", "position", { unique: false })
db.createObjectStore(META_STORE, { keyPath: "key" })
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
export async function saveQuestions(examSlug, questions, locale) {
// transaction で全件 put、meta に sync 時刻と件数を記録
}2-3. Stimulus 同期コントローラ(silent sync)
データの取得と保存をいつ走らせるか。オンラインでアプリを開いたとき、最終同期から 1 時間以上たっていたら自動で fetch する方式にしました。
これを offline_sync_controller.js という Stimulus controller にして、ログイン後のレイアウト全体に張ります。ナビ直後に <div data-controller="offline-sync" data-offline-sync-exam-slug-value="sap"> を置くだけです。
connect()でnavigator.onLineをチェック- オンラインなら最終同期時刻を確認し、1 時間以上経過なら
fetch("/exams/sap/questions.json")→saveQuestions() windowのonline/offlineイベントを購読して、オフラインバナーを出し分け
// app/javascript/controllers/offline_sync_controller.js
import { Controller } from "@hotwired/stimulus"
import { saveQuestions, countQuestions, getSyncMeta } from "offline/question_store"
const SYNC_INTERVAL_MS = 60 * 60 * 1000 // 1 時間
export default class extends Controller {
static values = { examSlug: String }
connect() {
window.addEventListener("online", () => this.maybeSync())
window.addEventListener("offline", () => this.handleOffline())
navigator.onLine ? this.maybeSync() : this.handleOffline()
}
// 最終同期から 1 時間以上たっていたら取りに行く
async maybeSync() {
const meta = await getSyncMeta(this.examSlugValue)
const lastSync = meta?.synced_at ? Date.parse(meta.synced_at) : 0
if (Date.now() - lastSync < SYNC_INTERVAL_MS) return
await this.runSync()
}
async runSync() {
const slug = this.examSlugValue
const res = await fetch(`/exams/${slug}/questions.json`, {
credentials: "same-origin",
headers: { "Accept": "application/json" },
})
if (!res.ok) return
const payload = await res.json()
await saveQuestions(slug, payload.questions || [], payload.locale)
console.info(`[offline-sync] saved ${payload.questions?.length || 0} questions for ${slug}`)
}
// handleOffline() で「○○ 問が端末に保存済み」バナーを出す処理は省略
}ユーザに何も意識させず、裏で問題データを最新化しておく ── いわば silent sync です。おかげで、オフライン練習を始めようとした時点では、たいてい問題データはもう端末にある状態にできます。
3. Phase 4 ── オフライン専用練習モード
Phase 3 でデータレイヤーが揃ったので、Phase 4 で 「オフラインで問題を解く UI」と「回答キュー + 一括同期」を作ります。
3-1. オフライン専用の練習ページを新設する
§1 の方針どおり、既存フローには触れず 別経路 /exams/:slug/offline_practice を新設します。Rails でページを 1 つ作るので、コントローラ・ビュー・JS・ルーティングを 1 セットで用意します。各部品と、それを解説する節は次のとおりです。
| 部品 | ファイル | 役割 | 解説 |
|---|---|---|---|
| ルーティング | routes.rb(member 2 本) | URL を生やす | §3-3 |
| コントローラ | OfflinePracticeController | ページ表示 + 回答受け取り | §3-3 |
| ビュー | offline_practice/show.html.erb | start / question / result の 3 ビューを持つ HTML シェル | §3-4 |
| JS(Stimulus) | offline_practice_controller.js | 出題・採点・回答キュー・同期を動かす | §3-4 |
| データ層 | db.js / question_store.js / attempt_queue.js | 問題と回答を IndexedDB に出し入れ(Phase 3 で作成) | §3-2 |
動作面では、このページは次のような作りです。
- 問題は IndexedDB から出す(サーバーに取りに行かない)
- 画面は HTML シェル + クライアントレンダリング
- 回答はその場で送らず、IndexedDB のキューに溜めて後でまとめて送る
- 採点は その場でクライアント側 → 同期時にサーバーが再評価
3-2. 回答も IndexedDB に貯める
オフラインで解いた回答は、オンラインに復帰して同期するまで 端末に貯めておく必要があります。これも IndexedDB に、回答用の attempts_queue ストアを作って溜めます。
ここで、§2-2 で作った「問題を読み書きする処理」に加えて、この「回答を貯める処理」も同じ IndexedDB を使うことになります。つまり 同じ DB を使うコードが 2 つになります。
IndexedDB では、ストアを作れるのは「初回(またはバージョンを上げたとき)に 1 度だけ走る初期化」の中だけです。もし 2 つのモジュールがそれぞれ別々に DB を開くと、先に開いた方の初期化しか走らず、もう一方が作るはずのストアができません。
そこで、ストアの定義と DB を開く処理を app/javascript/offline/db.js の 1 か所にまとめ、両モジュールはそこから同じ DB を受け取って使います。これなら初期化は 1 回で済み、ストアも全部そろいます。
ストアを増やす(attempts_queue の追加)には、IndexedDB の バージョンを上げる必要があります。Phase 3 の時点で既に v1(questions + meta)を配ってしまっているので、v2 に上げて、既存ユーザ(v1 を持っている)と新規ユーザの両方を 1 つのコードで移行します。
// app/javascript/offline/db.js
const DB_NAME = "app"
const DB_VERSION = 2
let dbPromise = null
export function openDB() {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = (event) => {
const db = event.target.result
const oldVersion = event.oldVersion
if (oldVersion < 1) {
// v1 スキーマ(questions + meta)
}
if (oldVersion < 2) {
// v2 で追加:attempts_queue(autoIncrement key)
const attempts = db.createObjectStore("attempts_queue", {
keyPath: "id", autoIncrement: true
})
attempts.createIndex("exam_slug", "exam_slug", { unique: false })
attempts.createIndex("attempted_at", "attempted_at", { unique: false })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
return dbPromise
}oldVersion < 1 と oldVersion < 2 を独立した if で書くのがポイントです。新規ユーザ(v0 → v2)は両方の if を通り、既存ユーザ(v1 → v2)は v2 の差分だけ通る。1 つのコードで両方のユーザをカバーできます。dbPromise をモジュールスコープにキャッシュして、複数回 openDB() を呼んでも接続は 1 つに保ちます。
共通化した db.js の上に、回答を貯める attempt_queue.js を作ります。autoIncrement: true の id を採用したので、サーバーに送らない端末ローカルな id 体系を持てます。同期に成功したら、その id でキューから削除します。
// app/javascript/offline/attempt_queue.js
import { withStore, ATTEMPTS_QUEUE_STORE } from "offline/db"
export async function enqueueAttempt({
examSlug, questionId, selectedAnswers, correct, timeSpentSeconds
}) {
return withStore(ATTEMPTS_QUEUE_STORE, "readwrite", (store) =>
new Promise((resolve, reject) => {
const request = store.add({
exam_slug: examSlug,
question_id: questionId,
selected_answers: selectedAnswers,
correct: !!correct,
time_spent_seconds: timeSpentSeconds || null,
attempted_at: new Date().toISOString(),
})
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
)
}
// countQueuedAttempts / getQueuedAttempts / removeAttempts も同様attempted_at に 回答した時刻を記録しておくのが重要です。オフラインで解いてから同期するまでに時間が空くので、同期した時刻ではなく解いた時刻を残します。これはサーバー側で created_at に反映します(§3-3)。
3-3. サーバー側:一括同期エンドポイント
キューに溜まった回答を受け取る bulk_create を作ります。ルートは、オフライン練習ページを表示する GET と、溜まった回答を受け取る POST の 2 本です。
# config/routes.rb
get :offline_practice, to: "offline_practice#show", on: :member
post :offline_attempts, to: "offline_practice#bulk_create", on: :member# app/controllers/offline_practice_controller.rb
def bulk_create
items = Array(params[:attempts])
saved = skipped = 0
Attempt.transaction do
items.each do |item|
question = @exam.questions.find_by(id: item[:question_id])
next (skipped += 1) unless question
attempt = current_user.attempts.new(
question: question,
selected_answers: Array(item[:selected_answers]).reject(&:blank?),
time_spent_seconds: item[:time_spent_seconds].presence&.to_i,
)
# オフライン中の実時刻を created_at に保つ
attempt.created_at = item[:attempted_at] if item[:attempted_at].present?
attempt.save ? (saved += 1) : (skipped += 1)
end
end
render json: { ok: true, saved: saved, skipped: skipped }
endここでのポイントは 2 つです。
- 採点はサーバーで再評価 ── クライアントから届く
correctは信用せず、保存時にAttemptモデル(before_save)が採点し直します - 1 件の不整合で同期全体を止めない ── 不正な
question_idなどは skip して件数を数えるだけ。残りはAttempt.transactionでまとめて保存します
3-4. オフライン練習の画面を作る
オフライン練習の画面は、show.html.erb の 同一ページ内に start / question / result の 3 つのビューを置き、Stimulus controller が classList.toggle("hidden") で切り替えます。Turbo Frames は使いません。Turbo Frame はサーバー往復が前提なので、オフラインで動く必要があるこの画面とは相性が悪いためです。
| ビュー | いつ表示 | 中身 |
|---|---|---|
| start | 練習を始める前 | 「○○ 問が利用可能です」+「練習を始める」 |
| question | 問題を解いている間 | 問題文・選択肢・「回答する」 |
| result | 回答した後 | 正解 / 不正解・「次の問題」 |
サーバーから controller へのデータ受け渡しは data-* 属性で行います。同期先 URL、CSRF トークン、i18n 文字列などを埋め込みます。
<div data-controller="offline-practice"
data-offline-practice-exam-slug-value="<%= @exam.slug %>"
data-offline-practice-sync-url-value="<%= offline_attempts_exam_path(@exam) %>"
data-offline-practice-csrf-token-value="<%= form_authenticity_token %>"
data-correct-label="<%= t("pwa.offline.practice.correct") %>">
<!-- start / question / result の 3 ビュー -->
</div>controller(offline_practice_controller.js)の処理フローは次のとおりです。
connect():IndexedDB から問題を読み出し、利用可能件数 / 同期待ち件数を表示start():Fisher-Yates でシャッフルし、最初の問題を表示submit():選択肢をarraysEqualでローカル採点 →enqueueAttempt()で IndexedDB に保存 → 結果表示next():次の問題へ。末尾まで来たらシャッフルし直してループsync():同期ボタン押下時にPOST /offline_attemptsで一括送信 → 成功ならremoveAttempts()
同期は、オンラインに戻ったときに溜まった回答データをサーバーへ送る処理です。この POST にも Rails の CSRF 保護は効くので、コードの data-...-csrf-token-value で渡したトークンを X-CSRF-Token ヘッダに乗せて送ります。
4. オフライン同期の実装について
§3 では同期を、window の online イベントと「同期」ボタンで実装しました。実はオフライン同期には、もう一つ Background Sync API という定番の選択肢があります。Service Worker に sync イベントを登録しておけば、ブラウザがネットワーク復帰を検知して自動で同期を走らせてくれる仕組みです。今回はこれを採用しませんでした。
理由は 2 つです。1 つは iOS Safari が Background Sync API に対応していないこと。このアプリの主要ターゲットは iPhone なので、iOS で動かない仕組みをメイン経路には据えられません。
もう 1 つは UX 設計上の判断です。学習アプリでは 「自分の回答がいつサーバーに届いたか」をユーザが把握できる方が安心感がある。Background Sync で裏側で勝手に同期されるより、「同期待ち N 件」を表示して「同期」ボタンで送る、あるいはオンライン復帰時に自動同期する、という ユーザに見える同期の方が、この用途には合っています。
5. 動作確認
動作確認は Claude Code(Playwright の自動テスト)と iPhone 実機で行いました。確認した項目は次のとおりです。
- Playwright:コンソールに
[offline-sync] saved N questions for sapが出て、IndexedDB のquestionsストアに件数分のレコードが入る(ローカル 225 / 本番 423 問) - iPhone 実機:オフライン時に「○○ 問が端末に保存済み」バナーが出る ── fetch・保存・オフライン検知が一通り効いている証拠
- iPhone 実機:オフラインで解く →「未同期 N 件」が増える → オンライン復帰 →「今すぐ同期」でバナーが消える、のフルフロー
- 同期は成功(200)時だけキューを消す設計なので、「バナーが消えた =
attemptsに記録済み」と言い切れる
6. ハマったところ・判断の記録
6-1. importmap 未更新で module specifier が解決できない
offline/question_store を import しようとして Failed to resolve module specifier で止まりました。importmap-rails は明示的に pin したモジュールしか解決しません。config/importmap.rb に pin_all_from "app/javascript/offline", under: "offline" を足す必要があります。bin/importmap json で実際に pin されているか確認できます。Webpack などのバンドラに慣れていると「ファイルを置けば import できる」と思いがちですが、importmap は違います。
6-2. DB バージョンバンプ時のテストの難しさ
IndexedDB のスキーマを v1 → v2 に上げたとき、確認すべきは「新規ユーザ(v0 → v2)」と「既存ユーザ(v1 → v2)」の 2 経路です。前者は DevTools → Application → IndexedDB → Delete database でクリーンスタートすれば確認できます。問題は後者で、「一度 v1 のコードでアプリを開いてから、v2 のコードに差し替える」という手順を踏まないと再現できません。本番デプロイでは既存ユーザは全員この経路を通るので、横着せず dev で再現テストをやっておくのが事故防止になります。
7. Phase 3+4 で変わったこと、次フェーズの予告
Phase 1+2 では「オフラインで開ける」だけでしたが、Phase 3+4 を入れて 「オフラインで実際に学習できる」状態になりました。
- 機内モード・地下鉄でも、IndexedDB に保存済みの問題を解ける
- 「次の問題へ」が押せる ── サーバー往復がないので、むしろオンラインより軽快
- オフライン中の回答は端末にキューイングされ、オンライン復帰時に一括同期される
- 既存のサーバー駆動セッションは一切変更していない ── 2 経路が住み分けて共存
ここまでで「アプリを開けば学習できる」は完成しました。残るのは 「アプリを開くきっかけ」をアプリ側から作ること ── つまり通知です。次の Phase 5(最終回)では、Web Push 通知を実装します。VAPID 鍵の管理、iOS 16.4+ の standalone モード制約、Solid Queue による日次配信、失敗購読の自動破棄まで扱う予定です。続編:Rails 8 で Web Push 通知を実装した話 へ続きます。
あとがき
本記事は、Rails 8 の PWA に「実際に使えるオフライン機能」を足すまでの実装記録です。技術的な核は IndexedDB ですが、本当に伝えたかったのは 「既存のサーバー駆動 UI を SPA 化で作り直すのではなく、独立した別経路として足す」という設計判断の方です。オフライン対応は、ともすると「アプリ全体を作り直す」大工事になりがちですが、役割を割り切って住み分けさせれば、既存 UX を一切壊さずに段階的に足せます。
MOOBON では Rails / Web アプリケーションの新規開発・既存案件への機能追加・PWA 化やオフライン対応の設計 を承っています。「既存の Rails アプリにオフライン機能を足したいが、影響範囲を抑えたい」「IndexedDB や Service Worker の設計をレビューしてほしい」といったご相談も歓迎です。info@moobon.jp までお気軽にどうぞ。
よくある質問
Qなぜ既存の study_session 経路を SPA 化してオフライン対応しなかったのですか?
既存の学習セッション(study_session)は、「次に出すべき問題」をサーバー側で都度計算する設計です。ユーザの正答率や苦手分野を見て出題を最適化するロジックが Rails 側にあり、「次の問題へ」のたびにサーバー往復が前提になっています。これをオフラインで動かすには、出題ロジックごとクライアントに移植する SPA 化レベルのリライトが必要で、影響範囲が大きすぎます。本案件では既存フローを一切触らず、別経路 /exams/:slug/offline_practice を新設しました。オフライン練習は「ランダムにシャッフルして解く軽い復習」と役割を割り切り、本気で解くなら従来のセッション、という住み分けです。既存 UX への影響をゼロにできること、機能として独立して育てられることがメリットです。
Qなぜ idb や Dexie などの IndexedDB ライブラリを使わなかったのですか?
IndexedDB は API が古く Promise ベースでないため、ライブラリで包むのが一般的です。ただし本案件で扱うストアは 3 つ(questions / meta / attempts_queue)だけで、必要な操作も「全件 put」「全件 get」「条件付き delete」程度です。openDB と withStore というヘルパーを Promise でラップすると、スキーマ定義込みで 50 行程度に収まりました。importmap-rails 環境では npm 依存を増やすとビルド構成にも影響が出るため、この規模なら自前ラッパーの方が依存を増やさず見通しが良いという判断です。ストア数や操作の複雑さが増えてきたら、その時点で idb への移行を検討します。
QJSON ペイロードに correct_answers(正解)を含めるのはセキュリティ上問題ないですか?
オフラインでローカル採点するために correct_answers をペイロードに含めています。「DevTools を開けば正解が見える」状態にはなりますが、これは元々「回答を送信したあとに表示される情報」であり、機密情報ではありません。試験問題そのものの著作的な扱いは別途考慮が要りますが、正解データを隠すことに技術的な意味は薄いと判断しました。逆に、本当に隠すべき情報(他ユーザのデータ、課金情報など)は当然ペイロードに含めません。「オフラインで自己採点する」という機能要件と、「隠しても意味がない情報」という性質が噛み合っているケースです。
QIndexedDB のスキーマアップグレード(v1 → v2)はどうテストしましたか?
IndexedDB は onupgradeneeded の中で oldVersion を見て段階的にマイグレーションを書きます(if oldVersion < 1 / if oldVersion < 2 のパターン)。テストで確認すべきは 2 経路で、(1) 新規ユーザ(v0 → v2 を一気に通る)、(2) 既存ユーザ(v1 → v2 の差分だけ通る)。開発中は DevTools → Application → IndexedDB → Delete database で v0 状態を作って (1) を確認し、一度 v1 のコードで開いてから v2 のコードに差し替えて (2) を確認します。本番デプロイでは既存ユーザは全員 (2) の経路を通るので、この再現を dev で必ずやっておくのが事故防止になります。
Qユーザが端末を変えた場合、オフライン中の回答はどうなりますか?
オフライン中の回答は、その端末の IndexedDB の attempts_queue ストアにローカル保存されます。オンライン復帰時にサーバーへ一括同期し、成功したらキューから削除されます。同期する前に別端末に乗り換えたり、PWA をアンインストールしたりすると、その未同期分は失われます。これは「オフライン中の回答は端末ローカルな一時データ」と割り切った設計で、同期さえ済めばサーバー側の attempts テーブルに正規データとして残ります。実運用上は「オンラインに戻ったら早めに同期ボタンを押す」「アプリ起動時に自動同期する」で大半をカバーできます。複数端末間のリアルタイム同期が要件になったら、その時点で設計を見直す前提です。
