Merge branch 'v143/dawarich' into release/v1.43.0

# Conflicts:
#	src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 02:51:02 +02:00
commit da860aeb3e
101 files changed
+5354 -216

No files matched your search

@@ -0,0 +1,63 @@
/**
* "On the move" renders the server's values as they come and leaves out what
* the day has no value for: a null is no coverage, never a zero.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { DayMovementSection } from "../day-movement";
function render(
movement: Parameters<typeof DayMovementSection>[0]["movement"],
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DayMovementSection movement={movement} />
</I18nProvider>,
);
}
const base = {
source: "dawarich" as const,
distance: { unit: "km" as const, total: 48.3, driving: 12, train: 0 },
placesVisited: 4,
awayFromHomeMinutes: 600,
travel: { isTravelDay: true, countryCode: "ES" },
fetchedAt: "2026-06-16T01:20:00.000Z",
};
describe("DayMovementSection", () => {
it("shows each value the day has", () => {
const html = render(base);
expect(html).toContain('data-movement="total"');
expect(html).toContain("48.3 km");
expect(html).toContain('data-movement="driving"');
expect(html).toContain('data-movement="away"');
expect(html).toContain("Spain");
// No train that day: no row, rather than "0 km".
expect(html).not.toContain('data-movement="train"');
});
it("leaves out time away from home without coverage", () => {
const html = render({
...base,
awayFromHomeMinutes: null,
travel: null,
});
expect(html).not.toContain('data-movement="away"');
expect(html).not.toContain('data-movement="travel"');
});
it("renders nothing when the day has no value at all", () => {
const html = render({
...base,
distance: { unit: "mi", total: null, driving: null, train: null },
placesVisited: null,
awayFromHomeMinutes: null,
travel: null,
});
expect(html).toBe("");
});
});
+95
View File
@@ -0,0 +1,95 @@
"use client";
import type { DayResponse } from "@/lib/day/contract";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { DAY_SECTION_LABEL } from "./day-sections";
type DayMovement = NonNullable<DayResponse["movement"]>;
/**
* "On the move": the day's movement context from Dawarich, as the server
* resolved it. Distances arrive in the account's unit; a field the day has no
* value for is left out, never shown as zero. The country of a travel day is
* named in the reader's language.
*/
export function DayMovementSection({ movement }: { movement: DayMovement }) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const unit = movement.distance.unit;
const distance = (value: number) =>
t(unit === "mi" ? "day.movement.miles" : "day.movement.kilometres", {
value: fmt.number(value, 1),
});
const rows: Array<{ key: string; label: string; value: string }> = [];
if (movement.distance.total !== null) {
rows.push({
key: "total",
label: t("day.movement.total"),
value: distance(movement.distance.total),
});
}
if (movement.distance.driving) {
rows.push({
key: "driving",
label: t("day.movement.driving"),
value: distance(movement.distance.driving),
});
}
if (movement.distance.train) {
rows.push({
key: "train",
label: t("day.movement.train"),
value: distance(movement.distance.train),
});
}
if (movement.placesVisited !== null) {
rows.push({
key: "places",
label: t("day.movement.places"),
value: fmt.number(movement.placesVisited, 0),
});
}
if (movement.awayFromHomeMinutes !== null) {
rows.push({
key: "away",
label: t("day.movement.away"),
value: formatDurationMinutes(movement.awayFromHomeMinutes, t),
});
}
if (movement.travel?.isTravelDay) {
const code = movement.travel.countryCode;
rows.push({
key: "travel",
label: t("day.movement.travel"),
value: code ? countryName(code, locale) : t("day.movement.travelYes"),
});
}
if (rows.length === 0) return null;
return (
<section className="space-y-2" data-slot="day-movement">
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.movement")}</h3>
<dl className="grid grid-cols-2 gap-x-3 gap-y-2">
{rows.map((row) => (
<div key={row.key} className="min-w-0" data-movement={row.key}>
<dt className="text-muted-foreground truncate text-xs">
{row.label}
</dt>
<dd className="truncate text-sm tabular-nums">{row.value}</dd>
</div>
))}
</dl>
</section>
);
}
function countryName(code: string, locale: string): string {
try {
return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code;
} catch {
return code;
}
}
+6 -1
View File
@@ -38,6 +38,7 @@ import {
DayRunning,
DayValues,
} from "./day-sections";
import { DayMovementSection } from "./day-movement";
import { DayScores } from "./day-scores";
import { tileKeyOf } from "./day-values-model";
import { shiftDateKey } from "./day-url";
@@ -148,7 +149,8 @@ export function DayView({
data.values.length === 0 &&
data.events.length === 0 &&
data.running.length === 0 &&
(data.scores ?? []).length === 0;
(data.scores ?? []).length === 0 &&
!data.movement;
const notShared = data
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
: false;
@@ -302,6 +304,9 @@ export function DayView({
/>
)}
<DayScores scores={data.scores ?? []} />
{data.movement ? (
<DayMovementSection movement={data.movement} />
) : null}
<DayEvents events={data.events} />
</>
)}
@@ -77,6 +77,9 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
ENV_PM25: "environment.fields.pm25",
ENV_OZONE_8H: "environment.fields.ozone8h",
ENV_POLLEN_MAX: "environment.fields.pollenMax",
// v1.43 — the Dawarich movement channels.
MOVEMENT_AWAY: "environment.movement.awayFromHome",
MOVEMENT_DISTANCE: "environment.movement.distance",
};
/** Keep only the discovered pairs that involve the mood channel. */
@@ -21,6 +21,7 @@ import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react";
import { TravelSuggestions } from "@/components/settings/environment-travel-suggestions";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -415,6 +416,16 @@ export function EnvironmentSection() {
<p className="text-sm leading-relaxed">
{t("settings.sections.environment.travel.description")}
</p>
<TravelSuggestions
enabled={enabled}
onUse={({ startDate, endDate, place }) => {
setTravelStart(startDate);
setTravelEnd(endDate);
setTravelQuery(place);
setTravelLoc(null);
setTravelSearched(false);
}}
/>
<div className="space-y-3">
{data && data.travel.length > 0 ? (
<ul className="divide-border divide-y rounded-md border">
@@ -0,0 +1,130 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
interface TravelSuggestion {
id: string;
startDate: string;
endDate: string;
countryCode: string;
}
function countryName(code: string, locale: string): string {
try {
return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code;
} catch {
return code;
}
}
/**
* Trips suggested from Dawarich travel days. "Use" fills the trip form below
* with the dates and the country, and the person picks the place; the saved
* trip then covers the run and the suggestion goes. "Dismiss" hides it for
* good. Renders nothing while there is nothing to suggest, which includes
* every account without a Dawarich connection.
*/
export function TravelSuggestions({
enabled,
onUse,
}: {
enabled: boolean;
onUse: (suggestion: {
startDate: string;
endDate: string;
place: string;
}) => void;
}) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const suggestions = useQuery({
queryKey: queryKeys.environmentTravelSuggestions(),
enabled,
queryFn: () =>
apiGet<{ suggestions: TravelSuggestion[] }>(
"/api/environment/travel/suggestions",
),
});
const dismiss = useMutation({
mutationFn: (id: string) =>
apiPost(
`/api/environment/travel/suggestions/${encodeURIComponent(id)}/dismiss`,
),
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: queryKeys.environmentTravelSuggestions(),
}),
onError: (failure) =>
showErrorToast(t("settings.sections.environment.saveError"), {
cause: failure,
}),
});
const list = suggestions.data?.suggestions ?? [];
if (list.length === 0) return null;
return (
<div className="space-y-2" data-slot="travel-suggestions">
<p className="text-sm">
{t("settings.sections.environment.travel.suggestionsIntro")}
</p>
<ul className="divide-border divide-y rounded-md border">
{list.map((s) => {
const place = countryName(s.countryCode, locale);
return (
<li
key={s.id}
data-slot="travel-suggestion"
className="flex items-center justify-between gap-2 p-2"
>
<span className="min-w-0">
<span className="block truncate text-sm">{place}</span>
<span className="text-muted-foreground block text-xs">
{t("settings.sections.environment.travel.range", {
start: fmt.dateShortSmartCalendar(s.startDate),
end: fmt.dateShortSmartCalendar(s.endDate),
})}
</span>
</span>
<span className="flex shrink-0 gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() =>
onUse({ startDate: s.startDate, endDate: s.endDate, place })
}
>
<Check className="size-3.5" aria-hidden="true" />
{t("settings.sections.environment.travel.suggestionUse")}
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t(
"settings.sections.environment.travel.suggestionDismiss",
)}
disabled={dismiss.isPending}
onClick={() => dismiss.mutate(s.id)}
>
<X className="size-4" aria-hidden="true" />
</Button>
</span>
</li>
);
})}
</ul>
</div>
);
}
@@ -24,9 +24,11 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/settings/integrations",
}));
// The Dawarich card belongs to the opt-in environment module.
let environmentOn = true;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER" },
user: { id: "u1", role: "USER", modules: { environment: environmentOn } },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
@@ -150,6 +152,18 @@ describe("ConnectionsPanel — every envelope entry surfaces", () => {
expect(render()).not.toContain('data-testid="integration-fallback-row"');
});
it("shows a stored Dawarich connection read-only while the environment module is off", () => {
environmentOn = false;
try {
envelope = fullEnvelope();
const html = render();
expect(html).not.toContain('data-testid="dawarich-card"');
expect(html).toContain('data-integration="dawarich"');
} finally {
environmentOn = true;
}
});
it("renders no fallback row for a card-less provider that is not configured", () => {
// Absence stays absence: an unconfigured provider is not a problem to
// report, so the panel says nothing about it.
@@ -18,6 +18,7 @@ import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
@@ -193,6 +194,7 @@ const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
"oura",
"strava",
"nightscout",
"dawarich",
]);
export function ConnectionsPanel({
@@ -207,7 +209,9 @@ export function ConnectionsPanel({
callbackUrls: IntegrationCallbackUrls;
}) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { isAuthenticated, user } = useAuth();
// Dawarich is part of the environment module, which is opt-in.
const environmentOn = user?.modules?.environment === true;
const router = useRouter();
const queryClient = useQueryClient();
@@ -296,7 +300,12 @@ export function ConnectionsPanel({
const unclaimedIntegrations = (integrationStatus?.integrations ?? [])
.filter(
(entry) =>
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
// With the environment module off the Dawarich card is not shown, so
// a connection still stored falls through to the read-only row.
!(
CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
(entry.integration !== "dawarich" || environmentOn)
) &&
(entry.configured || entry.connected),
)
.map((entry) => ({
@@ -376,6 +385,11 @@ export function ConnectionsPanel({
viewModel={pickStatus(integrationStatus, "nightscout")}
/>
</div>
{environmentOn ? (
<div id="dawarich" className="scroll-mt-28">
<DawarichCard viewModel={pickStatus(integrationStatus, "dawarich")} />
</div>
) : null}
{/* Every configured envelope entry no card above claims. Normally this
renders nothing; on an account carrying a dropped-but-still-running
integration it renders the honest status that provider otherwise has
@@ -0,0 +1,481 @@
"use client";
// Dawarich movement context. Like Nightscout it is an address and a key the
// self-hoster pastes once; unlike it, it only ever reads daily values (how
// far, by car or train, how long away from home, which country) and never a
// coordinate. The card reads the consolidated envelope for `health`, and the
// Dawarich status route for what only this card shows: the home place, the
// home country and how many days are stored. It belongs to the environment
// module and is not shown while that module is off.
import { useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
BookOpen,
KeyRound,
Link2,
Loader2,
MapPinned,
Plug,
RefreshCw,
Save,
Trash2,
Unlink,
} from "lucide-react";
import { toast } from "sonner";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { queryKeys } from "@/lib/query-keys";
import { ActionResultLines, useConnectionTest } from "./integration-actions";
import {
IntegrationCard,
type IntegrationMenuAction,
} from "./integration-card";
import {
IntegrationCardDescription,
integrationDocsHref,
} from "./setup-guide-link";
import type { IntegrationStatusViewModel } from "./shared";
interface DawarichStatus {
connected: boolean;
configured: boolean;
homePlaceSet: boolean;
homeCountry: string | null;
daysStored: number;
oldestDay: string | null;
latestDay: string | null;
health: ConnectionHealth | null;
}
/** Error codes the connect route answers, with the sentence each one gets. */
const CONNECT_ERROR_KEYS: Record<string, string> = {
"dawarich.invalid": "settings.dawarichErrors.invalid",
private_origin_not_approved: "settings.dawarichErrors.privateOrigin",
"dawarich.credentials_rejected": "settings.dawarichErrors.credentials",
"dawarich.unreachable": "settings.dawarichErrors.unreachable",
"dawarich.rate_limited": "settings.dawarichErrors.rateLimited",
"dawarich.home_place_unknown": "settings.dawarichErrors.homePlaceUnknown",
"dawarich.not_connected": "settings.dawarichErrors.notConnected",
};
function errorKeyOf(error: unknown): string {
const code =
error instanceof ApiError && typeof error.meta?.errorCode === "string"
? error.meta.errorCode
: null;
return (
(code && CONNECT_ERROR_KEYS[code]) ?? "settings.dawarichErrors.generic"
);
}
export function DawarichCard({
viewModel,
}: {
viewModel?: IntegrationStatusViewModel;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const connected = viewModel?.connected ?? false;
const test = useConnectionTest("/api/dawarich/test");
const [editing, setEditing] = useState(false);
const formRef = useRef<HTMLFormElement | null>(null);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
queryClient.invalidateQueries({ queryKey: queryKeys.dawarichStatus() });
};
const sync = useMutation({
mutationFn: () => apiPost<{ enqueued: boolean }>("/api/dawarich/sync", {}),
onSuccess: (result) => {
toast.success(
t(
result.enqueued
? "settings.dawarichSyncQueued"
: "settings.dawarichSyncAlreadyQueued",
),
);
refresh();
},
onError: (error) => showErrorToast(t(errorKeyOf(error)), { cause: error }),
});
const disconnect = useMutation({
mutationFn: (deleteData: boolean) =>
apiPost("/api/dawarich/disconnect", { deleteData }),
onSuccess: () => {
refresh();
queryClient.invalidateQueries({ queryKey: queryKeys.environment() });
},
onError: (error) =>
showErrorToast(t("settings.dawarichErrors.generic"), { cause: error }),
});
function openForm() {
setEditing(true);
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
formRef.current
?.querySelector<HTMLInputElement>("#dawarich-url")
?.focus();
});
}
const form = (
<DawarichForm
formRef={formRef}
connected={connected}
onConnected={() => {
setEditing(false);
refresh();
}}
/>
);
if (!connected) {
return (
<IntegrationCard
provider="dawarich"
providerName={t("settings.dawarich")}
icon={MapPinned}
titleAccessory={<TagChip>{t("settings.dawarichTag")}</TagChip>}
description={
<IntegrationCardDescription
i18nPrefix="settings.dawarich"
provider="dawarich"
/>
}
loading={viewModel === undefined}
connected={false}
health={null}
setup={form}
/>
);
}
const menu: IntegrationMenuAction[] = [
{
id: "sync",
label: t("settings.dawarichSync"),
icon: RefreshCw,
disabled: sync.isPending,
onSelect: () => sync.mutate(),
},
{
id: "test",
label: t("settings.testConnection.test"),
icon: Plug,
disabled: test.testing,
onSelect: test.run,
},
{
id: "credentials",
label: t("settings.integrationCard.menu.address"),
icon: KeyRound,
onSelect: () => setEditing((open) => !open),
},
{
id: "setup-guide",
label: t("settings.integrationSetupGuide"),
icon: BookOpen,
href: integrationDocsHref("dawarich"),
external: true,
},
{
id: "disconnect",
label: t("settings.dawarichDisconnect"),
icon: Unlink,
destructive: true,
onSelect: () => disconnect.mutate(false),
confirm: {
title: t("settings.dawarichDisconnectTitle"),
body: t("settings.dawarichDisconnectDescription"),
confirmLabel: t("settings.dawarichDisconnect"),
},
},
{
id: "disconnect-delete",
label: t("settings.dawarichDisconnectDelete"),
icon: Trash2,
destructive: true,
onSelect: () => disconnect.mutate(true),
confirm: {
title: t("settings.dawarichDisconnectDeleteTitle"),
body: t("settings.dawarichDisconnectDeleteDescription"),
confirmLabel: t("settings.dawarichDisconnectDelete"),
},
},
];
return (
<IntegrationCard
provider="dawarich"
providerName={t("settings.dawarich")}
icon={MapPinned}
titleAccessory={<TagChip>{t("settings.dawarichTag")}</TagChip>}
connected
health={viewModel?.health ?? null}
primary={{ onReconnect: openForm, onResume: openForm }}
menu={menu}
>
<DawarichHome />
<ActionResultLines provider="dawarich" test={test} />
{editing ? form : null}
</IntegrationCard>
);
}
/** The home place and home country, and what is stored. */
function DawarichHome() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const status = useQuery({
queryKey: queryKeys.dawarichStatus(),
queryFn: () => apiGet<DawarichStatus>("/api/dawarich/status"),
});
const [picking, setPicking] = useState(false);
const [placeId, setPlaceId] = useState("");
const [country, setCountry] = useState("");
const places = useQuery({
queryKey: queryKeys.dawarichPlaces(""),
queryFn: () =>
apiGet<{ places: Array<{ id: number; name: string }> }>(
"/api/dawarich/places",
),
enabled: picking,
});
const save = useMutation({
mutationFn: (body: {
homePlaceId?: number | null;
homeCountry?: string | null;
}) => apiPatch("/api/dawarich/settings", body),
onSuccess: () => {
toast.success(t("settings.dawarichHomeSaved"));
setPicking(false);
setPlaceId("");
setCountry("");
queryClient.invalidateQueries({ queryKey: queryKeys.dawarichStatus() });
queryClient.invalidateQueries({
queryKey: queryKeys.environmentTravelSuggestions(),
});
},
onError: (error) => showErrorToast(t(errorKeyOf(error)), { cause: error }),
});
const data = status.data;
if (!data) return null;
const countryValue = country.trim().toUpperCase();
return (
<div className="space-y-3" data-slot="dawarich-home">
<p className="text-sm">
{data.homePlaceSet
? t("settings.dawarichHomePlaceSet")
: t("settings.dawarichHomePlaceMissing")}{" "}
{data.homeCountry
? t("settings.dawarichHomeCountryIs", { country: data.homeCountry })
: t("settings.dawarichHomeCountryMissing")}
</p>
<p className="text-muted-foreground text-xs">
{t("settings.dawarichDaysStored", { count: data.daysStored })}
</p>
{picking ? (
<div className="space-y-1.5">
<Label htmlFor="dawarich-home-place">
{t("settings.dawarichHomePlace")}
</Label>
{places.isError ? (
<p role="alert" className="text-destructive text-sm">
{t(errorKeyOf(places.error))}
</p>
) : (
<NativeSelect
id="dawarich-home-place"
value={placeId}
disabled={places.isLoading}
onChange={(e) => setPlaceId(e.target.value)}
>
<option value="">{t("settings.dawarichHomePlaceChoose")}</option>
{(places.data?.places ?? []).map((place) => (
<option key={place.id} value={String(place.id)}>
{place.name}
</option>
))}
</NativeSelect>
)}
</div>
) : null}
<div className="space-y-1.5">
<Label htmlFor="dawarich-home-country">
{t("settings.dawarichHomeCountry")}
</Label>
<Input
id="dawarich-home-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder={data.homeCountry ?? "DE"}
maxLength={2}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
className="w-24"
/>
<p className="text-muted-foreground text-xs">
{t("settings.dawarichHomeCountryHelp")}
</p>
</div>
<SettingsCardActions>
{picking ? null : (
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => setPicking(true)}
data-testid="dawarich-pick-home"
>
<MapPinned className="size-3.5" aria-hidden="true" />
{t("settings.dawarichHomePlaceChange")}
</Button>
)}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={
save.isPending ||
(placeId === "" && !/^[A-Z]{2}$/.test(countryValue))
}
onClick={() =>
save.mutate({
...(placeId !== "" ? { homePlaceId: Number(placeId) } : {}),
...(/^[A-Z]{2}$/.test(countryValue)
? { homeCountry: countryValue }
: {}),
})
}
data-testid="dawarich-save-home"
>
{save.isPending ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Save className="size-3.5" aria-hidden="true" />
)}
{t("settings.dawarichHomeSave")}
</Button>
</SettingsCardActions>
</div>
);
}
function DawarichForm({
formRef,
connected,
onConnected,
}: {
formRef: React.RefObject<HTMLFormElement | null>;
connected: boolean;
onConnected: () => void;
}) {
const { t } = useTranslations();
const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const connect = useMutation({
mutationFn: () =>
apiPost("/api/dawarich/connect", {
url: url.trim(),
apiKey: apiKey.trim(),
}),
onSuccess: () => {
setApiKey("");
toast.success(t("settings.dawarichConnected"));
onConnected();
},
});
return (
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
connect.mutate();
}}
className="space-y-3"
data-testid="dawarich-form"
>
<div className="space-y-1.5">
<Label htmlFor="dawarich-url">{t("settings.dawarichUrl")}</Label>
<Input
id="dawarich-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://dawarich.example.com"
maxLength={2048}
autoComplete="off"
inputMode="url"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="dawarich-api-key">{t("settings.dawarichApiKey")}</Label>
<PasswordInput
id="dawarich-api-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
maxLength={512}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
<p className="text-muted-foreground text-xs">
{t("settings.dawarichApiKeyHelp")}
</p>
</div>
{connect.isError ? (
<p role="alert" className="text-destructive text-sm">
{t(errorKeyOf(connect.error))}
</p>
) : null}
<SettingsCardActions>
<Button
type="submit"
size="sm"
variant={connected ? "outline" : "default"}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={connect.isPending || !url.trim() || !apiKey.trim()}
data-slot={connected ? undefined : "integration-primary-action"}
data-action={connected ? undefined : "connect"}
data-testid="dawarich-connect"
>
{connect.isPending ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : connected ? (
<Save className="size-3.5" aria-hidden="true" />
) : (
<Link2 className="size-3.5" aria-hidden="true" />
)}
{connected
? t("settings.dawarichUpdate")
: t("settings.dawarichConnect")}
</Button>
</SettingsCardActions>
</form>
);
}
@@ -39,6 +39,7 @@ export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
dawarich: "Dawarich",
};
/**
@@ -33,7 +33,9 @@ export type IntegrationDocsProvider =
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
| "google-health"
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
| "strava";
| "strava"
// v1.43 — Dawarich runbook (docs.healthlog.dev/integrations/dawarich).
| "dawarich";
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
return `${INTEGRATION_DOCS_BASE}/${provider}`;
@@ -26,7 +26,9 @@ export type IntegrationKey =
| "strava"
// Nightscout folds onto the envelope so the card stops fetching its own
// status and inherits the shared verdict.
| "nightscout";
| "nightscout"
// v1.43 — Dawarich movement context; publishes `health` only.
| "dawarich";
/**
* One envelope entry as the cards read it. Since v1.42.4 the cards render
* `health` alone; the older health-shaped fields (ledger `state`, `lastError`,