Skip to content

Commit 9939858

Browse files
scriptcodedclaude
andcommitted
feat: sv/en localization for the pre-checkin flow
Sessions now carry a language. The flow engine collapses `{ sv, en }` text maps in a step's `with` block into a plain string for that language, so a step declaring `title: "string"` accepts either form in YAML without any per-step schema change. Steps that generate text in code get `ctx.language` plus `resolveLocalized`. The language is chosen on a new `base:selectLanguage` step, placed first in stepConfig.pre-checkin.yml. Its screen is deliberately bilingual - at that point we don't know what the user reads. Going back and switching re-resolves the rest of the flow. On the client the language rides along on `session:info` into a LanguageContext, where screens localize their own hardcoded strings through a small `useTranslations(dict)` helper in plugin-api (no i18n framework). The link landing page renders before the socket authenticates, so it is bilingual rather than guessing a locale. Also resolves data source and subgroup names server-side, so they travel as plain strings instead of locale maps the client has to pick `.sv` from. The on-site kiosk flow is untouched: it has no selectLanguage step and stays Swedish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3172977 commit 9939858

38 files changed

Lines changed: 865 additions & 136 deletions

File tree

packages/backend/config/stepConfig.pre-checkin.yml

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
# Pre-checkin runs in a browser, opened from a link sent to the kår. Text is
2+
# authored per language as `{ sv, en }` maps; the flow engine collapses those to
3+
# a single string for the language the user picks in the first step.
14
steps:
5+
# Must stay first: every step after it gets its text resolved in the chosen
6+
# language. The screen itself is bilingual, so it needs no text config.
7+
- uses: base:selectLanguage
8+
29
- uses: base:identify
310
id: identify
411
with:
@@ -9,11 +16,17 @@ steps:
916
# Pre-checkin runs in a browser without a card reader, so the start screen
1017
# must not mention scanning. Manual entry is the only path here, hence the
1118
# larger button and the hidden scanner callout/arrow.
12-
title: Identifiera dig
13-
description: Ange ditt person- eller medlemsnummer för att komma igång.
19+
title:
20+
sv: Identifiera dig
21+
en: Identify yourself
22+
description:
23+
sv: Ange ditt person- eller medlemsnummer för att komma igång.
24+
en: Enter your social security or membership number to get started.
1425
hideCallout: true
1526
hideScannerArrow: true
16-
manualEntryLabel: Ange person- eller medlemsnummer
27+
manualEntryLabel:
28+
sv: Ange person- eller medlemsnummer
29+
en: Enter social security or membership number
1730
largeManualEntryButton: true
1831

1932
- if: |
@@ -24,16 +37,22 @@ steps:
2437
}}
2538
uses: base:block
2639
with:
27-
title: "Du kan inte checka in"
28-
message: "Endast ledare kan checka in deltagare."
40+
title:
41+
sv: "Du kan inte checka in"
42+
en: "You cannot check in"
43+
message:
44+
sv: "Endast ledare kan checka in deltagare."
45+
en: "Only leaders can check in participants."
2946
3047
- uses: base:deduplicateSession
3148
with:
3249
force: resume
3350

3451
- uses: base:selectSubjects
3552
with:
36-
description: Välj vilka du vill checka in. Avmarkera de som inte är på plats.
53+
description:
54+
sv: Välj vilka du vill checka in. Avmarkera de som inte är på plats.
55+
en: Select who you want to check in. Deselect anyone who isn't here.
3756

3857
# Lists the selected subjects that are "ledarfunktionärer" (a distinct fee,
3958
# mapped to the `leaderstaff` subGroup in dataSourceConfig.yml) with an
@@ -42,8 +61,12 @@ steps:
4261
- uses: base:filterSubjects
4362
with:
4463
filter: "participant.subGroup == 'leaderstaff'"
45-
title: "Ledarfunktionärer"
46-
message: "Följande personer är anmälda som ledarfunktionärer. Ledarfunktionärer bor och äter med er kår, men arbetar som funktionärer på lägret och deltar alltså inte på programpass med kåren. Kontrollera noggrant att alla personer i listan är medvetna om detta, och att ingen ledarfunktionär saknas i listan. Om något inte stämmer fortsätter ni med incheckningen och meddelar oss när ni checkar in på plats."
64+
title:
65+
sv: "Ledarfunktionärer"
66+
en: "Leader staff"
67+
message:
68+
sv: "Följande personer är anmälda som ledarfunktionärer. Ledarfunktionärer bor och äter med er kår, men arbetar som funktionärer på lägret och deltar alltså inte på programpass med kåren. Kontrollera noggrant att alla personer i listan är medvetna om detta, och att ingen ledarfunktionär saknas i listan. Om något inte stämmer fortsätter ni med incheckningen och meddelar oss när ni checkar in på plats."
69+
en: "The following people are registered as leader staff. Leader staff sleep and eat with your scout group, but work as staff at the camp and therefore do not take part in the group's programme activities. Check carefully that everyone in the list is aware of this, and that no leader staff is missing from the list. If something is not right, continue with the check-in and let us know when you check in on site."
4770
whenEmpty: skip
4871

