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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user