diff --git a/src/main/scheduler.ts b/src/main/scheduler.ts index 42212e5..18c1c2b 100644 --- a/src/main/scheduler.ts +++ b/src/main/scheduler.ts @@ -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. diff --git a/src/main/validate.ts b/src/main/validate.ts index 132bd83..3ed9d44 100644 --- a/src/main/validate.ts +++ b/src/main/validate.ts @@ -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) { diff --git a/src/renderer/src/components/ExerciseEditor.tsx b/src/renderer/src/components/ExerciseEditor.tsx index dd0f90f..70cf7c7 100644 --- a/src/renderer/src/components/ExerciseEditor.tsx +++ b/src/renderer/src/components/ExerciseEditor.tsx @@ -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({ + +
+ { + 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 && ( + + )} +
+
+ {t('editor.field.daily_goal.hint')} +
+
+
{ICON_CHOICES.map((name) => ( diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index cd35dc3..d279747 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -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', diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index e3ece5a..b981e8a 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -74,6 +74,7 @@ export default function Dashboard(): JSX.Element { intervalMinutes: number enabled: boolean category: import('@shared/types').ReminderCategory + dailyGoal?: number }): Promise { if (editing) await window.api.updateExercise(editing.id, draft) else await window.api.addExercise(draft) diff --git a/src/shared/types.ts b/src/shared/types.ts index 8852ade..a14c3d0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'