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; /// /// Тренировки всех атлетов (spec 03 m08): список с фильтрами, /// карточка (план/факт + facts-таймлайн), CSV выборки (КУ-4). /// workout_select требует одного атлета — выборка «всех» собирается /// обходом атлетов (v1: атлетов немного). 🛡3: только чтение. /// 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 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 SelectAll(int? athleteId, string? dateFrom, string? dateTo, out Dictionary names) { names = athletesRepo.Select(null).ToDictionary(a => a.Id, a => a.Name); var from = ParseDate(dateFrom); var to = ParseDate(dateTo); var result = new List(); 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 Card(Workout w, Dictionary 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 { ["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; } /// План/факт по упражнениям (spec 03 §2): план — первая /// сессия дня недели активной версии (v1 — без ротации недель). private object PlanFact(Workout w, Dictionary 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(); 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 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 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) : ""); } }