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 статистику после матча в игровые долги. -[![release](https://img.shields.io/badge/release-v0.7.1-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest) -[![tests](https://img.shields.io/badge/tests-245%20passing-green)]() +[![release](https://img.shields.io/badge/release-v0.8.0-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest) +[![tests](https://img.shields.io/badge/tests-249%20passing-green)]() [![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]() ## Что внутри @@ -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,