// Doesn't require special permission, just follow setup and authorize // Original script by John McLaughlin (loghound@gmail.com) // Modifications - Simon Bromberg (http://sbromberg.com) // Modifications - Mark Leavitt (PDX Quantified Self organizer) www.markleavitt.com // Modifications 2020 - Jozef Jarosciak - joe0.com // Modifications 2022 - Josh Kybett - JKybett.uk // -Replaced discontinued UiApp code to use HtmlService instead. // -Replace deprecated v1 FitBit API with current standard v2 FitBit API // -Now fetches data using daily summaries rather than per-item ranges to avoid hitting API limits when getting single-day data. // -Adapted to get data for more features of FitBit. // -Friendlier setup UI. // // Current version on GitHub: https://github.com/JKybett/GoogleFitBit/blob/main/FitBit.gs // // This is a free script: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // Copyright (c) 2022 JKybett /* * Do not change these key names. These are just keys to access these properties once you set them up by running the Setup function from the Fitbit menu */ // Key of ScriptProperty for Firtbit consumer key. var CONSUMER_KEY_PROPERTY_NAME = "fitbitConsumerKey"; // Key of ScriptProperty for Fitbit consumer secret. var CONSUMER_SECRET_PROPERTY_NAME = "fitbitConsumerSecret"; var SERVICE_IDENTIFIER = 'fitbit'; // usually do not need to change this either // List of all things this script logs var LOGGABLES = ["activeScore", "activityCalories", "caloriesBMR", "caloriesOut", "elevation", "fairlyActiveMinutes", "floors", "lightlyActiveMinutes", "marginalCalories", "sedentaryMinutes", "steps", "veryActiveMinutes", "bmi", "weight", "awakeCount", "awakeDuration", "awakeningsCount", "duration", "efficiency", "endTime", "minutesAfterWakeup", "minutesAsleep", "minutesAwake", "minutesToFallAsleep", "restlessCount", "restlessDuration", "startTime", "timeInBed", "calories", "carbs", "fat", "fiber", "protein", "sodium", "water" ]; //List of loggables that come from the activities section of API var LOGGABLE_ACTIVITIES = [ "activeScore", "activityCalories", "caloriesBMR", "caloriesOut", "elevation", "fairlyActiveMinutes", "floors", "lightlyActiveMinutes", "marginalCalories", "sedentaryMinutes", "steps", "veryActiveMinutes" ]; //List of loggables that come from the weight section of API var LOGGABLE_WEIGHT = [ "bmi", "weight" ]; //List of loggables that come from the sleep section of API var LOGGABLE_SLEEP = [ "awakeCount", "awakeDuration", "awakeningsCount", "duration", "efficiency", "endTime", "minutesAfterWakeup", "minutesAsleep", "minutesAwake", "minutesToFallAsleep", "restlessCount", "restlessDuration", "startTime", "timeInBed" ]; //List of loggables that come from the food section of API var LOGGABLE_FOOD = [ "calories", "carbs", "fat", "fiber", "protein", "sodium", "water" ]; /* Used to display information to the user via cell B3 to let them know that scripts are actively running. */ function working(stepStr = "Working"){ getSheet().getRange("R3C2").setValue(stepStr); } /* Used to display information to the user via cell B3 to let them know that scripts have stopped actively running. */ function done(){ getSheet().getRange("R3C2").setValue("Ready"); } /* */ function isConfigured() { return getConsumerKey() != "" && getConsumerSecret() != ""; } /* */ function getProperty(key) { Logger.log("get property "+key); return PropertiesService.getScriptProperties().getProperty(key); } /* */ function setProperty(key, value) { PropertiesService.getScriptProperties().setProperty(key, value); } /* */ function getSheet(){ try { var spreadSheetID = getProperty("spreadSheetID"); console.log(spreadSheetID); var spreadSheet = SpreadsheetApp.openById(spreadSheetID.toString()); var sheetID = getProperty("sheetID"); var sheet = spreadSheet.getSheets().filter( function(s) {return s.getSheetId().toString() === sheetID.toString()} )[0]; return sheet; } catch (error) { return null; } } /* */ function setSheet(sheet){ if(sheet == null){ setProperty("sheetID", ""); setProperty("spreadSheetID", ""); } else { setProperty("sheetID", sheet.getSheetId().toString()); setProperty("spreadSheetID", sheet.getParent().getId().toString()); } } /* */ function setConsumerKey(consumerKey) { setProperty(CONSUMER_KEY_PROPERTY_NAME, consumerKey); } /* */ function getConsumerKey() { var consumer = getProperty(CONSUMER_KEY_PROPERTY_NAME); if (consumer == null) { consumer = ""; } return consumer; } /* */ function setConsumerSecret(secret) { setProperty(CONSUMER_SECRET_PROPERTY_NAME, secret); } /* */ function getConsumerSecret() { var secret = getProperty(CONSUMER_SECRET_PROPERTY_NAME); if (secret == null) { secret = ""; } return secret; } function clearService(){ OAuth2.createService(SERVICE_IDENTIFIER) .setPropertyStore(PropertiesService.getUserProperties()) .reset(); setConsumerKey(""); setConsumerSecret(""); setSheet(null); } function getFitbitService() { // Create a new service with the given name. The name will be used when // persisting the authorized token, so ensure it is unique within the // scope of the property store if (!isConfigured()) { setup(); return; } return OAuth2.createService(SERVICE_IDENTIFIER) // Set the endpoint URLs, which are the same for all Google services. .setAuthorizationBaseUrl('https://www.fitbit.com/oauth2/authorize') .setTokenUrl('https://api.fitbit.com/oauth2/token') // Set the client ID and secret, from the Google Developers Console. .setClientId(getConsumerKey()) .setClientSecret(getConsumerSecret()) // Set the name of the callback function in the script referenced // above that should be invoked to complete the OAuth flow. .setCallbackFunction('authCallback') // Set the property store where authorized tokens should be persisted. .setPropertyStore(PropertiesService.getUserProperties()) .setScope('activity nutrition sleep weight profile settings') // but not desirable in a production application. //.setParam('approval_prompt', 'force') .setTokenHeaders({ 'Authorization': 'Basic ' + Utilities.base64Encode(getConsumerKey() + ':' + getConsumerSecret()) }); } function submitData(form) { switch(form.task){ case "setup": saveSetup(form); break; case "sync" : syncDate(new Date(form.year, form.month-1, form.day)); break; case "syncMany" : syncMany(new Date(form.firstYear, form.firstMonth-1, form.firstDay),new Date(form.secondYear, form.secondMonth-1, form.secondDay)); break; case "BackToFitBitAPI" : firstRun();break; case "FitBitAPI" : setup();break; //case "credits" : credits();break; } } // function saveSetup saves the setup params from the UI function saveSetup(e) { //problemPrompt(e.spreadSheetID); var doc = SpreadsheetApp.openById(e.spreadSheetID); if(parseInt(e.newSheet)>0){ if(e.sheetID.length<1){ e.sheetID="FitbitData"; } doc=doc.insertSheet(e.sheetID.toString()); e.sheetID = doc.getSheetId(); } var doc = SpreadsheetApp.openById(e.spreadSheetID); doc=doc.getSheets().filter( function(s) {return s.getSheetId().toString() === e.sheetID.toString();} )[0]; //problemPrompt("'"+e.sheetID+"'"); setSheet(doc); working(); doc.getRange("R2C2").setValue(new Date(e.year, e.month-1, e.day)); console.log(e); setConsumerKey(e.consumerKey); setConsumerSecret(e.consumerSecret); var i=2; var cell = doc.getRange("R4C2"); var titles = []; var wanted = []; while(!cell.isBlank()){ titles.push(cell.getValue()); cell = doc.getRange("R4C"+(++i)); wanted.push(false); } var index = -1; for (const [key, value] of Object.entries(e.loggables)) { index = titles.findIndex(e=>{return e==value}); if(index<0){ titles.push(value); wanted.push(true); } else { wanted[index]=true; } } for(i=0;i30){ problemPrompt( "Fitbit doesn't like sending too much data too quickly, so anything more than 30 days may cause issues.
If this stops working partway through, wait about an hour before trying again.", "Warning!" ); } if (days==0){ sync(secondDate); } if (days<0){ problemPrompt( "I think you got your dates the wrong way round. Please try again!" ); } else { var curDate=secondDate; while(Math.round((curDate-firstDate)/dayMil)>=0){ syncDate(curDate); curDate.setDate(curDate.getDate()-1); } } } /* function sync() is called to download all desired data from Fitbit API to the spreadsheet */ function syncDate(date = null) { working(); if(date==null){ date = new Date(); } var dateString = date.getFullYear() + '-' + ("00" + (date.getMonth() + 1)).slice(-2) + '-' + ("00" + (date.getDate())).slice(-2); var doc = getSheet(); var workingRow = rowFromDate(date); if(workingRow<5){ problemPrompt("The date given is before your defined Earliest Date. Extending before this date is not supported and causes problems."); done(); return; } working("Working row: "+workingRow); // if the user has never performed setup, do it now if (!isConfigured()) { setup(); return; } doc.setFrozenRows(4); doc.getRange("R1C1").setValue("Sheet last synced: " + new Date()); doc.getRange("R4C1").setValue("Date"); var options = { headers:{ "Authorization": 'Bearer ' + getFitbitService().getAccessToken(), "method": "GET" }}; doc.getRange("R"+workingRow+"C"+1).setValue(dateString); //ACTIVITIES if(fetchNeeded(doc,LOGGABLE_ACTIVITIES)){ result = UrlFetchApp.fetch( "https://api.fitbit.com/1/user/-/activities/date/"+dateString+".json", options ); console.log("ACTIVITIES"); var activeStats = JSON.parse(result.getContentText()); if(!logAllTheThings(doc,workingRow,activeStats["summary"])){ console.log("- active"); } } //WEIGHT if(fetchNeeded(doc,LOGGABLE_WEIGHT)){ result = UrlFetchApp.fetch( "https://api.fitbit.com/1/user/-/body/log/weight/date/"+dateString+".json", options ); console.log("WEIGHT"); var weightStats = JSON.parse(result.getContentText()); if(!logAllTheThings(doc,workingRow,weightStats["weight"][0])){ console.log("- weight"); } } //SLEEP if(fetchNeeded(doc,LOGGABLE_SLEEP)){ result = UrlFetchApp.fetch( "https://api.fitbit.com/1/user/-/sleep/date/"+dateString+".json", options ); console.log("SLEEP"); var sleepStats = JSON.parse(result.getContentText()); if(!logAllTheThings(doc,workingRow,sleepStats["sleep"][0])){ console.log("- sleep(sleep)"); } console.log("SLEEP"); if(!logAllTheThings(doc,workingRow,sleepStats["summary"])){ console.log("- sleep(summary)"); } } //FOOD if(fetchNeeded(doc,LOGGABLE_FOOD)){ result = UrlFetchApp.fetch( "https://api.fitbit.com/1/user/-/foods/log/date/"+dateString+".json", options ); console.log("FOOD"); var foodStats = JSON.parse(result.getContentText()); if(!logAllTheThings(doc,workingRow,foodStats["summary"])){ console.log("- food"); } } done(); } /* Calculates which row should be used for a particular date's data based on the user-provided earliest date that data can be from. */ function rowFromDate(date){ var dayMil = 1000 * 60 * 60 * 24; var firstDay = getSheet().getRange("R2C2").getValue(); date = (date-firstDay); date = (date-(date%dayMil))/dayMil; return date+5; } /* */ function fetchNeeded(doc,loggables){ var titles = doc.getRange("4:4").getValues(); return loggables.some(r=> titles[0].includes(r)) } /* */ function logAllTheThings(doc,row,entries){ var col; var titles = doc.getRange("4:4").getValues(); if(entries==null || entries == undefined){ console.log("Logging failure. Skipping thing."); return false; } for (const [k, v] of Object.entries(entries)) { col = titles[0].findIndex(e=>{return e==k})+1; if(col>0){ doc.getRange("R"+row+"C"+col).setValue(v); console.log(" logged:"+k); } else { console.log("unlogged:"+k); } } return true; } /* */ function firstRun(){ var doc = SpreadsheetApp.getActiveSpreadsheet(); var contentHTML=''+"\n"+ ''+"\n"+ ' '+"\n"+ ''+"\n"+ ' '+"\n"+ ' Go to https://dev.fitbit.com/apps/new

