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

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

View File

@@ -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}
/>
)

View File

@@ -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
}

View File

@@ -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
}