From e95573ca260d3e306cdc4af154fb4408f7d71666 Mon Sep 17 00:00:00 2001 From: wellivea1 <17185653+wellivea1@users.noreply.github.com> Date: Mon, 20 Apr 2026 05:39:08 -0400 Subject: [PATCH 1/2] Add Fitbit API sleep import support --- src/Fitbit/README.md | 7 ++ src/Fitbit/engine.js | 260 ++++++++++++++++++++++++++++++++++++++----- src/Fitbit/test.js | 153 +++++++++++++++++++++++++ 3 files changed, 393 insertions(+), 27 deletions(-) diff --git a/src/Fitbit/README.md b/src/Fitbit/README.md index f0e41cc..64e9156 100644 --- a/src/Fitbit/README.md +++ b/src/Fitbit/README.md @@ -11,6 +11,13 @@ You may find the following useful: ## Export format +The parser supports multiple Fitbit sleep inputs: + +- legacy Fitbit CSV exports that begin with `Sleep` +- Fitbit Web API v1.2 JSON responses such as `/1.2/user/-/sleep/date/...` and `/1.2/user/-/sleep/list.json` +- JSON exports produced by browser-side backup tools that wrap those API responses +- ZIP/account-archive inputs that contain `sleep-*.json` files + The sleep diary export feature produces an ASCII CSV file without a byte order mark. Here is an example: ```csv diff --git a/src/Fitbit/engine.js b/src/Fitbit/engine.js index 1507796..1855e8f 100644 --- a/src/Fitbit/engine.js +++ b/src/Fitbit/engine.js @@ -200,46 +200,252 @@ class DiaryFitbit extends DiaryBase { return ( str == "N/A" ) ? null : parse_number(str); } + function parse_csv_records(contents) { + if ( !fitbit_file_re.test(contents) ) return null; + let records = []; + contents.replace( + new RegExp(fitbit_line,'gi'), + (_, + start_time, start_year,start_month,start_day,start_hour,start_minute,start_ap, + end_time, end_year,end_month,end_day,end_hour,end_minute,end_ap, + minutes_asleep,minutes_awake,number_of_awakenings,time_in_bed,minutes_rem_sleep,minutes_light_sleep,minutes_deep_sleep + ) => { + let end = parse_timestamp( end_year, end_month, end_day, end_hour, end_minute, end_ap.toUpperCase() ), + record = { + "End Time" : end, + "Minutes Asleep" : parse_number(minutes_asleep), + "Minutes Awake" : parse_number(minutes_awake), + "Number of Awakenings": parse_maybe_number(number_of_awakenings), + "Time in Bed" : parse_maybe_number(time_in_bed), + "Minutes REM Sleep" : parse_maybe_number(minutes_rem_sleep), + "Minutes Light Sleep" : parse_maybe_number(minutes_light_sleep), + "Minutes Deep Sleep" : parse_maybe_number(minutes_deep_sleep), + "end" : end, + }; + record["Start Time"] = record["start"] = end - ( record["Minutes Asleep"] + record["Minutes Awake"] ) * 60*1000; + records.push(record); + } + ); + return records; + } + + function get_first_value(record,keys) { + for ( let n=0; n!=keys.length; ++n ) { + if ( Object.prototype.hasOwnProperty.call(record,keys[n]) ) { + return record[keys[n]]; + } + } + } + + function parse_json_timestamp(value) { + let ret = DiaryBase.parse_timestamp(value); + return isNaN(ret) ? NaN : ret; + } + + function parse_json_number(value) { + if ( value === null || value === undefined || value === '' || value === "N/A" ) return null; + if ( typeof(value) == "number" ) return value; + if ( value.replace ) { + value = value.replace(/,/g,'').trim(); + if ( !value.length || value == "N/A" ) return null; + value = parseInt(value,10); + return isNaN(value) ? NaN : value; + } + return NaN; + } + + function get_level_summary_minutes(entry,key) { + const levels = get_first_value(entry,["levels"]), + stages = get_first_value(entry,["stages"]) + ; + if ( levels && levels.summary && levels.summary[key] ) { + return levels.summary[key].minutes; + } + if ( stages && Object.prototype.hasOwnProperty.call(stages,key) ) { + return stages[key]; + } + switch ( key ) { + case "deep": + return get_first_value(entry,["SleepLevelDeep"]); + case "light": + return get_first_value(entry,["SleepLevelLight"]); + case "rem": + return get_first_value(entry,["SleepLevelRem"]); + case "wake": + return get_first_value(entry,["SleepLevelWake","SleepLevelAwake"]); + case "awake": + return get_first_value(entry,["SleepLevelAwake","SleepLevelWake"]); + } + } + + function looks_like_json_fitbit_record(entry) { + if ( !entry || typeof(entry) != "object" ) return false; + return ( + get_first_value(entry,["startTime","StartDate","endTime","EndDate","dateOfSleep"]) !== undefined + && get_first_value(entry,["minutesAsleep","MinutesAsleep","duration","Duration","durationMs","levels","stages"]) !== undefined + ); + } + + function extract_json_fitbit_records(node) { + let ret = []; + if ( !node ) return ret; + + if ( Array.isArray(node) ) { + node.forEach( entry => ret = ret.concat(extract_json_fitbit_records(entry)) ); + return ret; + } + + if ( typeof(node) != "object" ) return ret; + + if ( looks_like_json_fitbit_record(node) ) { + return [node]; + } + + if ( Array.isArray(node["sleep"]) ) { + return extract_json_fitbit_records(node["sleep"]); + } + + if ( node["sleep"] ) { + ret = ret.concat(extract_json_fitbit_records(node["sleep"])); + } + + if ( node["results"] ) { + ret = ret.concat(extract_json_fitbit_records(node["results"])); + } + + Object.keys(node).forEach( key => { + if ( key != "sleep" && key != "results" && key.search(/sleep/i) != -1 ) { + ret = ret.concat(extract_json_fitbit_records(node[key])); + } + }); + + return ret; + } + + function parse_json_record(entry) { + let minutes_asleep = parse_json_number( get_first_value(entry,["minutesAsleep","MinutesAsleep"]) ), + minutes_awake = parse_json_number( get_first_value(entry,["minutesAwake","MinutesAwake"]) ), + number_of_awakenings = parse_json_number( get_first_value(entry,["awakeningsCount","awakeCount","AwakeCount","Number of Awakenings"]) ), + time_in_bed = parse_json_number( get_first_value(entry,["timeInBed","TimeInBed"]) ), + minutes_to_fall_asleep = parse_json_number( get_first_value(entry,["minutesToFallAsleep","MinutesToFallAsleep"]) ) || 0, + minutes_after_wakeup = parse_json_number( get_first_value(entry,["minutesAfterWakeup","MinutesAfterWakeup"]) ) || 0, + start = parse_json_timestamp( get_first_value(entry,["startTime","StartDate"]) ), + end = parse_json_timestamp( get_first_value(entry,["endTime","EndDate"]) ), + duration = parse_json_number( get_first_value(entry,["duration","Duration","durationMs"]) ), + minutes_rem_sleep = parse_json_number( get_level_summary_minutes(entry,"rem" ) ), + minutes_light_sleep = parse_json_number( get_level_summary_minutes(entry,"light") ), + minutes_deep_sleep = parse_json_number( get_level_summary_minutes(entry,"deep" ) ) + ; + + if ( minutes_awake === null ) { + minutes_awake = parse_json_number( get_level_summary_minutes(entry,"wake") ); + } + if ( minutes_awake === null ) { + minutes_awake = parse_json_number( get_level_summary_minutes(entry,"awake") ); + } + if ( minutes_awake === null && time_in_bed !== null && !isNaN(time_in_bed) && minutes_asleep !== null && !isNaN(minutes_asleep) ) { + minutes_awake = time_in_bed - minutes_asleep - minutes_to_fall_asleep - minutes_after_wakeup; + } + if ( time_in_bed === null && duration !== null && !isNaN(duration) ) { + time_in_bed = Math.round(duration / (60*1000)); + } + if ( isNaN(end) && !isNaN(start) && duration !== null && !isNaN(duration) ) { + end = start + duration; + } + if ( minutes_asleep === null || minutes_awake === null ) return null; + if ( isNaN(minutes_asleep) || isNaN(minutes_awake) ) return null; + if ( isNaN(start) && isNaN(end) ) return null; + + if ( isNaN(end) ) { + end = start + ( minutes_asleep + minutes_awake ) * 60*1000; + } + // Match the legacy CSV importer: Fitbit exports expose a raw start + // time, but the dashboard historically plots sessions from the + // end time minus asleep+awake minutes. + start = end - ( minutes_asleep + minutes_awake ) * 60*1000; + + return { + "Start Time" : start, + "End Time" : end, + "Minutes Asleep" : minutes_asleep, + "Minutes Awake" : minutes_awake, + "Number of Awakenings": number_of_awakenings, + "Time in Bed" : time_in_bed, + "Minutes REM Sleep" : minutes_rem_sleep, + "Minutes Light Sleep" : minutes_light_sleep, + "Minutes Deep Sleep" : minutes_deep_sleep, + "start" : start, + "end" : end, + }; + } + + function normalise_records(records) { + const seen = {}; + return records + .filter( record => record ) + .sort( (a,b) => b["start"] - a["start"] ) + .filter( record => { + const id = record["start"] + '\uE000' + record["end"]; + if ( seen[id] ) return false; + seen[id] = 1; + return true; + }) + ; + } + + function parse_json_records(contents) { + let parsed; + try { + parsed = JSON.parse(contents); + } catch (e) { + return null; + } + let records = normalise_records(extract_json_fitbit_records(parsed).map(parse_json_record)); + return records.length ? records : null; + } + + function parse_archive_records(contents) { + let records = []; + Object.keys(contents).forEach( filename => { + const file_contents = contents[filename]; + if ( typeof(file_contents) != "string" ) return; + if ( filename.search(/\.csv$/i) != -1 ) { + const csv_records = parse_csv_records(file_contents); + if ( csv_records ) records = records.concat(csv_records); + } + if ( filename.search(/\.json$/i) != -1 ) { + const json_records = parse_json_records(file_contents); + if ( json_records ) records = records.concat(json_records); + } + }); + records = normalise_records(records); + return records.length ? records : null; + } + switch ( file["file_format"]() ) { case "string": const contents = file["contents"]; - if ( !fitbit_file_re.test(contents) ) { + let parsed_records = parse_csv_records(contents) || parse_json_records(contents); + if ( !parsed_records ) { return this.invalid(file); } else { - contents.replace( - new RegExp(fitbit_line,'gi'), - (_, - start_time, start_year,start_month,start_day,start_hour,start_minute,start_ap, - end_time, end_year,end_month,end_day,end_hour,end_minute,end_ap, - minutes_asleep,minutes_awake,number_of_awakenings,time_in_bed,minutes_rem_sleep,minutes_light_sleep,minutes_deep_sleep - ) => { - let end = parse_timestamp( end_year, end_month, end_day, end_hour, end_minute, end_ap.toUpperCase() ), - record = { - "End Time" : end, - "Minutes Asleep" : parse_number(minutes_asleep), - "Minutes Awake" : parse_number(minutes_awake), - "Number of Awakenings": parse_maybe_number(number_of_awakenings), - "Time in Bed" : parse_maybe_number(time_in_bed), - "Minutes REM Sleep" : parse_maybe_number(minutes_rem_sleep), - "Minutes Light Sleep" : parse_maybe_number(minutes_light_sleep), - "Minutes Deep Sleep" : parse_maybe_number(minutes_deep_sleep), - "end" : end, - }; - record["Start Time"] = record["start"] = end - ( record["Minutes Asleep"] + record["Minutes Awake"] ) * 60*1000; - records.push(record); - } - ); - - this["records"] = records; + this["records"] = parsed_records; } break; + case "archive": + + records = parse_archive_records(file["contents"]); + if ( !records ) return this.invalid(file); + this["records"] = records; + break; + default: if ( this.initialise_from_common_formats(file) ) { @@ -386,7 +592,7 @@ class DiaryFitbit extends DiaryBase { "title": "fitbit", "url": "/src/Fitbit", "statuses": [ "asleep" ], - "extension": ".csv", + "extension": ".csv,.json,.zip", "logo": "https://community.fitbit.com/html/assets/fitbit_logo_1200.png", "timezone": "tzdata", } diff --git a/src/Fitbit/test.js b/src/Fitbit/test.js index b88d0eb..b55f99b 100644 --- a/src/Fitbit/test.js +++ b/src/Fitbit/test.js @@ -35,6 +35,96 @@ describe("Fitbit format", () => { var simple_end_time = new Date(2010, 10, 12, 14, 34, 0, 0).getTime(); var other_end_time = new Date(2012, 10, 10, 16, 32, 0, 0).getTime(); var duration = ( 700 + 91 ) * 60*1000; + var simple_start_time = simple_end_time - duration; + + var api_sleep_entry = { + "dateOfSleep": "2010-11-12", + "startTime": "2010-11-12T01:23:00.000", + "endTime": "2010-11-12T14:34:00.000", + "duration": duration, + "minutesToFallAsleep": 0, + "minutesAfterWakeup": 0, + "minutesAsleep": 700, + "minutesAwake": 91, + "awakeCount": 1, + "timeInBed": 12, + "type": "stages", + "levels": { + "summary": { + "deep": { "minutes": 12345 }, + "light": { "minutes": 1234 }, + "rem": { "minutes": 123 }, + "wake": { "minutes": 91 }, + }, + }, + }; + + var api_diary = JSON.stringify({ + "sleep": [ api_sleep_entry ], + }); + + var api_diary_with_bedtime_offsets = JSON.stringify({ + "sleep": [ + Object.assign({},api_sleep_entry,{ + "startTime": "2010-11-12T00:58:00.000", + "duration": ( 700 + 91 + 25 + 40 ) * 60*1000, + "minutesToFallAsleep": 25, + "minutesAfterWakeup": 40, + "timeInBed": 700 + 91 + 25 + 40, + }), + ], + }); + + var fitsaver_diary = JSON.stringify({ + "format": "https://fitsaver.github.io/formats#date_range", + "results": { + "sleep": { + "sleep": [ api_sleep_entry ], + }, + "sleep/list": { + "sleep": [ api_sleep_entry ], + }, + }, + }); + + var fitbit_n24_diary = JSON.stringify({ + "sleep": [ + { + "dateOfSleep": "2010-11-12", + "startTime": new Date(simple_start_time).toISOString(), + "endTime": new Date(simple_end_time).toISOString(), + "durationMs": duration, + "durationHours": duration / (60*60*1000), + "efficiency": 98, + "minutesAsleep": 700, + "minutesAwake": 91, + "isMainSleep": true, + "stages": { + "deep": 12345, + "light": 1234, + "rem": 123, + "wake": 91, + }, + } + ], + }); + + var archive_json_diary = JSON.stringify({ + "StartDate": "2010-11-12T01:23:00.000", + "EndDate": "2010-11-12T14:34:00.000", + "Duration": duration, + "MinutesToFallAsleep": 0, + "MinutesAfterWakeup": 0, + "MinutesAsleep": 700, + "MinutesAwake": 91, + "AwakeCount": 1, + "TimeInBed": 12, + "Type": "stages", + "SleepLevelRem": 123, + "SleepLevelLight": 1234, + "SleepLevelDeep": 12345, + "SleepLevelWake": 91, + }); var simple_records = [ { @@ -52,6 +142,22 @@ describe("Fitbit format", () => { } ]; + var fitbit_n24_records = [ + { + "Minutes Asleep": 700, + "Minutes Awake": 91, + "Number of Awakenings": null, + "Time in Bed": 791, + "Minutes REM Sleep": 123, + "Minutes Light Sleep": 1234, + "Minutes Deep Sleep": 12345, + "End Time": simple_end_time, + "end" : simple_end_time, + "Start Time": simple_end_time - duration, + "start" : simple_end_time - duration, + } + ]; + test_parse({ file_format: "Fitbit", name: "Empty diary", @@ -70,6 +176,53 @@ describe("Fitbit format", () => { } }); + test_parse({ + file_format: "Fitbit", + name: "Fitbit API diary", + input: api_diary, + expected: { + "records": simple_records, + } + }); + + test_parse({ + file_format: "Fitbit", + name: "Fitbit API diary with bedtime offsets", + input: api_diary_with_bedtime_offsets, + expected: { + "records": simple_records, + } + }); + + test_parse({ + file_format: "Fitbit", + name: "Fitsaver diary", + input: fitsaver_diary, + expected: { + "records": simple_records, + } + }); + + test_parse({ + file_format: "Fitbit", + name: "fitbit-n24 diary", + input: fitbit_n24_diary, + expected: { + "records": fitbit_n24_records, + } + }); + + test_parse({ + file_format: "Fitbit", + name: "Archive diary", + input: { + "user-site-export/sleep-2010-11-12.json": archive_json_diary, + }, + expected: { + "records": simple_records, + } + }); + test_parse({ file_format: "Fitbit", name: "Hard-to-parse diary", From 2b4baf00ea1c5241c1ab5a8d75d0ccc17ea3e6c4 Mon Sep 17 00:00:00 2001 From: wellivea1 <17185653+wellivea1@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:10:17 -0400 Subject: [PATCH 2/2] Add Google Health sleep import support --- Makefile | 2 +- README.md | 1 + src/Fitbit/engine.js | 4 +- src/Fitbit/test.js | 8 +- src/GoogleHealth/README.md | 22 ++ src/GoogleHealth/engine.js | 557 +++++++++++++++++++++++++++++++++++ src/GoogleHealth/test.js | 279 ++++++++++++++++++ src/SleepAsAndroid/test.js | 1 + src/SleepChart1/test.js | 1 + src/Sleepmeter/test.js | 15 + src/SpreadsheetGraph/test.js | 17 ++ src/SpreadsheetTable/test.js | 19 ++ src/Standard/test.js | 16 + 13 files changed, 938 insertions(+), 4 deletions(-) create mode 100644 src/GoogleHealth/README.md create mode 100644 src/GoogleHealth/engine.js create mode 100644 src/GoogleHealth/test.js diff --git a/Makefile b/Makefile index 89683b2..4b9c711 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ FULL: build test .PHONY: DEFAULT_GOAL clean build test test-1 test-2 test-3 test-4 test-5 # Add your engines to the following line: -ENGINES = Standard Sleepmeter SleepAsAndroid PleesTracker SleepChart1 ActivityLog Fitbit +ENGINES = Standard Sleepmeter SleepAsAndroid PleesTracker SleepChart1 ActivityLog Fitbit GoogleHealth # Low priority engines: ENGINES += SpreadsheetTable SpreadsheetGraph diff --git a/README.md b/README.md index 5ba194d..97a8f25 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Third-party documentation for a variety of file formats. These describe issues - [Activity Log](src/ActivityLog/) - [Fitbit](src/Fitbit/) +- [Google Health](src/GoogleHealth/) - [Plees Tracker](src/PleesTracker/) - [Sleep as Android](src/SleepAsAndroid/) - [Sleepmeter](src/Sleepmeter/) diff --git a/src/Fitbit/engine.js b/src/Fitbit/engine.js index 1855e8f..c79ec25 100644 --- a/src/Fitbit/engine.js +++ b/src/Fitbit/engine.js @@ -258,8 +258,8 @@ class DiaryFitbit extends DiaryBase { const levels = get_first_value(entry,["levels"]), stages = get_first_value(entry,["stages"]) ; - if ( levels && levels.summary && levels.summary[key] ) { - return levels.summary[key].minutes; + if ( levels && levels["summary"] && levels["summary"][key] ) { + return levels["summary"][key]["minutes"]; } if ( stages && Object.prototype.hasOwnProperty.call(stages,key) ) { return stages[key]; diff --git a/src/Fitbit/test.js b/src/Fitbit/test.js index b55f99b..ac4758a 100644 --- a/src/Fitbit/test.js +++ b/src/Fitbit/test.js @@ -158,6 +158,12 @@ describe("Fitbit format", () => { } ]; + var api_diary_with_bedtime_offsets_records = [ + Object.assign({},simple_records[0],{ + "Time in Bed": 700 + 91 + 25 + 40, + }), + ]; + test_parse({ file_format: "Fitbit", name: "Empty diary", @@ -190,7 +196,7 @@ describe("Fitbit format", () => { name: "Fitbit API diary with bedtime offsets", input: api_diary_with_bedtime_offsets, expected: { - "records": simple_records, + "records": api_diary_with_bedtime_offsets_records, } }); diff --git a/src/GoogleHealth/README.md b/src/GoogleHealth/README.md new file mode 100644 index 0000000..05dec6a --- /dev/null +++ b/src/GoogleHealth/README.md @@ -0,0 +1,22 @@ +# Google Health format + +[Google Health API](https://developers.google.com/health) is the successor API for Fitbit Web API integrations. It exposes Fitbit and other health data through Google OAuth and a unified set of data types. + +## In this directory + +You may find the following useful: + +- [JavaScript example code](engine.js) +- [Test cases](test.js) + +## Import format + +The parser supports JSON responses from the Google Health API sleep data point list endpoint: + +```text +GET https://health.googleapis.com/v4/users/me/dataTypes/sleep/dataPoints +``` + +The expected response contains a `dataPoints` array. Each sleep data point includes a `sleep.interval` with RFC 3339 `startTime` and `endTime` values, plus optional sleep summaries and sleep stage segments. + +Sleep Diary uses the Google Health interval start and end times as the canonical session boundaries. Summary minute fields are preserved as source metrics, but they are not used to shift the session on the chart. diff --git a/src/GoogleHealth/engine.js b/src/GoogleHealth/engine.js new file mode 100644 index 0000000..1c50ed1 --- /dev/null +++ b/src/GoogleHealth/engine.js @@ -0,0 +1,557 @@ +/* + * Copyright 2020-2026 Sleepdiary Developers + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +"use strict"; + +/** + * @public + * @unrestricted + * @augments DiaryBase + * + * @example + * let diary = new_sleep_diary(contents_of_my_google_health_response); + * + * console.log(diary.records); + * -> [ + * { + * "start" : 1772581050000, + * "end" : 1772608890000, + * "Start Time" : 1772581050000, + * "End Time" : 1772608890000, + * "Minutes Asleep": 407, + * "Minutes Awake" : 57, + * }, + * ... + * ] + * + */ +class DiaryGoogleHealth extends DiaryBase { + + /** + * @param {Object} file - file contents + * @param {Function=} serialiser - function to serialise output + */ + constructor(file,serialiser) { + + super(file,serialiser); + + let records = []; + + /** + * Spreadsheet manager + * @protected + * @type {Spreadsheet} + */ + this["spreadsheet"] = new Spreadsheet( + this, + [ + { + "sheet" : "Records", + "member" : "records", + "cells": [ + { + "member": "Id", + "type": "string", + "optional": true, + }, + { + "member": "Start Time", + "type": "time", + }, + { + "member": "End Time", + "type": "time", + }, + { + "member": "Minutes Asleep", + "type": "number", + "optional": true, + }, + { + "member": "Minutes Awake", + "type": "number", + "optional": true, + }, + { + "member": "Number of Awakenings", + "type": "number", + "optional": true, + }, + { + "member": "Time in Bed", + "type": "number", + "optional": true, + }, + { + "member": "Minutes REM Sleep", + "type": "number", + "optional": true, + }, + { + "member": "Minutes Light Sleep", + "type": "number", + "optional": true, + }, + { + "member": "Minutes Deep Sleep", + "type": "number", + "optional": true, + }, + { + "member": "Type", + "type": "string", + "optional": true, + }, + { + "member": "Is Main Sleep", + "type": "boolean", + "optional": true, + }, + { + "member": "Is Nap", + "type": "boolean", + "optional": true, + }, + { + "members": [], + "export": (array_element,row,offset) => true, + "import": (array_element,row,offset) => { + array_element["start"] = array_element["Start Time"]; + array_element["end"] = array_element["End Time"]; + return true; + }, + }, + ], + }, + ] + ); + + function get_first_value(record,keys) { + for ( let n=0; n!=keys.length; ++n ) { + if ( Object.prototype.hasOwnProperty.call(record,keys[n]) ) { + return record[keys[n]]; + } + } + } + + function parse_timestamp(value) { + let ret = DiaryBase.parse_timestamp(value); + return isNaN(ret) ? NaN : ret; + } + + function parse_number(value) { + if ( value === null || value === undefined || value === '' ) return null; + if ( typeof(value) == "number" ) return value; + if ( value.replace ) { + value = value.replace(/,/g,'').trim(); + if ( !value.length ) return null; + value = parseInt(value,10); + return isNaN(value) ? NaN : value; + } + return NaN; + } + + function minutes_between(start,end) { + return Math.round((end-start)/(60*1000)); + } + + function stage_type(stage) { + return ( stage["type"] || "" ).toUpperCase(); + } + + function get_stage_summary(sleep,type,key) { + const summary = sleep["summary"], + stages_summary = summary && summary["stagesSummary"] + ; + if ( !Array.isArray(stages_summary) ) return null; + for ( let n=0; n!=stages_summary.length; ++n ) { + if ( stage_type(stages_summary[n]) == type ) { + return parse_number(stages_summary[n][key]); + } + } + return null; + } + + function sum_stage_minutes(sleep,types) { + const stages = sleep["stages"]; + if ( !Array.isArray(stages) ) return null; + let ret = 0, + found = false + ; + stages.forEach( stage => { + if ( types.indexOf(stage_type(stage)) == -1 ) return; + const start = parse_timestamp(stage["startTime"]), + end = parse_timestamp(stage["endTime"]) + ; + if ( isNaN(start) || isNaN(end) || end <= start ) return; + ret += minutes_between(start,end); + found = true; + }); + return found ? ret : null; + } + + function count_stage_segments(sleep,types) { + const stages = sleep["stages"]; + if ( !Array.isArray(stages) ) return null; + let ret = 0; + stages.forEach( stage => { + if ( types.indexOf(stage_type(stage)) != -1 ) ++ret; + }); + return ret || null; + } + + function looks_like_google_health_sleep_point(point) { + const sleep = point && point["sleep"], + interval = sleep && sleep["interval"] + ; + return !!( + interval + && interval["startTime"] + && interval["endTime"] + ); + } + + function extract_google_health_sleep_points(node) { + let ret = []; + if ( !node ) return ret; + + if ( Array.isArray(node) ) { + node.forEach( entry => ret = ret.concat(extract_google_health_sleep_points(entry)) ); + return ret; + } + + if ( typeof(node) != "object" ) return ret; + + if ( looks_like_google_health_sleep_point(node) ) { + return [node]; + } + + if ( Array.isArray(node["dataPoints"]) ) { + return extract_google_health_sleep_points(node["dataPoints"]); + } + + if ( node["dataPoint"] ) { + ret = ret.concat(extract_google_health_sleep_points(node["dataPoint"])); + } + + if ( node["response"] ) { + ret = ret.concat(extract_google_health_sleep_points(node["response"])); + } + + return ret; + } + + function parse_record(point) { + const sleep = point["sleep"], + interval = sleep["interval"], + summary = sleep["summary"] || {}, + metadata = sleep["metadata"] || {}, + start = parse_timestamp(interval["startTime"]), + end = parse_timestamp(interval["endTime"]) + ; + + if ( isNaN(start) || isNaN(end) || end <= start ) return null; + + let time_in_bed = parse_number(summary["minutesInSleepPeriod"]), + minutes_asleep = parse_number(summary["minutesAsleep"]), + minutes_awake = parse_number(summary["minutesAwake"]), + minutes_rem_sleep = get_stage_summary(sleep,"REM","minutes"), + minutes_light_sleep = get_stage_summary(sleep,"LIGHT","minutes"), + minutes_deep_sleep = get_stage_summary(sleep,"DEEP","minutes"), + number_of_awakenings = get_stage_summary(sleep,"AWAKE","count") + ; + + if ( time_in_bed === null || isNaN(time_in_bed) ) { + time_in_bed = minutes_between(start,end); + } + if ( minutes_rem_sleep === null ) { + minutes_rem_sleep = sum_stage_minutes(sleep,["REM"]); + } + if ( minutes_light_sleep === null ) { + minutes_light_sleep = sum_stage_minutes(sleep,["LIGHT"]); + } + if ( minutes_deep_sleep === null ) { + minutes_deep_sleep = sum_stage_minutes(sleep,["DEEP"]); + } + if ( minutes_awake === null || isNaN(minutes_awake) ) { + minutes_awake = sum_stage_minutes(sleep,["AWAKE","RESTLESS"]); + } + if ( minutes_asleep === null || isNaN(minutes_asleep) ) { + const sleep_stage_minutes = sum_stage_minutes(sleep,["ASLEEP","LIGHT","DEEP","REM"]); + if ( sleep_stage_minutes !== null ) { + minutes_asleep = sleep_stage_minutes; + } else if ( minutes_awake !== null && !isNaN(minutes_awake) ) { + minutes_asleep = time_in_bed - minutes_awake; + } + } + if ( number_of_awakenings === null ) { + number_of_awakenings = count_stage_segments(sleep,["AWAKE"]); + } + + const id = point["name"] || metadata["externalId"] || [ + interval["startTime"], + interval["endTime"], + (point["dataSource"]||{})["platform"] || "", + ].join("\uE000"); + + return { + "Id" : id, + "Start Time" : start, + "End Time" : end, + "Minutes Asleep" : minutes_asleep, + "Minutes Awake" : minutes_awake, + "Number of Awakenings": number_of_awakenings, + "Time in Bed" : time_in_bed, + "Minutes REM Sleep" : minutes_rem_sleep, + "Minutes Light Sleep" : minutes_light_sleep, + "Minutes Deep Sleep" : minutes_deep_sleep, + "Type" : sleep["type"] || null, + "Is Main Sleep" : get_first_value(metadata,["main","isMainSleep"]) === true, + "Is Nap" : metadata["nap"] === true, + "start" : start, + "end" : end, + }; + } + + function normalise_records(records) { + const seen = {}; + return records + .filter( record => record ) + .sort( (a,b) => b["start"] - a["start"] || b["end"] - a["end"] ) + .filter( record => { + const id = record["Id"] || record["start"] + '\uE000' + record["end"]; + if ( seen[id] ) return false; + seen[id] = 1; + return true; + }) + ; + } + + function parse_json_records(contents) { + let parsed; + try { + parsed = JSON.parse(contents); + } catch (e) { + return null; + } + const is_google_health_response = ( + parsed + && typeof(parsed) == "object" + && Array.isArray(parsed["dataPoints"]) + ); + const parsed_records = normalise_records( + extract_google_health_sleep_points(parsed).map(parse_record) + ); + if ( is_google_health_response && !parsed["dataPoints"].length ) return []; + return parsed_records.length ? parsed_records : null; + } + + function parse_archive_records(contents) { + let parsed_records = []; + Object.keys(contents).forEach( filename => { + const file_contents = contents[filename]; + if ( typeof(file_contents) != "string" ) return; + if ( filename.search(/\.json$/i) == -1 ) return; + const json_records = parse_json_records(file_contents); + if ( json_records ) parsed_records = parsed_records.concat(json_records); + }); + parsed_records = normalise_records(parsed_records); + return parsed_records.length ? parsed_records : null; + } + + function standard_record_to_google_health(record,n) { + const start = record["start"], + end = record["end"], + duration = end - start + ; + return { + "Id": "standard-" + n + "-" + start + "-" + end, + "Start Time": start, + "End Time": end, + "Minutes Asleep": Math.round(duration/(60*1000)), + "Minutes Awake": 0, + "Number of Awakenings": null, + "Time in Bed": Math.round(duration/(60*1000)), + "Minutes REM Sleep": null, + "Minutes Light Sleep": null, + "Minutes Deep Sleep": null, + "Type": "SLEEP_TYPE_UNSPECIFIED", + "Is Main Sleep": !!record["is_primary_sleep"], + "Is Nap": false, + "start": start, + "end": end, + }; + } + + switch ( file["file_format"]() ) { + + case "string": + + records = parse_json_records(file["contents"]); + if ( !records ) return this.invalid(file); + this["records"] = records; + break; + + case "archive": + + records = parse_archive_records(file["contents"]); + if ( !records ) return this.invalid(file); + this["records"] = records; + break; + + default: + + if ( this.initialise_from_common_formats(file) ) return; + + this["records"] = normalise_records( + file["to"]("Standard")["records"] + .filter( r => ( + r["status"] == "asleep" + && r["start"] !== undefined + && r["end"] !== undefined + && r["end"] > r["start"] + )) + .map(standard_record_to_google_health) + ); + + break; + + } + + } + + ["to"](to_format) { + + function record_to_data_point(record) { + const start = new Date(record["start"]).toISOString(), + end = new Date(record["end"]).toISOString(), + maybe_string = value => ( value === null || value === undefined ) ? undefined : String(value), + stages_summary = [ + [ "AWAKE", "Minutes Awake" , "Number of Awakenings" ], + [ "REM" , "Minutes REM Sleep" , null ], + [ "LIGHT", "Minutes Light Sleep" , null ], + [ "DEEP" , "Minutes Deep Sleep" , null ], + ].filter( pair => record[pair[1]] !== null && record[pair[1]] !== undefined ).map( + pair => { + const ret = { + "type": pair[0], + "minutes": String(record[pair[1]]), + }; + if ( pair[2] && record[pair[2]] !== null && record[pair[2]] !== undefined ) { + ret["count"] = String(record[pair[2]]); + } + return ret; + } + ) + ; + return { + "name": record["Id"], + "sleep": { + "interval": { + "startTime": start, + "startUtcOffset": "0s", + "endTime": end, + "endUtcOffset": "0s", + }, + "type": record["Type"] || "SLEEP_TYPE_UNSPECIFIED", + "metadata": { + "main": record["Is Main Sleep"] === true, + "nap": record["Is Nap"] === true, + }, + "summary": { + "minutesInSleepPeriod": maybe_string(record["Time in Bed"]), + "minutesAsleep": maybe_string(record["Minutes Asleep"]), + "minutesAwake": maybe_string(record["Minutes Awake"]), + "stagesSummary": stages_summary, + }, + }, + }; + } + + switch ( to_format ) { + + case "output": + + return this.serialise({ + "file_format": () => "string", + "contents": JSON.stringify({ + "dataPoints": this["records"].map(record_to_data_point), + },null,2), + }); + + case "Standard": + + return new DiaryStandard({ + "records": this["records"].map( + r => ({ + "status" : "asleep", + "start" : r["start"], + "end" : r["end"], + "duration": r["end"] - r["start"], + }) + ), + }, this.serialiser); + + default: + + return super["to"](to_format); + + } + + } + + ["merge"](other) { + + other = other["to"](this["file_format"]()); + + this["records"] = this["records"].concat( + DiaryBase.unique( + this["records"], + other["records"], + record => record["Id"] || record["start"] + "\uE000" + record["end"] + ) + ) + .sort( (a,b) => b["start"] - a["start"] || b["end"] - a["end"] ) + ; + + return this; + + } + + ["file_format"]() { return "GoogleHealth"; } + ["format_info"]() { + return { + "name": "GoogleHealth", + "title": "Google Health", + "url": "/src/GoogleHealth", + "statuses": [ "asleep" ], + "extension": ".json", + "logo": "https://www.gstatic.com/images/branding/product/1x/googleg_48dp.png", + "timezone": "UTC", + } + } + +} + +DiaryBase.register(DiaryGoogleHealth); diff --git a/src/GoogleHealth/test.js b/src/GoogleHealth/test.js new file mode 100644 index 0000000..cbb195a --- /dev/null +++ b/src/GoogleHealth/test.js @@ -0,0 +1,279 @@ +register_roundtrip_modifier("GoogleHealth",function(our_diary,roundtripped_diary,other_format) { + switch ( other_format.name ) { + case "Sleepmeter": + case "SleepAsAndroid": + case "SleepChart1": + case "Fitbit": + [our_diary,roundtripped_diary].forEach(function(diary) { + diary["records"].forEach( function(record) { + /* + * This format does not support comments or tags. + */ + ["comments","tags"].forEach(function(key) { + delete record[key]; + }); + }); + }); + } +}); + + +describe("GoogleHealth format", () => { + + var older_start_time = Date.parse("2026-03-02T21:00:00Z"), + older_end_time = Date.parse("2026-03-03T05:00:00Z"), + simple_start_time = Date.parse("2026-03-03T20:57:30Z"), + simple_end_time = Date.parse("2026-03-04T04:41:30Z"), + fallback_start_time = Date.parse("2026-03-05T01:00:00Z"), + fallback_end_time = Date.parse("2026-03-05T03:30:00Z") + ; + + var empty_diary = JSON.stringify({ + "dataPoints": [], + }); + + var simple_google_health_sleep = { + "name": "users/2515055256096816351/dataTypes/sleep/dataPoints/2724123844716220216", + "dataSource": { + "recordingMethod": "DERIVED", + "device": { + "displayName": "Charge 6", + }, + "platform": "FITBIT", + }, + "sleep": { + "interval": { + "startTime": "2026-03-03T20:57:30Z", + "startUtcOffset": "0s", + "endTime": "2026-03-04T04:41:30Z", + "endUtcOffset": "0s", + }, + "type": "STAGES", + "metadata": { + "stagesStatus": "SUCCEEDED", + "processed": true, + "main": true, + }, + "summary": { + "minutesInSleepPeriod": "464", + "minutesAfterWakeUp": "0", + "minutesToFallAsleep": "0", + "minutesAsleep": "407", + "minutesAwake": "57", + "stagesSummary": [ + { + "type": "AWAKE", + "minutes": "56", + "count": "12", + }, + { + "type": "LIGHT", + "minutes": "198", + "count": "19", + }, + { + "type": "DEEP", + "minutes": "114", + "count": "10", + }, + { + "type": "REM", + "minutes": "94", + "count": "4", + }, + ], + }, + }, + }; + + var older_google_health_sleep = { + "name": "users/2515055256096816351/dataTypes/sleep/dataPoints/older", + "sleep": { + "interval": { + "startTime": "2026-03-02T21:00:00Z", + "startUtcOffset": "0s", + "endTime": "2026-03-03T05:00:00Z", + "endUtcOffset": "0s", + }, + "type": "CLASSIC", + "metadata": { + "nap": false, + }, + "summary": { + "minutesInSleepPeriod": "480", + "minutesAsleep": "430", + "minutesAwake": "50", + }, + }, + }; + + var fallback_google_health_sleep = { + "name": "users/2515055256096816351/dataTypes/sleep/dataPoints/fallback", + "sleep": { + "interval": { + "startTime": "2026-03-05T01:00:00Z", + "startUtcOffset": "0s", + "endTime": "2026-03-05T03:30:00Z", + "endUtcOffset": "0s", + }, + "type": "STAGES", + "stages": [ + { + "startTime": "2026-03-05T01:00:00Z", + "endTime": "2026-03-05T02:00:00Z", + "type": "ASLEEP", + }, + { + "startTime": "2026-03-05T02:00:00Z", + "endTime": "2026-03-05T02:30:00Z", + "type": "RESTLESS", + }, + { + "startTime": "2026-03-05T02:30:00Z", + "endTime": "2026-03-05T03:30:00Z", + "type": "REM", + }, + ], + }, + }; + + var simple_diary = JSON.stringify({ + "dataPoints": [ + simple_google_health_sleep, + older_google_health_sleep, + ], + "nextPageToken": "", + }); + + var fallback_diary = JSON.stringify({ + "dataPoints": [ + fallback_google_health_sleep, + ], + }); + + var simple_records = [ + { + "Id": "users/2515055256096816351/dataTypes/sleep/dataPoints/2724123844716220216", + "Start Time": simple_start_time, + "End Time": simple_end_time, + "Minutes Asleep": 407, + "Minutes Awake": 57, + "Number of Awakenings": 12, + "Time in Bed": 464, + "Minutes REM Sleep": 94, + "Minutes Light Sleep": 198, + "Minutes Deep Sleep": 114, + "Type": "STAGES", + "Is Main Sleep": true, + "Is Nap": false, + "start": simple_start_time, + "end": simple_end_time, + }, + { + "Id": "users/2515055256096816351/dataTypes/sleep/dataPoints/older", + "Start Time": older_start_time, + "End Time": older_end_time, + "Minutes Asleep": 430, + "Minutes Awake": 50, + "Number of Awakenings": null, + "Time in Bed": 480, + "Minutes REM Sleep": null, + "Minutes Light Sleep": null, + "Minutes Deep Sleep": null, + "Type": "CLASSIC", + "Is Main Sleep": false, + "Is Nap": false, + "start": older_start_time, + "end": older_end_time, + }, + ]; + + test_parse({ + file_format: "GoogleHealth", + name: "Empty diary", + input: empty_diary, + expected: { + "records": [], + } + }); + + test_parse({ + file_format: "GoogleHealth", + name: "Simple diary", + input: simple_diary, + expected: { + "records": simple_records, + } + }); + + test_parse({ + file_format: "GoogleHealth", + name: "Stage fallback diary", + input: fallback_diary, + expected: { + "records": [ + { + "Id": "users/2515055256096816351/dataTypes/sleep/dataPoints/fallback", + "Start Time": fallback_start_time, + "End Time": fallback_end_time, + "Minutes Asleep": 120, + "Minutes Awake": 30, + "Number of Awakenings": null, + "Time in Bed": 150, + "Minutes REM Sleep": 60, + "Minutes Light Sleep": null, + "Minutes Deep Sleep": null, + "Type": "STAGES", + "Is Main Sleep": false, + "Is Nap": false, + "start": fallback_start_time, + "end": fallback_end_time, + }, + ], + } + }); + + test_to({ + name: "Standard Format test", + format: "Standard", + input: JSON.stringify({ "dataPoints": [ simple_google_health_sleep ] }), + expected: [ + { + "status" : 'asleep', + "start" : simple_start_time, + "end" : simple_end_time, + "duration": simple_end_time - simple_start_time, + "start_of_new_day": true, + "day_number" : 2, + "is_primary_sleep": true, + } + ], + }); + + test_merge({ + name: "Two identical diaries", + left: simple_diary, + right: simple_diary, + expected: { + "records": simple_records, + }, + }); + + test_merge({ + name: "Left empty, right non-empty", + left: empty_diary, + right: simple_diary, + expected: { + "records": simple_records, + }, + }); + + it("converts Google Health's newest-first API response to chronological Standard records", function() { + var standard_records = new_sleep_diary(simple_diary)["to"]("Standard")["records"]; + expect(standard_records.map( record => record["start"] ))["toEqual"]([ + older_start_time, + simple_start_time, + ]); + }); + +}); diff --git a/src/SleepAsAndroid/test.js b/src/SleepAsAndroid/test.js index a58b5a9..855be5e 100644 --- a/src/SleepAsAndroid/test.js +++ b/src/SleepAsAndroid/test.js @@ -4,6 +4,7 @@ register_roundtrip_modifier("SleepAsAndroid",function(our_diary,roundtripped_dia case "SleepChart1": case "PleesTracker": case "Fitbit": + case "GoogleHealth": [our_diary,roundtripped_diary].forEach(function(diary) { diary["records"].forEach( function(record) { /* diff --git a/src/SleepChart1/test.js b/src/SleepChart1/test.js index ac4f3b3..e7a2f68 100644 --- a/src/SleepChart1/test.js +++ b/src/SleepChart1/test.js @@ -5,6 +5,7 @@ register_roundtrip_modifier("SleepChart1",function(our_diary,roundtripped_diary, case "SpreadsheetGraph": case "SpreadsheetTable": case "Fitbit": + case "GoogleHealth": [our_diary,roundtripped_diary].forEach(function(diary) { diary["records"].forEach( function(record) { ["tags"].forEach(function(key) { diff --git a/src/Sleepmeter/test.js b/src/Sleepmeter/test.js index 4d84a18..e35db41 100644 --- a/src/Sleepmeter/test.js +++ b/src/Sleepmeter/test.js @@ -1,4 +1,17 @@ register_roundtrip_modifier("Sleepmeter",function(our_diary,roundtripped_diary,other_format) { + switch ( other_format.name ) { + case "GoogleHealth": + [our_diary,roundtripped_diary].forEach(function(diary) { + diary["records"] = diary["records"].filter( function(record) { + return ( + record["status"] == "asleep" + && record["start"] !== undefined + && record["end"] !== undefined + && record["end"] > record["start"] + ); + }); + }); + } switch ( other_format.name ) { case "ActivityLog": case "SleepChart1": @@ -6,6 +19,7 @@ register_roundtrip_modifier("Sleepmeter",function(our_diary,roundtripped_diary,o case "SpreadsheetGraph": case "SpreadsheetTable": case "Fitbit": + case "GoogleHealth": [our_diary,roundtripped_diary].forEach(function(diary) { diary["records"].forEach( function(record) { /* @@ -24,6 +38,7 @@ register_roundtrip_modifier("Sleepmeter",function(our_diary,roundtripped_diary,o case "SleepChart1": case "PleesTracker": case "Fitbit": + case "GoogleHealth": [our_diary,roundtripped_diary].forEach(function(diary) { diary["records"].forEach( function(record) { /* diff --git a/src/SpreadsheetGraph/test.js b/src/SpreadsheetGraph/test.js index 0cad87a..b45e63d 100644 --- a/src/SpreadsheetGraph/test.js +++ b/src/SpreadsheetGraph/test.js @@ -13,10 +13,27 @@ register_roundtrip_modifier("SpreadsheetGraph",function(our_diary,roundtripped_d }); } switch ( other_format.name ) { + case "GoogleHealth": + [our_diary,roundtripped_diary].forEach(function(diary) { + diary["records"] = diary["records"].filter( function(record) { + return ( + record["status"] == "asleep" + && record["start"] !== undefined + && record["end"] !== undefined + && record["end"] > record["start"] + ); + }); + diary["records"].forEach( function(record) { + delete record["missing_record_after"]; + }); + }); + } + switch ( other_format.name ) { case "ActivityLog": case "SleepChart1": case "PleesTracker": case "Fitbit": + case "GoogleHealth": [our_diary,roundtripped_diary].forEach(function(diary) { diary["records"].forEach( function(record) { /* diff --git a/src/SpreadsheetTable/test.js b/src/SpreadsheetTable/test.js index 8475813..10379d9 100644 --- a/src/SpreadsheetTable/test.js +++ b/src/SpreadsheetTable/test.js @@ -1,9 +1,28 @@ register_roundtrip_modifier("SpreadsheetTable",function(our_diary,roundtripped_diary,other_format) { + switch ( other_format.name ) { + case "GoogleHealth": + [our_diary,roundtripped_diary].forEach(function(diary) { + diary["records"] = diary["records"].filter( function(record) { + return ( + record["status"] == "asleep" + && record["start"] !== undefined + && record["end"] !== undefined + && record["end"] > record["start"] + ); + }); + diary["records"].forEach( function(record) { + ["duration","day_number","is_primary_sleep","start_of_new_day"].forEach(function(key) { + delete record[key]; + }); + }); + }); + } switch ( other_format.name ) { case "ActivityLog": case "SleepChart1": case "PleesTracker": case "Fitbit": + case "GoogleHealth": [our_diary,roundtripped_diary].forEach(function(diary) { diary["records"].forEach( function(record) { /* diff --git a/src/Standard/test.js b/src/Standard/test.js index ed739de..3cc5de1 100644 --- a/src/Standard/test.js +++ b/src/Standard/test.js @@ -23,12 +23,26 @@ register_roundtrip_modifier("Standard",function(our_diary,roundtripped_diary,oth }); } switch ( other_format.name ) { + case "GoogleHealth": + [our_diary,roundtripped_diary].forEach(function(diary) { + diary["records"] = diary["records"].filter( function(record) { + return ( + record["status"] == "asleep" + && record["start"] !== undefined + && record["end"] !== undefined + && record["end"] > record["start"] + ); + }); + }); + } + switch ( other_format.name ) { case "ActivityLog": case "SpreadsheetGraph": case "SpreadsheetTable": case "PleesTracker": case "SleepChart1": case "Fitbit": + case "GoogleHealth": [our_diary,roundtripped_diary].forEach(function(diary) { diary["records"].forEach( function(record) { /* @@ -57,6 +71,7 @@ register_roundtrip_modifier("Standard",function(our_diary,roundtripped_diary,oth case "Sleepmeter": case "SleepAsAndroid": case "Fitbit": + case "GoogleHealth": our_diary["records"].forEach( function(r,n) { /* * These formats converts missing timezones to Etc/GMT, which can also be specified manually. @@ -77,6 +92,7 @@ register_roundtrip_modifier("Standard",function(our_diary,roundtripped_diary,oth case "SleepChart1": case "PleesTracker": case "Fitbit": + case "GoogleHealth": [our_diary,roundtripped_diary].forEach(function(diary) { diary["records"].forEach( function(record) { ["comments"].forEach(function(key) {