mirror of
https://github.com/nprasad2077/health-auto-export-server.git
synced 2026-11-04 07:33:41 +00:00
Initial commit
This commit is contained in:
commit
4163bb5e8a
30 files changed
+5492
No files matched your search
@@ -0,0 +1,7 @@
|
||||
server/node_modules
|
||||
server/dist
|
||||
server/.env
|
||||
.env
|
||||
grafana-data
|
||||
|
||||
seed_data/*
|
||||
@@ -0,0 +1,96 @@
|
||||
# Health Auto Export
|
||||
|
||||
## Overview
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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 ✅"
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
dist
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
scripts
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
20.18.0
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MetricData } from './Metric';
|
||||
import { WorkoutData } from './Workout';
|
||||
|
||||
export interface IngestData {
|
||||
data: {
|
||||
metrics?: MetricData[];
|
||||
workouts?: WorkoutData[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface IngestResponse {
|
||||
metrics?: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
workouts?: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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');
|
||||
@@ -0,0 +1,9 @@
|
||||
import express from 'express';
|
||||
|
||||
import { ingestData } from '../controllers/ingester';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', ingestData);
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user