diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a76c8db4c..a39f3e579 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -65763,6 +65763,8 @@ components: type: string accountAccess: $ref: "#/components/schemas/AccountAccess" + moduleAccess: + $ref: "#/components/schemas/ModuleAccessMap" recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" @@ -65771,6 +65773,7 @@ components: - id - username - accountAccess + - moduleAccess - recordSession additionalProperties: {} description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. @@ -65780,7 +65783,9 @@ components: `cycleTrackingEnabled` describe the ACTIVE RECORD, since every surface they gate shows the record's data — and they are masked to the sections the active grant opens, so a scoped grant reads `false` for a module outside it rather than the record's true state. With no switch — which is every native request, since the Bearer transport - carries none — the two are the same account and nothing is masked." + carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is + not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the + `modules` booleans it never contradicts." AccountAccess: type: object properties: @@ -65985,6 +65990,191 @@ components: be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field." + ModuleAccessMap: + type: object + properties: + cycle: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "cycle" module is or is not available here. + mood: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mood" module is or is not available here. + sleep: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "sleep" module is or is not available here. + glucose: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "glucose" module is or is not available here. + workouts: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "workouts" module is or is not available here. + recovery: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "recovery" module is or is not available here. + labs: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "labs" module is or is not available here. + illness: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "illness" module is or is not available here. + achievements: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "achievements" module is or is not available here. + coach: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "coach" module is or is not available here. + insights: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "insights" module is or is not available here. + medications: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "medications" module is or is not available here. + doctorReport: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "doctorReport" module is or is not available here. + environment: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "environment" module is or is not available here. + mcp: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mcp" module is or is not available here. + inboundDocuments: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "inboundDocuments" module is or is not available here. + mentalHealth: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "mentalHealth" module is or is not available here. + nutrients: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "nutrients" module is or is not available here. + vaccinations: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "vaccinations" module is or is not available here. + required: + - cycle + - mood + - sleep + - glucose + - workouts + - recovery + - labs + - illness + - achievements + - coach + - insights + - medications + - doctorReport + - environment + - mcp + - inboundDocuments + - mentalHealth + - nutrients + - vaccinations + additionalProperties: false + description: "Why each toggleable module is or is not available for the record this session is inside. `enabled` — the + module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the + active grant's sections do not open the module's section, which also covers every module that reads across the + whole record (achievements, the assistant surfaces, the export, environment, the account's own MCP endpoint) + since no scoped grant opens one. `unavailable` — the operator switched the module off for the whole instance. + Precedence, highest first: `unavailable` > `not_granted` > `disabled` > `enabled`, so the reason published is + the one nothing further in can change. Own-record sessions and unscoped grants never produce `not_granted`. + Every key is present, and `modules[key] === (moduleAccess[key] === \"enabled\")` holds for every key — this + field adds a reason, never a different gate." RecordSessionState: type: object properties: diff --git a/messages/de.json b/messages/de.json index 5c326a8f0..7b73179a9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8682,6 +8682,20 @@ "description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung." } }, + "moduleOff": { + "disabled": { + "description": "{module} ist für diese Akte ausgeschaltet, deshalb bleiben die Seiten verborgen. Schalte das Modul wieder ein, um sie zurückzuholen.", + "title": "{module} ist ausgeschaltet" + }, + "notGranted": { + "description": "{module} liegt außerhalb der Bereiche, die mit dir geteilt wurden. Was ein geteilter Zugriff umfasst, legt die Person fest, der die Akte gehört.", + "title": "Gehört nicht zum geteilten Zugriff" + }, + "unavailable": { + "description": "Der Betreiber hat {module} für alle Konten auf diesem Server ausgeschaltet. Keine eigene Einstellung holt es zurück.", + "title": "Auf diesem Server nicht verfügbar" + } + }, "vaccinations": { "title": "Impfungen", "subtitle": "Dein Impfpass, Dosis für Dosis.", @@ -9920,7 +9934,6 @@ "page": { "title": "Nährstoffaufnahme", "description": "Flüssigkeits- und Vitamin-/Mineralstoffaufnahme aus der Gesundheits-App deines Telefons, plus manuelle Wassereinträge.", - "moduleOffTitle": "Nährstoffaufnahme ist aus", "moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.", "moduleOffCta": "Nährstoffe aktivieren", "emptyTitle": "Noch keine Nährstoffdaten", diff --git a/messages/en.json b/messages/en.json index 8c1e7452a..0d612b3d2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8682,6 +8682,20 @@ "description": "The vaccination record, structured: dose, series, next booster." } }, + "moduleOff": { + "disabled": { + "description": "{module} is off for this record, so its pages stay hidden. Turn it back on to bring them back.", + "title": "{module} is switched off" + }, + "notGranted": { + "description": "{module} sits outside the sections shared with you. What a shared record covers is decided by the person who owns it.", + "title": "Not part of the shared access" + }, + "unavailable": { + "description": "The operator switched {module} off for every account on this server. No setting of your own can bring it back.", + "title": "Not available on this server" + } + }, "vaccinations": { "title": "Vaccinations", "subtitle": "Your immunization record, dose by dose.", @@ -9920,7 +9934,6 @@ "page": { "title": "Nutrient intake", "description": "Hydration and vitamin/mineral intake synced from your phone's health app, plus manual water entries.", - "moduleOffTitle": "Nutrient intake is off", "moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.", "moduleOffCta": "Turn on nutrients", "emptyTitle": "No nutrient data yet", diff --git a/messages/es.json b/messages/es.json index a2244e52b..e51aa816a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8682,6 +8682,20 @@ "description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo." } }, + "moduleOff": { + "disabled": { + "description": "{module} está desactivado en este registro, así que sus páginas quedan ocultas. Vuelve a activarlo para recuperarlas.", + "title": "{module} está desactivado" + }, + "notGranted": { + "description": "{module} queda fuera de las secciones que se han compartido contigo. Lo que abarca un registro compartido lo decide la persona propietaria.", + "title": "No forma parte del acceso compartido" + }, + "unavailable": { + "description": "El operador ha desactivado {module} para todas las cuentas de este servidor. Ningún ajuste tuyo puede recuperarlo.", + "title": "No disponible en este servidor" + } + }, "vaccinations": { "title": "Vacunas", "subtitle": "Tu registro de vacunación, dosis a dosis.", @@ -9920,7 +9934,6 @@ "page": { "title": "Ingesta de nutrientes", "description": "Hidratación e ingesta de vitaminas/minerales sincronizadas desde la app de salud de tu teléfono, más entradas manuales de agua.", - "moduleOffTitle": "La ingesta de nutrientes está desactivada", "moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.", "moduleOffCta": "Activar nutrientes", "emptyTitle": "Aún no hay datos de nutrientes", diff --git a/messages/fr.json b/messages/fr.json index 38342e4fa..c039d6fdf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8682,6 +8682,20 @@ "description": "Le carnet de vaccination structuré : dose, série, prochain rappel." } }, + "moduleOff": { + "disabled": { + "description": "{module} est désactivé pour ce dossier, ses pages restent donc masquées. Réactivez le module pour les retrouver.", + "title": "{module} est désactivé" + }, + "notGranted": { + "description": "{module} se trouve en dehors des sections partagées avec vous. Ce que couvre un dossier partagé est décidé par la personne à qui il appartient.", + "title": "Ne fait pas partie de l'accès partagé" + }, + "unavailable": { + "description": "L'exploitant a désactivé {module} pour tous les comptes de ce serveur. Aucun réglage de votre côté ne peut le rétablir.", + "title": "Non disponible sur ce serveur" + } + }, "vaccinations": { "title": "Vaccinations", "subtitle": "Votre carnet de vaccination, dose par dose.", @@ -9920,7 +9934,6 @@ "page": { "title": "Apports en nutriments", "description": "Hydratation et apports en vitamines/minéraux synchronisés depuis l'appli santé de votre téléphone, plus les entrées manuelles d'eau.", - "moduleOffTitle": "Le suivi des nutriments est désactivé", "moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.", "moduleOffCta": "Activer les nutriments", "emptyTitle": "Pas encore de données nutritionnelles", diff --git a/messages/it.json b/messages/it.json index 22784cfb7..6584b0bf8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8682,6 +8682,20 @@ "description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo." } }, + "moduleOff": { + "disabled": { + "description": "{module} è disattivato per questi dati, quindi le sue pagine restano nascoste. Riattivalo per ritrovarle.", + "title": "{module} è disattivato" + }, + "notGranted": { + "description": "{module} è fuori dalle sezioni condivise con te. Cosa comprende un accesso condiviso lo decide la persona a cui appartengono i dati.", + "title": "Non fa parte dell'accesso condiviso" + }, + "unavailable": { + "description": "Chi gestisce il server ha disattivato {module} per tutti gli account. Nessuna impostazione tua può riportarlo indietro.", + "title": "Non disponibile su questo server" + } + }, "vaccinations": { "title": "Vaccinazioni", "subtitle": "Il tuo libretto vaccinale, dose per dose.", @@ -9920,7 +9934,6 @@ "page": { "title": "Apporto di nutrienti", "description": "Idratazione e apporto di vitamine/minerali sincronizzati dall'app salute del telefono, più le voci di acqua inserite manualmente.", - "moduleOffTitle": "L'apporto di nutrienti è disattivato", "moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.", "moduleOffCta": "Attiva nutrienti", "emptyTitle": "Ancora nessun dato sui nutrienti", diff --git a/messages/ko.json b/messages/ko.json index 3c96f69dd..8e11070ec 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8682,6 +8682,20 @@ "description": "예방접종 기록을 구조화해서: 용량, 차수, 다음 추가 접종." } }, + "moduleOff": { + "disabled": { + "description": "이 기록에서 {module} 모듈이 꺼져 있어서 관련 페이지가 보이지 않아요. 다시 켜면 돌아와요.", + "title": "{module} 모듈이 꺼져 있어요" + }, + "notGranted": { + "description": "{module} 모듈은 공유된 영역 밖에 있어요. 공유된 기록이 어디까지 포함되는지는 기록의 주인이 정해요.", + "title": "공유 범위에 없는 항목" + }, + "unavailable": { + "description": "운영자가 이 서버의 모든 계정에서 {module} 모듈을 껐어요. 개인 설정으로는 되돌릴 수 없어요.", + "title": "이 서버에서는 사용할 수 없어요" + } + }, "vaccinations": { "title": "예방접종", "subtitle": "접종 한 건씩 남기는 예방접종 기록이에요.", @@ -9920,7 +9934,6 @@ "page": { "title": "영양소 섭취", "description": "휴대폰 건강 앱에서 동기화된 수분·비타민/미네랄 섭취량과 직접 입력한 수분 기록이에요.", - "moduleOffTitle": "영양소 섭취가 꺼져 있어요", "moduleOffDescription": "영양소 모듈을 켜면 수분과 비타민/미네랄 섭취량을 여기서 볼 수 있어요. 동기화된 섭취량은 코치가 읽는 기록에 포함돼요.", "moduleOffCta": "영양소 켜기", "emptyTitle": "아직 영양소 데이터가 없어요", diff --git a/messages/pl.json b/messages/pl.json index 1ab5e3098..eaa8bb7b9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8682,6 +8682,20 @@ "description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie." } }, + "moduleOff": { + "disabled": { + "description": "{module} jest wyłączone dla tego zapisu, więc jego strony pozostają ukryte. Włącz moduł ponownie, aby je odzyskać.", + "title": "{module} jest wyłączone" + }, + "notGranted": { + "description": "{module} jest poza sekcjami udostępnionymi tobie. O tym, co obejmuje udostępniony zapis, decyduje osoba, do której należy.", + "title": "Nie należy do udostępnionego dostępu" + }, + "unavailable": { + "description": "Operator wyłączył {module} dla wszystkich kont na tym serwerze. Żadne twoje ustawienie tego nie przywróci.", + "title": "Niedostępne na tym serwerze" + } + }, "vaccinations": { "title": "Szczepienia", "subtitle": "Twoja karta szczepień, dawka po dawce.", @@ -9920,7 +9934,6 @@ "page": { "title": "Spożycie składników odżywczych", "description": "Nawodnienie oraz spożycie witamin/minerałów zsynchronizowane z aplikacji zdrowia telefonu, plus ręczne wpisy wody.", - "moduleOffTitle": "Śledzenie składników odżywczych jest wyłączone", "moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.", "moduleOffCta": "Włącz składniki odżywcze", "emptyTitle": "Brak jeszcze danych o składnikach odżywczych", diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 8f4b63a3c..90ca60fcc 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -57,7 +57,7 @@ import { import { parseTourProgress } from "@/lib/onboarding/tour-progress"; import { resolveAccountAccess } from "@/lib/sharing/account-access"; import { - maskModulesToSections, + buildModuleDisclosure, sectionsOpen, } from "@/lib/sharing/module-disclosure"; import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; @@ -144,7 +144,15 @@ export const GET = apiHandler(async () => { // opens. A closed section reads `false`, the same answer a module that is // off already gives, and the navigation the client builds from it drops // exactly the doors `isSharedRecordPathPresentable` drops anyway. - const modules = maskModulesToSections(resolvedModules, sections); + // Two maps out of one pass: `modules` is the boolean gate map, masked + // exactly as it was, and `moduleAccess` is the same answer with the reason + // attached — the record's own switch, the grant's edge, or the operator's. + // Every client that only wants "paint it or not" keeps reading the boolean. + const { modules, moduleAccess } = buildModuleDisclosure( + resolvedModules, + moduleAvailability, + sections, + ); // The cycle flag is masked on its own rather than read off `modules.cycle`: // that key also carries the operator's server-wide availability, and this // field never has. Same section, same answer, without borrowing a second @@ -249,6 +257,15 @@ export const GET = apiHandler(async () => { // this to show a "disabled server-wide" read-only row; everywhere else // the already-AND-ed `modules` map is the single gate to read. moduleAvailability, + // Why each module's surfaces are or are not there, for the record this + // session is inside: `enabled`, `disabled` (the record's own switch), + // `not_granted` (the active grant does not open the module's section) or + // `unavailable` (the operator switched it off for the whole instance). + // Precedence outside-in: unavailable > not_granted > disabled > enabled. + // `modules[key]` stays exactly `moduleAccess[key] === "enabled"`, so this + // adds a reason without moving a gate; empty states read it to say the + // honest thing rather than offering a switch the reader cannot reach. + moduleAccess, // v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu, // `active` is the record this session is inside (null when it is in its // own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds diff --git a/src/app/insights/nutrients/page.tsx b/src/app/insights/nutrients/page.tsx index 7e8b03008..db15686f9 100644 --- a/src/app/insights/nutrients/page.tsx +++ b/src/app/insights/nutrients/page.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { ModuleDisabledNotice } from "@/components/layout/module-disabled-notice"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { HydrationCard } from "@/components/insights/nutrients/hydration-card"; import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card"; @@ -52,11 +53,12 @@ function lastAttemptReasonKey(reason: string): string { * (self-gates to nothing without data), micronutrients (self-gates to * an EmptyState without data). Degradation ladder: * - * - module off → one EmptyState with an in-context enable CTA. The - * module STAYS opt-in (2026-07-17 memo — the HealthKit read prompt - * on the device is not visible consent to a server / Coach holding - * a supplement pattern); this page just makes the toggle - * discoverable in context instead of buried in Settings. + * - module off → the shared `ModuleDisabledNotice`, which names the reason + * and carries the in-context enable CTA when it is the record's own + * switch that is off. The module STAYS opt-in (2026-07-17 memo — the + * HealthKit read prompt on the device is not visible consent to a + * server / Coach holding a supplement pattern); this page just makes + * the toggle discoverable in context instead of buried in Settings. * - module on, the overview read failed → a `QueryErrorCard` with a * retry. This branch has to sit ABOVE the empty-state check below: * `overview.data` is `undefined` on a failed read exactly like it is @@ -106,11 +108,18 @@ export default function InsightsNutrientsPage() { if (!nutrientsEnabled) { return ( - } - title={t("nutrients.page.moduleOffTitle")} - description={t("nutrients.page.moduleOffDescription")} - ctaSize="lg" action={