4972
# Informational (never blocks): shows the checking-in leader which of the
@@ -63,13 +86,21 @@ steps:
6386
safeFromHarmValidYears: 3
6487
criminalRecordExtractValidYears: 3
6588
checkDate: "2026-08-01"
66-
title: "Ledare som saknar krav"
67-
message: "Följande ledare har inte giltigt Trygga Möten och/eller registerutdrag registrerat i Scoutnet. Ni som kår ansvarar själva för att säkerställa att samtliga ledare har uppvisat giltigt Trygga Möten-intyg samt utdrag ut polisens belastningsregister."
89+
title:
90+
sv: "Ledare som saknar krav"
91+
en: "Leaders missing requirements"
92+
message:
93+
sv: "Följande ledare har inte giltigt Trygga Möten och/eller registerutdrag registrerat i Scoutnet. Ni som kår ansvarar själva för att säkerställa att samtliga ledare har uppvisat giltigt Trygga Möten-intyg samt utdrag ut polisens belastningsregister."
94+
en: "The following leaders do not have a valid Safe from Harm (Trygga Möten) certificate and/or criminal record extract registered in Scoutnet. Your scout group is itself responsible for making sure that every leader has presented a valid Safe from Harm certificate and an extract from the Swedish police criminal records."
6895

6996
- uses: base:markPreliminaryCheckedIn
7097
required: true
7198

7299
- uses: base:message
73100
with:
74-
title: Tack!
75-
message: Nu är du klar för tillfället. Glöm inte att skicka en ledare med lite koll till infotältet på Hjärtat för att slutföra incheckningen och hämta er kårpåse med märken och annat kul när ni väl är på lägerområdet!
101+
title:
102+
sv: Tack!
103+
en: Thank you!
104+
message:
105+
sv: Nu är du klar för tillfället. Glöm inte att skicka en ledare med lite koll till infotältet på Hjärtat för att slutföra incheckningen och hämta er kårpåse med märken och annat kul när ni väl är på lägerområdet!
106+
en: You are done for now. Once you are on the camp site, don't forget to send a leader who is up to speed to the info tent at Hjärtat to complete the check-in and pick up your group bag with badges and other fun things!

packages/backend/config_dev/stepConfig.pre-checkin.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
steps:
2+
# Must stay first: every step after it gets its `{ sv, en }` text resolved in
3+
# the chosen language.
4+
- uses: base:selectLanguage
5+
26
- uses: base:identify
37
id: identify
48
with:
@@ -14,8 +18,12 @@ steps:
1418
}}
1519
uses: base:block
1620
with:
17-
title: "Du kan inte checka in"
18-
message: "Endast ledare kan checka in deltagare."
21+
title:
22+
sv: "Du kan inte checka in"
23+
en: "You cannot check in"
24+
message:
25+
sv: "Endast ledare kan checka in deltagare."
26+
en: "Only leaders can check in participants."
1927
2028
- uses: base:deduplicateSession
2129
with:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "CheckinSession" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'sv';

