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:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user