feat(#12): дневная цель — soft cap reps/день, после которого упражнение умолкает
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user