Skip to content

Commit 5f56dc1

Browse files
ptkNktqclaude
andauthored
fix: タブ復帰時にダッシュボードの時刻・日付・ゴミ表示を即時更新 (#186)
* fix: タブ復帰時に時刻・日付・ゴミ表示を即時更新する ブラウザが非アクティブタブの setTimeout を throttle(最低 1 分)・または Page Lifecycle で freeze した状態から復帰しても、ポーリングループの delay(10_000) が続行されるまでダッシュボードの時刻カードとゴミバッジが 古いまま残る問題を修正。 ポーリング本体を tick() に切り出し、TabResumedEvent の受信時にも 即時に tick() を走らせて表示を追い付かせる。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: tick() を refreshClockAndSchedules() に改名 tick だけでは何を更新するのか伝わりにくいため、onRefreshFeeding と一貫性のある名前へ変更し、役割を示す KDoc を追加する。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: タブ復帰時にポーリングジョブを再起動し給餌再取得の二重起動を回避 - pollingJob を保持し、タブ復帰時に cancel → 再 launch してブラウザの throttle / freeze により事実上停止した delay サイクルを確実にリセット - 日跨ぎ復帰時は refreshClockAndSchedules 内で onRefreshFeeding が呼ばれ、 直後のハンドラからも再呼び出しされて 2 回ローディングが走っていた問題を trackedFeedingDate 比較で解消 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: 給餌再取得の二重起動を refetch 発火フラグで確実に回避 - refreshClockAndSchedules が onRefreshFeeding / silentRefreshFeeding を 内部で呼んだかを Boolean で返すように変更 - TabResumedEvent ハンドラは戻り値を見て、再取得未発火時のみ onRefreshFeeding を呼ぶ構造に変更 (半時間跨ぎ復帰時の silentRefreshFeeding + onRefreshFeeding 二重起動を解消) - ポーリング再起動時に cancelAndJoin で古いジョブを確実に停止してから launch - startDateChangePolling の即時同期責務はハンドラ側に寄せ、delay から開始 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: startDateChangePolling の呼び出し契約を KDoc に明示 本関数は cancel を行わないため、再起動時は呼び出し側で cancelAndJoin を完了してから呼ぶ必要がある旨を明記する。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: 時刻/ゴミと給餌を別関数に分割し二重起動抑止の戻り値を廃止 - refreshClockAndSchedules (Boolean 戻り値) を以下に分割: - refreshTimeAndGarbage: 時刻カード・年月日・ゴミバッジ - autoRefreshFeedingOnTick: 日跨ぎ・半時間跨ぎ検知による給餌自動再取得 - onRefreshFeeding に trackedFeedingDate / lastFeedingHalfHour の同期を内包し、 手動更新・タブ復帰後に autoRefreshFeedingOnTick が誤発火しないようにする - autoRefreshFeedingOnTick は日跨ぎ検知時に半時間判定を return でスキップ、 日跨ぎ+半時間跨ぎ同時成立時の getFeedingLog 二重 fetch を構造的に解消 - タブ復帰ハンドラは戻り値判定を使わず refreshTimeAndGarbage + onRefreshFeeding を順に呼ぶだけのシンプルな形に Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: ポーリング再起動関数で cancelAndJoin を内包化し呼び出し契約を廃止 startDateChangePolling (fun) を restartDateChangePolling (suspend fun) に 変更し、内部で pollingJob?.cancelAndJoin() を実施。呼び出し側で cancel を 書き忘れるとジョブがリークする暗黙的契約を排除する。 init 側では viewModelScope.launch { restartDateChangePolling() } で呼ぶ。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ptkNktq <ptkNktq@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7e7775a commit 5f56dc1

1 file changed

Lines changed: 80 additions & 42 deletions

File tree

feature/dashboard/src/wasmJsMain/kotlin/feature/dashboard/DashboardViewModel.kt

Lines changed: 80 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import core.ui.util.todayDateJs
1818
import core.ui.util.tomorrowDayOfWeekIndexJs
1919
import core.ui.util.tomorrowWeekOfMonthJs
2020
import core.ui.util.weekOfMonthJs
21+
import kotlinx.coroutines.Job
22+
import kotlinx.coroutines.cancelAndJoin
2123
import kotlinx.coroutines.delay
2224
import kotlinx.coroutines.launch
2325
import model.FeedingLog
@@ -53,6 +55,7 @@ class DashboardViewModel(
5355
private var lastFeedingHalfHour = -1
5456
private var garbageRefreshedToday =
5557
(currentTimeJs().toString().substringBefore(":").toIntOrNull() ?: 0) >= GARBAGE_SWITCH_HOUR
58+
private var pollingJob: Job? = null
5659

5760
var uiState by mutableStateOf(
5861
DashboardUiState(
@@ -76,57 +79,86 @@ class DashboardViewModel(
7679
}
7780
}
7881
loadGarbageSchedule()
79-
startDateChangePolling()
80-
// バックグラウンド復帰時(トークンリフレッシュ完了後)にデータ再取得
82+
viewModelScope.launch { restartDateChangePolling() }
83+
// バックグラウンド復帰時: ブラウザの throttle / freeze により遅れた表示を即座に追い付かせ、
84+
// ポーリングも cancelAndJoin で古いジョブを確実に停止してから再起動する。
8185
viewModelScope.launch {
82-
tabResumedEvent.events.collect { onRefreshFeeding() }
86+
tabResumedEvent.events.collect {
87+
refreshTimeAndGarbage()
88+
onRefreshFeeding()
89+
restartDateChangePolling()
90+
}
8391
}
8492
}
8593

86-
private fun startDateChangePolling() {
87-
viewModelScope.launch {
88-
while (true) {
89-
delay(10_000)
90-
val timeStr = currentTimeJs().toString()
91-
uiState = uiState.copy(currentTime = timeStr)
92-
val newDate = todayDateJs().toString()
93-
if (newDate != trackedDate) {
94-
trackedDate = newDate
95-
garbageRefreshedToday = false
96-
uiState =
97-
uiState.copy(
98-
currentYear = currentYearJs().toString(),
99-
dateWithDay = formattedTodayJs().toString(),
100-
)
101-
refreshGarbageForToday()
102-
}
103-
val newFeedingDate = feedingDateJs().toString()
104-
if (newFeedingDate != trackedFeedingDate) {
105-
trackedFeedingDate = newFeedingDate
106-
onRefreshFeeding()
107-
}
108-
// 毎時0分・30分に給餌情報をサイレント更新
109-
val minute = timeStr.substringAfter(":").toIntOrNull() ?: 0
110-
val halfHour =
111-
timeStr
112-
.substringBefore(":")
113-
.toIntOrNull()
114-
?.times(2)
115-
?.plus(minute / 30) ?: 0
116-
if (lastFeedingHalfHour != -1 && halfHour != lastFeedingHalfHour) {
117-
silentRefreshFeeding()
118-
}
119-
lastFeedingHalfHour = halfHour
120-
// 更新時刻にゴミ出しスケジュールを再取得
121-
val hour = timeStr.substringBefore(":").toIntOrNull() ?: 0
122-
if (hour >= GARBAGE_SWITCH_HOUR && !garbageRefreshedToday) {
123-
garbageRefreshedToday = true
124-
loadGarbageSchedule()
94+
/**
95+
* ポーリングジョブを停止して再起動する。古いジョブが存在する場合は [cancelAndJoin] で
96+
* 完了を待ってから新規ジョブを launch するため、呼び出し側は in-flight 競合を意識する必要がない。
97+
*/
98+
private suspend fun restartDateChangePolling() {
99+
pollingJob?.cancelAndJoin()
100+
pollingJob =
101+
viewModelScope.launch {
102+
// ポーリング起点の即時同期は init 時の uiState 初期値 / TabResumedEvent ハンドラ側で担当するため、
103+
// ここは delay から開始し、10 秒周期で時刻・ゴミ・給餌のカードを同期するだけに留める。
104+
while (true) {
105+
delay(10_000)
106+
refreshTimeAndGarbage()
107+
autoRefreshFeedingOnTick()
125108
}
126109
}
110+
}
111+
112+
/** 時刻カード・年月日・ゴミバッジなど時刻依存の「非給餌」表示を同期する。 */
113+
private fun refreshTimeAndGarbage() {
114+
val timeStr = currentTimeJs().toString()
115+
uiState = uiState.copy(currentTime = timeStr)
116+
val newDate = todayDateJs().toString()
117+
if (newDate != trackedDate) {
118+
trackedDate = newDate
119+
garbageRefreshedToday = false
120+
uiState =
121+
uiState.copy(
122+
currentYear = currentYearJs().toString(),
123+
dateWithDay = formattedTodayJs().toString(),
124+
)
125+
refreshGarbageForToday()
126+
}
127+
val hour = timeStr.substringBefore(":").toIntOrNull() ?: 0
128+
if (hour >= GARBAGE_SWITCH_HOUR && !garbageRefreshedToday) {
129+
garbageRefreshedToday = true
130+
loadGarbageSchedule()
127131
}
128132
}
129133

134+
/**
135+
* ポーリング tick で日跨ぎ・半時間跨ぎを検知し、必要な場合のみ給餌ログを再取得する。
136+
* タブ復帰時は [onRefreshFeeding] で無条件再取得するため本関数を呼ばない。
137+
*/
138+
private suspend fun autoRefreshFeedingOnTick() {
139+
val newFeedingDate = feedingDateJs().toString()
140+
if (newFeedingDate != trackedFeedingDate) {
141+
// 日跨ぎは全量再取得に任せ、同一 tick 内での半時間判定はスキップ(二重 fetch 回避)
142+
onRefreshFeeding()
143+
return
144+
}
145+
val halfHour = computeCurrentHalfHour()
146+
if (lastFeedingHalfHour != -1 && halfHour != lastFeedingHalfHour) {
147+
silentRefreshFeeding()
148+
}
149+
lastFeedingHalfHour = halfHour
150+
}
151+
152+
private fun computeCurrentHalfHour(): Int {
153+
val timeStr = currentTimeJs().toString()
154+
val minute = timeStr.substringAfter(":").toIntOrNull() ?: 0
155+
return timeStr
156+
.substringBefore(":")
157+
.toIntOrNull()
158+
?.times(2)
159+
?.plus(minute / 30) ?: 0
160+
}
161+
130162
private suspend fun loadToday() {
131163
val id = petId ?: return
132164
try {
@@ -167,9 +199,15 @@ class DashboardViewModel(
167199
}
168200
}
169201

202+
/**
203+
* 給餌ログを無条件に再取得する。手動更新ボタン・タブ復帰・日跨ぎ検知から呼ばれる。
204+
* 次回 [autoRefreshFeedingOnTick] で日跨ぎ・半時間跨ぎ判定が再発火しないよう tracker も同期する。
205+
*/
170206
fun onRefreshFeeding() {
171207
val newDate = feedingDateJs().toString()
172208
today = newDate
209+
trackedFeedingDate = newDate
210+
lastFeedingHalfHour = computeCurrentHalfHour()
173211
viewModelScope.launch {
174212
uiState =
175213
uiState.copy(

0 commit comments

Comments
 (0)