Health 3.0
  • Weight
  • Exercise
Weight

token = localStorage.getItem('ath_token')

d3 = require('d3')

results = {
  yield undefined
  
  yield await fetch("https://api.andrewheiss.com/weight?start_date=2026-08-01&loess=true&forecast=true&forecast_start=2026-08-01", {
    body: "",
    headers: {
      "Authorization": `Bearer ${token}`,
      "content-type": "application/json"
    },
    method: "POST",
    mode: "cors"
  }).then(response => {
    if (!response.ok) {
        if(response.status === 401) {
            throw new Error('Unauthorized');
        } else {
            throw new Error('Network error');
        }
    }
    return response.json();
  })
  .then(data => ({
    ...data,
    wt: data.wt.map(d => ({
      ...d,
      timestamp: new Date(d.timestamp),
      timestamp_local_as_utc: new Date(d.timestamp_local_as_utc),
    })),
    trend: data.trend.map(d => ({
      ...d,
      timestamp_local_as_utc: new Date(d.timestamp_local_as_utc),
    })),
    forecast: data.forecast.map(d => ({
      ...d,
      timestamp_local_as_utc: new Date(d.timestamp_local_as_utc),
    }))
  }))
  .catch(error => {
      if (error.message === 'Network error') {
        console.error('Error with the API call:', error);
        return error.message;
      } else if (error.message === 'Unauthorized') {
        console.error('Unauthorized:', error);
        return error.message;
      } else {
        console.error('Some general error:', error);
        return "Error";
      }
    });
}
{
  const zoom = d3.zoom().on("zoom", handleZoom);

  function handleZoom(e) {
    const scale = plot.scale("x");
    const x = d3.scaleLinear().domain(scale.domain).range(scale.range);
    const startDate = new Date(
      Math.max(e.transform.rescaleX(x).domain()[0], extent[0].getTime())
    );

    const endDate = new Date(
      Math.min(e.transform.rescaleX(x).domain()[1], extent[1])
    );

    mutable domain = [startDate, endDate];
  }

  function initZoom() {
    d3.select(plot).call(zoom);
  }
  
  initZoom();
}
extent = {
  if (results == undefined) {
    return undefined;
  } else if (show_forecast) {
    let min = d3.min(results.wt, ({ timestamp_local_as_utc }) => timestamp_local_as_utc);
    let max = d3.max(results.forecast, ({ timestamp_local_as_utc }) => timestamp_local_as_utc);
    return [min, max];
  } else {
    return d3.extent(results.wt, ({ timestamp_local_as_utc }) => timestamp_local_as_utc)
  }
}
  
mutable domain = extent;
LBS_TO_KG = 0.45359237

