fix(mental-health): stop sending Portuguese readers in Portugal to Brazil-only crisis lines

The app ships one Portuguese bundle, and pt-PT, pt-AO and bare pt all
fold into pt-BR, so a stored pt-BR does not mean Brazil. A positive
PHQ-9 item 9 still answered it with SAMU 192 as the emergency number
and CVV 188 as the only line, neither of which connects from Portugal,
under a comment claiming pt-PT kept the international set.

With no region signal, Portuguese now gets the DE_AT_CH treatment: 112
first, the worldwide directory, then CVV and SAMU labelled Brazil and
SNS 24 (808 24 24 24, option 4) and SOS Voz Amiga labelled Portugal.
An explicit pt-PT tag gets the Portugal set and br the Brazil set.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 18:08:31 +02:00
1 parent 5448a43651
commit 38941c55d8
11 files changed
+196 -30

No files matched your search

+10 -1
View File
@@ -11064,7 +11064,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (rund um die Uhr, kostenlos, vertraulich)"
"name": "CVV, Centro de Valorização da Vida (Brasilien, rund um die Uhr, kostenlos, vertraulich)"
},
"samu": {
"name": "SAMU, medizinischer Notruf (Brasilien)"
},
"sns24": {
"name": "SNS 24, psychologische Beratung (Portugal, rund um die Uhr, Option 4 wählen)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, täglich 15:30 bis 0:30 Uhr)"
}
},
"instrumentDescription": {
+10 -1
View File
@@ -11074,7 +11074,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (24/7, free, confidential)"
"name": "CVV, Centro de Valorização da Vida (Brazil, 24/7, free, confidential)"
},
"samu": {
"name": "SAMU medical emergency service (Brazil)"
},
"sns24": {
"name": "SNS 24 psychological counselling (Portugal, 24/7, choose option 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, daily 3.30 pm to 12.30 am)"
}
},
"instrumentDescription": {
+10 -1
View File
@@ -11064,7 +11064,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (24/7, gratuito y confidencial)"
"name": "CVV, Centro de Valorização da Vida (Brasil, 24/7, gratuito y confidencial)"
},
"samu": {
"name": "SAMU, urgencias médicas (Brasil)"
},
"sns24": {
"name": "SNS 24, asesoramiento psicológico (Portugal, 24/7, opción 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, todos los días de 15:30 a 0:30)"
}
},
"instrumentDescription": {
+10 -1
View File
@@ -11064,7 +11064,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (24h/24, 7j/7, gratuit et confidentiel)"
"name": "CVV, Centro de Valorização da Vida (Brésil, 24h/24, 7j/7, gratuit et confidentiel)"
},
"samu": {
"name": "SAMU, urgences médicales (Brésil)"
},
"sns24": {
"name": "SNS 24, soutien psychologique (Portugal, 24h/24, 7j/7, option 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, tous les jours de 15h30 à 0h30)"
}
},
"instrumentDescription": {
+10 -1
View File
@@ -11064,7 +11064,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (24/7, gratuito e riservato)"
"name": "CVV, Centro de Valorização da Vida (Brasile, 24/7, gratuito e riservato)"
},
"samu": {
"name": "SAMU, emergenza sanitaria (Brasile)"
},
"sns24": {
"name": "SNS 24, supporto psicologico (Portogallo, 24/7, opzione 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portogallo, tutti i giorni dalle 15:30 alle 0:30)"
}
},
"instrumentDescription": {
+10 -1
View File
@@ -11064,7 +11064,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (24시간 연중무휴, 무료, 비밀 보장)"
"name": "CVV, Centro de Valorização da Vida (브라질, 24시간 연중무휴, 무료, 비밀 보장)"
},
"samu": {
"name": "SAMU 응급의료 서비스 (브라질)"
},
"sns24": {
"name": "SNS 24 심리 상담 (포르투갈, 24시간 연중무휴, 4번 선택)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (포르투갈, 매일 15:30~00:30)"
}
},
"instrumentDescription": {
+10 -1
View File
@@ -11022,7 +11022,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (dag en nacht, gratis, vertrouwelijk)"
"name": "CVV, Centro de Valorização da Vida (Brazilië, dag en nacht, gratis, vertrouwelijk)"
},
"samu": {
"name": "SAMU, medische spoedhulp (Brazilië)"
},
"sns24": {
"name": "SNS 24, psychologische hulp (Portugal, dag en nacht, keuze 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, dagelijks van 15.30 tot 0.30 uur)"
}
},
"instrumentDescription": {
+10 -1
View File
@@ -11064,7 +11064,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (całodobowo, bezpłatnie i poufnie)"
"name": "CVV, Centro de Valorização da Vida (Brazylia, całodobowo, bezpłatnie i poufnie)"
},
"samu": {
"name": "SAMU, pogotowie ratunkowe (Brazylia)"
},
"sns24": {
"name": "SNS 24, wsparcie psychologiczne (Portugalia, całodobowo, opcja 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugalia, codziennie od 15:30 do 0:30)"
}
},
"instrumentDescription": {
+10 -1
View File
@@ -11022,7 +11022,16 @@
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (24 horas, gratuito, sigiloso)"
"name": "CVV, Centro de Valorização da Vida (Brasil, 24 horas, gratuito, sigiloso)"
},
"samu": {
"name": "SAMU, urgência médica (Brasil)"
},
"sns24": {
"name": "SNS 24, aconselhamento psicológico (Portugal, 24 horas, opção 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, todos os dias das 15h30 às 0h30)"
}
},
"instrumentDescription": {
@@ -6,8 +6,31 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
import { crisisResourcesForLocale } from "../crisis-resources";
describe("crisisResourcesForLocale", () => {
it("gives a Brazilian Portuguese reader CVV and SAMU", () => {
const set = crisisResourcesForLocale("pt-BR");
it("gives a Portuguese reader with no region 112 first, then each country's lines by name", () => {
// The web stores the folded app locale: someone in Portugal reads the
// pt-BR bundle too, so "pt-BR" is no evidence of Brazil.
for (const locale of ["pt-BR", "pt_BR", "pt", "pt-AO"]) {
const set = crisisResourcesForLocale(locale);
expect(set.emergencyNumber, locale).toBe("112");
expect(set.resources.map((r) => r.id)).toEqual([
"findahelpline",
"cvv",
"samu",
"sns24",
"sosVozAmiga",
]);
}
const { t } = getServerTranslator("pt-BR");
expect(t("mentalHealth.crisisResource.cvv.name")).toContain("Brasil");
expect(t("mentalHealth.crisisResource.samu.name")).toContain("Brasil");
expect(t("mentalHealth.crisisResource.sns24.name")).toContain("Portugal");
expect(t("mentalHealth.crisisResource.sosVozAmiga.name")).toContain(
"Portugal",
);
});
it("gives an explicit Brazilian region CVV and SAMU", () => {
const set = crisisResourcesForLocale("br");
expect(set.emergencyNumber).toBe("192");
expect(set.resources[0]).toEqual({
id: "cvv",
@@ -15,12 +38,35 @@ describe("crisisResourcesForLocale", () => {
});
});
it("keeps the international set for Portuguese outside Brazil", () => {
// 188 does not connect from Portugal.
for (const locale of ["pt", "pt-PT"]) {
it("gives an explicit Portuguese region 112, SNS 24 and SOS Voz Amiga, never Brazil's lines", () => {
for (const locale of ["pt-PT", "pt_PT", "pt-pt"]) {
const set = crisisResourcesForLocale(locale);
expect(set.emergencyNumber).toBe("112");
expect(set.resources.map((r) => r.id)).not.toContain("cvv");
expect(set.resources[0]).toEqual({
id: "sns24",
contacts: ["808 24 24 24"],
});
const ids = set.resources.map((r) => r.id);
expect(ids).toContain("sosVozAmiga");
expect(ids).not.toContain("cvv");
expect(ids).not.toContain("samu");
}
});
it("names every crisis resource in every language", () => {
// History re-derives the set from the row's stored locale and renders it
// in whatever language the reader uses now.
const ids = new Set(
["pt-BR", "pt-PT", "br", "de", "de-AT", "de-CH", "en-US", "fr"].flatMap(
(l) => crisisResourcesForLocale(l).resources.map((r) => r.id),
),
);
for (const locale of locales) {
const { t } = getServerTranslator(locale);
for (const id of ids) {
const key = `mentalHealth.crisisResource.${id}.name`;
expect(t(key), `${locale} ${key}`).not.toBe(key);
}
}
});
+54 -15
View File
@@ -99,9 +99,10 @@ const US: CrisisResourceSet = {
};
/**
* Brazil (pt-BR) — CVV by phone and chat, SAMU as the emergency number.
* Both numbers are free and answer around the clock (cvv.org.br; the
* Ministry of Health's SAMU 192 page).
* Brazil (an explicit region signal: a bare "br" or "pt_BR"-style tag a
* native client sends from the device region) — CVV by phone and chat, SAMU
* as the emergency number. Both numbers are free and answer around the clock
* (cvv.org.br; the Ministry of Health's SAMU 192 page).
*/
const BR: CrisisResourceSet = {
emergencyNumber: "192",
@@ -111,27 +112,65 @@ const BR: CrisisResourceSet = {
],
};
/**
* Portugal (explicit "pt-PT" / "pt_PT" / "pt-pt"). 112 is the emergency
* number. SNS 24 (808 24 24 24) runs the national psychological counselling
* line around the clock as menu option 4 and transfers a call at risk to
* INEM; SOS Voz Amiga answers daily from 15:30 to 00:30 (sosvozamiga.org).
*/
const PT: CrisisResourceSet = {
emergencyNumber: "112",
resources: [
{ id: "sns24", contacts: ["808 24 24 24"] },
{ id: "sosVozAmiga", contacts: ["213 544 545", "sosvozamiga.org"] },
{ id: "findahelpline", contacts: ["findahelpline.com"] },
],
};
/**
* Portuguese with no region signal. The app ships one Portuguese bundle,
* `pt-BR`, and `localeFromLanguageTag` folds `pt-PT`, `pt-AO` and bare `pt`
* into it, so a stored "pt-BR" says only that the reader reads Portuguese:
* it is as likely to be someone in Lisbon as in São Paulo. The account keeps
* no country to tell them apart. Following DE_AT_CH, the set leads with 112
* (the emergency number across Portugal and the EU, which mobile networks in
* Brazil also route to the police) and the worldwide directory, then names
* every country's own lines with the country in the label, so nothing reads
* as if it reached everyone.
*/
const PT_BR_AMBIGUOUS: CrisisResourceSet = {
emergencyNumber: "112",
resources: [
{ id: "findahelpline", contacts: ["findahelpline.com"] },
...BR.resources.filter((r) => r.id !== "findahelpline"),
{ id: "samu", contacts: ["192"] },
...PT.resources.filter((r) => r.id !== "findahelpline"),
],
};
/**
* Resolve a crisis-resource set from the user's locale. Region detection is
* coarse (locale prefix) and defaults to the international set — the goal is to
* always surface SOMETHING actionable, never to be exhaustive.
*
* A region-qualified tag ("de-AT", "de_CH", …) resolves to the exact country
* set when the caller has one to give us. `User.locale` today only ever
* stores the bare short code, so in practice every "de" hits the DE_AT_CH
* branch below — that is the safe default for an unqualified "de": never
* assume Germany when the region is unknown.
* A region-qualified tag ("de-AT", "de_CH", "pt-PT", …) resolves to the exact
* country set when the caller has one to give us. The web stores the app
* locale, which is a bare "de" or the folded "pt-BR", so it lands on the
* combined sets: never assume Germany or Brazil when the region is unknown.
*/
export function crisisResourcesForLocale(
locale: string | null | undefined,
): CrisisResourceSet {
const lc = (locale ?? "").toLowerCase();
if (lc === "de-at" || lc === "de_at" || lc === "at") return AT;
if (lc === "de-ch" || lc === "de_ch" || lc === "ch") return CH;
const lc = (locale ?? "").toLowerCase().replace("_", "-");
if (lc === "de-at" || lc === "at") return AT;
if (lc === "de-ch" || lc === "ch") return CH;
if (lc.startsWith("de")) return DE_AT_CH;
if (lc === "en-us" || lc.startsWith("en_us") || lc === "us") return US;
// Brazilian Portuguese only: CVV's 188 does not connect from Portugal, so a
// bare "pt" or "pt-PT" keeps the international set.
if (lc === "pt-br" || lc === "pt_br" || lc === "br") return BR;
if (lc.startsWith("en-us") || lc === "us") return US;
if (lc === "br") return BR;
if (lc === "pt-pt") return PT;
// "pt-BR" from the web is the folded app locale, not a region signal; see
// PT_BR_AMBIGUOUS. Any other Portuguese tag (pt, pt-AO, pt-MZ) lands here
// too, and still gets 112 and the worldwide directory first.
if (lc === "pt" || lc.startsWith("pt-")) return PT_BR_AMBIGUOUS;
return INTERNATIONAL;
}