fix+test: автономные правки после ревью v0.5.7

Bug — Heatmap/streak/achievements не обновлялись после markDone/
    markChallengeDone. Регресс из Sprint C (история выделена из
    state-broadcast). Корень: store мутирует Exercise.lastDoneAt
    in-place → state.exercises ref не меняется → useEffect([exercises])
    не fires → Dashboard не перетягивает history.
    Фикс: новый event IPC.evtHistoryChanged + broadcastHistoryChanged().
    Триггерится после markDone/snooze/skip/markChallengeDone/
    clearHistory/import. Dashboard.useEffect подписывается через
    onHistoryChanged.

Settings → AboutCard теперь показывает текущую версию приложения
    (раньше была только кнопка «Что нового»). Версия через
    IPC.getAppVersion.

Tests:
    +6 для repsDoneTodayForExercise — match-challenges, snapshot,
       deleted-exercise fallback, ignore skip/snooze.
    +2 для dailyReps с новыми snapshot-полями (match-challenges
       и deleted exercises).
    +6 для unseenVersions + RELEASE_NOTES контракт.
    +7 для adjustNextFireAt (адаптивный шедулер): малая история,
       плохой/хороший час, MAX_SHIFT_HOURS, фильтр по упражнению,
       30-day window.
    Итого 135 → 159 (+24).

Грепнул src/ на стейл-references к removed setPaused/isPaused/
    `let paused` — чисто. Sprint C-D refactor завершён без residue.
This commit is contained in:
AnRil
2026-05-22 23:22:34 +07:00
parent c742417e82
commit 0c813c3ac8
10 changed files with 371 additions and 11 deletions

View File

