diff --git a/.travis.yml b/.travis.yml index 562c8d9edbb..c7b9ed5a57e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,23 @@ language: node_js sudo: false node_js: - - "0.10" - - "0.12" + - 0.10 + - 0.12 + - 4 + - 5 +matrix: + fast_finish: true + allow_failures: + - node_js: 4 + - node_js: 5 services: mongodb script: make travis after_script: make report +env: + - CXX=g++-4.8 +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 391df69cabe..5be3a99d625 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,10 +96,8 @@ appropriate. ## Co-ordination -There is a google groups nightscout-core developers list where lots of -people discuss Nightscout. Most cgm-remote-monitor hackers use -github's ticketing system, along with Facebook cgm-in-the-cloud, and -gitter system. +Most cgm-remote-monitor hackers use github's ticketing system, along with Facebook cgm-in-the-cloud, and +gitter. We use git-flow, with `master` as our production, stable branch, and `dev` is used to queue up for upcoming releases. Everything else is diff --git a/Makefile b/Makefile index 2b60d26001b..44e16a9d021 100644 --- a/Makefile +++ b/Makefile @@ -35,9 +35,6 @@ report: (npm install coveralls && cat ${ANALYZED} | \ ./node_modules/.bin/coveralls) || echo "NO COVERAGE" test -f ${ANALYZED} && \ - (npm install codecov.io && cat ${ANALYZED} | \ - ./node_modules/codecov.io/bin/codecov.io.js) || echo "NO COVERAGE" - test -f ${ANALYZED} && \ (npm install codacy-coverage && cat ${ANALYZED} | \ YOURPACKAGE_COVERAGE=1 ./node_modules/codacy-coverage/bin/codacy-coverage.js) || echo "NO COVERAGE" diff --git a/README.md b/README.md index 7aa0c8d7948..de742e4b83c 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,8 @@ Nightscout Web Monitor (a.k.a. cgm-remote-monitor) [![Coverage Status][coverage-img]][coverage-url] [![Codacy Badge][codacy-img]][codacy-url] [![Gitter chat][gitter-img]][gitter-url] -[![Stories in Ready][ready-img]][waffle] -[![Stories in Progress][progress-img]][waffle] -[![Deploy to Heroku][heroku-img]][heroku-url] +[![Deploy to Azure](http://azuredeploy.net/deploybutton.png)](https://azuredeploy.net/) [![Deploy to Heroku][heroku-img]][heroku-url] This acts as a web-based CGM (Continuous Glucose Monitor) to allow multiple caregivers to remotely view a patient's glucose data in @@ -39,9 +37,6 @@ Community maintained fork of the [codacy-url]: https://www.codacy.com/app/Nightscout/cgm-remote-monitor [gitter-img]: https://img.shields.io/badge/Gitter-Join%20Chat%20%E2%86%92-1dce73.svg [gitter-url]: https://gitter.im/nightscout/public -[ready-img]: https://badge.waffle.io/nightscout/cgm-remote-monitor.svg?label=ready&title=Ready -[waffle]: https://waffle.io/nightscout/cgm-remote-monitor -[progress-img]: https://badge.waffle.io/nightscout/cgm-remote-monitor.svg?label=in+progress&title=In+Progress [heroku-img]: https://www.herokucdn.com/deploy/button.png [heroku-url]: https://heroku.com/deploy [original]: https://github.com/rnpenguin/cgm-remote-monitor @@ -65,13 +60,40 @@ Community maintained fork of the - [Predefined values for your browser settings (optional)](#predefined-values-for-your-browser-settings-optional) - [Plugins](#plugins) - [Default Plugins](#default-plugins) - - [Built-in/Example Plugins:](#built-inexample-plugins) + - [`delta` (BG Delta)](#delta-bg-delta) + - [`direction` (BG Direction)](#direction-bg-direction) + - [`upbat` (Uploader Battery)](#upbat-uploader-battery) + - [`timeago` (Time Ago)](#timeago-time-ago) + - [`devicestatus` (Device Status)](#devicestatus-device-status) + - [`errorcodes` (CGM Error Codes)](#errorcodes-cgm-error-codes) + - [`ar2` (AR2 Forecasting)](#ar2-ar2-forecasting) + - [`simplealarms` (Simple BG Alarms)](#simplealarms-simple-bg-alarms) + - [`profile` (Treatment Profile)](#profile-treatment-profile) + - [Advanced Plugins](#advanced-plugins) + - [`careportal` (Careportal)](#careportal-careportal) + - [`boluscalc` (Bolus Wizard)](#boluscalc-bolus-wizard) + - [`food` (Custom Foods)](#food-custom-foods) + - [`rawbg` (Raw BG)](#rawbg-raw-bg) + - [`iob` (Insulin-on-Board)](#iob-insulin-on-board) + - [`cob` (Carbs-on-Board)](#cob-carbs-on-board) + - [`bwp` (Bolus Wizard Preview)](#bwp-bolus-wizard-preview) + - [`cage` (Cannula Age)](#cage-cannula-age) + - [`sage` (Sensor Age)](#sage-sensor-age) + - [`iage` (Insulin Age)](#iage-insulin-age) + - [`treatmentnotify` (Treatment Notifications)](#treatmentnotify-treatment-notifications) + - [`basal` (Basal Profile)](#basal-basal-profile) + - [`bridge` (Share2Nightscout bridge)](#bridge-share2nightscout-bridge) + - [`mmconnect` (MiniMed Connect bridge)](#mmconnect-minimed-connect-bridge) + - [`pump` (Pump Monitoring)](#pump-pump-monitoring) + - [`openaps` (OpenAPS)](#openaps-openaps) + - [`loop` (Loop)](#loop-loop) - [Extended Settings](#extended-settings) - [Pushover](#pushover) - [IFTTT Maker](#ifttt-maker) - [Treatment Profile](#treatment-profile) - [Setting environment variables](#setting-environment-variables) - [Vagrant install](#vagrant-install) + - [Installation on Windows](#installation-on-windows) - [More questions?](#more-questions) - [License](#license) @@ -123,11 +145,11 @@ The server status and settings are available from `/api/v1/status.json`. By default the `/entries` and `/treatments` APIs limit results to the the most recent 10 values from the last 2 days. You can get many more results, by using the `count`, `date`, `dateString`, and `created_at` parameters, depending on the type of data you're looking for. - + #### Example Queries (replace `http://localhost:1337` with your base url, YOUR-SITE) - + * 100's: `http://localhost:1337/api/v1/entries.json?find[sgv]=100` * BGs between 2 days: `http://localhost:1337/api/v1/entries/sgv.json?find[dateString][$gte]=2015-08-28&find[dateString][$lte]=2015-08-30` * Juice Box corrections in a year: `http://localhost:1337/api/v1/treatments.json?count=1000&find[carbs]=15&find[eventType]=Carb+Correction&find[created_at][$gte]=2015` @@ -152,13 +174,16 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm * `ENABLE` - Used to enable optional features, expects a space delimited list, such as: `careportal rawbg iob`, see [plugins](#plugins) below * `DISABLE` - Used to disable default features, expects a space delimited list, such as: `direction upbat`, see [plugins](#plugins) below * `API_SECRET` - A secret passphrase that must be at least 12 characters long, required to enable `POST` and `PUT`; also required for the Care Portal - * `TREATMENTS_AUTH` (`off`) - possible values `on` or `off`. When on device must be authenticated by entering `API_SECRET` to create treatments + * `AUTH_DEFAULT_ROLES` (`readable`) - possible values `readable`, `denied`, or any valid role + name. When `readable`, anyone can view Nightscout without a token. + Setting it to `denied` will require a token from every visit, using `status-only` will enable api-secret based login. + * `TREATMENTS_AUTH` (`on`) - possible values `on` or `off`. Deprecated, if set to `off` the `careportal` role will be added to `AUTH_DEFAULT_ROLES` ### Alarms These alarm setting effect all delivery methods (browser, pushover, maker, etc), some settings can be overridden per client (web browser) - + * `ALARM_TYPES` (`simple` if any `BG_`* ENV's are set, otherwise `predict`) - currently 2 alarm types are supported, and can be used independently or combined. The `simple` alarm type only compares the current BG to `BG_` thresholds above, the `predict` alarm type uses highly tuned formula that forecasts where the BG is going based on it's trend. `predict` **DOES NOT** currently use any of the `BG_`* ENV's * `BG_HIGH` (`260`) - must be set using mg/dl units; the high BG outside the target range that is considered urgent * `BG_TARGET_TOP` (`180`) - must be set using mg/dl units; the top of the target range, also used to draw the line on the chart @@ -181,7 +206,10 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm * `MONGO_COLLECTION` (`entries`) - The collection used to store SGV, MBG, and CAL records from your CGM device * `MONGO_TREATMENTS_COLLECTION` (`treatments`) -The collection used to store treatments entered in the Care Portal, see the `ENABLE` env var above * `MONGO_DEVICESTATUS_COLLECTION`(`devicestatus`) - The collection used to store device status information such as uploader battery + * `MONGO_PROFILE_COLLECTION`(`profile`) - The collection used to store your profiles + * `MONGO_FOOD_COLLECTION`(`food`) - The collection used to store your food database * `PORT` (`1337`) - The port that the node.js application will listen on. + * `HOSTNAME` - The hostname that the node.js application will listen on, null by default for any hostname for IPv6 you may need to use `::`. * `SSL_KEY` - Path to your ssl key file, so that ssl(https) can be enabled directly in node.js * `SSL_CERT` - Path to your ssl cert file, so that ssl(https) can be enabled directly in node.js * `SSL_CA` - Path to your ssl ca file, so that ssl(https) can be enabled directly in node.js @@ -199,11 +227,13 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm * `ALARM_TIMEAGO_URGENT` (`on`) - possible values `on` or `off` * `ALARM_TIMEAGO_URGENT_MINS` (`30`) - minutes since the last reading to trigger a urgent alarm * `SHOW_PLUGINS` - enabled plugins that should have their visualizations shown, defaults to all enabled + * `SHOW_FORECAST` (`ar2`) - plugin forecasts that should be shown by default, supports space delimited values such as `"ar2 openaps"` * `LANGUAGE` (`en`) - language of Nightscout. If not available english is used * `SCALE_Y` (`log`) - The type of scaling used for the Y axis of the charts system wide. * The default `log` (logarithmic) option will let you see more detail towards the lower range, while still showing the full CGM range. * The `linear` option has equidistant tick marks, the range used is dynamic so that space at the top of chart isn't wasted. - * The `log-dynamic` is similar to the default `log` options, but uses the same dynamic range and the `linear` scale. + * The `log-dynamic` is similar to the default `log` options, but uses the same dynamic range and the `linear` scale. + * `EDIT_MODE` (`on`) - possible values `on` or `off`. Enable or disable icon allowing enter treatments edit mode ### Plugins @@ -212,62 +242,164 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm The built-in/example plugins that are available by default are listed below. The plugins may still need to be enabled by adding to the `ENABLE` environment variable. #### Default Plugins - + These can be disabled by setting the `DISABLE` env var, for example `DISABLE="direction upbat"` - * `delta` (BG Delta) - Calculates and displays the change between the last 2 BG values. - * `direction` (BG Direction) - Displays the trend direction. - * `upbat` (Uploader Battery) - Displays the most recent battery status from the uploader phone. - * `errorcodes` (CGM Error Codes) - Generates alarms for CGM codes `9` (hourglass) and `10` (???). - * Use [extended settings](#extended-settings) to adjust what errorcodes trigger notifications and alarms: - * `ERRORCODES_INFO` (`1 2 3 4 5 6 7 8`) - By default the needs calibration (blood drop) and other codes below 9 generate an info level notification, set to a space separate list of number or `off` to disable - * `ERRORCODES_WARN` (`off`) - By default there are no warning configured, set to a space separate list of numbers or `off` to disable - * `ERRORCODES_URGENT` (`9 10`) - By default the hourglass and ??? generate an urgent alarm, set to a space separate list of numbers or `off` to disable - * `ar2` ([Forcasting using AR2 algorithm](https://github.com/nightscout/nightscout.github.io/wiki/Forecasting)) - Generates alarms based on forecasted values. - * Enabled by default if no thresholds are set **OR** `ALARM_TYPES` includes `predict`. - * Use [extended settings](#extended-settings) to adjust AR2 behavior: - * `AR2_USE_RAW` (`false`) - to forecast using `rawbg` values when standard values don't trigger an alarm. - * `AR2_CONE_FACTOR` (`2`) - to adjust size of cone, use `0` for a single line. - * `simplealarms` (Simple BG Alarms) - Uses `BG_HIGH`, `BG_TARGET_TOP`, `BG_TARGET_BOTTOM`, `BG_LOW` thresholds to generate alarms. - * Enabled by default if 1 of these thresholds is set **OR** `ALARM_TYPES` includes `simple`. - -#### Built-in/Example Plugins: - - * `rawbg` (Raw BG) - Calculates BG using sensor and calibration records from and displays an alternate BG values and noise levels. - * `iob` (Insulin-on-Board) - Adds the IOB pill visualization in the client and calculates values that used by other plugins. Uses treatments with insulin doses and the `dia` and `sens` fields from the [treatment profile](#treatment-profile). - * `cob` (Carbs-on-Board) - Adds the COB pill visualization in the client and calculates values that used by other plugins. Uses treatments with carb doses and the `carbs_hr`, `carbratio`, and `sens` fields from the [treatment profile](#treatment-profile). - * `bwp` (Bolus Wizard Preview) - This plugin in intended for the purpose of automatically snoozing alarms when the CGM indicates high blood sugar but there is also insulin on board (IOB) and secondly, alerting to user that it might be beneficial to measure the blood sugar using a glucometer and dosing insulin as calculated by the pump or instructed by trained medicare professionals. ***The values provided by the plugin are provided as a reference based on CGM data and insulin sensitivity you have configured, and are not intended to be used as a reference for bolus calculation.*** The plugin calculates the bolus amount when above your target, generates alarms when you should consider checking and bolusing, and snoozes alarms when there is enough IOB to cover a high BG. Uses the results of the `iob` plugin and `sens`, `target_high`, and `target_low` fields from the [treatment profile](#treatment-profile). Defaults that can be adjusted with [extended setting](#extended-settings) - * `BWP_WARN` (`0.50`) - If `BWP` is > `BWP_WARN` a warning alarm will be triggered. - * `BWP_URGENT` (`1.00`) - If `BWP` is > `BWP_URGENT` an urgent alarm will be triggered. - * `BWP_SNOOZE_MINS` (`10`) - minutes to snooze when there is enough IOB to cover a high BG. - * `BWP_SNOOZE` - (`0.10`) If BG is higher then the `target_high` and `BWP` < `BWP_SNOOZE` alarms will be snoozed for `BWP_SNOOZE_MINS`. - * `cage` (Cannula Age) - Calculates the number of hours since the last `Site Change` treatment that was recorded. - * `CAGE_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications to remind you of upcoming cannula change. - * `CAGE_INFO` (`44`) - If time since last `Site Change` matches `CAGE_INFO`, user will be warned of upcoming cannula change - * `CAGE_WARN` (`48`) - If time since last `Site Change` matches `CAGE_WARN`, user will be alarmed to to change the cannula - * `CAGE_URGENT` (`72`) - If time since last `Site Change` matches `CAGE_URGENT`, user will be issued a persistent warning of overdue change. - * `treatmentnotify` (Treatment Notifications) - Generates notifications when a treatment has been entered and snoozes alarms minutes after a treatment. Default snooze is 10 minutes, and can be set using the `TREATMENTNOTIFY_SNOOZE_MINS` [extended setting](#extended-settings). - * `basal` (Basal Profile) - Adds the Basal pill visualization to display the basal rate for the current time. Also enables the `bwp` plugin to calculate correction temp basal suggestions. Uses the `basal` field from the [treatment profile](#treatment-profile). Also uses the extended setting: - * `BASAL_RENDER` (`none`) - Possible values are `none`, `default`, or `icicle` (inverted) - * `bridge` (Share2Nightscout bridge) - Glucose reading directly from the Share service, uses these extended settings: - * `BRIDGE_USER_NAME` - Your user name for the Share service. - * `BRIDGE_PASSWORD` - Your password for the Share service. - * `BRIDGE_INTERVAL` (`150000` *2.5 minutes*) - The time to wait between each update. - * `BRIDGE_MAX_COUNT` (`1`) - The maximum number of records to fetch per update. - * `BRIDGE_FIRST_FETCH_COUNT` (`3`) - Changes max count during the very first update only. - * `BRIDGE_MAX_FAILURES` (`3`) - How many failures before giving up. - * `BRIDGE_MINUTES` (`1400`) - The time window to search for new data per update (default is one day in minutes). - * `mmconnect` (MiniMed Connect bridge) - Transfer real-time MiniMed Connect data from the Medtronic CareLink server into Nightscout ([read more](https://github.com/mddub/minimed-connect-to-nightscout)) - * `MMCONNECT_USER_NAME` - Your user name for CareLink Connect. - * `MMCONNECT_PASSWORD` - Your password for CareLink Connect. - * `MMCONNECT_INTERVAL` (`60000` *1 minute*) - Number of milliseconds to wait between requests to the CareLink server. - * `MMCONNECT_MAX_RETRY_DURATION` (`32`) - Maximum number of total seconds to spend retrying failed requests before giving up. - * `MMCONNECT_SGV_LIMIT` (`24`) - Maximum number of recent sensor glucose values to send to Nightscout on each request. - * `MMCONNECT_VERBOSE` - Set this to "true" to log CareLink request information to the console. - * `MMCONNECT_STORE_RAW_DATA` - Set this to "true" to store raw data returned from CareLink as `type: "carelink_raw"` database entries (useful for development). - +##### `delta` (BG Delta) + Calculates and displays the change between the last 2 BG values. + +##### `direction` (BG Direction) + Displays the trend direction. + +##### `upbat` (Uploader Battery) + Displays the most recent battery status from the uploader phone. . Use these [extended setting](#extended-settings) to adjust behavior: + * `UPBAT_ENABLE_ALERTS` (`false`) - Set to `true` to enable uploader battery alarms via Pushover and IFTTT. + * `UPBAT_WARN` (`30`) - Minimum battery percent to trigger warning. + * `UPBAT_URGENT` (`20`) - Minimum battery percent to trigger urgent alarm. + +##### `timeago` (Time Ago) + Displays the time since last CGM entry. Use these [extended setting](#extended-settings) to adjust behavior: + * `TIMEAGO_ENABLE_ALERTS` (`false`) - Set to `true` to enable stale data alarms via Pushover and IFTTT. + * `ALARM_TIMEAGO_WARN` (`on`) - possible values `on` or `off` + * `ALARM_TIMEAGO_WARN_MINS` (`15`) - minutes since the last reading to trigger a warning + * `ALARM_TIMEAGO_URGENT` (`on`) - possible values `on` or `off` + * `ALARM_TIMEAGO_URGENT_MINS` (`30`) - minutes since the last reading to trigger a urgent alarm + + +##### `devicestatus` (Device Status) + Used by `upbat` and other plugins to display device status info. Supports the `DEVICESTATUS_ADVANCED="true"` [extended setting](#extended-settings) to send all device statuses to the client for retrospective use and to support other plugins. + +##### `errorcodes` (CGM Error Codes) + Generates alarms for CGM codes `9` (hourglass) and `10` (???). + * Use [extended settings](#extended-settings) to adjust what errorcodes trigger notifications and alarms: + * `ERRORCODES_INFO` (`1 2 3 4 5 6 7 8`) - By default the needs calibration (blood drop) and other codes below 9 generate an info level notification, set to a space separate list of number or `off` to disable + * `ERRORCODES_WARN` (`off`) - By default there are no warning configured, set to a space separate list of numbers or `off` to disable + * `ERRORCODES_URGENT` (`9 10`) - By default the hourglass and ??? generate an urgent alarm, set to a space separate list of numbers or `off` to disable + +##### `ar2` (AR2 Forecasting) + Generates alarms based on forecasted values. See [Forecasting using AR2 algorithm](https://github.com/nightscout/nightscout.github.io/wiki/Forecasting) + * Enabled by default if no thresholds are set **OR** `ALARM_TYPES` includes `predict`. + * Use [extended settings](#extended-settings) to adjust AR2 behavior: + * `AR2_CONE_FACTOR` (`2`) - to adjust size of cone, use `0` for a single line. + +##### `simplealarms` (Simple BG Alarms) + Uses `BG_HIGH`, `BG_TARGET_TOP`, `BG_TARGET_BOTTOM`, `BG_LOW` thresholds to generate alarms. + * Enabled by default if 1 of these thresholds is set **OR** `ALARM_TYPES` includes `simple`. + +##### `profile` (Treatment Profile) + Add link to Profile Editor and allow to enter treatment profile settings. Also uses the extended setting: + * `PROFILE_HISTORY` (`off`) - possible values `on` or `off`. Enable/disable NS ability to keep history of your profiles (still experimental) + * `PROFILE_MULTIPLE` (`off`) - possible values `on` or `off`. Enable/disable NS ability to handle and switch between multiple treatment profiles + +#### Advanced Plugins: + +##### `careportal` (Careportal) + An optional form to enter treatments. + +##### `boluscalc` (Bolus Wizard) + +##### `food` (Custom Foods) + An option plugin to enable adding foods from database in Bolus Wizard and enable . + +##### `rawbg` (Raw BG) + Calculates BG using sensor and calibration records from and displays an alternate BG values and noise levels. + +##### `iob` (Insulin-on-Board) + Adds the IOB pill visualization in the client and calculates values that used by other plugins. Uses treatments with insulin doses and the `dia` and `sens` fields from the [treatment profile](#treatment-profile). + +##### `cob` (Carbs-on-Board) + Adds the COB pill visualization in the client and calculates values that used by other plugins. Uses treatments with carb doses and the `carbs_hr`, `carbratio`, and `sens` fields from the [treatment profile](#treatment-profile). + +##### `bwp` (Bolus Wizard Preview) + This plugin in intended for the purpose of automatically snoozing alarms when the CGM indicates high blood sugar but there is also insulin on board (IOB) and secondly, alerting to user that it might be beneficial to measure the blood sugar using a glucometer and dosing insulin as calculated by the pump or instructed by trained medicare professionals. ***The values provided by the plugin are provided as a reference based on CGM data and insulin sensitivity you have configured, and are not intended to be used as a reference for bolus calculation.*** The plugin calculates the bolus amount when above your target, generates alarms when you should consider checking and bolusing, and snoozes alarms when there is enough IOB to cover a high BG. Uses the results of the `iob` plugin and `sens`, `target_high`, and `target_low` fields from the [treatment profile](#treatment-profile). Defaults that can be adjusted with [extended setting](#extended-settings) + * `BWP_WARN` (`0.50`) - If `BWP` is > `BWP_WARN` a warning alarm will be triggered. + * `BWP_URGENT` (`1.00`) - If `BWP` is > `BWP_URGENT` an urgent alarm will be triggered. + * `BWP_SNOOZE_MINS` (`10`) - minutes to snooze when there is enough IOB to cover a high BG. + * `BWP_SNOOZE` - (`0.10`) If BG is higher then the `target_high` and `BWP` < `BWP_SNOOZE` alarms will be snoozed for `BWP_SNOOZE_MINS`. + +##### `cage` (Cannula Age) + Calculates the number of hours since the last `Site Change` treatment that was recorded. + * `CAGE_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications to remind you of upcoming cannula change. + * `CAGE_INFO` (`44`) - If time since last `Site Change` matches `CAGE_INFO`, user will be warned of upcoming cannula change + * `CAGE_WARN` (`48`) - If time since last `Site Change` matches `CAGE_WARN`, user will be alarmed to to change the cannula + * `CAGE_URGENT` (`72`) - If time since last `Site Change` matches `CAGE_URGENT`, user will be issued a persistent warning of overdue change. + * `CAGE_DISPLAY` (`hours`) - Possible values are 'hours' or 'days'. If 'days' is selected and age of canula is greater than 24h number is displayed in days and hours + +##### `sage` (Sensor Age) + Calculates the number of days and hours since the last `Sensor Start` and `Sensor Change` treatment that was recorded. + * `SAGE_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications to remind you of upcoming sensor change. + * `SAGE_INFO` (`144`) - If time since last sensor event matches `SAGE_INFO`, user will be warned of upcoming sensor change + * `SAGE_WARN` (`164`) - If time since last sensor event matches `SAGE_WARN`, user will be alarmed to to change/restart the sensor + * `SAGE_URGENT` (`166`) - If time since last sensor event matches `SAGE_URGENT`, user will be issued a persistent warning of overdue change. + +##### `iage` (Insulin Age) + Calculates the number of days and hours since the last `Insulin Change` treatment that was recorded. + * `IAGE_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications to remind you of upcoming insulin reservoir change. + * `IAGE_INFO` (`44`) - If time since last `Insulin Change` matches `IAGE_INFO`, user will be warned of upcoming insulin reservoir change + * `IAGE_WARN` (`48`) - If time since last `Insulin Change` matches `IAGE_WARN`, user will be alarmed to to change the insulin reservoir + * `IAGE_URGENT` (`72`) - If time since last `Insulin Change` matches `IAGE_URGENT`, user will be issued a persistent warning of overdue change. + +##### `treatmentnotify` (Treatment Notifications) + Generates notifications when a treatment has been entered and snoozes alarms minutes after a treatment. Default snooze is 10 minutes, and can be set using the `TREATMENTNOTIFY_SNOOZE_MINS` [extended setting](#extended-settings). + +##### `basal` (Basal Profile) + Adds the Basal pill visualization to display the basal rate for the current time. Also enables the `bwp` plugin to calculate correction temp basal suggestions. Uses the `basal` field from the [treatment profile](#treatment-profile). Also uses the extended setting: + * `BASAL_RENDER` (`none`) - Possible values are `none`, `default`, or `icicle` (inverted) + +##### `bridge` (Share2Nightscout bridge) + Glucose reading directly from the Share service, uses these extended settings: + * `BRIDGE_USER_NAME` - Your user name for the Share service. + * `BRIDGE_PASSWORD` - Your password for the Share service. + * `BRIDGE_INTERVAL` (`150000` *2.5 minutes*) - The time to wait between each update. + * `BRIDGE_MAX_COUNT` (`1`) - The maximum number of records to fetch per update. + * `BRIDGE_FIRST_FETCH_COUNT` (`3`) - Changes max count during the very first update only. + * `BRIDGE_MAX_FAILURES` (`3`) - How many failures before giving up. + * `BRIDGE_MINUTES` (`1400`) - The time window to search for new data per update (default is one day in minutes). + +##### `mmconnect` (MiniMed Connect bridge) + Transfer real-time MiniMed Connect data from the Medtronic CareLink server into Nightscout ([read more](https://github.com/mddub/minimed-connect-to-nightscout)) + * `MMCONNECT_USER_NAME` - Your user name for CareLink Connect. + * `MMCONNECT_PASSWORD` - Your password for CareLink Connect. + * `MMCONNECT_INTERVAL` (`60000` *1 minute*) - Number of milliseconds to wait between requests to the CareLink server. + * `MMCONNECT_MAX_RETRY_DURATION` (`32`) - Maximum number of total seconds to spend retrying failed requests before giving up. + * `MMCONNECT_SGV_LIMIT` (`24`) - Maximum number of recent sensor glucose values to send to Nightscout on each request. + * `MMCONNECT_VERBOSE` - Set this to "true" to log CareLink request information to the console. + * `MMCONNECT_STORE_RAW_DATA` - Set this to "true" to store raw data returned from CareLink as `type: "carelink_raw"` database entries (useful for development). + +##### `pump` (Pump Monitoring) + Generic Pump Monitoring for OpenAPS, MiniMed Connect, RileyLink, t:slim, with more on the way + * Requires `DEVICESTATUS_ADVANCED="true"` to be set + * `PUMP_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications for Pump battery and reservoir. + * `PUMP_FIELDS` (`reservoir battery`) - The fields to display by default. Any of the following fields: `reservoir`, `battery`, `clock`, `status`, and `device` + * `PUMP_RETRO_FIELDS` (`reservoir battery clock`) - The fields to display in retro mode. Any of the above fields. + * `PUMP_WARN_CLOCK` (`30`) - The number of minutes ago that needs to be exceed before an alert is triggered. + * `PUMP_URGENT_CLOCK` (`60`) - The number of minutes ago that needs to be exceed before an urgent alarm is triggered. + * `PUMP_WARN_RES` (`10`) - The number of units remaining, a warning will be triggered when dropping below this threshold. + * `PUMP_URGENT_RES` (`5`) - The number of units remaining, an urgent alarm will be triggered when dropping below this threshold. + * `PUMP_WARN_BATT_P` (`30`) - The % of the pump battery remaining, a warning will be triggered when dropping below this threshold. + * `PUMP_URGENT_BATT_P` (`20`) - The % of the pump battery remaining, an urgent alarm will be triggered when dropping below this threshold. + * `PUMP_WARN_BATT_V` (`1.35`) - The voltage (if percent isn't available) of the pump battery, a warning will be triggered when dropping below this threshold. + * `PUMP_URGENT_BATT_V` (`1.30`) - The voltage (if percent isn't available) of the pump battery, an urgent alarm will be triggered when dropping below this threshold. + +##### `openaps` (OpenAPS) + Integrated OpenAPS loop monitoring, uses these extended settings: + * Requires `DEVICESTATUS_ADVANCED="true"` to be set + * `OPENAPS_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications when OpenAPS isn't looping. If OpenAPS is going to offline for a period of time, you can add an `OpenAPS Offline` event for the expected duration from Careportal to avoid getting alerts. + * `OPENAPS_WARN` (`30`) - The number of minutes since the last loop that needs to be exceed before an alert is triggered + * `OPENAPS_URGENT` (`60`) - The number of minutes since the last loop that needs to be exceed before an urgent alarm is triggered + * `OPENAPS_FIELDS` (`status-symbol status-label iob meal-assist rssi`) - The fields to display by default. Any of the following fields: `status-symbol`, `status-label`, `iob`, `meal-assist`, `freq`, and `rssi` + * `OPENAPS_RETRO_FIELDS` (`status-symbol status-label iob meal-assist rssi`) - The fields to display in retro mode. Any of the above fields. + Also see [Pushover](#pushover) and [IFTTT Maker](#ifttt-maker). - + +##### `loop` (Loop) + iOS Loop app monitoring, uses these extended settings: + * Requires `DEVICESTATUS_ADVANCED="true"` to be set + * `LOOP_ENABLE_ALERTS` (`false`) - Set to `true` to enable notifications when Loop isn't looping. + * `LOOP_WARN` (`30`) - The number of minutes since the last loop that needs to be exceeded before an alert is triggered + * `LOOP_URGENT` (`60`) - The number of minutes since the last loop that needs to be exceeded before an urgent alarm is triggered + * Add `loop` to `SHOW_FORECAST` to show forecasted BG. + #### Extended Settings Some plugins support additional configuration using extra environment variables. These are prefixed with the name of the plugin and a `_`. For example setting `MYPLUGIN_EXAMPLE_VALUE=1234` would make `extendedSettings.exampleValue` available to the `MYPLUGIN` plugin. @@ -284,7 +416,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm You’ll need to [Create a Pushover Application](https://pushover.net/apps/build). You only need to set the Application name, you can ignore all the other settings, but setting an Icon is a nice touch. Maybe you'd like to use [this one](https://raw.githubusercontent.com/nightscout/cgm-remote-monitor/master/static/images/large.png)? Pushover is configured using the following Environment Variables: - + * `ENABLE` - `pushover` should be added to the list of plugin, for example: `ENABLE="pushover"`. * `PUSHOVER_API_TOKEN` - Used to enable pushover notifications, this token is specific to the application you create from in [Pushover](https://pushover.net/), ***[additional pushover information](#pushover)*** below. * `PUSHOVER_USER_KEY` - Your Pushover user key, can be found in the top left of the [Pushover](https://pushover.net/) site, this can also be a pushover delivery group key to send to a group rather than just a single user. This also supports a space delimited list of keys. To disable `INFO` level pushes set this to `off`. @@ -296,16 +428,16 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm If you never want to get info level notifications (treatments) use `PUSHOVER_USER_KEY="off"` If you never want to get an alarm via pushover use `PUSHOVER_ALARM_KEY="off"` If you never want to get an announcement via pushover use `PUSHOVER_ANNOUNCEMENT_KEY="off"` - + If only `PUSHOVER_USER_KEY` is set it will be used for all info notifications, alarms, and announcements For testing/development try [localtunnel](http://localtunnel.me/). #### IFTTT Maker In addition to the normal web based alarms, and pushover, there is also integration for [IFTTT Maker](https://ifttt.com/maker). - + With Maker you are able to integrate with all the other [IFTTT Channels](https://ifttt.com/channels). For example you can send a tweet when there is an alarm, change the color of hue light, send an email, send and sms, and so much more. - + 1. Setup IFTTT account: [login](https://ifttt.com/login) or [create an account](https://ifttt.com/join) 2. Find your secret key on the [maker page](https://ifttt.com/maker) 3. Configure Nightscout by setting these environment variables: @@ -313,7 +445,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm * `MAKER_KEY` - Set this to your secret key that you located in step 2, for example: `MAKER_KEY="abcMyExampleabc123defjt1DeNSiftttmak-XQb69p"` This also support a space delimited list of keys. * `MAKER_ANNOUNCEMENT_KEY` - An optional Maker key, will be used for system wide user generated announcements. If not defined this will fallback to `MAKER_KEY`. A possible use for this is sending important messages and alarms to a CWD that you don't want to send all notification too. This also support a space delimited list of keys. 4. [Create a recipe](https://ifttt.com/myrecipes/personal/new) or see [more detailed instructions](lib/plugins/maker-setup.md#create-a-recipe) - + Plugins can create custom events, but all events sent to maker will be prefixed with `ns-`. The core events are: * `ns-event` - This event is sent to the maker service for all alarms and notifications. This is good catch all event for general logging. * `ns-allclear` - This event is sent to the maker service when an alarm has been ack'd or when the server starts up without triggering any alarms. For example, you could use this event to turn a light to green. @@ -325,7 +457,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm ### Treatment Profile Some of the [plugins](#plugins) make use of a treatment profile that can be edited using the Profile Editor, see the link in the Settings drawer on your site. - + Treatment Profile Fields: * `timezone` (Time Zone) - time zone local to the patient. *Should be set.* @@ -337,7 +469,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm * `basal` The basal rate set on the pump. * `target_high` - Upper target for correction boluses. * `target_low` - Lower target for correction boluses. - + Some example profiles are [here](example-profiles.md). ## Setting environment variables @@ -372,6 +504,10 @@ The setup script will install OS packages then run `npm install`. The Vagrant VM serves to your host machine only on 192.168.33.10, you can access the web interface on [http://192.168.33.10:1337](http://192.168.33.10:1337) +## Installation on Windows + +If you have access to local computing resources and want to maintain more control over your data, you can host Nightscout and its database outside of the cloud. Windows Server supports MongoDB, Node.js, and Nightscout [installed on a single system](https://github.com/jaylagorio/Nightscout-on-Windows-Server). Although the instructions are intended for Windows Server the procedure is compatible with client versions of Windows such as Windows 7 and Windows 10. + More questions? --------------- diff --git a/app.js b/app.js index 9618fde24ed..58d93b77482 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,9 @@ +'use strict'; var express = require('express'); var compression = require('compression'); var bodyParser = require('body-parser'); + function create (env, ctx) { /////////////////////////////////////////////////// // api and json object variables @@ -13,6 +15,11 @@ function create (env, ctx) { app.set('title', appInfo); app.enable('trust proxy'); // Allows req.secure test on heroku https connections. + if (ctx.bootErrors && ctx.bootErrors.length > 0) { + app.get('*', require('./lib/booterror')(ctx)); + return app; + } + app.use(compression({filter: function shouldCompress(req, res) { //TODO: return false here if we find a condition where we don't want to compress // fallback to standard filter function @@ -25,6 +32,8 @@ function create (env, ctx) { //} app.use('/api/v1', bodyParser({limit: 1048576 * 50 }), api); + app.use('/api/v2/properties', ctx.properties); + app.use('/api/v2/authorization', ctx.authorization.endpoints); // pebble data app.get('/pebble', ctx.pebble); diff --git a/app.json b/app.json index c5a6417eed5..63a5aa37c1c 100644 --- a/app.json +++ b/app.json @@ -73,7 +73,7 @@ "required": false }, "THEME": { - "description": "Possible values default or colors", + "description": "Possible values default, colors or colorblindfriendly", "value": "", "required": false }, diff --git a/azuredeploy.json b/azuredeploy.json new file mode 100644 index 00000000000..5a173380911 --- /dev/null +++ b/azuredeploy.json @@ -0,0 +1,316 @@ +{ + "$schema": "http://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "siteName": { + "type": "string" + }, + "hostingPlanName": { + "type": "string" + }, + "siteLocation": { + "type": "string" + }, + "sku": { + "type": "string", + "allowedValues": [ + "Free", + "Shared", + "Basic", + "Standard" + ], + "defaultValue": "Free" + }, + "workerSize": { + "type": "string", + "allowedValues": [ + "0", + "1", + "2" + ], + "defaultValue": "0" + }, + "repoUrl": { + "type": "string" + + }, + "branch": { + "type": "string" + }, + "mongoConnection": { + "type": "string" + }, + "displayUnits": { + "type": "string", + "allowedValues": [ + "mg/dl", + "mmol" + ], + "defaultValue": "mg/dl" + }, + "apiSecret": { + "type": "string", + "minLength": "12", + "defaultValue": "Enter an API secret. Must be at least 12 characters" + }, + "theme": { + "type": "string", + "allowedValues": [ + "default", + "colors", + "colorblindfriendly" + ], + "defaultValue": "colors", + }, + "time_format": { + "type": "string", + "allowedValues": [ + "12", + "24" + ], + "defaultValue": "24", + }, + "language": { + "type": "string", + "allowedValues": [ + "bg", + "cs", + "dk", + "de", + "en", + "es", + "fr", + "he", + "hr", + "it", + "nl", + "nb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sv", + "fi", + "ko" + ], + "defaultValue": "en", + }, + "custom_title": { + "type": "string", + "defaultValue": "Nightscout", + }, + "alarm_high": { + "type": "string", + "allowedValues": [ + "on", + "off" + ], + "defaultValue": "off", + }, + "alarm_low": { + "type": "string", + "allowedValues": [ + "on", + "off" + ], + "defaultValue": "off", + }, + "alarm_timeago_urgent": { + "type": "string", + "allowedValues": [ + "on", + "off" + ], + "defaultValue": "off", + }, + "alarm_timeago_warn": { + "type": "string", + "allowedValues": [ + "on", + "off" + ], + "defaultValue": "off", + }, + "alarm_urgent_high": { + "type": "string", + "allowedValues": [ + "on", + "off" + ], + "defaultValue": "off", + }, + "alarm_urgent_low": { + "type": "string", + "allowedValues": [ + "on", + "off" + ], + "defaultValue": "off", + }, + "basal_render": { + "type": "string", + "allowedValues": [ + "none", + "default", + "icicle" + ], + "defaultValue": "none", + }, + "scale_y": { + "type": "string", + "allowedValues": [ + "log", + "linear", + "log-dynamic" + ], + "defaultValue": "log", + }, + "enable": { + "type": "string", + "defaultValue": "basal bwp cage careportal cob rawbg sage iage treatmentnotify boluscalc profile", + }, + "night_mode": { + "type": "string", + "allowedValues": [ + "on", + "off" + ], + "defaultValue": "off", + }, + "show_plugins": { + "type": "string", + "defaultValue": "careportal", + }, + "show_rawbg": { + "type": "string", + "allowedValues": [ + "always", + "never", + "noise" + ], + "defaultValue": "never", + }, + "devicestatus_advanced": { + "type": "string", + "allowedValues": [ + "true", + "false" + ], + "defaultValue": "false", + }, + "profile_multiple": { + "type": "string", + "allowedValues": [ + "on", + "off" + ], + "defaultValue": "off", + } + }, + "resources": [{ + "apiVersion": "2015-04-01", + "name": "[parameters('hostingPlanName')]", + "type": "Microsoft.Web/serverFarms", + "location": "[parameters('siteLocation')]", + "properties": { + "sku": "[parameters('sku')]", + "workerSize": "[parameters('workerSize')]", + "numberOfWorkers": 1 + } + }, { + "apiVersion": "2015-08-01", + "name": "[parameters('siteName')]", + "type": "Microsoft.Web/Sites", + "location": "[parameters('siteLocation')]", + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', parameters('hostingPlanName'))]" + ], + "tags": { + "[concat('hidden-related:', resourceGroup().id, '/providers/Microsoft.Web/serverfarms/', parameters('hostingPlanName'))]": "empty" + }, + "properties": { + "serverFarmId": "[parameters('hostingPlanName')]", + "siteConfig": { + "appSettings": [{ + "name": "MONGO_CONNECTION", + "value": "[parameters('mongoConnection')]" + }, { + "name": "DISPLAY_UNITS", + "value": "[parameters('displayUnits')]" + }, { + "name": "API_SECRET", + "value": "[parameters('apiSecret')]" + }, { + "name": "THEME", + "value": "[parameters('theme')]" + }, { + "name": "TIME_FORMAT", + "value": "[parameters('time_format')]" + }, { + "name": "LANGUAGE", + "value": "[parameters('language')]" + }, { + "name": "CUSTOM_TITLE", + "value": "[parameters('custom_title')]" + }, { + "name": "ALARM_HIGH", + "value": "[parameters('alarm_high')]" + }, { + "name": "ALARM_LOW", + "value": "[parameters('alarm_low')]" + }, { + "name": "ALARM_TIMEAGO_URGENT", + "value": "[parameters('alarm_timeago_urgent')]" + }, { + "name": "ALARM_TIMEAGO_WARN", + "value": "[parameters('alarm_timeago_warn')]" + }, { + "name": "ALARM_URGENT_HIGH", + "value": "[parameters('alarm_urgent_high')]" + }, { + "name": "ALARM_URGENT_LOW", + "value": "[parameters('alarm_urgent_low')]" + }, { + "name": "BASAL_RENDER", + "value": "[parameters('basal_render')]" + }, { + "name": "SCALE_Y", + "value": "[parameters('scale_y')]" + }, { + "name": "ENABLE", + "value": "[parameters('enable')]" + }, { + "name": "NIGHT_MODE", + "value": "[parameters('night_mode')]" + }, { + "name": "SHOW_PLUGINS", + "value": "[parameters('show_plugins')]" + }, { + "name": "SHOW_RAWBG", + "value": "[parameters('show_rawbg')]" + }, { + "name": "DEVICESTATUS_ADVANCED", + "value": "[parameters('devicestatus_advanced')]" + }, { + "name": "PROFILE_MULTIPLE", + "value": "[parameters('profile_multiple')]" + } + ] + } + }, + "resources": [{ + "apiVersion": "2015-08-01", + "name": "web", + "type": "sourcecontrols", + "dependsOn": [ + "[resourceId('Microsoft.Web/Sites', parameters('siteName'))]" + ], + "properties": { + "RepoUrl": "[parameters('repoUrl')]", + "branch": "[parameters('branch')]", + "IsManualIntegration": true + } + }] + }] +} \ No newline at end of file diff --git a/bin/post-sgv.sh b/bin/post-sgv.sh index 9b48e5dfcc9..354637525e8 100755 --- a/bin/post-sgv.sh +++ b/bin/post-sgv.sh @@ -1,9 +1,10 @@ #!/bin/sh -# "date": "1413782506964" +# Date is epoch in nanosecods...ie linux echo $(($(date +%s%N)/1000000)) +# $API_SECRET needs to be a hashed value of your secret key...ie linux echo -n "" | sha1sum curl -H "Content-Type: application/json" -H "api-secret: $API_SECRET" -XPOST 'http://localhost:1337/api/v1/entries/' -d '{ "sgv": 100, "type": "sgv", "direction": "Flat", - "date": "1415950912800" + "date": 1449872210706 }' diff --git a/bower.json b/bower.json index 7746f87c693..1b3812ebf41 100644 --- a/bower.json +++ b/bower.json @@ -1,13 +1,14 @@ { "name": "nightscout", - "version": "0.8.5", + "version": "0.9.0", "dependencies": { - "jquery": "2.1.0", + "colorbrewer": "~1.0.0", "jQuery-Storage-API": "~1.7.2", - "tipsy-jmalonzo": "~1.0.1", - "jquery-ui": "~1.11.3", + "jquery": "2.1.0", "jquery-flot": "0.8.3", - "swagger-ui": "~2.1.2" + "jquery-ui": "~1.11.3", + "swagger-ui": "~2.1.2", + "tipsy-jmalonzo": "~1.0.1" }, "resolutions": { "jquery": "2.1.0" diff --git a/bundle/bundle.source.js b/bundle/bundle.source.js index d78ecb3c868..3ad14260687 100644 --- a/bundle/bundle.source.js +++ b/bundle/bundle.source.js @@ -1,8 +1,11 @@ (function () { window._ = require('lodash'); + window.d3 = require('d3'); window.$ = window.jQuery = require('jquery'); window.moment = require('moment-timezone'); + window.sugar = require('sugar'); + window.crossfilter = require('crossfilter'); window.Nightscout = window.Nightscout || {}; window.Nightscout = { diff --git a/env.js b/env.js index b5decbaa31f..750475d7cb4 100644 --- a/env.js +++ b/env.js @@ -17,17 +17,19 @@ function config ( ) { */ env.DISPLAY_UNITS = readENV('DISPLAY_UNITS', 'mg/dl'); env.PORT = readENV('PORT', 1337); + env.HOSTNAME = readENV('HOSTNAME', null); env.static_files = readENV('NIGHTSCOUT_STATIC_FILES', __dirname + '/static/'); + if (env.err) { + delete env.err; + } + setSSL(); setAPISecret(); setVersion(); setMongo(); updateSettings(); - // require authorization for entering treatments - env.treatments_auth = readENV('TREATMENTS_AUTH',false); - return env; } @@ -54,12 +56,20 @@ function setAPISecret() { // if a passphrase was provided, get the hex digest to mint a single token if (useSecret) { if (readENV('API_SECRET').length < consts.MIN_PASSPHRASE_LENGTH) { - var msg = ['API_SECRET should be at least', consts.MIN_PASSPHRASE_LENGTH, 'characters']; - throw new Error(msg.join(' ')); + var msg = ['API_SECRET should be at least', consts.MIN_PASSPHRASE_LENGTH, 'characters'].join(' '); + console.error(msg); + env.err = {desc: msg}; + } else { + var shasum = crypto.createHash('sha1'); + shasum.update(readENV('API_SECRET')); + env.api_secret = shasum.digest('hex'); + + if (!readENV('TREATMENTS_AUTH', true)) { + + } + + } - var shasum = crypto.createHash('sha1'); - shasum.update(readENV('API_SECRET')); - env.api_secret = shasum.digest('hex'); } } @@ -97,9 +107,11 @@ function setMongo() { console.info('MQTT configured to use a custom client id, it will override the default: ', env.mqtt_client_id); } } + env.authentication_collections_prefix = readENV('MONGO_AUTHENTICATION_COLLECTIONS_PREFIX', 'auth_'); env.treatments_collection = readENV('MONGO_TREATMENTS_COLLECTION', 'treatments'); env.profile_collection = readENV('MONGO_PROFILE_COLLECTION', 'profile'); env.devicestatus_collection = readENV('MONGO_DEVICESTATUS_COLLECTION', 'devicestatus'); + env.food_collection = readENV('MONGO_FOOD_COLLECTION', 'food'); // TODO: clean up a bit // Some people prefer to use a json configuration file instead. @@ -124,6 +136,13 @@ function updateSettings() { //should always find extended settings last env.extendedSettings = findExtendedSettings(process.env); + + if (!readENVTruthy('TREATMENTS_AUTH', true)) { + env.settings.authDefaultRoles = env.settings.authDefaultRoles || ""; + env.settings.authDefaultRoles += ' careportal'; + } + + } function readENV(varName, defaultValue) { @@ -133,12 +152,17 @@ function readENV(varName, defaultValue) { || process.env[varName] || process.env[varName.toLowerCase()]; - if (typeof value === 'string' && value.toLowerCase() === 'on') { value = true; } - if (typeof value === 'string' && value.toLowerCase() === 'off') { value = false; } return value != null ? value : defaultValue; } +function readENVTruthy(varName, defaultValue) { + var value = readENV(varName, defaultValue); + if (typeof value === 'string' && (value.toLowerCase() === 'on' || value.toLowerCase() === 'true')) { value = true; } + if (typeof value === 'string' && (value.toLowerCase() === 'off' || value.toLowerCase() === 'false')) { value = false; } + return value; +} + function findExtendedSettings (envs) { var extended = {}; @@ -157,6 +181,8 @@ function findExtendedSettings (envs) { extended[enable] = exts; var ext = _.camelCase(env.substring(split + 1).toLowerCase()); if (!isNaN(value)) { value = Number(value); } + if (typeof value === 'string' && (value.toLowerCase() === 'on' || value.toLowerCase() === 'true')) { value = true; } + if (typeof value === 'string' && (value.toLowerCase() === 'off' || value.toLowerCase() === 'false')) { value = false; } exts[ext] = value; } } diff --git a/example-profiles.md b/example-profiles.md index 717bc1df9dc..fe9bc0f6454 100644 --- a/example-profiles.md +++ b/example-profiles.md @@ -75,3 +75,53 @@ These are only examples, make sure you update all fields to fit your needs ] } ``` + +##Starting 0.9.0 profile data will be converted following way + + source (result from api call) - only [0] used now + ```[ { XXX, startDate: xxx }, { YYY, startDate: yyy } ]``` + + converted data + ``` + [ + { + defaultProfile: "Default" + , store: { + "Default" : { XXX } + } + , startDate: xxx + } + , { + defaultProfile: "Default" + , store: { + "Default" : { YYY } + } + , startDate: yyy + } + ] + ``` + + example of one new profile + + ``` + { + defaultProfile: "2-Weekend" + , store: { + "1-Weekday" : { AAA } + "2-Weekend" : { BBB } + "3-Exercise" : { CCC } + } + , startDate: xxx + } + ``` + + for basals currently used profile will be determined by last treatment record of format + ``` + { + eventType: "Profile Change" + , profile: "2-Weekend" + } + ``` + + for boluscalc profile used for calculation will be specified by key `profile` + diff --git a/lib/admin_plugins/cleanstatusdb.js b/lib/admin_plugins/cleanstatusdb.js index 86e61e987f8..4769a724789 100644 --- a/lib/admin_plugins/cleanstatusdb.js +++ b/lib/admin_plugins/cleanstatusdb.js @@ -27,11 +27,12 @@ cleanstatusdb.actions[0].init = function init(client, callback) { $status.hide().text(translate('Loading database ...')).fadeIn('slow'); $.ajax('/api/v1/devicestatus.json?count=500', { - success: function (records) { + headers: client.headers() + , success: function (records) { var recs = (records.length === 500 ? '500+' : records.length); $status.hide().text(translate('Database contains %1 records',{ params: [recs] })).fadeIn('slow'); - }, - error: function () { + } + , error: function () { $status.hide().text(translate('Error loading database')).fadeIn('slow'); } }).done(function () { if (callback) { callback(); } }); @@ -53,9 +54,7 @@ cleanstatusdb.actions[0].code = function deleteRecords(client, callback) { $.ajax({ method: 'DELETE' , url: '/api/v1/devicestatus/*' - , headers: { - 'api-secret': client.hashauth.hash() - } + , headers: client.headers() }).done(function success () { $status.hide().text(translate('All records removed ...')).fadeIn('slow'); if (callback) { diff --git a/lib/admin_plugins/futureitems.js b/lib/admin_plugins/futureitems.js index ab888aff0a6..3d5acc23099 100644 --- a/lib/admin_plugins/futureitems.js +++ b/lib/admin_plugins/futureitems.js @@ -63,15 +63,16 @@ futureitems.actions[0].init = function init(client, callback) { $status.hide().text(translate('Loading database ...')).fadeIn('slow'); var nowiso = new Date().toISOString(); $.ajax('/api/v1/treatments.json?&find[created_at][$gte]=' + nowiso, { - success: function (records) { + headers: client.headers() + , success: function (records) { futureitems.treatmentrecords = records; $status.hide().text(translate('Database contains %1 future records',{ params: [records.length] })).fadeIn('slow'); var table = $('').css('margin-top','10px'); $('#admin_' + futureitems.name + '_0_html').append(table); showTreatments(records, table); futureitems.actions[0].confirmText = translate('Remove %1 selected records?', { params: [records.length] }); - }, - error: function () { + } + , error: function () { $status.hide().text(translate('Error loading database')).fadeIn('slow'); futureitems.treatmentrecords = []; } @@ -94,9 +95,7 @@ futureitems.actions[0].code = function deleteRecords(client, callback) { $.ajax({ method: 'DELETE' , url: '/api/v1/treatments/' + _id - , headers: { - 'api-secret': client.hashauth.hash() - } + , headers: client.headers() }).done(function success () { $status.text(translate('Record %1 removed ...', { params: [_id] })); }).fail(function fail() { @@ -121,13 +120,14 @@ futureitems.actions[1].init = function init(client, callback) { $status.hide().text(translate('Loading database ...')).fadeIn('slow'); var now = new Date().getTime(); - $.ajax('/api/v1/entries.json?&find[date][$gte]=' + now, { - success: function (records) { + $.ajax('/api/v1/entries.json?&find[date][$gte]=' + now + '&count=288', { + headers: client.headers() + , success: function (records) { futureitems.entriesrecords = records; $status.hide().text(translate('Database contains %1 future records',{ params: [records.length] })).fadeIn('slow'); futureitems.actions[1].confirmText = translate('Remove %1 selected records?', { params: [records.length] }); - }, - error: function () { + } + , error: function () { $status.hide().text(translate('Error loading database')).fadeIn('slow'); futureitems.entriesrecords = []; } @@ -150,9 +150,7 @@ futureitems.actions[1].code = function deleteRecords(client, callback) { $.ajax({ method: 'DELETE' , url: '/api/v1/entries/' + _id - , headers: { - 'api-secret': client.hashauth.hash() - } + , headers: client.headers() }).done(function success () { $status.text(translate('Record %1 removed ...', { params: [_id] })); }).fail(function fail() { diff --git a/lib/admin_plugins/index.js b/lib/admin_plugins/index.js index f7a5c74133d..c5e86cb5405 100644 --- a/lib/admin_plugins/index.js +++ b/lib/admin_plugins/index.js @@ -4,7 +4,9 @@ var _ = require('lodash'); function init() { var allPlugins = [ - require('./cleanstatusdb')() + require('./subjects')() + , require('./roles')() + , require('./cleanstatusdb')() , require('./futureitems')() ]; @@ -32,8 +34,10 @@ function init() { } var a = p.actions[i]; // add main plugin html - fs.append($('').css('text-decoration','underline').append(translate(a.name))); - fs.append('
'); + if (a.name) { + fs.append($('').css('text-decoration','underline').append(translate(a.name))); + fs.append('
'); + } fs.append($('').append(translate(a.description))); fs.append($('
').attr('id','admin_' + p.name + '_' + i + '_html')); fs.append($('
').css('margin-top','10px'); + $('#admin_' + roles.name + '_0_html').append(table).append(genDialog(client)); + reload(client, callback); + } + , preventClose: true + , code: function createNewRole (client, callback) { + var role = { }; + openDialog(role, client, callback); + } +}]; + +function createOrSaveRole (role, client, callback) { + + var method = _.isEmpty(role._id) ? 'POST' : 'PUT'; + + $.ajax({ + method: method + , url: '/api/v2/authorization/roles/' + , headers: client.headers() + , data: role + }).done(function success() { + reload(client, callback); + }).fail(function fail(err) { + console.error('Unable to ' + method + ' Subject', err.responseText); + window.alert(client.translate('Unable to ' + method + ' Subject')); + if (callback) { + callback(err); + } + }); +} + +function deleteRole (role, client, callback) { + $.ajax({ + method: 'DELETE' + , url: '/api/v2/authorization/roles/' + role._id + , headers: client.headers() + }).done(function success() { + reload(client, callback); + }).fail(function fail(err) { + console.error('Unable to delete Subject', err.responseText); + window.alert(client.translate('Unable to delete Subject')); + if (callback) { + callback(err); + } + }); +} + +function reload (client, callback) { + $.ajax({ + method: 'GET' + , url:'/api/v2/authorization/roles' + , headers: client.headers() + }).done(function success (records) { + roles.records = records; + $status.hide().text(client.translate('Database contains %1 roles',{ params: [records.length] })).fadeIn('slow'); + showRoles(records, client); + if (callback) { + callback(); + } + }).fail(function fail(err) { + $status.hide().text(client.translate('Error loading database')).fadeIn('slow'); + roles.records = []; + if (callback) { + callback(err); + } + }); +} + +function genDialog (client) { + var ret = + '' + ; + + return $(ret); +} + +function openDialog (role, client) { + $( '#editroledialog' ).dialog({ + width: 360 + , height: 360 + , buttons: [ + { text: client.translate('Save'), + class: 'leftButton', + click: function() { + + role.name = $('#edrole_name').val(); + role.permissions = + _.chain($('#edrole_permissions').val().toLowerCase().split(/[;, ]/)) + .map(_.trim) + .reject(_.isEmpty) + .sort() + .value(); + role.notes = $('#edrole_notes').val(); + + var self = this; + delete role.autoGenerated; + createOrSaveRole(role, client, function callback () { + $( self ).dialog('close'); + }); + } + }, + { text: client.translate('Cancel'), + click: function () { $( this ).dialog('close'); } + } + ] + , open : function() { + $(this).parent().css('box-shadow', '20px 20px 20px 0px black'); + $(this).parent().find('.ui-dialog-buttonset' ).css({'width':'100%','text-align':'right'}); + $(this).parent().find('button:contains("'+client.translate('Save')+'")').css({'float':'left'}); + $('#edrole_name').val(role.name || '').focus(); + $('#edrole_permissions').val(role.permissions ? role.permissions.join(' ') : ''); + $('#edrole_notes').val(role.notes || ''); + } + + }); + +} + +function showRole (role, table, client) { + var editIcon = $(''); + editIcon.click(function clicked ( ) { + openDialog(role, client); + }); + + var deleteIcon = ''; + if (role._id) { + deleteIcon = $(''); + deleteIcon.click(function clicked() { + var ok = window.confirm(client.translate('Are you sure you want to delete: ') + role.name); + if (ok) { + deleteRole(role, client); + } + }); + } + + table.append($('').css('background-color','#0f0f0f') + .append($('').css('background','#040404') + .append($('').css('background-color','#0f0f0f') + .append($('').css('background','#040404') + .append($('
').attr('width','20%').append(editIcon).append(deleteIcon).append(role.name)) + .append($('').attr('width','20%').append(_.isEmpty(role.permissions) ? '[none]' : role.permissions.join(' '))) + .append($('').attr('width','10%').append(role._id ? (role.notes ? role.notes : '') : '[system default]')) + ); +} + +function showRoles (roles, client) { + var table = $('#admin_roles_table'); + table.empty().append($('
').css('width','100px').attr('align','left').append(client.translate('Name'))) + .append($('').css('width','150px').attr('align','left').append(client.translate('Permissions'))) + .append($('').css('width','150px').attr('align','left').append(client.translate('Notes'))) + ); + for (var t=0; t').css('margin-top','10px'); + $('#admin_' + subjects.name + '_0_html').append(table).append(genDialog(client)); + reload(client, callback); + } + , preventClose: true + , code: function createNewSubject (client, callback) { + openDialog({}, client, callback); + } +}]; + +function createOrSaveSubject (subject, client, callback) { + + var method = _.isEmpty(subject._id) ? 'POST' : 'PUT'; + + $.ajax({ + method: method + , url: '/api/v2/authorization/subjects/' + , headers: client.headers() + , data: subject + }).done(function success() { + reload(client, callback); + }).fail(function fail(err) { + console.error('Unable to ' + method + ' Subject', err.responseText); + window.alert(client.translate('Unable to ' + method + ' Subject')); + if (callback) { + callback(); + } + }); +} + +function deleteSubject (subject, client, callback) { + $.ajax({ + method: 'DELETE' + , url: '/api/v2/authorization/subjects/' + subject._id + , headers: client.headers() + }).done(function success() { + reload(client, callback); + }).fail(function fail(err) { + console.error('Unable to delete Subject', err.responseText); + window.alert(client.translate('Unable to delete Subject')); + if (callback) { + callback(); + } + }); +} + +function reload (client, callback) { + $.ajax({ + method: 'GET' + , url:'/api/v2/authorization/subjects' + , headers: client.headers() + }).done(function success (records) { + subjects.records = records; + $status.hide().text(client.translate('Database contains %1 subjects',{ params: [records.length] })).fadeIn('slow'); + showSubjects(records, client); + if (callback) { + callback(); + } + }).fail(function fail(err) { + $status.hide().text(client.translate('Error loading database')).fadeIn('slow'); + subjects.records = []; + if (callback) { + callback(err); + } + }); +} + +function genDialog (client) { + var ret = + '' + ; + + return $(ret); +} + +function openDialog (subject, client) { + $( '#editsubjectdialog' ).dialog({ + width: 360 + , height: 300 + , buttons: [ + { text: client.translate('Save'), + class: 'leftButton', + click: function() { + subject.name = $('#edsub_name').val(); + subject.roles = + _.chain($('#edsub_roles').val().toLowerCase().split(/[;, ]/)) + .map(_.trim) + .reject(_.isEmpty) + .sort() + .value(); + subject.notes = $('#edsub_notes').val(); + + var self = this; + createOrSaveSubject(subject, client, function callback ( ) { + $( self ).dialog('close'); + }); + } + }, + { text: client.translate('Cancel'), + click: function () { $( this ).dialog('close'); } + } + ] + , open : function() { + $(this).parent().css('box-shadow', '20px 20px 20px 0px black'); + $(this).parent().find('.ui-dialog-buttonset' ).css({'width':'100%','text-align':'right'}); + $(this).parent().find('button:contains("'+client.translate('Save')+'")').css({'float':'left'}); + $('#edsub_name').val(subject.name || '').focus(); + $('#edsub_roles').val(subject.roles ? subject.roles.join(', ') : ''); + $('#edsub_notes').val(subject.notes || ''); + } + + }); + +} + +function showSubject (subject, table, client) { + var editIcon = $(''); + editIcon.click(function clicked ( ) { + openDialog(subject, client); + }); + var deleteIcon = $(''); + deleteIcon.click(function clicked ( ) { + var ok = window.confirm(client.translate('Are you sure you want to delete: ') + subject.name); + if (ok) { + deleteSubject(subject, client); + } + }); + table.append($('
').attr('width','20%').append(editIcon).append(deleteIcon).append(subject.name)) + .append($('').attr('width','20%').append(subject.roles ? subject.roles.join(', ') : '[none]')) + .append($('').attr('width','20%').append('' + subject.accessToken + '')) + .append($('').attr('width','10%').append(subject.notes ? subject.notes : '')) + ); +} + +function showSubjects (subjects, client) { + var table = $('#admin_subjects_table'); + table.empty().append($('
').css('width','100px').attr('align','left').append(client.translate('Name'))) + .append($('').css('width','150px').attr('align','left').append(client.translate('Roles'))) + .append($('').css('width','150px').attr('align','left').append(client.translate('Access Token'))) + .append($('').css('width','150px').attr('align','left').append(client.translate('Notes'))) + ); + for (var t=0; t 0) { + selected = _.filter(segments[0].split(','), notEmpty); + } + + var result = sbx.properties; + + if (selected.length > 0) { + result = _.pick(sbx.properties, selected); + } + + if (req.query && req.query.pretty) { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(result, null, 2)); + } else { + res.json(result); + } + + }); + + + return properties; +} + +module.exports = create; \ No newline at end of file diff --git a/lib/api/status.js b/lib/api/status.js index 2e3eeefb8a2..350f428fdc4 100644 --- a/lib/api/status.js +++ b/lib/api/status.js @@ -1,25 +1,34 @@ 'use strict'; -function configure (app, wares, env) { +function configure (app, wares, env, ctx) { var express = require('express'), api = express.Router( ) ; + api.use(wares.sendJSONStatus); api.use(wares.extensions([ 'json', 'svg', 'csv', 'txt', 'png', 'html', 'js' ])); + + api.use(ctx.authorization.isPermitted('api:status:read')); + // Status badge/text/json api.get('/status', function (req, res) { + var date = new Date(); var info = { status: 'ok' , name: app.get('name') , version: app.get('version') - , serverTime: new Date().toISOString() + , serverTime: date.toISOString() + , serverTimeEpoch: date.getTime() , apiEnabled: app.enabled('api') , careportalEnabled: app.enabled('api') && env.settings.enable.indexOf('careportal') > -1 + , boluscalcEnabled: app.enabled('api') && env.settings.enable.indexOf('boluscalc') > -1 , head: wares.get_head( ) , settings: env.settings , extendedSettings: app.extendedClientSettings + , authorized: ctx.authorization.authorize(req.query.token || '') }; + var badge = 'http://img.shields.io/badge/Nightscout-OK-green'; return res.format({ html: function ( ) { @@ -32,10 +41,9 @@ function configure (app, wares, env) { res.redirect(302, badge + '.svg'); }, js: function ( ) { - var head = 'this.serverSettings ='; - var body = JSON.stringify(info); - var tail = ';'; - res.send([head, body, tail].join(' ')); + var parts = ['this.serverSettings =', JSON.stringify(info), ';']; + + res.send(parts.join(' ')); }, text: function ( ) { res.send('STATUS OK'); diff --git a/lib/api/treatments/index.js b/lib/api/treatments/index.js index 4afa0ee4407..dc3d6337af1 100644 --- a/lib/api/treatments/index.js +++ b/lib/api/treatments/index.js @@ -1,5 +1,6 @@ 'use strict'; +var _ = require('lodash'); var consts = require('../../constants'); function configure (app, wares, ctx) { @@ -17,9 +18,12 @@ function configure (app, wares, ctx) { // invoke common middleware api.use(wares.sendJSONStatus); + api.use(ctx.authorization.isPermitted('api:treatments:read')); + // List treatments available - api.get('/treatments/', function(req, res) { + api.get('/treatments', function(req, res) { ctx.treatments.list(req.query, function (err, results) { + _.forEach(results, function clean(t) { t.carbs = Number(t.carbs); t.insulin = Number(t.insulin); }); return res.json(results); }); }); @@ -30,7 +34,7 @@ function configure (app, wares, ctx) { var treatment = req.body; ctx.treatments.create(treatment, function (err, created) { if (err) { - console.log('Error adding treatment'); + console.log('Error adding treatment', err); res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); } else { console.log('Treatment created'); @@ -38,19 +42,15 @@ function configure (app, wares, ctx) { } }); } - if (app.settings.treatments_auth) { - api.post('/treatments/', wares.bodyParser({limit: 1048576 * 50 }), wares.verifyAuthorization, post_response); - } else { - api.post('/treatments/', wares.bodyParser({limit: 1048576 * 50 }), post_response); - } - api.delete('/treatments/:_id', wares.verifyAuthorization, function(req, res) { + api.post('/treatments/', wares.bodyParser({limit: 1048576 * 50 }), ctx.authorization.isPermitted('api:treatments:create'), post_response); + api.delete('/treatments/:_id', ctx.authorization.isPermitted('api:treatments:delete'), function(req, res) { ctx.treatments.remove(req.params._id, function ( ) { res.json({ }); }); }); // update record - api.put('/treatments/', wares.verifyAuthorization, function(req, res) { + api.put('/treatments/', ctx.authorization.isPermitted('api:treatments:update'), function(req, res) { var data = req.body; ctx.treatments.save(data, function (err, created) { if (err) { diff --git a/lib/api/verifyauth.js b/lib/api/verifyauth.js index d92646928a1..b3c67433d5b 100644 --- a/lib/api/verifyauth.js +++ b/lib/api/verifyauth.js @@ -2,15 +2,20 @@ var consts = require('../constants'); -function configure (app, env) { +function configure (ctx) { var express = require('express'), api = express.Router( ); api.get('/verifyauth', function(req, res) { - var api_secret = env.api_secret; - var secret = req.params.secret ? req.params.secret : req.header('api-secret'); - var authorized = (app.enabled('api') && api_secret && api_secret.length > 12) ? (secret === api_secret) : false; - res.sendJSONStatus(res, consts.HTTP_OK, authorized ? 'OK' : 'UNAUTHORIZED'); + ctx.authorization.resolveWithRequest(req, function resolved (err, result) { + + // this is used to see if req has api-secret equivalent authorization + var authorized = !err && + ctx.authorization.checkMultiple('*:*:create,update,delete', result.shiros) && //can write to everything + ctx.authorization.checkMultiple('admin:*:*:*', result.shiros); //full admin permissions too + + res.sendJSONStatus(res, consts.HTTP_OK, authorized ? 'OK' : 'UNAUTHORIZED'); + }); }); return api; diff --git a/lib/authorization/endpoints.js b/lib/authorization/endpoints.js new file mode 100644 index 00000000000..194a97b0113 --- /dev/null +++ b/lib/authorization/endpoints.js @@ -0,0 +1,119 @@ +'use strict'; + +var _ = require('lodash'); +var express = require('express'); + +var consts = require('./../constants'); + +function init (env, authorization) { + var endpoints = express( ); + + var wares = require('./../middleware/index')(env); + + endpoints.use(wares.sendJSONStatus); + // text body types get handled as raw buffer stream + endpoints.use(wares.bodyParser.raw()); + // json body types get handled as parsed json + endpoints.use(wares.bodyParser.json()); + // also support url-encoded content-type + endpoints.use(wares.bodyParser.urlencoded({ extended: true })); + + endpoints.get('/request/:accessToken', function requestAuthorize (req, res) { + var authorized = authorization.authorize(req.params.accessToken); + + if (authorized) { + res.json(authorized); + } else { + res.sendJSONStatus(res, consts.HTTP_UNAUTHORIZED, 'Unauthorized', 'Invalid/Missing'); + } + }); + + endpoints.get('/permissions', authorization.isPermitted('admin:api:permissions:read'), function getSubjects (req, res) { + res.json(authorization.seenPermissions); + }); + + endpoints.get('/permissions/trie', authorization.isPermitted('admin:api:permissions:read'), function getSubjects (req, res) { + res.json(authorization.expandedPermissions()); + }); + + endpoints.get('/subjects', authorization.isPermitted('admin:api:subjects:read'), function getSubjects (req, res) { + res.json(_.map(authorization.storage.subjects, function eachSubject (subject) { + return _.pick(subject, ['_id', 'name', 'accessToken', 'roles']); + })); + }); + + endpoints.post('/subjects', authorization.isPermitted('admin:api:subjects:create'), function createSubject (req, res) { + authorization.storage.createSubject(req.body, function created (err, created) { + if (err) { + res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } else { + res.json(created); + } + }); + }); + + endpoints.put('/subjects', authorization.isPermitted('admin:api:subjects:update'), function saveSubject (req, res) { + authorization.storage.saveSubject(req.body, function saved (err, saved) { + if (err) { + res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } else { + res.json(saved); + } + }); + }); + + endpoints.delete('/subjects/:_id', authorization.isPermitted('admin:api:subjects:delete'), function deleteSubject (req, res) { + authorization.storage.removeSubject(req.params._id, function deleted (err) { + if (err) { + res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } else { + res.json({ }); + } + }); + }); + + endpoints.get('/roles', authorization.isPermitted('admin:api:roles:list'), function getRoles (req, res) { + res.json(authorization.storage.roles); + }); + + endpoints.post('/roles', authorization.isPermitted('admin:api:roles:create'), function createSubject (req, res) { + authorization.storage.createRole(req.body, function created (err, created) { + if (err) { + res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } else { + res.json(created); + } + }); + }); + + endpoints.put('/roles', authorization.isPermitted('admin:api:roles:update'), function saveRole (req, res) { + authorization.storage.saveRole(req.body, function saved (err, saved) { + if (err) { + res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } else { + res.json(saved); + } + }); + }); + + endpoints.delete('/roles/:_id', authorization.isPermitted('admin:api:roles:delete'), function deleteRole (req, res) { + authorization.storage.removeRole(req.params._id, function deleted (err) { + if (err) { + res.sendJSONStatus(res, consts.HTTP_INTERNAL_ERROR, 'Mongo Error', err); + } else { + res.json({ }); + } + }); + }); + + endpoints.get('/debug/check/:permission', function check (req, res, next) { + authorization.isPermitted(req.params.permission)(req, res, next); + }, function debug (req, res) { + res.json({check: true}); + }); + + + return endpoints; +} + +module.exports = init; diff --git a/lib/authorization/index.js b/lib/authorization/index.js new file mode 100644 index 00000000000..0c0c6f5f2a7 --- /dev/null +++ b/lib/authorization/index.js @@ -0,0 +1,254 @@ +'use strict'; + +var _ = require('lodash'); +var jwt = require('jsonwebtoken'); +var shiroTrie = require('shiro-trie'); + +var consts = require('./../constants'); + +var log_green = '\x1B[32m'; +var log_red = '\x1b[31m'; +var log_reset = '\x1B[0m'; +var LOG_GRANTED = log_green + 'GRANTED: ' + log_reset; +var LOG_DENIED = log_red + 'DENIED: ' + log_reset; + +function mkopts (opts) { + var options = opts && !_.isEmpty(opts) ? opts : { }; + if (!options.redirectDeniedURL) { + options.redirectDeniedURL = null; + } + return options; +} + +function getRemoteIP (req) { + return req.headers['x-forwarded-for'] || req.connection.remoteAddress; +} + +function init (env, ctx) { + var authorization = { }; + var storage = authorization.storage = require('./storage')(env, ctx); + var defaultRoles = (env.settings.authDefaultRoles || '').split(/[, :]/); + + function extractToken (req) { + var token; + var authorization = req.header('Authorization'); + + if (authorization) { + var parts = authorization.split(' '); + if (parts.length === 2 && parts[0] === 'Bearer') { + token = parts[1]; + } + } + + if (!token && req.auth_token) { + token = req.auth_token; + } + + if (!token) { + token = authorizeAccessToken(req); + } + + if (token) { + req.auth_token = token; + } + + return token; + } + + function authorizeAccessToken (req) { + + var accessToken = req.query.token; + + if (!accessToken && req.body) { + if (_.isArray(req.body) && req.body.length > 0 && req.body[0].token) { + accessToken = req.body[0].token; + delete req.body[0].token; + } else if (req.body.token) { + accessToken = req.body.token; + delete req.body.token; + } + } + + var authToken = null; + + if (accessToken) { + // make an auth token on the fly, based on an access token + var authed = authorization.authorize(accessToken); + if (authed && authed.token) { + authToken = authed.token; + } + } + + return authToken; + } + + function adminSecretFromRequest (req) { + var secret = req.query && req.query.secret ? req.query.secret : req.header('api-secret'); + + if (!secret && req.api_secret) { + //see if we already got the secret from the body, since it gets deleted + secret = req.api_secret; + } else if (!secret && req.body) { + // try to get the secret from the body, but don't leave it there + if (_.isArray(req.body) && req.body.length > 0 && req.body[0].secret) { + secret = req.body[0].secret; + delete req.body[0].secret; + } else if (req.body.secret) { + secret = req.body.secret; + delete req.body.secret; + } + } + + if (secret) { + // store the secret hash on the request since the req may get processed again + req.api_secret = secret; + } + + return secret; + } + + function authorizeAdminSecretWithRequest (req) { + return authorizeAdminSecret(adminSecretFromRequest(req)); + } + + function authorizeAdminSecret (secret) { + return (env.api_secret && env.api_secret.length > 12) ? (secret === env.api_secret) : false; + } + + authorization.seenPermissions = [ ]; + + authorization.expandedPermissions = function expandedPermissions ( ) { + var permissions = shiroTrie.new(); + permissions.add(authorization.seenPermissions); + return permissions; + }; + + authorization.resolveWithRequest = function resolveWithRequest (req, callback) { + authorization.resolve({ + api_secret: adminSecretFromRequest(req) + , token: extractToken(req) + }, callback); + }; + + authorization.checkMultiple = function checkMultiple(permission, shiros) { + var found = _.find(shiros, function checkEach (shiro) { + return shiro && shiro.check(permission); + }); + return _.isObject(found); + }; + + authorization.resolve = function resolve (data, callback) { + + if (authorizeAdminSecret(data.api_secret)) { + var admin = shiroTrie.new(); + admin.add(['*']); + return callback(null, { shiros: [ admin ] }); + } + + var defaultShiros = storage.rolesToShiros(defaultRoles); + + if (data.token) { + jwt.verify(data.token, env.api_secret, function result(err, verified) { + if (err) { + return callback(err, { shiros: [ ] }); + } else { + var resolved = storage.resolveSubjectAndPermissions(verified.accessToken); + var shiros = resolved.shiros.concat(defaultShiros); + return callback(null, { shiros: shiros, subject: resolved.subject }); + } + }); + } else { + return callback(null, { shiros: defaultShiros }); + } + + }; + + authorization.isPermitted = function isPermitted (permission, opts) { + + + opts = mkopts(opts); + authorization.seenPermissions = _.chain(authorization.seenPermissions) + .push(permission) + .sort() + .uniq() + .value(); + + function check(req, res, next) { + + var remoteIP = getRemoteIP(req); + + if (authorizeAdminSecretWithRequest(req)) { + console.log(LOG_GRANTED, remoteIP, 'api-secret', permission); + next( ); + return; + } + + var token = extractToken(req); + var defaultShiros = storage.rolesToShiros(defaultRoles); + + if (token) { + jwt.verify(token, env.api_secret, function result(err, verified) { + if (err) { + console.info('Error verifying Authorized Token', err); + res.status(consts.HTTP_UNAUTHORIZED).send('Unauthorized - Invalid/Missing'); + } else { + var resolved = storage.resolveSubjectAndPermissions(verified.accessToken); + if (authorization.checkMultiple(permission, resolved.shiros)) { + console.log(LOG_GRANTED, remoteIP, verified.accessToken , permission); + next(); + } else if (authorization.checkMultiple(permission, defaultShiros)) { + console.log(LOG_GRANTED, remoteIP, verified.accessToken, permission, 'default'); + next( ); + } else { + console.log(LOG_DENIED, remoteIP, verified.accessToken, permission); + res.sendJSONStatus(res, consts.HTTP_UNAUTHORIZED, 'Unauthorized', 'Invalid/Missing'); + } + } + }); + } else { + if (authorization.checkMultiple(permission, defaultShiros)) { + console.log(LOG_GRANTED, remoteIP, 'no-token', permission, 'default'); + return next( ); + } + console.log(LOG_DENIED, remoteIP, 'no-token', permission); + res.sendJSONStatus(res, consts.HTTP_UNAUTHORIZED, 'Unauthorized', 'Invalid/Missing'); + } + + } + + return check; + }; + + authorization.authorize = function authorize (accessToken) { + var subject = storage.findSubject(accessToken); + + var authorized = null; + + if (subject) { + var token = jwt.sign( { accessToken: subject.accessToken }, env.api_secret, { expiresIn: '1h' } ); + + //decode so we can tell the client the issued and expired times + var decoded = jwt.decode(token); + + var roles = _.uniq(subject.roles.concat(defaultRoles)); + + authorized = { + token: token + , sub: subject.name + // not sending roles to client to prevent us from treating them as magic + // instead group permissions by role so the we can create correct shiros on the client + , permissionGroups: _.map(roles, storage.roleToPermissions) + , iat: decoded.iat + , exp: decoded.exp + }; + } + + return authorized; + }; + + authorization.endpoints = require('./endpoints')(env, authorization); + + return authorization; +} + +module.exports = init; diff --git a/lib/authorization/storage.js b/lib/authorization/storage.js new file mode 100644 index 00000000000..5b89f2b4177 --- /dev/null +++ b/lib/authorization/storage.js @@ -0,0 +1,226 @@ +'use strict'; + +var _ = require('lodash'); +var crypto = require('crypto'); +var shiroTrie = require('shiro-trie'); +var ObjectID = require('mongodb').ObjectID; + +var find_options = require('../query'); + +function init (env, ctx) { + var storage = { }; + + var rolesCollection = ctx.store.collection(env.authentication_collections_prefix + 'roles'); + var subjectsCollection = ctx.store.collection(env.authentication_collections_prefix + 'subjects'); + + storage.queryOpts = { + dateField: 'created_at' + , noDateFilter: true + }; + + function query_for (opts) { + return find_options(opts, storage.queryOpts); + } + + function create (collection) { + function doCreate(obj, fn) { + if (!obj.hasOwnProperty('created_at')) { + obj.created_at = (new Date()).toISOString(); + } + collection.insert(obj, function (err, doc) { + storage.reload(function loaded() { + fn(null, doc.ops); + }); + }); + } + return doCreate; + } + + function list (collection) { + function doList(opts, fn) { + // these functions, find, sort, and limit, are used to + // dynamically configure the request, based on the options we've + // been given + + // determine sort options + function sort() { + return opts && opts.sort || {date: -1}; + } + + // configure the limit portion of the current query + function limit() { + if (opts && opts.count) { + return this.limit(parseInt(opts.count)); + } + return this; + } + + // handle all the results + function toArray(err, entries) { + fn(err, entries); + } + + // now just stitch them all together + limit.call(collection + .find(query_for(opts)) + .sort(sort()) + ).toArray(toArray); + } + + return doList; + } + + function remove (collection) { + function doRemove (_id, callback) { + collection.remove({ '_id': new ObjectID(_id) }, function (err) { + storage.reload(function loaded() { + callback(err, null); + }); + }); + } + return doRemove; + } + + function save (collection) { + function doSave (obj, callback) { + obj._id = new ObjectID(obj._id); + if (!obj.created_at) { + obj.created_at = (new Date()).toISOString(); + } + collection.save(obj, function (err) { + //id should be added for new docs + storage.reload(function loaded() { + callback(err, obj); + }); + }); + } + return doSave; + } + + storage.createSubject = create(subjectsCollection); + storage.saveSubject = save(subjectsCollection); + storage.removeSubject = remove(subjectsCollection); + storage.listSubjects = list(subjectsCollection); + + storage.createRole = create(rolesCollection); + storage.saveRole = save(rolesCollection); + storage.removeRole = remove(rolesCollection); + storage.listRoles = list(rolesCollection); + + storage.defaultRoles = [ + { name: 'admin', permissions: ['*'] } + , { name: 'denied', permissions: [ ] } + , { name: 'status-only', permissions: [ 'api:status:read' ] } + , { name: 'readable', permissions: [ '*:*:read' ] } + , { name: 'careportal', permissions: [ 'api:treatments:create' ] } + , { name: 'devicestatus-upload', permissions: [ 'api:devicestatus:create' ] } + ]; + + storage.reload = function reload (callback) { + + storage.listRoles({sort: {name: 1}}, function listResults (err, results) { + if (err) { + return callback && callback(err); + } + + storage.roles = results || [ ]; + + _.forEach(storage.defaultRoles, function eachRole (role) { + if (_.isEmpty(_.find(storage.roles, {name: role.name}))) { + storage.roles.push(role); + } + }); + + storage.roles = _.sortBy(storage.roles, 'name'); + + storage.listSubjects({sort: {name: 1}}, function listResults (err, results) { + if (err) { + return callback && callback(err); + } + + storage.subjects = _.map(results, function eachSubject (subject) { + if (env.api_secret) { + var shasum = crypto.createHash('sha1'); + shasum.update(env.api_secret); + shasum.update(subject._id.toString()); + var abbrev = subject.name.toLowerCase().replace(/[\W]/g, '').substring(0, 10); + subject.digest = shasum.digest('hex'); + subject.accessToken = abbrev + '-' + subject.digest.substring(0, 16); + } + + return subject; + }); + + if (callback) { + callback( ); + } + }); + }); + + }; + + storage.findRole = function findRole (roleName) { + return _.find(storage.roles, {name: roleName}); + }; + + storage.roleToShiro = function roleToShiro (roleName) { + var shiro = null; + + var role = storage.findRole(roleName); + if (role) { + shiro = shiroTrie.new(); + shiro.add(role.permissions); + } + + return shiro; + }; + + storage.rolesToShiros = function roleToShiro (roleNames) { + return _.chain(roleNames) + .map(storage.roleToShiro) + .reject(_.isEmpty) + .value(); + }; + + storage.roleToPermissions = function roleToPermissions (roleName) { + var permissions = [ ]; + + var role = storage.findRole(roleName); + if (role) { + permissions = role.permissions; + } + + return permissions; + }; + + storage.findSubject = function findSubject (accessToken) { + var prefix = _.last(accessToken.split('-')); + + if (prefix.length < 16) { + return null; + } + + return _.find(storage.subjects, function matches (subject) { + return subject.digest.indexOf(prefix) === 0; + }); + }; + + storage.resolveSubjectAndPermissions = function resolveSubjectAndPermissions (accessToken) { + var shiros = []; + + var subject = storage.findSubject(accessToken); + if (subject) { + shiros = storage.rolesToShiros(subject.roles); + } + + return { + subject: subject + , shiros: shiros + }; + }; + + return storage; + +} + +module.exports = init; diff --git a/lib/booterror.js b/lib/booterror.js new file mode 100644 index 00000000000..f08eb06db39 --- /dev/null +++ b/lib/booterror.js @@ -0,0 +1,22 @@ +'use strict'; + +var _ = require('lodash'); + +var head = 'Nightscout - Boot Error

Nightscout - Boot Error

'; +var tail = '
'; + +function bootError(ctx) { + + return function pageHandler (req, res) { + var errors = _.map(ctx.bootErrors, function (obj) { + obj.err = _.pick(obj.err, Object.getOwnPropertyNames(obj.err)); + return '
' + obj.desc + '
' + JSON.stringify(obj.err).replace(/\\n/g, '
') + '
'; + }).join(' '); + + res.set('Content-Type', 'text/html'); + res.send(head + errors + tail); + + } +} + +module.exports = bootError; \ No newline at end of file diff --git a/lib/bootevent.js b/lib/bootevent.js index 2e2665dfa33..2cd706ae55d 100644 --- a/lib/bootevent.js +++ b/lib/bootevent.js @@ -6,17 +6,60 @@ var UPDATE_THROTTLE = 1000; function boot (env) { + function checkEnv (ctx, next) { + if (env.err) { + ctx.bootErrors = ctx.bootErrors || [ ]; + ctx.bootErrors.push({'desc': 'ENV Error', err: env.err}); + } + next(); + } + + function hasBootErrors(ctx) { + return ctx.bootErrors && ctx.bootErrors.length > 0; + } + function setupMongo (ctx, next) { - require('./storage')(env, function ready ( err, store ) { - // FIXME, error is always null, if there is an error, the storage.js will throw an exception - console.log('Storage system ready'); - ctx.store = store; - next( ); + if (hasBootErrors(ctx)) { + return next(); + } + + try { + require('./storage')(env, function ready ( err, store ) { + // FIXME, error is always null, if there is an error, the storage.js will throw an exception + console.log('Storage system ready'); + ctx.store = store; + + next(); + }); + } catch (err) { + console.info('mongo err', err); + ctx.bootErrors = ctx.bootErrors || [ ]; + ctx.bootErrors.push({'desc': 'Unable to connect to Mongo', err: err}); + next(); + } + } + + function setupAuthorization (ctx, next) { + if (hasBootErrors(ctx)) { + return next(); + } + + ctx.authorization = require('./authorization')(env, ctx); + ctx.authorization.storage.reload(function loaded (err) { + if (err) { + ctx.bootErrors = ctx.bootErrors || [ ]; + ctx.bootErrors.push({'desc': 'Unable to setup authorization', err: err}); + } + next(); }); } function setupInternals (ctx, next) { + if (hasBootErrors(ctx)) { + return next(); + } + /////////////////////////////////////////////////// // api and json object variables /////////////////////////////////////////////////// @@ -30,27 +73,39 @@ function boot (env) { ctx.treatments = require('./treatments')(env, ctx); ctx.devicestatus = require('./devicestatus')(env.devicestatus_collection, ctx); ctx.profile = require('./profile')(env.profile_collection, ctx); + ctx.food = require('./food')(env, ctx); ctx.pebble = require('./pebble')(env, ctx); - ctx.bus = require('./bus')(env, ctx); - ctx.data = require('./data')(env, ctx); + ctx.properties = require('./api/properties')(env, ctx); + ctx.bus = require('./bus')(env.settings, ctx); + ctx.ddata = require('./data/ddata')(); + ctx.dataloader = require('./data/dataloader')(env, ctx); ctx.notifications = require('./notifications')(env, ctx); next( ); } function ensureIndexes (ctx, next) { + if (hasBootErrors(ctx)) { + return next(); + } + console.info('Ensuring indexes'); ctx.store.ensureIndexes(ctx.entries( ), ctx.entries.indexedFields); ctx.store.ensureIndexes(ctx.treatments( ), ctx.treatments.indexedFields); ctx.store.ensureIndexes(ctx.devicestatus( ), ctx.devicestatus.indexedFields); ctx.store.ensureIndexes(ctx.profile( ), ctx.profile.indexedFields); + ctx.store.ensureIndexes(ctx.food( ), ctx.food.indexedFields); next( ); } function setupListeners (ctx, next) { + if (hasBootErrors(ctx)) { + return next(); + } + var updateData = _.debounce(function debouncedUpdateData ( ) { - ctx.data.update(function dataUpdated () { + ctx.dataloader.update(ctx.ddata, function dataUpdated () { ctx.bus.emit('data-loaded'); }); }, UPDATE_THROTTLE); @@ -80,6 +135,10 @@ function boot (env) { } function setupBridge (ctx, next) { + if (hasBootErrors(ctx)) { + return next(); + } + ctx.bridge = require('./plugins/bridge')(env); if (ctx.bridge) { ctx.bridge.startEngine(ctx.entries); @@ -88,7 +147,11 @@ function boot (env) { } function setupMMConnect (ctx, next) { - ctx.mmconnect = require('./plugins/mmconnect').init(env, ctx.entries); + if (hasBootErrors(ctx)) { + return next(); + } + + ctx.mmconnect = require('./plugins/mmconnect').init(env, ctx.entries, ctx.devicestatus); if (ctx.mmconnect) { ctx.mmconnect.run(); } @@ -96,13 +159,19 @@ function boot (env) { } function finishBoot (ctx, next) { + if (hasBootErrors(ctx)) { + return next(); + } + ctx.bus.uptime( ); next( ); } return require('bootevent')( ) + .acquire(checkEnv) .acquire(setupMongo) + .acquire(setupAuthorization) .acquire(setupInternals) .acquire(ensureIndexes) .acquire(setupListeners) diff --git a/lib/bus.js b/lib/bus.js index 11dd7337939..5828130fa92 100644 --- a/lib/bus.js +++ b/lib/bus.js @@ -2,10 +2,10 @@ var Stream = require('stream'); -function init (env) { +function init (settings) { var beats = 0; var started = new Date( ); - var interval = env.settings.heartbeat * 1000; + var interval = settings.heartbeat * 1000; var stream = new Stream; diff --git a/lib/client/boluscalc.js b/lib/client/boluscalc.js new file mode 100644 index 00000000000..074661ab189 --- /dev/null +++ b/lib/client/boluscalc.js @@ -0,0 +1,791 @@ +'use strict'; + +var _ = require('lodash'); +var moment = require('moment-timezone'); +var times = require('../times'); + +function init(client, $) { + var boluscalc = { }; + + var translate = client.translate; + var storage = $.localStorage; + var iob = client.plugins('iob'); + var cob = client.plugins('cob'); + + var eventTime = $('#bc_eventTimeValue'); + var eventDate = $('#bc_eventDateValue'); + + var quickpicks = []; + var foods = []; + + var icon_remove = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACrElEQVQ4T42Ty2sTQRzHv5tmk2yyjRNtpfZhL8V6s2KoUNC2XqwgaCsVQcGiFqpHi0c9iRdR/ANE9KR40FIQX4cueKoPaKFoLdSYNtE0abKT1+5s9iW7aUMiHtzTzO7v85md+c6PA4DrHbsPCKIgOWO1pA7dT6YXnXH949SE/F63pqwZtRrO+SCKgjQ5NUV+azpmHj2krMwaJC4c8Erj+/eRyloMMwWFKgbn1nC3ervlK1evkXBLGBZT8SOewotnTylTNLdgeg/pDgZDC2cPHSR8bB22DVC9hFe0SG/H0xFXcHlykjRHRDBWgJcZSCY38Xx2lhqMnRYE34Px/sN9vlQWeoHBAx2yXsRruVAVuFsIBaSJ8+eJGPaBqQV4NROJjTzez89jLBoFn6FgybQL54wS3uTyVDFQ3cL2IYpBv3RhdJSIIQ80tQyv7gEqJvS8AmUlBs7UXPhtjtZgh3UFNYngk86NHCfNAg9dMwHVBPu+CpsVkTXKeJeVG+AGgTOZ3tt6MSKKjy+NjEBjFrR4ElZmA4pdxstMFsyyJu6tZZ7Ux9vwB6EAL50ZGiRECEPPUOixVTRxHlicgSVWxEdZpuZWfNuS2hk48NjwMIkIYZglBnV5Cbqtws/5IaAJmsfCglrEl2y2QeKmEBJ80tixKmxrFpSVr0gV0viQoxho2YUuPohmeFD22PiklLC4ma5JuBvdrfLJI0dJd0s7bM0ES8aR/BXDXGaTskqlL+D3Lwy0tZEePoAd4EA5YF4tYymdonfjmQh3s6dTPjU4SHYGwjAKecSXFyGlM1TdytntE56T+ts7SC/vhw3gm6njc2Kd3vm5Ub1IwQAvnYhGiZpYw1wiWYPrIw7wnBTt7CLOOwdmut14kQQvqt24tfK/utGR6LaF+iRqMf4N/O/8D28HiiCRYqzAAAAAAElFTkSuQmCC'; + + function roundTo (x, step) { + if (x) { + return Math.round(x / step) * step; + } + return 0; + } + + function maybePrevent (event) { + if (event) { + event.preventDefault(); + } + } + + function isProfileEnabled(profiles) { + return client.settings.enable.indexOf('profile') > -1 + && client.settings.extendedSettings.profile + && client.settings.extendedSettings.profile.multiple + && profiles.length > 1; + } + + function isTouch() { + try { document.createEvent('TouchEvent'); return true; } + catch (e) { return false; } + } + + function setDateAndTime (time) { + time = time || moment(); + eventTime.val(time.format('HH:mm')); + eventDate.val(time.format('YYYY-MM-DD')); + } + + function mergeDateAndTime ( ) { + return client.utils.mergeInputTime(eventTime.val(), eventDate.val()); + } + + function updateTime(ele, time) { + ele.attr('oldminutes', time.minutes()); + ele.attr('oldhours', time.hours()); + } + + function setBG (sgv, selectedTime) { + var sensorbg = 0; + boluscalc.oldbg = false; + if (sgv) { + sensorbg = sgv.mgdl; + if (sensorbg < 39) { + sensorbg = 0; + } else { + sensorbg = client.utils.scaleMgdl(sensorbg); + } + if (selectedTime.getTime() - sgv.mills > 10 * 60 * 1000) { + boluscalc.oldbg = true; // Do not use if record is older than 10 min + sensorbg = 0; + } + } + + // Set BG + if ($('#bc_bgfromsensor').is(':checked')) { + $('#bc_bg').val(sensorbg ? sensorbg : ''); + } + } + + boluscalc.updateVisualisations = function updateVisualisations (sbx) { + // update BG in GUI + setBG(sbx.lastSGVEntry(), mergeDateAndTime().toDate()); + + if (client.browserUtils.getLastOpenedDrawer !== '#boluscalcDrawer') { + return; + } + if ($('#bc_nowtime').is(':checked')) { + // Update time + setDateAndTime(); + + boluscalc.calculateInsulin(); + } + }; + + boluscalc.dateTimeFocus = function dateTimeFocus(event) { + $('#bc_othertime').prop('checked', true); + updateTime($(this), mergeDateAndTime()); + maybePrevent(event); + }; + + boluscalc.dateTimeChange = function dateTimeChange(event) { + $('#bc_othertime').prop('checked', true); +// client.utils.setYAxisOffset(50); //50% of extend + var ele = $(this); + var merged = mergeDateAndTime(); + + if (ele.attr('oldminutes') === '59' && merged.minutes() === 0) { + merged.add(1, 'hours'); + } + if (ele.attr('oldminutes') === '0' && merged.minutes() === 59) { + merged.add(-1, 'hours'); + } + + setDateAndTime(merged); + updateTime(ele, merged); + boluscalc.eventTimeTypeChange(); + + // update BG from sgv to this time + setBG(findClosestSGVToPastTime(merged.toDate()), merged.toDate()); + + boluscalc.calculateInsulin(); + maybePrevent(event); +// Nightscout.utils.updateBrushToTime(moment.toDate()); + }; + + boluscalc.eventTimeTypeChange = function eventTimeTypeChange(event) { + if ($('#bc_othertime').is(':checked')) { + $('#bc_eventTimeValue').focus(); + $('#bc_retro').css('display',''); + if (mergeDateAndTime()moment()) { + $('#bc_retro').css('background-color','blue').text(translate('IN THE FUTURE')); + } else { + $('#bc_retro').css('display','none'); + } + } else { + $('#bc_retro').css('display','none'); + setDateAndTime(); + boluscalc.updateVisualisations(client.sbx); + if (event) { + boluscalc.calculateInsulin(); + } +// Nightscout.utils.setYAxisOffset(50); //50% of extend +// Nightscout.utils.updateBrushToTime(Nightscout.utils.mergeInputTime($('#bc_eventTimeValue').val(), $('#bc_eventDateValue').val()).toDate()); + } + maybePrevent(event); + }; + + boluscalc.toggleDrawer = function toggleDrawer (event) { + boluscalc.prepare(); + client.browserUtils.toggleDrawer('#boluscalcDrawer'); + maybePrevent(event); + }; + + boluscalc.prepare = function prepare( ) { + foods = []; + $('#bc_profile').empty(); + var profiles = client.profilefunctions.listBasalProfiles(); + profiles.forEach(function (p) { + $('#bc_profile').append(''); + }); + $('#bc_profileLabel').toggle(isProfileEnabled(profiles)); + + $('#bc_usebg').prop('checked','checked'); + $('#bc_usecarbs').prop('checked','checked'); + $('#bc_usecob').prop('checked',''); + $('#bc_useiob').prop('checked','checked'); + $('#bc_bgfromsensor').prop('checked','checked'); + $('#bc_carbs').val(''); + $('#bc_quickpick').val(-1); + $('#bc_preBolus').val(0); + $('#bc_notes').val(''); + $('#bc_enteredBy').val($.localStorage.get('enteredBy') || ''); + $('#bc_nowtime').prop('checked', true); + $('#bc_othercorrection').val(0); + $('#bc_profile').val(client.profilefunctions.activeProfileToTime()); + setDateAndTime(); + boluscalc.eventTimeTypeChange(); + boluscalc.updateVisualisations(client.sbx); + boluscalc.calculateInsulin(); + }; + + boluscalc.calculateInsulin = function calculateInsulin (event) { + maybePrevent(event); + boluscalc.gatherBoluscalcData( ); + boluscalc.updateGui(boluscalc.record); + return boluscalc.record; + }; + + boluscalc.updateGui = function updateGui (record) { + record = record || boluscalc.record; + + if (record.eventTime === undefined) { + return; + } + + var targetBGLow = record.targetBGLow; + var targetBGHigh = record.targetBGHigh; + var isf = record.isf; + var ic = record.ic; + + // Clear results before calculation + $('#bc_insulintotal').text('0.00'); + $('#bc_carbsneeded').text('0.00'); + $('#bc_inzulinbg').text('0.00'); + $('#bc_inzulincarbs').text('0.00'); + + // Show IOB + if ($('#bc_useiob').is(':checked')) { + $('#bc_iob').text((record.iob > 0 ? '-' : '') + record.iob.toFixed(2)); + } else { + $('#bc_iob').text(''); + } + + // Show COB + if ($('#bc_usecob').is(':checked')) { + $('#bc_cob').text(record.cob.toFixed(2)); + $('#bc_cobu').text(record.insulincob.toFixed(2)); + } else { + $('#bc_cob').text(''); + $('#bc_cobu').text(''); + } + + // Show BG + if ($('#bc_usebg').is(':checked')) { + if (record.bg === 0 || (boluscalc.oldbg && $('#bc_bgfromsensor').is(':checked'))) { + $('#bc_bg').css('background-color', 'red'); + } else { + $('#bc_bg').css('background-color', ''); + } + $('#bc_inzulinbg').text(record.insulinbg.toFixed(2)); + $('#bc_inzulinbg').attr('title', + 'Target BG range: '+targetBGLow + ' - ' + targetBGHigh + + '\nISF: ' + isf + + '\nBG diff: ' + record.bgdiff.toFixed(1) + ); + } else { + $('#bc_inzulinbgtd').css('background-color', ''); + $('#bc_bg').css('background-color', ''); + $('#bc_inzulinbg').text(''); + $('#bc_inzulinbg').attr('title', ''); + } + + // Show foods + if (record.foods.length) { + var html = ''; + var carbs = 0; + for (var fi=0; fi'; + } + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + html += '
'+ f.name + ''+ (f.portion*f.portions).toFixed(1) + ' ' + translate(f.unit) + '('+ (f.carbs*f.portions).toFixed(1) + ' g)
'; + $('#bc_food').html(html); + $('.deleteFoodRecord').click(deleteFoodRecord); + $('#bc_carbs').val(carbs.toFixed(0)); + $('#bc_carbs').attr('disabled',true); + $('#bc_gi').css('display','none'); + $('#bc_gicalculated').css('display',''); + $('#bc_gicalculated').text(record.gi); + } else { + $('#bc_food').html(''); + $('#bc_carbs').attr('disabled',false); + $('#bc_gi').css('display',''); + $('#bc_gicalculated').css('display','none'); + $('#bc_gicalculated').text(''); + } + + // Show Carbs + if ($('#bc_usecarbs').is(':checked')) { + if ($('#bc_carbs').val() === '') { + $('#bc_carbs').css('background-color',''); + } else if (isNaN(parseInt($('#bc_carbs').val().replace(',','.')))) { + $('#bc_carbs').css('background-color','red'); + } else { + $('#bc_carbs').css('background-color',''); + } + $('#bc_inzulincarbs').text(record.insulincarbs.toFixed(2)); + $('#bc_inzulincarbs').attr('title','IC: ' + ic); + } else { + $('#bc_carbs').css('background-color',''); + $('#bc_inzulincarbs').text(''); + $('#bc_inzulincarbs').attr('title',''); + $('#bc_carbs').text(''); + } + + // Show Total + $('#bc_rouding').text(record.roundingcorrection.toFixed(2)); + $('#bc_insulintotal').text(record.insulin.toFixed(2)); + + // Carbs needed if too much iob or in range message when nothing entered and in range + var outcome = record.bg - record.iob * isf; + if (record.othercorrection === 0 && record.carbs === 0 && record.cob === 0 && record.bg > 0 && outcome > targetBGLow && outcome < targetBGHigh) { + $('#bc_carbsneeded').text(''); + $('#bc_insulinover').text(''); + $('#bc_carbsneededtr').css('display','none'); + $('#bc_insulinneededtr').css('display','none'); + $('#bc_calculationintarget').css('display',''); + } else if (record.insulin<0) { + $('#bc_carbsneeded').text(record.carbsneeded+' g'); + $('#bc_insulinover').text(record.insulin.toFixed(2)); + $('#bc_carbsneededtr').css('display',''); + $('#bc_insulinneededtr').css('display','none'); + $('#bc_calculationintarget').css('display','none'); + } else { + $('#bc_carbsneeded').text(''); + $('#bc_insulinover').text(''); + $('#bc_carbsneededtr').css('display','none'); + $('#bc_insulinneededtr').css('display',''); + $('#bc_calculationintarget').css('display','none'); + } + + // Show basal rate + var basal = client.sbx.data.profile.getTempBasal(record.eventTime); + var tempMark = ''; + tempMark += basal.treatment ? 'T' : ''; + tempMark += basal.combobolustreatment ? 'C' : ''; + tempMark += tempMark ? ': ' : ''; + $('#bc_basal').text(tempMark + basal.totalbasal.toFixed(3)); + }; + + boluscalc.gatherBoluscalcData = function gatherBoluscalcData() { + boluscalc.record = {}; + var record = boluscalc.record; + + if (!client.sbx) { + console.log('No sandbox data yet. Exiting gatherBoluscalcData()'); + return; + } + + record.profile = $('#bc_profile').val(); + if (!record.profile) { + delete record.profile; + console.log('No profile data. Exiting gatherBoluscalcData()'); + return; + } + + + // Calculate event time from date & time + record.eventTime = new Date(); + if ($('#bc_othertime').is(':checked')) { + record.eventTime = mergeDateAndTime().toDate(); + } + + // Load profile + var targetBGLow = client.sbx.data.profile.getLowBGTarget(record.eventTime, record.profile); + targetBGLow = targetBGLow || 0; + var targetBGHigh = client.sbx.data.profile.getHighBGTarget(record.eventTime, record.profile); + targetBGHigh = targetBGHigh || 0; + var isf = client.sbx.data.profile.getSensitivity(record.eventTime, record.profile); + isf = isf || 0; + var ic = client.sbx.data.profile.getCarbRatio(record.eventTime, record.profile); + ic = ic || 0; + record.targetBGLow = targetBGLow; + record.targetBGHigh = targetBGHigh; + record.isf = isf; + record.ic = ic; + + if (targetBGLow === 0 || targetBGHigh === 0 || isf === 0 || ic === 0) { + $('#bc_inzulinbgtd').css('background-color','red'); + boluscalc.record = {}; + return; + } else { + $('#bc_inzulinbgtd').css('background-color',''); + } + + if (ic === 0) { + $('#bc_inzulincarbstd').css('background-color','red'); + boluscalc.record = {}; + return; + } else { + $('#bc_inzulincarbstd').css('background-color',''); + } + + // Load IOB + record.iob = 0; + if ($('#bc_useiob').is(':checked')) { + record.iob = roundTo(iob.calcTotal(client.sbx.data.treatments, client.sbx.data.devicestatus, client.sbx.data.profile, record.eventTime, record.profile).iob, 0.01); + } + + // Load COB + record.cob = 0; + record.insulincob = 0; + if ($('#bc_usecob').is(':checked')) { + record.cob = roundTo(cob.cobTotal(client.sbx.data.treatments, client.sbx.data.devicestatus, client.sbx.data.profile, record.eventTime, record.profile).cob, 0.01); + record.insulincob = roundTo(record.cob / ic, 0.01); + } + + // Load BG + record.bg = 0; + record.insulinbg = 0; + record.bgdiff = 0; + if ($('#bc_usebg').is(':checked')) { + record.bg = parseFloat($('#bc_bg').val().replace(',','.')); + if (isNaN(record.bg)) { + record.bg = 0; + } + if (record.bg <= targetBGLow) { + record.bgdiff = record.bg - targetBGLow; + } else if (record.bg >= targetBGHigh) { + record.bgdiff = record.bg - targetBGHigh; + } + record.bgdiff = roundTo(record.bgdiff, 0.1); + if (record.bg !== 0){ + record.insulinbg = roundTo(record.bgdiff / isf, 0.01); + } + } + + // Load foods + record.carbs = 0; + record.foods = _.cloneDeep(foods); + if (record.foods.length) { + var gisum = 0; + for (var fi=0; fi= 0) { + var qp = quickpicks[qpiselected]; + if (qp.hideafteruse) { + qp.hidden = true; + + var apisecrethash = localStorage.getItem('apisecrethash'); + var dataJson = JSON.stringify(qp, null, ' '); + + var xhr = new XMLHttpRequest(); + xhr.open('PUT', '/api/v1/food/', true); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + xhr.setRequestHeader('api-secret', apisecrethash); + xhr.send(dataJson); + } + } + + boluscalc.calculateInsulin(); + maybePrevent(event); + } + + var categories = []; + var foodlist = []; + var databaseloaded = false; + var filter = { + category: '' + , subcategory: '' + , name: '' + }; + + boluscalc.loadFoodDatabase = function loadFoodDatabase(event, callback) { + categories = []; + foodlist = []; + $.ajax('/api/v1/food/regular.json', { + headers: client.headers() + , success: function (records) { + records.forEach(function (r) { + foodlist.push(r); + if (r.category && !categories[r.category]) { + categories[r.category] = {}; + } + if (r.category && r.subcategory) { + categories[r.category][r.subcategory] = true; + } + }); + databaseloaded = true; + console.log('Food database loaded'); + fillForm(); + } + }).done(function() { if (callback) { callback(); } }); + maybePrevent(event); + }; + + boluscalc.loadFoodQuickpicks = function loadFoodQuickpicks( ) { + // Load quickpicks + $.ajax('/api/v1/food/quickpicks.json', { + headers: client.headers() + , success: function (records) { + quickpicks = records; + $('#bc_quickpick').empty().append(''); + for (var i=0; i' + r.name + ' (' + r.carbs + ' g)'); + }; + $('#bc_quickpick').val(-1); + $('#bc_quickpick').change(quickpickChange); + } + }); + }; + + function fillForm(event) { + $('#bc_filter_category').empty().append(''); + Object.keys(categories).forEach( function eachCategory(s) { + $('#bc_filter_category').append(''); + }); + filter.category = ''; + fillSubcategories(); + + $('#bc_filter_category').change(fillSubcategories); + $('#bc_filter_subcategory').change(doFilter); + $('#bc_filter_name').on('input',doFilter); + + maybePrevent(event); + return false; + } + + function fillSubcategories(event) { + maybePrevent(event); + filter.category = $('#bc_filter_category').val(); + filter.subcategory = ''; + $('#bc_filter_subcategory').empty().append(''); + if (filter.category !== '') { + Object.keys(categories[filter.category]).forEach( function eachSubcategory(s) { + $('#bc_filter_subcategory').append(''); + }); + } + doFilter(); + } + + function doFilter(event) { + if (event) { + filter.category = $('#bc_filter_category').val(); + filter.subcategory = $('#bc_filter_subcategory').val(); + filter.name = $('#bc_filter_name').val(); + } + $('#bc_data').empty(); + for (var i=0; i' + o + ''); + } + $('#bc_addportions').val('1'); + + maybePrevent(event); + } + + function addFoodFromDatabase(event) { + if (!databaseloaded) { + boluscalc.loadFoodDatabase(event, addFoodFromDatabase); + return; + } + + $('#bc_addportions').val('1'); + $('#bc_addfooddialog').dialog({ + width: 640 + , height: 400 + , buttons: [ + { text: translate('Add'), + click: function() { + var index = $('#bc_data').val(); + var portions = parseFloat($('#bc_addportions').val().replace(',','.')); + if (index !== null && !isNaN(portions) && portions >0) { + foodlist[index].portions = portions; + foods.push(_.cloneDeep(foodlist[index])); + $( this ).dialog( 'close' ); + boluscalc.calculateInsulin(); + } + } + }, + { text: translate('Reload database'), + class: 'leftButton', + click: boluscalc.loadFoodDatabase + } + ] + , open : function() { + $(this).parent().css('box-shadow', '20px 20px 20px 0px black'); + $(this).parent().find('.ui-dialog-buttonset' ).css({'width':'100%','text-align':'right'}); + $(this).parent().find('button:contains("'+translate('Add')+'")').css({'float':'left'}); + $('#bc_filter_name').focus(); + } + + }); + maybePrevent(event); + return false; + } + + function findClosestSGVToPastTime(time) { + var nowData = client.entries.filter(function(d) { + return d.type === 'sgv' && d.mills <= time.getTime(); + }); + var focusPoint = _.last(nowData); + + if (!focusPoint || focusPoint.mills + times.mins(10).mills < time.getTime()) { + return null; + } + return focusPoint; + } + + if (isTouch()) { + // Make it faster on mobile devices + $('.insulincalculationpart').change(boluscalc.calculateInsulin); + } else { + $('.insulincalculationpart').on('input',boluscalc.calculateInsulin); + $('input:checkbox.insulincalculationpart').change(boluscalc.calculateInsulin); + } + $('#bc_bgfrommeter').change(boluscalc.calculateInsulin); + $('#bc_addfromdatabase').click(addFoodFromDatabase); + $('#bc_bgfromsensor').change(function bc_bgfromsensor_click(event) { + boluscalc.updateVisualisations(client.sbx); + boluscalc.calculateInsulin(); + maybePrevent(event); + }); + + $('#boluscalcDrawerToggle').click(boluscalc.toggleDrawer); + $('#boluscalcDrawer').find('button').click(boluscalc.submit); + $('#bc_eventTime input:radio').change(boluscalc.eventTimeTypeChange); + + $('.bc_eventtimeinput').focus(boluscalc.dateTimeFocus).change(boluscalc.dateTimeChange); + + boluscalc.loadFoodQuickpicks(); + setDateAndTime(); + + return boluscalc; +} + +module.exports = init; diff --git a/lib/client/browser-settings.js b/lib/client/browser-settings.js index 30e4bb79edb..070a82948a7 100644 --- a/lib/client/browser-settings.js +++ b/lib/client/browser-settings.js @@ -2,7 +2,12 @@ var _ = require('lodash'); -function init (client, plugins, serverSettings, $) { +// VERSION 1 - 0.9.0 - 2015-Nov-07 - initial version +var STORAGE_VERSION = 1; + +function init (client, serverSettings, $) { + + serverSettings = serverSettings || {settings: {}}; var storage = $.localStorage; var settings = require('../settings')(); @@ -32,6 +37,7 @@ function init (client, plugins, serverSettings, $) { $('#alarm-timeagourgentmins-browser').val(settings.alarmTimeagoUrgentMins); $('#nightmode-browser').prop('checked', settings.nightMode); + $('#editmode-browser').prop('checked', settings.editMode); if (settings.isEnabled('rawbg')) { $('#show-rawbg-option').show(); @@ -45,6 +51,8 @@ function init (client, plugins, serverSettings, $) { if (settings.theme === 'colors') { $('#theme-colors-browser').prop('checked', true); + } else if (settings.theme === 'colorblindfriendly') { + $('#theme-colorblindfriendly-browser').prop('checked', true); } else { $('#theme-default-browser').prop('checked', true); } @@ -69,8 +77,8 @@ function init (client, plugins, serverSettings, $) { var showPluginsSettings = $('#show-plugins'); var hasPluginsToShow = false; - plugins.eachEnabledPlugin(function each(plugin) { - if (plugins.specialPlugins.indexOf(plugin.name) > -1) { + client.plugins.eachEnabledPlugin(function each(plugin) { + if (client.plugins.specialPlugins.indexOf(plugin.name) > -1) { //ignore these, they are always on for now } else { var id = 'plugin-' + plugin.name; @@ -125,6 +133,7 @@ function init (client, plugins, serverSettings, $) { alarmTimeagoUrgent: $('#alarm-timeagourgent-browser').prop('checked'), alarmTimeagoUrgentMins: parseInt($('#alarm-timeagourgentmins-browser').val()) || 30, nightMode: $('#nightmode-browser').prop('checked'), + editMode: $('#editmode-browser').prop('checked'), showRawbg: $('input:radio[name=show-rawbg]:checked').val(), customTitle: $('input#customTitle').prop('value'), theme: $('input:radio[name=theme-browser]:checked').val(), @@ -132,7 +141,8 @@ function init (client, plugins, serverSettings, $) { language: $('#language').val(), scaleY: $('#scaleY').val(), basalrender: $('#basalrender').val(), - showPlugins: checkedPluginNames() + showPlugins: checkedPluginNames(), + storageVersion: STORAGE_VERSION }); event.preventDefault(); @@ -146,7 +156,23 @@ function init (client, plugins, serverSettings, $) { $('#save').hide(); } - settings.extendedSettings = serverSettings.extendedSettings; + function handleStorageVersions ( ) { + var previousVersion = parseInt(storage.get('storageVersion')); + + //un-versioned settings + if (isNaN(previousVersion)) { + //special showPlugins handling for careportal + //prevent careportal from being hidden by old stored settings + if (settings.isEnabled('careportal')) { + var storedShowPlugins = storage.get('showPlugins'); + if (storedShowPlugins && storedShowPlugins.indexOf('careportal') === -1) { + settings.showPlugins += ' careportal'; + } + } + } + } + + settings.extendedSettings = serverSettings.extendedSettings || {settings: {}}; try { settings.eachSetting(function setEach (name) { @@ -154,6 +180,18 @@ function init (client, plugins, serverSettings, $) { return stored !== undefined && stored !== null ? stored : serverSettings.settings[name]; }); + if (serverSettings.settings.thresholds) { + settings.thresholds = serverSettings.settings.thresholds; + } + + if (serverSettings.settings.enable) { + settings.enable = serverSettings.settings.enable; + } + + if (settings.enable.indexOf('ar2') < 0) { + settings.enable += ' ar2'; + } + handleStorageVersions(); if (!settings.extendedSettings.basal) { settings.extendedSettings.basal = {}; } @@ -165,14 +203,7 @@ function init (client, plugins, serverSettings, $) { showLocalstorageError(); } - settings.thresholds = serverSettings.settings.thresholds; - settings.enable = serverSettings.settings.enable; - - if (settings.enable.indexOf('ar2') < 0) { - settings.enable += ' ar2'; - } - - plugins.init(settings); + client.plugins.init(settings); loadForm(); wireForm(); @@ -181,4 +212,4 @@ function init (client, plugins, serverSettings, $) { } -module.exports = init; \ No newline at end of file +module.exports = init; diff --git a/lib/client/browser-utils.js b/lib/client/browser-utils.js index d39b501a2c3..6f6df6d0a7d 100644 --- a/lib/client/browser-utils.js +++ b/lib/client/browser-utils.js @@ -1,8 +1,9 @@ 'use strict'; -var lastOpenedDrawer = null; +var SMALL_SCREEN = 500; function init ($) { + var lastOpenedDrawer = null; // Tooltips can remain in the way on touch screens. if (!isTouch()) { @@ -64,6 +65,14 @@ function init ($) { catch (e) { return false; } } + function closeLastOpenedDrawer (callback) { + if (lastOpenedDrawer) { + closeDrawer(lastOpenedDrawer, callback); + } else if (callback) { + callback(); + } + } + function closeDrawer(id, callback) { lastOpenedDrawer = null; $('html, body').css({ scrollTop: 0 }); @@ -83,7 +92,30 @@ function init ($) { closeOpenDraw(function () { lastOpenedDrawer = id; if (prepare) { prepare(); } - $(id).css({display:'block', right: '0'}); + + var style = {display:'block', right: '0'}; + + var windowWidth = $(window).width(); + var windowHeight = $(window).height(); + //var chartTop = $('#chartContainer').offset().top - 45; + //var chartHeight = windowHeight - chartTop - 45; + if (windowWidth < SMALL_SCREEN || (windowHeight < SMALL_SCREEN) && windowWidth < 800) { + style.top = '0px'; + style.height = windowHeight + 'px'; + style.width = windowWidth + 'px'; + //TODO: maybe detect iOS and do this, doesn't work good with android + //if (chartHeight > windowHeight * 0.4) { + // style.top = chartTop + 'px'; + // style.height = chartHeight + 'px'; + //} + } else { + style.top = '0px'; + style.height = (windowHeight - 45) + 'px'; + style.width = '350px'; + } + + + $(id).css(style); }); } @@ -112,17 +144,25 @@ function init ($) { notify.addClass(type ? type : 'urgent'); notify.find('span').html(note); - notify.css('left', 'calc(50% - ' + (notify.width() / 2) + 'px)'); + var windowWidth = $(window).width(); + var left = (windowWidth - notify.width()) / 2; + notify.css('left', left + 'px'); notify.show(); } + function getLastOpenedDrawer() { + return lastOpenedDrawer; + } + return { reload: reload , queryParms: queryParms , closeDrawer: closeDrawer + , closeLastOpenedDrawer: closeLastOpenedDrawer , toggleDrawer: toggleDrawer , closeNotification: closeNotification , showNotification: showNotification + , getLastOpenedDrawer: getLastOpenedDrawer }; } diff --git a/lib/client/careportal.js b/lib/client/careportal.js index 30d87098e9f..fd84b401784 100644 --- a/lib/client/careportal.js +++ b/lib/client/careportal.js @@ -11,25 +11,11 @@ function init (client, $) { var translate = client.translate; var storage = $.localStorage; - careportal.events = [ - { val: '', name: '' } - , { val: 'BG Check', name: 'BG Check' } - , { val: 'Snack Bolus', name: 'Snack Bolus' } - , { val: 'Meal Bolus', name: 'Meal Bolus' } - , { val: 'Correction Bolus', name: 'Correction Bolus' } - , { val: 'Carb Correction', name: 'Carb Correction' } - , { val: 'Announcement', name: 'Announcement' } - , { val: 'Note', name: 'Note' } - , { val: 'Question', name: 'Question' } - , { val: 'Exercise', name: 'Exercise' } - , { val: 'Site Change', name: 'Pump Site Change' } - , { val: 'Sensor Start', name: 'Dexcom Sensor Start' } - , { val: 'Sensor Change', name: 'Dexcom Sensor Change' } - , { val: 'Insulin Change', name: 'Insulin Cartridge Change' } - , { val: 'Temp Basal Start', name: 'Temp Basal Start' } - , { val: 'Temp Basal End', name: 'Temp Basal End' } - , { val: 'D.A.D. Alert', name: 'D.A.D. Alert' } - ]; + careportal.allEventTypes = client.plugins.getAllEventTypes(client.sbx); + + careportal.events = _.map(careportal.allEventTypes, function each (event) { + return _.pick(event, ['val', 'name']); + }); var eventTime = $('#eventTimeValue'); var eventDate = $('#eventDateValue'); @@ -55,27 +41,13 @@ function init (client, $) { } } - careportal.filterInputs = function filterInputs ( event ) { - var inputMatrix = { - '': { bg: true, insulin: true, carbs: true, prebolus: false, duration: false, percent: false, absolute: false } - , 'BG Check': { bg: true, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'Snack Bolus': { bg: true, insulin: true, carbs: true, prebolus: true, duration: false, percent: false, absolute: false } - , 'Meal Bolus': { bg: true, insulin: true, carbs: true, prebolus: true, duration: false, percent: false, absolute: false } - , 'Correction Bolus': { bg: true, insulin: true, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'Carb Correction': { bg: true, insulin: false, carbs: true, prebolus: false, duration: false, percent: false, absolute: false } - , 'Announcement': { bg: true, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'Note': { bg: true, insulin: false, carbs: false, prebolus: false, duration: true, percent: false, absolute: false } - , 'Question': { bg: true, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'Exercise': { bg: false, insulin: false, carbs: false, prebolus: false, duration: true, percent: false, absolute: false } - , 'Site Change': { bg: true, insulin: true, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'Sensor Start': { bg: true, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'Sensor Change': { bg: true, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'Insulin Change': { bg: true, insulin: true, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'Temp Basal Start': { bg: true, insulin: false, carbs: false, prebolus: false, duration: true, percent: true, absolute: true } - , 'Temp Basal End': { bg: true, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - , 'D.A.D. Alert': { bg: true, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } - }; + var inputMatrix = {}; + + _.forEach(careportal.allEventTypes, function each (event) { + inputMatrix[event.val] = _.pick(event, ['bg', 'insulin', 'carbs', 'prebolus', 'duration', 'percent', 'absolute', 'profile', 'split', 'reasons', 'targets']); + }); + careportal.filterInputs = function filterInputs ( event ) { var eventType = $('#eventType').val(); function displayType (enabled) { @@ -91,36 +63,99 @@ function init (client, $) { $(id).val(''); } } - + + var reasons = inputMatrix[eventType]['reasons']; + $('#reasonLabel').css('display',displayType(reasons && reasons.length > 0)); + $('#targets').css('display',displayType(inputMatrix[eventType]['targets'])); + $('#bg').css('display',displayType(inputMatrix[eventType]['bg'])); $('#insulinGivenLabel').css('display',displayType(inputMatrix[eventType]['insulin'])); $('#carbsGivenLabel').css('display',displayType(inputMatrix[eventType]['carbs'])); $('#durationLabel').css('display',displayType(inputMatrix[eventType]['duration'])); $('#percentLabel').css('display',displayType(inputMatrix[eventType]['percent'] && $('#absolute').val() === '')); $('#absoluteLabel').css('display',displayType(inputMatrix[eventType]['absolute'] && $('#percent').val() === '')); + $('#profileLabel').css('display',displayType(inputMatrix[eventType]['profile'])); $('#preBolusLabel').css('display',displayType(inputMatrix[eventType]['prebolus'])); - + $('#insulinSplitLabel').css('display',displayType(inputMatrix[eventType]['split'])); + + $('#reason').empty(); + _.each(reasons, function eachReason (reason) { + $('#reason').append(''); + }); + + careportal.reasonable(); + resetIfHidden(inputMatrix[eventType]['insulin'], '#insulinGiven'); resetIfHidden(inputMatrix[eventType]['carbs'], '#carbsGiven'); resetIfHidden(inputMatrix[eventType]['duration'], '#duration'); resetIfHidden(inputMatrix[eventType]['absolute'], '#absolute'); resetIfHidden(inputMatrix[eventType]['percent'], '#percent'); resetIfHidden(inputMatrix[eventType]['prebolus'], '#preBolus'); - + resetIfHidden(inputMatrix[eventType]['split'], '#insulinSplitNow'); + resetIfHidden(inputMatrix[eventType]['split'], '#insulinSplitExt'); + maybePrevent(event); }; + careportal.reasonable = function reasonable ( ) { + var eventType = $('#eventType').val(); + var reasons = inputMatrix[eventType]['reasons']; + var selected = $('#reason').val(); + + var reason = _.find(reasons, function matches (r) { + return r.name === selected; + }); + + if (reason && reason.targetTop) { + $('#targetTop').val(reason.targetTop); + } else { + $('#targetTop').val(''); + } + + if (reason && reason.targetBottom) { + $('#targetBottom').val(reason.targetBottom); + } else { + $('#targetBottom').val(''); + } + + if (reason) { + if (reason.duration) { + $('#duration').val(reason.duration); + } else { + $('#duration').val(''); + } + } + }; + careportal.prepareEvents = function prepareEvents ( ) { $('#eventType').empty(); _.each(careportal.events, function eachEvent(event) { $('#eventType').append(''); }); $('#eventType').change(careportal.filterInputs); - $('#percentLabel').change(careportal.filterInputs); - $('#absoluteLabel').change(careportal.filterInputs); + $('#reason').change(careportal.reasonable); + $('#percent').on('input', careportal.filterInputs); + $('#absolute').on('input', careportal.filterInputs); + $('#insulinSplitNow').on('input', careportal.adjustSplit); + $('#insulinSplitExt').on('input', careportal.adjustSplit); careportal.filterInputs(); + careportal.adjustSplit(); }; + careportal.adjustSplit = function adjustSplit(event) { + if ($(this).attr('id') === 'insulinSplitNow') { + var nowval = parseInt($('#insulinSplitNow').val()) || 0; + $('#insulinSplitExt').val(100 - nowval); + $('#insulinSplitNow').val(nowval); + } else { + var extval = parseInt($('#insulinSplitExt').val()) || 0; + $('#insulinSplitNow').val(100 - extval); + $('#insulinSplitExt').val(extval); + } + + maybePrevent(event); + }; + careportal.resolveEventName = function resolveEventName(value) { _.each(careportal.events, function eachEvent(e) { if (e.val === value) { @@ -131,6 +166,10 @@ function init (client, $) { }; careportal.prepare = function prepare ( ) { + $('#profile').empty(); + client.profilefunctions.listBasalProfiles().forEach(function (p) { + $('#profile').append(''); + }); careportal.prepareEvents(); $('#eventType').val(''); $('#glucoseValue').val('').attr('placeholder', translate('Value in') + ' ' + client.settings.units); @@ -140,9 +179,10 @@ function init (client, $) { $('#duration').val(''); $('#percent').val(''); $('#absolute').val(''); + $('#profile').val(client.profilefunctions.activeProfileToTime()); $('#preBolus').val(0); $('#notes').val(''); - $('#enteredBy').val(storage.get('enteredBy') || ''); + $('#enteredBy').val(client.authorized ? client.authorized.sub : storage.get('enteredBy') || ''); $('#nowtime').prop('checked', true); setDateAndTime(); }; @@ -151,12 +191,16 @@ function init (client, $) { var data = { enteredBy: $('#enteredBy').val() , eventType: $('#eventType').val() - , glucose: $('#glucoseValue').val() + , glucose: $('#glucoseValue').val().replace(',','.') + , reason: $('#reason').val() + , targetTop: $('#targetTop').val().replace(',','.') + , targetBottom: $('#targetBottom').val().replace(',','.') , glucoseType: $('#treatment-form').find('input[name=glucoseType]:checked').val() , carbs: $('#carbsGiven').val() , insulin: $('#insulinGiven').val() , duration: times.msecs(parse_duration($('#duration').val())).mins < 1 ? $('#duration').val() : times.msecs(parse_duration($('#duration').val())).mins , percent: $('#percent').val() + , profile: $('#profile').val() , preBolus: parseInt($('#preBolus').val()) , notes: $('#notes').val() , units: client.settings.units @@ -172,10 +216,24 @@ function init (client, $) { data.eventTime = mergeDateAndTime().toDate(); } + if (!inputMatrix[data.eventType].profile) { + delete data.profile; + } + if (data.eventType.indexOf('Temp Basal') > -1) { data.eventType = 'Temp Basal'; } + if (data.eventType.indexOf('Temporary Target Cancel') > -1) { + data.duration = 0; + data.eventType = 'Temporary Target'; + } + + if (data.eventType.indexOf('Combo Bolus') > -1) { + data.splitNow = parseInt($('#insulinSplitNow').val()) || 0; + data.splitExt = parseInt($('#insulinSplitExt').val()) || 0; + } + return data; } @@ -197,14 +255,24 @@ function init (client, $) { } } + if (data.duration === 0 && data.eventType === 'Temporary Target') { + text[text.length - 1] += ' ' + translate('Cancel'); + } + pushIf(data.glucose, translate('Blood Glucose') + ': ' + data.glucose); pushIf(data.glucose, translate('Measurement Method') + ': ' + translate(data.glucoseType)); + pushIf(data.reason, translate('Reason') + ': ' + data.reason); + pushIf(data.targetTop, translate('Target Top') + ': ' + data.targetTop); + pushIf(data.targetBottom, translate('Target Bottom') + ': ' + data.targetBottom); + pushIf(data.carbs, translate('Carbs Given') + ': ' + data.carbs); pushIf(data.insulin, translate('Insulin Given') + ': ' + data.insulin); - pushIf(data.duration, translate('Duration') + ': ' + data.duration); + pushIf(data.eventType === 'Combo Bolus', translate('Combo Bolus') + ': ' + data.splitNow + '% : ' + data.splitExt + '%'); + pushIf(data.duration, translate('Duration') + ': ' + data.duration + ' ' + translate('mins')); pushIf(data.percent, translate('Percent') + ': ' + data.percent); - pushIf(data.absolute, translate('Basal value') + ': ' + data.absolute); + pushIf('absolute' in data, translate('Basal value') + ': ' + data.absolute); + pushIf(data.profile, translate('Profile') + ': ' + data.profile); pushIf(data.preBolus, translate('Carb Time') + ': ' + data.preBolus + ' ' + translate('mins')); pushIf(data.notes, translate('Notes') + ': ' + data.notes); pushIf(data.enteredBy, translate('Entered By') + ': ' + data.enteredBy); @@ -215,26 +283,34 @@ function init (client, $) { function confirmPost(data) { if (window.confirm(buildConfirmText(data))) { - $.ajax({ - method: 'POST', - url: '/api/v1/treatments/' - , headers: { - 'api-secret': client.hashauth.hash() - } - , data: data - }).done(function treatmentSaved (response) { - console.info('treatment saved', response); - }).fail(function treatmentSaveFail (response) { - console.info('treatment saved', response); - alert(translate('Entering record failed') + '. ' + translate('Status') + ': ' + response.status); - }); - - storage.set('enteredBy', data.enteredBy); - - client.browserUtils.closeDrawer('#treatmentDrawer'); + postTreatment(data); } } + function postTreatment(data) { + if (data.eventType === 'Combo Bolus') { + data.enteredinsulin = data.insulin; + data.insulin = data.enteredinsulin * data.splitNow / 100; + data.relative = data.enteredinsulin * data.splitExt / 100 / data.duration * 60; + } + + $.ajax({ + method: 'POST' + , url: '/api/v1/treatments/' + , headers: client.headers() + , data: data + }).done(function treatmentSaved (response) { + console.info('treatment saved', response); + }).fail(function treatmentSaveFail (response) { + console.info('treatment saved', response); + alert(translate('Entering record failed') + '. ' + translate('Status') + ': ' + response.status); + }); + + storage.set('enteredBy', data.enteredBy); + + client.browserUtils.closeDrawer('#treatmentDrawer'); + } + careportal.dateTimeFocus = function dateTimeFocus (event) { $('#othertime').prop('checked', true); updateTime($(this), mergeDateAndTime()); diff --git a/lib/client/chart.js b/lib/client/chart.js index c4512c7cf55..a2473cd2fa3 100644 --- a/lib/client/chart.js +++ b/lib/client/chart.js @@ -1,12 +1,9 @@ 'use strict'; -var _ = require('lodash'); +// var _ = require('lodash'); var times = require('../times'); -var d3locales = require('../d3locales'); - -var DEBOUNCE_MS = 10 - , padding = { bottom: 30 } - ; +var d3locales = require('./d3locales'); +var padding = { bottom: 30 }; function init (client, d3, $) { var chart = { }; @@ -14,19 +11,52 @@ function init (client, d3, $) { var utils = client.utils; var renderer = client.renderer; + var defs = d3.select('body').append('svg').append('defs'); + + // add defs for combo boluses + var dashWidth = 5; + defs.append('pattern') + .attr('id', 'hash') + .attr('patternUnits', 'userSpaceOnUse') + .attr('width', 6) + .attr('height', 6) + .attr('x', 0) + .attr('y', 0) + .append('g') + .style('fill', 'none') + .style('stroke', '#0099ff') + .style('stroke-width', 2) + .append('path').attr('d', 'M0,0 l' + dashWidth + ',' + dashWidth) + .append('path').attr('d', 'M' + dashWidth + ',0 l-' + dashWidth + ',' + dashWidth); + + // arrow head + defs.append('marker') + .attr({ + 'id': 'arrow', + 'viewBox': '0 -5 10 10', + 'refX': 5, + 'refY': 0, + 'markerWidth': 8, + 'markerHeight': 8, + 'orient': 'auto' + }) + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .attr('class', 'arrowHead'); + var localeFormatter = d3.locale(d3locales.locale(client.settings.language)); function brushStarted ( ) { // update the opacity of the context data points to brush extent chart.context.selectAll('circle') - .data(client.data) + .data(client.entries) .style('opacity', 1); } function brushEnded ( ) { // update the opacity of the context data points to brush extent chart.context.selectAll('circle') - .data(client.data) + .data(client.entries) .style('opacity', function (d) { return renderer.highlightBrushPoints(d) }); } @@ -45,7 +75,7 @@ function init (client, d3, $) { function dynamicDomain() { var mult = 1.3 , targetTop = client.settings.thresholds.bgTargetTop - , mgdlMax = d3.max(client.data, function (d) { return d.mgdl; }); + , mgdlMax = d3.max(client.entries, function (d) { return d.mgdl; }); return [ utils.scaleMgdl(30) @@ -80,7 +110,7 @@ function init (client, d3, $) { var tickFormat = localeFormatter.timeFormat.multi( [ ['.%L', function(d) { return d.getMilliseconds(); }], [':%S', function(d) { return d.getSeconds(); }], - ['%I:%M', function(d) { return d.getMinutes(); }], + [client.settings.timeFormat === 24 ? '%H:%M' : '%I:%M', function(d) { return d.getMinutes(); }], [client.settings.timeFormat === 24 ? '%H:%M' : '%-I %p', function(d) { return d.getHours(); }], ['%a %d', function(d) { return d.getDay() && d.getDate() !== 1; }], ['%b %d', function(d) { return d.getDate() !== 1; }], @@ -118,7 +148,10 @@ function init (client, d3, $) { chart.brush = d3.svg.brush() .x(xScale2) .on('brushstart', brushStarted) - .on('brush', client.brushed) + .on('brush', function brush (time) { + client.loadRetroIfNeeded(); + client.brushed(time); + }) .on('brushend', brushEnded); chart.futureOpacity = d3.scale.linear( ) @@ -131,9 +164,9 @@ function init (client, d3, $) { .attr('class', 'chartContainer'); chart.basals = chart.charts.append('g').attr('class', 'chart-basals'); - chart.basals.attr('display','none'); chart.focus = chart.charts.append('g').attr('class', 'chart-focus'); + chart.drag = chart.focus.append('g').attr('class', 'drag-area'); // create the x axis container chart.focus.append('g') @@ -178,7 +211,7 @@ function init (client, d3, $) { }; // called for initial update and updates for resize - chart.update = _.debounce(function debouncedUpdateChart(init) { + chart.update = function update(init) { if (client.documentHidden && !init) { console.info('Document Hidden, not updating - ' + (new Date())); @@ -207,7 +240,13 @@ function init (client, d3, $) { var currentBrushExtent = createAdjustedRange(); // only redraw chart if chart size has changed - if ((chart.prevChartWidth !== chartWidth) || (chart.prevChartHeight !== chartHeight)) { + var widthChanged = (chart.prevChartWidth !== chartWidth); + if (widthChanged || (chart.prevChartHeight !== chartHeight)) { + + //if rotated + if (widthChanged) { + client.browserUtils.closeLastOpenedDrawer(); + } chart.prevChartWidth = chartWidth; chart.prevChartHeight = chartHeight; @@ -489,7 +528,7 @@ function init (client, d3, $) { // update x axis domain chart.context.select('.x').call(chart.xAxis2); - }, DEBOUNCE_MS); + }; chart.scroll = function scroll (nowDate) { chart.xScale.domain(createAdjustedRange()); @@ -542,20 +581,12 @@ function init (client, d3, $) { renderer.addFocusCircles(); renderer.addTreatmentCircles(); - - // add treatment bubbles - chart.focus.selectAll('circle') - .data(client.treatments) - .each(function (d) { - renderer.drawTreatment(d, { - scale: renderer.bubbleScale() - , showLabels: true - }); - }); + renderer.addTreatmentProfiles(client); + renderer.drawTreatments(client); }; return chart; } -module.exports = init; \ No newline at end of file +module.exports = init; diff --git a/lib/d3locales.js b/lib/client/d3locales.js similarity index 100% rename from lib/d3locales.js rename to lib/client/d3locales.js diff --git a/lib/client/index.js b/lib/client/index.js index ce9bfb670c0..5bc1ce9a0a9 100644 --- a/lib/client/index.js +++ b/lib/client/index.js @@ -1,8 +1,10 @@ 'use strict'; +'use strict'; var _ = require('lodash'); var $ = (global && global.$) || require('jquery'); var d3 = (global && global.d3) || require('d3'); +var shiroTrie = require('shiro-trie'); var language = require('../language')(); var sandbox = require('../sandbox')(); @@ -10,10 +12,72 @@ var profile = require('../profilefunctions')(); var units = require('../units')(); var levels = require('../levels'); var times = require('../times'); +var receiveDData = require('./receiveddata'); var client = { }; -client.init = function init(serverSettings, plugins) { +client.hashauth = require('../hashauth').init(client, $); + +client.headers = function headers ( ) { + if (client.authorized) { + return { + Authorization: 'Bearer ' + client.authorized.token + }; + } else if (client.hashauth) { + return { + 'api-secret': client.hashauth.hash() + }; + } else { + return { }; + } +}; + +client.init = function init(plugins, callback) { + + client.plugins = plugins = client.plugins || plugins; + client.rawbg = plugins('rawbg'); + client.delta = plugins('delta'); + client.timeago = plugins('timeago'); + client.direction = plugins('direction'); + client.errorcodes = plugins('errorcodes'); + client.browserUtils = require('./browser-utils')($); + + var token = client.browserUtils.queryParms().token; + var secret = client.hashauth.apisecrethash || $.localStorage.get('apisecrethash'); + + var src = '/api/v1/status.json?t=' + new Date().getTime(); + + if (secret) { + src += '&secret=' + secret; + } else if (token) { + src += '&token=' + token; + } + + $.ajax({ + method: 'GET' + , url: src + , headers: client.headers() + }).done(function success (serverSettings) { + client.settingsFailed = false; + client.load(serverSettings, callback); + }).fail(function fail( ) { + //no server setting available, use defaults, auth, etc + if (client.settingsFailed) { + console.info('Already tried to get settings after auth, but failed'); + } else { + client.settingsFailed = true; + language.set('en'); + client.translate = language.translate; + + client.hashauth.requestAuthentication(function afterRequest ( ) { + client.init(null, callback); + }); + } + }); + +}; + +client.load = function load(serverSettings, callback) { var UPDATE_TRANS_MS = 750 // milliseconds , FORMAT_TIME_12 = '%-I:%M %p' @@ -23,79 +87,146 @@ client.init = function init(serverSettings, plugins) { , FORMAT_TIME_24_SCALE = '%H' ; + var history = 48; + var chart , socket , isInitialData = false - , SGVdata = [] - , MBGdata = [] - , latestUpdateTime , prevSGV - , devicestatusData , opacity = {current: 1, DAY: 1, NIGHT: 0.5} , clientAlarms = {} , alarmInProgress = false , alarmMessage - , currentAlarmType = null + , currentNotify , currentAnnouncement , alarmSound = 'alarm.mp3' , urgentAlarmSound = 'alarm2.mp3' ; - client.entryToDate = function entryToDate (entry) { return new Date(entry.mills) }; + client.entryToDate = function entryToDate (entry) { return new Date(entry.mills); }; client.now = Date.now(); + client.ddata = require('../data/ddata')(); client.forecastTime = times.mins(30).msecs; - client.data = []; - client.browserUtils = require('./browser-utils')($); - client.settings = require('./browser-settings')(client, plugins, serverSettings, $); - client.utils = require('../utils')(client.settings); + client.entries = []; client.ticks = require('./ticks'); - client.sbx = sandbox.clientInit(client.settings, client.now); - client.rawbg = plugins('rawbg'); - client.delta = plugins('delta'); - client.direction = plugins('direction'); - client.errorcodes = plugins('errorcodes'); - - language.set(client.settings.language).DOMtranslate($); - client.translate = language.translate; - - client.hashauth = require('../hashauth'); - client.hashauth.init(client, $).initAuthentication(); + //containers + var container = $('.container') + , bgStatus = $('.bgStatus') + , currentBG = $('.bgStatus .currentBG') + , majorPills = $('.bgStatus .majorPills') + , minorPills = $('.bgStatus .minorPills') + , statusPills = $('.status .statusPills') + , primary = $('.primary') + , editButton = $('#editbutton') + ; client.tooltip = d3.select('body').append('div') .attr('class', 'tooltip') .style('opacity', 0); - client.foucusRangeMS = times.hours(3).msecs; + client.settings = require('./browser-settings')(client, serverSettings, $); + + client.ctx = { + data: {} + , bus: require('../bus')(client.settings, client.ctx) + , settings: client.settings + , notifications: require('../notifications')(client.settings, client.ctx) + , pluginBase: client.plugins.base(majorPills, minorPills, statusPills, bgStatus, client.tooltip, $.localStorage) + }; + + client.utils = require('../utils')(client.ctx.settings); + + client.sbx = sandbox.clientInit(client.ctx, client.now); + + language.set(client.settings.language).DOMtranslate($); + client.translate = language.translate; + client.language = language; + client.renderer = require('./renderer')(client, d3, $); + + if (serverSettings && serverSettings.authorized) { + client.authorized = serverSettings.authorized; + client.authorized.lat = Date.now(); + client.authorized.shiros = _.map(client.authorized.permissionGroups, function toShiro (group) { + var shiro = shiroTrie.new(); + _.forEach(group, function eachPermission (permission) { + shiro.add(permission); + }); + return shiro; + }); + + client.authorized.check = function check (permission) { + var found = _.find(client.authorized.shiros, function checkEach (shiro) { + return shiro.check(permission); + }); + + return _.isObject(found); + }; + } + + client.afterAuth = function afterAuth (isAuthenticated) { + + var treatmentCreateAllowed = client.authorized ? client.authorized.check('api:treatments:create') : isAuthenticated; + var treatmentUpdateAllowed = client.authorized ? client.authorized.check('api:treatments:update') : isAuthenticated; + + $('#lockedToggle').click(client.hashauth.requestAuthentication).toggle(!treatmentCreateAllowed && client.settings.showPlugins.indexOf('careportal') > -1); + $('#treatmentDrawerToggle').toggle(treatmentCreateAllowed && client.settings.showPlugins.indexOf('careportal') > -1); + $('#boluscalcDrawerToggle').toggle(treatmentCreateAllowed && client.settings.showPlugins.indexOf('boluscalc') > -1); + + // Edit mode + editButton.toggle(client.settings.editMode && treatmentUpdateAllowed); + editButton.click(function editModeClick (event) { + client.editMode = !client.editMode; + if (client.editMode) { + client.renderer.drawTreatments(client); + editButton.find('i').addClass('selected'); + } else { + chart.focus.selectAll('.draggable-treatment') + .style('cursor', 'default') + .on('mousedown.drag', null); + editButton.find('i').removeClass('selected'); + } + if (event) { event.preventDefault(); } + }); + }; + + client.hashauth.initAuthentication(client.afterAuth); + + client.foucusRangeMS = times.hours(client.settings.focusHours).msecs; + $('.focus-range li[data-hours=' + client.settings.focusHours + ']').addClass('selected'); client.brushed = brushed; client.formatTime = formatTime; client.dataUpdate = dataUpdate; - client.renderer = require('./renderer')(client, d3, $); client.careportal = require('./careportal')(client, $); + client.boluscalc = require('./boluscalc')(client, $); - var timeAgo = client.utils.timeAgo; + client.profilefunctions = profile; + + client.editMode = false; - var container = $('.container') - , bgStatus = $('.bgStatus') - , currentBG = $('.bgStatus .currentBG') - , majorPills = $('.bgStatus .majorPills') - , minorPills = $('.bgStatus .minorPills') - , statusPills = $('.status .statusPills') - ; + //TODO: use the bus for updates and notifications + //client.ctx.bus.on('tick', function timedReload (tick) { + // console.info('tick', tick.now); + //}); + //broadcast 'tock' event each minute, start a new setTimeout each time it fires make it happen on the minute + //see updateClock + //start the bus after setting up listeners + //client.ctx.bus.uptime( ); client.dataExtent = function dataExtent ( ) { - return client.data.length > 0 ? - d3.extent(client.data, client.entryToDate) - : d3.extent([new Date(client.now - times.hours(48).msecs), new Date(client.now)]); + return client.entries.length > 0 ? + d3.extent(client.entries, client.entryToDate) + : d3.extent([new Date(client.now - times.hours(history).msecs), new Date(client.now)]); }; client.bottomOfPills = function bottomOfPills ( ) { //the offset's might not exist for some tests + var bottomOfPrimary = primary.offset() ? primary.offset().top + primary.height() : 0; var bottomOfMinorPills = minorPills.offset() ? minorPills.offset().top + minorPills.height() : 0; var bottomOfStatusPills = statusPills.offset() ? statusPills.offset().top + statusPills.height() : 0; - return Math.max(bottomOfMinorPills, bottomOfStatusPills); + return Math.max(bottomOfPrimary, bottomOfMinorPills, bottomOfStatusPills); }; function formatTime(time, compact) { @@ -132,10 +263,10 @@ client.init = function init(serverSettings, plugins) { var title = ''; - var time = client.latestSGV ? client.latestSGV.mills : (prevSGV ? prevSGV.mills : -1) - , ago = timeAgo(time); + var status = client.timeago.checkStatus(client.sbx); - if (ago && ago.status !== 'current') { + if (status !== 'current') { + var ago = client.timeago.calcDisplay(client.sbx.lastSGVEntry(), client.sbx.time); title = s(ago.value) + s(ago.label, ' - ') + title; } else if (client.latestSGV) { var currentMgdl = client.latestSGV.mgdl; @@ -143,8 +274,11 @@ client.init = function init(serverSettings, plugins) { if (currentMgdl < 39) { title = s(client.errorcodes.toDisplay(currentMgdl), ' - ') + title; } else { - var deltaDisplay = client.delta.calc(prevSGV, client.latestSGV, client.sbx).display; - title = s(scaleBg(currentMgdl)) + s(deltaDisplay) + s(client.direction.info(client.latestSGV).label) + title; + var delta = client.nowSBX.properties.delta; + if (delta) { + var deltaDisplay = delta.display; + title = s(scaleBg(currentMgdl)) + s(deltaDisplay) + s(client.direction.info(client.latestSGV).label) + title; + } } } return title; @@ -179,7 +313,7 @@ client.init = function init(serverSettings, plugins) { if (alarmMessage && alarmInProgress) { $('.customTitle').text(alarmMessage); - if (!isTimeAgoAlarmType(currentAlarmType)) { + if (!isTimeAgoAlarmType( )) { windowTitle = alarmMessage + ': ' + generateTitle(); } } else if (announcementStatus.inProgress && announcementStatus.message) { @@ -254,8 +388,7 @@ client.init = function init(serverSettings, plugins) { function updateCurrentSGV (entry) { var value = entry.mgdl - , ago = timeAgo(entry.mills) - , isCurrent = ago.status === 'current'; + , isCurrent = 'current' === client.timeago.checkStatus(client.sbx); if (value === 9) { currentBG.text(''); @@ -272,26 +405,57 @@ client.init = function init(serverSettings, plugins) { adjustCurrentSGVClasses(value, isCurrent); } - function updatePlugins (sgvs, time) { - var pluginBase = plugins.base(majorPills, minorPills, statusPills, bgStatus, client.tooltip, $.localStorage); + function updatePlugins (time) { + + //TODO: doing a clone was slow, but ok to let plugins muck with data? + //var ddata = client.ddata.clone(); + + client.ddata.inRetroMode = inRetroMode(); + client.ddata.profile = profile; client.sbx = sandbox.clientInit( - client.settings + client.ctx , new Date(time).getTime() //make sure we send a timestamp - , pluginBase, { - sgvs: sgvs - , cals: [client.cal] - , treatments: client.treatments - , profile: profile - , uploaderBattery: devicestatusData && devicestatusData.uploaderBattery - , inRetroMode: inRetroMode() - }); + , _.merge({}, client.retro.data || {}, client.ddata) + ); //all enabled plugins get a chance to set properties, even if they aren't shown - plugins.setProperties(client.sbx); + client.plugins.setProperties(client.sbx); //only shown plugins get a chance to update visualisations - plugins.updateVisualisations(client.sbx); + client.plugins.updateVisualisations(client.sbx); + + var viewMenu = $('#viewMenu'); + viewMenu.empty(); + + _.each(client.sbx.pluginBase.forecastInfos, function eachInfo (info) { + var forecastOption = $('
  • '); + var forecastLabel = $('