172 lines
8.0 KiB
C#
172 lines
8.0 KiB
C#
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);
|
||
}
|