Skip to content

Commit 08d4e55

Browse files
committed
Merge: main -> env/prod (#236 おみくじが引けるかどうかを開く前に分かるようにする)
2 parents cb6ed04 + 64a81c5 commit 08d4e55

6 files changed

Lines changed: 370 additions & 14 deletions

File tree

docs/backend.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,27 @@ Node版との差分(意図的な改善):
444444
「引く」ボタン押下時に peek 無しで実際に抽選する。おみくじには参拝のようなポイント等の
445445
ゲーム報酬は無く、フレーバー(占い)のみ。
446446

447+
### 引けるかどうかを開く前に伝える
448+
449+
引けるかどうかは**前回いつ引いたか**だけで決まり、それは前回引いた瞬間に確定して
450+
いる。にもかかわらずどこにも覚えていなかったため、「おみくじ」を開くたびに
451+
トークン取得 + `omikujiGo`(peek) の往復(関数がコールドなら数秒)を待たされ、
452+
そのあとで初めて「まだ引けない」と分かっていた。
453+
454+
- `web/utils/omikujiCooldown.js`**次に引ける時刻と前回の結果**を localStorage に
455+
持つ(キーに `github_id` を含め、アカウント切替で前の人のものを見せない)。
456+
純関数で `storage` を注入でき、`web/scripts/test-omikuji-cooldown.js` で検証する。
457+
- **トップページ**: クールダウン中は「次のおみくじまで ◯時間◯分」を出し、
458+
ボタンを「前回のおみくじ」に変える。引ければ「おみくじを引く」に戻る。
459+
ここでは**サーバーに問い合わせない**(前回いつ引いたかだけで決まるため)。
460+
- **おみくじページ**: マウント時に保存した状態を読み、**通信を待たずに**
461+
前回の結果とカウントダウンを出す。peek の応答が来たら上書きする(正はサーバー)。
462+
クールダウンが明けていて前回の結果だけある場合は、結果を添えてボタンを出す。
463+
- **ヘッダ**: 同じく引けないときは「前回のおみくじ」表記。
464+
- あくまで表示を先出しするだけで、**実際に引けるかは常にサーバーが決める**
465+
(端末の時計は書き換えられる)。残り時間は8時間(最長のクールダウン)で頭打ち。
466+
別端末では保存が無いので、従来どおり peek の応答から復元される。
467+
447468
### フロント
448469

449470
- `web/pages/omikuji.vue`(状態管理・API呼び出し)と `web/components/OmikujiResult.vue`

web/components/CommonHeader.vue

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
</ul>
3939
<ul class="navbar-nav mb-2 mb-lg-0" v-if="isLogin">
4040
<li class="nav-item">
41-
<nuxt-link class="nav-link active" to="/omikuji">おみくじ</nuxt-link>
41+
<nuxt-link class="nav-link active" to="/omikuji">{{
42+
omikujiLabel
43+
}}</nuxt-link>
4244
</li>
4345
<li class="nav-item">
4446
<nuxt-link
@@ -61,19 +63,54 @@
6163

6264
<script>
6365
import { mapGetters } from "vuex";
66+
import { loadOmikujiRemaining } from "@/utils/omikujiCooldown";
6467
6568
export default {
6669
data() {
67-
return {};
70+
return {
71+
// おみくじの残り秒(0なら引ける)。開く前に分かるようにリンクの文言へ出す。
72+
omikujiRemaining: 0,
73+
omikujiTimerId: null,
74+
};
75+
},
76+
mounted() {
77+
this.refreshOmikuji();
78+
// 残り時間はページを開かなくても減っていく。30秒ごとに見直して、
79+
// 引けるようになったらリンクの文言を戻す。
80+
this.omikujiTimerId = setInterval(this.refreshOmikuji, 30 * 1000);
81+
},
82+
beforeDestroy() {
83+
if (this.omikujiTimerId) clearInterval(this.omikujiTimerId);
6884
},
69-
mounted() {},
7085
methods: {
7186
logout() {
7287
this.$store.dispatch("logout");
7388
},
89+
refreshOmikuji() {
90+
this.omikujiRemaining = loadOmikujiRemaining(
91+
this.user && this.user.github_id,
92+
Date.now()
93+
);
94+
},
7495
},
7596
computed: {
76-
...mapGetters(["isLogin"]),
97+
...mapGetters(["isLogin", "user"]),
98+
// 引けないと分かっているときは「前回のおみくじ」。押す前に、引けるのか
99+
// 前回の結果を見に行くだけなのかが分かる。
100+
omikujiLabel() {
101+
return this.omikujiRemaining > 0 ? "前回のおみくじ" : "おみくじ";
102+
},
103+
},
104+
watch: {
105+
// ログイン直後やアカウント切替でユーザーが変わったら取り直す
106+
// (キーに github_id が入っているため)。
107+
user() {
108+
this.refreshOmikuji();
109+
},
110+
// ページ遷移のたびに見直す(おみくじを引いた直後に文言が変わる)。
111+
$route() {
112+
this.refreshOmikuji();
113+
},
77114
},
78115
};
79116
</script>

web/pages/index.vue

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,16 @@
4343
>
4444
参拝する
4545
</button>
46+
<!-- 引けないと分かっているときは「前回のおみくじ」。押せば
47+
前回の結果がその場で出る(通信を待たない)。 -->
4648
<nuxt-link to="/omikuji" class="btn btn-lg btn-outline-light">
47-
おみくじを引く
49+
{{ omikujiRemaining > 0 ? "前回のおみくじ" : "おみくじを引く" }}
4850
</nuxt-link>
4951
</div>
52+
<div v-if="omikujiRemaining > 0" class="mt-2 small text-muted">
53+
次のおみくじまで
54+
<span class="fw-bold">{{ omikujiRemainingText }}</span>
55+
</div>
5056
<div class="mt-4 p-2 d-inline-block">
5157
<div class="rounded border p-2 w-100 mb-2" v-if="isLogin">
5258
<img
@@ -148,6 +154,10 @@ import {
148154
} from "firebase/auth";
149155
import { mapGetters } from "vuex";
150156
import Ranking from "@/components/Ranking";
157+
import {
158+
loadOmikujiRemaining,
159+
formatOmikujiRemaining,
160+
} from "@/utils/omikujiCooldown";
151161
152162
export default {
153163
layout: "single",
@@ -163,16 +173,33 @@ export default {
163173
registerError: false,
164174
// らぼみの吹き出し。SSR/CSRの差異を避けるため mounted で選ぶ(初期は空)。
165175
labomiLine: "",
176+
// 次のおみくじまでの残り秒(0なら引ける)。localStorage から読むだけで、
177+
// サーバーには問い合わせない(引けるかは前回いつ引いたかだけで決まる)。
178+
omikujiRemaining: 0,
179+
omikujiTimerId: null,
166180
};
167181
},
168182
mounted() {
169183
this.pickLabomiLine();
184+
this.refreshOmikuji();
185+
// 1秒ごとに保存済みの「次に引ける時刻」から計算し直す。残り秒を引き算で
186+
// 減らすと、タブが背面のとき setInterval が間引かれて実際より長い残り時間を
187+
// 表示してしまい、引けるのに「前回のおみくじ」のままになる。
188+
this.omikujiTimerId = setInterval(this.refreshOmikuji, 1000);
189+
},
190+
beforeDestroy() {
191+
if (this.omikujiTimerId) clearInterval(this.omikujiTimerId);
170192
},
171193
watch: {
172194
// ログイン状態が変わったら(認証復元含む)セリフプールを切り替える
173195
isLogin() {
174196
this.pickLabomiLine();
175197
},
198+
// 認証復元でユーザーが入ったら残り時間を読み直す
199+
// (保存キーに github_id が入っているため)。
200+
user() {
201+
this.refreshOmikuji();
202+
},
176203
},
177204
async beforeMount() {
178205
const auth = getAuth();
@@ -186,6 +213,12 @@ export default {
186213
});
187214
},
188215
methods: {
216+
refreshOmikuji() {
217+
this.omikujiRemaining = loadOmikujiRemaining(
218+
this.user && this.user.github_id,
219+
Date.now()
220+
);
221+
},
189222
// らぼみのセリフ(オタクに優しいギャル。口調は docs/character.md 準拠)。
190223
// 表示のたびにランダムで1つ選び、再訪の楽しみにする。
191224
pickLabomiLine() {
@@ -274,6 +307,9 @@ export default {
274307
},
275308
computed: {
276309
...mapGetters(["isLogin", "user"]),
310+
omikujiRemainingText() {
311+
return formatOmikujiRemaining(this.omikujiRemaining);
312+
},
277313
},
278314
};
279315
</script>

web/pages/omikuji.vue

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ import { getAuth, onAuthStateChanged } from "firebase/auth";
8787
import { mapGetters } from "vuex";
8888
import ResultCard from "@/components/OmikujiResult";
8989
import OmikujiScene from "@/components/OmikujiScene";
90+
import {
91+
saveOmikujiState,
92+
loadOmikujiState,
93+
formatOmikujiRemaining,
94+
} from "@/utils/omikujiCooldown";
9095
9196
function resolveCurrentUser(auth) {
9297
return new Promise((resolve) => {
@@ -110,6 +115,24 @@ export default {
110115
};
111116
},
112117
async mounted() {
118+
// 覚えているものがあれば、通信を待たずにその場で出す。ここで待たせると
119+
// 「押してから待たされた末に引けないと分かる」になる。
120+
// サーバーの応答が来たら上書きされる(正はあくまでサーバー)。
121+
const saved = loadOmikujiState(
122+
this.user && this.user.github_id,
123+
Date.now()
124+
);
125+
if (saved) {
126+
this.result = saved.result;
127+
if (saved.remainingSeconds > 0) {
128+
this.remaining = saved.remainingSeconds;
129+
this.state = "cooldown";
130+
this.startTimer();
131+
} else if (saved.result) {
132+
// 引ける。前回の結果を添えてボタンを出す。
133+
this.state = "available";
134+
}
135+
}
113136
await this.fetchStatus();
114137
},
115138
beforeDestroy() {
@@ -129,7 +152,11 @@ export default {
129152
},
130153
// 引かずに現在の状態(引ける/クールダウン+前回結果)を取得する。
131154
async fetchStatus() {
132-
this.state = "loading";
155+
// 既に出せるものがあるなら、確認中もそれを出したままにする。
156+
// ローディングに戻すと、せっかく即表示した意味が無くなる。
157+
if (this.state !== "cooldown" && this.state !== "available") {
158+
this.state = "loading";
159+
}
133160
const token = await this.getToken();
134161
if (!token) return;
135162
try {
@@ -182,13 +209,32 @@ export default {
182209
this.remaining = this._pendingRemaining || 0;
183210
this.pendingResult = null;
184211
this.state = "cooldown";
212+
saveOmikujiState(
213+
this.user && this.user.github_id,
214+
this.remaining,
215+
this.result,
216+
Date.now()
217+
);
185218
this.startTimer();
186219
} else {
187220
// 演出が結果より先に終わった(スキップ等)→ サーバー状態を取り直す
188221
this.fetchStatus();
189222
}
190223
},
191224
applyResponse(d) {
225+
// 次に引ける時刻と前回の結果を覚えておく(次に開いたとき待たせないため)。
226+
if (
227+
d.status === "success" ||
228+
d.status === "cooldown" ||
229+
d.status === "available"
230+
) {
231+
saveOmikujiState(
232+
this.user && this.user.github_id,
233+
d.remaining_seconds || 0,
234+
d.result || this.result,
235+
Date.now()
236+
);
237+
}
192238
if (d.status === "success") {
193239
this.result = d.result;
194240
this.remaining = d.remaining_seconds || 0;
@@ -212,28 +258,37 @@ export default {
212258
},
213259
startTimer() {
214260
if (this.timerId) clearInterval(this.timerId);
261+
// 残り秒を1秒ずつ引くのではなく、明ける時刻を決めて毎回そこから計算する。
262+
// タブが背面にあると setInterval は間引かれる(止まることもある)ため、
263+
// 引き算だと戻ってきたときに実際より長い残り時間を表示してしまい、
264+
// 「引けるのに引けないと見える」という直したかった症状が再発する。
265+
const deadline = Date.now() + this.remaining * 1000;
215266
this.timerId = setInterval(() => {
216-
this.remaining -= 1;
267+
this.remaining = Math.max(
268+
0,
269+
Math.ceil((deadline - Date.now()) / 1000)
270+
);
217271
if (this.remaining <= 0) {
218272
clearInterval(this.timerId);
219273
this.timerId = null;
220274
this.remaining = 0;
221275
// 引ける状態へ。前回結果は残しつつボタンを出す。
222276
this.state = "available";
277+
saveOmikujiState(
278+
this.user && this.user.github_id,
279+
0,
280+
this.result,
281+
Date.now()
282+
);
223283
}
224284
}, 1000);
225285
},
226286
},
227287
computed: {
228288
...mapGetters(["user"]),
229289
remainingText() {
230-
const s = Math.max(0, this.remaining);
231-
const h = Math.floor(s / 3600);
232-
const m = Math.floor((s % 3600) / 60);
233-
const sec = s % 60;
234-
if (h > 0) return `${h}時間${m}`;
235-
if (m > 0) return `${m}${sec}`;
236-
return `${sec}`;
290+
// トップページと同じ表記にする(整形は omikujiCooldown.js に一本化)。
291+
return formatOmikujiRemaining(this.remaining);
237292
},
238293
shareUrl() {
239294
return this.$config.baseUrl;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// utils/omikujiCooldown.js の純関数テスト。
2+
// 実行: node web/scripts/test-omikuji-cooldown.js
3+
const assert = require("assert");
4+
const m = require("../utils/omikujiCooldown");
5+
6+
function fakeStorage() {
7+
const map = {};
8+
return {
9+
getItem: (k) => (k in map ? map[k] : null),
10+
setItem: (k, v) => {
11+
map[k] = v;
12+
},
13+
removeItem: (k) => {
14+
delete map[k];
15+
},
16+
};
17+
}
18+
19+
const now = 1700000000000;
20+
const result = { tier: "大吉", fortune: "ビルドが通る" };
21+
22+
// 保存した残り時間と結果が読める
23+
let s = fakeStorage();
24+
m.saveOmikujiState("428875", 3600, result, now, s);
25+
let got = m.loadOmikujiState("428875", now, s);
26+
assert.strictEqual(got.remainingSeconds, 3600);
27+
assert.deepStrictEqual(got.result, result);
28+
29+
// 時間が進めば減る
30+
assert.strictEqual(
31+
m.loadOmikujiState("428875", now + 600000, s).remainingSeconds,
32+
3000
33+
);
34+
35+
// 過ぎたら0。ただし前回の結果は残す(「前回のおみくじ」を出せるように)
36+
got = m.loadOmikujiState("428875", now + 3600001, s);
37+
assert.strictEqual(got.remainingSeconds, 0);
38+
assert.deepStrictEqual(got.result, result);
39+
40+
// 別ユーザーの記録は見えない
41+
s = fakeStorage();
42+
m.saveOmikujiState("428875", 3600, result, now, s);
43+
assert.strictEqual(m.loadOmikujiState("99999", now, s), null);
44+
45+
// 引ける(0)として保存しても結果は残る
46+
s = fakeStorage();
47+
m.saveOmikujiState("428875", 0, result, now, s);
48+
got = m.loadOmikujiState("428875", now, s);
49+
assert.strictEqual(got.remainingSeconds, 0);
50+
assert.deepStrictEqual(got.result, result);
51+
52+
// 何も無ければ null
53+
assert.strictEqual(m.loadOmikujiState("428875", now, fakeStorage()), null);
54+
55+
// 壊れた値は捨てて null
56+
s = fakeStorage();
57+
s.setItem(m.storageKey("428875"), "ぐちゃぐちゃ");
58+
assert.strictEqual(m.loadOmikujiState("428875", now, s), null);
59+
assert.strictEqual(s.getItem(m.storageKey("428875")), null);
60+
61+
// 端末の時計が大きくずれていても8時間で頭打ち
62+
s = fakeStorage();
63+
s.setItem(
64+
m.storageKey("428875"),
65+
JSON.stringify({ nextAt: now + 999 * 3600 * 1000, result: null })
66+
);
67+
assert.strictEqual(
68+
m.loadOmikujiState("428875", now, s).remainingSeconds,
69+
m.MAX_COOLDOWN_SECONDS
70+
);
71+
72+
// localStorage が使えなくても落ちない
73+
assert.strictEqual(m.loadOmikujiState("428875", now, null), null);
74+
assert.strictEqual(m.loadOmikujiRemaining("428875", now, null), 0);
75+
76+
// 表示用の整形
77+
assert.strictEqual(m.formatOmikujiRemaining(2 * 3600 + 30 * 60), "2時間30分");
78+
assert.strictEqual(m.formatOmikujiRemaining(45 * 60 + 5), "45分5秒");
79+
assert.strictEqual(m.formatOmikujiRemaining(30), "30秒");
80+
assert.strictEqual(m.formatOmikujiRemaining(-1), "0秒");
81+
82+
console.log("omikujiCooldown: 全て通過");

0 commit comments

Comments
 (0)