fix(P0): match-history, tray/dashboard pause sync, whatsnew для upgraders

P0 #1 — Match-челленджи теперь пишутся в историю.
    HistoryEntry расширен полями `reps?`, `name?`, `source?` (snapshot
    planned-reps + name на момент записи + 'reminder'/'match').
    Новый store.markChallengeDone(challengeId, reps) пишет entry с
    exerciseId='challenge:<id>' и source='match'.
    Зарегистрирован IPC.markChallengeDone handler (раньше канал был в
    enum, но handler не подключен).
    ReminderApp.MatchSummaryView вызывает window.api.markChallengeDone
    при ✓-клике. Стрик, today_done, achievements теперь учитывают
    игровые тренировки.
    Заодно dailyReps/dailyRepsRange/totalDoneReps используют
    entry.reps как fallback — heatmap не теряет данные после удаления
    упражнения (закрывает P2 #12).

P0 #2 — Tray-пауза синхронизирована с Dashboard.
    Раньше tray держал scheduler-local `paused` boolean, который не
    отражался в settings.globalEnabled — Dashboard показывал «running»
    с тикающим таймером, хотя fires не приходили. Сейчас оба пути
    (tray и Dashboard-кнопка) меняют единственный source of truth —
    settings.globalEnabled. setPaused/isPaused/paused удалены, IPC
    pauseAll/resumeAll переписаны на updateSettings.

P0 #3 — Whats-new покажется существующим пользователям при апгрейде.
    Раньше для всех undefined lastSeenVersion (включая обновляющихся
    с v0.5.5) делали silent-save без модалки — никто бы не увидел
    v0.5.6 changelog. Сейчас: если есть Exercise с lastDoneAt → это
    обновляющийся пользователь, показываем заметки текущей версии;
    если нет — новичок, silent.
This commit is contained in:
AnRil
2026-05-22 14:49:29 +07:00
parent 77007636df
commit 17df87b3aa
10 changed files with 138 additions and 43 deletions

View File

@@ -21,6 +21,7 @@ import {
getState,
getStateForRenderer,
importState,
markChallengeDone,
markDone,
setGameEnabled,
skip,
@@ -31,7 +32,7 @@ import {
} from './store'
import { broadcastState } from './state-actions'
import { setAutostart, isAutostartEnabled } from './autostart'
import { setPaused, forceCheck } from './scheduler'
import { forceCheck } from './scheduler'
import { hideReminderWindow, getMainWindow } from './windows'
import { refreshMenu } from './tray'
import {
@@ -152,17 +153,22 @@ export function registerIpc(): void {
}
const settings = updateSettings(merged)
broadcastState()
// Language change reflects in the tray menu next time it's opened.
if (patch.language !== undefined) refreshMenu()
// Tray-menu label «Пауза/Возобновить» зависит от globalEnabled — рефреш.
// А также language change.
if (patch.language !== undefined || patch.globalEnabled !== undefined) {
refreshMenu()
}
return settings
})
ipcMain.handle(IPC.pauseAll, () => {
setPaused(true)
updateSettings({ globalEnabled: false })
broadcastState()
refreshMenu()
})
ipcMain.handle(IPC.resumeAll, () => {
setPaused(false)
updateSettings({ globalEnabled: true })
broadcastState()
forceCheck()
refreshMenu()
})
@@ -282,6 +288,18 @@ export function registerIpc(): void {
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
ipcMain.handle(
IPC.markChallengeDone,
(_e, idRaw: unknown, repsRaw: unknown) => {
const id = validateId(idRaw)
const reps = validateActualReps(repsRaw)
if (!id || reps === undefined || reps <= 0) return false
markChallengeDone(id, reps)
broadcastState()
return true
}
)
// Dev helper: simulate a match end with given stats. NEVER registered in
// packaged builds — a compromised renderer (XSS, malicious npm dep) could
// otherwise fabricate arbitrary match-end events at will.

View File

@@ -36,10 +36,8 @@ const CHECK_MS = 5000
let tickHandle: NodeJS.Timeout | null = null
let powerListenersArmed = false
let lastCheckAt = 0
let paused = false
function checkDueExercises(): void {
if (paused) return
const settings = getSettings()
if (!settings.globalEnabled) return
@@ -148,14 +146,6 @@ export function stopScheduler(): void {
}
}
export function setPaused(value: boolean): void {
paused = value
}
export function isPaused(): boolean {
return paused
}
export function forceCheck(): void {
lastCheckAt = 0
tick()

View File

@@ -199,20 +199,32 @@ function load(): PersistedState {
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,
actualReps?: number
opts: AppendOpts = {}
): void {
const state = getState()
if (!state.history) state.history = []
const entry: HistoryEntry = { ts: Date.now(), exerciseId, action }
if (actualReps !== undefined) entry.actualReps = actualReps
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))
}
// Caller schedules the write; appendHistory itself is internal.
}
export function getHistory(sinceMs?: number): HistoryEntry[] {
@@ -425,7 +437,12 @@ export function markDone(
if (!ex) return undefined
ex.lastDoneAt = Date.now()
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'done', actualReps)
appendHistory(id, 'done', {
actualReps,
reps: ex.reps,
name: ex.name,
source: 'reminder'
})
scheduleWrite()
return ex
}
@@ -435,7 +452,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.nextFireAt = Date.now() + minutes * 60_000
appendHistory(id, 'snooze')
appendHistory(id, 'snooze', { reps: ex.reps, name: ex.name })
scheduleWrite()
return ex
}
@@ -445,11 +462,28 @@ export function skip(id: string): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'skip')
appendHistory(id, 'skip', { reps: ex.reps, name: ex.name })
scheduleWrite()
return ex
}
/**
* Записать выполнение челленджа из 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)

View File

@@ -1,9 +1,9 @@
import { Tray, Menu, nativeImage, app } from 'electron'
import { join } from 'node:path'
import { showMainWindow } from './windows'
import { isPaused, setPaused, forceCheck } from './scheduler'
import { snoozeAll } from './state-actions'
import { getSettings } from './store'
import { forceCheck } from './scheduler'
import { broadcastState, snoozeAll } from './state-actions'
import { getSettings, updateSettings } from './store'
import type { Language } from '@shared/types'
let tray: Tray | null = null
@@ -69,16 +69,21 @@ export function createTray(): Tray {
export function refreshMenu(): void {
if (!tray) return
const paused = isPaused()
// Single source of truth — settings.globalEnabled. Раньше tray держал
// отдельный scheduler-local `paused` flag, который не синхронизировался
// с Dashboard'ом (там кнопка читает globalEnabled). Теперь оба пути
// правят одно поле.
const paused = !getSettings().globalEnabled
const menu = Menu.buildFromTemplate([
{ label: trayLabel('open'), click: () => showMainWindow() },
{ type: 'separator' },
{
label: paused ? trayLabel('resume') : trayLabel('pause'),
click: () => {
setPaused(!paused)
updateSettings({ globalEnabled: paused }) // toggle
broadcastState() // чтобы Dashboard перерисовал кнопку сразу
refreshMenu()
if (!paused) forceCheck()
if (paused) forceCheck() // resuming — догнать пропущенные fires
}
},
{