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

@@ -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 должен спросить