using System.Text.Json; namespace Server.WatchBody.Web.Services; /// /// Таймлайн тренировки из facts_json (spec 03 §2 m08): по Lap — /// длительность/avg/peak пульс/отдых; пульсограмма records с /// прореживанием до 600 точек. /// public static class FactsTimeline { public static (List> Timeline, List> Curve) Parse( string? factsJson, int sessionDurationS) { var timeline = new List>(); var curve = new List>(); if (string.IsNullOrWhiteSpace(factsJson)) return (timeline, curve); try { using var doc = JsonDocument.Parse(factsJson); var root = doc.RootElement; var records = new List<(int t, int hr)>(); if (root.TryGetProperty("records", out var recordNodes)) foreach (var r in recordNodes.EnumerateArray()) if (r.TryGetProperty("t_offset_s", out var t) && r.TryGetProperty("hr", out var hr)) records.Add((t.GetInt32(), hr.GetInt32())); // пульсограмма: не более 600 точек if (records.Count > 0) { var step = Math.Max(1, (int)Math.Ceiling(records.Count / 600.0)); for (var i = 0; i < records.Count; i += step) curve.Add(new Dictionary { ["t"] = records[i].t, ["hr"] = records[i].hr }); } if (!root.TryGetProperty("laps", out var lapNodes)) return (timeline, curve); var laps = new List<(DateTime start, int durationS)>(); foreach (var l in lapNodes.EnumerateArray()) if (l.TryGetProperty("start", out var s) && l.TryGetProperty("duration_s", out var d) && DateTime.TryParse(s.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var start)) laps.Add((start, d.GetInt32())); var sessionStart = laps.Count > 0 ? laps.Min(l => l.start) : records.Count > 0 ? DateTime.MinValue : DateTime.MinValue; for (var i = 0; i < laps.Count; i++) { var offset = (int)(laps[i].start - sessionStart).TotalSeconds; var end = offset + laps[i].durationS; var nextStart = i + 1 < laps.Count ? (int)(laps[i + 1].start - sessionStart).TotalSeconds : end; var inLap = records.Where(r => r.t >= offset && r.t <= end).Select(r => r.hr).ToList(); timeline.Add(new Dictionary { ["index"] = i, ["startOffsetS"] = offset, ["durationS"] = laps[i].durationS, ["avgHr"] = inLap.Count > 0 ? (int)Math.Round(inLap.Average(), MidpointRounding.AwayFromZero) : null, ["peakHr"] = inLap.Count > 0 ? inLap.Max() : null, ["restAfterS"] = Math.Max(0, nextStart - end), }); } } catch (JsonException) { // битые факты — пустой таймлайн } return (timeline, curve); } }