From 734c1ba205747c6b33c368ae7b5655ae32b8f87b Mon Sep 17 00:00:00 2001 From: wellivea1 <17185653+wellivea1@users.noreply.github.com> Date: Mon, 20 Apr 2026 05:39:07 -0400 Subject: [PATCH 1/3] Add Fitbit API import flow to dashboard --- src/App.vue | 1 - src/diary_manager.js | 3 + src/fitbit_api.js | 271 ++++++++++++++++++++++++++++++++++ src/router/index.js | 9 ++ src/views/About.vue | 4 + src/views/Info.vue | 9 ++ src/views/Load.vue | 344 ++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 src/fitbit_api.js diff --git a/src/App.vue b/src/App.vue index 886d0d3..ab60f77 100644 --- a/src/App.vue +++ b/src/App.vue @@ -137,7 +137,6 @@ export default { document.title = this.section + ' - Sleep Diary Dashboard'; router.afterEach( to => { - this.busy = false; this.section = to.name; document.title = this.section + ' - Sleep Diary Dashboard'; }); diff --git a/src/diary_manager.js b/src/diary_manager.js index 7b188f9..e2c9523 100644 --- a/src/diary_manager.js +++ b/src/diary_manager.js @@ -126,6 +126,9 @@ export default { }) ; }, + add_diary_contents(contents) { + diary_loader.load(contents); + }, remove_diary(id) { for ( let n=0; n!=diaries.length; ++n ) { if ( diaries[n][0] == id ) { diff --git a/src/fitbit_api.js b/src/fitbit_api.js new file mode 100644 index 0000000..d03ae1e --- /dev/null +++ b/src/fitbit_api.js @@ -0,0 +1,271 @@ +"use strict"; + +const FITBIT_AUTHORIZE_URL = "https://www.fitbit.com/oauth2/authorize"; +const FITBIT_TOKEN_URL = "https://api.fitbit.com/oauth2/token"; +const FITBIT_API_URL = "https://api.fitbit.com"; +const FITBIT_SCOPE = "sleep"; +const PKCE_VERIFIER_KEY = "dashboard.fitbit.pkce_verifier"; +const PKCE_STATE_KEY = "dashboard.fitbit.oauth_state"; + +function fitbit_redirect_uri() { + return window.location.href.split(/[?#]/)[0]; +} + +function random_string(length=128) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", + bytes = crypto.getRandomValues(new Uint8Array(length)) + ; + return Array.from(bytes, byte => alphabet[byte % alphabet.length]).join(""); +} + +async function pkce_challenge(verifier) { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(verifier), + ); + return btoa(String.fromCharCode.apply(null,Array.from(new Uint8Array(digest)))) + .replace(/\+/g,"-") + .replace(/\//g,"_") + .replace(/=+$/,"") + ; +} + +async function fitbit_token_request(params) { + const response = await fetch( + FITBIT_TOKEN_URL, + { + "method": "POST", + "headers": { + "Content-Type": "application/x-www-form-urlencoded", + }, + "body": params.toString(), + }, + ); + if ( !response.ok ) { + throw new Error( + "Fitbit token request failed: " + + response.status + + " " + + await response.text() + ); + } + return response.json(); +} + +async function fitbit_fetch_json(url,access_token) { + const response = await fetch( + url, + { + "headers": { + "Authorization": "Bearer " + access_token, + }, + "mode": "cors", + }, + ); + if ( !response.ok ) { + throw new Error( + "Fitbit API request failed: " + + response.status + + " " + + await response.text() + ); + } + const json = await response.json(); + if ( json.meta && json.meta.state ) { + throw new Error("Fitbit API returned: " + json.meta.state); + } + return json; +} + +function get_first_defined_value(record,keys) { + for ( let n=0; n!=keys.length; ++n ) { + const value = record[keys[n]]; + if ( value !== undefined && value !== null && value !== "" ) { + return value; + } + } +} + +function get_fitbit_record_date(record) { + const explicit_date = get_first_defined_value(record,["dateOfSleep","DateOfSleep"]); + if ( explicit_date ) return explicit_date; + const end_time = get_first_defined_value(record,["endTime","EndDate"]); + if ( end_time && end_time.split ) return end_time.split("T")[0]; + const start_time = get_first_defined_value(record,["startTime","StartDate"]); + if ( start_time && start_time.split ) return start_time.split("T")[0]; + return ""; +} + +function get_fitbit_record_sort_time(record) { + const end_time = parse_fitbit_timestamp( get_first_defined_value(record,["endTime","EndDate"]) ); + if ( !isNaN(end_time) ) return end_time; + const start_time = parse_fitbit_timestamp( get_first_defined_value(record,["startTime","StartDate"]) ); + return isNaN(start_time) ? 0 : start_time; +} + +function parse_fitbit_timestamp(value) { + if ( !value ) return NaN; + value = new Date(value).getTime(); + return isNaN(value) ? NaN : value; +} + +function add_days_to_iso_date(date_string,days) { + const date = new Date(date_string + "T00:00:00"); + date.setDate(date.getDate() + days); + return date.toISOString().slice(0,10); +} + +function get_fitbit_record_key(record) { + const log_id = get_first_defined_value(record,["logId","LogId"]); + if ( log_id !== undefined && log_id !== null && log_id !== "" ) { + return "log:" + log_id; + } + return [ + get_fitbit_record_date(record), + get_first_defined_value(record,["startTime","StartDate"]) || "", + get_first_defined_value(record,["endTime","EndDate"]) || "", + ].join("|"); +} + +export function get_fitbit_callback_code() { + return new URL(window.location.href).searchParams.get("code"); +} + +export function get_fitbit_callback_error() { + const url = new URL(window.location.href), + error = url.searchParams.get("error") + ; + if ( !error ) return ""; + return url.searchParams.get("error_description") || error; +} + +export function get_fitbit_callback_state() { + return new URL(window.location.href).searchParams.get("state"); +} + +export function clear_fitbit_callback_code() { + const url = new URL(window.location.href), + hash = window.location.hash + ; + url.searchParams.delete("code"); + url.searchParams.delete("state"); + url.searchParams.delete("error"); + url.searchParams.delete("error_description"); + window.history.replaceState( + {}, + "", + url.pathname + ( url.search ? url.search : "" ) + hash, + ); +} + +export function get_fitbit_redirect_uri() { + return fitbit_redirect_uri(); +} + +export function validate_fitbit_callback_state(state) { + try { + const expected_state = window.sessionStorage.getItem(PKCE_STATE_KEY); + window.sessionStorage.removeItem(PKCE_STATE_KEY); + return !!state && !!expected_state && state == expected_state; + } catch (e) { + return false; + } +} + +export async function start_fitbit_auth(client_id) { + if ( !client_id ) { + throw new Error("Please enter a Fitbit Client ID."); + } + const verifier = random_string(), + challenge = await pkce_challenge(verifier), + state = random_string(64), + params = new URLSearchParams({ + "response_type": "code", + "client_id": client_id, + "redirect_uri": fitbit_redirect_uri(), + "scope": FITBIT_SCOPE, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + }) + ; + window.sessionStorage.setItem(PKCE_VERIFIER_KEY,verifier); + window.sessionStorage.setItem(PKCE_STATE_KEY,state); + window.location.href = FITBIT_AUTHORIZE_URL + "?" + params.toString(); +} + +export async function exchange_fitbit_code(client_id,code) { + const verifier = window.sessionStorage.getItem(PKCE_VERIFIER_KEY); + if ( !verifier ) { + throw new Error("No Fitbit PKCE verifier was found in this browser session."); + } + window.sessionStorage.removeItem(PKCE_VERIFIER_KEY); + return fitbit_token_request( + new URLSearchParams({ + "client_id": client_id, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": fitbit_redirect_uri(), + "code_verifier": verifier, + }) + ); +} + +export async function fetch_fitbit_sleep_range(access_token,start,end,progress_callback) { + let before_date = add_days_to_iso_date(end,1), + records = [], + pages = 0, + seen = {} + ; + + /* + * Fitbit's sleep/list pagination can stop early without returning a next + * cursor even when older records still exist. To avoid silently missing + * days, keep restarting the query with beforeDate set to the oldest + * dateOfSleep seen in the previous batch. + */ + while ( before_date ) { + let url = ( + FITBIT_API_URL + + "/1.2/user/-/sleep/list.json?beforeDate=" + + encodeURIComponent(before_date) + + "&sort=desc&offset=0&limit=100" + ), + oldest_date_in_batch = "" + ; + + while ( url ) { + const json = await fitbit_fetch_json(url,access_token), + raw_records = json.sleep || [] + ; + + raw_records.forEach( record => { + const record_date = get_fitbit_record_date(record); + if ( record_date && ( !oldest_date_in_batch || record_date < oldest_date_in_batch ) ) { + oldest_date_in_batch = record_date; + } + if ( record_date < start || record_date > end ) return; + const key = get_fitbit_record_key(record); + if ( seen[key] ) return; + seen[key] = true; + records.push(record); + }); + + ++pages; + + if ( progress_callback ) { + progress_callback(records.length,pages); + } + + url = json.pagination && json.pagination.next ? json.pagination.next : ""; + } + + if ( !oldest_date_in_batch || oldest_date_in_batch >= before_date ) break; + if ( oldest_date_in_batch <= start ) break; + before_date = oldest_date_in_batch; + } + + records.sort( (a,b) => get_fitbit_record_sort_time(b) - get_fitbit_record_sort_time(a) ); + + return { "sleep": records }; +} diff --git a/src/router/index.js b/src/router/index.js index 86d7bd4..9ae7394 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -34,4 +34,13 @@ const router = new VueRouter({ routes }); +router.beforeEach((to,from,next) => { + const search = new URL(window.location.href).searchParams; + if ( ( search.get("code") || search.get("error") ) && to.name !== 'Load' ) { + next({ path: '/' }); + } else { + next(); + } +}); + export default router; diff --git a/src/views/About.vue b/src/views/About.vue index 9094f73..e2fb02d 100644 --- a/src/views/About.vue +++ b/src/views/About.vue @@ -61,6 +61,10 @@ site_url: SITE_URL, }), + mounted() { + this.$emit("idle"); + }, + } diff --git a/src/views/Info.vue b/src/views/Info.vue index 4956ecc..0d0248b 100644 --- a/src/views/Info.vue +++ b/src/views/Info.vue @@ -1099,6 +1099,14 @@ this.$router.replace({ path: '/' }); } }); + diary_manager.add_permanent_callback( 'info', () => { + if ( diary_manager.get_diaries().length ) { + this.update_diary(); + } else if ( this.$route.path != '/' ) { + this.$emit("idle"); + this.$router.replace({ path: '/' }); + } + }); }, watch: { @@ -1146,6 +1154,7 @@ }, update_diary() { + this.$emit("busy"); this.worker.postMessage([ 'diary', diary_manager.merge_diaries().to("storage-line") diff --git a/src/views/Load.vue b/src/views/Load.vue index 7c5df0c..e2c4c30 100644 --- a/src/views/Load.vue +++ b/src/views/Load.vue @@ -55,9 +55,24 @@ color="primary" @click="$refs.opener.click()" > - Add a diary or spreadsheet + Add a diary, export, or spreadsheet +
+ + Import from Fitbit API + +
+ +

+ Google/Fitbit exports can be incomplete or slow.
+ You can also import sleep records directly from the Fitbit API. +

+

Don't have a diary yet?
Create a diary or try an example @@ -119,6 +134,178 @@ + + + + + Import from Fitbit API + + + +

+ Create a Fitbit Personal app, then register this exact callback URL in Fitbit: +

+

+ {{fitbit_redirect_uri}} +

+

+ The redirect URI must exactly match a callback URL registered with Fitbit, including the trailing slash. +

+ + + + + + + + + Cancel + + + OK + + + + + + + + + + Cancel + + + OK + + + + +

+ Sleep Diary only requests the sleep scope, and your Fitbit data stays in your browser. +

+ + + + + Cancel + + + mdi-cloud-download-outline + Connect + + + + + + + + + + + Could not import from Fitbit + + + + {{fitbit_error_message}} + + + + + mdi-close + Close + + + + + + r.json() ) .then( j => this.common_sleep_diaries = j ); + this.restore_fitbit_settings(); + this.process_fitbit_callback(); }, methods: { + get_fitbit_range() { + return [ + this.fitbit_start_date, + this.fitbit_end_date, + ].filter( value => value ).slice().sort(); + }, + set_fitbit_error(message) { + this.fitbit_error_message = message; + this.fitbit_error_popup = true; + }, + restore_fitbit_settings() { + try { + this.fitbit_client_id = sessionStorage.getItem(FITBIT_CLIENT_ID_KEY) || this.fitbit_client_id; + const start = sessionStorage.getItem(FITBIT_RANGE_START_KEY), + end = sessionStorage.getItem(FITBIT_RANGE_END_KEY) + ; + if ( start && end ) { + this.fitbit_start_date = start; + this.fitbit_end_date = end; + } + } catch (e) { + // Ignore browsers that block storage access. + } + }, + async process_fitbit_callback() { + const callback_error = get_fitbit_callback_error(), + code = get_fitbit_callback_code(), + state = get_fitbit_callback_state() + ; + if ( !code && !callback_error ) return; + + clear_fitbit_callback_code(); + + if ( callback_error ) { + return this.set_fitbit_error("Fitbit authorization failed: " + callback_error); + } + + if ( !validate_fitbit_callback_state(state) ) { + return this.set_fitbit_error("Fitbit authorization could not be verified. Please try again."); + } + + this.restore_fitbit_settings(); + + if ( !this.fitbit_client_id ) { + return this.set_fitbit_error("No Fitbit Client ID was saved for this authorization."); + } + + const range = this.get_fitbit_range(); + if ( range.length != 2 ) { + return this.set_fitbit_error("Please choose a start and end date."); + } + + this.fitbit_loading = true; + this.$emit("busy"); + + try { + const token = await exchange_fitbit_code(this.fitbit_client_id,code), + sleep_data = await fetch_fitbit_sleep_range( + token.access_token, + range[0], + range[1], + ) + ; + + diary_manager.add_diary_contents(JSON.stringify(sleep_data)); + this.fitbit_popup = false; + } catch (error) { + this.$emit("idle"); + this.set_fitbit_error(error && error.message ? error.message : "Fitbit import failed."); + } finally { + this.fitbit_loading = false; + try { + sessionStorage.removeItem(FITBIT_RANGE_START_KEY); + sessionStorage.removeItem(FITBIT_RANGE_END_KEY); + } catch (e) { + // Ignore storage cleanup failures. + } + } + }, on_diary_load(is_only_diary,is_error) { - this.$emit("idle"); if ( is_error ) { + this.$emit("idle"); this.error = true; } else if ( is_only_diary ) { this.$router.push({ path: '/info' }); } else { + this.$emit("idle"); ++this.trigger_rebuild; } }, @@ -237,6 +547,36 @@ export default { this.$emit("busy"); diary_manager.add_demo('/resources/common_sleep_diaries/'+filename); }, + async start_fitbit_import() { + const range = this.get_fitbit_range(); + + if ( range.length != 2 ) { + return this.set_fitbit_error("Please choose a start and end date."); + } + + try { + window.localStorage.setItem(FITBIT_CLIENT_ID_KEY,this.fitbit_client_id); + } catch (e) { + // Ignore browsers that block storage access. + } + + try { + window.sessionStorage.setItem(FITBIT_CLIENT_ID_KEY,this.fitbit_client_id); + window.sessionStorage.setItem(FITBIT_RANGE_START_KEY,range[0]); + window.sessionStorage.setItem(FITBIT_RANGE_END_KEY,range[1]); + } catch (e) { + return this.set_fitbit_error("Session storage is required for Fitbit API import."); + } + + this.fitbit_loading = true; + + try { + await start_fitbit_auth(this.fitbit_client_id); + } catch (error) { + this.fitbit_loading = false; + this.set_fitbit_error(error && error.message ? error.message : "Could not start Fitbit authorization."); + } + }, }, } From 6c92179c02bc4d25faabdcfd044642b24fe0e040 Mon Sep 17 00:00:00 2001 From: wellivea1 <17185653+wellivea1@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:10:16 -0400 Subject: [PATCH 2/3] Add Google Health import flow --- README.md | 10 ++ src/fitbit_api.js | 9 +- src/google_health_api.js | 198 +++++++++++++++++++++++++ src/health_import.js | 72 +++++++++ src/views/Info.vue | 3 + src/views/Load.vue | 311 ++++++++++++++++++++++++--------------- 6 files changed, 486 insertions(+), 117 deletions(-) create mode 100644 src/google_health_api.js create mode 100644 src/health_import.js diff --git a/README.md b/README.md index c2c07c6..db106a1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ As part of the [Sleep Diary Project](https://sleepdiary.github.io/), this reposi ## Get Involved +## Configuration + +To enable the hosted Google Health import without asking each user for an OAuth Client ID, build the dashboard with: + +```text +VUE_APP_GOOGLE_HEALTH_CLIENT_ID= +``` + +The Google Cloud OAuth client must be a Web application with `https://sleepdiary.github.io` in its authorized JavaScript origins. Local development can omit this value; the import dialog will then ask for a Client ID. + ### I found a bug, how should I tell you? [Create a new bug report](https://github.com/sleepdiary/dashboard/issues/new?assignees=&labels=&template=bug_report.md&title=) and we'll get right on it. diff --git a/src/fitbit_api.js b/src/fitbit_api.js index d03ae1e..b945e49 100644 --- a/src/fitbit_api.js +++ b/src/fitbit_api.js @@ -110,8 +110,13 @@ function parse_fitbit_timestamp(value) { } function add_days_to_iso_date(date_string,days) { - const date = new Date(date_string + "T00:00:00"); - date.setDate(date.getDate() + days); + const parts = date_string.split("-"), + date = new Date(Date.UTC( + parseInt(parts[0],10), + parseInt(parts[1],10)-1, + parseInt(parts[2],10) + days + )) + ; return date.toISOString().slice(0,10); } diff --git a/src/google_health_api.js b/src/google_health_api.js new file mode 100644 index 0000000..1c2fe68 --- /dev/null +++ b/src/google_health_api.js @@ -0,0 +1,198 @@ +"use strict"; + +const GOOGLE_IDENTITY_SERVICES_URL = "https://accounts.google.com/gsi/client"; +const GOOGLE_HEALTH_API_URL = "https://health.googleapis.com/v4"; +const GOOGLE_HEALTH_SCOPE = "https://www.googleapis.com/auth/googlehealth.sleep.readonly"; + +let google_identity_services_loading; + +function add_days_to_iso_date(date_string,days) { + const parts = date_string.split("-"), + date = new Date(Date.UTC( + parseInt(parts[0],10), + parseInt(parts[1],10)-1, + parseInt(parts[2],10) + days + )) + ; + return date.toISOString().slice(0,10); +} + +function get_google_health_error_message(error) { + if ( !error ) return "Google Health authorization failed."; + if ( error.message ) return error.message; + if ( error.type ) return "Google Health authorization failed: " + error.type; + if ( error.error ) return error.error_description || error.error; + return "Google Health authorization failed."; +} + +function google_identity_services_ready() { + return !!( + window.google + && window.google.accounts + && window.google.accounts.oauth2 + && window.google.accounts.oauth2.initTokenClient + ); +} + +export function load_google_health_auth() { + if ( google_identity_services_ready() ) return Promise.resolve(); + + if ( !google_identity_services_loading ) { + google_identity_services_loading = new Promise((resolve,reject) => { + const existing_script = document.querySelector( + 'script[src="' + GOOGLE_IDENTITY_SERVICES_URL + '"]' + ); + if ( existing_script ) { + existing_script.addEventListener("load",() => resolve()); + existing_script.addEventListener("error",() => reject(new Error("Could not load Google Identity Services."))); + return; + } + + const script = document.createElement("script"); + script.src = GOOGLE_IDENTITY_SERVICES_URL; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Could not load Google Identity Services.")); + document.head.appendChild(script); + }).then(() => { + if ( !google_identity_services_ready() ) { + throw new Error("Google Identity Services did not initialise."); + } + }); + } + + return google_identity_services_loading; +} + +export async function start_google_health_auth(client_id) { + if ( !client_id ) { + throw new Error("Please enter a Google OAuth Client ID."); + } + + await load_google_health_auth(); + + return new Promise((resolve,reject) => { + const client = window.google.accounts.oauth2.initTokenClient({ + "client_id": client_id, + "scope": GOOGLE_HEALTH_SCOPE, + "callback": response => { + if ( response && response.access_token ) { + resolve(response); + } else { + reject(new Error(get_google_health_error_message(response))); + } + }, + "error_callback": error => reject(new Error(get_google_health_error_message(error))), + }); + + client.requestAccessToken(); + }); +} + +async function google_health_fetch_json(url,access_token) { + const response = await fetch( + url, + { + "headers": { + "Accept": "application/json", + "Authorization": "Bearer " + access_token, + }, + "mode": "cors", + }, + ); + if ( !response.ok ) { + throw new Error( + "Google Health API request failed: " + + response.status + + " " + + await response.text() + ); + } + return response.json(); +} + +function get_google_health_sleep_interval(record) { + return record && record.sleep && record.sleep.interval ? record.sleep.interval : {}; +} + +function get_google_health_sleep_time(record,key) { + const interval = get_google_health_sleep_interval(record), + time = new Date(interval[key] || 0).getTime() + ; + return isNaN(time) ? 0 : time; +} + +function get_google_health_sleep_key(record) { + const interval = get_google_health_sleep_interval(record); + if ( record.name ) return record.name; + return [ + interval.startTime || "", + interval.endTime || "", + ((record.dataSource||{}).device||{}).displayName || "", + (record.dataSource||{}).platform || "", + ].join("|"); +} + +export async function fetch_google_health_sleep_range(access_token,start,end,progress_callback) { + const exclusive_end = add_days_to_iso_date(end,1), + filter = ( + 'sleep.interval.civil_end_time >= "' + + start + + '" AND sleep.interval.civil_end_time < "' + + exclusive_end + + '"' + ) + ; + + let records = [], + pages = 0, + seen = {}, + page_token = "" + ; + + do { + const params = new URLSearchParams({ + "pageSize": "25", + "filter": filter, + }); + if ( page_token ) params.set("pageToken",page_token); + + const json = await google_health_fetch_json( + GOOGLE_HEALTH_API_URL + + "/users/me/dataTypes/sleep/dataPoints?" + + params.toString(), + access_token, + ); + + (json.dataPoints || []).forEach( record => { + const key = get_google_health_sleep_key(record); + if ( seen[key] ) return; + seen[key] = true; + records.push(record); + }); + + ++pages; + + if ( progress_callback ) { + progress_callback(records.length,pages); + } + + page_token = json.nextPageToken || ""; + } while ( page_token ); + + records.sort( + (a,b) => ( + get_google_health_sleep_time(b,"startTime") + - get_google_health_sleep_time(a,"startTime") + ) || ( + get_google_health_sleep_time(b,"endTime") + - get_google_health_sleep_time(a,"endTime") + ) + ); + + return { "dataPoints": records }; +} + +export function get_google_health_scope() { + return GOOGLE_HEALTH_SCOPE; +} diff --git a/src/health_import.js b/src/health_import.js new file mode 100644 index 0000000..db1693f --- /dev/null +++ b/src/health_import.js @@ -0,0 +1,72 @@ +"use strict"; + +import { + clear_fitbit_callback_code, + exchange_fitbit_code, + fetch_fitbit_sleep_range, + get_fitbit_callback_code, + get_fitbit_callback_error, + get_fitbit_callback_state, + get_fitbit_redirect_uri, + start_fitbit_auth, + validate_fitbit_callback_state, +} from "@/fitbit_api.js"; + +import { + fetch_google_health_sleep_range, + get_google_health_scope, + load_google_health_auth, + start_google_health_auth, +} from "@/google_health_api.js"; + +const GOOGLE_HEALTH_CLIENT_ID = process.env.VUE_APP_GOOGLE_HEALTH_CLIENT_ID || ""; + +export const HEALTH_IMPORT_PROVIDERS = [ + { + "id": "google_health", + "title": "Google Health", + "short_title": "Google Health", + "client_id": GOOGLE_HEALTH_CLIENT_ID, + "client_id_label": "OAuth Client ID", + "client_id_hint": "Paste the Google OAuth Web Client ID for this dashboard", + "client_id_storage_key": "dashboard.google_health.client_id", + "range_start_storage_key": "dashboard.google_health.range.start", + "range_end_storage_key": "dashboard.google_health.range.end", + "setup_lines": GOOGLE_HEALTH_CLIENT_ID ? [] : [ + "Create a Google Cloud Web application client, enable Google Health API, and add this site as an authorized JavaScript origin:", + "https://sleepdiary.github.io", + ], + "scope_note": "Sleep Diary requests only the " + get_google_health_scope() + " scope, and your Google Health data stays in your browser.", + "preload": load_google_health_auth, + "start_auth": start_google_health_auth, + "fetch_sleep_range": fetch_google_health_sleep_range, + }, + { + "id": "fitbit", + "title": "Fitbit Web API", + "short_title": "Fitbit", + "client_id_label": "Client ID", + "client_id_hint": "Paste the Fitbit OAuth 2.0 Client ID for your Personal app", + "client_id_storage_key": "dashboard.fitbit.client_id", + "range_start_storage_key": "dashboard.fitbit.range.start", + "range_end_storage_key": "dashboard.fitbit.range.end", + "setup_lines": [ + "Create a Fitbit Personal app, then register this exact callback URL in Fitbit:", + get_fitbit_redirect_uri(), + "The redirect URI must exactly match a callback URL registered with Fitbit, including the trailing slash.", + ], + "scope_note": "Sleep Diary only requests the sleep scope, and your Fitbit data stays in your browser. Fitbit Web API is a legacy path scheduled for deprecation in September 2026.", + "get_callback_code": get_fitbit_callback_code, + "get_callback_error": get_fitbit_callback_error, + "get_callback_state": get_fitbit_callback_state, + "clear_callback": clear_fitbit_callback_code, + "validate_callback_state": validate_fitbit_callback_state, + "start_auth": start_fitbit_auth, + "exchange_code": exchange_fitbit_code, + "fetch_sleep_range": fetch_fitbit_sleep_range, + }, +]; + +export function get_health_import_provider(id) { + return HEALTH_IMPORT_PROVIDERS.filter( provider => provider.id == id )[0] || HEALTH_IMPORT_PROVIDERS[0]; +} diff --git a/src/views/Info.vue b/src/views/Info.vue index 0d0248b..bc4e4a2 100644 --- a/src/views/Info.vue +++ b/src/views/Info.vue @@ -980,6 +980,9 @@ }), mounted() { + this.worker.onerror = event => { + this.$emit("error",event.message || event); + }; this.worker.onmessage = ({data}) => { const within_expected_range = ( recent, long_term, expected, range ) => ( true diff --git a/src/views/Load.vue b/src/views/Load.vue index e2c4c30..49913d1 100644 --- a/src/views/Load.vue +++ b/src/views/Load.vue @@ -62,15 +62,15 @@ - Import from Fitbit API + Import sleep data

Google/Fitbit exports can be incomplete or slow.
- You can also import sleep records directly from the Fitbit API. + You can also import sleep records directly from Google Health or the legacy Fitbit API.

@@ -135,45 +135,60 @@ - Import from Fitbit API + Import Sleep Data -

- Create a Fitbit Personal app, then register this exact callback URL in Fitbit: -

-

- {{fitbit_redirect_uri}} -

-

- The redirect URI must exactly match a callback URL registered with Fitbit, including the trailing slash. + + + {{provider.short_title}} + + + +

+ {{line}} +

Cancel OK @@ -206,17 +221,17 @@ Cancel OK @@ -249,7 +264,7 @@

- Sleep Diary only requests the sleep scope, and your Fitbit data stays in your browser. + {{health_import_provider.scope_note}}

@@ -257,7 +272,7 @@ Cancel @@ -265,9 +280,9 @@ color="primary" width="50%" text - :disabled="!fitbit_client_id || !fitbit_start_date || !fitbit_end_date || fitbit_loading" - :loading="fitbit_loading" - @click="start_fitbit_import" + :disabled="!health_import_effective_client_id || !health_import_start_date || !health_import_end_date || health_import_loading" + :loading="health_import_loading" + @click="start_health_import" > mdi-cloud-download-outline Connect @@ -278,17 +293,17 @@
- Could not import from Fitbit + Could not import sleep data - {{fitbit_error_message}} + {{health_import_error_message}} @@ -296,7 +311,7 @@ color="primary" width="100%" text - @click="fitbit_error_popup = false" + @click="health_import_error_popup = false" > mdi-close Close @@ -352,20 +367,9 @@ import diary_manager from "@/diary_manager.js"; import { DOCS_URL } from "@/constants.js"; import { - clear_fitbit_callback_code, - exchange_fitbit_code, - fetch_fitbit_sleep_range, - get_fitbit_callback_code, - get_fitbit_callback_error, - get_fitbit_callback_state, - get_fitbit_redirect_uri, - start_fitbit_auth, - validate_fitbit_callback_state, -} from "@/fitbit_api.js"; - -const FITBIT_CLIENT_ID_KEY = "dashboard.fitbit.client_id"; -const FITBIT_RANGE_START_KEY = "dashboard.fitbit.range.start"; -const FITBIT_RANGE_END_KEY = "dashboard.fitbit.range.end"; + HEALTH_IMPORT_PROVIDERS, + get_health_import_provider, +} from "@/health_import.js"; function iso_today(days_ago) { return new Date( @@ -375,9 +379,9 @@ function iso_today(days_ago) { ).toISOString().substr(0,10); } -function get_saved_fitbit_client_id() { +function get_saved_item(storage,key) { try { - return window.localStorage.getItem(FITBIT_CLIENT_ID_KEY) || ""; + return storage.getItem(key) || ""; } catch (e) { return ""; } @@ -406,22 +410,32 @@ export default { docs_url: DOCS_URL, demo_popup: false, common_sleep_diaries: 0, - fitbit_popup: false, - fitbit_loading: false, - fitbit_client_id: get_saved_fitbit_client_id(), - fitbit_start_date: iso_today(365), - fitbit_end_date: iso_today(0), - show_fitbit_start_picker: false, - show_fitbit_end_picker: false, - fitbit_redirect_uri: get_fitbit_redirect_uri(), - fitbit_error_popup: false, - fitbit_error_message: '', + health_import_providers: HEALTH_IMPORT_PROVIDERS, + health_import_provider_id: HEALTH_IMPORT_PROVIDERS[0].id, + health_import_popup: false, + health_import_loading: false, + health_import_client_id: '', + health_import_start_date: iso_today(365), + health_import_end_date: iso_today(0), + show_health_import_start_picker: false, + show_health_import_end_picker: false, + health_import_error_popup: false, + health_import_error_message: '', }), computed: { diaries() { return this.trigger_rebuild && diary_manager.get_diaries(); }, + health_import_provider() { + return get_health_import_provider(this.health_import_provider_id); + }, + health_import_effective_client_id() { + return this.get_health_import_client_id(); + }, + health_import_needs_client_id() { + return !this.health_import_provider.client_id; + }, }, mounted() { @@ -432,69 +446,105 @@ export default { fetch("/resources/common_sleep_diaries.json") .then( r => r.json() ) .then( j => this.common_sleep_diaries = j ); - this.restore_fitbit_settings(); - this.process_fitbit_callback(); + this.restore_health_import_settings(); + this.process_health_import_callback(); + }, + + watch: { + health_import_provider_id() { + this.restore_health_import_settings(); + if ( this.health_import_popup && this.health_import_provider.preload ) { + this.health_import_provider.preload().catch( () => {} ); + } + }, }, methods: { - get_fitbit_range() { + get_health_import_range() { return [ - this.fitbit_start_date, - this.fitbit_end_date, + this.health_import_start_date, + this.health_import_end_date, ].filter( value => value ).slice().sort(); }, - set_fitbit_error(message) { - this.fitbit_error_message = message; - this.fitbit_error_popup = true; + get_health_import_client_id(provider) { + provider = provider || this.health_import_provider; + return provider.client_id || this.health_import_client_id; }, - restore_fitbit_settings() { + set_health_import_error(message) { + this.health_import_error_message = message; + this.health_import_error_popup = true; + }, + is_health_import_setup_url(line) { + return /^https?:/.test(line); + }, + restore_health_import_settings(provider) { + provider = provider || this.health_import_provider; try { - this.fitbit_client_id = sessionStorage.getItem(FITBIT_CLIENT_ID_KEY) || this.fitbit_client_id; - const start = sessionStorage.getItem(FITBIT_RANGE_START_KEY), - end = sessionStorage.getItem(FITBIT_RANGE_END_KEY) + this.health_import_client_id = ( + get_saved_item(window.sessionStorage,provider.client_id_storage_key) + || get_saved_item(window.localStorage,provider.client_id_storage_key) + || "" + ); + const start = get_saved_item(window.sessionStorage,provider.range_start_storage_key), + end = get_saved_item(window.sessionStorage,provider.range_end_storage_key) ; if ( start && end ) { - this.fitbit_start_date = start; - this.fitbit_end_date = end; + this.health_import_start_date = start; + this.health_import_end_date = end; } } catch (e) { // Ignore browsers that block storage access. } }, - async process_fitbit_callback() { - const callback_error = get_fitbit_callback_error(), - code = get_fitbit_callback_code(), - state = get_fitbit_callback_state() + open_health_import_popup() { + this.restore_health_import_settings(); + this.health_import_popup = true; + if ( this.health_import_provider.preload ) { + this.health_import_provider.preload().catch( () => {} ); + } + }, + async process_health_import_callback() { + const provider = this.health_import_providers.filter( + provider => provider.get_callback_code && ( provider.get_callback_code() || provider.get_callback_error() ) + )[0]; + if ( !provider ) return; + + const callback_error = provider.get_callback_error(), + code = provider.get_callback_code(), + state = provider.get_callback_state() ; if ( !code && !callback_error ) return; - clear_fitbit_callback_code(); + this.health_import_provider_id = provider.id; + provider.clear_callback(); if ( callback_error ) { - return this.set_fitbit_error("Fitbit authorization failed: " + callback_error); + return this.set_health_import_error(provider.short_title + " authorization failed: " + callback_error); } - if ( !validate_fitbit_callback_state(state) ) { - return this.set_fitbit_error("Fitbit authorization could not be verified. Please try again."); + if ( !provider.validate_callback_state(state) ) { + return this.set_health_import_error(provider.short_title + " authorization could not be verified. Please try again."); } - this.restore_fitbit_settings(); + this.restore_health_import_settings(provider); - if ( !this.fitbit_client_id ) { - return this.set_fitbit_error("No Fitbit Client ID was saved for this authorization."); + const client_id = this.get_health_import_client_id(provider); + + if ( !client_id ) { + return this.set_health_import_error("No " + provider.short_title + " Client ID was saved for this authorization."); } - const range = this.get_fitbit_range(); + const range = this.get_health_import_range(); if ( range.length != 2 ) { - return this.set_fitbit_error("Please choose a start and end date."); + return this.set_health_import_error("Please choose a start and end date."); } - this.fitbit_loading = true; + this.health_import_loading = true; this.$emit("busy"); try { - const token = await exchange_fitbit_code(this.fitbit_client_id,code), - sleep_data = await fetch_fitbit_sleep_range( + const token = await provider.exchange_code(client_id,code), + sleep_data = await provider.fetch_sleep_range( token.access_token, range[0], range[1], @@ -502,15 +552,15 @@ export default { ; diary_manager.add_diary_contents(JSON.stringify(sleep_data)); - this.fitbit_popup = false; + this.health_import_popup = false; } catch (error) { this.$emit("idle"); - this.set_fitbit_error(error && error.message ? error.message : "Fitbit import failed."); + this.set_health_import_error(error && error.message ? error.message : provider.short_title + " import failed."); } finally { - this.fitbit_loading = false; + this.health_import_loading = false; try { - sessionStorage.removeItem(FITBIT_RANGE_START_KEY); - sessionStorage.removeItem(FITBIT_RANGE_END_KEY); + sessionStorage.removeItem(provider.range_start_storage_key); + sessionStorage.removeItem(provider.range_end_storage_key); } catch (e) { // Ignore storage cleanup failures. } @@ -547,34 +597,65 @@ export default { this.$emit("busy"); diary_manager.add_demo('/resources/common_sleep_diaries/'+filename); }, - async start_fitbit_import() { - const range = this.get_fitbit_range(); + async start_health_import() { + const provider = this.health_import_provider, + range = this.get_health_import_range(), + client_id = this.get_health_import_client_id(provider) + ; if ( range.length != 2 ) { - return this.set_fitbit_error("Please choose a start and end date."); + return this.set_health_import_error("Please choose a start and end date."); + } + + if ( !client_id ) { + return this.set_health_import_error("Please enter a " + provider.short_title + " Client ID."); } try { - window.localStorage.setItem(FITBIT_CLIENT_ID_KEY,this.fitbit_client_id); + if ( !provider.client_id ) { + window.localStorage.setItem(provider.client_id_storage_key,this.health_import_client_id); + } } catch (e) { // Ignore browsers that block storage access. } try { - window.sessionStorage.setItem(FITBIT_CLIENT_ID_KEY,this.fitbit_client_id); - window.sessionStorage.setItem(FITBIT_RANGE_START_KEY,range[0]); - window.sessionStorage.setItem(FITBIT_RANGE_END_KEY,range[1]); + if ( !provider.client_id ) { + window.sessionStorage.setItem(provider.client_id_storage_key,this.health_import_client_id); + } + window.sessionStorage.setItem(provider.range_start_storage_key,range[0]); + window.sessionStorage.setItem(provider.range_end_storage_key,range[1]); } catch (e) { - return this.set_fitbit_error("Session storage is required for Fitbit API import."); + return this.set_health_import_error("Session storage is required for sleep data import."); } - this.fitbit_loading = true; + this.health_import_loading = true; try { - await start_fitbit_auth(this.fitbit_client_id); + const token = await provider.start_auth(client_id); + if ( provider.exchange_code ) return; + + this.$emit("busy"); + const sleep_data = await provider.fetch_sleep_range( + token.access_token, + range[0], + range[1], + ); + diary_manager.add_diary_contents(JSON.stringify(sleep_data)); + this.health_import_popup = false; } catch (error) { - this.fitbit_loading = false; - this.set_fitbit_error(error && error.message ? error.message : "Could not start Fitbit authorization."); + this.$emit("idle"); + this.set_health_import_error(error && error.message ? error.message : "Could not start " + provider.short_title + " authorization."); + } finally { + if ( !provider.exchange_code ) { + this.health_import_loading = false; + try { + sessionStorage.removeItem(provider.range_start_storage_key); + sessionStorage.removeItem(provider.range_end_storage_key); + } catch (e) { + // Ignore storage cleanup failures. + } + } } }, }, From e36b426df47c923a76381ec29d1daa414339ed58 Mon Sep 17 00:00:00 2001 From: wellivea1 <17185653+wellivea1@users.noreply.github.com> Date: Tue, 21 Apr 2026 02:18:12 -0400 Subject: [PATCH 3/3] Clarify Google Health client ID fallback --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index db106a1..6a2ede8 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ As part of the [Sleep Diary Project](https://sleepdiary.github.io/), this reposi [Click here to use the dashboard](https://sleepdiary.github.io/dashboard) -## Get Involved - ## Configuration To enable the hosted Google Health import without asking each user for an OAuth Client ID, build the dashboard with: @@ -14,7 +12,9 @@ To enable the hosted Google Health import without asking each user for an OAuth VUE_APP_GOOGLE_HEALTH_CLIENT_ID= ``` -The Google Cloud OAuth client must be a Web application with `https://sleepdiary.github.io` in its authorized JavaScript origins. Local development can omit this value; the import dialog will then ask for a Client ID. +The Google Cloud OAuth client must be a Web application with `https://sleepdiary.github.io` in its authorized JavaScript origins. If this value is omitted, no Google OAuth client is bundled with the dashboard; the import dialog will show the Google Cloud setup instructions and ask each user for their own Client ID. + +## Get Involved ### I found a bug, how should I tell you?