packages/backend/prisma/schema.prisma

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ model CheckinSession {
8989
params Json @default("{}")
9090
stepData CheckinSessionStepData[]
9191
92+
// The language this session is conducted in. Drives resolution of
93+
// `{ sv, en }` text maps in the step config and the kiosk/link UI. Set by
94+
// the `base:selectLanguage` step; flows without that step keep the default.
95+
language String @default("sv")
96+
9297
actor CheckinActor?
9398
9499
subjects CheckinSubject[]
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
coerceLanguage,
4+
isLocalizedString,
5+
resolveLocalized,
6+
resolveLocalizedDeep,
7+
} from "./localized.ts";
8+
9+
describe("isLocalizedString", () => {
10+
it("accepts maps whose keys are all supported languages", () => {
11+
expect(isLocalizedString({ sv: "Hej", en: "Hi" })).toBe(true);
12+
expect(isLocalizedString({ sv: "Hej" })).toBe(true);
13+
expect(isLocalizedString({ en: "Hi" })).toBe(true);
14+
});
15+
16+
it("rejects anything that isn't purely a language map", () => {
17+
expect(isLocalizedString({ sv: "Hej", nb: "Hei" })).toBe(false);
18+
expect(isLocalizedString({ sv: 1 })).toBe(false);
19+
expect(isLocalizedString({ dietMjolkprotein: "Mjölkprotein" })).toBe(false);
20+
expect(isLocalizedString({})).toBe(false);
21+
expect(isLocalizedString(["sv"])).toBe(false);
22+
expect(isLocalizedString(null)).toBe(false);
23+
expect(isLocalizedString("sv")).toBe(false);
24+
});
25+
});
26+
27+
describe("resolveLocalized", () => {
28+
it("picks the requested language", () => {
29+
expect(resolveLocalized({ sv: "Hej", en: "Hi" }, "en")).toBe("Hi");
30+
});
31+
32+
it("falls back to Swedish, then to any available value", () => {
33+
expect(resolveLocalized({ sv: "Hej" }, "en")).toBe("Hej");
34+
expect(resolveLocalized({ en: "Hi" }, "nb")).toBe("Hi");
35+
});
36+
});
37+
38+
describe("resolveLocalizedDeep", () => {
39+
it("collapses maps nested in objects and arrays", () => {
40+
const input = {
41+
title: { sv: "Tack!", en: "Thanks!" },
42+
message: "Plain",
43+
nested: { description: { sv: "Beskrivning", en: "Description" } },
44+
items: [{ label: { sv: "Ett", en: "One" } }, "raw"],
45+
};
46+
47+
expect(resolveLocalizedDeep(input, "en")).toEqual({
48+
title: "Thanks!",
49+
message: "Plain",
50+
nested: { description: "Description" },
51+
items: [{ label: "One" }, "raw"],
52+
});
53+
});
54+
55+
it("leaves non-locale objects and primitives untouched", () => {
56+
const input = {
57+
dataSources: ["groups"],
58+
safeFromHarmValidYears: 3,
59+
hideCallout: true,
60+
labels: { dietMjolkprotein: "Mjölkprotein" },
61+
missing: null,
62+
absent: undefined,
63+
};
64+
65+
expect(resolveLocalizedDeep(input, "en")).toEqual(input);
66+
});
67+
68+
it("handles nullish input", () => {
69+
expect(resolveLocalizedDeep(null, "en")).toBe(null);
70+
expect(resolveLocalizedDeep(undefined, "en")).toBe(undefined);
71+
});
72+
});
73+
74+
describe("coerceLanguage", () => {
75+
it("passes through supported languages and defaults otherwise", () => {
76+
expect(coerceLanguage("en")).toBe("en");
77+
expect(coerceLanguage("sv")).toBe("sv");
78+
expect(coerceLanguage("nb")).toBe("sv");
79+
expect(coerceLanguage(undefined)).toBe("sv");
80+
});
81+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Localized text resolution.
3+
*
4+
* Step config text may be authored either as a plain string (single language)
5+
* or as a `{ sv: "...", en: "..." }` map. Rather than making every step
6+
* declare a localized input type, the flow engine collapses these maps to a
7+
* single string for the session's language before the step ever sees its
8+
* inputs - so a step declaring `title: "string"` transparently supports both
9+
* forms. See `resolveLocalizedDeep` and `getCurrentStep`.
10+
*/
11+
12+
import {
13+
resolveLocalized,
14+
SUPPORTED_LANGUAGES,
15+
} from "@scouterna/scoutin-plugin-api/backend";
16+
17+
export {
18+
coerceLanguage,
19+
DEFAULT_LANGUAGE,
20+
isSupportedLanguage,
21+
type Language,
22+
resolveLocalized,
23+
SUPPORTED_LANGUAGES,
24+
} from "@scouterna/scoutin-plugin-api/backend";
25+
26+
const supported = new Set<string>(SUPPORTED_LANGUAGES);
27+
28+
/**
29+
* A localized string map is a plain object whose keys are *all* supported
30+
* language codes and whose values are all strings. The all-keys-known
31+
* requirement is what keeps this from misfiring on ordinary config objects
32+
* (e.g. a diet-flag map, or `{ sv: 1 }`).
33+
*/
34+
export function isLocalizedString(
35+
value: unknown,
36+
): value is Record<string, string> {
37+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
38+
return false;
39+
}
40+
41+
const entries = Object.entries(value);
42+
if (entries.length === 0) return false;
43+
44+
return entries.every(
45+
([key, val]) => supported.has(key) && typeof val === "string",
46+
);
47+
}
48+
49+
/**
50+
* Deep-walks objects and arrays, replacing every localized string map with the
51+
* resolved string for `language`. Everything else is returned untouched.
52+
*/
53+
export function resolveLocalizedDeep<T>(value: T, language: string): unknown {
54+
if (isLocalizedString(value)) {
55+
return resolveLocalized(value, language);
56+
}
57+
58+
if (Array.isArray(value)) {
59+
return value.map((item) => resolveLocalizedDeep(item, language));
60+
}
61+
62+
if (value !== null && typeof value === "object") {
63+
const result: Record<string, unknown> = {};
64+
for (const [key, val] of Object.entries(value)) {
65+
result[key] = resolveLocalizedDeep(val, language);
66+
}
67+
return result;
68+
}
69+
70+
return value;
71+
}
72+
73+
/** Object-preserving wrapper for the common `with`-block case. */
74+
export function resolveLocalizedInputs(
75+
inputs: Record<string, unknown>,
76+
language: string,
77+
): Record<string, unknown> {
78+
return resolveLocalizedDeep(inputs, language) as Record<string, unknown>;
79+
}

packages/backend/src/core/websocket/messageTypes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export const messageTypes = createMessageRegistry()
4242
"session:info",
4343
type({
4444
"actor?": type({ firstName: "string", lastName: "string" }).or("null"),
45+
// The session language, so the client can localize its own strings.
46+
"language?": "string",
4547
}),
4648
)
4749
.register(

packages/backend/src/core/websocket/sessionRegistry.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ type ScreenData = { screenId: string; payload: object };
66
type StepMeta = {
77
idInFlow?: string;
88
evaluatedInputs?: Record<string, unknown>;
9+
/** The session language the inputs above were resolved for. */
10+
language?: string;
911
};
1012

1113
type SessionEntry = {
@@ -59,6 +61,16 @@ export function setStepMeta(sessionId: string, meta: StepMeta): void {
5961
getOrCreate(sessionId).stepMeta = meta;
6062
}
6163

64+
/**
65+
* Updates the language on the current step's metadata. Used when a step
66+
* changes the session language mid-step (`base:selectLanguage`) so that
67+
* `ctx.language` reflects the new choice immediately, without a DB round trip.
68+
*/
69+
export function setStepMetaLanguage(sessionId: string, language: string): void {
70+
const entry = getOrCreate(sessionId);
71+
entry.stepMeta = { ...(entry.stepMeta ?? {}), language };
72+
}
73+
6274
export function clearScreenTracking(sessionId: string): void {
6375
getOrCreate(sessionId).hasShownScreen = false;
6476
}

packages/backend/src/core/workflow/step.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { StepDefinition } from "../../config/stepConfig.ts";
21
import {
32
deleteStepData,
43
findLastCompletedStep,
54
getCurrentStep,
5+
type ResolvedStepDefinition,
66
} from "../../domains/workflows/step.service.ts";
77
import { stepRegistry } from "../../domains/workflows/steps.ts";
88
import { getLogger } from "../logging/logger.ts";
@@ -18,7 +18,7 @@ import { createStepContext } from "./stepContext.ts";
1818
export async function startStep(
1919
c: TypedContext,
2020
ws: TypedWSContext<unknown>,
21-
stepDef: StepDefinition,
21+
stepDef: ResolvedStepDefinition,
2222
) {
2323
const step = stepRegistry.get(stepDef.uses);
2424

@@ -37,6 +37,7 @@ export async function startStep(
3737
setStepMeta(sessionId, {
3838
idInFlow: stepDef.id,
3939
evaluatedInputs: stepDef.with,
40+
language: stepDef.language,
4041
});
4142
clearScreenTracking(sessionId);
4243

0 commit comments

Comments
 (0)