146 lines
4.5 KiB
TypeScript
146 lines
4.5 KiB
TypeScript
import { powerMonitor, BrowserWindow } from 'electron'
|
||
import { IPC } from '@shared/ipc'
|
||
import type { Exercise, Tick, HistoryEntry } from '@shared/types'
|
||
import { isQuietAt } from '@shared/types'
|
||
import { getExercises, getHistory, getSettings, updateExercise } from './store'
|
||
import { fireReminder } from './notifications'
|
||
import { broadcastState } from './state-actions'
|
||
|
||
/**
|
||
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
|
||
* Учитываем actualReps если задано (частичное выполнение), иначе planned reps.
|
||
*/
|
||
function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number {
|
||
const todayKey = new Date()
|
||
todayKey.setHours(0, 0, 0, 0)
|
||
const startMs = todayKey.getTime()
|
||
let sum = 0
|
||
for (const e of history) {
|
||
if (e.action !== 'done') continue
|
||
if (e.exerciseId !== ex.id) continue
|
||
if (e.ts < startMs) continue
|
||
sum += e.actualReps ?? ex.reps
|
||
}
|
||
return sum
|
||
}
|
||
|
||
/**
|
||
* TICK_MS drives the per-second countdown UI; CHECK_MS gates the (cheaper)
|
||
* "is anything due to fire?" pass so we don't iterate exercises every second.
|
||
*/
|
||
const TICK_MS = 1000
|
||
const CHECK_MS = 5000
|
||
|
||
let tickHandle: NodeJS.Timeout | null = null
|
||
let powerListenersArmed = false
|
||
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 until it closes. The next
|
||
// CHECK_MS pass after the window ends will pick them up.
|
||
if (isQuietAt(settings.quietHours, new Date())) return
|
||
|
||
const now = Date.now()
|
||
const exercises = getExercises()
|
||
// history запрашивается только если хотя бы у одного упражнения есть
|
||
// dailyGoal — для большинства pure-interval упражнений не нужна.
|
||
const anyGoal = exercises.some((e) => e.dailyGoal !== undefined)
|
||
const history = anyGoal ? getHistory() : []
|
||
let anyFired = false
|
||
for (const ex of exercises) {
|
||
if (!ex.enabled) continue
|
||
if (ex.nextFireAt > now) continue
|
||
// Soft cap: если dailyGoal задан и уже выполнен — переносим
|
||
// следующий fire на «начало завтра» (без повторных проверок до утра).
|
||
if (ex.dailyGoal !== undefined && ex.dailyGoal > 0) {
|
||
const done = repsDoneToday(ex, history)
|
||
if (done >= ex.dailyGoal) {
|
||
const tomorrow = new Date()
|
||
tomorrow.setHours(0, 0, 0, 0)
|
||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||
updateExercise(ex.id, { nextFireAt: tomorrow.getTime() })
|
||
continue
|
||
}
|
||
}
|
||
const updated = updateExercise(ex.id, {
|
||
nextFireAt: now + ex.intervalMinutes * 60_000
|
||
})
|
||
if (updated) {
|
||
anyFired = true
|
||
fireReminder(updated, settings.notificationMode)
|
||
}
|
||
}
|
||
// Push fresh state so the renderer's Dashboard/Exercises pages don't show
|
||
// stale `nextFireAt` until the next state-changing IPC arrives.
|
||
if (anyFired) broadcastState()
|
||
}
|
||
|
||
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()
|
||
|
||
// Only attach powerMonitor listeners once per process — startScheduler may
|
||
// be invoked again after stopScheduler in dev hot-reload paths and we don't
|
||
// want the same handler firing N times after a resume.
|
||
if (!powerListenersArmed) {
|
||
powerListenersArmed = true
|
||
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()
|
||
}
|