feat(v0.5.0): history + streak + heatmap, quiet hours, partial reps, README

== История и стрики (#1) ==
- HistoryEntry { ts, exerciseId, action: done|skip|snooze, actualReps? }
  персистится в app-state.json, лимит 10k записей (~3 года), trim oldest 10%
- markDone/snooze/skip пишут в историю; markDone принимает optional actualReps
- IPC: getHistory(sinceMs?), clearHistory(beforeTs?) + preload bindings
- Renderer helpers (src/renderer/src/lib/history.ts):
  * dayKey(ts) — YYYY-MM-DD local
  * dailyReps(entries, exs, dayKey) — суммирует actualReps || planned
  * dailyRepsRange(entries, exs, days) — для heatmap, заполняет gaps нулями
  * currentStreak(entries) — consecutive days, today или yesterday (grace)
- Dashboard теперь 4 hero-карточки: Today (повторов за день) / Streak
  (дней подряд) / Next / Tracking
- Новый компонент HistoryHeatmap — GitHub-style 12-недельный календарь
  с 5 интенсивностями, локализованными подписями дней/месяцев

== Тихие часы (#2) ==
- shared/types.ts: QuietHours { enabled, from, to, days[] } + isQuietAt()
  helper с правильной обработкой wrap-around окон (22:00→08:00)
- DEFAULT_SETTINGS.quietHours = disabled, 22:00→08:00, все дни
- main/scheduler.ts: проверка isQuietAt перед fire; deferred fires
  поднимаются после окончания окна
- Settings UI: новая секция "Тихие часы" с toggle, time-pickers,
  day-of-week pills

== Сделал частично (#3) ==
- ReminderApp: stepper [−][число][+] вокруг счётчика повторов
- При adjusted (actualReps !== exercise.reps) число подсвечивается accent
  и появляется подпись "Засчитаем X из Y"
- markDone передаёт actualReps только если юзер реально изменил —
  иначе undefined чтобы история фиксировала планируемое значение чисто

== README.md (#4) ==
- Описание, фичи, скриншоты (TODO-плейсхолдер), установка, dev-команды,
  архитектура, тесты, stack, ссылка на RELEASING.md
- Бэйджи version / tests / platform

== i18n ==
- ~14 новых ключей × 2 языка: dashboard.stat.today_done, streak,
  settings.quiet.* (3 row'а), reminder.partial

== Тесты — 51 (было 33) ==
- shared/quiet-hours.test.ts (5): disabled, same-day, wrap-around,
  day filtering, zero-length
- renderer/lib/history.test.ts (13): dayKey, dailyReps (planned vs
  actual vs ignore non-done), currentStreak (empty, today gap,
  consecutive, yesterday grace, multi-entry same day), dailyRepsRange

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-18 12:41:13 +07:00
parent 973339ca62
commit 01c2ece4ef
16 changed files with 975 additions and 32 deletions

93
README.md Normal file
View File

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

View File

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

View File

@@ -4,8 +4,10 @@ import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
import { import {
addChallenge, addChallenge,
addExercise, addExercise,
clearHistory,
deleteChallenge, deleteChallenge,
deleteExercise, deleteExercise,
getHistory,
getState, getState,
markDone, markDone,
setGameEnabled, setGameEnabled,
@@ -73,11 +75,14 @@ export function registerIpc(): void {
return ex return ex
}) })
ipcMain.handle(IPC.markDone, (_e, id: string) => { ipcMain.handle(
const ex = markDone(id) IPC.markDone,
(_e, id: string, actualReps?: number) => {
const ex = markDone(id, actualReps)
broadcastState() broadcastState()
return ex return ex
}) }
)
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => { ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
const ex = snooze(id, minutes) const ex = snooze(id, minutes)
@@ -213,4 +218,10 @@ export function registerIpc(): void {
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates()) ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate()) ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall()) ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
// History
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) =>
clearHistory(beforeTs)
)
} }

View File

