mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'v143/dawarich' into release/v1.43.0
# Conflicts: # src/__tests__/contract-stub-inventory.test.ts
This commit is contained in:
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("");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user