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