832 lines
26 KiB
TypeScript
832 lines
26 KiB
TypeScript
import { app } from 'electron'
|
||
import {
|
||
existsSync,
|
||
mkdirSync,
|
||
readFileSync,
|
||
renameSync,
|
||
unlinkSync,
|
||
writeFileSync
|
||
} from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import { randomUUID } from 'node:crypto'
|
||
import {
|
||
AppState,
|
||
Challenge,
|
||
DEFAULT_SETTINGS,
|
||
Exercise,
|
||
GameId,
|
||
HistoryAction,
|
||
HistoryEntry,
|
||
HistorySource,
|
||
Meal,
|
||
nextMealOccurrence,
|
||
PersistedState,
|
||
SAMPLE_EXERCISES,
|
||
SAMPLE_MEALS,
|
||
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).
|
||
* When the cap is hit, drop oldest 10% so we don't trim on every write.
|
||
*/
|
||
const HISTORY_MAX = 10_000
|
||
|
||
const WRITE_DEBOUNCE_MS = 1500
|
||
const WRITE_RETRY_DELAYS = [50, 200, 800] // ms backoff on transient EBUSY/EPERM
|
||
|
||
let cache: PersistedState | null = null
|
||
let storePath = ''
|
||
let pendingWrite: NodeJS.Timeout | null = null
|
||
|
||
export function getStorePath(): string {
|
||
if (!storePath) {
|
||
const dir = app.getPath('userData')
|
||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||
storePath = join(dir, 'app-state.json')
|
||
}
|
||
return storePath
|
||
}
|
||
|
||
function makeInitial(): PersistedState {
|
||
const now = Date.now()
|
||
return {
|
||
exercises: SAMPLE_EXERCISES.map((e) => ({
|
||
...e,
|
||
id: randomUUID(),
|
||
nextFireAt: now + e.intervalMinutes * 60_000
|
||
})),
|
||
meals: SAMPLE_MEALS.map((m) => ({
|
||
...m,
|
||
id: randomUUID(),
|
||
nextFireAt: nextMealOccurrence(m.time, m.days, now)
|
||
})),
|
||
settings: { ...DEFAULT_SETTINGS },
|
||
challenges: [
|
||
{
|
||
id: randomUUID(),
|
||
name: 'За смерти в Dota — приседания',
|
||
gameId: 'dota2',
|
||
stat: 'deaths',
|
||
multiplier: 3,
|
||
exerciseName: 'Приседания',
|
||
icon: 'Activity',
|
||
enabled: true
|
||
},
|
||
{
|
||
id: randomUUID(),
|
||
name: 'За убийства — отжимания',
|
||
gameId: 'dota2',
|
||
stat: 'kills',
|
||
multiplier: 1,
|
||
exerciseName: 'Отжимания',
|
||
icon: 'Dumbbell',
|
||
enabled: false
|
||
}
|
||
],
|
||
gamesEnabled: {},
|
||
history: []
|
||
}
|
||
}
|
||
|
||
/** Quarantine a corrupt state file so the user can recover it manually. */
|
||
function quarantineCorrupt(p: string, reason: string): void {
|
||
try {
|
||
const stamp = new Date()
|
||
.toISOString()
|
||
.replace(/[:.]/g, '-')
|
||
.replace(/Z$/, '')
|
||
const dest = `${p}.corrupt-${stamp}`
|
||
renameSync(p, dest)
|
||
log.error(
|
||
`[store] app-state.json was unreadable (${reason}); moved to ${dest} and starting fresh.`
|
||
)
|
||
} catch (e) {
|
||
log.error('[store] failed to quarantine corrupt state file', e)
|
||
}
|
||
}
|
||
|
||
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.
|
||
*
|
||
* Additive changes (new optional fields, new entries in `gamesEnabled`) do
|
||
* NOT need a version bump — DEFAULT_SETTINGS spread + the `?? []` guards in
|
||
* `coerce()` handle them gracefully.
|
||
*/
|
||
const CURRENT_SCHEMA_VERSION = 1
|
||
|
||
type StoredState = Record<string, unknown> & { __schemaVersion?: number }
|
||
|
||
/**
|
||
* Migrations are applied in order until the stored version matches CURRENT.
|
||
* Each fn returns the next-version state. The receiver may freely mutate.
|
||
*
|
||
* Note: the v0→v1 migration is a no-op — v1 is the inaugural schema. The
|
||
* machinery exists so future structural changes (e.g. splitting
|
||
* `quietHours.days` into a per-window record) have a single explicit place
|
||
* to live.
|
||
*/
|
||
const MIGRATIONS: Record<number, (s: StoredState) => StoredState> = {
|
||
0: (s) => s
|
||
}
|
||
|
||
function runMigrations(s: StoredState): StoredState {
|
||
let version = typeof s.__schemaVersion === 'number' ? s.__schemaVersion : 0
|
||
let cursor = s
|
||
while (version < CURRENT_SCHEMA_VERSION) {
|
||
const fn = MIGRATIONS[version]
|
||
if (!fn) {
|
||
console.warn(
|
||
`[store] no migration from v${version}; skipping ahead and hoping for the best.`
|
||
)
|
||
break
|
||
}
|
||
cursor = fn(cursor)
|
||
version += 1
|
||
}
|
||
cursor.__schemaVersion = CURRENT_SCHEMA_VERSION
|
||
return cursor
|
||
}
|
||
|
||
/** 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.flatMap((raw) => {
|
||
const exercise = sanitizeExercise(raw, now)
|
||
return exercise ? [exercise] : []
|
||
})
|
||
: [],
|
||
// Additive: старые state'ы без `meals` получают пустой список (см. философию
|
||
// миграций — additive-поля не требуют bump'а схемы).
|
||
meals: Array.isArray(s.meals)
|
||
? s.meals.flatMap((raw) => {
|
||
const meal = sanitizeMeal(raw, now)
|
||
return meal ? [meal] : []
|
||
})
|
||
: [],
|
||
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] : []
|
||
})
|
||
: []
|
||
}
|
||
}
|
||
|
||
function load(): PersistedState {
|
||
const p = getStorePath()
|
||
if (!existsSync(p)) {
|
||
const initial = makeInitial()
|
||
// Cold path — sync write на инициализации (event-loop ещё не активен).
|
||
atomicWriteSync(
|
||
p,
|
||
JSON.stringify(
|
||
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial },
|
||
null,
|
||
2
|
||
)
|
||
)
|
||
return initial
|
||
}
|
||
let raw: string
|
||
try {
|
||
raw = readFileSync(p, 'utf-8')
|
||
} catch (e) {
|
||
log.error('[store] cannot read state file', e)
|
||
return makeInitial() // do not quarantine — we can't read it anyway
|
||
}
|
||
let parsed: unknown
|
||
try {
|
||
parsed = JSON.parse(raw)
|
||
} catch (e) {
|
||
quarantineCorrupt(p, `JSON parse error: ${String(e)}`)
|
||
return makeInitial()
|
||
}
|
||
if (!isValidParsed(parsed)) {
|
||
quarantineCorrupt(p, `expected object, got ${typeof parsed}`)
|
||
return makeInitial()
|
||
}
|
||
return coerce(runMigrations(parsed))
|
||
}
|
||
|
||
type AppendOpts = {
|
||
actualReps?: number
|
||
/** Planned reps snapshot — иначе после удаления упражнения теряем reps. */
|
||
reps?: number
|
||
/** Snapshot названия — для будущего log-view (необязательно). */
|
||
name?: string
|
||
/** 'reminder' (default) или 'match'. */
|
||
source?: import('@shared/types').HistorySource
|
||
}
|
||
|
||
function appendHistory(
|
||
exerciseId: string,
|
||
action: HistoryAction,
|
||
opts: AppendOpts = {}
|
||
): void {
|
||
const state = getState()
|
||
if (!state.history) state.history = []
|
||
const entry: HistoryEntry = { ts: Date.now(), exerciseId, action }
|
||
if (opts.actualReps !== undefined) entry.actualReps = opts.actualReps
|
||
if (opts.reps !== undefined) entry.reps = opts.reps
|
||
if (opts.name !== undefined) entry.name = opts.name
|
||
if (opts.source !== undefined) entry.source = opts.source
|
||
state.history.push(entry)
|
||
if (state.history.length > HISTORY_MAX) {
|
||
state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9))
|
||
}
|
||
}
|
||
|
||
export function getHistory(sinceMs?: number): HistoryEntry[] {
|
||
const all = getState().history ?? []
|
||
if (sinceMs == null) return all
|
||
return all.filter((e) => e.ts >= sinceMs)
|
||
}
|
||
|
||
export function clearHistory(beforeTs?: number): number {
|
||
const state = getState()
|
||
const before = state.history?.length ?? 0
|
||
if (beforeTs == null) {
|
||
// Refuse a full wipe via IPC — callers must pass an explicit boundary.
|
||
// (Settings UI passes 0 to wipe everything; that's an opt-in.)
|
||
return 0
|
||
}
|
||
state.history = (state.history ?? []).filter((e) => e.ts >= beforeTs)
|
||
scheduleWrite()
|
||
return before - (state.history?.length ?? 0)
|
||
}
|
||
|
||
/**
|
||
* Atomically write to `path` via a sibling .tmp file + rename. Retries a few
|
||
* times on transient EBUSY/EPERM (AV/OneDrive holding the file).
|
||
*
|
||
* Async version (используется debounced scheduleWrite/flush) — раньше был
|
||
* busy-loop `while (Date.now() < until)`, который морозил весь main process
|
||
* на retry-delay (до 800мс). При активном AV это превращалось в видимое
|
||
* залипание UI. Сейчас sleep через setTimeout-promise.
|
||
*
|
||
* Для процесса-выхода используется `atomicWriteSync` — там event-loop уже
|
||
* не работает, async sleep не сработает.
|
||
*/
|
||
async function atomicWrite(path: string, contents: string): Promise<void> {
|
||
const tmp = `${path}.tmp`
|
||
let lastErr: unknown
|
||
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
|
||
try {
|
||
writeFileSync(tmp, contents, 'utf-8')
|
||
renameSync(tmp, path)
|
||
return
|
||
} catch (e) {
|
||
lastErr = e
|
||
try {
|
||
if (existsSync(tmp)) unlinkSync(tmp)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
const delay = WRITE_RETRY_DELAYS[i]
|
||
if (delay === undefined) break
|
||
await new Promise<void>((r) => setTimeout(r, delay))
|
||
}
|
||
}
|
||
log.error('[store] atomic write failed after retries', lastErr)
|
||
}
|
||
|
||
/**
|
||
* Синхронный вариант для use-cases где event loop уже не работает
|
||
* (process exit в `before-quit`). При retry — короткий sync sleep, потому
|
||
* что иначе мы дропнем pending write при exit'е.
|
||
*/
|
||
function atomicWriteSync(path: string, contents: string): void {
|
||
const tmp = `${path}.tmp`
|
||
let lastErr: unknown
|
||
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
|
||
try {
|
||
writeFileSync(tmp, contents, 'utf-8')
|
||
renameSync(tmp, path)
|
||
return
|
||
} catch (e) {
|
||
lastErr = e
|
||
try {
|
||
if (existsSync(tmp)) unlinkSync(tmp)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
const delay = WRITE_RETRY_DELAYS[i]
|
||
if (delay === undefined) break
|
||
// Event-loop остановлен (exit-path), async sleep не вернётся — нужен
|
||
// блокирующий sync sleep. Atomics.wait на «свежем» буфере всегда уходит
|
||
// в таймаут (значение совпадает с ожидаемым 0), т.е. честно спит delay мс
|
||
// без сжигания CPU — в отличие от старого busy-loop.
|
||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
|
||
}
|
||
}
|
||
log.error('[store] atomic sync write failed after retries', lastErr)
|
||
}
|
||
|
||
async function flush(): Promise<void> {
|
||
if (!cache) return
|
||
// Persist the schema version alongside the state so future migrations know
|
||
// where to pick up from. The renderer never reads this key.
|
||
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
|
||
await atomicWrite(getStorePath(), JSON.stringify(payload, null, 2))
|
||
}
|
||
|
||
function flushSync(): void {
|
||
if (!cache) return
|
||
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
|
||
atomicWriteSync(getStorePath(), JSON.stringify(payload, null, 2))
|
||
}
|
||
|
||
function scheduleWrite(): void {
|
||
if (pendingWrite) return
|
||
pendingWrite = setTimeout(() => {
|
||
pendingWrite = null
|
||
void flush()
|
||
}, WRITE_DEBOUNCE_MS)
|
||
// Don't keep the event loop alive solely for a pending write — `before-quit`
|
||
// calls `flushNow()` and we explicitly want the process to exit on schedule.
|
||
pendingWrite.unref?.()
|
||
}
|
||
|
||
/**
|
||
* Internal persisted state — единственный source of truth. Включает историю.
|
||
* Mutate напрямую (mutations внутри store.ts), затем scheduleWrite().
|
||
*/
|
||
export function getState(): PersistedState {
|
||
if (!cache) cache = load()
|
||
return cache
|
||
}
|
||
|
||
/**
|
||
* State для отправки renderer'у. Копия БЕЗ `history` — историю renderer
|
||
* запрашивает отдельным IPC.getHistory. Раньше каждый markDone/snooze
|
||
* отправлял весь state через evtStateChanged, и при 10k entries в истории
|
||
* это 500KB JSON × N IPC mutations подряд → заметный лаг.
|
||
*
|
||
* Возвращаемая копия безопасна для мутации (ipc.ts накладывает на settings
|
||
* актуальное OS-значение startWithWindows) — мы НЕ мутируем cache.
|
||
*/
|
||
export function getStateForRenderer(): AppState {
|
||
const p = getState()
|
||
return {
|
||
exercises: p.exercises,
|
||
meals: p.meals,
|
||
settings: p.settings,
|
||
challenges: p.challenges,
|
||
gamesEnabled: p.gamesEnabled
|
||
}
|
||
}
|
||
|
||
export function getSettings(): Settings {
|
||
return getState().settings
|
||
}
|
||
|
||
export function getExercises(): Exercise[] {
|
||
return getState().exercises
|
||
}
|
||
|
||
export function updateSettings(patch: Partial<Settings>): Settings {
|
||
const state = getState()
|
||
state.settings = { ...state.settings, ...patch }
|
||
scheduleWrite()
|
||
return state.settings
|
||
}
|
||
|
||
export function addExercise(
|
||
input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>
|
||
): Exercise {
|
||
const state = getState()
|
||
const exercise: Exercise = {
|
||
...input,
|
||
id: randomUUID(),
|
||
nextFireAt: Date.now() + input.intervalMinutes * 60_000
|
||
}
|
||
state.exercises.push(exercise)
|
||
scheduleWrite()
|
||
return exercise
|
||
}
|
||
|
||
export function updateExercise(
|
||
id: string,
|
||
patch: Partial<Omit<Exercise, 'id'>>
|
||
): Exercise | undefined {
|
||
const state = getState()
|
||
const idx = state.exercises.findIndex((e) => e.id === id)
|
||
if (idx === -1) return undefined
|
||
const prev = state.exercises[idx]
|
||
// Drop `id` from the patch even though the type forbids it — runtime callers
|
||
// (IPC) can still smuggle it through. We never let the id change.
|
||
const { id: _ignoredId, ...safePatch } = patch as Partial<Exercise>
|
||
const merged: Exercise = { ...prev, ...safePatch }
|
||
// If interval changed, reschedule from now.
|
||
if (
|
||
patch.intervalMinutes !== undefined &&
|
||
patch.intervalMinutes !== prev.intervalMinutes
|
||
) {
|
||
merged.nextFireAt = Date.now() + merged.intervalMinutes * 60_000
|
||
}
|
||
state.exercises[idx] = merged
|
||
scheduleWrite()
|
||
return merged
|
||
}
|
||
|
||
export function deleteExercise(id: string): boolean {
|
||
const state = getState()
|
||
const before = state.exercises.length
|
||
state.exercises = state.exercises.filter((e) => e.id !== id)
|
||
const changed = state.exercises.length !== before
|
||
if (changed) scheduleWrite()
|
||
return changed
|
||
}
|
||
|
||
export function markDone(
|
||
id: string,
|
||
actualReps?: number
|
||
): Exercise | undefined {
|
||
const state = getState()
|
||
const ex = state.exercises.find((e) => e.id === id)
|
||
if (!ex) return undefined
|
||
ex.lastDoneAt = Date.now()
|
||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||
appendHistory(id, 'done', {
|
||
actualReps,
|
||
reps: ex.reps,
|
||
name: ex.name,
|
||
source: 'reminder'
|
||
})
|
||
scheduleWrite()
|
||
return ex
|
||
}
|
||
|
||
export function snooze(id: string, minutes: number): Exercise | undefined {
|
||
const state = getState()
|
||
const ex = state.exercises.find((e) => e.id === id)
|
||
if (!ex) return undefined
|
||
ex.nextFireAt = Date.now() + minutes * 60_000
|
||
appendHistory(id, 'snooze', { reps: ex.reps, name: ex.name })
|
||
scheduleWrite()
|
||
return ex
|
||
}
|
||
|
||
export function skip(id: string): Exercise | undefined {
|
||
const state = getState()
|
||
const ex = state.exercises.find((e) => e.id === id)
|
||
if (!ex) return undefined
|
||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||
appendHistory(id, 'skip', { reps: ex.reps, name: ex.name })
|
||
scheduleWrite()
|
||
return ex
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Meals (приёмы пищи — по времени суток)
|
||
// -------------------------------------------------------------------------
|
||
|
||
export function getMeals(): Meal[] {
|
||
return getState().meals
|
||
}
|
||
|
||
export function addMeal(
|
||
input: Omit<Meal, 'id' | 'nextFireAt' | 'lastDoneAt'>
|
||
): Meal {
|
||
const state = getState()
|
||
const meal: Meal = {
|
||
...input,
|
||
id: randomUUID(),
|
||
nextFireAt: nextMealOccurrence(input.time, input.days, Date.now())
|
||
}
|
||
state.meals.push(meal)
|
||
scheduleWrite()
|
||
return meal
|
||
}
|
||
|
||
export function updateMeal(
|
||
id: string,
|
||
patch: Partial<Omit<Meal, 'id'>>
|
||
): Meal | undefined {
|
||
const state = getState()
|
||
const idx = state.meals.findIndex((m) => m.id === id)
|
||
if (idx === -1) return undefined
|
||
const merged: Meal = { ...state.meals[idx], ...patch }
|
||
// Если поменялось время/дни/вкл — и nextFireAt не задан явно — пересчитать
|
||
// следующее срабатывание (toggle-on тоже сюда попадает).
|
||
if (
|
||
(patch.time !== undefined ||
|
||
patch.days !== undefined ||
|
||
patch.enabled !== undefined) &&
|
||
patch.nextFireAt === undefined
|
||
) {
|
||
merged.nextFireAt = nextMealOccurrence(merged.time, merged.days, Date.now())
|
||
}
|
||
state.meals[idx] = merged
|
||
scheduleWrite()
|
||
return merged
|
||
}
|
||
|
||
export function deleteMeal(id: string): boolean {
|
||
const state = getState()
|
||
const before = state.meals.length
|
||
state.meals = state.meals.filter((m) => m.id !== id)
|
||
const ok = state.meals.length < before
|
||
if (ok) scheduleWrite()
|
||
return ok
|
||
}
|
||
|
||
export function markMealDone(id: string): Meal | undefined {
|
||
const state = getState()
|
||
const meal = state.meals.find((m) => m.id === id)
|
||
if (!meal) return undefined
|
||
meal.lastDoneAt = Date.now()
|
||
// nextFireAt обычно уже перенесён планировщиком в момент срабатывания;
|
||
// подстраховка на случай ручного вызова — гарантируем будущее время.
|
||
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
|
||
}
|
||
|
||
/**
|
||
* Записать выполнение челленджа из match summary в историю. Не привязано
|
||
* к конкретному Exercise (челлендж может ссылаться на упражнение, которое
|
||
* пользователь даже не создал). Используем синтетический id 'challenge:<id>'.
|
||
*/
|
||
export function markChallengeDone(challengeId: string, reps: number): void {
|
||
const state = getState()
|
||
const ch = state.challenges.find((c) => c.id === challengeId)
|
||
appendHistory(`challenge:${challengeId}`, 'done', {
|
||
actualReps: reps,
|
||
reps,
|
||
name: ch?.exerciseName ?? ch?.name,
|
||
source: 'match'
|
||
})
|
||
scheduleWrite()
|
||
}
|
||
|
||
export function flushNow(): void {
|
||
if (pendingWrite) {
|
||
clearTimeout(pendingWrite)
|
||
pendingWrite = null
|
||
}
|
||
// before-quit вызывает нас когда event-loop уже на пути к выходу — async
|
||
// promise не успеет resolved, поэтому sync.
|
||
flushSync()
|
||
}
|
||
|
||
export function getChallenges(): Challenge[] {
|
||
return getState().challenges
|
||
}
|
||
|
||
export function addChallenge(input: Omit<Challenge, 'id'>): Challenge {
|
||
const state = getState()
|
||
const challenge: Challenge = { ...input, id: randomUUID() }
|
||
state.challenges.push(challenge)
|
||
scheduleWrite()
|
||
return challenge
|
||
}
|
||
|
||
export function updateChallenge(
|
||
id: string,
|
||
patch: Partial<Omit<Challenge, 'id'>>
|
||
): Challenge | undefined {
|
||
const state = getState()
|
||
const idx = state.challenges.findIndex((c) => c.id === id)
|
||
if (idx === -1) return undefined
|
||
// Same id-strip as updateExercise.
|
||
const { id: _ignoredId, ...safePatch } = patch as Partial<Challenge>
|
||
state.challenges[idx] = { ...state.challenges[idx], ...safePatch }
|
||
scheduleWrite()
|
||
return state.challenges[idx]
|
||
}
|
||
|
||
export function deleteChallenge(id: string): boolean {
|
||
const state = getState()
|
||
const before = state.challenges.length
|
||
state.challenges = state.challenges.filter((c) => c.id !== id)
|
||
const changed = state.challenges.length !== before
|
||
if (changed) scheduleWrite()
|
||
return changed
|
||
}
|
||
|
||
export function getGamesEnabled(): Partial<Record<GameId, boolean>> {
|
||
return getState().gamesEnabled
|
||
}
|
||
|
||
export function setGameEnabled(id: GameId, enabled: boolean): void {
|
||
const state = getState()
|
||
state.gamesEnabled = { ...state.gamesEnabled, [id]: enabled }
|
||
scheduleWrite()
|
||
}
|
||
|
||
// ----- Export / Import -----
|
||
|
||
/**
|
||
* Полный snapshot persisted-state (включая историю и schema-version).
|
||
* Используется для backup'а или переноса на другую машину.
|
||
*/
|
||
export function exportState(): string {
|
||
const state = getState()
|
||
return JSON.stringify(
|
||
{
|
||
__schemaVersion: CURRENT_SCHEMA_VERSION,
|
||
__exportedAt: new Date().toISOString(),
|
||
__appVersion: app.getVersion(),
|
||
...state
|
||
},
|
||
null,
|
||
2
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при
|
||
* успехе. Идёт через тот же coerce + runMigrations что и load(): валидные
|
||
* записи сохраняются, повреждённые записи/поля отбрасываются.
|
||
*
|
||
* НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты
|
||
* settings) — простое replace. Перед импортом UI должен спросить
|
||
* подтверждение.
|
||
*/
|
||
export function importState(raw: string): boolean {
|
||
let parsed: unknown
|
||
try {
|
||
parsed = JSON.parse(raw)
|
||
} catch (e) {
|
||
log.warn('[store] import: invalid JSON', e)
|
||
return false
|
||
}
|
||
if (!isValidParsed(parsed)) {
|
||
log.warn('[store] import: expected object')
|
||
return false
|
||
}
|
||
try {
|
||
const migrated = runMigrations(parsed)
|
||
const coerced = coerce(migrated)
|
||
cache = coerced
|
||
flushSync()
|
||
return true
|
||
} catch (e) {
|
||
log.error('[store] import: coerce/migrate failed', e)
|
||
return false
|
||
}
|
||
}
|