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,
|
getState,
|
||||||
getStateForRenderer,
|
getStateForRenderer,
|
||||||
importState,
|
importState,
|
||||||
|
markChallengeDone,
|
||||||
markDone,
|
markDone,
|
||||||
setGameEnabled,
|
setGameEnabled,
|
||||||
skip,
|
skip,
|
||||||
@@ -31,7 +32,7 @@ import {
|
|||||||
} from './store'
|
} from './store'
|
||||||
import { broadcastState } from './state-actions'
|
import { broadcastState } from './state-actions'
|
||||||
import { setAutostart, isAutostartEnabled } from './autostart'
|
import { setAutostart, isAutostartEnabled } from './autostart'
|
||||||
import { setPaused, forceCheck } from './scheduler'
|
import { forceCheck } from './scheduler'
|
||||||
import { hideReminderWindow, getMainWindow } from './windows'
|
import { hideReminderWindow, getMainWindow } from './windows'
|
||||||
import { refreshMenu } from './tray'
|
import { refreshMenu } from './tray'
|
||||||
import {
|
import {
|
||||||
@@ -152,17 +153,22 @@ export function registerIpc(): void {
|
|||||||
}
|
}
|
||||||
const settings = updateSettings(merged)
|
const settings = updateSettings(merged)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
// Language change reflects in the tray menu next time it's opened.
|
// Tray-menu label «Пауза/Возобновить» зависит от globalEnabled — рефреш.
|
||||||
if (patch.language !== undefined) refreshMenu()
|
// А также language change.
|
||||||
|
if (patch.language !== undefined || patch.globalEnabled !== undefined) {
|
||||||
|
refreshMenu()
|
||||||
|
}
|
||||||
return settings
|
return settings
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC.pauseAll, () => {
|
ipcMain.handle(IPC.pauseAll, () => {
|
||||||
setPaused(true)
|
updateSettings({ globalEnabled: false })
|
||||||
|
broadcastState()
|
||||||
refreshMenu()
|
refreshMenu()
|
||||||
})
|
})
|
||||||
ipcMain.handle(IPC.resumeAll, () => {
|
ipcMain.handle(IPC.resumeAll, () => {
|
||||||
setPaused(false)
|
updateSettings({ globalEnabled: true })
|
||||||
|
broadcastState()
|
||||||
forceCheck()
|
forceCheck()
|
||||||
refreshMenu()
|
refreshMenu()
|
||||||
})
|
})
|
||||||
@@ -282,6 +288,18 @@ export function registerIpc(): void {
|
|||||||
|
|
||||||
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
|
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
|
// Dev helper: simulate a match end with given stats. NEVER registered in
|
||||||
// packaged builds — a compromised renderer (XSS, malicious npm dep) could
|
// packaged builds — a compromised renderer (XSS, malicious npm dep) could
|
||||||
// otherwise fabricate arbitrary match-end events at will.
|
// otherwise fabricate arbitrary match-end events at will.
|
||||||
|
|||||||
@@ -36,10 +36,8 @@ const CHECK_MS = 5000
|
|||||||
let tickHandle: NodeJS.Timeout | null = null
|
let tickHandle: NodeJS.Timeout | null = null
|
||||||
let powerListenersArmed = false
|
let powerListenersArmed = false
|
||||||
let lastCheckAt = 0
|
let lastCheckAt = 0
|
||||||
let paused = false
|
|
||||||
|
|
||||||
function checkDueExercises(): void {
|
function checkDueExercises(): void {
|
||||||
if (paused) return
|
|
||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
if (!settings.globalEnabled) return
|
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 {
|
export function forceCheck(): void {
|
||||||
lastCheckAt = 0
|
lastCheckAt = 0
|
||||||
tick()
|
tick()
|
||||||
|
|||||||
@@ -199,20 +199,32 @@ function load(): PersistedState {
|
|||||||
return coerce(runMigrations(parsed))
|
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(
|
function appendHistory(
|
||||||
exerciseId: string,
|
exerciseId: string,
|
||||||
action: HistoryAction,
|
action: HistoryAction,
|
||||||
actualReps?: number
|
opts: AppendOpts = {}
|
||||||
): void {
|
): void {
|
||||||
const state = getState()
|
const state = getState()
|
||||||
if (!state.history) state.history = []
|
if (!state.history) state.history = []
|
||||||
const entry: HistoryEntry = { ts: Date.now(), exerciseId, action }
|
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)
|
state.history.push(entry)
|
||||||
if (state.history.length > HISTORY_MAX) {
|
if (state.history.length > HISTORY_MAX) {
|
||||||
state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9))
|
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[] {
|
export function getHistory(sinceMs?: number): HistoryEntry[] {
|
||||||
@@ -425,7 +437,12 @@ export function markDone(
|
|||||||
if (!ex) return undefined
|
if (!ex) return undefined
|
||||||
ex.lastDoneAt = Date.now()
|
ex.lastDoneAt = Date.now()
|
||||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
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()
|
scheduleWrite()
|
||||||
return ex
|
return ex
|
||||||
}
|
}
|
||||||
@@ -435,7 +452,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
|
|||||||
const ex = state.exercises.find((e) => e.id === id)
|
const ex = state.exercises.find((e) => e.id === id)
|
||||||
if (!ex) return undefined
|
if (!ex) return undefined
|
||||||
ex.nextFireAt = Date.now() + minutes * 60_000
|
ex.nextFireAt = Date.now() + minutes * 60_000
|
||||||
appendHistory(id, 'snooze')
|
appendHistory(id, 'snooze', { reps: ex.reps, name: ex.name })
|
||||||
scheduleWrite()
|
scheduleWrite()
|
||||||
return ex
|
return ex
|
||||||
}
|
}
|
||||||
@@ -445,11 +462,28 @@ export function skip(id: string): Exercise | undefined {
|
|||||||
const ex = state.exercises.find((e) => e.id === id)
|
const ex = state.exercises.find((e) => e.id === id)
|
||||||
if (!ex) return undefined
|
if (!ex) return undefined
|
||||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||||
appendHistory(id, 'skip')
|
appendHistory(id, 'skip', { reps: ex.reps, name: ex.name })
|
||||||
scheduleWrite()
|
scheduleWrite()
|
||||||
return ex
|
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 {
|
export function flushNow(): void {
|
||||||
if (pendingWrite) {
|
if (pendingWrite) {
|
||||||
clearTimeout(pendingWrite)
|
clearTimeout(pendingWrite)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Tray, Menu, nativeImage, app } from 'electron'
|
import { Tray, Menu, nativeImage, app } from 'electron'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { showMainWindow } from './windows'
|
import { showMainWindow } from './windows'
|
||||||
import { isPaused, setPaused, forceCheck } from './scheduler'
|
import { forceCheck } from './scheduler'
|
||||||
import { snoozeAll } from './state-actions'
|
import { broadcastState, snoozeAll } from './state-actions'
|
||||||
import { getSettings } from './store'
|
import { getSettings, updateSettings } from './store'
|
||||||
import type { Language } from '@shared/types'
|
import type { Language } from '@shared/types'
|
||||||
|
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
@@ -69,16 +69,21 @@ export function createTray(): Tray {
|
|||||||
|
|
||||||
export function refreshMenu(): void {
|
export function refreshMenu(): void {
|
||||||
if (!tray) return
|
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([
|
const menu = Menu.buildFromTemplate([
|
||||||
{ label: trayLabel('open'), click: () => showMainWindow() },
|
{ label: trayLabel('open'), click: () => showMainWindow() },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: paused ? trayLabel('resume') : trayLabel('pause'),
|
label: paused ? trayLabel('resume') : trayLabel('pause'),
|
||||||
click: () => {
|
click: () => {
|
||||||
setPaused(!paused)
|
updateSettings({ globalEnabled: paused }) // toggle
|
||||||
|
broadcastState() // чтобы Dashboard перерисовал кнопку сразу
|
||||||
refreshMenu()
|
refreshMenu()
|
||||||
if (!paused) forceCheck()
|
if (paused) forceCheck() // resuming — догнать пропущенные fires
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ const api = {
|
|||||||
ipcRenderer.invoke(IPC.deleteChallenge, id),
|
ipcRenderer.invoke(IPC.deleteChallenge, id),
|
||||||
toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> =>
|
toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> =>
|
||||||
ipcRenderer.invoke(IPC.toggleChallenge, id, enabled),
|
ipcRenderer.invoke(IPC.toggleChallenge, id, enabled),
|
||||||
|
markChallengeDone: (id: string, reps: number): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC.markChallengeDone, id, reps),
|
||||||
|
|
||||||
closeMatchSummary: (): Promise<void> =>
|
closeMatchSummary: (): Promise<void> =>
|
||||||
ipcRenderer.invoke(IPC.closeMatchSummary),
|
ipcRenderer.invoke(IPC.closeMatchSummary),
|
||||||
|
|||||||
@@ -36,19 +36,31 @@ export default function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// После хидрации сверяем текущую версию приложения с lastSeenVersion.
|
// Различаем три кейса по `lastSeenVersion`:
|
||||||
// Если первая хидрация и lastSeenVersion ещё не записан — это либо
|
// 1) есть и !== current → классический update path, показываем
|
||||||
// первый запуск, либо обновление со старой версии (где поля не было) —
|
// пропущенные заметки.
|
||||||
// в любом случае пишем текущую версию и НЕ показываем модалку (мы не
|
// 2) есть и === current → ничего не делаем.
|
||||||
// хотим бить нового пользователя CHANGELOG'ом).
|
// 3) нет (undefined) → это ИЛИ первый запуск нового пользователя,
|
||||||
// Если lastSeenVersion есть и не совпадает с current → показываем.
|
// ИЛИ существующий пользователь, который апгрейдится с версии,
|
||||||
|
// где поля ещё не было (всё < 0.5.6).
|
||||||
|
// Разрешаем эту неоднозначность через proxy «уже пользовался
|
||||||
|
// приложением» — хотя бы одно упражнение имеет `lastDoneAt`.
|
||||||
|
// Новичкам тихо записываем; обновляющимся — показываем заметки
|
||||||
|
// текущей версии, чтобы они узнали про новые фичи.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydrated || !settings) return
|
if (!hydrated || !settings) return
|
||||||
|
const exercises = useAppStore.getState().state?.exercises ?? []
|
||||||
|
const isExistingUser = exercises.some((e) => e.lastDoneAt !== undefined)
|
||||||
void window.api.getAppVersion().then((current) => {
|
void window.api.getAppVersion().then((current) => {
|
||||||
const last = settings.lastSeenVersion
|
const last = settings.lastSeenVersion
|
||||||
if (!last) {
|
if (!last) {
|
||||||
// Первая запись — сохраняем тихо.
|
if (isExistingUser) {
|
||||||
|
// Обновляющийся — показываем заметки текущей версии.
|
||||||
|
setWhatsNew({ open: true, versions: [current] })
|
||||||
|
} else {
|
||||||
|
// Новый — тихо записываем, не отвлекаем.
|
||||||
window.api.updateSettings({ lastSeenVersion: current })
|
window.api.updateSettings({ lastSeenVersion: current })
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (last !== current) {
|
if (last !== current) {
|
||||||
@@ -56,7 +68,6 @@ export default function App(): JSX.Element {
|
|||||||
if (versions.length > 0) {
|
if (versions.length > 0) {
|
||||||
setWhatsNew({ open: true, versions })
|
setWhatsNew({ open: true, versions })
|
||||||
} else {
|
} else {
|
||||||
// Версии есть, заметок нет — просто обновляем.
|
|
||||||
window.api.updateSettings({ lastSeenVersion: current })
|
window.api.updateSettings({ lastSeenVersion: current })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,9 +113,14 @@ export default function ReminderApp(): JSX.Element {
|
|||||||
summary={mode.summary}
|
summary={mode.summary}
|
||||||
done={mode.done}
|
done={mode.done}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onMarkDone={(id) =>
|
onMarkDone={(id) => {
|
||||||
// Functional update so a second rapid click can't race against a stale
|
// 1) IPC: записываем в историю (раньше делали только локальный set,
|
||||||
// `mode.done` captured in this closure.
|
// из-за чего матч-челленджи не считались в стрик/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) =>
|
setMode((m) =>
|
||||||
m.kind === 'match'
|
m.kind === 'match'
|
||||||
? {
|
? {
|
||||||
@@ -125,7 +130,7 @@ export default function ReminderApp(): JSX.Element {
|
|||||||
}
|
}
|
||||||
: m
|
: m
|
||||||
)
|
)
|
||||||
}
|
}}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function totalDoneReps(
|
|||||||
let sum = 0
|
let sum = 0
|
||||||
for (const e of history) {
|
for (const e of history) {
|
||||||
if (e.action !== 'done') continue
|
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
|
return sum
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ function shiftDays(base: Date, dayDelta: number): Date {
|
|||||||
* Reps logged on a given local day. Uses `actualReps` if present, otherwise
|
* Reps logged on a given local day. Uses `actualReps` if present, otherwise
|
||||||
* looks up exercise's planned `reps`.
|
* 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(
|
export function dailyReps(
|
||||||
entries: HistoryEntry[],
|
entries: HistoryEntry[],
|
||||||
exercises: Exercise[],
|
exercises: Exercise[],
|
||||||
@@ -40,7 +49,7 @@ export function dailyReps(
|
|||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.action !== 'done') continue
|
if (e.action !== 'done') continue
|
||||||
if (dayKey(e.ts) !== dayKeyStr) 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
|
return sum
|
||||||
}
|
}
|
||||||
@@ -72,7 +81,7 @@ export function dailyRepsRange(
|
|||||||
const k = dayKey(e.ts)
|
const k = dayKey(e.ts)
|
||||||
const bucket = buckets.get(k)
|
const bucket = buckets.get(k)
|
||||||
if (!bucket) continue
|
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
|
bucket.reps += reps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,13 +111,34 @@ export type PersistedState = AppState & {
|
|||||||
|
|
||||||
export type HistoryAction = 'done' | 'skip' | 'snooze'
|
export type HistoryAction = 'done' | 'skip' | 'snooze'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Источник записи: обычное напоминание (от scheduler'а) или матч (челлендж).
|
||||||
|
* Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики.
|
||||||
|
*/
|
||||||
|
export type HistorySource = 'reminder' | 'match'
|
||||||
|
|
||||||
export type HistoryEntry = {
|
export type HistoryEntry = {
|
||||||
/** ms epoch */
|
/** ms epoch */
|
||||||
ts: number
|
ts: number
|
||||||
|
/**
|
||||||
|
* Для обычных напоминаний — Exercise.id. Для challenge'ей — `challenge:<id>`
|
||||||
|
* (синтетический ключ; renderer'у не нужно искать exercise по нему).
|
||||||
|
*/
|
||||||
exerciseId: string
|
exerciseId: string
|
||||||
action: HistoryAction
|
action: HistoryAction
|
||||||
/** When user did less than planned. Only meaningful for `done`. */
|
/** When user did less than planned. Only meaningful for `done`. */
|
||||||
actualReps?: number
|
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 = {
|
export type Tick = {
|
||||||
|
|||||||
Reference in New Issue
Block a user