226 lines
10 KiB
C#
226 lines
10 KiB
C#
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) : "");
|
||
}
|
||
}
|