fix: harden reminders and state handling

This commit is contained in:
Codex
2026-06-06 02:27:04 +07:00
parent ad000c722e
commit ffe80b62c4
17 changed files with 1752 additions and 288 deletions

View File

@@ -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}