fix: harden reminders and state handling
This commit is contained in:
@@ -30,6 +30,8 @@ type Mode =
|
||||
| { kind: 'meal'; meal: Meal }
|
||||
| { kind: 'match'; summary: MatchSummary; done: Set<string> }
|
||||
|
||||
type ActiveMode = Exclude<Mode, { kind: 'idle' }>
|
||||
|
||||
/** Минимальный нативный confirm. В reminder-окне нет места для модалки,
|
||||
* проще использовать встроенный диалог. */
|
||||
function nativeConfirm(message: string): boolean {
|
||||
@@ -41,6 +43,8 @@ export default function ReminderApp(): JSX.Element {
|
||||
const [mode, setMode] = useState<Mode>({ kind: 'idle' })
|
||||
const [settings, setSettings] = useState<Settings | null>(null)
|
||||
const settingsRef = useRef<Settings | null>(null)
|
||||
const modeRef = useRef<Mode>({ kind: 'idle' })
|
||||
const queueRef = useRef<ActiveMode[]>([])
|
||||
// ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref,
|
||||
// не state — нужен только для дедупа rapid double-click. Сбрасывается
|
||||
// когда приходит новый match summary (см. onMatchEnd ниже).
|
||||
@@ -50,53 +54,21 @@ export default function ReminderApp(): JSX.Element {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
modeRef.current = mode
|
||||
}, [mode])
|
||||
|
||||
useEffect(() => {
|
||||
window.api.getState().then((s) => setSettings(s.settings))
|
||||
const u0 = window.api.onStateChanged((s) => setSettings(s.settings))
|
||||
const u1 = window.api.onFire((ex) => {
|
||||
setMode({ kind: 'exercise', exercise: ex })
|
||||
const s = settingsRef.current
|
||||
if (s?.soundEnabled) playBeep()
|
||||
if (s?.voicePromptsEnabled) {
|
||||
// Задержка 800ms даёт пользователю шанс decrement'нуть stepper до
|
||||
// фактического количества — TTS прозвучит уже под реальную цифру,
|
||||
// если успел нажать -. Иначе скажет планируемые reps.
|
||||
const lang = s.language ?? 'ru'
|
||||
setTimeout(() => {
|
||||
const phrase =
|
||||
lang === 'ru'
|
||||
? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}`
|
||||
: `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}`
|
||||
speak(phrase, lang)
|
||||
}, 800)
|
||||
}
|
||||
enqueueMode({ kind: 'exercise', exercise: ex })
|
||||
})
|
||||
const u1b = window.api.onFireMeal((meal) => {
|
||||
setMode({ kind: 'meal', meal })
|
||||
const s = settingsRef.current
|
||||
if (s?.soundEnabled) playBeep()
|
||||
if (s?.voicePromptsEnabled) {
|
||||
const lang = s.language ?? 'ru'
|
||||
const phrase =
|
||||
lang === 'ru' ? `Пора поесть. ${meal.name}` : `Time to eat. ${meal.name}`
|
||||
speak(phrase, lang)
|
||||
}
|
||||
enqueueMode({ kind: 'meal', meal })
|
||||
})
|
||||
const u2 = window.api.onMatchEnd((summary) => {
|
||||
// Новый матч — сбрасываем дедуп challenge'ей.
|
||||
sentChallengesRef.current = new Set()
|
||||
setMode({ kind: 'match', summary, done: new Set() })
|
||||
const s = settingsRef.current
|
||||
if (s?.soundEnabled) playBeep()
|
||||
if (s?.voicePromptsEnabled) {
|
||||
const total = summary.results.reduce((acc, r) => acc + r.reps, 0)
|
||||
const lang = s.language ?? 'ru'
|
||||
const phrase =
|
||||
lang === 'ru'
|
||||
? `Матч завершён. ${total} ${repWordRu(total)} ждут.`
|
||||
: `Match complete. ${total} ${total === 1 ? 'rep' : 'reps'} await.`
|
||||
speak(phrase, lang)
|
||||
}
|
||||
enqueueMode({ kind: 'match', summary, done: new Set() })
|
||||
})
|
||||
return () => {
|
||||
u0()
|
||||
@@ -104,6 +76,9 @@ export default function ReminderApp(): JSX.Element {
|
||||
u1b()
|
||||
u2()
|
||||
}
|
||||
// IPC-подписки должны жить один раз; enqueueMode читает актуальный mode
|
||||
// через ref, поэтому зависимость здесь не нужна.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ESC closes the match summary view too — keyboard parity with exercise mode.
|
||||
@@ -117,6 +92,63 @@ export default function ReminderApp(): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode.kind])
|
||||
|
||||
function enqueueMode(next: ActiveMode): void {
|
||||
if (modeRef.current.kind === 'idle') {
|
||||
activateMode(next)
|
||||
return
|
||||
}
|
||||
queueRef.current.push(next)
|
||||
}
|
||||
|
||||
function activateMode(next: ActiveMode): void {
|
||||
if (next.kind === 'match') {
|
||||
// Новый match summary получает чистый дедуп-сет только когда реально
|
||||
// становится активным; иначе queued summary не сбивает текущий матч.
|
||||
sentChallengesRef.current = new Set()
|
||||
}
|
||||
modeRef.current = next
|
||||
setMode(next)
|
||||
playAlertFor(next)
|
||||
}
|
||||
|
||||
function playAlertFor(next: ActiveMode): void {
|
||||
const s = settingsRef.current
|
||||
if (s?.soundEnabled) playBeep()
|
||||
if (!s?.voicePromptsEnabled) return
|
||||
|
||||
const lang = s.language ?? 'ru'
|
||||
if (next.kind === 'exercise') {
|
||||
const ex = next.exercise
|
||||
// Задержка 800ms даёт пользователю шанс decrement'нуть stepper до
|
||||
// фактического количества — TTS прозвучит уже под реальную цифру,
|
||||
// если успел нажать -. Иначе скажет планируемые reps.
|
||||
setTimeout(() => {
|
||||
const phrase =
|
||||
lang === 'ru'
|
||||
? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}`
|
||||
: `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}`
|
||||
speak(phrase, lang)
|
||||
}, 800)
|
||||
return
|
||||
}
|
||||
|
||||
if (next.kind === 'meal') {
|
||||
const phrase =
|
||||
lang === 'ru'
|
||||
? `Пора поесть. ${next.meal.name}`
|
||||
: `Time to eat. ${next.meal.name}`
|
||||
speak(phrase, lang)
|
||||
return
|
||||
}
|
||||
|
||||
const total = next.summary.results.reduce((acc, r) => acc + r.reps, 0)
|
||||
const phrase =
|
||||
lang === 'ru'
|
||||
? `Матч завершён. ${total} ${repWordRu(total)} ждут.`
|
||||
: `Match complete. ${total} ${total === 1 ? 'rep' : 'reps'} await.`
|
||||
speak(phrase, lang)
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
// Если в Match Summary остались незакрытые челленджи — подтверждаем,
|
||||
// чтобы пользователь не «пролетел» окно по привычке и не потерял
|
||||
@@ -139,6 +171,12 @@ export default function ReminderApp(): JSX.Element {
|
||||
if (!nativeConfirm(msg)) return
|
||||
}
|
||||
}
|
||||
const next = queueRef.current.shift()
|
||||
if (next) {
|
||||
activateMode(next)
|
||||
return
|
||||
}
|
||||
modeRef.current = { kind: 'idle' }
|
||||
setMode({ kind: 'idle' })
|
||||
window.api.reminderClose()
|
||||
}
|
||||
@@ -189,13 +227,16 @@ export default function ReminderApp(): JSX.Element {
|
||||
}
|
||||
// 2) Functional update: rapid-click race-safe.
|
||||
setMode((m) =>
|
||||
m.kind === 'match'
|
||||
? {
|
||||
{
|
||||
if (m.kind !== 'match') return m
|
||||
const nextMode: Mode = {
|
||||
kind: 'match',
|
||||
summary: m.summary,
|
||||
done: new Set([...m.done, id])
|
||||
}
|
||||
: m
|
||||
modeRef.current = nextMode
|
||||
return nextMode
|
||||
}
|
||||
)
|
||||
}}
|
||||
onClose={close}
|
||||
|
||||
Reference in New Issue
Block a user