@@ -1,6 +1,7 @@
import { powerMonitor, BrowserWindow } from 'electron' import { powerMonitor, BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { Tick } from '@shared/types' import type { Tick } from '@shared/types'
import { isQuietAt } from '@shared/types'
import { getExercises, getSettings, updateExercise } from './store' import { getExercises, getSettings, updateExercise } from './store'
import { fireReminder } from './notifications' import { fireReminder } from './notifications'
@@ -15,12 +16,15 @@ function checkDueExercises(): void {
const settings = getSettings() const settings = getSettings()
if (!settings.globalEnabled) return 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 now = Date.now()
const exercises = getExercises() const exercises = getExercises()
for (const ex of exercises) { for (const ex of exercises) {
if (!ex.enabled) continue if (!ex.enabled) continue
if (ex.nextFireAt <= now) { if (ex.nextFireAt <= now) {
// Fire once, reschedule from now (drop missed intervals).
const updated = updateExercise(ex.id, { const updated = updateExercise(ex.id, {
nextFireAt: now + ex.intervalMinutes * 60_000 nextFireAt: now + ex.intervalMinutes * 60_000
}) })

View File

@@ -8,10 +8,15 @@ import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
Exercise, Exercise,
GameId, GameId,
HistoryAction,
HistoryEntry,
SAMPLE_EXERCISES, SAMPLE_EXERCISES,
Settings Settings
} from '@shared/types' } 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 cache: AppState | null = null
let storePath = '' let storePath = ''
let pendingWrite: NodeJS.Timeout | null = null let pendingWrite: NodeJS.Timeout | null = null
@@ -56,7 +61,8 @@ function makeInitial(): AppState {
enabled: false enabled: false
} }
], ],
gamesEnabled: {} gamesEnabled: {},
history: []
} }
} }
@@ -74,13 +80,49 @@ function load(): AppState {
exercises: parsed.exercises ?? [], exercises: parsed.exercises ?? [],
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) }, settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
challenges: parsed.challenges ?? [], challenges: parsed.challenges ?? [],
gamesEnabled: parsed.gamesEnabled ?? {} gamesEnabled: parsed.gamesEnabled ?? {},
history: parsed.history ?? []
} }
} catch { } catch {
return makeInitial() 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 { function flush(): void {
if (!cache) return if (!cache) return
writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8') writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8')
@@ -155,12 +197,16 @@ export function deleteExercise(id: string): boolean {
return changed return changed
} }
export function markDone(id: string): Exercise | undefined { export function markDone(
id: string,
actualReps?: number
): Exercise | undefined {
const state = getState() const state = getState()
const ex = state.exercises.find((e) => e.id === id) const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined if (!ex) return undefined
ex.lastDoneAt = Date.now() ex.lastDoneAt = Date.now()
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'done', actualReps)
scheduleWrite() scheduleWrite()
return ex return ex
} }
@@ -170,6 +216,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id) const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined if (!ex) return undefined
ex.nextFireAt = Date.now() + minutes * 60_000 ex.nextFireAt = Date.now() + minutes * 60_000
appendHistory(id, 'snooze')
scheduleWrite() scheduleWrite()
return ex return ex
} }
@@ -179,6 +226,7 @@ export function skip(id: string): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id) const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined if (!ex) return undefined
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'skip')
scheduleWrite() scheduleWrite()
return ex return ex
} }

View File

@@ -6,6 +6,7 @@ import type {
Exercise, Exercise,
GameId, GameId,
GameStatus, GameStatus,
HistoryEntry,
MatchSummary, MatchSummary,
Settings, Settings,
Tick, Tick,
@@ -33,7 +34,8 @@ const api = {
ipcRenderer.invoke(IPC.deleteExercise, id), ipcRenderer.invoke(IPC.deleteExercise, id),
toggleExercise: (id: string, enabled: boolean): Promise<Exercise> => toggleExercise: (id: string, enabled: boolean): Promise<Exercise> =>
ipcRenderer.invoke(IPC.toggleExercise, id, enabled), ipcRenderer.invoke(IPC.toggleExercise, id, enabled),
markDone: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.markDone, id), markDone: (id: string, actualReps?: number): Promise<Exercise> =>
ipcRenderer.invoke(IPC.markDone, id, actualReps),
snooze: (id: string, minutes: number): Promise<Exercise> => snooze: (id: string, minutes: number): Promise<Exercise> =>
ipcRenderer.invoke(IPC.snooze, id, minutes), ipcRenderer.invoke(IPC.snooze, id, minutes),
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id), skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id),
@@ -87,6 +89,12 @@ const api = {
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload), updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload),
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall), updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall),
// History
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
ipcRenderer.invoke(IPC.getHistory, sinceMs),
clearHistory: (beforeTs?: number): Promise<number> =>
ipcRenderer.invoke(IPC.clearHistory, beforeTs),
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h), onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h), onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h), onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),

