diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b013c57..619f50c 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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. diff --git a/src/main/scheduler.ts b/src/main/scheduler.ts index 0f74cf3..ffdce84 100644 --- a/src/main/scheduler.ts +++ b/src/main/scheduler.ts @@ -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() diff --git a/src/main/store.ts b/src/main/store.ts index 35cdd94..d2c0c61 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -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:'. + */ +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) diff --git a/src/main/tray.ts b/src/main/tray.ts index 0bf0417..818071e 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -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 } }, { diff --git a/src/preload/index.ts b/src/preload/index.ts index 10874cc..57e5a3e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -83,6 +83,8 @@ const api = { ipcRenderer.invoke(IPC.deleteChallenge, id), toggleChallenge: (id: string, enabled: boolean): Promise => ipcRenderer.invoke(IPC.toggleChallenge, id, enabled), + markChallengeDone: (id: string, reps: number): Promise => + ipcRenderer.invoke(IPC.markChallengeDone, id, reps), closeMatchSummary: (): Promise => ipcRenderer.invoke(IPC.closeMatchSummary), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 907da1f..ce44f7b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -36,19 +36,31 @@ export default function App(): JSX.Element { } }, []) - // После хидрации сверяем текущую версию приложения с lastSeenVersion. - // Если первая хидрация и lastSeenVersion ещё не записан — это либо - // первый запуск, либо обновление со старой версии (где поля не было) — - // в любом случае пишем текущую версию и НЕ показываем модалку (мы не - // хотим бить нового пользователя CHANGELOG'ом). - // Если lastSeenVersion есть и не совпадает с current → показываем. + // Различаем три кейса по `lastSeenVersion`: + // 1) есть и !== current → классический update path, показываем + // пропущенные заметки. + // 2) есть и === current → ничего не делаем. + // 3) нет (undefined) → это ИЛИ первый запуск нового пользователя, + // ИЛИ существующий пользователь, который апгрейдится с версии, + // где поля ещё не было (всё < 0.5.6). + // Разрешаем эту неоднозначность через proxy «уже пользовался + // приложением» — хотя бы одно упражнение имеет `lastDoneAt`. + // Новичкам тихо записываем; обновляющимся — показываем заметки + // текущей версии, чтобы они узнали про новые фичи. useEffect(() => { if (!hydrated || !settings) return + const exercises = useAppStore.getState().state?.exercises ?? [] + const isExistingUser = exercises.some((e) => e.lastDoneAt !== undefined) void window.api.getAppVersion().then((current) => { const last = settings.lastSeenVersion if (!last) { - // Первая запись — сохраняем тихо. - window.api.updateSettings({ lastSeenVersion: current }) + if (isExistingUser) { + // Обновляющийся — показываем заметки текущей версии. + setWhatsNew({ open: true, versions: [current] }) + } else { + // Новый — тихо записываем, не отвлекаем. + window.api.updateSettings({ lastSeenVersion: current }) + } return } if (last !== current) { @@ -56,7 +68,6 @@ export default function App(): JSX.Element { if (versions.length > 0) { setWhatsNew({ open: true, versions }) } else { - // Версии есть, заметок нет — просто обновляем. window.api.updateSettings({ lastSeenVersion: current }) } } diff --git a/src/renderer/src/ReminderApp.tsx b/src/renderer/src/ReminderApp.tsx index ca0b3ea..d040bc7 100644 --- a/src/renderer/src/ReminderApp.tsx +++ b/src/renderer/src/ReminderApp.tsx @@ -113,9 +113,14 @@ export default function ReminderApp(): JSX.Element { summary={mode.summary} done={mode.done} lang={lang} - onMarkDone={(id) => - // Functional update so a second rapid click can't race against a stale - // `mode.done` captured in this closure. + onMarkDone={(id) => { + // 1) IPC: записываем в историю (раньше делали только локальный set, + // из-за чего матч-челленджи не считались в стрик/achievements). + const result = mode.summary.results.find((r) => r.challengeId === id) + if (result && result.reps > 0) { + void window.api.markChallengeDone(id, result.reps) + } + // 2) Functional update: rapid-click race-safe. setMode((m) => m.kind === 'match' ? { @@ -125,7 +130,7 @@ export default function ReminderApp(): JSX.Element { } : m ) - } + }} onClose={close} /> ) diff --git a/src/renderer/src/lib/achievements.ts b/src/renderer/src/lib/achievements.ts index 24779f9..99f4a78 100644 --- a/src/renderer/src/lib/achievements.ts +++ b/src/renderer/src/lib/achievements.ts @@ -42,7 +42,7 @@ function totalDoneReps( let sum = 0 for (const e of history) { if (e.action !== 'done') continue - sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 + sum += e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0 } return sum } diff --git a/src/renderer/src/lib/history.ts b/src/renderer/src/lib/history.ts index 1dbfd64..38d55e0 100644 --- a/src/renderer/src/lib/history.ts +++ b/src/renderer/src/lib/history.ts @@ -30,6 +30,15 @@ function shiftDays(base: Date, dayDelta: number): Date { * Reps logged on a given local day. Uses `actualReps` if present, otherwise * looks up exercise's planned `reps`. */ +/** + * Сколько reps пользователь сделал в заданный day-key. Источники в порядке + * приоритета: + * 1. entry.actualReps — что фактически сделал (stepper в reminder'е) + * 2. entry.reps — snapshot planned-reps на момент записи (выживает после + * удаления упражнения и работает для match-челленджей у которых нет + * связанного Exercise) + * 3. byId.get(exerciseId).reps — fallback для старых entries без snapshot'а + */ export function dailyReps( entries: HistoryEntry[], exercises: Exercise[], @@ -40,7 +49,7 @@ export function dailyReps( for (const e of entries) { if (e.action !== 'done') continue if (dayKey(e.ts) !== dayKeyStr) continue - sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 + sum += e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0 } return sum } @@ -72,7 +81,7 @@ export function dailyRepsRange( const k = dayKey(e.ts) const bucket = buckets.get(k) if (!bucket) continue - const reps = e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 + const reps = e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0 bucket.reps += reps } diff --git a/src/shared/types.ts b/src/shared/types.ts index ec75552..098a2b3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -111,13 +111,34 @@ export type PersistedState = AppState & { export type HistoryAction = 'done' | 'skip' | 'snooze' +/** + * Источник записи: обычное напоминание (от scheduler'а) или матч (челлендж). + * Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики. + */ +export type HistorySource = 'reminder' | 'match' + export type HistoryEntry = { /** ms epoch */ ts: number + /** + * Для обычных напоминаний — Exercise.id. Для challenge'ей — `challenge:` + * (синтетический ключ; renderer'у не нужно искать exercise по нему). + */ exerciseId: string action: HistoryAction /** When user did less than planned. Only meaningful for `done`. */ actualReps?: number + /** + * Snapshot повторений на момент записи. Гарантирует, что после удаления + * упражнения история не теряет «сколько было сделано» (раньше lookup + * `byId.get(exerciseId).reps` возвращал undefined → heatmap показывал 0). + * Для match-челленджей — фактическое число повторов из match summary. + */ + reps?: number + /** Snapshot названия упражнения/челленджа — для будущего log-view. */ + name?: string + /** undefined = reminder (для обратной совместимости со старыми entries). */ + source?: HistorySource } export type Tick = {