'+"\n"+ ' Login and register a new app using the following details:

'+"\n"+ '
'+"\n"+ ' Only the options that must have specific values are shown below.
'+"\n"+ ' Click here for example data you can copy and paste into the other fields.'+"\n"+ '
'+"\n"+ '
'+"\n"+ ' These options can be filled with different data. This is only an example.
'+"\n"+ ' You can hide these options if you want.'+"\n"+ '

'+"\n"+ '


'+"\n"+ '


'+"\n"+ '


'+"\n"+ '


'+"\n"+ '


'+"\n"+ '


'+"\n"+ '


'+"\n"+ '
'+"\n"+ '
'+"\n"+ ' These options must be filled with the following data.

'+"\n"+ '
'+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ '

'+"\n"+ '


'+"\n"+ '
'+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ '

'+"\n"+ '
'+"\n"+ ' Once you have accepted the terms and conditions and clicked "register", make a note of the following details on the next page:
'+"\n"+ ' '+"\n"+ ' Then click the button below to move on to the next step:'+"\n"+ '
'+"\n"+ ' '+"\n"+ ' '+"\n"+ '
'+"\n"+ ' '+"\n"+signature()+ ' '+"\n"+ ''; var app= HtmlService.createHtmlOutput().setTitle("Setup: FitBit App").setContent(contentHTML); doc.show(app); } /* */ function setup() { var doc = SpreadsheetApp.getActiveSpreadsheet(); var selected; var sheets = doc.getSheets(); var selectSheet = doc.getActiveSheet(); var earliestDate = new Date(); if(getSheet()!=null){ selectSheet = getSheet(); earliestDate = getSheet().getRange("R2C2").getValue(); } var contentHTML =''+ ''+"\n"+ ''+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ '
'+"\n"+ ' '+"\n"+ '
'+ ' '+"\n"+ '
'+ '
'+"\n"+ '
'+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ '

'+"\n\n"+ ' '+"\n"+ '
'+"\n\n"+ ' '+"\n"+ '

'+"\n\n"+ ' '+"\n"+ ' '+" -\n\n"+ ' '+" -\n\n"+ '
'+"\n\n"+ ' '+"\n"+ '

'+"\n"+ ' '+"\n"+ '
'+"\n"+ ' '+"\n"+ '

'+"\n"+ ' '+"\n"+ '
'+ ' '+"\n"+ '
'+ '
'+"\n"+ ' '+"\n"+signature()+ ' '+"\n"+ ''; var app= HtmlService.createHtmlOutput().setTitle("Setup Fitbit Download").setContent(contentHTML); doc.show(app); } function authWindow(){ var doc = SpreadsheetApp.getActiveSpreadsheet(); var service = getFitbitService(); var authorizationUrl = service.getAuthorizationUrl(); var contentHTML ='Click here to Authorize with Fitbit'+signature(); var app= HtmlService.createHtmlOutput().setTitle("Setup Fitbit Download").setContent(contentHTML); doc.show(app); } function authCallback(request) { Logger.log("authcallback"); var service = getFitbitService(); var isAuthorized = service.handleCallback(request); var app; var contentHTML; if (isAuthorized) { var displayContentHTML = 'Success! Please refresh the page .'+signature(); var displayApp= HtmlService.createHtmlOutput().setTitle("All done!").setContent(displayContentHTML); contentHTML = 'Success! You can close this tab.'; app= HtmlService.createHtmlOutput().setTitle("Authorised!").setContent(contentHTML); var doc = SpreadsheetApp.getActiveSpreadsheet(); doc.show(displayApp); } else { contentHTML = 'Authorisation was denied.
Please check your FitBit credentials and try again!'; app= HtmlService.createHtmlOutput().setTitle("Oh no!").setContent(contentHTML); } return app; } function syncCustom(){ var doc = SpreadsheetApp.getActiveSpreadsheet(); var contentHTML =''+ ''+"\n"+ ''+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+"\n"+ '
'+"\n"+ ' '+"\n"+ ' '+"\n"+ ' '+" -\n\n"+ ' '+" -\n\n"+ '
'+"\n\n"+ ' '+"\n"+ '
'+"\n"+ ' '+"\n"+signature()+ ' '+"\n"+ ''; var app= HtmlService.createHtmlOutput().setTitle("Sync Specific Day").setContent(contentHTML); doc.show(app); } function problemPrompt(problem="Undefined problem.", pTitle = "There was a problem!"){ var doc = SpreadsheetApp.getActiveSpreadsheet(); var contentHTML =''+ ''+"\n"+ ''+"\n"+ ' '+"\n"+ '

