using System.Text.Json; using System.Text.Json.Nodes; using Server.WatchBody.Core.Dal.Src.Domain; using Server.WatchBody.Core.Dal.Src.Repository; namespace Server.WatchBody.Core.Programs; /// /// Перерывы (Ф-14): авто-детект по пропускам (source=auto), закрытие /// первой сессией, формула возврата (документ перерывов разд. 3.2) → /// draft-версия (author=rules) с session_kind=return|test в week 1. /// 🛡 Активная версия не меняется; запись разрешена только в breaks /// и через ProgramStore.CreateDraft. /// public class BreakEngine( IWorkoutRepository workouts, IBreakRepository breaks, ProgramStore programs, ProgressionConfig config) { private static readonly string[] DayNames = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; /// Авто-детект (spec 04 §2): все плановые дни окна /// MissDaysForBreak без единой тренировки → break_insert(auto). public bool DetectBreak(int athleteId, DateOnly today) { if (OpenBreak(athleteId) != null) return false; var active = programs.GetActive(athleteId); if (active == null || string.IsNullOrWhiteSpace(active.Body)) return false; var plannedDays = PlannedDays(active.Body); var windowStart = today.AddDays(-(config.MissDaysForBreak - 1)); var workoutDays = workouts .Select(new WorkoutFilter { AthleteId = athleteId, DateFrom = windowStart, DateTo = today }) .Select(w => DateOnly.FromDateTime(w.StartedAt)) .ToHashSet(); if (workoutDays.Count > 0) return false; // в окне есть тренировка — не перерыв var missed = new List(); for (var day = windowStart; day <= today; day = day.AddDays(1)) { if (!plannedDays.Contains(DayName(day))) continue; missed.Add(day); } if (missed.Count == 0) return false; breaks.Insert(athleteId, new Break { Id = Guid.NewGuid(), AthleteId = athleteId, Source = BreakSources.Auto, StartedOn = missed[0], Note = $"авто: пропущены все сессии за {config.MissDaysForBreak} дней", }); return true; } /// Закрытие первой сессией после break (ended_on) и /// draft-версия возврата с коэффициентом по таблице (spec 04 §3). public ProgramVersion? CloseOnReturn(int athleteId, Guid workoutId, DateOnly today) { var open = OpenBreak(athleteId); if (open == null) return null; var active = programs.GetActive(athleteId); if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null; var workout = workouts.Get(athleteId, workoutId); if (workout == null) return null; var endedOn = DateOnly.FromDateTime(workout.StartedAt); open.EndedOn = endedOn; breaks.Update(athleteId, open); // closed_by_first_session (db-m-8) if (HasRulesDraft(athleteId, active.Version)) return null; var days = endedOn.DayNumber - open.StartedOn.DayNumber; var factor = open.Kind == BreakKinds.Illness ? config.ReturnMinFactor // болезнь — перезапуск (правило 12) : days switch { <= 6 => 1m, // 4–6 дней: веса без снижения <= 13 => 0.9m, // 7–13: −10 % <= 27 => 0.85m, // 14–27: −15 % <= 59 => 0.75m, // 28–59: −25 % _ => config.ReturnMinFactor, // 60+: перезапуск }; if (factor < 1m) factor = Math.Clamp(factor, config.ReturnMinFactor, config.ReturnMaxFactor); var kind = days >= 14 && open.Kind != BreakKinds.Illness ? SessionKinds.Test // правило 17: тест-сессия RPE ≤ 6 : SessionKinds.Return; var body = JsonNode.Parse(active.Body) as JsonObject ?? throw new InvalidOperationException("body активной версии не является объектом"); var firstWeek = body["weeks"] is JsonArray weeks && weeks.Count > 0 ? weeks[0] as JsonObject : null; if (firstWeek != null) foreach (var session in firstWeek["sessions"] is JsonArray sessions ? sessions.OfType().ToList() : []) { session["session_kind"] = kind; foreach (var exerciseNode in session["exercises"] is JsonArray exerciseNodes ? exerciseNodes.OfType().ToList() : []) foreach (var set in exerciseNode["sets"] is JsonArray sets ? sets.OfType().ToList() : []) { if (set["weight_kg"] is JsonValue value && value.TryGetValue(out decimal weight)) set["weight_kg"] = Halves(weight * factor); // 4–6 дней: первая сессия мягче (−1 подход, RPE 5–6) if (days <= 6 && set["target_rpe"] != null) set["target_rpe"] = 5; } if (days <= 6) TrimSets(session); } var kindNote = open.Kind == BreakKinds.Illness ? "болезнь" : $"простой {days} дн."; return programs.CreateDraft(athleteId, body.ToJsonString(), ProgramVersionAuthors.Rules, $"Возврат после break ({kindNote}): веса ×{factor}, session_kind={kind}"); } private Break? OpenBreak(int athleteId) => breaks.Select(new BreakFilter { AthleteId = athleteId }) .Where(b => b.EndedOn == null) .OrderByDescending(b => b.StartedOn) .FirstOrDefault(); /// Плановые дни недели активной версии (v1 — все недели /// одинаковы, ротация — v2). private static HashSet PlannedDays(string body) { var result = new HashSet(StringComparer.OrdinalIgnoreCase); using var doc = JsonDocument.Parse(body); foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray()) foreach (var session in week.GetProperty("sessions").EnumerateArray()) if (session.TryGetProperty("day", out var day) && day.ValueKind == JsonValueKind.String) result.Add(day.GetString()!); return result; } private static string DayName(DateOnly date) => DayNames[((int)date.DayOfWeek + 6) % 7]; /// Первая сессия мягче: подходы −1 (минимум 1). private static void TrimSets(JsonObject session) { if (session["exercises"] is not JsonArray exerciseNodes) return; foreach (var exerciseNode in exerciseNodes.OfType()) { if (exerciseNode["sets"] is not JsonArray sets) continue; if (sets.Count > 1) sets.RemoveAt(sets.Count - 1); } } private static decimal Halves(decimal value) => Math.Round(value * 2m, MidpointRounding.AwayFromZero) / 2m; /// Rules-draft на активную версию или новее — предложений /// не создаём (spec 04 §3.3). private bool HasRulesDraft(int athleteId, int activeVersion) => programs.ListVersions(athleteId).Any(v => v.Author == ProgramVersionAuthors.Rules && v.Status == ProgramVersionStatuses.Draft && (v.BasedOnVersion ?? 0) >= activeVersion); }