fix(mental-health): read the country from the region subtag for every language

A tag such as en-BE, fr-CH or en-AT fell through to the international
crisis set because region detection hung on the language. The region
subtag now decides first for every language; bare language codes keep the
combined sets, and the web's folded pt-BR bundle stays ambiguous.

Refs #1338
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 21:02:02 +01:00
1 parent d3401b5b89
commit a812fb699f
3 files changed
+106 -22

No files matched your search

@@ -86,6 +86,49 @@ describe("crisisResourcesForLocale", () => {
}
});
it("resolves the country from the region subtag whatever the language", () => {
const cases: Array<[string, string]> = [
["en-BE", "zelfmoordlijn1813"],
["fr-BE", "zelfmoordlijn1813"],
["en-NL", "zelfmoordpreventie113"],
["en-AT", "telefonSeelsorgeAt"],
["fr-CH", "dargeboteneHand"],
["it_CH", "dargeboteneHand"],
["en-DE", "telefonSeelsorge"],
["de-DE", "telefonSeelsorge"],
["en-PT", "sns24"],
["en-BR", "cvv"],
["es-US", "lifeline988"],
["zh-Hant-CH", "dargeboteneHand"],
];
for (const [locale, first] of cases) {
const set = crisisResourcesForLocale(locale);
expect(set.resources[0]?.id, locale).toBe(first);
}
// A country set never carries another country's lines.
expect(
crisisResourcesForLocale("en-DE").resources.map((r) => r.id),
).not.toContain("dargeboteneHand");
expect(crisisResourcesForLocale("en-AT").emergencyNumber).toBe("112");
expect(crisisResourcesForLocale("es-US").emergencyNumber).toBe("911");
});
it("keeps the language rules when the region has no set of its own", () => {
expect(
crisisResourcesForLocale("de-LI").resources.map((r) => r.id),
).toEqual(crisisResourcesForLocale("de").resources.map((r) => r.id));
expect(crisisResourcesForLocale("nl-SR").resources[0]?.id).toBe(
"zelfmoordpreventie113",
);
expect(crisisResourcesForLocale("nl-SR").resources).toHaveLength(3);
for (const locale of ["en", "en-GB", "fr-FR", "", null, undefined]) {
expect(
crisisResourcesForLocale(locale).resources.map((r) => r.id),
String(locale),
).toEqual(crisisResourcesForLocale("xx").resources.map((r) => r.id));
}
});
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.
+57 -22
View File
@@ -195,33 +195,68 @@ const NL_BE: CrisisResourceSet = {
};
/**
* 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.
* Country sets reached by a region subtag, whatever the language. Someone in
* Belgium with the phone in English sends "en-BE" and gets Belgium's line,
* not the international set.
*/
const BY_REGION: Readonly<Record<string, CrisisResourceSet>> = {
at: AT,
be: BE,
br: BR,
ch: CH,
de: DE,
nl: NL,
pt: PT,
us: US,
};
/**
* The region subtag of a BCP 47 tag ("en-BE" -> "be", "zh-Hant-TW" -> "tw"),
* or null for a bare language code. Only a two-letter subtag after the
* language counts; a script subtag ("Hant") is skipped.
*/
function regionSubtag(tag: string): string | null {
const parts = tag.split("-");
for (const part of parts.slice(1)) {
if (/^[a-z]{2}$/.test(part)) return part;
}
return null;
}
/**
* Resolve a crisis-resource set from the user's locale. The region subtag
* decides first: any language with a region we have a set for ("en-BE",
* "fr-CH", "en-AT", "de-DE") gets that country's set. Without a region the
* language decides, coarsely, and anything unknown gets the international
* set: the goal is to always surface SOMETHING actionable, never to be
* exhaustive.
*
* 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", "nl" or the folded "pt-BR", so it lands on the
* combined sets: never assume Germany, the Netherlands or Brazil when the
* region is unknown.
* One tag is not a region signal: "pt-BR" is the web's folded Portuguese
* bundle (see PT_BR_AMBIGUOUS), so it keeps the ambiguous set. The web stores
* the app locale, which is otherwise a bare "de", "nl", "en", so it lands on
* the combined sets: never assume Germany, the Netherlands or Brazil when the
* region is unknown. A bare country code ("at", "ch", "us", "br", "be")
* still resolves to that country.
*/
export function crisisResourcesForLocale(
locale: string | null | undefined,
): CrisisResourceSet {
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.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;
if (lc === "nl-nl") return NL;
if (lc === "nl-be" || lc === "be") return BE;
const lc = (locale ?? "").trim().toLowerCase().replaceAll("_", "-");
if (lc === "pt-br") return PT_BR_AMBIGUOUS;
const region = regionSubtag(lc);
if (region !== null) {
const byRegion = BY_REGION[region];
if (byRegion) return byRegion;
}
if (lc === "at" || lc === "ch" || lc === "us" || lc === "br" || lc === "be") {
return BY_REGION[lc];
}
const language = lc.split("-")[0];
if (language === "de") return DE_AT_CH;
// Any other Portuguese tag (pt, pt-AO, pt-MZ) still gets 112 and the
// worldwide directory first.
if (language === "pt") return PT_BR_AMBIGUOUS;
// The bare "nl" the web stores, and any other Dutch tag: both countries.
if (lc === "nl" || lc.startsWith("nl-")) return NL_BE;
if (language === "nl") return NL_BE;
return INTERNATIONAL;
}