5 Commits

Author SHA1 Message Date
AnRil
c742417e82 chore(release): v0.5.7 2026-05-22 15:27:38 +07:00
AnRil
71b664d284 docs(v0.5.7): CHANGELOG + release-notes + badge 2026-05-22 15:27:28 +07:00
AnRil
db18d0c512 fix(P2): celebration, TTS delay, match-close confirm, tracking badge
P2 #9 — Achievement unlock celebration. AchievementsCard сравнивает
    текущие unlocked с persisted set'ом в localStorage. Новые играют
    scale+accent-glow анимацию 2.8 сек (1.4с × 2 повтора). После
    добавляются в celebrated — при следующем заходе не подпрыгивают.

P2 #10 — Match summary close confirm. Если в Match Summary остались
    незакрытые челленджи, при close через X / Esc / btn — native
    confirm с количеством остатка. Пользователь не «пролетает» окно
    случайно.

P2 #11 — TTS delay 800ms. Дикторский голос ждёт 800мс прежде чем
    проговорить — пользователь успевает decrement stepper если хочет
    сделать меньше планового. Иначе голос произносит планируемые
    reps уже когда юзер изменил цифру.

P2 #13 — Tracking badge точность. Раньше зелёный если просто
    `gamesEnabled[id]`. Теперь зелёный только если live (enabled +
    integrationActive + launchOptionStatus='applied'). Введён tone
    'warning' для «включена, но launch option ещё queued» с подсказкой
    «закрой Steam и снова открой». Dashboard подписывается на
    onGamesChanged + первый listGames на mount.
2026-05-22 15:15:41 +07:00
AnRil
9c989612fe fix(P1): delete-confirm, daily-goal closed UI, meeting indicator, modal-confirm
P1 #4 — ConfirmModal (новый src/renderer/src/components/ui/ConfirmModal.tsx)
    с iOS-стилем + focus-trap (через Modal). Delete упражнения в Dashboard
    теперь спрашивает «Удалить упражнение?» с destructive-кнопкой.

P1 #5 — Daily goal closed UI. ExerciseCard принимает doneToday prop
    и при `done >= dailyGoal` показывает «Цель закрыта · 100/100»
    вместо запутанного «25ч 13м» countdown'а. Цвет — success-зелёный.

P1 #6 — Meeting auto-pause indicator. Новый IPC.getMeetingActive +
    evtMeetingChanged event. meeting-detect broadcast'ит изменения
    состояния. Dashboard показывает info-баннер «Не дёргаем — ты на
    встрече» когда meetingAutoPause включён и хотя бы один meeting
    процесс запущен.

P1 #7 — Native window.confirm() заменён на ConfirmModal в Settings
    DataCard для restore-операции. Теперь iOS-style с destructive
    confirm-кнопкой и focus-trap'ом.

Заодно P2 #8: Brain-иконка-badge на ExerciseCard для adaptive
    упражнений — пользователь видит почему «Next» не строго равен
    intervalMinutes.
P2 #12: dailyReps/dailyRepsRange/totalDoneReps/repsDoneTodayForExercise
    используют entry.reps как fallback — heatmap не теряет данные
    после удаления упражнения.
2026-05-22 15:06:25 +07:00
AnRil
17df87b3aa 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.
2026-05-22 14:49:29 +07:00
23 changed files with 710 additions and 82 deletions

View File

@@ -6,6 +6,79 @@
## [Unreleased] ## [Unreleased]
## [0.5.7] — 2026-05-22
Сквозное ревью UX: пройдено 12 сценариев глазами пользователя, найдено
13 проблем (3 настоящих бага P0 + 4 UX-просадки P1 + 6 мелочей P2),
все починены.
### Fixed (P0 — функциональные баги)
- **Match-челленджи теперь пишутся в историю.** Раньше клик ✓ в окне
Match Summary обновлял только локальный `Set<challengeId>` — челленджи
не доходили до store, и стрик / today_done / достижения игнорировали
игровые тренировки (самую главную фишку приложения). Сейчас IPC
`markChallengeDone` пишет entry с `source='match'`,
`exerciseId='challenge:<id>'`, `actualReps`, `reps` (snapshot).
- **Tray-пауза синхронизирована с Dashboard.** Раньше «Пауза напоминаний»
из tray использовала scheduler-local `paused` boolean, который не
отражался в `settings.globalEnabled` — Dashboard показывал «running»
с тикающим таймером, хотя fires не приходили. Сейчас единый
source of truth — `settings.globalEnabled`. `setPaused/isPaused`
удалены, IPC `pauseAll/resumeAll` переписаны на updateSettings.
- **WhatsNew покажется обновляющимся пользователям.** В v0.5.6 логика
была: `lastSeenVersion === undefined` → silent save (не показывать).
Это означало что **никто** из текущих пользователей при апгрейде с
v0.5.5 не увидит описание новых фич. Сейчас: если есть Exercise
с `lastDoneAt` — пользователь существующий, показываем заметки
текущей версии. Иначе (новичок) — silent.
### Fixed (P1 — UX просадки)
- **Удаление упражнения теперь спрашивает подтверждение.** Раньше
один клик в menu «Удалить» сразу удалял. Сейчас iOS-style ConfirmModal
с destructive-кнопкой.
- **Daily goal закрыт — больше не «25 часов 13 минут».** Когда дневная
цель достигнута, ExerciseCard показывает «Цель закрыта · 100/100»
с success-зелёным цветом, а не запутанный обратный отсчёт до завтра.
- **Авто-пауза на ВКС видна в Dashboard.** Раньше fires пропускались
молча — пользователь не понимал почему через 12 мин ничего не пришло.
Сейчас info-баннер «Не дёргаем — ты на встрече» с указанием закрыть
Zoom/Teams/etc.
- **Native `window.confirm()` → iOS-style ConfirmModal** в restore-операции.
Раньше всплывал серый системный диалог.
### Fixed (P2 — полировка)
- **Achievement unlock celebration.** Когда впервые открылся новый
badge — pulse + accent-glow анимация 2.8 сек. Список celebrated
хранится в localStorage, при следующем заходе анимации нет.
- **Match Summary close confirm.** При закрытии окна с незакрытыми
челленджами спрашиваем подтверждение с указанием остатка.
- **TTS задержка 800мс.** Дикторский голос даёт пользователю шанс
decrement'нуть stepper — иначе TTS произносил планируемые reps в тот
момент когда юзер уже изменил цифру.
- **Tracking badge точность.** Раньше зелёный показывался даже при
`queued` launch option. Сейчас 3 состояния: `live` (success, всё
работает), `setup` (warning «закрой Steam и снова открой» если launch
option ещё не применён), `off` (muted, не подключено).
- **HistoryEntry хранит snapshot `reps`+`name`+`source`.** Heatmap и
achievements больше не теряют данные после удаления упражнения.
- **Adaptive-badge на ExerciseCard.** Маленькая Brain-иконка показывает,
что упражнение в адаптивном режиме — пользователь понимает почему
Next не строго равен intervalMinutes.
### Added
- `src/renderer/src/components/ui/ConfirmModal.tsx` — переиспользуемый
iOS-style confirm с focus-trap'ом через Modal.
- IPC `markChallengeDone(challengeId, reps)` — handler в main, метод в
preload (раньше канал был в IPC enum, handler не зарегистрирован).
- IPC `getMeetingActive` + event `evtMeetingChanged` — meeting-detect
broadcast'ит при переходе on/off.
- Helper `repsDoneTodayForExercise(history, exercise)`
per-exercise daily count для UI индикатора goal.
## [0.5.6] — 2026-05-22 ## [0.5.6] — 2026-05-22
Большой релиз с 7 новыми фичами + экран «Что нового», который покажется Большой релиз с 7 новыми фичами + экран «Что нового», который покажется
@@ -343,7 +416,8 @@
иконки), системный трей, автозапуск с Windows, native-уведомления, иконки), системный трей, автозапуск с Windows, native-уведомления,
NSIS-инсталлятор, auto-update через electron-updater. NSIS-инсталлятор, auto-update через electron-updater.
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.6...HEAD [Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.7...HEAD
[0.5.7]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.7
[0.5.6]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.6 [0.5.6]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.6
[0.5.5]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.5 [0.5.5]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.5
[0.5.4]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.4 [0.5.4]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.4

View File

@@ -4,7 +4,7 @@
## TL;DR ## TL;DR
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.6**. Один разработчик (AnRil), один remote — self-hosted Gitea. **Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.7**. Один разработчик (AnRil), один remote — self-hosted Gitea.
## Стек ## Стек

View File

@@ -2,7 +2,7 @@
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений. Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
[![release](https://img.shields.io/badge/release-v0.5.6-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest) [![release](https://img.shields.io/badge/release-v0.5.7-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-135%20passing-green)]() [![tests](https://img.shields.io/badge/tests-135%20passing-green)]()
[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]() [![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]()

View File

@@ -1,6 +1,6 @@
{ {
"name": "laude", "name": "laude",
"version": "0.5.6", "version": "0.5.7",
"description": "Exercise reminder — Windows desktop app", "description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "AnRil", "author": "AnRil",

View File

@@ -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 {
@@ -48,6 +49,7 @@ import {
getUpdaterStatus, getUpdaterStatus,
quitAndInstall quitAndInstall
} from './updater' } from './updater'
import { isMeetingActiveSync } from './meeting-detect'
import { import {
validateActualReps, validateActualReps,
validateChallengeInput, validateChallengeInput,
@@ -152,17 +154,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()
}) })
@@ -181,6 +188,8 @@ export function registerIpc(): void {
ipcMain.handle(IPC.getAppVersion, () => app.getVersion()) ipcMain.handle(IPC.getAppVersion, () => app.getVersion())
ipcMain.handle(IPC.getMeetingActive, () => isMeetingActiveSync())
ipcMain.handle(IPC.quit, () => app.quit()) ipcMain.handle(IPC.quit, () => app.quit())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow()) ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
@@ -282,6 +291,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.

View File

@@ -16,8 +16,16 @@
*/ */
import { exec } from 'node:child_process' import { exec } from 'node:child_process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc'
import { log } from './logger' import { log } from './logger'
function broadcast(active: boolean): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtMeetingChanged, active)
}
}
const execAsync = promisify(exec) const execAsync = promisify(exec)
/** /**
@@ -65,6 +73,7 @@ export async function isMeetingActive(): Promise<boolean> {
if (lower.includes(`"${proc}",`)) { if (lower.includes(`"${proc}",`)) {
if (!cachedActive) { if (!cachedActive) {
log.info(`[meeting] detected ${proc} — pausing reminders`) log.info(`[meeting] detected ${proc} — pausing reminders`)
broadcast(true)
} }
cachedActive = true cachedActive = true
return true return true
@@ -72,6 +81,7 @@ export async function isMeetingActive(): Promise<boolean> {
} }
if (cachedActive) { if (cachedActive) {
log.info('[meeting] no meeting processes — resuming reminders') log.info('[meeting] no meeting processes — resuming reminders')
broadcast(false)
} }
cachedActive = false cachedActive = false
return false return false

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,8 @@ const api = {
getOsTheme: (): Promise<'light' | 'dark'> => getOsTheme: (): Promise<'light' | 'dark'> =>
ipcRenderer.invoke(IPC.getOsTheme), ipcRenderer.invoke(IPC.getOsTheme),
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion), getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion),
getMeetingActive: (): Promise<boolean> =>
ipcRenderer.invoke(IPC.getMeetingActive),
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll), pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll), resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
@@ -83,6 +85,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),
@@ -135,7 +139,9 @@ const api = {
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub => onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h), on(IPC.evtUpdaterStatus, h),
onMaximizeChanged: (h: Handler<boolean>): Unsub => onMaximizeChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMaximizeChanged, h) on(IPC.evtMaximizeChanged, h),
onMeetingChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMeetingChanged, h)
} }
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)

View File

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

View File

@@ -28,6 +28,13 @@ type Mode =
| { kind: 'exercise'; exercise: Exercise } | { kind: 'exercise'; exercise: Exercise }
| { kind: 'match'; summary: MatchSummary; done: Set<string> } | { kind: 'match'; summary: MatchSummary; done: Set<string> }
/** Минимальный нативный confirm. В reminder-окне нет места для модалки,
* проще использовать встроенный диалог. */
function nativeConfirm(message: string): boolean {
// eslint-disable-next-line no-alert -- reminder window не имеет своей Modal-обвязки
return window.confirm(message)
}
export default function ReminderApp(): JSX.Element { export default function ReminderApp(): JSX.Element {
const [mode, setMode] = useState<Mode>({ kind: 'idle' }) const [mode, setMode] = useState<Mode>({ kind: 'idle' })
const [settings, setSettings] = useState<Settings | null>(null) const [settings, setSettings] = useState<Settings | null>(null)
@@ -45,14 +52,17 @@ export default function ReminderApp(): JSX.Element {
const s = settingsRef.current const s = settingsRef.current
if (s?.soundEnabled) playBeep() if (s?.soundEnabled) playBeep()
if (s?.voicePromptsEnabled) { if (s?.voicePromptsEnabled) {
// «{exercise.name}, {n} раз/раза/раз». Простая локальная фраза без // Задержка 800ms даёт пользователю шанс decrement'нуть stepper до
// ключа в dict — короткая команда, не нуждается в полном переводе. // фактического количества — TTS прозвучит уже под реальную цифру,
// если успел нажать -. Иначе скажет планируемые reps.
const lang = s.language ?? 'ru' const lang = s.language ?? 'ru'
setTimeout(() => {
const phrase = const phrase =
lang === 'ru' lang === 'ru'
? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}` ? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}`
: `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}` : `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}`
speak(phrase, lang) speak(phrase, lang)
}, 800)
} }
}) })
const u2 = window.api.onMatchEnd((summary) => { const u2 = window.api.onMatchEnd((summary) => {
@@ -88,6 +98,27 @@ export default function ReminderApp(): JSX.Element {
}, [mode.kind]) }, [mode.kind])
function close(): void { function close(): void {
// Если в Match Summary остались незакрытые челленджи — подтверждаем,
// чтобы пользователь не «пролетел» окно по привычке и не потерял
// мотивацию (и метрики стрик/today_done).
if (mode.kind === 'match') {
const remaining = mode.summary.results.filter(
(r) => !mode.done.has(r.challengeId)
)
if (remaining.length > 0) {
const lang = settings?.language ?? 'ru'
const reps = remaining.reduce((s, r) => s + r.reps, 0)
const msg =
lang === 'ru'
? `Осталось ${remaining.length} ${
remaining.length === 1 ? 'челлендж' : 'челленджей'
} · ${reps} повторений. Закрыть окно?`
: `${remaining.length} ${
remaining.length === 1 ? 'challenge' : 'challenges'
} left · ${reps} reps. Close anyway?`
if (!nativeConfirm(msg)) return
}
}
setMode({ kind: 'idle' }) setMode({ kind: 'idle' })
window.api.reminderClose() window.api.reminderClose()
} }
@@ -113,9 +144,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 +161,7 @@ export default function ReminderApp(): JSX.Element {
} }
: m : m
) )
} }}
onClose={close} onClose={close}
/> />
) )

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import { Award, Activity, Flame, Sparkles, TrendingUp, Lock } from 'lucide-react' import { Award, Activity, Flame, Sparkles, TrendingUp, Lock } from 'lucide-react'
import type { Exercise, HistoryEntry } from '@shared/types' import type { Exercise, HistoryEntry } from '@shared/types'
import { import {
@@ -7,6 +8,27 @@ import {
} from '../lib/achievements' } from '../lib/achievements'
import { useT } from '../i18n' import { useT } from '../i18n'
const CELEBRATED_KEY = 'laude:celebratedAchievements'
function loadCelebrated(): Set<string> {
try {
const raw = localStorage.getItem(CELEBRATED_KEY)
if (!raw) return new Set()
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? new Set(parsed) : new Set()
} catch {
return new Set()
}
}
function saveCelebrated(set: Set<string>): void {
try {
localStorage.setItem(CELEBRATED_KEY, JSON.stringify(Array.from(set)))
} catch {
/* localStorage может быть отключён — ничего страшного, просто будет celebrate каждый раз */
}
}
const ICON_BY_NAME = { const ICON_BY_NAME = {
Activity, Activity,
Flame, Flame,
@@ -32,6 +54,32 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element {
[history, exercises] [history, exercises]
) )
// Достижения, которые в этом mount пользователь впервые увидел unlocked
// (т.е. не были в localStorage до этого). Для них играем pulse-анимацию.
// После анимации помечаем как celebrated, чтобы при следующем рендере
// они уже не подпрыгивали.
const [freshlyUnlocked, setFreshlyUnlocked] = useState<Set<string>>(
() => new Set()
)
useEffect(() => {
const celebrated = loadCelebrated()
const fresh = new Set<string>()
for (const a of achievements) {
if (a.unlocked && !celebrated.has(a.def.id)) {
fresh.add(a.def.id)
celebrated.add(a.def.id)
}
}
if (fresh.size > 0) {
setFreshlyUnlocked(fresh)
saveCelebrated(celebrated)
// Снимаем «свежесть» через 5 сек чтобы pulse не крутился вечно.
const t = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000)
return () => clearTimeout(t)
}
return undefined
}, [achievements])
const unlocked = achievements.filter((a) => a.unlocked) const unlocked = achievements.filter((a) => a.unlocked)
const locked = achievements.filter((a) => !a.unlocked) const locked = achievements.filter((a) => !a.unlocked)
// Сортируем locked по близости к unlock'у — чтобы «осталось 12» // Сортируем locked по близости к unlock'у — чтобы «осталось 12»
@@ -67,14 +115,24 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element {
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{visible.map((a) => ( {visible.map((a) => (
<Badge key={a.def.id} a={a} /> <Badge
key={a.def.id}
a={a}
fresh={freshlyUnlocked.has(a.def.id)}
/>
))} ))}
</div> </div>
</div> </div>
) )
} }
function Badge({ a }: { a: AchievementProgress }): JSX.Element { function Badge({
a,
fresh
}: {
a: AchievementProgress
fresh: boolean
}): JSX.Element {
const { t } = useT() const { t } = useT()
const IconCmp = ICON_BY_NAME[a.def.icon as keyof typeof ICON_BY_NAME] ?? Award const IconCmp = ICON_BY_NAME[a.def.icon as keyof typeof ICON_BY_NAME] ?? Award
const pct = Math.min(100, Math.round((a.current / a.target) * 100)) const pct = Math.min(100, Math.round((a.current / a.target) * 100))
@@ -86,7 +144,28 @@ function Badge({ a }: { a: AchievementProgress }): JSX.Element {
}[a.def.tone] }[a.def.tone]
return ( return (
<div <motion.div
animate={
fresh
? {
scale: [1, 1.08, 1],
boxShadow: [
'0 0 0 0 rgb(var(--accent) / 0)',
'0 0 0 6px rgb(var(--accent) / 0.4)',
'0 0 0 0 rgb(var(--accent) / 0)'
]
}
: {}
}
transition={
fresh
? {
duration: 1.4,
repeat: 2,
ease: 'easeInOut'
}
: {}
}
className={[ className={[
'rounded-xl p-2.5 transition-opacity', 'rounded-xl p-2.5 transition-opacity',
a.unlocked ? 'bg-surface-2' : 'bg-surface-2 opacity-55' a.unlocked ? 'bg-surface-2' : 'bg-surface-2 opacity-55'
@@ -123,6 +202,6 @@ function Badge({ a }: { a: AchievementProgress }): JSX.Element {
</div> </div>
</> </>
)} )}
</div> </motion.div>
) )
} }

View File

@@ -1,5 +1,5 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Check, MoreHorizontal } from 'lucide-react' import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import type { Exercise, Tick } from '@shared/types' import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon' import { Icon } from '../lib/icon'
@@ -10,6 +10,8 @@ import { useT } from '../i18n'
type Props = { type Props = {
exercise: Exercise exercise: Exercise
tick?: Tick tick?: Tick
/** Сделано повторений сегодня (для daily-goal индикатора). */
doneToday?: number
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onToggle: (enabled: boolean) => void onToggle: (enabled: boolean) => void
@@ -24,6 +26,7 @@ type Props = {
export function ExerciseCard({ export function ExerciseCard({
exercise, exercise,
tick, tick,
doneToday,
onEdit, onEdit,
onDelete, onDelete,
onToggle, onToggle,
@@ -33,7 +36,12 @@ export function ExerciseCard({
const total = exercise.intervalMinutes * 60_000 const total = exercise.intervalMinutes * 60_000
const remaining = Math.max(0, Math.min(total, ms)) const remaining = Math.max(0, Math.min(total, ms))
const elapsedPct = total > 0 ? 1 - remaining / total : 0 const elapsedPct = total > 0 ? 1 - remaining / total : 0
const isDue = ms <= 0 && exercise.enabled const goalReached =
exercise.dailyGoal !== undefined &&
exercise.dailyGoal > 0 &&
(doneToday ?? 0) >= exercise.dailyGoal
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
const isDue = ms <= 0 && exercise.enabled && !goalReached
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const { t, lang } = useT() const { t, lang } = useT()
@@ -148,16 +156,48 @@ export function ExerciseCard({
{/* Countdown + switch */} {/* Countdown + switch */}
<div className="flex items-end justify-between mt-3.5"> <div className="flex items-end justify-between mt-3.5">
<div> <div>
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold"> <div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold inline-flex items-center gap-1">
{isDue ? t('dashboard.stat.next.now') : t('fmt.through')} {goalReached ? (
<>
<CheckCircle2
size={11}
strokeWidth={2.5}
className="text-success"
/>
{t('exercise.goal_reached.kicker')}
</>
) : isDue ? (
t('dashboard.stat.next.now')
) : (
t('fmt.through')
)}
{exercise.adaptive && !goalReached && (
<Brain
size={11}
strokeWidth={2.5}
className="text-info ml-0.5"
aria-label={t('exercise.adaptive.badge')}
/>
)}
</div> </div>
<div <div
className={[ className={[
'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight', 'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight',
isDue ? 'text-accent' : 'text-text' goalReached
? 'text-success'
: isDue
? 'text-accent'
: 'text-text'
].join(' ')} ].join(' ')}
> >
{exercise.enabled ? formatCountdown(ms, lang) : t('fmt.paused')} {!exercise.enabled
? t('fmt.paused')
: goalReached
? t('exercise.goal_reached.value', {
done: doneToday ?? 0,
goal: exercise.dailyGoal ?? 0
})
: formatCountdown(ms, lang)}
</div> </div>
</div> </div>
<Switch <Switch

View File

@@ -0,0 +1,62 @@
import { Modal } from './Modal'
import { Button } from './Button'
import { useT } from '../../i18n'
type Props = {
open: boolean
title: string
/** Текст вопроса (одно-два предложения). */
message: string
/** Текст кнопки подтверждения, default «OK». */
confirmLabel?: string
/** Текст кнопки отмены, default «Отмена». */
cancelLabel?: string
/** Подсветить confirm красным (delete-style). */
destructive?: boolean
onConfirm: () => void
onCancel: () => void
}
/**
* iOS-style confirm-диалог. Заменяет `window.confirm()` — нативный prompt
* выглядит инородно в дизайне приложения, без вибрации accent-цвета и без
* focus-trap'a. Использует наш Modal под капотом, поэтому получаем
* focus-trap и Esc-to-cancel бесплатно.
*/
export function ConfirmModal({
open,
title,
message,
confirmLabel,
cancelLabel,
destructive,
onConfirm,
onCancel
}: Props): JSX.Element {
const { t } = useT()
return (
<Modal
open={open}
onClose={onCancel}
title={title}
size="sm"
footer={
<>
<Button variant="plain" onClick={onCancel}>
{cancelLabel ?? t('btn.cancel')}
</Button>
<Button
variant={destructive ? 'destructive' : 'filled'}
onClick={onConfirm}
>
{confirmLabel ?? t('btn.ok')}
</Button>
</>
}
>
<div className="text-[14px] text-text/85 leading-relaxed py-1">
{message}
</div>
</Modal>
)
}

View File

@@ -31,6 +31,15 @@ export const ru: Dict = {
'btn.add': 'Добавить', 'btn.add': 'Добавить',
'btn.new': 'Новый', 'btn.new': 'Новый',
'btn.cancel': 'Отмена', 'btn.cancel': 'Отмена',
'btn.ok': 'OK',
'btn.delete.confirm': 'Удалить',
'exercise.delete.title': 'Удалить упражнение?',
'exercise.delete.body':
'Упражнение «{name}» будет удалено. История останется, но в ней останется только название.',
'exercise.goal_reached.kicker': 'Цель закрыта',
'exercise.goal_reached.value': '{done}/{goal}',
'exercise.adaptive.badge': 'адаптивный режим',
'settings.data.import.modal.title': 'Восстановить из файла?',
'btn.save': 'Сохранить', 'btn.save': 'Сохранить',
'btn.done': 'Готово', 'btn.done': 'Готово',
'btn.start': 'Старт', 'btn.start': 'Старт',
@@ -65,10 +74,16 @@ export const ru: Dict = {
'dashboard.stat.tracking': 'Трекинг матчей', 'dashboard.stat.tracking': 'Трекинг матчей',
'dashboard.stat.tracking.on': 'On', 'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off', 'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.pending': 'Setup',
'dashboard.stat.tracking.subtitle_on': 'в реальном времени', 'dashboard.stat.tracking.subtitle_on': 'в реальном времени',
'dashboard.stat.tracking.subtitle_off': 'выключен', 'dashboard.stat.tracking.subtitle_off': 'выключен',
'dashboard.stat.tracking.subtitle_pending':
'нужно закрыть Steam и снова открыть',
'dashboard.paused.title': 'Напоминания на паузе', 'dashboard.paused.title': 'Напоминания на паузе',
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт', 'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
'dashboard.meeting.title': 'Не дёргаем — ты на встрече',
'dashboard.meeting.hint':
'Запущен Zoom / Teams / Discord / Webex / Slack-huddle. Напоминания возобновятся когда закроешь.',
'dashboard.empty.title': 'Программа пуста', 'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
@@ -346,6 +361,15 @@ export const en: Dict = {
'btn.add': 'Add', 'btn.add': 'Add',
'btn.new': 'New', 'btn.new': 'New',
'btn.cancel': 'Cancel', 'btn.cancel': 'Cancel',
'btn.ok': 'OK',
'btn.delete.confirm': 'Delete',
'exercise.delete.title': 'Delete exercise?',
'exercise.delete.body':
'Exercise "{name}" will be removed. History stays but will only show the name.',
'exercise.goal_reached.kicker': 'Goal hit',
'exercise.goal_reached.value': '{done}/{goal}',
'exercise.adaptive.badge': 'adaptive mode',
'settings.data.import.modal.title': 'Restore from file?',
'btn.save': 'Save', 'btn.save': 'Save',
'btn.done': 'Done', 'btn.done': 'Done',
'btn.start': 'Start', 'btn.start': 'Start',
@@ -380,9 +404,14 @@ export const en: Dict = {
'dashboard.stat.tracking': 'Match tracking', 'dashboard.stat.tracking': 'Match tracking',
'dashboard.stat.tracking.on': 'On', 'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off', 'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.pending': 'Setup',
'dashboard.stat.tracking.subtitle_on': 'real-time', 'dashboard.stat.tracking.subtitle_on': 'real-time',
'dashboard.stat.tracking.subtitle_off': 'disabled', 'dashboard.stat.tracking.subtitle_off': 'disabled',
'dashboard.stat.tracking.subtitle_pending': 'close & reopen Steam',
'dashboard.paused.title': 'Reminders paused', 'dashboard.paused.title': 'Reminders paused',
'dashboard.meeting.title': "You're in a meeting — won't interrupt",
'dashboard.meeting.hint':
'Zoom / Teams / Discord / Webex / Slack-huddle is running. Reminders resume when you close it.',
'dashboard.paused.hint': 'Resume to continue countdown', 'dashboard.paused.hint': 'Resume to continue countdown',
'dashboard.empty.title': 'Program is empty', 'dashboard.empty.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start', 'dashboard.empty.hint': 'Add your first exercise to start',

View File

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

View File

@@ -30,6 +30,35 @@ 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'а
*/
/**
* Сколько reps конкретное упражнение принесло за сегодня. Учитываем как
* обычные «по таймеру», так и match-челленджи (если их exerciseId совпадает,
* чего обычно нет; но fallback не помешает).
*/
export function repsDoneTodayForExercise(
entries: HistoryEntry[],
exercise: Exercise
): number {
const today = todayKey()
let sum = 0
for (const e of entries) {
if (e.action !== 'done') continue
if (e.exerciseId !== exercise.id) continue
if (dayKey(e.ts) !== today) continue
sum += e.actualReps ?? e.reps ?? exercise.reps
}
return sum
}
export function dailyReps( export function dailyReps(
entries: HistoryEntry[], entries: HistoryEntry[],
exercises: Exercise[], exercises: Exercise[],
@@ -40,7 +69,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 +101,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
} }

View File

@@ -1,22 +1,37 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react' import {
Plus,
Pause,
Play,
Flame,
Activity,
TrendingUp,
Video
} from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard' import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor' import { ExerciseEditor } from '../components/ExerciseEditor'
import { HistoryHeatmap } from '../components/HistoryHeatmap' import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { AchievementsCard } from '../components/AchievementsCard' import { AchievementsCard } from '../components/AchievementsCard'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import type { Exercise, HistoryEntry } from '@shared/types' import { ConfirmModal } from '../components/ui/ConfirmModal'
import type { Exercise, GameStatus, HistoryEntry } from '@shared/types'
import { formatCountdown } from '../lib/format' import { formatCountdown } from '../lib/format'
import { useT } from '../i18n' import { useT } from '../i18n'
import { currentStreak, dailyReps, todayKey } from '../lib/history' import {
currentStreak,
dailyReps,
repsDoneTodayForExercise,
todayKey
} from '../lib/history'
export default function Dashboard(): JSX.Element { export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state) const state = useAppStore((s) => s.state)
const ticks = useAppStore((s) => s.ticks) const ticks = useAppStore((s) => s.ticks)
const [editorOpen, setEditorOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null) const [editing, setEditing] = useState<Exercise | null>(null)
const [deleting, setDeleting] = useState<Exercise | null>(null)
const { t, lang } = useT() const { t, lang } = useT()
// Memoise the exercises array reference so downstream useMemos don't fire // Memoise the exercises array reference so downstream useMemos don't fire
@@ -24,7 +39,26 @@ export default function Dashboard(): JSX.Element {
// the parent re-renders even when nothing changed. // the parent re-renders even when nothing changed.
const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises]) const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises])
const settings = state?.settings const settings = state?.settings
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
// Игры: запрашиваем реальный статус (integrationActive + launchOption
// applied), а не просто `state.gamesEnabled`. Без этого badge показывал
// success-зелёный даже когда launch option ещё «queued» (Steam перезапи-
// сывает) — пользователь думал что GSI работает, а fires не приходили.
const [games, setGames] = useState<GameStatus[]>([])
useEffect(() => {
void window.api.listGames().then(setGames)
return window.api.onGamesChanged(setGames)
}, [])
const gamesLive = games.some(
(g) =>
g.enabled &&
g.integrationActive &&
g.launchOptionStatus === 'applied'
)
// «Включена, но не готова» — отдельное состояние, в badge другой tone.
const gamesEnabledButNotLive = games.some(
(g) => g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied')
)
// Local history mirror; reloaded only when exercises change (not on every // Local history mirror; reloaded only when exercises change (not on every
// tick or settings tweak — those don't affect history). When ticks/settings // tick or settings tweak — those don't affect history). When ticks/settings
@@ -34,6 +68,16 @@ export default function Dashboard(): JSX.Element {
void window.api.getHistory().then(setHistory) void window.api.getHistory().then(setHistory)
}, [exercises]) }, [exercises])
// Meeting auto-pause indicator: подписываемся на evtMeetingChanged +
// запрашиваем актуальное значение при mount. Показываем баннер только
// если фича включена в settings.
const [meetingActive, setMeetingActive] = useState(false)
useEffect(() => {
void window.api.getMeetingActive().then(setMeetingActive)
return window.api.onMeetingChanged(setMeetingActive)
}, [])
const meetingPaused = meetingActive && settings?.meetingAutoPause === true
const todayDone = useMemo( const todayDone = useMemo(
() => dailyReps(history, exercises, todayKey()), () => dailyReps(history, exercises, todayKey()),
[history, exercises] [history, exercises]
@@ -158,23 +202,29 @@ export default function Dashboard(): JSX.Element {
icon={<Activity size={14} strokeWidth={2.6} />} icon={<Activity size={14} strokeWidth={2.6} />}
/> />
<HeroStat <HeroStat
tone={gamesEnabled ? 'success' : 'muted'} tone={
gamesLive ? 'success' : gamesEnabledButNotLive ? 'warning' : 'muted'
}
label={t('dashboard.stat.tracking')} label={t('dashboard.stat.tracking')}
value={ value={
gamesEnabled gamesLive
? t('dashboard.stat.tracking.on') ? t('dashboard.stat.tracking.on')
: gamesEnabledButNotLive
? t('dashboard.stat.tracking.pending')
: t('dashboard.stat.tracking.off') : t('dashboard.stat.tracking.off')
} }
subvalue={ subvalue={
gamesEnabled gamesLive
? t('dashboard.stat.tracking.subtitle_on') ? t('dashboard.stat.tracking.subtitle_on')
: gamesEnabledButNotLive
? t('dashboard.stat.tracking.subtitle_pending')
: t('dashboard.stat.tracking.subtitle_off') : t('dashboard.stat.tracking.subtitle_off')
} }
icon={ icon={
<span <span
className={[ className={[
'w-1.5 h-1.5 rounded-full', 'w-1.5 h-1.5 rounded-full',
gamesEnabled ? 'bg-white' : 'bg-text/30' gamesLive ? 'bg-white' : 'bg-text/30'
].join(' ')} ].join(' ')}
/> />
} }
@@ -211,6 +261,26 @@ export default function Dashboard(): JSX.Element {
</motion.div> </motion.div>
)} )}
{!paused && meetingPaused && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 rounded-2xl bg-info/12 p-4 flex items-center gap-3"
>
<div className="w-10 h-10 rounded-xl bg-info/18 text-info grid place-items-center shrink-0">
<Video size={18} strokeWidth={2.5} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold leading-tight">
{t('dashboard.meeting.title')}
</div>
<div className="text-[14px] text-text/70 mt-1">
{t('dashboard.meeting.hint')}
</div>
</div>
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence> <AnimatePresence>
{exercises.map((ex) => ( {exercises.map((ex) => (
@@ -218,8 +288,13 @@ export default function Dashboard(): JSX.Element {
key={ex.id} key={ex.id}
exercise={ex} exercise={ex}
tick={ticks[ex.id]} tick={ticks[ex.id]}
doneToday={
ex.dailyGoal !== undefined
? repsDoneTodayForExercise(history, ex)
: undefined
}
onEdit={() => openEdit(ex)} onEdit={() => openEdit(ex)}
onDelete={() => window.api.deleteExercise(ex.id)} onDelete={() => setDeleting(ex)}
onToggle={(v) => window.api.toggleExercise(ex.id, v)} onToggle={(v) => window.api.toggleExercise(ex.id, v)}
onMarkDone={() => window.api.markDone(ex.id)} onMarkDone={() => window.api.markDone(ex.id)}
/> />
@@ -247,6 +322,19 @@ export default function Dashboard(): JSX.Element {
onClose={() => setEditorOpen(false)} onClose={() => setEditorOpen(false)}
onSave={handleSave} onSave={handleSave}
/> />
<ConfirmModal
open={deleting !== null}
title={t('exercise.delete.title')}
message={t('exercise.delete.body', { name: deleting?.name ?? '' })}
confirmLabel={t('btn.delete.confirm')}
destructive
onConfirm={() => {
if (deleting) void window.api.deleteExercise(deleting.id)
setDeleting(null)
}}
onCancel={() => setDeleting(null)}
/>
</div> </div>
</div> </div>
) )

View File

@@ -4,6 +4,7 @@ import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card' import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard' import { UpdaterCard } from '../components/UpdaterCard'
import { WhatsNewModal } from '../components/WhatsNewModal' import { WhatsNewModal } from '../components/WhatsNewModal'
import { ConfirmModal } from '../components/ui/ConfirmModal'
import { RELEASE_NOTES } from '@shared/release-notes' import { RELEASE_NOTES } from '@shared/release-notes'
import { useT } from '../i18n' import { useT } from '../i18n'
import type { import type {
@@ -228,6 +229,7 @@ function DataCard(): JSX.Element {
const { t } = useT() const { t } = useT()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null) const [toast, setToast] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
// Простое toast'-сообщение в карточке; через 4 сек чистится. // Простое toast'-сообщение в карточке; через 4 сек чистится.
useEffect(() => { useEffect(() => {
@@ -250,9 +252,8 @@ function DataCard(): JSX.Element {
} }
} }
async function onImport(): Promise<void> { async function performImport(): Promise<void> {
// eslint-disable-next-line no-alert -- modal-confirm для destructive action setConfirmOpen(false)
if (!window.confirm(t('settings.data.import.confirm'))) return
setBusy(true) setBusy(true)
try { try {
const r = await window.api.importState() const r = await window.api.importState()
@@ -294,7 +295,7 @@ function DataCard(): JSX.Element {
</div> </div>
</div> </div>
<button <button
onClick={onImport} onClick={() => setConfirmOpen(true)}
disabled={busy} disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50" className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
> >
@@ -306,6 +307,15 @@ function DataCard(): JSX.Element {
{toast} {toast}
</div> </div>
)} )}
<ConfirmModal
open={confirmOpen}
title={t('settings.data.import.modal.title')}
message={t('settings.data.import.confirm')}
confirmLabel={t('settings.data.import.btn')}
destructive
onConfirm={performImport}
onCancel={() => setConfirmOpen(false)}
/>
</Card> </Card>
) )
} }

View File

@@ -66,5 +66,8 @@ export const IPC = {
evtAccentChanged: 'evt:accentChanged', evtAccentChanged: 'evt:accentChanged',
evtGamesChanged: 'evt:gamesChanged', evtGamesChanged: 'evt:gamesChanged',
evtUpdaterStatus: 'evt:updaterStatus', evtUpdaterStatus: 'evt:updaterStatus',
evtMaximizeChanged: 'evt:maximizeChanged' evtMaximizeChanged: 'evt:maximizeChanged',
evtMeetingChanged: 'evt:meetingChanged',
getMeetingActive: 'system:meetingActive'
} as const } as const

View File

@@ -21,6 +21,86 @@ export type ReleaseNoteItem = {
export type ReleaseNotes = Record<Language, ReleaseNoteItem[]> export type ReleaseNotes = Record<Language, ReleaseNoteItem[]>
export const RELEASE_NOTES: Record<string, ReleaseNotes> = { export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
'0.5.7': {
ru: [
{
title: 'Челленджи из матчей идут в историю',
detail:
'Раньше ✓ в Match Summary не считался — стрик и достижения игнорировали игровые тренировки. Исправлено.',
tag: 'fix'
},
{
title: 'Пауза из трея и из Dashboard теперь синхронизированы',
detail: 'Раньше Dashboard показывал «running» когда tray был на паузе.',
tag: 'fix'
},
{
title: 'Удаление упражнения спрашивает подтверждение',
tag: 'fix'
},
{
title: 'Daily goal: «Цель закрыта · 100/100»',
detail:
'Когда дневная цель достигнута — больше не показываем обратный отсчёт «25ч 13м».',
tag: 'fix'
},
{
title: 'Видно когда мы молчим из-за ВКС',
detail:
'Запущен Zoom/Teams — на Dashboard баннер «Не дёргаем — ты на встрече».',
tag: 'new'
},
{
title: 'Celebration анимация на новых достижениях',
tag: 'new'
},
{
title: 'Tracking-badge точнее',
detail:
'Live / Setup (закрой Steam) / Off — раньше зелёный показывался даже когда launch option не применён.',
tag: 'fix'
}
],
en: [
{
title: 'Match challenges now write to history',
detail:
'Previously ✓ in Match Summary did not count — streak and achievements ignored game training. Fixed.',
tag: 'fix'
},
{
title: 'Tray pause and Dashboard pause are now synced',
detail: "Previously Dashboard showed 'running' while tray was paused.",
tag: 'fix'
},
{
title: 'Exercise deletion asks for confirmation',
tag: 'fix'
},
{
title: 'Daily goal: "Goal hit · 100/100"',
detail:
'When the daily goal is met — no more confusing 25h countdown to tomorrow.',
tag: 'fix'
},
{
title: "Visible when we're quiet because of a meeting",
detail:
"Zoom/Teams running — Dashboard shows a banner: 'You're in a meeting'.",
tag: 'new'
},
{
title: 'Celebration animation on newly unlocked achievements',
tag: 'new'
},
{
title: 'Tracking badge more accurate',
detail:
'Live / Setup (close Steam) / Off — previously green even when launch option was not applied.',
tag: 'fix'
}
]
},
'0.5.6': { '0.5.6': {
ru: [ ru: [
{ {

View File

@@ -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 = {