diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b93aad..69e7577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ ## [Unreleased] +## [0.6.3] — 2026-06-07 + +### Added + +- Главный экран получил новый блок прогресса: мягкий уровень, недельные + мини-челленджи и “игровой долг” после каток. +- Добавлен `src/renderer/src/lib/momentum.ts`: вычисляемая модель ритма недели, + XP/уровня и Dota match-debt без изменения persisted state. +- Добавлены тесты `momentum.test.ts` на недельные челленджи, игровые долги и + расчет уровня. + +### Changed + +- Визуальный бренд в интерфейсе сменен на “Не Залипай”. +- README обновлен под новую продуктовую концепцию: план дня, недельные + челленджи, игровые долги и 241 passing tests. + ## [0.6.2] — 2026-06-06 ### Added @@ -48,13 +65,13 @@ history не перетягивалась. Heatmap стоял пока пользователь не добавит/удалит упражнение. Сейчас новый event `evtHistoryChanged` шлётся из main после `markDone/snooze/skip/markChallengeDone/ - clearHistory/import`, Dashboard на него подписан. +clearHistory/import`, Dashboard на него подписан. - **Rapid double-click больше не пишет в историю дважды.** В Match Summary при быстром тыке ✓ дважды один и тот же challenge мог записаться 2 раза → лишние +N reps в стрик. То же для кнопки «Готово» в ExerciseCard. ref-based дедуп. - **Native save/open dialogs локализованы.** Раньше title `«Сохранить - резервную копию»` показывался даже в EN-локали. +резервную копию»` показывался даже в EN-локали. - **Default exerciseName в challenge editor — пустой** (было «Приседания» — выглядело как недопереведённый русский в EN UI). @@ -362,7 +379,7 @@ блокирует CSRF от browser-вкладок. Body cap 256 KB (OOM-вектор закрыт). Require `application/json`. Generic 400 без error-echo. - **`isQuietAt` wrap-around + day filter.** С `22:00 → 07:00, - days=[Mon..Fri]` теперь правильно проверяется день *начала* окна +days=[Mon..Fri]` теперь правильно проверяется день _начала_ окна (старт Fri 22:00 → активно ночью Sat 02:00). - **DST drift в `history.ts`.** Календарная арифметика (`setDate`) вместо ms-арифметики — на границе DST дни больше не дублируются. @@ -474,7 +491,8 @@ иконки), системный трей, автозапуск с Windows, native-уведомления, NSIS-инсталлятор, auto-update через electron-updater. -[Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.2...HEAD +[Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.3...HEAD +[0.6.3]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.2...v0.6.3 [0.6.2]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.5.8...v0.6.2 [0.5.8]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.8 [0.5.7]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.7 diff --git a/README.md b/README.md index 28cd717..bdb13e0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# Laude — Exercise Reminder +# Не Залипай — Exercise Reminder -Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений. +Windows desktop приложение, которое помогает не залипать за компьютером: держит план дня, напоминает размяться, ведёт недельные челленджи и превращает Dota 2 статистику после матча в игровые долги. -[![release](https://img.shields.io/badge/release-v0.6.1-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest) -[![tests](https://img.shields.io/badge/tests-238%20passing-green)]() +[![release](https://img.shields.io/badge/release-v0.6.3-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest) +[![tests](https://img.shields.io/badge/tests-241%20passing-green)]() [![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]() ## Что внутри @@ -11,6 +11,7 @@ Windows desktop приложение, которое напоминает дел - **Гибкие напоминания** — любое количество упражнений, интервал от минуты до часов, разные иконки. - **Питание** — отдельная вкладка с приёмами пищи по времени суток (завтрак/обед/ужин/перекусы), выбор дней недели, пресеты быстрого добавления. Напоминания по настенным часам, а не по интервалу. - **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд. +- **Сегодня** — главный экран с планом дня, уровнем, недельными мини-челленджами и игровым долгом. - **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели. - **Сделал частично** — степпер `−/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число. - **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`). @@ -84,8 +85,9 @@ src/main/adaptive.test.ts (6) src/renderer/src/lib/day-plan.test.ts (6) src/shared/types.test.ts (4) src/renderer/src/lib/icon-choices.test.ts (4) +src/renderer/src/lib/momentum.test.ts (3) ────────────────────────────────────────── - 238 ✓ + 241 ✓ ``` Покрытие: IPC-валидация (упражнения/челленджи/приёмы пищи), persistence (миграции, карантин битого JSON, history cap), scheduler-гейтинг (тихие часы, ВКС-пауза, daily-goal), планирование приёмов пищи по времени суток (DST-safe), детект ВКС, история/стрики (DST), тихие часы (wrap), парсер VDF для Steam-конфигов, достижения, i18n с плюрализацией RU/EN, дефолты shared-типов. diff --git a/package.json b/package.json index 679a83e..6c761bb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "laude", "version": "0.6.2", - "description": "Exercise reminder — Windows desktop app", + "description": "Не Залипай — Windows desktop rhythm and exercise reminder", "main": "out/main/index.js", "author": "AnRil", "private": true, diff --git a/src/renderer/index.html b/src/renderer/index.html index 79ffb89..78bbd44 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -9,8 +9,11 @@ для Tailwind utility-классов и инлайн-стилей framer-motion. font-src включает data: на случай если кто-то вставит base64 SVG-glyph. --> - - Exercise Reminder + + Не Залипай
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 762c257..0aedd5b 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -162,7 +162,7 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element { <>
- Laude + Не Залипай
{t('sidebar.slogan')} diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 2fd45a5..63ef2e0 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -18,7 +18,7 @@ export const ru: Dict = { 'nav.games': 'Игры', 'nav.challenges': 'Челленджи', 'nav.settings': 'Настройки', - 'sidebar.slogan': 'Двигайся осознанно', + 'sidebar.slogan': 'Держи ритм за компом', 'sidebar.status_tracking': 'Активность отслеживается', 'titlebar.menu_aria': 'Меню', 'titlebar.minimize_aria': 'Свернуть', @@ -26,7 +26,7 @@ export const ru: Dict = { 'titlebar.restore_aria': 'Восстановить размер', 'titlebar.tray_aria': 'В трей', 'titlebar.close_aria': 'Закрыть', - 'titlebar.app_title': 'Exercise Reminder', + 'titlebar.app_title': 'Не Залипай', // Common buttons / actions 'btn.add': 'Добавить', @@ -124,6 +124,44 @@ export const ru: Dict = { 'dashboard.empty.title': 'Программа пуста', 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', + // Momentum / today redesign + 'momentum.level.title': 'Уровень', + 'momentum.level.number': 'уровень {n}', + 'momentum.level.next': 'до «{name}» осталось {n} XP', + 'momentum.level.max': 'максимальный уровень', + 'momentum.level.warmup': 'Разогрелся', + 'momentum.level.rhythm': 'Вошёл в ритм', + 'momentum.level.steady': 'Держу форму', + 'momentum.level.back': 'Железная спина', + 'momentum.level.machine': 'Машина привычек', + 'momentum.level.legend': 'Легенда перерывов', + 'momentum.week.kicker': 'Челленджи недели', + 'momentum.week.title': 'Ритм за неделю', + 'momentum.week.summary': '{days} дн · {reps} повт', + 'momentum.quest.complete': 'закрыто', + 'momentum.quest.week_rhythm.title': '5 дней без нуля', + 'momentum.quest.week_rhythm.desc': 'отметь активность в 5 разных дней', + 'momentum.quest.week_reps.title': '1000 повторов', + 'momentum.quest.week_reps.desc': 'набери тысячу повторов за неделю', + 'momentum.quest.match_debt.title': 'Закрыть катки', + 'momentum.quest.match_debt.desc': 'закрой 3 игровых долга за неделю', + 'momentum.quest.today_anchor.title': 'Сегодня не ноль', + 'momentum.quest.today_anchor.desc': 'сделай хотя бы одно действие сегодня', + 'momentum.game.kicker': 'После катки', + 'momentum.game.title': 'Игровой долг', + 'momentum.game.status': 'Dota GSI', + 'momentum.game.live': 'live', + 'momentum.game.setup': 'настройка', + 'momentum.game.off': 'выкл', + 'momentum.game.today': 'сегодня', + 'momentum.game.week': 'неделя', + 'momentum.game.reps': '{n} повт', + 'momentum.game.entries': '{n} закрыто', + 'momentum.game.last': 'последняя катка: {date}', + 'momentum.game.no_matches': 'закрытых игровых долгов пока нет', + 'momentum.game.no_rules': + 'Добавь челлендж за матч, и здесь появится долг после каток.', + // Exercises 'exercises.kicker': 'Программа', 'exercises.title': 'Упражнения', @@ -433,7 +471,7 @@ export const en: Dict = { 'nav.games': 'Games', 'nav.challenges': 'Challenges', 'nav.settings': 'Settings', - 'sidebar.slogan': 'Move with intention', + 'sidebar.slogan': 'Keep your PC rhythm', 'sidebar.status_tracking': 'Activity tracking is on', 'titlebar.menu_aria': 'Menu', 'titlebar.minimize_aria': 'Minimize', @@ -441,7 +479,7 @@ export const en: Dict = { 'titlebar.restore_aria': 'Restore size', 'titlebar.tray_aria': 'To tray', 'titlebar.close_aria': 'Close', - 'titlebar.app_title': 'Exercise Reminder', + 'titlebar.app_title': 'Ne Zalipay', // Common buttons 'btn.add': 'Add', @@ -538,6 +576,44 @@ export const en: Dict = { 'dashboard.empty.title': 'Program is empty', 'dashboard.empty.hint': 'Add your first exercise to start', + // Momentum / today redesign + 'momentum.level.title': 'Level', + 'momentum.level.number': 'level {n}', + 'momentum.level.next': '{n} XP to "{name}"', + 'momentum.level.max': 'max level', + 'momentum.level.warmup': 'Warmed up', + 'momentum.level.rhythm': 'In rhythm', + 'momentum.level.steady': 'Keeping shape', + 'momentum.level.back': 'Iron back', + 'momentum.level.machine': 'Habit machine', + 'momentum.level.legend': 'Break legend', + 'momentum.week.kicker': 'Weekly challenges', + 'momentum.week.title': 'Week rhythm', + 'momentum.week.summary': '{days} d · {reps} reps', + 'momentum.quest.complete': 'done', + 'momentum.quest.week_rhythm.title': '5 non-zero days', + 'momentum.quest.week_rhythm.desc': 'log activity on 5 different days', + 'momentum.quest.week_reps.title': '1000 reps', + 'momentum.quest.week_reps.desc': 'reach one thousand reps this week', + 'momentum.quest.match_debt.title': 'Close matches', + 'momentum.quest.match_debt.desc': 'close 3 game debts this week', + 'momentum.quest.today_anchor.title': 'Today is not zero', + 'momentum.quest.today_anchor.desc': 'complete at least one action today', + 'momentum.game.kicker': 'After match', + 'momentum.game.title': 'Game debt', + 'momentum.game.status': 'Dota GSI', + 'momentum.game.live': 'live', + 'momentum.game.setup': 'setup', + 'momentum.game.off': 'off', + 'momentum.game.today': 'today', + 'momentum.game.week': 'week', + 'momentum.game.reps': '{n} reps', + 'momentum.game.entries': '{n} closed', + 'momentum.game.last': 'last match: {date}', + 'momentum.game.no_matches': 'no closed game debts yet', + 'momentum.game.no_rules': + 'Add a per-match challenge and game debt will show up here.', + // Exercises 'exercises.kicker': 'Program', 'exercises.title': 'Exercises', diff --git a/src/renderer/src/lib/momentum.test.ts b/src/renderer/src/lib/momentum.test.ts new file mode 100644 index 0000000..49d95b8 --- /dev/null +++ b/src/renderer/src/lib/momentum.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { Challenge, Exercise, HistoryEntry } from '@shared/types' +import { computeMomentumSummary } from './momentum' + +const NOW = new Date(2026, 5, 10, 12, 0, 0, 0).getTime() +const DAY = 24 * 60 * 60 * 1000 + +function exercise(partial: Partial & { id: string }): Exercise { + return { + id: partial.id, + name: partial.name ?? partial.id, + reps: partial.reps ?? 10, + icon: partial.icon ?? 'Activity', + intervalMinutes: partial.intervalMinutes ?? 30, + enabled: partial.enabled ?? true, + nextFireAt: partial.nextFireAt ?? NOW + 60_000, + category: partial.category, + dailyGoal: partial.dailyGoal, + adaptive: partial.adaptive + } +} + +function challenge(partial: Partial & { id: string }): Challenge { + return { + id: partial.id, + name: partial.name ?? partial.id, + gameId: 'dota2', + stat: partial.stat ?? 'deaths', + multiplier: partial.multiplier ?? 3, + exerciseName: partial.exerciseName ?? 'Squats', + icon: partial.icon ?? 'Dumbbell', + enabled: partial.enabled ?? true + } +} + +function done( + exerciseId: string, + daysAgo: number, + reps: number, + source?: HistoryEntry['source'] +): HistoryEntry { + return { + exerciseId, + ts: NOW - daysAgo * DAY, + action: 'done', + reps, + source + } +} + +describe('computeMomentumSummary', () => { + it('tracks weekly quests and match debt from history', () => { + const summary = computeMomentumSummary({ + now: NOW, + exercises: [exercise({ id: 'pushups', reps: 15 })], + challenges: [challenge({ id: 'c1' })], + history: [ + done('pushups', 0, 15), + done('pushups', 1, 20), + done('pushups', 2, 25), + done('challenge:c1', 0, 30, 'match'), + done('challenge:c1', 2, 45, 'match') + ] + }) + + expect(summary.todayReps).toBe(45) + expect(summary.weekReps).toBe(135) + expect(summary.weekActiveDays).toBe(3) + expect(summary.gameDebt.matchEntriesToday).toBe(1) + expect(summary.gameDebt.matchEntriesWeek).toBe(2) + expect(summary.gameDebt.matchRepsWeek).toBe(75) + expect(summary.weeklyQuests.map((quest) => quest.id)).toEqual([ + 'week_rhythm', + 'week_reps', + 'match_debt', + 'today_anchor' + ]) + }) + + it('hides match quest when there are no enabled challenge rules', () => { + const summary = computeMomentumSummary({ + now: NOW, + exercises: [exercise({ id: 'pushups' })], + challenges: [challenge({ id: 'c1', enabled: false })], + history: [done('pushups', 0, 10)] + }) + + expect(summary.gameDebt.activeRules).toBe(0) + expect(summary.weeklyQuests.map((quest) => quest.id)).toEqual([ + 'week_rhythm', + 'week_reps', + 'today_anchor' + ]) + }) + + it('computes a soft level from reps, active days and match activity', () => { + const summary = computeMomentumSummary({ + now: NOW, + exercises: [exercise({ id: 'pushups' })], + challenges: [challenge({ id: 'c1' })], + history: [ + done('pushups', 0, 100), + done('pushups', 1, 100), + done('challenge:c1', 0, 100, 'match') + ] + }) + + expect(summary.level.xp).toBe(425) + expect(summary.level.key).toBe('momentum.level.rhythm') + expect(summary.level.progressPct).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/lib/momentum.ts b/src/renderer/src/lib/momentum.ts new file mode 100644 index 0000000..851aa36 --- /dev/null +++ b/src/renderer/src/lib/momentum.ts @@ -0,0 +1,216 @@ +import type { Challenge, Exercise, HistoryEntry } from '@shared/types' +import { dayKey } from './history' + +export type MomentumLevel = { + key: string + xp: number + levelIndex: number + current: number + target: number + progressPct: number + nextKey?: string +} + +export type WeeklyQuest = { + id: 'week_rhythm' | 'week_reps' | 'match_debt' | 'today_anchor' + titleKey: string + descKey: string + current: number + target: number + progressPct: number + complete: boolean + tone: 'accent' | 'success' | 'warning' | 'info' +} + +export type GameDebtSummary = { + activeRules: number + matchRepsToday: number + matchRepsWeek: number + matchEntriesToday: number + matchEntriesWeek: number + lastMatchAt?: number +} + +export type MomentumSummary = { + level: MomentumLevel + weeklyQuests: WeeklyQuest[] + gameDebt: GameDebtSummary + weekReps: number + weekActiveDays: number + todayReps: number +} + +const LEVELS = [ + { key: 'momentum.level.warmup', xp: 0 }, + { key: 'momentum.level.rhythm', xp: 120 }, + { key: 'momentum.level.steady', xp: 450 }, + { key: 'momentum.level.back', xp: 1000 }, + { key: 'momentum.level.machine', xp: 2500 }, + { key: 'momentum.level.legend', xp: 6000 } +] as const + +function startOfDay(ts: number): Date { + const d = new Date(ts) + d.setHours(0, 0, 0, 0) + return d +} + +function startOfWeek(ts: number): Date { + const d = startOfDay(ts) + const day = d.getDay() + const delta = day === 0 ? -6 : 1 - day + d.setDate(d.getDate() + delta) + return d +} + +function entryReps( + entry: HistoryEntry, + exercisesById: Map +): number { + return ( + entry.actualReps ?? + entry.reps ?? + exercisesById.get(entry.exerciseId)?.reps ?? + 0 + ) +} + +function isMatchEntry(entry: HistoryEntry): boolean { + return entry.source === 'match' || entry.exerciseId.startsWith('challenge:') +} + +function computeLevel(xp: number): MomentumLevel { + let index = 0 + for (let i = 0; i < LEVELS.length; i++) { + if (xp >= LEVELS[i].xp) index = i + } + + const currentLevel = LEVELS[index] + const nextLevel = LEVELS[index + 1] + const current = Math.max(0, xp - currentLevel.xp) + const target = nextLevel ? nextLevel.xp - currentLevel.xp : current || 1 + const progressPct = nextLevel + ? Math.min(100, Math.round((current / target) * 100)) + : 100 + + return { + key: currentLevel.key, + xp, + levelIndex: index + 1, + current, + target, + progressPct, + nextKey: nextLevel?.key + } +} + +function quest( + id: WeeklyQuest['id'], + current: number, + target: number, + tone: WeeklyQuest['tone'] +): WeeklyQuest { + return { + id, + titleKey: `momentum.quest.${id}.title`, + descKey: `momentum.quest.${id}.desc`, + current, + target, + progressPct: Math.min(100, Math.round((current / target) * 100)), + complete: current >= target, + tone + } +} + +export function computeMomentumSummary({ + history, + exercises, + challenges, + now = Date.now() +}: { + history: HistoryEntry[] + exercises: Exercise[] + challenges: Challenge[] + now?: number +}): MomentumSummary { + const exercisesById = new Map( + exercises.map((exercise) => [exercise.id, exercise]) + ) + const today = dayKey(now) + const weekStart = startOfWeek(now).getTime() + + let totalReps = 0 + let todayReps = 0 + let weekReps = 0 + let matchRepsToday = 0 + let matchRepsWeek = 0 + let matchEntriesToday = 0 + let matchEntriesWeek = 0 + let lastMatchAt: number | undefined + const allActiveDays = new Set() + const weekActiveDays = new Set() + + for (const entry of history) { + if (entry.action !== 'done') continue + + const reps = entryReps(entry, exercisesById) + const key = dayKey(entry.ts) + const inWeek = entry.ts >= weekStart && entry.ts <= now + const match = isMatchEntry(entry) + + totalReps += reps + allActiveDays.add(key) + + if (key === today) todayReps += reps + if (inWeek) { + weekReps += reps + weekActiveDays.add(key) + } + + if (match) { + if (lastMatchAt === undefined || entry.ts > lastMatchAt) { + lastMatchAt = entry.ts + } + if (key === today) { + matchEntriesToday++ + matchRepsToday += reps + } + if (inWeek) { + matchEntriesWeek++ + matchRepsWeek += reps + } + } + } + + const activeRules = challenges.filter((challenge) => challenge.enabled).length + const xp = totalReps + allActiveDays.size * 50 + matchEntriesWeek * 25 + const weeklyQuests: WeeklyQuest[] = [ + quest('week_rhythm', weekActiveDays.size, 5, 'success'), + quest('week_reps', weekReps, 1000, 'accent'), + quest('today_anchor', todayReps > 0 ? 1 : 0, 1, 'info') + ] + + if (activeRules > 0) { + weeklyQuests.splice( + 2, + 0, + quest('match_debt', matchEntriesWeek, 3, 'warning') + ) + } + + return { + level: computeLevel(xp), + weeklyQuests, + gameDebt: { + activeRules, + matchRepsToday, + matchRepsWeek, + matchEntriesToday, + matchEntriesWeek, + lastMatchAt + }, + weekReps, + weekActiveDays: weekActiveDays.size, + todayReps + } +} diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 9dab452..c5329d5 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -11,7 +11,10 @@ import { CalendarCheck, Target, RotateCcw, - Check + Check, + Trophy, + Swords, + BadgeCheck } from 'lucide-react' import { useAppStore } from '../store/appStore' import { ExerciseCard } from '../components/ExerciseCard' @@ -40,6 +43,11 @@ import { repsDoneTodayForExercise, todayKey } from '../lib/history' +import { + computeMomentumSummary, + type MomentumSummary, + type WeeklyQuest +} from '../lib/momentum' export default function Dashboard(): JSX.Element { const state = useAppStore((s) => s.state) @@ -54,6 +62,7 @@ export default function Dashboard(): JSX.Element { // the parent re-renders even when nothing changed. const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises]) const meals = useMemo(() => state?.meals ?? [], [state?.meals]) + const challenges = useMemo(() => state?.challenges ?? [], [state?.challenges]) const settings = state?.settings const [planActionKey, setPlanActionKey] = useState(null) @@ -129,6 +138,10 @@ export default function Dashboard(): JSX.Element { void ticks return computeTodayPlan({ exercises, meals, history }) }, [exercises, meals, history, ticks]) + const momentum = useMemo( + () => computeMomentumSummary({ history, exercises, challenges }), + [history, exercises, challenges] + ) const paused = !settings?.globalEnabled @@ -327,6 +340,14 @@ export default function Dashboard(): JSX.Element { onItemDone={(item) => void handlePlanItemDone(item)} /> + + {history.length > 0 && (
@@ -393,6 +414,238 @@ export default function Dashboard(): JSX.Element { ) } +function MomentumPanel({ + momentum, + gamesLive, + gamesEnabledButNotLive, + lang, + t +}: { + momentum: MomentumSummary + gamesLive: boolean + gamesEnabledButNotLive: boolean + lang: Language + t: TFn +}): JSX.Element { + const gameStatus = gamesLive + ? t('momentum.game.live') + : gamesEnabledButNotLive + ? t('momentum.game.setup') + : t('momentum.game.off') + + return ( +
+
+
+
+ +
+
+
+ {t('momentum.level.title')} +
+
+ {t(momentum.level.key)} +
+
+
+
+
+
+ {t('momentum.level.number', { + n: momentum.level.levelIndex + })} +
+
+ {momentum.level.xp} +
+
+
+ {momentum.level.nextKey + ? t('momentum.level.next', { + name: t(momentum.level.nextKey), + n: momentum.level.target - momentum.level.current + }) + : t('momentum.level.max')} +
+
+ +
+ +
+
+
+
+ {t('momentum.week.kicker')} +
+

+ {t('momentum.week.title')} +

+
+
+ {t('momentum.week.summary', { + days: momentum.weekActiveDays, + reps: momentum.weekReps + })} +
+
+
+ {momentum.weeklyQuests.map((quest) => ( + + ))} +
+
+ +
+
+
+ +
+
+
+ {t('momentum.game.kicker')} +
+

+ {t('momentum.game.title')} +

+
+
+ +
+
+
+ {t('momentum.game.status')} +
+
+ {gameStatus} +
+
+ + {momentum.gameDebt.activeRules > 0 ? ( + <> +
+ + +
+
+ {momentum.gameDebt.lastMatchAt + ? t('momentum.game.last', { + date: new Date( + momentum.gameDebt.lastMatchAt + ).toLocaleDateString(lang === 'en' ? 'en-US' : 'ru-RU', { + day: 'numeric', + month: 'short' + }) + }) + : t('momentum.game.no_matches')} +
+ + ) : ( +
+ {t('momentum.game.no_rules')} +
+ )} +
+
+
+ ) +} + +function WeeklyQuestRow({ + quest, + t +}: { + quest: WeeklyQuest + t: TFn +}): JSX.Element { + const IconCmp = + quest.id === 'match_debt' + ? Swords + : quest.id === 'today_anchor' + ? BadgeCheck + : quest.id === 'week_rhythm' + ? Flame + : TrendingUp + + return ( +
+
+ +
+
+
+
+ {t(quest.titleKey)} +
+
+ {quest.current}/{quest.target} +
+
+
+ {quest.complete ? t('momentum.quest.complete') : t(quest.descKey)} +
+ +
+
+ ) +} + +function GameDebtStat({ + label, + value, + hint +}: { + label: string + value: string + hint: string +}): JSX.Element { + return ( +
+
{label}
+
+ {value} +
+
{hint}
+
+ ) +} + function TodayPlanPanel({ plan, paused, @@ -650,17 +903,33 @@ function PlanListRow({ function ProgressBar({ pct, - tone + tone, + compact = false }: { pct: number - tone: 'accent' | 'success' + tone: 'accent' | 'success' | 'warning' | 'info' + compact?: boolean }): JSX.Element { + const toneClass = + tone === 'accent' + ? 'bg-accent' + : tone === 'success' + ? 'bg-success' + : tone === 'warning' + ? 'bg-warning' + : 'bg-info' + return ( -
+
diff --git a/src/shared/release-notes.ts b/src/shared/release-notes.ts index 8208f29..52f8503 100644 --- a/src/shared/release-notes.ts +++ b/src/shared/release-notes.ts @@ -21,6 +21,60 @@ export type ReleaseNoteItem = { export type ReleaseNotes = Record export const RELEASE_NOTES: Record = { + '0.6.3': { + ru: [ + { + title: 'Новый главный экран “Не Залипай”', + detail: + 'Сегодня теперь показывает не только план, но и недельный ритм, уровень и игровые долги.', + tag: 'new' + }, + { + title: 'Мини-челленджи недели', + detail: + '5 дней без нуля, 1000 повторов, “сегодня не ноль” и закрытые катки считаются автоматически.', + tag: 'new' + }, + { + title: 'Игровой долг после каток', + detail: + 'На Dashboard видно, сколько Dota-долгов закрыто сегодня и за неделю.', + tag: 'new' + }, + { + title: 'Мягкая система уровней', + detail: + 'XP считается из повторов, активных дней и закрытых игровых челленджей.', + tag: 'new' + } + ], + en: [ + { + title: 'New “Ne Zalipay” Today screen', + detail: + 'Today now shows the day plan, weekly rhythm, level and game debts.', + tag: 'new' + }, + { + title: 'Weekly mini-challenges', + detail: + '5 non-zero days, 1000 reps, “today is not zero” and closed matches are tracked automatically.', + tag: 'new' + }, + { + title: 'Game debt after matches', + detail: + 'Dashboard shows how many Dota debts were closed today and this week.', + tag: 'new' + }, + { + title: 'Soft level system', + detail: + 'XP comes from reps, active days and completed game challenges.', + tag: 'new' + } + ] + }, '0.5.8': { ru: [ { @@ -204,14 +258,12 @@ export const RELEASE_NOTES: Record = { en: [ { title: 'Reminder categories', - detail: - 'Beyond exercises — hydration, eye rest (20-20-20), posture.', + detail: 'Beyond exercises — hydration, eye rest (20-20-20), posture.', tag: 'new' }, { title: 'Voice prompts', - detail: - 'Speaks the exercise name and count. Toggle in Settings.', + detail: 'Speaks the exercise name and count. Toggle in Settings.', tag: 'new' }, { @@ -255,7 +307,8 @@ export const RELEASE_NOTES: Record = { ru: [ { title: 'Sandbox для окон', - detail: 'Окна изолированы на уровне OS — даже RCE в рендере не достанет main.', + detail: + 'Окна изолированы на уровне OS — даже RCE в рендере не достанет main.', tag: 'security' }, { @@ -282,7 +335,8 @@ export const RELEASE_NOTES: Record = { en: [ { title: 'Window sandbox', - detail: 'OS-level isolation — even RCE in the renderer cannot reach main.', + detail: + 'OS-level isolation — even RCE in the renderer cannot reach main.', tag: 'security' }, { @@ -311,24 +365,28 @@ export const RELEASE_NOTES: Record = { ru: [ { title: 'Фоновое скачивание апдейта', - detail: 'Можно уйти на Dashboard и заниматься — апдейт качается в фоне.', + detail: + 'Можно уйти на Dashboard и заниматься — апдейт качается в фоне.', tag: 'new' }, { title: 'Моментальный рестарт', - detail: 'Кнопка «Рестарт» — ~1-2 сек до открытия новой версии, без диалогов NSIS.', + detail: + 'Кнопка «Рестарт» — ~1-2 сек до открытия новой версии, без диалогов NSIS.', tag: 'new' } ], en: [ { title: 'Background update download', - detail: 'You can go to Dashboard and work — the update keeps downloading.', + detail: + 'You can go to Dashboard and work — the update keeps downloading.', tag: 'new' }, { title: 'Instant restart', - detail: 'Restart button — ~1-2 sec to the new version, no NSIS dialogs.', + detail: + 'Restart button — ~1-2 sec to the new version, no NSIS dialogs.', tag: 'new' } ] @@ -352,7 +410,9 @@ export function unseenVersions( // явный «What's new» из Settings. return all.filter((v) => v === current) } - return all.filter((v) => compareSemver(v, lastSeen) > 0 && compareSemver(v, current) <= 0) + return all.filter( + (v) => compareSemver(v, lastSeen) > 0 && compareSemver(v, current) <= 0 + ) } function parseSemver(v: string): [number, number, number] {