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

View File

@@ -13,6 +13,8 @@ type Draft = {
intervalMinutes: number
enabled: boolean
category: ReminderCategory
/** undefined = без дневной цели (только interval). */
dailyGoal?: number
}
const EMPTY: Draft = {
@@ -21,7 +23,8 @@ const EMPTY: Draft = {
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
category: 'exercise'
category: 'exercise',
dailyGoal: undefined
}
type Props = {
@@ -48,7 +51,8 @@ export function ExerciseEditor({
icon: exercise.icon,
intervalMinutes: exercise.intervalMinutes,
enabled: exercise.enabled,
category: exercise.category ?? 'exercise'
category: exercise.category ?? 'exercise',
dailyGoal: exercise.dailyGoal
})
} else {
setDraft(EMPTY)
@@ -156,6 +160,39 @@ export function ExerciseEditor({
</Field>
</div>
<Field label={t('editor.field.daily_goal')}>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
placeholder={t('editor.field.daily_goal.placeholder')}
value={draft.dailyGoal ?? ''}
onChange={(e) => {
const v = e.target.value
if (v === '') setDraft({ ...draft, dailyGoal: undefined })
else
setDraft({
...draft,
dailyGoal: Math.max(1, Number(v) || 1)
})
}}
className="ios-input font-mono-num flex-1"
/>
{draft.dailyGoal !== undefined && (
<button
type="button"
onClick={() => setDraft({ ...draft, dailyGoal: undefined })}
className="h-9 px-3 rounded-xl bg-surface-2 text-text/65 text-[13px] font-semibold hover:text-text"
>
{t('editor.field.daily_goal.clear')}
</button>
)}
</div>
<div className="text-[12px] text-text/55 mt-1.5 leading-snug">
{t('editor.field.daily_goal.hint')}
</div>
</Field>
<Field label={t('editor.field.icon')}>
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
{ICON_CHOICES.map((name) => (

View File

@@ -250,6 +250,11 @@ export const ru: Dict = {
'category.eyes.cta': 'Дай глазам отдохнуть',
'category.posture.cta': 'Проверь осанку',
'editor.field.category': 'Категория',
'editor.field.daily_goal': 'Дневная цель',
'editor.field.daily_goal.placeholder': 'без ограничения',
'editor.field.daily_goal.clear': 'Снять',
'editor.field.daily_goal.hint':
'Когда наберёшь столько повторений за день, напоминания этого упражнения умолкнут до завтра.',
// Reminder window
'reminder.kicker': 'Время тренировки',
@@ -547,6 +552,11 @@ export const en: Dict = {
'category.eyes.cta': 'Rest your eyes',
'category.posture.cta': 'Check your posture',
'editor.field.category': 'Category',
'editor.field.daily_goal': 'Daily goal',
'editor.field.daily_goal.placeholder': 'no limit',
'editor.field.daily_goal.clear': 'Clear',
'editor.field.daily_goal.hint':
'Once you hit this many reps in a day, this reminder goes quiet until tomorrow.',
// Reminder window
'reminder.kicker': 'Workout time',

View File

@@ -74,6 +74,7 @@ export default function Dashboard(): JSX.Element {
intervalMinutes: number
enabled: boolean
category: import('@shared/types').ReminderCategory
dailyGoal?: number
}): Promise<void> {
if (editing) await window.api.updateExercise(editing.id, draft)
else await window.api.addExercise(draft)

View File

@@ -26,6 +26,14 @@ export type Exercise = {
lastDoneAt?: number
/** Default 'exercise' если undefined — обратная совместимость. */
category?: ReminderCategory
/**
* Опциональная дневная цель в reps. Если задана, scheduler перестаёт
* fire'ить упражнение в течение дня, когда total reps за сегодня
* (учитывая actualReps в истории) достигают `dailyGoal`. Это «soft cap»
* поверх обычного interval'а: не меняет схему таймера, просто блокирует
* fires когда цель закрыта. Завтра счётчик обнуляется (по local day).
*/
dailyGoal?: number
}
export type NotificationMode = 'toast' | 'modal' | 'both'