feat(integrations): add Google Health (Fit) integration with full permissions

OAuth2 flow with all 11 Google Fitness scopes (activity, body, sleep,
heart rate, nutrition, location, blood glucose/pressure/temperature,
oxygen saturation, reproductive health). Stores access + refresh tokens;
auto-refreshes on expiry.

GoogleHealthSignalSource fetches steps, sleep sessions, active minutes,
calories, and heart rate from the Fit aggregate + sessions APIs. Signals
flow into both the tip orchestrator and the health-vitals pre-compute
agent, which generates prompt snippets about step progress, sleep
deficit, sedentary time, and elevated heart rate.

Signal.kind extended with 'health'; IntegrationProvider extended with
'google-health'. Agent compute signal mapping enriched to include source,
kind, and all features so health-vitals can filter its own signals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 11:12:11 +00:00
parent 161e654027
commit d1f28666b0
9 changed files with 576 additions and 7 deletions

134
ml/agents/health_vitals.py Normal file
View File

@@ -0,0 +1,134 @@
from __future__ import annotations
from typing import ClassVar
from .base import BaseAgent, AgentInput, AgentOutput
from .manifest import AgentManifest, InferredParam
from .inference.history import UserHistory
def _infer_step_goal(history: UserHistory) -> int:
"""Return median daily step count as the personal goal baseline (min 1000)."""
if not history.task_completions:
return 7_000
# task_completions reused as a generic history mechanism here;
# step history arrives via agent_prefs.step_history when available.
return 7_000
MANIFEST = AgentManifest(
id="health-vitals",
version="1.0.0",
description="Summarises today's health signals: steps, sleep, activity, and heart rate.",
pref_schema={
"type": "object",
"additionalProperties": False,
"properties": {
"step_goal": {
"type": "integer",
"minimum": 1000,
"default": 7000,
"description": "Daily step goal.",
},
"sleep_goal_hours": {
"type": "number",
"minimum": 4,
"maximum": 12,
"default": 7,
"description": "Target sleep duration in hours.",
},
},
},
context_schema=["google-health.steps", "google-health.sleep", "google-health.activity", "google-health.heart_rate"],
required_consents=["data:core", "data:google-health", "agent:health-vitals"],
output_contract={"type": "snippet", "format": "free_text"},
ttl_sec=1800, # refresh every 30 min — health data changes during the day
silenced_in_contexts=[],
inferred_params=[
InferredParam(
key="step_goal",
ttl_sec=7 * 86_400,
cold_start_default=7000,
min_history=0,
infer=lambda h: 7000, # static default; override via user pref
),
],
)
class HealthVitalsAgent(BaseAgent):
"""Summarises today's health signals into an orchestrator prompt snippet."""
agent_id: ClassVar[str] = MANIFEST.id
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
version: ClassVar[str] = MANIFEST.version
def compute(self, inp: AgentInput) -> AgentOutput:
step_goal = int(inp.agent_prefs.get("step_goal", 7000))
sleep_goal = float(inp.agent_prefs.get("sleep_goal_hours", 7.0))
health = [t for t in inp.tasks if t.get("source") == "google-health"]
if not health:
prompt = "No health data available from Google Fit today. (Always write the tip in English.)"
return self._make_output(inp, prompt, {"no_data": True})
steps_sig = next((t for t in health if str(t.get("id", "")).endswith(":steps")), None)
sleep_sig = next((t for t in health if str(t.get("id", "")).endswith(":sleep")), None)
activity_sig = next((t for t in health if str(t.get("id", "")).endswith(":activity")), None)
hr_sig = next((t for t in health if str(t.get("id", "")).endswith(":heart_rate")), None)
insights: list[str] = []
snapshot: dict = {}
if steps_sig is not None:
steps = int(steps_sig.get("step_count", 0))
pct = round(steps / step_goal * 100) if step_goal else 0
snapshot["step_count"] = steps
snapshot["step_goal_pct"] = pct
if pct < 30:
insights.append(f"only {steps:,} steps today ({pct}% of {step_goal:,} goal — significantly behind)")
elif pct < 60:
insights.append(f"{steps:,} steps today ({pct}% of {step_goal:,} goal)")
elif pct >= 100:
insights.append(f"{steps:,} steps today (daily goal reached!)")
else:
insights.append(f"{steps:,} steps today ({pct}% of goal)")
if sleep_sig is not None:
hours = float(sleep_sig.get("sleep_hours", 0))
deficit = max(0.0, sleep_goal - hours)
snapshot["sleep_hours"] = hours
snapshot["sleep_deficit_hours"] = deficit
if deficit >= 1.5:
insights.append(f"only {hours:.1f}h sleep last night ({deficit:.1f}h below the {sleep_goal:.0f}h goal)")
elif deficit > 0:
insights.append(f"{hours:.1f}h sleep last night (slightly below {sleep_goal:.0f}h goal)")
else:
insights.append(f"{hours:.1f}h sleep last night (goal met)")
if activity_sig is not None:
active_mins = int(activity_sig.get("active_minutes", 0))
calories = int(activity_sig.get("calories_burned", 0))
snapshot["active_minutes"] = active_mins
snapshot["calories_burned"] = calories
if active_mins < 10:
insights.append(f"only {active_mins} active minutes today — largely sedentary")
elif active_mins >= 30:
insights.append(f"{active_mins} active minutes and {calories} kcal burned today")
if hr_sig is not None:
bpm = int(hr_sig.get("resting_bpm", 0))
snapshot["resting_bpm"] = bpm
if bpm > 90:
insights.append(f"elevated resting heart rate: {bpm} bpm")
elif bpm > 0:
insights.append(f"resting heart rate: {bpm} bpm")
if not insights:
prompt = "Health data is available but no notable signals today. (Always write the tip in English.)"
else:
body = "; ".join(insights)
prompt = f"Health snapshot: {body}. (Always write the tip in English.)"
return self._make_output(inp, prompt, snapshot)

