mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(import): read Health Connect exports
Upload the ZIP the Android Health Connect app writes and import its `health_connect_export.db` in the background, the same way an Apple Health export is imported: streamed to disk with a SHA-256, capped at 1 GiB, one import per account, a job row of kind `health_connect` that the shared reconcile cron and staging sweep now look up on its own queue. The worker extracts the database (4 GiB ceiling, zip-bomb checks) and opens it read-only with `immutable=1`, `trusted_schema=OFF` and `query_only`. A view in place of a record table refuses the file, columns are found by name, and schema versions below 9 are refused. Records are read in batches of 500 and aggregated in SQLite: steps, energy and distance as one total per day from the app the person's Health Connect priority list ranks first, heart rate per minute for 90 days and as hourly means before that, sleep stages with the unknown stage counted only in sessions without real stages, workouts, cycle days, water and micronutrients. Apps whose integration the account has connected are left out, and a reading already stored under another source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the `stats:` ids, so a second import of the same export writes nothing. Both staged files are removed on every way out; the job result holds counts per type and per app only. Settings gain a Health Connect card with the export steps, progress and the outcome, in all seven languages, and the import is documented in docs/integrations/health-connect.md.
This commit is contained in:
1 parent
2622f63571
commit
996d2519d1
45 files changed
+6289
-100
No files matched your search
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
healthConnectFailureKind,
|
||||
summarizeHealthConnectResult,
|
||||
} from "../health-connect-import-card";
|
||||
|
||||
describe("healthConnectFailureKind", () => {
|
||||
it("reads the code at the front of a worker reason", () => {
|
||||
expect(
|
||||
healthConnectFailureKind("unsupported_version: database version 7"),
|
||||
).toBe("unsupportedVersion");
|
||||
expect(healthConnectFailureKind("not_health_connect: no member")).toBe(
|
||||
"notHealthConnect",
|
||||
);
|
||||
expect(healthConnectFailureKind("unsafe_schema: x is a view")).toBe(
|
||||
"unsafeSchema",
|
||||
);
|
||||
expect(healthConnectFailureKind("too_large: 5 GB")).toBe("tooLarge");
|
||||
expect(healthConnectFailureKind("staging_missing: gone")).toBe(
|
||||
"stagingMissing",
|
||||
);
|
||||
expect(healthConnectFailureKind("interrupted_by_restart")).toBe(
|
||||
"interrupted",
|
||||
);
|
||||
expect(healthConnectFailureKind("connection reset")).toBe("raw");
|
||||
expect(healthConnectFailureKind(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeHealthConnectResult", () => {
|
||||
it("lists the written types, largest first, and the records left out", () => {
|
||||
const summary = summarizeHealthConnectResult({
|
||||
perType: {
|
||||
WEIGHT: { inserted: 3, updated: 1 },
|
||||
PULSE: { inserted: 1440 },
|
||||
BODY_FAT: { inserted: 0, skipped: 2 },
|
||||
},
|
||||
perApp: {
|
||||
"com.withings.wiscale2": { records: 12, leftOut: true },
|
||||
"com.fitbit.FitbitMobile": { records: 1500, leftOut: false },
|
||||
},
|
||||
totals: { rowsUpserted: 1444 },
|
||||
});
|
||||
expect(summary.types).toEqual([
|
||||
{ type: "PULSE", written: 1440 },
|
||||
{ type: "WEIGHT", written: 4 },
|
||||
]);
|
||||
expect(summary.written).toBe(1444);
|
||||
expect(summary.leftOutRecords).toBe(12);
|
||||
});
|
||||
|
||||
it("counts genuine refusals, not deliberate skips", () => {
|
||||
const summary = summarizeHealthConnectResult({
|
||||
totals: { rowsUpserted: 1 },
|
||||
skipped: {
|
||||
connected_integration: 40,
|
||||
"SLEEP_DURATION::out_of_bed": 4,
|
||||
"WEIGHT::out_of_range": 2,
|
||||
},
|
||||
});
|
||||
expect(summary.refused).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.42 (#972) — Settings → Export & Import → Health Connect export.
|
||||
*
|
||||
* Uploads the ZIP the Android Health Connect app writes and follows the
|
||||
* background import through `GET /api/import/health-connect-export/status`,
|
||||
* which answers the account's latest job, so the outcome is still there
|
||||
* after a reload. Health Connect only exports to a cloud storage app, never
|
||||
* to the phone's own storage; the card says so up front, because that is
|
||||
* where people get stuck.
|
||||
*/
|
||||
import { useCallback, useId, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, Loader2, Smartphone, Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { ImportCardShell } from "./import-card-shell";
|
||||
|
||||
const TERMINAL_STATES: readonly string[] = ["done", "failed"];
|
||||
|
||||
interface TypeStat {
|
||||
inserted?: number;
|
||||
updated?: number;
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
/** The job as the status route answers it. Counts only. */
|
||||
export interface HealthConnectJob {
|
||||
jobId: string;
|
||||
status: string;
|
||||
progress: { rowsUpserted?: number; recordsRead?: number } | null;
|
||||
result: {
|
||||
perType?: Record<string, TypeStat>;
|
||||
perApp?: Record<string, { records?: number; leftOut?: boolean }>;
|
||||
workouts?: { inserted?: number };
|
||||
cycle?: { written?: number; moduleOff?: boolean };
|
||||
nutrients?: { written?: number; moduleOff?: boolean };
|
||||
totals?: { rowsUpserted?: number; recordsRead?: number };
|
||||
warnings?: string[];
|
||||
skipped?: Record<string, number>;
|
||||
} | null;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
/** The failure code at the front of a worker reason, when there is one. */
|
||||
export function healthConnectFailureKind(
|
||||
reason: string | null,
|
||||
):
|
||||
| "unsupportedVersion"
|
||||
| "notHealthConnect"
|
||||
| "unsafeSchema"
|
||||
| "tooLarge"
|
||||
| "stagingMissing"
|
||||
| "interrupted"
|
||||
| "raw"
|
||||
| null {
|
||||
if (!reason) return null;
|
||||
if (reason === "interrupted_by_restart") return "interrupted";
|
||||
if (reason.startsWith("unsupported_version")) return "unsupportedVersion";
|
||||
if (reason.startsWith("not_health_connect")) return "notHealthConnect";
|
||||
if (reason.startsWith("unsafe_schema")) return "unsafeSchema";
|
||||
if (reason.startsWith("too_large")) return "tooLarge";
|
||||
if (reason.startsWith("staging_missing")) return "stagingMissing";
|
||||
return "raw";
|
||||
}
|
||||
|
||||
/** What the result line and the per-type list show. */
|
||||
export function summarizeHealthConnectResult(
|
||||
result: HealthConnectJob["result"],
|
||||
): {
|
||||
written: number;
|
||||
refused: number;
|
||||
types: Array<{ type: string; written: number }>;
|
||||
leftOutRecords: number;
|
||||
} {
|
||||
const types: Array<{ type: string; written: number }> = [];
|
||||
let refused = 0;
|
||||
for (const [type, stat] of Object.entries(result?.perType ?? {})) {
|
||||
const written = (stat.inserted ?? 0) + (stat.updated ?? 0);
|
||||
if (written > 0) types.push({ type, written });
|
||||
}
|
||||
types.sort((a, b) => b.written - a.written);
|
||||
let leftOutRecords = 0;
|
||||
for (const app of Object.values(result?.perApp ?? {})) {
|
||||
if (app.leftOut) leftOutRecords += app.records ?? 0;
|
||||
}
|
||||
for (const [reason, count] of Object.entries(result?.skipped ?? {})) {
|
||||
if (/::(out_of_range|write_failed|unreadable_record)$/.test(reason)) {
|
||||
refused += count;
|
||||
}
|
||||
}
|
||||
return {
|
||||
written: result?.totals?.rowsUpserted ?? 0,
|
||||
refused,
|
||||
types,
|
||||
leftOutRecords,
|
||||
};
|
||||
}
|
||||
|
||||
export function HealthConnectImportCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const dropDescId = useId();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.healthConnectImportStatus(),
|
||||
refetchInterval: (query) => {
|
||||
const job = (
|
||||
query.state.data as { job: HealthConnectJob | null } | undefined
|
||||
)?.job;
|
||||
return job && !TERMINAL_STATES.includes(job.status) ? 2000 : false;
|
||||
},
|
||||
queryFn: () =>
|
||||
apiGet<{ job: HealthConnectJob | null }>(
|
||||
"/api/import/health-connect-export/status",
|
||||
{ credentials: "include" },
|
||||
),
|
||||
});
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File) => {
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await apiFetchRaw("/api/import/health-connect-export", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
if (res.status === 429)
|
||||
return setUploadError(
|
||||
t("settings.sections.export.import.healthConnect.rateLimited"),
|
||||
);
|
||||
if (res.status === 413)
|
||||
return setUploadError(
|
||||
t("settings.sections.export.import.healthConnect.tooLarge"),
|
||||
);
|
||||
if (res.status === 409)
|
||||
return setUploadError(
|
||||
t("settings.sections.export.import.healthConnect.busy"),
|
||||
);
|
||||
if (!res.ok)
|
||||
return setUploadError(
|
||||
t("settings.sections.export.import.healthConnect.uploadFailed"),
|
||||
);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.healthConnectImportStatus(),
|
||||
});
|
||||
} catch {
|
||||
setUploadError(
|
||||
t("settings.sections.export.import.healthConnect.uploadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[t, queryClient],
|
||||
);
|
||||
|
||||
const job = statusQuery.data?.job ?? null;
|
||||
const isRunning = job !== null && !TERMINAL_STATES.includes(job.status);
|
||||
const isDone = job?.status === "done";
|
||||
const isFailed = job?.status === "failed";
|
||||
const busy = uploading || isRunning;
|
||||
const summary = summarizeHealthConnectResult(job?.result ?? null);
|
||||
|
||||
const failureText = (() => {
|
||||
const reason = job?.failureReason ?? null;
|
||||
const kind = healthConnectFailureKind(reason);
|
||||
if (kind === null) return null;
|
||||
if (kind === "raw")
|
||||
return t("settings.sections.export.import.healthConnect.failed", {
|
||||
reason: reason ?? "",
|
||||
});
|
||||
return t(`settings.sections.export.import.healthConnect.failure.${kind}`);
|
||||
})();
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-health-connect"
|
||||
icon={Smartphone}
|
||||
title={t("settings.sections.export.import.healthConnect.title")}
|
||||
description={t(
|
||||
"settings.sections.export.import.healthConnect.description",
|
||||
)}
|
||||
>
|
||||
<ol className="text-foreground list-decimal space-y-1 pl-4 text-xs">
|
||||
<li>
|
||||
{t("settings.sections.export.import.healthConnect.steps.export")}
|
||||
</li>
|
||||
<li>
|
||||
{t("settings.sections.export.import.healthConnect.steps.cloud")}
|
||||
</li>
|
||||
<li>
|
||||
{t("settings.sections.export.import.healthConnect.steps.upload")}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-describedby={dropDescId}
|
||||
aria-disabled={busy}
|
||||
onClick={() => !busy && fileInputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && !busy) {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!busy) setDragActive(true);
|
||||
}}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file && !busy) void upload(file);
|
||||
}}
|
||||
className={cn(
|
||||
"border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
dragActive && "border-primary bg-primary/5",
|
||||
busy && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
<Upload className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{t("settings.sections.export.import.healthConnect.dropLabel")}
|
||||
</span>
|
||||
<span id={dropDescId} className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.healthConnect.dropHint")}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="sr-only"
|
||||
aria-label={t(
|
||||
"settings.sections.export.import.healthConnect.fileInputLabel",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void upload(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.healthConnect.privacyNote")}
|
||||
</p>
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{uploading && (
|
||||
<p className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{t("settings.sections.export.import.healthConnect.uploading")}
|
||||
</p>
|
||||
)}
|
||||
{isRunning && (
|
||||
<div
|
||||
data-testid="import-health-connect-progress"
|
||||
className="space-y-1"
|
||||
>
|
||||
<p className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
{t(
|
||||
`settings.sections.export.import.healthConnect.phase.${job?.status ?? "queued"}`,
|
||||
)}
|
||||
</p>
|
||||
{typeof job?.progress?.rowsUpserted === "number" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.healthConnect.rowsSoFar", {
|
||||
count: job.progress.rowsUpserted,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isDone && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={classifyWrittenOutcome({
|
||||
written: summary.written,
|
||||
skipped: summary.refused,
|
||||
})}
|
||||
message={
|
||||
summary.written > 0
|
||||
? t(
|
||||
"settings.sections.export.import.healthConnect.doneSummary",
|
||||
{ count: summary.written },
|
||||
)
|
||||
: t("settings.sections.export.import.healthConnect.doneNothing")
|
||||
}
|
||||
testId="import-health-connect-result"
|
||||
/>
|
||||
)}
|
||||
{isDone && summary.types.length > 0 && (
|
||||
<ul
|
||||
data-testid="import-health-connect-types"
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
>
|
||||
{summary.types.map(({ type, written }) => (
|
||||
<li key={type} className="flex justify-between gap-2">
|
||||
<span className="text-foreground">
|
||||
{MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType]
|
||||
? t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType])
|
||||
: type}
|
||||
</span>
|
||||
<span className="tabular-nums">{written}</span>
|
||||
</li>
|
||||
))}
|
||||
{(job?.result?.workouts?.inserted ?? 0) > 0 && (
|
||||
<li className="flex justify-between gap-2">
|
||||
<span className="text-foreground">
|
||||
{t("settings.sections.export.import.healthConnect.workouts")}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{job?.result?.workouts?.inserted}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
{isDone && summary.leftOutRecords > 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"settings.sections.export.import.healthConnect.leftOutConnected",
|
||||
{ count: summary.leftOutRecords },
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{isDone && job?.result?.nutrients?.moduleOff && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"settings.sections.export.import.healthConnect.nutrientsModuleOff",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{isDone && job?.result?.cycle?.moduleOff && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.export.import.healthConnect.cycleModuleOff")}
|
||||
</p>
|
||||
)}
|
||||
{(isFailed || uploadError) && (
|
||||
<p
|
||||
data-testid="import-health-connect-error"
|
||||
role="alert"
|
||||
className="text-destructive flex items-start gap-2 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{uploadError ??
|
||||
failureText ??
|
||||
t("settings.sections.export.import.healthConnect.uploadFailed")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SettingsCardActions className="mt-auto" align="start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
data-testid="import-action-health-connect"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.import.healthConnect.choose")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</ImportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
*
|
||||
* 1. Apple Health `export.zip` — `apple-health-import-card.tsx`
|
||||
* (multipart POST + status poll).
|
||||
* 1b. Health Connect export ZIP — `health-connect-import-card.tsx`
|
||||
* (v1.42; multipart POST + poll of the account's latest job).
|
||||
* 2. Generic JSON import — `json-import-card.tsx`
|
||||
* (file/paste, client-side parseability guard).
|
||||
* 3. CSV import — `csv-import-card.tsx`
|
||||
@@ -22,6 +24,7 @@ import { Upload } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { AppleHealthImportCard } from "./apple-health-import-card";
|
||||
import { HealthConnectImportCard } from "./health-connect-import-card";
|
||||
import { JsonImportCard } from "./json-import-card";
|
||||
import { CsvImportCard } from "./csv-import-card";
|
||||
import { DoseHistoryImportCard } from "./dose-history-import-card";
|
||||
@@ -48,6 +51,7 @@ export function ImportPanel() {
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<AppleHealthImportCard />
|
||||
<HealthConnectImportCard />
|
||||
<JsonImportCard />
|
||||
<CsvImportCard />
|
||||
<DoseHistoryImportCard />
|
||||
|
||||
Reference in new issue
Block a user