import { useState, useEffect } from 'react'; import { Alert } from 'react-native'; import * as rdf from '@jasonpaulos/rdflib'; import { EventEmitter } from 'events'; import { URL } from 'whatwg-url'; import moment from 'moment'; import { onWebIdChange, authenticatedFetch } from './auth'; import { DataPoint, getDailySteps as getGoogleFitSteps, getDailyDistance as getGoogleFitDistance, getHeartRate as getGoogleFitHeartRate, } from './googlefit'; import { FOAF, RDF, SOLID, FHIR } from './ns'; export interface Profile { webId: string, name?: string, image?: string, friends: string[], privateTypeIndex?: string, } export interface SyncStatus { value: number, maxValue: number | null, description: string, } export type PodDataPoint = DataPoint & { uri: string, parsedDate: Date }; const syncEvents = new EventEmitter(); const syncStatus: SyncStatus = { value: 0, maxValue: null, description: 'Setting up...', }; let profile: Profile | null = null; let observationLocation: string | null = null; const fitnessData: { loading: boolean, heartRate: PodDataPoint[], steps: PodDataPoint[], distance: PodDataPoint[], } = { loading: true, heartRate: [], steps: [], distance: [], }; function setSyncStatus(desc: string, value?: number, maxValue?: number) { syncStatus.description = desc; syncStatus.value = value == null || maxValue == null ? 0 : value; syncStatus.maxValue = value == null || maxValue == null ? null : maxValue; console.log(`${desc} ${value == null || maxValue == null ? '...' : `${value}/${maxValue}`}`) syncEvents.emit('status', { ...syncStatus }); } export function useSyncStatus() { const [status, setStatus] = useState<SyncStatus>(syncStatus); useEffect(() => { syncEvents.on('status', setStatus); return () => { syncEvents.removeListener('status', setStatus); }; }, []); return status; } export function useProfile(): Profile | null { const [p, setProfile] = useState<Profile | null>(profile); useEffect(() => { syncEvents.on('profile', setProfile); return () => { syncEvents.removeListener('profile', setProfile) }; }, []); return p; } onWebIdChange(async (webId) => { profile = null; observationLocation = null; fitnessData.loading = true; fitnessData.heartRate = []; fitnessData.steps = []; fitnessData.distance = []; syncEvents.emit('fitnessData', fitnessData.loading); if (webId == null) { fitnessData.loading = false; syncEvents.emit('profile', profile); return; } let success = false; try { const store = rdf.graph(); const fetcher = new rdf.Fetcher(store, { fetch: authenticatedFetch }); setSyncStatus('Loading profile'); profile = await fetchProfile(store, fetcher, webId); syncEvents.emit('profile', profile); if (!profile.privateTypeIndex) { throw new Error('No private type index'); } setSyncStatus('Loading private type index'); observationLocation = await fetchTypeIndex(store, fetcher, webId, profile.privateTypeIndex); setSyncStatus('Loading data from pod'); await fetchObservations(store, fetcher, webId, observationLocation); success = true; setSyncStatus('Done', 1, 1); } catch (err) { console.warn(err); setSyncStatus('Could not load profile', 0, 1); Alert.alert('Could not load profile', err.toString()); } fitnessData.loading = false; syncEvents.emit('fitnessData', fitnessData.loading); if (success) { const end = moment.utc().endOf('month'); const start = end.clone().startOf('month'); syncData(start, end); } }); async function fetchProfile(store: rdf.IndexedFormula, fetcher: rdf.Fetcher, webId: string): Promise<Profile> { await fetcher.load(webId); const profile: Profile = { webId, friends: [] }; const user = store.sym(webId); const name = store.any(user, FOAF('name')); if (name && name.value) { profile.name = name.value; } const image = store.any(user, FOAF('img')); if (image && image.value) { profile.image = image.value; } const friends = store.each(user, FOAF('knows')); profile.friends = friends.map(friend => friend.value); const privateTypeIndex = store.any(user, SOLID('privateTypeIndex')); if (privateTypeIndex && privateTypeIndex.value) { profile.privateTypeIndex = privateTypeIndex.value; } return profile; } async function fetchTypeIndex(store: rdf.IndexedFormula, fetcher: rdf.Fetcher, webId: string, typeIndex: string): Promise<string> { await fetcher.load(typeIndex); let observationLocation: string | null = null; const observationTypeRegistration = store.any(null, SOLID('forClass'), FHIR('Observation')); if (observationTypeRegistration && observationTypeRegistration.value) { const observationTypeFile = store.any(observationTypeRegistration as rdf.NamedNode, SOLID('instance')); const observationTypeDirectory = store.any(observationTypeRegistration as rdf.NamedNode, SOLID('instanceContainer')); if (observationTypeFile && observationTypeFile.value) { observationLocation = observationTypeFile.value; } else if (observationTypeDirectory && observationTypeDirectory.value) { observationLocation = new URL('./fitness.ttl', observationTypeDirectory.value).href; } } if (observationLocation == null) { console.log('Observation location not found, creating'); const defaultLocation = '/private/health/'; const query = `INSERT DATA { <#FHIRObservation> a <http://www.w3.org/ns/solid/terms#TypeRegistration> ; <http://www.w3.org/ns/solid/terms#forClass> <http://hl7.org/fhir/Observation> ; <http://www.w3.org/ns/solid/terms#instanceContainer> <${defaultLocation}> . <> <http://purl.org/dc/terms/references> <#FHIRObservation> . }`; const ret = await authenticatedFetch(typeIndex, { method: 'PATCH', headers: { 'Content-Type': 'application/sparql-update' }, body: query, credentials: 'include', }); if (!ret.ok) { throw new Error('Type registration insert unsuccessful: response ' + ret.status); } console.log('Added triple to private type index'); const privateFolder = new URL('/private', typeIndex).href; await authenticatedFetch(privateFolder, { method: 'POST', headers: { 'Content-Type': 'text/turtle', 'Link': '<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"', 'Slug': 'health', }, body: '<> <http://purl.org/dc/terms/title> "FHIR Health Observations" .', credentials: 'include', }); console.log('Created /private/health'); observationLocation = new URL(defaultLocation + 'fitness.ttl', typeIndex).href; } return observationLocation; } async function fetchObservations(store: rdf.IndexedFormula, fetcher: rdf.Fetcher, webId: string, observationLocation: string) { try { await fetcher.load(observationLocation); } catch (err) { await authenticatedFetch(observationLocation, { method: 'PATCH', headers: { 'Content-Type': 'application/sparql-update' }, body: `INSERT DATA { <${webId}> a <http://hl7.org/fhir/Patient> . <> a <http://www.w3.org/2002/07/owl#Ontology>; <http://www.w3.org/2002/07/owl#imports> <http://hl7.org/fhir/fhir.ttl>. }`, credentials: 'include', }); console.log('Populated ' + observationLocation); } function getSubject(observation: rdf.NamedNode): string | null { const subject = store.any(observation, FHIR('Observation.subject')); if (!subject) return null; const patient = store.any(subject as rdf.BlankNode, FHIR('link')); return patient ? patient.value : null; } function getEffectiveDateTime(observation: rdf.NamedNode): string | null { const effectiveDateTime = store.any(observation, FHIR('Observation.effectiveDateTime')); if (!effectiveDateTime) return null; const effectiveDateTimeValue = store.any(effectiveDateTime as rdf.BlankNode, FHIR('value')); if (!effectiveDateTimeValue || effectiveDateTimeValue.termType !== 'Literal') return null; const dateTimeLiteral = effectiveDateTimeValue as rdf.Literal; if (dateTimeLiteral.datatype.value !== 'http://www.w3.org/2001/XMLSchema#date') return null; return dateTimeLiteral.value; } function getValue(observation: rdf.NamedNode): number | null { const valueQuantity = store.any(observation, FHIR('Observation.valueQuantity')); if (!valueQuantity) return null; const quantityValue = store.any(valueQuantity as rdf.BlankNode, FHIR('Quantity.value')); if (!quantityValue) return null const quantityValueValue = store.any(quantityValue as rdf.BlankNode, FHIR('value')); if (!quantityValueValue || quantityValueValue.termType !== 'Literal') return null; const quantityValueValueLiteral = quantityValueValue as rdf.Literal; if (quantityValueValueLiteral.datatype.value !== 'http://www.w3.org/2001/XMLSchema#decimal' && quantityValueValueLiteral.datatype.value !== 'http://www.w3.org/2001/XMLSchema#integer') return null; const value = parseFloat(quantityValueValueLiteral.value); return isNaN(value) ? null : value; } function getType(observation: rdf.NamedNode): 'heartrate' | 'steps' | 'distance' | null { const code = store.any(observation, FHIR('Observation.code')); if (!code) return null; const coding = store.any(code as rdf.BlankNode, FHIR('CodeableConcept.coding')); if (!coding) return null; const codingSystem = store.any(coding as rdf.BlankNode, FHIR('Coding.system')); const codingCode = store.any(coding as rdf.BlankNode, FHIR('Coding.code')); if (!codingSystem || !codingCode) return null; const codingSytemValue = store.any(codingSystem as rdf.BlankNode, FHIR('value')); const codingCodeValue = store.any(codingCode as rdf.BlankNode, FHIR('value')); if (!codingSytemValue || !codingCodeValue) return null; if (codingSytemValue.value === 'http://loinc.org' && codingCodeValue.value === '8867-4') { return 'heartrate'; } if (codingSytemValue.value === 'http://loinc.org' && codingCodeValue.value === '55423-8') { return 'steps'; } if (codingSytemValue.value === 'http://loinc.org' && codingCodeValue.value === '41953-1') { return 'distance'; } return null; } for (const observation of store.each(null, RDF('type'), FHIR('Observation'))) { const subject = getSubject(observation as rdf.NamedNode); const effectiveDateTime = getEffectiveDateTime(observation as rdf.NamedNode); const value = getValue(observation as rdf.NamedNode); const type = getType(observation as rdf.NamedNode); if (subject !== webId || effectiveDateTime == null || value == null) continue; const dataPoint: PodDataPoint = { uri: observation.value, parsedDate: new Date(effectiveDateTime), date: effectiveDateTime, value }; switch (type) { case 'heartrate': fitnessData.heartRate.push(dataPoint); break; case 'steps': fitnessData.steps.push(dataPoint); break; case 'distance': fitnessData.distance.push(dataPoint); break; } } } function waitForFitnessData(): Promise<void> { return new Promise((resolve, reject) => { if (!fitnessData.loading) return resolve(); const fitnessListener = () => { if (!fitnessData.loading) { resolve(); syncEvents.removeListener('fitnessData', fitnessListener); } } syncEvents.on('fitnessData', fitnessListener); }); } export async function getHeartRate(startDate: string | Date | moment.Moment, endDate: string | Date | moment.Moment): Promise<PodDataPoint[]> { if (typeof startDate === 'string') { startDate = new Date(startDate); } else if (!(startDate instanceof Date)) { startDate = startDate.toDate(); } if (typeof endDate === 'string') { endDate = new Date(endDate); } else if (!(endDate instanceof Date)) { endDate = endDate.toDate(); } await waitForFitnessData(); return fitnessData.heartRate .filter(point => (startDate <= point.parsedDate && point.parsedDate <= endDate)); } export async function getDailySteps(startDate: string | Date | moment.Moment, endDate: string | Date | moment.Moment): Promise<PodDataPoint[]> { if (typeof startDate === 'string') { startDate = new Date(startDate); } else if (!(startDate instanceof Date)) { startDate = startDate.toDate(); } if (typeof endDate === 'string') { endDate = new Date(endDate); } else if (!(endDate instanceof Date)) { endDate = endDate.toDate(); } await waitForFitnessData(); return fitnessData.steps .filter(point => (startDate <= point.parsedDate && point.parsedDate <= endDate)); } export async function getDailyDistance(startDate: string | Date | moment.Moment, endDate: string | Date | moment.Moment): Promise<PodDataPoint[]> { if (typeof startDate === 'string') { startDate = new Date(startDate); } else if (!(startDate instanceof Date)) { startDate = startDate.toDate(); } if (typeof endDate === 'string') { endDate = new Date(endDate); } else if (!(endDate instanceof Date)) { endDate = endDate.toDate(); } await waitForFitnessData(); return fitnessData.distance .filter(point => (startDate <= point.parsedDate && point.parsedDate <= endDate)); } async function syncData(start: moment.Moment, end: moment.Moment) { const month = end.format('MMMM YYYY'); try { const [ podSteps, podDistance, podHeartRate, googleSteps, googleDistance, googleHeartRate, ] = await Promise.all<PodDataPoint[], PodDataPoint[], PodDataPoint[], DataPoint[], DataPoint[], DataPoint[]>([ getDailySteps(start, end), getDailyDistance(start, end), getHeartRate(start, end), getGoogleFitSteps(start.format(), end.format()), getGoogleFitDistance(start.format(), end.format()), getGoogleFitHeartRate(start.format(), end.format()), ]); const stepsToUpload: DataPoint[] = []; const distanceToUpload: DataPoint[] = []; const heartRateToUpload: DataPoint[] = []; const pointsToModify: [PodDataPoint, number][] = []; for (const googlePoint of googleSteps) { const googleTime = new Date(googlePoint.date).getTime(); let found = false; for (const podPoint of podSteps) { const podTime = podPoint.parsedDate.getTime(); if (googleTime === podTime) { if (Math.abs(googlePoint.value - podPoint.value) > 0.01) { pointsToModify.push([podPoint, googlePoint.value]); } found = true; break; } } if (!found) { stepsToUpload.push(googlePoint); } } for (const googlePoint of googleDistance) { const googleTime = new Date(googlePoint.date).getTime(); let found = false; for (const podPoint of podDistance) { const podTime = podPoint.parsedDate.getTime(); if (googleTime === podTime) { if (Math.abs(googlePoint.value - podPoint.value) > 0.01) { pointsToModify.push([podPoint, googlePoint.value]); } found = true; break; } } if (!found) { distanceToUpload.push(googlePoint); } } for (let i = 0; i < googleHeartRate.length; i++) { const googlePoint = googleHeartRate[i]; const googleTime = new Date(googlePoint.date).getTime(); if (i > 0) { const lastPoint = googleHeartRate[i - 1]; if (Math.abs(googlePoint.value - lastPoint.value) <= 1 && Math.abs(googleTime - new Date(lastPoint.date).getTime()) <= 60000 ) { // for some reason Google Fit APIs tend to return multiple heart rate // readings with the similar values very close together. This ignores // readings within 1 minute of each other with similar values. continue; } } let found = false; for (const podPoint of podHeartRate) { const podTime = podPoint.parsedDate.getTime(); if (googleTime === podTime) { found = true; break; } } if (!found) { heartRateToUpload.push(googlePoint); } } console.log(`Points to modify: ${pointsToModify.length}`); console.log(`Steps to upload: ${stepsToUpload.length}`); console.log(`Distance to upload: ${distanceToUpload.length}`); console.log(`Heart rate to upload: ${heartRateToUpload.length}`); if (pointsToModify.length !== 0) { setSyncStatus('Updating pod data for ' + month, 0, pointsToModify.length); } for (let i = 0; i < pointsToModify.length; i++) { const [point, newValue] = pointsToModify[i]; const type = Math.floor(newValue) === newValue ? 'integer' : 'decimal'; const query = `DELETE { ?s <http://hl7.org/fhir/value> ?o } INSERT { ?s <http://hl7.org/fhir/value> "${newValue}"^^<http://www.w3.org/2001/XMLSchema#${type}> } WHERE { <${point.uri}> <http://hl7.org/fhir/Observation.valueQuantity> [ <http://hl7.org/fhir/Quantity.value> ?s ] . ?s <http://hl7.org/fhir/value> ?o }`; console.log(query); const ret = await authenticatedFetch(observationLocation, { method: 'PATCH', headers: { 'Content-Type': 'application/sparql-update' }, body: query, credentials: 'include', }); if (!ret.ok) { let txt = ''; try { txt = await ret.text(); } catch (_) { } throw new Error(`Invalid status: ${ret.status} ${txt}`); } setSyncStatus('Updating pod data for ' + month, i+1, pointsToModify.length); } let observationsToUpload: string[] = []; for (const point of stepsToUpload) { const { observation, uri } = stepsToObservation(point); observationsToUpload.push(observation); fitnessData.steps.push({ date: point.date, value: point.value, parsedDate: new Date(point.date), uri, }); } for (const point of distanceToUpload) { const { observation, uri } = distanceToObservation(point); observationsToUpload.push(observation); fitnessData.distance.push({ date: point.date, value: point.value, parsedDate: new Date(point.date), uri, }); } for (const point of heartRateToUpload) { const { observation, uri } = heartRateToObservation(point); observationsToUpload.push(observation); fitnessData.heartRate.push({ date: point.date, value: point.value, parsedDate: new Date(point.date), uri, }); } syncEvents.emit('fitness', fitnessData.loading); const needToUpload = observationsToUpload.length; let totalUploaded = 0; setSyncStatus('Syncing data to pod for ' + month, totalUploaded, needToUpload); while (observationsToUpload.length !== 0) { let batch; if (observationsToUpload.length > 50) { batch = observationsToUpload.slice(0, 50); observationsToUpload = observationsToUpload.slice(50); } else { batch = observationsToUpload; observationsToUpload = []; } const query = 'INSERT DATA {\n' + batch.join('\n') + '\n}'; const ret = await authenticatedFetch(observationLocation, { method: 'PATCH', headers: { 'Content-Type': 'application/sparql-update' }, body: query, credentials: 'include', }); if (!ret.ok) { let txt = ''; try { txt = await ret.text(); } catch (_) { } throw new Error(`Invalid status: ${ret.status} ${txt}`); } totalUploaded += batch.length; setSyncStatus('Syncing data to pod for ' + month, totalUploaded, needToUpload); } if (needToUpload !== 0) { syncData(start.clone().subtract(1, 'month'), end.clone().subtract(1, 'month')); } else { setSyncStatus('Everything up to date', 1, 1); } } catch (err) { console.warn(err); setSyncStatus('Could not sync data', 0, 1); Alert.alert('Could not sync data', err.toString()); } } function stepsToObservation({ date, value }: DataPoint): { uri: string, observation: string } { const webId = profile?.webId; const id = '#steps_' + moment(date).format('YYYYMMDD'); const observation = `<${id}> a <http://hl7.org/fhir/Observation>; <http://hl7.org/fhir/nodeRole> <http://hl7.org/fhir/treeRoot>; <http://hl7.org/fhir/Observation.status> [ <http://hl7.org/fhir/value> "final"]; <http://hl7.org/fhir/Observation.code> [ <http://hl7.org/fhir/CodeableConcept.coding> [ <http://hl7.org/fhir/index> 0; a <http://loinc.org/rdf#55423-8>; <http://hl7.org/fhir/Coding.system> [ <http://hl7.org/fhir/value> "http://loinc.org" ]; <http://hl7.org/fhir/Coding.code> [ <http://hl7.org/fhir/value> "55423-8" ]; <http://hl7.org/fhir/Coding.display> [ <http://hl7.org/fhir/value> "Step count" ] ]; <http://hl7.org/fhir/CodeableConcept.text> [ <http://hl7.org/fhir/value> "Step count" ] ]; <http://hl7.org/fhir/Observation.subject> [ <http://hl7.org/fhir/link> <${webId}>; ]; <http://hl7.org/fhir/Observation.effectiveDateTime> [ <http://hl7.org/fhir/value> "${date}"^^<http://www.w3.org/2001/XMLSchema#date> ]; <http://hl7.org/fhir/Observation.valueQuantity> [ <http://hl7.org/fhir/Quantity.value> [ <http://hl7.org/fhir/value> "${value}"^^<http://www.w3.org/2001/XMLSchema#integer> ]; <http://hl7.org/fhir/Quantity.unit> [ <http://hl7.org/fhir/value> "/d" ]; <http://hl7.org/fhir/Quantity.system> [ <http://hl7.org/fhir/value> "http://unitsofmeasure.org" ]; <http://hl7.org/fhir/Quantity.code> [ <http://hl7.org/fhir/value> "/d" ] ] . <> <http://purl.org/dc/terms/references> <${id}> .`; return { uri: id, observation }; } function distanceToObservation({ date, value }: DataPoint): { uri: string, observation: string } { const webId = profile?.webId; const id = '#distance_' + moment(date).format('YYYYMMDD'); const observation = `<${id}> a <http://hl7.org/fhir/Observation>; <http://hl7.org/fhir/nodeRole> <http://hl7.org/fhir/treeRoot>; <http://hl7.org/fhir/Observation.status> [ <http://hl7.org/fhir/value> "final"]; <http://hl7.org/fhir/Observation.code> [ <http://hl7.org/fhir/CodeableConcept.coding> [ <http://hl7.org/fhir/index> 0; a <http://loinc.org/rdf#41953-1>; <http://hl7.org/fhir/Coding.system> [ <http://hl7.org/fhir/value> "http://loinc.org" ]; <http://hl7.org/fhir/Coding.code> [ <http://hl7.org/fhir/value> "41953-1" ]; <http://hl7.org/fhir/Coding.display> [ <http://hl7.org/fhir/value> "Distanced walked" ] ]; <http://hl7.org/fhir/CodeableConcept.text> [ <http://hl7.org/fhir/value> "Distanced walked" ] ]; <http://hl7.org/fhir/Observation.subject> [ <http://hl7.org/fhir/link> <${webId}>; ]; <http://hl7.org/fhir/Observation.effectiveDateTime> [ <http://hl7.org/fhir/value> "${date}"^^<http://www.w3.org/2001/XMLSchema#date> ]; <http://hl7.org/fhir/Observation.valueQuantity> [ <http://hl7.org/fhir/Quantity.value> [ <http://hl7.org/fhir/value> "${value}"^^<http://www.w3.org/2001/XMLSchema#decimal> ]; <http://hl7.org/fhir/Quantity.unit> [ <http://hl7.org/fhir/value> "m/d" ]; <http://hl7.org/fhir/Quantity.system> [ <http://hl7.org/fhir/value> "http://unitsofmeasure.org" ]; <http://hl7.org/fhir/Quantity.code> [ <http://hl7.org/fhir/value> "/d" ] ] . <> <http://purl.org/dc/terms/references> <${id}> .`; return { uri: id, observation }; } function heartRateToObservation({ date, value }: DataPoint): { uri: string, observation: string } { const webId = profile?.webId; const id = '#heartrate_' + moment(date).format('x'); const observation = `<${id}> a <http://hl7.org/fhir/Observation>; <http://hl7.org/fhir/nodeRole> <http://hl7.org/fhir/treeRoot>; <http://hl7.org/fhir/Observation.status> [ <http://hl7.org/fhir/value> "final"]; <http://hl7.org/fhir/Observation.code> [ <http://hl7.org/fhir/CodeableConcept.coding> [ <http://hl7.org/fhir/index> 0; a <http://loinc.org/rdf#8867-4>; <http://hl7.org/fhir/Coding.system> [ <http://hl7.org/fhir/value> "http://loinc.org" ]; <http://hl7.org/fhir/Coding.code> [ <http://hl7.org/fhir/value> "8867-4" ]; <http://hl7.org/fhir/Coding.display> [ <http://hl7.org/fhir/value> "Heart rate" ] ]; <http://hl7.org/fhir/CodeableConcept.text> [ <http://hl7.org/fhir/value> "Heart rate" ] ]; <http://hl7.org/fhir/Observation.subject> [ <http://hl7.org/fhir/link> <${webId}>; ]; <http://hl7.org/fhir/Observation.effectiveDateTime> [ <http://hl7.org/fhir/value> "${date}"^^<http://www.w3.org/2001/XMLSchema#date> ]; <http://hl7.org/fhir/Observation.valueQuantity> [ <http://hl7.org/fhir/Quantity.value> [ <http://hl7.org/fhir/value> "${value}"^^<http://www.w3.org/2001/XMLSchema#decimal> ]; <http://hl7.org/fhir/Quantity.unit> [ <http://hl7.org/fhir/value> "beats/min" ]; <http://hl7.org/fhir/Quantity.system> [ <http://hl7.org/fhir/value> "http://unitsofmeasure.org" ]; <http://hl7.org/fhir/Quantity.code> [ <http://hl7.org/fhir/value> "/min" ] ] . <> <http://purl.org/dc/terms/references> <${id}> .`; return { uri: id, observation }; }