== История и стрики (#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>
93 lines
2.2 KiB
TypeScript
93 lines
2.2 KiB
TypeScript
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'
|
|
|
|
const TICK_MS = 1000
|
|
const CHECK_MS = 5000
|
|
let tickHandle: NodeJS.Timeout | null = null
|
|
let lastCheckAt = 0
|
|
let paused = false
|
|
|
|
function checkDueExercises(): void {
|
|
if (paused) return
|
|
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) {
|
|
const updated = updateExercise(ex.id, {
|
|
nextFireAt: now + ex.intervalMinutes * 60_000
|
|
})
|
|
if (updated) fireReminder(updated, settings.notificationMode)
|
|
}
|
|
}
|
|
}
|
|
|
|
function broadcastTicks(): void {
|
|
const now = Date.now()
|
|
const ticks: Tick[] = getExercises().map((e) => ({
|
|
exerciseId: e.id,
|
|
msUntilFire: Math.max(0, e.nextFireAt - now),
|
|
enabled: e.enabled
|
|
}))
|
|
for (const win of BrowserWindow.getAllWindows()) {
|
|
if (!win.isDestroyed()) win.webContents.send(IPC.evtTick, ticks)
|
|
}
|
|
}
|
|
|
|
function tick(): void {
|
|
broadcastTicks()
|
|
const now = Date.now()
|
|
if (now - lastCheckAt >= CHECK_MS) {
|
|
lastCheckAt = now
|
|
checkDueExercises()
|
|
}
|
|
}
|
|
|
|
export function startScheduler(): void {
|
|
if (tickHandle) return
|
|
lastCheckAt = 0
|
|
tickHandle = setInterval(tick, TICK_MS)
|
|
// Run an immediate tick so renderer hydrates quickly.
|
|
tick()
|
|
|
|
powerMonitor.on('resume', () => {
|
|
lastCheckAt = 0
|
|
tick()
|
|
})
|
|
powerMonitor.on('unlock-screen', () => {
|
|
lastCheckAt = 0
|
|
tick()
|
|
})
|
|
}
|
|
|
|
export function stopScheduler(): void {
|
|
if (tickHandle) {
|
|
clearInterval(tickHandle)
|
|
tickHandle = null
|
|
}
|
|
}
|
|
|
|
export function setPaused(value: boolean): void {
|
|
paused = value
|
|
}
|
|
|
|
export function isPaused(): boolean {
|
|
return paused
|
|
}
|
|
|
|
export function forceCheck(): void {
|
|
lastCheckAt = 0
|
|
tick()
|
|
}
|