4 Commits

Author SHA1 Message Date
Codex
b5f6952ad2 chore(release): v0.6.1 2026-06-06 12:37:53 +07:00
Codex
ffe80b62c4 fix: harden reminders and state handling 2026-06-06 02:27:04 +07:00
AnRil
ad000c722e fix(meals): плавная анимация переключателя в обе стороны
Список «Питания» разбивался на два <Card> (активные/выключенные). При
переключении строка переезжала между списками → её Switch размонтировался и
монтировался заново, и ползунок анимировался только при включении (mount
x:0→20), а при выключении — нет.

Теперь единый keyed-список (включённые сверху): Switch остаётся смонтированным,
ползунок плавно ездит в обе стороны, а строка «переезжает» в свою группу через
framer layout-анимацию. Иконке добавлен transition-colors. Глобальный Switch не
трогал — Упражнения/Дашборд не затронуты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:13:07 +07:00
AnRil
c0827f887f chore(release): v0.6.0 2026-06-05 22:16:52 +07:00
19 changed files with 1790 additions and 330 deletions

1404
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "laude", "name": "laude",
"version": "0.5.8", "version": "0.6.1",
"description": "Exercise reminder — Windows desktop app", "description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "AnRil", "author": "AnRil",
@@ -43,10 +43,12 @@
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"@vitest/coverage-v8": "^4.1.6",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"electron": "^33.2.0", "electron": "^33.2.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"electron-vite": "^2.3.0", "electron-vite": "^2.3.0",
"esbuild": "^0.28.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",

View File

