diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f13b62 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Laude — Exercise Reminder + +Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений. + +[![release](https://img.shields.io/badge/release-v0.4.0-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest) +[![tests](https://img.shields.io/badge/tests-33%20passing-green)]() +[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]() + +## Что внутри + +- **Гибкие напоминания** — любое количество упражнений, интервал от минуты до часов, разные иконки. +- **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд. +- **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели. +- **Сделал частично** — степпер `−/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число. +- **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`). +- **Apple-style интерфейс** — Plus Jakarta Sans + Bricolage Grotesque, iOS-палитра, vibrancy sidebar, spring-анимации, светлая/тёмная/системная тема. +- **Два языка** — русский и английский, переключение мгновенное. +- **Auto-update** — приложение само скачивает новые версии из Gitea release (проверка каждый час). + +## Скриншоты + +> _TODO: вставить screenshots Dashboard / Reminder / Match summary (light + dark)._ + +## Установка + +Скачай последний `Exercise-Reminder-Setup-X.Y.Z.exe` со страницы релизов и запусти. Установщик: + +- Создаёт ярлык на рабочем столе и в Пуске +- Сохраняет настройки в `%APPDATA%\Exercise Reminder\` +- При запуске поверх существующей инсталляции — обновляет, настройки сохраняются + +Windows SmartScreen может предупредить «не доверено» — приложение не подписано code-signing сертификатом. Нажми `Подробнее` → `Выполнить в любом случае`. + +## Разработка + +```bash +git clone https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude.git +cd laude +npm install +npm run dev +``` + +Полезные команды: + +```bash +npm run typecheck # tsc по main + renderer +npm run test # vitest в watch-режиме +npm run test:run # vitest один раз (для CI) +npm run build # сборка без NSIS +npm run dist # сборка + NSIS-инсталлятор → release/ +npm run release -- -Bump patch # bump версии + tag + push + upload в Gitea +``` + +Документ `RELEASING.md` описывает процесс выпуска новых версий. + +## Архитектура + +- **Electron 33** — multi-process: main (Node/scheduler/GSI) + preload (contextBridge) + renderer (React) +- **Renderer** — React 18, TypeScript 5, Vite 5, Tailwind 3, framer-motion, react-router, zustand +- **Persistence** — единственный JSON-файл `%APPDATA%\Exercise Reminder\app-state.json` (debounced writes) +- **IPC** — типизированные каналы через `src/shared/ipc.ts`, обёрнуто preload-ом +- **i18n** — самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка) + хук `useT()` +- **Auto-update** — `electron-updater` с `generic` provider, манифест `latest.yml` лежит в Gitea release attachments +- **GSI Dota 2** — локальный HTTP-сервер слушает GameStateIntegration коллбэки от Steam, парсит match-end events + +## Тесты + +``` +src/shared/types.test.ts (4) +src/renderer/src/lib/format.test.ts (8) +src/main/games/vdf.test.ts (11) +src/renderer/src/i18n/i18n.test.ts (10) +───────────────────────────────────── + 33 ✓ +``` + +Покрытие: чистые helpers (форматирование, парсер VDF для Steam-конфигов), i18n с плюрализацией для RU/EN, дефолты shared-типов. + +## Лицензия + +Пока не указана. По умолчанию все права защищены. Если хочешь форк/использование — открой issue. + +## Stack + +- [Electron](https://www.electronjs.org/) · runtime +- [electron-vite](https://electron-vite.org/) · build +- [React](https://react.dev/) + [TypeScript](https://www.typescriptlang.org/) +- [Tailwind CSS](https://tailwindcss.com/) · стили +- [framer-motion](https://motion.dev/) · анимации +- [lucide-react](https://lucide.dev/) · иконки +- [electron-updater](https://www.electron.build/auto-update) · auto-update +- [Vitest](https://vitest.dev/) · тесты +- Шрифты: [Plus Jakarta Sans](https://fonts.google.com/specimen/Plus+Jakarta+Sans), [Bricolage Grotesque](https://fonts.google.com/specimen/Bricolage+Grotesque), [JetBrains Mono](https://fonts.google.com/specimen/JetBrains+Mono) diff --git a/package.json b/package.json index f1d5172..73f170f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "laude", - "version": "0.4.0", + "version": "0.5.0", "description": "Exercise reminder — Windows desktop app", "main": "out/main/index.js", "author": "AnRil", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0dfe8a0..f3f54a1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,8 +4,10 @@ import type { Challenge, Exercise, GameId, Settings } from '@shared/types' import { addChallenge, addExercise, + clearHistory, deleteChallenge, deleteExercise, + getHistory, getState, markDone, setGameEnabled, @@ -73,11 +75,14 @@ export function registerIpc(): void { return ex }) - ipcMain.handle(IPC.markDone, (_e, id: string) => { - const ex = markDone(id) - broadcastState() - return ex - }) + ipcMain.handle( + IPC.markDone, + (_e, id: string, actualReps?: number) => { + const ex = markDone(id, actualReps) + broadcastState() + return ex + } + ) ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => { const ex = snooze(id, minutes) @@ -213,4 +218,10 @@ export function registerIpc(): void { ipcMain.handle(IPC.updaterCheck, () => checkForUpdates()) ipcMain.handle(IPC.updaterDownload, () => downloadUpdate()) ipcMain.handle(IPC.updaterInstall, () => quitAndInstall()) + + // History + ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs)) + ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => + clearHistory(beforeTs) + ) } diff --git a/src/main/scheduler.ts b/src/main/scheduler.ts index a0ba8d3..d177a70 100644 --- a/src/main/scheduler.ts +++ b/src/main/scheduler.ts @@ -1,6 +1,7 @@ import { powerMonitor, BrowserWindow } from 'electron' import { IPC } from '@shared/ipc' import type { Tick } from '@shared/types' +import { isQuietAt } from '@shared/types' import { getExercises, getSettings, updateExercise } from './store' import { fireReminder } from './notifications' @@ -15,12 +16,15 @@ function checkDueExercises(): void { const settings = getSettings() if (!settings.globalEnabled) return + // Inside the quiet window: defer all due fires to the next minute boundary. + // The next tick after the window closes will pick them up. + if (isQuietAt(settings.quietHours, new Date())) return + const now = Date.now() const exercises = getExercises() for (const ex of exercises) { if (!ex.enabled) continue if (ex.nextFireAt <= now) { - // Fire once, reschedule from now (drop missed intervals). const updated = updateExercise(ex.id, { nextFireAt: now + ex.intervalMinutes * 60_000 }) diff --git a/src/main/store.ts b/src/main/store.ts index d57175e..782e34f 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -8,10 +8,15 @@ import { DEFAULT_SETTINGS, Exercise, GameId, + HistoryAction, + HistoryEntry, SAMPLE_EXERCISES, Settings } from '@shared/types' +/** Keep at most this many entries (~3 years if ~10/day). Trim oldest. */ +const HISTORY_MAX = 10_000 + let cache: AppState | null = null let storePath = '' let pendingWrite: NodeJS.Timeout | null = null @@ -56,7 +61,8 @@ function makeInitial(): AppState { enabled: false } ], - gamesEnabled: {} + gamesEnabled: {}, + history: [] } } @@ -74,13 +80,49 @@ function load(): AppState { exercises: parsed.exercises ?? [], settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) }, challenges: parsed.challenges ?? [], - gamesEnabled: parsed.gamesEnabled ?? {} + gamesEnabled: parsed.gamesEnabled ?? {}, + history: parsed.history ?? [] } } catch { return makeInitial() } } +function appendHistory( + exerciseId: string, + action: HistoryAction, + actualReps?: number +): void { + const state = getState() + if (!state.history) state.history = [] + const entry: HistoryEntry = { ts: Date.now(), exerciseId, action } + if (actualReps !== undefined) entry.actualReps = actualReps + state.history.push(entry) + // Cap size — trim oldest 10% when over limit, so we don't trim every write. + if (state.history.length > HISTORY_MAX) { + state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9)) + } + scheduleWrite() +} + +export function getHistory(sinceMs?: number): HistoryEntry[] { + const all = getState().history ?? [] + if (sinceMs == null) return all + return all.filter((e) => e.ts >= sinceMs) +} + +export function clearHistory(beforeTs?: number): number { + const state = getState() + const before = state.history?.length ?? 0 + if (beforeTs == null) { + state.history = [] + } else { + state.history = (state.history ?? []).filter((e) => e.ts >= beforeTs) + } + scheduleWrite() + return before - (state.history?.length ?? 0) +} + function flush(): void { if (!cache) return writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8') @@ -155,12 +197,16 @@ export function deleteExercise(id: string): boolean { return changed } -export function markDone(id: string): Exercise | undefined { +export function markDone( + id: string, + actualReps?: number +): Exercise | undefined { const state = getState() const ex = state.exercises.find((e) => e.id === id) if (!ex) return undefined ex.lastDoneAt = Date.now() ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 + appendHistory(id, 'done', actualReps) scheduleWrite() return ex } @@ -170,6 +216,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined { const ex = state.exercises.find((e) => e.id === id) if (!ex) return undefined ex.nextFireAt = Date.now() + minutes * 60_000 + appendHistory(id, 'snooze') scheduleWrite() return ex } @@ -179,6 +226,7 @@ export function skip(id: string): Exercise | undefined { const ex = state.exercises.find((e) => e.id === id) if (!ex) return undefined ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 + appendHistory(id, 'skip') scheduleWrite() return ex } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7817f5b..77afa74 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import type { Exercise, GameId, GameStatus, + HistoryEntry, MatchSummary, Settings, Tick, @@ -33,7 +34,8 @@ const api = { ipcRenderer.invoke(IPC.deleteExercise, id), toggleExercise: (id: string, enabled: boolean): Promise => ipcRenderer.invoke(IPC.toggleExercise, id, enabled), - markDone: (id: string): Promise => ipcRenderer.invoke(IPC.markDone, id), + markDone: (id: string, actualReps?: number): Promise => + ipcRenderer.invoke(IPC.markDone, id, actualReps), snooze: (id: string, minutes: number): Promise => ipcRenderer.invoke(IPC.snooze, id, minutes), skip: (id: string): Promise => ipcRenderer.invoke(IPC.skip, id), @@ -87,6 +89,12 @@ const api = { updaterDownload: (): Promise => ipcRenderer.invoke(IPC.updaterDownload), updaterInstall: (): Promise => ipcRenderer.invoke(IPC.updaterInstall), + // History + getHistory: (sinceMs?: number): Promise => + ipcRenderer.invoke(IPC.getHistory, sinceMs), + clearHistory: (beforeTs?: number): Promise => + ipcRenderer.invoke(IPC.clearHistory, beforeTs), + onTick: (h: Handler): Unsub => on(IPC.evtTick, h), onFire: (h: Handler): Unsub => on(IPC.evtFire, h), onMatchEnd: (h: Handler): Unsub => on(IPC.evtMatchEnd, h), diff --git a/src/renderer/src/ReminderApp.tsx b/src/renderer/src/ReminderApp.tsx index 1868425..8e4b46d 100644 --- a/src/renderer/src/ReminderApp.tsx +++ b/src/renderer/src/ReminderApp.tsx @@ -1,6 +1,15 @@ import { useEffect, useRef, useState } from 'react' import { motion } from 'framer-motion' -import { Check, Clock, X, Trophy, Frown, Gamepad2 } from 'lucide-react' +import { + Check, + Clock, + X, + Trophy, + Frown, + Gamepad2, + Minus, + Plus +} from 'lucide-react' import type { Exercise, MatchSummary, @@ -113,8 +122,16 @@ function ExerciseReminder({ const t = (key: string, vars?: Record): string => translate(lang, key, vars) + const [actualReps, setActualReps] = useState(exercise.reps) + const adjusted = actualReps !== exercise.reps + async function done(): Promise { - await window.api.markDone(exercise.id) + // Only pass actualReps when user adjusted — otherwise leave undefined + // so history records the full planned value cleanly. + await window.api.markDone( + exercise.id, + adjusted ? actualReps : undefined + ) onClose() } async function snooze(): Promise { @@ -125,6 +142,8 @@ function ExerciseReminder({ await window.api.skip(exercise.id) onClose() } + const dec = (): void => setActualReps((n) => Math.max(0, n - 1)) + const inc = (): void => setActualReps((n) => n + 1) return (
@@ -157,14 +176,41 @@ function ExerciseReminder({ {exercise.name} -
- - {exercise.reps} - - - {t('reminder.reps')} - + {/* Reps stepper — tap +/− if you did less than planned. */} +
+ +
+ + {actualReps} + + + {t('reminder.reps')} + +
+
+ {adjusted && ( +
+ {t('reminder.partial', { actual: actualReps, planned: exercise.reps })} +
+ )}
diff --git a/src/renderer/src/components/HistoryHeatmap.tsx b/src/renderer/src/components/HistoryHeatmap.tsx new file mode 100644 index 0000000..e40fe59 --- /dev/null +++ b/src/renderer/src/components/HistoryHeatmap.tsx @@ -0,0 +1,174 @@ +import { useMemo } from 'react' +import { dailyRepsRange } from '../lib/history' +import type { Exercise, HistoryEntry, Language } from '@shared/types' + +type Props = { + history: HistoryEntry[] + exercises: Exercise[] + days?: number + lang: Language +} + +/** + * GitHub-style contribution grid: weeks as columns, days-of-week as rows. + * Intensity bucket from 0 to 4 based on relative reps within the window. + */ +export function HistoryHeatmap({ + history, + exercises, + days = 84, // 12 weeks + lang +}: Props): JSX.Element { + const cells = useMemo( + () => dailyRepsRange(history, exercises, days), + [history, exercises, days] + ) + const max = cells.reduce((m, c) => Math.max(m, c.reps), 0) + + // Bucket function — 0 for zero, 1-4 for low/med/high/peak. + function bucket(n: number): number { + if (n === 0) return 0 + if (max === 0) return 0 + const ratio = n / max + if (ratio < 0.25) return 1 + if (ratio < 0.5) return 2 + if (ratio < 0.85) return 3 + return 4 + } + + // Group cells into columns (weeks). Pad start so first column aligns to + // its actual week (Mon-first). + const firstDay = cells[0]?.date ?? new Date() + const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon + const padded: ({ + key: string + date: Date + reps: number + } | null)[] = [...Array(firstWeekday).fill(null), ...cells] + const weeks: (typeof padded)[] = [] + for (let i = 0; i < padded.length; i += 7) { + weeks.push(padded.slice(i, i + 7)) + } + + const dayLabels = + lang === 'en' + ? ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'] + : ['Пн', '', 'Ср', '', 'Пт', '', 'Вс'] + + const monthLabels = useMemo(() => { + const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', { + month: 'short' + }) + return weeks.map((w) => { + const first = w.find((c) => c !== null) + return first ? fmt.format(first.date) : '' + }) + }, [weeks, lang]) + + // Compress repeated month labels (only show on first week of the month) + const monthLabelsCompressed = monthLabels.map((label, i) => + label && label !== monthLabels[i - 1] ? label : '' + ) + + const dateFmt = useMemo( + () => + new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', { + day: 'numeric', + month: 'long' + }), + [lang] + ) + + return ( +
+
+
+ {lang === 'en' ? 'Activity, last 12 weeks' : 'Активность за 12 недель'} +
+
+ +
+ {/* Month labels above grid */} +
+ {monthLabelsCompressed.map((label, i) => ( +
+ {label} +
+ ))} +
+
+
+ {dayLabels.map((l, i) => ( +
+ {l} +
+ ))} +
+
+ {weeks.map((w, wi) => ( +
+ {w.map((c, di) => { + if (!c) { + return ( +
+ ) + } + const b = bucket(c.reps) + const tone = + b === 0 + ? 'bg-surface-2' + : b === 1 + ? 'bg-accent/30' + : b === 2 + ? 'bg-accent/55' + : b === 3 + ? 'bg-accent/80' + : 'bg-accent' + return ( +
+ ) + })} +
+ ))} +
+
+
+ + {/* Legend */} +
+ {lang === 'en' ? 'Less' : 'Меньше'} + {[0, 1, 2, 3, 4].map((b) => ( +
+ ))} + {lang === 'en' ? 'More' : 'Больше'} +
+
+ ) +} diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 7ef4684..c48ddb6 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -52,6 +52,10 @@ export const ru: Dict = { 'dashboard.title': 'Сегодня', 'dashboard.stat.active': 'Активных', 'dashboard.stat.active.of': 'из {total}', + 'dashboard.stat.today_done': 'Сегодня', + 'dashboard.stat.today_done.subtitle': 'повторов за день', + 'dashboard.stat.streak': 'Стрик', + 'dashboard.stat.streak.subtitle': '{n} дн. подряд', 'dashboard.stat.next': 'До следующего', 'dashboard.stat.next.now': 'Сейчас', 'dashboard.stat.next.subtitle_paused': 'на паузе', @@ -133,6 +137,7 @@ export const ru: Dict = { 'settings.kicker': 'Конфигурация', 'settings.title': 'Настройки', 'settings.section.reminders': 'Напоминания', + 'settings.section.quiet': 'Тихие часы', 'settings.section.window': 'Окно и трей', 'settings.section.appearance': 'Внешний вид', 'settings.section.language': 'Язык', @@ -151,6 +156,12 @@ export const ru: Dict = { 'settings.snooze.10': '10 минут', 'settings.snooze.15': '15 минут', 'settings.snooze.30': '30 минут', + 'settings.quiet.enabled.label': 'Тихие часы', + 'settings.quiet.enabled.hint': 'Не показывать напоминания в указанные часы', + 'settings.quiet.times.label': 'С и до', + 'settings.quiet.times.hint': 'Если до раньше — окно переходит через полночь', + 'settings.quiet.days.label': 'Дни недели', + 'settings.quiet.days.hint': 'Тихие часы действуют в выбранные дни', 'settings.tray.label': 'Сворачивать в трей', 'settings.tray.hint': 'При закрытии остаётся работать в фоне', 'settings.autostart.label': 'Запускать с Windows', @@ -188,6 +199,7 @@ export const ru: Dict = { 'reminder.subkicker': 'Двигайся', 'reminder.reps': 'раз', 'reminder.next_in': 'Следующее через {interval}', + 'reminder.partial': 'Засчитаем {actual} из {planned}', 'reminder.btn.done': 'Готово', 'match.title.won': 'Победа', 'match.title.lost': 'Поражение', @@ -254,6 +266,10 @@ export const en: Dict = { 'dashboard.title': 'Today', 'dashboard.stat.active': 'Active', 'dashboard.stat.active.of': 'of {total}', + 'dashboard.stat.today_done': 'Today', + 'dashboard.stat.today_done.subtitle': 'reps logged', + 'dashboard.stat.streak': 'Streak', + 'dashboard.stat.streak.subtitle': '{n} days in a row', 'dashboard.stat.next': 'Next in', 'dashboard.stat.next.now': 'Now', 'dashboard.stat.next.subtitle_paused': 'paused', @@ -334,6 +350,7 @@ export const en: Dict = { 'settings.kicker': 'Configuration', 'settings.title': 'Settings', 'settings.section.reminders': 'Reminders', + 'settings.section.quiet': 'Quiet hours', 'settings.section.window': 'Window & tray', 'settings.section.appearance': 'Appearance', 'settings.section.language': 'Language', @@ -352,6 +369,12 @@ export const en: Dict = { 'settings.snooze.10': '10 minutes', 'settings.snooze.15': '15 minutes', 'settings.snooze.30': '30 minutes', + 'settings.quiet.enabled.label': 'Quiet hours', + 'settings.quiet.enabled.hint': 'Suppress reminders during the chosen window', + 'settings.quiet.times.label': 'From and to', + 'settings.quiet.times.hint': 'If `to` is earlier, the window wraps midnight', + 'settings.quiet.days.label': 'Days of week', + 'settings.quiet.days.hint': 'Quiet hours apply on the selected days', 'settings.tray.label': 'Minimize to tray', 'settings.tray.hint': 'Keep running in background when closed', 'settings.autostart.label': 'Start with Windows', @@ -389,6 +412,7 @@ export const en: Dict = { 'reminder.subkicker': 'Move', 'reminder.reps': 'reps', 'reminder.next_in': 'Next in {interval}', + 'reminder.partial': "We'll log {actual} of {planned}", 'reminder.btn.done': 'Done', 'match.title.won': 'Victory', 'match.title.lost': 'Defeat', diff --git a/src/renderer/src/lib/history.test.ts b/src/renderer/src/lib/history.test.ts new file mode 100644 index 0000000..3d20d6a --- /dev/null +++ b/src/renderer/src/lib/history.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import type { Exercise, HistoryEntry } from '@shared/types' +import { currentStreak, dailyReps, dayKey, dailyRepsRange } from './history' + +const MS_DAY = 24 * 60 * 60 * 1000 + +function ex(id: string, reps: number): Exercise { + return { + id, + name: id, + reps, + icon: 'Activity', + intervalMinutes: 30, + enabled: true, + nextFireAt: 0 + } +} + +function entry( + exerciseId: string, + ts: number, + action: 'done' | 'skip' | 'snooze' = 'done', + actualReps?: number +): HistoryEntry { + const e: HistoryEntry = { exerciseId, ts, action } + if (actualReps !== undefined) e.actualReps = actualReps + return e +} + +describe('dayKey', () => { + it('returns local YYYY-MM-DD', () => { + // Midnight local time is "today" — we cannot pin exact value across + // timezones, so just assert the format. + expect(dayKey(Date.now())).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) +}) + +describe('dailyReps', () => { + const today = Date.now() + const exs = [ex('a', 10), ex('b', 5)] + + it('counts planned reps when actualReps absent', () => { + const hist = [entry('a', today), entry('b', today)] + expect(dailyReps(hist, exs, dayKey(today))).toBe(15) + }) + + it('counts actualReps when present (partial completion)', () => { + const hist = [entry('a', today, 'done', 7)] + expect(dailyReps(hist, exs, dayKey(today))).toBe(7) + }) + + it('ignores skip / snooze entries', () => { + const hist = [ + entry('a', today, 'skip'), + entry('a', today, 'snooze'), + entry('b', today) + ] + expect(dailyReps(hist, exs, dayKey(today))).toBe(5) + }) + + it('only counts the requested day', () => { + const yesterday = today - MS_DAY + const hist = [entry('a', today), entry('a', yesterday)] + expect(dailyReps(hist, exs, dayKey(today))).toBe(10) + expect(dailyReps(hist, exs, dayKey(yesterday))).toBe(10) + }) +}) + +describe('currentStreak', () => { + const today = Date.now() + const day = (n: number): number => today - n * MS_DAY + + it('returns 0 for empty history', () => { + expect(currentStreak([])).toBe(0) + }) + + it('returns 0 if no done in last 2 days', () => { + expect(currentStreak([entry('a', day(3))])).toBe(0) + }) + + it('counts consecutive days ending today', () => { + const hist = [ + entry('a', day(0)), + entry('a', day(1)), + entry('a', day(2)), + entry('a', day(4)) // gap + ] + expect(currentStreak(hist)).toBe(3) + }) + + it('allows yesterday as grace day if today not done yet', () => { + const hist = [entry('a', day(1)), entry('a', day(2))] + expect(currentStreak(hist)).toBe(2) + }) + + it('ignores skip and snooze', () => { + const hist = [ + entry('a', day(0), 'skip'), + entry('a', day(1), 'snooze') + ] + expect(currentStreak(hist)).toBe(0) + }) + + it('multiple entries same day count once', () => { + const hist = [ + entry('a', day(0)), + entry('b', day(0)), + entry('a', day(1)) + ] + expect(currentStreak(hist)).toBe(2) + }) +}) + +describe('dailyRepsRange', () => { + it('always returns exactly `days` entries even if no history', () => { + expect(dailyRepsRange([], [], 7)).toHaveLength(7) + }) + + it('sums reps into correct buckets', () => { + const today = Date.now() + const exs = [ex('a', 10)] + const hist = [entry('a', today), entry('a', today - MS_DAY, 'done', 3)] + const range = dailyRepsRange(hist, exs, 7) + expect(range.at(-1)?.reps).toBe(10) // today + expect(range.at(-2)?.reps).toBe(3) // yesterday, partial + }) +}) diff --git a/src/renderer/src/lib/history.ts b/src/renderer/src/lib/history.ts new file mode 100644 index 0000000..4a7f836 --- /dev/null +++ b/src/renderer/src/lib/history.ts @@ -0,0 +1,119 @@ +import type { Exercise, HistoryEntry } from '@shared/types' + +const MS_DAY = 24 * 60 * 60 * 1000 + +/** YYYY-MM-DD in local time. */ +export function dayKey(ts: number): string { + const d = new Date(ts) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +/** Today's local midnight. */ +export function todayKey(): string { + return dayKey(Date.now()) +} + +/** + * Reps logged on a given local day. Uses `actualReps` if present, otherwise + * looks up exercise's planned `reps`. + */ +export function dailyReps( + entries: HistoryEntry[], + exercises: Exercise[], + dayKeyStr: string +): number { + const byId = new Map(exercises.map((e) => [e.id, e])) + let sum = 0 + for (const e of entries) { + if (e.action !== 'done') continue + if (dayKey(e.ts) !== dayKeyStr) continue + sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 + } + return sum +} + +/** + * Map of `dayKey → totalReps` for the last `days` days (most recent last). + * Missing days are still included with value 0. + */ +export function dailyRepsRange( + entries: HistoryEntry[], + exercises: Exercise[], + days: number +): { key: string; date: Date; reps: number }[] { + const today = new Date() + today.setHours(0, 0, 0, 0) + const buckets = new Map() + const byId = new Map(exercises.map((e) => [e.id, e])) + + // Seed all days with 0 so heatmap renders contiguous. + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today.getTime() - i * MS_DAY) + buckets.set(dayKey(d.getTime()), 0) + } + + for (const e of entries) { + if (e.action !== 'done') continue + const k = dayKey(e.ts) + if (!buckets.has(k)) continue + const reps = e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 + buckets.set(k, (buckets.get(k) ?? 0) + reps) + } + + return Array.from(buckets, ([key, reps]) => ({ + key, + date: new Date(`${key}T00:00:00`), + reps + })) +} + +/** + * Current streak: consecutive days ending today (or yesterday — grace day) + * where at least one `done` was logged. Returns 0 if neither today nor + * yesterday has any done activity. + */ +export function currentStreak(entries: HistoryEntry[]): number { + const doneDays = new Set() + for (const e of entries) { + if (e.action === 'done') doneDays.add(dayKey(e.ts)) + } + if (doneDays.size === 0) return 0 + + const today = new Date() + today.setHours(0, 0, 0, 0) + const todayK = dayKey(today.getTime()) + const yesterdayK = dayKey(today.getTime() - MS_DAY) + + // Start from today if active today, else yesterday (grace), else 0. + let cursor = doneDays.has(todayK) + ? today + : doneDays.has(yesterdayK) + ? new Date(today.getTime() - MS_DAY) + : null + if (!cursor) return 0 + + let streak = 0 + while (doneDays.has(dayKey(cursor.getTime()))) { + streak++ + cursor = new Date(cursor.getTime() - MS_DAY) + } + return streak +} + +/** Total scheduled reps across all enabled exercises today (planned target). */ +export function plannedRepsToday(exercises: Exercise[]): number { + // For now, "planned today" = sum of enabled exercises' reps × times per day + // approximation. A more honest target would count expected fires before + // midnight. We use a simple proxy: reps per exercise weighted by how often + // it'd fire in a day (1440 min / intervalMinutes). + let sum = 0 + for (const e of exercises) { + if (!e.enabled) continue + const firesPerDay = Math.max(1, Math.floor(1440 / e.intervalMinutes)) + sum += e.reps * firesPerDay + } + return sum +} diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 00d9ddd..dc48695 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -1,13 +1,15 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' -import { Plus, Pause, Play, Flame, Activity } from 'lucide-react' +import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react' import { useAppStore } from '../store/appStore' import { ExerciseCard } from '../components/ExerciseCard' import { ExerciseEditor } from '../components/ExerciseEditor' +import { HistoryHeatmap } from '../components/HistoryHeatmap' import { Button } from '../components/ui/Button' -import type { Exercise } from '@shared/types' +import type { Exercise, HistoryEntry } from '@shared/types' import { formatCountdown } from '../lib/format' import { useT } from '../i18n' +import { currentStreak, dailyReps, todayKey } from '../lib/history' export default function Dashboard(): JSX.Element { const state = useAppStore((s) => s.state) @@ -20,6 +22,18 @@ export default function Dashboard(): JSX.Element { const settings = state?.settings const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean) + // Local history mirror; reloaded whenever app-state changes. + const [history, setHistory] = useState([]) + useEffect(() => { + void window.api.getHistory().then(setHistory) + }, [state]) + + const todayDone = useMemo( + () => dailyReps(history, exercises, todayKey()), + [history, exercises] + ) + const streak = useMemo(() => currentStreak(history), [history]) + const stats = useMemo(() => { const enabled = exercises.filter((e) => e.enabled) const next = enabled @@ -94,13 +108,20 @@ export default function Dashboard(): JSX.Element {
-
+
} + label={t('dashboard.stat.today_done')} + value={`${todayDone}`} + subvalue={t('dashboard.stat.today_done.subtitle')} + icon={} + /> + 0 ? 'warning' : 'muted'} + label={t('dashboard.stat.streak')} + value={`${streak}`} + subvalue={t('dashboard.stat.streak.subtitle', { n: streak })} + icon={} /> } + icon={} />
+ {history.length > 0 && ( +
+ +
+ )} + {paused && ( diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 12fc5da..cf94052 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { useT } from '../i18n' import type { Language, NotificationMode, + QuietHours, Settings as SettingsType, Theme } from '@shared/types' @@ -91,6 +92,29 @@ export default function SettingsPage(): JSX.Element { /> + + + + patch({ quietHours: { ...settings.quietHours, enabled: v } }) + } + /> + patch({ quietHours: qh })} + disabled={!settings.quietHours.enabled} + /> + patch({ quietHours: qh })} + disabled={!settings.quietHours.enabled} + last + /> + + void + disabled?: boolean + last?: boolean +}): JSX.Element { + const { t } = useT() + return ( + +
+
+ {t('settings.quiet.times.label')} +
+
+ {t('settings.quiet.times.hint')} +
+
+
+ onChange({ ...qh, from: e.target.value })} + className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num" + /> + + onChange({ ...qh, to: e.target.value })} + className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num" + /> +
+
+ ) +} + +function QuietDaysRow({ + qh, + onChange, + disabled, + last = false +}: { + qh: QuietHours + onChange: (next: QuietHours) => void + disabled?: boolean + last?: boolean +}): JSX.Element { + const { t, lang } = useT() + const labels = + lang === 'en' + ? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + : ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'] + + function toggle(d: number): void { + const set = new Set(qh.days) + if (set.has(d)) set.delete(d) + else set.add(d) + onChange({ ...qh, days: Array.from(set).sort() }) + } + + return ( + +
+
+ {t('settings.quiet.days.label')} +
+
+ {t('settings.quiet.days.hint')} +
+
+
+ {labels.map((label, d) => { + const on = qh.days.includes(d) + return ( + + ) + })} +
+
+ ) +} + function SelectRow({ label, hint, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e1906ad..27a14c5 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -42,6 +42,10 @@ export const IPC = { updaterDownload: 'updater:download', updaterInstall: 'updater:install', + // History + getHistory: 'history:get', + clearHistory: 'history:clear', + // events from main → renderer evtTick: 'evt:tick', evtFire: 'evt:fire', diff --git a/src/shared/quiet-hours.test.ts b/src/shared/quiet-hours.test.ts new file mode 100644 index 0000000..cb20f70 --- /dev/null +++ b/src/shared/quiet-hours.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { isQuietAt, type QuietHours } from './types' + +function at(iso: string): Date { + return new Date(iso) +} + +const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6] + +describe('isQuietAt', () => { + it('returns false when disabled', () => { + const qh: QuietHours = { + enabled: false, + from: '00:00', + to: '23:59', + days: ALL_DAYS + } + expect(isQuietAt(qh, at('2026-05-17T12:00:00'))).toBe(false) + }) + + it('same-day window: inside is quiet, outside is not', () => { + const qh: QuietHours = { + enabled: true, + from: '13:00', + to: '14:00', + days: ALL_DAYS + } + expect(isQuietAt(qh, at('2026-05-17T13:30:00'))).toBe(true) + expect(isQuietAt(qh, at('2026-05-17T12:59:00'))).toBe(false) + expect(isQuietAt(qh, at('2026-05-17T14:00:00'))).toBe(false) // exclusive end + }) + + it('wrap-around window 22:00 → 08:00', () => { + const qh: QuietHours = { + enabled: true, + from: '22:00', + to: '08:00', + days: ALL_DAYS + } + expect(isQuietAt(qh, at('2026-05-17T23:00:00'))).toBe(true) + expect(isQuietAt(qh, at('2026-05-17T02:00:00'))).toBe(true) + expect(isQuietAt(qh, at('2026-05-17T07:59:00'))).toBe(true) + expect(isQuietAt(qh, at('2026-05-17T08:00:00'))).toBe(false) + expect(isQuietAt(qh, at('2026-05-17T15:00:00'))).toBe(false) + expect(isQuietAt(qh, at('2026-05-17T21:59:00'))).toBe(false) + }) + + it('day filtering: window inactive on excluded days', () => { + const qh: QuietHours = { + enabled: true, + from: '13:00', + to: '14:00', + days: [1, 2, 3, 4, 5] // weekdays only + } + // 2026-05-17 is Sunday (day 0) + expect(isQuietAt(qh, at('2026-05-17T13:30:00'))).toBe(false) + // 2026-05-18 is Monday (day 1) + expect(isQuietAt(qh, at('2026-05-18T13:30:00'))).toBe(true) + }) + + it('zero-length window (from === to) is never quiet', () => { + const qh: QuietHours = { + enabled: true, + from: '12:00', + to: '12:00', + days: ALL_DAYS + } + expect(isQuietAt(qh, at('2026-05-17T12:00:00'))).toBe(false) + expect(isQuietAt(qh, at('2026-05-17T12:00:01'))).toBe(false) + }) +}) diff --git a/src/shared/types.ts b/src/shared/types.ts index 76ed697..6de47cd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -13,6 +13,19 @@ export type NotificationMode = 'toast' | 'modal' | 'both' export type Theme = 'light' | 'dark' | 'system' export type Language = 'ru' | 'en' +/** + * Hours when reminders are silenced. `from`/`to` are "HH:MM" 24h strings, + * `days` are weekday indices 0=Sun..6=Sat. Empty `days` = applies every day. + * If `to <= from` the window wraps across midnight (e.g. 22:00 → 07:00). + */ +export type QuietHours = { + enabled: boolean + from: string + to: string + /** Days when the quiet window is active. */ + days: number[] +} + export type Settings = { globalEnabled: boolean notificationMode: NotificationMode @@ -23,6 +36,7 @@ export type Settings = { theme: Theme language: Language snoozeMinutes: number + quietHours: QuietHours } export type AppState = { @@ -30,6 +44,18 @@ export type AppState = { settings: Settings challenges: Challenge[] gamesEnabled: Partial> + history?: HistoryEntry[] +} + +export type HistoryAction = 'done' | 'skip' | 'snooze' + +export type HistoryEntry = { + /** ms epoch */ + ts: number + exerciseId: string + action: HistoryAction + /** When user did less than planned. Only meaningful for `done`. */ + actualReps?: number } export type Tick = { @@ -141,7 +167,36 @@ export const DEFAULT_SETTINGS: Settings = { startMinimized: false, theme: 'light', language: 'ru', - snoozeMinutes: 5 + snoozeMinutes: 5, + quietHours: { + enabled: false, + from: '22:00', + to: '08:00', + days: [0, 1, 2, 3, 4, 5, 6] + } +} + +/** + * Returns true if `now` falls inside the quiet window. Handles wrap-around + * windows (e.g. 22:00 → 08:00). Exposed from shared so both main scheduler + * and renderer settings UI can use the same logic. + */ +export function isQuietAt(qh: QuietHours, now: Date): boolean { + if (!qh.enabled) return false + const dow = now.getDay() // 0..6 + if (qh.days.length > 0 && !qh.days.includes(dow)) return false + const [fh, fm] = qh.from.split(':').map(Number) + const [th, tm] = qh.to.split(':').map(Number) + const cur = now.getHours() * 60 + now.getMinutes() + const fromMin = fh * 60 + fm + const toMin = th * 60 + tm + if (fromMin === toMin) return false + if (fromMin < toMin) { + // Same-day window. + return cur >= fromMin && cur < toMin + } + // Wraps midnight: active if after `from` today OR before `to` today. + return cur >= fromMin || cur < toMin } export const SAMPLE_EXERCISES: Omit[] = [