From a812fb699f1e6c275463238f28f51ec3605bb4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 4 Nov 2026 21:02:02 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 6 ++ .../__tests__/crisis-resources.test.ts | 43 ++++++++++ src/lib/mental-health/crisis-resources.ts | 79 +++++++++++++------ 3 files changed, 106 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b545980e5..778a927c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ range, so the first digit turned into 1900. Both fields now keep what is typed and commit it once it is a valid value, and leaving the month field pads "2019-3" to "2019-03". (#1334) +- **Crisis lines for the country you are in.** A questionnaire answered + from a phone set to English in Belgium (`en-BE`), French in Switzerland, + or English in Austria got the international crisis set, because the + country was only read for some languages. The region in the language tag + now picks the country's lines whatever the language; a bare language + code keeps the combined sets as before. (#1338) ### Changed diff --git a/src/lib/mental-health/__tests__/crisis-resources.test.ts b/src/lib/mental-health/__tests__/crisis-resources.test.ts index 657ff6cd6..0c6504d32 100644 --- a/src/lib/mental-health/__tests__/crisis-resources.test.ts +++ b/src/lib/mental-health/__tests__/crisis-resources.test.ts @@ -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. diff --git a/src/lib/mental-health/crisis-resources.ts b/src/lib/mental-health/crisis-resources.ts index 402290cc3..341c57c37 100644 --- a/src/lib/mental-health/crisis-resources.ts +++ b/src/lib/mental-health/crisis-resources.ts @@ -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> = { + 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; }