@@ -174,6 +174,8 @@ export const ru: Dict = {
'settings.data.import.ok': 'Восстановлено',
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
'settings.section.about': 'О приложении',
'settings.version.label': 'Версия',
'settings.version.hint': 'Текущая установленная версия приложения.',
'settings.whatsnew.label': 'Что нового',
'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.',
'settings.whatsnew.btn': 'Открыть',
@@ -503,6 +505,8 @@ export const en: Dict = {
'settings.data.import.ok': 'Restored',
'settings.data.import.err': "Couldn't read the file — not our backup?",
'settings.section.about': 'About',
'settings.version.label': 'Version',
'settings.version.hint': 'Currently installed app version.',
'settings.whatsnew.label': "What's new",
'settings.whatsnew.hint': 'See the latest release notes.',
'settings.whatsnew.btn': 'Open',

View File

@@ -5,7 +5,8 @@ import {
dailyReps,
dayKey,
dailyRepsRange,
plannedRepsToday
plannedRepsToday,
repsDoneTodayForExercise
} from './history'
const MS_DAY = 24 * 60 * 60 * 1000
@@ -197,3 +198,105 @@ describe('currentStreak edge cases', () => {
expect(currentStreak(hist)).toBe(1)
})
})
describe('repsDoneTodayForExercise', () => {
const today = Date.now()
const exercise = ex('a', 10)
const other = ex('b', 5)
it('returns 0 if no entries', () => {
expect(repsDoneTodayForExercise([], exercise)).toBe(0)
})
it('counts only entries for this exercise today', () => {
const hist = [
entry('a', today),
entry('a', today),
entry('b', today), // other exercise — игнорируем
entry('a', today - 2 * 24 * 60 * 60 * 1000) // позавчера — игнорируем
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(20)
expect(repsDoneTodayForExercise(hist, other)).toBe(5)
})
it('uses actualReps when set', () => {
const hist = [entry('a', today, 'done', 7), entry('a', today)]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(7 + 10)
})
it('ignores skip / snooze entries', () => {
const hist = [
entry('a', today, 'skip'),
entry('a', today, 'snooze'),
entry('a', today)
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(10)
})
it('prefers entry.reps snapshot over exercise.reps (historical accuracy)', () => {
// Контракт: entry.reps это «сколько было запланировано на момент
// записи». Если пользователь раньше делал 15 раз приседаний, потом
// изменил планку на 10 — history должна показывать 15 для старых
// entries, не 10. Это правильнее для аналитики «что я тогда делал».
const histWithSnapshot: HistoryEntry[] = [
{ exerciseId: 'a', ts: today, action: 'done', reps: 15 }
]
expect(repsDoneTodayForExercise(histWithSnapshot, exercise)).toBe(15)
})
it('falls back to exercise.reps when entry has no snapshot', () => {
// Старые entries (до Sprint #1 / v0.5.7) не имеют entry.reps.
// Должны fall'back'нуться на текущий exercise.reps.
const histOldEntry: HistoryEntry[] = [
{ exerciseId: 'a', ts: today, action: 'done' }
]
expect(repsDoneTodayForExercise(histOldEntry, exercise)).toBe(10)
})
it('survives match challenges (exerciseId=challenge:<id>)', () => {
// Match-челлендж не привязан к exercise — repsDoneTodayForExercise
// его игнорирует (это не reps для этого упражнения).
const hist: HistoryEntry[] = [
{
exerciseId: 'challenge:abc',
ts: today,
action: 'done',
actualReps: 30,
reps: 30,
source: 'match'
}
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(0)
})
})
describe('dailyReps with new entry.reps snapshot', () => {
const today = Date.now()
const exs = [ex('a', 10)]
it('counts match-challenge entries via entry.reps snapshot', () => {
// У match-челленджа exerciseId='challenge:<id>', byId.get вернёт
// undefined. entry.reps snapshot — единственный источник.
const hist: HistoryEntry[] = [
{
exerciseId: 'challenge:abc',
ts: today,
action: 'done',
actualReps: 30,
reps: 30,
source: 'match'
},
entry('a', today) // обычная entry — 10 reps через byId
]
expect(dailyReps(hist, exs, dayKey(today))).toBe(40)
})
it('survives deleted exercise via entry.reps snapshot', () => {
// Упражнение 'gone' удалено, но entry.reps=8 был записан до удаления.
const hist: HistoryEntry[] = [
{ exerciseId: 'gone', ts: today, action: 'done', reps: 8 }
]
// byId.get('gone') = undefined → fallback на entry.reps=8.
expect(dailyReps(hist, exs, dayKey(today))).toBe(8)
})
})

View File

@@ -60,12 +60,20 @@ export default function Dashboard(): JSX.Element {
(g) => g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied')
)
// Local history mirror; reloaded only when exercises change (not on every
// tick or settings tweak — those don't affect history). When ticks/settings
// change we don't re-fetch.
// Local history mirror. Перетягиваем (а) на mount, (б) при изменении
// exercises (add/delete/edit — могут поменять name/icon в snapshot'ах
// для будущих entries), (в) при evtHistoryChanged — это event который
// main отправляет ПОСЛЕ любого markDone/markChallengeDone/clearHistory/
// import. Без (в) heatmap и стрик стояли на месте после markDone —
// store мутирует exercise in place, ref не меняется, useEffect не
// fire'ил.
const [history, setHistory] = useState<HistoryEntry[]>([])
useEffect(() => {
void window.api.getHistory().then(setHistory)
const refetch = (): void => {
void window.api.getHistory().then(setHistory)
}
refetch()
return window.api.onHistoryChanged(refetch)
}, [exercises])
// Meeting auto-pause indicator: подписываемся на evtMeetingChanged +

View File

@@ -191,6 +191,10 @@ export default function SettingsPage(): JSX.Element {
function AboutCard(): JSX.Element {
const { t } = useT()
const [open, setOpen] = useState(false)
const [version, setVersion] = useState<string>('')
useEffect(() => {
void window.api.getAppVersion().then(setVersion)
}, [])
// Все версии для которых у нас есть заметки, отсортированы desc.
const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => {
const pa = a.split('.').map(Number)
@@ -200,6 +204,19 @@ function AboutCard(): JSX.Element {
})
return (
<Card>
<Row>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.version.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.version.hint')}
</div>
</div>
<div className="text-[14px] font-mono-num font-semibold text-text/70">
{version ? `v${version}` : '—'}
</div>
</Row>
<Row last>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">