mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge the final display polish into release/v1.42.0
This commit is contained in:
commit
dcfec1f35d
144 files changed
+4866
-2817
No files matched your search
+241
-12
@@ -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
File diff suppressed because it is too large.
Load diff
+387
-392
File diff suppressed because it is too large.
Load diff
+160
-165
File diff suppressed because it is too large.
Load diff
+309
-314
File diff suppressed because it is too large.
Load diff
+249
-254
File diff suppressed because it is too large.
Load diff
+341
-346
File diff suppressed because it is too large.
Load diff
+383
-388
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":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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?"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user