mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix: complete managed record settings
- Expose canonical defaults and reject invalid settings families\n- Localize Guardian controls and refresh dependent caches
This commit is contained in:
1 parent
e788675b82
commit
247ac8494c
16 files changed
+759
-91
No files matched your search
@@ -4434,6 +4434,76 @@
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"displayName": "Anzeigename",
|
||||
"height": "Größe (cm)",
|
||||
"dateOfBirth": "Geburtsdatum",
|
||||
"gender": "Geschlecht",
|
||||
"notSpecified": "Nicht angegeben",
|
||||
"male": "Männlich",
|
||||
"female": "Weiblich",
|
||||
"other": "Andere Angabe",
|
||||
"locale": "Sprache",
|
||||
"systemDefault": "Systemstandard",
|
||||
"timezone": "Zeitzone",
|
||||
"units": "Einheiten",
|
||||
"metric": "Metrisch",
|
||||
"imperial": "Imperial",
|
||||
"timeFormat": "Zeitformat",
|
||||
"automatic": "Automatisch",
|
||||
"hour12": "12 Stunden",
|
||||
"hour24": "24 Stunden",
|
||||
"dateFormat": "Datumsformat",
|
||||
"dateDmy": "Tag / Monat / Jahr",
|
||||
"dateMdy": "Monat / Tag / Jahr",
|
||||
"dateYmd": "Jahr / Monat / Tag"
|
||||
},
|
||||
"modules": { "title": "Module" },
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"moodReminder": "Stimmungserinnerung aktivieren",
|
||||
"reminderHour": "Uhrzeit der Stimmungserinnerung",
|
||||
"lowStockRunwayDays": "Tage bis zum niedrigen Bestand",
|
||||
"reorderLeadDays": "Vorlaufzeit für Nachbestellung"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Zielwerte",
|
||||
"metric": "Messwert",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"disable": "Coach deaktivieren",
|
||||
"tone": "Ton",
|
||||
"warm": "Herzlich",
|
||||
"neutral": "Neutral",
|
||||
"concise": "Knapp",
|
||||
"verbosity": "Ausführlichkeit",
|
||||
"brief": "Kurz",
|
||||
"default": "Standard",
|
||||
"detailed": "Ausführlich",
|
||||
"defaultWindow": "Standardzeitraum",
|
||||
"last7days": "Letzte 7 Tage",
|
||||
"last30days": "Letzte 30 Tage",
|
||||
"last90days": "Letzte 90 Tage",
|
||||
"allTime": "Gesamter Zeitraum",
|
||||
"showEvidence": "Evidenz standardmäßig anzeigen",
|
||||
"excludedMetrics": "Ausgeschlossene Messwerte",
|
||||
"dataClusters": "Datenbereiche",
|
||||
"customClusters": "Eigene Auswahl von Datenbereichen verwenden",
|
||||
"value": "{value}"
|
||||
},
|
||||
"insights": {
|
||||
"title": "Erkenntnisse",
|
||||
"sections": "Übersichtsbereiche",
|
||||
"tiles": "Navigationskacheln",
|
||||
"item": "{id}",
|
||||
"order": "Reihenfolge"
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
|
||||
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
|
||||
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz. Verbindungssteuerungen sind nicht verfügbar."
|
||||
|
||||
@@ -4434,6 +4434,76 @@
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
"displayName": "Display name",
|
||||
"height": "Height (cm)",
|
||||
"dateOfBirth": "Date of birth",
|
||||
"gender": "Gender",
|
||||
"notSpecified": "Not specified",
|
||||
"male": "Male",
|
||||
"female": "Female",
|
||||
"other": "Other",
|
||||
"locale": "Locale",
|
||||
"systemDefault": "System default",
|
||||
"timezone": "Timezone",
|
||||
"units": "Units",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"timeFormat": "Time format",
|
||||
"automatic": "Automatic",
|
||||
"hour12": "12-hour",
|
||||
"hour24": "24-hour",
|
||||
"dateFormat": "Date format",
|
||||
"dateDmy": "Day / month / year",
|
||||
"dateMdy": "Month / day / year",
|
||||
"dateYmd": "Year / month / day"
|
||||
},
|
||||
"modules": { "title": "Modules" },
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"moodReminder": "Enable mood reminder",
|
||||
"reminderHour": "Mood reminder hour",
|
||||
"lowStockRunwayDays": "Low-stock runway days",
|
||||
"reorderLeadDays": "Reorder lead days"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Targets",
|
||||
"metric": "Metric",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"disable": "Disable Coach",
|
||||
"tone": "Tone",
|
||||
"warm": "Warm",
|
||||
"neutral": "Neutral",
|
||||
"concise": "Concise",
|
||||
"verbosity": "Verbosity",
|
||||
"brief": "Brief",
|
||||
"default": "Default",
|
||||
"detailed": "Detailed",
|
||||
"defaultWindow": "Default window",
|
||||
"last7days": "Last 7 days",
|
||||
"last30days": "Last 30 days",
|
||||
"last90days": "Last 90 days",
|
||||
"allTime": "All time",
|
||||
"showEvidence": "Show evidence by default",
|
||||
"excludedMetrics": "Excluded metrics",
|
||||
"dataClusters": "Data clusters",
|
||||
"customClusters": "Use a custom cluster selection",
|
||||
"value": "{value}"
|
||||
},
|
||||
"insights": {
|
||||
"title": "Insights",
|
||||
"sections": "Overview sections",
|
||||
"tiles": "Navigation tiles",
|
||||
"item": "{id}",
|
||||
"order": "Order"
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Unavailable in this shared record",
|
||||
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
|
||||
"integrationStatusDescription": "Connection status for this managed record. Connection controls are unavailable."
|
||||
|
||||
@@ -4434,6 +4434,76 @@
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Perfil",
|
||||
"displayName": "Nombre visible",
|
||||
"height": "Altura (cm)",
|
||||
"dateOfBirth": "Fecha de nacimiento",
|
||||
"gender": "Género",
|
||||
"notSpecified": "No especificado",
|
||||
"male": "Hombre",
|
||||
"female": "Mujer",
|
||||
"other": "Otro",
|
||||
"locale": "Idioma",
|
||||
"systemDefault": "Predeterminado del sistema",
|
||||
"timezone": "Zona horaria",
|
||||
"units": "Unidades",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"timeFormat": "Formato de hora",
|
||||
"automatic": "Automático",
|
||||
"hour12": "12 horas",
|
||||
"hour24": "24 horas",
|
||||
"dateFormat": "Formato de fecha",
|
||||
"dateDmy": "Día / mes / año",
|
||||
"dateMdy": "Mes / día / año",
|
||||
"dateYmd": "Año / mes / día"
|
||||
},
|
||||
"modules": { "title": "Módulos" },
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"moodReminder": "Activar recordatorio de ánimo",
|
||||
"reminderHour": "Hora del recordatorio de ánimo",
|
||||
"lowStockRunwayDays": "Días hasta existencias bajas",
|
||||
"reorderLeadDays": "Días de margen para reponer"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Objetivos",
|
||||
"metric": "Métrica",
|
||||
"minimum": "Mínimo",
|
||||
"maximum": "Máximo"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Asistente",
|
||||
"disable": "Desactivar asistente",
|
||||
"tone": "Tono",
|
||||
"warm": "Cálido",
|
||||
"neutral": "Neutro",
|
||||
"concise": "Conciso",
|
||||
"verbosity": "Nivel de detalle",
|
||||
"brief": "Breve",
|
||||
"default": "Predeterminado",
|
||||
"detailed": "Detallado",
|
||||
"defaultWindow": "Periodo predeterminado",
|
||||
"last7days": "Últimos 7 días",
|
||||
"last30days": "Últimos 30 días",
|
||||
"last90days": "Últimos 90 días",
|
||||
"allTime": "Todo el tiempo",
|
||||
"showEvidence": "Mostrar evidencia de forma predeterminada",
|
||||
"excludedMetrics": "Métricas excluidas",
|
||||
"dataClusters": "Grupos de datos",
|
||||
"customClusters": "Usar una selección personalizada de grupos",
|
||||
"value": "{value}"
|
||||
},
|
||||
"insights": {
|
||||
"title": "Análisis",
|
||||
"sections": "Secciones del resumen",
|
||||
"tiles": "Mosaicos de navegación",
|
||||
"item": "{id}",
|
||||
"order": "Orden"
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "No disponible en este registro compartido",
|
||||
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
|
||||
"integrationStatusDescription": "Estado de conexión de este registro gestionado. Los controles de conexión no están disponibles."
|
||||
|
||||
@@ -4434,6 +4434,76 @@
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"displayName": "Nom affiché",
|
||||
"height": "Taille (cm)",
|
||||
"dateOfBirth": "Date de naissance",
|
||||
"gender": "Genre",
|
||||
"notSpecified": "Non précisé",
|
||||
"male": "Homme",
|
||||
"female": "Femme",
|
||||
"other": "Autre",
|
||||
"locale": "Langue",
|
||||
"systemDefault": "Valeur système",
|
||||
"timezone": "Fuseau horaire",
|
||||
"units": "Unités",
|
||||
"metric": "Métrique",
|
||||
"imperial": "Impérial",
|
||||
"timeFormat": "Format de l’heure",
|
||||
"automatic": "Automatique",
|
||||
"hour12": "12 heures",
|
||||
"hour24": "24 heures",
|
||||
"dateFormat": "Format de date",
|
||||
"dateDmy": "Jour / mois / année",
|
||||
"dateMdy": "Mois / jour / année",
|
||||
"dateYmd": "Année / mois / jour"
|
||||
},
|
||||
"modules": { "title": "Modules" },
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"moodReminder": "Activer le rappel d’humeur",
|
||||
"reminderHour": "Heure du rappel d’humeur",
|
||||
"lowStockRunwayDays": "Jours avant stock faible",
|
||||
"reorderLeadDays": "Délai de réapprovisionnement"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Objectifs",
|
||||
"metric": "Mesure",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"disable": "Désactiver le Coach",
|
||||
"tone": "Ton",
|
||||
"warm": "Chaleureux",
|
||||
"neutral": "Neutre",
|
||||
"concise": "Concis",
|
||||
"verbosity": "Niveau de détail",
|
||||
"brief": "Bref",
|
||||
"default": "Par défaut",
|
||||
"detailed": "Détaillé",
|
||||
"defaultWindow": "Période par défaut",
|
||||
"last7days": "7 derniers jours",
|
||||
"last30days": "30 derniers jours",
|
||||
"last90days": "90 derniers jours",
|
||||
"allTime": "Depuis toujours",
|
||||
"showEvidence": "Afficher les sources par défaut",
|
||||
"excludedMetrics": "Mesures exclues",
|
||||
"dataClusters": "Groupes de données",
|
||||
"customClusters": "Utiliser une sélection personnalisée de groupes",
|
||||
"value": "{value}"
|
||||
},
|
||||
"insights": {
|
||||
"title": "Analyses",
|
||||
"sections": "Sections d’aperçu",
|
||||
"tiles": "Tuiles de navigation",
|
||||
"item": "{id}",
|
||||
"order": "Ordre"
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Indisponible dans ce dossier partagé",
|
||||
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
|
||||
"integrationStatusDescription": "État de connexion pour ce dossier géré. Les commandes de connexion ne sont pas disponibles."
|
||||
|
||||
@@ -4434,6 +4434,76 @@
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profilo",
|
||||
"displayName": "Nome visualizzato",
|
||||
"height": "Altezza (cm)",
|
||||
"dateOfBirth": "Data di nascita",
|
||||
"gender": "Genere",
|
||||
"notSpecified": "Non specificato",
|
||||
"male": "Uomo",
|
||||
"female": "Donna",
|
||||
"other": "Altro",
|
||||
"locale": "Lingua",
|
||||
"systemDefault": "Predefinito di sistema",
|
||||
"timezone": "Fuso orario",
|
||||
"units": "Unità",
|
||||
"metric": "Metrico",
|
||||
"imperial": "Imperiale",
|
||||
"timeFormat": "Formato ora",
|
||||
"automatic": "Automatico",
|
||||
"hour12": "12 ore",
|
||||
"hour24": "24 ore",
|
||||
"dateFormat": "Formato data",
|
||||
"dateDmy": "Giorno / mese / anno",
|
||||
"dateMdy": "Mese / giorno / anno",
|
||||
"dateYmd": "Anno / mese / giorno"
|
||||
},
|
||||
"modules": { "title": "Moduli" },
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"moodReminder": "Attiva promemoria dell’umore",
|
||||
"reminderHour": "Ora del promemoria dell’umore",
|
||||
"lowStockRunwayDays": "Giorni prima delle scorte basse",
|
||||
"reorderLeadDays": "Margine per il riordino"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Obiettivi",
|
||||
"metric": "Metrica",
|
||||
"minimum": "Minimo",
|
||||
"maximum": "Massimo"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"disable": "Disattiva Coach",
|
||||
"tone": "Tono",
|
||||
"warm": "Caldo",
|
||||
"neutral": "Neutro",
|
||||
"concise": "Conciso",
|
||||
"verbosity": "Livello di dettaglio",
|
||||
"brief": "Breve",
|
||||
"default": "Predefinito",
|
||||
"detailed": "Dettagliato",
|
||||
"defaultWindow": "Periodo predefinito",
|
||||
"last7days": "Ultimi 7 giorni",
|
||||
"last30days": "Ultimi 30 giorni",
|
||||
"last90days": "Ultimi 90 giorni",
|
||||
"allTime": "Tutto il periodo",
|
||||
"showEvidence": "Mostra le fonti per impostazione predefinita",
|
||||
"excludedMetrics": "Metriche escluse",
|
||||
"dataClusters": "Gruppi di dati",
|
||||
"customClusters": "Usa una selezione personalizzata di gruppi",
|
||||
"value": "{value}"
|
||||
},
|
||||
"insights": {
|
||||
"title": "Analisi",
|
||||
"sections": "Sezioni della panoramica",
|
||||
"tiles": "Riquadri di navigazione",
|
||||
"item": "{id}",
|
||||
"order": "Ordine"
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Non disponibile in questo record condiviso",
|
||||
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
|
||||
"integrationStatusDescription": "Stato di connessione per questo record gestito. I controlli di connessione non sono disponibili."
|
||||
|
||||
@@ -4434,6 +4434,76 @@
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"managedSettings": {
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
"displayName": "Wyświetlana nazwa",
|
||||
"height": "Wzrost (cm)",
|
||||
"dateOfBirth": "Data urodzenia",
|
||||
"gender": "Płeć",
|
||||
"notSpecified": "Nie podano",
|
||||
"male": "Mężczyzna",
|
||||
"female": "Kobieta",
|
||||
"other": "Inna",
|
||||
"locale": "Język",
|
||||
"systemDefault": "Ustawienie systemowe",
|
||||
"timezone": "Strefa czasowa",
|
||||
"units": "Jednostki",
|
||||
"metric": "Metryczne",
|
||||
"imperial": "Imperialne",
|
||||
"timeFormat": "Format czasu",
|
||||
"automatic": "Automatyczny",
|
||||
"hour12": "12-godzinny",
|
||||
"hour24": "24-godzinny",
|
||||
"dateFormat": "Format daty",
|
||||
"dateDmy": "Dzień / miesiąc / rok",
|
||||
"dateMdy": "Miesiąc / dzień / rok",
|
||||
"dateYmd": "Rok / miesiąc / dzień"
|
||||
},
|
||||
"modules": { "title": "Moduły" },
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"moodReminder": "Włącz przypomnienie nastroju",
|
||||
"reminderHour": "Godzina przypomnienia nastroju",
|
||||
"lowStockRunwayDays": "Dni do niskiego stanu zapasów",
|
||||
"reorderLeadDays": "Wyprzedzenie ponownego zamówienia"
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Cele",
|
||||
"metric": "Metryka",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maksimum"
|
||||
},
|
||||
"coach": {
|
||||
"title": "Trener",
|
||||
"disable": "Wyłącz trenera",
|
||||
"tone": "Ton",
|
||||
"warm": "Ciepły",
|
||||
"neutral": "Neutralny",
|
||||
"concise": "Zwięzły",
|
||||
"verbosity": "Szczegółowość",
|
||||
"brief": "Krótka",
|
||||
"default": "Domyślna",
|
||||
"detailed": "Szczegółowa",
|
||||
"defaultWindow": "Domyślny okres",
|
||||
"last7days": "Ostatnie 7 dni",
|
||||
"last30days": "Ostatnie 30 dni",
|
||||
"last90days": "Ostatnie 90 dni",
|
||||
"allTime": "Cały okres",
|
||||
"showEvidence": "Domyślnie pokazuj źródła",
|
||||
"excludedMetrics": "Wykluczone metryki",
|
||||
"dataClusters": "Grupy danych",
|
||||
"customClusters": "Użyj własnego wyboru grup",
|
||||
"value": "{value}"
|
||||
},
|
||||
"insights": {
|
||||
"title": "Wnioski",
|
||||
"sections": "Sekcje przeglądu",
|
||||
"tiles": "Kafelki nawigacji",
|
||||
"item": "{id}",
|
||||
"order": "Kolejność"
|
||||
}
|
||||
},
|
||||
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
|
||||
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
|
||||
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne."
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
managedModulePreferencesFrom,
|
||||
parseManagedRecordSettingsPatch,
|
||||
} from "@/lib/record-settings/configuration";
|
||||
import { WRITABLE_MODULE_KEYS } from "@/lib/validations/modules";
|
||||
|
||||
describe("managed record settings configuration contract", () => {
|
||||
it("enumerates the only target-record fields each settings DTO may write", () => {
|
||||
@@ -95,4 +99,29 @@ describe("managed record settings configuration contract", () => {
|
||||
] as const)("accepts a typed %s patch", (family, patch) => {
|
||||
expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses the complete canonical module inventory for a fresh record", () => {
|
||||
expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual(
|
||||
WRITABLE_MODULE_KEYS,
|
||||
);
|
||||
expect(managedModulePreferencesFrom(null)).toEqual(
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts only the canonical profile gender enum", () => {
|
||||
expect(() =>
|
||||
parseManagedRecordSettingsPatch("profile", { gender: "MALE" }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
parseManagedRecordSettingsPatch("profile", { gender: "UNKNOWN" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])(
|
||||
"rejects inherited family name %s",
|
||||
(family) => {
|
||||
expect(isManagedRecordSettingsFamily(family)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -57,5 +57,10 @@ describe("record settings route guard", () => {
|
||||
for (const family of families) {
|
||||
expect(source).toContain(`case \"${family}\"`);
|
||||
}
|
||||
expect(source).toContain("if (!isManagedRecordSettingsFamily(rawFamily))");
|
||||
expect(source).toContain('apiError("Settings family not found", 404)');
|
||||
expect(
|
||||
source.indexOf("isManagedRecordSettingsFamily(rawFamily)"),
|
||||
).toBeLessThan(source.indexOf("const context = await requireGuardianAuth"));
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,27 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
|
||||
if (
|
||||
expected === null ||
|
||||
typeof expected !== "object" ||
|
||||
actual === null ||
|
||||
typeof actual !== "object"
|
||||
) {
|
||||
expect(typeof actual).toBe(typeof expected);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedRecord = expected as Record<string, unknown>;
|
||||
const actualRecord = actual as Record<string, unknown>;
|
||||
expect(Object.keys(actualRecord).sort()).toEqual(
|
||||
Object.keys(expectedRecord).sort(),
|
||||
);
|
||||
for (const key of Object.keys(expectedRecord)) {
|
||||
expectMatchingMessageKeys(expectedRecord[key], actualRecord[key]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("managed record settings UI", () => {
|
||||
it("gates shared deep links and renders integration status without controls", async () => {
|
||||
const [
|
||||
@@ -54,10 +75,31 @@ describe("managed record settings UI", () => {
|
||||
expect(managedSettingsView).not.toContain("/api/auth/");
|
||||
expect(managedSettingsView).not.toContain("/api/user/");
|
||||
expect(managedSettingsView).not.toContain("<textarea");
|
||||
expect(managedSettingsView).not.toContain("Display name");
|
||||
expect(managedSettingsView).not.toContain("Enable mood reminder");
|
||||
const referenceMessages = JSON.parse(localeBundles[0]).settings.sharedRecord
|
||||
.managedSettings;
|
||||
for (const bundle of localeBundles) {
|
||||
expect(bundle).toContain('"sharedRecord"');
|
||||
expect(bundle).toContain('"unavailableTitle"');
|
||||
expect(bundle).toContain('"integrationStatusDescription"');
|
||||
const sharedRecord = JSON.parse(bundle).settings.sharedRecord;
|
||||
expect(sharedRecord).toMatchObject({
|
||||
unavailableTitle: expect.any(String),
|
||||
integrationStatusDescription: expect.any(String),
|
||||
managedSettings: {
|
||||
profile: {
|
||||
title: expect.any(String),
|
||||
displayName: expect.any(String),
|
||||
},
|
||||
modules: { title: expect.any(String) },
|
||||
notifications: { title: expect.any(String) },
|
||||
thresholds: { title: expect.any(String) },
|
||||
coach: { title: expect.any(String) },
|
||||
insights: { title: expect.any(String) },
|
||||
},
|
||||
});
|
||||
expectMatchingMessageKeys(
|
||||
referenceMessages,
|
||||
sharedRecord.managedSettings,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,16 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
invalidateUserHealthScore,
|
||||
invalidateUserInsightsLayout,
|
||||
invalidateUserMedications,
|
||||
invalidateUserProfile,
|
||||
} from "@/lib/cache/invalidate";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { type ThresholdOverridesJson } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
@@ -117,11 +122,15 @@ export const GET = apiHandler(
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "thresholds":
|
||||
case "thresholds": {
|
||||
const overrides = (record.thresholdsJson ??
|
||||
{}) as ThresholdOverridesJson;
|
||||
settings = {
|
||||
overrides: (record.thresholdsJson ?? {}) as ThresholdOverridesJson,
|
||||
overrides,
|
||||
effective: getAllEffectiveRanges(record, overrides),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "coach": {
|
||||
const preferences = parseCoachPrefs(record.coachPrefsJson);
|
||||
settings = {
|
||||
@@ -211,6 +220,9 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
});
|
||||
invalidateUserProfile(access.recordId);
|
||||
if (patch.timezone !== undefined) {
|
||||
invalidateUserMedications(access.recordId, { evict: true });
|
||||
}
|
||||
settings = {
|
||||
...record,
|
||||
dateOfBirth: record.dateOfBirth?.toISOString().slice(0, 10) ?? null,
|
||||
@@ -232,6 +244,7 @@ export const PATCH = apiHandler(
|
||||
where: { id: access.recordId },
|
||||
data: { modulePreferencesJson: toJson(modulePreferences) },
|
||||
});
|
||||
invalidateUserHealthScore(access.recordId);
|
||||
settings = { modulePreferences };
|
||||
changed = Object.keys(patch.modulePreferences);
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS } from "@/lib/record-settings";
|
||||
import { ALL_METRICS } from "@/lib/validations/thresholds";
|
||||
|
||||
import { ManagedRecordSettingsForm } from "../managed-record-settings-section";
|
||||
|
||||
const render = (
|
||||
@@ -8,13 +12,15 @@ const render = (
|
||||
settings: Record<string, unknown>,
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
<ManagedRecordSettingsForm
|
||||
disabled={false}
|
||||
family={family}
|
||||
onSave={vi.fn()}
|
||||
saveLabel="Save"
|
||||
settings={settings}
|
||||
/>,
|
||||
<I18nProvider initialLocale="en">
|
||||
<ManagedRecordSettingsForm
|
||||
disabled={false}
|
||||
family={family}
|
||||
onSave={vi.fn()}
|
||||
saveLabel="Save"
|
||||
settings={settings}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("managed record settings forms", () => {
|
||||
@@ -62,4 +68,26 @@ describe("managed record settings forms", () => {
|
||||
expect(html).not.toContain("<textarea");
|
||||
},
|
||||
);
|
||||
|
||||
it("renders every direct module and threshold metric for a fresh record", () => {
|
||||
const modules = render("modules", { modulePreferences: {} });
|
||||
const thresholds = render("thresholds", {
|
||||
overrides: {},
|
||||
effective: Object.fromEntries(
|
||||
ALL_METRICS.map((metric) => [
|
||||
metric,
|
||||
{ range: { greenMin: 10, greenMax: 20 } },
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
for (const module of Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)) {
|
||||
expect(modules).toContain(`name="${module}"`);
|
||||
}
|
||||
for (const metric of ALL_METRICS) {
|
||||
expect(thresholds).toContain(`value="${metric}"`);
|
||||
}
|
||||
expect(thresholds).toContain('value="10"');
|
||||
expect(thresholds).toContain('value="20"');
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,19 @@ import { Settings } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MODULE_REGISTRY } from "@/lib/modules/registry";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
assertRecordSettingsResponseForRecord,
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
safeParseManagedRecordSettingsPatch,
|
||||
type ManagedRecordSettingsFamily,
|
||||
} from "@/lib/record-settings";
|
||||
import {
|
||||
METRIC_BOUNDS,
|
||||
type ThresholdMetric,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { ALL_METRICS } from "@/lib/validations/thresholds";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SettingsCardHeader } from "./_card-header";
|
||||
@@ -60,6 +67,22 @@ const COACH_DATA_CLUSTERS = [
|
||||
"mobility",
|
||||
"environment",
|
||||
] as const;
|
||||
const METRIC_LABEL_KEYS: Record<ThresholdMetric, string> = {
|
||||
WEIGHT: "thresholds.metricWeight",
|
||||
BLOOD_PRESSURE_SYS: "thresholds.metricBpSys",
|
||||
BLOOD_PRESSURE_DIA: "thresholds.metricBpDia",
|
||||
PULSE: "thresholds.metricPulse",
|
||||
BODY_FAT: "thresholds.metricBodyFat",
|
||||
SLEEP_DURATION: "thresholds.metricSleep",
|
||||
ACTIVITY_STEPS: "thresholds.metricSteps",
|
||||
BLOOD_GLUCOSE_FASTING: "thresholds.metricGlucoseFasting",
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial",
|
||||
BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom",
|
||||
BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime",
|
||||
TOTAL_BODY_WATER: "thresholds.metricBodyWater",
|
||||
BONE_MASS: "thresholds.metricBoneMass",
|
||||
OXYGEN_SATURATION: "thresholds.metricOxygenSaturation",
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -100,6 +123,7 @@ function ProfileSettingsForm({
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const { t } = useTranslations();
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
@@ -121,7 +145,7 @@ function ProfileSettingsForm({
|
||||
return (
|
||||
<form className="mt-4 grid gap-4 sm:grid-cols-2" onSubmit={submit}>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-display-name">
|
||||
Display name
|
||||
{t("settings.sharedRecord.managedSettings.profile.displayName")}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.displayName)}
|
||||
@@ -131,7 +155,7 @@ function ProfileSettingsForm({
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-height">
|
||||
Height (cm)
|
||||
{t("settings.sharedRecord.managedSettings.profile.height")}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={
|
||||
@@ -146,7 +170,7 @@ function ProfileSettingsForm({
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-date-of-birth">
|
||||
Date of birth
|
||||
{t("settings.sharedRecord.managedSettings.profile.dateOfBirth")}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.dateOfBirth)}
|
||||
@@ -157,7 +181,7 @@ function ProfileSettingsForm({
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-gender">
|
||||
Gender
|
||||
{t("settings.sharedRecord.managedSettings.profile.gender")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.gender)}
|
||||
@@ -165,14 +189,22 @@ function ProfileSettingsForm({
|
||||
id="managed-gender"
|
||||
name="gender"
|
||||
>
|
||||
<option value="">Not specified</option>
|
||||
<option value="MALE">Male</option>
|
||||
<option value="FEMALE">Female</option>
|
||||
<option value="OTHER">Other</option>
|
||||
<option value="">
|
||||
{t("settings.sharedRecord.managedSettings.profile.notSpecified")}
|
||||
</option>
|
||||
<option value="MALE">
|
||||
{t("settings.sharedRecord.managedSettings.profile.male")}
|
||||
</option>
|
||||
<option value="FEMALE">
|
||||
{t("settings.sharedRecord.managedSettings.profile.female")}
|
||||
</option>
|
||||
<option value="OTHER">
|
||||
{t("settings.sharedRecord.managedSettings.profile.other")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-locale">
|
||||
Locale
|
||||
{t("settings.sharedRecord.managedSettings.profile.locale")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.locale)}
|
||||
@@ -180,7 +212,9 @@ function ProfileSettingsForm({
|
||||
id="managed-locale"
|
||||
name="locale"
|
||||
>
|
||||
<option value="">System default</option>
|
||||
<option value="">
|
||||
{t("settings.sharedRecord.managedSettings.profile.systemDefault")}
|
||||
</option>
|
||||
{["de", "en", "es", "fr", "it", "pl"].map((locale) => (
|
||||
<option key={locale} value={locale}>
|
||||
{locale}
|
||||
@@ -189,7 +223,7 @@ function ProfileSettingsForm({
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
|
||||
Timezone
|
||||
{t("settings.sharedRecord.managedSettings.profile.timezone")}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.timezone, "Europe/Berlin")}
|
||||
@@ -200,7 +234,7 @@ function ProfileSettingsForm({
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-units">
|
||||
Units
|
||||
{t("settings.sharedRecord.managedSettings.profile.units")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.unitPreference, "metric")}
|
||||
@@ -208,12 +242,16 @@ function ProfileSettingsForm({
|
||||
id="managed-units"
|
||||
name="unitPreference"
|
||||
>
|
||||
<option value="metric">Metric</option>
|
||||
<option value="imperial">Imperial</option>
|
||||
<option value="metric">
|
||||
{t("settings.sharedRecord.managedSettings.profile.metric")}
|
||||
</option>
|
||||
<option value="imperial">
|
||||
{t("settings.sharedRecord.managedSettings.profile.imperial")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-time-format">
|
||||
Time format
|
||||
{t("settings.sharedRecord.managedSettings.profile.timeFormat")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.timeFormat, "AUTO")}
|
||||
@@ -221,13 +259,19 @@ function ProfileSettingsForm({
|
||||
id="managed-time-format"
|
||||
name="timeFormat"
|
||||
>
|
||||
<option value="AUTO">Automatic</option>
|
||||
<option value="H12">12-hour</option>
|
||||
<option value="H24">24-hour</option>
|
||||
<option value="AUTO">
|
||||
{t("settings.sharedRecord.managedSettings.profile.automatic")}
|
||||
</option>
|
||||
<option value="H12">
|
||||
{t("settings.sharedRecord.managedSettings.profile.hour12")}
|
||||
</option>
|
||||
<option value="H24">
|
||||
{t("settings.sharedRecord.managedSettings.profile.hour24")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-date-format">
|
||||
Date format
|
||||
{t("settings.sharedRecord.managedSettings.profile.dateFormat")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(settings.dateFormat, "AUTO")}
|
||||
@@ -235,10 +279,18 @@ function ProfileSettingsForm({
|
||||
id="managed-date-format"
|
||||
name="dateFormat"
|
||||
>
|
||||
<option value="AUTO">Automatic</option>
|
||||
<option value="DMY">Day / month / year</option>
|
||||
<option value="MDY">Month / day / year</option>
|
||||
<option value="YMD">Year / month / day</option>
|
||||
<option value="AUTO">
|
||||
{t("settings.sharedRecord.managedSettings.profile.automatic")}
|
||||
</option>
|
||||
<option value="DMY">
|
||||
{t("settings.sharedRecord.managedSettings.profile.dateDmy")}
|
||||
</option>
|
||||
<option value="MDY">
|
||||
{t("settings.sharedRecord.managedSettings.profile.dateMdy")}
|
||||
</option>
|
||||
<option value="YMD">
|
||||
{t("settings.sharedRecord.managedSettings.profile.dateYmd")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="sm:col-span-2">
|
||||
@@ -254,10 +306,12 @@ function ModulesSettingsForm({
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const preferences = asRecord(settings.modulePreferences);
|
||||
const entries = Object.entries(preferences).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
const entries = Object.entries(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS) as [
|
||||
keyof typeof MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
boolean,
|
||||
][];
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -271,15 +325,19 @@ function ModulesSettingsForm({
|
||||
|
||||
return (
|
||||
<form className="mt-4 space-y-3" onSubmit={submit}>
|
||||
{entries.map(([key, enabled]) => (
|
||||
{entries.map(([key, defaultEnabled]) => (
|
||||
<label className="flex items-center gap-3 text-sm" key={key}>
|
||||
<input
|
||||
defaultChecked={enabled === true}
|
||||
defaultChecked={
|
||||
preferences[key] === undefined
|
||||
? defaultEnabled
|
||||
: preferences[key] === true
|
||||
}
|
||||
disabled={disabled}
|
||||
name={key}
|
||||
type="checkbox"
|
||||
/>
|
||||
{key}
|
||||
{t(MODULE_REGISTRY[key].labelKey)}
|
||||
</label>
|
||||
))}
|
||||
<SaveButton disabled={disabled} label={saveLabel} />
|
||||
@@ -293,6 +351,7 @@ function NotificationsSettingsForm({
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const preferences = asRecord(settings.notificationPreferences);
|
||||
const medication = asRecord(preferences.medication);
|
||||
const mood = asRecord(preferences.mood);
|
||||
@@ -324,10 +383,10 @@ function NotificationsSettingsForm({
|
||||
name="moodReminderEnabled"
|
||||
type="checkbox"
|
||||
/>
|
||||
Enable mood reminder
|
||||
{t("settings.sharedRecord.managedSettings.notifications.moodReminder")}
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-reminder-hour">
|
||||
Mood reminder hour
|
||||
{t("settings.sharedRecord.managedSettings.notifications.reminderHour")}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asNumber(mood.reminderHour, 22)}
|
||||
@@ -340,7 +399,9 @@ function NotificationsSettingsForm({
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-low-stock">
|
||||
Low-stock runway days
|
||||
{t(
|
||||
"settings.sharedRecord.managedSettings.notifications.lowStockRunwayDays",
|
||||
)}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={
|
||||
@@ -357,7 +418,9 @@ function NotificationsSettingsForm({
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-reorder-lead">
|
||||
Reorder lead days
|
||||
{t(
|
||||
"settings.sharedRecord.managedSettings.notifications.reorderLeadDays",
|
||||
)}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asNumber(medication.reorderLeadDays, 10)}
|
||||
@@ -382,20 +445,30 @@ function ThresholdsSettingsForm({
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const overrides = asRecord(settings.overrides);
|
||||
const metrics = Object.keys(overrides);
|
||||
const metric = metrics[0] ?? "WEIGHT";
|
||||
const firstRange = asRecord(overrides[metric]);
|
||||
const effective = asRecord(settings.effective);
|
||||
const [metric, setMetric] = useState<ThresholdMetric>(ALL_METRICS[0]);
|
||||
const overrideRange = asRecord(overrides[metric]);
|
||||
const defaultRange = asRecord(asRecord(effective[metric]).range);
|
||||
const bounds = METRIC_BOUNDS[metric];
|
||||
const minimum = asNumber(
|
||||
overrideRange.min,
|
||||
asNumber(defaultRange.greenMin, bounds.min),
|
||||
);
|
||||
const maximum = asNumber(
|
||||
overrideRange.max,
|
||||
asNumber(defaultRange.greenMax, bounds.max),
|
||||
);
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const selectedMetric = asString(form.get("metric"), metric);
|
||||
onSave({
|
||||
overrides: {
|
||||
[selectedMetric]: {
|
||||
min: formNumber(form, "minimum", asNumber(firstRange.min, 0)),
|
||||
max: formNumber(form, "maximum", asNumber(firstRange.max, 0)),
|
||||
[metric]: {
|
||||
min: formNumber(form, "minimum", minimum),
|
||||
max: formNumber(form, "maximum", maximum),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -404,40 +477,43 @@ function ThresholdsSettingsForm({
|
||||
return (
|
||||
<form className="mt-4 grid gap-4 sm:grid-cols-3" onSubmit={submit}>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-metric">
|
||||
Metric
|
||||
{t("settings.sharedRecord.managedSettings.thresholds.metric")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={metric}
|
||||
onChange={(event) => setMetric(event.target.value as ThresholdMetric)}
|
||||
value={metric}
|
||||
disabled={disabled}
|
||||
id="managed-threshold-metric"
|
||||
name="metric"
|
||||
>
|
||||
{[...new Set([metric, ...metrics])].map((entry) => (
|
||||
{ALL_METRICS.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{entry}
|
||||
{t(METRIC_LABEL_KEYS[entry])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-minimum">
|
||||
Minimum
|
||||
{t("settings.sharedRecord.managedSettings.thresholds.minimum")}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asNumber(firstRange.min, 0)}
|
||||
defaultValue={minimum}
|
||||
disabled={disabled}
|
||||
id="managed-threshold-minimum"
|
||||
key={`${metric}-minimum`}
|
||||
name="minimum"
|
||||
required
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-maximum">
|
||||
Maximum
|
||||
{t("settings.sharedRecord.managedSettings.thresholds.maximum")}
|
||||
<input
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asNumber(firstRange.max, 0)}
|
||||
defaultValue={maximum}
|
||||
disabled={disabled}
|
||||
id="managed-threshold-maximum"
|
||||
key={`${metric}-maximum`}
|
||||
name="maximum"
|
||||
required
|
||||
type="number"
|
||||
@@ -456,6 +532,7 @@ function CoachSettingsForm({
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const preferences = asRecord(settings.preferences);
|
||||
const excluded = new Set(asStringArray(preferences.excludeMetrics));
|
||||
const dataClusters = new Set(asStringArray(preferences.dataClusters));
|
||||
@@ -493,11 +570,11 @@ function CoachSettingsForm({
|
||||
name="disableCoach"
|
||||
type="checkbox"
|
||||
/>
|
||||
Disable Coach
|
||||
{t("settings.sharedRecord.managedSettings.coach.disable")}
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-coach-tone">
|
||||
Tone
|
||||
{t("settings.sharedRecord.managedSettings.coach.tone")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(preferences.tone, "warm")}
|
||||
@@ -505,13 +582,19 @@ function CoachSettingsForm({
|
||||
id="managed-coach-tone"
|
||||
name="tone"
|
||||
>
|
||||
<option value="warm">Warm</option>
|
||||
<option value="neutral">Neutral</option>
|
||||
<option value="concise">Concise</option>
|
||||
<option value="warm">
|
||||
{t("settings.sharedRecord.managedSettings.coach.warm")}
|
||||
</option>
|
||||
<option value="neutral">
|
||||
{t("settings.sharedRecord.managedSettings.coach.neutral")}
|
||||
</option>
|
||||
<option value="concise">
|
||||
{t("settings.sharedRecord.managedSettings.coach.concise")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-coach-verbosity">
|
||||
Verbosity
|
||||
{t("settings.sharedRecord.managedSettings.coach.verbosity")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(preferences.verbosity, "default")}
|
||||
@@ -519,13 +602,19 @@ function CoachSettingsForm({
|
||||
id="managed-coach-verbosity"
|
||||
name="verbosity"
|
||||
>
|
||||
<option value="brief">Brief</option>
|
||||
<option value="default">Default</option>
|
||||
<option value="detailed">Detailed</option>
|
||||
<option value="brief">
|
||||
{t("settings.sharedRecord.managedSettings.coach.brief")}
|
||||
</option>
|
||||
<option value="default">
|
||||
{t("settings.sharedRecord.managedSettings.coach.default")}
|
||||
</option>
|
||||
<option value="detailed">
|
||||
{t("settings.sharedRecord.managedSettings.coach.detailed")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm" htmlFor="managed-coach-window">
|
||||
Default window
|
||||
{t("settings.sharedRecord.managedSettings.coach.defaultWindow")}
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 py-2"
|
||||
defaultValue={asString(preferences.defaultWindow, "allTime")}
|
||||
@@ -533,10 +622,18 @@ function CoachSettingsForm({
|
||||
id="managed-coach-window"
|
||||
name="defaultWindow"
|
||||
>
|
||||
<option value="last7days">Last 7 days</option>
|
||||
<option value="last30days">Last 30 days</option>
|
||||
<option value="last90days">Last 90 days</option>
|
||||
<option value="allTime">All time</option>
|
||||
<option value="last7days">
|
||||
{t("settings.sharedRecord.managedSettings.coach.last7days")}
|
||||
</option>
|
||||
<option value="last30days">
|
||||
{t("settings.sharedRecord.managedSettings.coach.last30days")}
|
||||
</option>
|
||||
<option value="last90days">
|
||||
{t("settings.sharedRecord.managedSettings.coach.last90days")}
|
||||
</option>
|
||||
<option value="allTime">
|
||||
{t("settings.sharedRecord.managedSettings.coach.allTime")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -547,10 +644,12 @@ function CoachSettingsForm({
|
||||
name="showEvidenceByDefault"
|
||||
type="checkbox"
|
||||
/>
|
||||
Show evidence by default
|
||||
{t("settings.sharedRecord.managedSettings.coach.showEvidence")}
|
||||
</label>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Excluded metrics</legend>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("settings.sharedRecord.managedSettings.coach.excludedMetrics")}
|
||||
</legend>
|
||||
{COACH_EXCLUDE_METRICS.map((metric) => (
|
||||
<label
|
||||
className="mr-4 inline-flex items-center gap-2 text-sm"
|
||||
@@ -562,12 +661,16 @@ function CoachSettingsForm({
|
||||
name={`exclude-${metric}`}
|
||||
type="checkbox"
|
||||
/>
|
||||
{metric}
|
||||
{t("settings.sharedRecord.managedSettings.coach.value", {
|
||||
value: metric,
|
||||
})}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Data clusters</legend>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("settings.sharedRecord.managedSettings.coach.dataClusters")}
|
||||
</legend>
|
||||
<label className="mb-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
defaultChecked={Array.isArray(preferences.dataClusters)}
|
||||
@@ -575,7 +678,7 @@ function CoachSettingsForm({
|
||||
name="useCustomClusters"
|
||||
type="checkbox"
|
||||
/>
|
||||
Use a custom cluster selection
|
||||
{t("settings.sharedRecord.managedSettings.coach.customClusters")}
|
||||
</label>
|
||||
{COACH_DATA_CLUSTERS.map((cluster) => (
|
||||
<label
|
||||
@@ -588,7 +691,9 @@ function CoachSettingsForm({
|
||||
name={`cluster-${cluster}`}
|
||||
type="checkbox"
|
||||
/>
|
||||
{cluster}
|
||||
{t("settings.sharedRecord.managedSettings.coach.value", {
|
||||
value: cluster,
|
||||
})}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
@@ -622,6 +727,7 @@ function InsightsSettingsForm({
|
||||
onSave,
|
||||
saveLabel,
|
||||
}: SettingsFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const layout = asRecord(settings.layout);
|
||||
const sections = layoutItems(layout.sections);
|
||||
const tiles = layoutItems(layout.tiles);
|
||||
@@ -646,7 +752,9 @@ function InsightsSettingsForm({
|
||||
const renderItems = (kind: "section" | "tile", items: LayoutItem[]) => (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{kind === "section" ? "Overview sections" : "Navigation tiles"}
|
||||
{kind === "section"
|
||||
? t("settings.sharedRecord.managedSettings.insights.sections")
|
||||
: t("settings.sharedRecord.managedSettings.insights.tiles")}
|
||||
</legend>
|
||||
{items.map((item) => (
|
||||
<div className="flex items-center gap-3 text-sm" key={item.id}>
|
||||
@@ -657,13 +765,15 @@ function InsightsSettingsForm({
|
||||
name={`${kind}-visible-${item.id}`}
|
||||
type="checkbox"
|
||||
/>
|
||||
{item.id}
|
||||
{t("settings.sharedRecord.managedSettings.insights.item", {
|
||||
id: item.id,
|
||||
})}
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-2"
|
||||
htmlFor={`${kind}-order-${item.id}`}
|
||||
>
|
||||
Order
|
||||
{t("settings.sharedRecord.managedSettings.insights.order")}
|
||||
<input
|
||||
className="border-input bg-background w-16 rounded-md border px-2 py-1"
|
||||
defaultValue={item.order}
|
||||
@@ -770,7 +880,7 @@ export function ManagedRecordSettingsSection({
|
||||
<SettingsCard aria-busy={query.isLoading}>
|
||||
<SettingsCardHeader
|
||||
icon={Settings}
|
||||
title={t(`settings.sections.${family}.title`)}
|
||||
title={t(`settings.sharedRecord.managedSettings.${family}.title`)}
|
||||
/>
|
||||
{query.data ? (
|
||||
<>
|
||||
|
||||
@@ -4,8 +4,12 @@ import {
|
||||
ACCEPTED_INSIGHTS_TILE_IDS,
|
||||
INSIGHTS_SECTION_IDS,
|
||||
} from "@/lib/insights-layout";
|
||||
import { MODULE_REGISTRY } from "@/lib/modules/registry";
|
||||
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
import {
|
||||
modulePrefsPatchSchema,
|
||||
WRITABLE_MODULE_KEYS,
|
||||
} from "@/lib/validations/modules";
|
||||
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
||||
|
||||
/**
|
||||
@@ -54,7 +58,7 @@ const profilePatchSchema = z
|
||||
displayName: z.string().trim().min(1).max(100).nullable().optional(),
|
||||
heightCm: z.number().finite().min(30).max(300).nullable().optional(),
|
||||
dateOfBirth: z.string().date().nullable().optional(),
|
||||
gender: z.string().trim().min(1).max(64).nullable().optional(),
|
||||
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable().optional(),
|
||||
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable().optional(),
|
||||
timezone: timezoneSchema.optional(),
|
||||
unitPreference: z.enum(["metric", "imperial"]).optional(),
|
||||
@@ -161,7 +165,7 @@ export type ManagedRecordSettingsPatch = {
|
||||
export function isManagedRecordSettingsFamily(
|
||||
value: string,
|
||||
): value is ManagedRecordSettingsFamily {
|
||||
return value in MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS;
|
||||
return Object.hasOwn(MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS, value);
|
||||
}
|
||||
|
||||
/** Parse one named DTO family. Strict schemas make an actor field a 422. */
|
||||
@@ -179,15 +183,28 @@ export function safeParseManagedRecordSettingsPatch<
|
||||
return MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS[family].safeParse(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same direct module inventory and default posture as `/api/auth/me/modules`.
|
||||
* Every entry is present for a fresh record so a Guardian can administer the
|
||||
* whole supported surface instead of seeing only previously persisted keys.
|
||||
*/
|
||||
export const MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS = Object.freeze(
|
||||
Object.fromEntries(
|
||||
WRITABLE_MODULE_KEYS.map((key) => [key, !MODULE_REGISTRY[key].optIn]),
|
||||
),
|
||||
) as Readonly<Record<(typeof WRITABLE_MODULE_KEYS)[number], boolean>>;
|
||||
|
||||
/** Preserve only directly-owned module keys from a legacy persisted blob. */
|
||||
export function managedModulePreferencesFrom(
|
||||
raw: unknown,
|
||||
): ManagedRecordSettingsPatch["modules"]["modulePreferences"] {
|
||||
const allowed: Record<string, boolean> = {
|
||||
...MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
};
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
return allowed;
|
||||
}
|
||||
|
||||
const allowed: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const parsed = modulePrefsPatchSchema.safeParse({ [key]: value });
|
||||
if (parsed.success && value !== undefined) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
isManagedRecordSettingsFamily,
|
||||
managedModulePreferencesFrom,
|
||||
MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST,
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
parseManagedRecordSettingsPatch,
|
||||
safeParseManagedRecordSettingsPatch,
|
||||
type ManagedRecordSettingsFamily,
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
* opt into it gets a clean 422 rather than persisting an inert `true` the
|
||||
* gate would ignore anyway.
|
||||
*/
|
||||
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
|
||||
export const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
|
||||
(k) => moduleDelegatesTo(k) === undefined && !isCodeDisabledModule(k),
|
||||
);
|
||||
|
||||
|
||||
@@ -78,5 +78,8 @@ describe("record settings authorization", () => {
|
||||
expect(route).toContain("actorUserId: access.actorId");
|
||||
expect(route).toContain("details: { changed }");
|
||||
expect(route).not.toContain("details: { recordId:");
|
||||
expect(route).toContain("invalidateUserMedications(access.recordId, {");
|
||||
expect(route).toContain("invalidateUserHealthScore(access.recordId)");
|
||||
expect(route).toContain("getAllEffectiveRanges(record, overrides)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user