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

+1 -14
View File
@@ -33,23 +33,10 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"anamnesis.life_circumstances.confirm",
],
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
"dawarich/connect/route.ts": ["dawarich.connect"],
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
"dawarich/places/route.ts": ["dawarich.places.get"],
"dawarich/settings/route.ts": ["dawarich.settings.patch"],
"dawarich/status/route.ts": ["dawarich.status.get"],
"dawarich/sync/route.ts": ["dawarich.sync"],
"dawarich/test/route.ts": ["dawarich.test"],
"environment/travel/suggestions/[id]/dismiss/route.ts": [
"environment.travel_suggestion.dismiss",
],
"environment/travel/suggestions/route.ts": [
"environment.travel_suggestions.get",
],
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 14;
const REGISTER_SIZE = 5;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
@@ -0,0 +1,109 @@
/**
* v1.43 — pair guard binding `DawarichDay` in `prisma/schema.prisma` to the
* backup that carries it (`src/lib/export/environment-backup.ts`, the
* `dawarichDays` arm of the environment section), on the pattern of
* `environment-backup-completeness.test.ts`.
*
* A column missing from the select comes back NULL on every restored day
* without a word, because the section schema is `.passthrough()`. So both
* ends are compared as literals:
*
* - every scalar column of `DawarichDay` is in `DAWARICH_DAY_BACKUP_SELECT`
* (or excluded here with a reason);
* - every selected column is written by the export (`row.<column>`);
* - every selected column is written back by the restore (`<column>:`).
*
* Mutation check: delete `trainDistanceM: true,` from the select and the
* first case names it; delete `trainDistanceM:` from the restore and the
* restore case names it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8");
const source = readFileSync(
join(ROOT, "src", "lib", "export", "environment-backup.ts"),
"utf8",
);
const EXCLUDED: Record<string, string> = {
id: "nothing addresses a day by its id; a day is identified by its date",
userId:
"user-scoped backup; the owner is re-derived from the restoring account",
};
function block(text: string, start: string, end = "\n}"): string {
const from = text.indexOf(start);
expect(from, `${start} not found`).toBeGreaterThan(-1);
const to = text.indexOf(end, from);
expect(to).toBeGreaterThan(from);
return text.slice(from, to);
}
function scalarColumns(model: string): string[] {
const models = new Set(
[...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]),
);
const out: string[] = [];
for (const raw of block(schema, `model ${model} {`).split("\n").slice(1)) {
const line = raw.trim();
if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?/.exec(line);
if (!m || m[3] || models.has(m[2]) || line.includes("@relation")) continue;
out.push(m[1]);
}
return out;
}
const columns = scalarColumns("DawarichDay");
const selected = [
...block(source, "const DAWARICH_DAY_BACKUP_SELECT = {").matchAll(
/^ {2}(\w+): true,$/gm,
),
].map((m) => m[1]);
const exportBody = block(source, "dawarichDays: dawarichRows.map(", "})),");
const restoreBody = block(
source,
"export async function restoreEnvironmentData(",
"\n}\n",
);
const restoreDawarich = block(restoreBody, "tx.dawarichDay.createMany(", "});");
describe("DawarichDay backup completeness", () => {
it("parses both ends into a plausible shape", () => {
expect(columns).toContain("countryCodeEncrypted");
expect(columns).toContain("awayFromHomeMinutes");
expect(selected.length).toBeGreaterThan(8);
});
it("the backup select covers every column", () => {
const missing = columns.filter(
(c) => !selected.includes(c) && !(c in EXCLUDED),
);
expect(
missing,
"DAWARICH_DAY_BACKUP_SELECT omits these columns; a restore brings them back NULL",
).toEqual([]);
});
it("the export writes every selected column into the entry", () => {
const unwritten = selected.filter(
(c) => !new RegExp(`row\\.${c}\\b`).test(exportBody),
);
expect(unwritten, "selected but never exported").toEqual([]);
});
it("the restore writes every selected column back", () => {
const unwritten = selected.filter(
(c) => !new RegExp(`\\b${c}:`).test(restoreDawarich),
);
expect(unwritten, "selected but never restored").toEqual([]);
});
it("the restore clears the days it replaces", () => {
expect(restoreBody).toMatch(/tx\.dawarichDay\.deleteMany\(/);
});
});
@@ -0,0 +1,73 @@
/**
* v1.43 — Dawarich is a movement context, never a location store. The schema
* holds daily values only: no column of `DawarichDay`, and no Dawarich column
* on `User`, may hold a coordinate, a place name, an address or a visit.
*
* The check reads the column names and their doc comments in
* `prisma/schema.prisma`. A new column whose name or description says
* latitude, longitude, coordinates, geometry, address, label, a place name or
* a visit list fails here and has to argue its case in review.
*
* Mutation check: add `placeName String?` to `DawarichDay` and the first case
* names it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const schema = readFileSync(
join(process.cwd(), "prisma", "schema.prisma"),
"utf8",
);
const FORBIDDEN =
/\b(lat|lon|lng|latitude|longitude|coord\w*|geo\w*|geometry|address|label|street|city|placeName|place_name|visits?Json|track|path|point)\b/i;
/** Column lines of a model with the `///` doc comment above each. */
function columns(model: string): Array<{ name: string; text: string }> {
const start = schema.indexOf(`model ${model} {`);
expect(start, `model ${model} not found`).toBeGreaterThan(-1);
const body = schema.slice(start, schema.indexOf("\n}", start));
const out: Array<{ name: string; text: string }> = [];
let doc = "";
for (const raw of body.split("\n").slice(1)) {
const line = raw.trim();
if (line.startsWith("///")) {
doc += ` ${line.slice(3)}`;
continue;
}
const m = /^([a-zA-Z_]\w*)\s+\w/.exec(line);
if (m && !line.startsWith("@@"))
out.push({ name: m[1], text: `${m[1]} ${doc}` });
doc = "";
}
return out;
}
describe("Dawarich stores no location", () => {
it("DawarichDay holds daily values only", () => {
const cols = columns("DawarichDay");
expect(cols.map((c) => c.name)).toContain("awayFromHomeMinutes");
const offending = cols
.filter((c) => c.name !== "user")
.filter((c) => FORBIDDEN.test(c.name) || FORBIDDEN.test(c.text))
.map((c) => c.name);
expect(offending).toEqual([]);
});
it("the User row holds the connection, never a place", () => {
const cols = columns("User").filter((c) => /^dawarich/.test(c.name));
expect(cols.map((c) => c.name).sort()).toEqual([
"dawarichAllowPrivateHost",
"dawarichApiKeyEncrypted",
"dawarichDays",
"dawarichPrefsEncrypted",
"dawarichUrlEncrypted",
]);
const offending = cols
.filter((c) => FORBIDDEN.test(c.name))
.map((c) => c.name);
expect(offending).toEqual([]);
});
});
@@ -649,6 +649,14 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/dawarich-card.tsx",
destroys:
"the stored Dawarich address, key and home settings, and with the second entry every stored movement day — reconnecting means typing them again; deleted days come back only as far as the instance still holds them",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/document-sources-card.tsx",
destroys:
@@ -157,6 +157,7 @@ describe("record settings contract", () => {
oura: false,
"google-health": false,
strava: false,
dawarich: false,
} as const;
expect(
@@ -187,6 +188,7 @@ describe("record settings contract", () => {
oura: false,
"google-health": false,
strava: false,
dawarich: false,
} as const;
expect(
@@ -377,6 +377,11 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 2,
},
// The saved-credentials confirmation line.
// Connected, a fetch queued, the home saved: confirmations of a request
// that wrote no record (the fetch itself reports through `health`).
"src/components/settings/integrations/dawarich-card.tsx": {
"toast.success": 3,
},
"src/components/settings/integrations/credentials-form.tsx": {
"text-success": 1,
},
+147 -3
View File
@@ -7,18 +7,54 @@
* `private_origin_not_approved`). The key is checked against the instance
* before anything is stored, then a 30-day backfill starts. Part of the
* `environment` module; own account only, ten attempts a minute.
*
* Reconnecting to the same instance keeps the home place and country; a
* different instance starts without them, because a place id means nothing
* on another instance.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { decrypt, encrypt } from "@/lib/crypto";
import {
DawarichApiError,
DawarichPolicyError,
probeDawarich,
} from "@/lib/dawarich/client";
import {
EMPTY_DAWARICH_PREFS,
sealDawarichPrefs,
} from "@/lib/dawarich/connection";
import {
configuredDawarichPrivateOrigins,
DawarichOriginConfigError,
evaluateDawarichUrl,
} from "@/lib/dawarich/origin";
import { yesterdayKey } from "@/lib/dawarich/sync";
import { prisma } from "@/lib/db";
import { markReconnected } from "@/lib/integrations/status";
import {
DAWARICH_CONNECT_BACKFILL_DAYS,
enqueueDawarichSync,
} from "@/lib/jobs/dawarich-sync";
import { annotate, getEvent } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { INVALID_ORIGIN } from "@/lib/private-origin-policy";
import { checkRateLimit } from "@/lib/rate-limit";
import { shiftDateKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { dawarichConnectSchema } from "@/lib/validations/dawarich";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "dawarich.connect" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
@@ -40,5 +76,113 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("dawarich.connect");
// A malformed operator list is the operator's to fix: name the entry and
// the reason instead of a 500.
let privateOrigins: ReadonlySet<string>;
try {
privateOrigins = configuredDawarichPrivateOrigins();
} catch (error) {
if (error instanceof DawarichOriginConfigError) {
annotate({ meta: { refused: "operator_origin_list_invalid" } });
return apiError(error.message, 422, { errorCode: "dawarich.invalid" });
}
throw error;
}
// The SSRF floor applies to the value that gets stored, not only to the
// probe below.
const target = evaluateDawarichUrl(parsed.data.url, privateOrigins);
if (!target.allowed || !target.baseUrl) {
if (target.reasonCode === INVALID_ORIGIN) {
return apiError(
"Use the address of the Dawarich instance, without credentials or a query",
422,
{ errorCode: "dawarich.invalid" },
);
}
annotate({ meta: { refused: target.reasonCode } });
return apiError(
"A Dawarich instance on a private network needs the server operator's approval",
422,
{ errorCode: "private_origin_not_approved" },
);
}
const baseUrl = target.baseUrl;
const apiKey = parsed.data.apiKey;
try {
await probeDawarich({ baseUrl, apiKey });
} catch (error) {
if (error instanceof DawarichPolicyError) {
return apiError(
"A Dawarich instance on a private network needs the server operator's approval",
422,
{ errorCode: "private_origin_not_approved" },
);
}
if (
error instanceof DawarichApiError &&
(error.status === 401 || error.status === 403)
) {
return apiError("Dawarich did not accept this API key", 422, {
errorCode: "dawarich.credentials_rejected",
});
}
annotate({
meta: {
dawarich_probe_status:
error instanceof DawarichApiError ? error.status : null,
},
});
return apiError(
"Could not reach the Dawarich instance. Check the address and that it is online.",
422,
{ errorCode: "dawarich.unreachable" },
);
}
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
});
const sameInstance =
previous?.dawarichUrlEncrypted != null &&
decrypt(previous.dawarichUrlEncrypted) === baseUrl;
await prisma.user.update({
where: { id: user.id },
data: {
dawarichUrlEncrypted: encrypt(baseUrl),
dawarichApiKeyEncrypted: encrypt(apiKey),
dawarichPrefsEncrypted: sameInstance
? (previous?.dawarichPrefsEncrypted ?? null)
: sealDawarichPrefs(EMPTY_DAWARICH_PREFS),
// Display only: the operator list is re-read before every request.
dawarichAllowPrivateHost: target.privateOriginApproved,
},
});
await markReconnected(user.id, "dawarich");
await auditLog("dawarich.connect", {
userId: user.id,
details: { privateOrigin: target.privateOriginApproved },
});
// The first month arrives without waiting for the night. Best effort: the
// nightly run is the safety net when no worker is bound.
const tz = await resolveUserTimezone(user.id);
const outcome = await enqueueDawarichSync({
userId: user.id,
fromDate: shiftDateKey(
yesterdayKey(tz),
-(DAWARICH_CONNECT_BACKFILL_DAYS - 1),
),
}).catch((error: unknown) => {
getEvent()?.addWarning(
`dawarich backfill enqueue failed: ${error instanceof Error ? error.name : "unknown"}`,
);
return "failed" as const;
});
annotate({ meta: { dawarich_backfill: outcome } });
return apiSuccess({ connected: true as const });
});
+38 -8
View File
@@ -2,25 +2,32 @@
* `POST /api/dawarich/disconnect` — forget the connection; with
* `deleteData: true` the stored days go too. Answers
* `{ disconnected: true, deletedDays }`. Part of the `environment` module;
* own account only.
* own account only. Disconnecting twice is harmless.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { markDisconnected } from "@/lib/integrations/status";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { dawarichDisconnectSchema } from "@/lib/validations/dawarich";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "dawarich.disconnect" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
// The body is optional: no body is `{ deleteData: false }`.
let body: unknown = {};
if (request.headers.get("content-type")?.includes("application/json")) {
const read = await safeJson(request, { maxBytes: 1024 });
if (read.error) return read.error;
body = read.data;
}
const parsed = dawarichDisconnectSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -28,5 +35,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("dawarich.disconnect");
const deletedDays = await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: user.id },
data: {
dawarichUrlEncrypted: null,
dawarichApiKeyEncrypted: null,
dawarichPrefsEncrypted: null,
dawarichAllowPrivateHost: false,
},
});
if (!parsed.data.deleteData) return 0;
const { count } = await tx.dawarichDay.deleteMany({
where: { userId: user.id },
});
return count;
});
await markDisconnected(user.id, "dawarich");
await auditLog("dawarich.disconnect", {
userId: user.id,
details: { deletedDays },
});
annotate({ meta: { deleted_days: deletedDays } });
return apiSuccess({ disconnected: true as const, deletedDays });
});
@@ -0,0 +1,118 @@
/**
* `GET /api/dawarich/places` answers the instance's places for the home-place
* picker and stores none of them. The database is a spy that records every
* call: the only one allowed is the read of the connection.
*
* Mutation check: make the route `prisma.user.update(...)` the places into
* the prefs and the write assertion names it.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { calls, fetchPlacesMock } = vi.hoisted(() => ({
calls: [] as string[],
fetchPlacesMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ addWarning: vi.fn() }),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (value: string) => value.replace(/^enc:/, ""),
decryptBytes: vi.fn(),
encryptBytes: vi.fn(),
}));
vi.mock("@/lib/dawarich/client", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/dawarich/client")>()),
fetchPlaces: fetchPlacesMock,
}));
/** Every delegate call is recorded as `model.method`. */
vi.mock("@/lib/db", () => {
const delegate = (model: string) =>
new Proxy(
{},
{
get: (_t, method: string) =>
vi.fn(async () => {
calls.push(`${model}.${method}`);
if (model === "user" && method === "findUnique") {
return {
dawarichUrlEncrypted: "enc:https://dawarich.example.com",
dawarichApiKeyEncrypted: "enc:key",
dawarichPrefsEncrypted: null,
};
}
return null;
}),
},
);
return {
prisma: new Proxy(
{},
{
get: (_t, model: string) =>
model.startsWith("$")
? vi.fn(async () => {
calls.push(model);
return [];
})
: delegate(model),
},
),
};
});
import { GET } from "../route";
const WRITE =
/\.(create|createMany|update|updateMany|upsert|delete|deleteMany)$|^\$(executeRaw|executeRawUnsafe|transaction)/;
beforeEach(() => {
calls.length = 0;
fetchPlacesMock.mockReset();
});
describe("GET /api/dawarich/places", () => {
it("answers the places and writes none of them anywhere", async () => {
fetchPlacesMock.mockResolvedValue([
{ id: 7, name: "Home" },
{ id: 9, name: "Office" },
]);
const res = (await GET(
new NextRequest("http://localhost/api/dawarich/places?q=o"),
)) as Response;
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.places).toEqual([
{ id: 7, name: "Home" },
{ id: 9, name: "Office" },
]);
expect(fetchPlacesMock).toHaveBeenCalledWith(
{ baseUrl: "https://dawarich.example.com", apiKey: "key" },
"o",
);
expect(calls).toEqual(["user.findUnique"]);
expect(calls.filter((c) => WRITE.test(c))).toEqual([]);
});
it("says the instance is unreachable instead of failing", async () => {
fetchPlacesMock.mockRejectedValue(new Error("ECONNREFUSED"));
const res = (await GET(
new NextRequest("http://localhost/api/dawarich/places"),
)) as Response;
expect(res.status).toBe(502);
expect((await res.json()).meta.errorCode).toBe("dawarich.unreachable");
});
});
+30 -4
View File
@@ -2,20 +2,24 @@
* `GET /api/dawarich/places?q=` — the instance's places, to pick the home
* place from. Answered from Dawarich live (at most 200, filtered by `q`) and
* never stored. 409 `dawarich.not_connected`, 502 `dawarich.unreachable`.
* Part of the `environment` module; own account only.
* Part of the `environment` module; own account only, thirty reads a minute.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { fetchPlaces } from "@/lib/dawarich/client";
import { loadDawarichConnection } from "@/lib/dawarich/connection";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { dawarichPlacesQuerySchema } from "@/lib/validations/dawarich";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "dawarich.places" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
@@ -28,5 +32,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("dawarich.places.get");
const rl = await checkRateLimit(`dawarich-places:${user.id}`, 30, 60_000);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429, {
errorCode: "dawarich.rate_limited",
});
}
const stored = await loadDawarichConnection(user.id);
if (!stored) {
return apiError("Dawarich is not connected", 409, {
errorCode: "dawarich.not_connected",
});
}
try {
const places = await fetchPlaces(stored.connection, parsed.data.q);
annotate({ meta: { places: places.length } });
return apiSuccess({ places });
} catch {
return apiError("Could not reach the Dawarich instance", 502, {
errorCode: "dawarich.unreachable",
});
}
});
+97 -5
View File
@@ -2,18 +2,45 @@
* `PATCH /api/dawarich/settings` — the home place (an id on the Dawarich
* instance) and the home country (ISO 3166-1 alpha-2) the daily values are
* measured against. 422 `dawarich.home_place_unknown` for a place id the
* instance does not have. Part of the `environment` module; own account only.
* instance does not have; 409 `dawarich.not_connected`; 502
* `dawarich.unreachable` when the place cannot be checked. Part of the
* `environment` module; own account only.
*
* A new home country re-judges the stored travel days at once. A new home
* place needs the visits again, so the stored days are fetched again in the
* background (up to a year).
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { placeExists } from "@/lib/dawarich/client";
import {
effectiveHomeCountry,
loadDawarichConnection,
saveDawarichPrefs,
} from "@/lib/dawarich/connection";
import { yesterdayKey } from "@/lib/dawarich/sync";
import { rejudgeTravelDays } from "@/lib/dawarich/travel";
import { prisma } from "@/lib/db";
import { enqueueDawarichSync } from "@/lib/jobs/dawarich-sync";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { dawarichSettingsSchema } from "@/lib/validations/dawarich";
import { shiftDateKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
DAWARICH_SYNC_MAX_DAYS,
dawarichSettingsSchema,
} from "@/lib/validations/dawarich";
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "dawarich.settings.update" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
@@ -28,5 +55,70 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("dawarich.settings.patch");
const stored = await loadDawarichConnection(user.id);
if (!stored) {
return apiError("Dawarich is not connected", 409, {
errorCode: "dawarich.not_connected",
});
}
const { prefs } = stored;
const { homePlaceId, homeCountry } = parsed.data;
if (homePlaceId != null && homePlaceId !== prefs.homePlaceId) {
let known: boolean;
try {
known = await placeExists(stored.connection, homePlaceId);
} catch {
return apiError("Could not reach the Dawarich instance", 502, {
errorCode: "dawarich.unreachable",
});
}
if (!known) {
return apiError("The Dawarich instance has no such place", 422, {
errorCode: "dawarich.home_place_unknown",
});
}
}
const next = {
...prefs,
homePlaceId: homePlaceId === undefined ? prefs.homePlaceId : homePlaceId,
homeCountry: homeCountry === undefined ? prefs.homeCountry : homeCountry,
};
await saveDawarichPrefs(user.id, next);
const countryChanged =
effectiveHomeCountry(next) !== effectiveHomeCountry(prefs);
if (countryChanged) {
await rejudgeTravelDays(user.id, effectiveHomeCountry(next));
}
let refetch = "none";
if (next.homePlaceId !== prefs.homePlaceId) {
const oldest = await prisma.dawarichDay.findFirst({
where: { userId: user.id },
orderBy: { date: "asc" },
select: { date: true },
});
if (oldest) {
const yesterday = yesterdayKey(await resolveUserTimezone(user.id));
const floor = shiftDateKey(yesterday, -(DAWARICH_SYNC_MAX_DAYS - 1));
refetch = await enqueueDawarichSync({
userId: user.id,
fromDate: oldest.date > floor ? oldest.date : floor,
});
}
}
annotate({
meta: {
home_place_set: next.homePlaceId !== null,
home_country_set: next.homeCountry !== null,
travel_rejudged: countryChanged,
refetch,
},
});
return apiSuccess({
homePlaceId: next.homePlaceId,
homeCountry: next.homeCountry,
});
});
+5 -2
View File
@@ -5,14 +5,17 @@
* Part of the `environment` module; own account only.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess } from "@/lib/api-response";
import { loadDawarichStatus } from "@/lib/dawarich/status";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dawarich.status" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented("dawarich.status.get");
return apiSuccess(await loadDawarichStatus(user.id));
});
+50 -11
View File
@@ -1,26 +1,44 @@
/**
* `POST /api/dawarich/sync` — fetch the daily values again from `fromDate`
* (at most 365 days back; default: the nightly window). 202
* `{ enqueued }`; 409 `dawarich.not_connected`. Part of the `environment`
* module; own account only.
* (at most 365 days back, ending yesterday; default: the nightly window).
* 202 `{ enqueued }`: false when a run for this account is already queued or
* no worker is bound. 409 `dawarich.not_connected`; 422 `dawarich.invalid`.
* Part of the `environment` module; own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { loadDawarichConnection } from "@/lib/dawarich/connection";
import { yesterdayKey } from "@/lib/dawarich/sync";
import { enqueueDawarichSync } from "@/lib/jobs/dawarich-sync";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { dawarichSyncSchema } from "@/lib/validations/dawarich";
import { daysBetweenDateKeys } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
DAWARICH_SYNC_MAX_DAYS,
dawarichSyncSchema,
} from "@/lib/validations/dawarich";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "dawarich.sync.request" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
// The body is optional: no body is the nightly window.
let body: unknown = {};
if (request.headers.get("content-type")?.includes("application/json")) {
const read = await safeJson(request, { maxBytes: 1024 });
if (read.error) return read.error;
body = read.data;
}
const parsed = dawarichSyncSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -28,5 +46,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
return notImplemented("dawarich.sync");
if (!(await loadDawarichConnection(user.id))) {
return apiError("Dawarich is not connected", 409, {
errorCode: "dawarich.not_connected",
});
}
const { fromDate } = parsed.data;
if (fromDate !== undefined) {
const yesterday = yesterdayKey(await resolveUserTimezone(user.id));
const span = daysBetweenDateKeys(fromDate, yesterday) + 1;
if (!Number.isFinite(span) || span < 1 || span > DAWARICH_SYNC_MAX_DAYS) {
return apiError(
`fromDate must lie within the ${DAWARICH_SYNC_MAX_DAYS} days ending yesterday`,
422,
{ errorCode: "dawarich.invalid" },
);
}
}
const outcome = await enqueueDawarichSync({ userId: user.id, fromDate });
annotate({ meta: { dawarich_sync: outcome } });
return apiSuccess({ enqueued: outcome === "enqueued" }, 202);
});
+80 -4
View File
@@ -1,15 +1,91 @@
/**
* `POST /api/dawarich/test` — probe the connected Dawarich instance and answer
* the shared integration probe shape. Part of the `environment` module; own
* account only.
* the shared integration probe shape. A failed probe is a 502 whose
* `errorCode` is one of the shared probe classes (`credentials_rejected`,
* `rate_limited`, `upstream_error`, `timeout`, `url_not_public`,
* `connection_failed`); 422 `not_configured` without a connection. Part of the
* `environment` module; own account only, five probes a minute.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
DawarichApiError,
DawarichPolicyError,
probeDawarich,
} from "@/lib/dawarich/client";
import { loadDawarichConnection } from "@/lib/dawarich/connection";
import { getIntegrationStatus } from "@/lib/integrations/status";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { SafeFetchError } from "@/lib/safe-fetch";
const TIMEOUT_MS = 5_000;
function probeClass(error: unknown): { code: string; message: string } {
if (error instanceof DawarichPolicyError) {
return {
code: "url_not_public",
message:
"The instance is on a private network the operator has not approved",
};
}
if (error instanceof DawarichApiError) {
if (error.status === 401 || error.status === 403) {
return {
code: "credentials_rejected",
message: "Dawarich rejected the API key",
};
}
if (error.status === 429) {
return {
code: "rate_limited",
message: "Dawarich rate-limited the request",
};
}
if (error.status !== null && error.status >= 500) {
return { code: "upstream_error", message: "Dawarich returned an error" };
}
}
if (error instanceof SafeFetchError && error.kind === "timeout") {
return { code: "timeout", message: "Dawarich request timed out" };
}
return { code: "connection_failed", message: "Dawarich connection failed" };
}
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dawarich.test" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented("dawarich.test");
const rl = await checkRateLimit(`dawarich-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const stored = await loadDawarichConnection(user.id);
if (!stored) {
return apiError("Dawarich not connected", 422, {
errorCode: "not_configured",
});
}
const start = performance.now();
try {
await probeDawarich(stored.connection, TIMEOUT_MS);
} catch (error) {
const probe = probeClass(error);
annotate({ meta: { dawarich_test_code: probe.code } });
return apiError(probe.message, 502, { errorCode: probe.code });
}
const latencyMs = Math.round(performance.now() - start);
const ledger = await getIntegrationStatus(user.id, "dawarich");
return apiSuccess({
ok: true as const,
lastSyncedAt: ledger.lastSuccessAt,
latencyMs,
});
});
@@ -1,23 +1,56 @@
/**
* `POST /api/environment/travel/suggestions/{id}/dismiss` — never suggest
* this run of travel days again. Answers `{ dismissed: true }`; 404
* `environment.travel_suggestion.not_found`. Part of the `environment`
* module; own account only.
* `environment.travel_suggestion.not_found` for an id that is not an open
* suggestion. Part of the `environment` module; own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
openDawarichPrefs,
saveDawarichPrefs,
} from "@/lib/dawarich/connection";
import { listTravelSuggestions } from "@/lib/dawarich/travel";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
await params;
const { id } = await params;
annotate({ action: { name: "environment.travel_suggestion.dismiss" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented("environment.travel_suggestion.dismiss");
const notFound = () =>
apiError("No such travel suggestion", 404, {
errorCode: "environment.travel_suggestion.not_found",
});
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
});
if (!row?.dawarichUrlEncrypted || id.length > 64) return notFound();
const prefs = openDawarichPrefs(row.dawarichPrefsEncrypted);
const today = userDayKey(new Date(), await resolveUserTimezone(user.id));
const open = await listTravelSuggestions(
user.id,
today,
new Set(prefs.dismissedSuggestionIds),
);
if (!open.some((s) => s.id === id)) return notFound();
await saveDawarichPrefs(user.id, {
...prefs,
dismissedSuggestionIds: [...prefs.dismissedSuggestionIds, id],
});
return apiSuccess({ dismissed: true as const });
},
);
@@ -3,17 +3,39 @@
* travel days no stored trip covers yet, as `{ id, startDate, endDate,
* countryCode }`. Accepting one goes through `POST /api/environment/travel`
* (the person picks the place); dismissing through the sibling route. Part of
* the `environment` module; own account only.
* the `environment` module; own account only. Without a Dawarich connection
* the list is empty.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { apiSuccess } from "@/lib/api-response";
import { openDawarichPrefs } from "@/lib/dawarich/connection";
import { listTravelSuggestions } from "@/lib/dawarich/travel";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "environment.travel_suggestions.list" } });
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented("environment.travel_suggestions.get");
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { dawarichUrlEncrypted: true, dawarichPrefsEncrypted: true },
});
if (!row?.dawarichUrlEncrypted) return apiSuccess({ suggestions: [] });
const prefs = openDawarichPrefs(row.dawarichPrefsEncrypted);
const today = userDayKey(new Date(), await resolveUserTimezone(user.id));
const suggestions = await listTravelSuggestions(
user.id,
today,
new Set(prefs.dismissedSuggestionIds),
);
annotate({ meta: { suggestions: suggestions.length } });
return apiSuccess({ suggestions });
});
@@ -62,6 +62,7 @@ vi.mock("@/lib/db", () => ({
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
@@ -215,6 +216,7 @@ beforeEach(() => {
vi.mocked(prisma.environmentTravelLocation.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.dawarichDay.findMany).mockResolvedValue([] as never);
});
afterEach(() => {
@@ -86,6 +86,7 @@ vi.mock("@/lib/db", () => ({
medicationConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
@@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
@@ -30,6 +30,7 @@ vi.mock("@/lib/db", () => ({
// channels in (read from EnvironmentContext); default empty so no env
// channel produces a point.
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
},
@@ -159,6 +160,7 @@ beforeEach(() => {
).mockResolvedValue([]);
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.dawarichDay.findMany).mockResolvedValue([] as never);
vi.mocked(hadFindingsBeforeSeasonalAdjustment).mockResolvedValue(false);
});
@@ -338,7 +338,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
expect(envelope.metricFreshnessDegraded).toBe(true);
// Still fail-soft: the rest of the envelope survives, all eight entries
// present, because a freshness hiccup is never worth the settings surface.
expect(envelope.integrations).toHaveLength(8);
expect(envelope.integrations).toHaveLength(9);
for (const entry of envelope.integrations) {
expect(entry.metricFreshness).toEqual([]);
}
@@ -469,6 +469,7 @@ describe("/api/integrations/status — the unified connection health", () => {
oura: ouraHealth,
strava: null,
nightscout: null,
dawarich: null,
"apple-health": null,
} as never);
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
+24
View File
@@ -69,6 +69,7 @@ export const GET = apiHandler(async () => {
ouraStatus,
stravaStatus,
nightscoutStatus,
dawarichStatus,
dbUser,
withingsConn,
whoopConn,
@@ -89,6 +90,7 @@ export const GET = apiHandler(async () => {
getIntegrationStatus(user.id, "oura"),
getIntegrationStatus(user.id, "strava"),
getIntegrationStatus(user.id, "nightscout"),
getIntegrationStatus(user.id, "dawarich"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -115,6 +117,7 @@ export const GET = apiHandler(async () => {
nightscoutUrlEncrypted: true,
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
dawarichUrlEncrypted: true,
},
}),
prisma.withingsConnection.findUnique({
@@ -173,6 +176,7 @@ export const GET = apiHandler(async () => {
"oura",
"strava",
"nightscout",
"dawarich",
]);
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
@@ -283,6 +287,7 @@ export const GET = apiHandler(async () => {
const ouraConnected = !!dbUser?.ouraAccessTokenEncrypted;
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
const dawarichConfigured = !!dbUser?.dawarichUrlEncrypted;
if (metricFreshnessDegraded) {
annotate({ meta: { integrations_metric_freshness_degraded: true } });
@@ -460,6 +465,18 @@ export const GET = apiHandler(async () => {
configured: nightscoutConfigured,
}),
} satisfies IntegrationViewModel & NightscoutExtras,
{
...publish(dawarichStatus),
// v1.43 — Dawarich, read by the card through `health`. The ledger core
// rides along like on every entry, and goes with the rest in v1.44.
connected: dawarichConfigured,
configured: dawarichConfigured,
health: health["dawarich"],
...resolve("dawarich", dawarichStatus, {
connected: dawarichConfigured,
configured: dawarichConfigured,
}),
} satisfies IntegrationViewModel & DawarichExtras,
],
});
});
@@ -560,3 +577,10 @@ interface NightscoutExtras {
hasToken: boolean;
allowPrivateHost: boolean;
}
// v1.43 — Dawarich: the connect marker only; the card reads `health` and its
// own status route.
interface DawarichExtras {
connected: boolean;
configured: boolean;
}
@@ -70,6 +70,9 @@ export const GET = apiHandler(async () => {
polar: context.user.polarAccessTokenEncrypted !== null,
oura: context.user.ouraAccessTokenEncrypted !== null,
strava: context.user.stravaAccessTokenEncrypted !== null,
// Not offered on a managed profile (no managed integrations before
// v1.44); listed so the map stays total.
dawarich: false,
};
// The one server-resolved state per connection, from the same loader the
// owner's own envelope uses, so a guardian reads the record's connections in
@@ -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`,
@@ -40,6 +40,7 @@ vi.mock("@/lib/db", () => ({
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
dawarichDay: { findMany: async () => [] },
},
}));
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
parsePlaces,
parseResidency,
parseTimelineDays,
parseVisits,
} from "../client";
describe("parseTimelineDays", () => {
it("keeps the summary figures and drops everything else", () => {
const days = parseTimelineDays({
days: [
{
date: "2026-06-15",
summary: {
total_distance: 48.3,
places_visited: 4,
mode_distances: { train: 30.5, driving: 12, walking: 1.2 },
},
bounds: { sw_lat: 41.1, sw_lng: 2.1 },
entries: [{ type: "visit", name: "Office" }],
},
],
});
expect(days).toEqual([
{
date: "2026-06-15",
totalKm: 48.3,
drivingKm: 12,
trainKm: 30.5,
placesVisited: 4,
},
]);
expect(JSON.stringify(days)).not.toContain("Office");
});
it("reads a mode Dawarich left out as zero for that day", () => {
const [day] = parseTimelineDays({
days: [{ date: "2026-06-15", summary: { total_distance: 2.1 } }],
});
expect(day.drivingKm).toBe(0);
expect(day.trainKm).toBe(0);
expect(day.placesVisited).toBeNull();
});
it("answers nothing for a payload that is not a timeline", () => {
expect(parseTimelineDays({ error: "nope" })).toEqual([]);
expect(parseTimelineDays(null)).toEqual([]);
expect(
parseTimelineDays({ days: [{ date: "yesterday", summary: {} }] }),
).toEqual([]);
});
});
describe("parseVisits", () => {
it("reduces a visit to its place id and span, and drops declined ones", () => {
const visits = parseVisits([
{
id: 1,
started_at: "2026-06-15T07:00:00Z",
ended_at: "2026-06-15T15:00:00Z",
status: "confirmed",
name: "Office",
place: { id: 9, latitude: 52.5, longitude: 13.4 },
},
{
id: 2,
started_at: "2026-06-15T16:00:00Z",
ended_at: "2026-06-15T17:00:00Z",
status: "declined",
place: { id: 7 },
},
{ id: 3, started_at: "bad", ended_at: "2026-06-15T17:00:00Z" },
]);
expect(visits).toEqual([
{
placeId: 9,
startMs: Date.parse("2026-06-15T07:00:00Z"),
endMs: Date.parse("2026-06-15T15:00:00Z"),
},
]);
});
});
describe("parseResidency", () => {
it("translates each day's country name into its alpha-2 code", () => {
const map = parseResidency({
year: 2026,
daily_countries: {
"2026-06-15": "Spain",
"2026-06-14": "Germany",
"2026-06-13": "Atlantis",
},
countries: [
{ country_name: "Spain", iso_a2: "ES" },
{ country_name: "Germany", iso_a2: "de" },
{ country_name: "Atlantis", iso_a2: null },
],
});
expect([...map]).toEqual([
["2026-06-15", "ES"],
["2026-06-14", "DE"],
]);
});
});
describe("parsePlaces", () => {
const payload = [
{ id: 9, name: "Office", latitude: 52.5 },
{ id: 7, name: "Home", longitude: 13.4 },
{ id: 3, name: " " },
{ id: "x", name: "Broken" },
];
it("keeps id and name, sorted by name", () => {
expect(parsePlaces(payload)).toEqual([
{ id: 7, name: "Home" },
{ id: 9, name: "Office" },
]);
});
it("filters by the query, ignoring case", () => {
expect(parsePlaces(payload, "off")).toEqual([{ id: 9, name: "Office" }]);
});
});
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import {
awayFromHomeMinutes,
buildDayValues,
mostFrequentCountry,
travelDay,
} from "../day-values";
const at = (iso: string) => Date.parse(iso);
const HOME = 7;
// 2026-06-15 in Europe/Berlin is 22:00Z on the 14th to 22:00Z on the 15th.
const dayStart = new Date("2026-06-14T22:00:00Z");
const dayEnd = new Date("2026-06-15T22:00:00Z");
describe("awayFromHomeMinutes", () => {
it("subtracts the minutes the home visits cover inside the day", () => {
const visits = [
{
placeId: HOME,
startMs: at("2026-06-14T18:00:00Z"),
endMs: at("2026-06-15T06:00:00Z"),
},
{
placeId: 9,
startMs: at("2026-06-15T07:00:00Z"),
endMs: at("2026-06-15T15:00:00Z"),
},
{
placeId: HOME,
startMs: at("2026-06-15T16:00:00Z"),
endMs: at("2026-06-16T05:00:00Z"),
},
];
// 8 h + 6 h at home out of 24 h.
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, HOME)).toBe(600);
});
it("counts overlapping home visits once", () => {
const visits = [
{
placeId: HOME,
startMs: at("2026-06-15T00:00:00Z"),
endMs: at("2026-06-15T10:00:00Z"),
},
{
placeId: HOME,
startMs: at("2026-06-15T05:00:00Z"),
endMs: at("2026-06-15T12:00:00Z"),
},
];
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, HOME)).toBe(
1440 - 12 * 60,
);
});
it("is null without a single visit that day: no coverage, not zero", () => {
const visits = [
{
placeId: HOME,
startMs: at("2026-06-13T10:00:00Z"),
endMs: at("2026-06-13T12:00:00Z"),
},
];
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, HOME)).toBeNull();
});
it("is null without a home place", () => {
const visits = [
{
placeId: 9,
startMs: at("2026-06-15T07:00:00Z"),
endMs: at("2026-06-15T15:00:00Z"),
},
];
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, null)).toBeNull();
});
it("reads a day spent elsewhere as the whole day away", () => {
const visits = [
{
placeId: 9,
startMs: at("2026-06-15T07:00:00Z"),
endMs: at("2026-06-15T15:00:00Z"),
},
];
expect(awayFromHomeMinutes(visits, dayStart, dayEnd, HOME)).toBe(1440);
});
});
describe("travelDay", () => {
it("needs a country and a home country", () => {
expect(travelDay("ES", "DE")).toBe(true);
expect(travelDay("DE", "DE")).toBe(false);
expect(travelDay(null, "DE")).toBeNull();
expect(travelDay("ES", null)).toBeNull();
});
});
describe("mostFrequentCountry", () => {
it("counts the window only and breaks a tie alphabetically", () => {
const map = new Map([
["2026-01-01", "ES"],
["2026-01-02", "DE"],
["2025-01-01", "FR"],
["2025-01-02", "FR"],
["2025-01-03", "FR"],
]);
expect(mostFrequentCountry(map, "2026-01-01", "2026-12-31")).toBe("DE");
expect(mostFrequentCountry(map, "2025-01-01", "2026-12-31")).toBe("FR");
expect(
mostFrequentCountry(new Map(), "2025-01-01", "2026-12-31"),
).toBeNull();
});
});
describe("buildDayValues", () => {
it("writes metres, keeps nulls honest and leaves out empty days", () => {
const rows = buildDayValues({
days: ["2026-06-15", "2026-06-16", "2026-06-17"],
tz: "Europe/Berlin",
timeline: [
{
date: "2026-06-15",
totalKm: 48.3,
drivingKm: 12,
trainKm: 30.5,
placesVisited: 4,
},
],
visits: [
{
placeId: 9,
startMs: at("2026-06-15T07:00:00Z"),
endMs: at("2026-06-15T15:00:00Z"),
},
],
countries: new Map([["2026-06-15", "ES"]]),
homePlaceId: HOME,
homeCountry: "DE",
});
expect(rows).toEqual([
{
date: "2026-06-15",
totalDistanceM: 48_300,
drivingDistanceM: 12_000,
trainDistanceM: 30_500,
placesVisited: 4,
awayFromHomeMinutes: 1440,
countryCode: "ES",
isTravelDay: true,
},
]);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it } from "vitest";
import {
configuredDawarichPrivateOrigins,
DawarichOriginConfigError,
evaluateDawarichUrl,
} from "../origin";
const NONE: ReadonlySet<string> = new Set();
afterEach(() => {
delete process.env.DAWARICH_PRIVATE_ORIGINS;
});
describe("evaluateDawarichUrl", () => {
it("lets a public instance through and keeps its path", () => {
const target = evaluateDawarichUrl("https://8.8.8.8/dawarich/", NONE);
expect(target).toMatchObject({
allowed: true,
baseUrl: "https://8.8.8.8/dawarich",
privateOriginApproved: false,
});
});
it("refuses a private instance the operator did not list", () => {
const target = evaluateDawarichUrl("http://192.168.1.20:3000", NONE);
expect(target.allowed).toBe(false);
expect(target.reasonCode).toBe("private_origin_not_approved");
});
it("opens exactly the listed origin, not a sibling port", () => {
const grants = new Set(["http://192.168.1.20:3000"]);
expect(
evaluateDawarichUrl("http://192.168.1.20:3000", grants),
).toMatchObject({ allowed: true, privateOriginApproved: true });
expect(
evaluateDawarichUrl("http://192.168.1.20:3001", grants).allowed,
).toBe(false);
});
it("refuses credentials and a query in the address", () => {
expect(
evaluateDawarichUrl("https://user:pw@8.8.8.8", NONE).reasonCode,
).toBe("invalid_origin");
expect(
evaluateDawarichUrl("https://8.8.8.8/?api_key=x", NONE).reasonCode,
).toBe("invalid_origin");
});
});
describe("configuredDawarichPrivateOrigins", () => {
it("reads the operator list", () => {
process.env.DAWARICH_PRIVATE_ORIGINS =
"http://dawarich.lan:3000, http://10.0.0.5";
expect([...configuredDawarichPrivateOrigins()]).toEqual([
"http://dawarich.lan:3000",
"http://10.0.0.5",
]);
});
it("names a malformed entry instead of granting part of it", () => {
process.env.DAWARICH_PRIVATE_ORIGINS = "http://*.lan";
expect(() => configuredDawarichPrivateOrigins()).toThrow(
DawarichOriginConfigError,
);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { travelRuns } from "../travel";
import { distanceInUnit } from "../movement";
describe("travelRuns", () => {
it("groups consecutive days in one country and splits on a gap or a border", () => {
expect(
travelRuns([
{ date: "2026-06-16", countryCode: "ES" },
{ date: "2026-06-15", countryCode: "ES" },
{ date: "2026-06-17", countryCode: "FR" },
{ date: "2026-06-20", countryCode: "FR" },
]),
).toEqual([
{
id: "2026-06-15_2026-06-16_ES",
startDate: "2026-06-15",
endDate: "2026-06-16",
countryCode: "ES",
},
{
id: "2026-06-17_2026-06-17_FR",
startDate: "2026-06-17",
endDate: "2026-06-17",
countryCode: "FR",
},
{
id: "2026-06-20_2026-06-20_FR",
startDate: "2026-06-20",
endDate: "2026-06-20",
countryCode: "FR",
},
]);
});
});
describe("distanceInUnit", () => {
it("answers one decimal in the account's unit", () => {
expect(distanceInUnit(48_300, "km")).toBe(48.3);
expect(distanceInUnit(16_093, "mi")).toBe(10);
expect(distanceInUnit(null, "km")).toBeNull();
});
});
+378
View File
@@ -0,0 +1,378 @@
/**
* Dawarich API client: the wire shapes HealthLog reads and nothing more.
*
* Dawarich (https://dawarich.app) is a self-hosted location history. HealthLog
* reads four endpoints, all with `Authorization: Bearer <api key>`:
*
* GET /api/v1/timeline?start_at&end_at&distance_unit=km
* one entry per local day (the instance's own zone) with a `summary`:
* `total_distance`, `places_visited`, `mode_distances.{driving,train}`
* in km to one decimal. At most 31 days per request.
* GET /api/v1/visits?start_at&end_at
* visits that STARTED in the window: `started_at`, `ended_at`,
* `status`, `place.id`. The coordinates and the name in the payload
* are read past and dropped here; nothing but the place id survives.
* GET /api/v1/residency?year=YYYY
* `daily_countries` (UTC date to country name) and `countries[]`
* (country name to ISO 3166-1 alpha-2). Answers 403 on a hosted plan
* without API access; then there is no travel signal.
* GET /api/v1/places, /api/v1/places/:id
* to pick and check the home place; id and name only, never stored.
* GET /api/v1/users/me
* the cheapest authenticated call, used to check a key.
*
* Every call goes through `safeFetch`: `requirePublicHost` for a public
* instance, the operator-approved pinned dispatcher for an origin listed in
* `DAWARICH_PRIVATE_ORIGINS`. The policy is re-read on every call, so taking
* an origin off the list stops the next request.
*/
import { SafeFetchError, safeFetch } from "@/lib/safe-fetch";
import {
configuredDawarichPrivateOrigins,
evaluateDawarichUrl,
} from "./origin";
const DEFAULT_TIMEOUT_MS = 20_000;
/** Dawarich refuses a timeline window longer than 31 days. */
export const TIMELINE_MAX_DAYS = 31;
/** The places list is read once per request and capped before filtering. */
const PLACES_PAGE_SIZE = 500;
/** At most this many places go back to the picker. */
const PLACES_RESULT_LIMIT = 200;
export interface DawarichConnection {
baseUrl: string;
apiKey: string;
}
/** The instance answered with a status outside 2xx, or not with JSON. */
export class DawarichApiError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null) {
super(message);
this.name = "DawarichApiError";
this.status = status;
}
}
/** The address is private and not on the operator's list, or not usable at all. */
export class DawarichPolicyError extends Error {
readonly reasonCode: string;
constructor(reasonCode: string) {
super(reasonCode);
this.name = "DawarichPolicyError";
this.reasonCode = reasonCode;
}
}
async function dawarichGet(
conn: DawarichConnection,
path: string,
query: Record<string, string> = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<unknown> {
const target = evaluateDawarichUrl(
conn.baseUrl,
configuredDawarichPrivateOrigins(),
);
if (!target.allowed || !target.baseUrl || !target.origin) {
throw new DawarichPolicyError(
target.reasonCode ?? "private_origin_not_approved",
);
}
const url = new URL(`${target.baseUrl}${path}`);
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value);
}
let res: Response;
try {
res = await safeFetch(
url.toString(),
{
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${conn.apiKey}`,
},
},
{
requirePublicHost: !target.privateOriginApproved,
...(target.privateOriginApproved
? { operatorApprovedPrivateOrigin: target.origin }
: {}),
timeoutMs,
},
);
} catch (error) {
if (error instanceof SafeFetchError && error.kind === "private_host") {
throw new DawarichPolicyError("private_origin_not_approved");
}
throw error;
}
if (res.status < 200 || res.status >= 300) {
throw new DawarichApiError(`Dawarich responded ${res.status}`, res.status);
}
try {
return await res.json();
} catch {
throw new DawarichApiError("Dawarich returned a non-JSON body", null);
}
}
// ── Timeline ─────────────────────────────────────────────────────────
/** One day's summary, distances in km as Dawarich reports them. */
export interface TimelineDaySummary {
date: string;
totalKm: number | null;
drivingKm: number | null;
trainKm: number | null;
placesVisited: number | null;
}
function finiteOrNull(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: null;
}
/**
* Read the day summaries out of a timeline answer. A mode absent from
* `mode_distances` is zero for that day (Dawarich leaves out a mode that moved
* less than half a kilometre), so it reads as 0 whenever the summary itself is
* there. Everything else in the payload (entries, bounds, names) is dropped.
*/
export function parseTimelineDays(payload: unknown): TimelineDaySummary[] {
const days = (payload as { days?: unknown } | null)?.days;
if (!Array.isArray(days)) return [];
const out: TimelineDaySummary[] = [];
for (const raw of days) {
if (!raw || typeof raw !== "object") continue;
const day = raw as { date?: unknown; summary?: unknown };
if (typeof day.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(day.date)) {
continue;
}
const summary =
day.summary && typeof day.summary === "object"
? (day.summary as Record<string, unknown>)
: null;
if (!summary) continue;
const modes =
summary.mode_distances && typeof summary.mode_distances === "object"
? (summary.mode_distances as Record<string, unknown>)
: {};
out.push({
date: day.date,
totalKm: finiteOrNull(summary.total_distance),
drivingKm: finiteOrNull(modes.driving) ?? 0,
trainKm: finiteOrNull(modes.train) ?? 0,
placesVisited:
typeof summary.places_visited === "number" &&
Number.isInteger(summary.places_visited) &&
summary.places_visited >= 0
? summary.places_visited
: null,
});
}
return out;
}
export async function fetchTimeline(
conn: DawarichConnection,
startAt: Date,
endAt: Date,
): Promise<TimelineDaySummary[]> {
return parseTimelineDays(
await dawarichGet(conn, "/api/v1/timeline", {
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
distance_unit: "km",
}),
);
}
// ── Visits ───────────────────────────────────────────────────────────
/** A visit reduced to what time away from home needs. */
export interface VisitSpan {
placeId: number | null;
startMs: number;
endMs: number;
}
/**
* Read the visits. A declined visit (the person said it did not happen) is
* dropped; so is one without a usable time span. Coordinates and names in the
* payload are never copied out.
*/
export function parseVisits(payload: unknown): VisitSpan[] {
if (!Array.isArray(payload)) return [];
const out: VisitSpan[] = [];
for (const raw of payload) {
if (!raw || typeof raw !== "object") continue;
const visit = raw as Record<string, unknown>;
if (visit.status === "declined") continue;
const startMs =
typeof visit.started_at === "string" ? Date.parse(visit.started_at) : NaN;
const endMs =
typeof visit.ended_at === "string" ? Date.parse(visit.ended_at) : NaN;
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
if (endMs <= startMs) continue;
const place =
visit.place && typeof visit.place === "object"
? (visit.place as Record<string, unknown>)
: null;
const placeId =
typeof place?.id === "number" && Number.isInteger(place.id)
? place.id
: null;
out.push({ placeId, startMs, endMs });
}
return out;
}
export async function fetchVisits(
conn: DawarichConnection,
startAt: Date,
endAt: Date,
): Promise<VisitSpan[]> {
return parseVisits(
await dawarichGet(conn, "/api/v1/visits", {
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
}),
);
}
// ── Residency ────────────────────────────────────────────────────────
/** UTC date to ISO 3166-1 alpha-2, for the days the year holds a country. */
export type DailyCountries = Map<string, string>;
/**
* Read `daily_countries` and translate each country name through the
* `countries[]` table into its alpha-2 code. A name without a code is dropped:
* a country HealthLog cannot name is no basis for a travel day.
*/
export function parseResidency(payload: unknown): DailyCountries {
const out: DailyCountries = new Map();
if (!payload || typeof payload !== "object") return out;
const body = payload as Record<string, unknown>;
const codeByName = new Map<string, string>();
if (Array.isArray(body.countries)) {
for (const raw of body.countries) {
if (!raw || typeof raw !== "object") continue;
const country = raw as Record<string, unknown>;
if (
typeof country.country_name === "string" &&
typeof country.iso_a2 === "string" &&
/^[A-Za-z]{2}$/.test(country.iso_a2)
) {
codeByName.set(country.country_name, country.iso_a2.toUpperCase());
}
}
}
const daily =
body.daily_countries && typeof body.daily_countries === "object"
? (body.daily_countries as Record<string, unknown>)
: {};
for (const [date, name] of Object.entries(daily)) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || typeof name !== "string") continue;
const code = codeByName.get(name);
if (code) out.set(date, code);
}
return out;
}
/**
* One year of residency, or null when the instance does not offer it (a
* hosted plan without API access answers 403, an older instance 404). Any
* other failure is thrown like every other call.
*/
export async function fetchResidency(
conn: DawarichConnection,
year: number,
): Promise<DailyCountries | null> {
try {
return parseResidency(
await dawarichGet(conn, "/api/v1/residency", { year: String(year) }),
);
} catch (error) {
if (
error instanceof DawarichApiError &&
(error.status === 403 || error.status === 404)
) {
return null;
}
throw error;
}
}
// ── Places ───────────────────────────────────────────────────────────
export interface DawarichPlace {
id: number;
name: string;
}
/** Id and name of each place, filtered by `q`, sorted by name, capped. */
export function parsePlaces(payload: unknown, q?: string): DawarichPlace[] {
if (!Array.isArray(payload)) return [];
const needle = q?.trim().toLocaleLowerCase() ?? "";
const out: DawarichPlace[] = [];
for (const raw of payload) {
if (!raw || typeof raw !== "object") continue;
const place = raw as Record<string, unknown>;
if (typeof place.id !== "number" || !Number.isInteger(place.id)) continue;
const name = typeof place.name === "string" ? place.name.trim() : "";
if (!name) continue;
if (needle && !name.toLocaleLowerCase().includes(needle)) continue;
out.push({ id: place.id, name: name.slice(0, 200) });
}
out.sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id);
return out.slice(0, PLACES_RESULT_LIMIT);
}
export async function fetchPlaces(
conn: DawarichConnection,
q?: string,
): Promise<DawarichPlace[]> {
return parsePlaces(
await dawarichGet(conn, "/api/v1/places", {
filter: "all",
page: "1",
per_page: String(PLACES_PAGE_SIZE),
}),
q,
);
}
/** Whether the instance knows a place with this id. */
export async function placeExists(
conn: DawarichConnection,
id: number,
): Promise<boolean> {
try {
await dawarichGet(conn, `/api/v1/places/${id}`);
return true;
} catch (error) {
if (error instanceof DawarichApiError && error.status === 404) {
return false;
}
throw error;
}
}
/** Check the key with the cheapest authenticated call. */
export async function probeDawarich(
conn: DawarichConnection,
timeoutMs?: number,
): Promise<void> {
await dawarichGet(conn, "/api/v1/users/me", {}, timeoutMs);
}
+173
View File
@@ -0,0 +1,173 @@
/**
* The stored Dawarich connection: the sealed URL and key on the user row, the
* sealed preferences beside them, and each day's sealed country code.
*
* Preferences are one sealed JSON value (AAD `healthlog/dawarich-prefs/v1`):
*
* homePlaceId a place id on the instance, the anchor of time away
* from home. An id, never the place itself.
* homeCountry ISO 3166-1 alpha-2 the person chose, or null.
* derivedHomeCountry the most frequent residency country of the last
* 365 days, refreshed by every sync; used while
* `homeCountry` is null.
* dismissedSuggestionIds travel suggestions the person turned down.
*/
import { Buffer } from "node:buffer";
import { decrypt, decryptBytes, encryptBytes } from "@/lib/crypto";
import {
DAWARICH_DAY_COUNTRY_AAD,
DAWARICH_PREFS_AAD,
} from "@/lib/crypto/encrypted-columns";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type { DawarichConnection } from "./client";
export interface DawarichPrefs {
homePlaceId: number | null;
homeCountry: string | null;
derivedHomeCountry: string | null;
dismissedSuggestionIds: string[];
}
export const EMPTY_DAWARICH_PREFS: DawarichPrefs = {
homePlaceId: null,
homeCountry: null,
derivedHomeCountry: null,
dismissedSuggestionIds: [],
};
/** At most this many dismissed suggestions are remembered, newest kept. */
const DISMISSED_LIMIT = 200;
const COUNTRY = /^[A-Z]{2}$/;
function toBytes(sealed: Buffer): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(sealed.byteLength));
out.set(sealed);
return out;
}
export function sealDawarichPrefs(
prefs: DawarichPrefs,
): Uint8Array<ArrayBuffer> {
return toBytes(
encryptBytes(
Buffer.from(
JSON.stringify({
homePlaceId: prefs.homePlaceId,
homeCountry: prefs.homeCountry,
derivedHomeCountry: prefs.derivedHomeCountry,
dismissedSuggestionIds:
prefs.dismissedSuggestionIds.slice(-DISMISSED_LIMIT),
}),
"utf8",
),
DAWARICH_PREFS_AAD,
),
);
}
/**
* Open the sealed preferences. A value that does not open, or holds an
* unexpected shape, reads as empty preferences with a warning on the event:
* losing the home place is recoverable from the settings card, failing every
* read of the connection is not.
*/
export function openDawarichPrefs(
sealed: Uint8Array | null | undefined,
): DawarichPrefs {
if (!sealed) return { ...EMPTY_DAWARICH_PREFS };
try {
const raw = JSON.parse(
decryptBytes(Buffer.from(sealed), DAWARICH_PREFS_AAD).toString("utf8"),
) as Record<string, unknown>;
const country = (value: unknown) =>
typeof value === "string" && COUNTRY.test(value) ? value : null;
return {
homePlaceId:
typeof raw.homePlaceId === "number" &&
Number.isInteger(raw.homePlaceId) &&
raw.homePlaceId > 0
? raw.homePlaceId
: null,
homeCountry: country(raw.homeCountry),
derivedHomeCountry: country(raw.derivedHomeCountry),
dismissedSuggestionIds: Array.isArray(raw.dismissedSuggestionIds)
? raw.dismissedSuggestionIds.filter(
(id): id is string => typeof id === "string" && id.length <= 64,
)
: [],
};
} catch (error) {
getEvent()?.addWarning(
`dawarich prefs did not open: ${error instanceof Error ? error.name : "unknown"}`,
);
return { ...EMPTY_DAWARICH_PREFS };
}
}
/** The home country in force: the chosen one, else the derived one. */
export function effectiveHomeCountry(prefs: DawarichPrefs): string | null {
return prefs.homeCountry ?? prefs.derivedHomeCountry;
}
export function sealDayCountry(code: string): Uint8Array<ArrayBuffer> {
return toBytes(
encryptBytes(Buffer.from(code, "utf8"), DAWARICH_DAY_COUNTRY_AAD),
);
}
/** A day's country code, or null when the value does not open. */
export function openDayCountry(sealed: Uint8Array | null): string | null {
if (!sealed) return null;
try {
const code = decryptBytes(
Buffer.from(sealed),
DAWARICH_DAY_COUNTRY_AAD,
).toString("utf8");
return COUNTRY.test(code) ? code : null;
} catch {
getEvent()?.addWarning("dawarich day country did not open");
return null;
}
}
export interface StoredDawarich {
connection: DawarichConnection;
prefs: DawarichPrefs;
}
/** The account's connection, or null when none is stored. */
export async function loadDawarichConnection(
userId: string,
): Promise<StoredDawarich | null> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: {
dawarichUrlEncrypted: true,
dawarichApiKeyEncrypted: true,
dawarichPrefsEncrypted: true,
},
});
if (!row?.dawarichUrlEncrypted || !row.dawarichApiKeyEncrypted) return null;
return {
connection: {
baseUrl: decrypt(row.dawarichUrlEncrypted),
apiKey: decrypt(row.dawarichApiKeyEncrypted),
},
prefs: openDawarichPrefs(row.dawarichPrefsEncrypted),
};
}
/** Replace the sealed preferences. */
export async function saveDawarichPrefs(
userId: string,
prefs: DawarichPrefs,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { dawarichPrefsEncrypted: sealDawarichPrefs(prefs) },
});
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Fold what Dawarich answered into one row of daily values per local day.
*
* Pure: the sync fetches, this decides. Nothing here sees a coordinate or a
* name; the client has already reduced visits to a place id and a span, and
* the residency to a country code per date.
*
* distances from the timeline summary, km to whole metres.
* places `places_visited` of the summary.
* away from home the day's minutes minus the minutes a visit at the home
* place covers. Without a home place, or without a single
* visit touching the day, there is no coverage and the value
* is null, never zero.
* country the residency country of the date. Dawarich keys residency
* by UTC date, so on a day that crosses a border late in the
* evening the country may be the next morning's; the travel
* run is days long, one edge day does not change it.
* travel day the country differs from the home country; null without
* either.
*/
import { localDayWindow } from "@/lib/tz/local-day";
import type { DailyCountries, TimelineDaySummary, VisitSpan } from "./client";
export interface DawarichDayValues {
date: string;
totalDistanceM: number | null;
drivingDistanceM: number | null;
trainDistanceM: number | null;
placesVisited: number | null;
awayFromHomeMinutes: number | null;
countryCode: string | null;
isTravelDay: boolean | null;
}
const MINUTE_MS = 60_000;
function kmToMetres(km: number | null): number | null {
return km === null ? null : Math.round(km * 1000);
}
/**
* Minutes of `[dayStart, dayEnd)` not covered by a visit at `homePlaceId`.
* Null when no home place is set or no visit at all touches the day.
*/
export function awayFromHomeMinutes(
visits: readonly VisitSpan[],
dayStart: Date,
dayEnd: Date,
homePlaceId: number | null,
): number | null {
if (homePlaceId === null) return null;
const start = dayStart.getTime();
const end = dayEnd.getTime();
const touching = visits.filter((v) => v.startMs < end && v.endMs > start);
if (touching.length === 0) return null;
const home = touching
.filter((v) => v.placeId === homePlaceId)
.map((v) => [Math.max(v.startMs, start), Math.min(v.endMs, end)] as const)
.sort((a, b) => a[0] - b[0]);
// Merge overlapping home spans so a visit Dawarich split in two is not
// counted twice.
let covered = 0;
let runStart: number | null = null;
let runEnd = 0;
for (const [s, e] of home) {
if (runStart === null || s > runEnd) {
if (runStart !== null) covered += runEnd - runStart;
runStart = s;
runEnd = e;
} else if (e > runEnd) {
runEnd = e;
}
}
if (runStart !== null) covered += runEnd - runStart;
return Math.max(0, Math.round((end - start - covered) / MINUTE_MS));
}
/**
* The most frequent country over the given dates, or null when none has one.
* Ties go to the alphabetically first code, so the answer does not depend on
* the order Dawarich listed the days in.
*/
export function mostFrequentCountry(
countries: DailyCountries,
fromDate: string,
toDate: string,
): string | null {
const counts = new Map<string, number>();
for (const [date, code] of countries) {
if (date < fromDate || date > toDate) continue;
counts.set(code, (counts.get(code) ?? 0) + 1);
}
let best: string | null = null;
let bestCount = 0;
for (const [code, count] of [...counts].sort(([a], [b]) =>
a.localeCompare(b),
)) {
if (count > bestCount) {
best = code;
bestCount = count;
}
}
return best;
}
/**
* One row per day that Dawarich holds anything for. A day with no summary, no
* visit and no country is left out: there is nothing to say about it, and an
* empty row would read as "measured, nothing happened".
*/
export function buildDayValues(input: {
days: readonly string[];
tz: string;
timeline: readonly TimelineDaySummary[];
visits: readonly VisitSpan[];
countries: DailyCountries | null;
homePlaceId: number | null;
homeCountry: string | null;
}): DawarichDayValues[] {
const summaryByDate = new Map(input.timeline.map((d) => [d.date, d]));
const out: DawarichDayValues[] = [];
for (const date of input.days) {
const summary = summaryByDate.get(date) ?? null;
const { dayStart, dayEnd } = localDayWindow(date, input.tz);
const away = awayFromHomeMinutes(
input.visits,
dayStart,
dayEnd,
input.homePlaceId,
);
const touched = input.visits.some(
(v) => v.startMs < dayEnd.getTime() && v.endMs > dayStart.getTime(),
);
const countryCode = input.countries?.get(date) ?? null;
if (!summary && !touched && !countryCode) continue;
out.push({
date,
totalDistanceM: kmToMetres(summary?.totalKm ?? null),
drivingDistanceM: kmToMetres(summary?.drivingKm ?? null),
trainDistanceM: kmToMetres(summary?.trainKm ?? null),
placesVisited: summary?.placesVisited ?? null,
awayFromHomeMinutes: away,
countryCode,
isTravelDay: travelDay(countryCode, input.homeCountry),
});
}
return out;
}
/** A travel day needs both a country and a home country to compare with. */
export function travelDay(
countryCode: string | null,
homeCountry: string | null,
): boolean | null {
if (!countryCode || !homeCountry) return null;
return countryCode !== homeCountry;
}
+78
View File
@@ -0,0 +1,78 @@
/**
* The day's movement block (`DayMovement`), resolved for every reader of a
* day: the web view, the native client, and through the model projection the
* Coach and the MCP endpoint.
*
* Distances leave in the account's unit, to one decimal; a client renders
* them as they come. A null field means "no coverage that day", never zero.
*/
import type { DayResponse } from "@/lib/day/contract";
import { prisma } from "@/lib/db";
import { openDayCountry } from "./connection";
type DayMovement = NonNullable<DayResponse["movement"]>;
const METRES_PER_MILE = 1609.344;
/** Metres in the account's unit, to one decimal. */
export function distanceInUnit(
metres: number | null,
unit: "km" | "mi",
): number | null {
if (metres === null) return null;
const value = unit === "mi" ? metres / METRES_PER_MILE : metres / 1000;
return Math.round(value * 10) / 10;
}
/**
* The movement block for one day, `undefined` when the record has no Dawarich
* connection (the field is then left off), null when it has one but no values
* for the day.
*/
export async function readDayMovement(
userId: string,
day: string,
): Promise<DayMovement | null | undefined> {
const [user, row] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { dawarichUrlEncrypted: true, unitPreference: true },
}),
prisma.dawarichDay.findUnique({
where: { userId_date: { userId, date: day } },
select: {
totalDistanceM: true,
drivingDistanceM: true,
trainDistanceM: true,
placesVisited: true,
awayFromHomeMinutes: true,
countryCodeEncrypted: true,
isTravelDay: true,
fetchedAt: true,
},
}),
]);
if (!user?.dawarichUrlEncrypted) return undefined;
if (!row) return null;
const unit = user.unitPreference === "imperial" ? "mi" : "km";
return {
source: "dawarich",
distance: {
unit,
total: distanceInUnit(row.totalDistanceM, unit),
driving: distanceInUnit(row.drivingDistanceM, unit),
train: distanceInUnit(row.trainDistanceM, unit),
},
placesVisited: row.placesVisited,
awayFromHomeMinutes: row.awayFromHomeMinutes,
travel:
row.isTravelDay === null
? null
: {
isTravelDay: row.isTravelDay,
countryCode: openDayCountry(row.countryCodeEncrypted),
},
fetchedAt: row.fetchedAt.toISOString(),
};
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Which Dawarich instances HealthLog may call.
*
* Dawarich is self-hosted, and most instances live on the operator's own
* network. The rule is the Nightscout one: a public host is reachable without
* configuration, a private one only when its exact origin is listed in
* `DAWARICH_PRIVATE_ORIGINS` (the shared grant grammar in
* `@/lib/private-origin-policy`: one `scheme://host[:port]` per entry, comma
* separated, never a suffix or a range). The request body can never widen it.
*
* Unlike a Nightscout address, a Dawarich base URL may carry a path (an
* instance served under a sub-path behind a reverse proxy). The grant is
* matched on the origin; the path is kept as the base the API paths hang off.
*/
import {
evaluateOriginGrant,
INVALID_ORIGIN,
originOfUrl,
parsePrivateOrigins,
type OriginReason,
} from "@/lib/private-origin-policy";
/**
* `DAWARICH_PRIVATE_ORIGINS` holds an entry that is not a grant. The message
* names the entry (scheme and host only) and why, so the connect form shows
* the operator what to edit.
*/
export class DawarichOriginConfigError extends Error {
constructor(redactedEntry: string, reason: string) {
super(
`Invalid DAWARICH_PRIVATE_ORIGINS entry "${redactedEntry}": ${reason}`,
);
this.name = "DawarichOriginConfigError";
}
}
/** The operator's exact-origin grants; throws on a malformed entry. */
export function configuredDawarichPrivateOrigins(): ReadonlySet<string> {
return parsePrivateOrigins(
process.env.DAWARICH_PRIVATE_ORIGINS,
(redactedEntry, reason) => {
throw new DawarichOriginConfigError(redactedEntry, reason);
},
);
}
export interface DawarichTarget {
allowed: boolean;
/** The base URL without a trailing slash, null when the input is not a usable URL. */
baseUrl: string | null;
/** The canonical origin of `baseUrl`; the value the private grant is matched on. */
origin: string | null;
privateOriginApproved: boolean;
reasonCode: OriginReason | null;
}
/**
* Resolve a user-supplied Dawarich base URL against the operator's grants.
* Credentials, a query and a fragment make the URL unusable (`invalid_origin`):
* the API key travels in a header, never in the address.
*/
export function evaluateDawarichUrl(
value: string,
privateOrigins: ReadonlySet<string>,
): DawarichTarget {
const refused = (reasonCode: OriginReason): DawarichTarget => ({
allowed: false,
baseUrl: null,
origin: null,
privateOriginApproved: false,
reasonCode,
});
let url: URL;
try {
url = new URL(value);
} catch {
return refused(INVALID_ORIGIN);
}
if (url.username || url.password || url.search || url.hash) {
return refused(INVALID_ORIGIN);
}
const origin = originOfUrl(value);
if (!origin) return refused(INVALID_ORIGIN);
const verdict = evaluateOriginGrant(origin, privateOrigins, value);
const path = url.pathname.replace(/\/+$/, "");
return {
allowed: verdict.allowed,
baseUrl: verdict.allowed ? `${origin}${path}` : null,
origin,
privateOriginApproved: verdict.privateOriginApproved,
reasonCode: verdict.reasonCode,
};
}
+56
View File
@@ -0,0 +1,56 @@
/**
* `DawarichStatus`, resolved once for every reader (the status route and the
* settings card through it).
*/
import { prisma } from "@/lib/db";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { effectiveHomeCountry, openDawarichPrefs } from "./connection";
export interface DawarichStatus {
connected: boolean;
configured: boolean;
homePlaceSet: boolean;
homeCountry: string | null;
daysStored: number;
oldestDay: string | null;
latestDay: string | null;
health: ConnectionHealth | null;
}
export async function loadDawarichStatus(
userId: string,
): Promise<DawarichStatus> {
const [user, span, daysStored] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: {
dawarichUrlEncrypted: true,
dawarichApiKeyEncrypted: true,
dawarichPrefsEncrypted: true,
},
}),
prisma.dawarichDay.aggregate({
where: { userId },
_min: { date: true },
_max: { date: true },
}),
prisma.dawarichDay.count({ where: { userId } }),
]);
const configured = !!user?.dawarichUrlEncrypted;
const connected = configured && !!user?.dawarichApiKeyEncrypted;
const prefs = openDawarichPrefs(user?.dawarichPrefsEncrypted);
return {
connected,
configured,
homePlaceSet: prefs.homePlaceId !== null,
homeCountry: effectiveHomeCountry(prefs),
daysStored,
oldestDay: span._min.date ?? null,
latestDay: span._max.date ?? null,
health: connected
? await loadOneConnectionHealth(userId, "dawarich")
: null,
};
}
+222
View File
@@ -0,0 +1,222 @@
/**
* Fetch a range of days from Dawarich and store their daily values.
*
* Per run: one timeline request per 30 days, one visits request for the whole
* range (reaching a week back, so a stay at home that began before the range
* still counts), and one residency request per calendar year the range or the
* home-country window touches. Each stored day is an upsert on
* `(user_id, date)`, so a run over days already stored refreshes them.
*
* The outcome lands on the shared integration ledger (`dawarich`): a clean run
* records a success, a failed request a classified failure. The connection
* health and the watchdog read that ledger like every other connection.
*/
import { prisma } from "@/lib/db";
import {
markSyncFailureRecorded,
recordSyncFailure,
recordSyncSuccess,
type FailureKind,
} from "@/lib/integrations/status";
import { localDayWindow } from "@/lib/tz/local-day";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
DawarichApiError,
DawarichPolicyError,
fetchResidency,
fetchTimeline,
fetchVisits,
TIMELINE_MAX_DAYS,
type DailyCountries,
type TimelineDaySummary,
} from "./client";
import {
effectiveHomeCountry,
loadDawarichConnection,
saveDawarichPrefs,
sealDayCountry,
} from "./connection";
import { buildDayValues, mostFrequentCountry } from "./day-values";
/** The window the home country is derived from. */
const HOME_COUNTRY_WINDOW_DAYS = 365;
/** How far before the range the visits request reaches. */
const VISIT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
/** Days per timeline request, inside Dawarich's 31-day limit. */
const TIMELINE_CHUNK_DAYS = TIMELINE_MAX_DAYS - 1;
export interface DawarichSyncResult {
/** Days written this run (new or refreshed). */
daysStored: number;
/** Nothing ran: there is no stored connection. */
notConnected: boolean;
}
/** Map a failed request onto the ledger's failure kind. */
export function classifyDawarichFailure(error: unknown): FailureKind {
if (error instanceof DawarichPolicyError) return "persistent";
if (error instanceof DawarichApiError && error.status !== null) {
if (error.status === 401 || error.status === 403) return "reauth_required";
if (error.status >= 400 && error.status < 500 && error.status !== 429) {
return "persistent";
}
}
// 5xx, 429, timeouts and unreachable hosts: try again next run.
return "transient";
}
/** The short code a failure is recorded with; never a message or a URL. */
export function dawarichFailureCode(error: unknown): string {
if (error instanceof DawarichPolicyError) return error.reasonCode;
if (error instanceof DawarichApiError) {
return error.status === null ? "invalid_body" : String(error.status);
}
return "unreachable";
}
async function recordFailure(userId: string, error: unknown): Promise<void> {
const code = dawarichFailureCode(error);
await recordSyncFailure({
userId,
integration: "dawarich",
kind: classifyDawarichFailure(error),
// The code alone: an upstream message can carry the instance address, and
// the address is the person's own network.
message: `dawarich ${code}`,
errorCode: code,
});
}
function daysOf(fromDate: string, toDate: string): string[] {
const out: string[] = [];
for (let d = fromDate; d <= toDate; d = shiftDateKey(d, 1)) out.push(d);
return out;
}
function yearsOf(...ranges: Array<[string, string]>): number[] {
const years = new Set<number>();
for (const [from, to] of ranges) {
for (let y = Number(from.slice(0, 4)); y <= Number(to.slice(0, 4)); y++) {
years.add(y);
}
}
return [...years].sort();
}
/** Yesterday in the account's zone: the newest day a run fetches. */
export function yesterdayKey(tz: string, now: Date = new Date()): string {
return shiftDateKey(userDayKey(now, tz), -1);
}
/**
* Fetch and store `[fromDate, toDate]` (local day keys, inclusive). Throws on
* a failed request after recording it on the ledger (marked, so a caller does
* not record it twice).
*/
export async function syncDawarichRange(
userId: string,
fromDate: string,
toDate: string,
now: Date = new Date(),
): Promise<DawarichSyncResult> {
const stored = await loadDawarichConnection(userId);
if (!stored) return { daysStored: 0, notConnected: true };
if (fromDate > toDate) return { daysStored: 0, notConnected: false };
const { connection, prefs } = stored;
const tz = await resolveUserTimezone(userId);
const days = daysOf(fromDate, toDate);
const today = userDayKey(now, tz);
const homeWindowFrom = shiftDateKey(today, -HOME_COUNTRY_WINDOW_DAYS);
let timeline: TimelineDaySummary[] = [];
let visits: Awaited<ReturnType<typeof fetchVisits>> = [];
let countries: DailyCountries | null = null;
try {
for (let i = 0; i < days.length; i += TIMELINE_CHUNK_DAYS) {
const chunk = days.slice(i, i + TIMELINE_CHUNK_DAYS);
const { dayStart } = localDayWindow(chunk[0], tz);
const { dayEnd } = localDayWindow(chunk[chunk.length - 1], tz);
timeline = timeline.concat(
await fetchTimeline(
connection,
dayStart,
new Date(dayEnd.getTime() - 1000),
),
);
}
const rangeStart = localDayWindow(fromDate, tz).dayStart;
const rangeEnd = localDayWindow(toDate, tz).dayEnd;
visits = await fetchVisits(
connection,
new Date(rangeStart.getTime() - VISIT_LOOKBACK_MS),
rangeEnd,
);
const years = yearsOf(
[fromDate, toDate],
...(prefs.homeCountry === null
? [[homeWindowFrom, today] as [string, string]]
: []),
);
for (const year of years) {
const yearCountries = await fetchResidency(connection, year);
if (!yearCountries) continue;
countries ??= new Map();
for (const [date, code] of yearCountries) countries.set(date, code);
}
} catch (error) {
await recordFailure(userId, error);
throw markSyncFailureRecorded(error);
}
// The derived home country follows the residency the instance holds; a run
// that could not read residency keeps the previous one.
let nextPrefs = prefs;
if (countries && prefs.homeCountry === null) {
const derived = mostFrequentCountry(countries, homeWindowFrom, today);
if (derived !== prefs.derivedHomeCountry) {
nextPrefs = { ...prefs, derivedHomeCountry: derived };
await saveDawarichPrefs(userId, nextPrefs);
}
}
const rows = buildDayValues({
days,
tz,
timeline,
visits,
countries,
homePlaceId: nextPrefs.homePlaceId,
homeCountry: effectiveHomeCountry(nextPrefs),
});
const fetchedAt = new Date();
await prisma.$transaction(
rows.map((row) => {
const data = {
totalDistanceM: row.totalDistanceM,
drivingDistanceM: row.drivingDistanceM,
trainDistanceM: row.trainDistanceM,
placesVisited: row.placesVisited,
awayFromHomeMinutes: row.awayFromHomeMinutes,
countryCodeEncrypted: row.countryCode
? sealDayCountry(row.countryCode)
: null,
isTravelDay: row.isTravelDay,
fetchedAt,
};
return prisma.dawarichDay.upsert({
where: { userId_date: { userId, date: row.date } },
create: { userId, date: row.date, ...data },
update: data,
});
}),
);
await recordSyncSuccess(userId, "dawarich");
return { daysStored: rows.length, notConnected: false };
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Travel days: re-judging them when the home country changes, and offering
* runs of them as trips for the environment module.
*
* A suggestion is a run of consecutive travel days in one country that no
* stored trip (`EnvironmentTravelLocation`) overlaps. Its id is derived from
* its dates and country, so it is stable across reads and a dismissal sticks
* until the run itself changes (one more day of travel is a new suggestion).
* Accepting goes through `POST /api/environment/travel`, where the person
* picks the place; the trip then covers the run and the suggestion is gone.
*/
import { prisma } from "@/lib/db";
import { shiftDateKey } from "@/lib/tz/format";
import { openDayCountry } from "./connection";
import { travelDay } from "./day-values";
export interface TravelSuggestion {
id: string;
startDate: string;
endDate: string;
countryCode: string;
}
/** Suggestions reach back this far; an older trip is history, not a prompt. */
const SUGGESTION_WINDOW_DAYS = 365;
/** A run needs at least this many days to be worth a trip. */
const MIN_RUN_DAYS = 2;
export function suggestionId(
startDate: string,
endDate: string,
countryCode: string,
): string {
return `${startDate}_${endDate}_${countryCode}`;
}
/**
* Group travel days into runs: consecutive dates in the same country. Pure;
* input order does not matter.
*/
export function travelRuns(
days: ReadonlyArray<{ date: string; countryCode: string }>,
): TravelSuggestion[] {
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
const runs: TravelSuggestion[] = [];
let current: { start: string; end: string; code: string } | null = null;
for (const day of sorted) {
if (
current &&
day.countryCode === current.code &&
day.date === shiftDateKey(current.end, 1)
) {
current.end = day.date;
continue;
}
if (current) runs.push(toSuggestion(current));
current = { start: day.date, end: day.date, code: day.countryCode };
}
if (current) runs.push(toSuggestion(current));
return runs;
}
function toSuggestion(run: {
start: string;
end: string;
code: string;
}): TravelSuggestion {
return {
id: suggestionId(run.start, run.end, run.code),
startDate: run.start,
endDate: run.end,
countryCode: run.code,
};
}
/**
* The open suggestions: runs of at least two travel days in the last year
* that no stored trip overlaps and the person has not dismissed. Newest first.
*/
export async function listTravelSuggestions(
userId: string,
today: string,
dismissed: ReadonlySet<string>,
): Promise<TravelSuggestion[]> {
const from = shiftDateKey(today, -SUGGESTION_WINDOW_DAYS);
const [rows, trips] = await Promise.all([
prisma.dawarichDay.findMany({
where: { userId, isTravelDay: true, date: { gte: from } },
select: { date: true, countryCodeEncrypted: true },
orderBy: { date: "asc" },
}),
prisma.environmentTravelLocation.findMany({
where: { userId, endDate: { gte: from } },
select: { startDate: true, endDate: true },
}),
]);
const days: Array<{ date: string; countryCode: string }> = [];
for (const row of rows) {
const code = openDayCountry(row.countryCodeEncrypted);
if (code) days.push({ date: row.date, countryCode: code });
}
return travelRuns(days)
.filter(
(run) =>
daysIn(run) >= MIN_RUN_DAYS &&
!dismissed.has(run.id) &&
!trips.some(
(trip) =>
trip.startDate <= run.endDate && trip.endDate >= run.startDate,
),
)
.reverse();
}
function daysIn(run: TravelSuggestion): number {
let n = 1;
for (let d = run.startDate; d < run.endDate; d = shiftDateKey(d, 1)) n++;
return n;
}
/**
* Re-judge every stored day against a new home country. Days without a
* country stay null; the rest flip in two statements.
*/
export async function rejudgeTravelDays(
userId: string,
homeCountry: string | null,
): Promise<void> {
const rows = await prisma.dawarichDay.findMany({
where: { userId, countryCodeEncrypted: { not: null } },
select: { id: true, countryCodeEncrypted: true },
});
const travel: string[] = [];
const home: string[] = [];
const unknown: string[] = [];
for (const row of rows) {
const verdict = travelDay(
openDayCountry(row.countryCodeEncrypted),
homeCountry,
);
if (verdict === true) travel.push(row.id);
else if (verdict === false) home.push(row.id);
else unknown.push(row.id);
}
await prisma.$transaction([
prisma.dawarichDay.updateMany({
where: { userId, id: { in: travel } },
data: { isTravelDay: true },
}),
prisma.dawarichDay.updateMany({
where: { userId, id: { in: home } },
data: { isTravelDay: false },
}),
prisma.dawarichDay.updateMany({
where: { userId, id: { in: unknown } },
data: { isTravelDay: null },
}),
]);
}
@@ -222,3 +222,36 @@ describe("scores", () => {
expect(JSON.stringify(projected.scores)).not.toContain("COMPUTED");
});
});
describe("the movement block (v1.43)", () => {
const withMovement: DayResponse = {
...day,
movement: {
source: "dawarich",
distance: { unit: "km", total: 48.3, driving: 12, train: 30.5 },
placesVisited: 4,
awayFromHomeMinutes: 600,
travel: { isTravelDay: true, countryCode: "ES" },
fetchedAt: "2026-03-30T01:20:00.000Z",
},
};
it("reaches a model without the country of the travel day", () => {
const model = projectDayForModel(withMovement, []);
expect(model.movement).toEqual({
distance: { unit: "km", total: 48.3, driving: 12, train: 30.5 },
placesVisited: 4,
awayFromHomeMinutes: 600,
travel: { isTravelDay: true },
});
expect(JSON.stringify(model)).not.toContain("ES");
});
it("stays out when the caller excludes the environment section", () => {
const model = projectDayForModel(withMovement, [], undefined, {
sections: new Set(["environment"]),
types: new Set(),
});
expect(model).not.toHaveProperty("movement");
});
});
+8 -1
View File
@@ -16,6 +16,7 @@
*/
import type { MeasurementType } from "@/generated/prisma/enums";
import { readDayMovement } from "@/lib/dawarich/movement";
import type { DayEvent, DayResponse, DayRunningItem } from "@/lib/day/contract";
import { loadNotableRange } from "@/lib/day/notable";
import {
@@ -101,7 +102,9 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
const recordSections = [...access.readable].filter(
(section) => RECORD_SECTION_READERS[section] !== undefined,
);
const [readings, parts, scores] = await Promise.all([
// The movement context rides the environment section: owner-only, and
// gone with the module.
const [readings, parts, scores, movement] = await Promise.all([
valuesReadable || sleepReadable
? resolveDayReadings(
recordId,
@@ -131,6 +134,9 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
floor: args.floor ?? null,
})
: Promise.resolve([]),
access.readable.has("environment")
? readDayMovement(recordId, day)
: Promise.resolve(undefined),
]);
const presentTypes = [...readings.rowsByType.keys()];
@@ -179,5 +185,6 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
scores,
sections,
...(movement === undefined ? {} : { movement }),
};
}
+29
View File
@@ -16,6 +16,8 @@
* own, separately fenced reads where a feature needs them;
* - names a switched-off module's section as `module_disabled`, so a model
* says "that module is off" instead of "nothing happened";
* - drops the country of a travel day from the movement block: a model
* reads that it was a travel day, not where the person was;
* - leaves out what the caller excludes (`exclude`): the Coach passes the
* person's Coach exclusions as sections, measurement types and scores,
* so a metric left out of every other Coach read is left out of the day
@@ -78,6 +80,21 @@ export interface ModelDay {
max: number;
band: { lo: number; hi: number; n: number } | null;
}>;
/**
* The day's movement context (Dawarich), without the travel country. Absent
* when the day has none to give or the environment section is excluded.
*/
movement?: {
distance: {
unit: "km" | "mi";
total: number | null;
driving: number | null;
train: number | null;
};
placesVisited: number | null;
awayFromHomeMinutes: number | null;
travel: { isTravelDay: boolean } | null;
} | null;
/** Sections the reader does not get, and why. Never a model-excluded one. */
unavailable: Array<{
section: DaySectionKey;
@@ -189,6 +206,18 @@ export function projectDayForModel(
band: s.band,
}))
: [],
...(day.movement !== undefined && visible("environment")
? {
movement: day.movement && {
distance: day.movement.distance,
placesVisited: day.movement.placesVisited,
awayFromHomeMinutes: day.movement.awayFromHomeMinutes,
travel: day.movement.travel && {
isTravelDay: day.movement.travel.isTravelDay,
},
},
}
: {}),
unavailable,
};
}
@@ -438,6 +438,7 @@ function makePrisma() {
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
// The Coach transcript. Deliberately NOT `?? []` at the call site, so a
// future `include` that forgets the relation fails here loudly instead of
// exporting an account whose Coach never spoke.
@@ -304,6 +304,7 @@ function sourceClient() {
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
dawarichDay: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
+4 -2
View File
@@ -531,6 +531,10 @@ export const TWO_ENDED_MODELS = [
// fortnight at home, days after a restore that reported success.
"EnvironmentContext",
"EnvironmentTravelLocation",
// v1.43 — the Dawarich movement days ride the same environment section
// (`src/lib/export/environment-backup.ts`): owned values a pruned instance
// can no longer hand back.
"DawarichDay",
// How a schedule got to where it is. The register said a restored
// medication keeps today's schedule and loses the record of when the dose or
// the timing moved. Measured, it costs more than a record: the era minter
@@ -638,8 +642,6 @@ export const STRUCTURALLY_UNATTRIBUTABLE: Readonly<Record<string, string>> = {
export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
// v1.43 — the new tables are declared here with the schema and move to
// TWO_ENDED_MODELS when their section writer and restore branch land.
DawarichDay:
"The daily movement values fetched from Dawarich: distances, places visited, time away from home, travel days. Days the Dawarich instance has since pruned cannot be fetched again, so they are lost for good.",
WorkoutRoute:
"GPS traces. Deliberately absent from the payload today and DISCLOSED as absent in the file's own manifest, which is why this is a documented exclusion rather than a silent one — but it is still a loss for a self-hoster with no other copy.",
WorkoutSamples:
+1
View File
@@ -195,6 +195,7 @@ const SUMMARY_COUNTS = [
"userAchievements",
"environmentContexts",
"environmentTravelLocations",
"dawarichDays",
"ecgRecordings",
] as const satisfies readonly (keyof BackupSummary & keyof FullBackupCounts)[];
+131 -3
View File
@@ -72,12 +72,24 @@
* alike. Every one is optional on the way in: a file written before them
* says nothing about air quality, and the restored day then reads as never
* fetched (`aqFetchedAt` absent), so the nightly gap fill asks for it.
*
* ## The Dawarich days (v1.43)
*
* The movement context belongs to the same module and rides the same section
* (`dawarichDays`): distances, places visited, minutes away from home, the
* travel-day verdict, and the day's country. The country is the one sealed
* column and follows the two purposes like a location: readable
* `countryCode` in a portable file, `countryCodeEncrypted` (base64) in a
* disaster-recovery file. A file written before v1.43 carries no key and
* restores none. The connection itself (URL, key, preferences) is a
* credential and never travels.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import type { AirQualityDay } from "@/lib/environment/air-quality-contract";
import { openDayCountry, sealDayCountry } from "@/lib/dawarich/connection";
import {
openLocation,
readLocation,
@@ -178,14 +190,36 @@ export interface EnvironmentTravelLocationBackupEntry {
updatedAt: string;
}
/** One local day of Dawarich movement values. */
export interface DawarichDayBackupEntry {
/** `YYYY-MM-DD` in the account's zone. A string end to end. */
date: string;
totalDistanceM: number | null;
drivingDistanceM: number | null;
trainDistanceM: number | null;
placesVisited: number | null;
awayFromHomeMinutes: number | null;
/** Readable ISO 3166-1 alpha-2; portable files only. */
countryCode: string | null;
/** The sealed country, base64; disaster-recovery files only. */
countryCodeEncrypted?: string | null;
isTravelDay: boolean | null;
/** When the day was last fetched from Dawarich. Verbatim. */
fetchedAt: string;
createdAt: string;
updatedAt: string;
}
export interface EnvironmentBackupSection {
environmentContexts: EnvironmentContextBackupEntry[];
environmentTravelLocations: EnvironmentTravelLocationBackupEntry[];
dawarichDays: DawarichDayBackupEntry[];
}
export interface EnvironmentBackupCounts {
environmentContexts: number;
environmentTravelLocations: number;
dawarichDays: number;
}
/**
@@ -246,6 +280,20 @@ const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = {
updatedAt: true,
} as const satisfies Prisma.EnvironmentTravelLocationSelect;
const DAWARICH_DAY_BACKUP_SELECT = {
date: true,
totalDistanceM: true,
drivingDistanceM: true,
trainDistanceM: true,
placesVisited: true,
awayFromHomeMinutes: true,
countryCodeEncrypted: true,
isTravelDay: true,
fetchedAt: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.DawarichDaySelect;
/** A sealed column as it rides a disaster-recovery file. */
function toBase64(bytes: Uint8Array | null): string | null {
return bytes && bytes.byteLength > 0
@@ -287,13 +335,13 @@ function locationFields(
export async function buildEnvironmentBackupSection(
prisma: Pick<
PrismaClient,
"environmentContext" | "environmentTravelLocation"
"environmentContext" | "environmentTravelLocation" | "dawarichDay"
>,
userId: string,
options: EnvironmentBackupOptions = {},
): Promise<EnvironmentBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [contextRows, travelRows] = await Promise.all([
const [contextRows, travelRows, dawarichRows] = await Promise.all([
prisma.environmentContext.findMany({
where: { userId },
orderBy: { date: "asc" },
@@ -304,6 +352,11 @@ export async function buildEnvironmentBackupSection(
orderBy: { startDate: "asc" },
select: ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT,
}),
prisma.dawarichDay.findMany({
where: { userId },
orderBy: { date: "asc" },
select: DAWARICH_DAY_BACKUP_SELECT,
}),
]);
return {
@@ -363,6 +416,24 @@ export async function buildEnvironmentBackupSection(
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
dawarichDays: dawarichRows.map((row) => ({
date: row.date,
totalDistanceM: row.totalDistanceM,
drivingDistanceM: row.drivingDistanceM,
trainDistanceM: row.trainDistanceM,
placesVisited: row.placesVisited,
awayFromHomeMinutes: row.awayFromHomeMinutes,
...(disasterRecovery
? {
countryCode: null,
countryCodeEncrypted: toBase64(row.countryCodeEncrypted),
}
: { countryCode: openDayCountry(row.countryCodeEncrypted) }),
isTravelDay: row.isTravelDay,
fetchedAt: row.fetchedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
};
}
@@ -373,6 +444,7 @@ export function countEnvironmentBackupSection(
return {
environmentContexts: section.environmentContexts.length,
environmentTravelLocations: section.environmentTravelLocations.length,
dawarichDays: section.dawarichDays.length,
};
}
@@ -380,6 +452,7 @@ export function countEnvironmentBackupSection(
export interface EnvironmentRestoreCleared {
environmentContexts: number;
environmentTravelLocations: number;
dawarichDays: number;
}
/**
@@ -474,9 +547,40 @@ function restoredLocation(
return sealLocation({ lat: entry.lat, lon: entry.lon, label: entry.label });
}
export type RestoredDawarichDay = Pick<DawarichDayBackupEntry, "date"> &
OptionalNullable<Omit<DawarichDayBackupEntry, "date">>;
/**
* The sealed country a restored day is written with: the file's sealed value
* as it came (named in `unopened` when this host cannot open it), or the
* readable code sealed under this host's key; null with neither.
*/
function restoredCountry(
entry: RestoredDawarichDay,
unopened: string[],
): Uint8Array<ArrayBuffer> | null {
if (typeof entry.countryCodeEncrypted === "string") {
const buffer = Buffer.from(entry.countryCodeEncrypted, "base64");
if (buffer.byteLength > 0) {
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
bytes.set(buffer);
if (openDayCountry(bytes) === null) {
unopened.push(`dawarichDays.${entry.date}`);
}
return bytes;
}
}
return typeof entry.countryCode === "string" &&
/^[A-Z]{2}$/.test(entry.countryCode)
? sealDayCountry(entry.countryCode)
: null;
}
export interface EnvironmentRestoreInput {
environmentContexts: RestoredEnvironmentContext[];
environmentTravelLocations: RestoredEnvironmentTravelLocation[];
/** Absent in a file written before v1.43; nothing to restore then. */
dawarichDays?: RestoredDawarichDay[];
}
/**
@@ -502,9 +606,10 @@ export async function restoreEnvironmentData(
): Promise<EnvironmentRestoreCleared> {
// Sealed locations this host's keys do not open, by file path.
const unopened: string[] = [];
const [clearedTravel, clearedContexts] = await Promise.all([
const [clearedTravel, clearedContexts, clearedDawarich] = await Promise.all([
tx.environmentTravelLocation.deleteMany({ where: { userId: ownerId } }),
tx.environmentContext.deleteMany({ where: { userId: ownerId } }),
tx.dawarichDay.deleteMany({ where: { userId: ownerId } }),
]);
if (payload.environmentTravelLocations.length > 0) {
@@ -570,6 +675,28 @@ export async function restoreEnvironmentData(
});
}
const dawarichDays = payload.dawarichDays ?? [];
if (dawarichDays.length > 0) {
await tx.dawarichDay.createMany({
data: dawarichDays.map((entry) => ({
userId: ownerId,
date: entry.date,
totalDistanceM: entry.totalDistanceM ?? null,
drivingDistanceM: entry.drivingDistanceM ?? null,
trainDistanceM: entry.trainDistanceM ?? null,
placesVisited: entry.placesVisited ?? null,
awayFromHomeMinutes: entry.awayFromHomeMinutes ?? null,
countryCodeEncrypted: restoredCountry(entry, unopened),
isTravelDay: entry.isTravelDay ?? null,
// Verbatim, like the weather rows: when Dawarich was read, not when
// the row came back. A file without it reads as fetched now.
fetchedAt: entry.fetchedAt ? new Date(entry.fetchedAt) : new Date(),
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
})),
});
}
if (skips) {
recordUnknownKeys(
skips,
@@ -582,5 +709,6 @@ export async function restoreEnvironmentData(
return {
environmentContexts: clearedContexts.count,
environmentTravelLocations: clearedTravel.count,
dawarichDays: clearedDawarich.count,
};
}
+2
View File
@@ -198,6 +198,7 @@ export interface RestoreResponse {
userAchievements: number;
environmentContexts: number;
environmentTravelLocations: number;
dawarichDays: number;
ecgRecordings: number;
symptomDefinitions: number;
lifeEvents: number;
@@ -2833,6 +2834,7 @@ export async function restoreBackup(
environmentContexts: environmentCleared.environmentContexts,
environmentTravelLocations:
environmentCleared.environmentTravelLocations,
dawarichDays: environmentCleared.dawarichDays,
ecgRecordings: ecgCleared.ecgRecordings,
symptomDefinitions: symptomsCleared.symptomDefinitions,
lifeEvents: lifeEventsCleared.lifeEvents,
@@ -39,6 +39,9 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({
fetchEnvironmentSeries: vi.fn(async () => [
{ key: "ENV_TEMP_MEAN", role: "behaviour", points: pts },
]),
fetchMovementSeries: vi.fn(async () => [
{ key: "MOVEMENT_AWAY", role: "behaviour", points: pts },
]),
fetchCustomMetricBehaviourSeries: vi.fn(async () => [
{ key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts },
]),
@@ -54,7 +57,10 @@ import {
assembleDiscoveryMatrix,
maskSeriesByModules,
} from "@/lib/insights/discovery-matrix";
import { fetchEnvironmentSeries } from "@/lib/insights/correlation-channel-series";
import {
fetchEnvironmentSeries,
fetchMovementSeries,
} from "@/lib/insights/correlation-channel-series";
const OPTS = {
tz: "UTC",
@@ -110,6 +116,7 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
"SYMPTOM_SEVERITY",
"SYMPTOM:def1",
"ENV_TEMP_MEAN",
"MOVEMENT_AWAY",
]) {
expect(keys, gone).not.toContain(gone);
expect(byMetric.has(gone), gone).toBe(false);
@@ -132,6 +139,19 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
expect(read).toHaveBeenCalledTimes(1);
});
it("does not read the Dawarich days with the environment module off", async () => {
const read = vi.mocked(fetchMovementSeries);
read.mockClear();
await assembleDiscoveryMatrix("u1", {
...OPTS,
modules: { environment: false },
});
expect(read).not.toHaveBeenCalled();
const on = await assembleDiscoveryMatrix("u1", OPTS);
expect(read).toHaveBeenCalledTimes(1);
expect(on.series.map((s) => s.key)).toContain("MOVEMENT_AWAY");
});
it("drops nothing for AI analysis off", async () => {
const all = await assembleDiscoveryMatrix("u1", OPTS);
const off = await assembleDiscoveryMatrix("u1", {
@@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({
illnessEpisode: { findMany: vi.fn() },
illnessDayLog: { findMany: vi.fn() },
environmentContext: { findMany: vi.fn() },
dawarichDay: { findMany: vi.fn(async () => []) },
customMetric: { findMany: vi.fn() },
symptomDefinition: { findMany: vi.fn(async () => []) },
},
@@ -0,0 +1,47 @@
/**
* The two Dawarich movement channels: hours away from home and kilometres
* travelled, one point per stored day that has the value. A day without it is
* no coverage and gets no point, never a zero.
*/
import { describe, expect, it, vi } from "vitest";
const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: { dawarichDay: { findMany } } }));
import { fetchMovementSeries } from "@/lib/insights/correlation-channel-series";
import { metricFamily } from "@/lib/insights/correlation-discovery";
describe("fetchMovementSeries", () => {
it("emits a point only where the day has the value", async () => {
findMany.mockResolvedValue([
{ date: "2026-06-14", awayFromHomeMinutes: 90, totalDistanceM: null },
{ date: "2026-06-15", awayFromHomeMinutes: null, totalDistanceM: 48_300 },
]);
const [away, distance] = await fetchMovementSeries(
"u1",
"Europe/Berlin",
new Date("2026-06-01T12:00:00Z"),
);
expect(away).toMatchObject({
key: "MOVEMENT_AWAY",
role: "behaviour",
points: [{ day: "2026-06-14", value: 1.5 }],
});
expect(distance).toMatchObject({
key: "MOVEMENT_DISTANCE",
role: "behaviour",
points: [{ day: "2026-06-15", value: 48.3 }],
});
expect(findMany.mock.calls[0][0].where).toEqual({
userId: "u1",
date: { gte: "2026-06-01" },
});
});
it("keeps the two channels in one family so they never pair with each other", () => {
expect(metricFamily("MOVEMENT_AWAY")).toBe(
metricFamily("MOVEMENT_DISTANCE"),
);
expect(metricFamily("MOVEMENT_AWAY")).not.toBe(metricFamily("MOOD"));
});
});
+46 -1
View File
@@ -30,6 +30,8 @@ import {
import {
FACTOR_CHANNEL_PREFIX,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
MOVEMENT_AWAY_CHANNEL_KEY,
MOVEMENT_DISTANCE_CHANNEL_KEY,
SYMPTOM_SEVERITY_CHANNEL_KEY,
type DailySeriesPoint,
type LabDrawPoint,
@@ -58,7 +60,7 @@ import {
pickMainNightAndNaps,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { isNearUtc, shiftDateKey } from "@/lib/tz/format";
import { isNearUtc, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type {
@@ -332,6 +334,49 @@ export function averageWithPreviousDay(
return out;
}
/**
* v1.43 — the movement BEHAVIOUR channels from the stored Dawarich days:
* hours away from home and kilometres travelled. A day without a value (no
* home place, no visit, no summary) has no point; it is no coverage, not
* zero. Empty for an account that never connected Dawarich.
*/
export async function fetchMovementSeries(
userId: string,
tz: string,
since: Date,
): Promise<NamedSeries[]> {
const rows = await prisma.dawarichDay.findMany({
where: { userId, date: { gte: userDayKey(since, tz) } },
orderBy: { date: "asc" },
take: 1000,
select: { date: true, awayFromHomeMinutes: true, totalDistanceM: true },
});
const away: DailySeriesPoint[] = [];
const distance: DailySeriesPoint[] = [];
for (const row of rows) {
if (row.awayFromHomeMinutes !== null) {
away.push({ day: row.date, value: row.awayFromHomeMinutes / 60 });
}
if (row.totalDistanceM !== null) {
distance.push({ day: row.date, value: row.totalDistanceM / 1000 });
}
}
return [
{
key: MOVEMENT_AWAY_CHANNEL_KEY,
role: "behaviour",
label: "time away from home",
points: away,
},
{
key: MOVEMENT_DISTANCE_CHANNEL_KEY,
role: "behaviour",
label: "distance travelled",
points: distance,
},
];
}
/** Explicit deterministic ceiling on opt-in custom behaviour channels. */
export const MAX_CUSTOM_CORRELATION_CHANNELS = 8;
export const CUSTOM_METRIC_CHANNEL_PREFIX = "CUSTOM_METRIC:";
+11
View File
@@ -135,6 +135,7 @@ export function metricFamily(key: string): string {
// near-tautology). They stay free to pair cross-domain against mood / sleep /
// vitals — the whole point of the module.
if (key.startsWith("ENV_")) return "ENVIRONMENT";
if (key.startsWith("MOVEMENT_")) return "MOVEMENT";
if (key.startsWith("CUSTOM_METRIC:")) return key;
// v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form
// their OWN single-channel family, so the loop's same-family guard collapses
@@ -453,6 +454,16 @@ export const MEDICATION_COMPLIANCE_CHANNEL_KEY = "MEDICATION_COMPLIANCE";
*/
export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY";
/**
* v1.43 — the Dawarich movement context as two BEHAVIOUR channels: minutes
* away from home and the day's total distance. Both share the `MOVEMENT`
* family (a day far from home is a day with distance; pairing the two with
* each other would be a tautology) and pair at the default next-day lag:
* "a day out, then the night's sleep". Owned by the environment module.
*/
export const MOVEMENT_AWAY_CHANNEL_KEY = "MOVEMENT_AWAY";
export const MOVEMENT_DISTANCE_CHANNEL_KEY = "MOVEMENT_DISTANCE";
/**
* v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum
* values: each is backed by a different model (MOOD → MoodEntry,
@@ -53,6 +53,7 @@ vi.mock("@/lib/db", () => ({
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
dawarichDay: { findMany: async () => [] },
},
}));
+15 -1
View File
@@ -40,6 +40,7 @@ import {
DISCOVERY_OUTCOMES,
discoveryMeasurementTypes,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
MOVEMENT_AWAY_CHANNEL_KEY,
SYMPTOM_SEVERITY_CHANNEL_KEY,
type DailySeriesPoint,
type NamedSeries,
@@ -50,6 +51,7 @@ import {
fetchCustomMetricBehaviourSeries,
fetchSymptomEventSeries,
fetchEnvironmentSeries,
fetchMovementSeries,
fetchMeasurementDailySeriesTiered,
fetchMeasurementWindowSeries,
fetchMoodFactorWindowSeries,
@@ -152,6 +154,8 @@ export interface DiscoveryMatrixDiagnostics {
symptomDays: number;
/** Stored daily points summed across the environmental-exposure channels. */
environmentDays: number;
/** Stored daily points summed across the two movement channels. */
movementDays: number;
customMetricChannels: number;
customMetricDays: number;
/** `FACTOR:*` channels admitted (0 unless `includeMoodFactors`). */
@@ -299,6 +303,7 @@ export async function assembleDiscoveryMatrix(
environmentSeries,
customMetricSeries,
symptomEventSeries,
movementSeries,
] = await Promise.all([
readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode),
readMood(userId, tz, since, opts.includeMoodFactors === true),
@@ -316,6 +321,14 @@ export async function assembleDiscoveryMatrix(
: Promise.resolve([] as NamedSeries[]),
fetchCustomMetricBehaviourSeries(userId, tz, since),
fetchSymptomEventSeries(userId, tz, since),
// v1.43 — the Dawarich movement channels, skipped like the weather when
// the environment module is off.
isSurfaceVisible(
correlationChannelSurfaceId(MOVEMENT_AWAY_CHANNEL_KEY),
opts.modules,
)
? fetchMovementSeries(userId, tz, since)
: Promise.resolve([] as NamedSeries[]),
]);
const { factorSeries } = mood;
@@ -347,7 +360,7 @@ export async function assembleDiscoveryMatrix(
// exposure already averaged across D−1 and D (see `fetchEnvironmentSeries`).
// Both run under the same n ≥ 20 / FDR / effect-size gates, so a thin
// weather or custom series degrades to absent.
series.push(...environmentSeries, ...customMetricSeries);
series.push(...environmentSeries, ...movementSeries, ...customMetricSeries);
// The person's own symptoms are OUTCOMES: "short sleep, then an aura the
// next day" is the question they log them to answer. Each is its own
// channel and its own family, capped at eight; the illness module owns them
@@ -387,6 +400,7 @@ export async function assembleDiscoveryMatrix(
(sum, s) => sum + s.points.length,
0,
),
movementDays: movementSeries.reduce((sum, s) => sum + s.points.length, 0),
customMetricChannels: customMetricSeries.length,
customMetricDays: customMetricSeries.reduce(
(sum, s) => sum + s.points.length,
@@ -50,6 +50,7 @@ vi.mock("@/lib/db", () => ({
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
dawarichDay: { findMany: async () => [] },
},
}));
@@ -200,9 +200,11 @@ describe("resolveSyncVerdict — the ladder", () => {
).toEqual({ verdict: "fresh", since: null });
});
it("puts every ledger-backed integration on the polled cadence", () => {
for (const cadence of Object.values(INTEGRATION_CADENCE)) {
expect(cadence.attemptStaleAfterMs).toBe(24 * HOUR);
it("puts every hourly ledger-backed integration on the polled cadence", () => {
for (const [key, cadence] of Object.entries(INTEGRATION_CADENCE)) {
// Dawarich runs once a night; a run is overdue after two days.
const expected = key === "dawarich" ? 48 * HOUR : 24 * HOUR;
expect(cadence.attemptStaleAfterMs, key).toBe(expected);
}
});
});
@@ -44,6 +44,8 @@ import { withingsPlanUnavailableFeatures } from "@/lib/withings/plan-features";
import { hasActivityScope } from "@/lib/withings/client";
import { readOuraScopeDenied } from "@/lib/oura/credentials";
import { ouraScopeReconnectList } from "@/lib/oura/scope";
import { localDayWindow } from "@/lib/tz/local-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export type ConnectionHealthMap = Record<
ConnectionKey,
@@ -101,6 +103,20 @@ async function newestWorkoutAt(
return row?.startedAt ?? null;
}
/**
* Dawarich stores days, not readings: its newest data is the start of the
* newest stored day, in the account's zone.
*/
async function newestDawarichDayAt(userId: string): Promise<Date | null> {
const row = await prisma.dawarichDay.findFirst({
where: { userId },
orderBy: { date: "desc" },
select: { date: true },
});
if (!row) return null;
return localDayWindow(row.date, await resolveUserTimezone(userId)).dayStart;
}
function newestOf(...values: Array<Date | null | undefined>): Date | null {
let max: Date | null = null;
for (const v of values) {
@@ -166,6 +182,7 @@ export async function loadConnectionFacts(
ouraScopeDenied: true,
stravaAccessTokenEncrypted: true,
nightscoutUrlEncrypted: true,
dawarichUrlEncrypted: true,
healthKitLastSyncedAt: true,
},
}),
@@ -223,6 +240,7 @@ export async function loadConnectionFacts(
oura: !!dbUser?.ouraAccessTokenEncrypted,
strava: !!dbUser?.stravaAccessTokenEncrypted,
nightscout: !!dbUser?.nightscoutUrlEncrypted,
dawarich: !!dbUser?.dawarichUrlEncrypted,
// Apple Health has nothing to connect; it is "connected" once the phone
// has delivered anything (a live batch, or rows from the export import).
"apple-health": false,
@@ -245,6 +263,10 @@ export async function loadConnectionFacts(
return;
}
if (!connected[key]) return;
if (key === "dawarich") {
lastData.set(key, await newestDawarichDayAt(userId));
return;
}
const mSource = INTEGRATION_MEASUREMENT_SOURCE[key as IntegrationKey];
const wSource = INTEGRATION_WORKOUT_SOURCE[key as IntegrationKey];
const [m, w] = await Promise.all([
@@ -43,6 +43,7 @@ export const CONNECTION_KEYS: readonly ConnectionKey[] = [
"oura",
"strava",
"nightscout",
"dawarich",
"apple-health",
];
+4 -1
View File
@@ -60,7 +60,9 @@ export type IntegrationKey =
// `fitbit` transport, which sunsets Sept 2026.
| "google-health"
// v1.28.x — Strava OAuth workout source.
| "strava";
| "strava"
// v1.43 — Dawarich movement context (daily values, nightly).
| "dawarich";
/**
* Failure kinds carried into `recordSyncFailure`.
@@ -1280,6 +1282,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
oura: "Oura",
"google-health": "Google Health",
strava: "Strava",
dawarich: "Dawarich",
};
const FAILURE_KIND_COPY: Record<
+14 -3
View File
@@ -102,9 +102,19 @@ export const PUSH_CADENCE: SyncCadence = {
};
/**
* Every `IntegrationKey` is polled by an hourly cron registered in
* `src/lib/jobs/reminder/register-integration-sync.ts`. Apple Health is not an
* `IntegrationKey` — it takes `PUSH_CADENCE` directly.
* Dawarich runs once a night (`dawarich-sync`, 03:20). A run is overdue after
* two days, and a successful run is the freshness: a phone that recorded
* nothing is not a failing connection.
*/
const NIGHTLY_CADENCE: SyncCadence = {
attemptStaleAfterMs: 2 * ATTEMPT_STALE_AFTER_MS,
dataStaleAfterMs: DATA_STALE_AFTER_MS,
};
/**
* Every `IntegrationKey` but Dawarich is polled by an hourly cron registered
* in `src/lib/jobs/reminder/register-integration-sync.ts`; Dawarich nightly.
* Apple Health is not an `IntegrationKey` — it takes `PUSH_CADENCE` directly.
*/
export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
withings: POLLED_CADENCE,
@@ -115,6 +125,7 @@ export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
oura: POLLED_CADENCE,
"google-health": POLLED_CADENCE,
strava: POLLED_CADENCE,
dawarich: NIGHTLY_CADENCE,
};
export interface SyncVerdictInput {
@@ -69,6 +69,10 @@ export const INCIDENT_AFTER_MS = DAY_MS;
* low on storage, in low-power mode or simply not unlocked, and a day of
* that is ordinary too.
*
* Dawarich is on-demand as well: it records where a phone was, and a phone
* left at home or switched off for days is ordinary. It is watched for
* errors only, never for silence.
*
* The history gate already limits the check to sources that deliver almost
* daily; the window only decides how long a gap is ordinary.
*/
@@ -81,6 +85,7 @@ export const SOURCE_PROFILES: Record<ConnectionKey, SourceProfile> = {
"google-health": { kind: "continuous", silenceWindowMs: DAY_MS },
polar: { kind: "continuous", silenceWindowMs: DAY_MS },
nightscout: { kind: "continuous", silenceWindowMs: DAY_MS },
dawarich: { kind: "on_demand", silenceWindowMs: DAY_MS },
"apple-health": { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
};
+2
View File
@@ -71,6 +71,7 @@ const LABELS: Record<ConnectionKey, string> = {
oura: "Oura",
strava: "Strava",
nightscout: "Nightscout",
dawarich: "Dawarich",
"apple-health": "Apple Health",
};
@@ -127,6 +128,7 @@ async function listWatchedUserIds(): Promise<string[]> {
{ ouraAccessTokenEncrypted: { not: null } },
{ stravaAccessTokenEncrypted: { not: null } },
{ nightscoutUrlEncrypted: { not: null } },
{ dawarichUrlEncrypted: { not: null } },
{ healthKitLastSyncedAt: { not: null } },
],
},
+159
View File
@@ -0,0 +1,159 @@
/**
* v1.43 — the Dawarich movement context, fetched once a night.
*
* The daily tick (empty payload) fans out one job per account with a stored
* connection and the `environment` module on. A per-account job fetches the
* last seven days ending yesterday, so a phone that uploaded late fills in the
* days it missed; the connect route and the settings card send an explicit
* `fromDate` for the 30-day backfill and for a longer catch-up (up to 365
* days). Without a connection, or with the module off, nothing leaves the host.
*
* Every run is its own background event (`job.dawarich_sync`), so its lines
* and its failure carry the action they belong to.
*/
import type { Job, PgBoss } from "pg-boss";
import { loadDawarichConnection } from "@/lib/dawarich/connection";
import { syncDawarichRange, yesterdayKey } from "@/lib/dawarich/sync";
import { prisma } from "@/lib/db";
import { isReauthRequired } from "@/lib/integrations/status";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { withBackgroundEvent } from "@/lib/logging/background";
import { annotate } from "@/lib/logging/context";
import {
getOperatorModuleAvailability,
isModuleEnabled,
normalisePrefs,
resolveModuleEnabled,
} from "@/lib/modules/gate";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { DAWARICH_SYNC_MAX_DAYS } from "@/lib/validations/dawarich";
export const DAWARICH_SYNC_QUEUE = "dawarich-sync";
/** Daily 03:20 Europe/Berlin, after the night's environment fetch. */
export const DAWARICH_SYNC_CRON = "20 3 * * *";
/** The nightly window: yesterday and the six days before it. */
const NIGHTLY_WINDOW_DAYS = 7;
/** The backfill a fresh connection starts with. */
export const DAWARICH_CONNECT_BACKFILL_DAYS = 30;
/** One account has at most one requested run per slot. */
const REQUEST_SINGLETON_SECONDS = 5 * 60;
export interface DawarichSyncPayload {
userId?: string;
/** First day to fetch (`YYYY-MM-DD`); the nightly window when absent. */
fromDate?: string;
}
/**
* Queue one account's run from a request (connect, the settings card). One run
* per account per five minutes; a send inside the slot is refused and says so.
*/
export async function enqueueDawarichSync(payload: {
userId: string;
fromDate?: string;
}): Promise<"enqueued" | "no_worker" | "already_queued"> {
const boss = getGlobalBoss();
if (!boss) return "no_worker";
const id = await boss.send(DAWARICH_SYNC_QUEUE, payload, {
singletonKey: `dawarich-sync:${payload.userId}`,
singletonSeconds: REQUEST_SINGLETON_SECONDS,
});
return id ? "enqueued" : "already_queued";
}
async function discover(boss: PgBoss): Promise<JobOutcome> {
const [candidates, operatorAvailability] = await Promise.all([
prisma.user.findMany({
where: { dawarichUrlEncrypted: { not: null } },
select: { id: true, modulePreferencesJson: true },
}),
getOperatorModuleAvailability(),
]);
let enqueued = 0;
let skipped = 0;
for (const { id, modulePreferencesJson } of candidates) {
const enabled = resolveModuleEnabled(
"environment",
{
gender: null,
disableCoach: false,
modulePreferences: normalisePrefs(modulePreferencesJson),
cycleTrackingEnabled: null,
},
false,
operatorAvailability,
);
// A connection waiting for a new key or paused after a day of failures
// is not retried every night; reconnecting starts it again.
if (!enabled || (await isReauthRequired(id, "dawarich"))) {
skipped += 1;
continue;
}
await boss.send(
DAWARICH_SYNC_QUEUE,
{ userId: id } satisfies DawarichSyncPayload,
{ singletonKey: `dawarich-sync:${id}` },
);
enqueued += 1;
}
annotate({
action: { name: "dawarich.sync.discover" },
meta: { users_enqueued: enqueued, users_skipped: skipped },
});
return jobDone({ users_enqueued: enqueued, users_skipped: skipped });
}
async function runForUser(
userId: string,
fromDate: string | undefined,
): Promise<JobOutcome> {
annotate({ action: { name: "dawarich.sync.run" } });
if (!(await isModuleEnabled(userId, "environment"))) {
return jobDone({ skipped: "environment_module_disabled" });
}
if (!(await loadDawarichConnection(userId))) {
return jobDone({ skipped: "not_connected" });
}
const tz = await resolveUserTimezone(userId);
const toDate = yesterdayKey(tz);
const from = fromDate ?? shiftDateKey(toDate, -(NIGHTLY_WINDOW_DAYS - 1));
// Clamp so a crafted payload cannot fan out an unbounded range.
const span = daysBetweenDateKeys(from, toDate) + 1;
const start =
Number.isFinite(span) && span <= DAWARICH_SYNC_MAX_DAYS
? from
: shiftDateKey(toDate, -(DAWARICH_SYNC_MAX_DAYS - 1));
try {
const result = await syncDawarichRange(userId, start, toDate);
annotate({ meta: { dawarich_days_stored: result.daysStored } });
return jobDone({ days_stored: result.daysStored });
} catch (error) {
// Already on the ledger, classified; the job fails so the retry policy
// applies to a transient outage.
return jobFailed("dawarich fetch failed", error);
}
}
/** The queue handler: discovery on an empty payload, one account otherwise. */
export async function handleDawarichSync(
boss: PgBoss,
jobs: Job<DawarichSyncPayload>[],
): Promise<JobOutcome> {
let failure: JobOutcome | null = null;
for (const job of jobs) {
const outcome = await withBackgroundEvent("job.dawarich_sync", () =>
job.data?.userId
? runForUser(job.data.userId, job.data.fromDate)
: discover(boss),
);
if (!outcome.ok && failure === null) failure = outcome;
}
return failure ?? jobDone({ jobs: jobs.length });
}
+3
View File
@@ -496,6 +496,9 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
"environment-fetch": short(
"A fan-out of sends, or one account's bounded archive range.",
),
"dawarich-sync": short(
"A fan-out of sends, or one account's range: one timeline request per 30 days (at most 13), one visits request and at most two residency requests.",
),
"environment-aq-history": short(
"A fan-out of sends, or one account's history run: at most eight ranges of up to 90 days, two requests each, then a follow-up.",
),
@@ -207,6 +207,12 @@ import {
handleEnvironmentFetch,
type EnvironmentFetchPayload,
} from "@/lib/jobs/environment-fetch";
import {
DAWARICH_SYNC_CRON,
DAWARICH_SYNC_QUEUE,
handleDawarichSync,
type DawarichSyncPayload,
} from "@/lib/jobs/dawarich-sync";
import {
ENVIRONMENT_AQ_HISTORY_CRON,
ENVIRONMENT_AQ_HISTORY_QUEUE,
@@ -440,6 +446,9 @@ const allQueues = [
// v1.42 — the air-quality history backfill: per account, from boot and
// nightly discovery, the home being set and air quality switched on.
ENVIRONMENT_AQ_HISTORY_QUEUE,
// v1.43 — the Dawarich movement context: nightly discovery, per-account
// runs, and the connect / settings backfills.
DAWARICH_SYNC_QUEUE,
DATA_BACKUP_QUEUE,
RATE_LIMIT_CLEANUP_QUEUE,
IDEMPOTENCY_CLEANUP_QUEUE,
@@ -630,6 +639,9 @@ const schedules: ScheduleEntry[] = [
// v1.42 — daily 02:40 discovery for the air-quality history backfill, after
// the night's environment fetch has taken its share of the budget.
[ENVIRONMENT_AQ_HISTORY_QUEUE, ENVIRONMENT_AQ_HISTORY_CRON],
// v1.43 — daily 03:20 discovery for the Dawarich movement context. A
// failed run is an outage at the instance; the default retries apply.
[DAWARICH_SYNC_QUEUE, DAWARICH_SYNC_CRON],
// The send options are the point of this tuple: without them the weekly
// snapshot inherits pg-boss's 15-minute expiration and is killed mid-run on
// any sizeable record. See `data-backup-policy.ts` for why the window rides
@@ -1870,6 +1882,15 @@ export async function registerMaintenanceQueues(
},
);
// v1.43 — Dawarich movement context. Serial: one account at a time, so a
// slow instance never crowds the request pool.
await createAndWork<DawarichSyncPayload>(
boss,
DAWARICH_SYNC_QUEUE,
{ localConcurrency: 1 },
(jobs) => handleDawarichSync(boss, jobs),
);
return allQueues;
}
+1
View File
@@ -98,6 +98,7 @@ const subsystemSurface = {
// register-maintenance.ts
"environment-fetch": { audience: "account" },
"environment-aq-history": { audience: "system" },
"dawarich-sync": { audience: "account" },
"data-backup": { audience: "system" },
"rate-limit-cleanup": { audience: "system" },
"idempotency-cleanup": { audience: "system" },
+15
View File
@@ -1057,6 +1057,21 @@ const getDayOutput: z.ZodRawShape = {
.nullable(),
}),
),
// v1.43 — the movement context (Dawarich), without a travel country.
movement: z
.object({
distance: z.object({
unit: z.enum(["km", "mi"]),
total: z.number().nullable(),
driving: z.number().nullable(),
train: z.number().nullable(),
}),
placesVisited: z.number().nullable(),
awayFromHomeMinutes: z.number().nullable(),
travel: z.object({ isTravelDay: z.boolean() }).nullable(),
})
.nullable()
.optional(),
unavailable: z.array(
z.object({
section: modelDaySection,
@@ -56,6 +56,8 @@ import { SUMMARY_TYPE_MODULE } from "@/lib/dashboard/widget-modules";
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
import {
DISCOVERY_BEHAVIOURS,
MOVEMENT_AWAY_CHANNEL_KEY,
MOVEMENT_DISTANCE_CHANNEL_KEY,
DISCOVERY_OUTCOMES,
FACTOR_CHANNEL_PREFIX,
} from "@/lib/insights/correlation-discovery";
@@ -179,11 +181,13 @@ describe("every surface id names a surface that exists", () => {
}
});
it("correlation: a discovery channel, a weather field or the lab pass", () => {
it("correlation: a discovery channel, a weather field, a movement channel or the lab pass", () => {
const channels = new Set<string>([
...DISCOVERY_BEHAVIOURS,
...DISCOVERY_OUTCOMES,
...ENVIRONMENT_FIELDS.map((f) => f.key),
MOVEMENT_AWAY_CHANNEL_KEY,
MOVEMENT_DISTANCE_CHANNEL_KEY,
"LAB_DRAWS",
]);
for (const id of localIds("correlation")) {
+3
View File
@@ -217,6 +217,9 @@ const STATIC_SURFACE_MODULE = {
"correlation:SYMPTOM_SEVERITY": "illness",
// The labs ↔ outcome pass over lab draws.
"correlation:LAB_DRAWS": "labs",
// v1.43 — the Dawarich movement channels ride the environment module.
"correlation:MOVEMENT_AWAY": "environment",
"correlation:MOVEMENT_DISTANCE": "environment",
// ── Day view sections (v1.42) ──
// The readings carry no owner here: each value follows its own type's
+9 -1
View File
@@ -127,7 +127,14 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
"disable-coach": ["disable-coach.body.invalid_json"],
dawarich: ["dawarich.invalid", "dawarich.rate_limited"],
dawarich: [
"dawarich.credentials_rejected",
"dawarich.home_place_unknown",
"dawarich.invalid",
"dawarich.not_connected",
"dawarich.rate_limited",
"dawarich.unreachable",
],
day: ["day.invalid"],
demo: ["demo.readOnly"],
documents: [
@@ -193,6 +200,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"environment.no_home",
"environment.range_too_large",
"environment.travel.not_found",
"environment.travel_suggestion.not_found",
],
export: ["export.selection.empty", "export.selection.unknown_leaf"],
familyHistory: ["familyHistory.invalid"],
+44 -33
View File
@@ -22,7 +22,6 @@ import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
notImplementedResponse,
stdResponses,
} from "./shared";
@@ -76,7 +75,6 @@ const responses = (
...moduleDisabledResponse,
...extra,
...stdResponses,
...notImplementedResponse,
});
const errorOnly = (description: string) => ({
@@ -165,27 +163,33 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Integrations"],
summary: "Dawarich home place and country (v1.43)",
description:
"422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have.",
"422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have; 409 `dawarich.not_connected`; 502 `dawarich.unreachable` when the place cannot be checked. A new home country re-judges the stored travel days at once; a new home place fetches the stored days again in the background (up to a year).",
requestBody: {
required: true,
content: { "application/json": { schema: dawarichSettings } },
},
responses: responses({
"200": {
description: "The settings after the change.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
homePlaceId: z.number().int().nullable(),
homeCountry: z.string().nullable(),
}),
"DawarichSettingsEnvelope",
),
responses: responses(
{
"200": {
description: "The settings after the change.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
homePlaceId: z.number().int().nullable(),
homeCountry: z.string().nullable(),
}),
"DawarichSettingsEnvelope",
),
},
},
},
},
}),
{
"409": errorOnly("`dawarich.not_connected`."),
"502": errorOnly("`dawarich.unreachable`."),
},
),
},
},
"/api/dawarich/test": {
@@ -193,24 +197,29 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Integrations"],
summary: "Probe the Dawarich connection (v1.43)",
description:
"Answers the shared integration probe shape; a failed probe is an error status. The probe classes are those of the other `/test` routes.",
responses: responses({
"200": {
description: "The probe reached the instance.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
ok: z.literal(true),
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
latencyMs: z.number().int(),
}),
"DawarichProbeEnvelope",
),
"Answers the shared integration probe shape. A failed probe is a 502 whose `errorCode` is one of the shared probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `url_not_public`, `connection_failed`); 422 `not_configured` without a connection; 429 `rate_limited_self` past five probes a minute.",
responses: responses(
{
"200": {
description: "The probe reached the instance.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
ok: z.literal(true),
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
latencyMs: z.number().int(),
}),
"DawarichProbeEnvelope",
),
},
},
},
},
}),
{
"502": errorOnly("A probe class; see the description."),
},
),
},
},
"/api/dawarich/sync": {
@@ -218,7 +227,7 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Integrations"],
summary: "Fetch Dawarich daily values again (v1.43)",
description:
"From `fromDate` (at most 365 days back). 409 `dawarich.not_connected`; 422 `dawarich.invalid`.",
"From `fromDate` (inside the 365 days ending yesterday; without it the nightly window of seven days). `enqueued` is false when a run for this account is already queued or no worker is bound. 409 `dawarich.not_connected`; 422 `dawarich.invalid`.",
requestBody: {
required: false,
content: { "application/json": { schema: dawarichSyncSchema } },
@@ -246,7 +255,7 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Integrations"],
summary: "Disconnect Dawarich (v1.43)",
description:
"Forgets the connection; with `deleteData: true` the stored days go too.",
"Forgets the connection; with `deleteData: true` the stored days go too. No body is `{ deleteData: false }`. Disconnecting twice is harmless.",
requestBody: {
required: false,
content: { "application/json": { schema: dawarichDisconnectSchema } },
@@ -273,6 +282,8 @@ export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
get: {
tags: ["Environment"],
summary: "Suggested trips from Dawarich travel days (v1.43)",
description:
"Runs of at least two consecutive travel days in one country, inside the last year, that no stored trip overlaps and the person has not dismissed; newest first. Empty without a Dawarich connection.",
responses: responses({
"200": {
description: "The suggestions.",
+6 -5
View File
@@ -1046,6 +1046,7 @@ const integrationEnvelopeEntry = z
"oura",
"strava",
"nightscout",
"dawarich",
])
.describe("Which provider this entry describes."),
state: integrationLedgerState,
@@ -1121,18 +1122,18 @@ const integrationStatusEnvelope = z
metricFreshnessDegraded: z
.boolean()
.describe(
"The per-metric freshness query failed, so EVERY entry's `metricFreshness` is an empty array regardless of what the record actually holds. One flag rather than one per entry, because it is a single query for all eight providers: it fails for all of them or for none. When true, render the freshness section as unavailable rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire, which turned the honesty signal into the thing it was built to prevent. The rest of the envelope is unaffected and still complete.",
"The per-metric freshness query failed, so EVERY entry's `metricFreshness` is an empty array regardless of what the record actually holds. One flag rather than one per entry, because it is a single query for every provider: it fails for all of them or for none. When true, render the freshness section as unavailable rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire, which turned the honesty signal into the thing it was built to prevent. The rest of the envelope is unaffected and still complete.",
),
integrations: z
.array(integrationEnvelopeEntry)
.describe(
"All eight providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout. A provider the user has never touched still appears, with `connected: false` and a synthesised `connected` ledger state — absence from this list never means anything, because nothing is ever absent from it.",
"All nine providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout, dawarich. A provider the user has never touched still appears, with `connected: false` and a synthesised `connected` ledger state — absence from this list never means anything, because nothing is ever absent from it.",
),
})
.meta({
id: "IntegrationStatusEnvelope",
description:
"The one read behind the integrations settings surface. It carries every field the per-provider status routes carry, so a client has the choice of eight round-trips or this one; the per-provider routes remain for callers that want a single provider.",
"The one read behind the integrations settings surface. It carries every field the per-provider status routes carry, so a client has the choice of nine round-trips or this one; the per-provider routes remain for callers that want a single provider.",
});
export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
@@ -2551,10 +2552,10 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Integrations"],
summary: "Read every integration's status in one call",
description:
"The consolidated envelope behind the integrations settings surface: all eight providers, always, in a fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this over eight per-provider round-trips. Pure read. Auth via cookie or Bearer.",
"The consolidated envelope behind the integrations settings surface: all nine providers, always, in a fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this over nine per-provider round-trips. Pure read. Auth via cookie or Bearer.",
responses: {
"200": {
description: "All eight provider entries plus the alert threshold.",
description: "All nine provider entries plus the alert threshold.",
content: {
"application/json": {
schema: dataEnvelope(
+6
View File
@@ -13,4 +13,10 @@ export const environmentKeys = {
/** Geocoding search results for a query string (cached per query). */
environmentGeocode: (query: string) =>
["environment", "geocode", query] as const,
/**
* v1.43 — trips suggested from Dawarich travel days. Under the root prefix,
* so adding a trip (which covers a suggestion) refreshes the list too.
*/
environmentTravelSuggestions: () =>
["environment", "travel-suggestions"] as const,
};
+5
View File
@@ -63,6 +63,11 @@ export const integrationKeys = {
strava: () => ["strava"] as const,
stravaStatus: () => ["strava", "status"] as const,
// v1.43 — the Dawarich movement context card: its status (home place and
// country, stored days) and the instance's places, read live per query.
dawarichStatus: () => ["dawarich", "status"] as const,
dawarichPlaces: (query: string) => ["dawarich", "places", query] as const,
integrationsStatus: () => ["integrations", "status"] as const,
// Apple Health live-sync freshness comes from the existing HealthKit config
// endpoint. It is intentionally separate from the OAuth integration ledger:
+3
View File
@@ -113,6 +113,8 @@ export interface BackupSummary {
environmentContexts: number;
/** Declared stretches spent away from home. */
environmentTravelLocations: number;
/** Local days of Dawarich movement values. */
dawarichDays: number;
/** ECG strips, with their rhythm verdict and their trace. */
ecgRecordings: number;
}
@@ -209,6 +211,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
userAchievements: payload.userAchievements.length,
environmentContexts: payload.environmentContexts.length,
environmentTravelLocations: payload.environmentTravelLocations.length,
dawarichDays: payload.dawarichDays.length,
ecgRecordings: payload.ecgRecordings.length,
};
}
+34
View File
@@ -1651,6 +1651,37 @@ const environmentTravelLocationBackupSchema = z
})
.passthrough();
/**
* v1.43 — one local day of Dawarich movement values. Daily values only; the
* country rides readable (portable) or sealed (disaster recovery).
*/
const dawarichDayBackupSchema = z
.object({
date: environmentDayKey,
totalDistanceM: z.number().int().nonnegative().nullable().optional(),
drivingDistanceM: z.number().int().nonnegative().nullable().optional(),
trainDistanceM: z.number().int().nonnegative().nullable().optional(),
placesVisited: z.number().int().nonnegative().nullable().optional(),
awayFromHomeMinutes: z
.number()
.int()
.min(0)
.max(25 * 60)
.nullable()
.optional(),
countryCode: z
.string()
.regex(/^[A-Z]{2}$/)
.nullable()
.optional(),
countryCodeEncrypted: base64BytesSchema.nullable().optional(),
isTravelDay: z.boolean().nullable().optional(),
fetchedAt: isoDateTime.optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
/**
* A turn's stored tables, bounded at restore. The live path never stores more
* than `RESULTS_MAX_BYTES` of table JSON per turn (`fitResultsToStorage`), so
@@ -2293,6 +2324,9 @@ export const backupPayloadSchema = z
environmentTravelLocations: z
.array(environmentTravelLocationBackupSchema)
.default([]),
// v1.43 — the Dawarich movement days, restored with the environment
// section. A file written before them carries no key.
dawarichDays: z.array(dawarichDayBackupSchema).default([]),
// The ECG strips. Defaulted for the same reason as the sections above: a
// file written before the recordings travelled carries no key, and an
// account whose watch has never taken one writes [].