resultsConverted = {
  if (results == undefined) {
    return undefined;
  }

  const factor = weight_unit === "kg" ? LBS_TO_KG : 1;
  const convert = (x) => x * factor;

  return {
    ...results,
    wt: results.wt.map(d => ({...d, weight: convert(d.weight)})),
    trend: results.trend.map(d => ({
      ...d,
      weight_pred: convert(d.weight_pred),
      conf_low: convert(d.conf_low),
      conf_high: convert(d.conf_high)
    })),
    forecast: results.forecast.map(d => ({
      ...d,
      weight_pred: convert(d.weight_pred),
      conf_low: convert(d.conf_low),
      conf_high: convert(d.conf_high)
    })),
    bmi: results.bmi.map(d => ({...d, weight: convert(d.weight)}))
  };
}
{
  let plotContainer = document.querySelector('.weight-plot');

  let container = document.getElementById('quarto-content');
  let loadingContainer = document.getElementById('loading-alert');

  // If the loading container doesn't exist, create it
  if (!loadingContainer) {
    container.insertAdjacentHTML('afterbegin', `
      <div id="loading-alert" class="container mt-3">
      </div>
    `);
    loadingContainer = document.getElementById('loading-alert');
  }

  if (results === undefined) {
    loadingContainer.innerHTML = `
      <div class="alert alert-info" role="alert">
          <i class="fa-solid fa-clock fa-spin"></i>&ensp;Loading data…
      </div>
    `;
    
    plotContainer.style.display = "";
  } else if (!token) {
    loadingContainer.innerHTML = `
      <div class="alert alert-warning" role="alert">
          <i class="fa-solid fa-lock"></i>&ensp;Not logged in!
      </div>
    `;
    
    plotContainer.style.display = "none";
  } else {
    loadingContainer.remove();
    plotContainer.style.display = "";
  }
}
{
  if (!token) {
  // Hide the .observablehq--error div
    let errorDiv = document.querySelector('.weight-plot .observablehq--error');
    if (errorDiv) {
      errorDiv.style.display = 'none';
    }
  }
}
Progress over time
viewof show_forecast = Inputs.toggle({label: "Forecast", value: false})
viewof show_trend_tips = Inputs.toggle({label: "Tooltips for trendline", value: false})
viewof show_thresholds = Inputs.toggle({label: "Thresholds", value: false})
viewof weight_unit = Inputs.radio(["lbs", "kg"], {label: "Units", value: "lbs"})
plot = Plot.plot({
    style: {
      fontSize: "14px",
      fontFamily: "Manrope",
    },
    marginBottom: 40,
    marginLeft: 40,
    
    x: {
      domain,
      label: "Time",
      grid: true
    },
    y: {
      label: `Weight (${weight_unit})`,
      grid: true
    },
  
    color: {
      label: "Time of day",
      domain: ["Morning", "Evening"],
      range: ["#FB9E07", "#771C6D"]
    },
  
    marks: [
      Plot.axisX({
        label: null,
        tickSize: 0, 
        nice: true
      }),
      
      Plot.axisY({
        tickSize: 0, 
        nice: true
      }),
      
      Plot.ruleY(show_thresholds ? resultsConverted.bmi : [], {
          y: "weight",
          stroke: "#42159D",
          strokeDasharray: "3,2",
          channels: {"Threshold": "threshold"},
          tip: {
            format: {
              Threshold: true,
              y: (d) => d.toFixed(2)
            }
          }
        }
      ),
      
      Plot.areaY(resultsConverted.trend, {
        x: "timestamp_local_as_utc",
        y1: "conf_low",
        y2: "conf_high",
        fillOpacity: 0.1
      }),
      Plot.line(resultsConverted.trend, {
        x: "timestamp_local_as_utc", 
        y: "weight_pred",
        channels: {"Average weight": "weight_pred"},
        tip: !show_trend_tips ? false : {
          format: {
            x: (d) => d3.utcFormat("%A, %B %e at %H:%M")(d).replace(/  +/g, ' '),
            y: false,
            "Average weight": (d) => d.toFixed(2)
          }
        }
      }),
      
      Plot.areaY(show_forecast ? resultsConverted.forecast : [], {
        x: "timestamp_local_as_utc",
        y1: "conf_low",
        y2: "conf_high",
        fill: "#A52C60",
        fillOpacity: 0.1
      }),
      Plot.line(show_forecast ? resultsConverted.forecast : [], {
        x: "timestamp_local_as_utc", 
        y: "weight_pred",
        stroke: "#A52C60",
        channels: {"Predicted weight": "weight_pred"},
        tip: {
          format: {
            x: (d) => d3.utcFormat("%A, %B %e")(d).replace(/  +/g, ' '),
            y: false,
            "Predicted weight": (d) => d.toFixed(2)
          }
        }
      }),
      
      Plot.dot(resultsConverted.wt, {
        x: "timestamp_local_as_utc",
        y: "weight",
        fill: "time_of_day",
        r: 5,
        tip: show_trend_tips ? false : {
          format: {
            fill: true,
            x: (d) => d3.utcFormat("%A, %B %e at %H:%M")(d).replace(/  +/g, ' '),
            y: true
          }
        }
      })
    ]
  })