mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Garmin Connect now offers a Personal API: a person creates a key for their own account and any client can buy short-lived tokens with it. HealthLog takes that key in a new Garmin card, checks it with Garmin before storing it sealed on the user row, and polls every hour. - Transport: key exchange with a per-process token cache keyed on the key, one re-exchange on a refused token and then reauth_required, the 429 Retry-After carried to the caller, the China host tried when the global one does not know the key, answers read with a byte cap, all egress through safeFetch to the two fixed hosts. - Sync: daily summaries, sleep summaries, HRV, weigh-ins and the activity search, chunked to each resource's documented range, read one after another and stopped at the first rate limit; today's menstrual cycle summary adds logged period days the person has not logged. - Backfill: a resumable walk through the shared admission lane that learns where the history starts from the daily summaries and the activities, waits out Garmin's rate limit and stamps its marker. - Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest token. All four chart on the recovery page and sit on the ladders, units, ranges, labels and report groups like the device-native scores. - Key expiry: learned from a token capped short of a day, else estimated from the validity picked at connect; the card warns a week ahead and asks for a new key once Garmin refuses the old one. - Ledger, health, freshness, watchdog, backup classes, wipe plan, encrypted-column registry, rotation script, OpenAPI and all ten locales carry the new integration. The old "no Garmin connector" note is gone.
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/**
|
|
* Settings → Gesundheitsakte: what the export panel opens with.
|
|
*
|
|
* Two things are seeded from `/api/auth/me` during render — the practice name
|
|
* and the saved selection. The seed runs during render because a `useState`
|
|
* initialiser would read `undefined` while `useAuth` is still resolving and
|
|
* stick, and it is gated on the user id so a late re-resolve never clobbers
|
|
* edits in flight.
|
|
*
|
|
* The three panel states are the load-bearing part. A first run must select
|
|
* NOTHING — no template, no server default, no group half on — and must leave
|
|
* Generate disabled until a person ticks something, with the standard-report
|
|
* button one click away. A repeat run must open collapsed carrying the owner's
|
|
* own last scope. A set that arrives without being asked for is a
|
|
* preselection whatever it is called, and material nobody ticked is exactly
|
|
* what this surface must never send.
|
|
*
|
|
* SSR-only, per the project convention for this surface.
|
|
*
|
|
* Mutation checks (each verified red, then reverted):
|
|
* - remove the seeding block → "pre-fills the input" and "restores the saved
|
|
* selection" go red.
|
|
* - seed `selected` from `STANDARD_TEMPLATE_LEAVES` → "selects nothing on a
|
|
* first run", "counts nothing in any group" and "disables Generate" go
|
|
* red.
|
|
* - start `pickerOpen` at `false` → "opens expanded on the first run" goes
|
|
* red.
|
|
* - drop `selected.size === 0` from the Generate `disabled` expression →
|
|
* "disables Generate while the scope is empty" goes red.
|
|
*/
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import type { AuthUser } from "@/hooks/use-auth";
|
|
|
|
const authSpy = vi.fn<() => { user: AuthUser | null }>();
|
|
vi.mock("@/hooks/use-auth", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
|
"@/hooks/use-auth",
|
|
);
|
|
return { ...actual, useAuth: () => authSpy() };
|
|
});
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
|
// The panel's visit offer reads the encounter list. Pending forever here:
|
|
// this file is about the panel's own state, and a resolved read would put
|
|
// an offer in the markup every assertion below would have to know about.
|
|
useQuery: () => ({ data: undefined, isPending: true, isError: false }),
|
|
}));
|
|
|
|
import { I18nProvider } from "@/lib/i18n/context";
|
|
import { HealthRecordExportPanel } from "../report-selection/selection-panel";
|
|
|
|
function buildUser(lastReportPracticeName: string | null): AuthUser {
|
|
return {
|
|
id: "user-1",
|
|
username: "user",
|
|
email: null,
|
|
role: "USER",
|
|
heightCm: null,
|
|
dateOfBirth: null,
|
|
gender: null,
|
|
timezone: "Europe/Berlin",
|
|
onboardingCompletedAt: null,
|
|
onboardingTourCompleted: true,
|
|
onboardingTourProgress: null,
|
|
avatarUrl: null,
|
|
glucoseUnit: null,
|
|
unitPreference: "metric",
|
|
timeFormat: "AUTO",
|
|
dateFormat: "AUTO",
|
|
disableCoach: false,
|
|
fullName: null,
|
|
insurerName: null,
|
|
insurerIkNumber: null,
|
|
insuranceNumber: null,
|
|
lastReportPracticeName,
|
|
reportSelection: null,
|
|
cycleTrackingEnabled: false,
|
|
modules: {},
|
|
} as AuthUser;
|
|
}
|
|
|
|
function withSavedSelection(user: AuthUser, leaves: string[]): AuthUser {
|
|
return {
|
|
...user,
|
|
reportSelection: {
|
|
v: 2,
|
|
leaves,
|
|
format: "fhir",
|
|
rangeDays: 180,
|
|
includeCharts: false,
|
|
},
|
|
} as AuthUser;
|
|
}
|
|
|
|
/**
|
|
* Whether a leaf row renders as checked. Only the fenced tier and an expanded
|
|
* group render leaf rows at all — a collapsed group shows its count chip
|
|
* instead, which is the point of the two-interaction repeat path.
|
|
*/
|
|
function leafChecked(html: string, leaf: string): boolean {
|
|
const row = html.match(
|
|
new RegExp(`data-testid="report-leaf-${leaf}-export"[\\s\\S]*?</label>`),
|
|
)?.[0];
|
|
if (!row) return false;
|
|
return /data-state="checked"/.test(row);
|
|
}
|
|
|
|
/** The "n/total" chip a collapsed group row carries. */
|
|
function groupChip(html: string, group: string): string | null {
|
|
const row = html.match(
|
|
new RegExp(
|
|
`data-testid="report-group-row-${group}-export"[\\s\\S]*?</div>`,
|
|
),
|
|
)?.[0];
|
|
return row?.match(/data-slot="tag-chip"[^>]*>([^<]*)</)?.[1] ?? null;
|
|
}
|
|
|
|
function renderPanel(user: AuthUser | null): string {
|
|
authSpy.mockReturnValue({ user });
|
|
return renderToStaticMarkup(
|
|
<I18nProvider initialLocale="en">
|
|
<HealthRecordExportPanel />
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
function practiceInputValue(html: string): string | null {
|
|
const input = html.match(/<input[^>]*id="hr-practice"[^>]*>/)?.[0];
|
|
if (!input) return null;
|
|
return input.match(/value="([^"]*)"/)?.[1] ?? "";
|
|
}
|
|
|
|
/** Whether the Generate control refuses the press. */
|
|
function generateDisabled(html: string): boolean {
|
|
const button = html.match(
|
|
/<button[^>]*data-testid="health-record-generate"[^>]*>/,
|
|
)?.[0];
|
|
if (!button) throw new Error("the Generate button did not render");
|
|
return /\sdisabled=""/.test(button);
|
|
}
|
|
|
|
describe("<HealthRecordExportPanel> — remembered practice name", () => {
|
|
it("pre-fills the input with the last-used practice name", () => {
|
|
const html = renderPanel(buildUser("Sample Practice"));
|
|
expect(practiceInputValue(html)).toBe("Sample Practice");
|
|
});
|
|
|
|
it("leaves the input empty when no name was ever remembered", () => {
|
|
const html = renderPanel(buildUser(null));
|
|
expect(practiceInputValue(html)).toBe("");
|
|
});
|
|
|
|
it("leaves the input empty while the auth payload is still resolving", () => {
|
|
const html = renderPanel(null);
|
|
expect(practiceInputValue(html)).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("<HealthRecordExportPanel> — the first run", () => {
|
|
it("opens the picker expanded with the standard-report button in view", () => {
|
|
const html = renderPanel(buildUser(null));
|
|
expect(html).toContain('data-testid="health-record-included-data-panel"');
|
|
expect(html).toContain('data-testid="report-scope-picker-export"');
|
|
// The template is offered by name on a button, not applied behind the
|
|
// user's back. One click is still one click; it just has to happen.
|
|
expect(html).toContain('data-testid="report-apply-standard-export"');
|
|
expect(html).toContain("Apply Standard doctor's report");
|
|
});
|
|
|
|
it("selects nothing at all — no template, no default", () => {
|
|
const html = renderPanel(buildUser(null));
|
|
expect(html).toContain('data-testid="report-scope-summary-export"');
|
|
expect(html).toContain("Nothing selected yet");
|
|
// Every leaf the panel renders at all is off. Only the fenced tier renders
|
|
// leaf rows while every group is collapsed, and none of them is checked.
|
|
for (const leaf of ["MOOD", "CYCLE", "FAMILY_HISTORY", "PHQ9_SCORE"]) {
|
|
expect(leafChecked(html, leaf), leaf).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("counts nothing in any group, the template's own groups included", () => {
|
|
const html = renderPanel(buildUser(null));
|
|
// These six carry the standard template's leaves. On a first run they read
|
|
// 0/n like every other group: the template is a button, not a starting
|
|
// state.
|
|
expect(groupChip(html, "identity")).toBe("0/3");
|
|
expect(groupChip(html, "vitals")).toBe("0/7");
|
|
expect(groupChip(html, "body")).toBe("0/12");
|
|
expect(groupChip(html, "labs")).toBe("0/1");
|
|
expect(groupChip(html, "medications")).toBe("0/5");
|
|
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
|
|
expect(groupChip(html, "history")).toBe("0/5");
|
|
expect(groupChip(html, "cardio")).toBe("0/16");
|
|
expect(groupChip(html, "activity")).toBe("0/12");
|
|
expect(groupChip(html, "sleepRecovery")).toBe("0/19");
|
|
expect(groupChip(html, "mobility")).toBe("0/11");
|
|
expect(groupChip(html, "environment")).toBe("0/7");
|
|
});
|
|
|
|
it("disables Generate while the scope is empty, and says what to do", () => {
|
|
const html = renderPanel(buildUser(null));
|
|
expect(generateDisabled(html)).toBe(true);
|
|
// The reason sits beside the control and points at the way out of it,
|
|
// rather than telling the person off after they press.
|
|
expect(html).toContain(
|
|
"Nothing selected yet. Choose what to include, or apply the standard report.",
|
|
);
|
|
});
|
|
|
|
it("leaves every fenced leaf unchecked and renders the tier", () => {
|
|
const html = renderPanel(buildUser(null));
|
|
expect(html).toContain('data-testid="report-sensitive-tier-export"');
|
|
for (const leaf of [
|
|
"MOOD",
|
|
"CYCLE",
|
|
"FAMILY_HISTORY",
|
|
"PHQ9_SCORE",
|
|
"GAD7_SCORE",
|
|
"WHO5_SCORE",
|
|
"SCI_SCORE",
|
|
]) {
|
|
expect(leafChecked(html, leaf), leaf).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("gives the fenced tier no group checkbox", () => {
|
|
const html = renderPanel(buildUser(null));
|
|
expect(html).toContain('data-testid="report-group-check-vitals-export"');
|
|
expect(html).not.toContain(
|
|
'data-testid="report-group-check-sensitive-export"',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("<HealthRecordExportPanel> — a repeat run", () => {
|
|
it("restores the saved selection and collapses the picker", () => {
|
|
const html = renderPanel(
|
|
withSavedSelection(buildUser("Sample Practice"), [
|
|
"RESTING_HEART_RATE",
|
|
"MOOD",
|
|
]),
|
|
);
|
|
expect(html).not.toContain(
|
|
'data-testid="health-record-included-data-panel"',
|
|
);
|
|
// The scope line still says what will be in the document, and it names the
|
|
// fenced inclusion rather than folding it into the count.
|
|
expect(html).toContain("2 entries from 2 areas");
|
|
expect(html).toContain("incl. Mood");
|
|
});
|
|
|
|
it("enables Generate once a scope has been chosen", () => {
|
|
const html = renderPanel(withSavedSelection(buildUser(null), ["WEIGHT"]));
|
|
expect(generateDisabled(html)).toBe(false);
|
|
expect(html).not.toContain("Nothing selected yet");
|
|
});
|
|
|
|
it("restores the saved format and range", () => {
|
|
const html = renderPanel(withSavedSelection(buildUser(null), ["WEIGHT"]));
|
|
// FHIR was saved, so the practice-name line (PDF only) is not rendered.
|
|
expect(practiceInputValue(html)).toBeNull();
|
|
expect(html).toContain('<option value="180" selected=""');
|
|
});
|
|
|
|
it("falls back to an empty scope when the saved blob cannot be read", () => {
|
|
const user = {
|
|
...buildUser(null),
|
|
reportSelection: { bp: true, weight: true },
|
|
} as AuthUser;
|
|
const html = renderPanel(user);
|
|
// A shape the server cannot read is not consent to anything, so the panel
|
|
// treats it as a first run: nothing selected, Generate refused, the
|
|
// standard-report button one click away.
|
|
expect(groupChip(html, "body")).toBe("0/12");
|
|
expect(generateDisabled(html)).toBe(true);
|
|
expect(html).toContain('data-testid="report-apply-standard-export"');
|
|
});
|
|
});
|