feat(dawarich): fetch daily movement values from a Dawarich instance

Connect, status, places, settings, test, sync and disconnect replace their
stubs. A nightly job (03:20) and the connect backfill read the timeline,
visits and residency endpoints through safeFetch and store one row of daily
values per local day: distances, places visited, minutes away from the home
place and the sealed country with its travel-day verdict. A private instance
needs its exact origin in DAWARICH_PRIVATE_ORIGINS.

The connection joins the shared ledger and the connection health model,
watched for errors only. The day carries a movement block, the model
projection drops the travel country, the two movement channels join the
correlation matrix under the environment module, travel days become trip
suggestions, and the days travel in the environment backup section.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 01:20:53 +02:00
1 parent 1e7fc08f57
commit b838ed4403
95 files changed
+4466 -158

No files matched your search

+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`,