![]()
diff --git a/web/pages/omikuji.vue b/web/pages/omikuji.vue
index 47bf899..6c871f4 100644
--- a/web/pages/omikuji.vue
+++ b/web/pages/omikuji.vue
@@ -87,6 +87,11 @@ import { getAuth, onAuthStateChanged } from "firebase/auth";
import { mapGetters } from "vuex";
import ResultCard from "@/components/OmikujiResult";
import OmikujiScene from "@/components/OmikujiScene";
+import {
+ saveOmikujiState,
+ loadOmikujiState,
+ formatOmikujiRemaining,
+} from "@/utils/omikujiCooldown";
function resolveCurrentUser(auth) {
return new Promise((resolve) => {
@@ -110,6 +115,24 @@ export default {
};
},
async mounted() {
+ // 覚えているものがあれば、通信を待たずにその場で出す。ここで待たせると
+ // 「押してから待たされた末に引けないと分かる」になる。
+ // サーバーの応答が来たら上書きされる(正はあくまでサーバー)。
+ const saved = loadOmikujiState(
+ this.user && this.user.github_id,
+ Date.now()
+ );
+ if (saved) {
+ this.result = saved.result;
+ if (saved.remainingSeconds > 0) {
+ this.remaining = saved.remainingSeconds;
+ this.state = "cooldown";
+ this.startTimer();
+ } else if (saved.result) {
+ // 引ける。前回の結果を添えてボタンを出す。
+ this.state = "available";
+ }
+ }
await this.fetchStatus();
},
beforeDestroy() {
@@ -129,7 +152,11 @@ export default {
},
// 引かずに現在の状態(引ける/クールダウン+前回結果)を取得する。
async fetchStatus() {
- this.state = "loading";
+ // 既に出せるものがあるなら、確認中もそれを出したままにする。
+ // ローディングに戻すと、せっかく即表示した意味が無くなる。
+ if (this.state !== "cooldown" && this.state !== "available") {
+ this.state = "loading";
+ }
const token = await this.getToken();
if (!token) return;
try {
@@ -182,6 +209,12 @@ export default {
this.remaining = this._pendingRemaining || 0;
this.pendingResult = null;
this.state = "cooldown";
+ saveOmikujiState(
+ this.user && this.user.github_id,
+ this.remaining,
+ this.result,
+ Date.now()
+ );
this.startTimer();
} else {
// 演出が結果より先に終わった(スキップ等)→ サーバー状態を取り直す
@@ -189,6 +222,19 @@ export default {
}
},
applyResponse(d) {
+ // 次に引ける時刻と前回の結果を覚えておく(次に開いたとき待たせないため)。
+ if (
+ d.status === "success" ||
+ d.status === "cooldown" ||
+ d.status === "available"
+ ) {
+ saveOmikujiState(
+ this.user && this.user.github_id,
+ d.remaining_seconds || 0,
+ d.result || this.result,
+ Date.now()
+ );
+ }
if (d.status === "success") {
this.result = d.result;
this.remaining = d.remaining_seconds || 0;
@@ -212,14 +258,28 @@ export default {
},
startTimer() {
if (this.timerId) clearInterval(this.timerId);
+ // 残り秒を1秒ずつ引くのではなく、明ける時刻を決めて毎回そこから計算する。
+ // タブが背面にあると setInterval は間引かれる(止まることもある)ため、
+ // 引き算だと戻ってきたときに実際より長い残り時間を表示してしまい、
+ // 「引けるのに引けないと見える」という直したかった症状が再発する。
+ const deadline = Date.now() + this.remaining * 1000;
this.timerId = setInterval(() => {
- this.remaining -= 1;
+ this.remaining = Math.max(
+ 0,
+ Math.ceil((deadline - Date.now()) / 1000)
+ );
if (this.remaining <= 0) {
clearInterval(this.timerId);
this.timerId = null;
this.remaining = 0;
// 引ける状態へ。前回結果は残しつつボタンを出す。
this.state = "available";
+ saveOmikujiState(
+ this.user && this.user.github_id,
+ 0,
+ this.result,
+ Date.now()
+ );
}
}, 1000);
},
@@ -227,13 +287,8 @@ export default {
computed: {
...mapGetters(["user"]),
remainingText() {
- const s = Math.max(0, this.remaining);
- const h = Math.floor(s / 3600);
- const m = Math.floor((s % 3600) / 60);
- const sec = s % 60;
- if (h > 0) return `${h}時間${m}分`;
- if (m > 0) return `${m}分${sec}秒`;
- return `${sec}秒`;
+ // トップページと同じ表記にする(整形は omikujiCooldown.js に一本化)。
+ return formatOmikujiRemaining(this.remaining);
},
shareUrl() {
return this.$config.baseUrl;
diff --git a/web/scripts/test-omikuji-cooldown.js b/web/scripts/test-omikuji-cooldown.js
new file mode 100644
index 0000000..b13016c
--- /dev/null
+++ b/web/scripts/test-omikuji-cooldown.js
@@ -0,0 +1,82 @@
+// utils/omikujiCooldown.js の純関数テスト。
+// 実行: node web/scripts/test-omikuji-cooldown.js
+const assert = require("assert");
+const m = require("../utils/omikujiCooldown");
+
+function fakeStorage() {
+ const map = {};
+ return {
+ getItem: (k) => (k in map ? map[k] : null),
+ setItem: (k, v) => {
+ map[k] = v;
+ },
+ removeItem: (k) => {
+ delete map[k];
+ },
+ };
+}
+
+const now = 1700000000000;
+const result = { tier: "大吉", fortune: "ビルドが通る" };
+
+// 保存した残り時間と結果が読める
+let s = fakeStorage();
+m.saveOmikujiState("428875", 3600, result, now, s);
+let got = m.loadOmikujiState("428875", now, s);
+assert.strictEqual(got.remainingSeconds, 3600);
+assert.deepStrictEqual(got.result, result);
+
+// 時間が進めば減る
+assert.strictEqual(
+ m.loadOmikujiState("428875", now + 600000, s).remainingSeconds,
+ 3000
+);
+
+// 過ぎたら0。ただし前回の結果は残す(「前回のおみくじ」を出せるように)
+got = m.loadOmikujiState("428875", now + 3600001, s);
+assert.strictEqual(got.remainingSeconds, 0);
+assert.deepStrictEqual(got.result, result);
+
+// 別ユーザーの記録は見えない
+s = fakeStorage();
+m.saveOmikujiState("428875", 3600, result, now, s);
+assert.strictEqual(m.loadOmikujiState("99999", now, s), null);
+
+// 引ける(0)として保存しても結果は残る
+s = fakeStorage();
+m.saveOmikujiState("428875", 0, result, now, s);
+got = m.loadOmikujiState("428875", now, s);
+assert.strictEqual(got.remainingSeconds, 0);
+assert.deepStrictEqual(got.result, result);
+
+// 何も無ければ null
+assert.strictEqual(m.loadOmikujiState("428875", now, fakeStorage()), null);
+
+// 壊れた値は捨てて null
+s = fakeStorage();
+s.setItem(m.storageKey("428875"), "ぐちゃぐちゃ");
+assert.strictEqual(m.loadOmikujiState("428875", now, s), null);
+assert.strictEqual(s.getItem(m.storageKey("428875")), null);
+
+// 端末の時計が大きくずれていても8時間で頭打ち
+s = fakeStorage();
+s.setItem(
+ m.storageKey("428875"),
+ JSON.stringify({ nextAt: now + 999 * 3600 * 1000, result: null })
+);
+assert.strictEqual(
+ m.loadOmikujiState("428875", now, s).remainingSeconds,
+ m.MAX_COOLDOWN_SECONDS
+);
+
+// localStorage が使えなくても落ちない
+assert.strictEqual(m.loadOmikujiState("428875", now, null), null);
+assert.strictEqual(m.loadOmikujiRemaining("428875", now, null), 0);
+
+// 表示用の整形
+assert.strictEqual(m.formatOmikujiRemaining(2 * 3600 + 30 * 60), "2時間30分");
+assert.strictEqual(m.formatOmikujiRemaining(45 * 60 + 5), "45分5秒");
+assert.strictEqual(m.formatOmikujiRemaining(30), "30秒");
+assert.strictEqual(m.formatOmikujiRemaining(-1), "0秒");
+
+console.log("omikujiCooldown: 全て通過");
diff --git a/web/utils/omikujiCooldown.js b/web/utils/omikujiCooldown.js
new file mode 100644
index 0000000..fdda398
--- /dev/null
+++ b/web/utils/omikujiCooldown.js
@@ -0,0 +1,125 @@
+// おみくじの「次に引ける時刻」と「前回の結果」をローカルに覚えておく。
+//
+// 引けるかどうかは前回いつ引いたかだけで決まる。それは前回引いた瞬間に
+// 確定しているのに、どこにも保存していなかったため、「おみくじ」を開くたびに
+// トークン取得 → omikujiGo(peek) の往復を待たされ、そのあとで初めて
+// 「まだ引けない」と分かっていた。押してから待たされた末に引けないと
+// 知らされるのが体感として一番つらい。
+//
+// 前回の結果も一緒に持っておく。クールダウン中の「前回のおみくじ」は
+// 通信を待たずにその場で出せる(サーバーにも omikuji_result があるので、
+// 端末を変えれば peek の応答から復元される)。
+//
+// 保存するのは表示を先に出すためだけで、実際に引けるかは常にサーバーが
+// 決める(端末の時計は書き換えられる)。
+//
+// 判定は storage を注入できる純関数にしてあり、Nodeで決定論的に検証できる
+// (sanpaiSession.js と同じ流儀)。web/scripts/test-omikuji-cooldown.js 参照。
+
+// アカウントを切り替えたときに前の人の結果や残り時間を見せないよう、
+// キーに github_id を含める。
+var STORAGE_PREFIX = "debug-shrine:omikuji:";
+
+// クールダウンの最長(8時間)。端末の時計が大きくずれていても、これ以上の
+// 残り時間は表示しない。
+var MAX_COOLDOWN_SECONDS = 8 * 60 * 60;
+
+function storageKey(githubId) {
+ return STORAGE_PREFIX + (githubId == null ? "" : String(githubId));
+}
+
+function defaultStorage() {
+ // プライベートモード等で localStorage が使えない環境では諦める
+ try {
+ return window.localStorage;
+ } catch (e) {
+ return null;
+ }
+}
+
+// サーバーの応答を受け取ったときに呼ぶ。
+// remainingSeconds が 0 以下なら「引ける」として記録する(結果は残す)。
+function saveOmikujiState(githubId, remainingSeconds, result, now, storage) {
+ storage = storage || defaultStorage();
+ if (!storage) return;
+ var seconds = typeof remainingSeconds === "number" ? remainingSeconds : 0;
+ var record = {
+ nextAt: seconds > 0 ? now + seconds * 1000 : 0,
+ result: result || null,
+ };
+ try {
+ storage.setItem(storageKey(githubId), JSON.stringify(record));
+ } catch (e) {
+ // 容量超過等。保存できなくても表示が遅くなるだけなので黙って続行
+ }
+}
+
+// { remainingSeconds, result } を返す。何も無ければ null。
+// remainingSeconds が 0 なら引ける(結果は前回のもの)。
+function loadOmikujiState(githubId, now, storage) {
+ storage = storage || defaultStorage();
+ if (!storage) return null;
+ var raw;
+ try {
+ raw = storage.getItem(storageKey(githubId));
+ } catch (e) {
+ return null;
+ }
+ if (!raw) return null;
+ var record;
+ try {
+ record = JSON.parse(raw);
+ } catch (e) {
+ clearOmikujiState(githubId, storage);
+ return null;
+ }
+ if (!record || typeof record.nextAt !== "number") {
+ clearOmikujiState(githubId, storage);
+ return null;
+ }
+ var remaining = 0;
+ if (record.nextAt > now) {
+ remaining = Math.min(
+ Math.ceil((record.nextAt - now) / 1000),
+ MAX_COOLDOWN_SECONDS
+ );
+ }
+ return { remainingSeconds: remaining, result: record.result || null };
+}
+
+// 残り秒だけ欲しいとき(ヘッダやトップの文言切り替え)。
+function loadOmikujiRemaining(githubId, now, storage) {
+ var state = loadOmikujiState(githubId, now, storage);
+ return state ? state.remainingSeconds : 0;
+}
+
+function clearOmikujiState(githubId, storage) {
+ storage = storage || defaultStorage();
+ if (!storage) return;
+ try {
+ storage.removeItem(storageKey(githubId));
+ } catch (e) {
+ // noop
+ }
+}
+
+// 残り秒を「2時間30分」「45分」「30秒」の形にする(表示専用の純関数)。
+function formatOmikujiRemaining(seconds) {
+ var s = Math.max(0, Math.floor(seconds));
+ var h = Math.floor(s / 3600);
+ var m = Math.floor((s % 3600) / 60);
+ if (h > 0) return h + "時間" + m + "分";
+ if (m > 0) return m + "分" + (s % 60) + "秒";
+ return s + "秒";
+}
+
+module.exports = {
+ STORAGE_PREFIX: STORAGE_PREFIX,
+ MAX_COOLDOWN_SECONDS: MAX_COOLDOWN_SECONDS,
+ storageKey: storageKey,
+ saveOmikujiState: saveOmikujiState,
+ loadOmikujiState: loadOmikujiState,
+ loadOmikujiRemaining: loadOmikujiRemaining,
+ clearOmikujiState: clearOmikujiState,
+ formatOmikujiRemaining: formatOmikujiRemaining,
+};