feat(#12): дневная цель — soft cap reps/день, после которого упражнение умолкает

This commit is contained in:
AnRil
2026-05-22 13:45:37 +07:00
parent 9e59be9cba
commit a6ae931461
6 changed files with 121 additions and 11 deletions

View File

@@ -1,11 +1,29 @@
import { powerMonitor, BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc'
import type { Tick } from '@shared/types'
import type { Exercise, Tick, HistoryEntry } from '@shared/types'
import { isQuietAt } from '@shared/types'
import { getExercises, getSettings, updateExercise } from './store'
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.
@@ -29,18 +47,33 @@ function checkDueExercises(): void {
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) {
const updated = updateExercise(ex.id, {
nextFireAt: now + ex.intervalMinutes * 60_000
})
if (updated) {
anyFired = true
fireReminder(updated, settings.notificationMode)
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.

View File

@@ -92,6 +92,16 @@ export function validateExerciseInput(
const icon = safeStr(raw.icon, 64) ?? 'Activity'
const enabled = bool(raw.enabled) ?? true
const category = oneOf(raw.category, VALID_CATEGORIES) // undefined OK = default
const dailyGoal =
raw.dailyGoal === undefined || raw.dailyGoal === null
? undefined
: intInRange(raw.dailyGoal, 1, 100_000)
// dailyGoal: undefined = не задан (нет soft-cap'a), null от UI приводим к
// undefined; иначе — должен пройти int-range, иначе reject (нельзя
// отправить из renderer'а NaN/негатив и тихо обнулить).
if (raw.dailyGoal !== undefined && raw.dailyGoal !== null && dailyGoal === undefined) {
return null
}
if (
name === undefined ||
reps === undefined ||
@@ -107,6 +117,7 @@ export function validateExerciseInput(
enabled
}
if (category !== undefined) out.category = category
if (dailyGoal !== undefined) out.dailyGoal = dailyGoal
return out
}
@@ -145,6 +156,16 @@ export function validateExercisePatch(
if (v === undefined) return null
out.category = v
}
if ('dailyGoal' in raw) {
// Допустим null/undefined как «снять goal».
if (raw.dailyGoal === null || raw.dailyGoal === undefined) {
out.dailyGoal = undefined
} else {
const v = intInRange(raw.dailyGoal, 1, 100_000)
if (v === undefined) return null
out.dailyGoal = v
}
}
// Allow scheduler-controlled fields to be patched (used by store.markDone
// through this same boundary), but range-check them.
if ('nextFireAt' in raw) {