Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5f6952ad2 | ||
|
|
ffe80b62c4 | ||
|
|
ad000c722e |
1404
package-lock.json
generated
1404
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "laude",
|
"name": "laude",
|
||||||
"version": "0.6.0",
|
"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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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()
|
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[]> {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
if (ex) {
|
||||||
broadcastState()
|
broadcastState()
|
||||||
broadcastHistoryChanged()
|
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)
|
||||||
|
if (ex) {
|
||||||
broadcastState()
|
broadcastState()
|
||||||
broadcastHistoryChanged()
|
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)
|
||||||
|
if (ex) {
|
||||||
broadcastState()
|
broadcastState()
|
||||||
broadcastHistoryChanged()
|
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)
|
||||||
|
if (m) {
|
||||||
broadcastState()
|
broadcastState()
|
||||||
|
broadcastHistoryChanged()
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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' })
|
|
||||||
try {
|
|
||||||
const { isMeetingActive } = await load()
|
const { isMeetingActive } = await load()
|
||||||
expect(await isMeetingActive()).toBe(false)
|
expect(await isMeetingActive()).toBe(false)
|
||||||
expect(h.calls).toBe(0)
|
expect(h.calls).toBe(0)
|
||||||
} finally {
|
|
||||||
Object.defineProperty(process, 'platform', { value: original })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 должен спросить
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<motion.div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<MealRow
|
||||||
meal={m}
|
meal={m}
|
||||||
last={i === disabled.length - 1}
|
last={i === ordered.length - 1}
|
||||||
meta={`${m.time} · ${daysLabel(m.days, t)}`}
|
meta={`${m.time} · ${daysLabel(m.days, t)}`}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditing(m)
|
setEditing(m)
|
||||||
setEditorOpen(true)
|
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(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 не
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user