mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge keyboard shortcuts and the command palette into release/v1.42.0
This commit is contained in:
commit
67d8657336
81 files changed
+4486
-167
No files matched your search
@@ -98,6 +98,14 @@ an earlier fold computed from part of a day.
|
||||
`REINDEX INDEX CONCURRENTLY` per index, largest first.
|
||||
- **Query statistics.** The bundled database loads `pg_stat_statements`;
|
||||
`CREATE EXTENSION` once to read it (`docs/ops/query-statistics.md`).
|
||||
- **Keyboard shortcuts.** `g` then `d`, `m`, `l`, `t`, `i`, `c` or `s` opens
|
||||
a page, `n` the add menu, `[` and `]` step the open day, and `?` (or the
|
||||
account menu) lists them; ignored while typing in a field.
|
||||
- **Command palette.** Cmd+K (Ctrl+K) or the search field in the top bar
|
||||
finds pages, Insights pages, every Settings card by its anchor, module
|
||||
switches and actions (new entry, the Coach, a backup, today, the
|
||||
shortcuts), filtered by modules, AI availability, role and a shared
|
||||
record's grant; typo-tolerant, with recent places when empty.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
/**
|
||||
* v1.42 — the command palette.
|
||||
*
|
||||
* - Cmd/Ctrl+K, "sleep", Enter lands on the sleep page;
|
||||
* - a settings card is a link with its anchor, and the page scrolls to it;
|
||||
* - a module's entry is a switch: Enter sends the module write and the
|
||||
* palette stays open (the write is answered by a route mock, so the
|
||||
* shared account's modules never move);
|
||||
* - the top bar carries the search field on a wide desktop and a
|
||||
* magnifier on a phone, which opens a full-height sheet with the field
|
||||
* focused;
|
||||
* - the open palette passes axe.
|
||||
*
|
||||
* Stable hooks only: `command-palette` (+ `data-variant`),
|
||||
* `command-palette-input`, `command-palette-trigger` (+ `data-variant`),
|
||||
* options by `data-entry`.
|
||||
*/
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
const palette = (page: Page) => page.locator('[data-slot="command-palette"]');
|
||||
const input = (page: Page) =>
|
||||
page.locator('[data-slot="command-palette-input"]');
|
||||
|
||||
async function shellReady(page: Page): Promise<void> {
|
||||
await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("command palette", () => {
|
||||
test.beforeEach(async ({ context }, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"palette journeys run on the desktop project (the phone case resizes)",
|
||||
);
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "en", url: baseURL },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Cmd/Ctrl+K, sleep, Enter opens the sleep page", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
await expect(palette(page)).toHaveAttribute("data-variant", "dialog");
|
||||
await expect(input(page)).toBeFocused();
|
||||
await input(page).fill("sleep");
|
||||
// The best hit is the highlighted option.
|
||||
const active = await input(page).getAttribute("aria-activedescendant");
|
||||
await expect(page.locator(`[id="${active}"]`)).toHaveAttribute(
|
||||
"data-entry",
|
||||
"insights:sleep",
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/insights\/sleep$/);
|
||||
await expect(palette(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a settings card opens at its anchor", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
await input(page).fill("passkeys");
|
||||
await page.locator('[data-entry="settings:security#passkeys"]').click();
|
||||
await expect(page).toHaveURL(/\/settings\/security#passkeys$/);
|
||||
await expect(page.locator("#passkeys")).toBeInViewport({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("a module is a switch; Enter flips it and the palette stays", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const writes: unknown[] = [];
|
||||
await page.route("**/api/auth/me/modules", async (route) => {
|
||||
if (route.request().method() !== "PATCH") return route.fallback();
|
||||
writes.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
modules: { timeline: true },
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
await page.keyboard.press("ControlOrMeta+k");
|
||||
await input(page).fill("timeline");
|
||||
const option = page.locator('[data-entry="module:timeline"]');
|
||||
await expect(option).toHaveAttribute("aria-checked", "false");
|
||||
await option.hover();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(() => writes.length).toBe(1);
|
||||
expect(writes[0]).toMatchObject({ timeline: true });
|
||||
await expect(palette(page)).toBeVisible();
|
||||
await expect(input(page)).toHaveValue("timeline");
|
||||
});
|
||||
|
||||
test("the top bar: a search field on a wide desktop", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
const field = page.locator(
|
||||
'[data-slot="command-palette-trigger"][data-variant="field"]',
|
||||
);
|
||||
await expect(field).toBeVisible();
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="command-palette-trigger"][data-variant="icon"]',
|
||||
),
|
||||
).toBeHidden();
|
||||
await field.click();
|
||||
await expect(palette(page)).toBeVisible();
|
||||
await expect(input(page)).toBeFocused();
|
||||
|
||||
const axe = await new AxeBuilder({ page })
|
||||
.include('[data-slot="command-palette"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(axe.violations).toEqual([]);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(palette(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("on a phone: the magnifier opens a full-height sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
const magnifier = page.locator(
|
||||
'[data-slot="command-palette-trigger"][data-variant="icon"]',
|
||||
);
|
||||
await expect(magnifier).toBeVisible();
|
||||
await magnifier.click();
|
||||
await expect(palette(page)).toHaveAttribute("data-variant", "sheet");
|
||||
await expect(input(page)).toBeFocused();
|
||||
const box = await palette(page).boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(844 - 1);
|
||||
await input(page).fill("weight");
|
||||
await expect(page.locator('[data-entry="insights:weight"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { mockTimeline } from "./utils/mock-timeline";
|
||||
|
||||
/**
|
||||
* v1.42 — the keyboard shortcuts.
|
||||
*
|
||||
* - `g t` opens the timeline while its module is on, and does nothing while
|
||||
* it is off;
|
||||
* - keys typed into a field stay in the field;
|
||||
* - `?` opens the list: a titled dialog that keeps focus inside and closes
|
||||
* on Escape; the account menu opens the same list;
|
||||
* - `n` opens the add menu;
|
||||
* - `[` and `]` step the open day, never past today.
|
||||
*
|
||||
* The module map is the account's real `/api/auth/me` with only `timeline`
|
||||
* rewritten, so the switch is never moved on a shared account. Desktop only:
|
||||
* a phone has no keyboard to press these on.
|
||||
*/
|
||||
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
const TODAY = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
|
||||
function shiftDay(key: string, delta: number): string {
|
||||
const d = new Date(`${key}T12:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + delta);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function withTimelineModule(page: Page, on: boolean): Promise<void> {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
const res = await route.fetch();
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!json?.data?.modules) return route.fulfill({ response: res });
|
||||
json.data.modules.timeline = on;
|
||||
return route.fulfill({
|
||||
response: res,
|
||||
body: JSON.stringify(json),
|
||||
headers: { ...res.headers(), "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The shell is up and its listener registered. */
|
||||
async function shellReady(page: Page): Promise<void> {
|
||||
await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
|
||||
}
|
||||
|
||||
const helpDialog = (page: Page) =>
|
||||
page.getByRole("dialog", { name: "Keyboard shortcuts" });
|
||||
|
||||
test.describe("keyboard shortcuts", () => {
|
||||
test.beforeEach(async ({ context }, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"keyboard journeys run on the desktop project",
|
||||
);
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "en", url: baseURL },
|
||||
]);
|
||||
});
|
||||
|
||||
test("g t opens the timeline while its module is on", async ({ page }) => {
|
||||
await withTimelineModule(page, true);
|
||||
await mockTimeline(page, "ready", TODAY);
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("t");
|
||||
await expect(page).toHaveURL(/\/timeline$/);
|
||||
});
|
||||
|
||||
test("g t does nothing while the timeline module is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await withTimelineModule(page, false);
|
||||
// Every client navigation, in order, so "nothing happened" is a fact
|
||||
// rather than a wait.
|
||||
await page.addInitScript(() => {
|
||||
const log: string[] = [];
|
||||
(window as unknown as { __pushed: string[] }).__pushed = log;
|
||||
const push = history.pushState.bind(history);
|
||||
history.pushState = (state, unused, url) => {
|
||||
if (url != null) log.push(String(url));
|
||||
push(state, unused, url);
|
||||
};
|
||||
});
|
||||
await page.goto("/insights");
|
||||
await shellReady(page);
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("t");
|
||||
// The positive control: the same reader opens a page that is on.
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("d");
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
const pushed = await page.evaluate(
|
||||
() => (window as unknown as { __pushed: string[] }).__pushed,
|
||||
);
|
||||
expect(pushed.some((url) => url.includes("/timeline"))).toBe(false);
|
||||
});
|
||||
|
||||
test("keys typed into a field stay in the field", async ({ page }) => {
|
||||
await withTimelineModule(page, true);
|
||||
await page.goto("/");
|
||||
const main = page.locator("#main-content");
|
||||
await expect(main).toBeVisible({ timeout: 20_000 });
|
||||
// A plain field in the page: the rule is about the focused element, not
|
||||
// about which page carries it.
|
||||
await page.evaluate(() => {
|
||||
const field = document.createElement("input");
|
||||
field.setAttribute("data-testid", "probe-field");
|
||||
field.setAttribute("aria-label", "Probe");
|
||||
document.getElementById("main-content")?.prepend(field);
|
||||
});
|
||||
const field = page.getByTestId("probe-field");
|
||||
await expect(field).toBeVisible();
|
||||
await field.focus();
|
||||
await page.keyboard.type("gt n ? [");
|
||||
await expect(field).toHaveValue("gt n ? [");
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(helpDialog(page)).toHaveCount(0);
|
||||
await expect(page.getByTestId("capture-picker-options")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("? opens a titled list that keeps focus and closes on Escape", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
await page.keyboard.press("Shift+?");
|
||||
const dialog = helpDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(
|
||||
dialog.locator('[data-slot="keyboard-shortcuts"] [data-group]'),
|
||||
).toHaveCount(3);
|
||||
await expect(dialog.locator('[data-shortcut="go-d"] kbd')).toHaveText([
|
||||
"g",
|
||||
"d",
|
||||
]);
|
||||
|
||||
// Focus trap: Tab and Shift+Tab never leave the dialog.
|
||||
for (const step of ["Tab", "Tab", "Tab", "Shift+Tab", "Shift+Tab"]) {
|
||||
await page.keyboard.press(step);
|
||||
expect(
|
||||
await dialog.evaluate((el) => el.contains(document.activeElement)),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// While the list is open, the page's shortcuts are off.
|
||||
await page.keyboard.press("g");
|
||||
await page.keyboard.press("i");
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
|
||||
const axe = await new AxeBuilder({ page })
|
||||
.include('[role="dialog"]')
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(axe.violations).toEqual([]);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the account menu opens the same list", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await shellReady(page);
|
||||
await page
|
||||
.getByRole("button", { name: "User menu" })
|
||||
.locator("visible=true")
|
||||
.first()
|
||||
.click();
|
||||
await page.locator('[data-slot="open-keyboard-shortcuts"]').click();
|
||||
await expect(helpDialog(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("n opens the add menu", async ({ page }) => {
|
||||
await page.goto("/insights");
|
||||
await shellReady(page);
|
||||
await page.keyboard.press("n");
|
||||
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("capture-picker-options")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("[ and ] step the open day, never past today", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await mockDay(page);
|
||||
const yesterday = shiftDay(TODAY, -1);
|
||||
await page.goto(`/insights?day=${yesterday}`);
|
||||
await shellReady(page);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toHaveAttribute("data-shell", "docked");
|
||||
// Focus on the page, not in the panel: docked, the keys still reach it.
|
||||
await page.locator("#main-content").focus();
|
||||
|
||||
await page.keyboard.press("[");
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]day=${shiftDay(TODAY, -2)}`));
|
||||
await page.keyboard.press("]");
|
||||
await page.keyboard.press("]");
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
|
||||
await page.keyboard.press("]");
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
|
||||
});
|
||||
});
|
||||
@@ -12554,6 +12554,52 @@
|
||||
"body": "Titel und Notiz sind danach nicht mehr da."
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Suchen…",
|
||||
"openLabel": "Seiten, Einstellungen und Aktionen suchen",
|
||||
"title": "Suche",
|
||||
"description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.",
|
||||
"placeholder": "Seiten, Einstellungen, Module suchen…",
|
||||
"results": "Treffer",
|
||||
"noResults": "Nichts gefunden für „{query}“.",
|
||||
"moduleOn": "An",
|
||||
"moduleOff": "Aus",
|
||||
"hintMove": "Wählen",
|
||||
"hintOpen": "Öffnen",
|
||||
"hintClose": "Schließen",
|
||||
"groups": {
|
||||
"recent": "Zuletzt",
|
||||
"pages": "Seiten",
|
||||
"insights": "Insights",
|
||||
"settings": "Einstellungen",
|
||||
"modules": "Module",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
"actions": {
|
||||
"capture": "Neuer Eintrag",
|
||||
"coach": "Coach fragen",
|
||||
"backup": "Backup erstellen",
|
||||
"today": "Heute öffnen",
|
||||
"shortcuts": "Tastenkürzel zeigen"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Tastenkürzel",
|
||||
"description": "Tasten, die überall in der App funktionieren, außer während du in ein Feld tippst.",
|
||||
"menuEntry": "Tastenkürzel",
|
||||
"then": "dann",
|
||||
"search": "Suchen",
|
||||
"help": "Diese Liste zeigen",
|
||||
"close": "Dialog oder Bereich schließen",
|
||||
"previousDay": "Vorheriger Tag",
|
||||
"nextDay": "Nächster Tag",
|
||||
"dayHint": "Während ein Tag geöffnet ist.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Aktionen",
|
||||
"day": "Tag"
|
||||
}
|
||||
},
|
||||
"aboutPage": {
|
||||
"eyebrow": "Info",
|
||||
"title": "Über HealthLog",
|
||||
|
||||
@@ -12554,6 +12554,52 @@
|
||||
"body": "Its title and note will be gone."
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Search…",
|
||||
"openLabel": "Search pages, settings and actions",
|
||||
"title": "Search",
|
||||
"description": "Find a page, a setting, a module or an action.",
|
||||
"placeholder": "Search pages, settings, modules…",
|
||||
"results": "Results",
|
||||
"noResults": "Nothing found for “{query}”.",
|
||||
"moduleOn": "On",
|
||||
"moduleOff": "Off",
|
||||
"hintMove": "Move",
|
||||
"hintOpen": "Open",
|
||||
"hintClose": "Close",
|
||||
"groups": {
|
||||
"recent": "Recent",
|
||||
"pages": "Pages",
|
||||
"insights": "Insights",
|
||||
"settings": "Settings",
|
||||
"modules": "Modules",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"actions": {
|
||||
"capture": "New entry",
|
||||
"coach": "Ask the Coach",
|
||||
"backup": "Create a backup",
|
||||
"today": "Open today",
|
||||
"shortcuts": "Show keyboard shortcuts"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Keyboard shortcuts",
|
||||
"description": "Keys that work anywhere in the app, except while you type in a field.",
|
||||
"menuEntry": "Keyboard shortcuts",
|
||||
"then": "then",
|
||||
"search": "Search",
|
||||
"help": "Show this list",
|
||||
"close": "Close a dialog or panel",
|
||||
"previousDay": "Previous day",
|
||||
"nextDay": "Next day",
|
||||
"dayHint": "While a day is open.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Actions",
|
||||
"day": "Day"
|
||||
}
|
||||
},
|
||||
"aboutPage": {
|
||||
"eyebrow": "About",
|
||||
"title": "About HealthLog",
|
||||
|
||||
@@ -12554,6 +12554,52 @@
|
||||
"body": "Su título y su nota desaparecerán."
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Buscar…",
|
||||
"openLabel": "Buscar páginas, ajustes y acciones",
|
||||
"title": "Buscar",
|
||||
"description": "Encuentra una página, un ajuste, un módulo o una acción.",
|
||||
"placeholder": "Buscar páginas, ajustes, módulos…",
|
||||
"results": "Resultados",
|
||||
"noResults": "No hay resultados para «{query}».",
|
||||
"moduleOn": "Activado",
|
||||
"moduleOff": "Desactivado",
|
||||
"hintMove": "Mover",
|
||||
"hintOpen": "Abrir",
|
||||
"hintClose": "Cerrar",
|
||||
"groups": {
|
||||
"recent": "Recientes",
|
||||
"pages": "Páginas",
|
||||
"insights": "Insights",
|
||||
"settings": "Ajustes",
|
||||
"modules": "Módulos",
|
||||
"actions": "Acciones"
|
||||
},
|
||||
"actions": {
|
||||
"capture": "Nueva entrada",
|
||||
"coach": "Preguntar al Coach",
|
||||
"backup": "Crear una copia de seguridad",
|
||||
"today": "Abrir hoy",
|
||||
"shortcuts": "Mostrar atajos de teclado"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Atajos de teclado",
|
||||
"description": "Teclas que funcionan en toda la aplicación, salvo mientras escribes en un campo.",
|
||||
"menuEntry": "Atajos de teclado",
|
||||
"then": "luego",
|
||||
"search": "Buscar",
|
||||
"help": "Mostrar esta lista",
|
||||
"close": "Cerrar un diálogo o panel",
|
||||
"previousDay": "Día anterior",
|
||||
"nextDay": "Día siguiente",
|
||||
"dayHint": "Mientras hay un día abierto.",
|
||||
"groups": {
|
||||
"navigation": "Navegación",
|
||||
"actions": "Acciones",
|
||||
"day": "Día"
|
||||
}
|
||||
},
|
||||
"aboutPage": {
|
||||
"eyebrow": "Información",
|
||||
"title": "Acerca de HealthLog",
|
||||
|
||||
@@ -12554,6 +12554,52 @@
|
||||
"body": "Son titre et sa note disparaîtront."
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Rechercher…",
|
||||
"openLabel": "Rechercher des pages, réglages et actions",
|
||||
"title": "Recherche",
|
||||
"description": "Trouver une page, un réglage, un module ou une action.",
|
||||
"placeholder": "Rechercher des pages, réglages, modules…",
|
||||
"results": "Résultats",
|
||||
"noResults": "Aucun résultat pour « {query} ».",
|
||||
"moduleOn": "Activé",
|
||||
"moduleOff": "Désactivé",
|
||||
"hintMove": "Naviguer",
|
||||
"hintOpen": "Ouvrir",
|
||||
"hintClose": "Fermer",
|
||||
"groups": {
|
||||
"recent": "Récents",
|
||||
"pages": "Pages",
|
||||
"insights": "Insights",
|
||||
"settings": "Paramètres",
|
||||
"modules": "Modules",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"actions": {
|
||||
"capture": "Nouvelle saisie",
|
||||
"coach": "Demander au Coach",
|
||||
"backup": "Créer une sauvegarde",
|
||||
"today": "Ouvrir aujourd’hui",
|
||||
"shortcuts": "Afficher les raccourcis clavier"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Raccourcis clavier",
|
||||
"description": "Des touches qui fonctionnent partout dans l’application, sauf pendant la saisie dans un champ.",
|
||||
"menuEntry": "Raccourcis clavier",
|
||||
"then": "puis",
|
||||
"search": "Rechercher",
|
||||
"help": "Afficher cette liste",
|
||||
"close": "Fermer une boîte de dialogue ou un panneau",
|
||||
"previousDay": "Jour précédent",
|
||||
"nextDay": "Jour suivant",
|
||||
"dayHint": "Lorsqu’un jour est ouvert.",
|
||||
"groups": {
|
||||
"navigation": "Navigation",
|
||||
"actions": "Actions",
|
||||
"day": "Jour"
|
||||
}
|
||||
},
|
||||
"aboutPage": {
|
||||
"eyebrow": "À propos",
|
||||
"title": "À propos de HealthLog",
|
||||
|
||||
@@ -12554,6 +12554,52 @@
|
||||
"body": "Titolo e nota non ci saranno più."
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Cerca…",
|
||||
"openLabel": "Cerca pagine, impostazioni e azioni",
|
||||
"title": "Cerca",
|
||||
"description": "Trova una pagina, un’impostazione, un modulo o un’azione.",
|
||||
"placeholder": "Cerca pagine, impostazioni, moduli…",
|
||||
"results": "Risultati",
|
||||
"noResults": "Nessun risultato per «{query}».",
|
||||
"moduleOn": "Attivo",
|
||||
"moduleOff": "Disattivo",
|
||||
"hintMove": "Sposta",
|
||||
"hintOpen": "Apri",
|
||||
"hintClose": "Chiudi",
|
||||
"groups": {
|
||||
"recent": "Recenti",
|
||||
"pages": "Pagine",
|
||||
"insights": "Insights",
|
||||
"settings": "Impostazioni",
|
||||
"modules": "Moduli",
|
||||
"actions": "Azioni"
|
||||
},
|
||||
"actions": {
|
||||
"capture": "Nuova voce",
|
||||
"coach": "Chiedi al Coach",
|
||||
"backup": "Crea un backup",
|
||||
"today": "Apri oggi",
|
||||
"shortcuts": "Mostra scorciatoie da tastiera"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Scorciatoie da tastiera",
|
||||
"description": "Tasti che funzionano ovunque nell’app, tranne mentre scrivi in un campo.",
|
||||
"menuEntry": "Scorciatoie da tastiera",
|
||||
"then": "poi",
|
||||
"search": "Cerca",
|
||||
"help": "Mostra questo elenco",
|
||||
"close": "Chiudi una finestra o un pannello",
|
||||
"previousDay": "Giorno precedente",
|
||||
"nextDay": "Giorno successivo",
|
||||
"dayHint": "Quando un giorno è aperto.",
|
||||
"groups": {
|
||||
"navigation": "Navigazione",
|
||||
"actions": "Azioni",
|
||||
"day": "Giorno"
|
||||
}
|
||||
},
|
||||
"aboutPage": {
|
||||
"eyebrow": "Informazioni",
|
||||
"title": "Informazioni su HealthLog",
|
||||
|
||||
@@ -12554,6 +12554,52 @@
|
||||
"body": "제목과 메모가 사라져요."
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "검색…",
|
||||
"openLabel": "페이지, 설정, 작업 검색",
|
||||
"title": "검색",
|
||||
"description": "페이지, 설정, 모듈 또는 작업을 찾습니다.",
|
||||
"placeholder": "페이지, 설정, 모듈 검색…",
|
||||
"results": "결과",
|
||||
"noResults": "“{query}”에 대한 결과가 없습니다.",
|
||||
"moduleOn": "켜짐",
|
||||
"moduleOff": "꺼짐",
|
||||
"hintMove": "이동",
|
||||
"hintOpen": "열기",
|
||||
"hintClose": "닫기",
|
||||
"groups": {
|
||||
"recent": "최근",
|
||||
"pages": "페이지",
|
||||
"insights": "인사이트",
|
||||
"settings": "설정",
|
||||
"modules": "모듈",
|
||||
"actions": "작업"
|
||||
},
|
||||
"actions": {
|
||||
"capture": "새 항목",
|
||||
"coach": "코치에게 묻기",
|
||||
"backup": "백업 만들기",
|
||||
"today": "오늘 열기",
|
||||
"shortcuts": "키보드 단축키 보기"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "키보드 단축키",
|
||||
"description": "입력란에 입력하는 중이 아니면 앱 어디에서나 작동하는 키입니다.",
|
||||
"menuEntry": "키보드 단축키",
|
||||
"then": "다음",
|
||||
"search": "검색",
|
||||
"help": "이 목록 보기",
|
||||
"close": "대화 상자나 패널 닫기",
|
||||
"previousDay": "이전 날",
|
||||
"nextDay": "다음 날",
|
||||
"dayHint": "하루가 열려 있을 때.",
|
||||
"groups": {
|
||||
"navigation": "탐색",
|
||||
"actions": "작업",
|
||||
"day": "하루"
|
||||
}
|
||||
},
|
||||
"aboutPage": {
|
||||
"eyebrow": "정보",
|
||||
"title": "HealthLog 정보",
|
||||
|
||||
@@ -12554,6 +12554,52 @@
|
||||
"body": "Tytuł i notatka znikną."
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"open": "Szukaj…",
|
||||
"openLabel": "Szukaj stron, ustawień i akcji",
|
||||
"title": "Szukaj",
|
||||
"description": "Znajdź stronę, ustawienie, moduł lub akcję.",
|
||||
"placeholder": "Szukaj stron, ustawień, modułów…",
|
||||
"results": "Wyniki",
|
||||
"noResults": "Brak wyników dla „{query}”.",
|
||||
"moduleOn": "Wł.",
|
||||
"moduleOff": "Wył.",
|
||||
"hintMove": "Wybierz",
|
||||
"hintOpen": "Otwórz",
|
||||
"hintClose": "Zamknij",
|
||||
"groups": {
|
||||
"recent": "Ostatnie",
|
||||
"pages": "Strony",
|
||||
"insights": "Insights",
|
||||
"settings": "Ustawienia",
|
||||
"modules": "Moduły",
|
||||
"actions": "Akcje"
|
||||
},
|
||||
"actions": {
|
||||
"capture": "Nowy wpis",
|
||||
"coach": "Zapytaj Coacha",
|
||||
"backup": "Utwórz kopię zapasową",
|
||||
"today": "Otwórz dzisiaj",
|
||||
"shortcuts": "Pokaż skróty klawiszowe"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Skróty klawiszowe",
|
||||
"description": "Klawisze działające w całej aplikacji, z wyjątkiem pisania w polu.",
|
||||
"menuEntry": "Skróty klawiszowe",
|
||||
"then": "potem",
|
||||
"search": "Szukaj",
|
||||
"help": "Pokaż tę listę",
|
||||
"close": "Zamknij okno lub panel",
|
||||
"previousDay": "Poprzedni dzień",
|
||||
"nextDay": "Następny dzień",
|
||||
"dayHint": "Gdy otwarty jest dzień.",
|
||||
"groups": {
|
||||
"navigation": "Nawigacja",
|
||||
"actions": "Akcje",
|
||||
"day": "Dzień"
|
||||
}
|
||||
},
|
||||
"aboutPage": {
|
||||
"eyebrow": "Informacje",
|
||||
"title": "O HealthLog",
|
||||
|
||||
@@ -144,8 +144,8 @@ const RULES: ResolvedFieldRule[] = [
|
||||
allow: {
|
||||
"src/components/settings/ai/disable-coach-card.tsx":
|
||||
"the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value",
|
||||
"src/components/settings/modules-section.tsx":
|
||||
"the module toggle row for the Coach: its checked state is the stored opt-out it writes",
|
||||
"src/hooks/use-module-toggle.ts":
|
||||
"the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes",
|
||||
"src/components/settings/managed-record-settings-section.tsx":
|
||||
"the managed record's own settings form: the field's default is the record's stored opt-out",
|
||||
"src/hooks/use-auth.ts":
|
||||
@@ -169,6 +169,8 @@ const RULES: ResolvedFieldRule[] = [
|
||||
allow: {
|
||||
"src/components/settings/modules-section.tsx":
|
||||
"the Modules hub: it shows the operator-off state of each toggle row, which is what `moduleAvailability` is published for",
|
||||
"src/components/command-palette/use-palette-index.ts":
|
||||
"the command palette's module switches: like the Modules hub it leaves out a switch the operator turned off, which is what `moduleAvailability` is published for",
|
||||
"src/hooks/use-auth.ts":
|
||||
"the `/me` payload normaliser: types and coerces the field, decides nothing",
|
||||
},
|
||||
|
||||
@@ -382,7 +382,6 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 4,
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/settings/modules-section.tsx": { "toast.success": 1 },
|
||||
"src/components/settings/notification-status-card.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Activity, Moon, Plus, Smile } from "lucide-react";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { PaletteEntry } from "@/lib/command-palette/build-index";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { id: "user-1" } }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-module-toggle", () => ({
|
||||
useModuleToggle: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/insights/coach-launch-context", () => ({
|
||||
useCoachLaunch: () => null,
|
||||
}));
|
||||
vi.mock("@/components/day/use-today-key", () => ({
|
||||
useTodayKey: () => "2026-10-09",
|
||||
}));
|
||||
|
||||
const ENTRIES: PaletteEntry[] = [
|
||||
{
|
||||
id: "insights:sleep",
|
||||
group: "insights",
|
||||
title: "Sleep",
|
||||
hint: "Insights",
|
||||
keywords: ["schlaf"],
|
||||
icon: Moon,
|
||||
run: { kind: "href", href: "/insights/sleep" },
|
||||
},
|
||||
{
|
||||
id: "nav:/measurements",
|
||||
group: "pages",
|
||||
title: "Measurements",
|
||||
keywords: [],
|
||||
icon: Activity,
|
||||
run: { kind: "href", href: "/measurements" },
|
||||
},
|
||||
{
|
||||
id: "module:mood",
|
||||
group: "modules",
|
||||
title: "Mood",
|
||||
keywords: [],
|
||||
icon: Smile,
|
||||
run: { kind: "module", module: "mood", enabled: true },
|
||||
},
|
||||
{
|
||||
id: "action:capture",
|
||||
group: "actions",
|
||||
title: "New entry",
|
||||
keywords: [],
|
||||
icon: Plus,
|
||||
run: { kind: "action", action: "capture" },
|
||||
},
|
||||
{
|
||||
id: "action:today",
|
||||
group: "actions",
|
||||
title: "Open today",
|
||||
keywords: [],
|
||||
icon: Plus,
|
||||
run: { kind: "action", action: "today" },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("../use-palette-index", () => ({
|
||||
usePaletteIndex: () => ENTRIES,
|
||||
}));
|
||||
|
||||
import { PaletteBody, arrangeResults } from "../command-palette";
|
||||
|
||||
function render(isMobile = false) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<PaletteBody isMobile={isMobile} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("arrangeResults", () => {
|
||||
it("empty query: recent places first, then the main actions", () => {
|
||||
const sections = arrangeResults("", ENTRIES, ["insights:sleep", "gone"]);
|
||||
expect(sections.map((s) => s.group)).toEqual(["recent", "actions"]);
|
||||
expect(sections[0]?.entries.map((e) => e.id)).toEqual(["insights:sleep"]);
|
||||
expect(sections[1]?.entries.map((e) => e.id)).toEqual([
|
||||
"action:capture",
|
||||
"action:today",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a query groups the hits in the order of each group's best hit", () => {
|
||||
const sections = arrangeResults("schlaf", ENTRIES, []);
|
||||
expect(sections.map((s) => s.group)).toEqual(["insights"]);
|
||||
// "mo": Mood is a prefix (modules), Measurements no match; "o" in
|
||||
// "Open today" is a word start (actions) and ranks below the prefix.
|
||||
const mixed = arrangeResults("mo", ENTRIES, []);
|
||||
expect(mixed.map((s) => s.group)).toEqual(["modules"]);
|
||||
const both = arrangeResults("o", ENTRIES, []);
|
||||
expect(both[0]?.entries[0]?.title).toBe("Open today");
|
||||
});
|
||||
|
||||
it("no hit, no sections", () => {
|
||||
expect(arrangeResults("zzzz", ENTRIES, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<PaletteBody>", () => {
|
||||
it("is a combobox that controls a listbox and points at the first option", () => {
|
||||
const html = render();
|
||||
const controls = html.match(/aria-controls="([^"]+)"/)?.[1];
|
||||
expect(html).toContain('role="combobox"');
|
||||
expect(html).toContain('aria-autocomplete="list"');
|
||||
expect(controls).toBeTruthy();
|
||||
expect(html).toContain(`id="${controls}" role="listbox"`);
|
||||
const active = html.match(/aria-activedescendant="([^"]+)"/)?.[1];
|
||||
expect(html).toContain(`id="${active}" role="option" aria-selected="true"`);
|
||||
});
|
||||
|
||||
it("labels its groups and the field", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('role="group"');
|
||||
expect(html).toContain('aria-label="Search"');
|
||||
expect(html).toContain('placeholder="Search pages, settings, modules…"');
|
||||
expect(html).toContain(">Actions<");
|
||||
});
|
||||
|
||||
it("shows the key hints on a desktop and a Cancel button on a phone", () => {
|
||||
expect(render(false)).toContain("Esc");
|
||||
const phone = render(true);
|
||||
expect(phone).not.toContain(">Esc<");
|
||||
expect(phone).toContain(">Cancel<");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isApplePlatform } from "@/lib/keyboard/global-shortcuts";
|
||||
|
||||
import { openCommandPalette, preloadCommandPalette } from "./palette-store";
|
||||
|
||||
/**
|
||||
* The way into the command palette, at the trailing end of the top bar on
|
||||
* every signed-in page. Where the bar has room it looks like a search field
|
||||
* ("Search…" and the key that opens it); where it has not (a phone, or a
|
||||
* desktop bar narrowed by the sidebar and a docked panel) it is a magnifier
|
||||
* with a tooltip. The switch reads the bar's own width (`@container/topbar`
|
||||
* on the header), not the window's, because a docked panel takes the room
|
||||
* without the window changing.
|
||||
*/
|
||||
export function CommandPaletteTrigger() {
|
||||
const { t } = useTranslations();
|
||||
// The key hint depends on the platform, which the server cannot know.
|
||||
const mounted = useMounted();
|
||||
const apple = mounted && isApplePlatform();
|
||||
const label = t("palette.openLabel");
|
||||
const keyHint = apple ? "⌘K" : "Ctrl K";
|
||||
const shortcut = apple ? "Meta+K" : "Control+K";
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="command-palette-trigger"
|
||||
data-variant="field"
|
||||
aria-label={label}
|
||||
aria-keyshortcuts={mounted ? shortcut : undefined}
|
||||
aria-haspopup="dialog"
|
||||
onClick={openCommandPalette}
|
||||
onPointerEnter={preloadCommandPalette}
|
||||
onFocus={preloadCommandPalette}
|
||||
className="border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 hidden h-9 w-56 items-center gap-2 rounded-md border px-3 text-sm transition-colors outline-none focus-visible:ring-[3px] @2xl/topbar:flex"
|
||||
>
|
||||
<Search className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-left">{t("palette.open")}</span>
|
||||
{mounted ? (
|
||||
<kbd
|
||||
aria-hidden="true"
|
||||
className="border-border bg-muted text-foreground rounded border px-1.5 font-mono text-xs"
|
||||
>
|
||||
{keyHint}
|
||||
</kbd>
|
||||
) : null}
|
||||
</button>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="command-palette-trigger"
|
||||
data-variant="icon"
|
||||
aria-label={label}
|
||||
aria-keyshortcuts={mounted ? shortcut : undefined}
|
||||
aria-haspopup="dialog"
|
||||
onClick={openCommandPalette}
|
||||
onPointerEnter={preloadCommandPalette}
|
||||
onFocus={preloadCommandPalette}
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 @2xl/topbar:hidden"
|
||||
>
|
||||
<Search className="size-5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useCommandPaletteOpen } from "./palette-store";
|
||||
|
||||
const CommandPalette = dynamic(() => import("./command-palette"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Mounted once by the signed-in shell. The palette's own chunk loads the
|
||||
* first time it opens (or when the pointer reaches the top-bar button), and
|
||||
* stays mounted afterwards so a second open is instant.
|
||||
*/
|
||||
export function CommandPaletteMount() {
|
||||
const open = useCommandPaletteOpen();
|
||||
const [wanted, setWanted] = useState(false);
|
||||
if (open && !wanted) setWanted(true);
|
||||
return wanted ? <CommandPalette /> : null;
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { openDay } from "@/components/day/day-layer-controller";
|
||||
import { useTodayKey } from "@/components/day/use-today-key";
|
||||
import {
|
||||
openCapturePicker,
|
||||
openShortcutsHelp,
|
||||
} from "@/components/layout/global-shortcuts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useModuleToggle } from "@/hooks/use-module-toggle";
|
||||
import {
|
||||
EMPTY_STATE_ACTIONS,
|
||||
type PaletteEntry,
|
||||
type PaletteGroup,
|
||||
} from "@/lib/command-palette/build-index";
|
||||
import { rankEntries } from "@/lib/command-palette/rank";
|
||||
import { pushRecent, readRecent } from "@/lib/command-palette/recent";
|
||||
import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store";
|
||||
import { usePaletteIndex } from "./use-palette-index";
|
||||
|
||||
/**
|
||||
* The command palette: one field, and below it the places, settings, module
|
||||
* switches and actions that match, grouped. Arrow keys move, Enter opens,
|
||||
* Escape closes. A module's entry is a switch: Enter flips it and the palette
|
||||
* stays open.
|
||||
*
|
||||
* Accessibility follows the combobox pattern: the field is the
|
||||
* `role="combobox"` that keeps focus, the results are a `listbox` of grouped
|
||||
* `option`s, and `aria-activedescendant` names the highlighted one.
|
||||
*
|
||||
* A centred dialog from 768 px; on a phone a full-height sheet with the field
|
||||
* at the top. That one is a raw `Sheet` rather than `ResponsiveSheet` on
|
||||
* purpose: the responsive primitive's phone branch is a 90 % bottom sheet
|
||||
* that keeps the keyboard down on open, and a search wants the opposite, the
|
||||
* whole height and the keyboard up at once.
|
||||
*
|
||||
* Loaded on first use (`command-palette.lazy.tsx`), so no page pays for it.
|
||||
*/
|
||||
|
||||
/** At most this many hits per group, so one group cannot bury the others. */
|
||||
const PER_GROUP = 7;
|
||||
|
||||
interface Section {
|
||||
group: PaletteGroup;
|
||||
entries: PaletteEntry[];
|
||||
}
|
||||
|
||||
function hashOf(href: string): { pathname: string; hash: string } {
|
||||
const at = href.indexOf("#");
|
||||
return at === -1
|
||||
? { pathname: href, hash: "" }
|
||||
: { pathname: href.slice(0, at), hash: href.slice(at + 1) };
|
||||
}
|
||||
|
||||
export function arrangeResults(
|
||||
query: string,
|
||||
index: ReadonlyArray<PaletteEntry>,
|
||||
recentIds: ReadonlyArray<string>,
|
||||
): Section[] {
|
||||
if (query.trim() === "") {
|
||||
const byId = new Map(index.map((entry) => [entry.id, entry]));
|
||||
const recent = recentIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((entry): entry is PaletteEntry => entry !== undefined);
|
||||
const actions = EMPTY_STATE_ACTIONS.map((id) => byId.get(id)).filter(
|
||||
(entry): entry is PaletteEntry =>
|
||||
entry !== undefined && !recentIds.includes(entry.id),
|
||||
);
|
||||
return [
|
||||
{ group: "recent" as const, entries: recent },
|
||||
{ group: "actions" as const, entries: actions },
|
||||
].filter((section) => section.entries.length > 0);
|
||||
}
|
||||
const ranked = rankEntries(query, index);
|
||||
// Groups appear in the order of their best hit, so the best match is
|
||||
// always the first option.
|
||||
const order: PaletteGroup[] = [];
|
||||
const grouped = new Map<PaletteGroup, PaletteEntry[]>();
|
||||
for (const entry of ranked) {
|
||||
const list = grouped.get(entry.group);
|
||||
if (!list) {
|
||||
order.push(entry.group);
|
||||
grouped.set(entry.group, [entry]);
|
||||
} else if (list.length < PER_GROUP) {
|
||||
list.push(entry);
|
||||
}
|
||||
}
|
||||
return order.map((group) => ({ group, entries: grouped.get(group) ?? [] }));
|
||||
}
|
||||
|
||||
export default function CommandPalette() {
|
||||
const open = useCommandPaletteOpen();
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useTranslations();
|
||||
const title = t("palette.title");
|
||||
const description = t("palette.description");
|
||||
|
||||
const body = open ? (
|
||||
<PaletteBody isMobile={isMobile} key="palette-body" />
|
||||
) : null;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setCommandPaletteOpen}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
data-slot="command-palette"
|
||||
data-variant="sheet"
|
||||
className="h-dvh max-h-dvh gap-0 rounded-none p-0 pt-[env(safe-area-inset-top,0px)]"
|
||||
>
|
||||
<SheetTitle className="sr-only">{title}</SheetTitle>
|
||||
<SheetDescription className="sr-only">{description}</SheetDescription>
|
||||
{body}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setCommandPaletteOpen}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
data-slot="command-palette"
|
||||
data-variant="dialog"
|
||||
className="top-[12vh] flex translate-y-0 flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
{body}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaletteBody({ isMobile }: { isMobile: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const today = useTodayKey();
|
||||
const coachLaunch = useCoachLaunch();
|
||||
const index = usePaletteIndex();
|
||||
const toggle = useModuleToggle();
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const [recentIds] = useState(() => readRecent(user?.id));
|
||||
const listId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const sections = useMemo(
|
||||
() => arrangeResults(query, index, recentIds),
|
||||
[query, index, recentIds],
|
||||
);
|
||||
const flat = useMemo(
|
||||
() => sections.flatMap((section) => section.entries),
|
||||
[sections],
|
||||
);
|
||||
const activeIndex =
|
||||
flat.length === 0 ? -1 : Math.min(active, flat.length - 1);
|
||||
const optionId = (i: number) => `${listId}-option-${i}`;
|
||||
|
||||
// Keep the highlighted option in view as the arrows move through a list
|
||||
// taller than the panel.
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) return;
|
||||
document
|
||||
.getElementById(optionId(activeIndex))
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
// `optionId` is derived from `listId`, which is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeIndex]);
|
||||
|
||||
function run(entry: PaletteEntry) {
|
||||
const target = entry.run;
|
||||
if (target.kind === "module") {
|
||||
// A switch: flip it where it is, and stay.
|
||||
toggle.mutate({ key: target.module, enabled: !target.enabled });
|
||||
return;
|
||||
}
|
||||
setCommandPaletteOpen(false);
|
||||
if (target.kind === "href") {
|
||||
pushRecent(user?.id, entry.id);
|
||||
router.push(target.href);
|
||||
const { pathname, hash } = hashOf(target.href);
|
||||
if (hash) scrollToAnchorWhenReady(hash, { pathname });
|
||||
return;
|
||||
}
|
||||
switch (target.action) {
|
||||
case "capture":
|
||||
openCapturePicker();
|
||||
return;
|
||||
case "coach":
|
||||
if (coachLaunch) coachLaunch.askCoach();
|
||||
else router.push("/coach");
|
||||
return;
|
||||
case "backup": {
|
||||
router.push("/settings/export#full-backup");
|
||||
scrollToAnchorWhenReady("full-backup", {
|
||||
pathname: "/settings/export",
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "today":
|
||||
openDay(today);
|
||||
return;
|
||||
case "shortcuts":
|
||||
openShortcutsHelp();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (flat.length === 0) return;
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
setActive((activeIndex + step + flat.length) % flat.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const entry = flat[activeIndex];
|
||||
if (entry) run(entry);
|
||||
return;
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
setCommandPaletteOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Each section's first position in the flat option list.
|
||||
const starts = sections.reduce<number[]>(
|
||||
(acc, section, i) => [
|
||||
...acc,
|
||||
i === 0 ? 0 : acc[i - 1] + sections[i - 1].entries.length,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col",
|
||||
isMobile ? "h-full" : "max-h-[min(70dvh,34rem)]",
|
||||
)}
|
||||
>
|
||||
<div className="border-border flex shrink-0 items-center gap-2 border-b px-4">
|
||||
<Search
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={flat.length > 0}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
activeIndex >= 0 ? optionId(activeIndex) : undefined
|
||||
}
|
||||
aria-label={t("palette.title")}
|
||||
data-slot="command-palette-input"
|
||||
placeholder={t("palette.placeholder")}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
enterKeyHint="go"
|
||||
className="placeholder:text-muted-foreground h-14 min-w-0 flex-1 bg-transparent text-base outline-none md:text-sm"
|
||||
/>
|
||||
{isMobile ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0"
|
||||
onClick={() => setCommandPaletteOpen(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={t("palette.results")}
|
||||
data-slot="command-palette-results"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2"
|
||||
>
|
||||
{sections.length === 0 ? (
|
||||
<p
|
||||
data-slot="command-palette-empty"
|
||||
className="text-muted-foreground px-3 py-8 text-center text-sm"
|
||||
>
|
||||
{t("palette.noResults", { query: query.trim() })}
|
||||
</p>
|
||||
) : (
|
||||
sections.map((section, sectionIndex) => {
|
||||
const headingId = `${listId}-group-${section.group}`;
|
||||
return (
|
||||
<div
|
||||
key={section.group}
|
||||
role="group"
|
||||
aria-labelledby={headingId}
|
||||
data-group={section.group}
|
||||
className="pb-1"
|
||||
>
|
||||
<div
|
||||
id={headingId}
|
||||
className="text-muted-foreground px-3 pt-3 pb-1 text-xs font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t(`palette.groups.${section.group}`)}
|
||||
</div>
|
||||
{section.entries.map((entry, entryIndex) => {
|
||||
const i = starts[sectionIndex] + entryIndex;
|
||||
const selected = i === activeIndex;
|
||||
const Icon = entry.icon;
|
||||
const isModule = entry.run.kind === "module";
|
||||
const on = entry.run.kind === "module" && entry.run.enabled;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
id={optionId(i)}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-checked={isModule ? on : undefined}
|
||||
data-entry={entry.id}
|
||||
onMouseMove={() => {
|
||||
if (!selected) setActive(i);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
// Keep focus in the field; the click runs the entry.
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => run(entry)}
|
||||
className={cn(
|
||||
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm",
|
||||
selected && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{entry.title}
|
||||
</span>
|
||||
{entry.hint ? (
|
||||
<span className="text-muted-foreground max-w-[40%] shrink-0 truncate text-xs">
|
||||
{entry.hint}
|
||||
</span>
|
||||
) : null}
|
||||
{isModule ? (
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{on
|
||||
? t("palette.moduleOn")
|
||||
: t("palette.moduleOff")}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-state={on ? "checked" : "unchecked"}
|
||||
className="data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-off inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full p-px"
|
||||
>
|
||||
{/* The Switch primitive's thumb, drawn: an
|
||||
option holds no control of its own. */}
|
||||
<span
|
||||
className={cn(
|
||||
"bg-background block size-4 rounded-full transition-transform motion-reduce:transition-none",
|
||||
on
|
||||
? "dark:bg-primary-foreground translate-x-[calc(100%-2px)]"
|
||||
: "dark:bg-foreground",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile ? null : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="border-border text-muted-foreground flex shrink-0 items-center gap-4 border-t px-4 py-2 text-xs"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="border-border bg-muted text-foreground rounded border px-1.5 font-mono">
|
||||
↑↓
|
||||
</kbd>
|
||||
{t("palette.hintMove")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="border-border bg-muted text-foreground rounded border px-1.5 font-mono">
|
||||
↵
|
||||
</kbd>
|
||||
{t("palette.hintOpen")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="border-border bg-muted text-foreground rounded border px-1.5 font-mono">
|
||||
Esc
|
||||
</kbd>
|
||||
{t("palette.hintClose")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Whether the command palette is open, reachable from anywhere: the top-bar
|
||||
* button, Cmd/Ctrl+K in the shortcut handler, and the palette itself. The
|
||||
* palette's code is a separate chunk (`command-palette.lazy.tsx`); this file
|
||||
* is the few lines every page carries.
|
||||
*/
|
||||
let open = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function setCommandPaletteOpen(next: boolean): void {
|
||||
if (open === next) return;
|
||||
open = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function openCommandPalette(): void {
|
||||
setCommandPaletteOpen(true);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useCommandPaletteOpen(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => open,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/** Start loading the palette's chunk ahead of the click (hover, focus). */
|
||||
export function preloadCommandPalette(): void {
|
||||
void import("./command-palette").catch(() => {
|
||||
// The click loads it again and reports a failure there.
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabledState } from "@/hooks/use-module-toggle";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
buildPaletteIndex,
|
||||
type PaletteEntry,
|
||||
} from "@/lib/command-palette/build-index";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
MODULE_REGISTRY,
|
||||
isCodeDisabledModule,
|
||||
moduleDelegatesTo,
|
||||
type ModuleKey,
|
||||
} from "@/lib/modules/registry";
|
||||
import { isSettingsDestinationListedForRecord } from "@/lib/record-settings/classification";
|
||||
|
||||
/**
|
||||
* The palette's index for this session, read from the account payload the
|
||||
* shell already holds. See `buildPaletteIndex` for what is offered when.
|
||||
*/
|
||||
export function usePaletteIndex(): PaletteEntry[] {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord, sections, recordKind, level } = capabilities;
|
||||
const coach = useAiCapability("coach");
|
||||
const isEnabled = useModuleEnabledState();
|
||||
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
|
||||
const moduleAvailability = user?.moduleAvailability;
|
||||
const canCapture =
|
||||
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
|
||||
.length > 0;
|
||||
|
||||
// A module the operator switched off cannot be switched on by the person,
|
||||
// so it is not offered as a switch (Settings → Modules explains why).
|
||||
const moduleToggles = inSharedRecord
|
||||
? []
|
||||
: (Object.keys(MODULE_REGISTRY) as ModuleKey[])
|
||||
.filter((key) => !isCodeDisabledModule(key))
|
||||
.filter((key) =>
|
||||
moduleDelegatesTo(key) === "coach"
|
||||
? coach.reason !== "operator_disabled"
|
||||
: moduleAvailability?.[key] !== false,
|
||||
)
|
||||
.map((key) => ({ key, enabled: isEnabled(key) }));
|
||||
const togglesKey = moduleToggles
|
||||
.map((m) => `${m.key}:${m.enabled ? 1 : 0}`)
|
||||
.join(",");
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
buildPaletteIndex({
|
||||
t,
|
||||
modules: user?.modules,
|
||||
navModules,
|
||||
inSharedRecord,
|
||||
sections,
|
||||
isAdmin: user?.role === "ADMIN",
|
||||
settingsListed: inSharedRecord
|
||||
? (slug) =>
|
||||
isSettingsDestinationListedForRecord(slug, {
|
||||
recordKind,
|
||||
level,
|
||||
manageableDomains: manageableDomains ?? [],
|
||||
})
|
||||
: () => true,
|
||||
canCapture,
|
||||
moduleToggles,
|
||||
}),
|
||||
// `moduleToggles` is rebuilt every render; its content is `togglesKey`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
t,
|
||||
user?.modules,
|
||||
user?.role,
|
||||
navModules,
|
||||
inSharedRecord,
|
||||
sections,
|
||||
recordKind,
|
||||
level,
|
||||
manageableDomains,
|
||||
canCapture,
|
||||
togglesKey,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// The sheet primitive portals through Radix, which renders nothing on the
|
||||
// server; this stand-in keeps its contract (title + description name the
|
||||
// surface) so the list itself can be read. Focus trap and Escape are the
|
||||
// primitive's and are proven in `e2e/keyboard-shortcuts.spec.ts`.
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<section role="dialog" aria-label={String(title)}>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
{children}
|
||||
</section>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { KeyboardShortcutsDialog } from "../keyboard-shortcuts-dialog";
|
||||
|
||||
const EVERYTHING = {
|
||||
navHrefs: ["/", "/medications", "/labs", "/timeline", "/insights", "/coach"],
|
||||
settingsHref: "/settings/account",
|
||||
};
|
||||
|
||||
function render(
|
||||
props: Partial<React.ComponentProps<typeof KeyboardShortcutsDialog>> = {},
|
||||
locale = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale as "en"}>
|
||||
<KeyboardShortcutsDialog
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
offer={EVERYTHING}
|
||||
canCapture
|
||||
{...props}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<KeyboardShortcutsDialog>", () => {
|
||||
it("renders nothing while closed", () => {
|
||||
expect(render({ open: false })).toBe("");
|
||||
});
|
||||
|
||||
it("is titled and lists three groups", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('aria-label="Keyboard shortcuts"');
|
||||
expect(html).toContain('data-group="navigation"');
|
||||
expect(html).toContain('data-group="actions"');
|
||||
expect(html).toContain('data-group="day"');
|
||||
});
|
||||
|
||||
it("shows every destination as g then its key, in kbd", () => {
|
||||
const html = render();
|
||||
for (const k of ["d", "m", "l", "t", "i", "c", "s"]) {
|
||||
expect(html).toContain(`data-shortcut="go-${k}"`);
|
||||
}
|
||||
expect(html).toMatch(/<kbd[^>]*>g<\/kbd>/);
|
||||
expect(html).toContain(">then<");
|
||||
expect(html).toMatch(/<kbd[^>]*>\?<\/kbd>/);
|
||||
expect(html).toMatch(/<kbd[^>]*>\[<\/kbd>/);
|
||||
expect(html).toMatch(/<kbd[^>]*>\]<\/kbd>/);
|
||||
expect(html).toMatch(/<kbd[^>]*>Esc<\/kbd>/);
|
||||
});
|
||||
|
||||
it("drops a destination the session does not offer", () => {
|
||||
const html = render({
|
||||
offer: { navHrefs: ["/", "/insights"], settingsHref: null },
|
||||
});
|
||||
expect(html).toContain('data-shortcut="go-d"');
|
||||
expect(html).toContain('data-shortcut="go-i"');
|
||||
for (const k of ["m", "l", "t", "c", "s"]) {
|
||||
expect(html).not.toContain(`data-shortcut="go-${k}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops n where there is nothing to log", () => {
|
||||
expect(render({ canCapture: false })).not.toContain(
|
||||
'data-shortcut="capture"',
|
||||
);
|
||||
expect(render()).toContain('data-shortcut="capture"');
|
||||
});
|
||||
|
||||
it("uses token classes for the keys, no raw colour", () => {
|
||||
const kbd = render().match(/<kbd class="([^"]+)"/)?.[1] ?? "";
|
||||
expect(kbd).toContain("bg-muted");
|
||||
expect(kbd).toContain("text-foreground");
|
||||
expect(kbd).not.toMatch(/#[0-9a-f]{3,6}|dracula|\[/i);
|
||||
});
|
||||
|
||||
it.each(["de", "es", "fr", "it", "pl", "ko"])(
|
||||
"is translated in %s",
|
||||
(locale) => {
|
||||
const html = render({}, locale);
|
||||
expect(html).not.toContain('aria-label="Keyboard shortcuts"');
|
||||
expect(html).not.toContain("shortcuts.");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -24,6 +24,8 @@ import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { isManageDelegateSettingsDestination } from "@/lib/record-settings/classification";
|
||||
import { isDestinationInSharedRecord } from "./nav-model";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { GlobalShortcuts } from "./global-shortcuts";
|
||||
import { CommandPaletteMount } from "@/components/command-palette/command-palette.lazy";
|
||||
import { DemoBanner } from "./demo-banner";
|
||||
import { OfflineBanner } from "./offline-banner";
|
||||
import { SharedRecordBanner } from "./shared-record-banner";
|
||||
@@ -579,6 +581,13 @@ export function AuthShell({
|
||||
got a raw 403. Demo is read-only by design; the FAB has no job
|
||||
here, so it's mounted only outside demo mode. */}
|
||||
{!demoMode && !inSharedRecord && <LayoutCoachFab />}
|
||||
{/* v1.42 — the keyboard shortcuts (`g d`, `n`, `?` …) and their list.
|
||||
Mounted here and only here, so they never run on the sign-in pages
|
||||
or in the setup flow, which return above. */}
|
||||
<GlobalShortcuts />
|
||||
{/* v1.42 — the command palette (Cmd/Ctrl+K, the top-bar search). Its
|
||||
code loads on first use. */}
|
||||
<CommandPaletteMount />
|
||||
</CoachLaunchProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
import { stepDay, useOpenDay } from "@/components/day/day-layer-controller";
|
||||
import { useTodayKey } from "@/components/day/use-today-key";
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
CapturePicker,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
import { openCommandPalette } from "@/components/command-palette/palette-store";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
isSettingsUtilityDestination,
|
||||
visibleNavDestinations,
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
createShortcutReader,
|
||||
resolveGoTo,
|
||||
shortcutScope,
|
||||
type ShortcutOffer,
|
||||
} from "@/lib/keyboard/global-shortcuts";
|
||||
|
||||
/**
|
||||
* The app-wide keyboard shortcuts, mounted once by the authenticated shell
|
||||
* (never on the sign-in pages or in the setup flow, which the shell renders
|
||||
* without it).
|
||||
*
|
||||
* `g` + a key opens a page, `n` the add menu, `[` / `]` step the open day,
|
||||
* `?` the list of all of them. Which key does what, and when a key is not
|
||||
* ours, is decided in `src/lib/keyboard/global-shortcuts.ts`; this component
|
||||
* reads the page's state and carries the action out. A destination comes from
|
||||
* the same lists the navigation renders, so `g t` with the timeline module
|
||||
* off, or `g c` without an AI provider, does nothing at all.
|
||||
*
|
||||
* The account menu opens the list too (`openShortcutsHelp`), for anyone who
|
||||
* does not know there is a `?` to press.
|
||||
*/
|
||||
|
||||
// The list is loaded the first time it opens; no page pays for it before.
|
||||
const KeyboardShortcutsDialog = dynamic(
|
||||
() =>
|
||||
import("@/components/layout/keyboard-shortcuts-dialog").then(
|
||||
(m) => m.KeyboardShortcutsDialog,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
let helpOpen = false;
|
||||
const helpListeners = new Set<() => void>();
|
||||
|
||||
function setShortcutsHelpOpen(open: boolean): void {
|
||||
if (helpOpen === open) return;
|
||||
helpOpen = open;
|
||||
for (const listener of helpListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeHelp(listener: () => void): () => void {
|
||||
helpListeners.add(listener);
|
||||
return () => helpListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Open the shortcut list from anywhere inside the signed-in shell. */
|
||||
export function openShortcutsHelp(): void {
|
||||
setShortcutsHelpOpen(true);
|
||||
}
|
||||
|
||||
let captureOpen = false;
|
||||
const captureListeners = new Set<() => void>();
|
||||
|
||||
function setCaptureOpen(open: boolean): void {
|
||||
if (captureOpen === open) return;
|
||||
captureOpen = open;
|
||||
for (const listener of captureListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeCapture(listener: () => void): () => void {
|
||||
captureListeners.add(listener);
|
||||
return () => captureListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Open the add menu (the same picker as the bottom bar's button). */
|
||||
export function openCapturePicker(): void {
|
||||
setCaptureOpen(true);
|
||||
}
|
||||
|
||||
function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
|
||||
const { user } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord, sections, recordKind, level } = capabilities;
|
||||
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
|
||||
const canCapture =
|
||||
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
|
||||
.length > 0;
|
||||
const navHrefs = useMemo(
|
||||
() =>
|
||||
visibleNavDestinations(navModules, true, inSharedRecord, sections).map(
|
||||
(d) => d.href,
|
||||
),
|
||||
[navModules, inSharedRecord, sections],
|
||||
);
|
||||
const settingsHref = useMemo(
|
||||
() =>
|
||||
visibleUtilityDestinations({
|
||||
record: inSharedRecord
|
||||
? { recordKind, level, manageableDomains: manageableDomains ?? [] }
|
||||
: null,
|
||||
}).find(isSettingsUtilityDestination)?.href ?? null,
|
||||
[inSharedRecord, recordKind, level, manageableDomains],
|
||||
);
|
||||
return { navHrefs, settingsHref, canCapture };
|
||||
}
|
||||
|
||||
export function GlobalShortcuts() {
|
||||
const router = useRouter();
|
||||
const open = useSyncExternalStore(
|
||||
subscribeHelp,
|
||||
() => helpOpen,
|
||||
() => false,
|
||||
);
|
||||
const capture = useSyncExternalStore(
|
||||
subscribeCapture,
|
||||
() => captureOpen,
|
||||
() => false,
|
||||
);
|
||||
const offer = useShortcutOffer();
|
||||
const openDay = useOpenDay();
|
||||
const today = useTodayKey();
|
||||
|
||||
// The listener is registered once; it reads the current state from here.
|
||||
const latest = useRef({ offer, openDay, today, router });
|
||||
useEffect(() => {
|
||||
latest.current = { offer, openDay, today, router };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const reader = createShortcutReader();
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const action = reader.read(
|
||||
{
|
||||
key: event.key,
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
altGraph: event.getModifierState?.("AltGraph") ?? false,
|
||||
repeat: event.repeat,
|
||||
isComposing: event.isComposing,
|
||||
target: event.target,
|
||||
},
|
||||
shortcutScope(document),
|
||||
event.timeStamp,
|
||||
);
|
||||
if (action === null || action === "pending") return;
|
||||
const state = latest.current;
|
||||
switch (action.type) {
|
||||
case "go": {
|
||||
const href = resolveGoTo(action.key, state.offer);
|
||||
if (href === null) return;
|
||||
event.preventDefault();
|
||||
state.router.push(href);
|
||||
return;
|
||||
}
|
||||
case "capture":
|
||||
if (!state.offer.canCapture) return;
|
||||
event.preventDefault();
|
||||
setCaptureOpen(true);
|
||||
return;
|
||||
case "palette":
|
||||
event.preventDefault();
|
||||
openCommandPalette();
|
||||
return;
|
||||
case "help":
|
||||
event.preventDefault();
|
||||
setShortcutsHelpOpen(true);
|
||||
return;
|
||||
case "day": {
|
||||
const date = state.openDay;
|
||||
if (date === null) return;
|
||||
event.preventDefault();
|
||||
// No day after today: the layer refuses it, so the step does too.
|
||||
if (action.delta > 0 && date >= state.today) return;
|
||||
stepDay(date, action.delta);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
// Leaving the shell (signing out) leaves the list closed.
|
||||
setShortcutsHelpOpen(false);
|
||||
setCaptureOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mounted from the first open on, so the close animation still plays.
|
||||
const [helpWanted, setHelpWanted] = useState(false);
|
||||
if (open && !helpWanted) setHelpWanted(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
{helpWanted ? (
|
||||
<KeyboardShortcutsDialog
|
||||
open={open}
|
||||
onOpenChange={setShortcutsHelpOpen}
|
||||
offer={offer}
|
||||
canCapture={offer.canCapture}
|
||||
/>
|
||||
) : null}
|
||||
{offer.canCapture ? (
|
||||
<CapturePicker open={capture} onOpenChange={setCaptureOpen} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { NAV_DESTINATIONS } from "@/components/layout/nav-model";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
GO_TO_SHORTCUTS,
|
||||
isApplePlatform,
|
||||
resolveGoTo,
|
||||
type ShortcutOffer,
|
||||
} from "@/lib/keyboard/global-shortcuts";
|
||||
|
||||
/**
|
||||
* The list of keyboard shortcuts, opened with `?` or from the account menu.
|
||||
*
|
||||
* It lists what works for this session: a destination whose module is off,
|
||||
* or a Coach without an AI provider, is not offered by `g` and is not listed
|
||||
* either. Key names are literal keys, not translated words.
|
||||
*/
|
||||
export interface KeyboardShortcutsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
offer: ShortcutOffer;
|
||||
/** Whether `n` has anything to open (a read-only record has not). */
|
||||
canCapture: boolean;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Keys pressed one after the other, or held together with `chord`. */
|
||||
sequence: ReadonlyArray<string>;
|
||||
chord?: boolean;
|
||||
}
|
||||
|
||||
function Kbd({ children }: { children: string }) {
|
||||
// Foreground, not muted: a key name has to be readable at a glance, and
|
||||
// the muted token sits under the contrast floor on the muted wash.
|
||||
return (
|
||||
<kbd className="border-border bg-muted text-foreground inline-flex min-w-6 items-center justify-center rounded border px-1.5 py-0.5 font-mono text-xs">
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
offer,
|
||||
canCapture,
|
||||
}: KeyboardShortcutsDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const navigation: Row[] = GO_TO_SHORTCUTS.flatMap((shortcut) => {
|
||||
if (resolveGoTo(shortcut.key, offer) === null) return [];
|
||||
const tKey =
|
||||
shortcut.destination === "settings"
|
||||
? "nav.settings"
|
||||
: NAV_DESTINATIONS.find((d) => d.href === shortcut.destination)?.tKey;
|
||||
if (!tKey) return [];
|
||||
return [
|
||||
{
|
||||
id: `go-${shortcut.key}`,
|
||||
label: t(tKey),
|
||||
sequence: ["g", shortcut.key],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Rendered only while open, so the platform is the browser's.
|
||||
const mod = isApplePlatform() ? "⌘" : "Ctrl";
|
||||
const actions: Row[] = [
|
||||
{
|
||||
id: "palette",
|
||||
label: t("shortcuts.search"),
|
||||
sequence: [mod, "K"],
|
||||
chord: true,
|
||||
},
|
||||
...(canCapture
|
||||
? [
|
||||
{
|
||||
id: "capture",
|
||||
label: t("nav.capture.title"),
|
||||
sequence: ["n"],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: "help", label: t("shortcuts.help"), sequence: ["?"] },
|
||||
{ id: "close", label: t("shortcuts.close"), sequence: ["Esc"] },
|
||||
];
|
||||
|
||||
const day: Row[] = [
|
||||
{ id: "day-previous", label: t("shortcuts.previousDay"), sequence: ["["] },
|
||||
{ id: "day-next", label: t("shortcuts.nextDay"), sequence: ["]"] },
|
||||
];
|
||||
|
||||
const groups = [
|
||||
{
|
||||
id: "navigation",
|
||||
title: t("shortcuts.groups.navigation"),
|
||||
rows: navigation,
|
||||
},
|
||||
{ id: "actions", title: t("shortcuts.groups.actions"), rows: actions },
|
||||
{
|
||||
id: "day",
|
||||
title: t("shortcuts.groups.day"),
|
||||
hint: t("shortcuts.dayHint"),
|
||||
rows: day,
|
||||
},
|
||||
].filter((group) => group.rows.length > 0);
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("shortcuts.title")}
|
||||
description={t("shortcuts.description")}
|
||||
>
|
||||
<div data-slot="keyboard-shortcuts" className="flex flex-col gap-6">
|
||||
{groups.map((group) => (
|
||||
<section
|
||||
key={group.id}
|
||||
data-group={group.id}
|
||||
aria-labelledby={`shortcuts-group-${group.id}`}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3
|
||||
id={`shortcuts-group-${group.id}`}
|
||||
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
|
||||
>
|
||||
{group.title}
|
||||
</h3>
|
||||
{"hint" in group && group.hint ? (
|
||||
<p className="text-muted-foreground text-xs">{group.hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="flex flex-col gap-2">
|
||||
{group.rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
data-shortcut={row.id}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<dt className="min-w-0 flex-1 text-sm">{row.label}</dt>
|
||||
<dd className="flex shrink-0 items-center gap-1.5">
|
||||
{row.sequence.map((key, index) => (
|
||||
<Fragment key={`${row.id}-${index}`}>
|
||||
{index > 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.chord ? "+" : t("shortcuts.then")}
|
||||
</span>
|
||||
) : null}
|
||||
<Kbd>{key}</Kbd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Keyboard,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Moon,
|
||||
@@ -17,6 +18,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu";
|
||||
import { openShortcutsHelp } from "@/components/layout/global-shortcuts";
|
||||
import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
|
||||
import {
|
||||
isNavDestinationActive,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
@@ -200,6 +203,19 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* v1.42 — the keyboard shortcuts, findable without knowing that `?`
|
||||
lists them. Desktop only, like this menu: the phone's account menu
|
||||
has no keyboard to describe. */}
|
||||
<DropdownMenuItem
|
||||
data-slot="open-keyboard-shortcuts"
|
||||
aria-keyshortcuts="?"
|
||||
onSelect={() => openShortcutsHelp()}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Keyboard className="mr-2 h-4 w-4" />
|
||||
{t("shortcuts.menuEntry")}
|
||||
<DropdownMenuShortcut aria-hidden="true">?</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{/* The about section lives at the end of the settings shell nav;
|
||||
the avatar menu stays focused on account-level actions. */}
|
||||
<DropdownMenuSub>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Shield,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { CommandPaletteTrigger } from "@/components/command-palette/command-palette-trigger";
|
||||
import { AccountSwitcherMenuItems } from "@/components/layout/account-switcher-menu";
|
||||
import {
|
||||
isSettingsUtilityDestination,
|
||||
@@ -89,7 +90,9 @@ export function TopBar() {
|
||||
// sidebar logo band reads too, so the two borders draw one
|
||||
// continuous line. Never restate a height here.
|
||||
className={cn(
|
||||
"bg-card/80 border-border sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
|
||||
// `@container/topbar`: the search control sizes to the bar's own
|
||||
// width, which a docked side panel narrows without the window moving.
|
||||
"bg-card/80 border-border @container/topbar sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
|
||||
SHELL_HEADER_BAND,
|
||||
)}
|
||||
// The status-bar inset of the installed app is the shell's
|
||||
@@ -110,6 +113,11 @@ export function TopBar() {
|
||||
sidebar. */}
|
||||
<TopBarContextOutlet className="shell-desktop:flex hidden min-w-0 flex-1 items-center" />
|
||||
|
||||
{/* v1.42 — the command palette's door, on every signed-in page: a
|
||||
search-field button where the bar has room, a magnifier where it
|
||||
has not (and on a phone). `ml-auto` keeps it at the trailing end. */}
|
||||
<CommandPaletteTrigger />
|
||||
|
||||
{/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
|
||||
keeps them at the trailing edge, right before the mobile avatar
|
||||
menu; `empty:hidden` takes the slot out of the row on every page
|
||||
|
||||
@@ -55,6 +55,12 @@ export interface SettingsCardHeaderProps {
|
||||
* broken. Multi-sentence prose goes in the body as
|
||||
* `<p className="text-sm">` (foreground). */
|
||||
description?: React.ReactNode;
|
||||
/** Optional deep-link anchor: the header carries it as its `id` (with the
|
||||
* shell's `scroll-mt-28`), so `/settings/<section>#<anchor>` lands on this
|
||||
* card. The command palette links every card it lists this way; the
|
||||
* anchors it relies on are pinned in `src/lib/command-palette/
|
||||
* settings-cards.ts`. */
|
||||
anchor?: string;
|
||||
/** Optional right-aligned status surface — typically an
|
||||
* `<IntegrationStatusPill>` or a wrapper around badges. */
|
||||
status?: React.ReactNode;
|
||||
@@ -69,10 +75,18 @@ export function SettingsCardHeader({
|
||||
titleAccessory,
|
||||
description,
|
||||
status,
|
||||
anchor,
|
||||
className,
|
||||
}: SettingsCardHeaderProps) {
|
||||
return (
|
||||
<header className={cn("flex items-start gap-2", className)}>
|
||||
<header
|
||||
id={anchor}
|
||||
className={cn(
|
||||
"flex items-start gap-2",
|
||||
anchor && "scroll-mt-28",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -162,6 +162,7 @@ export function GrantInviteCard() {
|
||||
return (
|
||||
<SettingsCard data-slot="grant-invite-card">
|
||||
<SettingsCardHeader
|
||||
anchor="share-invite"
|
||||
icon={UserPlus}
|
||||
title={t("recordSharing.invite.title")}
|
||||
description={t("recordSharing.invite.description")}
|
||||
|
||||
@@ -48,6 +48,7 @@ export function GrantsGivenCard() {
|
||||
return (
|
||||
<SettingsCard data-slot="grants-given-card">
|
||||
<SettingsCardHeader
|
||||
anchor="shared-by-you"
|
||||
icon={Users}
|
||||
title={t("recordSharing.given.title")}
|
||||
description={t("recordSharing.given.description")}
|
||||
|
||||
@@ -64,6 +64,7 @@ export function GrantsReceivedCard() {
|
||||
return (
|
||||
<SettingsCard data-slot="grants-received-card">
|
||||
<SettingsCardHeader
|
||||
anchor="shared-with-you"
|
||||
icon={FolderOpen}
|
||||
title={t("recordSharing.received.title")}
|
||||
description={t("recordSharing.received.description")}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function ManagedProfileCard() {
|
||||
return (
|
||||
<SettingsCard data-slot="managed-profile-card">
|
||||
<SettingsCardHeader
|
||||
anchor="managed-profiles"
|
||||
icon={UserRoundCog}
|
||||
title={t("recordSharing.managed.title")}
|
||||
description={t("recordSharing.managed.description")}
|
||||
|
||||
@@ -46,6 +46,7 @@ export function RecordActivityCard() {
|
||||
return (
|
||||
<SettingsCard data-slot="record-activity-card">
|
||||
<SettingsCardHeader
|
||||
anchor="record-activity"
|
||||
icon={History}
|
||||
title={t("recordSharing.activity.title")}
|
||||
description={t("recordSharing.activity.description")}
|
||||
|
||||
@@ -108,7 +108,11 @@ export function AvatarSection() {
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader icon={ImageUp} title={t("settings.avatar.title")} />
|
||||
<SettingsCardHeader
|
||||
anchor="avatar"
|
||||
icon={ImageUp}
|
||||
title={t("settings.avatar.title")}
|
||||
/>
|
||||
{/* The accepted formats and limits belong to the sentence that explains
|
||||
what this card does, not to a caption of their own under the buttons.
|
||||
Two strings rather than one merged key: the same wording is the
|
||||
|
||||
@@ -383,7 +383,11 @@ export function AccountSection() {
|
||||
// spinner (reference: admin/coach-feedback-section).
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader icon={User} title={t("settings.profile")} />
|
||||
<SettingsCardHeader
|
||||
anchor="profile"
|
||||
icon={User}
|
||||
title={t("settings.profile")}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
@@ -714,6 +718,7 @@ export function DisplayPreferencesCard({
|
||||
return (
|
||||
<SettingsCard data-testid="settings-display-card">
|
||||
<SettingsCardHeader
|
||||
anchor="display"
|
||||
icon={Languages}
|
||||
title={t("settings.displayCard.title")}
|
||||
description={t("settings.displayCard.description")}
|
||||
|
||||
@@ -100,6 +100,7 @@ function DataResetCard() {
|
||||
The protective gate (confirmation dialog) is unchanged; this
|
||||
is purely a visual-tone fix per the v1.4.43 audit. */}
|
||||
<SettingsCardHeader
|
||||
anchor="danger-zone"
|
||||
icon={Trash2}
|
||||
title={t("settings.dangerZone")}
|
||||
description={t("settings.dangerZoneDescription")}
|
||||
@@ -238,6 +239,7 @@ function AccountDeleteCard() {
|
||||
return (
|
||||
<SettingsCard data-slot="settings-account-delete-card">
|
||||
<SettingsCardHeader
|
||||
anchor="delete-account"
|
||||
icon={Trash2}
|
||||
title={t("settings.deleteAccountCardTitle")}
|
||||
description={t("settings.deleteAccountCardDescription")}
|
||||
|
||||
@@ -129,6 +129,7 @@ export function AiConsentCard({
|
||||
return (
|
||||
<SettingsCard as="section" data-slot="ai-consent">
|
||||
<SettingsCardHeader
|
||||
anchor="ai-consent"
|
||||
icon={ShieldCheck}
|
||||
title={t("settings.ai.consent.title")}
|
||||
description={<Skeleton className="h-4 w-56" />}
|
||||
|
||||
@@ -195,6 +195,7 @@ export function AiInsightsCard({
|
||||
<div className="space-y-6">
|
||||
<SettingsCard data-slot="ai-provider-card">
|
||||
<SettingsCardHeader
|
||||
anchor="ai-provider"
|
||||
icon={Sparkles}
|
||||
title={t("settings.ai.activeProviderHeading")}
|
||||
description={t("settings.ai.providerCardDescription")}
|
||||
|
||||
@@ -91,6 +91,7 @@ export function AutoReadCard() {
|
||||
return (
|
||||
<SettingsCard data-slot="documents-auto-read-card">
|
||||
<SettingsCardHeader
|
||||
anchor="auto-read"
|
||||
icon={ScanText}
|
||||
title={t("settings.ai.autoRead.title")}
|
||||
description={t("settings.ai.autoRead.subLabel")}
|
||||
|
||||
@@ -149,6 +149,7 @@ export function DisableCoachCard({
|
||||
data-testid="settings-disable-coach-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
anchor="coach-activate"
|
||||
icon={MessageCircleHeart}
|
||||
title={t("settings.coach.activate.title")}
|
||||
titleId="settings-coach-activate-title"
|
||||
|
||||
@@ -154,6 +154,7 @@ export function FallbackChainCard({
|
||||
return (
|
||||
<SettingsCard data-testid="ai-fallback-chain">
|
||||
<SettingsCardHeader
|
||||
anchor="provider-chain"
|
||||
icon={ListOrdered}
|
||||
title={t("settings.ai.providerChain.title")}
|
||||
description={t("settings.ai.providerChain.description")}
|
||||
|
||||
@@ -105,6 +105,7 @@ export function ResponseTimeoutCard({
|
||||
return (
|
||||
<SettingsCard as="form" onSubmit={submit} noValidate>
|
||||
<SettingsCardHeader
|
||||
anchor="response-timeout"
|
||||
icon={Timer}
|
||||
title={t("settings.ai.responseTimeoutHeading")}
|
||||
/>
|
||||
|
||||
@@ -222,6 +222,7 @@ export function RuntimeActionsRow({
|
||||
// result) sit ABOVE it, never under it.
|
||||
<SettingsCard data-slot="ai-runtime-card">
|
||||
<SettingsCardHeader
|
||||
anchor="ai-runtime"
|
||||
icon={Activity}
|
||||
title={t("settings.ai.runtimeTitle")}
|
||||
description={lastInsightLine ?? undefined}
|
||||
|
||||
@@ -56,6 +56,7 @@ export function AnamnesisSection() {
|
||||
{(coachEnabled || insightsEnabled) && (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="ai-inclusion"
|
||||
icon={EyeOff}
|
||||
title={t("records.aiInclusion.cardTitle")}
|
||||
description={t("records.aiInclusion.cardDescription")}
|
||||
@@ -67,6 +68,7 @@ export function AnamnesisSection() {
|
||||
{coachEnabled && (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="conditions"
|
||||
icon={HeartPulse}
|
||||
title={t("records.conditions.cardTitle")}
|
||||
description={t("records.conditions.cardDescription")}
|
||||
@@ -87,6 +89,7 @@ export function AnamnesisSection() {
|
||||
data-testid="records-about-me-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
anchor="about-me"
|
||||
icon={NotebookPen}
|
||||
title={t("settings.ai.aboutMe.title")}
|
||||
titleId="records-about-me-title"
|
||||
@@ -98,6 +101,7 @@ export function AnamnesisSection() {
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="profile-facts"
|
||||
icon={ClipboardList}
|
||||
title={t("records.profileFacts.cardTitle")}
|
||||
description={t("records.profileFacts.cardDescription")}
|
||||
@@ -107,6 +111,7 @@ export function AnamnesisSection() {
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="allergies"
|
||||
icon={ShieldAlert}
|
||||
title={t("records.allergies.cardTitle")}
|
||||
description={t("records.allergies.cardDescription")}
|
||||
@@ -122,6 +127,7 @@ export function AnamnesisSection() {
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="family-history"
|
||||
icon={Users}
|
||||
title={t("records.family.cardTitle")}
|
||||
description={t("records.family.cardDescription")}
|
||||
@@ -131,6 +137,7 @@ export function AnamnesisSection() {
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="emergency"
|
||||
icon={Siren}
|
||||
title={t("records.emergency.cardTitle")}
|
||||
description={t("records.emergency.cardDescription")}
|
||||
|
||||
@@ -86,6 +86,7 @@ function MeasurementTokensCard() {
|
||||
endpoint="/api/tokens/measurements"
|
||||
icon={Upload}
|
||||
slot="settings-measurements-token-created"
|
||||
anchor="measurements-token"
|
||||
copy={{
|
||||
title: t("settings.measurementsToken.title"),
|
||||
description: t("settings.measurementsToken.description"),
|
||||
@@ -110,6 +111,7 @@ function WorkoutTokensCard() {
|
||||
endpoint="/api/tokens/workouts"
|
||||
icon={Activity}
|
||||
slot="settings-workouts-token-created"
|
||||
anchor="workouts-token"
|
||||
copy={{
|
||||
title: t("settings.workoutsToken.title"),
|
||||
description: t("settings.workoutsToken.description"),
|
||||
@@ -134,6 +136,7 @@ function DocumentTokensCard() {
|
||||
endpoint="/api/tokens/documents"
|
||||
icon={FileUp}
|
||||
slot="settings-documents-token-created"
|
||||
anchor="documents-token"
|
||||
copy={{
|
||||
title: t("settings.documentsToken.title"),
|
||||
description: t("settings.documentsToken.description"),
|
||||
@@ -151,8 +154,11 @@ function IngestTokenCard({
|
||||
endpoint,
|
||||
icon,
|
||||
slot,
|
||||
anchor,
|
||||
copy,
|
||||
}: {
|
||||
/** Deep-link anchor for the card (`/settings/api#<anchor>`). */
|
||||
anchor: string;
|
||||
endpoint:
|
||||
| "/api/tokens/measurements"
|
||||
| "/api/tokens/workouts"
|
||||
@@ -232,6 +238,7 @@ function IngestTokenCard({
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor={anchor}
|
||||
icon={icon}
|
||||
title={copy.title}
|
||||
description={copy.description}
|
||||
@@ -347,6 +354,7 @@ function ApiEndpointsCard() {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="api-endpoints"
|
||||
icon={Key}
|
||||
title={t("settings.apiEndpointsTitle")}
|
||||
description={t("settings.apiEndpointsDescription")}
|
||||
@@ -469,6 +477,7 @@ function ApiTokensCard() {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="api-tokens"
|
||||
icon={Key}
|
||||
title={t("settings.apiTokens")}
|
||||
description={t("settings.apiTokensDescription")}
|
||||
|
||||
@@ -72,6 +72,7 @@ export function CoachConversationsMemoryCard({
|
||||
data-testid="settings-coach-conversations-card"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
anchor="coach-conversations"
|
||||
icon={MessagesSquare}
|
||||
titleId="settings-coach-conversations-title"
|
||||
title={t("settings.ai.coachConversations.title")}
|
||||
|
||||
@@ -154,6 +154,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
return (
|
||||
<SettingsCard data-slot="coach-prefs-section">
|
||||
<SettingsCardHeader
|
||||
anchor="coach-preferences"
|
||||
icon={SlidersHorizontal}
|
||||
title={t("insights.coach.settingsTitle")}
|
||||
description={t("insights.coach.settingsDescription")}
|
||||
|
||||
@@ -298,6 +298,7 @@ export function EnvironmentSection() {
|
||||
{/* Home location */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="home-location"
|
||||
icon={MapPin}
|
||||
title={t("settings.sections.environment.home.title")}
|
||||
/>
|
||||
@@ -387,6 +388,7 @@ export function EnvironmentSection() {
|
||||
{/* Travel overrides */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="travel"
|
||||
icon={Plane}
|
||||
title={t("settings.sections.environment.travel.title")}
|
||||
/>
|
||||
@@ -540,6 +542,7 @@ export function EnvironmentSection() {
|
||||
{/* Backfill */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="weather-backfill"
|
||||
icon={CloudSun}
|
||||
title={t("settings.sections.environment.backfill.title")}
|
||||
/>
|
||||
@@ -614,6 +617,7 @@ export function EnvironmentSection() {
|
||||
{data && (
|
||||
<SettingsCard data-testid="environment-air-quality">
|
||||
<SettingsCardHeader
|
||||
anchor="air-quality"
|
||||
icon={Wind}
|
||||
title={t("settings.sections.environment.airQuality.title")}
|
||||
status={
|
||||
|
||||
@@ -116,6 +116,8 @@ export function ExportSection() {
|
||||
|
||||
interface ExportCardShellProps {
|
||||
testId: string;
|
||||
/** Deep-link anchor for the card (`/settings/export#<anchor>`). */
|
||||
anchor: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -129,6 +131,7 @@ interface ExportCardShellProps {
|
||||
|
||||
function ExportCardShell({
|
||||
testId,
|
||||
anchor,
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
@@ -143,6 +146,7 @@ function ExportCardShell({
|
||||
className={cn("flex h-full flex-col", outerClassName)}
|
||||
>
|
||||
<SettingsCardHeader
|
||||
anchor={anchor}
|
||||
icon={Icon}
|
||||
title={title}
|
||||
description={description}
|
||||
@@ -215,6 +219,7 @@ function buildQueryString(base: Record<string, string | undefined>): string {
|
||||
|
||||
interface CsvCardProps {
|
||||
testId: string;
|
||||
anchor: string;
|
||||
actionTestId: string;
|
||||
icon: LucideIcon;
|
||||
titleKey: string;
|
||||
@@ -225,6 +230,7 @@ interface CsvCardProps {
|
||||
|
||||
function CsvCard({
|
||||
testId,
|
||||
anchor,
|
||||
actionTestId,
|
||||
icon,
|
||||
titleKey,
|
||||
@@ -260,6 +266,7 @@ function CsvCard({
|
||||
return (
|
||||
<ExportCardShell
|
||||
testId={testId}
|
||||
anchor={anchor}
|
||||
icon={icon}
|
||||
title={t(titleKey)}
|
||||
description={t(descriptionKey)}
|
||||
@@ -301,6 +308,7 @@ function MeasurementsCsvCard() {
|
||||
return (
|
||||
<CsvCard
|
||||
testId="export-card-measurements-csv"
|
||||
anchor="measurements-csv"
|
||||
actionTestId="export-action-measurements-csv"
|
||||
icon={FileSpreadsheet}
|
||||
titleKey="settings.sections.export.cards.measurementsCsv.title"
|
||||
@@ -346,6 +354,7 @@ function MedicationsCsvCard() {
|
||||
return (
|
||||
<ExportCardShell
|
||||
testId="export-card-medications-csv"
|
||||
anchor="medications-csv"
|
||||
icon={Pill}
|
||||
title={t("settings.sections.export.cards.medicationsCsv.title")}
|
||||
description={t(
|
||||
@@ -403,6 +412,7 @@ function MoodCsvCard() {
|
||||
return (
|
||||
<CsvCard
|
||||
testId="export-card-mood-csv"
|
||||
anchor="mood-csv"
|
||||
actionTestId="export-action-mood-csv"
|
||||
icon={Waves}
|
||||
titleKey="settings.sections.export.cards.moodCsv.title"
|
||||
@@ -483,6 +493,7 @@ function FullBackupCard() {
|
||||
return (
|
||||
<ExportCardShell
|
||||
testId="export-card-full-backup"
|
||||
anchor="full-backup"
|
||||
icon={FileJson}
|
||||
title={t("settings.sections.export.cards.fullBackup.title")}
|
||||
description={t("settings.sections.export.cards.fullBackup.description")}
|
||||
|
||||
@@ -66,6 +66,7 @@ export function GlucoseReferenceCard() {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="glucose-reference"
|
||||
icon={Droplet}
|
||||
title={t("settings.glucoseReference.title")}
|
||||
description={t("settings.glucoseReference.description")}
|
||||
|
||||
@@ -396,6 +396,7 @@ export function AppleHealthImportCard() {
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-apple-health"
|
||||
anchor="import-apple-health"
|
||||
icon={Upload}
|
||||
title={t("settings.sections.export.import.appleHealth.title")}
|
||||
description={t("settings.sections.export.import.appleHealth.description")}
|
||||
|
||||
@@ -108,6 +108,7 @@ export function CsvImportCard() {
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-csv"
|
||||
anchor="import-csv"
|
||||
icon={FileSpreadsheet}
|
||||
title={t("settings.sections.export.import.csv.title")}
|
||||
description={t("settings.sections.export.import.csv.description")}
|
||||
|
||||
@@ -212,6 +212,7 @@ export function DoseHistoryImportCard() {
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-dose-history"
|
||||
anchor="import-dose-history"
|
||||
icon={PillBottle}
|
||||
title={t("settings.sections.export.import.doseHistory.title")}
|
||||
description={t("settings.sections.export.import.doseHistory.description")}
|
||||
|
||||
@@ -194,6 +194,7 @@ export function HealthConnectImportCard() {
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-health-connect"
|
||||
anchor="import-health-connect"
|
||||
icon={Smartphone}
|
||||
title={t("settings.sections.export.import.healthConnect.title")}
|
||||
description={t(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
|
||||
export interface ImportCardShellProps {
|
||||
testId: string;
|
||||
/** Deep-link anchor for the card (`/settings/export#<anchor>`). */
|
||||
anchor: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -13,6 +15,7 @@ export interface ImportCardShellProps {
|
||||
|
||||
export function ImportCardShell({
|
||||
testId,
|
||||
anchor,
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
@@ -20,7 +23,12 @@ export function ImportCardShell({
|
||||
}: ImportCardShellProps) {
|
||||
return (
|
||||
<SettingsCard data-testid={testId} className="flex h-full flex-col">
|
||||
<SettingsCardHeader icon={Icon} title={title} description={description} />
|
||||
<SettingsCardHeader
|
||||
anchor={anchor}
|
||||
icon={Icon}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-3">{children}</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -128,6 +128,7 @@ export function JsonImportCard() {
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-json"
|
||||
anchor="import-json"
|
||||
icon={FileJson}
|
||||
title={t("settings.sections.export.import.json.title")}
|
||||
description={t("settings.sections.export.import.json.description")}
|
||||
|
||||
@@ -120,6 +120,7 @@ function McpConnectionsCard() {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="mcp-connections"
|
||||
icon={Link2}
|
||||
title={t("settings.mcp.connectionsTitle")}
|
||||
description={t("settings.mcp.connectionsDescription")}
|
||||
@@ -229,6 +230,7 @@ function McpEnableCard() {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="mcp-enable"
|
||||
icon={Plug}
|
||||
title={t("settings.mcp.enableTitle")}
|
||||
description={t("settings.mcp.enableDescription")}
|
||||
@@ -358,6 +360,7 @@ function McpTokensCard() {
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="mcp-tokens"
|
||||
icon={Key}
|
||||
title={t("settings.mcp.tokensTitle")}
|
||||
description={t("settings.mcp.tokensDescription")}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Activity,
|
||||
Award,
|
||||
Brain,
|
||||
CalendarHeart,
|
||||
CloudSun,
|
||||
Droplet,
|
||||
Dumbbell,
|
||||
FileScan,
|
||||
FileText,
|
||||
History,
|
||||
Leaf,
|
||||
MessageCircleHeart,
|
||||
Moon,
|
||||
Pill,
|
||||
Plug,
|
||||
Smile,
|
||||
Sparkles,
|
||||
Syringe,
|
||||
TestTube,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/** Neutral Lucide glyph per toggleable module. */
|
||||
export const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
cycle: CalendarHeart,
|
||||
mood: Smile,
|
||||
sleep: Moon,
|
||||
glucose: Droplet,
|
||||
workouts: Dumbbell,
|
||||
recovery: Activity,
|
||||
labs: TestTube,
|
||||
illness: Thermometer,
|
||||
achievements: Award,
|
||||
coach: MessageCircleHeart,
|
||||
insights: Sparkles,
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
||||
medications: Pill,
|
||||
doctorReport: FileText,
|
||||
// v1.25.0 — the environmental-context module (opt-in weather/daylight feed).
|
||||
environment: CloudSun,
|
||||
// v1.22.0 — the remote MCP endpoint (opt-in connectivity module).
|
||||
mcp: Plug,
|
||||
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (opt-in).
|
||||
inboundDocuments: FileScan,
|
||||
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
|
||||
mentalHealth: Brain,
|
||||
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
|
||||
nutrients: Leaf,
|
||||
// v1.38.0 — the immunization log.
|
||||
vaccinations: Syringe,
|
||||
// v1.42 — the life timeline (opt-in).
|
||||
timeline: History,
|
||||
};
|
||||
@@ -34,51 +34,20 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Activity,
|
||||
Award,
|
||||
Blocks,
|
||||
Brain,
|
||||
CalendarHeart,
|
||||
Droplet,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
FileScan,
|
||||
FileText,
|
||||
History,
|
||||
MessageCircleHeart,
|
||||
Leaf,
|
||||
Moon,
|
||||
Plug,
|
||||
Pill,
|
||||
Smile,
|
||||
Sparkles,
|
||||
Syringe,
|
||||
TestTube,
|
||||
Thermometer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Blocks } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { MODULE_ICONS } from "@/components/settings/module-icons";
|
||||
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
|
||||
import { TimelineReadinessSheet } from "@/components/timeline/readiness-sheet.lazy";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
useModuleEnabledState,
|
||||
useModuleToggle,
|
||||
} from "@/hooks/use-module-toggle";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import {
|
||||
MODULE_REGISTRY,
|
||||
isCodeDisabledModule,
|
||||
@@ -86,129 +55,23 @@ import {
|
||||
type ModuleKey,
|
||||
} from "@/lib/modules/registry";
|
||||
|
||||
/** Shape of the `/api/auth/me/modules` PATCH response (resolved map + token). */
|
||||
type ModulesPatchResult = {
|
||||
modules: Partial<Record<ModuleKey, boolean>>;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
/** Neutral Lucide glyph per toggleable module. */
|
||||
const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
cycle: CalendarHeart,
|
||||
mood: Smile,
|
||||
sleep: Moon,
|
||||
glucose: Droplet,
|
||||
workouts: Dumbbell,
|
||||
recovery: Activity,
|
||||
labs: TestTube,
|
||||
illness: Thermometer,
|
||||
achievements: Award,
|
||||
coach: MessageCircleHeart,
|
||||
insights: Sparkles,
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
||||
medications: Pill,
|
||||
doctorReport: FileText,
|
||||
// v1.25.0 — the environmental-context module (opt-in weather/daylight feed).
|
||||
environment: CloudSun,
|
||||
// v1.22.0 — the remote MCP endpoint (opt-in connectivity module).
|
||||
mcp: Plug,
|
||||
// v1.25.0 (W-DOCS-IN) — inbound clinical documents (opt-in).
|
||||
inboundDocuments: FileScan,
|
||||
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
|
||||
mentalHealth: Brain,
|
||||
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
|
||||
nutrients: Leaf,
|
||||
// v1.38.0 — the immunization log.
|
||||
vaccinations: Syringe,
|
||||
// v1.42 — the life timeline (opt-in).
|
||||
timeline: History,
|
||||
};
|
||||
|
||||
export function ModulesSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const coach = useAiCapability("coach");
|
||||
const queryClient = useQueryClient();
|
||||
// v1.42 (#613) — switching the timeline on opens its readiness inventory:
|
||||
// an honest look at what it can already show, with one link per gap,
|
||||
// before the person goes there. It blocks nothing.
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
|
||||
const modules = user?.modules ?? {};
|
||||
const moduleAvailability = user?.moduleAvailability ?? {};
|
||||
|
||||
const toggle = useMutation({
|
||||
// Factory-routed; the in-repo eslint rule forbids a bare literal here.
|
||||
mutationKey: queryKeys.modulesPrefs(),
|
||||
mutationFn: async (vars: { key: ModuleKey; enabled: boolean }) => {
|
||||
// The delegated modules keep a single source of truth: their switch
|
||||
// drives the canonical column, not the `modulePreferencesJson`
|
||||
// allowlist (the gate ignores the blob for these two keys). Reuse the
|
||||
// existing per-column endpoints rather than inventing a new one.
|
||||
const delegate = moduleDelegatesTo(vars.key);
|
||||
if (delegate === "coach") {
|
||||
// The stored column is `disableCoach` — the inverse of "on".
|
||||
return apiPatch("/api/auth/me/disable-coach", {
|
||||
disableCoach: !vars.enabled,
|
||||
});
|
||||
}
|
||||
if (delegate === "cycle") {
|
||||
// `enabled` maps straight onto `cycleTrackingEnabled`.
|
||||
return apiPatch("/api/auth/me/cycle-prefs", { enabled: vars.enabled });
|
||||
}
|
||||
// DISABLED allowlist: send only the single key the user flipped.
|
||||
//
|
||||
// v1.32.22 (R5b) — echo the optimistic-concurrency token so an
|
||||
// interleaved modules PATCH (another tab, the iOS client) 409s instead of
|
||||
// clobbering. The token rides on `User.updatedAt`; the module map itself
|
||||
// rides on `/auth/me`, and no dedicated GET populates the token, so it is
|
||||
// seeded from each write response below and read back here at mutate
|
||||
// time. The first write of a session is tokenless — the server's
|
||||
// backward-compatible unconditional arm.
|
||||
const result = await apiPatch<ModulesPatchResult>(
|
||||
"/api/auth/me/modules",
|
||||
withBaseToken(
|
||||
{ [vars.key]: vars.enabled },
|
||||
readUpdatedAtToken(queryClient, queryKeys.modulesPrefs()),
|
||||
),
|
||||
);
|
||||
queryClient.setQueryData<{ updatedAt?: string }>(
|
||||
queryKeys.modulesPrefs(),
|
||||
{ updatedAt: result.updatedAt },
|
||||
);
|
||||
return result;
|
||||
},
|
||||
onSuccess: (_data, vars) => {
|
||||
// Re-gate every surface that reads the module map off /auth/me: nav,
|
||||
// Insights pills, dashboard tiles, quick-add, search. The two delegated
|
||||
// keys also feed dedicated settings surfaces, so evict their reads too
|
||||
// (mirrors the canonical Coach / cycle cards' own invalidation).
|
||||
// The module map is also an input of the AI capability answer.
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
if (moduleDelegatesTo(vars.key) === "cycle") {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
|
||||
}
|
||||
toast.success(t("settings.sections.modules.saved"));
|
||||
const toggle = useModuleToggle({
|
||||
onToggled: (vars) => {
|
||||
if (vars.key === "timeline" && vars.enabled) setReadinessOpen(true);
|
||||
},
|
||||
onError: (err) => {
|
||||
// v1.32.22 (R5b) — instant-write disposition: a 409 means the module map
|
||||
// advanced since this switch was based (another tab / the iOS client
|
||||
// wrote in between). Drop the now-stale seeded token and refetch /auth/me
|
||||
// so the switch snaps back to server truth, then nudge — nothing was
|
||||
// clobbered. A retry re-seeds a fresh token from its own response.
|
||||
if (isConflict(err)) {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.modulesPrefs() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("settings.sections.modules.error"));
|
||||
},
|
||||
});
|
||||
const isEnabled = useModuleEnabledState();
|
||||
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` (the former muted-`<p>` intro folded into the
|
||||
@@ -233,19 +96,9 @@ export function ModulesSection() {
|
||||
const def = MODULE_REGISTRY[key];
|
||||
const delegate = moduleDelegatesTo(key);
|
||||
|
||||
// Enabled-state per key. Delegated modules read their canonical
|
||||
// per-user state, NOT the `modulePreferencesJson` allowlist the
|
||||
// gate ignores for them: coach ← `!disableCoach`, cycle ← the
|
||||
// resolved `cycleTrackingEnabled`. Every other module is
|
||||
// default-on unless explicitly `false`.
|
||||
let enabled: boolean;
|
||||
if (delegate === "coach") {
|
||||
enabled = !(user?.disableCoach ?? false);
|
||||
} else if (delegate === "cycle") {
|
||||
enabled = user?.cycleTrackingEnabled ?? false;
|
||||
} else {
|
||||
enabled = modules[key] !== false;
|
||||
}
|
||||
// Enabled-state per key: delegated modules read their canonical
|
||||
// per-user state (see `useModuleEnabledState`).
|
||||
const enabled = isEnabled(key);
|
||||
|
||||
// Operator precedence. A per-user toggle can never re-enable a
|
||||
// module the operator turned off server-wide, so the switch goes
|
||||
|
||||
@@ -128,6 +128,7 @@ export function NotificationStatusCard() {
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="delivery-status"
|
||||
icon={Bell}
|
||||
title={t("settings.notificationStatus.title")}
|
||||
description={t("settings.notificationStatus.description")}
|
||||
|
||||
@@ -72,6 +72,7 @@ export function NutrientIntakeCard() {
|
||||
return (
|
||||
<SettingsCard data-slot="nutrient-intake-card">
|
||||
<SettingsCardHeader
|
||||
anchor="nutrient-intake"
|
||||
icon={Leaf}
|
||||
title={t("settings.sections.sources.nutrients.title")}
|
||||
description={t("settings.sections.sources.nutrients.description", {
|
||||
|
||||
@@ -95,6 +95,7 @@ export function PasswordCard() {
|
||||
used to break through the card's right border on a narrow phone. */}
|
||||
<SettingsCard data-slot="settings-password-card">
|
||||
<SettingsCardHeader
|
||||
anchor="password"
|
||||
icon={Shield}
|
||||
title={t("settings.passwordTitle")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
|
||||
@@ -84,6 +84,7 @@ export function PrivacySection() {
|
||||
{/* Encryption at rest */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="encryption"
|
||||
icon={ShieldCheck}
|
||||
title={t("settings.privacy.encryption.title")}
|
||||
/>
|
||||
@@ -103,6 +104,7 @@ export function PrivacySection() {
|
||||
{/* What's stored */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="stored-data"
|
||||
icon={Database}
|
||||
title={t("settings.privacy.stored.title")}
|
||||
/>
|
||||
@@ -128,6 +130,7 @@ export function PrivacySection() {
|
||||
{/* Retention */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="retention"
|
||||
icon={Clock}
|
||||
title={t("settings.privacy.retention.title")}
|
||||
description={t("settings.privacy.retention.description")}
|
||||
@@ -166,6 +169,7 @@ export function PrivacySection() {
|
||||
{/* Export */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="your-data-export"
|
||||
icon={Download}
|
||||
title={t("settings.privacy.export.title")}
|
||||
description={t("settings.privacy.export.description")}
|
||||
@@ -187,6 +191,7 @@ export function PrivacySection() {
|
||||
{/* Delete / reset — with the honest backup lag disclosure */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="delete-data"
|
||||
icon={Trash2}
|
||||
title={t("settings.privacy.delete.title")}
|
||||
description={t("settings.privacy.delete.description")}
|
||||
@@ -208,6 +213,7 @@ export function PrivacySection() {
|
||||
no longer carries the control it named. */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="privacy-posture"
|
||||
icon={Sparkles}
|
||||
title={t("settings.privacy.posture.title")}
|
||||
description={t("settings.privacy.posture.description")}
|
||||
|
||||
@@ -50,6 +50,7 @@ export function RecordAnamnesisSection() {
|
||||
<div className="space-y-6" data-slot="record-anamnesis-section">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="allergies"
|
||||
icon={ShieldAlert}
|
||||
title={t("records.allergies.cardTitle")}
|
||||
description={t("records.allergies.cardDescription")}
|
||||
@@ -59,6 +60,7 @@ export function RecordAnamnesisSection() {
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="family-history"
|
||||
icon={Users}
|
||||
title={t("records.family.cardTitle")}
|
||||
description={t("records.family.cardDescription")}
|
||||
|
||||
@@ -251,6 +251,7 @@ export function HealthRecordExportPanel() {
|
||||
data-testid="health-record-export-panel"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
anchor="health-record"
|
||||
icon={FileText}
|
||||
titleId="health-record-export-title"
|
||||
title={t("settings.healthRecord.title")}
|
||||
|
||||
@@ -80,6 +80,7 @@ export function SecurityActivityCard({
|
||||
return (
|
||||
<SettingsCard data-slot="settings-security-activity-card">
|
||||
<SettingsCardHeader
|
||||
anchor="security-activity"
|
||||
icon={ShieldCheck}
|
||||
title={
|
||||
<button
|
||||
|
||||
@@ -190,6 +190,7 @@ export function PasskeyListSection({
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="passkeys"
|
||||
icon={Fingerprint}
|
||||
title={t("settings.passkeys")}
|
||||
description={t("settings.passkeysDescription")}
|
||||
|
||||
@@ -193,6 +193,7 @@ export function SecurityKeysCard({
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
anchor="security-keys"
|
||||
icon={Usb}
|
||||
title={t("settings.security.keys.title")}
|
||||
description={t("settings.security.keys.description")}
|
||||
|
||||
@@ -251,6 +251,7 @@ export function TotpCard({
|
||||
return (
|
||||
<SettingsCard data-testid="totp-card">
|
||||
<SettingsCardHeader
|
||||
anchor="two-factor"
|
||||
icon={Smartphone}
|
||||
title={t("settings.security.totp.title")}
|
||||
description={t("settings.security.totp.description")}
|
||||
|
||||
@@ -135,6 +135,7 @@ export function SecuritySessionsCard({
|
||||
return (
|
||||
<SettingsCard data-slot="settings-security-sessions-card">
|
||||
<SettingsCardHeader
|
||||
anchor="sessions"
|
||||
icon={MonitorSmartphone}
|
||||
title={
|
||||
<button
|
||||
|
||||
@@ -160,6 +160,7 @@ function ShareLinksCard() {
|
||||
|
||||
<SettingsCard data-testid="share-links-card">
|
||||
<SettingsCardHeader
|
||||
anchor="share-links-active"
|
||||
icon={List}
|
||||
title={t("settings.sharing.activeTitle")}
|
||||
description={t("settings.sharing.activeDescription")}
|
||||
|
||||
@@ -90,6 +90,7 @@ export function TrustedDevicesCard({
|
||||
return (
|
||||
<SettingsCard data-slot="settings-trusted-devices-card">
|
||||
<SettingsCardHeader
|
||||
anchor="trusted-devices"
|
||||
icon={ShieldCheck}
|
||||
title={t("settings.security.trustedDevices.title")}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
isConflict,
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moduleDelegatesTo, type ModuleKey } from "@/lib/modules/registry";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
/** Shape of the `/api/auth/me/modules` PATCH response (resolved map + token). */
|
||||
type ModulesPatchResult = {
|
||||
modules: Partial<Record<ModuleKey, boolean>>;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export interface ModuleToggleVars {
|
||||
key: ModuleKey;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching a module on or off for one's own record: the one mutation the
|
||||
* Settings → Modules hub and the command palette share.
|
||||
*
|
||||
* Most modules PATCH `/api/auth/me/modules` (a DISABLED allowlist; the
|
||||
* endpoint refuses core keys). The two delegated modules drive their
|
||||
* canonical column instead — coach → `PATCH /api/auth/me/disable-coach`
|
||||
* (`User.disableCoach`, inverted), cycle → `PATCH /api/auth/me/cycle-prefs`
|
||||
* (`cycleTrackingEnabled`) — so there is exactly one source of truth. Every
|
||||
* path then invalidates what reads the module map, so the nav, Insights
|
||||
* pills and dashboard tiles re-gate live.
|
||||
*/
|
||||
export function useModuleToggle(options?: {
|
||||
onToggled?: (vars: ModuleToggleVars) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
// Factory-routed; the in-repo eslint rule forbids a bare literal here.
|
||||
mutationKey: queryKeys.modulesPrefs(),
|
||||
mutationFn: async (vars: ModuleToggleVars) => {
|
||||
const delegate = moduleDelegatesTo(vars.key);
|
||||
if (delegate === "coach") {
|
||||
// The stored column is `disableCoach` — the inverse of "on".
|
||||
return apiPatch("/api/auth/me/disable-coach", {
|
||||
disableCoach: !vars.enabled,
|
||||
});
|
||||
}
|
||||
if (delegate === "cycle") {
|
||||
// `enabled` maps straight onto `cycleTrackingEnabled`.
|
||||
return apiPatch("/api/auth/me/cycle-prefs", { enabled: vars.enabled });
|
||||
}
|
||||
// DISABLED allowlist: send only the single key the user flipped.
|
||||
//
|
||||
// v1.32.22 (R5b) — echo the optimistic-concurrency token so an
|
||||
// interleaved modules PATCH (another tab, the iOS client) 409s instead of
|
||||
// clobbering. The token rides on `User.updatedAt`; the module map itself
|
||||
// rides on `/auth/me`, and no dedicated GET populates the token, so it is
|
||||
// seeded from each write response below and read back here at mutate
|
||||
// time. The first write of a session is tokenless — the server's
|
||||
// backward-compatible unconditional arm.
|
||||
const result = await apiPatch<ModulesPatchResult>(
|
||||
"/api/auth/me/modules",
|
||||
withBaseToken(
|
||||
{ [vars.key]: vars.enabled },
|
||||
readUpdatedAtToken(queryClient, queryKeys.modulesPrefs()),
|
||||
),
|
||||
);
|
||||
queryClient.setQueryData<{ updatedAt?: string }>(
|
||||
queryKeys.modulesPrefs(),
|
||||
{ updatedAt: result.updatedAt },
|
||||
);
|
||||
return result;
|
||||
},
|
||||
onSuccess: (_data, vars) => {
|
||||
// The module map is also an input of the AI capability answer.
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
if (moduleDelegatesTo(vars.key) === "cycle") {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
|
||||
}
|
||||
toast.success(t("settings.sections.modules.saved"));
|
||||
options?.onToggled?.(vars);
|
||||
},
|
||||
onError: (err) => {
|
||||
// v1.32.22 (R5b) — a 409 means the module map advanced since this
|
||||
// switch was based (another tab / the iOS client wrote in between).
|
||||
// Drop the now-stale seeded token and refetch /auth/me so the switch
|
||||
// snaps back to server truth, then nudge — nothing was clobbered.
|
||||
if (isConflict(err)) {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.modulesPrefs() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("settings.sections.modules.error"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a module reads as on for this account, the way the Modules hub
|
||||
* shows it: coach from `!disableCoach`, cycle from `cycleTrackingEnabled`,
|
||||
* every other module default-on unless explicitly `false`.
|
||||
*/
|
||||
export function useModuleEnabledState(): (key: ModuleKey) => boolean {
|
||||
const { user } = useAuth();
|
||||
const modules = user?.modules ?? {};
|
||||
return (key) => {
|
||||
const delegate = moduleDelegatesTo(key);
|
||||
if (delegate === "coach") return !(user?.disableCoach ?? false);
|
||||
if (delegate === "cycle") return user?.cycleTrackingEnabled ?? false;
|
||||
return modules[key] !== false;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildPaletteIndex,
|
||||
type PaletteIndexInput,
|
||||
} from "@/lib/command-palette/build-index";
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
function index(over: Partial<PaletteIndexInput> = {}) {
|
||||
return buildPaletteIndex({
|
||||
t,
|
||||
modules: {},
|
||||
navModules: {},
|
||||
inSharedRecord: false,
|
||||
sections: null,
|
||||
isAdmin: false,
|
||||
settingsListed: () => true,
|
||||
canCapture: true,
|
||||
moduleToggles: [
|
||||
{ key: "mood", enabled: true },
|
||||
{ key: "timeline", enabled: false },
|
||||
],
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
const ids = (entries: ReturnType<typeof index>) => entries.map((e) => e.id);
|
||||
|
||||
describe("buildPaletteIndex — modules", () => {
|
||||
it("a switched-off module takes its page, its Insights page and its cards along", () => {
|
||||
const off = { mood: false, timeline: false, environment: false };
|
||||
const entries = ids(index({ modules: off, navModules: off }));
|
||||
expect(entries).not.toContain("nav:/mood");
|
||||
expect(entries).not.toContain("nav:/timeline");
|
||||
expect(entries).not.toContain("insights:mood");
|
||||
expect(entries).not.toContain("settings:notifications#mood-reminder");
|
||||
expect(entries).not.toContain("settings:environment");
|
||||
expect(entries).not.toContain("settings:environment#home-location");
|
||||
});
|
||||
|
||||
it("and they come back while it is on", () => {
|
||||
const on = { mood: true, timeline: true, environment: true };
|
||||
const entries = ids(index({ modules: on, navModules: on }));
|
||||
expect(entries).toContain("nav:/mood");
|
||||
expect(entries).toContain("nav:/timeline");
|
||||
expect(entries).toContain("insights:mood");
|
||||
expect(entries).toContain("insights:sleep");
|
||||
expect(entries).toContain("settings:notifications#mood-reminder");
|
||||
expect(entries).toContain("settings:environment#home-location");
|
||||
});
|
||||
|
||||
it("a layout sub-page follows its module", () => {
|
||||
expect(ids(index({ modules: { labs: false } }))).not.toContain(
|
||||
"settings:layout/labs",
|
||||
);
|
||||
expect(ids(index({ modules: { labs: true } }))).toContain(
|
||||
"settings:layout/labs",
|
||||
);
|
||||
});
|
||||
|
||||
it("the Coach page and action follow the capability folded into the nav map", () => {
|
||||
const without = ids(index({ navModules: { coach: false } }));
|
||||
expect(without).not.toContain("nav:/coach");
|
||||
expect(without).not.toContain("action:coach");
|
||||
const withIt = ids(index({ navModules: { coach: true } }));
|
||||
expect(withIt).toContain("nav:/coach");
|
||||
expect(withIt).toContain("action:coach");
|
||||
});
|
||||
|
||||
it("offers the switches it is given, with their state", () => {
|
||||
const toggles = index().filter((e) => e.group === "modules");
|
||||
expect(toggles.map((e) => e.run)).toEqual([
|
||||
{ kind: "module", module: "mood", enabled: true },
|
||||
{ kind: "module", module: "timeline", enabled: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPaletteIndex — admin", () => {
|
||||
it("lists the admin pages for an admin only", () => {
|
||||
expect(ids(index()).some((id) => id.startsWith("admin:"))).toBe(false);
|
||||
const admin = index({ isAdmin: true }).filter((e) =>
|
||||
e.id.startsWith("admin:"),
|
||||
);
|
||||
expect(admin.length).toBeGreaterThan(5);
|
||||
expect(admin[0]?.run).toEqual({
|
||||
kind: "href",
|
||||
href: `/admin/${admin[0]?.id.slice("admin:".length)}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPaletteIndex — a shared record", () => {
|
||||
const shared = (over: Partial<PaletteIndexInput> = {}) =>
|
||||
index({
|
||||
inSharedRecord: true,
|
||||
isAdmin: true,
|
||||
moduleToggles: [],
|
||||
settingsListed: (slug) => slug === "anamnesis",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("offers only the doors sharing opens", () => {
|
||||
const entries = ids(shared());
|
||||
expect(entries).toContain("nav:/");
|
||||
expect(entries).not.toContain("nav:/insights");
|
||||
expect(entries).not.toContain("nav:/coach");
|
||||
expect(entries).not.toContain("nav:/notifications");
|
||||
expect(entries.some((id) => id.startsWith("insights:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("never the account around it: no admin, no cards, no switches, no backup", () => {
|
||||
const entries = ids(shared());
|
||||
expect(entries.some((id) => id.startsWith("admin:"))).toBe(false);
|
||||
expect(entries.some((id) => id.includes("#"))).toBe(false);
|
||||
expect(entries.some((id) => id.startsWith("module:"))).toBe(false);
|
||||
expect(entries).not.toContain("action:backup");
|
||||
expect(entries).not.toContain("action:coach");
|
||||
});
|
||||
|
||||
it("lists the Settings pages the record lists, and those only", () => {
|
||||
const settings = shared().filter((e) => e.group === "settings");
|
||||
expect(settings.map((e) => e.id)).toEqual(["settings:anamnesis"]);
|
||||
});
|
||||
|
||||
it("drops the add action when the grant cannot write", () => {
|
||||
expect(ids(shared({ canCapture: false }))).not.toContain("action:capture");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPaletteIndex — links", () => {
|
||||
it("a card links to its section with its anchor", () => {
|
||||
const passkeys = index().find((e) => e.id === "settings:security#passkeys");
|
||||
expect(passkeys?.run).toEqual({
|
||||
kind: "href",
|
||||
href: "/settings/security#passkeys",
|
||||
});
|
||||
expect(passkeys?.hint).toBe("settings.sections.security.title");
|
||||
});
|
||||
|
||||
it("every id is unique", () => {
|
||||
const all = ids(index({ isAdmin: true }));
|
||||
expect(new Set(all).size).toBe(all.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
editDistance,
|
||||
normaliseSearchText,
|
||||
rankEntries,
|
||||
scoreEntry,
|
||||
} from "@/lib/command-palette/rank";
|
||||
|
||||
const e = (title: string, keywords: string[] = []) => ({ title, keywords });
|
||||
|
||||
describe("normaliseSearchText", () => {
|
||||
it("folds case, diacritics and ß", () => {
|
||||
expect(normaliseSearchText(" Größe Gewícht ")).toBe("grosse gewicht");
|
||||
expect(normaliseSearchText("Préférences")).toBe("preferences");
|
||||
});
|
||||
});
|
||||
|
||||
describe("editDistance", () => {
|
||||
it("counts substitutions, insertions, deletions and adjacent swaps", () => {
|
||||
expect(editDistance("sleep", "sleep")).toBe(0);
|
||||
expect(editDistance("slep", "sleep")).toBe(1);
|
||||
expect(editDistance("selep", "sleep")).toBe(1);
|
||||
expect(editDistance("sleap", "sleep")).toBe(1);
|
||||
expect(editDistance("abc", "xyz")).toBe(3);
|
||||
});
|
||||
|
||||
it("stops early past the allowance", () => {
|
||||
expect(editDistance("abcdef", "uvwxyz", 1)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tiers", () => {
|
||||
it("prefix beats word start beats contains beats typo beats subsequence", () => {
|
||||
const prefix = scoreEntry("blood", e("Blood pressure"));
|
||||
const wordStart = scoreEntry("press", e("Blood pressure"));
|
||||
const contains = scoreEntry("ressure", e("Blood pressure"));
|
||||
const typo = scoreEntry("presure", e("Blood pressure"));
|
||||
const subsequence = scoreEntry("bldprs", e("Blood pressure"));
|
||||
expect(prefix).toBeGreaterThan(wordStart);
|
||||
expect(wordStart).toBeGreaterThan(contains);
|
||||
expect(contains).toBeGreaterThan(typo);
|
||||
expect(typo).toBeGreaterThan(subsequence);
|
||||
expect(subsequence).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a multi-word query matches word starts in any order", () => {
|
||||
expect(scoreEntry("pres blo", e("Blood pressure"))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a synonym matches one step below the same match on a title", () => {
|
||||
const title = scoreEntry("weight", e("Weight"));
|
||||
const synonym = scoreEntry("weight", e("Gewicht", ["weight"]));
|
||||
expect(synonym).toBeGreaterThan(0);
|
||||
expect(synonym).toBeLessThan(title);
|
||||
});
|
||||
|
||||
it("nothing for an empty query or no match", () => {
|
||||
expect(scoreEntry("", e("Sleep"))).toBe(0);
|
||||
expect(scoreEntry("zzzz", e("Sleep"))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("typo tolerance", () => {
|
||||
it("one edit from four characters, two from seven", () => {
|
||||
expect(scoreEntry("slep", e("Sleep"))).toBeGreaterThan(0);
|
||||
expect(scoreEntry("medikamnte", e("Medikamente"))).toBeGreaterThan(0);
|
||||
expect(scoreEntry("mdeikamnte", e("Medikamente"))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("no typo allowance under four characters", () => {
|
||||
expect(scoreEntry("slp", e("Sleep"))).toBe(100 - 2);
|
||||
expect(scoreEntry("xle", e("Sleep"))).toBe(0);
|
||||
});
|
||||
|
||||
it("a typo in a partly typed word still finds it", () => {
|
||||
expect(scoreEntry("passkye", e("Passkeys"))).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankEntries", () => {
|
||||
it("orders by tier, then the shorter title", () => {
|
||||
const entries = [
|
||||
e("Resting pulse"),
|
||||
e("Sleep score"),
|
||||
e("Breathing disturbances during sleep"),
|
||||
e("Sleep"),
|
||||
];
|
||||
expect(rankEntries("sleep", entries).map((x) => x.title)).toEqual([
|
||||
"Sleep",
|
||||
"Sleep score",
|
||||
"Breathing disturbances during sleep",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a German title from an English word through its synonym", () => {
|
||||
const entries = [e("Gewicht", ["weight", "kg"]), e("Gehstrecke")];
|
||||
expect(rankEntries("weight", entries)[0]?.title).toBe("Gewicht");
|
||||
});
|
||||
|
||||
it("drops what does not match", () => {
|
||||
expect(rankEntries("labs", [e("Sleep"), e("Weight")])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { requireFloor } from "@/__tests__/helpers/source-files";
|
||||
import en from "../../../../messages/en.json";
|
||||
import {
|
||||
SETTINGS_CARDS,
|
||||
SETTINGS_LAYOUT_PAGES,
|
||||
} from "@/lib/command-palette/settings-cards";
|
||||
|
||||
/**
|
||||
* The palette links each card by an anchor. An anchor that exists nowhere in
|
||||
* the settings sources is a link to the top of the page that says it goes
|
||||
* somewhere else; this reads the sources and fails on it.
|
||||
*/
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) {
|
||||
if (name !== "__tests__") out.push(...sourceFiles(path));
|
||||
} else if (name.endsWith(".tsx") || name.endsWith(".ts")) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A sweep that read nothing would find no anchor and fail loudly anyway; the
|
||||
// floor makes a walker that stopped descending fail by name.
|
||||
const SETTINGS_SRC = requireFloor(
|
||||
"settings source files",
|
||||
sourceFiles(join(process.cwd(), "src/components/settings")),
|
||||
150,
|
||||
)
|
||||
.map((path) => readFileSync(path, "utf8"))
|
||||
.join("\n");
|
||||
|
||||
function hasKey(key: string): boolean {
|
||||
let node: unknown = en;
|
||||
for (const part of key.split(".")) {
|
||||
if (typeof node !== "object" || node === null || !(part in node)) {
|
||||
return false;
|
||||
}
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof node === "string";
|
||||
}
|
||||
|
||||
describe("SETTINGS_CARDS", () => {
|
||||
it.each(SETTINGS_CARDS.map((c) => [c.anchor, c] as const))(
|
||||
"#%s exists in the settings sources",
|
||||
(anchor) => {
|
||||
const patterns = [
|
||||
`anchor="${anchor}"`,
|
||||
`id="${anchor}"`,
|
||||
`anchor: "${anchor}"`,
|
||||
`id: "${anchor}"`,
|
||||
];
|
||||
expect(patterns.some((p) => SETTINGS_SRC.includes(p))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("every title key resolves", () => {
|
||||
const missing = [...SETTINGS_CARDS, ...SETTINGS_LAYOUT_PAGES]
|
||||
.map((c) => c.titleKey)
|
||||
.filter((key) => !hasKey(key));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("anchors are unique within a section", () => {
|
||||
const seen = new Set<string>();
|
||||
for (const card of SETTINGS_CARDS) {
|
||||
const key = `${card.section}#${card.anchor}`;
|
||||
expect(seen.has(key)).toBe(false);
|
||||
seen.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Bell,
|
||||
CalendarDays,
|
||||
ChartLine,
|
||||
DatabaseBackup,
|
||||
Keyboard,
|
||||
MessagesSquare,
|
||||
Palette,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
|
||||
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
|
||||
import { visibleNavDestinations } from "@/components/layout/nav-model";
|
||||
import { MODULE_ICONS } from "@/components/settings/module-icons";
|
||||
import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
|
||||
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
|
||||
import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
import { SETTINGS_CARDS, SETTINGS_LAYOUT_PAGES } from "./settings-cards";
|
||||
import { PALETTE_SYNONYMS } from "./synonyms";
|
||||
|
||||
/**
|
||||
* The command palette's index: every place and action this session may
|
||||
* reach, built on the client from the same lists the navigation renders.
|
||||
*
|
||||
* Nothing here is a second opinion about access. A page comes from
|
||||
* `visibleNavDestinations` (modules, the Coach's AI capability folded into
|
||||
* the map by `useNavModules`, what a shared record covers); an Insights page,
|
||||
* a Settings section and a card from the surface map; an admin page only for
|
||||
* an admin in their own record. Inside somebody else's record the palette
|
||||
* offers the doors sharing opens and the Settings pages the record lists,
|
||||
* and nothing that configures the account around it: no cards, no module
|
||||
* switches, no backup.
|
||||
*/
|
||||
|
||||
export type PaletteGroup =
|
||||
"recent" | "pages" | "insights" | "settings" | "modules" | "actions";
|
||||
|
||||
export type PaletteActionId =
|
||||
"capture" | "coach" | "backup" | "today" | "shortcuts";
|
||||
|
||||
export type PaletteRun =
|
||||
| { kind: "href"; href: string }
|
||||
| { kind: "action"; action: PaletteActionId }
|
||||
| { kind: "module"; module: ModuleKey; enabled: boolean };
|
||||
|
||||
export interface PaletteEntry {
|
||||
id: string;
|
||||
group: Exclude<PaletteGroup, "recent">;
|
||||
title: string;
|
||||
/** Where it sits, shown beside the title (a card's section). */
|
||||
hint?: string;
|
||||
keywords: ReadonlyArray<string>;
|
||||
icon: LucideIcon;
|
||||
run: PaletteRun;
|
||||
}
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export interface PaletteIndexInput {
|
||||
t: Translate;
|
||||
/** The record's resolved module map (`GET /api/auth/me` → `modules`). */
|
||||
modules: SurfaceModuleMap | undefined;
|
||||
/** The same map with `coach` following the AI capability (`useNavModules`). */
|
||||
navModules: SurfaceModuleMap | undefined;
|
||||
inSharedRecord: boolean;
|
||||
/** The shared record's covered sections, when there is one. */
|
||||
sections: ReadonlyArray<ShareDomain> | null;
|
||||
isAdmin: boolean;
|
||||
/**
|
||||
* Whether the Settings shell lists a section for the record on screen.
|
||||
* One's own record: every section. A shared record: the record's own list.
|
||||
*/
|
||||
settingsListed: (slug: string) => boolean;
|
||||
/** Whether the add menu has anything to offer. */
|
||||
canCapture: boolean;
|
||||
/**
|
||||
* The module switches to offer with their state. Empty inside a shared
|
||||
* record (a delegate does not configure the account) and for a module the
|
||||
* operator switched off or code disabled.
|
||||
*/
|
||||
moduleToggles: ReadonlyArray<{ key: ModuleKey; enabled: boolean }>;
|
||||
}
|
||||
|
||||
const INSIGHTS_EXTRA: ReadonlyArray<{
|
||||
slug: string;
|
||||
labelKey: string;
|
||||
surface?: string;
|
||||
}> = [
|
||||
{ slug: "catalog", labelKey: "insights.navCatalog" },
|
||||
{
|
||||
slug: "recovery",
|
||||
labelKey: "insights.navRecovery",
|
||||
surface: "insights-page:recovery",
|
||||
},
|
||||
{ slug: "ecg", labelKey: "insights.navEcg" },
|
||||
];
|
||||
|
||||
function keywordsFor(id: string, extra: ReadonlyArray<string> = []) {
|
||||
return [...extra, ...(PALETTE_SYNONYMS[id] ?? [])];
|
||||
}
|
||||
|
||||
export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] {
|
||||
const { t, modules } = input;
|
||||
const entries: PaletteEntry[] = [];
|
||||
|
||||
// ── Pages: the navigation's own list. ──
|
||||
const nav = visibleNavDestinations(
|
||||
input.navModules,
|
||||
true,
|
||||
input.inSharedRecord,
|
||||
input.sections,
|
||||
);
|
||||
const navHrefs = new Set(nav.map((d) => d.href));
|
||||
for (const d of nav) {
|
||||
const id = `nav:${d.href}`;
|
||||
entries.push({
|
||||
id,
|
||||
group: "pages",
|
||||
title: t(d.tKey),
|
||||
keywords: keywordsFor(id),
|
||||
icon: d.icon,
|
||||
run: { kind: "href", href: d.href },
|
||||
});
|
||||
}
|
||||
if (!input.inSharedRecord) {
|
||||
entries.push({
|
||||
id: "nav:/notifications",
|
||||
group: "pages",
|
||||
title: t("nav.notifications"),
|
||||
keywords: keywordsFor("nav:/notifications"),
|
||||
icon: Bell,
|
||||
run: { kind: "href", href: "/notifications" },
|
||||
});
|
||||
}
|
||||
if (input.isAdmin && !input.inSharedRecord) {
|
||||
const admin = t("nav.admin");
|
||||
for (const section of ADMIN_SECTIONS) {
|
||||
const id = `admin:${section.slug}`;
|
||||
entries.push({
|
||||
id,
|
||||
group: "pages",
|
||||
title: t(section.titleKey),
|
||||
hint: admin,
|
||||
keywords: keywordsFor(id, [admin]),
|
||||
icon: section.icon,
|
||||
run: { kind: "href", href: `/admin/${section.slug}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Insights pages, while the area is offered at all. ──
|
||||
if (navHrefs.has("/insights")) {
|
||||
const area = t("nav.insights");
|
||||
const pages = [
|
||||
...SUB_PAGE_SLUGS.map((slug) => ({
|
||||
slug: slug as string,
|
||||
labelKey: SUB_PAGE_TABS[slug].labelKey,
|
||||
surface: `insights-page:${slug}`,
|
||||
})),
|
||||
...INSIGHTS_EXTRA,
|
||||
];
|
||||
for (const page of pages) {
|
||||
if (page.surface && !isSurfaceVisible(page.surface, modules)) continue;
|
||||
const id = `insights:${page.slug}`;
|
||||
entries.push({
|
||||
id,
|
||||
group: "insights",
|
||||
title: t(page.labelKey),
|
||||
hint: area,
|
||||
keywords: keywordsFor(id),
|
||||
icon: ChartLine,
|
||||
run: { kind: "href", href: `/insights/${page.slug}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings: sections, then each card, then the Appearance pages. ──
|
||||
const sectionTitle = new Map<string, string>();
|
||||
const sectionVisible = new Set<string>();
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
if (!isSurfaceVisible(`settings:${section.slug}`, modules)) continue;
|
||||
if (!input.settingsListed(section.slug)) continue;
|
||||
const title = t(section.titleKey);
|
||||
sectionTitle.set(section.slug, title);
|
||||
sectionVisible.add(section.slug);
|
||||
const id = `settings:${section.slug}`;
|
||||
entries.push({
|
||||
id,
|
||||
group: "settings",
|
||||
title,
|
||||
hint: t("nav.settings"),
|
||||
keywords: keywordsFor(id),
|
||||
icon: section.icon,
|
||||
run: { kind: "href", href: `/settings/${section.slug}` },
|
||||
});
|
||||
}
|
||||
if (!input.inSharedRecord) {
|
||||
for (const card of SETTINGS_CARDS) {
|
||||
if (!sectionVisible.has(card.section)) continue;
|
||||
if (
|
||||
card.modules &&
|
||||
!card.modules.some((key) => modules?.[key] !== false)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const section = sectionTitle.get(card.section) ?? "";
|
||||
const id = `settings:${card.section}#${card.anchor}`;
|
||||
entries.push({
|
||||
id,
|
||||
group: "settings",
|
||||
title: t(card.titleKey),
|
||||
hint: section,
|
||||
keywords: keywordsFor(id, [section]),
|
||||
icon:
|
||||
SETTINGS_SECTIONS.find((s) => s.slug === card.section)?.icon ??
|
||||
Palette,
|
||||
run: {
|
||||
kind: "href",
|
||||
href: `/settings/${card.section}#${card.anchor}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (sectionVisible.has("layout")) {
|
||||
const layout = sectionTitle.get("layout") ?? "";
|
||||
for (const page of SETTINGS_LAYOUT_PAGES) {
|
||||
if (page.module && modules?.[page.module] === false) continue;
|
||||
const id = `settings:layout/${page.slug}`;
|
||||
entries.push({
|
||||
id,
|
||||
group: "settings",
|
||||
title: t(page.titleKey),
|
||||
hint: layout,
|
||||
keywords: keywordsFor(id, [layout]),
|
||||
icon: Palette,
|
||||
run: { kind: "href", href: `/settings/layout/${page.slug}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Module switches. ──
|
||||
for (const toggle of input.moduleToggles) {
|
||||
const def = MODULE_REGISTRY[toggle.key];
|
||||
const id = `module:${toggle.key}`;
|
||||
entries.push({
|
||||
id,
|
||||
group: "modules",
|
||||
title: t(def.labelKey),
|
||||
keywords: keywordsFor(id, [t("settings.sections.modules.title")]),
|
||||
icon: MODULE_ICONS[toggle.key],
|
||||
run: { kind: "module", module: toggle.key, enabled: toggle.enabled },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Actions. ──
|
||||
const action = (
|
||||
id: PaletteActionId,
|
||||
titleKey: string,
|
||||
icon: LucideIcon,
|
||||
): PaletteEntry => ({
|
||||
id: `action:${id}`,
|
||||
group: "actions",
|
||||
title: t(titleKey),
|
||||
keywords: keywordsFor(`action:${id}`),
|
||||
icon,
|
||||
run: { kind: "action", action: id },
|
||||
});
|
||||
if (input.canCapture) {
|
||||
entries.push(action("capture", "palette.actions.capture", Plus));
|
||||
}
|
||||
if (navHrefs.has("/coach") && !input.inSharedRecord) {
|
||||
entries.push(action("coach", "palette.actions.coach", MessagesSquare));
|
||||
}
|
||||
if (!input.inSharedRecord && sectionVisible.has("export")) {
|
||||
entries.push(action("backup", "palette.actions.backup", DatabaseBackup));
|
||||
}
|
||||
entries.push(action("today", "palette.actions.today", CalendarDays));
|
||||
entries.push(action("shortcuts", "palette.actions.shortcuts", Keyboard));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The actions the empty palette offers below the recent places. */
|
||||
export const EMPTY_STATE_ACTIONS: ReadonlyArray<string> = [
|
||||
"action:capture",
|
||||
"action:today",
|
||||
"action:coach",
|
||||
"action:shortcuts",
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Ranking for the command palette: what a typed query matches, and in which
|
||||
* order. Pure and dependency-free, so it can be pinned without a DOM and adds
|
||||
* nothing to a bundle beyond itself.
|
||||
*
|
||||
* Tiers, best first:
|
||||
*
|
||||
* 1. the title is the query, or starts with it;
|
||||
* 2. a word of the title starts with the query (every word of a multi-word
|
||||
* query, each against its own word);
|
||||
* 3. the same two against a synonym;
|
||||
* 4. the query appears inside the title or a synonym;
|
||||
* 5. a typo: a word is within a small edit distance of the query (one edit
|
||||
* from four characters, two from seven, none below that, where a short
|
||||
* query would match half the index);
|
||||
* 6. the query's letters appear in order (`blprs` → "Blood pressure"),
|
||||
* only from three characters.
|
||||
*
|
||||
* Text is compared case-folded and without diacritics, with `ß` as `ss`, so
|
||||
* "Gewicht", "gewicht" and "Gewícht" are one word and "Größe" finds "grosse".
|
||||
*/
|
||||
|
||||
export interface Rankable {
|
||||
title: string;
|
||||
/** Synonyms and related words; matched one tier below the title. */
|
||||
keywords?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
const TIER = {
|
||||
exact: 1000,
|
||||
prefix: 900,
|
||||
wordStart: 750,
|
||||
contains: 500,
|
||||
typo: 300,
|
||||
subsequence: 100,
|
||||
} as const;
|
||||
|
||||
/** A synonym scores this much below the same match on the title. */
|
||||
const KEYWORD_PENALTY = 60;
|
||||
|
||||
/** Case-folded, diacritics removed, `ß` as `ss`, whitespace collapsed. */
|
||||
export function normaliseSearchText(text: string): string {
|
||||
return text
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function words(text: string): string[] {
|
||||
return text.split(/[\s\-–—/(),.:;·&+]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
/** How many edits a query of this length may be away from a word. */
|
||||
function typoAllowance(length: number): number {
|
||||
if (length < 4) return 0;
|
||||
if (length < 7) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimal-string-alignment distance (Levenshtein plus adjacent swaps), with
|
||||
* an early exit once every cell of a row is past `max`.
|
||||
*/
|
||||
export function editDistance(a: string, b: string, max = Infinity): number {
|
||||
if (a === b) return 0;
|
||||
if (Math.abs(a.length - b.length) > max) return max + 1;
|
||||
const rows: number[][] = [];
|
||||
for (let i = 0; i <= a.length; i++) {
|
||||
rows.push(new Array<number>(b.length + 1).fill(0));
|
||||
rows[i][0] = i;
|
||||
}
|
||||
for (let j = 0; j <= b.length; j++) rows[0][j] = j;
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let rowMin = Infinity;
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
let value = Math.min(
|
||||
rows[i - 1][j] + 1,
|
||||
rows[i][j - 1] + 1,
|
||||
rows[i - 1][j - 1] + cost,
|
||||
);
|
||||
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
||||
value = Math.min(value, rows[i - 2][j - 2] + 1);
|
||||
}
|
||||
rows[i][j] = value;
|
||||
if (value < rowMin) rowMin = value;
|
||||
}
|
||||
if (rowMin > max) return max + 1;
|
||||
}
|
||||
return rows[a.length][b.length];
|
||||
}
|
||||
|
||||
function isSubsequence(query: string, text: string): boolean {
|
||||
let at = 0;
|
||||
for (const ch of text) {
|
||||
if (ch === query[at]) at++;
|
||||
if (at === query.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The score of one normalised field against a normalised query; 0 = none. */
|
||||
function scoreField(query: string, field: string): number {
|
||||
if (field === "") return 0;
|
||||
if (field === query) return TIER.exact;
|
||||
if (field.startsWith(query)) return TIER.prefix;
|
||||
|
||||
const fieldWords = words(field);
|
||||
const queryWords = words(query);
|
||||
if (
|
||||
queryWords.length > 0 &&
|
||||
queryWords.every((q) => fieldWords.some((w) => w.startsWith(q)))
|
||||
) {
|
||||
return TIER.wordStart;
|
||||
}
|
||||
if (field.includes(query)) return TIER.contains;
|
||||
|
||||
// A typo: each query word close to a word of the field, or to the start of
|
||||
// one (a typo in the middle of typing: "medik" for "medikamente" is a
|
||||
// prefix, "mdikam" is a typo of its prefix).
|
||||
const typoScore = (() => {
|
||||
let worst = 0;
|
||||
for (const q of queryWords) {
|
||||
const allowed = typoAllowance(q.length);
|
||||
if (allowed === 0) return 0;
|
||||
let best = Infinity;
|
||||
for (const w of fieldWords) {
|
||||
const whole = editDistance(q, w, allowed);
|
||||
const head =
|
||||
w.length > q.length
|
||||
? editDistance(q, w.slice(0, q.length), allowed)
|
||||
: whole;
|
||||
best = Math.min(best, whole, head);
|
||||
}
|
||||
if (best > allowed) return 0;
|
||||
worst = Math.max(worst, best);
|
||||
}
|
||||
return TIER.typo - worst * 40;
|
||||
})();
|
||||
if (typoScore > 0) return typoScore;
|
||||
|
||||
const compact = query.replace(/ /g, "");
|
||||
if (compact.length >= 3 && isSubsequence(compact, field.replace(/ /g, ""))) {
|
||||
// Denser matches first: "bp" letters close together beat letters strewn
|
||||
// across a long title.
|
||||
return TIER.subsequence - Math.min(50, field.length - compact.length);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** How well `entry` matches `query`; 0 means not at all. */
|
||||
export function scoreEntry(query: string, entry: Rankable): number {
|
||||
const q = normaliseSearchText(query);
|
||||
if (q === "") return 0;
|
||||
let best = scoreField(q, normaliseSearchText(entry.title));
|
||||
for (const keyword of entry.keywords ?? []) {
|
||||
const score = scoreField(q, normaliseSearchText(keyword));
|
||||
if (score > 0) best = Math.max(best, score - KEYWORD_PENALTY);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries that match `query`, best first. Ties keep the shorter title
|
||||
* first (the more specific page), then the order they came in.
|
||||
*/
|
||||
export function rankEntries<T extends Rankable>(
|
||||
query: string,
|
||||
entries: ReadonlyArray<T>,
|
||||
): T[] {
|
||||
return entries
|
||||
.map((entry, index) => ({ entry, index, score: scoreEntry(query, entry) }))
|
||||
.filter((hit) => hit.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
a.entry.title.length - b.entry.title.length ||
|
||||
a.index - b.index,
|
||||
)
|
||||
.map((hit) => hit.entry);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The places a person last opened from the palette, per account and per
|
||||
* browser: a convenience for the empty palette, nothing more. Storage can be
|
||||
* missing, full or refused (a private window, a blocked site); every access
|
||||
* is wrapped, and the palette works the same without it.
|
||||
*/
|
||||
|
||||
const PREFIX = "healthlog-palette-recent:";
|
||||
export const RECENT_LIMIT = 5;
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return typeof window === "undefined" ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The ids, newest first, for `account`. */
|
||||
export function readRecent(account: string | null | undefined): string[] {
|
||||
if (!account) return [];
|
||||
try {
|
||||
const raw = storage()?.getItem(PREFIX + account);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Put `id` first, keeping the list short and without repeats. */
|
||||
export function pushRecent(
|
||||
account: string | null | undefined,
|
||||
id: string,
|
||||
): void {
|
||||
if (!account) return;
|
||||
try {
|
||||
const next = [id, ...readRecent(account).filter((x) => x !== id)].slice(
|
||||
0,
|
||||
RECENT_LIMIT,
|
||||
);
|
||||
storage()?.setItem(PREFIX + account, JSON.stringify(next));
|
||||
} catch {
|
||||
// A refused write only costs the next empty state its history.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/**
|
||||
* Every Settings card the command palette lists on its own, with the anchor
|
||||
* that lands on it (`/settings/<section>#<anchor>`).
|
||||
*
|
||||
* Most anchors are the `anchor` prop of the card's `SettingsCardHeader`; a
|
||||
* few are older wrapper ids (the integration providers, `#channels`,
|
||||
* `#sharing`, `#thresholds`, the reminder and Coach memory cards). The test
|
||||
* `settings-cards.test.ts` reads the settings sources and fails when an
|
||||
* anchor listed here exists nowhere, so a renamed card cannot quietly turn
|
||||
* into a link to the top of its page.
|
||||
*
|
||||
* Left out on purpose: notices that come and go (the passkey nudge, a
|
||||
* pending handover decision, score change notices, the AI operator notice),
|
||||
* cards that only exist when the operator configured a channel (Telegram,
|
||||
* ntfy, Web Push, email, document sources, the central Codex switch), and
|
||||
* sections that are a single card, where the section entry is the card.
|
||||
*/
|
||||
export interface SettingsCardEntry {
|
||||
/** The settings section slug, or a `layout/<module>` sub-page. */
|
||||
section: string;
|
||||
anchor: string;
|
||||
/** i18n key of the card title, as the card itself renders it. */
|
||||
titleKey: string;
|
||||
/** Shown only while one of these modules is on. */
|
||||
modules?: ReadonlyArray<ModuleKey>;
|
||||
}
|
||||
|
||||
export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
|
||||
// ── Account ──
|
||||
{ section: "account", anchor: "avatar", titleKey: "settings.avatar.title" },
|
||||
{ section: "account", anchor: "profile", titleKey: "settings.profile" },
|
||||
{
|
||||
section: "account",
|
||||
anchor: "display",
|
||||
titleKey: "settings.displayCard.title",
|
||||
},
|
||||
// ── Security ──
|
||||
{
|
||||
section: "security",
|
||||
anchor: "two-factor",
|
||||
titleKey: "settings.security.totp.title",
|
||||
},
|
||||
{
|
||||
section: "security",
|
||||
anchor: "security-keys",
|
||||
titleKey: "settings.security.keys.title",
|
||||
},
|
||||
{ section: "security", anchor: "passkeys", titleKey: "settings.passkeys" },
|
||||
{
|
||||
section: "security",
|
||||
anchor: "password",
|
||||
titleKey: "settings.passwordTitle",
|
||||
},
|
||||
// ── Access ──
|
||||
{
|
||||
section: "access",
|
||||
anchor: "share-invite",
|
||||
titleKey: "recordSharing.invite.title",
|
||||
},
|
||||
{
|
||||
section: "access",
|
||||
anchor: "managed-profiles",
|
||||
titleKey: "recordSharing.managed.title",
|
||||
},
|
||||
{
|
||||
section: "access",
|
||||
anchor: "shared-by-you",
|
||||
titleKey: "recordSharing.given.title",
|
||||
},
|
||||
{
|
||||
section: "access",
|
||||
anchor: "shared-with-you",
|
||||
titleKey: "recordSharing.received.title",
|
||||
},
|
||||
{
|
||||
section: "access",
|
||||
anchor: "record-activity",
|
||||
titleKey: "recordSharing.activity.title",
|
||||
},
|
||||
// ── Privacy ──
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "encryption",
|
||||
titleKey: "settings.privacy.encryption.title",
|
||||
},
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "stored-data",
|
||||
titleKey: "settings.privacy.stored.title",
|
||||
},
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "retention",
|
||||
titleKey: "settings.privacy.retention.title",
|
||||
},
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "your-data-export",
|
||||
titleKey: "settings.privacy.export.title",
|
||||
},
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "delete-data",
|
||||
titleKey: "settings.privacy.delete.title",
|
||||
},
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "privacy-posture",
|
||||
titleKey: "settings.privacy.posture.title",
|
||||
},
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "sessions",
|
||||
titleKey: "settings.security.sessionsTitle",
|
||||
},
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "trusted-devices",
|
||||
titleKey: "settings.security.trustedDevices.title",
|
||||
},
|
||||
{
|
||||
section: "privacy",
|
||||
anchor: "security-activity",
|
||||
titleKey: "settings.security.activityTitle",
|
||||
},
|
||||
// ── Integrations: connections (wrapper ids) ──
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "withings",
|
||||
titleKey: "settings.withings",
|
||||
},
|
||||
{ section: "integrations", anchor: "whoop", titleKey: "settings.whoop" },
|
||||
{ section: "integrations", anchor: "fitbit", titleKey: "settings.fitbit" },
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "google-health",
|
||||
titleKey: "settings.googleHealth",
|
||||
},
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "apple-health",
|
||||
titleKey: "settings.appleHealth.title",
|
||||
},
|
||||
{ section: "integrations", anchor: "polar", titleKey: "settings.polar" },
|
||||
{ section: "integrations", anchor: "oura", titleKey: "settings.oura" },
|
||||
{ section: "integrations", anchor: "strava", titleKey: "settings.strava" },
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "nightscout",
|
||||
titleKey: "settings.nightscout",
|
||||
},
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "garmin",
|
||||
titleKey: "settings.garminInfo.title",
|
||||
},
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "nutrient-intake",
|
||||
titleKey: "settings.sections.sources.nutrients.title",
|
||||
modules: ["nutrients"],
|
||||
},
|
||||
// ── Integrations: channels ──
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "channels",
|
||||
titleKey: "settings.sections.integrations.channelsHeading",
|
||||
},
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "delivery-status",
|
||||
titleKey: "settings.notificationStatus.title",
|
||||
},
|
||||
{ section: "integrations", anchor: "webhook", titleKey: "settings.webhook" },
|
||||
// ── Health record ──
|
||||
{
|
||||
section: "gesundheitsakte",
|
||||
anchor: "health-record",
|
||||
titleKey: "settings.healthRecord.title",
|
||||
},
|
||||
{
|
||||
section: "gesundheitsakte",
|
||||
anchor: "share-link-create",
|
||||
titleKey: "settings.sharing.createTitle",
|
||||
},
|
||||
{
|
||||
section: "gesundheitsakte",
|
||||
anchor: "share-links-active",
|
||||
titleKey: "settings.sharing.activeTitle",
|
||||
},
|
||||
// ── Export and import ──
|
||||
{
|
||||
section: "export",
|
||||
anchor: "measurements-csv",
|
||||
titleKey: "settings.sections.export.cards.measurementsCsv.title",
|
||||
},
|
||||
{
|
||||
section: "export",
|
||||
anchor: "medications-csv",
|
||||
titleKey: "settings.sections.export.cards.medicationsCsv.title",
|
||||
},
|
||||
{
|
||||
section: "export",
|
||||
anchor: "mood-csv",
|
||||
titleKey: "settings.sections.export.cards.moodCsv.title",
|
||||
},
|
||||
{
|
||||
section: "export",
|
||||
anchor: "full-backup",
|
||||
titleKey: "settings.sections.export.cards.fullBackup.title",
|
||||
},
|
||||
{
|
||||
section: "export",
|
||||
anchor: "import-apple-health",
|
||||
titleKey: "settings.sections.export.import.appleHealth.title",
|
||||
},
|
||||
{
|
||||
section: "export",
|
||||
anchor: "import-health-connect",
|
||||
titleKey: "settings.sections.export.import.healthConnect.title",
|
||||
},
|
||||
{
|
||||
section: "export",
|
||||
anchor: "import-json",
|
||||
titleKey: "settings.sections.export.import.json.title",
|
||||
},
|
||||
{
|
||||
section: "export",
|
||||
anchor: "import-csv",
|
||||
titleKey: "settings.sections.export.import.csv.title",
|
||||
},
|
||||
{
|
||||
section: "export",
|
||||
anchor: "import-dose-history",
|
||||
titleKey: "settings.sections.export.import.doseHistory.title",
|
||||
},
|
||||
// ── Thresholds ──
|
||||
{
|
||||
section: "thresholds",
|
||||
anchor: "glucose-reference",
|
||||
titleKey: "settings.glucoseReference.title",
|
||||
},
|
||||
{
|
||||
section: "thresholds",
|
||||
anchor: "thresholds",
|
||||
titleKey: "thresholds.cardTitle",
|
||||
},
|
||||
// ── Notifications ──
|
||||
{
|
||||
section: "notifications",
|
||||
anchor: "mood-reminder",
|
||||
titleKey: "notifications.moodReminder.title",
|
||||
modules: ["mood"],
|
||||
},
|
||||
{
|
||||
section: "notifications",
|
||||
anchor: "low-stock",
|
||||
titleKey: "notifications.lowStock.title",
|
||||
modules: ["medications"],
|
||||
},
|
||||
// ── Anamnesis ──
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "ai-inclusion",
|
||||
titleKey: "records.aiInclusion.cardTitle",
|
||||
modules: ["coach", "insights"],
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "conditions",
|
||||
titleKey: "records.conditions.cardTitle",
|
||||
modules: ["coach"],
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "about-me",
|
||||
titleKey: "settings.ai.aboutMe.title",
|
||||
modules: ["coach", "insights"],
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "profile-facts",
|
||||
titleKey: "records.profileFacts.cardTitle",
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "allergies",
|
||||
titleKey: "records.allergies.cardTitle",
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "family-history",
|
||||
titleKey: "records.family.cardTitle",
|
||||
},
|
||||
{
|
||||
section: "anamnesis",
|
||||
anchor: "emergency",
|
||||
titleKey: "records.emergency.cardTitle",
|
||||
},
|
||||
// ── Environment ──
|
||||
{
|
||||
section: "environment",
|
||||
anchor: "home-location",
|
||||
titleKey: "settings.sections.environment.home.title",
|
||||
modules: ["environment"],
|
||||
},
|
||||
{
|
||||
section: "environment",
|
||||
anchor: "travel",
|
||||
titleKey: "settings.sections.environment.travel.title",
|
||||
modules: ["environment"],
|
||||
},
|
||||
{
|
||||
section: "environment",
|
||||
anchor: "weather-backfill",
|
||||
titleKey: "settings.sections.environment.backfill.title",
|
||||
modules: ["environment"],
|
||||
},
|
||||
{
|
||||
section: "environment",
|
||||
anchor: "air-quality",
|
||||
titleKey: "settings.sections.environment.airQuality.title",
|
||||
modules: ["environment"],
|
||||
},
|
||||
// ── AI ──
|
||||
{
|
||||
section: "ai",
|
||||
anchor: "ai-provider",
|
||||
titleKey: "settings.ai.activeProviderHeading",
|
||||
},
|
||||
{
|
||||
section: "ai",
|
||||
anchor: "provider-chain",
|
||||
titleKey: "settings.ai.providerChain.title",
|
||||
},
|
||||
{
|
||||
section: "ai",
|
||||
anchor: "response-timeout",
|
||||
titleKey: "settings.ai.responseTimeoutHeading",
|
||||
},
|
||||
{
|
||||
section: "ai",
|
||||
anchor: "auto-read",
|
||||
titleKey: "settings.ai.autoRead.title",
|
||||
},
|
||||
{
|
||||
section: "ai",
|
||||
anchor: "ai-consent",
|
||||
titleKey: "settings.ai.consent.title",
|
||||
},
|
||||
{ section: "ai", anchor: "ai-runtime", titleKey: "settings.ai.runtimeTitle" },
|
||||
// ── Coach ──
|
||||
{
|
||||
section: "coach",
|
||||
anchor: "coach-activate",
|
||||
titleKey: "settings.coach.activate.title",
|
||||
},
|
||||
{
|
||||
section: "coach",
|
||||
anchor: "coach-preferences",
|
||||
titleKey: "insights.coach.settingsTitle",
|
||||
},
|
||||
{
|
||||
section: "coach",
|
||||
anchor: "coach-nudge",
|
||||
titleKey: "notifications.coachNudge.title",
|
||||
},
|
||||
{
|
||||
section: "coach",
|
||||
anchor: "coach-memory",
|
||||
titleKey: "settings.ai.coachMemory.title",
|
||||
},
|
||||
{
|
||||
section: "coach",
|
||||
anchor: "coach-conversations",
|
||||
titleKey: "settings.ai.coachConversations.title",
|
||||
},
|
||||
// ── API ──
|
||||
{
|
||||
section: "api",
|
||||
anchor: "api-endpoints",
|
||||
titleKey: "settings.apiEndpointsTitle",
|
||||
},
|
||||
{
|
||||
section: "api",
|
||||
anchor: "measurements-token",
|
||||
titleKey: "settings.measurementsToken.title",
|
||||
},
|
||||
{
|
||||
section: "api",
|
||||
anchor: "workouts-token",
|
||||
titleKey: "settings.workoutsToken.title",
|
||||
},
|
||||
{
|
||||
section: "api",
|
||||
anchor: "documents-token",
|
||||
titleKey: "settings.documentsToken.title",
|
||||
},
|
||||
{ section: "api", anchor: "api-tokens", titleKey: "settings.apiTokens" },
|
||||
// ── MCP ──
|
||||
{
|
||||
section: "mcp",
|
||||
anchor: "mcp-enable",
|
||||
titleKey: "settings.mcp.enableTitle",
|
||||
},
|
||||
{
|
||||
section: "mcp",
|
||||
anchor: "mcp-connections",
|
||||
titleKey: "settings.mcp.connectionsTitle",
|
||||
},
|
||||
{
|
||||
section: "mcp",
|
||||
anchor: "mcp-tokens",
|
||||
titleKey: "settings.mcp.tokensTitle",
|
||||
},
|
||||
// ── Advanced ──
|
||||
{
|
||||
section: "advanced",
|
||||
anchor: "danger-zone",
|
||||
titleKey: "settings.dangerZone",
|
||||
},
|
||||
{
|
||||
section: "advanced",
|
||||
anchor: "delete-account",
|
||||
titleKey: "settings.deleteAccountCardTitle",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The Appearance hub's sub-pages, each its own entry. A module's page shows
|
||||
* only while the module is on.
|
||||
*/
|
||||
export const SETTINGS_LAYOUT_PAGES: ReadonlyArray<{
|
||||
slug: string;
|
||||
titleKey: string;
|
||||
module?: ModuleKey;
|
||||
}> = [
|
||||
{ slug: "dashboard", titleKey: "settings.sections.layout.dashboard.title" },
|
||||
{ slug: "insights", titleKey: "settings.sections.layout.insights.title" },
|
||||
{
|
||||
slug: "medications",
|
||||
titleKey: "settings.sections.layout.medications.title",
|
||||
module: "medications",
|
||||
},
|
||||
{
|
||||
slug: "mood",
|
||||
titleKey: "settings.sections.layout.mood.title",
|
||||
module: "mood",
|
||||
},
|
||||
{
|
||||
slug: "labs",
|
||||
titleKey: "settings.sections.layout.labs.title",
|
||||
module: "labs",
|
||||
},
|
||||
{
|
||||
slug: "illness",
|
||||
titleKey: "settings.sections.layout.illness.title",
|
||||
module: "illness",
|
||||
},
|
||||
{
|
||||
slug: "documents",
|
||||
titleKey: "settings.sections.layout.documents.title",
|
||||
module: "inboundDocuments",
|
||||
},
|
||||
{ slug: "vorsorge", titleKey: "settings.sections.layout.vorsorge.title" },
|
||||
];
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Extra words people search with, per palette entry id, for the cases where
|
||||
* the title in the reader's language is not the word they type: an English
|
||||
* term in a German interface, an abbreviation, the device rather than the
|
||||
* metric. Matched one tier below the title (`rank.ts`).
|
||||
*
|
||||
* Kept small on purpose. Every title is already searchable in its own
|
||||
* language; an entry here earns its place by a word that title cannot carry.
|
||||
* Only pages that exist are listed: there is no calf or other limb
|
||||
* circumference page, so no synonym points at one.
|
||||
*/
|
||||
export const PALETTE_SYNONYMS: Readonly<Record<string, ReadonlyArray<string>>> =
|
||||
{
|
||||
// ── Pages ──
|
||||
"nav:/": ["home", "start", "overview", "übersicht", "today", "heute"],
|
||||
"nav:/measurements": ["values", "werte", "readings", "messwerte"],
|
||||
"nav:/medications": [
|
||||
"meds",
|
||||
"pills",
|
||||
"tablets",
|
||||
"drugs",
|
||||
"medikamente",
|
||||
"tabletten",
|
||||
"arzneimittel",
|
||||
],
|
||||
"nav:/labs": ["blood test", "bloodwork", "blutwerte", "labor", "labwerte"],
|
||||
"nav:/checkups": ["preventive", "vorsorge", "screening", "reminders"],
|
||||
"nav:/coach": ["chat", "ai", "ki", "assistant", "assistent"],
|
||||
"nav:/documents": ["pdf", "scan", "befunde", "arztbrief", "letters"],
|
||||
"nav:/timeline": ["history", "verlauf", "lebenslauf", "chronik"],
|
||||
"nav:/illness": ["sick", "krank", "krankheit", "symptoms"],
|
||||
"nav:/vaccinations": ["vaccine", "impfung", "impfpass", "shots"],
|
||||
"nav:/mood": ["stimmung", "feelings", "gefühle", "journal"],
|
||||
"nav:/cycle": ["period", "menstruation", "periode", "zyklus"],
|
||||
"nav:/notifications": ["alerts", "benachrichtigungen", "inbox"],
|
||||
|
||||
// ── Insights ──
|
||||
"insights:weight": ["weight", "gewicht", "kg", "scale", "waage"],
|
||||
"insights:blood-pressure": [
|
||||
"bp",
|
||||
"rr",
|
||||
"blood pressure",
|
||||
"blutdruck",
|
||||
"hypertension",
|
||||
"systolic",
|
||||
"diastolic",
|
||||
],
|
||||
"insights:pulse": ["heart rate", "herzfrequenz", "hr", "puls", "bpm"],
|
||||
"insights:resting-pulse": ["resting heart rate", "ruhepuls", "rhr"],
|
||||
"insights:hrv": ["heart rate variability", "herzfrequenzvariabilität"],
|
||||
"insights:sleep": ["sleep", "schlaf", "nacht", "night"],
|
||||
"insights:steps": ["steps", "schritte", "walking", "gehen"],
|
||||
"insights:blood-glucose": [
|
||||
"glucose",
|
||||
"sugar",
|
||||
"blutzucker",
|
||||
"zucker",
|
||||
"cgm",
|
||||
"diabetes",
|
||||
],
|
||||
"insights:body-fat": ["fat", "körperfett", "kfa"],
|
||||
"insights:oxygen": ["spo2", "sauerstoff", "oxygen", "o2"],
|
||||
"insights:body-temperature": ["fever", "fieber", "temperatur"],
|
||||
"insights:cardio-fitness": ["vo2", "vo2max", "fitness"],
|
||||
"insights:workouts": ["training", "sport", "exercise", "workouts"],
|
||||
"insights:mood": ["stimmung", "mood"],
|
||||
"insights:waist": ["waist", "taille", "bauchumfang", "umfang"],
|
||||
"insights:daylight": ["sun", "sonne", "tageslicht", "outdoors"],
|
||||
|
||||
// ── Settings ──
|
||||
"settings:security#passkeys": [
|
||||
"passkey",
|
||||
"webauthn",
|
||||
"face id",
|
||||
"touch id",
|
||||
],
|
||||
"settings:security#two-factor": ["2fa", "totp", "mfa", "authenticator"],
|
||||
"settings:security#password": ["passwort", "kennwort"],
|
||||
"settings:export#full-backup": ["backup", "sicherung", "datensicherung"],
|
||||
"settings:export#import-apple-health": ["healthkit", "iphone", "import"],
|
||||
"settings:integrations#apple-health": ["healthkit", "iphone"],
|
||||
"settings:integrations#withings": ["scale", "waage"],
|
||||
"settings:integrations#channels": ["telegram", "ntfy", "push", "email"],
|
||||
"settings:privacy#delete-data": ["delete", "löschen", "gdpr", "dsgvo"],
|
||||
"settings:advanced#delete-account": ["delete account", "konto löschen"],
|
||||
"settings:ai#ai-provider": ["openai", "anthropic", "ollama", "llm", "key"],
|
||||
"settings:api#api-tokens": ["token", "api key", "bearer"],
|
||||
"settings:modules": ["features", "funktionen", "module"],
|
||||
"settings:account": ["profil", "profile", "name", "email"],
|
||||
|
||||
// ── Actions ──
|
||||
"action:capture": ["add", "new", "log", "neu", "eintragen", "erfassen"],
|
||||
"action:today": ["today", "heute", "day", "tag"],
|
||||
"action:backup": ["backup", "sicherung", "export"],
|
||||
"action:coach": ["chat", "ai", "ki", "ask", "fragen"],
|
||||
"action:shortcuts": ["keyboard", "tastatur", "hotkeys", "shortcuts"],
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Scroll to the element with `id` once it exists, for a link that lands on a
|
||||
* card of a page that is still loading (a Settings section is its own lazy
|
||||
* chunk, and some cards wait for their reads).
|
||||
*
|
||||
* The shell resets the page's scroll on every route change, in an effect
|
||||
* that can run after the card has already appeared; the scroll therefore
|
||||
* waits two frames, and looks once more a moment later and scrolls again if
|
||||
* the card is no longer near the top. It gives up after `timeoutMs` and
|
||||
* returns a cancel function.
|
||||
*
|
||||
* With `pathname`, an element only counts once the URL is on that page, so a
|
||||
* same-named element on the page being left is never the one scrolled to.
|
||||
*/
|
||||
export function scrollToAnchorWhenReady(
|
||||
id: string,
|
||||
options: { pathname?: string; timeoutMs?: number } = {},
|
||||
): () => void {
|
||||
const { pathname, timeoutMs = 5000 } = options;
|
||||
if (typeof document === "undefined" || id === "") return () => {};
|
||||
let done = false;
|
||||
const timers: number[] = [];
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
const finish = () => {
|
||||
done = true;
|
||||
observer?.disconnect();
|
||||
for (const timer of timers) window.clearTimeout(timer);
|
||||
};
|
||||
|
||||
const scroll = (el: HTMLElement) => {
|
||||
el.scrollIntoView({ block: "start" });
|
||||
};
|
||||
|
||||
const land = (el: HTMLElement) => {
|
||||
observer?.disconnect();
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
if (done) return;
|
||||
scroll(el);
|
||||
timers.push(
|
||||
window.setTimeout(() => {
|
||||
const target = document.getElementById(id);
|
||||
if (!done && target) {
|
||||
const top = target.getBoundingClientRect().top;
|
||||
if (top < 0 || top > window.innerHeight * 0.4) scroll(target);
|
||||
}
|
||||
finish();
|
||||
}, 300),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const look = () => {
|
||||
if (pathname !== undefined && window.location.pathname !== pathname) {
|
||||
return false;
|
||||
}
|
||||
const el = document.getElementById(id);
|
||||
if (el && el.getClientRects().length > 0) {
|
||||
land(el);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (!look()) {
|
||||
observer = new MutationObserver(() => {
|
||||
if (!done && look()) observer?.disconnect();
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "hidden", "style"],
|
||||
});
|
||||
timers.push(window.setTimeout(finish, timeoutMs));
|
||||
}
|
||||
return finish;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { visibleNavDestinations } from "@/components/layout/nav-model";
|
||||
import {
|
||||
SEQUENCE_TIMEOUT_MS,
|
||||
blockedByModifier,
|
||||
createShortcutReader,
|
||||
isApplePlatform,
|
||||
isEditableTarget,
|
||||
resolveGoTo,
|
||||
shortcutScope,
|
||||
type ShortcutKeyEvent,
|
||||
} from "@/lib/keyboard/global-shortcuts";
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
over: Partial<ShortcutKeyEvent> = {},
|
||||
): ShortcutKeyEvent {
|
||||
return {
|
||||
key: k,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
target: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** An element whose `closest` answers for the selectors it matches. */
|
||||
function element(matches: (selector: string) => boolean) {
|
||||
return {
|
||||
closest: (selector: string) =>
|
||||
selector.split(", ").some((s) => matches(s)) ? {} : null,
|
||||
};
|
||||
}
|
||||
|
||||
const input = element((s) => s === "input");
|
||||
const textarea = element((s) => s === "textarea");
|
||||
const select = element((s) => s === "select");
|
||||
const combobox = element((s) => s === '[role="combobox"]');
|
||||
const editable = { isContentEditable: true, closest: () => null };
|
||||
const button = element(() => false);
|
||||
|
||||
describe("isEditableTarget", () => {
|
||||
it("treats fields, comboboxes and contenteditable as the field's", () => {
|
||||
for (const target of [input, textarea, select, combobox, editable]) {
|
||||
expect(isEditableTarget(target)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves buttons, the body and non-elements to the shortcuts", () => {
|
||||
expect(isEditableTarget(button)).toBe(false);
|
||||
expect(isEditableTarget(null)).toBe(false);
|
||||
expect(isEditableTarget(undefined)).toBe(false);
|
||||
expect(isEditableTarget({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reader ignores keys typed into a field", () => {
|
||||
it.each([
|
||||
["input", input],
|
||||
["textarea", textarea],
|
||||
["select", select],
|
||||
["combobox", combobox],
|
||||
["contenteditable", editable],
|
||||
])("%s: no action for g d, n, ? or [", (_name, target) => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("g", { target }), "page", 0)).toBeNull();
|
||||
expect(reader.read(key("d", { target }), "page", 10)).toBeNull();
|
||||
expect(reader.read(key("n", { target }), "page", 20)).toBeNull();
|
||||
expect(reader.read(key("?", { target }), "page", 30)).toBeNull();
|
||||
expect(reader.read(key("[", { target }), "page", 40)).toBeNull();
|
||||
});
|
||||
|
||||
it("a field between g and d breaks the sequence", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("g"), "page", 0)).toBe("pending");
|
||||
expect(reader.read(key("d", { target: input }), "page", 10)).toBeNull();
|
||||
expect(reader.read(key("d"), "page", 20)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("modifiers", () => {
|
||||
it("Cmd, Ctrl and Alt make a letter somebody else's", () => {
|
||||
const reader = createShortcutReader();
|
||||
for (const over of [
|
||||
{ metaKey: true },
|
||||
{ ctrlKey: true },
|
||||
{ altKey: true },
|
||||
]) {
|
||||
expect(reader.read(key("n", over), "page", 0)).toBeNull();
|
||||
expect(reader.read(key("g", over), "page", 0)).toBeNull();
|
||||
}
|
||||
expect(reader.read(key("?", { metaKey: true }), "page", 0)).toBeNull();
|
||||
expect(reader.read(key("?", { ctrlKey: true }), "page", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("a modified second key does not complete g", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("g"), "page", 0)).toBe("pending");
|
||||
expect(reader.read(key("d", { metaKey: true }), "page", 10)).toBeNull();
|
||||
expect(reader.read(key("d"), "page", 20)).toBeNull();
|
||||
});
|
||||
|
||||
it("Shift alone, pressed to type ?, neither acts nor breaks a sequence", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("Shift"), "page", 0)).toBeNull();
|
||||
expect(reader.read(key("?"), "page", 5)).toEqual({ type: "help" });
|
||||
expect(reader.read(key("g"), "page", 10)).toBe("pending");
|
||||
expect(reader.read(key("Shift"), "page", 20)).toBeNull();
|
||||
expect(reader.read(key("D"), "page", 30)).toEqual({ type: "go", key: "d" });
|
||||
});
|
||||
|
||||
it("[ typed with Option (German Mac) or AltGr (German PC) still steps the day", () => {
|
||||
expect(blockedByModifier(key("[", { altKey: true }))).toBe(false);
|
||||
expect(
|
||||
blockedByModifier(
|
||||
key("]", { altKey: true, ctrlKey: true, altGraph: true }),
|
||||
),
|
||||
).toBe(false);
|
||||
// Ctrl+Alt without AltGr is a command, not a layout key.
|
||||
expect(blockedByModifier(key("[", { altKey: true, ctrlKey: true }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(blockedByModifier(key("[", { metaKey: true }))).toBe(true);
|
||||
// An Alt+arrow is the day layer's own step and stays its own.
|
||||
expect(blockedByModifier(key("ArrowLeft", { altKey: true }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sequence timing", () => {
|
||||
it("g then a destination within the window goes there", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("g"), "page", 1000)).toBe("pending");
|
||||
expect(reader.read(key("t"), "page", 1000 + SEQUENCE_TIMEOUT_MS)).toEqual({
|
||||
type: "go",
|
||||
key: "t",
|
||||
});
|
||||
});
|
||||
|
||||
it("a second key after the window does nothing", () => {
|
||||
const reader = createShortcutReader();
|
||||
reader.read(key("g"), "page", 0);
|
||||
expect(reader.read(key("t"), "page", SEQUENCE_TIMEOUT_MS + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("a destination key without g does nothing", () => {
|
||||
const reader = createShortcutReader();
|
||||
for (const k of ["d", "t", "c", "m", "l", "i", "s"]) {
|
||||
expect(reader.read(key(k), "page", 0)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("an unknown second key cancels; it does not fall through to n", () => {
|
||||
const reader = createShortcutReader();
|
||||
reader.read(key("g"), "page", 0);
|
||||
expect(reader.read(key("n"), "page", 10)).toBeNull();
|
||||
expect(reader.read(key("d"), "page", 20)).toBeNull();
|
||||
});
|
||||
|
||||
it("g g restarts the window", () => {
|
||||
const reader = createShortcutReader();
|
||||
reader.read(key("g"), "page", 0);
|
||||
expect(reader.read(key("g"), "page", 900)).toBe("pending");
|
||||
expect(reader.read(key("s"), "page", 1800)).toEqual({
|
||||
type: "go",
|
||||
key: "s",
|
||||
});
|
||||
});
|
||||
|
||||
it("a held key does not repeat the action", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("n", { repeat: true }), "page", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("reset forgets a pending g", () => {
|
||||
const reader = createShortcutReader();
|
||||
reader.read(key("g"), "page", 0);
|
||||
reader.reset();
|
||||
expect(reader.read(key("d"), "page", 10)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scope", () => {
|
||||
it("an open dialog, menu or popover takes every key", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("n"), "off", 0)).toBeNull();
|
||||
expect(reader.read(key("?"), "off", 0)).toBeNull();
|
||||
expect(reader.read(key("["), "off", 0)).toBeNull();
|
||||
reader.read(key("g"), "page", 0);
|
||||
expect(reader.read(key("d"), "off", 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("over the day sheet only [ and ] act", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("["), "day-sheet", 0)).toEqual({
|
||||
type: "day",
|
||||
delta: -1,
|
||||
});
|
||||
expect(reader.read(key("]"), "day-sheet", 0)).toEqual({
|
||||
type: "day",
|
||||
delta: 1,
|
||||
});
|
||||
expect(reader.read(key("n"), "day-sheet", 0)).toBeNull();
|
||||
expect(reader.read(key("g"), "day-sheet", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("Escape is never ours", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("Escape"), "page", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the scope from the open surfaces", () => {
|
||||
const none = { querySelectorAll: () => [] };
|
||||
expect(shortcutScope(none)).toBe("page");
|
||||
const daySheet = {
|
||||
querySelectorAll: () => [
|
||||
{
|
||||
closest: (s: string) => (s === '[data-slot="day-panel"]' ? {} : null),
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(shortcutScope(daySheet)).toBe("day-sheet");
|
||||
const other = { querySelectorAll: () => [{ closest: () => null }] };
|
||||
expect(shortcutScope(other)).toBe("off");
|
||||
const both = {
|
||||
querySelectorAll: () => [
|
||||
{
|
||||
closest: (s: string) => (s === '[data-slot="day-panel"]' ? {} : null),
|
||||
},
|
||||
{ closest: () => null },
|
||||
],
|
||||
};
|
||||
expect(shortcutScope(both)).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module gating", () => {
|
||||
function offer(modules: Record<string, boolean> | undefined) {
|
||||
return {
|
||||
navHrefs: visibleNavDestinations(modules).map((d) => d.href),
|
||||
settingsHref: "/settings/account",
|
||||
};
|
||||
}
|
||||
|
||||
it("g t goes nowhere while the timeline module is off", () => {
|
||||
expect(resolveGoTo("t", offer({ timeline: false }))).toBeNull();
|
||||
expect(resolveGoTo("t", offer({ timeline: true }))).toBe("/timeline");
|
||||
});
|
||||
|
||||
it("g m and g l follow the medications and labs modules", () => {
|
||||
expect(resolveGoTo("m", offer({ medications: false }))).toBeNull();
|
||||
expect(resolveGoTo("l", offer({ labs: false }))).toBeNull();
|
||||
expect(resolveGoTo("m", offer({ medications: true }))).toBe("/medications");
|
||||
expect(resolveGoTo("l", offer({ labs: true }))).toBe("/labs");
|
||||
});
|
||||
|
||||
it("g c follows the coach entry, which carries the AI capability", () => {
|
||||
// `useNavModules` folds the capability into `coach`.
|
||||
expect(resolveGoTo("c", offer({ coach: false }))).toBeNull();
|
||||
expect(resolveGoTo("c", offer({ coach: true }))).toBe("/coach");
|
||||
});
|
||||
|
||||
it("g d and g i belong to no module", () => {
|
||||
const off = offer({ timeline: false, labs: false, medications: false });
|
||||
expect(resolveGoTo("d", off)).toBe("/");
|
||||
expect(resolveGoTo("i", off)).toBe("/insights");
|
||||
});
|
||||
|
||||
it("inside a shared record the doors sharing does not cover stay shut", () => {
|
||||
const shared = {
|
||||
navHrefs: visibleNavDestinations({}, true, true).map((d) => d.href),
|
||||
settingsHref: null,
|
||||
};
|
||||
expect(resolveGoTo("i", shared)).toBeNull();
|
||||
expect(resolveGoTo("c", shared)).toBeNull();
|
||||
expect(resolveGoTo("s", shared)).toBeNull();
|
||||
expect(resolveGoTo("d", shared)).toBe("/");
|
||||
});
|
||||
|
||||
it("g s lands where the Settings entry lands", () => {
|
||||
expect(
|
||||
resolveGoTo("s", { navHrefs: [], settingsHref: "/settings/profile" }),
|
||||
).toBe("/settings/profile");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cmd/Ctrl+K opens the command palette", () => {
|
||||
it("with Cmd or Ctrl, on the page and from a field", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("k", { metaKey: true }), "page", 0)).toEqual({
|
||||
type: "palette",
|
||||
});
|
||||
expect(reader.read(key("k", { ctrlKey: true }), "page", 0)).toEqual({
|
||||
type: "palette",
|
||||
});
|
||||
expect(
|
||||
reader.read(key("K", { ctrlKey: true, target: input }), "page", 0),
|
||||
).toEqual({ type: "palette" });
|
||||
});
|
||||
|
||||
it("not with Alt, not as a bare k, not while a dialog is open", () => {
|
||||
const reader = createShortcutReader();
|
||||
expect(reader.read(key("k"), "page", 0)).toBeNull();
|
||||
expect(reader.read(key("k", { altKey: true }), "page", 0)).toBeNull();
|
||||
expect(
|
||||
reader.read(key("k", { metaKey: true, altKey: true }), "page", 0),
|
||||
).toBeNull();
|
||||
expect(reader.read(key("k", { metaKey: true }), "off", 0)).toBeNull();
|
||||
expect(reader.read(key("k", { metaKey: true }), "day-sheet", 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isApplePlatform", () => {
|
||||
it("reads the platform and the user agent", () => {
|
||||
expect(isApplePlatform({ platform: "MacIntel" })).toBe(true);
|
||||
expect(
|
||||
isApplePlatform({ userAgent: "Mozilla/5.0 (iPad; CPU OS 18_0)" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isApplePlatform({ platform: "Win32", userAgent: "Windows NT" }),
|
||||
).toBe(false);
|
||||
expect(isApplePlatform({})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* The app-wide keyboard shortcuts, as pure logic.
|
||||
*
|
||||
* The listener in `src/components/layout/global-shortcuts.tsx` hands every
|
||||
* keydown to `createShortcutReader().read()` together with the scope the page
|
||||
* is in, and gets back an action, a "wait for the second key", or nothing.
|
||||
* Everything that decides whether a key is ours lives here, so it can be
|
||||
* pinned without a DOM:
|
||||
*
|
||||
* - a key typed into a field is the field's (`isEditableTarget`);
|
||||
* - a key held with Cmd, Ctrl or Alt belongs to the browser, the operating
|
||||
* system or a screen reader (`blockedByModifier`), with two exceptions:
|
||||
* Cmd+K / Ctrl+K, which opens the command palette, and a
|
||||
* punctuation key the layout itself needs Alt or AltGr to type (`[` is
|
||||
* Option+5 on a German Mac, AltGr+8 on a German PC) still counts, because
|
||||
* for that person there is no other way to press it;
|
||||
* - while a dialog, sheet, menu or popover is open the keys are its own,
|
||||
* except over the day sheet, where `[` and `]` step the day it shows.
|
||||
*
|
||||
* `g` starts a two-key sequence; the second key has to follow within
|
||||
* `SEQUENCE_TIMEOUT_MS`. Escape is never handled here: every surface closes
|
||||
* itself on it, as before.
|
||||
*/
|
||||
|
||||
/** How long `g` waits for its second key. */
|
||||
export const SEQUENCE_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
* `g` + key: where it goes. The destination is a nav href, or `settings`,
|
||||
* which lands wherever the Settings entry of the account menu lands (it
|
||||
* differs inside a shared record). Listed in navigation order, which is the
|
||||
* order the help dialog shows them in.
|
||||
*/
|
||||
export const GO_TO_SHORTCUTS = [
|
||||
{ key: "d", destination: "/" },
|
||||
{ key: "m", destination: "/medications" },
|
||||
{ key: "l", destination: "/labs" },
|
||||
{ key: "t", destination: "/timeline" },
|
||||
{ key: "i", destination: "/insights" },
|
||||
{ key: "c", destination: "/coach" },
|
||||
{ key: "s", destination: "settings" },
|
||||
] as const;
|
||||
|
||||
export type GoToKey = (typeof GO_TO_SHORTCUTS)[number]["key"];
|
||||
|
||||
export type ShortcutAction =
|
||||
| { type: "go"; key: GoToKey }
|
||||
| { type: "palette" }
|
||||
| { type: "capture" }
|
||||
| { type: "help" }
|
||||
| { type: "day"; delta: -1 | 1 };
|
||||
|
||||
/**
|
||||
* Where the keyboard is:
|
||||
* - `page`: nothing modal is open, every shortcut applies;
|
||||
* - `day-sheet`: the day sheet is the only modal surface, `[` / `]` apply;
|
||||
* - `off`: another dialog, sheet, menu or popover has the keys.
|
||||
*/
|
||||
export type ShortcutScope = "page" | "day-sheet" | "off";
|
||||
|
||||
/** What the reader needs from a `KeyboardEvent`. */
|
||||
export interface ShortcutKeyEvent {
|
||||
key: string;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
/** `event.getModifierState("AltGraph")`. */
|
||||
altGraph?: boolean;
|
||||
repeat?: boolean;
|
||||
isComposing?: boolean;
|
||||
target?: unknown;
|
||||
}
|
||||
|
||||
/** Keys a layout may need Alt or AltGr for, and that still count then. */
|
||||
const LAYOUT_CHARACTERS: ReadonlySet<string> = new Set(["[", "]", "?"]);
|
||||
|
||||
/** Pressing one of these alone never starts, ends or breaks a shortcut. */
|
||||
const MODIFIER_KEYS: ReadonlySet<string> = new Set([
|
||||
"Shift",
|
||||
"Control",
|
||||
"Alt",
|
||||
"AltGraph",
|
||||
"Meta",
|
||||
"CapsLock",
|
||||
"Fn",
|
||||
"OS",
|
||||
]);
|
||||
|
||||
/** Something a person types into, by element or by role. */
|
||||
const EDITABLE_SELECTOR = [
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
'[contenteditable]:not([contenteditable="false"])',
|
||||
'[role="combobox"]',
|
||||
'[role="textbox"]',
|
||||
'[role="searchbox"]',
|
||||
'[role="spinbutton"]',
|
||||
].join(", ");
|
||||
|
||||
/** Open surfaces that own the keyboard while they are up. */
|
||||
export const MODAL_SURFACE_SELECTOR = [
|
||||
'[role="dialog"][data-state="open"]',
|
||||
'[role="alertdialog"][data-state="open"]',
|
||||
'[aria-modal="true"]',
|
||||
'[role="menu"][data-state="open"]',
|
||||
'[role="listbox"][data-state="open"]',
|
||||
].join(", ");
|
||||
|
||||
/** The day layer's frame, docked or as a sheet (`day-layer.tsx`). */
|
||||
const DAY_PANEL_SELECTOR = '[data-slot="day-panel"]';
|
||||
|
||||
/** True when the key went to a field, which keeps every key it gets. */
|
||||
export function isEditableTarget(target: unknown): boolean {
|
||||
if (target === null || typeof target !== "object") return false;
|
||||
const el = target as {
|
||||
isContentEditable?: boolean;
|
||||
closest?: (selector: string) => unknown;
|
||||
};
|
||||
if (el.isContentEditable === true) return true;
|
||||
if (typeof el.closest !== "function") return false;
|
||||
return el.closest(EDITABLE_SELECTOR) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a modifier makes the key somebody else's: Cmd and Ctrl always
|
||||
* (browser and screen-reader commands), Alt unless the layout needs it to
|
||||
* type one of our punctuation keys. Shift is free: it is how `?` is typed.
|
||||
*/
|
||||
export function blockedByModifier(event: ShortcutKeyEvent): boolean {
|
||||
if (event.metaKey) return true;
|
||||
const altGraph = event.altGraph === true;
|
||||
// AltGr reports as Ctrl+Alt on Windows; it is a layout key, not a command.
|
||||
if (event.ctrlKey && !altGraph) return true;
|
||||
if ((event.altKey || altGraph) && !LAYOUT_CHARACTERS.has(event.key)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The scope the page is in, read from the open surfaces. */
|
||||
export function shortcutScope(root: {
|
||||
querySelectorAll: (selector: string) => ArrayLike<unknown>;
|
||||
}): ShortcutScope {
|
||||
const open = Array.from(root.querySelectorAll(MODAL_SURFACE_SELECTOR));
|
||||
if (open.length === 0) return "page";
|
||||
const onlyDay = open.every((el) => {
|
||||
const node = el as { closest?: (selector: string) => unknown };
|
||||
return (
|
||||
typeof node.closest === "function" &&
|
||||
node.closest(DAY_PANEL_SELECTOR) != null
|
||||
);
|
||||
});
|
||||
return onlyDay ? "day-sheet" : "off";
|
||||
}
|
||||
|
||||
function normalise(key: string): string {
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
|
||||
function isGoToKey(key: string): key is GoToKey {
|
||||
return GO_TO_SHORTCUTS.some((shortcut) => shortcut.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reader with the one piece of state a sequence needs: when `g` was
|
||||
* pressed. `read` returns the action a key completes, `"pending"` after `g`,
|
||||
* or `null` for a key that is not ours (which also drops a pending `g`).
|
||||
*/
|
||||
export function createShortcutReader(timeoutMs = SEQUENCE_TIMEOUT_MS) {
|
||||
let pendingSince: number | null = null;
|
||||
|
||||
function read(
|
||||
event: ShortcutKeyEvent,
|
||||
scope: ShortcutScope,
|
||||
now: number,
|
||||
): ShortcutAction | "pending" | null {
|
||||
if (event.isComposing) return null;
|
||||
if (MODIFIER_KEYS.has(event.key)) return null;
|
||||
if (event.repeat) return null;
|
||||
|
||||
const pending = pendingSince;
|
||||
pendingSince = null;
|
||||
|
||||
if (scope === "off") return null;
|
||||
|
||||
// Cmd+K (Ctrl+K off a Mac) opens the command palette: the one modifier
|
||||
// combination that is ours, and from a field too, the way every search
|
||||
// palette behaves. The browser's own Ctrl+K (its search bar) gives way.
|
||||
if (
|
||||
scope === "page" &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
event.altGraph !== true &&
|
||||
event.key.toLowerCase() === "k"
|
||||
) {
|
||||
return { type: "palette" };
|
||||
}
|
||||
|
||||
if (isEditableTarget(event.target)) return null;
|
||||
if (blockedByModifier(event)) return null;
|
||||
|
||||
const key = normalise(event.key);
|
||||
|
||||
if (key === "[" || key === "]") {
|
||||
return { type: "day", delta: key === "[" ? -1 : 1 };
|
||||
}
|
||||
if (scope !== "page") return null;
|
||||
|
||||
if (pending !== null && now - pending <= timeoutMs) {
|
||||
// The second key of a sequence: a destination, or nothing at all, so
|
||||
// a mistyped `g n` does not open the add menu.
|
||||
if (isGoToKey(key)) return { type: "go", key };
|
||||
if (key !== "g") return null;
|
||||
}
|
||||
if (key === "g") {
|
||||
pendingSince = now;
|
||||
return "pending";
|
||||
}
|
||||
if (key === "n") return { type: "capture" };
|
||||
if (key === "?") return { type: "help" };
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
/** Forget a pending `g`. */
|
||||
reset() {
|
||||
pendingSince = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What this session may open, as the navigation offers it. */
|
||||
export interface ShortcutOffer {
|
||||
/** The hrefs of the visible nav destinations. */
|
||||
navHrefs: ReadonlyArray<string>;
|
||||
/** Where the Settings entry lands, or null when there is none. */
|
||||
settingsHref: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The href `g` + `key` opens, or null when the destination is not offered: a
|
||||
* switched-off module, a Coach without an AI provider, a page a shared record
|
||||
* does not cover. Read from the same lists the navigation renders, so a
|
||||
* shortcut never opens a door the sidebar does not show.
|
||||
*/
|
||||
export function resolveGoTo(key: GoToKey, offer: ShortcutOffer): string | null {
|
||||
const shortcut = GO_TO_SHORTCUTS.find((s) => s.key === key);
|
||||
if (!shortcut) return null;
|
||||
if (shortcut.destination === "settings") return offer.settingsHref;
|
||||
return offer.navHrefs.includes(shortcut.destination)
|
||||
? shortcut.destination
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Whether this browser runs on an Apple platform (⌘ rather than Ctrl). */
|
||||
export function isApplePlatform(
|
||||
nav:
|
||||
{ platform?: string; userAgent?: string } | undefined = typeof navigator ===
|
||||
"undefined"
|
||||
? undefined
|
||||
: navigator,
|
||||
): boolean {
|
||||
if (!nav) return false;
|
||||
return /mac|iphone|ipad|ipod/i.test(
|
||||
`${nav.platform ?? ""} ${nav.userAgent ?? ""}`,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user