|
|
|
|
@@ -7,33 +7,20 @@ import { config } from '../config.js';
|
|
|
|
|
import { logger } from '../logger.js';
|
|
|
|
|
|
|
|
|
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
|
|
|
const FIT_AGGREGATE_URL = 'https://www.googleapis.com/fitness/v1/users/me/dataset:aggregate';
|
|
|
|
|
const FIT_SESSIONS_URL = 'https://www.googleapis.com/fitness/v1/users/me/sessions';
|
|
|
|
|
const HEALTH_API_BASE = 'https://health.googleapis.com/v4/users/me/dataTypes';
|
|
|
|
|
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
|
|
|
|
|
|
|
|
const STEP_DAILY_GOAL = 7_000;
|
|
|
|
|
const SLEEP_GOAL_HOURS = 7;
|
|
|
|
|
|
|
|
|
|
interface FitBucket {
|
|
|
|
|
dataset: Array<{
|
|
|
|
|
dataSourceId: string;
|
|
|
|
|
point: Array<{ value: Array<{ intVal?: number; fpVal?: number }> }>;
|
|
|
|
|
}>;
|
|
|
|
|
// v4 DataPoint shape is a union keyed by data type; we read defensively.
|
|
|
|
|
interface DataPoint {
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FitAggregateResponse {
|
|
|
|
|
bucket?: FitBucket[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FitSession {
|
|
|
|
|
name: string;
|
|
|
|
|
startTimeMillis: string;
|
|
|
|
|
endTimeMillis: string;
|
|
|
|
|
activityType: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FitSessionsResponse {
|
|
|
|
|
session?: FitSession[];
|
|
|
|
|
interface DataPointsResponse {
|
|
|
|
|
dataPoints?: DataPoint[];
|
|
|
|
|
nextPageToken?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshGoogleToken(
|
|
|
|
|
@@ -66,81 +53,62 @@ async function refreshGoogleToken(
|
|
|
|
|
return data.access_token;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function todayMidnightMs(): number {
|
|
|
|
|
function todayMidnightIso(): string {
|
|
|
|
|
const d = new Date();
|
|
|
|
|
d.setHours(0, 0, 0, 0);
|
|
|
|
|
return d.getTime();
|
|
|
|
|
return d.toISOString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function yesterdayIso(): string {
|
|
|
|
|
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchAggregates(
|
|
|
|
|
async function fetchDataPoints(
|
|
|
|
|
token: string,
|
|
|
|
|
startMs: number,
|
|
|
|
|
endMs: number,
|
|
|
|
|
): Promise<FitAggregateResponse> {
|
|
|
|
|
const res = await fetch(FIT_AGGREGATE_URL, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
aggregateBy: [
|
|
|
|
|
{ dataTypeName: 'com.google.step_count.delta' },
|
|
|
|
|
{ dataTypeName: 'com.google.calories.expended' },
|
|
|
|
|
{ dataTypeName: 'com.google.active_minutes' },
|
|
|
|
|
{ dataTypeName: 'com.google.heart_rate.bpm' },
|
|
|
|
|
],
|
|
|
|
|
bucketByTime: { durationMillis: endMs - startMs },
|
|
|
|
|
startTimeMillis: String(startMs),
|
|
|
|
|
endTimeMillis: String(endMs),
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error(`Fit aggregate: ${res.status}`);
|
|
|
|
|
return res.json() as Promise<FitAggregateResponse>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchSleepSessions(token: string): Promise<FitSessionsResponse> {
|
|
|
|
|
const url = new URL(FIT_SESSIONS_URL);
|
|
|
|
|
url.searchParams.set('activityType', '72');
|
|
|
|
|
url.searchParams.set('startTime', yesterdayIso());
|
|
|
|
|
url.searchParams.set('endTime', new Date().toISOString());
|
|
|
|
|
dataType: string,
|
|
|
|
|
filter: string,
|
|
|
|
|
): Promise<DataPoint[]> {
|
|
|
|
|
const url = new URL(`${HEALTH_API_BASE}/${dataType}/dataPoints`);
|
|
|
|
|
url.searchParams.set('filter', filter);
|
|
|
|
|
const res = await fetch(url.toString(), {
|
|
|
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error(`Fit sessions: ${res.status}`);
|
|
|
|
|
return res.json() as Promise<FitSessionsResponse>;
|
|
|
|
|
if (!res.ok) throw new Error(`health ${dataType}: ${res.status}`);
|
|
|
|
|
const data = (await res.json()) as DataPointsResponse;
|
|
|
|
|
return data.dataPoints ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractMetric(
|
|
|
|
|
bucket: FitBucket,
|
|
|
|
|
dataTypeName: string,
|
|
|
|
|
valueKey: 'intVal' | 'fpVal',
|
|
|
|
|
): number {
|
|
|
|
|
for (const ds of bucket.dataset) {
|
|
|
|
|
if (!ds.dataSourceId.includes(dataTypeName.replace('com.google.', '').replace('.', '_'))) continue;
|
|
|
|
|
for (const pt of ds.point) {
|
|
|
|
|
const v = pt.value[0];
|
|
|
|
|
if (v) return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
|
|
|
|
|
// Defensive numeric reader — probes likely field names in a v4 DataPoint payload.
|
|
|
|
|
function readNumber(point: DataPoint, paths: string[][]): number {
|
|
|
|
|
for (const path of paths) {
|
|
|
|
|
let cur: unknown = point;
|
|
|
|
|
for (const key of path) {
|
|
|
|
|
if (cur && typeof cur === 'object' && key in (cur as object)) {
|
|
|
|
|
cur = (cur as Record<string, unknown>)[key];
|
|
|
|
|
} else {
|
|
|
|
|
cur = undefined;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (typeof cur === 'number') return cur;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractAnyMetric(
|
|
|
|
|
bucket: FitBucket,
|
|
|
|
|
typeSuffix: string,
|
|
|
|
|
valueKey: 'intVal' | 'fpVal',
|
|
|
|
|
): number {
|
|
|
|
|
for (const ds of bucket.dataset) {
|
|
|
|
|
if (!ds.dataSourceId.includes(typeSuffix)) continue;
|
|
|
|
|
const pt = ds.point[0];
|
|
|
|
|
if (pt?.value[0]) {
|
|
|
|
|
const v = pt.value[0];
|
|
|
|
|
return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
|
|
|
|
|
function readString(point: DataPoint, paths: string[][]): string | undefined {
|
|
|
|
|
for (const path of paths) {
|
|
|
|
|
let cur: unknown = point;
|
|
|
|
|
for (const key of path) {
|
|
|
|
|
if (cur && typeof cur === 'object' && key in (cur as object)) {
|
|
|
|
|
cur = (cur as Record<string, unknown>)[key];
|
|
|
|
|
} else {
|
|
|
|
|
cur = undefined;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
if (typeof cur === 'string') return cur;
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class GoogleHealthSignalSource implements SignalSource {
|
|
|
|
|
@@ -187,21 +155,35 @@ export class GoogleHealthSignalSource implements SignalSource {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const startMs = todayMidnightMs();
|
|
|
|
|
const endMs = Date.now();
|
|
|
|
|
const dayStartIso = todayMidnightIso();
|
|
|
|
|
const dayEndIso = new Date().toISOString();
|
|
|
|
|
const yIso = yesterdayIso();
|
|
|
|
|
|
|
|
|
|
const [aggData, sleepData] = await Promise.all([
|
|
|
|
|
fetchAggregates(token, startMs, endMs),
|
|
|
|
|
fetchSleepSessions(token),
|
|
|
|
|
const stepsFilter = `steps.interval.start_time >= "${dayStartIso}" AND steps.interval.start_time < "${dayEndIso}"`;
|
|
|
|
|
const caloriesFilter = `total_calories.interval.start_time >= "${dayStartIso}" AND total_calories.interval.start_time < "${dayEndIso}"`;
|
|
|
|
|
const hrFilter = `heart_rate.sample_time.physical_time >= "${dayStartIso}" AND heart_rate.sample_time.physical_time < "${dayEndIso}"`;
|
|
|
|
|
const sleepFilter = `sleep.interval.start_time >= "${yIso}" AND sleep.interval.start_time < "${dayEndIso}"`;
|
|
|
|
|
|
|
|
|
|
const [stepsPts, caloriesPts, hrPts, sleepPts] = await Promise.all([
|
|
|
|
|
fetchDataPoints(token, 'steps', stepsFilter),
|
|
|
|
|
fetchDataPoints(token, 'total-calories', caloriesFilter),
|
|
|
|
|
fetchDataPoints(token, 'heart-rate', hrFilter),
|
|
|
|
|
fetchDataPoints(token, 'sleep', sleepFilter),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const bucket = aggData.bucket?.[0];
|
|
|
|
|
// One-time peek at raw shape so we can refine field paths after first real OAuth.
|
|
|
|
|
logger.debug(
|
|
|
|
|
{ userId, samples: { stepsPts: stepsPts.slice(0, 1), caloriesPts: caloriesPts.slice(0, 1), hrPts: hrPts.slice(0, 1), sleepPts: sleepPts.slice(0, 1) } },
|
|
|
|
|
'google-health: v4 dataPoints sample',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const signals: Signal[] = [];
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
|
|
|
|
if (bucket) {
|
|
|
|
|
// Steps
|
|
|
|
|
const steps = extractAnyMetric(bucket, 'step_count', 'intVal');
|
|
|
|
|
const steps = stepsPts.reduce(
|
|
|
|
|
(sum, p) => sum + readNumber(p, [['steps', 'count'], ['count']]),
|
|
|
|
|
0,
|
|
|
|
|
);
|
|
|
|
|
const stepGoalPct = Math.round((steps / STEP_DAILY_GOAL) * 100);
|
|
|
|
|
signals.push({
|
|
|
|
|
id: `google-health:steps`,
|
|
|
|
|
@@ -218,26 +200,31 @@ export class GoogleHealthSignalSource implements SignalSource {
|
|
|
|
|
timestamp: now,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Calories + active minutes
|
|
|
|
|
const calories = Math.round(extractAnyMetric(bucket, 'calories', 'fpVal'));
|
|
|
|
|
const activeMinutes = extractAnyMetric(bucket, 'active_minutes', 'intVal');
|
|
|
|
|
const calories = Math.round(
|
|
|
|
|
caloriesPts.reduce(
|
|
|
|
|
(sum, p) =>
|
|
|
|
|
sum + readNumber(p, [['totalCalories', 'kilocalories'], ['kilocalories'], ['energy', 'kilocalories']]),
|
|
|
|
|
0,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
signals.push({
|
|
|
|
|
id: `google-health:activity`,
|
|
|
|
|
source: 'google-health',
|
|
|
|
|
kind: 'health',
|
|
|
|
|
content: `${activeMinutes} active minutes, ${calories} calories burned today`,
|
|
|
|
|
content: `${calories} calories burned today`,
|
|
|
|
|
metadata: { dataType: 'activity' },
|
|
|
|
|
features: {
|
|
|
|
|
active_minutes: activeMinutes,
|
|
|
|
|
calories_burned: calories,
|
|
|
|
|
sedentary: activeMinutes < 20,
|
|
|
|
|
},
|
|
|
|
|
timestamp: now,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Heart rate
|
|
|
|
|
const bpm = Math.round(extractAnyMetric(bucket, 'heart_rate', 'fpVal'));
|
|
|
|
|
if (bpm > 0) {
|
|
|
|
|
if (hrPts.length > 0) {
|
|
|
|
|
const hrValues = hrPts
|
|
|
|
|
.map((p) => readNumber(p, [['heartRate', 'beatsPerMinute'], ['beatsPerMinute']]))
|
|
|
|
|
.filter((v) => v > 0);
|
|
|
|
|
if (hrValues.length > 0) {
|
|
|
|
|
const bpm = Math.round(hrValues.reduce((a, b) => a + b, 0) / hrValues.length);
|
|
|
|
|
signals.push({
|
|
|
|
|
id: `google-health:heart_rate`,
|
|
|
|
|
source: 'google-health',
|
|
|
|
|
@@ -250,13 +237,17 @@ export class GoogleHealthSignalSource implements SignalSource {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sleep — find the most recent sleep session
|
|
|
|
|
if (sleepData.session?.length) {
|
|
|
|
|
const sorted = [...sleepData.session].sort(
|
|
|
|
|
(a, b) => Number(b.endTimeMillis) - Number(a.endTimeMillis),
|
|
|
|
|
);
|
|
|
|
|
const last = sorted[0]!;
|
|
|
|
|
const durationMs = Number(last.endTimeMillis) - Number(last.startTimeMillis);
|
|
|
|
|
if (sleepPts.length > 0) {
|
|
|
|
|
const sleepSessions = sleepPts
|
|
|
|
|
.map((p) => ({
|
|
|
|
|
start: readString(p, [['sleep', 'interval', 'startTime'], ['interval', 'startTime'], ['startTime']]),
|
|
|
|
|
end: readString(p, [['sleep', 'interval', 'endTime'], ['interval', 'endTime'], ['endTime']]),
|
|
|
|
|
}))
|
|
|
|
|
.filter((s): s is { start: string; end: string } => !!s.start && !!s.end)
|
|
|
|
|
.sort((a, b) => Date.parse(b.end) - Date.parse(a.end));
|
|
|
|
|
const last = sleepSessions[0];
|
|
|
|
|
if (last) {
|
|
|
|
|
const durationMs = Date.parse(last.end) - Date.parse(last.start);
|
|
|
|
|
const sleepHours = Math.round((durationMs / 3_600_000) * 10) / 10;
|
|
|
|
|
const belowGoal = sleepHours < SLEEP_GOAL_HOURS;
|
|
|
|
|
signals.push({
|
|
|
|
|
@@ -264,7 +255,7 @@ export class GoogleHealthSignalSource implements SignalSource {
|
|
|
|
|
source: 'google-health',
|
|
|
|
|
kind: 'health',
|
|
|
|
|
content: `${sleepHours}h sleep last night (${belowGoal ? 'below' : 'meets'} ${SLEEP_GOAL_HOURS}h goal)`,
|
|
|
|
|
metadata: { dataType: 'sleep', sessionName: last.name },
|
|
|
|
|
metadata: { dataType: 'sleep' },
|
|
|
|
|
features: {
|
|
|
|
|
sleep_hours: sleepHours,
|
|
|
|
|
sleep_goal_hours: SLEEP_GOAL_HOURS,
|
|
|
|
|
@@ -274,6 +265,7 @@ export class GoogleHealthSignalSource implements SignalSource {
|
|
|
|
|
timestamp: now,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.cache.set(userId, { signals, fetchedAt: Date.now() });
|
|
|
|
|
bus.publish('signals.task.synced', {
|
|
|
|
|
|