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}}
+ {{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?