Something went wrong! Here\'s the message from the code:

'+"\n"+ ' '+problem+''+"\n"+ '

This is just to let you know. You can close this window if you want.

'+"\n"+signature()+ ' '+"\n"+ ''; var app= HtmlService.createHtmlOutput().setTitle(pTitle).setContent(contentHTML); doc.show(app); } function signature(){ return "

By JKybett
"; } function credits(){ var doc = SpreadsheetApp.getActiveSpreadsheet(); var contentHTML =''+ ''+"\n"+ ''+"\n"+ ' '+"\n"+ '

Something went wrong! Here\'s the message from the code:

'+"\n"+ ' '+problem+''+"\n"+ '

This is just to let you know. You can close this window if you want.

'+"\n"+signature()+ ' '+"\n"+ ''; var app= HtmlService.createHtmlOutput().setTitle("").setContent(contentHTML); doc.show(app); } // function onOpen is called when the spreadsheet is opened; adds the Fitbit menu function onOpen() { var ss = SpreadsheetApp.getActiveSpreadsheet(); date = new Date(); var dateString = date.getFullYear() + '-' + ("00" + (date.getMonth() + 1)).slice(-2) + '-' + ("00" + (date.getDate())).slice(-2); var menuEntries = [{ name: "Setup", functionName: "firstRun" }]; if(isConfigured()){ menuEntries = [{ name: "Sync Today (" + dateString + ")", functionName: "sync" }, { name: "Sync (custom Date)", functionName: "syncCustom" }, { name: "Setup", functionName: "setup" }, { name: "Reset", functionName: "clearService" }]; } ss.addMenu("Fitbit", menuEntries); }