Add Ukrainian translation

This commit is contained in:
Franz Rolfsvaag 2026-06-08 12:35:07 +02:00
parent 7dec8eb885
commit f6546bb3ff
12 changed files with 836 additions and 55 deletions

View File

@ -51,9 +51,11 @@ The user owns their data locally in the browser and actively shares it by export
- advisor notes per user, report and follow-up
- fullscreen graph analysis for advisors
- local report integrity checks
- Norwegian and English user interface
- Norwegian, English and Ukrainian user interface
- installable as a PWA/WebApp on phones and tablets
The language selector only translates static UI text and display labels. Report content, comments, advisor notes and imported JSON are never changed.
---
## Screenshots
@ -325,7 +327,7 @@ Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(),
- JSON import/export
- local advisor view
- optional FastAPI/SQLite backend for encrypted one-time sharing
- Norwegian/English UI
- Norwegian/English/Ukrainian UI
### Production

View File

@ -51,9 +51,11 @@ Brukeren eier dataene sine lokalt i nettleseren og deler dem aktivt ved å ekspo
- veiledernotater per bruker, rapport og oppfølging
- fullskjerm grafanalyse for veileder
- lokal integritetskontroll av rapporter
- norsk og engelsk brukergrensesnitt
- norsk, engelsk og ukrainsk brukergrensesnitt
- kan installeres som PWA/WebApp på telefon og nettbrett
Språkvalget oversetter bare statiske tekster og visningsetiketter. Rapportinnhold, kommentarer, veiledernotater og importert JSON endres ikke.
---
## Skjermbilder
@ -325,7 +327,7 @@ Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(),
- JSON import/eksport
- lokal veiledervisning
- valgfri FastAPI/SQLite-backend for kryptert engangsdeling
- norsk/engelsk UI
- norsk/engelsk/ukrainsk UI
### Produksjon

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "arbeidspuls",
"version": "1.3.0",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arbeidspuls",
"version": "1.3.0",
"version": "1.3.1",
"dependencies": {
"lucide-react": "^0.475.0",
"react": "^19.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "arbeidspuls",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"type": "module",
"scripts": {

View File

@ -1,4 +1,4 @@
const CACHE_NAME = "arbeidspuls-v9";
const CACHE_NAME = "arbeidspuls-v10";
const ASSETS = ["/", "/index.html", "/manifest.webmanifest", "/icon.svg"];
self.addEventListener("install", (event) => {

View File

@ -71,6 +71,8 @@ import {
} from "./feedback";
import {
I18nProvider,
localeForLanguage,
readStoredLanguage,
translateScoreBand,
translateScoreBandDescription,
translateVerificationStatus,
@ -129,7 +131,7 @@ type LineVisibility = "normal" | "dim" | "hidden";
type TrendMetric = { key: keyof Report | "total_score_percent"; label: string; short: string; color: string };
const PERIOD_KEY = "arbeidsevne-egenvurdering:trend-period";
const APP_VERSION = "1.3.0";
const APP_VERSION = "1.3.1";
const COMMERCIAL_LICENSE_URL = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Arbeidspuls/src/branch/main/about-commercial-license.md";
const taskTypeOptions = Object.keys(taskTypeLabels) as TaskType[];
@ -137,14 +139,23 @@ const limitationOptions = Object.keys(limitationLabels) as Limitation[];
const accommodationOptions = Object.keys(accommodationLabels) as Accommodation[];
const delayedSymptomOptions = Object.keys(delayedSymptomLabels) as DelayedSymptom[];
const visibleStatusOptions: ReportStatus[] = ["kan_fortsette", "trenger_pause", "trenger_enklere_oppgave", "bør_stoppe"];
const trendMetrics: TrendMetric[] = [
{ key: "total_score_percent", label: "Totalscore", short: "T", color: "#235b5e" },
{ key: "work_ability", label: "Arbeidsevne", short: "A", color: "#307f78" },
{ key: "energy_level", label: "Energi", short: "E", color: "#b2642a" },
{ key: "mental_clarity", label: "Klarhet", short: "K", color: "#4f5c9c" },
{ key: "symptom_burden", label: "Symptomer", short: "S", color: "#b23b4a" },
{ key: "effort_strain", label: "Belastning", short: "B", color: "#6b5a3a" }
];
const trendMetricDefinitions = [
{ key: "total_score_percent", labelKey: "metricTotalScore", shortKey: "metricTotalScoreShort", color: "#235b5e" },
{ key: "work_ability", labelKey: "metricWorkAbility", shortKey: "metricWorkAbilityShort", color: "#307f78" },
{ key: "energy_level", labelKey: "metricEnergy", shortKey: "metricEnergyShort", color: "#b2642a" },
{ key: "mental_clarity", labelKey: "metricClarity", shortKey: "metricClarityShort", color: "#4f5c9c" },
{ key: "symptom_burden", labelKey: "metricSymptoms", shortKey: "metricSymptomsShort", color: "#b23b4a" },
{ key: "effort_strain", labelKey: "metricStrain", shortKey: "metricStrainShort", color: "#6b5a3a" }
] as const;
function localizedTrendMetrics(t: ReturnType<typeof useI18n>["t"]): TrendMetric[] {
return trendMetricDefinitions.map((metric) => ({
key: metric.key,
label: t(`graphModal.${metric.labelKey}`),
short: t(`graphModal.${metric.shortKey}`),
color: metric.color
}));
}
function App() {
return (
@ -609,6 +620,16 @@ function AppFooter({
>
{t("footer.english")} 🇬🇧
</button>
<span className="footer-language-separator">|</span>
<button
aria-current={language === "uk-UA"}
aria-label={t("footer.switchToUkrainian")}
className="footer-language-button"
onClick={() => setLanguage("uk-UA")}
type="button"
>
{t("footer.ukrainian")} 🇺🇦
</button>
</div>
<div className="footer-credit">
© {yearLabel} {t("footer.developedBy")} <Heart aria-label={t("footer.heart")} className="footer-heart" />
@ -1511,7 +1532,7 @@ function Trend({ entries, period, showScoreBands = false }: { entries: StoredEnt
const workTimeBands = useMemo(() => filterWorkTimeBandsByPeriod(buildWorkTimeBands(entries), period), [entries, period]);
const periodAssessment = showScoreBands ? calculatePeriodWorkAbilityAssessment(reports) : null;
const metrics = trendMetrics;
const metrics = localizedTrendMetrics(t);
if (reports.length < 2) {
return (
@ -1724,6 +1745,7 @@ function FullscreenGraphModal({
onClose: () => void;
}) {
const { t } = useI18n();
const metrics = localizedTrendMetrics(t);
const [period, setPeriod] = useState(initialPeriod);
const [visibility, setVisibility] = useState<Record<string, LineVisibility>>({ total_score_percent: "dim", followup_response: "normal" });
const [inspect, setInspect] = useState<{ x: number; report: Report | null } | null>(null);
@ -1825,7 +1847,7 @@ function FullscreenGraphModal({
<p>{t("graphModal.visibleHelp")}</p>
</div>
<div className="legend interactive">
{trendMetrics.map((metric) => {
{metrics.map((metric) => {
const state = visibility[metric.key] ?? "normal";
return (
<button className={`legend-chip ${state}`} key={metric.key} onClick={() => cycleVisibility(metric.key)} type="button">
@ -1884,7 +1906,7 @@ function FullscreenGraphModal({
</text>
</g>
))}
{trendMetrics.flatMap((metric) => {
{metrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
const points = reports
@ -1921,7 +1943,7 @@ function FullscreenGraphModal({
</g>
);
})}
{trendMetrics.flatMap((metric) => {
{metrics.flatMap((metric) => {
const state = visibility[metric.key] ?? "normal";
if (state === "hidden") return [];
return reports
@ -2004,6 +2026,7 @@ function GraphInspectTooltip({
visibility: Record<string, LineVisibility>;
}) {
const { labels, t } = useI18n();
const metrics = localizedTrendMetrics(t);
if (!report) {
return (
<aside className="graph-inspect-panel">
@ -2027,7 +2050,7 @@ function GraphInspectTooltip({
</p>
)}
<dl>
{trendMetrics
{metrics
.filter((metric) => (visibility[metric.key] ?? "normal") !== "hidden")
.map((metric) => {
const value = normalizeReportMetric(report, metric.key);
@ -2198,7 +2221,7 @@ function PrivacyPanel({ demoMode, entries, onDeleted }: { demoMode: boolean; ent
setImportMessage("");
} catch (error) {
setPendingImport(null);
setImportMessage(error instanceof Error ? error.message : "Importfilen kunne ikke leses.");
setImportMessage(error instanceof Error ? error.message : t("evaluator.importReadError"));
} finally {
event.target.value = "";
}
@ -2640,18 +2663,18 @@ function EvaluatorApp() {
try {
const result = importExportPayload(await file.text(), displayName, targetId || undefined, demoMode);
const flagged = result.verification.modified + result.verification.missing + result.verification.unsupported + result.verification.error;
const actionText =
result.target_resolution === "matched_existing_person"
? "Importen ble knyttet til eksisterende bruker."
: "Opprettet ny bruker.";
setMessage(
`${actionText} ${result.added} nye rapporter, ${result.skipped} duplikater hoppet over.${result.demo_entries_detected > 0 ? ` ADVARSEL: Importen inneholder ${result.demo_entries_detected} demodata-oppføringer.` : ""}${flagged > 0 ? " Noen rapporter kunne ikke verifiseres." : ""}`
);
const actionText = result.target_resolution === "matched_existing_person" ? t("evaluator.importMatched") : t("evaluator.importCreated");
const parts = [t("evaluator.importResult", { action: actionText, added: result.added, skipped: result.skipped })];
if (result.demo_entries_detected > 0) {
parts.push(t("evaluator.demoImportResultWarning", { count: result.demo_entries_detected }));
}
if (flagged > 0) parts.push(t("evaluator.verificationWarning"));
setMessage(parts.join(" "));
setDisplayName("");
setTargetId("");
refresh(result.profile.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Import feilet.");
setMessage(error instanceof Error ? error.message : t("evaluator.importFailed"));
} finally {
event.target.value = "";
}
@ -2664,10 +2687,14 @@ function EvaluatorApp() {
const text = await file.text();
const demoEntries = countEvaluatorBackupDemoEntries(text);
importEvaluatorBackup(text, demoMode);
setMessage(`${t("evaluator.importedBackup")}${demoEntries > 0 ? ` ADVARSEL: Backupen inneholder ${demoEntries} demodata-oppføringer.` : ""}`);
setMessage(
[t("evaluator.importedBackup"), demoEntries > 0 ? t("evaluator.demoBackupWarning", { count: demoEntries }) : ""]
.filter(Boolean)
.join(" ")
);
refresh();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Backup kunne ikke importeres.");
setMessage(error instanceof Error ? error.message : t("evaluator.backupFailed"));
} finally {
event.target.value = "";
}
@ -3188,7 +3215,7 @@ function ReportDetailSections({ report, followups }: { report: Report; followups
<Detail label={t("details.status")} value={labels.statusLabels[report.status]} />
<Detail label={t("details.totalScore")} value={score === null ? t("common.notCalculated") : `${score}%`} />
<Detail label={t("details.functionLevel")} value={translateScoreBand(band, t)} />
<Detail label={t("details.description")} value={translateScoreBandDescription(band, language) ?? "Totalscore mangler eller kan ikke beregnes."} />
<Detail label={t("details.description")} value={translateScoreBandDescription(band, language) ?? t("details.scoreUnavailable")} />
<Detail label={t("details.important")} value={t("scoring.disclaimer")} />
</DetailSection>
<DetailSection title={t("details.coreValues")}>
@ -3267,7 +3294,7 @@ function ReportTechnicalDetails({ report }: { report: Report }) {
<p>{t("details.technicalDescription")}</p>
<DetailSection title={t("details.technical")}>
<Detail label={t("details.reportId")} value={report.id} />
<Detail label="Type" value={report.type} />
<Detail label={t("details.type")} value={report.type} />
<Detail label={t("details.created")} value={formatIsoDateTime(report.created_at)} />
{report.updated_at && <Detail label={t("details.updated")} value={formatIsoDateTime(report.updated_at)} />}
<Detail label={t("details.reportDate")} value={report.report_date} />
@ -3275,7 +3302,7 @@ function ReportTechnicalDetails({ report }: { report: Report }) {
<Detail label={t("details.integrityStatus")} value={translateVerificationStatus(report.integrity?.verification_status, t)} />
<Detail label={t("details.explanation")} value={report.integrity?.verification_reason || verificationTooltip(report.integrity?.verification_status)} />
<Detail label={t("details.algorithm")} value={report.integrity?.algorithm || t("common.notProvided")} />
<Detail label="Canonicalization" value={report.integrity?.canonicalization || t("common.notProvided")} />
<Detail label={t("details.canonicalization")} value={report.integrity?.canonicalization || t("common.notProvided")} />
<Detail label={t("details.signed")} value={report.integrity?.signed_at ? formatIsoDateTime(report.integrity.signed_at) : t("common.notProvided")} />
<Detail label={t("details.payloadHash")} value={report.integrity?.payload_hash_sha256 || t("common.notProvided")} />
</DetailSection>
@ -3294,7 +3321,7 @@ function FollowupTechnicalDetails({ followup }: { followup: DelayedFollowup }) {
<DetailSection title={t("details.technical")}>
<Detail label={t("details.followupId")} value={followup.id} />
<Detail label={t("details.connectedReport")} value={findParentId(followup) || t("common.notSelected")} />
<Detail label="Type" value={followup.type} />
<Detail label={t("details.type")} value={followup.type} />
<Detail label={t("details.created")} value={formatIsoDateTime(followup.created_at)} />
{followup.updated_at && <Detail label={t("details.updated")} value={formatIsoDateTime(followup.updated_at)} />}
<Detail label={t("details.reportDate")} value={followup.report_date} />
@ -3379,7 +3406,7 @@ function getFollowupNote(notes: EvaluatorProfile["evaluator_notes"], followupId:
}
function currentDateLocale() {
return localStorage.getItem("arbeidspuls.language") === "en" ? "en-GB" : "nb-NO";
return localeForLanguage(readStoredLanguage());
}
export function formatDateTime(entry: StoredEntry) {

View File

@ -91,6 +91,12 @@ describe("oppfølgingsrespons", () => {
expect(formatDateTime(report)).toContain("Onsdag");
});
it("bruker ukrainsk ukedag når språk er ukrainsk", () => {
localStorage.setItem("arbeidspuls.language", "uk-UA");
expect(formatDateTime(report)).toContain("Середа");
});
it("lager arbeidstidsband og håndterer arbeidstid over midnatt", () => {
const daytime = { ...report, id: "rapport-dag", work_start_time: "08:00", work_end_time: "10:00" };
const overnight = { ...report, id: "rapport-natt", work_start_time: "23:00", work_end_time: "01:00" };
@ -108,6 +114,7 @@ describe("oppfølgingsrespons", () => {
it("har språktilpassede datoplaceholdere for graffilter", () => {
expect(datePlaceholderForLanguage("nb-NO")).toBe("dd/mm/åååå");
expect(datePlaceholderForLanguage("en")).toBe("dd/mm/yyyy");
expect(datePlaceholderForLanguage("uk-UA")).toBe("дд/мм/рррр");
});
it("runder grafstart ned til 00:00 på første synlige dato", () => {

50
src/i18n.test.ts Normal file
View File

@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { labelsByLanguage, localeForLanguage, missingTranslationKeys, readStoredLanguage, supportedLanguages } from "./i18n";
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear()
});
});
describe("Ukrainian language support", () => {
it("registers Ukrainian with its native name and flag", () => {
expect(supportedLanguages).toContainEqual({
code: "uk-UA",
nativeName: "Українська",
flag: "🇺🇦"
});
});
it("has every translation key available in Ukrainian and English", () => {
expect(missingTranslationKeys("uk-UA")).toEqual([]);
expect(missingTranslationKeys("en")).toEqual([]);
});
it("persists Ukrainian and keeps Norwegian as the default", () => {
expect(readStoredLanguage()).toBe("nb-NO");
localStorage.setItem("arbeidspuls.language", "uk-UA");
expect(readStoredLanguage()).toBe("uk-UA");
expect(localeForLanguage(readStoredLanguage())).toBe("uk-UA");
});
it("changes only display labels and leaves stored report content untouched", () => {
const report = {
status: "kan_fortsette",
comment: "This user comment must stay exactly as written.",
advisorNote: "Veiledernotat skal ikke oversettes."
} as const;
const serialized = JSON.stringify(report);
expect(labelsByLanguage["uk-UA"].statusLabels[report.status]).toBe("Може продовжувати");
expect(JSON.stringify(report)).toBe(serialized);
expect(report.comment).toBe("This user comment must stay exactly as written.");
expect(report.advisorNote).toBe("Veiledernotat skal ikke oversettes.");
});
});

View File

@ -26,12 +26,33 @@ import type {
VerificationStatus,
WorseThanBefore
} from "./types";
import {
ukAccommodationLabels,
ukCategoryTooltipTexts,
ukDelayedSymptomLabels,
ukLimitationLabels,
ukNegativeScale,
ukPositiveScale,
ukRecoveryLabels,
ukStatusLabels,
ukTaskCompletionLabels,
ukTaskTypeLabels,
ukTooltipTexts,
ukWorseLabels
} from "./labels.uk";
import { uk } from "./i18n.uk";
export type Language = "nb-NO" | "en";
export type Language = "nb-NO" | "en" | "uk-UA";
const LANGUAGE_KEY = "arbeidspuls.language";
type TranslationTree = { [key: string]: string | TranslationTree };
export type TranslationTree = { [key: string]: string | TranslationTree };
export const supportedLanguages = [
{ code: "nb-NO", nativeName: "Norsk", flag: "🇳🇴" },
{ code: "en", nativeName: "English", flag: "🇬🇧" },
{ code: "uk-UA", nativeName: "Українська", flag: "🇺🇦" }
] as const satisfies ReadonlyArray<{ code: Language; nativeName: string; flag: string }>;
const nb = {
common: {
@ -66,8 +87,10 @@ const nb = {
footer: {
norwegian: "Norsk",
english: "English",
ukrainian: "Українська",
switchToNorwegian: "Bytt språk til norsk",
switchToEnglish: "Switch language to English",
switchToUkrainian: "Bytt språk til ukrainsk",
developedBy: "Utviklet av Rolfsvaag Datateknikk med",
heart: "hjerte",
languageChoice: "Språkvalg",
@ -340,7 +363,16 @@ const nb = {
hasFollowup: "Har oppfølging",
hasComment: "Har brukerkommentar",
hasEvaluatorNote: "Har veiledernotat",
scoreValues: "Scoreverdier"
scoreValues: "Scoreverdier",
importReadError: "Importfilen kunne ikke leses.",
importMatched: "Importen ble knyttet til eksisterende bruker.",
importCreated: "Opprettet ny bruker.",
importFailed: "Import feilet.",
backupFailed: "Backup kunne ikke importeres.",
demoBackupWarning: "ADVARSEL: Backupen inneholder {count} demodata-oppføringer.",
importResult: "{action} {added} nye rapporter, {skipped} duplikater hoppet over.",
demoImportResultWarning: "ADVARSEL: Importen inneholder {count} demodata-oppføringer.",
verificationWarning: "Noen rapporter kunne ikke verifiseres."
},
details: {
summary: "Kort oppsummering",
@ -389,6 +421,9 @@ const nb = {
algorithm: "Algoritme",
signed: "Signert",
payloadHash: "Payload-hash",
type: "Type",
canonicalization: "Canonicalization",
scoreUnavailable: "Totalscore mangler eller kan ikke beregnes.",
worse: "Verre enn før",
delayedSymptoms: "Forsinkede symptomer",
recovery: "Restitusjon",
@ -425,7 +460,19 @@ const nb = {
followupResponseDisclaimer: "Dette er en visuell ettereffektindikator, ikke en ny totalscore.",
noFollowups: "Ingen oppfølginger for valgt rapport.",
followupDelay: "Forsinkelse: {hours} timer",
followupEffect: "Ettereffekt: {score}% fra rapportens {parent}%"
followupEffect: "Ettereffekt: {score}% fra rapportens {parent}%",
metricTotalScore: "Totalscore",
metricWorkAbility: "Arbeidsevne",
metricEnergy: "Energi",
metricClarity: "Klarhet",
metricSymptoms: "Symptomer",
metricStrain: "Belastning",
metricTotalScoreShort: "T",
metricWorkAbilityShort: "A",
metricEnergyShort: "E",
metricClarityShort: "K",
metricSymptomsShort: "S",
metricStrainShort: "B"
},
scoring: {
disclaimer:
@ -478,8 +525,10 @@ const en: TranslationTree = {
footer: {
norwegian: "Norsk",
english: "English",
ukrainian: "Українська",
switchToNorwegian: "Bytt språk til norsk",
switchToEnglish: "Switch language to English",
switchToUkrainian: "Switch language to Ukrainian",
developedBy: "Developed by Rolfsvaag Datateknikk with",
heart: "heart",
languageChoice: "Language choice",
@ -751,7 +800,16 @@ const en: TranslationTree = {
hasFollowup: "Has follow-up",
hasComment: "Has user comment",
hasEvaluatorNote: "Has advisor note",
scoreValues: "Score values"
scoreValues: "Score values",
importReadError: "The import file could not be read.",
importMatched: "The import was connected to an existing user.",
importCreated: "A new user was created.",
importFailed: "Import failed.",
backupFailed: "The backup could not be imported.",
demoBackupWarning: "WARNING: The backup contains {count} demo data entries.",
importResult: "{action} {added} new reports, {skipped} duplicates skipped.",
demoImportResultWarning: "WARNING: The import contains {count} demo data entries.",
verificationWarning: "Some reports could not be verified."
},
details: {
summary: "Short summary",
@ -800,6 +858,9 @@ const en: TranslationTree = {
algorithm: "Algorithm",
signed: "Signed",
payloadHash: "Payload hash",
type: "Type",
canonicalization: "Canonicalization",
scoreUnavailable: "The total score is missing or could not be calculated.",
worse: "Worse than before",
delayedSymptoms: "Delayed symptoms",
recovery: "Recovery",
@ -836,7 +897,19 @@ const en: TranslationTree = {
followupResponseDisclaimer: "This is a visual after-effect indicator, not a new total score.",
noFollowups: "No follow-ups for the selected report.",
followupDelay: "Delay: {hours} hours",
followupEffect: "After-effect: {score}% from report {parent}%"
followupEffect: "After-effect: {score}% from report {parent}%",
metricTotalScore: "Total score",
metricWorkAbility: "Work ability",
metricEnergy: "Energy",
metricClarity: "Clarity",
metricSymptoms: "Symptoms",
metricStrain: "Strain",
metricTotalScoreShort: "T",
metricWorkAbilityShort: "A",
metricEnergyShort: "E",
metricClarityShort: "C",
metricSymptomsShort: "S",
metricStrainShort: "S"
},
scoring: {
disclaimer:
@ -993,6 +1066,20 @@ export const labelsByLanguage = {
B: "Effort/strain",
T: "Total score"
}
},
"uk-UA": {
statusLabels: ukStatusLabels,
taskTypeLabels: ukTaskTypeLabels,
limitationLabels: ukLimitationLabels,
accommodationLabels: ukAccommodationLabels,
taskCompletionLabels: ukTaskCompletionLabels,
worseLabels: ukWorseLabels,
delayedSymptomLabels: ukDelayedSymptomLabels,
recoveryLabels: ukRecoveryLabels,
positiveScale: ukPositiveScale,
negativeScale: ukNegativeScale,
tooltipTexts: ukTooltipTexts,
categoryTooltipTexts: ukCategoryTooltipTexts
}
} as const;
@ -1018,7 +1105,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
setLanguage,
labels: labelsByLanguage[language],
t: (key, params) => interpolate(readTranslation(language, key), params),
locale: language === "en" ? "en-GB" : "nb-NO"
locale: localeForLanguage(language)
};
}, [language]);
@ -1051,31 +1138,58 @@ export function translateScoreBand(
export function translateScoreBandDescription(band: { label: string; description: string } | null | undefined, language: Language) {
if (!band || language === "nb-NO") return band?.description;
const key = scoreBandKeyFromNorwegianLabel(band.label);
const descriptions: Record<string, string> = {
exceptionallyHigh: "The report suggests very good function in the current work situation, above what can normally be expected.",
expected: "The report is within an expected/good function level for the current work situation.",
moderatelyReduced: "The report suggests a noticeable reduction in work ability, energy, symptom burden or sustainability.",
significantlyReduced: "The report suggests clearly limited function and/or high strain when completing the activity.",
veryLow: "The report suggests very low current function, high strain or that the activity may not be sustainable."
const descriptions: Record<Exclude<Language, "nb-NO">, Record<string, string>> = {
en: {
exceptionallyHigh: "The report suggests very good function in the current work situation, above what can normally be expected.",
expected: "The report is within an expected/good function level for the current work situation.",
moderatelyReduced: "The report suggests a noticeable reduction in work ability, energy, symptom burden or sustainability.",
significantlyReduced: "The report suggests clearly limited function and/or high strain when completing the activity.",
veryLow: "The report suggests very low current function, high strain or that the activity may not be sustainable."
},
"uk-UA": {
exceptionallyHigh: "Звіт свідчить про дуже добрий рівень функціонування в поточній робочій ситуації, вищий за звичайно очікуваний.",
expected: "Звіт відповідає очікуваному або доброму рівню функціонування в поточній робочій ситуації.",
moderatelyReduced: "Звіт свідчить про помітне зниження працездатності, енергії, переносимості симптомів або витривалості.",
significantlyReduced: "Звіт свідчить про суттєво обмежене функціонування та/або високе навантаження під час виконання роботи.",
veryLow: "Звіт свідчить про дуже низький поточний рівень функціонування, високе навантаження або нестійкість такої діяльності."
}
};
return key ? descriptions[key] : band.description;
return key ? descriptions[language][key] : band.description;
}
export function datePlaceholderForLanguage(language: Language) {
return readTranslation(language, "overview.datePlaceholder");
}
function readStoredLanguage(): Language {
export function localeForLanguage(language: Language) {
if (language === "en") return "en-GB";
return language;
}
export function readStoredLanguage(): Language {
const stored = localStorage.getItem(LANGUAGE_KEY);
return stored === "en" || stored === "nb-NO" ? stored : "nb-NO";
return stored === "en" || stored === "nb-NO" || stored === "uk-UA" ? stored : "nb-NO";
}
function readTranslation(language: Language, key: string) {
const translated = lookup(language === "en" ? en : nb, key);
const translated = lookup(language === "en" ? en : language === "uk-UA" ? uk : nb, key);
const fallback = lookup(nb, key);
return typeof translated === "string" ? translated : typeof fallback === "string" ? fallback : key;
}
export function missingTranslationKeys(language: Exclude<Language, "nb-NO">) {
const referenceKeys = flattenTranslationKeys(nb);
const translatedKeys = new Set(flattenTranslationKeys(language === "en" ? en : uk));
return referenceKeys.filter((key) => !translatedKeys.has(key));
}
function flattenTranslationKeys(tree: TranslationTree, prefix = ""): string[] {
return Object.entries(tree).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return typeof value === "string" ? [path] : flattenTranslationKeys(value, path);
});
}
function lookup(tree: TranslationTree, key: string): string | TranslationTree | undefined {
return key.split(".").reduce<string | TranslationTree | undefined>((current, part) => {
if (!current || typeof current === "string") return undefined;

438
src/i18n.uk.ts Normal file
View File

@ -0,0 +1,438 @@
import type { TranslationTree } from "./i18n";
export const uk: TranslationTree = {
"common": {
"cancel": "Скасувати",
"close": "Закрити",
"copied": "Скопійовано",
"delete": "Видалити",
"save": "Зберегти",
"notSelected": "Не вибрано",
"notProvided": "Не вказано",
"notCalculated": "Не розраховано",
"unknownWorkplace": "Невідоме місце роботи"
},
"app": {
"eyebrow": "Локальна самооцінка",
"title": "Arbeidspuls",
"evaluator": "Консультант",
"navLabel": "Основна навігація"
},
"nav": {
"register": "Реєстрація",
"followup": "Подальший запис",
"overview": "Огляд",
"privacy": "Конфіденційність"
},
"demo": {
"enter": "Демо",
"exit": "Вийти з демонстрації",
"label": "Демо дані",
"notice": "Ви в демонстраційному режимі. Ці дані є окремими від реальних звітів, а експорт/імпорт застосовується лише до демонстраційних даних."
},
"footer": {
"norwegian": "Norsk",
"english": "English",
"ukrainian": "Українська",
"switchToNorwegian": "Перейти на норвезьку мову",
"switchToEnglish": "Перейти на англійську мову",
"switchToUkrainian": "Перейти на українську мову",
"developedBy": "Розроблено Rolfsvaag Datateknikk з",
"heart": "серце",
"languageChoice": "Вибір мови",
"sourceCode": "Вихідний код v.{version}",
"commercialLicense": "Комерційна ліцензія"
},
"feedback": {
"button": "Відгук",
"buttonAria": "Надіслати анонімний відгук",
"title": "Надіслати відгук",
"stepOne": "1 із 2: Опишіть відгук",
"stepTwo": "2 з 2: підтвердити публічне подання",
"intro": "Після успішної перевірки відгук буде створено як публічне звернення.",
"typeLabel": "Тип",
"typeBug": "Помилка чи проблема",
"typeFeature": "Пропозиція щодо покращення",
"typeDocs": "Документація або текст",
"typeTask": "Загальне завдання",
"typeOther": "Інше",
"questionsNotice": "Запитання слід надсилати безпосередньо власнику проекту, а не як публічний відгук про проект.",
"areaLabel": "Область",
"technicalAreas": "Технічні категорії",
"areaReporting": "Реєстрація звіту",
"areaFollowup": "Подальший запис",
"areaGraph": "Огляд і графік",
"areaAdvisor": "Перегляд консультанта",
"areaImportExport": "Імпорт, експорт і обмін",
"areaI18n": "Мова і переклад",
"areaPrivacy": "Конфіденційність і локальне зберігання",
"areaUi": "Компонування та зручність використання",
"areaAccessibility": "Доступність",
"areaLegal": "Юридичний або ліцензійний",
"areaApi": "API",
"areaBackend": "Бекенд/сервер",
"areaDeployment": "Розгортання",
"areaPwa": "Встановити як додаток/PWA",
"areaSecurity": "Безпека",
"areaPerformance": "Продуктивність",
"legalNotice": "Спеціальне ліцензування або комерційне використання має вирішуватися безпосередньо з власником проекту.",
"commercialLicenseLink": "Прочитайте про спеціальні ліцензії та комерційне використання.",
"messageLabel": "Опис",
"messagePlaceholder": "Опишіть, що сталося, що ви очікували або що ви пропонуєте.",
"validationTitle": "Вимоги до опису",
"validationMinChars": "Напишіть не менше 30 символів.",
"validationMaxChars": "Максимум 4000 символів.",
"validationMinWords": "Напишіть не менше 5 слів.",
"validationMinLetters": "Напишіть не менше 15 букв.",
"validationMeaningful": "Текст повинен містити змістовний зміст, а не лише цифри чи символи.",
"validationRepetition": "Текст не може складатися з повторів.",
"validationContact": "Видаліть електронну адресу, номер телефону, IP-адресу або довгі номерні послідовності.",
"validationType": "Виберіть саме один вид.",
"validationArea": "Виберіть принаймні одну область.",
"publicNotice": "Зворотній зв'язок може бути створений як загальнодоступні проблеми в списку проблем проекту. Не вводьте конфіденційну інформацію, не повідомляйте про вміст, імена людей чи організацій, адреси електронної пошти, номери телефонів чи іншу інформацію, яка може ідентифікувати вас чи інших осіб.",
"purposeNotice": "Відгуки використовуються лише для покращення Arbeidspuls.",
"consent": "Я підтверджую, що відгук не містить конфіденційної інформації чи інформації, яку можна ідентифікувати, і що вміст може оброблятися, зберігатися та відображатися публічно як звичайний відгук про проект.",
"back": "Назад",
"continue": "Продовжити",
"submit": "Надіслати",
"submitWaiting": "Надіслати ({secondsLeft})",
"cancel": "Скасувати",
"success": "Дякуємо. Відгук надіслано.",
"fakeSuccess": "Дякуємо. Відгук отримано.",
"disabled": "Функцію відгуку не ввімкнено.",
"error": "Не вдалося надіслати відгук. Повторіть спробу пізніше.",
"tooFast": "Зачекайте трохи, перш ніж надсилати форму.",
"urlWarning": "URL-адреси дозволені, але видаліть конфіденційні посилання або параметри запиту."
},
"deleteModal": {
"title": "Підтвердити видалення",
"body": "Ви збираєтеся видалити: {itemName}. Цю дію не можна скасувати.",
"waiting": "Видалити ({secondsLeft})",
"ready": "Видалити"
},
"register": {
"intro": "Заповніть основні поля. За потреби можна додати додаткові деталі.",
"workSession": "Робоча сесія",
"workplace": "Робоче місце",
"workAbility": "Працездатність прямо зараз",
"energyLevel": "Енергетичний рівень",
"mentalClarity": "Ясність розуму / концентрація",
"symptomBurden": "Тягар симптомів",
"effortStrain": "Зусилля / напруга",
"optional": "Додатково",
"startTime": "Час початку",
"endTime": "Час закінчення",
"continueQuestion": "Ви можете продовжити?",
"chooseBestNow": "Виберіть варіант, який найкраще підходить прямо зараз.",
"optionalDetails": "Додаткові деталі",
"physicalEnergy": "Фізична енергія",
"mentalEnergy": "Розумова енергія",
"productivity": "Відчутна продуктивність",
"taskCompletion": "Скільки ви зробили?",
"taskTypes": "Робота включала",
"limitations": "Що вас найбільше обмежувало?",
"accommodations": "Що допомогло?",
"note": "Короткий коментар",
"missing": "Відсутній: {fields}.",
"saved": "Звіт збережено локально.",
"saveReport": "Зберегти звіт",
"oneEqualsFiveEquals": "1 = {low}, 5 = {high}."
},
"followup": {
"intro": "Використовуйте це наступного дня або пізніше, щоб зафіксувати відстрочені реакції.",
"worseQuestion": "Вам гірше, ніж до робочого сеансу?",
"chooseOne": "Виберіть одну відповідь.",
"connectReport": "Підключитися до звіту",
"delayedSymptoms": "Відстрочені симптоми",
"recovery": "Відновлення",
"missingWorse": "Оберіть, чи гірше вам, ніж до робочого сеансу.",
"saved": "Подальший запис збережено локально.",
"saveFollowup": "Зберегти подальший запис",
"orphan": "Подальший запис без вибраного основного звіту",
"worsePrefix": "гірше",
"latestOnly": "Подальший запис можна створити лише для останнього звіту."
},
"edit": {
"edit": "Редагувати",
"reportIntro": "Відредагуйте звіт. Редагування доступне лише протягом 24 годин після створення.",
"followupIntro": "Відредагуйте подальший запис. Редагування доступне лише протягом 24 годин після створення.",
"saveChanges": "Зберегти зміни",
"saved": "Зміни збережено локально.",
"cancelEdit": "Скасувати редагування",
"expired": "Редагування доступне лише протягом 24 годин після створення."
},
"overview": {
"noReports": "Жодного звіту ще не збережено.",
"report": "Звіт",
"followup": "Подальший запис",
"closeReport": "Закрити звіт",
"trend": "Тренд",
"explainTrend": "Поясніть графік трендів",
"trendTooltip": "На діаграмі використовуються нормалізовані значення 0-100. Більше значення означає кращу роботу або менше навантаження.",
"minimumTwo": "Для діаграми необхідно принаймні два звіти за вибраний період.",
"average": "Нормована середня за період",
"chartLabel": "Діаграма трендів у часі для звітів",
"dashedLine": "Пунктирна лінія означає, що між звітами проходить 3 дні або більше.",
"from": "Від",
"to": "До",
"fromDate": "Від дати",
"toDate": "До дати",
"datePlaceholder": "дд/мм/рррр",
"all": "Усе",
"week": "1 тиждень",
"month": "1 місяць",
"threeMonths": "3 місяці",
"sixMonths": "6 місяців",
"year": "1 рік",
"threeYears": "3 роки",
"fiveYears": "5 років"
},
"privacy": {
"warning": "Звіти можуть містити конфіденційну інформацію про здоров'я. Arbeidspuls зберігає дані локально за замовчуванням із додатковим зашифрованим одноразовим доступом, коли ви вибираєте Надіслати електронною поштою.",
"demoWarning": "У демонстраційному режимі використовується окреме локальне сховище. Ці звіти є тестовими даними і не змішуються з реальними звітами.",
"exportJson": "Експорт JSON",
"exportEmail": "Надіслати електронною поштою",
"exportRangeTitle": "Експорт даних",
"exportRangeIntro": "Додатково виберіть діапазон дат для експорту. Якщо дати не вибрано, експортуються всі локальні дані.",
"fromDate": "Від дати",
"toDate": "До дати",
"allDataDefault": "За замовчуванням: усі дані експортуються.",
"exportInvalidRange": "Початкова дата не може бути пізнішою за кінцеву.",
"exportSensitiveNotice": "Файл експорту може містити конфіденційну інформацію про здоров'я та функції. Діліться ним лише з людьми, які мають мати доступ.",
"emailExportTitle": "Надіслати дані електронною поштою",
"recipientEmail": "Електронна адреса одержувача",
"recipientPlaceholder": "advisor@example.com",
"openEmail": "Відкрийте електронну пошту",
"invalidEmail": "Введіть дійсну адресу електронної пошти.",
"emailSubject": "Дані звіту Arbeidspuls для перегляду",
"emailShared": "Захищений ресурс створено.",
"manualAttachmentRequired": "Скопіюйте текст вручну, якщо поштовий клієнт відкрився неправильно.",
"limitPeriod": "Обмеження діапазону дат",
"allDataSelected": "Усі дані вибрано за замовчуванням.",
"emailClientNotice": "Для автоматичного відкриття потрібно налаштувати поштовий клієнт на вашому пристрої. Якщо це не спрацює, ви отримаєте текст, який можна скопіювати та вставити вручну.",
"serverStorageConsent": "Я розумію, що вибрані дані тимчасово зберігаються в зашифрованому та нечитабельному вигляді на сервері Arbeidspuls для створення одноразового посилання. Дані видаляються після першого успішного імпорту або через 7 днів.",
"serverStorageConsentRequired": "Перш ніж створити зв'язок, потрібно схвалити тимчасове зашифроване сховище на сервері.",
"createEmail": "Створити електронну пошту",
"emailLinkCreated": "Поштовий клієнт було відкрито, якщо він доступний. Використовуйте скопіювати та вставити текст, якщо він був заповнений неправильно.",
"emailLinkFailed": "Не вдалося створити безпечний спільний доступ.",
"copyPasteBackupTitle": "Текст електронної пошти вручну",
"copyPasteBackupIntro": "Якщо поштовий клієнт неправильно відкрив або заповнив текст, скопіюйте одержувача, тему та текст вручну.",
"emailSubjectLabel": "Тема",
"emailBodyLabel": "Текст електронної пошти",
"secureEmailBody": "Привіт\n\nЯ надаю вам дані Arbeidspuls для перегляду.\n\nВідкрийте це одноразове посилання, щоб отримати та імпортувати дані в поданні консультанта:\n{shareUrl}\n\nПосиланням можна скористатися один раз і термін його дії автоматично закінчується через 7 днів.\n\nЯк діяти далі:\n1. Відкрийте посилання.\n2. Натисніть «Отримати й імпортувати».\n3. Переглядайте графіки, звіти та подальші дії в поданні радника.\n4. За потреби ви можете додати власні нотатки.\n\nДані зберігаються локально у вашому браузері/пристрої після імпорту. Сервер Arbeidspuls зберігає лише тимчасову зашифровану та нечитабельну копію, доки дані не будуть отримані або протягом максимум 7 днів.\n\nArbeidspuls\n{appUrl}",
"importBackup": "Імпорт резервної копії",
"deleteAll": "Видалити всі локальні дані",
"importHelp": "Імпортуйте раніше експортований JSON, якщо ви змінили пристрій або втратили локальні дані. Імпортовані дані зберігаються локально в цьому браузері.",
"importFrom": "Імпорт даних із {fileName}",
"importStats": "Нові звіти: {reports}. Нові подальші дії: {followups}. Дублікати пропущено: {duplicates}. Конфлікти: {conflicts}.",
"demoImportWarning": "Імпорт містить записи демонстраційних даних {count} і може бути імпортований лише в демонстраційному режимі.",
"cancelImport": "Скасувати імпорт",
"importData": "Імпорт даних",
"baseCopy": "Додаток не збирає імена, національні ідентифікаційні номери чи контактну інформацію. За замовчуванням дані зберігаються локально. У разі надсилання електронною поштою вибраний експорт шифрується у браузері перед тимчасовим завантаженням. Вільний текст необов'язковий і має використовуватися лише за потреби.",
"storageTitle": "Де зберігаються дані?",
"storageBody": "Звіти зберігаються локально в браузері пристрою, який ви використовуєте, у localStorage. Якщо вибрати «Надіслати електронною поштою», на сервер Arbeidspuls тимчасово завантажуватимуться лише зашифровані та нечитабельні дані спільного доступу.",
"backupTitle": "Резервне копіювання та зміни пристрою",
"backupBody": "Оскільки дані зберігаються локально, вони можуть бути втрачені, якщо ви зміните пристрій, видалите дані браузера, скористаєтеся іншим браузером або скинете пристрій. Регулярно експортуйте файл JSON, якщо хочете зберегти звіти.",
"sharingTitle": "Поширення для консультанта",
"sharingBody": "Якщо консультант або інша особа переглядатиме звіти, ви можете експортувати локальний файл JSON або використати Надіслати електронною поштою. Обмін електронною поштою створює зашифроване одноразове посилання, де ключ знаходиться у фрагменті URL-адреси та ніколи не надсилається на сервер.",
"sensitiveTitle": "Конфіденційна інформація",
"sensitiveBody": "Звіти можуть містити інформацію про здоров'я та функції. Діліться файлами JSON або одноразовими посиланнями лише з людьми, які мають мати доступ. Зашифровані спільні ресурси видаляються після першого успішного імпорту або автоматично через 7 днів.",
"exportIntegrity": "Експорт включає локальні перевірки цілісності, де звіти містять дані перевірки.",
"deleteAllName": "усі локальні звіти"
},
"share": {
"title": "Поширення Arbeidspuls",
"intro": "Ви відкрили одноразове поширення даних Arbeidspuls.",
"activeImportNotice": "Дані не буде отримано, доки ви не натиснете кнопку. Вони розшифровуються та імпортуються локально в перегляді консультанта.",
"fetchAndImport": "Отримання та імпорт",
"fetching": "Отримання...",
"openAdvisor": "Відкрити перегляд консультанта",
"alreadyDeleted": "Цей спільний доступ уже отримано або видалено.",
"expired": "Це поширення автоматично видалено, оскільки минуло понад 7 днів.",
"requestNewShare": "Попросіть відправника створити новий спільний доступ, якщо вам все ще потрібен доступ.",
"missingKey": "У посиланні відсутній ключ розшифровки. Попросіть відправника ще раз надіслати повне посилання.",
"missingConfirmToken": "У посиланні відсутній код підтвердження. Попросіть відправника ще раз надіслати повне посилання.",
"decryptFailed": "Не вдалося розшифрувати спільний доступ. Посилання може бути неповним або неправильним.",
"notFound": "Поширення не існує.",
"imported": "Імпорт завершено. Нові записи: {reports}. Дублікати пропущено: {skipped}."
},
"evaluator": {
"eyebrow": "Консультант",
"title": "Перегляд консультанта",
"backToApp": "Назад до програми",
"importJson": "Імпорт JSON",
"importHelp": "Виберіть експорт із програми. Дані зберігаються локально в цьому браузері.",
"displayName": "Ім'я/ідентифікатор",
"optionalImportName": "Додатково під час імпорту",
"connectExisting": "Підключитися до існуючої людини",
"autoMatch": "Створіть новий або скористайтеся автоматичним збігом",
"chooseJson": "Виберіть файл JSON",
"exportBackup": "Експорт даних консультанта",
"importBackup": "Імпорт резервної копії консультанта",
"importedBackup": "Резервну копію консультанта імпортовано.",
"demoDataWarning": "Цей імпорт містить демонстраційні дані. Не використовуйте його як справжню інформацію про користувача чи здоров'я.",
"importStart": "Щоб почати перегляд, імпортуйте файл JSON.",
"saveName": "Зберегти назву",
"deletePerson": "Видалити користувача та звіти",
"deletePersonTitle": "Після підтвердження буде видалено користувача, імпортовані звіти, подальші записи та нотатки консультанта.",
"reportCount": "Звітів: {count}",
"personNote": "Загальна примітка для користувача",
"personNoteHelp": "Ця примітка стосується особи в цілому, а не окремого звіту.",
"saveNote": "Зберегти нотатку",
"changedAt": "Останні зміни: {time}",
"searchReports": "Пошук у звітах",
"newest": "Спочатку найновіші",
"oldest": "Спочатку найстаріші",
"noSelectedTitle": "Звіт не вибрано",
"noSelectedBody": "Виберіть звіт зі списку, щоб переглянути деталі, подальші дії та примітки.",
"details": "Показати деталі",
"reportDetails": "Деталі звіту",
"followupDetails": "Деталі подальшого запису",
"closeDetails": "Закрийте деталі",
"reportNote": "Нотатка до звіту",
"followupNote": "Нотатка до подальшого запису",
"noteHelp": "Примітка консультанта зберігається локально та не змінює дані імпортованого звіту.",
"deleteNote": "Видалити примітку",
"validationError": "Помилка підтвердження",
"comment": "коментар",
"evaluatorNote": "Нотатка консультанта",
"followupNoteBadge": "Нотатка до подальшого запису",
"hasFollowup": "Є подальший запис",
"hasComment": "Є коментарі користувача",
"hasEvaluatorNote": "Є нотатка консультанта",
"scoreValues": "Значення оцінок",
"importReadError": "Не вдалося прочитати файл імпорту.",
"importMatched": "Імпорт пов'язано з наявним користувачем.",
"importCreated": "Створено нового користувача.",
"importFailed": "Не вдалося виконати імпорт.",
"backupFailed": "Не вдалося імпортувати резервну копію.",
"demoBackupWarning": "ПОПЕРЕДЖЕННЯ: резервна копія містить демонстраційні записи: {count}.",
"importResult": "{action} Нових звітів: {added}, пропущено дублікатів: {skipped}.",
"demoImportResultWarning": "ПОПЕРЕДЖЕННЯ: імпорт містить демонстраційні записи: {count}.",
"verificationWarning": "Деякі звіти не вдалося перевірити."
},
"details": {
"summary": "Короткий зміст",
"coreValues": "Основні цінності",
"details": "Подробиці",
"userComment": "Коментар користувача",
"followups": "Подальші записи",
"technical": "Технічна інформація",
"rawData": "Необроблені дані",
"integrity": "Перевірка цілісності",
"dataMode": "Режим даних",
"time": "Час",
"workTime": "Час роботи",
"status": "Статус",
"totalScore": "Загальний бал",
"functionLevel": "Рівень функції",
"description": "Опис",
"important": "Важливо",
"workAbility": "Працездатність",
"energy": "Енергетичний рівень",
"physicalEnergy": "Фізична енергія",
"mentalEnergy": "Розумова енергія",
"mentalClarity": "Розумова ясність",
"symptoms": "Тягар симптомів",
"strain": "Зусилля та навантаження",
"productivity": "Продуктивність",
"taskCompletion": "Скільки ви зробили?",
"taskTypes": "Робота включала",
"limitations": "Обмежується",
"accommodations": "Що допомогло",
"comment": "Коментар",
"noComment": "Без коментарів",
"noOptionalDetails": "Додаткові деталі не надано.",
"technicalDescription": "Розширена інформація для контролю, усунення несправностей і перевірки.",
"rawDescription": "Повний перегляд JSON для усунення несправностей або технічного контролю.",
"copyRaw": "Скопіюйте необроблені дані",
"reportId": "Ідентифікатор звіту",
"followupId": "ID подальшого запису",
"connectedReport": "Підключений звіт",
"created": "Створено",
"updated": "Оновлено",
"reportDate": "Дата звіту",
"reportTime": "Час звіту",
"integrityStatus": "Статус цілісності",
"explanation": "Пояснення",
"algorithm": "Алгоритм",
"signed": "Підписано",
"payloadHash": "Хеш корисного навантаження",
"type": "Тип",
"canonicalization": "Канонізація",
"scoreUnavailable": "Загальна оцінка відсутня або її не вдалося розрахувати.",
"worse": "Гірше ніж раніше",
"delayedSymptoms": "Відстрочені симптоми",
"recovery": "Відновлення",
"outOfFive": "{value} з 5",
"worseValue": "Гірше: {value}",
"recoveryValue": "Відновлення: {value}"
},
"danger": {
"title": "Небезпечна зона",
"deleteEntryHelp": "Це видаляє звіт або подальші дії з локального сховища в цьому браузері.",
"deleteReport": "Видалити звіт",
"deleteFollowup": "Видалити подальший запис",
"reportName": "звіт",
"followupName": "подальший запис"
},
"graphModal": {
"open": "Відкрити діаграму на весь екран",
"title": "Аналіз діаграми",
"close": "Закрити діаграму",
"timeRange": "Діапазон часу",
"visibleLines": "Видимі лінії",
"visibleHelp": "Натисніть один раз для частково видимого, двічі для прихованого.",
"snapHelp": "Наведіть курсор на діаграму, щоб побачити точні значення звіту.",
"noReports": "Немає звітів за вибраний період.",
"noPoint": "Точка звіту не вибрана",
"noPointHelp": "Наведіть курсор біля точки, щоб побачити фактичні значення звіту.",
"nearestReport": "Найближчий звіт",
"chartLabel": "Повноекранна діаграма тенденцій для звітів",
"workTime": "Час роботи",
"workTimeLegend": "Затінені області показують зареєстрований робочий час",
"workTimeTooltip": "Оформлений робочий час",
"followupResponse": "Реакція після роботи",
"followupResponseHelp": "Показує наступні дії в їх фактичний час і візуалізує наслідки робочого сеансу.",
"followupResponseDisclaimer": "Це візуальний індикатор наслідків, а не нова загальна оцінка.",
"noFollowups": "Немає подальших дій для вибраного звіту.",
"followupDelay": "Затримка: {hours} годин",
"followupEffect": "Післядія: {score}% зі звіту {parent}%",
"metricTotalScore": "Загальна оцінка",
"metricWorkAbility": "Працездатність",
"metricEnergy": "Енергія",
"metricClarity": "Ясність мислення",
"metricSymptoms": "Симптоми",
"metricStrain": "Навантаження",
"metricTotalScoreShort": "З",
"metricWorkAbilityShort": "П",
"metricEnergyShort": "Е",
"metricClarityShort": "Я",
"metricSymptomsShort": "С",
"metricStrainShort": "Н"
},
"scoring": {
"disclaimer": "Загальна оцінка нормалізується таким чином, що 75% приблизно відповідає очікуваному/хорошому функціональному рівню, тоді як 100% відповідає виключно високому функціональному рівню. Оцінка є внутрішнім індикатором функції для огляду та тенденції, а не медичного висновку.",
"periodLabel": "Оцінка працездатності за період",
"periodEmpty": "Немає даних за вибраний період",
"periodTooltip": "Це нормалізована оцінка на основі основних звітів у вибраному часовому діапазоні. 75% приблизно вважається очікуваним/хорошим рівнем функціонування. Оцінка підтверджує огляд і тенденцію, а не медичний висновок.",
"exceptionallyHigh": "Винятково високий рівень функціональності",
"expected": "Очікуваний функціональний рівень",
"moderatelyReduced": "Помірно знижена функція",
"significantlyReduced": "Значно знижена функція",
"veryLow": "Дуже низький рівень функціонування",
"exceptionallyHighShort": "Винятково висока",
"expectedShort": "Очікуваний",
"moderatelyReducedShort": "Помірно знижений",
"significantlyReducedShort": "Значно зменшено",
"veryLowShort": "Дуже низький"
},
"verification": {
"valid": "Цілісність в порядку",
"modified": "Можливо змінено",
"missing": "Відсутній локальний хеш",
"unsupported": "Перевірка не підтримується",
"error": "Помилка перевірки",
"invalidLabel": "Попередження: звіт не вдалося перевірити"
}
};

134
src/labels.uk.ts Normal file
View File

@ -0,0 +1,134 @@
import type {
Accommodation,
DelayedSymptom,
Limitation,
RecoveryStatus,
ReportStatus,
ScaleValue,
TaskCompletion,
TaskType,
WorseThanBefore
} from "./types";
export const ukStatusLabels = {
kan_fortsette: "Може продовжувати",
trenger_pause: "Потрібна перерва",
trenger_enklere_oppgave: "Потрібне простіше завдання",
bør_stoppe: "Варто зупинитися",
onsker_a_stoppe: "Хоче зупинитися",
ønsker_å_stoppe: "Хоче зупинитися"
} satisfies Record<ReportStatus, string>;
export const ukTaskTypeLabels = {
sitting: "Сидяча робота",
standing: "Робота стоячи",
walking: "Ходьба",
lifting: "Підіймання",
computer_work: "Робота за комп'ютером",
concentration: "Концентрація",
multitasking: "Багатозадачність",
talking_social: "Спілкування",
noise: "Шум",
bright_light: "Яскраве світло",
time_pressure: "Брак часу"
} satisfies Record<TaskType, string>;
export const ukLimitationLabels = {
fatigue: "Втома",
brain_fog: "Затьмарення свідомості",
pain: "Біль",
headache_migraine: "Головний біль або мігрень",
dizziness: "Запаморочення",
nausea: "Нудота",
sensory_overload: "Сенсорне перевантаження",
stress_anxiety: "Стрес або тривога",
weakness: "Слабкість",
sleepiness: "Сонливість",
other: "Інше"
} satisfies Record<Limitation, string>;
export const ukAccommodationLabels = {
extra_breaks: "Додаткові перерви",
sitting_down: "Можливість сісти",
quiet_room: "Тихе приміщення",
reduced_pace: "Повільніший темп",
shorter_task: "Коротше завдання",
help_from_others: "Допомога інших",
remote_work: "Віддалена робота",
task_switching: "Зміна завдань",
nothing_helped: "Нічого не допомогло"
} satisfies Record<Accommodation, string>;
export const ukTaskCompletionLabels = {
nesten_ingenting: "Майже нічого",
litt: "Трохи",
delvis: "Частково",
som_forventet: "Як очікувалося",
mer_enn_forventet: "Більше, ніж очікувалося"
} satisfies Record<TaskCompletion, string>;
export const ukWorseLabels = {
nei: "Ні",
litt: "Трохи",
moderat: "Помірно",
mye: "Значно"
} satisfies Record<WorseThanBefore, string>;
export const ukDelayedSymptomLabels = {
fatigue: "Втома",
pain: "Біль",
brain_fog: "Затьмарення свідомості",
flu_like: "Грипоподібний стан",
headache: "Головний біль",
dizziness: "Запаморочення",
sleep_disruption: "Порушення сну",
sensory_sensitivity: "Чутливість до світла або звуку"
} satisfies Record<DelayedSymptom, string>;
export const ukRecoveryLabels = {
tilbake_til_baseline: "Повернення до звичного рівня",
samme_dag: "Того самого дня",
en_dag: "Один день",
to_tre_dager: "Два-три дні",
fire_pluss_dager: "Чотири дні або більше",
ikke_tilbake_til_baseline: "Ще не відновився звичний рівень"
} satisfies Record<RecoveryStatus, string>;
export const ukPositiveScale = {
1: "Дуже низький",
2: "Низький",
3: "Середній",
4: "Добрий",
5: "Дуже добрий"
} satisfies Record<ScaleValue, string>;
export const ukNegativeScale = {
1: "Майже немає",
2: "Незначне",
3: "Помірне",
4: "Високе",
5: "Дуже високе"
} satisfies Record<ScaleValue, string>;
export const ukTooltipTexts = {
work_ability: "Наскільки добре ви можете функціонувати в робочій ситуації зараз, незалежно від того, чи вдається виконати все.",
energy_level: "Загальний відчутний рівень енергії. Якщо ви не розрізняєте фізичну й розумову енергію, це значення використовується для обох.",
physical_energy: "Фізична енергія, витривалість і фізичні можливості.",
mental_energy: "Розумова витривалість, здатність довго зберігати увагу й витримувати когнітивне навантаження.",
mental_clarity: "Наскільки ясно ви мислите та наскільки легко зосереджуватися, пам'ятати, розуміти й стежити за перебігом роботи.",
symptom_burden: "Наскільки симптоми турбують або обмежують вас, наприклад біль, головний біль, запаморочення, нудота чи сенсорне перевантаження.",
effort_strain: "Скільки зусиль потребує виконання роботи. Йдеться про навантаження, а не лише про інтенсивність симптомів.",
symptom_vs_effort: "Тягар симптомів описує їхню вираженість. Зусилля та навантаження описують, наскільки сильно доводиться напружуватися для виконання роботи.",
total_score_percent: "Розрахований показник функціонування від 0 до 100 на основі відповідей. Це не медично валідований тест.",
workplace: "Місце або контекст роботи. За наявності поле заповнюється значенням із попереднього звіту.",
work_time: "Час початку й завершення робочого сеансу. Значення можна змінити до збереження звіту."
};
export const ukCategoryTooltipTexts = {
A: "Працездатність",
E: "Енергія",
K: "Ясність мислення та концентрація",
S: "Тягар симптомів",
B: "Зусилля та навантаження",
T: "Загальна оцінка"
};

View File

@ -860,6 +860,12 @@ textarea {
opacity: 1;
}
@media (max-width: 520px) {
.tooltip [role="tooltip"] {
width: min(200px, calc(100vw - 32px));
}
}
.compact-session,
.period-panel {
border: 1px solid #d9ded8;
@ -1881,6 +1887,7 @@ textarea {
}
.footer-language-switcher {
flex-wrap: wrap;
gap: 0.45rem;
font-size: 0.9rem;
}