VEU.WathBody/3-app/2-cli/Server.WatchBody.Core/Programs/BreakEngine.cs

172 lines
8.0 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Перерывы (Ф-14): авто-детект по пропускам (source=auto), закрытие
/// первой сессией, формула возврата (документ перерывов разд. 3.2) →
/// draft-версия (author=rules) с session_kind=return|test в week 1.
/// 🛡 Активная версия не меняется; запись разрешена только в breaks
/// и через ProgramStore.CreateDraft.
/// </summary>
public class BreakEngine(
IWorkoutRepository workouts,
IBreakRepository breaks,
ProgramStore programs,
ProgressionConfig config)
{
private static readonly string[] DayNames =
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
/// <summary>Авто-детект (spec 04 §2): все плановые дни окна
/// MissDaysForBreak без единой тренировки → break_insert(auto).</summary>
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<DateOnly>();
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;
}
/// <summary>Закрытие первой сессией после break (ended_on) и
/// draft-версия возврата с коэффициентом по таблице (spec 04 §3).</summary>
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<JsonObject>().ToList()
: [])
{
session["session_kind"] = kind;
foreach (var exerciseNode in session["exercises"] is JsonArray exerciseNodes
? exerciseNodes.OfType<JsonObject>().ToList()
: [])
foreach (var set in exerciseNode["sets"] is JsonArray sets
? sets.OfType<JsonObject>().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();
/// <summary>Плановые дни недели активной версии (v1 — все недели
/// одинаковы, ротация — v2).</summary>
private static HashSet<string> PlannedDays(string body)
{
var result = new HashSet<string>(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];
/// <summary>Первая сессия мягче: подходы −1 (минимум 1).</summary>
private static void TrimSets(JsonObject session)
{
if (session["exercises"] is not JsonArray exerciseNodes) return;
foreach (var exerciseNode in exerciseNodes.OfType<JsonObject>())
{
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;
/// <summary>Rules-draft на активную версию или новее — предложений
/// не создаём (spec 04 §3.3).</summary>
private bool HasRulesDraft(int athleteId, int activeVersion) =>
programs.ListVersions(athleteId).Any(v =>
v.Author == ProgramVersionAuthors.Rules &&
v.Status == ProgramVersionStatuses.Draft &&
(v.BasedOnVersion ?? 0) >= activeVersion);
}