VEU.WathBody/3-app/2-cli/Server.WatchBody.Web/Controllers/Admin/AdminWorkoutsController.cs

226 lines
10 KiB
C#
Raw 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;
using Microsoft.AspNetCore.Mvc;
using Server.WatchBody.Core.Dal.Src.Domain;
using Server.WatchBody.Core.Dal.Src.Repository;
using Server.WatchBody.Web.Services;
namespace Server.WatchBody.Web.Controllers.Admin;
/// <summary>
/// Тренировки всех атлетов (spec 03 m08): список с фильтрами,
/// карточка (план/факт + facts-таймлайн), CSV выборки (КУ-4).
/// workout_select требует одного атлета — выборка «всех» собирается
/// обходом атлетов (v1: атлетов немного). 🛡3: только чтение.
/// </summary>
public class AdminWorkoutsController(
AdminAuthService auth,
IAthleteRepository athletesRepo,
IWorkoutRepository workouts,
IWorkoutMetricsRepository metrics,
IExerciseRepository exercises,
Core.Programs.ProgramStore programs) : AdminControllerBase(auth)
{
private const int CsvLimit = 10_000;
private static readonly string[] DayNames =
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
public ActionResult List(int? athleteId, string? dateFrom, string? dateTo, int? limit, int? offset)
{
if (Denied()) return UnauthorizedJson();
var take = limit ?? 50;
var skip = offset ?? 0;
if (take is < 1 or > 100) return ErrorJson(400, "limit 1–100");
if (skip < 0) return ErrorJson(400, "offset >= 0");
var all = SelectAll(athleteId, dateFrom, dateTo, out var names);
var page = all.OrderByDescending(w => w.StartedAt)
.Skip(skip).Take(take)
.Select(w => Card(w, names, withSets: false)).ToList();
return new JsonResult(new { total = all.Count, items = page });
}
public ActionResult Get(string id)
{
if (Denied()) return UnauthorizedJson();
if (!Guid.TryParse(id, out var workoutId)) return ErrorJson(400, "id не uuid");
Workout? found = null;
Dictionary<int, string> names = new();
foreach (var athlete in athletesRepo.Select(null))
{
var candidate = workouts.Get(athlete.Id, workoutId);
if (candidate == null) continue;
found = candidate;
names[athlete.Id] = athlete.Name;
break;
}
if (found == null) return ErrorJson(404, "not_found");
var card = Card(found, names, withSets: true);
var (timeline, curve) = FactsTimeline.Parse(found.FactsJson, 0);
card["timeline"] = timeline;
card["hrCurve"] = curve;
return new JsonResult(card);
}
public ActionResult Csv(int? athleteId, string? dateFrom, string? dateTo)
{
if (Denied()) return UnauthorizedJson();
var all = SelectAll(athleteId, dateFrom, dateTo, out var names);
// tech-req 2.4: ';' + UTF-8 BOM (Excel), заголовок первой строкой
var csv = new StringBuilder();
csv.AppendLine("athlete;date;duration_min;avg_hr;sets;exercises;adherence_pct");
foreach (var workout in all.OrderByDescending(w => w.StartedAt).Take(CsvLimit))
csv.AppendLine(CsvRow(workout, names));
var bytes = Encoding.UTF8.GetPreamble()
.Concat(Encoding.UTF8.GetBytes(csv.ToString())).ToArray();
return File(bytes, "text/csv; charset=utf-8", "workouts.csv");
}
// ---------- вспомогательные ----------
private List<Workout> SelectAll(int? athleteId, string? dateFrom, string? dateTo,
out Dictionary<int, string> names)
{
names = athletesRepo.Select(null).ToDictionary(a => a.Id, a => a.Name);
var from = ParseDate(dateFrom);
var to = ParseDate(dateTo);
var result = new List<Workout>();
foreach (var athlete in names.Keys)
{
if (athleteId != null && athlete != athleteId.Value) continue;
result.AddRange(workouts.Select(new WorkoutFilter
{
AthleteId = athlete, DateFrom = from, DateTo = to,
}));
}
return result;
}
private static DateOnly? ParseDate(string? text) =>
text != null && DateOnly.TryParse(text, out var date) ? date : null;
private Dictionary<string, object?> Card(Workout w, Dictionary<int, string> names, bool withSets)
{
var durationMin = w.EndedAt != null
? (int)Math.Round((w.EndedAt.Value - w.StartedAt).TotalMinutes, MidpointRounding.AwayFromZero)
: (int?)null;
var metric = metrics.Select(new WorkoutMetricsFilter { AthleteId = w.AthleteId, WorkoutId = w.Id })
.FirstOrDefault();
var sets = w.Sets.ToList();
var card = new Dictionary<string, object?>
{
["workoutId"] = w.Id,
["athleteId"] = w.AthleteId,
["athleteName"] = names.GetValueOrDefault(w.AthleteId, $"#{w.AthleteId}"),
["startedAt"] = w.StartedAt,
["endedAt"] = w.EndedAt,
["durationMin"] = durationMin,
["status"] = w.Status,
["sessionKind"] = w.SessionKind,
["avgHr"] = w.AvgHr,
["setsCount"] = sets.Count,
["exercisesCount"] = sets.Where(s => s.ExerciseId != null)
.Select(s => s.ExerciseId!.Value).Distinct().Count(),
["adherence"] = metric?.Adherence,
};
if (!withSets) return card;
var exNames = exercises.Select(new ExerciseFilter { AthleteId = w.AthleteId })
.ToDictionary(e => e.Id, e => e.Name);
card["sets"] = sets.OrderBy(s => s.Seq).Select(s => new
{
s.Seq,
exerciseName = s.ExerciseId != null && exNames.TryGetValue(s.ExerciseId.Value, out var n) ? n : null,
s.WeightKg,
s.Reps,
s.InPlan,
s.Rpe,
s.KneePain,
}).ToList();
card["planFact"] = PlanFact(w, exNames);
return card;
}
/// <summary>План/факт по упражнениям (spec 03 §2): план — первая
/// сессия дня недели активной версии (v1 — без ротации недель).</summary>
private object PlanFact(Workout w, Dictionary<Guid, string> exNames)
{
var active = programs.GetActive(w.AthleteId);
var date = DateOnly.FromDateTime(w.StartedAt);
var day = DayNames[((int)date.DayOfWeek + 6) % 7];
var plan = new List<object>();
if (active == null || string.IsNullOrWhiteSpace(active.Body))
return new { day, plan };
try
{
using var doc = System.Text.Json.JsonDocument.Parse(active.Body);
foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray())
foreach (var session in week.GetProperty("sessions").EnumerateArray())
{
if (!session.TryGetProperty("day", out var d) ||
!string.Equals(d.GetString(), day, StringComparison.OrdinalIgnoreCase)) continue;
foreach (var exercise in session.GetProperty("exercises").EnumerateArray())
plan.Add(PlanFactExercise(exercise, w, exNames));
return new { day, plan }; // первая сессия дня
}
}
catch (Exception ex) when (ex is System.Text.Json.JsonException or KeyNotFoundException)
{
// кривой body — только факт
}
return new { day, plan };
}
private static object PlanFactExercise(System.Text.Json.JsonElement exercise, Workout w,
Dictionary<Guid, string> exNames)
{
var name = exercise.TryGetProperty("exercise_id", out var e) ? e.GetString() : "?";
var setsNode = exercise.TryGetProperty("sets", out var s) ? s : default;
var hasSets = setsNode.ValueKind == System.Text.Json.JsonValueKind.Array;
var first = hasSets && setsNode.GetArrayLength() > 0 ? setsNode[0] : default;
var weight = first.ValueKind == System.Text.Json.JsonValueKind.Object &&
first.TryGetProperty("weight_kg", out var wNode) &&
wNode.ValueKind == System.Text.Json.JsonValueKind.Number
? wNode.GetDecimal() : (decimal?)null;
var reps = first.ValueKind == System.Text.Json.JsonValueKind.Object &&
first.TryGetProperty("reps", out var rNode) &&
rNode.ValueKind == System.Text.Json.JsonValueKind.Number
? rNode.GetInt32() : (int?)null;
var factSets = w.Sets.Where(fs =>
fs.ExerciseId != null && exNames.GetValueOrDefault(fs.ExerciseId.Value) == name)
.ToList();
return new
{
exercise = name,
plannedSets = hasSets ? setsNode.GetArrayLength() : 0,
plannedWeight = weight,
plannedReps = reps,
factSets = factSets.Count,
factWeight = factSets.Count > 0 ? factSets.Max(fs => fs.WeightKg) : (decimal?)null,
factReps = factSets.Count > 0 ? factSets.Max(fs => fs.Reps) : (int?)null,
};
}
private string CsvRow(Workout w, Dictionary<int, string> names)
{
var metric = metrics.Select(new WorkoutMetricsFilter { AthleteId = w.AthleteId, WorkoutId = w.Id })
.FirstOrDefault();
var sets = w.Sets.ToList();
var invariant = System.Globalization.CultureInfo.InvariantCulture;
var durationMin = w.EndedAt != null
? ((int)Math.Round((w.EndedAt.Value - w.StartedAt).TotalMinutes, MidpointRounding.AwayFromZero))
.ToString(invariant) : "";
return string.Join(';',
names.GetValueOrDefault(w.AthleteId, $"#{w.AthleteId}"),
DateOnly.FromDateTime(w.StartedAt).ToString("yyyy-MM-dd"),
durationMin,
w.AvgHr?.ToString(invariant) ?? "",
sets.Count.ToString(invariant),
sets.Where(s => s.ExerciseId != null).Select(s => s.ExerciseId!.Value).Distinct().Count()
.ToString(invariant),
metric != null ? metric.Adherence.ToString("0.0", invariant) : "");
}
}