View File

@@ -1,6 +1,15 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion' 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 { import type {
Exercise, Exercise,
MatchSummary, MatchSummary,
@@ -113,8 +122,16 @@ function ExerciseReminder({
const t = (key: string, vars?: Record<string, string | number>): string => const t = (key: string, vars?: Record<string, string | number>): string =>
translate(lang, key, vars) translate(lang, key, vars)
const [actualReps, setActualReps] = useState(exercise.reps)
const adjusted = actualReps !== exercise.reps
async function done(): Promise<void> { async function done(): Promise<void> {
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() onClose()
} }
async function snooze(): Promise<void> { async function snooze(): Promise<void> {
@@ -125,6 +142,8 @@ function ExerciseReminder({
await window.api.skip(exercise.id) await window.api.skip(exercise.id)
onClose() onClose()
} }
const dec = (): void => setActualReps((n) => Math.max(0, n - 1))
const inc = (): void => setActualReps((n) => n + 1)
return ( return (
<div className="reminder-shell flex flex-col h-full"> <div className="reminder-shell flex flex-col h-full">
@@ -157,14 +176,41 @@ function ExerciseReminder({
{exercise.name} {exercise.name}
</h1> </h1>
<div className="inline-flex items-baseline gap-2 font-mono-num"> {/* Reps stepper — tap +/ if you did less than planned. */}
<span className="text-[56px] font-semibold tracking-tight text-text leading-none"> <div className="inline-flex items-center gap-3 select-none">
{exercise.reps} <button
onClick={dec}
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
aria-label=""
>
<Minus size={16} strokeWidth={2.5} />
</button>
<div className="inline-flex items-baseline gap-2 font-mono-num min-w-[120px] justify-center">
<span
className={[
'text-[56px] font-semibold tracking-tight leading-none',
adjusted ? 'text-accent' : 'text-text'
].join(' ')}
>
{actualReps}
</span> </span>
<span className="text-[15px] text-text/65 font-semibold"> <span className="text-[15px] text-text/65 font-semibold">
{t('reminder.reps')} {t('reminder.reps')}
</span> </span>
</div> </div>
<button
onClick={inc}
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
aria-label="+"
>
<Plus size={16} strokeWidth={2.5} />
</button>
</div>
{adjusted && (
<div className="text-[12px] text-accent mt-2 font-medium">
{t('reminder.partial', { actual: actualReps, planned: exercise.reps })}
</div>
)}
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium"> <div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
<Clock size={12} strokeWidth={2.4} /> <Clock size={12} strokeWidth={2.4} />

View File

@@ -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 (
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex items-center gap-2 mb-3">
<div className="text-[14px] text-text/75 font-semibold">
{lang === 'en' ? 'Activity, last 12 weeks' : 'Активность за 12 недель'}
</div>
</div>
<div className="overflow-x-auto">
{/* Month labels above grid */}
<div className="flex gap-[3px] mb-1 pl-7">
{monthLabelsCompressed.map((label, i) => (
<div
key={i}
className="w-[12px] text-[10px] text-text/45 font-medium"
>
{label}
</div>
))}
</div>
<div className="flex gap-[6px]">
<div className="flex flex-col gap-[3px] justify-around pt-0.5">
{dayLabels.map((l, i) => (
<div
key={i}
className="h-[12px] text-[10px] text-text/40 font-medium leading-none w-5 text-right"
>
{l}
</div>
))}
</div>
<div className="flex gap-[3px]">
{weeks.map((w, wi) => (
<div key={wi} className="flex flex-col gap-[3px]">
{w.map((c, di) => {
if (!c) {
return (
<div key={di} className="w-[12px] h-[12px]" />
)
}
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 (
<div
key={di}
title={`${dateFmt.format(c.date)} · ${c.reps} ${lang === 'en' ? 'reps' : 'повторов'}`}
className={[
'w-[12px] h-[12px] rounded-[3px] transition-colors',
tone
].join(' ')}
/>
)
})}
</div>
))}
</div>
</div>
</div>
{/* Legend */}
<div className="flex items-center justify-end gap-1.5 mt-3 text-[10px] text-text/45 font-medium">
<span>{lang === 'en' ? 'Less' : 'Меньше'}</span>
{[0, 1, 2, 3, 4].map((b) => (
<div
key={b}
className={[
'w-[10px] h-[10px] rounded-[2px]',
b === 0
? 'bg-surface-2'
: b === 1
? 'bg-accent/30'
: b === 2
? 'bg-accent/55'
: b === 3
? 'bg-accent/80'
: 'bg-accent'
].join(' ')}
/>
))}
<span>{lang === 'en' ? 'More' : 'Больше'}</span>
</div>
</div>
)
}

View File

@@ -52,6 +52,10 @@ export const ru: Dict = {
'dashboard.title': 'Сегодня', 'dashboard.title': 'Сегодня',
'dashboard.stat.active': 'Активных', 'dashboard.stat.active': 'Активных',
'dashboard.stat.active.of': 'из {total}', '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': 'До следующего',
'dashboard.stat.next.now': 'Сейчас', 'dashboard.stat.next.now': 'Сейчас',
'dashboard.stat.next.subtitle_paused': 'на паузе', 'dashboard.stat.next.subtitle_paused': 'на паузе',
@@ -133,6 +137,7 @@ export const ru: Dict = {
'settings.kicker': 'Конфигурация', 'settings.kicker': 'Конфигурация',
'settings.title': 'Настройки', 'settings.title': 'Настройки',
'settings.section.reminders': 'Напоминания', 'settings.section.reminders': 'Напоминания',
'settings.section.quiet': 'Тихие часы',
'settings.section.window': 'Окно и трей', 'settings.section.window': 'Окно и трей',
'settings.section.appearance': 'Внешний вид', 'settings.section.appearance': 'Внешний вид',
'settings.section.language': 'Язык', 'settings.section.language': 'Язык',
@@ -151,6 +156,12 @@ export const ru: Dict = {
'settings.snooze.10': '10 минут', 'settings.snooze.10': '10 минут',
'settings.snooze.15': '15 минут', 'settings.snooze.15': '15 минут',
'settings.snooze.30': '30 минут', '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.label': 'Сворачивать в трей',
'settings.tray.hint': 'При закрытии остаётся работать в фоне', 'settings.tray.hint': 'При закрытии остаётся работать в фоне',
'settings.autostart.label': 'Запускать с Windows', 'settings.autostart.label': 'Запускать с Windows',
@@ -188,6 +199,7 @@ export const ru: Dict = {
'reminder.subkicker': 'Двигайся', 'reminder.subkicker': 'Двигайся',
'reminder.reps': 'раз', 'reminder.reps': 'раз',
'reminder.next_in': 'Следующее через {interval}', 'reminder.next_in': 'Следующее через {interval}',
'reminder.partial': 'Засчитаем {actual} из {planned}',
'reminder.btn.done': 'Готово', 'reminder.btn.done': 'Готово',
'match.title.won': 'Победа', 'match.title.won': 'Победа',
'match.title.lost': 'Поражение', 'match.title.lost': 'Поражение',
@@ -254,6 +266,10 @@ export const en: Dict = {
'dashboard.title': 'Today', 'dashboard.title': 'Today',
'dashboard.stat.active': 'Active', 'dashboard.stat.active': 'Active',
'dashboard.stat.active.of': 'of {total}', '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': 'Next in',
'dashboard.stat.next.now': 'Now', 'dashboard.stat.next.now': 'Now',
'dashboard.stat.next.subtitle_paused': 'paused', 'dashboard.stat.next.subtitle_paused': 'paused',
@@ -334,6 +350,7 @@ export const en: Dict = {
'settings.kicker': 'Configuration', 'settings.kicker': 'Configuration',
'settings.title': 'Settings', 'settings.title': 'Settings',
'settings.section.reminders': 'Reminders', 'settings.section.reminders': 'Reminders',
'settings.section.quiet': 'Quiet hours',
'settings.section.window': 'Window & tray', 'settings.section.window': 'Window & tray',
'settings.section.appearance': 'Appearance', 'settings.section.appearance': 'Appearance',
'settings.section.language': 'Language', 'settings.section.language': 'Language',
@@ -352,6 +369,12 @@ export const en: Dict = {
'settings.snooze.10': '10 minutes', 'settings.snooze.10': '10 minutes',
'settings.snooze.15': '15 minutes', 'settings.snooze.15': '15 minutes',
'settings.snooze.30': '30 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.label': 'Minimize to tray',
'settings.tray.hint': 'Keep running in background when closed', 'settings.tray.hint': 'Keep running in background when closed',
'settings.autostart.label': 'Start with Windows', 'settings.autostart.label': 'Start with Windows',
@@ -389,6 +412,7 @@ export const en: Dict = {
'reminder.subkicker': 'Move', 'reminder.subkicker': 'Move',
'reminder.reps': 'reps', 'reminder.reps': 'reps',
'reminder.next_in': 'Next in {interval}', 'reminder.next_in': 'Next in {interval}',
'reminder.partial': "We'll log {actual} of {planned}",
'reminder.btn.done': 'Done', 'reminder.btn.done': 'Done',
'match.title.won': 'Victory', 'match.title.won': 'Victory',
'match.title.lost': 'Defeat', 'match.title.lost': 'Defeat',

View File

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

View File

@@ -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<string, number>()
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<string>()
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
}

View File

@@ -1,13 +1,15 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity } from 'lucide-react' import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard' import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor' import { ExerciseEditor } from '../components/ExerciseEditor'
import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { Button } from '../components/ui/Button' 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 { formatCountdown } from '../lib/format'
import { useT } from '../i18n' import { useT } from '../i18n'
import { currentStreak, dailyReps, todayKey } from '../lib/history'
export default function Dashboard(): JSX.Element { export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state) const state = useAppStore((s) => s.state)
@@ -20,6 +22,18 @@ export default function Dashboard(): JSX.Element {
const settings = state?.settings const settings = state?.settings
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean) const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
// Local history mirror; reloaded whenever app-state changes.
const [history, setHistory] = useState<HistoryEntry[]>([])
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 stats = useMemo(() => {
const enabled = exercises.filter((e) => e.enabled) const enabled = exercises.filter((e) => e.enabled)
const next = enabled const next = enabled
@@ -94,13 +108,20 @@ export default function Dashboard(): JSX.Element {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
<HeroStat <HeroStat
tone="accent" tone="accent"
label={t('dashboard.stat.active')} label={t('dashboard.stat.today_done')}
value={`${stats.active}`} value={`${todayDone}`}
subvalue={t('dashboard.stat.active.of', { total: stats.total })} subvalue={t('dashboard.stat.today_done.subtitle')}
icon={<Activity size={14} strokeWidth={2.6} />} icon={<TrendingUp size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone={streak > 0 ? 'warning' : 'muted'}
label={t('dashboard.stat.streak')}
value={`${streak}`}
subvalue={t('dashboard.stat.streak.subtitle', { n: streak })}
icon={<Flame size={14} strokeWidth={2.6} />}
/> />
<HeroStat <HeroStat
tone="info" tone="info"
@@ -117,7 +138,7 @@ export default function Dashboard(): JSX.Element {
? t('dashboard.stat.next.subtitle_paused') ? t('dashboard.stat.next.subtitle_paused')
: t('dashboard.stat.next.subtitle_running') : t('dashboard.stat.next.subtitle_running')
} }
icon={<Flame size={14} strokeWidth={2.6} />} icon={<Activity size={14} strokeWidth={2.6} />}
/> />
<HeroStat <HeroStat
tone={gamesEnabled ? 'success' : 'muted'} tone={gamesEnabled ? 'success' : 'muted'}
@@ -143,6 +164,16 @@ export default function Dashboard(): JSX.Element {
/> />
</div> </div>
{history.length > 0 && (
<div className="mb-8">
<HistoryHeatmap
history={history}
exercises={exercises}
lang={lang}
/>
</div>
)}
{paused && ( {paused && (
<motion.div <motion.div
initial={{ opacity: 0, y: -4 }} initial={{ opacity: 0, y: -4 }}
@@ -214,7 +245,7 @@ function HeroStat({
subvalue, subvalue,
icon icon
}: { }: {
tone: 'accent' | 'info' | 'success' | 'muted' tone: 'accent' | 'info' | 'success' | 'warning' | 'muted'
label: string label: string
value: string value: string
subvalue?: string subvalue?: string
@@ -227,6 +258,8 @@ function HeroStat({
? 'bg-info' ? 'bg-info'
: tone === 'success' : tone === 'success'
? 'bg-success' ? 'bg-success'
: tone === 'warning'
? 'bg-warning'
: 'bg-text/40' : 'bg-text/40'
return ( return (

View File

@@ -6,6 +6,7 @@ import { useT } from '../i18n'
import type { import type {
Language, Language,
NotificationMode, NotificationMode,
QuietHours,
Settings as SettingsType, Settings as SettingsType,
Theme Theme
} from '@shared/types' } from '@shared/types'
@@ -91,6 +92,29 @@ export default function SettingsPage(): JSX.Element {
/> />
</Card> </Card>
<SectionHeader title={t('settings.section.quiet')} />
<Card className="mb-6">
<ToggleRow
label={t('settings.quiet.enabled.label')}
hint={t('settings.quiet.enabled.hint')}
checked={settings.quietHours.enabled}
onChange={(v) =>
patch({ quietHours: { ...settings.quietHours, enabled: v } })
}
/>
<QuietTimesRow
qh={settings.quietHours}
onChange={(qh) => patch({ quietHours: qh })}
disabled={!settings.quietHours.enabled}
/>
<QuietDaysRow
qh={settings.quietHours}
onChange={(qh) => patch({ quietHours: qh })}
disabled={!settings.quietHours.enabled}
last
/>
</Card>
<SectionHeader title={t('settings.section.window')} /> <SectionHeader title={t('settings.section.window')} />
<Card className="mb-6"> <Card className="mb-6">
<ToggleRow <ToggleRow
@@ -168,6 +192,108 @@ function ToggleRow({
) )
} }
function QuietTimesRow({
qh,
onChange,
disabled,
last = false
}: {
qh: QuietHours
onChange: (next: QuietHours) => void
disabled?: boolean
last?: boolean
}): JSX.Element {
const { t } = useT()
return (
<Row last={last} className={disabled ? 'opacity-50' : ''}>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.quiet.times.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.quiet.times.hint')}
</div>
</div>
<div className="flex items-center gap-2">
<input
type="time"
value={qh.from}
disabled={disabled}
onChange={(e) => 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"
/>
<span className="text-text/45 text-[14px]"></span>
<input
type="time"
value={qh.to}
disabled={disabled}
onChange={(e) => 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"
/>
</div>
</Row>
)
}
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 (
<Row last={last} className={disabled ? 'opacity-50' : ''}>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.quiet.days.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.quiet.days.hint')}
</div>
</div>
<div className="flex items-center gap-1 flex-wrap justify-end max-w-[60%]">
{labels.map((label, d) => {
const on = qh.days.includes(d)
return (
<button
key={d}
type="button"
disabled={disabled}
onClick={() => toggle(d)}
className={[
'h-7 min-w-[28px] px-1.5 rounded-full text-[11px] font-semibold transition-all',
on
? 'bg-accent text-white'
: 'bg-surface-2 text-text/55 hover:text-text'
].join(' ')}
>
{label}
</button>
)
})}
</div>
</Row>
)
}
function SelectRow({ function SelectRow({
label, label,
hint, hint,

View File

@@ -42,6 +42,10 @@ export const IPC = {
updaterDownload: 'updater:download', updaterDownload: 'updater:download',
updaterInstall: 'updater:install', updaterInstall: 'updater:install',
// History
getHistory: 'history:get',
clearHistory: 'history:clear',
// events from main → renderer // events from main → renderer
evtTick: 'evt:tick', evtTick: 'evt:tick',
evtFire: 'evt:fire', evtFire: 'evt:fire',

View File

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

View File

@@ -13,6 +13,19 @@ export type NotificationMode = 'toast' | 'modal' | 'both'
export type Theme = 'light' | 'dark' | 'system' export type Theme = 'light' | 'dark' | 'system'
export type Language = 'ru' | 'en' 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 = { export type Settings = {
globalEnabled: boolean globalEnabled: boolean
notificationMode: NotificationMode notificationMode: NotificationMode
@@ -23,6 +36,7 @@ export type Settings = {
theme: Theme theme: Theme
language: Language language: Language
snoozeMinutes: number snoozeMinutes: number
quietHours: QuietHours
} }
export type AppState = { export type AppState = {
@@ -30,6 +44,18 @@ export type AppState = {
settings: Settings settings: Settings
challenges: Challenge[] challenges: Challenge[]
gamesEnabled: Partial<Record<GameId, boolean>> gamesEnabled: Partial<Record<GameId, boolean>>
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 = { export type Tick = {
@@ -141,7 +167,36 @@ export const DEFAULT_SETTINGS: Settings = {
startMinimized: false, startMinimized: false,
theme: 'light', theme: 'light',
language: 'ru', 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<Exercise, 'id' | 'nextFireAt'>[] = [ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [