From dfa1898332afcd5dfdccf6d7ba836d5543591034 Mon Sep 17 00:00:00 2001
From: Codex
Date: Tue, 9 Jun 2026 01:55:45 +0700
Subject: [PATCH] feat(app): add smart wellness workflow
---
CHANGELOG.md | 25 +-
README.md | 8 +-
src/main/store.ts | 1 +
src/main/validate.ts | 7 +
src/renderer/src/ReminderApp.tsx | 115 +++++++-
src/renderer/src/i18n/dict.ts | 119 ++++++++
src/renderer/src/lib/wellness.test.ts | 111 +++++++
src/renderer/src/lib/wellness.ts | 399 ++++++++++++++++++++++++++
src/renderer/src/pages/Dashboard.tsx | 274 +++++++++++++++++-
src/renderer/src/pages/Exercises.tsx | 73 ++++-
src/renderer/src/pages/Settings.tsx | 25 ++
src/shared/release-notes.ts | 78 +++++
src/shared/types.ts | 3 +
13 files changed, 1219 insertions(+), 19 deletions(-)
create mode 100644 src/renderer/src/lib/wellness.test.ts
create mode 100644 src/renderer/src/lib/wellness.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf901eb..8037adb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,28 @@
## [Unreleased]
+## [0.8.0] — 2026-06-09
+
+### Added
+
+- На `Обзор` добавлен помощник дня: рекомендации по пропускам, питанию,
+ вечерним провалам, первому запуску и хорошему ритму.
+- Добавлены разминка-сессии на 3/5/10 минут: приложение собирает короткий
+ набор действий из включённых упражнений и записывает выполнение в историю.
+- Добавлена компактная недельная аналитика: активные дни, повторы, процент
+ закрытия, пропуски и лучший день.
+- На странице `Упражнения` появились пресеты: офисная разминка, спина и шея,
+ минимум на день и набор после катки.
+- В `Настройки` добавлен тон напоминаний: спокойный, краткий, настойчивый или
+ с юмором.
+- Dota-долг после матча теперь предлагает разбивать большой объём на подходы:
+ сколько сделать сейчас и сколько можно оставить на потом.
+
+### Changed
+
+- Новый набор фич встроен в существующий `v0.6.6` / `последнее-удачное`
+ интерфейс без возврата к отклонённому редизайну `v0.7.0`.
+
## [0.7.1] — 2026-06-09
### Changed
@@ -560,7 +582,8 @@ days=[Mon..Fri]` теперь правильно проверяется день
иконки), системный трей, автозапуск с Windows, native-уведомления,
NSIS-инсталлятор, auto-update через electron-updater.
-[Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.7.1...HEAD
+[Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.8.0...HEAD
+[0.8.0]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.7.1...v0.8.0
[0.7.1]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.7.0...v0.7.1
[0.6.6]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.5...v0.6.6
[0.6.5]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.4...v0.6.5
diff --git a/README.md b/README.md
index 2404eab..cb357e6 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
Windows desktop приложение, которое помогает делать короткие перерывы без потери фокуса: держит план дня, напоминает размяться, ведёт недельные челленджи и превращает Dota 2 статистику после матча в игровые долги.
-[](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest)
-[]()
+[](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest)
+[]()
[]()
## Что внутри
@@ -12,9 +12,11 @@ Windows desktop приложение, которое помогает делат
- **Питание** — отдельная вкладка с приёмами пищи по времени суток (завтрак/обед/ужин/перекусы), выбор дней недели, пресеты быстрого добавления. Напоминания по настенным часам, а не по интервалу.
- **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд.
- **Обзор** — главный экран с ближайшим действием, планом дня, уровнем, недельными мини-челленджами и игровым долгом.
+- **Помощник дня** — советы по пропускам, питанию, вечерним провалам, короткие разминка-сессии и недельная аналитика.
+- **Пресеты** — готовые наборы упражнений для офиса, спины/шеи, минимального дня и нагрузки после катки.
- **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели.
- **Сделал частично** — степпер `−/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число.
-- **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`).
+- **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями и помогает разбить большой долг на подходы.
- **Фирменный desktop-интерфейс** — Plus Jakarta Sans + Bricolage Grotesque, мягкая палитра, sidebar, spring-анимации, светлая/тёмная/системная тема.
- **Два языка** — русский и английский, переключение мгновенное.
- **Auto-update** — приложение само скачивает новые версии из фиксированного `update-channel` (проверка каждый час, силент-ретрай при сетевых сбоях).
diff --git a/src/main/store.ts b/src/main/store.ts
index 453c014..4ed1deb 100644
--- a/src/main/store.ts
+++ b/src/main/store.ts
@@ -142,6 +142,7 @@ function safeStr(v: unknown, max = 200): string | undefined {
const SETTINGS_KEYS: (keyof Settings)[] = [
'globalEnabled',
'notificationMode',
+ 'notificationTone',
'soundEnabled',
'voicePromptsEnabled',
'meetingAutoPause',
diff --git a/src/main/validate.ts b/src/main/validate.ts
index 1e2bc2a..2d5bc83 100644
--- a/src/main/validate.ts
+++ b/src/main/validate.ts
@@ -21,6 +21,7 @@ import type {
Theme,
Language,
NotificationMode,
+ NotificationTone,
ReminderCategory
} from '@shared/types'
@@ -28,6 +29,7 @@ const MAX_STR_LEN = 200
const VALID_THEMES: Theme[] = ['system', 'light', 'dark']
const VALID_LANGS: Language[] = ['ru', 'en']
const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both']
+const VALID_TONES: NotificationTone[] = ['calm', 'brief', 'firm', 'playful']
const VALID_GAME_IDS: GameId[] = ['dota2']
const VALID_STATS: GameStat[] = [
'deaths',
@@ -416,6 +418,11 @@ export function validateSettingsPatch(raw: unknown): Partial | null {
if (v === undefined) return null
out.notificationMode = v
}
+ if ('notificationTone' in raw) {
+ const v = oneOf(raw.notificationTone, VALID_TONES)
+ if (v === undefined) return null
+ out.notificationTone = v
+ }
if ('theme' in raw) {
const v = oneOf(raw.theme, VALID_THEMES)
if (v === undefined) return null
diff --git a/src/renderer/src/ReminderApp.tsx b/src/renderer/src/ReminderApp.tsx
index 35d5d7b..03209b6 100644
--- a/src/renderer/src/ReminderApp.tsx
+++ b/src/renderer/src/ReminderApp.tsx
@@ -16,13 +16,15 @@ import type {
Meal,
Settings,
ChallengeResult,
- Language
+ Language,
+ NotificationTone
} from '@shared/types'
import { statLabel } from '@shared/types'
import { Icon } from './lib/icon'
import { formatInterval } from './lib/format'
import { speak } from './lib/tts'
import { translate, translateN } from './i18n'
+import { planGameDebt } from './lib/wellness'
type Mode =
| { kind: 'idle' }
@@ -193,6 +195,7 @@ export default function ReminderApp(): JSX.Element {
exercise={mode.exercise}
snoozeMinutes={settings?.snoozeMinutes ?? 5}
lang={lang}
+ tone={settings?.notificationTone ?? 'calm'}
onClose={close}
/>
)
@@ -204,6 +207,7 @@ export default function ReminderApp(): JSX.Element {
meal={mode.meal}
snoozeMinutes={settings?.snoozeMinutes ?? 5}
lang={lang}
+ tone={settings?.notificationTone ?? 'calm'}
onClose={close}
/>
)
@@ -213,6 +217,7 @@ export default function ReminderApp(): JSX.Element {
summary={mode.summary}
done={mode.done}
lang={lang}
+ tone={settings?.notificationTone ?? 'calm'}
onMarkDone={(id) => {
// Дедупликация: rapid double-click может два раза вызвать
// onMarkDone до того как `disabled={done}` доедет до DOM.
@@ -226,18 +231,16 @@ export default function ReminderApp(): JSX.Element {
void window.api.markChallengeDone(id, result.reps)
}
// 2) Functional update: rapid-click race-safe.
- setMode((m) =>
- {
- if (m.kind !== 'match') return m
- const nextMode: Mode = {
- kind: 'match',
- summary: m.summary,
- done: new Set([...m.done, id])
- }
- modeRef.current = nextMode
- return nextMode
+ setMode((m) => {
+ if (m.kind !== 'match') return m
+ const nextMode: Mode = {
+ kind: 'match',
+ summary: m.summary,
+ done: new Set([...m.done, id])
}
- )
+ modeRef.current = nextMode
+ return nextMode
+ })
}}
onClose={close}
/>
@@ -248,11 +251,13 @@ function ExerciseReminder({
exercise,
snoozeMinutes,
lang,
+ tone,
onClose
}: {
exercise: Exercise
snoozeMinutes: number
lang: Language
+ tone: NotificationTone
onClose: () => void
}): JSX.Element {
const t = (key: string, vars?: Record): string =>
@@ -338,6 +343,14 @@ function ExerciseReminder({
{t(`category.${exercise.category ?? 'exercise'}.cta`)}
+
+ {reminderToneLine({
+ tone,
+ lang,
+ kind: 'exercise',
+ name: exercise.name
+ })}
+
{exercise.name}
@@ -420,11 +433,13 @@ function MealReminder({
meal,
snoozeMinutes,
lang,
+ tone,
onClose
}: {
meal: Meal
snoozeMinutes: number
lang: Language
+ tone: NotificationTone
onClose: () => void
}): JSX.Element {
const t = (key: string, vars?: Record): string =>
@@ -452,7 +467,10 @@ function MealReminder({
} else if (e.key === 'Escape') {
e.preventDefault()
onClose()
- } else if ((e.key === ' ' || e.code === 'Space') && targetTag !== 'BUTTON') {
+ } else if (
+ (e.key === ' ' || e.code === 'Space') &&
+ targetTag !== 'BUTTON'
+ ) {
e.preventDefault()
void snooze()
}
@@ -489,6 +507,9 @@ function MealReminder({
{t('meal.cta')}
+
+ {reminderToneLine({ tone, lang, kind: 'meal', name: meal.name })}
+
{meal.name}
@@ -520,12 +541,14 @@ function MatchSummaryView({
summary,
done,
lang,
+ tone,
onMarkDone,
onClose
}: {
summary: MatchSummary
done: Set
lang: Language
+ tone: NotificationTone
onMarkDone: (id: string) => void
onClose: () => void
}): JSX.Element {
@@ -539,6 +562,7 @@ function MatchSummaryView({
const allDone = summary.results.every((r) => done.has(r.challengeId))
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)
+ const debtPlan = planGameDebt(totalReps)
const remainingReps = summary.results
.filter((r) => !done.has(r.challengeId))
.reduce((s, r) => s + r.reps, 0)
@@ -601,6 +625,17 @@ function MatchSummaryView({
)}
+ {totalReps > 0 && (
+
+ {matchToneLine({ tone, lang, total: totalReps })}
+
+ {t('match.debt_plan', {
+ now: debtPlan.now,
+ later: debtPlan.later
+ })}
+
+
+ )}
@@ -713,6 +748,60 @@ function ChallengeRow({
)
}
+function reminderToneLine({
+ tone,
+ lang,
+ kind,
+ name
+}: {
+ tone: NotificationTone
+ lang: Language
+ kind: 'exercise' | 'meal'
+ name: string
+}): string {
+ if (lang === 'en') {
+ if (tone === 'brief') return kind === 'meal' ? 'Eat now.' : 'Do it now.'
+ if (tone === 'firm') return `No delay: ${name}.`
+ if (tone === 'playful')
+ return kind === 'meal' ? 'Fuel break.' : 'Tiny win time.'
+ return kind === 'meal'
+ ? 'A calm food break fits here.'
+ : 'A short reset fits here.'
+ }
+
+ if (tone === 'brief')
+ return kind === 'meal' ? 'Поесть сейчас.' : 'Сделай сейчас.'
+ if (tone === 'firm') return `Не откладываем: ${name}.`
+ if (tone === 'playful')
+ return kind === 'meal' ? 'Дозаправка.' : 'Маленькая победа.'
+ return kind === 'meal'
+ ? 'Спокойный перерыв на еду здесь к месту.'
+ : 'Короткая перезагрузка здесь к месту.'
+}
+
+function matchToneLine({
+ tone,
+ lang,
+ total
+}: {
+ tone: NotificationTone
+ lang: Language
+ total: number
+}): string {
+ if (lang === 'en') {
+ if (tone === 'brief') return `${total} reps. Split it.`
+ if (tone === 'firm')
+ return `Close the match debt in small sets: ${total} reps.`
+ if (tone === 'playful') return `The match left a receipt: ${total} reps.`
+ return `No need to do everything at once. Split ${total} reps into sets.`
+ }
+
+ if (tone === 'brief') return `${total} повторов. Разбиваем.`
+ if (tone === 'firm') return `Закрываем долг подходами: ${total} повторов.`
+ if (tone === 'playful') return `Катка оставила чек: ${total} повторов.`
+ return `Не нужно делать всё одним куском. Разбей ${total} повторов на подходы.`
+}
+
/**
* CLDR-минимум для русского склонения «раз». 1 раз / 2 раза / 5 раз.
* Не тащим сюда полную плюрализацию из i18n — это TTS-only фраза.
diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts
index aee97de..2086067 100644
--- a/src/renderer/src/i18n/dict.ts
+++ b/src/renderer/src/i18n/dict.ts
@@ -149,6 +149,47 @@ export const ru: Dict = {
'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
+ // Smart work / sessions / analytics
+ 'smart.kicker': 'Помощник дня',
+ 'smart.title': 'Что улучшить сейчас',
+ 'insight.default.title': 'День идёт ровно',
+ 'insight.default.desc':
+ 'Срочных корректировок нет. Держи короткие перерывы и не копи всё на вечер.',
+ 'insight.first_run.title': 'Начни с пресета',
+ 'insight.first_run.desc':
+ 'Выбери готовую программу на странице упражнений — так быстрее, чем собирать всё вручную.',
+ 'insight.too_many_skips.title': 'Много пропусков',
+ 'insight.too_many_skips.desc':
+ '{n} пропусков за неделю. Снизь нагрузку или запускай короткую сессию вместо полного плана.',
+ 'insight.late_slump.title': 'Вечером сложнее',
+ 'insight.late_slump.desc':
+ '{n} вечерних пропусков. Лучше закрыть базу до 18:00 или разбить долг на подходы.',
+ 'insight.empty_meals.title': 'Питание не настроено',
+ 'insight.empty_meals.desc':
+ 'Добавь завтрак, обед или перекус, чтобы день был стабильнее.',
+ 'insight.good_rhythm.title': 'Ритм хороший',
+ 'insight.good_rhythm.desc':
+ 'Закрываемость {pct}%. Можно слегка поднять цель или оставить темп как есть.',
+ 'session.kicker': 'Разминка-сессия',
+ 'session.title': 'Запустить коротко',
+ 'session.3.title': 'Быстрый сброс',
+ 'session.5.title': 'Нормальная пауза',
+ 'session.10.title': 'Полная разминка',
+ 'session.empty': 'Добавь упражнения или пресет, чтобы запускать сессии.',
+ 'analytics.kicker': 'Аналитика',
+ 'analytics.title': 'Неделя в цифрах',
+ 'analytics.active_days': 'Дни',
+ 'analytics.active_days.hint': 'с активностью',
+ 'analytics.done_reps': 'Повторы',
+ 'analytics.done_reps.hint': 'сделано',
+ 'analytics.completion': 'Закрытие',
+ 'analytics.completion.hint': 'done / skip',
+ 'analytics.skips': 'Пропуски',
+ 'analytics.skips.hint': 'за неделю',
+ 'analytics.best_day': 'Лучший день',
+ 'analytics.best_day.hint': '{day}',
+ 'analytics.best_day.empty': 'пока нет',
+
// Momentum / today redesign
'momentum.level.title': 'Уровень',
'momentum.level.number': 'уровень {n}',
@@ -203,6 +244,17 @@ export const ru: Dict = {
'exercises.section.disabled': 'Выключенные · {n}',
'exercises.row.meta': '{reps} раз · {interval}',
'exercises.empty': 'Программа пуста — добавь первое упражнение',
+ 'exercises.presets.title': 'Пресеты',
+ 'exercises.presets.add': 'Добавить',
+ 'preset.office.title': 'Офисная разминка',
+ 'preset.office.desc': 'Шея, глаза и приседания для обычного рабочего дня.',
+ 'preset.back.title': 'Спина и шея',
+ 'preset.back.desc':
+ 'Осанка, лопатки и лёгкие наклоны без спортивного режима.',
+ 'preset.minimum.title': 'Минимум на день',
+ 'preset.minimum.desc': 'Самый мягкий старт: вода и короткая мини-разминка.',
+ 'preset.after_match.title': 'После катки',
+ 'preset.after_match.desc': 'База под игровые долги: приседания и отжимания.',
// Meals (приёмы пищи)
'meals.kicker': 'Режим питания',
@@ -384,6 +436,13 @@ export const ru: Dict = {
'settings.notification_mode.modal': 'Окно поверх всех',
'settings.notification_mode.toast': 'Системное уведомление',
'settings.notification_mode.both': 'Окно и уведомление',
+ 'settings.notification_tone.label': 'Тон напоминаний',
+ 'settings.notification_tone.hint':
+ 'Как формулировать подсказки в окне напоминания',
+ 'settings.notification_tone.calm': 'Спокойно',
+ 'settings.notification_tone.brief': 'Кратко',
+ 'settings.notification_tone.firm': 'Настойчиво',
+ 'settings.notification_tone.playful': 'С юмором',
'settings.global.label': 'Напоминания включены',
'settings.global.hint': 'Главный режим работы приложения',
'settings.sound.label': 'Звук уведомления',
@@ -532,6 +591,7 @@ export const ru: Dict = {
'match.summary.remaining': '{n} осталось',
'match.total': 'Всего',
'match.total_reps_suffix': 'повторов',
+ 'match.debt_plan': 'Сейчас {now}, позже {later}',
// Format helpers
'fmt.now': 'сейчас',
@@ -682,6 +742,47 @@ export const en: Dict = {
'dashboard.empty.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start',
+ // Smart work / sessions / analytics
+ 'smart.kicker': 'Day assistant',
+ 'smart.title': 'What to improve now',
+ 'insight.default.title': 'The day is steady',
+ 'insight.default.desc':
+ 'No urgent adjustments. Keep breaks short and avoid leaving everything for the evening.',
+ 'insight.first_run.title': 'Start with a preset',
+ 'insight.first_run.desc':
+ 'Pick a ready-made program on the Exercises page; it is faster than building one manually.',
+ 'insight.too_many_skips.title': 'Many skips',
+ 'insight.too_many_skips.desc':
+ '{n} skips this week. Lower the load or run a short session instead of the full plan.',
+ 'insight.late_slump.title': 'Evenings are harder',
+ 'insight.late_slump.desc':
+ '{n} evening skips. Close the basics before 18:00 or split debt into sets.',
+ 'insight.empty_meals.title': 'Meals are not configured',
+ 'insight.empty_meals.desc':
+ 'Add breakfast, lunch or a snack to keep the day steadier.',
+ 'insight.good_rhythm.title': 'Good rhythm',
+ 'insight.good_rhythm.desc':
+ '{pct}% completion. You can slightly raise a target or keep the pace.',
+ 'session.kicker': 'Warm-up session',
+ 'session.title': 'Run a short one',
+ 'session.3.title': 'Quick reset',
+ 'session.5.title': 'Normal pause',
+ 'session.10.title': 'Full warm-up',
+ 'session.empty': 'Add exercises or a preset to run sessions.',
+ 'analytics.kicker': 'Analytics',
+ 'analytics.title': 'Week in numbers',
+ 'analytics.active_days': 'Days',
+ 'analytics.active_days.hint': 'with activity',
+ 'analytics.done_reps': 'Reps',
+ 'analytics.done_reps.hint': 'done',
+ 'analytics.completion': 'Completion',
+ 'analytics.completion.hint': 'done / skip',
+ 'analytics.skips': 'Skips',
+ 'analytics.skips.hint': 'this week',
+ 'analytics.best_day': 'Best day',
+ 'analytics.best_day.hint': '{day}',
+ 'analytics.best_day.empty': 'none yet',
+
// Momentum / today redesign
'momentum.level.title': 'Level',
'momentum.level.number': 'level {n}',
@@ -736,6 +837,17 @@ export const en: Dict = {
'exercises.section.disabled': 'Disabled · {n}',
'exercises.row.meta': '{reps} reps · {interval}',
'exercises.empty': 'Program is empty — add your first exercise',
+ 'exercises.presets.title': 'Presets',
+ 'exercises.presets.add': 'Add',
+ 'preset.office.title': 'Office warm-up',
+ 'preset.office.desc': 'Neck, eyes and squats for a normal workday.',
+ 'preset.back.title': 'Back and neck',
+ 'preset.back.desc':
+ 'Posture, shoulder blades and easy bends without gym mode.',
+ 'preset.minimum.title': 'Daily minimum',
+ 'preset.minimum.desc': 'The softest start: water and one mini warm-up.',
+ 'preset.after_match.title': 'After match',
+ 'preset.after_match.desc': 'A base for game debt: squats and push-ups.',
// Meals
'meals.kicker': 'Eating schedule',
@@ -917,6 +1029,12 @@ export const en: Dict = {
'settings.notification_mode.modal': 'Window on top',
'settings.notification_mode.toast': 'System notification',
'settings.notification_mode.both': 'Window and notification',
+ 'settings.notification_tone.label': 'Reminder tone',
+ 'settings.notification_tone.hint': 'How reminder-window hints are phrased',
+ 'settings.notification_tone.calm': 'Calm',
+ 'settings.notification_tone.brief': 'Brief',
+ 'settings.notification_tone.firm': 'Firm',
+ 'settings.notification_tone.playful': 'Playful',
'settings.global.label': 'Reminders enabled',
'settings.global.hint': 'Main operating mode for the app',
'settings.sound.label': 'Notification sound',
@@ -1061,6 +1179,7 @@ export const en: Dict = {
'match.summary.remaining': '{n} left',
'match.total': 'Total',
'match.total_reps_suffix': 'reps',
+ 'match.debt_plan': 'Now {now}, later {later}',
// Format helpers
'fmt.now': 'now',
diff --git a/src/renderer/src/lib/wellness.test.ts b/src/renderer/src/lib/wellness.test.ts
new file mode 100644
index 0000000..1aa675c
--- /dev/null
+++ b/src/renderer/src/lib/wellness.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest'
+import type { Exercise, HistoryEntry, Meal } from '@shared/types'
+import {
+ buildSessionPlan,
+ computeWeeklyAnalytics,
+ computeWellnessInsights,
+ planGameDebt
+} from './wellness'
+
+const mondayNoon = new Date(2026, 5, 8, 12, 0, 0, 0).getTime()
+
+const exercise: Exercise = {
+ id: 'ex-1',
+ name: 'Приседания',
+ reps: 10,
+ icon: 'Activity',
+ intervalMinutes: 60,
+ enabled: true,
+ nextFireAt: mondayNoon + 60_000,
+ category: 'exercise'
+}
+
+const meal: Meal = {
+ id: 'meal-1',
+ name: 'Обед',
+ time: '13:00',
+ icon: 'UtensilsCrossed',
+ enabled: false,
+ days: [],
+ nextFireAt: mondayNoon + 60_000
+}
+
+describe('wellness analytics', () => {
+ it('computes weekly completion and late skips', () => {
+ const history: HistoryEntry[] = [
+ {
+ ts: mondayNoon,
+ exerciseId: exercise.id,
+ action: 'done',
+ reps: 10
+ },
+ {
+ ts: mondayNoon + 7 * 60 * 60 * 1000,
+ exerciseId: exercise.id,
+ action: 'skip'
+ }
+ ]
+
+ const analytics = computeWeeklyAnalytics({
+ history,
+ exercises: [exercise],
+ now: mondayNoon + 8 * 60 * 60 * 1000
+ })
+
+ expect(analytics.activeDays).toBe(1)
+ expect(analytics.doneReps).toBe(10)
+ expect(analytics.skippedActions).toBe(1)
+ expect(analytics.lateSkips).toBe(1)
+ expect(analytics.completionPct).toBe(50)
+ })
+
+ it('surfaces useful insights from state', () => {
+ const analytics = {
+ activeDays: 1,
+ doneReps: 10,
+ doneActions: 1,
+ skippedActions: 4,
+ snoozedActions: 0,
+ bestDayReps: 10,
+ lateSkips: 2,
+ completionPct: 20
+ }
+
+ const insights = computeWellnessInsights({
+ exercises: [exercise],
+ meals: [meal],
+ analytics
+ })
+
+ expect(insights.map((i) => i.id)).toContain('too_many_skips')
+ expect(insights.map((i) => i.id)).toContain('late_slump')
+ expect(insights.map((i) => i.id)).toContain('empty_meals')
+ })
+})
+
+describe('wellness sessions', () => {
+ it('builds a compact session from enabled exercises', () => {
+ const session = buildSessionPlan({ exercises: [exercise], minutes: 5 })
+
+ expect(session.steps).toHaveLength(3)
+ expect(session.steps[0]).toMatchObject({
+ exerciseId: exercise.id,
+ reps: 10
+ })
+ })
+
+ it('splits game debt into realistic sets', () => {
+ expect(planGameDebt(12)).toEqual({
+ total: 12,
+ now: 12,
+ later: 0,
+ sets: [12]
+ })
+ expect(planGameDebt(55)).toEqual({
+ total: 55,
+ now: 20,
+ later: 35,
+ sets: [20, 20, 15]
+ })
+ })
+})
diff --git a/src/renderer/src/lib/wellness.ts b/src/renderer/src/lib/wellness.ts
new file mode 100644
index 0000000..6353023
--- /dev/null
+++ b/src/renderer/src/lib/wellness.ts
@@ -0,0 +1,399 @@
+import type { Exercise, HistoryEntry, Meal } from '@shared/types'
+import { dayKey } from './history'
+
+export type ExercisePreset = {
+ id: string
+ titleKey: string
+ descKey: string
+ exercises: Omit
[]
+ meals?: Omit[]
+}
+
+export type WellnessInsight = {
+ id:
+ | 'first_run'
+ | 'too_many_skips'
+ | 'good_rhythm'
+ | 'late_slump'
+ | 'empty_meals'
+ titleKey: string
+ descKey: string
+ tone: 'accent' | 'success' | 'warning' | 'info'
+ vars?: Record
+}
+
+export type WeeklyAnalytics = {
+ activeDays: number
+ doneReps: number
+ doneActions: number
+ skippedActions: number
+ snoozedActions: number
+ bestDayKey?: string
+ bestDayReps: number
+ lateSkips: number
+ completionPct: number
+}
+
+export type SessionStep = {
+ exerciseId?: string
+ name: string
+ icon: string
+ reps: number
+}
+
+export type SessionPlan = {
+ minutes: 3 | 5 | 10
+ titleKey: string
+ steps: SessionStep[]
+}
+
+export type GameDebtPlan = {
+ total: number
+ now: number
+ later: number
+ sets: number[]
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+export const EXERCISE_PRESETS: ExercisePreset[] = [
+ {
+ id: 'office',
+ titleKey: 'preset.office.title',
+ descKey: 'preset.office.desc',
+ exercises: [
+ {
+ name: 'Плечи и шея',
+ reps: 8,
+ icon: 'StretchHorizontal',
+ intervalMinutes: 45,
+ enabled: true,
+ category: 'posture',
+ dailyGoal: 32,
+ adaptive: true
+ },
+ {
+ name: 'Приседания',
+ reps: 10,
+ icon: 'Activity',
+ intervalMinutes: 60,
+ enabled: true,
+ category: 'exercise',
+ dailyGoal: 40,
+ adaptive: true
+ },
+ {
+ name: 'Отдых глазам',
+ reps: 1,
+ icon: 'Eye',
+ intervalMinutes: 30,
+ enabled: true,
+ category: 'eyes',
+ dailyGoal: 4
+ }
+ ]
+ },
+ {
+ id: 'back',
+ titleKey: 'preset.back.title',
+ descKey: 'preset.back.desc',
+ exercises: [
+ {
+ name: 'Проверь осанку',
+ reps: 1,
+ icon: 'PersonStanding',
+ intervalMinutes: 25,
+ enabled: true,
+ category: 'posture',
+ dailyGoal: 8
+ },
+ {
+ name: 'Лопатки назад',
+ reps: 12,
+ icon: 'StretchHorizontal',
+ intervalMinutes: 50,
+ enabled: true,
+ category: 'posture',
+ dailyGoal: 48,
+ adaptive: true
+ },
+ {
+ name: 'Наклоны',
+ reps: 8,
+ icon: 'Activity',
+ intervalMinutes: 70,
+ enabled: true,
+ category: 'exercise',
+ dailyGoal: 32
+ }
+ ]
+ },
+ {
+ id: 'minimum',
+ titleKey: 'preset.minimum.title',
+ descKey: 'preset.minimum.desc',
+ exercises: [
+ {
+ name: 'Мини-разминка',
+ reps: 5,
+ icon: 'Dumbbell',
+ intervalMinutes: 90,
+ enabled: true,
+ category: 'exercise',
+ dailyGoal: 20
+ },
+ {
+ name: 'Стакан воды',
+ reps: 1,
+ icon: 'GlassWater',
+ intervalMinutes: 120,
+ enabled: true,
+ category: 'hydration',
+ dailyGoal: 4
+ }
+ ]
+ },
+ {
+ id: 'after_match',
+ titleKey: 'preset.after_match.title',
+ descKey: 'preset.after_match.desc',
+ exercises: [
+ {
+ name: 'Приседания после катки',
+ reps: 12,
+ icon: 'Activity',
+ intervalMinutes: 75,
+ enabled: true,
+ category: 'exercise',
+ dailyGoal: 48
+ },
+ {
+ name: 'Отжимания после катки',
+ reps: 8,
+ icon: 'Dumbbell',
+ intervalMinutes: 90,
+ enabled: true,
+ category: 'exercise',
+ dailyGoal: 32
+ }
+ ]
+ }
+]
+
+function startOfWeek(ts: number): number {
+ const d = new Date(ts)
+ d.setHours(0, 0, 0, 0)
+ const day = d.getDay()
+ d.setDate(d.getDate() + (day === 0 ? -6 : 1 - day))
+ return d.getTime()
+}
+
+function entryReps(
+ entry: HistoryEntry,
+ exercisesById: Map
+): number {
+ return (
+ entry.actualReps ??
+ entry.reps ??
+ exercisesById.get(entry.exerciseId)?.reps ??
+ 0
+ )
+}
+
+export function computeWeeklyAnalytics({
+ history,
+ exercises,
+ now = Date.now()
+}: {
+ history: HistoryEntry[]
+ exercises: Exercise[]
+ now?: number
+}): WeeklyAnalytics {
+ const weekStart = startOfWeek(now)
+ const exercisesById = new Map(exercises.map((e) => [e.id, e]))
+ const days = new Map()
+ let doneReps = 0
+ let doneActions = 0
+ let skippedActions = 0
+ let snoozedActions = 0
+ let lateSkips = 0
+
+ for (const entry of history) {
+ if (entry.ts < weekStart || entry.ts > now) continue
+ const key = dayKey(entry.ts)
+ if (entry.action === 'done') {
+ const reps = entryReps(entry, exercisesById)
+ doneReps += reps
+ doneActions++
+ days.set(key, (days.get(key) ?? 0) + reps)
+ } else if (entry.action === 'skip') {
+ skippedActions++
+ if (new Date(entry.ts).getHours() >= 18) lateSkips++
+ } else if (entry.action === 'snooze') {
+ snoozedActions++
+ }
+ }
+
+ let bestDayKey: string | undefined
+ let bestDayReps = 0
+ for (const [key, reps] of days) {
+ if (reps > bestDayReps) {
+ bestDayKey = key
+ bestDayReps = reps
+ }
+ }
+
+ const attempts = doneActions + skippedActions
+ const completionPct =
+ attempts > 0 ? Math.round((doneActions / attempts) * 100) : 0
+
+ return {
+ activeDays: days.size,
+ doneReps,
+ doneActions,
+ skippedActions,
+ snoozedActions,
+ bestDayKey,
+ bestDayReps,
+ lateSkips,
+ completionPct
+ }
+}
+
+export function computeWellnessInsights({
+ exercises,
+ meals,
+ analytics
+}: {
+ exercises: Exercise[]
+ meals: Meal[]
+ analytics: WeeklyAnalytics
+}): WellnessInsight[] {
+ const insights: WellnessInsight[] = []
+ const activeExercises = exercises.filter((e) => e.enabled).length
+ const activeMeals = meals.filter((m) => m.enabled).length
+
+ if (activeExercises === 0) {
+ insights.push({
+ id: 'first_run',
+ titleKey: 'insight.first_run.title',
+ descKey: 'insight.first_run.desc',
+ tone: 'accent'
+ })
+ }
+
+ if (analytics.skippedActions >= 3 && analytics.completionPct < 60) {
+ insights.push({
+ id: 'too_many_skips',
+ titleKey: 'insight.too_many_skips.title',
+ descKey: 'insight.too_many_skips.desc',
+ tone: 'warning',
+ vars: { n: analytics.skippedActions }
+ })
+ }
+
+ if (analytics.lateSkips >= 2) {
+ insights.push({
+ id: 'late_slump',
+ titleKey: 'insight.late_slump.title',
+ descKey: 'insight.late_slump.desc',
+ tone: 'info',
+ vars: { n: analytics.lateSkips }
+ })
+ }
+
+ if (activeMeals === 0) {
+ insights.push({
+ id: 'empty_meals',
+ titleKey: 'insight.empty_meals.title',
+ descKey: 'insight.empty_meals.desc',
+ tone: 'info'
+ })
+ }
+
+ if (analytics.activeDays >= 4 && analytics.completionPct >= 70) {
+ insights.push({
+ id: 'good_rhythm',
+ titleKey: 'insight.good_rhythm.title',
+ descKey: 'insight.good_rhythm.desc',
+ tone: 'success',
+ vars: { pct: analytics.completionPct }
+ })
+ }
+
+ return insights.slice(0, 3)
+}
+
+export function buildSessionPlan({
+ exercises,
+ minutes
+}: {
+ exercises: Exercise[]
+ minutes: 3 | 5 | 10
+}): SessionPlan {
+ const enabled = exercises.filter((e) => e.enabled)
+ const pool = enabled.length > 0 ? enabled : exercises
+ const count = minutes === 3 ? 2 : minutes === 5 ? 3 : 5
+ const steps = pool.slice(0, count).map((e) => ({
+ exerciseId: e.enabled ? e.id : undefined,
+ name: e.name,
+ icon: e.icon,
+ reps:
+ e.category === 'hydration' ||
+ e.category === 'eyes' ||
+ e.category === 'posture'
+ ? 1
+ : Math.max(
+ 3,
+ Math.min(e.reps, minutes === 3 ? 8 : minutes === 5 ? 12 : 16)
+ )
+ }))
+
+ while (steps.length < count) {
+ const fallback =
+ fallbackSessionSteps[steps.length % fallbackSessionSteps.length]
+ steps.push(fallback)
+ }
+
+ return {
+ minutes,
+ titleKey: `session.${minutes}.title`,
+ steps
+ }
+}
+
+const fallbackSessionSteps: SessionStep[] = [
+ { name: 'Плечи назад', icon: 'StretchHorizontal', reps: 8 },
+ { name: 'Приседания', icon: 'Activity', reps: 8 },
+ { name: 'Отдых глазам', icon: 'Eye', reps: 1 },
+ { name: 'Проверь осанку', icon: 'PersonStanding', reps: 1 },
+ { name: 'Стакан воды', icon: 'GlassWater', reps: 1 }
+]
+
+export function planGameDebt(total: number): GameDebtPlan {
+ const safeTotal = Math.max(0, Math.trunc(total))
+ if (safeTotal === 0) return { total: 0, now: 0, later: 0, sets: [] }
+
+ const now = Math.min(safeTotal, safeTotal <= 20 ? safeTotal : 20)
+ const later = safeTotal - now
+ const sets: number[] = []
+ let left = safeTotal
+ while (left > 0) {
+ const next = Math.min(left, 20)
+ sets.push(next)
+ left -= next
+ }
+ return { total: safeTotal, now, later, sets }
+}
+
+export function weekWindowLabel(now = Date.now()): {
+ from: string
+ to: string
+} {
+ const from = new Date(startOfWeek(now))
+ const to = new Date(from.getTime() + 6 * DAY_MS)
+ return {
+ from: from.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }),
+ to: to.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
+ }
+}
diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx
index c63458d..7a3dd97 100644
--- a/src/renderer/src/pages/Dashboard.tsx
+++ b/src/renderer/src/pages/Dashboard.tsx
@@ -14,7 +14,10 @@ import {
Check,
Trophy,
Swords,
- BadgeCheck
+ BadgeCheck,
+ Lightbulb,
+ BarChart3,
+ Timer
} from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard'
@@ -48,6 +51,15 @@ import {
type MomentumSummary,
type WeeklyQuest
} from '../lib/momentum'
+import {
+ buildSessionPlan,
+ computeWeeklyAnalytics,
+ computeWellnessInsights,
+ weekWindowLabel,
+ type SessionPlan,
+ type WellnessInsight,
+ type WeeklyAnalytics
+} from '../lib/wellness'
export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state)
@@ -142,6 +154,26 @@ export default function Dashboard(): JSX.Element {
() => computeMomentumSummary({ history, exercises, challenges }),
[history, exercises, challenges]
)
+ const weeklyAnalytics = useMemo(
+ () => computeWeeklyAnalytics({ history, exercises }),
+ [history, exercises]
+ )
+ const wellnessInsights = useMemo(
+ () =>
+ computeWellnessInsights({
+ exercises,
+ meals,
+ analytics: weeklyAnalytics
+ }),
+ [exercises, meals, weeklyAnalytics]
+ )
+ const sessions = useMemo(
+ () =>
+ ([3, 5, 10] as const).map((minutes) =>
+ buildSessionPlan({ exercises, minutes })
+ ),
+ [exercises]
+ )
const paused = !settings?.globalEnabled
@@ -361,6 +393,24 @@ export default function Dashboard(): JSX.Element {
onItemDone={(item) => void handlePlanItemDone(item)}
/>
+ {
+ for (const step of session.steps) {
+ if (!step.exerciseId) continue
+ const ex = exercises.find((item) => item.id === step.exerciseId)
+ await window.api.markDone(
+ step.exerciseId,
+ ex && step.reps !== ex.reps ? step.reps : undefined
+ )
+ }
+ }}
+ />
+
Promise
+}): JSX.Element {
+ const [busySession, setBusySession] = useState(null)
+ const week = weekWindowLabel()
+ const shownInsights =
+ insights.length > 0
+ ? insights
+ : [
+ {
+ id: 'good_rhythm' as const,
+ titleKey: 'insight.default.title',
+ descKey: 'insight.default.desc',
+ tone: 'success' as const
+ }
+ ]
+
+ async function run(session: SessionPlan): Promise {
+ if (busySession !== null) return
+ setBusySession(session.minutes)
+ try {
+ await onRunSession(session)
+ } finally {
+ setBusySession(null)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {t('smart.kicker')}
+
+
+ {t('smart.title')}
+
+
+
+
+ {shownInsights.map((insight) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {t('session.kicker')}
+
+
+ {t('session.title')}
+
+
+
+
+ {sessions.map((session) => (
+
+ ))}
+
+ {exercises.length === 0 && (
+
+ {t('session.empty')}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t('analytics.kicker')}
+
+
+ {t('analytics.title')}
+
+
+
+
+ {week.from} — {week.to}
+
+
+
+
+
+
+
+
0 ? `${analytics.bestDayReps}` : '—'}
+ hint={
+ analytics.bestDayKey
+ ? t('analytics.best_day.hint', { day: analytics.bestDayKey })
+ : t('analytics.best_day.empty')
+ }
+ />
+
+
+
+ )
+}
+
+function InsightLine({
+ insight,
+ t
+}: {
+ insight: WellnessInsight
+ t: TFn
+}): JSX.Element {
+ const toneClass =
+ insight.tone === 'success'
+ ? 'bg-success/12 text-success'
+ : insight.tone === 'warning'
+ ? 'bg-warning/12 text-warning'
+ : insight.tone === 'info'
+ ? 'bg-info/12 text-info'
+ : 'bg-accent/12 text-accent'
+
+ return (
+
+
+
+
+
+
+ {t(insight.titleKey, insight.vars)}
+
+
+ {t(insight.descKey, insight.vars)}
+
+
+
+ )
+}
+
+function AnalyticsTile({
+ label,
+ value,
+ hint
+}: {
+ label: string
+ value: string
+ hint: string
+}): JSX.Element {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
{hint}
+
+ )
+}
+
function MomentumPanel({
momentum,
gamesLive,
diff --git a/src/renderer/src/pages/Exercises.tsx b/src/renderer/src/pages/Exercises.tsx
index 0672bef..9baad33 100644
--- a/src/renderer/src/pages/Exercises.tsx
+++ b/src/renderer/src/pages/Exercises.tsx
@@ -1,5 +1,12 @@
import { useState } from 'react'
-import { Activity, ChevronRight, Dumbbell, Plus, Target } from 'lucide-react'
+import {
+ Activity,
+ ChevronRight,
+ Dumbbell,
+ Plus,
+ Sparkles,
+ Target
+} from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseEditor } from '../components/ExerciseEditor'
import {
@@ -14,6 +21,7 @@ import { Icon } from '../lib/icon'
import { formatInterval } from '../lib/format'
import { useT } from '../i18n'
import type { Exercise } from '@shared/types'
+import { EXERCISE_PRESETS, type ExercisePreset } from '../lib/wellness'
export default function Exercises(): JSX.Element {
const exercises = useAppStore((s) => s.state?.exercises ?? [])
@@ -69,6 +77,17 @@ export default function Exercises(): JSX.Element {
/>
+
+
+ {EXERCISE_PRESETS.map((preset, index) => (
+
+ ))}
+
+
{enabled.length > 0 && (
<>
{
+ if (busy) return
+ setBusy(true)
+ try {
+ for (const exercise of preset.exercises) {
+ await window.api.addExercise(exercise)
+ }
+ for (const meal of preset.meals ?? []) {
+ await window.api.addMeal(meal)
+ }
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {t(preset.titleKey)}
+
+
+ {t(preset.descKey)}
+
+
+
+
+ )
+}
+
function ExerciseRow({
exercise,
last,
diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx
index f901ba8..fda0b75 100644
--- a/src/renderer/src/pages/Settings.tsx
+++ b/src/renderer/src/pages/Settings.tsx
@@ -24,6 +24,7 @@ import type {
DiagnosticsInfo,
Language,
NotificationMode,
+ NotificationTone,
QuietHours,
Settings as SettingsType,
Theme
@@ -148,6 +149,30 @@ export default function SettingsPage(): JSX.Element {
}
]}
/>
+ patch({ notificationTone: v as NotificationTone })}
+ options={[
+ {
+ value: 'calm',
+ label: t('settings.notification_tone.calm')
+ },
+ {
+ value: 'brief',
+ label: t('settings.notification_tone.brief')
+ },
+ {
+ value: 'firm',
+ label: t('settings.notification_tone.firm')
+ },
+ {
+ value: 'playful',
+ label: t('settings.notification_tone.playful')
+ }
+ ]}
+ />
export const RELEASE_NOTES: Record = {
+ '0.8.0': {
+ ru: [
+ {
+ title: 'Добавлен помощник дня',
+ detail:
+ 'Обзор теперь показывает советы по пропускам, питанию, вечерним провалам и текущему ритму.',
+ tag: 'new'
+ },
+ {
+ title: 'Появились быстрые пресеты',
+ detail:
+ 'Офисная разминка, спина и шея, минимум на день и набор после катки добавляются одним кликом.',
+ tag: 'new'
+ },
+ {
+ title: 'Короткие разминка-сессии',
+ detail:
+ 'На главном экране можно запустить 3, 5 или 10 минут и записать подходы в историю.',
+ tag: 'new'
+ },
+ {
+ title: 'Неделя в цифрах',
+ detail:
+ 'Добавлена компактная аналитика: дни активности, повторы, закрываемость, пропуски и лучший день.',
+ tag: 'new'
+ },
+ {
+ title: 'Dota-долг стал мягче',
+ detail:
+ 'Большой долг после матча теперь предлагается разбивать на подходы: сколько сейчас и сколько позже.',
+ tag: 'new'
+ },
+ {
+ title: 'Тон напоминаний',
+ detail:
+ 'В настройках можно выбрать спокойные, краткие, настойчивые или более игровые формулировки.',
+ tag: 'new'
+ }
+ ],
+ en: [
+ {
+ title: 'Added a day assistant',
+ detail:
+ 'Overview now shows suggestions for skips, meals, evening slumps and current rhythm.',
+ tag: 'new'
+ },
+ {
+ title: 'Fast presets are here',
+ detail:
+ 'Office warm-up, back and neck, daily minimum and after-match sets can be added in one click.',
+ tag: 'new'
+ },
+ {
+ title: 'Short warm-up sessions',
+ detail:
+ 'Run 3, 5 or 10 minutes from Overview and log the selected actions into history.',
+ tag: 'new'
+ },
+ {
+ title: 'Week in numbers',
+ detail:
+ 'Compact analytics now show active days, reps, completion, skips and best day.',
+ tag: 'new'
+ },
+ {
+ title: 'Game debt is easier to close',
+ detail:
+ 'Large Dota match debt now suggests sets: how much to do now and how much can wait.',
+ tag: 'new'
+ },
+ {
+ title: 'Reminder tone',
+ detail:
+ 'Settings can switch reminder wording between calm, brief, firm and playful.',
+ tag: 'new'
+ }
+ ]
+ },
'0.7.1': {
ru: [
{
diff --git a/src/shared/types.ts b/src/shared/types.ts
index b2e35f8..7d14d88 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -77,6 +77,7 @@ export const MEAL_PRESETS: MealPreset[] = [
]
export type NotificationMode = 'toast' | 'modal' | 'both'
+export type NotificationTone = 'calm' | 'brief' | 'firm' | 'playful'
export type Theme = 'light' | 'dark' | 'system'
export type Language = 'ru' | 'en'
@@ -96,6 +97,7 @@ export type QuietHours = {
export type Settings = {
globalEnabled: boolean
notificationMode: NotificationMode
+ notificationTone: NotificationTone
soundEnabled: boolean
/**
* TTS голос диктора в окне напоминания: «Время приседать. Десять раз».
@@ -280,6 +282,7 @@ export type MatchSummary = {
export const DEFAULT_SETTINGS: Settings = {
globalEnabled: true,
notificationMode: 'modal',
+ notificationTone: 'calm',
soundEnabled: true,
voicePromptsEnabled: false, // opt-in — на работе с коллегами может смущать
meetingAutoPause: true,