diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95d6fb2b9..7174405eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
+## [1.11.2] — 2026-06-04 — Coach-memory controls, privacy hardening, more pinnable metrics
+
+### Added
+
+- **Review and clear what the Coach remembers.** A new "What the Coach remembers" settings section lists the durable facts the assistant has learned about you, grouped by category, and lets you forget a single one or clear all of them. Encrypted at rest; available wherever the Coach is enabled.
+- **More dashboard metrics you can pin.** Cardio recovery, six-minute walk distance, stair ascent/descent speed, breathing disturbances, wrist temperature, fall count, and walking steadiness can now be pinned to your Home layout.
+
+### Security
+
+- **Outbound request hardening.** The AI provider clients (when pointed at a custom base URL) and the optional IP-geo lookup now reject any address that resolves to a private or internal host, closing a server-side request-forgery / DNS-rebinding gap.
+
+### Fixed
+
+- A rare write race when two updates recompute the same day's rollup at once is now handled cleanly instead of surfacing a transient error.
+- Analytics fast paths resolve your source-priority preference once per request instead of repeatedly.
+
## [1.11.1] — 2026-06-04 — source-aware vitals + Coach long-term memory
Closes the three follow-ups left open by v1.11.0: cross-source vital de-duplication, the Coach's conversation memory, and durable personal facts.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 39ef54640..19c3d242a 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.11.1
+ version: 1.11.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/messages/de.json b/messages/de.json
index 8637a8d75..fbd360987 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.",
"testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.",
"testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.",
+ "coachMemory": {
+ "title": "Woran sich der Coach erinnert",
+ "description": "Dauerhafte Angaben, die du dem Coach mitgeteilt hast. Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte.",
+ "empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst – deine Ziele, Beschwerden, Vorlieben – und du kannst sie hier jederzeit löschen.",
+ "learnedPrefix": "gelernt {when}",
+ "categoryPreference": "Vorlieben",
+ "categoryCondition": "Beschwerden",
+ "categoryGoal": "Ziele",
+ "categoryConstraint": "Einschränkungen",
+ "categoryContext": "Kontext",
+ "forget": "Vergessen",
+ "forgetAria": "Diese Angabe vergessen",
+ "forgetAll": "Alles vergessen",
+ "forgetAllConfirmTitle": "Alles vergessen, woran sich der Coach erinnert?",
+ "forgetAllConfirmBody": "Dadurch werden alle gespeicherten Angaben entfernt. Der Coach beginnt beim nächsten Gespräch von vorn. Das lässt sich nicht rückgängig machen.",
+ "forgetAllConfirmAction": "Alles vergessen",
+ "cancel": "Abbrechen",
+ "summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung – wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.",
+ "loadError": "Konnte nicht laden, woran sich der Coach erinnert.",
+ "forgotToast": "Angabe vergessen.",
+ "forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.",
+ "clearedToast": "{count} gespeicherte Angaben vergessen.",
+ "clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen."
+ },
"disableCoach": {
"title": "Coach ausblenden",
"description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
diff --git a/messages/en.json b/messages/en.json
index 6a8b15084..f82aea721 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
+ "coachMemory": {
+ "title": "What the Coach remembers",
+ "description": "Durable facts you have told the Coach. It uses these to keep its replies relevant. Remove anything you would rather it forgot.",
+ "empty": "The Coach has not stored any facts yet. As you chat, it remembers durable details you share — your goals, conditions, preferences — and you can clear them here at any time.",
+ "learnedPrefix": "learned {when}",
+ "categoryPreference": "Preferences",
+ "categoryCondition": "Conditions",
+ "categoryGoal": "Goals",
+ "categoryConstraint": "Constraints",
+ "categoryContext": "Context",
+ "forget": "Forget",
+ "forgetAria": "Forget this fact",
+ "forgetAll": "Forget everything",
+ "forgetAllConfirmTitle": "Forget everything the Coach remembers?",
+ "forgetAllConfirmBody": "This removes every stored fact. The Coach will start fresh the next time you chat. This cannot be undone.",
+ "forgetAllConfirmAction": "Forget everything",
+ "cancel": "Cancel",
+ "summaryNote": "The Coach may also keep a short rolling summary of each conversation. There is no per-item control for it — deleting a conversation removes its summary.",
+ "loadError": "Could not load what the Coach remembers.",
+ "forgotToast": "Fact forgotten.",
+ "forgotError": "Could not forget that fact. Please try again.",
+ "clearedToast": "Forgot {count} stored facts.",
+ "clearError": "Could not clear the stored facts. Please try again."
+ },
"disableCoach": {
"title": "Hide Coach",
"description": "Hides the Coach button and drawer everywhere.",
diff --git a/messages/es.json b/messages/es.json
index 9e4bd7cbf..a45cc6212 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
+ "coachMemory": {
+ "title": "Lo que el Coach recuerda",
+ "description": "Datos duraderos que le has contado al Coach. Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide.",
+ "empty": "El Coach aún no ha guardado ningún dato. A medida que conversas, recuerda detalles duraderos que compartes: tus objetivos, afecciones y preferencias, y puedes borrarlos aquí cuando quieras.",
+ "learnedPrefix": "aprendido {when}",
+ "categoryPreference": "Preferencias",
+ "categoryCondition": "Afecciones",
+ "categoryGoal": "Objetivos",
+ "categoryConstraint": "Restricciones",
+ "categoryContext": "Contexto",
+ "forget": "Olvidar",
+ "forgetAria": "Olvidar este dato",
+ "forgetAll": "Olvidar todo",
+ "forgetAllConfirmTitle": "¿Olvidar todo lo que recuerda el Coach?",
+ "forgetAllConfirmBody": "Esto elimina todos los datos guardados. El Coach empezará de cero la próxima vez que converses. No se puede deshacer.",
+ "forgetAllConfirmAction": "Olvidar todo",
+ "cancel": "Cancelar",
+ "summaryNote": "El Coach también puede mantener un breve resumen continuo de cada conversación. No hay un control individual para él: eliminar una conversación elimina su resumen.",
+ "loadError": "No se pudo cargar lo que recuerda el Coach.",
+ "forgotToast": "Dato olvidado.",
+ "forgotError": "No se pudo olvidar ese dato. Inténtalo de nuevo.",
+ "clearedToast": "Se olvidaron {count} datos guardados.",
+ "clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo."
+ },
"disableCoach": {
"title": "Ocultar el Coach",
"description": "Oculta el botón del Coach y el panel en todas las páginas.",
diff --git a/messages/fr.json b/messages/fr.json
index 2cba90fc8..d79ae8d4d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
+ "coachMemory": {
+ "title": "Ce dont le Coach se souvient",
+ "description": "Les informations durables que vous avez confiées au Coach. Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie.",
+ "empty": "Le Coach n'a encore enregistré aucune information. Au fil de vos échanges, il retient les détails durables que vous partagez — vos objectifs, vos affections, vos préférences — et vous pouvez les effacer ici à tout moment.",
+ "learnedPrefix": "appris {when}",
+ "categoryPreference": "Préférences",
+ "categoryCondition": "Affections",
+ "categoryGoal": "Objectifs",
+ "categoryConstraint": "Contraintes",
+ "categoryContext": "Contexte",
+ "forget": "Oublier",
+ "forgetAria": "Oublier cette information",
+ "forgetAll": "Tout oublier",
+ "forgetAllConfirmTitle": "Oublier tout ce dont le Coach se souvient ?",
+ "forgetAllConfirmBody": "Cela supprime toutes les informations enregistrées. Le Coach repartira de zéro lors de votre prochaine conversation. Cette action est irréversible.",
+ "forgetAllConfirmAction": "Tout oublier",
+ "cancel": "Annuler",
+ "summaryNote": "Le Coach peut aussi conserver un bref résumé évolutif de chaque conversation. Il n'existe pas de commande individuelle pour cela — supprimer une conversation supprime son résumé.",
+ "loadError": "Impossible de charger ce dont le Coach se souvient.",
+ "forgotToast": "Information oubliée.",
+ "forgotError": "Impossible d'oublier cette information. Veuillez réessayer.",
+ "clearedToast": "{count} informations enregistrées oubliées.",
+ "clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer."
+ },
"disableCoach": {
"title": "Masquer le Coach",
"description": "Masque le bouton et le panneau du Coach sur toutes les pages.",
diff --git a/messages/it.json b/messages/it.json
index 7e6f967ae..e25ee1368 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
+ "coachMemory": {
+ "title": "Ciò che il Coach ricorda",
+ "description": "Informazioni durature che hai comunicato al Coach. Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi.",
+ "empty": "Il Coach non ha ancora memorizzato alcuna informazione. Mentre chatti, ricorda i dettagli duraturi che condividi — i tuoi obiettivi, le condizioni, le preferenze — e puoi cancellarli qui in qualsiasi momento.",
+ "learnedPrefix": "appreso {when}",
+ "categoryPreference": "Preferenze",
+ "categoryCondition": "Condizioni",
+ "categoryGoal": "Obiettivi",
+ "categoryConstraint": "Vincoli",
+ "categoryContext": "Contesto",
+ "forget": "Dimentica",
+ "forgetAria": "Dimentica questa informazione",
+ "forgetAll": "Dimentica tutto",
+ "forgetAllConfirmTitle": "Dimenticare tutto ciò che il Coach ricorda?",
+ "forgetAllConfirmBody": "Questo rimuove tutte le informazioni memorizzate. Il Coach ripartirà da zero alla prossima chat. L'operazione non può essere annullata.",
+ "forgetAllConfirmAction": "Dimentica tutto",
+ "cancel": "Annulla",
+ "summaryNote": "Il Coach può anche conservare un breve riassunto progressivo di ogni conversazione. Non esiste un controllo per ogni voce — eliminando una conversazione si elimina anche il suo riassunto.",
+ "loadError": "Impossibile caricare ciò che il Coach ricorda.",
+ "forgotToast": "Informazione dimenticata.",
+ "forgotError": "Impossibile dimenticare questa informazione. Riprova.",
+ "clearedToast": "Dimenticate {count} informazioni memorizzate.",
+ "clearError": "Impossibile cancellare le informazioni memorizzate. Riprova."
+ },
"disableCoach": {
"title": "Nascondi il Coach",
"description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.",
diff --git a/messages/pl.json b/messages/pl.json
index 89f4460a9..39097727f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3336,6 +3336,30 @@
"testReasonRateLimited": "Provider rate-limited the request — try again shortly.",
"testReasonServerError": "The AI provider returned a server error.",
"testReasonUnreachable": "Could not reach the AI provider.",
+ "coachMemory": {
+ "title": "Co Coach pamięta",
+ "description": "Trwałe informacje, które przekazałeś Coachowi. Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał.",
+ "empty": "Coach nie zapisał jeszcze żadnych informacji. Podczas rozmowy zapamiętuje trwałe szczegóły, którymi się dzielisz — Twoje cele, dolegliwości, preferencje — i możesz je tutaj w każdej chwili usunąć.",
+ "learnedPrefix": "zapamiętano {when}",
+ "categoryPreference": "Preferencje",
+ "categoryCondition": "Dolegliwości",
+ "categoryGoal": "Cele",
+ "categoryConstraint": "Ograniczenia",
+ "categoryContext": "Kontekst",
+ "forget": "Zapomnij",
+ "forgetAria": "Zapomnij tę informację",
+ "forgetAll": "Zapomnij wszystko",
+ "forgetAllConfirmTitle": "Zapomnieć wszystko, co Coach pamięta?",
+ "forgetAllConfirmBody": "Spowoduje to usunięcie wszystkich zapisanych informacji. Coach zacznie od nowa przy następnej rozmowie. Tej operacji nie można cofnąć.",
+ "forgetAllConfirmAction": "Zapomnij wszystko",
+ "cancel": "Anuluj",
+ "summaryNote": "Coach może też przechowywać krótkie, bieżące podsumowanie każdej rozmowy. Nie ma dla niego osobnej kontrolki — usunięcie rozmowy usuwa jej podsumowanie.",
+ "loadError": "Nie udało się wczytać tego, co Coach pamięta.",
+ "forgotToast": "Informacja zapomniana.",
+ "forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.",
+ "clearedToast": "Zapomniano {count} zapisanych informacji.",
+ "clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie."
+ },
"disableCoach": {
"title": "Ukryj Coacha",
"description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.",
diff --git a/package.json b/package.json
index 955eee106..3dff3e73c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.11.1",
+ "version": "1.11.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts
index 341787b3a..5ab6e51d7 100644
--- a/src/app/api/dashboard/widgets/route.ts
+++ b/src/app/api/dashboard/widgets/route.ts
@@ -70,10 +70,11 @@ const layoutSchema = z.object({
}),
)
.min(1)
- // v1.7.0 W1 — raised from 20 to 30 so the full 27-id catalogue PUT
- // (web 16 + iOS-only 11) fits with headroom; the enum still bounds
- // each id to one of the 27.
- .max(30),
+ // v1.11.2 B5 — raised to 40 so the full catalogue PUT (24 writable +
+ // 11 iOS-only = 35) fits with headroom after the v1.10 additive
+ // HealthKit signals became pinnable; the enum still bounds each id to
+ // one of the catalogue ids.
+ .max(40),
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
// so v1.4.15 clients that don't know the field can still PUT.
diff --git a/src/components/settings/__tests__/coach-memory-section.test.tsx b/src/components/settings/__tests__/coach-memory-section.test.tsx
new file mode 100644
index 000000000..2ce1a0feb
--- /dev/null
+++ b/src/components/settings/__tests__/coach-memory-section.test.tsx
@@ -0,0 +1,153 @@
+/**
+ * v1.11.2 — Settings → AI "What the Coach remembers" panel contract.
+ *
+ * Project convention is SSR-only tests (no `@testing-library/react`;
+ * the Vitest environment is `node`). We pre-seed the `coachFacts`
+ * query in a real `QueryClient` so `useQuery` resolves synchronously
+ * during the `renderToStaticMarkup` pass, then assert the rendered
+ * shape: facts grouped + labelled by category, the empty state, and
+ * the per-fact + bulk "forget" controls that drive the DELETE
+ * mutations.
+ *
+ * The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts`
+ * → `queryKeys.coachFacts()` invalidation + toast) is pinned by the
+ * route-shape contract in the component plus the typecheck gate; this
+ * suite guards the render contract the way the sibling
+ * `disable-coach-card.test.tsx` does for the toggle.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import { CoachMemorySection } from "../coach-memory-section";
+
+interface SeedFact {
+ id: string;
+ category: "preference" | "condition" | "goal" | "constraint" | "context";
+ text: string;
+ confidence: number;
+ createdAt: string;
+}
+
+const NOW = "2026-06-04T12:00:00.000Z";
+
+function buildFacts(): SeedFact[] {
+ return [
+ {
+ id: "fact-goal-1",
+ category: "goal",
+ text: "Wants to reach 80 kg by autumn",
+ confidence: 0.9,
+ createdAt: NOW,
+ },
+ {
+ id: "fact-pref-1",
+ category: "preference",
+ text: "Prefers morning workouts",
+ confidence: 0.8,
+ createdAt: NOW,
+ },
+ {
+ id: "fact-cond-1",
+ category: "condition",
+ text: "Takes ramipril for blood pressure",
+ confidence: 0.95,
+ createdAt: NOW,
+ },
+ ];
+}
+
+beforeEach(() => {
+ // The component never fetches when the query is pre-seeded fresh, but
+ // stub fetch so any background refetch can't escape to the network.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ async () =>
+ new Response(JSON.stringify({ data: { facts: [] } }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ ),
+ );
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+function render(facts: SeedFact[] | "skip"): string {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { retry: 0, staleTime: Infinity, gcTime: Infinity },
+ },
+ });
+ if (facts !== "skip") {
+ client.setQueryData(queryKeys.coachFacts(), facts);
+ }
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+describe("Settings — CoachMemorySection", () => {
+ it("renders the facts grouped by category", () => {
+ const html = render(buildFacts());
+ // Each populated category surfaces its group heading...
+ expect(html).toContain(
+ 'data-testid="settings-coach-memory-group-preference"',
+ );
+ expect(html).toContain('data-testid="settings-coach-memory-group-goal"');
+ expect(html).toContain(
+ 'data-testid="settings-coach-memory-group-condition"',
+ );
+ // ...and the localized category labels render.
+ expect(html).toContain("Preferences");
+ expect(html).toContain("Goals");
+ expect(html).toContain("Conditions");
+ // The fact text appears verbatim.
+ expect(html).toContain("Prefers morning workouts");
+ expect(html).toContain("Wants to reach 80 kg by autumn");
+ // No group rows for categories that have no facts.
+ expect(html).not.toContain(
+ 'data-testid="settings-coach-memory-group-constraint"',
+ );
+ });
+
+ it("renders a per-fact forget control and the bulk forget-all action", () => {
+ const html = render(buildFacts());
+ expect(html).toContain('data-testid="settings-coach-memory-forget"');
+ expect(html).toContain('data-testid="settings-coach-memory-forget-all"');
+ // One forget button per fact (3 seeded).
+ const forgetButtons = html.match(
+ /data-testid="settings-coach-memory-forget"/g,
+ );
+ expect(forgetButtons).toHaveLength(3);
+ });
+
+ it("renders the empty state with the explainer when there are no facts", () => {
+ const html = render([]);
+ expect(html).toContain('data-testid="settings-coach-memory-empty"');
+ // The bulk action hides when there is nothing to forget.
+ expect(html).not.toContain(
+ 'data-testid="settings-coach-memory-forget-all"',
+ );
+ // No fact rows.
+ expect(html).not.toContain('data-testid="settings-coach-memory-fact"');
+ });
+
+ it("always shows the rolling-summary note", () => {
+ const withFacts = render(buildFacts());
+ const empty = render([]);
+ // The summary explainer is present in both states (it has no
+ // per-row control — deleting a conversation removes its summary).
+ expect(withFacts).toContain("rolling summary");
+ expect(empty).toContain("rolling summary");
+ });
+});
diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx
index 3ea8f1d2b..81edb4ead 100644
--- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx
+++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx
@@ -4,9 +4,18 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
DEFAULT_DASHBOARD_LAYOUT,
DASHBOARD_IOS_ONLY_WIDGET_IDS,
+ DASHBOARD_WIDGET_IDS,
+ IOS_PIN_ONLY_WIDGET_IDS,
type DashboardLayout,
} from "@/lib/dashboard-layout";
+// v1.11.2 HIGH-1 — the web Settings list renders one row per WRITABLE id
+// (`DASHBOARD_WIDGET_IDS`) MINUS the `IOS_PIN_ONLY_WIDGET_IDS` (writable so
+// the iOS pin PUT validates, but with no web render path). The default
+// layout still carries all 24 writable widgets; only this subset renders.
+const WEB_RENDERABLE_ROW_COUNT =
+ DASHBOARD_WIDGET_IDS.length - IOS_PIN_ONLY_WIDGET_IDS.length;
+
// Mutable holder so individual tests can inject a layout (e.g. one that
// carries iOS-only ids) into the mocked `useQuery` without re-mocking
// the module. Defaults to the 16-tile web default layout.
@@ -88,10 +97,10 @@ describe(" — tile + chart split", () => {
const html = render();
const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? [];
const chartSwitches = html.match(/data-slot="widget-chart-switch"/g) ?? [];
- // Default layout has 13 widgets — both switch counts must match
- // exactly so the split control covers every widget.
- expect(tileSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
- expect(chartSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
+ // One tile + one chart switch per WEB-renderable widget (the
+ // iOS-pin-only ids are filtered out — they have no web render path).
+ expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
+ expect(chartSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
});
});
@@ -108,7 +117,7 @@ describe(" — drag-and-drop reorder", () => {
it("paints a drag handle for every widget row", () => {
const html = render();
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
- expect(handles).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
+ expect(handles).toHaveLength(WEB_RENDERABLE_ROW_COUNT);
});
it("each drag handle has an aria-describedby pointing to a shared hint", () => {
@@ -117,7 +126,7 @@ describe(" — drag-and-drop reorder", () => {
// to care about attribute ordering inside the rendered