Merge the final display polish into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 17:25:35 +02:00
commit dcfec1f35d
144 files changed
+4866 -2817

No files matched your search

+241 -12
View File
@@ -173,22 +173,21 @@ test("the timeline hands the selected day to the day view through ?day=", async
await page.keyboard.press("Enter");
await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`));
// "Open <day>" in the selection bar does the same for the selected day.
// An incoming ?day= selects that day; the selection bar has no separate
// "Open <day>" link, a chip opens the day it touches.
await page.goto("/timeline?day=2026-01-03");
const open = page.locator('[data-slot="timeline-open-day"]');
await expect(open).toHaveAttribute("data-date", "2026-01-03", {
timeout: 20_000,
});
await expect(
page.locator('[data-slot="timeline-selection"]'),
).toHaveAttribute("data-date", "2026-01-03", { timeout: 20_000 });
await expect(
page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'),
).toHaveAttribute("aria-checked", "true");
await expect(
page.locator('[data-slot="timeline-selection-chip"][data-item="ill-5"]'),
).toBeVisible();
await page.goto("/timeline");
await expect(chart).toBeVisible({ timeout: 20_000 });
await page.locator('[data-slot="timeline-zoom"] [data-value="year"]').click();
await open.click();
await expect(page.locator('[data-slot="timeline-open-day"]')).toHaveCount(0);
const chip = page.locator(
'[data-slot="timeline-selection-chip"][data-item="ill-5"]',
);
await expect(chip).toBeVisible();
await chip.click();
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
});
@@ -546,3 +545,233 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn
.click();
await expect(page).toHaveURL(/[?&]day=2026-01-03/);
});
/** A calendar date `days` before today, in the account's zone. */
function daysAgo(days: number): string {
const at = new Date(`${TODAY}T12:00:00Z`);
at.setUTCDate(at.getUTCDate() - days);
return at.toISOString().slice(0, 10);
}
test("every medication still taken is drawn, from the account's real record", async ({
page,
}) => {
await setTimelineModuleOn(page);
// Written through the app's own routes, once: a medication with a start
// date (it opens a course), one without (its first intake is its start).
const list = await page.request.get("/api/medications");
const held = JSON.stringify(await list.json());
const create = async (body: Record<string, unknown>) => {
const res = await page.request.post("/api/medications", { data: body });
expect(res.status()).toBeLessThan(300);
return ((await res.json()) as { data: { id: string } }).data.id;
};
if (!held.includes("E2E Ramipril")) {
await create({
name: "E2E Ramipril",
dose: "5 mg",
startsOn: daysAgo(40),
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
});
}
if (!held.includes("E2E Vitamin D3")) {
const id = await create({
name: "E2E Vitamin D3",
dose: "2000 IU",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
});
const intake = await page.request.post(`/api/medications/${id}/intake`, {
data: { takenAt: new Date(Date.now() - 20 * 86_400_000).toISOString() },
});
expect(intake.status()).toBeLessThan(300);
}
await page.goto("/timeline?zoom=quarter");
const lane = page.locator('svg [data-lane="medications"]');
await expect(lane).toHaveCount(1, { timeout: 20_000 });
// One row each, and every one of them reaches today.
for (const name of ["E2E Ramipril", "E2E Vitamin D3"]) {
const bars = lane.locator("g[data-row]", {
has: page.locator(`title:text-matches("^${name}")`),
});
await expect(bars.first()).toBeAttached();
const rows = await bars.evaluateAll((els) =>
els.map((el) => el.getAttribute("data-row")),
);
expect(new Set(rows).size).toBe(1);
}
});
test("an open day stays open from the timeline to another page", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
await page.setViewportSize({ width: 1440, height: 900 });
const day = daysAgo(3);
await page.goto(`/timeline?day=${day}`);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toBeVisible({ timeout: 20_000 });
await page.locator('a[href="/labs"]').first().click();
await expect(page).toHaveURL(new RegExp(`/labs\\?(.*&)?day=${day}`));
await expect(panel).toBeVisible();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
// Folded by the person, it stays folded on the next page.
await page.locator('[data-slot="day-close"]').click();
await expect(panel).toHaveCount(0);
await page.locator('a[href="/timeline"]').first().click();
await expect(page).toHaveURL(/\/timeline$/);
await expect(page.locator('[data-slot="day-rail"]')).toBeVisible();
await expect(panel).toHaveCount(0);
});
test("a value point reads on its own, by pointer and by keyboard", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/timeline");
const targets = page.locator('[data-slot="timeline-point-target"]');
await expect(targets.first()).toBeAttached({ timeout: 20_000 });
const target = targets.last();
const series = await target.getAttribute("data-series");
const t = await target.getAttribute("data-t");
await target.hover();
const tip = page.locator('[data-slot="timeline-point-tip"]');
await expect(tip).toBeVisible();
await expect(tip).toHaveAttribute("data-series", series!);
await expect(tip).toHaveAttribute("data-t", t!);
// One line only, never the whole bucket.
await expect(tip.locator('[data-slot="rich-chart-tooltip-row"]')).toHaveCount(
1,
);
// Inside the chart, and the page does not scroll sideways for it.
const chart = (await page
.locator('[data-slot="timeline-chart"]')
.boundingBox())!;
const box = (await tip.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(chart.x - 1);
expect(box.x + box.width).toBeLessThanOrEqual(chart.x + chart.width + 1);
// The keyboard: focus shows the card, the arrows walk the line.
await page.mouse.move(0, 0);
await target.focus();
await expect(tip).toHaveAttribute("data-t", t!);
await page.keyboard.press("ArrowLeft");
await expect(tip).not.toHaveAttribute("data-t", t!);
await expect(tip).toHaveAttribute("data-series", series!);
});
test("the timeline page ends where its content does, at every desktop width", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
for (const width of [1280, 1440, 1920]) {
for (const day of [null, daysAgo(3)]) {
await page.setViewportSize({ width, height: 900 });
await page.goto(day ? `/timeline?day=${day}` : "/timeline");
await expect(
page.locator('[data-slot="timeline-chart"] > svg'),
).toBeVisible({ timeout: 20_000 });
const m = await page.evaluate(() => {
const main = document.getElementById("main-content")!;
const page = document.querySelector('[data-slot="timeline-page"]')!;
const doc = document.scrollingElement!;
return {
mainSw: main.scrollWidth,
mainCw: main.clientWidth,
docSw: doc.scrollWidth,
docCw: doc.clientWidth,
docSh: doc.scrollHeight,
docCh: doc.clientHeight,
mainSh: main.scrollHeight,
contentBottom:
page.getBoundingClientRect().bottom -
main.getBoundingClientRect().top +
main.scrollTop,
};
});
const where = `${width}px ${day ? "with" : "without"} the day`;
expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1);
expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1);
expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1);
// The shell's own buffer below the last card (pb-20 and the top
// padding), nothing more.
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2);
}
}
});
test("listing all of a day's values scrolls the panel, never the page", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
const day = daysAgo(3);
const types = [
"WEIGHT",
"BODY_FAT",
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
"MUSCLE_MASS",
"BONE_MASS",
"FAT_MASS",
"VISCERAL_FAT",
"BLOOD_GLUCOSE",
"ACTIVITY_STEPS",
];
await page.route(`**/api/day/${day}`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
date: day,
tz: "Europe/Berlin",
counts: { values: types.length, entries: 0 },
running: [],
values: types.map((type, i) => ({
type,
value: 10 + i,
unit: "u",
at: `${day}T0${i % 10}:00:00.000Z`,
source: "MANUAL",
band: null,
})),
events: [],
notable: [],
scores: [],
sections: {},
},
error: null,
}),
}),
);
await page.setViewportSize({ width: 1440, height: 800 });
await page.goto(`/timeline?day=${day}`);
const all = page.locator('[data-slot="day-values-all"]');
await expect(all).toBeVisible({ timeout: 20_000 });
const heights = () =>
page.evaluate(() => ({
doc: document.scrollingElement!.scrollHeight,
main: document.getElementById("main-content")!.scrollHeight,
}));
const before = await heights();
await all.click();
await expect(all).toHaveAttribute("aria-expanded", "true");
const after = await heights();
expect(Math.abs(after.doc - before.doc)).toBeLessThanOrEqual(2);
expect(Math.abs(after.main - before.main)).toBeLessThanOrEqual(2);
});
+406 -411
View File
File diff suppressed because it is too large. Load diff
+387 -392
View File
File diff suppressed because it is too large. Load diff
+160 -165
View File
File diff suppressed because it is too large. Load diff
+309 -314
View File
File diff suppressed because it is too large. Load diff
+249 -254
View File
File diff suppressed because it is too large. Load diff
+341 -346
View File
File diff suppressed because it is too large. Load diff
+383 -388
View File
File diff suppressed because it is too large. Load diff
@@ -81,6 +81,8 @@ const DENSE_TYPES: ReadonlySet<string> = new Set([
* list, keyed `<file>::<type expression>`, with why each stays bounded.
*/
const ALLOWED: Record<string, string> = {
"lib/analytics/summaries-slice.ts::(shorthand)":
"one hourly-mean type (pulse) over one local day, the latest: the same rows the day view reads for that day, bounded by a day, not by the sampling history",
"lib/insights/comprehensive-generate.ts::(shorthand)":
'Only reached for SLEEP_DURATION (guarded by `type === "SLEEP_DURATION"`); a few stage rows per night.',
"lib/insights/derived/vascular-age.ts::VASCULAR_AGE_TYPE":
+18 -13
View File
@@ -3,14 +3,18 @@ import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
/**
* No middle-dot separator in visible strings.
* No middle-dot or dash separator in visible strings.
*
* A value like "Added {date} · {size}" or "Weight · body fat · muscle" reads
* as a list of fragments rather than as the app's plain voice. Facts are
* joined with a comma, a colon or a real phrase instead. This guard walks
* every bundle under `messages/` and fails on any string that contains a
* space, U+00B7 and a space. A middle dot inside a unit ("mL/(kg·min)") has
* no surrounding spaces and is not matched.
* as a list of fragments rather than as the app's plain voice, and so does
* "{what} — tomorrow" or "Could not save — try again": the dash stands in
* for a comma, a colon or a full stop the sentence should have said. Facts
* are joined with a comma, a colon or a real phrase instead. This guard
* walks every bundle under `messages/` and fails on any string that contains
* a space, then U+00B7 (middle dot), U+2014 (em dash) or U+2013 (en dash),
* then a space. A middle dot inside a unit ("mL/(kg·min)") and a range
* ("70–100 mmHg", "{start}–{end}") have no surrounding spaces and are not
* matched: an unspaced en dash is how a range is written, not a pause.
*
* A key that genuinely needs the pattern goes into `ALLOWED` with the reason
* written next to it. An allowed key that no longer contains the pattern
@@ -18,7 +22,10 @@ import { join } from "node:path";
*/
const MESSAGES_DIR = join(__dirname, "../../messages");
const SEPARATOR = " · ";
const SEPARATORS = [" · ", " — ", " – "] as const;
const hasSeparator = (value: string) =>
SEPARATORS.some((separator) => value.includes(separator));
/** Key path -> why the separator is right there. Empty by intent. */
const ALLOWED: Record<string, string> = {};
@@ -41,7 +48,7 @@ const bundles = readdirSync(MESSAGES_DIR)
.filter((file) => file.endsWith(".json"))
.sort();
describe("message bundles carry no ' · ' separator", () => {
describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () => {
it("finds the bundles", () => {
expect(bundles.length).toBeGreaterThanOrEqual(7);
});
@@ -57,18 +64,16 @@ describe("message bundles carry no ' · ' separator", () => {
expect(values.size).toBeGreaterThan(1000);
});
it(`${file} has no dot separator outside the allowlist`, () => {
it(`${file} has no dot or dash separator outside the allowlist`, () => {
const offenders = [...values]
.filter(
([key, value]) => value.includes(SEPARATOR) && !(key in ALLOWED),
)
.filter(([key, value]) => hasSeparator(value) && !(key in ALLOWED))
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(offenders).toEqual([]);
});
it(`${file} has no stale allowlist entry`, () => {
const stale = Object.keys(ALLOWED).filter(
(key) => !(values.get(key) ?? "").includes(SEPARATOR),
(key) => !hasSeparator(values.get(key) ?? ""),
);
expect(stale).toEqual([]);
});
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
/**
* The code-side twin of `i18n-no-dot-separator.test.ts`: a string the code
* composes itself (an aria-label `${action} — ${name}`, a page title, a
* Coach prefill) carries no spaced em or en dash either. The bundles say
* "Move up: Ramipril" and "No connection: changes can't be saved", so a
* label put together in a component says it the same way.
*
* The matcher looks at string and template literals on one line, outside
* comments: a quote or backtick, then text, a space, U+2014 or U+2013, a
* space. A range written in code ("10–12", `${from}–${to}`) has no spaces
* around its dash and is not matched. A template literal broken across lines
* escapes the one-line matcher, which is this guard's known limit.
*/
const ROOT = join(__dirname, "..");
const DIRS = ["components", "app"].map((d) => join(ROOT, d));
const LITERAL = /(["'`])[^"'`]* [—–] [^"'`]*\1/;
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
if (name === "__tests__") continue;
walk(path, out);
} else if (/\.(ts|tsx)$/.test(name) && !/\.test\./.test(name)) {
out.push(path);
}
}
return out;
}
const isComment = (line: string) => /^\s*(\/\/|\*|\/\*|\{\/\*)/.test(line);
describe("composed UI strings carry no spaced dash", () => {
const files = DIRS.flatMap((d) => walk(d));
it("walks the component and app trees", () => {
expect(files.length).toBeGreaterThan(500);
});
it("has no ' — ' or ' – ' inside a string or template literal", () => {
const offenders: string[] = [];
for (const file of files) {
readFileSync(file, "utf8")
.split("\n")
.forEach((line, i) => {
if (isComment(line)) return;
const code = line.replace(/\s\/\/\s.*$/, "");
if (LITERAL.test(code)) {
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
}
});
}
expect(offenders).toEqual([]);
});
it("catches the pattern it is written for", () => {
expect(LITERAL.test("aria-label={`${a} — ${b}`}")).toBe(true);
expect(LITERAL.test('title: "About – HealthLog"')).toBe(true);
expect(LITERAL.test("`${from}–${to}`")).toBe(false);
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ export const dynamic = "force-dynamic";
const LAST_UPDATED = "2026-08-02";
export const metadata: Metadata = {
title: "About — HealthLog",
title: "About | HealthLog",
description:
"Open-source credits and third-party data attributions for the HealthLog project.",
robots: { index: true, follow: true },
@@ -84,7 +84,7 @@ const handler = apiHandler(
details: { reason: "missing_confirmation", backupId: id },
});
return apiError(
"Confirmation token missing — replacing an account's data requires confirm: 'RESTORE'",
"Confirmation token missing. Replacing an account's data requires confirm: 'RESTORE'",
422,
);
}
@@ -45,7 +45,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
userCode = decoded.userCode;
} catch {
cookieStore.delete("central_codex_device");
return apiError("Invalid device-auth state — restart the flow", 400);
return apiError("Invalid device-auth state. Restart the flow", 400);
}
let result;
@@ -55,7 +55,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { central_codex_device_poll_error: message } });
return apiError(
"Central Codex device-poll failed — please restart the flow",
"Central Codex device-poll failed. Please restart the flow",
502,
);
}
+3 -3
View File
@@ -276,13 +276,13 @@ function classifyTestFailure(
return {
reasonCode: "credentials",
reason:
"Provider rejected the credentials — re-authenticate in AI settings.",
"Provider rejected the credentials. Re-authenticate in AI settings.",
};
}
if (status === 429) {
return {
reasonCode: "rate_limited",
reason: "Provider rate-limited the request — try again shortly.",
reason: "Provider rate-limited the request. Try again shortly.",
};
}
if (status >= 500) {
@@ -298,7 +298,7 @@ function classifyTestFailure(
if (status >= 400 && status < 500) {
return {
reasonCode: "bad_request",
reason: `The provider rejected the request (HTTP ${status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
reason: `The provider rejected the request (HTTP ${status}). The endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
};
}
return {
+2 -2
View File
@@ -47,7 +47,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
userCode = decoded.userCode;
} catch {
cookieStore.delete("codex_device");
return apiError("Invalid device-auth state — restart the flow", 400);
return apiError("Invalid device-auth state. Restart the flow", 400);
}
let result;
@@ -56,7 +56,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_device_poll_error: message } });
return apiError("Codex device-poll failed — please restart the flow", 502);
return apiError("Codex device-poll failed. Please restart the flow", 502);
}
if (result.status === "pending") {
@@ -88,7 +88,7 @@ const healthScoreConfigPatchSchema = z
*/
const BREADTH_REFUSAL = {
no_pillars_selected:
"A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from.",
"A health score needs at least one area of health to score. Keep one selected. The score will say which areas it is built from.",
} as const;
export const GET = apiHandler(async () => {
+1 -1
View File
@@ -135,7 +135,7 @@ export const DELETE = apiHandler(
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
"Cannot delete. At least one authentication method must remain",
400,
);
}
+2 -2
View File
@@ -96,14 +96,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
// the token and re-authenticates.
if (result.reason === "already_used") {
return apiError(
"Refresh token reuse detected — please log in again.",
"Refresh token reuse detected. Please log in again.",
401,
{ errorCode: "auth.refresh.reuse" },
);
}
if (result.reason === "revoked") {
return apiError(
"Refresh token has been revoked — please log in again.",
"Refresh token has been revoked. Please log in again.",
401,
{ errorCode: "auth.refresh.revoked" },
);
+1 -1
View File
@@ -58,7 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (existingSession) {
annotate({ action: { name: "auth.register.refused_authenticated" } });
return apiError(
"Already signed in — sign out first to use an invitation",
"Already signed in. Sign out first to use an invitation",
409,
{ errorCode: "auth.already_authenticated" },
);
+1 -1
View File
@@ -297,7 +297,7 @@ export const PATCH = apiHandler(
if (outcome === "contradictory-reminder") {
return apiError(
"A booked visit cannot also close a checkup — it has not happened yet",
"A booked visit cannot also close a checkup. It has not happened yet",
422,
{ errorCode: "encounter.reminder-conflict" },
);
+1 -1
View File
@@ -301,7 +301,7 @@ async function postEncounter(request: NextRequest): Promise<Response> {
}
if (created === "contradictory-reminder") {
return apiError(
"A planned visit cannot also close a checkup — it has not happened yet",
"A planned visit cannot also close a checkup. It has not happened yet",
422,
{ errorCode: "encounter.reminder-conflict" },
);
+1 -1
View File
@@ -149,7 +149,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (okRows.length > MAX_ROWS) {
return apiError(
`CSV has ${okRows.length} valid rows — the limit is ${MAX_ROWS} per import`,
`CSV has ${okRows.length} valid rows. The limit is ${MAX_ROWS} per import`,
422,
);
}
@@ -1240,7 +1240,7 @@ exports[`coach chat golden transcripts > 04 inbound refusal on a new conversatio
},
{
"call": "appendMessage",
"content": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"content": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
"metricSource": {
"metrics": [
"general",
@@ -1256,7 +1256,7 @@ exports[`coach chat golden transcripts > 04 inbound refusal on a new conversatio
"contentType": "text/event-stream; charset=utf-8",
"frames": [
{
"token": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"token": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
"type": "token",
},
{
@@ -1302,7 +1302,7 @@ exports[`coach chat golden transcripts > 05 replay injection in a stored user tu
},
{
"call": "appendMessage",
"content": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"content": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
"metricSource": {
"metrics": [
"general",
@@ -1318,7 +1318,7 @@ exports[`coach chat golden transcripts > 05 replay injection in a stored user tu
"contentType": "text/event-stream; charset=utf-8",
"frames": [
{
"token": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"token": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data. Please rephrase your question in those terms.",
"type": "token",
},
{
+6 -6
View File
@@ -810,7 +810,7 @@ export const POST = apiHandler((request: NextRequest) =>
// bounds cost. 429 with a distinct code so the client can say so.
if (e instanceof BriefingBudgetExceededError) {
return apiError(
"Daily AI token budget reached — insights will be available again tomorrow.",
"Daily AI token budget reached. Insights will be available again tomorrow.",
429,
{ errorCode: "insights.generate.budgetExceeded" },
);
@@ -859,7 +859,7 @@ export const POST = apiHandler((request: NextRequest) =>
);
if (allAuth) {
return apiError(
"AI provider rejected the request — check your API key in Settings > AI",
"AI provider rejected the request. Check your API key in Settings > AI",
422,
);
}
@@ -881,7 +881,7 @@ export const POST = apiHandler((request: NextRequest) =>
);
if (allTransport) {
return apiError(
"AI provider connection failed — check your AI settings",
"AI provider connection failed. Check your AI settings",
422,
);
}
@@ -907,7 +907,7 @@ export const POST = apiHandler((request: NextRequest) =>
const status = err.httpStatus ?? 0;
if (status === 401 || status === 403) {
return apiError(
"AI provider rejected the request — check your API key in Settings > AI",
"AI provider rejected the request. Check your API key in Settings > AI",
422,
);
}
@@ -921,7 +921,7 @@ export const POST = apiHandler((request: NextRequest) =>
);
}
return apiError(
"AI provider connection failed — check your AI settings",
"AI provider connection failed. Check your AI settings",
422,
);
}
@@ -945,7 +945,7 @@ export const POST = apiHandler((request: NextRequest) =>
if (result.finishReason === "length") {
annotate({ meta: { insights_response_truncated: true } });
return apiError(
"AI response was cut off before the JSON completed — raise the token limit (INSIGHTS_MAX_TOKENS)",
"AI response was cut off before the JSON completed. Raise the token limit (INSIGHTS_MAX_TOKENS)",
422,
{ errorCode: "ai_response_truncated" },
);
+2 -2
View File
@@ -172,14 +172,14 @@ export const PUT = apiHandler(
const rowIsQualitative = existing.valueText !== null;
if (rowIsQualitative && d.value !== undefined) {
return apiError(
"This is a qualitative reading — edit its result text, not a numeric value",
"This is a qualitative reading. Edit its result text, not a numeric value",
422,
{ errorCode: "labs.update.qualitativeExpected" },
);
}
if (!rowIsQualitative && d.valueText !== undefined) {
return apiError(
"This is a numeric reading — edit its value, not a qualitative result",
"This is a numeric reading. Edit its value, not a qualitative result",
422,
{ errorCode: "labs.update.numericExpected" },
);
+3 -3
View File
@@ -85,7 +85,7 @@ function badRequest(error: string, description: string): Response {
function html(body: string, status = 200): Response {
return new Response(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HealthLog — Authorize access</title></head><body>${body}</body></html>`,
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HealthLog: Authorize access</title></head><body>${body}</body></html>`,
{
status,
headers: { "content-type": "text/html; charset=utf-8" },
@@ -419,13 +419,13 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
const provenance =
v.clientSource === "cimd"
? `<p>Verified origin: <code>${htmlEscape(v.clientOrigin)}</code></p>`
: `<p><strong>Unverified application</strong> (dynamically registered — the name above is self-reported and not verified).</p>`;
: `<p><strong>Unverified application</strong> (dynamically registered, the name above is self-reported and not verified).</p>`;
// Plain-language access summary. When write is granted, say so explicitly —
// the app will be able to READ and LOG/WRITE health data, not just read.
const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE);
const accessSummary = writeGranted
? `<p><strong>This grants read AND write access:</strong> the application will be able to read your own health records <strong>and log new measurements and mood entries</strong> to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.</p>`
: `<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>`;
: `<p>Scope: <code>${htmlEscape(v.scope)}</code>. Read-only access to your own health records.</p>`;
// What a read reaches, in plain words, before the person clicks Allow.
// Document text is named on its own because it is the widest thing a
// connection can read (v1.39.3: search and fetch reach it).
@@ -176,7 +176,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (plan.entries.length > MAX_QUEUED_ROWS) {
return apiError(
`The file holds ${plan.entries.length} importable rows — the limit is ${MAX_QUEUED_ROWS} per import`,
`The file holds ${plan.entries.length} importable rows. The limit is ${MAX_QUEUED_ROWS} per import`,
422,
{ errorCode: "too_many_rows" },
);
+1 -1
View File
@@ -66,7 +66,7 @@ async function checkAndWarn(
}
if (auth.via === "query") {
getEvent()?.addWarning(
`withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
`withings webhook secret received via legacy URL query. Migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
);
}
return null;
+1 -1
View File
@@ -27,7 +27,7 @@ export default function InsightsActiveEnergyPage() {
yAxisUnit="kcal"
emptyStateIcon={<Flame className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any active energy yet — what's a reasonable daily target, and how does it compare to total calories?"
coachPrefill="I haven't logged any active energy yet. What's a reasonable daily target, and how does it compare to total calories?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsLaermereignissePage() {
yAxisUnit=""
emptyStateIcon={<Volume2 className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any loud-environment events yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any loud-environment events yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -60,7 +60,7 @@ export default function InsightsBlutzuckerPage() {
captureType="BLOOD_GLUCOSE"
targetSummarySlug="blood-glucose"
afterChart={<GlucoseClinicalPanel />}
coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any blood glucose yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function InsightsBlutdruckPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any blood pressure yet — why does it matter, and what should I know before I start?"
coachPrefill="I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -69,7 +69,7 @@ export default function InsightsBmiPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any weight yet — why does BMI matter for me, and what should I know before I start tracking it?"
coachPrefill="I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsBodyTemperaturePage() {
color="var(--chart-3)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType="BODY_TEMPERATURE"
coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?"
coachPrefill="I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsKoerperwasserPage() {
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
captureType="TOTAL_BODY_WATER"
coachPrefill="I haven't logged any total body water yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any total body water yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsKnochenmassePage() {
emptyStateIcon={<Bone className="size-6" />}
emptyStateCtaType={null}
captureType="BONE_MASS"
coachPrefill="I haven't logged any bone mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any bone mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -24,7 +24,7 @@ export default function InsightsBreathingDisturbancesPage() {
unitKey="insights.units.breathingEvents"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any sleep-breathing-disturbance data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any sleep-breathing-disturbance data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export default function InsightsCardioFitnessPage() {
statIcon={Gauge}
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsCardioRecoveryPage() {
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any cardio-recovery data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any cardio-recovery data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsTageslichtPage() {
yAxisUnit="min"
emptyStateIcon={<Sun className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any time spent in daylight yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any time spent in daylight yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,7 @@ export default function InsightsDoppelstandphasePage() {
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any double support time yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any double support time yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -26,7 +26,7 @@ export default function InsightsLaermbelastungPage() {
yAxisUnit="dBA"
emptyStateIcon={<Volume2 className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any environmental sound exposure yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any environmental sound exposure yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ export default function InsightsFallsPage() {
unitKey="insights.units.falls"
emptyStateIcon={<PersonStanding className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fall data yet — what does this metric tell me about my health, and how do I lower my fall risk?"
coachPrefill="I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?"
/>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsFettfreieMassePage() {
color="var(--chart-1)"
emptyStateIcon={<Scale className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat-free mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any fat-free mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsFettmassePage() {
color="var(--chart-5)"
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any fat mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsStockwerkePage() {
unitKey="insights.units.flights"
emptyStateIcon={<TrendingUp className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any flights climbed yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any flights climbed yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsKopfhoererpegelPage() {
yAxisUnit="dBA"
emptyStateIcon={<Headphones className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any headphone audio exposure yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any headphone audio exposure yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export default function InsightsHrvPage() {
yAxisUnit="ms"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?"
coachPrefill="I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?"
// S10 / H1 — device-attributed pointer into the ECG viewer, in the
// HRV / heart context. Self-gates to nothing without recordings.
afterAssessment={<EcgCrossLink />}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsMagermassePage() {
color="var(--chart-1)"
emptyStateIcon={<Scale className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any lean body mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any lean body mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -132,7 +132,7 @@ export default function InsightsMoodPageClient() {
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
coachPrefill="I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsMuskelmassePage() {
color="var(--chart-1)"
emptyStateIcon={<Dumbbell className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any muscle mass yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any muscle mass yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export default function InsightsOxygenSaturationPage() {
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
captureType="OXYGEN_SATURATION"
coachPrefill="I haven't logged any oxygen-saturation data yet — what does SpO₂ tell me, and what range should I aim for?"
coachPrefill="I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?"
/>
);
}
@@ -26,7 +26,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
yAxisUnit="m/s"
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any pulse wave velocity yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any pulse wave velocity yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -114,7 +114,7 @@ export default function InsightsPulsPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any resting pulse yet — why does it matter, and what should I know before I start?"
coachPrefill="I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsAtemfrequenzPage() {
unitKey="insights.units.respiratoryRate"
emptyStateIcon={<Wind className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any respiratory rate yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any respiratory rate yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsSixMinuteWalkPage() {
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any six-minute-walk data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any six-minute-walk data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function InsightsHauttemperaturPage() {
color="var(--chart-5)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any skin temperature yet. What does this metric tell me about my health, and how do I improve it?"
afterChart={
<ConnectedDeviceScoreTile
type="BODY_TEMPERATURE_DEVIATION"
+1 -1
View File
@@ -67,7 +67,7 @@ export default function InsightsSchlafPage() {
</Link>
</Button>
}
coachPrefill="I don't have any sleep data yet — why does sleep tracking matter, and what should I know before I connect a source?"
coachPrefill="I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsStairAscentSpeedPage() {
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any stair-ascent-speed data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any stair-ascent-speed data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
@@ -25,7 +25,7 @@ export default function InsightsStairDescentSpeedPage() {
yAxisUnit="m/s"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any stair-descent-speed data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any stair-descent-speed data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsSchrittlaengePage() {
yAxisUnit="m"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking step length yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking step length yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function InsightsStepsPage() {
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
captureType="ACTIVITY_STEPS"
coachPrefill="I haven't logged any steps yet — what's a reasonable daily step target, and how does walking more help my health?"
coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
/>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function InsightsGefaessalterPage() {
unitKey="insights.units.years"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any vascular age yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any vascular age yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsViszeralfettPage() {
yAxisUnit=""
emptyStateIcon={<Droplets className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any visceral fat yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any visceral fat yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsGangasymmetriePage() {
yAxisUnit="%"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking asymmetry yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking asymmetry yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsGehstreckePage() {
color="var(--chart-2)"
emptyStateIcon={<Footprints className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking and running distance yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsGehpulsPage() {
yAxisUnit="bpm"
emptyStateIcon={<HeartPulse className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking heart rate average yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking heart rate average yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsGehgeschwindigkeitPage() {
color="var(--chart-2)"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking speed yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function InsightsGangstabilitaetPage() {
yAxisUnit="%"
emptyStateIcon={<Gauge className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking steadiness yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any walking steadiness yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+1 -1
View File
@@ -129,7 +129,7 @@ export default function InsightsGewichtPage() {
</Link>
</Button>
}
coachPrefill="I haven't recorded any weight yet — why does it matter, and what should I know before I start tracking?"
coachPrefill="I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?"
/>
</SubPageShell>
);
+1 -1
View File
@@ -132,7 +132,7 @@ export default function InsightsWorkoutsPageClient() {
</Button>
) : null
}
coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
coachPrefill="I haven't logged any workouts yet. Why does tracking them matter, and what should I focus on first?"
/>
) : (
<>
+1 -1
View File
@@ -24,7 +24,7 @@ export default function InsightsWristTemperaturePage() {
color="var(--chart-5)"
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?"
coachPrefill="I haven't logged any wrist-temperature data yet. What does this metric tell me about my health, and how do I improve it?"
/>
);
}
+2 -2
View File
@@ -21,7 +21,7 @@ const inter = Inter({
export const metadata: Metadata = {
title: "HealthLog",
description:
"Self-hosted health tracker — weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.",
"Self-hosted health tracker: weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.",
manifest: "/manifest.json",
icons: {
icon: "/favicon.svg",
@@ -51,7 +51,7 @@ export const metadata: Metadata = {
url: "/logo-readme.png",
width: 1000,
height: 1000,
alt: "HealthLog — your health data, your server",
alt: "HealthLog: your health data, your server",
},
],
},
+16 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -448,6 +449,19 @@ export default function DashboardPageClient({
const rhr = data?.summaries?.RESTING_HEART_RATE;
const hasRestingHr = (rhr?.count ?? 0) > 0;
const pulseTileSummary = hasRestingHr ? rhr : p;
// The pulse widget shows ONE of two statistics, and its name says which:
// with resting rows it is the resting heart rate (one figure a day, the
// same series the day view names "Resting heart rate"); without them it
// is the pulse, whose latest value is the latest day's pulse (the mean of
// its hours' means, `summaries-slice.ts`), the number the day view shows
// as "Pulse" for that day. Calling the resting figure "Pulse" put 62 on
// the dashboard beside a day view reading 77 for the same name.
const pulseLabel = hasRestingHr
? t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE)
: t("dashboard.pulseShort");
const pulseTitle = hasRestingHr
? t(MEASUREMENT_TYPE_LABEL_KEYS.RESTING_HEART_RATE)
: t("dashboard.pulse");
const bf = data?.summaries?.BODY_FAT;
const sleepSummary = data?.summaries?.SLEEP_DURATION;
// v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep
@@ -1131,7 +1145,7 @@ export default function DashboardPageClient({
<TrendCard
key="pulse"
fractionDigits={0}
label={t("dashboard.pulseShort")}
label={pulseLabel}
latest={pulseTileSummary?.latest ?? null}
unit="bpm"
avg7={pulseTileSummary?.avg7 ?? null}
@@ -1809,7 +1823,7 @@ export default function DashboardPageClient({
// rate WITHOUT the resting-band overlay (it would mark
// expected-high workout HR as "outside target").
types={hasRestingHr ? ["RESTING_HEART_RATE"] : ["PULSE"]}
title={t("dashboard.pulse")}
title={pulseTitle}
colors={["var(--success)"]}
unit="bpm"
valueBands={hasRestingHr ? pulseBands : undefined}
+1 -1
View File
@@ -50,7 +50,7 @@ const LAST_UPDATED_EN = new Intl.DateTimeFormat("en-GB", {
}).format(LAST_UPDATED_DATE);
export const metadata: Metadata = {
title: "Datenschutzerklärung / Privacy Policy — HealthLog",
title: "Datenschutzerklärung / Privacy Policy | HealthLog",
description:
"Wie HealthLog personenbezogene Gesundheitsdaten verarbeitet, Auftragsverarbeiter, DSGVO-Rechte und die EU-MDR-Medizinprodukte-Grenze. Bilingual DE/EN.",
robots: { index: true, follow: true },
@@ -72,7 +72,7 @@ describe("chart isError rendering — error state, not empty state", () => {
expect(html).toContain("Pulse");
// The retry button carries the chart title in its accessible name
// so several failed charts on one page stay distinguishable.
expect(html).toContain('aria-label="Erneut versuchen – Pulse"');
expect(html).toContain('aria-label="Erneut versuchen: Pulse"');
// The empty-state copy must NOT paint — an outage is not "no data".
expect(html).not.toContain("Keine Daten in diesem Zeitraum");
@@ -169,7 +169,7 @@ describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
it("joins the action context into the retry button's accessible name", async () => {
const html = await renderState({ actionContext: "Pulse" });
expect(html).toContain('aria-label="Retry – Pulse"');
expect(html).toContain('aria-label="Retry: Pulse"');
});
it("omits the aria-label without a context so the label is the name", async () => {
+1 -1
View File
@@ -82,7 +82,7 @@ export function ChartErrorState({
size="sm"
onClick={onAction}
aria-label={
actionContext ? `${actionLabel} – ${actionContext}` : undefined
actionContext ? `${actionLabel}: ${actionContext}` : undefined
}
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
@@ -152,7 +152,7 @@ export function IllnessSection({
: null,
t(`illness.type.${episode.type}`),
t(`illness.lifecycle.${episode.lifecycle}`),
`${fmtDate(episode.onsetAt)} – ${
`${fmtDate(episode.onsetAt)}–${
episode.resolvedAt
? fmtDate(episode.resolvedAt)
: t("doctorReport.illnessOngoing")
+1 -1
View File
@@ -357,7 +357,7 @@ export function MedicationsSection({
return (
<Fragment key={med.id}>
<StatRow
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
label={med.dose ? `${med.name} ${med.dose}` : med.name}
value={
rate
? t("clinicianView.adherence", { rate })
@@ -206,7 +206,7 @@ export function DoseLogSection({
{shown.map((dose, index) => (
<StatRow
key={`${dose.medicationName}-${dose.effectiveAt}-${index}`}
label={`${fmtDateTime(dose.effectiveAt)} — ${dose.medicationName}`}
label={`${fmtDateTime(dose.effectiveAt)}: ${dose.medicationName}`}
value={
(dose.status === "completed"
? t("doctorReport.colTaken")
+2 -2
View File
@@ -99,7 +99,7 @@ export function PredictionsPanel({
<Row
hue={FERTILE_HUE}
label={t("cycle.predictions.fertileWindow")}
value={`${formatDate(prediction.fertileWindowStart, fmt)} – ${formatDate(
value={`${formatDate(prediction.fertileWindowStart, fmt)}–${formatDate(
prediction.fertileWindowEnd,
fmt,
)}`}
@@ -264,7 +264,7 @@ function HistoryCard({
>
<span className="text-foreground tabular-nums">
{formatDate(c.startDate, fmt)}
{" – "}
{"–"}
{c.endDate
? formatDate(c.endDate, fmt)
: t("cycle.history.ongoing")}
+27 -4
View File
@@ -179,10 +179,12 @@ beforeEach(() => {
});
describe("<DayView>", () => {
it("names the day and counts what it holds", async () => {
it("names the day and carries no count line", async () => {
const html = await render();
expect(html).toContain("Saturday, January 3, 2026");
expect(html).toContain("3 values, 2 entries");
// The sections say what the day holds; a count above them repeated it.
expect(html).not.toContain("3 values, 2 entries");
expect(html).not.toContain('data-slot="day-meta"');
});
it("lists what ran through the day with its running count", async () => {
@@ -264,6 +266,26 @@ describe("<DayView>", () => {
expect(weightTile.split("</li>")[0]).not.toContain("day-number-line");
});
it("leads each value tile with a page of its own to that page", async () => {
const html = await render();
expect(html).toMatch(
/data-type="WEIGHT"[^>]*>\s*<a[^>]*href="\/insights\/weight"[^>]*data-slot="day-value-link"|data-type="WEIGHT"[^>]*><a[^>]*href="\/insights\/weight"/,
);
expect(html).toMatch(
/data-type="BLOOD_PRESSURE"[^>]*><a[^>]*href="\/insights\/blood-pressure"/,
);
// A 44 px target and a visible focus ring, like every other link here.
expect(html).toMatch(/data-slot="day-value-link"[^>]*min-h-11/);
expect(html).toMatch(/data-slot="day-value-link"[^>]*focus-visible:ring/);
});
it("keeps an event's time on one line", async () => {
const html = await render();
const times = html.match(/data-slot="day-event-time"[^>]*>/g) ?? [];
expect(times.length).toBeGreaterThan(0);
for (const tag of times) expect(tag).toContain("whitespace-nowrap");
});
it("lists what happened, each entry linking to its source", async () => {
const html = await render();
expect(html).toContain("What happened");
@@ -284,8 +306,9 @@ describe("<DayView>", () => {
},
});
expect(html).toContain('data-slot="day-focus"');
expect(html).toContain("Your 30 days before");
expect(html).toContain("121–134/78–86");
// The usual range is the tiles' to draw, not repeated beside the value.
expect(html).not.toContain("Your 30 days before");
expect(html).not.toContain('data-slot="day-focus-compare"');
expect(html).toContain('data-selected="true"');
expect(html).toContain("Highest daily value since March 2025");
});
+74 -12
View File
@@ -1,6 +1,6 @@
"use client";
import { useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import {
Suspense,
useCallback,
@@ -27,8 +27,10 @@ import { useAuth } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import {
readDayOpen,
readLastDay,
subscribeLastDay,
writeDayClosed,
writeLastDay,
} from "@/lib/day/last-day";
import { getRecordScope } from "@/lib/query-keys/record-scope";
@@ -155,6 +157,34 @@ function DayLayer() {
() => readLastDay(owner),
() => null,
);
const leftOpen = useSyncExternalStore(
subscribeLastDay,
() => readDayOpen(owner),
() => false,
);
// Every way the person folds or closes the day goes through here, so a page
// change (which drops `?day=` too) is never mistaken for one.
const closeByPerson = useCallback(() => {
if (owner !== null) writeDayClosed(owner);
closeDay();
}, [owner]);
// `?day=` gone while the page stayed the same is the person leaving the
// day (Back past the step that opened it); gone with a page change is
// only the URL of another page, and the day comes with it. Declared
// before the effects that read the flag, so they see the close.
const pathname = usePathname();
const openOn = useRef<string | null>(null);
useEffect(() => {
if (date !== null) {
openOn.current = pathname;
return;
}
if (openOn.current !== null && openOn.current === pathname) {
if (owner !== null) writeDayClosed(owner);
}
openOn.current = null;
}, [date, pathname, owner]);
const wide = useWideViewport();
const phone = useIsMobile();
@@ -191,8 +221,26 @@ function DayLayer() {
const collapse = useCallback(() => {
collapsing.current = true;
closeDay();
}, []);
closeByPerson();
}, [closeByPerson]);
// Open stays open across pages: a page reached without `?day=` while the
// day was left open puts the remembered day back in its URL, on every page
// that docks it. Restoring is not opening, so focus stays with the page.
const restoring = useRef(false);
useEffect(() => {
if (shell !== "docked" || raw !== null || !leftOpen || railDay === null) {
return;
}
// Read again, not from the render: a Back on this page has just closed it.
if (!readDayOpen(owner)) return;
restoring.current = true;
window.history.replaceState(
null,
"",
withDayHref(window.location.pathname, window.location.search, railDay),
);
}, [shell, raw, leftOpen, railDay, owner]);
// What the polite region says: the day that just opened or was stepped to.
const announcement = date !== null ? longLabel(date) : "";
@@ -202,15 +250,25 @@ function DayLayer() {
previous.current = date;
if (date !== null) {
// Opening moves focus to the day's heading; a step keeps it where it is
// (on the arrow the person pressed).
if (before === null && shell === "docked") {
// (on the arrow the person pressed), and so does a day restored on a
// new page.
if (before === null && shell === "docked" && !restoring.current) {
titleRef.current?.focus({ preventScroll: true });
}
restoring.current = false;
return;
}
if (before !== null && shell === "docked") {
// Collapsed for a neighbouring panel: focus stays where it was.
// Collapsed for a neighbouring panel: focus stays where it was, and the
// day counts as folded by the person, who opened the other panel.
if (takeDayYield()) {
if (owner !== null) writeDayClosed(owner);
collapsing.current = false;
return;
}
// Gone from the URL by a page change, not by the person: the day comes
// back on the new page, and focus stays with the page.
if (readDayOpen(owner)) {
collapsing.current = false;
return;
}
@@ -223,7 +281,7 @@ function DayLayer() {
}
}
collapsing.current = false;
}, [date, shell]);
}, [date, shell, owner]);
const onStep = useCallback(
(delta: number) => {
@@ -260,7 +318,7 @@ function DayLayer() {
if (event.key === "Escape" && shell === "docked") {
if (modalSurfaceOpen() && !inside) return;
event.preventDefault();
closeDay();
closeByPerson();
return;
}
if (
@@ -276,7 +334,7 @@ function DayLayer() {
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [date, shell, onStep, prefetchDay, today]);
}, [date, shell, onStep, prefetchDay, today, closeByPerson]);
const live = (
<p
@@ -357,7 +415,7 @@ function DayLayer() {
today={today}
focus={focus}
shell={shell}
onClose={shell === "docked" ? collapse : closeDay}
onClose={shell === "docked" ? collapse : closeByPerson}
onStep={onStep}
onPick={onPick}
Title={Title}
@@ -400,6 +458,7 @@ function DayLayer() {
shell={shell}
panelRef={panelRef}
titleRef={titleRef}
onDismiss={closeByPerson}
render={(extra) => view(SheetTitle, extra)}
/>
</>
@@ -494,11 +553,14 @@ function DaySheet({
shell,
panelRef,
titleRef,
onDismiss,
render,
}: {
shell: "sheet" | "bottom";
panelRef: React.RefObject<HTMLElement | null>;
titleRef: React.RefObject<HTMLHeadingElement | null>;
/** The person closed the sheet (a swipe down, Escape, the overlay). */
onDismiss: () => void;
render: (extra: Partial<Parameters<typeof DayView>[0]>) => React.ReactNode;
}) {
const { t } = useTranslations();
@@ -530,7 +592,7 @@ function DaySheet({
if (dy < -40) setFull(true);
else if (dy > 60) {
if (full) setFull(false);
else closeDay();
else onDismiss();
}
}}
onClick={() => {
@@ -551,7 +613,7 @@ function DaySheet({
<Sheet
open
onOpenChange={(open) => {
if (!open) closeDay();
if (!open) onDismiss();
}}
>
<SheetContent
+48 -25
View File
@@ -41,6 +41,7 @@ import {
} from "@/components/timeline/label-keys";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { metricPageHref } from "@/lib/insights/metric-page";
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
import { cn } from "@/lib/utils";
@@ -78,18 +79,13 @@ export const DAY_SECTION_LABEL =
/* ─── Focus ───────────────────────────────────────────────────────────── */
export function DayFocusCard({
focus,
usual,
}: {
focus: DayFocus;
/** The usual range of the focus type that day, when the server has one. */
usual: string | null;
}) {
const { t } = useTranslations();
const compare =
focus.compare ??
(usual ? { label: t("day.usualLabel"), value: usual } : null);
/**
* The value the person came from. A comparison shows only when the opening
* surface brings one (an earlier lab result); the usual range of the 30 days
* before is not repeated here, because the value tiles below draw it.
*/
export function DayFocusCard({ focus }: { focus: DayFocus }) {
const compare = focus.compare ?? null;
return (
<div
data-slot="day-focus"
@@ -264,7 +260,7 @@ export function dayMeta(
: null;
const bandKey = bands && band ? keyOf(bands, band) : undefined;
const bandText = bandKey ? t(bandKey) : null;
return [score, bandText].filter(Boolean).join(" · ") || null;
return [score, bandText].filter(Boolean).join(", ") || null;
}
case "symptom":
return t("symptoms.intensityPill", { value: meta });
@@ -444,16 +440,11 @@ function ValueTile({
const shown = formatTile(tile);
// The line plots the first row: systolic for a blood pressure.
const lead = tile.values[0];
return (
<li
data-slot="day-value"
data-type={tile.key}
data-selected={selected ? "true" : undefined}
className={cn(
"bg-muted/60 flex min-w-0 flex-col rounded-lg px-3 py-2.5",
selected && "ring-foreground ring-[1.5px] ring-inset",
)}
>
// Like a score tile, a value tile leads to its metric's page; a kind
// without a page of its own stays a plain tile.
const href = metricPageHref(tile.key);
const body = (
<>
<span className="text-muted-foreground truncate text-xs">
{shown.label}
</span>
@@ -485,6 +476,33 @@ function ValueTile({
) : null}
</>
) : null}
</>
);
const frame = cn(
"bg-muted/60 flex min-h-11 min-w-0 flex-col rounded-lg px-3 py-2.5",
selected && "ring-foreground ring-[1.5px] ring-inset",
);
return (
<li
data-slot="day-value"
data-type={tile.key}
data-selected={selected ? "true" : undefined}
className="flex min-w-0"
>
{href ? (
<Link
href={href}
data-slot="day-value-link"
className={cn(
frame,
"hover:bg-muted focus-visible:ring-ring/50 w-full transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
)}
>
{body}
</Link>
) : (
<div className={cn(frame, "w-full")}>{body}</div>
)}
</li>
);
}
@@ -590,11 +608,16 @@ export function DayEvents({ events }: { events: readonly DayEvent[] }) {
data-slot="day-event"
data-kind={event.kind}
className={cn(
"relative grid grid-cols-[2.75rem_1.25rem_minmax(0,1fr)_1rem] items-start gap-x-2.5 py-2.5",
// The time column fits "08:10 AM" and "오전 08:10" on one
// line; the time itself never wraps.
"relative grid grid-cols-[3.75rem_1.25rem_minmax(0,1fr)_1rem] items-start gap-x-2.5 py-2.5",
event.href && "hover:bg-muted/40 rounded-md",
)}
>
<span className="text-muted-foreground text-xs leading-5 tabular-nums">
<span
data-slot="day-event-time"
className="text-muted-foreground text-xs leading-5 whitespace-nowrap tabular-nums"
>
{event.at ? fmt.time(event.at) : ""}
</span>
<Icon
+4 -27
View File
@@ -40,7 +40,7 @@ import {
DayValues,
} from "./day-sections";
import { DayScores } from "./day-scores";
import { curateDayValues, tileKeyOf } from "./day-values-model";
import { tileKeyOf } from "./day-values-model";
import { shiftDateKey } from "./day-url";
import { useDay, usePrefetchDay } from "./use-day";
import { useDayValueFormat } from "./use-day-value-format";
@@ -124,7 +124,7 @@ export function DayView({
above,
headerClassName,
}: DayViewProps) {
const { t, tCount } = useTranslations();
const { t } = useTranslations();
const longLabel = useLongDayLabel();
// "Mo., 5. Okt. 2026" in the phone sheet's header: a September Thursday
// spelled out in full does not fit beside the arrows at 360 px, and a
@@ -132,7 +132,7 @@ export function DayView({
const shortLabel = useLongDayLabel("short");
const pathname = usePathname();
const day = useDay(date);
const { labelFor, formatTile } = useDayValueFormat();
const { labelFor } = useDayValueFormat();
const timelineOn = useModuleEnabled("timeline");
const coach = useAiCapability("coach");
const capabilities = useRecordCapabilities();
@@ -163,12 +163,6 @@ export function DayView({
data?.notable.filter(
(n) => !(n.type !== null && focusKeys.has(tileKeyOf(n.type))),
) ?? [];
const focusTile =
focus && data
? curateDayValues(data.values, focusTypes).curated.find((tile) =>
focusKeys.has(tile.key),
)
: undefined;
const isEmpty =
data !== undefined &&
data.values.length === 0 &&
@@ -178,12 +172,6 @@ export function DayView({
const notShared = data
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
: false;
const meta = data
? [
tCount("day.countValues", data.counts.values),
tCount("day.countEntries", data.counts.entries),
].join(", ")
: null;
const compact = shell === "bottom";
const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
@@ -299,20 +287,9 @@ export function DayView({
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-4 pb-6",
)}
>
{/* What the day holds, as meta under the header. The line keeps its
height while the day loads, so nothing below it moves. */}
<p
className="text-muted-foreground -mb-3 text-xs tabular-nums"
data-slot="day-meta"
>
{meta ?? <span className="invisible">0</span>}
</p>
{focus ? (
<div className="space-y-2.5">
<DayFocusCard
focus={focus}
usual={focusTile ? formatTile(focusTile).usual : null}
/>
<DayFocusCard focus={focus} />
<DayNotableLines
notables={focusNotables}
labelFor={labelFor}
@@ -125,7 +125,7 @@ export function AchievementUnlockNotifier({
// card) so the surprise lands.
if (achievement.isHidden) {
toast(t("achievements.hiddenUnlockToast.title"), {
description: `${t(achievement.titleKey)} — ${t(achievement.descriptionKey)}`,
description: `${t(achievement.titleKey)}: ${t(achievement.descriptionKey)}`,
icon: <Sparkles className="size-4" />,
duration: 8000,
});
@@ -194,7 +194,7 @@ describe("<CoincidentDeviationCard>", () => {
expect(html).toContain('data-state="fired"');
expect(html).toContain('data-slot="coincident-factors"');
// The load-bearing framing line.
expect(html).toContain("Possible factors — never a cause");
expect(html).toContain("Possible factors (never a cause)");
expect(html).toContain("not a diagnosis");
// At most amber — never destructive/red.
expect(html).toContain("border-warning");
@@ -226,7 +226,7 @@ describe("<CoincidentDeviationCard>", () => {
expect(html).toContain('data-state="fired"');
// The reframe line replaces the open-ended "possible factors" copy.
expect(html).toContain("the condition you logged");
expect(html).not.toContain("Possible factors — never a cause");
expect(html).not.toContain("Possible factors (never a cause)");
// Annotate, don't escalate: no new destructive colour beyond the amber the
// fired state already carries.
expect(html).not.toContain("text-destructive");
@@ -282,8 +282,8 @@ describe("<MetricTargetSummary>", () => {
expect(html).toContain("Source: ADA 2024 / DDG");
// The i18n label keys resolve to EN context headings (rendered
// upper-cased by the heading style but kept as text content).
expect(html).toContain("Glucose — fasting");
expect(html).toContain("Glucose — post-meal");
expect(html).toContain("Glucose (fasting)");
expect(html).toContain("Glucose (post-meal)");
});
it("converts the glucose bands to mmol/L when the profile prefers it", () => {
@@ -476,7 +476,7 @@ function SortableSectionRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${title}`}
aria-label={`${labels.dragHandle}: ${title}`}
title={labels.dragHandle}
disabled={disabled}
data-slot="insights-edit-section-handle"
@@ -507,7 +507,7 @@ function SortableSectionRow({
<EyeToggle
visible={section.visible}
disabled={disabled || gatedOff}
label={`${section.visible ? labels.hide : labels.show} — ${title}`}
label={`${section.visible ? labels.hide : labels.show}: ${title}`}
onClick={() => onToggle(section.id, !section.visible)}
slot="insights-edit-section-eye"
/>
@@ -316,7 +316,7 @@ export function MetricStatStrip({
role="group"
aria-label={
seriesLabel
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
? `${t("insights.subPage.stats.label")}: ${seriesLabel}`
: t("insights.subPage.stats.label")
}
className="gap-2 py-3 md:py-4"
@@ -140,5 +140,5 @@ export function formatDateRange(
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
return `${dateFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`;
}
@@ -54,14 +54,14 @@ describe("<OfflineBanner>", () => {
// the honest thing.
const en = loadMessages("en");
expect(en.offlineBanner.message).toBe(
"No connection — changes can't be saved until you're back online.",
"No connection: changes can't be saved until you're back online.",
);
});
it("ships the DE copy for `offlineBanner.message`", () => {
const de = loadMessages("de");
expect(de.offlineBanner.message).toBe(
"Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.",
"Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.",
);
});
@@ -105,7 +105,7 @@ describe("<InjectionSitePicker>", () => {
// Exactly one site is recommended → exactly one composed label.
const composed =
html.match(/aria-label="[^"]*— recommended next site"/g) ?? [];
html.match(/aria-label="[^"]*: recommended next site"/g) ?? [];
expect(composed.length).toBe(1);
});
@@ -153,7 +153,7 @@ describe("<InjectionSitePicker>", () => {
// empty history the recommender lands on ABDOMEN_LEFT, so its label
// folds in the "recommended next site" cue.
expect(html).toContain(
'aria-label="Abdomen, lower left — recommended next site"',
'aria-label="Abdomen, lower left: recommended next site"',
);
expect(html).toContain('aria-label="Abdomen, lower right"');
expect(html).toContain('aria-label="Abdomen, upper left"');
@@ -173,7 +173,7 @@ describe("<InjectionSitePicker>", () => {
);
expect(html).toContain(
'aria-label="Bauch, unten links — empfohlene nächste Stelle"',
'aria-label="Bauch, unten links: empfohlene nächste Stelle"',
);
expect(html).toContain('aria-label="Bauch, unten rechts"');
expect(html).toContain('aria-label="Bauch, oben links"');
@@ -7,7 +7,7 @@
* - every column renders from the SAME payloads the cards consume
* (the list row's `nextDueAt` / `stockDosesRemaining`, the batched
* compliance-summary cache entry the cards share);
* - the action buttons are per-row labelled ("Take – {name}") and the
* - the action buttons are per-row labelled ("Take {name}") and the
* row rides the cards' shared intake hook (source-level guard);
* - tri-state sorting semantics incl. `aria-sort` and null-last
* ordering, with `null` sort = the manual order the page passed in;
@@ -254,8 +254,8 @@ describe("<MedicationTable> — structure + shared payloads", () => {
client,
);
expect(html).toContain('aria-label="Take – Ramipril"');
expect(html).toContain('aria-label="Skip – Ramipril"');
expect(html).toContain('aria-label="Take Ramipril"');
expect(html).toContain('aria-label="Skip Ramipril"');
// 44-px mobile floor on the action buttons (shrinks at sm).
expect(html).toContain("size-11 sm:size-9");
});
@@ -345,7 +345,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
expect(html).toContain("opacity-60");
expect(html).toContain("Inactive");
// No take/skip affordance on the inactive row.
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
expect(html).not.toContain('aria-label="Take Amoxicillin"');
});
// v1.29.x — the Today digest's "Log dose" deep-link
@@ -407,7 +407,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
);
expect(html).toContain("Mounjaro");
expect(html).toContain('aria-label="Take – Mounjaro"');
expect(html).toContain('aria-label="Take Mounjaro"');
expect(html).toContain("4 doses");
});
@@ -436,12 +436,12 @@ describe("<MedicationTable> — structure + shared payloads", () => {
client,
);
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
expect(html).not.toContain('aria-label="Skip – Amoxicillin"');
expect(html).not.toContain('aria-label="Take Amoxicillin"');
expect(html).not.toContain('aria-label="Skip Amoxicillin"');
expect(html).toContain('data-slot="medication-table-course-ended-marker"');
expect(html).toContain(">Ended<");
expect(html).toContain('aria-label="Take – Prednisolone"');
expect(html).toContain('aria-label="Skip – Prednisolone"');
expect(html).toContain('aria-label="Take Prednisolone"');
expect(html).toContain('aria-label="Skip Prednisolone"');
});
it("renders an as-needed row: calm marker, no compliance bars (v1.16.11 #316)", () => {
@@ -476,7 +476,7 @@ describe("<MedicationTable> — structure + shared payloads", () => {
// Compliance column shows the en-dash, not bars or a skeleton.
expect(html).not.toContain("Adherence (");
// The quick actions stay — an as-needed dose is loggable inline.
expect(html).toContain('aria-label="Take – Ibuprofen"');
expect(html).toContain('aria-label="Take Ibuprofen"');
// Stock column still renders (inventory tracking works for PRN).
expect(html).toContain("10 doses");
});
@@ -292,7 +292,7 @@ export function LogIntakeDialog({
>
{medications.map((m) => (
<option key={m.id} value={m.id}>
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
{m.dose ? `${m.name} ${formatDose(m.dose, t)}` : m.name}
</option>
))}
</NativeSelect>
@@ -355,7 +355,7 @@ function SortableMedicationRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${medication.name}`}
aria-label={`${labels.dragHandle}: ${medication.name}`}
aria-describedby={dragHintId}
title={labels.dragHandle}
disabled={disabled}
@@ -380,7 +380,7 @@ function SortableMedicationRow({
className="size-11 sm:size-9"
onClick={() => onMove(medication.id, -1)}
disabled={index === 0 || disabled}
aria-label={`${labels.moveUp} — ${medication.name}`}
aria-label={`${labels.moveUp}: ${medication.name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -391,7 +391,7 @@ function SortableMedicationRow({
className="size-11 sm:size-9"
onClick={() => onMove(medication.id, 1)}
disabled={index === total - 1 || disabled}
aria-label={`${labels.moveDown} — ${medication.name}`}
aria-label={`${labels.moveDown}: ${medication.name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
@@ -216,7 +216,7 @@ export function ScheduleHistoryTimeline({
local day (issue #490 audit). */}
<p className="text-muted-foreground text-xs tabular-nums">
{fmt.date(revision.validFrom)}
{" – "}
{"–"}
{fmt.date(revision.validUntil)}
</p>
</div>
@@ -132,7 +132,7 @@ export function InstrumentCard({
type="button"
onClick={onOpenDetail}
data-slot="instrument-card-open"
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
aria-label={`${title}: ${t("mentalHealth.openDetail")}`}
className="focus-visible:ring-ring block w-full rounded-t-xl text-left focus-visible:ring-2 focus-visible:outline-none"
>
<MedicationCardHeader
@@ -180,7 +180,7 @@ function SortableRow({
type="button"
{...attributes}
{...listeners}
aria-label={`${labels.dragHandle} — ${item.name}`}
aria-label={`${labels.dragHandle}: ${item.name}`}
aria-describedby={dragHintId}
title={labels.dragHandle}
data-slot="module-drag-handle"
@@ -205,7 +205,7 @@ function SortableRow({
className="size-11 sm:size-9"
onClick={() => onMove(item.id, -1)}
disabled={index === 0}
aria-label={`${labels.moveUp} — ${item.name}`}
aria-label={`${labels.moveUp}: ${item.name}`}
>
<ArrowUp className="h-4 w-4" />
</Button>
@@ -216,7 +216,7 @@ function SortableRow({
className="size-11 sm:size-9"
onClick={() => onMove(item.id, 1)}
disabled={index === total - 1}
aria-label={`${labels.moveDown} — ${item.name}`}
aria-label={`${labels.moveDown}: ${item.name}`}
>
<ArrowDown className="h-4 w-4" />
</Button>
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more