Initial commit

This commit is contained in:
Lybron Sobers committed 2025-02-27 15:00:12 +01:00
commit 4163bb5e8a
30 files changed
+5492

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
server/node_modules
server/dist
server/.env
.env
grafana-data
seed_data/*
+96
View File
@@ -0,0 +1,96 @@
# Health Auto Export
## Overview
![Helath Dashboard](docs/images/hae-grafana-health-metrics.png)
This project provides a web interface for viewing Apple Health data via a web interface using Grafana and a Node.js server.
This project aims to be as beginner-friendly as possible, so if you're just getting started with programming, or consider yourself a "non-technical" person, this is a safe space! Of course, this also serves as a great base for the more seasoned developers to build on.
## Requirements
In order to use this project, you will need:
1. The [Health Auto Export](https://apple.co/3iqbU2d) app for iPhone
2. [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed on your computer
## Setup Guide
### Step 1: Computer Setup
1. Download and install Docker Desktop for your platform [here](https://www.docker.com/products/docker-desktop/)
2. Clone or download this repository to your desired location on your computer
3. Open a terminal/command prompt in the project folder
4. Run `sh ./create-env.sh` to create the `.env` file, or create the file manually with the following variables:
- `NODE_ENV`: `production`
- `MONGO_HOST`: `hae-mongo`
- `MONGO_USERNAME`: `admin`
- `MONGO_PASSWORD`: `mypassword` (set a secure password)
- `MONGO_DB`: `health-auto-export`
- `MONGO_PORT`: `27017`
5. Run `docker compose up -d` in your terminal
6. Open Grafana in your browser at http://localhost:3000
7. Login with the default credentials: `admin / admin`
8. Determine [your computer's local IP address](https://geekflare.com/consumer-tech/find-ip-address-of-windows-linux-mac-and-website/) and note it down
9. Configure the Health Auto Export app to send data to http://your-computer-ip:3001/api/health-data as outlined in **Step 3**
### Step 2: Grafana Setup
1. Configure the Grafana data source by selecting `Administration` in the left sidebar, then search for `Infinity`
2. Select and install `Infinity` plugin. After installation, select `Add new data source`
3. Give the data source a name, for example `Health Auto Export`
4. In the `URL, Headers & Params` section, set the `Base URL` to `http://host.docker.internal:3001`
5. Select `Save & test` to complete the data source setup
6. This data source can now be used to create dashboards using the synced data
### Step 3: iPhone Setup
1. Install and open the [Health Auto Export](https://apple.co/3iqbU2d) app on your iPhone
2. Navigate to the `Automations` tab
3. Create a new automation
4. Configure the automation with the following settings:
- Automation Type: `REST API`
- URL: `http://your-computer-ip:3001/api/data`
- Data Type: `Health Metrics` or `Workouts` (create separate automations for each if you want to sync different data types)
- Export Format: `JSON`
- Aggregate Data: `Enabled` 🟢
- Aggregate Interval: `Days`
- Batch Requests: `Enabled` 🟢
5. Tap `Update` in the top navigation bar to save the automation
6. Use `Manual Export` to select a date range and manually trigger a data export to your computer
### Step 4: Viewing Data
1. Open Grafana in your browser at http://localhost:3000
2. Create a new dashboard or import a pre-configured dashboard as described in **Step 5**
### Step 5: Import Dashboards (Optional)
If you are unfamiliar with Grafana, you can import pre-configured dashboards to get started quickly.
1. Navigate to the `Dashboards` tab
2. Select `New` and then `Import`
3. Upload the dashboard JSON file from the `dashboards` folder, or copy the JSON into the `Import via dashboard JSON model` text area
# Troubleshooting
If you encounter issues:
1. You can use [ChatGPT](https://chatgpt.com/) or [Claude](https://claude.ai/) to help you troubleshoot or fix specific errors
2. Ensure Docker is running
3. If you're still stuck, feel free to reach out using the support options below.
## Support
If you need assistance:
- Open an issue on GitHub
- Join the [Discord server](https://discord.gg/PY7urEVDnj)
- Contact [support](https://healthyapps.dev/contact)
## Contributing
Your contributions are welcome! Whether it's a bug fix, a new feature, a documentation update, or sharing your dashboards, we appreciate your help.
If you'd like to contribute to this project, create a pull request with your changes.
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Default values
USERNAME="admin"
PASSWORD="mypassword"
DATABASE="health-auto-export"
ENVIRONMENT="production"
PORT="27017"
echo "Creating .env file..."
cat > .env << EOF
NODE_ENV=${ENVIRONMENT}
MONGO_HOST=hae-mongo
MONGO_USERNAME=${USERNAME}
MONGO_PASSWORD=${PASSWORD}
MONGO_DB=${DATABASE}
MONGO_PORT=${PORT}
EOF
echo "Environment configuration saved to .env file ✅"
+723
View File
@@ -0,0 +1,723 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 5,
"links": [],
"panels": [
{
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"fieldConfig": {
"defaults": {
"color": {
"fixedColor": "blue",
"mode": "shades"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"fillOpacity": 80,
"gradientMode": "hue",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 1,
"scaleDistribution": {
"type": "linear"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 3,
"options": {
"barRadius": 0,
"barWidth": 0.97,
"fullHighlight": false,
"groupWidth": 0.7,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"orientation": "auto",
"showValue": "auto",
"stacking": "none",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
},
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
},
"pluginVersion": "11.5.1",
"targets": [
{
"columns": [
{
"selector": "date",
"text": "Date",
"type": "timestamp"
},
{
"selector": "awake",
"text": "Awake",
"type": "number"
},
{
"selector": "asleep",
"text": "Asleep",
"type": "number"
},
{
"selector": "core",
"text": "Core",
"type": "number"
},
{
"selector": "rem",
"text": "REM",
"type": "number"
},
{
"selector": "deep",
"text": "Deep",
"type": "number"
}
],
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"filters": [],
"format": "table",
"global_query_id": "",
"refId": "A",
"root_selector": "",
"source": "url",
"type": "json",
"url": "/api/metrics/sleep_analysis?from=${__from}&to=${__to}",
"url_options": {
"data": "",
"method": "GET"
}
}
],
"title": "Sleep Analysis",
"type": "barchart"
},
{
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-YlRd"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "scheme",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineStyle": {
"fill": "solid"
},
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"candleStyle": "candles",
"colorStrategy": "open-close",
"colors": {
"down": "red",
"up": "red"
},
"fields": {
"close": "Max",
"high": "Max",
"low": "Min",
"open": "Min"
},
"includeAllFields": false,
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"mode": "candles+volume",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"columns": [
{
"selector": "date",
"text": "Timestamp",
"type": "timestamp"
},
{
"selector": "Min",
"text": "Min",
"type": "number"
},
{
"selector": "Avg",
"text": "Avg",
"type": "number"
},
{
"selector": "Max",
"text": "Max",
"type": "number"
},
{
"selector": "units",
"text": "Units",
"type": "string"
},
{
"selector": "source",
"text": "Source",
"type": "string"
}
],
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"filters": [],
"format": "table",
"global_query_id": "",
"refId": "A",
"root_selector": "",
"source": "url",
"type": "json",
"url": "/api/metrics/heart_rate?from=${__from}&to=${__to}",
"url_options": {
"data": "",
"method": "GET"
}
}
],
"title": "Heart Rate",
"type": "candlestick"
},
{
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd",
"seriesBy": "min"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "scheme",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "#EAB839",
"value": 400
},
{
"color": "orange",
"value": 600
},
{
"color": "red",
"value": 800
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"columns": [
{
"selector": "date",
"text": "Timestamp",
"type": "timestamp"
},
{
"selector": "qty",
"text": "Value",
"type": "number"
},
{
"selector": "units",
"text": "Units",
"type": "string"
},
{
"selector": "source",
"text": "Source",
"type": "string"
}
],
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"filters": [],
"format": "table",
"global_query_id": "",
"refId": "Active Energy",
"root_selector": "",
"source": "url",
"type": "json",
"url": "/api/metrics/active_energy?from=${__from}&to=${__to}",
"url_options": {
"data": "",
"method": "GET"
}
}
],
"title": "Active Energy",
"type": "timeseries"
},
{
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"fieldConfig": {
"defaults": {
"color": {
"fixedColor": "blue",
"mode": "shades",
"seriesBy": "min"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "bars",
"fillOpacity": 0,
"gradientMode": "hue",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 6,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"columns": [
{
"selector": "date",
"text": "Timestamp",
"type": "timestamp"
},
{
"selector": "qty",
"text": "Value",
"type": "number"
},
{
"selector": "units",
"text": "Units",
"type": "string"
},
{
"selector": "source",
"text": "Source",
"type": "string"
}
],
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"filters": [],
"format": "table",
"global_query_id": "",
"refId": "Step Count",
"root_selector": "",
"source": "url",
"type": "json",
"url": "/api/metrics/step_count?from=${__from}&to=${__to}",
"url_options": {
"data": "",
"method": "GET"
}
}
],
"title": "Step Count",
"type": "timeseries"
},
{
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-RdYlGr",
"seriesBy": "min"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "bars",
"fillOpacity": 0,
"gradientMode": "hue",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "#EAB839",
"value": 400
},
{
"color": "orange",
"value": 600
},
{
"color": "red",
"value": 800
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"columns": [
{
"selector": "date",
"text": "Timestamp",
"type": "timestamp"
},
{
"selector": "qty",
"text": "Value",
"type": "number"
},
{
"selector": "units",
"text": "Units",
"type": "string"
},
{
"selector": "source",
"text": "Source",
"type": "string"
}
],
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"filters": [],
"format": "table",
"global_query_id": "",
"refId": "Cycling Distance",
"root_selector": "",
"source": "url",
"type": "json",
"url": "/api/metrics/cycling_distance?from=${__from}&to=${__to}",
"url_options": {
"data": "",
"method": "GET"
}
}
],
"title": "Cycling Distance",
"type": "timeseries"
}
],
"preload": false,
"refresh": "",
"schemaVersion": 40,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-30d",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Health Metrics",
"uid": "dee1dgpz9tam8f",
"version": 22,
"weekStart": ""
}
+388
View File
@@ -0,0 +1,388 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 3,
"links": [],
"panels": [
{
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 2,
"options": {
"basemap": {
"config": {
"showLabels": true,
"theme": "auto"
},
"name": "Layer 0",
"type": "default"
},
"controls": {
"mouseWheelZoom": true,
"showAttribution": true,
"showDebug": false,
"showMeasure": false,
"showScale": false,
"showZoom": true
},
"layers": [
{
"config": {
"arrow": 0,
"style": {
"color": {
"fixed": "green"
},
"lineWidth": 2,
"opacity": 1,
"rotation": {
"fixed": 0,
"max": 360,
"min": -360,
"mode": "mod"
},
"size": {
"fixed": 5,
"max": 15,
"min": 2
},
"symbol": {
"field": "",
"fixed": "",
"mode": "fixed"
},
"symbolAlign": {
"horizontal": "center",
"vertical": "center"
},
"textConfig": {
"fontSize": 12,
"offsetX": 0,
"offsetY": 0,
"textAlign": "center",
"textBaseline": "middle"
}
}
},
"filterData": {
"id": "byRefId",
"options": "Workout Route"
},
"location": {
"latitude": "Latitude",
"longitude": "Longitude",
"mode": "coords"
},
"name": "Route",
"opacity": 0.4,
"tooltip": true,
"type": "route"
}
],
"tooltip": {
"mode": "details"
},
"view": {
"allLayers": false,
"id": "fit",
"lastOnly": false,
"lat": 0,
"layer": "Route",
"lon": 0,
"padding": 1,
"zoom": 15
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"columns": [
{
"selector": "latitude",
"text": "Latitude",
"type": "number"
},
{
"selector": "longitude",
"text": "Longitude",
"type": "number"
},
{
"selector": "time",
"text": "Timestamp",
"type": "timestamp"
}
],
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"filters": [],
"format": "table",
"global_query_id": "",
"parser": "backend",
"refId": "Workout Route",
"root_selector": "route",
"source": "url",
"type": "json",
"url": "/api/workouts/${selected_workout}",
"url_options": {
"data": "",
"method": "GET"
}
}
],
"title": "Workout Route",
"type": "geomap"
},
{
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd",
"seriesBy": "last"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "scheme",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "#6ED0E0",
"value": 0
},
{
"color": "blue",
"value": 40
},
{
"color": "green",
"value": 100
},
{
"color": "#EAB839",
"value": 120
},
{
"color": "red",
"value": 135
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.5.1",
"targets": [
{
"columns": [
{
"selector": "timestamp",
"text": "Time",
"type": "timestamp"
},
{
"selector": "value",
"text": "Heart Rate",
"type": "number"
}
],
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"filters": [],
"format": "timeseries",
"global_query_id": "",
"parser": "backend",
"refId": "Heart Rate",
"root_selector": "heartRateData",
"source": "url",
"type": "json",
"url": "/api/workouts/${selected_workout}",
"url_options": {
"data": "",
"method": "GET"
}
}
],
"title": "Heart Rate",
"type": "timeseries"
}
],
"preload": false,
"refresh": "",
"schemaVersion": 40,
"tags": [],
"templating": {
"list": [
{
"allowCustomValue": false,
"current": {
"text": "9F0C477C-51B9-4F14-8FC6-109DBE455F7D",
"value": "9F0C477C-51B9-4F14-8FC6-109DBE455F7D"
},
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "bee4k1otx5urkf"
},
"definition": "",
"label": "Selected Workout",
"name": "selected_workout",
"options": [],
"query": {
"infinityQuery": {
"columns": [
{
"selector": "id",
"text": "ID",
"type": "string"
}
],
"filters": [],
"format": "table",
"parser": "backend",
"refId": "variable",
"root_selector": "",
"source": "url",
"type": "json",
"url": "/api/workouts",
"url_options": {
"data": "",
"method": "GET"
}
},
"query": "",
"queryType": "infinity"
},
"refresh": 1,
"regex": "",
"type": "query"
}
]
},
"time": {
"from": "2025-02-25T17:46:33.000Z",
"to": "2025-02-25T18:22:58.000Z"
},
"timepicker": {},
"timezone": "browser",
"title": "Workout Details",
"uid": "eedjdc3i47klcb",
"version": 22,
"weekStart": ""
}
+210
View File
@@ -0,0 +1,210 @@
{
"__inputs": [
{
"name": "DS_INFINITY-DATASOURCE-SERVER-INTERNAL",
"label": "infinity-datasource-server-internal",
"description": "",
"type": "datasource",
"pluginId": "yesoreyeram-infinity-datasource",
"pluginName": "Infinity"
}
],
"__elements": {},
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "11.5.1"
},
{
"type": "panel",
"id": "table",
"name": "Table",
"version": ""
},
{
"type": "datasource",
"id": "yesoreyeram-infinity-datasource",
"name": "Infinity",
"version": "3.0.0-beta.1"
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "${DS_INFINITY-DATASOURCE-SERVER-INTERNAL}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "ID"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Set Selected Workout",
"url": "/d/eedjdc3i47klcb/workout-details?var-selected_workout=${__data.fields[0]}&from=${__data.fields.Start}&to=${__data.fields.End}"
}
]
}
]
}
]
},
"gridPos": {
"h": 40,
"w": 21,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true
},
"pluginVersion": "11.5.1",
"targets": [
{
"columns": [
{
"selector": "id",
"text": "ID",
"type": "string"
},
{
"selector": "workout_type",
"text": "Workout Type",
"type": "string"
},
{
"selector": "start_time",
"text": "Start",
"type": "timestamp"
},
{
"selector": "end_time",
"text": "End",
"type": "timestamp"
},
{
"selector": "duration_minutes",
"text": "Duration",
"type": "number"
},
{
"selector": "calories_burned",
"text": "Active Energy",
"type": "number"
}
],
"datasource": {
"type": "yesoreyeram-infinity-datasource",
"uid": "${DS_INFINITY-DATASOURCE-SERVER-INTERNAL}"
},
"filters": [],
"format": "table",
"global_query_id": "",
"parser": "simple",
"refId": "Workouts",
"root_selector": "",
"source": "url",
"type": "json",
"url": "/api/workouts?startDate=${__from}&endDate=${__to}",
"url_options": {
"data": "",
"headers": [],
"method": "GET",
"params": [
{
"key": "to",
"value": "${__to:date}"
},
{
"key": "from",
"value": "${__from:date}"
}
]
}
}
],
"title": "Workouts",
"type": "table"
}
],
"refresh": "",
"schemaVersion": 40,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-2y",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Workouts Table",
"uid": "eeddiaujdhc00c",
"version": 37,
"weekStart": ""
}
+57
View File
@@ -0,0 +1,57 @@
services:
hae-mongo:
container_name: hae-mongo
image: mongo:latest
restart: unless-stopped
volumes:
- mongodb-data:/data/db
networks:
- hae_network
ports:
- "${MONGO_PORT}:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USERNAME}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
MONGO_INITDB_DATABASE: ${MONGO_DB}
hae-grafana:
container_name: hae-grafana
image: grafana/grafana-oss:latest
ports:
- 3000:3000
volumes:
- ./grafana-data:/var/lib/grafana
restart: unless-stopped
networks:
- hae_network
depends_on:
- hae-mongo
hae-server:
container_name: hae-server
build:
context: ./server
dockerfile: Dockerfile
ports:
- "3001:3001"
environment:
- NODE_ENV=${NODE_ENV}
- MONGO_HOST=${MONGO_HOST}
- MONGO_PORT=${MONGO_PORT}
- MONGO_DB=${MONGO_DB}
- MONGO_USERNAME=${MONGO_USERNAME}
- MONGO_PASSWORD=${MONGO_PASSWORD}
depends_on:
- hae-mongo
networks:
- hae_network
restart: unless-stopped
networks:
hae_network:
driver: bridge
volumes:
postgres-data:
grafana-data:
mongodb-data:
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

+7
View File
@@ -0,0 +1,7 @@
node_modules
npm-debug.log
.git
.env
*.log
.DS_Store
dist
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
coverage
scripts
+45
View File
@@ -0,0 +1,45 @@
module.exports = {
parser: '@typescript-eslint/parser',
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:import/errors',
'plugin:import/warnings',
'plugin:import/typescript',
'plugin:prettier/recommended',
],
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
rules: {
// Customize rules here
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'import/order': [
'error',
{
'groups': [
'builtin',
'external',
'internal',
['parent', 'sibling'],
'index',
],
'newlines-between': 'always',
'alphabetize': {
order: 'asc',
caseInsensitive: true,
},
},
],
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
};
+1
View File
@@ -0,0 +1 @@
20.18.0
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "auto"
}
+15
View File
@@ -0,0 +1,15 @@
FROM node:lts-alpine
WORKDIR /usr/src/server
COPY package*.json ./
RUN yarn install
COPY . .
RUN yarn build
EXPOSE 3001
CMD ["yarn", "start"]
+39
View File
@@ -0,0 +1,39 @@
{
"name": "server",
"version": "0.0.1",
"license": "MIT",
"main": "dist/app.js",
"scripts": {
"build": "tsc",
"start": "node dist/app.js",
"dev": "ts-node-dev src/app.ts",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\""
},
"dependencies": {
"axios": "^1.7.9",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"mongodb": "^6.3.0",
"mongoose": "^8.2.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/mongodb": "^4.0.7",
"@types/mongoose": "^5.11.97",
"@types/node": "^20.11.0",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"prettier": "^3.2.5",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
}
}
+35
View File
@@ -0,0 +1,35 @@
import cors from 'cors';
import express from 'express';
import mongodb from './database/mongodb';
import ingesterRouter from './routes/ingester';
import metricsRouter from './routes/metrics';
import workoutsRouter from './routes/workouts';
const app = express();
const port = 3001;
mongodb.connect();
const corsOptions = {
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
};
app.use(cors(corsOptions));
app.use(express.json({ limit: '200mb' }));
app.use(express.urlencoded({ limit: '200mb', extended: true }));
app.use('/api/data', ingesterRouter);
app.use('/api/metrics', metricsRouter);
app.use('/api/workouts', workoutsRouter);
app.get('/', (req: express.Request, res: express.Response) => {
res.json({ message: 'Hello world!' });
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
+41
View File
@@ -0,0 +1,41 @@
import { Request, Response } from 'express';
import { saveMetrics } from './metrics';
import { saveWorkouts } from './workouts';
import { IngestData } from '../models/IngestData';
import { IngestResponse } from '../models/IngestResponse';
export const ingestData = async (req: Request, res: Response) => {
let response: IngestResponse = {};
try {
const data = req.body as IngestData;
if (!data) {
throw new Error('No data provided');
}
const [metricsResponse, workoutsResponse] = await Promise.all([
saveMetrics(data),
saveWorkouts(data),
]);
response = { ...metricsResponse, ...workoutsResponse };
const hasErrors = Object.values(response).some((r) => !r.success);
const allFailed = Object.values(response).every((r) => !r.success);
if (allFailed) {
res.status(500).json(response);
return;
}
res.status(hasErrors ? 207 : 200).json(response);
} catch (error) {
console.error('Error processing request:', error);
res.status(500).json({
error: 'Failed to process request',
message: error instanceof Error ? error.message : 'An error occurred',
});
}
};
+164
View File
@@ -0,0 +1,164 @@
import { Request, Response } from 'express';
import { IngestData } from '../models/IngestData';
import { IngestResponse } from '../models/IngestResponse';
import {
BaseMetric,
BloodPressureMetric,
BloodPressureModel,
HeartRateMetric,
HeartRateModel,
Metric,
SleepMetric,
SleepModel,
mapMetric,
createMetricModel,
} from '../models/Metric';
import { MetricName } from '../models/MetricName';
export const getMetrics = async (req: Request, res: Response) => {
try {
const { from, to } = req.query;
const selectedMetric = req.params.selected_metric as MetricName;
if (!selectedMetric) {
throw new Error('No metric selected');
}
const fromDate = new Date(Number(from));
const toDate = new Date(Number(to));
const isDate = (date: Date) => !isNaN(date.getTime());
let query = {};
if (isDate(fromDate) && isDate(toDate)) {
query = {
date: {
$gte: fromDate,
$lte: toDate,
},
};
}
let metrics;
switch (selectedMetric) {
case MetricName.BLOOD_PRESSURE:
metrics = await BloodPressureModel.find(query).lean();
break;
case MetricName.HEART_RATE:
metrics = await HeartRateModel.find(query).lean();
break;
case MetricName.SLEEP_ANALYSIS:
metrics = await SleepModel.find(query).lean();
break;
default:
metrics = await createMetricModel(selectedMetric).find(query).lean();
}
console.log(metrics);
res.json(metrics);
} catch (error) {
console.error('Error getting metrics:', error);
res.json({ error: error instanceof Error ? error.message : 'Error getting metrics' });
}
};
export const saveMetrics = async (ingestData: IngestData): Promise<IngestResponse> => {
try {
const response: IngestResponse = {};
const metricsData = ingestData.data.metrics;
if (!metricsData || metricsData.length === 0) {
response.metrics = {
success: true,
error: 'No metrics data provided',
};
return response;
}
// Group metrics by type and map the data
const metricsByType = metricsData.reduce(
(acc, metric) => {
const mappedMetrics = mapMetric(metric);
const key = metric.name;
acc[key] = acc[key] || [];
acc[key].push(...mappedMetrics);
return acc;
},
{} as {
[key: string]: Metric[];
},
);
const saveOperations = Object.entries(metricsByType).map(([key, metrics]) => {
switch (key as MetricName) {
case MetricName.BLOOD_PRESSURE:
const bpMetrics = metrics as BloodPressureMetric[];
return BloodPressureModel.bulkWrite(
bpMetrics.map((metric) => ({
updateOne: {
filter: { source: metric.source, date: metric.date },
update: { $set: metric },
upsert: true,
},
})),
);
case MetricName.HEART_RATE:
const hrMetrics = metrics as HeartRateMetric[];
return HeartRateModel.bulkWrite(
hrMetrics.map((metric) => ({
updateOne: {
filter: { source: metric.source, date: metric.date },
update: { $set: metric },
upsert: true,
},
})),
);
case MetricName.SLEEP_ANALYSIS:
const sleepMetrics = metrics as SleepMetric[];
return SleepModel.bulkWrite(
sleepMetrics.map((metric) => ({
updateOne: {
filter: { source: metric.source, date: metric.date },
update: { $set: metric },
upsert: true,
},
})),
);
default:
const baseMetrics = metrics as BaseMetric[];
const model = createMetricModel(key as MetricName);
return model.bulkWrite(
baseMetrics.map((metric) => ({
updateOne: {
filter: { source: metric.source, date: metric.date },
update: { $set: metric },
upsert: true,
},
})),
);
}
});
await Promise.all(saveOperations);
response.metrics = {
success: true,
message: `${metricsData.length} metrics saved successfully`,
};
return response;
} catch (error) {
console.error('Error saving metrics:', error);
const errorResponse: IngestResponse = {};
errorResponse.metrics = {
success: false,
error: error instanceof Error ? error.message : 'Error saving metrics',
};
return errorResponse;
}
};
+178
View File
@@ -0,0 +1,178 @@
import { Request, Response } from 'express';
import { IngestData } from '../models/IngestData';
import { IngestResponse } from '../models/IngestResponse';
import { RouteModel, WorkoutModel, mapWorkoutData, mapRoute } from '../models/Workout';
export const getWorkouts = async (req: Request, res: Response) => {
try {
const { startDate, endDate } = req.query;
const fromDate = new Date(Number(startDate));
const toDate = new Date(Number(endDate));
console.log(fromDate, toDate);
const isDate = (date: Date) => !isNaN(date.getTime());
let query = {};
if (isDate(fromDate) && isDate(toDate)) {
query = {
start: {
$gte: fromDate,
$lte: toDate,
},
};
}
const workouts = await WorkoutModel.find(query)
.sort({ start: -1 })
.lean()
.then((workouts) => {
const mappedWorkouts = workouts.map((workout) => {
const startDate = new Date(workout.start);
const endDate = new Date(workout.end);
const result = {
id: workout.workoutId,
workout_type: workout.name,
start_time: startDate.toISOString(),
end_time: endDate.toISOString(),
duration_minutes: workout.duration / 60,
calories_burned: workout.activeEnergyBurned?.qty || null,
};
return result;
});
return mappedWorkouts;
});
console.log(`${workouts.length} workouts fetched`);
res.status(200).json(workouts);
} catch (error) {
console.error('Error fetching workouts:', error);
res.status(500).json({ error: 'Error fetching workouts' });
}
};
export const getWorkout = async (req: Request, res: Response) => {
try {
const { id } = req.params;
console.log('Fetching workout details for ID:', id);
const workoutMetadata = await WorkoutModel.findOne({ workoutId: id })
.lean()
.then((workout) => {
if (!workout) {
return null;
}
const heartRateData =
workout.heartRateData?.map((hr) => ({
type: 'Heart Rate',
timestamp: new Date(hr.date).toISOString(),
value: hr.Avg,
})) || [];
const heartRateRecovery =
workout.heartRateRecovery?.map((hr) => ({
type: 'Heart Rate Recovery',
timestamp: new Date(hr.date).toISOString(),
value: hr.Avg,
})) || [];
return { heartRateData, heartRateRecovery };
});
if (!workoutMetadata) {
return res.status(404).json({ error: 'Workout not found' });
}
const route = await RouteModel.findOne({ workoutId: id })
.lean()
.then((route) => {
return route?.locations.map((x) => {
return {
latitude: x.latitude,
longitude: x.longitude,
time: new Date(x.timestamp).toISOString(),
};
});
});
const ret = { ...workoutMetadata, route: route || [] };
console.log(`Workout ${id} fetched with ${route?.length ?? 0} locations`);
res.status(200).json(ret);
} catch (error) {
console.error('Error fetching workout details:', error);
res.status(500).json({ error: 'Error fetching workout details' });
}
};
export const saveWorkouts = async (ingestData: IngestData): Promise<IngestResponse> => {
try {
const response: IngestResponse = {};
const workouts = ingestData.data.workouts;
if (!workouts || !workouts.length) {
response.workouts = {
success: true,
message: 'No workout data provided',
};
return response;
}
const workoutOperations = workouts.map((workout) => {
return {
updateOne: {
filter: { workoutId: workout.id },
update: {
$set: mapWorkoutData(workout),
},
upsert: true,
},
};
});
const routeOperations = workouts
.filter((workout) => workout.route && workout.route.length > 0)
.map(mapRoute)
.map((route) => ({
updateOne: {
filter: { workoutId: route.workoutId },
update: {
$set: route,
},
upsert: true,
},
}));
await Promise.all([
WorkoutModel.bulkWrite(workoutOperations),
routeOperations.length > 0 ? RouteModel.bulkWrite(routeOperations) : Promise.resolve(),
]);
response.workouts = {
success: true,
message: `${workoutOperations.length} Workouts and ${routeOperations.length} Routes saved successfully`,
};
console.debug(`Processed ${workouts.length} workouts`);
return response;
} catch (error) {
console.error('Error processing workouts:', error);
const errorResponse: IngestResponse = {};
errorResponse.workouts = {
success: false,
message: 'Workouts not saved',
error: error instanceof Error ? error.message : 'An error occurred',
};
return errorResponse;
}
};
+45
View File
@@ -0,0 +1,45 @@
import dotenv from 'dotenv';
import mongoose from 'mongoose';
dotenv.config();
const username = process.env.MONGO_USERNAME;
const password = process.env.MONGO_PASSWORD;
const host = process.env.MONGO_HOST;
const port = process.env.MONGO_PORT;
const url = `mongodb://${username}:${password}@${host}:${port}/`;
const dbName = process.env.MONGO_DB;
const options: mongoose.ConnectOptions = {
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
connectTimeoutMS: 10000,
dbName: dbName,
};
async function connect() {
try {
await mongoose.connect(url, options);
console.log(`Connected successfully to ${dbName}`);
} catch (err) {
console.error('Error connecting to MongoDB:', err);
process.exit(-1);
}
}
mongoose.connection.on('disconnected', () => {
console.log('MongoDB disconnected');
});
process.on('SIGINT', async () => {
try {
await mongoose.connection.close();
console.log('MongoDB connection closed');
process.exit(0);
} catch (err) {
console.error('Error closing MongoDB connection:', err);
process.exit(-1);
}
});
export default { connect };
+9
View File
@@ -0,0 +1,9 @@
import { MetricData } from './Metric';
import { WorkoutData } from './Workout';
export interface IngestData {
data: {
metrics?: MetricData[];
workouts?: WorkoutData[];
};
}
+12
View File
@@ -0,0 +1,12 @@
export interface IngestResponse {
metrics?: {
success: boolean;
message?: string;
error?: string;
};
workouts?: {
success: boolean;
message?: string;
error?: string;
};
}
+189
View File
@@ -0,0 +1,189 @@
import mongoose, { Schema, Document } from 'mongoose';
import { MetricName } from './MetricName';
export interface MetricData {
name: string;
units: string;
data: Metric[];
}
export interface BaseMetric {
qty: number;
units: string;
date: Date;
source: string;
metadata?: Record<string, string>;
}
export interface BloodPressureMetric {
systolic: number;
diastolic: number;
units: string;
date: Date;
source: string;
metadata?: Record<string, string>;
}
export interface HeartRateMetric {
Min: number;
Avg: number;
Max: number;
units: string;
date: Date;
source: string;
metadata?: Record<string, string>;
}
export interface SleepMetric {
date: Date;
inBedStart: Date;
inBedEnd: Date;
sleepStart: Date;
sleepEnd: Date;
core: number;
rem: number;
deep: number;
awake: number;
inBed: number;
units: string;
source: string;
metadata?: Record<string, string>;
}
export const mapMetric = (
metric: MetricData,
): (Metric | BloodPressureMetric | SleepMetric | HeartRateMetric)[] => {
switch (metric.name) {
case MetricName.BLOOD_PRESSURE:
const bpData = metric.data as BloodPressureMetric[];
return bpData.map((measurement) => ({
systolic: measurement.systolic,
diastolic: measurement.diastolic,
units: metric.units,
date: new Date(measurement.date),
source: measurement.source,
metadata: measurement.metadata,
}));
case MetricName.HEART_RATE:
const hrData = metric.data as HeartRateMetric[];
return hrData.map((measurement) => ({
Min: measurement.Min,
Avg: measurement.Avg,
Max: measurement.Max,
units: metric.units,
date: new Date(measurement.date),
source: measurement.source,
metadata: measurement.metadata,
}));
case MetricName.SLEEP_ANALYSIS:
const sleepData = metric.data as SleepMetric[];
return sleepData.map((measurement) => ({
date: new Date(measurement.date),
inBedStart: new Date(measurement.inBedStart),
inBedEnd: new Date(measurement.inBedEnd),
sleepStart: new Date(measurement.sleepStart),
sleepEnd: new Date(measurement.sleepEnd),
core: measurement.core,
rem: measurement.rem,
deep: measurement.deep,
awake: measurement.awake,
inBed: measurement.inBed,
units: metric.units,
source: measurement.source,
metadata: measurement.metadata,
}));
default:
const baseData = metric.data as BaseMetric[];
return baseData.map((measurement) => ({
qty: measurement.qty,
units: metric.units,
date: new Date(measurement.date),
source: measurement.source,
metadata: measurement.metadata,
}));
}
};
export type Metric = BaseMetric | BloodPressureMetric | SleepMetric | HeartRateMetric;
// Separate interfaces for documents
interface IMetric extends BaseMetric, Document {}
interface IBloodPressureMetric extends BloodPressureMetric, Document {}
interface IHeartRateMetric extends HeartRateMetric, Document {}
interface ISleepMetric extends SleepMetric, Document {}
// Base Metric Schema
const BaseMetricSchema: Schema = new Schema({
qty: { type: Number, required: true },
units: { type: String, required: true },
date: { type: Date, required: true },
source: { type: String, required: true },
metadata: { type: Object, required: false },
});
BaseMetricSchema.index({ date: 1, source: 1 }, { unique: true });
// Blood Pressure Schema
const BloodPressureSchema: Schema = new Schema({
systolic: { type: Number, required: true },
diastolic: { type: Number, required: true },
units: { type: String, required: true },
date: { type: Date, required: true },
source: { type: String, required: true },
metadata: { type: Object, required: false },
});
BloodPressureSchema.index({ date: 1, source: 1 }, { unique: true });
// Heart Rate Schema
const HeartRateSchema: Schema = new Schema({
Min: { type: Number, required: true },
Avg: { type: Number, required: true },
Max: { type: Number, required: true },
units: { type: String, required: true },
date: { type: Date, required: true },
source: { type: String, required: true },
metadata: { type: Object, required: false },
});
HeartRateSchema.index({ date: 1, source: 1 }, { unique: true });
// Sleep Schema
const SleepSchema: Schema = new Schema({
date: { type: Date, required: true },
inBedStart: { type: Date, required: true },
inBedEnd: { type: Date, required: true },
sleepStart: { type: Date, required: true },
sleepEnd: { type: Date, required: true },
core: { type: Number, required: true },
rem: { type: Number, required: true },
deep: { type: Number, required: true },
awake: { type: Number, required: true },
inBed: { type: Number, required: true },
units: { type: String, required: true },
source: { type: String, required: true },
metadata: { type: Object, required: false },
});
SleepSchema.index({ date: 1, source: 1 }, { unique: true });
export const createMetricModel = (name: MetricName) => {
return mongoose.model<IMetric>(String(name), BaseMetricSchema, String(name));
};
export const BloodPressureModel = mongoose.model<IBloodPressureMetric>(
'BloodPressure',
BloodPressureSchema,
'blood_pressure',
);
export const HeartRateModel = mongoose.model<IHeartRateMetric>(
'HeartRate',
HeartRateSchema,
'heart_rate',
);
export const SleepModel = mongoose.model<ISleepMetric>(
'SleepAnalysis',
SleepSchema,
'sleep_analysis',
);
+135
View File
@@ -0,0 +1,135 @@
export enum MetricName {
// Activity and Exercise
ACTIVE_ENERGY = 'active_energy',
RESTING_ENERGY = 'basal_energy_burned',
APPLE_EXERCISE_TIME = 'apple_exercise_time',
APPLE_MOVE_TIME = 'apple_move_time',
APPLE_STAND_TIME = 'apple_stand_time',
APPLE_STAND_HOUR = 'apple_stand_hour',
CYCLING_DISTANCE = 'cycling_distance',
DOWNHILL_SNOW_SPORTS = 'downhill_snow_sports',
SWIMMING_DISTANCE = 'swimming_distance',
WALKING_RUNNING_DISTANCE = 'walking_running_distance',
WHEELCHAIR_DISTANCE = 'wheelchair_distance',
FLIGHTS_CLIMBED = 'flights_climbed',
WHEELCHAIR_PUSH_COUNT = 'wheelchair_push_count',
SWIM_STROKE_COUNT = 'swim_stroke_count',
VO2_MAX = 'vo2max',
PHYSICAL_EFFORT = 'physical_effort',
STEP_COUNT = 'step_count',
CYCLING_CADENCE = 'cycling_cadence',
CYCLING_FUNCTIONAL_THRESHOLD_POWER = 'cycling_functional_threshold_power',
CYCLING_POWER = 'cycling_power',
CYCLING_SPEED = 'cycling_speed',
RUNNING_POWER = 'running_power',
RUNNING_SPEED = 'running_speed',
RUNNING_GROUND_CONTACT_TIME = 'running_ground_contact_time',
RUNNING_STRIDE_LENGTH = 'running_stride_length',
// Body Measurements
HEIGHT = 'height',
WEIGHT_BODY_MASS = 'weight_body_mass',
WAIST_CIRCUMFERENCE = 'waist_circumference',
BODY_FAT_PERCENTAGE = 'body_fat_percentage',
BODY_MASS_INDEX = 'body_mass_index',
LEAN_BODY_MASS = 'lean_body_mass',
// Heart and Vitals
HEART_RATE = 'heart_rate',
RESTING_HEART_RATE = 'resting_heart_rate',
HEART_RATE_VARIABILITY = 'heart_rate_variability',
HEART_RATE_RECOVERY_ONE_MINUTE = 'cardio_recovery',
WALKING_HEART_RATE = 'walking_heart_rate',
ATRIAL_FIBRILLATION_BURDEN = 'atrial_fibrillation_burden',
BLOOD_PRESSURE = 'blood_pressure',
// Mobility Metrics
WALKING_SPEED = 'walking_speed',
WALKING_STEP_LENGTH = 'walking_step_length',
WALKING_ASYMMETRY_PERCENTAGE = 'walking_asymmetry_percentage',
WALKING_DOUBLE_SUPPORT_PERCENTAGE = 'walking_double_support_percentage',
SIX_MINUTE_WALKING_TEST_DISTANCE = 'six_minute_walking_test_distance',
STAIR_SPEED_UP = 'stair_speed_up',
STAIR_SPEED_DOWN = 'stair_speed_down',
RUNNING_VERTICAL_OSCILLATION = 'running_vertical_oscillation',
// Respiratory
RESPIRATORY_RATE = 'respiratory_rate',
FORCED_EXPIRATORY_VOLUME = 'forced_expiratory_volume',
FORCED_VITAL_CAPACITY = 'forced_vital_capacity',
BLOOD_OXYGEN_SATURATION = 'blood_oxygen_saturation',
PERIPHERAL_PERFUSION_INDEX = 'peripheral_perfusion_index',
PEAK_EXPIRATORY_FLOW_RATE = 'peak_expiratory_flow_rate',
INHALER_USAGE = 'inhaler_usage',
// Temperature and Environmental
BODY_TEMPERATURE = 'body_temperature',
BASAL_BODY_TEMPERATURE = 'basal_body_temperature',
UNDER_WATER_DEPTH = 'under_water_depth',
UNDER_WATER_TEMPERATURE = 'under_water_temperature',
UV_EXPOSURE = 'uv_exposure',
TIME_IN_DAYLIGHT = 'time_in_daylight',
ENVIRONMENTAL_AUDIO = 'environmental_audio',
HEADPHONE_AUDIO = 'headphone_audio',
// Mindfulness and Wellness
MINDFUL_MINUTES = 'mindful_minutes',
SEXUAL_ACTIVITY = 'sexual_activity',
ELECTRODERMAL_ACTIVITY = 'electrodermal_activity',
// Sleep
SLEEP_ANALYSIS = 'sleep_analysis',
APPLE_SLEEPING_WRIST_TEMPERATURE = 'apple_sleeping_wrist_temperature',
BREATHING_DISTURBANCES = 'breathing_disturbances',
// Medical
INSULIN_DELIVERY = 'insulin_delivery',
BLOOD_GLUCOSE = 'blood_glucose',
// Nutrition
DIETARY_ENERGY = 'dietary_energy',
DIETARY_WATER = 'dietary_water',
DIETARY_SUGAR = 'dietary_sugar',
DIETARY_CHOLESTEROL = 'cholesterol',
DIETARY_CARBOHYDRATES = 'carbohydrates',
DIETARY_BIOTIN = 'biotin',
DIETARY_CAFFEINE = 'caffeine',
DIETARY_CALCIUM = 'calcium',
DIETARY_CHLORIDE = 'chloride',
DIETARY_CHROMIUM = 'chromium',
DIETARY_COPPER = 'copper',
DIETARY_FIBER = 'fiber',
DIETARY_FOLATE = 'folate',
DIETARY_IODINE = 'iodine',
DIETARY_IRON = 'iron',
DIETARY_MAGNESIUM = 'magnesium',
DIETARY_MANGANESE = 'manganese',
DIETARY_MOLYBDENUM = 'molybdenum',
DIETARY_MONOSATURATED_FAT = 'monosaturated_fat',
DIETARY_NIACIN = 'niacin',
DIETARY_PANTOTHENIC_ACID = 'pantothenic_acid',
DIETARY_POLYUNSATURATED_FAT = 'polyunsaturated_fat',
DIETARY_POTASSIUM = 'potassium',
DIETARY_PROTEIN = 'protein',
DIETARY_RIBOFLAVIN = 'riboflavin',
DIETARY_SATURATED_FAT = 'saturated_fat',
DIETARY_SELENIUM = 'selenium',
DIETARY_SODIUM = 'sodium',
DIETARY_THIAMIN = 'thiamin',
DIETARY_TOTAL_FAT = 'total_fat',
DIETARY_VITAMIN_A = 'vitamin_a',
DIETARY_VITAMIN_B6 = 'vitamin_b6',
DIETARY_VITAMIN_B12 = 'vitamin_b12',
DIETARY_VITAMIN_C = 'vitamin_c',
DIETARY_VITAMIN_D = 'vitamin_d',
DIETARY_VITAMIN_E = 'vitamin_e',
DIETARY_VITAMIN_K = 'vitamin_k',
DIETARY_ZINC = 'zinc',
// Other Health Metrics
BLOOD_ALCOHOL_CONTENT = 'blood_alcohol_content',
HANDWASHING = 'handwashing',
NUMBER_OF_TIME_FALLEN = 'number_of_time_fallen',
NUMBER_OF_ALCOHOLIC_BEVERAGES = 'number_of_alcoholic_beverages',
TOOTHBRUSHING = 'toothbrushing',
}
+218
View File
@@ -0,0 +1,218 @@
import mongoose, { Schema, Document } from 'mongoose';
interface IQuantityMetric {
qty: number;
date: Date;
units: string;
source: string;
}
interface IMeasurement {
qty: number;
units: string;
date: Date;
source: string;
}
interface IHeartRate extends IMeasurement {
Min: number;
Avg: number;
Max: number;
date: Date;
units: string;
source: string;
}
interface ILocation {
latitude: number;
longitude: number;
course: number;
courseAccuracy: number;
speed: number;
speedAccuracy: number;
altitude: number;
verticalAccuracy: number;
horizontalAccuracy: number;
timestamp: Date;
}
interface IRoute {
workoutId: string;
locations: ILocation[];
}
export interface WorkoutData {
id: string;
name: string;
start: Date;
end: Date;
duration: number;
// --- Optional fields ---
distance?: IMeasurement;
activeEnergyBurned?: IMeasurement;
activeEnergy?: IQuantityMetric;
heartRateData?: IHeartRate[];
heartRateRecovery?: IHeartRate[];
stepCount?: IQuantityMetric[];
temperature?: IMeasurement;
humidity?: IMeasurement;
intensity?: IMeasurement;
route?: ILocation[];
}
interface IWorkout extends Document, Omit<WorkoutData, 'id' | 'route'> {
workoutId: string;
createdAt: Date;
updatedAt: Date;
}
const QuantityMetricSchema = new Schema<IQuantityMetric>(
{
qty: { type: Number, required: true, min: 0 },
units: { type: String, required: true },
date: { type: Date, required: true },
source: { type: String, required: true },
},
{ _id: false },
);
const HeartRateSchema = new Schema<IHeartRate>(
{
Min: { type: Number, required: true, min: 0 },
Avg: { type: Number, required: true, min: 0 },
Max: { type: Number, required: true, min: 0 },
date: { type: Date, required: true },
units: { type: String, required: true },
source: { type: String, required: true },
},
{ _id: false },
);
const WorkoutSchema = new Schema(
{
workoutId: {
type: String,
required: true,
unique: true,
index: true,
},
name: {
type: String,
required: true,
},
start: {
type: Date,
required: true,
},
end: {
type: Date,
required: true,
},
duration: {
type: Number,
required: true,
min: 0,
},
// --- Optional fields ---
activeEnergyBurned: {
type: QuantityMetricSchema,
required: true,
},
distance: {
type: QuantityMetricSchema,
required: false,
min: 0,
},
activeEnergy: {
type: QuantityMetricSchema,
required: false,
},
heartRateData: {
type: [HeartRateSchema],
required: false,
},
heartRateRecovery: {
type: [HeartRateSchema],
required: false,
},
stepCount: {
type: [QuantityMetricSchema],
required: false,
},
temperature: {
type: QuantityMetricSchema,
required: false,
},
humidity: {
type: QuantityMetricSchema,
required: false,
},
intensity: {
type: QuantityMetricSchema,
required: false,
},
},
{
timestamps: true,
},
);
const locationSchema = new Schema<ILocation>(
{
// Required fields
latitude: { type: Number, required: true },
longitude: { type: Number, required: true },
timestamp: { type: Date, required: true },
// Optional fields
course: { type: Number, required: false },
courseAccuracy: { type: Number, required: false },
speed: { type: Number, required: false },
speedAccuracy: { type: Number, required: false },
altitude: { type: Number, required: false },
verticalAccuracy: { type: Number, required: false },
horizontalAccuracy: { type: Number, required: false },
},
{ _id: false },
);
const routeSchema = new Schema<IRoute>(
{
workoutId: { type: String, required: true },
locations: {
type: [locationSchema],
required: true,
validate: {
validator: function (array: ILocation[]) {
return array.length > 0;
},
message: 'Locations array must contain at least one point',
},
},
},
{ timestamps: true },
);
export function mapWorkoutData(data: WorkoutData) {
const { id, ...rest } = data;
rest.start = new Date(rest.start);
rest.end = new Date(rest.end);
return {
workoutId: id,
...rest,
};
}
export function mapRoute(data: WorkoutData) {
return {
workoutId: data.id,
locations: data.route?.map((loc) => ({
...loc,
timestamp: new Date(loc.timestamp),
})),
};
}
export const WorkoutModel = mongoose.model<IWorkout>('Workout', WorkoutSchema, 'workouts');
export const RouteModel = mongoose.model<IRoute>('Route', routeSchema, 'workout_routes');
+9
View File
@@ -0,0 +1,9 @@
import express from 'express';
import { ingestData } from '../controllers/ingester';
const router = express.Router();
router.post('/', ingestData);
export default router;
+9
View File
@@ -0,0 +1,9 @@
import express from 'express';
import { getMetrics } from '../controllers/metrics';
const router = express.Router();
router.get('/:selected_metric', getMetrics);
export default router;
+14
View File
@@ -0,0 +1,14 @@
import express from 'express';
import { getWorkouts, getWorkout, saveWorkouts } from '../controllers/workouts';
const router = express.Router();
router.post('/', saveWorkouts);
router.get('/', getWorkouts);
router.get('/:id', getWorkout);
router.get('/health', (req, res) => {
res.status(200).send('OK');
});
export default router;
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*", "scripts/migrations"],
"exclude": ["node_modules", "scripts/seed.js"]
}
+2799
View File
File diff suppressed because it is too large. Load diff