diff --git a/README.md b/README.md index fff1d1180a6..10ff8742b97 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ To learn more about the Nightscout API, visit https://YOUR-SITE.com/api-docs.htm * `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 any truthy value to log CareLink request information to the console. + * `MMCONNECT_STORE_RAW_DATA` - Set this to any truthy value to store raw data returned from CareLink as `type: "carelink_raw"` database entries (useful for development). Also see [Pushover](#pushover) and [IFTTT Maker](#ifttt-maker). diff --git a/lib/client/careportal.js b/lib/client/careportal.js index 3d153e5ee42..91e810c085b 100644 --- a/lib/client/careportal.js +++ b/lib/client/careportal.js @@ -10,7 +10,8 @@ function init (client, $) { var storage = $.localStorage; careportal.events = [ - { val: 'BG Check', name: 'BG Check' } + { 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' } @@ -23,6 +24,8 @@ function init (client, $) { , { 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' } ]; @@ -50,11 +53,70 @@ function init (client, $) { } } + careportal.filterInputs = function filterInputs ( event ) { + var inputMatrix = { + '': { bg: true, insulin: true, carbs: true, prebolus: true, 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: false, insulin: true, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } + , 'Sensor Start': { bg: false, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } + , 'Sensor Change': { bg: false, insulin: false, carbs: false, prebolus: false, duration: false, percent: false, absolute: false } + , 'Insulin Change': { bg: false, insulin: false, 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 eventType = $('#eventType').val(); + + function displayType (enabled) { + if (enabled) { + return ''; + } else { + return 'none'; + } + } + + function resetIfHidden(visible, id) { + if (!visible) { + $(id).val(''); + } + } + + $('#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() === '')); + $('#preBolusLabel').css('display',displayType(inputMatrix[eventType]['prebolus'])); + + 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'); + + maybePrevent(event); + }; + 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); + careportal.filterInputs(); }; careportal.resolveEventName = function resolveEventName(value) { @@ -68,11 +130,14 @@ function init (client, $) { careportal.prepare = function prepare ( ) { careportal.prepareEvents(); - $('#eventType').val('BG Check'); + $('#eventType').val(''); $('#glucoseValue').val('').attr('placeholder', translate('Value in') + ' ' + client.settings.units); $('#meter').prop('checked', true); $('#carbsGiven').val(''); $('#insulinGiven').val(''); + $('#duration').val(''); + $('#percent').val(''); + $('#absolute').val(''); $('#preBolus').val(0); $('#notes').val(''); $('#enteredBy').val(storage.get('enteredBy') || ''); @@ -88,6 +153,9 @@ function init (client, $) { , glucoseType: $('#treatment-form').find('input[name=glucoseType]:checked').val() , carbs: $('#carbsGiven').val() , insulin: $('#insulinGiven').val() + , duration: $('#duration').val() + , percent: $('#percent').val() + , absolute: $('#absolute').val() , preBolus: parseInt($('#preBolus').val()) , notes: $('#notes').val() , units: client.settings.units @@ -96,6 +164,10 @@ function init (client, $) { if ($('#othertime').is(':checked')) { data.eventTime = mergeDateAndTime().toDate(); } + + if (data.eventType.indexOf('Temp Basal') > -1) { + data.eventType = 'Temp Basal'; + } return data; } @@ -123,6 +195,9 @@ function init (client, $) { 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.percent, translate('Percent') + ': ' + data.percent); + pushIf(data.absolute, translate('Basal value') + ': ' + data.absolute); 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); diff --git a/lib/client/chart.js b/lib/client/chart.js index df5fb559b9b..061ecdad463 100644 --- a/lib/client/chart.js +++ b/lib/client/chart.js @@ -43,6 +43,11 @@ function init (client, d3, $) { var yScale2 = chart.yScale2 = d3.scale.log() .domain([utils.scaleMgdl(36), utils.scaleMgdl(420)]); + chart.xScaleBasals = d3.time.scale().domain(extent); + + chart.yScaleBasals = d3.scale.linear() + .domain([0, 5]); + var tickFormat = localeFormatter.timeFormat.multi( [ ['.%L', function(d) { return d.getMilliseconds(); }], [':%S', function(d) { return d.getSeconds(); }], @@ -103,6 +108,13 @@ function init (client, d3, $) { .tickValues(tickValues) .orient('right'); +// chart.yAxisBasals = d3.svg.axis() +// .scale(yScaleBasals) +// .tickFormat(d3.format('d')) + //.tickValues(tickValues) +// .ticks(4) +// .orient('right'); + // setup a brush chart.brush = d3.svg.brush() .x(xScale2) @@ -119,7 +131,7 @@ function init (client, d3, $) { .append('g') .attr('class', 'chartContainer'); - chart.focus = chart.charts.append('g'); + chart.focus = chart.charts.append('g').attr('class', 'chart-focus'); // create the x axis container chart.focus.append('g') @@ -129,7 +141,7 @@ function init (client, d3, $) { chart.focus.append('g') .attr('class', 'y axis'); - chart.context = chart.charts.append('g'); + chart.context = chart.charts.append('g').attr('class', 'chart-context'); // create the x axis container chart.context.append('g') @@ -139,6 +151,13 @@ function init (client, d3, $) { chart.context.append('g') .attr('class', 'y axis'); + chart.basals = chart.charts.append('g').attr('class', 'chart-basals'); + chart.basals.attr('display','none'); + + // create the y axis container +// chart.basals.append('g') +// .attr('class', 'y axis'); + function createAdjustedRange() { var range = chart.brush.extent().slice(); @@ -187,6 +206,7 @@ function init (client, d3, $) { // get the height of each chart based on its container size ratio var focusHeight = chart.focusHeight = chartHeight * .7; var contextHeight = chart.contextHeight = chartHeight * .2; + chart.basalsHeight = focusHeight / 4; // get current brush extent var currentBrushExtent = createAdjustedRange(); @@ -204,8 +224,10 @@ function init (client, d3, $) { // ranges are based on the width and height available so reset chart.xScale.range([0, chartWidth]); chart.xScale2.range([0, chartWidth]); + chart.xScaleBasals.range([0, chartWidth]); chart.yScale.range([focusHeight, 0]); chart.yScale2.range([chartHeight, chartHeight - contextHeight]); + chart.yScaleBasals.range([0, focusHeight / 4]); if (init) { @@ -223,6 +245,10 @@ function init (client, d3, $) { .attr('transform', 'translate(0,' + chartHeight + ')') .call(chart.xAxis2); +// chart.basals.select('.y') +// .attr('transform', 'translate(0,' + 0 + ')') +// .call(chart.yAxisBasals); + chart.context.append('g') .attr('class', 'x brush') .call(d3.svg.brush().x(chart.xScale2).on('brush', client.brushed)) @@ -294,18 +320,18 @@ function init (client, d3, $) { .attr('stroke', '#777'); // add a y-axis line that opens up the brush extent from the context to the focus - chart.focus.append('line') + chart.context.append('line') .attr('class', 'open-top') .attr('stroke', '#111') .attr('stroke-width', 15); // add a x-axis line that closes the the brush container on left side - chart.focus.append('line') + chart.context.append('line') .attr('class', 'open-left') .attr('stroke', 'white'); // add a x-axis line that closes the the brush container on right side - chart.focus.append('line') + chart.context.append('line') .attr('class', 'open-right') .attr('stroke', 'white'); @@ -358,6 +384,12 @@ function init (client, d3, $) { .attr('transform', 'translate(0,' + chartHeight + ')') .call(chart.xAxis2); + chart.basals.transition(); + +// basalsTransition.select('.y') +// .attr('transform', 'translate(0,' + 0 + ')') +// .call(chart.yAxisBasals); + if (chart.clip) { // reset clip to new dimensions chart.clip.transition() @@ -407,7 +439,7 @@ function init (client, d3, $) { .attr('y2', chart.yScale(utils.scaleMgdl(client.settings.thresholds.bgLow))); // transition open-top line to correct location - chart.focus.select('.open-top') + chart.context.select('.open-top') .transition() .attr('x1', chart.xScale2(currentBrushExtent[0])) .attr('y1', chart.yScale(utils.scaleMgdl(30))) @@ -415,7 +447,7 @@ function init (client, d3, $) { .attr('y2', chart.yScale(utils.scaleMgdl(30))); // transition open-left line to correct location - chart.focus.select('.open-left') + chart.context.select('.open-left') .transition() .attr('x1', chart.xScale2(currentBrushExtent[0])) .attr('y1', focusHeight) @@ -423,7 +455,7 @@ function init (client, d3, $) { .attr('y2', chartHeight); // transition open-right line to correct location - chart.focus.select('.open-right') + chart.context.select('.open-right') .transition() .attr('x1', chart.xScale2(currentBrushExtent[1])) .attr('y1', focusHeight) @@ -450,6 +482,7 @@ function init (client, d3, $) { // update domain chart.xScale2.domain(dataRange); + chart.xScaleBasals.domain(dataRange); var updateBrush = d3.select('.brush').transition(); updateBrush @@ -465,26 +498,27 @@ function init (client, d3, $) { chart.scroll = function scroll (nowDate) { chart.xScale.domain(createAdjustedRange()); + chart.xScaleBasals.domain(createAdjustedRange()); // remove all insulin/carb treatment bubbles so that they can be redrawn to correct location d3.selectAll('.path').remove(); // transition open-top line to correct location - chart.focus.select('.open-top') + chart.context.select('.open-top') .attr('x1', chart.xScale2(chart.brush.extent()[0])) .attr('y1', chart.yScale(utils.scaleMgdl(30))) .attr('x2', chart.xScale2(new Date(chart.brush.extent()[1].getTime() + client.forecastTime))) .attr('y2', chart.yScale(utils.scaleMgdl(30))); // transition open-left line to correct location - chart.focus.select('.open-left') + chart.context.select('.open-left') .attr('x1', chart.xScale2(chart.brush.extent()[0])) .attr('y1', chart.focusHeight) .attr('x2', chart.xScale2(chart.brush.extent()[0])) .attr('y2', chart.prevChartHeight); // transition open-right line to correct location - chart.focus.select('.open-right') + chart.context.select('.open-right') .attr('x1', chart.xScale2(new Date(chart.brush.extent()[1].getTime() + client.forecastTime))) .attr('y1', chart.focusHeight) .attr('x2', chart.xScale2(new Date(chart.brush.extent()[1].getTime() + client.forecastTime))) @@ -509,6 +543,7 @@ function init (client, d3, $) { renderer.addFocusCircles(); renderer.addTreatmentCircles(); + renderer.addBasals(client); // add treatment bubbles chart.focus.selectAll('circle') diff --git a/lib/client/index.js b/lib/client/index.js index de0c626c0f2..3121f92a62d 100644 --- a/lib/client/index.js +++ b/lib/client/index.js @@ -766,7 +766,6 @@ client.init = function init(serverSettings, plugins) { $('#treatmentDrawerToggle').toggle(serverSettings.careportalEnabled); container.toggleClass('has-minor-pills', plugins.hasShownType('pill-minor', client.settings)); - function prepareData ( ) { // Post processing after data is in var temp1 = [ ]; @@ -810,6 +809,42 @@ client.init = function init(serverSettings, plugins) { MBGdata = mergeDataUpdate(d.delta,MBGdata, d.mbgs); client.treatments = mergeDataUpdate(d.delta, client.treatments, d.treatments); + // filter & prepare temp basals + var tempbasaltreatments = client.treatments.filter( function filterBasals(t) { + return t.eventType.indexOf('Temp Basal') > -1; + }); + // cut temp basals by end events + // better to do it only on data update + var endevents = tempbasaltreatments.filter(function filterEnd(t) { + return ! t.duration; + }); + + function cutIfInInterval(base, end) { + if (base.mills < end.mills && base.mills + times.mins(base.duration).msecs > end.mills) { + base.duration = times.msecs(end.mills-base.mills).mins; + } + } + + // cut by end events + tempbasaltreatments.forEach(function allTreatments(t) { + endevents.forEach(function allEndevents(e) { + cutIfInInterval(t, e); + }); + }); + + // cut by overlaping events + tempbasaltreatments.forEach(function allTreatments(t) { + tempbasaltreatments.forEach(function allEndevents(e) { + cutIfInInterval(t, e); + }); + }); + + // store prepared temp basal treatments + client.tempbasaltreatments = tempbasaltreatments.filter(function filterEnd(t) { + return t.duration; + }); + + // Do some reporting on the console console.log('Total SGV data size', SGVdata.length); console.log('Total treatment data size', client.treatments.length); diff --git a/lib/client/renderer.js b/lib/client/renderer.js index c475c0815cd..339bfe76b70 100644 --- a/lib/client/renderer.js +++ b/lib/client/renderer.js @@ -1,5 +1,6 @@ 'use strict'; +var _ = require('lodash'); var times = require('../times'); var DEFAULT_FOCUS = times.hours(3).msecs @@ -167,7 +168,7 @@ function init (client, d3) { renderer.addTreatmentCircles = function addTreatmentCircles ( ) { function treatmentTooltip (d) { return ''+translate('Time')+': ' + client.formatTime(new Date(d.mills)) + '
' + - (d.eventType ? ''+translate('Treatment type')+': ' + d.eventType + '
' : '') + + (d.eventType ? ''+translate('Treatment type')+': ' + translate(client.careportal.resolveEventName(d.eventType)) + '
' : '') + (d.glucose ? ''+translate('BG')+': ' + d.glucose + (d.glucoseType ? ' (' + translate(d.glucoseType) + ')': '') + '
' : '') + (d.enteredBy ? ''+translate('Entered By')+': ' + d.enteredBy + '
' : '') + (d.notes ? ''+translate('Notes')+': ' + d.notes : ''); @@ -182,8 +183,8 @@ function init (client, d3) { //NOTE: treatments with insulin or carbs are drawn by drawTreatment() // bind up the focus chart data to an array of circles - var treatCircles = chart().focus.selectAll('rect').data(client.treatments.filter(function(treatment) { - return !treatment.carbs && !treatment.insulin; + var treatCircles = chart().focus.selectAll('treatment-dot').data(client.treatments.filter(function(treatment) { + return !treatment.carbs && !treatment.insulin && !treatment.duration && treatment.eventType.indexOf('Temp Basal') < 0; })); function prepareTreatCircles(sel) { @@ -237,6 +238,69 @@ function init (client, d3) { .on('mouseout', hideTooltip); treatCircles.attr('clip-path', 'url(#clip)'); + + // treatments with duration + var treatRects = chart().focus.selectAll('.g-duration').data(client.treatments.filter(function(treatment) { + return !treatment.carbs && !treatment.insulin && treatment.duration && treatment.eventType.indexOf('Temp Basal') < 0; + })); + + function fillColor(d) { + // this is going to be updated by Event Type + var color = 'grey'; + if (d.eventType === 'Exercise') { + color = 'Violet'; + } else if (d.eventType === 'Note') { + color = 'Salmon'; + } + return color; + } + + // if already existing then transition each rect to its new position + treatRects.transition() + .attr('transform', function (d) { + return 'translate(' + chart().xScale(new Date(d.mills)) + ',' + chart().yScale(utils.scaleMgdl(50)) + ')'; + }); + + // if new rect then just display + var gs = treatRects.enter().append('g') + .attr('class','g-duration') + .attr('transform', function (d) { + return 'translate(' + chart().xScale(new Date(d.mills)) + ',' + chart().yScale(utils.scaleMgdl(50)) + ')'; + }) + .on('mouseover', function (d) { + client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9); + client.tooltip.html(d.isAnnouncement ? announcementTooltip(d) : treatmentTooltip(d)) + .style('left', (d3.event.pageX) + 'px') + .style('top', (d3.event.pageY + 15) + 'px'); + }) + .on('mouseout', hideTooltip); + + gs.append('rect') + .attr('width', function (d) { + return chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(new Date(d.mills)); + }) + .attr('height', 20) + .attr('rx', 5) + .attr('ry', 5) + //.attr('stroke-width', 2) + .attr('opacity', .2) + //.attr('stroke', 'white') + .attr('fill', fillColor); + + gs.append('text') + .style('font-size', 15) + .attr('fill', 'white') + .attr('text-anchor', 'middle') + .attr('dy', '.35em') + .attr('transform', function (d) { + return 'translate(' + (chart().xScale(new Date(d.mills + times.mins(d.duration).msecs)) - chart().xScale(new Date(d.mills)))/2 + ',' + 10 + ')'; + }) + .text(function (d) { + return d.notes; + }); + + + treatRects.attr('clip-path', 'url(#clip)'); }; renderer.addContextCircles = function addContextCircles ( ) { @@ -337,7 +401,7 @@ function init (client, d3) { function appendTreatments(treatment, arc) { function treatmentTooltip() { client.tooltip.transition().duration(TOOLTIP_TRANS_MS).style('opacity', .9); - client.tooltip.html('' + translate('Time') + ': ' + client.formatTime(new Date(treatment.mills)) + '
' + '' + translate('Treatment type') + ': ' + translate(treatment.eventType) + '
' + + client.tooltip.html('' + translate('Time') + ': ' + client.formatTime(new Date(treatment.mills)) + '
' + '' + translate('Treatment type') + ': ' + translate(client.careportal.resolveEventName(treatment.eventType)) + '
' + (treatment.carbs ? '' + translate('Carbs') + ': ' + treatment.carbs + '
' : '') + (treatment.insulin ? '' + translate('Insulin') + ': ' + treatment.insulin + '
' : '') + (treatment.glucose ? '' + translate('BG') + ': ' + treatment.glucose + (treatment.glucoseType ? ' (' + translate(treatment.glucoseType) + ')' : '') + '
' : '') + @@ -348,7 +412,7 @@ function init (client, d3) { .style('top', (d3.event.pageY + 15) + 'px'); } - var treatmentDots = chart().focus.selectAll('treatment-dot') + var treatmentDots = chart().focus.selectAll('treatment-insulincarbs') .data(arc.data) .enter() .append('g') @@ -422,6 +486,116 @@ function init (client, d3) { appendLabels(treatmentDots, arc, opts); }; + renderer.addBasals = function addBasals (client) { + var profile = client.sbx.data.profile; + var linedata = []; + var notemplinedata = []; + var basalareadata = []; + var tempbasalareadata = []; + var from = chart().brush.extent()[0].getTime(); + var to = Math.max(chart().brush.extent()[1].getTime(), new Date().getTime()) + client.forecastTime; + + var date = from; + var lastbasal = 0; + var lastdate = from; + + while (date <= to) { + var basalvalue = profile.getTempBasal(date, client.tempbasaltreatments); + if (!_.isEqual(lastbasal, basalvalue)) { + linedata.push( { d: date, b: basalvalue.tempbasal } ); + notemplinedata.push( { d: date, b: basalvalue.basal } ); + if (basalvalue.treatment) { + tempbasalareadata.push( { d: date, b: basalvalue.tempbasal } ); + basalareadata.push( { d: date, b: 0 } ); + } else { + basalareadata.push( { d: date, b: basalvalue.tempbasal } ); + tempbasalareadata.push( { d: date, b: 0 } ); + } + } + lastbasal = basalvalue; + lastdate = date; + date += times.mins(1).msecs; + } + linedata.push( { d: to, b: profile.getTempBasal(to,client.tempbasaltreatments).tempbasal } ); + notemplinedata.push( { d: to, b: profile.getTempBasal(to,client.tempbasaltreatments).basal } ); + basalareadata.push( { d: to, b: profile.getTempBasal(to,client.tempbasaltreatments).basal } ); + tempbasalareadata.push( { d: to, b: profile.getTempBasal(to,client.tempbasaltreatments).tempbasal } ); + + chart().yScaleBasals.domain([0, d3.max(linedata, function(d) { return d.b; }) ]); + // update y axis domain +// chart().basals.select('.y') +// .call(chart().yAxisBasals); + + chart().basals.selectAll('g').remove(); + chart().basals.selectAll('.basalline').remove().data(linedata); + chart().basals.selectAll('.notempline').remove().data(notemplinedata); + chart().basals.selectAll('.basalarea').remove().data(basalareadata); + chart().basals.selectAll('.tempbasalarea').remove().data(tempbasalareadata); +// chart().basals.selectAll('.tempbasaltext').remove(); + + var valueline = d3.svg.line() + .interpolate('step-after') + .x(function(d) { return chart().xScaleBasals(d.d); }) + .y(function(d) { return chart().yScaleBasals(d.b); }); + + var area = d3.svg.area() + .interpolate('step-after') + .x(function(d) { return chart().xScaleBasals(d.d); }) + .y0(chart().yScaleBasals(0)) + .y1(function(d) { return chart().yScaleBasals(d.b); }); + + var g = chart().basals.append('g'); + + g.append('path') + .attr('class', 'line basalline') + .attr('stroke', '#0099ff') + .attr('stroke-width', 2) + .attr('fill', 'none') + .attr('d', valueline(linedata)) + .attr('clip-path', 'url(#clip)'); + + g.append('path') + .attr('class', 'line notempline') + .attr('stroke', '#0099ff') + .attr('stroke-width', 1) + .attr('stroke-dasharray', ('3, 3')) + .attr('fill', 'none') + .attr('d', valueline(notemplinedata)) + .attr('clip-path', 'url(#clip)'); + + g.append('path') + .attr('class', 'area basalarea') + .datum(basalareadata) + .attr('fill', '#0099ff') + .attr('fill-opacity', .1) + .attr('stroke-width', 0) + .attr('d', area); + + g.append('path') + .attr('class', 'area tempbasalarea') + .datum(tempbasalareadata) + .attr('fill', '#0099ff') + .attr('fill-opacity', .6) + .attr('stroke-width', 2) + .attr('d', area); + //console.log(tempbasals); + + client.tempbasaltreatments.forEach(function (t) { + if (t.mills > to || t.mills + times.msecs(t.duration).msecs < from) { + return; + } + g.append('text') + .attr('class', 'tempbasaltext') + .style('font-size', 15) + .attr('fill', 'white') + .attr('text-anchor', 'middle') + .attr('dy', '.35em') + .attr('x', chart().xScaleBasals(t.mills + times.mins(t.duration).msecs/2)) + .attr('y', 10) + .text((t.percent ? (t.percent > 0 ? '+' : '') + t.percent + '%' : '') + (t.absolute ? t.absolute + 'U' : '')); + }); + }; + return renderer; } diff --git a/lib/d3locales.js b/lib/d3locales.js index 4cf3a76f54a..afb04cbad5c 100644 --- a/lib/d3locales.js +++ b/lib/d3locales.js @@ -195,7 +195,8 @@ d3locales.bg_BG = { date: '%d.%m.%Y', time: '%H:%M:%S', periods: ['AM', 'PM'], - days: ['Понеделник', 'Вторник', 'Сряда', 'Четвъртък', 'Петък', 'Събота', 'Неделя'], + days: ['Неделя', 'Понеделник', 'Вторник', 'Сряда', 'Четвъртък', 'Петък', 'Събота'], + shortDays: ['нд', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'], months: ['Януари', 'Февруари', 'Март', 'Април', 'Май', 'Юни', 'Юли', 'Август', 'Септмври', 'Октомври', 'Ноември', 'Декември'], shortMonths: ['Ян', 'Февр', 'Март', 'Апр', 'Май', 'Юни', 'Юли', 'Авг', 'Септ', 'Окт', 'Ноем', 'Дек'] }; diff --git a/lib/devicestatus.js b/lib/devicestatus.js index f82db4c5ee2..6dd5eccb9ac 100644 --- a/lib/devicestatus.js +++ b/lib/devicestatus.js @@ -1,5 +1,7 @@ 'use strict'; +var find_options = require('./query'); + function storage (collection, ctx) { var ObjectID = require('mongodb').ObjectID; @@ -13,7 +15,7 @@ function storage (collection, ctx) { } function last(fn) { - return api().find({}).sort({created_at: -1}).limit(1).toArray(function (err, entries) { + return list({count: 1}, function (err, entries) { if (entries && entries.length > 0) { fn(err, entries[0]); } else { @@ -22,9 +24,42 @@ function storage (collection, ctx) { }); } + var with_collection = ctx.store.with_collection(collection); + + function query_for (opts) { + return find_options(opts, storage.queryOpts); + } + function list(opts, fn) { - var q = opts && opts.find ? opts.find : { }; - return ctx.store.limit.call(api().find(q).sort({created_at: -1}), opts).toArray(fn); + with_collection(function (err, collection) { + // 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 || {created_at: -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); + }); } function remove (_id, fn) { @@ -49,4 +84,8 @@ function storage (collection, ctx) { return api; } +storage.queryOpts = { + dateField: 'created_at' +}; + module.exports = storage; diff --git a/lib/language.js b/lib/language.js index 74fb09f704d..503c333f7df 100644 --- a/lib/language.js +++ b/lib/language.js @@ -763,7 +763,7 @@ function init() { ,he: 'גודל' } ,'(none)' : { - cs: '(Prázdný)' + cs: '(Žádný)' ,de: '(nichts)' ,es: '(ninguno)' ,fr: '(aucun)' @@ -778,6 +778,22 @@ function init() { ,nb: '(ingen)' ,he: '(ללא)' } + ,'' : { + cs: '<Žádný>' + ,de: '' + ,es: '' + ,fr: '' + ,pt: '' + ,sv: '' + ,ro: '' + ,bg: '<няма>' + ,hr: '' + ,it: '' + ,dk: '' + ,fi: '' + ,nb: '' + ,he: '<ללא>' + } ,'Result is empty' : { cs: 'Prázdný výsledek' ,de: 'Leeres Ergebnis' @@ -2078,7 +2094,7 @@ function init() { ,fr: 'Ajouter à partir de la base de données' ,pt: 'Adicionar do banco de dados' ,ro: 'Adaugă din baza de date' - ,bg: 'Добави към базата с данни' + ,bg: 'Добави от базата с данни' ,hr: 'Dodaj iz baze podataka' ,sv: 'Lägg till från databas' ,it: 'Aggiungi dal database' @@ -3103,13 +3119,6 @@ function init() { ,nb: 'Spørsmål' ,he: 'שאלה' } - ,'Announcement' : { - bg: 'Известяване' - , fi: 'Tiedoitus' - ,pt: 'Aviso' - ,ro: 'Anunț' - ,he: 'הודעה' - } ,'Exercise' : { cs: 'Cvičení' ,de: 'Bewegung' @@ -3182,7 +3191,7 @@ function init() { ,pt: 'Início de sensor' ,sv: 'Dexcom sensorstart' ,ro: 'Pornire senzor Dexcom' - ,bg: 'Поставяне на Декском сензор' + ,bg: 'Стартиране на Декском сензор' ,hr: 'Start Dexcom senzora' ,it: 'Avvio sensore Dexcom' ,dk: 'Dexcom sensor start' @@ -3724,7 +3733,7 @@ function init() { ,pt: 'Calculadora de bolus' ,sv: 'Boluskalkylator' ,ro: 'Calculator sugestie bolus' - ,bg: 'Съветник при изчисление на болуса' + ,bg: 'Болус съветник ' ,hr: 'Bolus wizard' ,it: 'Bolo guidato' ,dk: 'Bolusberegner' @@ -4134,21 +4143,25 @@ function init() { cs: 'Najít a odstranit CGM data v budoucnosti' ,nb: 'Finn og fjern fremtidige hendelser' ,bg: 'Намери и премахни данни от сензора в бъдещето' + ,ro: 'Caută și elimină valorile din viitor' } ,'This task find and remove CGM data in the future created by uploader with wrong date/time.' : { cs: 'Tento úkol najde a odstraní CGM data v budoucnosti vzniklé špatně nastaveným datem v uploaderu.' ,nb: 'Finn og fjern fremtidige cgm data lastet opp med feil dato/tid' ,bg: 'Тази опция ще намери и премахне данни от сензора в бъдещето, създадени поради грешна дата/време.' + ,ro: 'Instrument de căutare și eliminare a datelor din viitor, create de uploader cu ora setată greșit' } ,'Remove entries in the future' : { cs: 'Odstraň CGM data v budoucnosti' ,nb: 'Fjern fremtidige hendelser' ,bg: 'Премахни данните от сензора в бъдещето' + ,ro: 'Elimină înregistrările din viitor' } ,'Loading database ...' : { cs: 'Nahrávám databázi ...' ,nb: 'Leser database ...' ,bg: 'Зареждане на базата с данни ...' + ,ro: 'Încarc baza de date' } ,'Database contains %1 future records' : { cs: 'Databáze obsahuje %1 záznamů v budoucnosti' @@ -4252,6 +4265,58 @@ function init() { ,ro: 'Modifică înregistrarea' ,bg: 'Редакция на събитие' } + ,'Duration' : { + cs: 'Doba trvání' + ,ro: 'Durata' + ,bg: 'Времетраене' + } + ,'Duration in minutes' : { + cs: 'Doba trvání v minutách' + ,ro: 'Durata în minute' + ,bg: 'Времетраене в мин.' + } + ,'Temp Basal' : { + cs: 'Dočasný bazál' + ,ro: 'Bazală temporară' + ,bg: 'Временен базал' + } + ,'Temp Basal Start' : { + cs: 'Dočasný bazál začátek' + ,ro: 'Start bazală temporară' + ,bg: 'Начало на временен базал' + } + ,'Temp Basal End' : { + cs: 'Dočasný bazál konec' + ,ro: 'Sfârșit bazală temporară' + ,bg: 'Край на временен базал' + } + ,'Percent' : { // value in % for temp basal + cs: 'Procenta' + ,ro: 'Procent' + ,bg: 'Процент' + } + ,'Basal change in %' : { + cs: 'Změna bazálu v %' + ,ro: 'Bazală schimbată în %' + ,bg: 'Промяна на базала с %' + } + ,'Basal value' : { // absolute value for temp basal + cs: 'Hodnota bazálu' + ,ro: 'Valoare bazală' + ,bg: 'Временен базал' + } + ,'Absolute basal value' : { + cs: 'Hodnota bazálu' + ,bg: 'Базална стойност' + } + ,'Announcement' : { + cs: 'Oznámení' + ,bg: 'Известяване' + ,fi: 'Tiedoitus' + ,pt: 'Aviso' + ,ro: 'Anunț' + ,he: 'הודעה' + } }; diff --git a/lib/plugins/basalprofile.js b/lib/plugins/basalprofile.js index 2b5eb7ac3de..aa2606fab74 100644 --- a/lib/plugins/basalprofile.js +++ b/lib/plugins/basalprofile.js @@ -6,8 +6,18 @@ function init() { name: 'basal' , label: 'Basal Profile' , pluginType: 'pill-minor' + , additionalHtml: '' }; + basal.htmlInitCode = function htmlInitCode () { + $('#basals-switch').change(function switchChart (event) { + window.Nightscout.client.chart.basals.attr('display', $('#basals-switch').is(':checked') ? '' : 'none'); + if (event) { + event.preventDefault(); + } + }); + }; + function hasRequiredInfo (sbx) { if (!sbx.data.profile) { return false; } diff --git a/lib/plugins/mmconnect.js b/lib/plugins/mmconnect.js index 98f7e750cac..cab9182bfa8 100644 --- a/lib/plugins/mmconnect.js +++ b/lib/plugins/mmconnect.js @@ -1,7 +1,8 @@ /* jshint node: true */ 'use strict'; -var connect = require('minimed-connect-to-nightscout'); +var _ = require('lodash'), + connect = require('minimed-connect-to-nightscout'); function init (env, entries) { if (env.extendedSettings.mmconnect && env.extendedSettings.mmconnect.userName && env.extendedSettings.mmconnect.password) { @@ -18,7 +19,7 @@ function makeRunner_ (env, entries) { var client = connect.carelink.Client(options); connect.logger.setVerbose(options.verbose); - var handleData = makeHandler_(entries, options.storeRawData); + var handleData = makeHandler_(entries, makeRecentSgvFilter_(), options.storeRawData); return function run () { setInterval(function() { @@ -35,40 +36,27 @@ function getOptions_ (env) { , interval: parseInt(env.extendedSettings.mmconnect.interval || 60*1000, 10) , maxRetryDuration: parseInt(env.extendedSettings.mmconnect.maxRetryDuration || 32, 10) , verbose: !!env.extendedSettings.mmconnect.verbose - - // TODO(@mddub|2015-10-15): remove - // This is a temporary config variable to enable beta testers to store raw - // CareLink JSON in Mongo so we can learn what values it can contain. , storeRawData: !!env.extendedSettings.mmconnect.storeRawData }; } -function makeHandler_ (entries, storeRawData) { +function makeHandler_ (entries, filter, storeRawData) { return function handleCarelinkData (err, data) { if (err) { console.error('MiniMed Connect error: ' + err); } else { var transformed = connect.transform(data); - // TODO(@mddub|2015-10-15): remove if (storeRawData && transformed.length) { - - // redact PII - data['firstName'] = data['lastName'] = data['medicalDeviceSerialNumber'] = ''; - - // trim the default 288 sgvs returned by carelink - if (data['sgs'] && data['sgs'] instanceof Array) { - data['sgs'] = data['sgs'].slice(Math.max(0, data['sgs'].length - 6)); - } - - transformed.push({ - 'date': transformed[0]['date'] - , 'type': 'carelink_raw' - , 'data': data - }); + transformed.push(rawDataEntry_(data)); } - entries.create(transformed, function afterCreate (err) { + // If we blindly upsert the SGV entries, we will lose trend data for + // entries we've already stored, since all SGVs from CareLink except + // the most recent are missing trend data. + var filtered = filter(transformed); + + entries.create(filtered, function afterCreate (err) { if (err) { console.error('MiniMed Connect storage error: ' + err); } @@ -77,10 +65,51 @@ function makeHandler_ (entries, storeRawData) { }; } +function makeRecentSgvFilter_ () { + var lastSgvDate = 0; + + return function filter (entries) { + var out = []; + + entries.forEach(function(entry) { + if (entry['type'] !== 'sgv' || entry['date'] > lastSgvDate) { + out.push(entry); + } + }); + + out.filter(function(e) { return e['type'] === 'sgv'; }) + .forEach(function(e) { + lastSgvDate = Math.max(lastSgvDate, e['date']); + }); + + return out; + }; +} + +function rawDataEntry_ (data) { + var cleansed = _.cloneDeep(data); + + // redact PII + cleansed['firstName'] = cleansed['lastName'] = cleansed['medicalDeviceSerialNumber'] = ''; + + // trim the default 288 sgvs returned by carelink + if (cleansed['sgs'] && cleansed['sgs'] instanceof Array) { + cleansed['sgs'] = cleansed['sgs'].slice(Math.max(0, cleansed['sgs'].length - 6)); + } + + var timestamp = data['lastMedicalDeviceDataUpdateServerTime']; + return { + 'date': timestamp + , 'dateString': new Date(timestamp).toISOString() + , 'type': 'carelink_raw' + , 'data': cleansed + }; +} + module.exports = { init: init // exposed for testing - , makeRunner_: makeRunner_ , getOptions_: getOptions_ - , makeHandler_: makeHandler_ + , makeRecentSgvFilter_: makeRecentSgvFilter_ + , rawDataEntry_: rawDataEntry_ }; diff --git a/lib/plugins/pluginbase.js b/lib/plugins/pluginbase.js index eed09d50f32..303323c9028 100644 --- a/lib/plugins/pluginbase.js +++ b/lib/plugins/pluginbase.js @@ -37,10 +37,16 @@ function init (majorPills, minorPills, statusPills, bgStatus, tooltip) { pill.append(pillLabel); } else { pill.append(pillLabel); + if (plugin.additionalHtml) { + pill.append(plugin.additionalHtml); + } pill.append(pillValue); } container.append(pill); + if (plugin.htmlInitCode) { + plugin.htmlInitCode(); + } } else { //reset in case a pill class was added and needs to be removed pill.attr('class', classes); diff --git a/lib/profilefunctions.js b/lib/profilefunctions.js index 2c742ead251..b9a0ecfa0dd 100644 --- a/lib/profilefunctions.js +++ b/lib/profilefunctions.js @@ -3,6 +3,7 @@ var _ = require('lodash'); var moment = require('moment-timezone'); var NodeCache = require('node-cache'); +var times = require('./times'); function init(profileData) { @@ -125,6 +126,34 @@ function init(profileData) { return profile.getValueByTime(time,'basal'); }; + profile.tempBasalTreatment = function tempBasalTreatment(time, basaltreatments) { + var treatment = null; + basaltreatments.forEach( function eachTreatment (t) { + var duration = times.mins(t.duration || 0).msecs; + if (time < t.mills + duration && time > t.mills) { + treatment = t; + } + }); + return treatment; + }; + + profile.getTempBasal = function getTempBasal(time, basaltreatments) { + var basal = profile.getValueByTime(time,'basal'); + var tempbasal = basal; + var treatment = profile.tempBasalTreatment(time, basaltreatments); + if (treatment && treatment.percent) { + tempbasal = basal * (100 + treatment.percent) / 100; + } + if (treatment && treatment.absolute) { + tempbasal = treatment.absolute; + } + return { + basal: basal + , treatment: treatment + , tempbasal: tempbasal + }; + }; + if (profileData) { profile.loadData(profileData); } return profile; diff --git a/lib/report_plugins/daytoday.js b/lib/report_plugins/daytoday.js index 2918f2199c8..bb91685d1d2 100644 --- a/lib/report_plugins/daytoday.js +++ b/lib/report_plugins/daytoday.js @@ -2,6 +2,7 @@ var _ = require('lodash'); var moment = window.moment; +var times = require('../times'); var daytoday = { name: 'daytoday' @@ -225,7 +226,7 @@ daytoday.report = function report_daytoday(datastorage,sorteddaystoshow,options) .attr('class', 'y axis'); context.select('.y') - .attr('transform', 'translate(' + (/*chartWidth + */ padding.left) + ',' + padding.top + ')') + .attr('transform', 'translate(' + (padding.left) + ',' + padding.top + ')') .style('stroke', 'black') .style('shape-rendering', 'crispEdges') .style('fill', 'none') @@ -338,19 +339,19 @@ daytoday.report = function report_daytoday(datastorage,sorteddaystoshow,options) .style('font-size', '10px') .style('font-weight', 'normal') .attr('fill', 'black') - .attr('y', foodtexts * 15) + .attr('y', foodtexts * 15 + padding.top) .attr('transform', 'translate(' + (xScale2(treatment.mills) + padding.left) + ',' + padding.top + ')') .html(text); - foodtexts = (foodtexts+1)%6; + foodtexts = (foodtexts + 1) % 6; drawpointer = true; } } - if (treatment.notes && options.notes) { + if (treatment.notes && options.notes && !treatment.duration) { context.append('text') .style('font-size', '10px') .style('font-weight', 'normal') .attr('fill', 'black') - .attr('y', foodtexts * 15) + .attr('y', foodtexts * 15 + padding.top) .attr('transform', 'translate(' + (xScale2(treatment.mills) + padding.left) + ',' + padding.top + ')') .html(treatment.notes); foodtexts = (foodtexts+1)%6; @@ -404,8 +405,8 @@ daytoday.report = function report_daytoday(datastorage,sorteddaystoshow,options) .attr('transform', 'translate(' + (xScale2(treatment.mills) + padding.left - 2) + ',' + +padding.top + ')') .text(treatment.insulin+'U'); } - // other treatments - if (!treatment.insulin && !treatment.carbs) { + // other treatments without duration + if (!treatment.insulin && !treatment.carbs && !treatment.duration && treatment.eventType.indexOf('Temp Basal') < 0) { context.append('circle') .attr('cx', xScale2(treatment.mills) + padding.left) .attr('cy', yScale2(scaledTreatmentBG(treatment,data.sgv)) + padding.top) @@ -422,6 +423,30 @@ daytoday.report = function report_daytoday(datastorage,sorteddaystoshow,options) .attr('x', xScale2(treatment.mills) + padding.left + 10) .text(translate(client.careportal.resolveEventName(treatment.eventType))); } + // other treatments with duration + if (!treatment.insulin && !treatment.carbs && treatment.duration && treatment.eventType.indexOf('Temp Basal') < 0) { + context.append('rect') + .attr('x', xScale2(treatment.mills) + padding.left) + .attr('y', 0 + padding.top) + .attr('width', xScale2(treatment.mills + times.mins(treatment.duration).msecs) - xScale2(treatment.mills)) + .attr('height', chartHeight) + //.attr('rx', 5) + //.attr('ry', 5) + .attr('stroke-width', 1) + .attr('opacity', .2) + .attr('stroke', 'white') + .attr('fill', treatment.eventType === 'Exercise' ? 'Violet' : (treatment.eventType === 'Note' ? 'Salmon' : 'black')); + context.append('text') + .style('font-size', '12px') + .style('font-weight', 'bold') + .attr('fill', treatment.eventType === 'Exercise' ? 'Violet' : (treatment.eventType === 'Note' ? 'Salmon' : 'black')) + .attr('text-anchor', 'middle') + .attr('dy', '.35em') + .attr('y', foodtexts * 15 + 10 + padding.top) + .attr('x', xScale2(treatment.mills + times.mins(treatment.duration).msecs/2) + padding.left) + .text(treatment.notes); + foodtexts = (foodtexts + 1) % 6; + } }); } }; diff --git a/lib/report_plugins/treatments.js b/lib/report_plugins/treatments.js index 5f385c2b8f7..a129b2550d6 100644 --- a/lib/report_plugins/treatments.js +++ b/lib/report_plugins/treatments.js @@ -48,6 +48,21 @@ treatments.html = function html(client) { + ' ' + ' ' + '
' + + ' ' + + '
' + + ' ' + + '
' + + ' ' + + '
' + ' ' + '
' + '