fix: harden reminders and state handling
This commit is contained in:
@@ -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 должен спросить
|
||||
|
||||
Reference in New Issue
Block a user