Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-tablet

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 19:53:46 +02:00
commit 803ef73a4d
97 files changed
+4035 -461

No files matched your search

@@ -0,0 +1,124 @@
/**
* The Apple Health write-back controls on the API settings (#1272).
*
* SSR-only, as the rest of this directory: the render pins which token rows
* carry the switch and in which state, when the notice about readings that
* belong to no token shows, and the write the switch sends is pinned against
* a stubbed `fetch`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
import {
AppleHealthMirrorOptIn,
AppleHealthMirrorSwitch,
UnattributedReadingsNotice,
patchTokenMirror,
} from "../apple-health-mirror-token";
function render(node: React.ReactNode, client = new QueryClient()) {
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
const token = (permissions: string[], mirrorToAppleHealth = false) => ({
id: "tok-1",
name: "Scale",
permissions,
mirrorToAppleHealth,
});
describe("the switch on a token row", () => {
it("appears on a measurement token only", () => {
expect(
render(<AppleHealthMirrorSwitch token={token(["measurements:write"])} />),
).toContain('data-slot="apple-health-mirror-switch"');
for (const permissions of [["*"], ["health:read"], ["workouts:write"]]) {
expect(
render(<AppleHealthMirrorSwitch token={token(permissions)} />),
).toBe("");
}
});
it("shows the stored setting and names the token", () => {
const on = render(
<AppleHealthMirrorSwitch token={token(["measurements:write"], true)} />,
);
expect(on).toContain('aria-checked="true"');
expect(on).toContain("Write readings from Scale to Apple Health");
const off = render(
<AppleHealthMirrorSwitch token={token(["measurements:write"])} />,
);
expect(off).toContain('aria-checked="false"');
});
});
describe("the opt-in on the mint form", () => {
it("reads as the setting with its explanation", () => {
const html = render(
<AppleHealthMirrorOptIn checked={false} onCheckedChange={() => {}} />,
);
expect(html).toContain("Write readings to Apple Health");
expect(html).toContain("adds this token&#x27;s readings to Apple Health");
});
});
describe("the notice about readings no token reaches", () => {
function withSummary(unattributedReadings: number) {
const client = new QueryClient();
client.setQueryData(queryKeys.measurementTokenSummary(), {
unattributedReadings,
});
return render(<UnattributedReadingsNotice />, client);
}
it("shows only when there are such readings", () => {
expect(withSummary(3)).toContain(
"Readings sent before this setting existed can&#x27;t be matched to a token",
);
expect(withSummary(0)).toBe("");
expect(render(<UnattributedReadingsNotice />)).toBe("");
});
});
describe("the write", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
data: { id: "tok 1", mirrorToAppleHealth: true },
error: null,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
it("patches the token with the one field", async () => {
await expect(patchTokenMirror("tok 1", true)).resolves.toEqual({
id: "tok 1",
mirrorToAppleHealth: true,
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/api/tokens/tok%201");
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({
mirrorToAppleHealth: true,
});
});
});
@@ -0,0 +1,130 @@
"use client";
import { Key } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
/** The ingest endpoints a token reaches, with the header and a body sketch. */
export function ApiEndpointsCard() {
const { t } = useTranslations();
// Dual-render: <md gets a stacked card list, md+
// keeps the original table. Mobile cards wrap inside their box and
// never trigger horizontal scroll, mirroring the pattern from
// `<ApiTokenOverviewSection>`. The endpoint catalogue is array-driven
// so adding a row remains a one-line edit on both surfaces — which is
// what the two measurement ingest rows below cost.
const endpoints = [
{
method: "POST",
path: "/api/ingest/medication",
auth: "Authorization: Bearer hlk_...",
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
},
{
method: "POST",
path: "/api/measurements",
auth: "Authorization: Bearer hlk_...",
example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
},
{
method: "POST",
path: "/api/measurements/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
},
{
method: "POST",
path: "/api/workouts/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
},
{
method: "POST",
path: "/api/documents/inbound",
auth: "Authorization: Bearer hlk_...",
example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
},
];
return (
<SettingsCard>
<SettingsCardHeader
anchor="api-endpoints"
icon={Key}
title={t("settings.apiEndpointsTitle")}
description={t("settings.apiEndpointsDescription")}
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointMethod")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointPath")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointAuth")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointExample")}
</th>
</tr>
</thead>
<tbody>
{endpoints.map((endpoint) => (
<tr key={`${endpoint.method} ${endpoint.path}`}>
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
<li
key={`${endpoint.method} ${endpoint.path}`}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">
{endpoint.method}
</Badge>
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
{endpoint.example}
</code>
</div>
</li>
))}
</ul>
</SettingsCard>
);
}
+142 -225
View File
@@ -31,6 +31,12 @@ import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { ApiEndpointsCard } from "@/components/settings/api-endpoints-card";
import {
AppleHealthMirrorOptIn,
AppleHealthMirrorSwitch,
UnattributedReadingsNotice,
} from "@/components/settings/apple-health-mirror-token";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -45,6 +51,7 @@ interface ApiTokenInfo {
expiresAt: string | null;
createdAt: string;
revoked: boolean;
mirrorToAppleHealth: boolean;
}
export function ApiSection() {
@@ -78,6 +85,8 @@ function MeasurementTokensCard() {
icon={Upload}
slot="settings-measurements-token-created"
anchor="measurements-token"
mirrorOptIn
footer={<UnattributedReadingsNotice />}
copy={{
title: t("settings.measurementsToken.title"),
description: t("settings.measurementsToken.description"),
@@ -147,7 +156,12 @@ function IngestTokenCard({
slot,
anchor,
copy,
mirrorOptIn = false,
footer,
}: {
/** Offer the Apple Health write-back on the mint (measurement tokens). */
mirrorOptIn?: boolean;
footer?: React.ReactNode;
/** Deep-link anchor for the card (`/settings/api#<anchor>`). */
anchor: string;
endpoint:
@@ -172,6 +186,7 @@ function IngestTokenCard({
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [mirror, setMirror] = useState(false);
// The token outlives the session, so minting one asks for a fresh proof
// unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
@@ -184,7 +199,10 @@ function IngestTokenCard({
try {
// No scope field: the endpoint mints one shape, so there is nothing
// here for a caller to choose or for this form to get wrong.
const body = JSON.stringify({ name: newName.trim() });
const body = JSON.stringify({
name: newName.trim(),
...(mirrorOptIn && { mirrorToAppleHealth: mirror }),
});
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw(endpoint, {
@@ -198,6 +216,7 @@ function IngestTokenCard({
if (res.ok) {
setNewToken(json.data.token);
setNewName("");
setMirror(false);
// The sibling card lists this token, so it has to hear about it.
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
} else {
@@ -263,6 +282,12 @@ function IngestTokenCard({
{t("common.create")}
</Button>
</form>
{mirrorOptIn && (
<AppleHealthMirrorOptIn
checked={mirror}
onCheckedChange={setMirror}
/>
)}
{newToken && (
<div
@@ -295,137 +320,16 @@ function IngestTokenCard({
)}
{tokenMsg && <p className="text-destructive text-sm">{tokenMsg}</p>}
{footer}
</div>
</SettingsCard>
);
}
function ApiEndpointsCard() {
const { t } = useTranslations();
// v1.4.27 — dual-render: <md gets a stacked card list, md+
// keeps the original table. Mobile cards wrap inside their box and
// never trigger horizontal scroll, mirroring the pattern from
// `<ApiTokenOverviewSection>`. The endpoint catalogue is array-driven
// so adding a row remains a one-line edit on both surfaces — which is
// what the two measurement ingest rows below cost.
const endpoints = [
{
method: "POST",
path: "/api/ingest/medication",
auth: "Authorization: Bearer hlk_...",
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
},
{
method: "POST",
path: "/api/measurements",
auth: "Authorization: Bearer hlk_...",
example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
},
{
method: "POST",
path: "/api/measurements/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
},
{
method: "POST",
path: "/api/workouts/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
},
{
method: "POST",
path: "/api/documents/inbound",
auth: "Authorization: Bearer hlk_...",
example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
},
];
return (
<SettingsCard>
<SettingsCardHeader
anchor="api-endpoints"
icon={Key}
title={t("settings.apiEndpointsTitle")}
description={t("settings.apiEndpointsDescription")}
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointMethod")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointPath")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointAuth")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointExample")}
</th>
</tr>
</thead>
<tbody>
{endpoints.map((endpoint) => (
<tr key={`${endpoint.method} ${endpoint.path}`}>
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
<li
key={`${endpoint.method} ${endpoint.path}`}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">
{endpoint.method}
</Badge>
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
{endpoint.example}
</code>
</div>
</li>
))}
</ul>
</SettingsCard>
);
}
function ApiTokensCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
const {
data: tokens,
@@ -557,7 +461,10 @@ function ApiTokensCard() {
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="px-3 py-2 font-medium">
{tok.name}
<AppleHealthMirrorSwitch token={tok} />
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
@@ -660,6 +567,7 @@ function ApiTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AppleHealthMirrorSwitch token={tok} />
<ConfirmButton
label={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
@@ -679,107 +587,7 @@ function ApiTokensCard() {
</div>
{revokedTokens.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevokedTokens((prev) => !prev)}
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
/>
{t("settings.revokedTokensTitle", {
count: revokedTokens.length,
})}
</button>
{showRevokedTokens && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">
{tok.name}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">
{tok.name}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
</li>
))}
</ul>
</>
)}
</div>
<RevokedTokens tokens={revokedTokens} />
)}
</>
)}
@@ -787,3 +595,112 @@ function ApiTokensCard() {
</SettingsCard>
);
}
/** The revoked tokens, folded away until asked for. Read-only but for the switch. */
function RevokedTokens({ tokens: revokedTokens }: { tokens: ApiTokenInfo[] }) {
const { t } = useTranslations();
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
return (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevokedTokens((prev) => !prev)}
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
/>
{t("settings.revokedTokensTitle", {
count: revokedTokens.length,
})}
</button>
{showRevokedTokens && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">
{tok.name}
<AppleHealthMirrorSwitch token={tok} />
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">{tok.name}</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AppleHealthMirrorSwitch token={tok} />
</li>
))}
</ul>
</>
)}
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
/**
* A measurement token's one setting (#1272): whether the HealthLog app on the
* iPhone writes the readings that token sent into Apple Health.
*
* The readings keep their EXTERNAL source either way; the server resolves
* the decision per reading and the app follows it. Three pieces, used by the
* API settings: the switch on a measurement token's row, the opt-in on the
* mint form, and the notice about readings that belong to no token.
*/
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/hooks/use-auth";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/** The scope a measurement token carries, and nothing else. */
const MEASUREMENTS_WRITE = "measurements:write";
export function isMeasurementTokenInfo(permissions: readonly string[]) {
return permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE;
}
/** `PATCH /api/tokens/{id}`: the one write behind the switch. */
export function patchTokenMirror(
tokenId: string,
mirrorToAppleHealth: boolean,
) {
return apiPatch<{ id: string; mirrorToAppleHealth: boolean }>(
`/api/tokens/${encodeURIComponent(tokenId)}`,
{ mirrorToAppleHealth },
);
}
/** The switch on a measurement token's row; nothing on any other token. */
export function AppleHealthMirrorSwitch({
token,
}: {
token: {
id: string;
name: string;
permissions: string[];
mirrorToAppleHealth: boolean;
};
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
if (!isMeasurementTokenInfo(token.permissions)) return null;
async function handleChange(next: boolean) {
setSaving(true);
try {
await patchTokenMirror(token.id, next);
await queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
} catch (failure) {
showErrorToast(t("settings.measurementsToken.appleHealth.updateFailed"), {
cause: failure,
});
} finally {
setSaving(false);
}
}
return (
<label
className="flex min-h-11 items-center gap-2 sm:min-h-9"
data-slot="apple-health-mirror-switch"
>
<Switch
checked={token.mirrorToAppleHealth}
onCheckedChange={(next) => void handleChange(next)}
disabled={saving}
aria-label={t("settings.measurementsToken.appleHealth.toggleAria", {
name: token.name,
})}
/>
<span className="text-xs">
{t("settings.measurementsToken.appleHealth.label")}
</span>
</label>
);
}
/** The opt-in on the measurement mint form. Off unless the person ticks it. */
export function AppleHealthMirrorOptIn({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (next: boolean) => void;
}) {
const { t } = useTranslations();
return (
<label className="flex items-start gap-2 text-sm">
<Checkbox
data-slot="apple-health-mirror-opt-in"
checked={checked}
onCheckedChange={(next) => onCheckedChange(next === true)}
className="mt-0.5"
/>
<span>
{t("settings.measurementsToken.appleHealth.label")}
<span className="text-muted-foreground block text-xs">
{t("settings.measurementsToken.appleHealth.createHint")}
</span>
</span>
</label>
);
}
/**
* Shown only when the account holds EXTERNAL readings no token reaches: sent
* before readings were linked to their token, by an account that held more
* than one, so no setting here can put them into Apple Health.
*/
export function UnattributedReadingsNotice() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.measurementTokenSummary(),
queryFn: () =>
apiGet<{ unattributedReadings: number }>("/api/tokens/measurements"),
enabled: isAuthenticated,
});
if (!data || !(data.unattributedReadings > 0)) return null;
return (
<p className="text-sm" data-slot="apple-health-unattributed-notice">
{t("settings.measurementsToken.appleHealth.unattributed")}
</p>
);
}