Hi
I’ve just seen your message. Here is the code:
<!-- import Highcharts + Chartkick libraries -->
<script src="https://code.highcharts.com/highcharts.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartkick@5.0.1/dist/chartkick.min.js"></script>
<script src="https://code.highcharts.com/modules/pattern-fill.js"></script>
<div class="view">
<div class="layout layout--col">
<div id="chart-combined" style="width: 100%"></div>
</div>
<div class="title_bar">
<img class="image" src="data:image/jpeg;base64,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" />
<span class="title">Intervals.icu</span>
<span class="instance">TSB Model</span>
</div>
</div>
<script type="text/javascript">
// 1) BUILD THE RAW DATA (from Liquid)
// Now, wellness_data is an array of arrays: [[57,57], [58,62], ...]
var data = {{ wellness_data | json }};
// 2) Calculate dynamic dates
var today = new Date();
today.setHours(0, 0, 0, 0);
var todayTs = today.getTime();
// Oldest: today minus 90 days
var oldestTs = todayTs - (90 * 24 * 3600 * 1000);
// Newest: today plus 5 days
var newestTs = todayTs + (5 * 24 * 3600 * 1000);
// 3) Validate the data source.
// Each entry should be an array with at least 2 values: [ctl, atl]
var validData = data.filter(function(entry) {
return Array.isArray(entry) && entry.length >= 2;
});
var n = validData.length;
// Calculate the time step between data points (evenly spaced)
var step = n > 1 ? (newestTs - oldestTs) / (n - 1) : 0;
// 4) Build the series arrays for the chart
var fitnessData = [];
var fatigueData = [];
var formData = [];
for (var i = 0; i < n; i++) {
// Compute an evenly spaced timestamp for each data point and shift it one day forward.
var ts = Math.floor(oldestTs + i * step) + (24 * 3600 * 1000);
var ctlValue = Math.round(validData[i][0]);
var atlValue = Math.round(validData[i][1]);
var formValue = Math.round(ctlValue - atlValue);
fitnessData.push([ts, ctlValue]);
fatigueData.push([ts, atlValue]);
formData.push([ts, formValue]);
}
// 5) Determine the closest values to today's timestamp
function findClosestValue(seriesArray, targetTimestamp) {
if (!seriesArray.length) return null;
var closest = seriesArray[0];
for (var i = 1; i < seriesArray.length; i++) {
var curr = seriesArray[i];
if (Math.abs(curr[0] - targetTimestamp) < Math.abs(closest[0] - targetTimestamp)) {
closest = curr;
}
}
return closest[1];
}
var fitnessToday = findClosestValue(fitnessData, todayTs);
var fatigueToday = findClosestValue(fatigueData, todayTs);
var formToday = findClosestValue(formData, todayTs);
// 6) Create multiSeriesData with series names including today's values
var multiSeriesData = [
{ name: "Fitness: " + fitnessToday, data: fitnessData },
{ name: "Fatigue: " + fatigueToday, data: fatigueData },
{ name: "Form: " + formToday, data: formData }
];
// 7) Render the combined chart
new Chartkick.LineChart("chart-combined", multiSeriesData, {
adapter: "highcharts",
curve: true,
colors: ["#000000", "#000000", "#000000"],
library: {
chart: {
height: 400,
animation: false,
events: {
load: function() {
var s = this.series;
if (s[0]) { s[0].update({ dashStyle: "Dash", lineWidth: 3 }, false); }
if (s[1]) { s[1].update({ dashStyle: "Dot", lineWidth: 2 }, false); }
if (s[2]) { s[2].update({ dashStyle: "Solid", lineWidth: 3 }, false); }
this.redraw();
}
}
},
plotOptions: {
series: { animation: false, lineWidth: 2 }
},
legend: {
enabled: true,
itemStyle: { fontSize: "16px", color: "#000000", fontWeight: "600" }
},
xAxis: {
type: "datetime",
// Shift the x-axis boundaries by one day as well
min: oldestTs + (24 * 3600 * 1000),
max: newestTs + (24 * 3600 * 1000),
dateTimeLabelFormats: { day: "%b %e, %Y" },
labels: { style: { fontSize: "16px", color: "#000000" } },
tickPixelInterval: 120,
plotLines: [{
color: "#000000",
value: todayTs,
width: 2,
dashStyle: "Solid",
zIndex: 10,
label: {
text: '<span class="label label--small label--outline bg-white">Today</span>',
useHTML: true,
align: "right",
verticalAlign: "bottom",
y: -34,
x: 10,
}
}]
},
yAxis: {
opposite: true,
labels: { style: { fontSize: "16px", color: "#000000" } },
softMin: -40,
softMax: 50,
tickInterval: 20,
gridLineWidth: 0,
gridLineColor: "#000000",
plotBands: [
{
from: -9999,
to: -30,
color: {
pattern: {
image: "https://usetrmnl.com/images/grayscale/gray-5.png",
width: 12,
height: 12
}
},
label: {
text: '<span class="label label--small label--outline bg--white">High Risk</span>',
useHTML: true,
align: "left",
x: 5
}
},
{
from: -30,
to: -10,
color: {
pattern: {
image: "https://usetrmnl.com/images/grayscale/gray-4.png",
width: 12,
height: 12
}
},
label: {
text: '<span class="label label--small label--outline bg--white">Optimal</span>',
useHTML: true,
align: "left",
x: 5
}
},
{
from: -10,
to: 5,
color: {
pattern: {
image: "https://usetrmnl.com/images/grayscale/gray-7.png",
width: 12,
height: 12
}
},
label: {
text: '<span class="label label--small label--outline bg--white">Grey Zone</span>',
useHTML: true,
align: "left",
x: 5
}
},
{
from: 5,
to: 20,
color: {
pattern: {
image: "https://usetrmnl.com/images/grayscale/white.png",
width: 12,
height: 12
}
},
label: {
text: '<span class="label label--small label--outline bg--white">Fresh</span>',
useHTML: true,
align: "left",
x: 5
}
},
{
from: 20,
to: 9999,
color: {
pattern: {
image: "https://usetrmnl.com/images/grayscale/gray-6.png",
width: 12,
height: 12
}
},
label: {
text: '<span class="label label--small label--outline bg--white">Transition</span>',
useHTML: true,
align: "left",
x: 5
}
}
]
}
}
});
</script>
The only challenge is to get the data in there. I’m using the webhook feature because TRML introduced a limit to not overwhelm their servers, so I had to optimize it. So basically what I’m sending as webhook is an array of arrays: for every day I send the fitness and the fatique. For example this is the payload:
[
[
48,
39
],
[
49,
45
],
[
48,
39
],
[
48,
40
],
[
47,
34
],
[
46,
31
],
[
45,
27
],
[
46,
39
],
[
45,
34
],
[
46,
41
],
[
45,
35
],
[
44,
31
],
[
46,
45
],
[
48,
54
],
[
49,
60
],
[
50,
64
],
[
49,
56
],
[
48,
48
],
[
46,
42
],
[
48,
48
],
[
48,
53
],
[
47,
47
],
[
48,
52
],
[
49,
57
],
[
48,
49
],
[
48,
50
],
[
49,
57
],
[
50,
64
],
[
51,
66
],
[
51,
62
],
[
49,
54
],
[
48,
47
],
[
47,
41
],
[
47,
43
],
[
48,
48
],
[
47,
43
],
[
48,
51
],
[
49,
56
],
[
48,
49
],
[
49,
54
],
[
48,
47
],
[
50,
57
],
[
51,
61
],
[
52,
66
],
[
52,
65
],
[
53,
68
],
[
51,
59
],
[
53,
66
],
[
52,
58
],
[
53,
65
],
[
54,
69
],
[
54,
69
],
[
56,
76
],
[
55,
66
],
[
57,
76
],
[
55,
68
],
[
57,
73
],
[
57,
77
],
[
58,
80
],
[
57,
69
],
[
57,
69
],
[
57,
66
],
[
56,
57
],
[
57,
64
],
[
58,
69
],
[
58,
69
],
[
59,
74
],
[
58,
64
],
[
60,
75
],
[
61,
80
],
[
62,
83
],
[
61,
73
],
[
61,
75
],
[
63,
79
],
[
61,
69
],
[
63,
80
],
[
64,
83
],
[
65,
86
],
[
66,
86
],
[
64,
75
],
[
65,
80
],
[
64,
71
],
[
69,
102
],
[
68,
88
],
[
66,
77
],
[
67,
79
],
[
65,
68
],
[
64,
59
],
[
64,
64
],
[
65,
68
],
[
66,
72
],
[
64,
62
],
[
63,
54
],
[
61,
47
],
[
60,
41
],
[
58,
35
],
[
57,
31
]
]
And the plugin code itself calculates the form and also the date. I’m using n8n on my end but I think this could also be done with a simple curl command and .jq (I’m sure AI can help with this fantastically)
The initial query I do to intervals.icu is: [GET] https://intervals.icu/api/v1/athlete/{your_athlate_id_HERE}/wellness.json?oldest={{ new Date(Date.now() - (90 * 24 * 60 * 60 * 1000)).toISOString().slice(0, 10) }}&newest={{ new Date(Date.now() + (6 * 24 * 60 * 60 * 1000)).toISOString().slice(0, 10) }} and the actual payload I send to the TRML server via webhook is:
[
{
"message": null,
"merge_variables": {
"wellness_data": [
[
48,
39
],
[
49,
45
],
[
48,
39
],
[
48,
40
],
[
47,
34
],
[
46,
31
],
[
45,
27
],
[
46,
39
],
[
45,
34
],
[
46,
41
],
[
45,
35
],
[
44,
31
],
[
46,
45
],
[
48,
54
],
[
49,
60
],
[
50,
64
],
[
49,
56
],
[
48,
48
],
[
46,
42
],
[
48,
48
],
[
48,
53
],
[
47,
47
],
[
48,
52
],
[
49,
57
],
[
48,
49
],
[
48,
50
],
[
49,
57
],
[
50,
64
],
[
51,
66
],
[
51,
62
],
[
49,
54
],
[
48,
47
],
[
47,
41
],
[
47,
43
],
[
48,
48
],
[
47,
43
],
[
48,
51
],
[
49,
56
],
[
48,
49
],
[
49,
54
],
[
48,
47
],
[
50,
57
],
[
51,
61
],
[
52,
66
],
[
52,
65
],
[
53,
68
],
[
51,
59
],
[
53,
66
],
[
52,
58
],
[
53,
65
],
[
54,
69
],
[
54,
69
],
[
56,
76
],
[
55,
66
],
[
57,
76
],
[
55,
68
],
[
57,
73
],
[
57,
77
],
[
58,
80
],
[
57,
69
],
[
57,
69
],
[
57,
66
],
[
56,
57
],
[
57,
64
],
[
58,
69
],
[
58,
69
],
[
59,
74
],
[
58,
64
],
[
60,
75
],
[
61,
80
],
[
62,
83
],
[
61,
73
],
[
61,
75
],
[
63,
79
],
[
61,
69
],
[
63,
80
],
[
64,
83
],
[
65,
86
],
[
66,
86
],
[
64,
75
],
[
65,
80
],
[
64,
71
],
[
69,
102
],
[
68,
88
],
[
66,
77
],
[
67,
79
],
[
65,
68
],
[
64,
59
],
[
64,
64
],
[
65,
68
],
[
66,
72
],
[
64,
62
],
[
63,
54
],
[
61,
47
],
[
60,
41
],
[
58,
35
],
[
57,
31
]
]
}
}
]