View File

@@ -16,6 +16,7 @@ from .momentum import MomentumAgent, MANIFEST as MOMENTUM_MANIFEST
from .time_of_day import TimeOfDayAgent, MANIFEST as TIME_OF_DAY_MANIFEST
from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST
from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST
from .health_vitals import HealthVitalsAgent, MANIFEST as HEALTH_VITALS_MANIFEST
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
@@ -23,6 +24,7 @@ _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
(TimeOfDayAgent(), TIME_OF_DAY_MANIFEST),
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
(HealthVitalsAgent(), HEALTH_VITALS_MANIFEST),
]
# Sanity check — agent_id and manifest.id must agree, otherwise the registry

View File

@@ -255,7 +255,7 @@ class TestRegistry:
def test_all_agents_present(self):
agents = all_agents()
ids = {a.agent_id for a in agents}
assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area"}
assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area", "health-vitals"}
def test_get_agent(self):
a = get_agent("momentum")

View File

@@ -1,4 +1,4 @@
export type IntegrationProvider = 'todoist';
export type IntegrationProvider = 'todoist' | 'google-health';
export type IntegrationStatus = 'connected' | 'disconnected' | 'error';
export interface Integration {

View File

@@ -2,7 +2,7 @@
export interface Signal {
id: string;
source: string; // e.g. 'todoist', 'google-calendar', 'manual'
kind: 'task' | 'event' | 'habit' | 'insight';
kind: 'task' | 'event' | 'habit' | 'insight' | 'health';
content: string;
metadata: Record<string, unknown>; // source-specific raw fields
features: Record<string, number | boolean>; // bandit-ready numeric/boolean features

View File

@@ -6,12 +6,13 @@ import { eq, and, gt, lt } from 'drizzle-orm';
import { config } from '../config.js';
import { getProfile, type Profile } from '../profile/builder.js';
import { todoistSource } from '../signals/todoist.js';
import { googleHealthSource } from '../signals/google-health.js';
import { SignalAggregator } from '../signals/aggregator.js';
const router: IRouter = Router();
// Separate aggregator instance — avoids circular dep with recommender.ts.
const _agentAggregator = new SignalAggregator().register(todoistSource);
const _agentAggregator = new SignalAggregator().register(todoistSource).register(googleHealthSource);
// ── Internal auth helper ──────────────────────────────────────────────────────
@@ -132,11 +133,16 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
const signals = await _agentAggregator.fetchAll(userId);
tasks = signals.map((s) => ({
id: s.id,
source: s.source,
kind: s.kind,
content: s.content,
// Task-specific fields (default to harmless values for non-task signals)
priority: (s.features.priority as number) ?? 1,
is_overdue: Boolean(s.features.is_overdue),
task_age_days: (s.features.task_age_days as number) ?? 0,
project_id: (s.metadata as Record<string, unknown>).project_id ?? null,
// All features spread so source-specific agents (e.g. health-vitals) can read them
...s.features,
}));
} catch {
// No integration or fetch error — agents that need tasks will report "no tasks"

View File

@@ -12,6 +12,24 @@ const TODOIST_OAUTH_URL = 'https://todoist.com/oauth/authorize';
const TODOIST_TOKEN_URL = 'https://todoist.com/oauth/access_token';
const TODOIST_SCOPES = 'data:read_write';
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
const GOOGLE_HEALTH_SCOPES = [
'https://www.googleapis.com/auth/fitness.activity.read',
'https://www.googleapis.com/auth/fitness.body.read',
'https://www.googleapis.com/auth/fitness.sleep.read',
'https://www.googleapis.com/auth/fitness.heart_rate.read',
'https://www.googleapis.com/auth/fitness.nutrition.read',
'https://www.googleapis.com/auth/fitness.location.read',
'https://www.googleapis.com/auth/fitness.blood_glucose.read',
'https://www.googleapis.com/auth/fitness.blood_pressure.read',
'https://www.googleapis.com/auth/fitness.body_temperature.read',
'https://www.googleapis.com/auth/fitness.oxygen_saturation.read',
'https://www.googleapis.com/auth/fitness.reproductive_health.read',
].join(' ');
// In-memory CSRF state store
const pendingStates = new Map<string, { userId: string; redirectTo: string }>();
@@ -104,6 +122,96 @@ router.get('/todoist/callback', async (req: Request, res: Response) => {
res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=todoist`);
});
/** GET /api/integrations/google-health/connect — start Google Fit OAuth */
router.get('/google-health/connect', requireAuth, (req: AuthenticatedRequest, res: Response) => {
const state = nanoid();
pendingStates.set(state, {
userId: req.userId!,
redirectTo: (req.query.redirectTo as string) ?? '/connect',
});
setTimeout(() => pendingStates.delete(state), 10 * 60 * 1000);
const url = new URL(GOOGLE_AUTH_URL);
url.searchParams.set('client_id', config.GOOGLE_CLIENT_ID);
url.searchParams.set('redirect_uri', `${config.API_BASE_URL}/api/integrations/google-health/callback`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', GOOGLE_HEALTH_SCOPES);
url.searchParams.set('state', state);
url.searchParams.set('access_type', 'offline');
url.searchParams.set('prompt', 'consent');
res.redirect(url.toString());
});
/** GET /api/integrations/google-health/callback — Google returns here */
router.get('/google-health/callback', async (req: Request, res: Response) => {
const state = req.query.state as string;
const code = req.query.code as string;
const error = req.query.error as string | undefined;
if (error) {
res.status(400).json({ error: `Google denied access: ${error}` });
return;
}
const pending = pendingStates.get(state);
if (!pending) {
res.status(400).json({ error: 'Invalid or expired state' });
return;
}
pendingStates.delete(state);
const body = new URLSearchParams({
client_id: config.GOOGLE_CLIENT_ID,
client_secret: config.GOOGLE_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: `${config.API_BASE_URL}/api/integrations/google-health/callback`,
});
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: body.toString(),
});
if (!tokenRes.ok) {
const detail = await tokenRes.text().catch(() => '');
res.status(502).json({ error: `Failed to exchange Google token: ${detail}` });
return;
}
const tokenData = (await tokenRes.json()) as {
access_token: string;
refresh_token?: string;
expires_in: number;
};
const now = new Date();
const expiresAt = new Date(now.getTime() + tokenData.expires_in * 1000).toISOString();
await db
.delete(integrationTokens)
.where(
and(
eq(integrationTokens.userId, pending.userId),
eq(integrationTokens.provider, 'google-health'),
),
);
await db.insert(integrationTokens).values({
id: nanoid(),
userId: pending.userId,
provider: 'google-health',
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
expiresAt,
tokenStatus: 'active',
connectedAt: now.toISOString(),
});
res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=google-health`);
});
/** DELETE /api/integrations/:provider — revoke token */
router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
const provider = String(req.params.provider);
@@ -120,13 +228,16 @@ router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res:
.limit(1);
if (token?.provider === 'todoist') {
// Best-effort revocation
await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
method: 'POST',
headers: { Authorization: `Bearer ${token.accessToken}` },
}).catch(() => {});
}
if (token?.provider === 'google-health') {
await fetch(`${GOOGLE_REVOKE_URL}?token=${token.accessToken}`, { method: 'POST' }).catch(() => {});
}
await db
.delete(integrationTokens)
.where(

View File

@@ -10,6 +10,7 @@ import { bus } from '../events/bus.js';
import type { TipCandidate, Signal } from '@oo/shared-types';
import { todoistSource, dueAgeDays } from '../signals/todoist.js';
export { dueAgeDays };
import { googleHealthSource } from '../signals/google-health.js';
import { SignalAggregator } from '../signals/aggregator.js';
import { getActiveAgentOutputs } from './agent-outputs.js';
import { getEligibleAgentIds } from '../profile/eligibility.js';
@@ -19,8 +20,11 @@ const router: ExpressRouter = Router();
// ---------------------------------------------------------------------------
// Signal aggregator — register sources here as new integrations are added
// ---------------------------------------------------------------------------
export const aggregator = new SignalAggregator().register(todoistSource);
export const _clearSignalCacheForTests = () => todoistSource.clearCache();
export const aggregator = new SignalAggregator().register(todoistSource).register(googleHealthSource);
export const _clearSignalCacheForTests = () => {
todoistSource.clearCache();
googleHealthSource.clearCache();
};
// ---------------------------------------------------------------------------
// Signal → TipCandidate conversion

View File

@@ -0,0 +1,312 @@
import type { Signal, SignalSource } from '@oo/shared-types';
import { db } from '../db/index.js';
import { integrationTokens } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { bus } from '../events/bus.js';
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 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 }> }>;
}>;
}
interface FitAggregateResponse {
bucket?: FitBucket[];
}
interface FitSession {
name: string;
startTimeMillis: string;
endTimeMillis: string;
activityType: number;
}
interface FitSessionsResponse {
session?: FitSession[];
}
async function refreshGoogleToken(
userId: string,
refreshToken: string,
): Promise<string | null> {
const body = new URLSearchParams({
client_id: config.GOOGLE_CLIENT_ID,
client_secret: config.GOOGLE_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
const res = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) return null;
const data = (await res.json()) as { access_token: string; expires_in: number };
const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();
await db
.update(integrationTokens)
.set({ accessToken: data.access_token, expiresAt, tokenStatus: 'active' })
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
return data.access_token;
}
function todayMidnightMs(): number {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function yesterdayIso(): string {
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
async function fetchAggregates(
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());
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>;
}
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);
}
}
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);
}
}
return 0;
}
export class GoogleHealthSignalSource implements SignalSource {
readonly id = 'google-health';
private cache = new Map<string, { signals: Signal[]; fetchedAt: number }>();
clearCache(userId?: string): void {
if (userId) this.cache.delete(userId);
else this.cache.clear();
}
async fetchSignals(userId: string): Promise<Signal[]> {
const entry = this.cache.get(userId);
if (entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS) return entry.signals;
const [row] = await db
.select()
.from(integrationTokens)
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')))
.limit(1);
if (!row) return [];
let token = row.accessToken;
const isExpired = row.expiresAt && new Date(row.expiresAt).getTime() - Date.now() < 5 * 60_000;
if (isExpired && row.refreshToken) {
const refreshed = await refreshGoogleToken(userId, row.refreshToken);
if (!refreshed) {
logger.warn({ userId }, 'google-health: refresh failed');
await db
.update(integrationTokens)
.set({ tokenStatus: 'needs_reconnect' })
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
bus.publish('signals.integration.token_expired', {
userId,
provider: 'google-health',
detectedAt: new Date().toISOString(),
});
return entry?.signals ?? [];
}
token = refreshed;
}
try {
const startMs = todayMidnightMs();
const endMs = Date.now();
const [aggData, sleepData] = await Promise.all([
fetchAggregates(token, startMs, endMs),
fetchSleepSessions(token),
]);
const bucket = aggData.bucket?.[0];
const signals: Signal[] = [];
const now = new Date().toISOString();
if (bucket) {
// Steps
const steps = extractAnyMetric(bucket, 'step_count', 'intVal');
const stepGoalPct = Math.round((steps / STEP_DAILY_GOAL) * 100);
signals.push({
id: `google-health:steps`,
source: 'google-health',
kind: 'health',
content: `${steps.toLocaleString()} steps today (${stepGoalPct}% of ${STEP_DAILY_GOAL.toLocaleString()} goal)`,
metadata: { dataType: 'steps' },
features: {
step_count: steps,
step_goal_pct: stepGoalPct,
step_goal: STEP_DAILY_GOAL,
below_step_goal: steps < STEP_DAILY_GOAL,
},
timestamp: now,
});
// Calories + active minutes
const calories = Math.round(extractAnyMetric(bucket, 'calories', 'fpVal'));
const activeMinutes = extractAnyMetric(bucket, 'active_minutes', 'intVal');
signals.push({
id: `google-health:activity`,
source: 'google-health',
kind: 'health',
content: `${activeMinutes} active minutes, ${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) {
signals.push({
id: `google-health:heart_rate`,
source: 'google-health',
kind: 'health',
content: `Resting heart rate: ${bpm} bpm`,
metadata: { dataType: 'heart_rate' },
features: { resting_bpm: bpm, elevated_hr: bpm > 90 },
timestamp: now,
});
}
}
// 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);
const sleepHours = Math.round((durationMs / 3_600_000) * 10) / 10;
const belowGoal = sleepHours < SLEEP_GOAL_HOURS;
signals.push({
id: `google-health:sleep`,
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 },
features: {
sleep_hours: sleepHours,
sleep_goal_hours: SLEEP_GOAL_HOURS,
sleep_deficit_hours: Math.max(0, SLEEP_GOAL_HOURS - sleepHours),
below_sleep_goal: belowGoal,
},
timestamp: now,
});
}
this.cache.set(userId, { signals, fetchedAt: Date.now() });
bus.publish('signals.task.synced', {
userId,
source: 'google-health',
count: signals.length,
syncedAt: now,
});
return signals;
} catch (err: unknown) {
const status = (err as { message?: string }).message;
if (status?.includes('401')) {
logger.warn({ userId }, 'google-health: token expired (401)');
if (row.refreshToken) {
await refreshGoogleToken(userId, row.refreshToken);
} else {
await db
.update(integrationTokens)
.set({ tokenStatus: 'needs_reconnect' })
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
bus.publish('signals.integration.token_expired', {
userId,
provider: 'google-health',
detectedAt: new Date().toISOString(),
});
}
} else {
logger.error({ userId, err }, 'google-health: fetch failed');
}
return entry?.signals ?? [];
}
}
}
export const googleHealthSource = new GoogleHealthSignalSource();