fix: harden reminders and state handling
This commit is contained in:
119
src/main/games/registry.test.ts
Normal file
119
src/main/games/registry.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
provider: {
|
||||
displayName: 'Dota 2',
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
detect: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
reconcile: vi.fn()
|
||||
},
|
||||
startGsiServer: vi.fn(),
|
||||
stopGsiServer: vi.fn(),
|
||||
onLaunchOptionsApplied: vi.fn(),
|
||||
gamesEnabled: { dota2: true },
|
||||
fireMatchSummary: vi.fn(),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] }
|
||||
}))
|
||||
|
||||
vi.mock('./dota2', () => ({
|
||||
Dota2Provider: vi.fn(function Dota2Provider() {
|
||||
return h.provider
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./gsi-server', () => ({
|
||||
startGsiServer: h.startGsiServer,
|
||||
stopGsiServer: h.stopGsiServer
|
||||
}))
|
||||
|
||||
vi.mock('./steam-launch-options', () => ({
|
||||
onLaunchOptionsApplied: h.onLaunchOptionsApplied
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
getChallenges: () => [],
|
||||
getGamesEnabled: () => h.gamesEnabled
|
||||
}))
|
||||
|
||||
vi.mock('../notifications', () => ({
|
||||
fireMatchSummary: h.fireMatchSummary
|
||||
}))
|
||||
|
||||
vi.mock('../logger', () => ({
|
||||
log: h.log
|
||||
}))
|
||||
|
||||
async function loadRegistry(): Promise<typeof import('./registry')> {
|
||||
return import('./registry')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
h.provider.start.mockResolvedValue(undefined)
|
||||
h.provider.stop.mockResolvedValue(undefined)
|
||||
h.provider.detect.mockResolvedValue({
|
||||
id: 'dota2',
|
||||
name: 'Dota 2',
|
||||
installed: true,
|
||||
integrationActive: true,
|
||||
launchOptionStatus: 'applied',
|
||||
enabled: true
|
||||
})
|
||||
h.provider.install.mockResolvedValue(undefined)
|
||||
h.provider.uninstall.mockResolvedValue(undefined)
|
||||
h.provider.reconcile.mockResolvedValue(undefined)
|
||||
h.startGsiServer.mockReset()
|
||||
h.startGsiServer.mockResolvedValue(undefined)
|
||||
h.stopGsiServer.mockReset()
|
||||
h.stopGsiServer.mockResolvedValue(undefined)
|
||||
h.onLaunchOptionsApplied.mockClear()
|
||||
h.fireMatchSummary.mockClear()
|
||||
h.log.info.mockClear()
|
||||
h.log.warn.mockClear()
|
||||
h.log.error.mockClear()
|
||||
h.log.debug.mockClear()
|
||||
})
|
||||
|
||||
describe('games registry lifecycle', () => {
|
||||
it('сбрасывает running после ошибки старта GSI и позволяет повторный старт', async () => {
|
||||
h.startGsiServer
|
||||
.mockRejectedValueOnce(new Error('port busy'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
const { startGamesRegistry } = await loadRegistry()
|
||||
await startGamesRegistry()
|
||||
await startGamesRegistry()
|
||||
|
||||
expect(h.startGsiServer).toHaveBeenCalledTimes(2)
|
||||
expect(h.provider.start).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stopGamesRegistry ждёт полного shutdown GSI-сервера', async () => {
|
||||
let releaseStop!: () => void
|
||||
const stopPromise = new Promise<void>((resolve) => {
|
||||
releaseStop = resolve
|
||||
})
|
||||
h.stopGsiServer.mockReturnValue(stopPromise)
|
||||
|
||||
const { startGamesRegistry, stopGamesRegistry } = await loadRegistry()
|
||||
await startGamesRegistry()
|
||||
|
||||
let resolved = false
|
||||
const pending = stopGamesRegistry().then(() => {
|
||||
resolved = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(resolved).toBe(false)
|
||||
releaseStop()
|
||||
await pending
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -87,6 +87,7 @@ export async function startGamesRegistry(): Promise<void> {
|
||||
await startGsiServer()
|
||||
log.info('[games] GSI server started on port 4701')
|
||||
} catch (err) {
|
||||
running = false
|
||||
log.error('[games] GSI server failed to start', err)
|
||||
return
|
||||
}
|
||||
@@ -119,7 +120,7 @@ export async function stopGamesRegistry(): Promise<void> {
|
||||
for (const id of Object.keys(providers) as GameId[]) {
|
||||
await providers[id].stop()
|
||||
}
|
||||
stopGsiServer()
|
||||
await stopGsiServer()
|
||||
}
|
||||
|
||||
export async function listGamesStatus(): Promise<GameStatus[]> {
|
||||
|
||||
@@ -176,8 +176,10 @@ export function registerIpc(): void {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return null
|
||||
const ex = markDone(id, validateActualReps(repsRaw))
|
||||
broadcastState()
|
||||
broadcastHistoryChanged()
|
||||
if (ex) {
|
||||
broadcastState()
|
||||
broadcastHistoryChanged()
|
||||
}
|
||||
return ex
|
||||
})
|
||||
|
||||
@@ -186,8 +188,10 @@ export function registerIpc(): void {
|
||||
const minutes = validateSnoozeMinutes(minRaw)
|
||||
if (!id || minutes === null) return null
|
||||
const ex = snooze(id, minutes)
|
||||
broadcastState()
|
||||
broadcastHistoryChanged()
|
||||
if (ex) {
|
||||
broadcastState()
|
||||
broadcastHistoryChanged()
|
||||
}
|
||||
return ex
|
||||
})
|
||||
|
||||
@@ -195,8 +199,10 @@ export function registerIpc(): void {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return null
|
||||
const ex = skip(id)
|
||||
broadcastState()
|
||||
broadcastHistoryChanged()
|
||||
if (ex) {
|
||||
broadcastState()
|
||||
broadcastHistoryChanged()
|
||||
}
|
||||
return ex
|
||||
})
|
||||
|
||||
@@ -238,7 +244,10 @@ export function registerIpc(): void {
|
||||
const id = validateId(idRaw)
|
||||
if (!id) return null
|
||||
const m = markMealDone(id)
|
||||
broadcastState()
|
||||
if (m) {
|
||||
broadcastState()
|
||||
broadcastHistoryChanged()
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ const h = vi.hoisted(() => ({
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
exec: (cmd: string, opts: unknown, cb: ExecCb) => {
|
||||
h.calls += 1
|
||||
@@ -39,8 +41,16 @@ async function load(): Promise<typeof import('./meeting-detect')> {
|
||||
return import('./meeting-detect')
|
||||
}
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setPlatform('win32')
|
||||
h.calls = 0
|
||||
h.execImpl = (_cmd, _opts, cb) => cb(null, { stdout: '' })
|
||||
h.log.info.mockClear()
|
||||
@@ -48,6 +58,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -96,14 +107,9 @@ describe('isMeetingActive', () => {
|
||||
})
|
||||
|
||||
it('на не-Windows возвращает false без вызова tasklist', async () => {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
try {
|
||||
const { isMeetingActive } = await load()
|
||||
expect(await isMeetingActive()).toBe(false)
|
||||
expect(h.calls).toBe(0)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: original })
|
||||
}
|
||||
setPlatform('linux')
|
||||
const { isMeetingActive } = await load()
|
||||
expect(await isMeetingActive()).toBe(false)
|
||||
expect(h.calls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,6 +186,21 @@ describe('checkDueExercises gating', () => {
|
||||
expect(h.updateExercise).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dailyGoal использует reps snapshot из истории, а не текущие reps упражнения', async () => {
|
||||
h.exercises = [makeExercise({ reps: 25, dailyGoal: 20 })]
|
||||
h.history = [
|
||||
{
|
||||
ts: Date.now(),
|
||||
exerciseId: 'ex1',
|
||||
action: 'done',
|
||||
reps: 10
|
||||
}
|
||||
]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
forceCheck()
|
||||
expect(h.fireReminder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('adaptive: применяет adjustNextFireAt к кандидату', async () => {
|
||||
h.exercises = [makeExercise({ adaptive: true })]
|
||||
const { forceCheck } = await loadScheduler()
|
||||
|
||||
@@ -17,7 +17,8 @@ import { adjustNextFireAt } from './adaptive'
|
||||
|
||||
/**
|
||||
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
|
||||
* Учитываем actualReps если задано (частичное выполнение), иначе planned reps.
|
||||
* Учитываем actualReps если задано (частичное выполнение), затем snapshot
|
||||
* reps из истории, и только потом текущие planned reps упражнения.
|
||||
*/
|
||||
function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number {
|
||||
const todayKey = new Date()
|
||||
@@ -28,7 +29,7 @@ function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number {
|
||||
if (e.action !== 'done') continue
|
||||
if (e.exerciseId !== ex.id) continue
|
||||
if (e.ts < startMs) continue
|
||||
sum += e.actualReps ?? ex.reps
|
||||
sum += e.actualReps ?? e.reps ?? ex.reps
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DEFAULT_SETTINGS } from '@shared/types'
|
||||
|
||||
/**
|
||||
* Тесты persistence-слоя. Мокаем electron.app.getPath на временную директорию
|
||||
@@ -147,6 +148,43 @@ describe('store · history cap', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · meal history', () => {
|
||||
it('markMealDone пишет meal-entry в историю', async () => {
|
||||
writeFileSync(
|
||||
statePath(),
|
||||
JSON.stringify({
|
||||
exercises: [],
|
||||
meals: [
|
||||
{
|
||||
id: 'm1',
|
||||
name: 'Обед',
|
||||
time: '13:00',
|
||||
icon: 'Soup',
|
||||
enabled: true,
|
||||
days: [],
|
||||
nextFireAt: Date.now() + 60_000
|
||||
}
|
||||
],
|
||||
challenges: [],
|
||||
history: []
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const { markMealDone, getHistory } = await load()
|
||||
expect(markMealDone('m1')).toBeDefined()
|
||||
expect(getHistory()).toMatchObject([
|
||||
{
|
||||
exerciseId: 'meal:m1',
|
||||
action: 'done',
|
||||
reps: 1,
|
||||
name: 'Обед',
|
||||
source: 'meal'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('store · clearHistory', () => {
|
||||
it('удаляет записи старше границы и возвращает количество', async () => {
|
||||
const ex = {
|
||||
@@ -195,4 +233,79 @@ describe('store · export / import', () => {
|
||||
expect(importState('not json at all')).toBe(false)
|
||||
expect(importState('42')).toBe(false)
|
||||
})
|
||||
|
||||
it('import сохраняет валидные части snapshot и отбрасывает повреждённые записи', async () => {
|
||||
const validExercise = {
|
||||
id: 'x1',
|
||||
name: 'Тест',
|
||||
reps: 10,
|
||||
icon: 'Activity',
|
||||
intervalMinutes: 30,
|
||||
enabled: true,
|
||||
nextFireAt: Date.now() + 1000
|
||||
}
|
||||
const validMeal = {
|
||||
id: 'm1',
|
||||
name: 'Обед',
|
||||
time: '13:00',
|
||||
icon: 'Soup',
|
||||
enabled: true,
|
||||
days: [],
|
||||
nextFireAt: Date.now() + 1000
|
||||
}
|
||||
const validChallenge = {
|
||||
id: 'c1',
|
||||
name: 'За убийства',
|
||||
gameId: 'dota2',
|
||||
stat: 'kills',
|
||||
multiplier: 1,
|
||||
exerciseName: 'Отжимания',
|
||||
icon: 'Dumbbell',
|
||||
enabled: true
|
||||
}
|
||||
|
||||
const { importState, getState, getSettings, getHistory } = await load()
|
||||
expect(
|
||||
importState(
|
||||
JSON.stringify({
|
||||
exercises: [
|
||||
validExercise,
|
||||
{ ...validExercise, id: 'bad-ex', intervalMinutes: -5 }
|
||||
],
|
||||
meals: [validMeal, { ...validMeal, id: 'bad-meal', time: '25:00' }],
|
||||
settings: {
|
||||
globalEnabled: false,
|
||||
snoozeMinutes: -1,
|
||||
language: 'xx'
|
||||
},
|
||||
challenges: [
|
||||
validChallenge,
|
||||
{ ...validChallenge, id: 'bad-challenge', gameId: 'cs2' }
|
||||
],
|
||||
gamesEnabled: { dota2: true, cs2: true },
|
||||
history: [
|
||||
{ ts: 100, exerciseId: 'x1', action: 'done', reps: 10 },
|
||||
{ ts: -1, exerciseId: 'x1', action: 'done', reps: 10 },
|
||||
{
|
||||
ts: 200,
|
||||
exerciseId: 'meal:m1',
|
||||
action: 'done',
|
||||
reps: 1,
|
||||
source: 'meal'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const state = getState()
|
||||
expect(state.exercises.map((e) => e.id)).toEqual(['x1'])
|
||||
expect(state.meals.map((m) => m.id)).toEqual(['m1'])
|
||||
expect(state.challenges.map((c) => c.id)).toEqual(['c1'])
|
||||
expect(state.gamesEnabled).toEqual({ dota2: true })
|
||||
expect(getHistory().map((e) => e.ts)).toEqual([100, 200])
|
||||
expect(getSettings().globalEnabled).toBe(false)
|
||||
expect(getSettings().snoozeMinutes).toBe(DEFAULT_SETTINGS.snoozeMinutes)
|
||||
expect(getSettings().language).toBe(DEFAULT_SETTINGS.language)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
GameId,
|
||||
HistoryAction,
|
||||
HistoryEntry,
|
||||
HistorySource,
|
||||
Meal,
|
||||
nextMealOccurrence,
|
||||
PersistedState,
|
||||
@@ -25,6 +26,13 @@ import {
|
||||
Settings
|
||||
} from '@shared/types'
|
||||
import { log } from './logger'
|
||||
import {
|
||||
validateChallengeInput,
|
||||
validateExerciseInput,
|
||||
validateId,
|
||||
validateMealInput,
|
||||
validateSettingsPatch
|
||||
} from './validate'
|
||||
|
||||
/**
|
||||
* Keep at most this many history entries (≈2.7 years at 10/day).
|
||||
@@ -110,6 +118,136 @@ function isValidParsed(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
}
|
||||
|
||||
function finiteMs(v: unknown): number | undefined {
|
||||
return typeof v === 'number' &&
|
||||
Number.isFinite(v) &&
|
||||
v >= 0 &&
|
||||
v <= Number.MAX_SAFE_INTEGER
|
||||
? v
|
||||
: undefined
|
||||
}
|
||||
|
||||
function intInRange(v: unknown, min: number, max: number): number | undefined {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined
|
||||
const n = Math.trunc(v)
|
||||
return n >= min && n <= max ? n : undefined
|
||||
}
|
||||
|
||||
function safeStr(v: unknown, max = 200): string | undefined {
|
||||
if (typeof v !== 'string') return undefined
|
||||
if (v.length === 0 || v.length > max) return undefined
|
||||
return v
|
||||
}
|
||||
|
||||
const SETTINGS_KEYS: (keyof Settings)[] = [
|
||||
'globalEnabled',
|
||||
'notificationMode',
|
||||
'soundEnabled',
|
||||
'voicePromptsEnabled',
|
||||
'meetingAutoPause',
|
||||
'startWithWindows',
|
||||
'minimizeToTray',
|
||||
'startMinimized',
|
||||
'theme',
|
||||
'language',
|
||||
'snoozeMinutes',
|
||||
'quietHours',
|
||||
'lastSeenVersion'
|
||||
]
|
||||
|
||||
const GAME_IDS: GameId[] = ['dota2']
|
||||
const HISTORY_ACTIONS: HistoryAction[] = ['done', 'skip', 'snooze']
|
||||
const HISTORY_SOURCES: HistorySource[] = ['reminder', 'meal', 'match']
|
||||
|
||||
function sanitizeSettings(raw: unknown): Settings {
|
||||
const out: Settings = { ...DEFAULT_SETTINGS }
|
||||
if (!isValidParsed(raw)) return out
|
||||
|
||||
for (const key of SETTINGS_KEYS) {
|
||||
if (!(key in raw)) continue
|
||||
const patch = validateSettingsPatch({ [key]: raw[key] })
|
||||
if (patch) Object.assign(out, patch)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function sanitizeExercise(raw: unknown, now = Date.now()): Exercise | null {
|
||||
if (!isValidParsed(raw)) return null
|
||||
const id = validateId(raw.id)
|
||||
const base = validateExerciseInput(raw)
|
||||
if (!id || !base) return null
|
||||
|
||||
const exercise: Exercise = {
|
||||
...base,
|
||||
id,
|
||||
nextFireAt: finiteMs(raw.nextFireAt) ?? now + base.intervalMinutes * 60_000
|
||||
}
|
||||
const lastDoneAt = finiteMs(raw.lastDoneAt)
|
||||
if (lastDoneAt !== undefined) exercise.lastDoneAt = lastDoneAt
|
||||
return exercise
|
||||
}
|
||||
|
||||
function sanitizeMeal(raw: unknown, now = Date.now()): Meal | null {
|
||||
if (!isValidParsed(raw)) return null
|
||||
const id = validateId(raw.id)
|
||||
const base = validateMealInput(raw)
|
||||
if (!id || !base) return null
|
||||
|
||||
const meal: Meal = {
|
||||
...base,
|
||||
id,
|
||||
nextFireAt: finiteMs(raw.nextFireAt) ?? nextMealOccurrence(base.time, base.days, now)
|
||||
}
|
||||
const lastDoneAt = finiteMs(raw.lastDoneAt)
|
||||
if (lastDoneAt !== undefined) meal.lastDoneAt = lastDoneAt
|
||||
return meal
|
||||
}
|
||||
|
||||
function sanitizeChallenge(raw: unknown): Challenge | null {
|
||||
if (!isValidParsed(raw)) return null
|
||||
const id = validateId(raw.id)
|
||||
const base = validateChallengeInput(raw)
|
||||
if (!id || !base) return null
|
||||
return { ...base, id }
|
||||
}
|
||||
|
||||
function sanitizeGamesEnabled(raw: unknown): Partial<Record<GameId, boolean>> {
|
||||
const out: Partial<Record<GameId, boolean>> = {}
|
||||
if (!isValidParsed(raw)) return out
|
||||
for (const id of GAME_IDS) {
|
||||
if (typeof raw[id] === 'boolean') out[id] = raw[id]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function sanitizeHistoryEntry(raw: unknown): HistoryEntry | null {
|
||||
if (!isValidParsed(raw)) return null
|
||||
const ts = finiteMs(raw.ts)
|
||||
const exerciseId = validateId(raw.exerciseId)
|
||||
const action =
|
||||
typeof raw.action === 'string' &&
|
||||
HISTORY_ACTIONS.includes(raw.action as HistoryAction)
|
||||
? (raw.action as HistoryAction)
|
||||
: undefined
|
||||
if (ts === undefined || !exerciseId || action === undefined) return null
|
||||
|
||||
const entry: HistoryEntry = { ts, exerciseId, action }
|
||||
const actualReps = intInRange(raw.actualReps, 0, 100_000)
|
||||
if (actualReps !== undefined) entry.actualReps = actualReps
|
||||
const reps = intInRange(raw.reps, 0, 100_000)
|
||||
if (reps !== undefined) entry.reps = reps
|
||||
const name = safeStr(raw.name)
|
||||
if (name !== undefined) entry.name = name
|
||||
if (
|
||||
typeof raw.source === 'string' &&
|
||||
HISTORY_SOURCES.includes(raw.source as HistorySource)
|
||||
) {
|
||||
entry.source = raw.source as HistorySource
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Current persisted-state schema version. Bump this and add a migration to
|
||||
* MIGRATIONS whenever the on-disk shape changes in a non-additive way.
|
||||
@@ -155,22 +293,36 @@ function runMigrations(s: StoredState): StoredState {
|
||||
|
||||
/** Coerce a (possibly partial) migrated state into a fully-formed PersistedState. */
|
||||
function coerce(s: StoredState): PersistedState {
|
||||
const now = Date.now()
|
||||
return {
|
||||
exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [],
|
||||
exercises: Array.isArray(s.exercises)
|
||||
? s.exercises.flatMap((raw) => {
|
||||
const exercise = sanitizeExercise(raw, now)
|
||||
return exercise ? [exercise] : []
|
||||
})
|
||||
: [],
|
||||
// Additive: старые state'ы без `meals` получают пустой список (см. философию
|
||||
// миграций — additive-поля не требуют bump'а схемы).
|
||||
meals: Array.isArray(s.meals) ? (s.meals as Meal[]) : [],
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(isValidParsed(s.settings) ? (s.settings as Partial<Settings>) : {})
|
||||
},
|
||||
challenges: Array.isArray(s.challenges)
|
||||
? (s.challenges as Challenge[])
|
||||
meals: Array.isArray(s.meals)
|
||||
? s.meals.flatMap((raw) => {
|
||||
const meal = sanitizeMeal(raw, now)
|
||||
return meal ? [meal] : []
|
||||
})
|
||||
: [],
|
||||
gamesEnabled: isValidParsed(s.gamesEnabled)
|
||||
? (s.gamesEnabled as Partial<Record<GameId, boolean>>)
|
||||
: {},
|
||||
history: Array.isArray(s.history) ? (s.history as HistoryEntry[]) : []
|
||||
settings: sanitizeSettings(s.settings),
|
||||
challenges: Array.isArray(s.challenges)
|
||||
? s.challenges.flatMap((raw) => {
|
||||
const challenge = sanitizeChallenge(raw)
|
||||
return challenge ? [challenge] : []
|
||||
})
|
||||
: [],
|
||||
gamesEnabled: sanitizeGamesEnabled(s.gamesEnabled),
|
||||
history: Array.isArray(s.history)
|
||||
? s.history.flatMap((raw) => {
|
||||
const entry = sanitizeHistoryEntry(raw)
|
||||
return entry ? [entry] : []
|
||||
})
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,6 +695,11 @@ export function markMealDone(id: string): Meal | undefined {
|
||||
if (meal.nextFireAt <= Date.now()) {
|
||||
meal.nextFireAt = nextMealOccurrence(meal.time, meal.days, Date.now())
|
||||
}
|
||||
appendHistory(`meal:${id}`, 'done', {
|
||||
reps: 1,
|
||||
name: meal.name,
|
||||
source: 'meal'
|
||||
})
|
||||
scheduleWrite()
|
||||
return meal
|
||||
}
|
||||
@@ -641,8 +798,8 @@ export function exportState(): string {
|
||||
|
||||
/**
|
||||
* Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при
|
||||
* успехе. Идёт через тот же coerce + runMigrations что и load() — это
|
||||
* валидирует тип/диапазоны.
|
||||
* успехе. Идёт через тот же coerce + runMigrations что и load(): валидные
|
||||
* записи сохраняются, повреждённые записи/поля отбрасываются.
|
||||
*
|
||||
* НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты
|
||||
* settings) — простое replace. Перед импортом UI должен спросить
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import type {
|
||||
Challenge,
|
||||
Exercise,
|
||||
GameId,
|
||||
GameStat,
|
||||
Meal,
|
||||
Settings,
|
||||
@@ -27,6 +28,7 @@ const MAX_STR_LEN = 200
|
||||
const VALID_THEMES: Theme[] = ['system', 'light', 'dark']
|
||||
const VALID_LANGS: Language[] = ['ru', 'en']
|
||||
const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both']
|
||||
const VALID_GAME_IDS: GameId[] = ['dota2']
|
||||
const VALID_STATS: GameStat[] = [
|
||||
'deaths',
|
||||
'kills',
|
||||
@@ -289,7 +291,7 @@ export function validateChallengeInput(
|
||||
): Omit<Challenge, 'id'> | null {
|
||||
if (!isObj(raw)) return null
|
||||
const name = safeStr(raw.name)
|
||||
const gameId = safeStr(raw.gameId, 32)
|
||||
const gameId = oneOf(raw.gameId, VALID_GAME_IDS)
|
||||
const stat = oneOf(raw.stat, VALID_STATS)
|
||||
const multiplier = numInRange(raw.multiplier, 0, 1000)
|
||||
const exerciseName = safeStr(raw.exerciseName)
|
||||
@@ -306,7 +308,7 @@ export function validateChallengeInput(
|
||||
}
|
||||
return {
|
||||
name,
|
||||
gameId: gameId as Challenge['gameId'],
|
||||
gameId,
|
||||
stat,
|
||||
multiplier,
|
||||
exerciseName,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -77,7 +77,7 @@ export const IPC = {
|
||||
evtMaximizeChanged: 'evt:maximizeChanged',
|
||||
evtMeetingChanged: 'evt:meetingChanged',
|
||||
/**
|
||||
* Шлётся когда история мутирует (markDone / snooze / skip /
|
||||
* Шлётся когда история мутирует (markDone / markMealDone / snooze / skip /
|
||||
* markChallengeDone / clearHistory / import). Renderer'у достаточно
|
||||
* перезапросить getHistory. Раньше Dashboard переключал history по
|
||||
* `exercises` ref'у — но markDone мутирует Exercise in place, ref не
|
||||
|
||||
@@ -147,10 +147,11 @@ export type PersistedState = AppState & {
|
||||
export type HistoryAction = 'done' | 'skip' | 'snooze'
|
||||
|
||||
/**
|
||||
* Источник записи: обычное напоминание (от scheduler'а) или матч (челлендж).
|
||||
* Источник записи: обычное напоминание (от scheduler'а), приём пищи или
|
||||
* матч (челлендж).
|
||||
* Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики.
|
||||
*/
|
||||
export type HistorySource = 'reminder' | 'match'
|
||||
export type HistorySource = 'reminder' | 'meal' | 'match'
|
||||
|
||||
export type HistoryEntry = {
|
||||
/** ms epoch */
|
||||
|
||||
Reference in New Issue
Block a user