@@ -116,9 +116,6 @@ $pkgJson = [System.IO.File]::ReadAllText($pkgPath, $utf8NoBom)
$pkgJson = $pkgJson -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`"" $pkgJson = $pkgJson -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
[System.IO.File]::WriteAllText($pkgPath, $pkgJson, $utf8NoBom) [System.IO.File]::WriteAllText($pkgPath, $pkgJson, $utf8NoBom)
git add package.json
git commit -m "chore(release): $tag"
# --- Quality gates ------------------------------------------------------ # --- Quality gates ------------------------------------------------------
if (-not $SkipBuild) { if (-not $SkipBuild) {
Write-Host "Typecheck..." -ForegroundColor Cyan Write-Host "Typecheck..." -ForegroundColor Cyan
@@ -145,6 +142,12 @@ foreach ($f in @($installer, $blockmap, $manifest)) {
} }
} }
# Commit only after quality gates and artifact verification pass. If a check
# fails, package.json remains modified but history does not get a broken
# release commit/tag.
git add package.json
git commit -m "chore(release): $tag"
# --- Tag + push --------------------------------------------------------- # --- Tag + push ---------------------------------------------------------
Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan
git tag -a $tag -m "Release $tag" git tag -a $tag -m "Release $tag"

View 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)
})
})

View File

@@ -87,6 +87,7 @@ export async function startGamesRegistry(): Promise<void> {
await startGsiServer() await startGsiServer()
log.info('[games] GSI server started on port 4701') log.info('[games] GSI server started on port 4701')
} catch (err) { } catch (err) {
running = false
log.error('[games] GSI server failed to start', err) log.error('[games] GSI server failed to start', err)
return return
} }
@@ -119,7 +120,7 @@ export async function stopGamesRegistry(): Promise<void> {
for (const id of Object.keys(providers) as GameId[]) { for (const id of Object.keys(providers) as GameId[]) {
await providers[id].stop() await providers[id].stop()
} }
stopGsiServer() await stopGsiServer()
} }
export async function listGamesStatus(): Promise<GameStatus[]> { export async function listGamesStatus(): Promise<GameStatus[]> {

View File

@@ -176,8 +176,10 @@ export function registerIpc(): void {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return null if (!id) return null
const ex = markDone(id, validateActualReps(repsRaw)) const ex = markDone(id, validateActualReps(repsRaw))
broadcastState() if (ex) {
broadcastHistoryChanged() broadcastState()
broadcastHistoryChanged()
}
return ex return ex
}) })
@@ -186,8 +188,10 @@ export function registerIpc(): void {
const minutes = validateSnoozeMinutes(minRaw) const minutes = validateSnoozeMinutes(minRaw)
if (!id || minutes === null) return null if (!id || minutes === null) return null
const ex = snooze(id, minutes) const ex = snooze(id, minutes)
broadcastState() if (ex) {
broadcastHistoryChanged() broadcastState()
broadcastHistoryChanged()
}
return ex return ex
}) })
@@ -195,8 +199,10 @@ export function registerIpc(): void {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return null if (!id) return null
const ex = skip(id) const ex = skip(id)
broadcastState() if (ex) {
broadcastHistoryChanged() broadcastState()
broadcastHistoryChanged()
}
return ex return ex
}) })
@@ -238,7 +244,10 @@ export function registerIpc(): void {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return null if (!id) return null
const m = markMealDone(id) const m = markMealDone(id)
broadcastState() if (m) {
broadcastState()
broadcastHistoryChanged()
}
return m return m
}) })

View File

@@ -21,6 +21,8 @@ const h = vi.hoisted(() => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
})) }))
const originalPlatform = process.platform
vi.mock('node:child_process', () => ({ vi.mock('node:child_process', () => ({
exec: (cmd: string, opts: unknown, cb: ExecCb) => { exec: (cmd: string, opts: unknown, cb: ExecCb) => {
h.calls += 1 h.calls += 1
@@ -39,8 +41,16 @@ async function load(): Promise<typeof import('./meeting-detect')> {
return import('./meeting-detect') return import('./meeting-detect')
} }
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true
})
}
beforeEach(() => { beforeEach(() => {
vi.resetModules() vi.resetModules()
setPlatform('win32')
h.calls = 0 h.calls = 0
h.execImpl = (_cmd, _opts, cb) => cb(null, { stdout: '' }) h.execImpl = (_cmd, _opts, cb) => cb(null, { stdout: '' })
h.log.info.mockClear() h.log.info.mockClear()
@@ -48,6 +58,7 @@ beforeEach(() => {
}) })
afterEach(() => { afterEach(() => {
setPlatform(originalPlatform)
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
@@ -96,14 +107,9 @@ describe('isMeetingActive', () => {
}) })
it('на не-Windows возвращает false без вызова tasklist', async () => { it('на не-Windows возвращает false без вызова tasklist', async () => {
const original = process.platform setPlatform('linux')
Object.defineProperty(process, 'platform', { value: 'linux' }) const { isMeetingActive } = await load()
try { expect(await isMeetingActive()).toBe(false)
const { isMeetingActive } = await load() expect(h.calls).toBe(0)
expect(await isMeetingActive()).toBe(false)
expect(h.calls).toBe(0)
} finally {
Object.defineProperty(process, 'platform', { value: original })
}
}) })
}) })

View File

@@ -186,6 +186,21 @@ describe('checkDueExercises gating', () => {
expect(h.updateExercise).toHaveBeenCalled() 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 () => { it('adaptive: применяет adjustNextFireAt к кандидату', async () => {
h.exercises = [makeExercise({ adaptive: true })] h.exercises = [makeExercise({ adaptive: true })]
const { forceCheck } = await loadScheduler() const { forceCheck } = await loadScheduler()

View File

@@ -17,7 +17,8 @@ import { adjustNextFireAt } from './adaptive'
/** /**
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day). * Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
* Учитываем actualReps если задано (частичное выполнение), иначе planned reps. * Учитываем actualReps если задано (частичное выполнение), затем snapshot
* reps из истории, и только потом текущие planned reps упражнения.
*/ */
function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number { function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number {
const todayKey = new Date() const todayKey = new Date()
@@ -28,7 +29,7 @@ function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number {
if (e.action !== 'done') continue if (e.action !== 'done') continue
if (e.exerciseId !== ex.id) continue if (e.exerciseId !== ex.id) continue
if (e.ts < startMs) continue if (e.ts < startMs) continue
sum += e.actualReps ?? ex.reps sum += e.actualReps ?? e.reps ?? ex.reps
} }
return sum return sum
} }

View File

@@ -8,6 +8,7 @@ import {
} from 'node:fs' } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { DEFAULT_SETTINGS } from '@shared/types'
/** /**
* Тесты persistence-слоя. Мокаем electron.app.getPath на временную директорию * Тесты 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', () => { describe('store · clearHistory', () => {
it('удаляет записи старше границы и возвращает количество', async () => { it('удаляет записи старше границы и возвращает количество', async () => {
const ex = { const ex = {
@@ -195,4 +233,79 @@ describe('store · export / import', () => {
expect(importState('not json at all')).toBe(false) expect(importState('not json at all')).toBe(false)
expect(importState('42')).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)
})
}) })

View File

@@ -17,6 +17,7 @@ import {
GameId, GameId,
HistoryAction, HistoryAction,
HistoryEntry, HistoryEntry,
HistorySource,
Meal, Meal,
nextMealOccurrence, nextMealOccurrence,
PersistedState, PersistedState,
@@ -25,6 +26,13 @@ import {
Settings Settings
} from '@shared/types' } from '@shared/types'
import { log } from './logger' 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). * 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) 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 * Current persisted-state schema version. Bump this and add a migration to
* MIGRATIONS whenever the on-disk shape changes in a non-additive way. * 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. */ /** Coerce a (possibly partial) migrated state into a fully-formed PersistedState. */
function coerce(s: StoredState): PersistedState { function coerce(s: StoredState): PersistedState {
const now = Date.now()
return { 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: старые state'ы без `meals` получают пустой список (см. философию
// миграций — additive-поля не требуют bump'а схемы). // миграций — additive-поля не требуют bump'а схемы).
meals: Array.isArray(s.meals) ? (s.meals as Meal[]) : [], meals: Array.isArray(s.meals)
settings: { ? s.meals.flatMap((raw) => {
...DEFAULT_SETTINGS, const meal = sanitizeMeal(raw, now)
...(isValidParsed(s.settings) ? (s.settings as Partial<Settings>) : {}) return meal ? [meal] : []
}, })
challenges: Array.isArray(s.challenges)
? (s.challenges as Challenge[])
: [], : [],
gamesEnabled: isValidParsed(s.gamesEnabled) settings: sanitizeSettings(s.settings),
? (s.gamesEnabled as Partial<Record<GameId, boolean>>) challenges: Array.isArray(s.challenges)
: {}, ? s.challenges.flatMap((raw) => {
history: Array.isArray(s.history) ? (s.history as HistoryEntry[]) : [] 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()) { if (meal.nextFireAt <= Date.now()) {
meal.nextFireAt = nextMealOccurrence(meal.time, meal.days, Date.now()) meal.nextFireAt = nextMealOccurrence(meal.time, meal.days, Date.now())
} }
appendHistory(`meal:${id}`, 'done', {
reps: 1,
name: meal.name,
source: 'meal'
})
scheduleWrite() scheduleWrite()
return meal return meal
} }
@@ -641,8 +798,8 @@ export function exportState(): string {
/** /**
* Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при * Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при
* успехе. Идёт через тот же coerce + runMigrations что и load() — это * успехе. Идёт через тот же coerce + runMigrations что и load(): валидные
* валидирует тип/диапазоны. * записи сохраняются, повреждённые записи/поля отбрасываются.
* *
* НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты * НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты
* settings) — простое replace. Перед импортом UI должен спросить * settings) — простое replace. Перед импортом UI должен спросить

View File

@@ -14,6 +14,7 @@
import type { import type {
Challenge, Challenge,
Exercise, Exercise,
GameId,
GameStat, GameStat,
Meal, Meal,
Settings, Settings,
@@ -27,6 +28,7 @@ const MAX_STR_LEN = 200
const VALID_THEMES: Theme[] = ['system', 'light', 'dark'] const VALID_THEMES: Theme[] = ['system', 'light', 'dark']
const VALID_LANGS: Language[] = ['ru', 'en'] const VALID_LANGS: Language[] = ['ru', 'en']
const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both'] const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both']
const VALID_GAME_IDS: GameId[] = ['dota2']
const VALID_STATS: GameStat[] = [ const VALID_STATS: GameStat[] = [
'deaths', 'deaths',
'kills', 'kills',
@@ -289,7 +291,7 @@ export function validateChallengeInput(
): Omit<Challenge, 'id'> | null { ): Omit<Challenge, 'id'> | null {
if (!isObj(raw)) return null if (!isObj(raw)) return null
const name = safeStr(raw.name) 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 stat = oneOf(raw.stat, VALID_STATS)
const multiplier = numInRange(raw.multiplier, 0, 1000) const multiplier = numInRange(raw.multiplier, 0, 1000)
const exerciseName = safeStr(raw.exerciseName) const exerciseName = safeStr(raw.exerciseName)
@@ -306,7 +308,7 @@ export function validateChallengeInput(
} }
return { return {
name, name,
gameId: gameId as Challenge['gameId'], gameId,
stat, stat,
multiplier, multiplier,
exerciseName, exerciseName,

View File

@@ -30,6 +30,8 @@ type Mode =
| { kind: 'meal'; meal: Meal } | { kind: 'meal'; meal: Meal }
| { kind: 'match'; summary: MatchSummary; done: Set<string> } | { kind: 'match'; summary: MatchSummary; done: Set<string> }
type ActiveMode = Exclude<Mode, { kind: 'idle' }>
/** Минимальный нативный confirm. В reminder-окне нет места для модалки, /** Минимальный нативный confirm. В reminder-окне нет места для модалки,
* проще использовать встроенный диалог. */ * проще использовать встроенный диалог. */
function nativeConfirm(message: string): boolean { function nativeConfirm(message: string): boolean {
@@ -41,6 +43,8 @@ export default function ReminderApp(): JSX.Element {
const [mode, setMode] = useState<Mode>({ kind: 'idle' }) const [mode, setMode] = useState<Mode>({ kind: 'idle' })
const [settings, setSettings] = useState<Settings | null>(null) const [settings, setSettings] = useState<Settings | null>(null)
const settingsRef = useRef<Settings | null>(null) const settingsRef = useRef<Settings | null>(null)
const modeRef = useRef<Mode>({ kind: 'idle' })
const queueRef = useRef<ActiveMode[]>([])
// ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref, // ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref,
// не state — нужен только для дедупа rapid double-click. Сбрасывается // не state — нужен только для дедупа rapid double-click. Сбрасывается
// когда приходит новый match summary (см. onMatchEnd ниже). // когда приходит новый match summary (см. onMatchEnd ниже).
@@ -50,53 +54,21 @@ export default function ReminderApp(): JSX.Element {
settingsRef.current = settings settingsRef.current = settings
}, [settings]) }, [settings])
useEffect(() => {
modeRef.current = mode
}, [mode])
useEffect(() => { useEffect(() => {
window.api.getState().then((s) => setSettings(s.settings)) window.api.getState().then((s) => setSettings(s.settings))
const u0 = window.api.onStateChanged((s) => setSettings(s.settings)) const u0 = window.api.onStateChanged((s) => setSettings(s.settings))
const u1 = window.api.onFire((ex) => { const u1 = window.api.onFire((ex) => {
setMode({ kind: 'exercise', exercise: ex }) enqueueMode({ 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)
}
}) })
const u1b = window.api.onFireMeal((meal) => { const u1b = window.api.onFireMeal((meal) => {
setMode({ kind: 'meal', meal }) enqueueMode({ 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)
}
}) })
const u2 = window.api.onMatchEnd((summary) => { const u2 = window.api.onMatchEnd((summary) => {
// Новый матч — сбрасываем дедуп challenge'ей. enqueueMode({ kind: 'match', summary, done: new Set() })
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)
}
}) })
return () => { return () => {
u0() u0()
@@ -104,6 +76,9 @@ export default function ReminderApp(): JSX.Element {
u1b() u1b()
u2() 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. // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode.kind]) }, [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 { function close(): void {
// Если в Match Summary остались незакрытые челленджи — подтверждаем, // Если в Match Summary остались незакрытые челленджи — подтверждаем,
// чтобы пользователь не «пролетел» окно по привычке и не потерял // чтобы пользователь не «пролетел» окно по привычке и не потерял
@@ -139,6 +171,12 @@ export default function ReminderApp(): JSX.Element {
if (!nativeConfirm(msg)) return if (!nativeConfirm(msg)) return
} }
} }
const next = queueRef.current.shift()
if (next) {
activateMode(next)
return
}
modeRef.current = { kind: 'idle' }
setMode({ kind: 'idle' }) setMode({ kind: 'idle' })
window.api.reminderClose() window.api.reminderClose()
} }
@@ -189,13 +227,16 @@ export default function ReminderApp(): JSX.Element {
} }
// 2) Functional update: rapid-click race-safe. // 2) Functional update: rapid-click race-safe.
setMode((m) => setMode((m) =>
m.kind === 'match' {
? { if (m.kind !== 'match') return m
const nextMode: Mode = {
kind: 'match', kind: 'match',
summary: m.summary, summary: m.summary,
done: new Set([...m.done, id]) done: new Set([...m.done, id])
} }
: m modeRef.current = nextMode
return nextMode
}
) )
}} }}
onClose={close} onClose={close}

View File

@@ -100,6 +100,7 @@ export const ru: Dict = {
'meals.kicker': 'Режим питания', 'meals.kicker': 'Режим питания',
'meals.title': 'Питание', 'meals.title': 'Питание',
'meals.presets': 'Быстрое добавление', 'meals.presets': 'Быстрое добавление',
'meals.schedule': 'Расписание',
'meals.section.active': 'Активные · {n}', 'meals.section.active': 'Активные · {n}',
'meals.section.disabled': 'Выключенные · {n}', 'meals.section.disabled': 'Выключенные · {n}',
'meals.empty': 'Пока нет приёмов пищи — добавь первый или выбери пресет', 'meals.empty': 'Пока нет приёмов пищи — добавь первый или выбери пресет',
@@ -460,6 +461,7 @@ export const en: Dict = {
'meals.kicker': 'Eating schedule', 'meals.kicker': 'Eating schedule',
'meals.title': 'Meals', 'meals.title': 'Meals',
'meals.presets': 'Quick add', 'meals.presets': 'Quick add',
'meals.schedule': 'Schedule',
'meals.section.active': 'Active · {n}', 'meals.section.active': 'Active · {n}',
'meals.section.disabled': 'Disabled · {n}', 'meals.section.disabled': 'Disabled · {n}',
'meals.empty': 'No meals yet — add one or pick a preset', 'meals.empty': 'No meals yet — add one or pick a preset',

View File

@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, ChevronRight, UtensilsCrossed } from 'lucide-react' import { Plus, ChevronRight, UtensilsCrossed } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { MealEditor, type MealDraft } from '../components/MealEditor' import { MealEditor, type MealDraft } from '../components/MealEditor'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
@@ -27,8 +28,15 @@ export default function Meals(): JSX.Element {
const [editing, setEditing] = useState<Meal | null>(null) const [editing, setEditing] = useState<Meal | null>(null)
const { t } = useT() const { t } = useT()
const enabled = meals.filter((m) => m.enabled) // Единый список (включённые сверху). Важно: НЕ разбиваем на два <Card>, иначе
const disabled = meals.filter((m) => !m.enabled) // при переключении строка переезжает между списками → её Switch
// размонтируется/монтируется заново, и анимация ползунка есть только при
// включении (mount с x:0→20), а при выключении нет. В одном keyed-списке
// компонент остаётся смонтированным → ползунок плавно ездит в обе стороны,
// а строка «переезжает» в свою группу через layout-анимацию.
const ordered = [...meals].sort((a, b) =>
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
)
async function addPreset( async function addPreset(
preset: (typeof MEAL_PRESETS)[number] preset: (typeof MEAL_PRESETS)[number]
@@ -82,46 +90,32 @@ export default function Meals(): JSX.Element {
))} ))}
</div> </div>
{enabled.length > 0 && ( {meals.length > 0 && (
<> <>
<SectionHeader <SectionHeader title={t('meals.schedule')} />
title={t('meals.section.active', { n: enabled.length })}
/>
<Card className="mb-6">
{enabled.map((m, i) => (
<MealRow
key={m.id}
meal={m}
last={i === enabled.length - 1}
meta={`${m.time} · ${daysLabel(m.days, t)}`}
onEdit={() => {
setEditing(m)
setEditorOpen(true)
}}
/>
))}
</Card>
</>
)}
{disabled.length > 0 && (
<>
<SectionHeader
title={t('meals.section.disabled', { n: disabled.length })}
/>
<Card> <Card>
{disabled.map((m, i) => ( <AnimatePresence initial={false}>
<MealRow {ordered.map((m, i) => (
key={m.id} <motion.div
meal={m} key={m.id}
last={i === disabled.length - 1} layout
meta={`${m.time} · ${daysLabel(m.days, t)}`} initial={{ opacity: 0 }}
onEdit={() => { animate={{ opacity: 1 }}
setEditing(m) exit={{ opacity: 0 }}
setEditorOpen(true) transition={{ duration: 0.18, ease: 'easeOut' }}
}} >
/> <MealRow
))} meal={m}
last={i === ordered.length - 1}
meta={`${m.time} · ${daysLabel(m.days, t)}`}
onEdit={() => {
setEditing(m)
setEditorOpen(true)
}}
/>
</motion.div>
))}
</AnimatePresence>
</Card> </Card>
</> </>
)} )}
@@ -169,7 +163,7 @@ function MealRow({
<Row last={last}> <Row last={last}>
<div <div
className={[ className={[
'w-9 h-9 rounded-lg grid place-items-center shrink-0', 'w-9 h-9 rounded-lg grid place-items-center shrink-0 transition-colors duration-200',
meal.enabled ? 'bg-accent text-white' : 'bg-text/15 text-text/45' meal.enabled ? 'bg-accent text-white' : 'bg-text/15 text-text/45'
].join(' ')} ].join(' ')}
> >

View File

@@ -77,7 +77,7 @@ export const IPC = {
evtMaximizeChanged: 'evt:maximizeChanged', evtMaximizeChanged: 'evt:maximizeChanged',
evtMeetingChanged: 'evt:meetingChanged', evtMeetingChanged: 'evt:meetingChanged',
/** /**
* Шлётся когда история мутирует (markDone / snooze / skip / * Шлётся когда история мутирует (markDone / markMealDone / snooze / skip /
* markChallengeDone / clearHistory / import). Renderer'у достаточно * markChallengeDone / clearHistory / import). Renderer'у достаточно
* перезапросить getHistory. Раньше Dashboard переключал history по * перезапросить getHistory. Раньше Dashboard переключал history по
* `exercises` ref'у — но markDone мутирует Exercise in place, ref не * `exercises` ref'у — но markDone мутирует Exercise in place, ref не

View File

@@ -147,10 +147,11 @@ export type PersistedState = AppState & {
export type HistoryAction = 'done' | 'skip' | 'snooze' export type HistoryAction = 'done' | 'skip' | 'snooze'
/** /**
* Источник записи: обычное напоминание (от scheduler'а) или матч (челлендж). * Источник записи: обычное напоминание (от scheduler'а), приём пищи или
* матч (челлендж).
* Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики. * Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики.
*/ */
export type HistorySource = 'reminder' | 'match' export type HistorySource = 'reminder' | 'meal' | 'match'
export type HistoryEntry = { export type HistoryEntry = {
/** ms epoch */ /** ms epoch */