From a0b89ddf7132d3d18b7b3c731dd7e3ee75352bcf Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 22 May 2026 13:52:38 +0700 Subject: [PATCH] =?UTF-8?q?feat(#2):=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D1=8B=D0=B9=20=D1=88=D0=B5=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=20=E2=80=94=20=D1=81=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20=C2=AB=D1=85=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B5=C2=BB=20=D1=87=D0=B0=D1=81=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/adaptive.ts | 94 +++++++++++++++++++ src/main/scheduler.ts | 21 +++-- src/main/validate.ts | 7 ++ .../src/components/ExerciseEditor.tsx | 26 ++++- src/renderer/src/i18n/dict.ts | 6 ++ src/renderer/src/pages/Dashboard.tsx | 1 + src/shared/types.ts | 6 ++ 7 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 src/main/adaptive.ts diff --git a/src/main/adaptive.ts b/src/main/adaptive.ts new file mode 100644 index 0000000..8ceed42 --- /dev/null +++ b/src/main/adaptive.ts @@ -0,0 +1,94 @@ +/** + * Адаптивный шедулер v1: смотрит на исторические success/skip-паттерны + * по часам дня и сдвигает следующий fire на ближайший «хороший» час. + * + * Без ML, чистая heuristic. Идея: каждому часу 0..23 присваиваем + * success-rate = done / (done + skip + snooze). Если интервал шедулера + * приходится на «плохой» час (success ≤ 30%), сдвигаем fire вперёд на + * ближайший «хороший» (≥ 50% success или не имеющий данных = neutral). + * + * Окно учитываемой истории: 30 дней. Требует минимум 10 событий по + * упражнению, иначе истории слишком мало для статистики — возвращаем + * candidate без изменений. + * + * Этот модуль НЕ обязателен для работы шедулера; вызывается опционально + * когда у упражнения `adaptive: true`. + */ +import type { Exercise, HistoryEntry } from '@shared/types' + +const LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000 // 30 дней +const MIN_EVENTS_FOR_TRUST = 10 +const BAD_HOUR_THRESHOLD = 0.3 +const GOOD_HOUR_THRESHOLD = 0.5 +/** Максимальный сдвиг — чтобы не утащить fire на 6 часов вперёд. */ +const MAX_SHIFT_HOURS = 4 + +type HourStats = { + done: number + skipped: number + total: number +} + +function buildHourStats( + exerciseId: string, + history: HistoryEntry[] +): HourStats[] { + const cutoff = Date.now() - LOOKBACK_MS + const stats: HourStats[] = Array.from({ length: 24 }, () => ({ + done: 0, + skipped: 0, + total: 0 + })) + for (const e of history) { + if (e.exerciseId !== exerciseId) continue + if (e.ts < cutoff) continue + const h = new Date(e.ts).getHours() + stats[h].total++ + if (e.action === 'done') stats[h].done++ + else stats[h].skipped++ // skip + snooze оба считаются «не done» + } + return stats +} + +function isHourGood(s: HourStats): boolean { + if (s.total === 0) return true // нет данных — нейтрально, ОК + return s.done / s.total >= GOOD_HOUR_THRESHOLD +} + +function isHourBad(s: HourStats): boolean { + if (s.total < 3) return false // данных мало, не делаем выводы + return s.done / s.total <= BAD_HOUR_THRESHOLD +} + +/** + * Возвращает скорректированный timestamp следующего fire. Если candidate + * попадает на «плохой» час и есть «хороший» час в пределах MAX_SHIFT_HOURS, + * сдвигаем на начало этого хорошего часа. Иначе возвращаем candidate + * как есть. + */ +export function adjustNextFireAt( + exercise: Exercise, + candidateTs: number, + history: HistoryEntry[] +): number { + // Сначала собираем total — если истории мало, не двигаем. + const stats = buildHourStats(exercise.id, history) + const total = stats.reduce((s, h) => s + h.total, 0) + if (total < MIN_EVENTS_FOR_TRUST) return candidateTs + + const candDate = new Date(candidateTs) + const candHour = candDate.getHours() + if (!isHourBad(stats[candHour])) return candidateTs + + // Ищем ближайший «хороший» час в будущем (внутри MAX_SHIFT_HOURS). + for (let shift = 1; shift <= MAX_SHIFT_HOURS; shift++) { + const h = (candHour + shift) % 24 + if (isHourGood(stats[h])) { + const target = new Date(candDate) + target.setHours(candHour + shift, 0, 0, 0) + return target.getTime() + } + } + // Не нашли — оставляем как есть. + return candidateTs +} diff --git a/src/main/scheduler.ts b/src/main/scheduler.ts index 3654efb..0f74cf3 100644 --- a/src/main/scheduler.ts +++ b/src/main/scheduler.ts @@ -6,6 +6,7 @@ import { getExercises, getHistory, getSettings, updateExercise } from './store' import { fireReminder } from './notifications' import { broadcastState } from './state-actions' import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect' +import { adjustNextFireAt } from './adaptive' /** * Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day). @@ -57,10 +58,12 @@ 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() : [] + // history запрашивается если у какого-нибудь упражнения есть + // dailyGoal или adaptive: false — иначе экономим IPC-нагрузку. + const needsHistory = exercises.some( + (e) => e.dailyGoal !== undefined || e.adaptive + ) + const history = needsHistory ? getHistory() : [] let anyFired = false for (const ex of exercises) { if (!ex.enabled) continue @@ -77,9 +80,13 @@ function checkDueExercises(): void { continue } } - const updated = updateExercise(ex.id, { - nextFireAt: now + ex.intervalMinutes * 60_000 - }) + // Базовый candidate. Если adaptive — сдвигаем на «хороший» час + // по исторической статистике успеха/скипов. + let nextFireAt = now + ex.intervalMinutes * 60_000 + if (ex.adaptive) { + nextFireAt = adjustNextFireAt(ex, nextFireAt, history) + } + const updated = updateExercise(ex.id, { nextFireAt }) if (updated) { anyFired = true fireReminder(updated, settings.notificationMode) diff --git a/src/main/validate.ts b/src/main/validate.ts index d414240..b835933 100644 --- a/src/main/validate.ts +++ b/src/main/validate.ts @@ -118,6 +118,8 @@ export function validateExerciseInput( } if (category !== undefined) out.category = category if (dailyGoal !== undefined) out.dailyGoal = dailyGoal + const adaptive = bool(raw.adaptive) + if (adaptive !== undefined) out.adaptive = adaptive return out } @@ -166,6 +168,11 @@ export function validateExercisePatch( out.dailyGoal = v } } + if ('adaptive' in raw) { + const v = bool(raw.adaptive) + if (v === undefined) return null + out.adaptive = 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 70cf7c7..76014c4 100644 --- a/src/renderer/src/components/ExerciseEditor.tsx +++ b/src/renderer/src/components/ExerciseEditor.tsx @@ -15,6 +15,7 @@ type Draft = { category: ReminderCategory /** undefined = без дневной цели (только interval). */ dailyGoal?: number + adaptive?: boolean } const EMPTY: Draft = { @@ -24,7 +25,8 @@ const EMPTY: Draft = { intervalMinutes: 30, enabled: true, category: 'exercise', - dailyGoal: undefined + dailyGoal: undefined, + adaptive: false } type Props = { @@ -52,7 +54,8 @@ export function ExerciseEditor({ intervalMinutes: exercise.intervalMinutes, enabled: exercise.enabled, category: exercise.category ?? 'exercise', - dailyGoal: exercise.dailyGoal + dailyGoal: exercise.dailyGoal, + adaptive: exercise.adaptive ?? false }) } else { setDraft(EMPTY) @@ -193,6 +196,25 @@ export function ExerciseEditor({ + +
{ICON_CHOICES.map((name) => ( diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index adb5079..77bde07 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -258,6 +258,9 @@ export const ru: Dict = { 'editor.field.daily_goal.clear': 'Снять', 'editor.field.daily_goal.hint': 'Когда наберёшь столько повторений за день, напоминания этого упражнения умолкнут до завтра.', + 'editor.field.adaptive.label': 'Адаптивное расписание', + 'editor.field.adaptive.hint': + 'Шедулер изучает, в какие часы ты чаще делаешь упражнение, и сдвигает напоминания на удобное тебе время. Заработает после 10 событий в истории.', // Reminder window 'reminder.kicker': 'Время тренировки', @@ -563,6 +566,9 @@ export const en: Dict = { '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.', + 'editor.field.adaptive.label': 'Adaptive scheduling', + 'editor.field.adaptive.hint': + 'Scheduler learns which hours you reliably do this exercise and shifts reminders into your good windows. Kicks in after 10 history events.', // Reminder window 'reminder.kicker': 'Workout time', diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index b981e8a..80c1ae7 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -75,6 +75,7 @@ export default function Dashboard(): JSX.Element { enabled: boolean category: import('@shared/types').ReminderCategory dailyGoal?: number + adaptive?: boolean }): 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 d700f43..f91b2e8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -34,6 +34,12 @@ export type Exercise = { * fires когда цель закрыта. Завтра счётчик обнуляется (по local day). */ dailyGoal?: number + /** + * Адаптивный режим: scheduler анализирует исторические success/skip + * паттерны по часам и сдвигает fire'ы на «хорошие» часы. Не меняет + * базовый интервал — корректирует только timestamps. + */ + adaptive?: boolean } export type NotificationMode = 'toast' | 'modal' | 'both'